"""Imperative, awaitable system alerts.
Inspired by React Native's ``Alert.alert()`` but designed around
``async`` / ``await`` instead of per-button callbacks. There are three
entry points:
- [`Alert.show`][pythonnative.alerts.Alert.show]: fire-and-forget
one-button notice (no return value).
- [`Alert.confirm`][pythonnative.alerts.Alert.confirm]: awaitable
two-button yes/no, resolves to a ``bool``.
- [`Alert.choose`][pythonnative.alerts.Alert.choose]: awaitable
multi-button picker / action sheet, resolves to the selected
label (or ``None`` if dismissed).
Example:
```python
import pythonnative as pn
async def maybe_delete():
if await pn.Alert.confirm(
title="Delete item?",
message="This action cannot be undone.",
confirm_label="Delete",
cancel_label="Keep",
):
await delete_item()
```
"""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List, Optional, Sequence
from .platform import Platform
from .runtime import resolve_future
# ======================================================================
# Internal dispatch helpers
# ======================================================================
def _dispatch_alert(
*,
title: str,
message: Optional[str],
buttons: List[Dict[str, Any]],
style: str,
on_result: Any,
) -> None:
"""Route an alert request to the active platform presenter.
``buttons`` is a list of ``{"label": str, "style":
"default"|"cancel"|"destructive"}`` dicts. The presenter must
invoke ``on_result(index)`` exactly once when the user picks a
button, or ``on_result(-1)`` if the dialog is dismissed without a
selection. ``on_result`` may run on any thread.
"""
if Platform.is_ios:
try:
from .native_views.ios import _present_alert as _ios_present_alert
_ios_present_alert(
title=title,
message=message,
buttons=buttons,
style=style,
on_result=on_result,
)
return
except Exception:
on_result(-1)
return
if Platform.is_android:
try:
from .native_views.android import _present_alert as _android_present_alert
_android_present_alert(
title=title,
message=message,
buttons=buttons,
style=style,
on_result=on_result,
)
return
except Exception:
on_result(-1)
return
# Test backend: record the call so unit tests can assert on it,
# then deliver the configured response.
Alert._test_log.append(
{
"title": title,
"message": message,
"buttons": list(buttons),
"style": style,
}
)
response = Alert._next_test_response()
on_result(response)
# ======================================================================
# Public Alert API
# ======================================================================
class Alert:
"""Imperative alert / action-sheet helper.
All methods are static. Use [`show`][pythonnative.alerts.Alert.show]
for a fire-and-forget single-button notice,
[`confirm`][pythonnative.alerts.Alert.confirm] for an awaitable
yes/no dialog, and
[`choose`][pythonnative.alerts.Alert.choose] for a multi-option
picker.
"""
#: Records every alert call when running off-device. Tests reset
#: this between cases via ``Alert._test_log.clear()``. Each entry
#: contains ``title``, ``message``, ``buttons``, and ``style``.
_test_log: List[Dict[str, Any]] = []
#: Queue of indices to deliver to upcoming alerts in tests. Set via
#: [`Alert.set_test_response`][pythonnative.alerts.Alert.set_test_response].
#: A negative value (or empty queue) simulates a dismiss.
_test_responses: List[int] = []
@staticmethod
def set_test_response(*indices: int) -> None:
"""Queue indices to return from upcoming test alerts.
Use in async tests to script the user's choices: each pending
call to [`confirm`][pythonnative.alerts.Alert.confirm] or
[`choose`][pythonnative.alerts.Alert.choose] pops the next
queued index. Pass ``-1`` to simulate a dismiss.
Args:
*indices: Sequence of button indices to deliver, oldest
first. Calls beyond the queue length resolve to ``-1``.
"""
Alert._test_responses[:] = list(indices)
@staticmethod
def _next_test_response() -> int:
if Alert._test_responses:
return Alert._test_responses.pop(0)
return -1
@staticmethod
def show(
title: str,
message: Optional[str] = None,
*,
button: str = "OK",
) -> None:
"""Display a simple, one-button alert and return immediately.
Args:
title: Dialog title.
message: Optional body text.
button: Label for the single dismiss button (default
``"OK"``).
This is fire-and-forget. To know what the user did, use
[`confirm`][pythonnative.alerts.Alert.confirm] or
[`choose`][pythonnative.alerts.Alert.choose] and ``await``
the result.
"""
_dispatch_alert(
title=title,
message=message,
buttons=[{"label": button, "style": "default"}],
,
on_result=lambda _idx: None,
)
@staticmethod
async def confirm(
title: str,
message: Optional[str] = None,
*,
confirm_label: str = "OK",
cancel_label: str = "Cancel",
) -> bool:
"""Present a two-button yes/no dialog and wait for the choice.
Args:
title: Dialog title.
message: Optional body text.
confirm_label: Label for the "yes" button (default
``"OK"``).
cancel_label: Label for the "no" button (default
``"Cancel"``).
Returns:
``True`` if the user pressed the confirm button, ``False``
for the cancel button or a dismiss.
Example:
```python
if await pn.Alert.confirm("Save changes?"):
await save()
```
"""
loop = asyncio.get_running_loop()
future: asyncio.Future[bool] = loop.create_future()
def _on_result(index: int) -> None:
resolve_future(future, index == 1)
_dispatch_alert(
title=title,
message=message,
buttons=[
{"label": cancel_label, "style": "cancel"},
{"label": confirm_label, "style": "default"},
],
,
on_result=_on_result,
)
return await future
@staticmethod
async def choose(
title: str,
options: Sequence[str],
*,
message: Optional[str] = None,
cancel_label: Optional[str] = None,
style: str = "action_sheet",
destructive_labels: Sequence[str] = (),
) -> Optional[str]:
"""Present a multi-option picker and wait for the user's choice.
Args:
title: Dialog title.
options: Sequence of option labels (in display order).
message: Optional body text.
cancel_label: If provided, adds a "cancel" button with
this label. Selecting it resolves to ``None``.
style: ``"action_sheet"`` (default) for an iOS-style
sheet, or ``"alert"`` for a stacked alert dialog.
destructive_labels: Labels in ``options`` that should be
styled destructively (red on iOS).
Returns:
The selected label, or ``None`` if the user dismissed or
tapped the cancel button.
Example:
```python
choice = await pn.Alert.choose(
"Photo source",
options=["Camera", "Gallery"],
cancel_label="Cancel",
)
if choice == "Camera":
...
```
"""
if not options:
raise ValueError("Alert.choose requires at least one option")
loop = asyncio.get_running_loop()
future: asyncio.Future[Optional[str]] = loop.create_future()
destructive = set(destructive_labels)
buttons: List[Dict[str, Any]] = [
{
"label": opt,
"style": "destructive" if opt in destructive else "default",
}
for opt in options
]
if cancel_label is not None:
buttons.append({"label": cancel_label, "style": "cancel"})
def _on_result(index: int) -> None:
if 0