Skip to content

Use Modals

ModalState models modal flow without depending on Textual. A widget adapter can render the current modal spec and feed submitted values back into the state.

from lazy_cuh import InputModalSpec, InputValidator, ModalState
def require_value(value: str) -> str | None:
return "Name is required." if not value else None
validator: InputValidator = require_value
state = ModalState().open(
InputModalSpec(
title="Profile name",
initial="main",
validator=validator,
result_id="rename-profile",
)
)
state, result = state.submit_input("dev")
assert result.result_id == "rename-profile"

Validation errors keep the modal open and store the error on the returned state.

Input modals may also parse submitted text into a domain value. The UI boundary is still text, but the modal result carries the parsed value when parsing succeeds.

state = ModalState().open(
InputModalSpec[int](
title="Max messages",
initial="100",
parser=int,
)
)
state, result = state.submit_input("250")
assert result.value == 250

Parser errors keep the modal open. Use parse_error to customize the message.

EditableOption is a convenience model for config-style rows that can render as a navigable list item and open a typed input modal. This keeps app code from duplicating the same label, parser, formatter, and modal setup for every setting.

from lazy_cuh import EditableOption, OptionSet, parse_bool, format_bool
options = OptionSet(
(
EditableOption(
id="scrolloff",
name="scrolloff",
value=2,
parser=int,
value_validator=lambda value: (
None if value >= 0 else "Scrolloff must be zero or greater."
),
parse_error="Scrolloff must be an integer.",
),
EditableOption(
id="redact",
name="redact",
value=True,
parser=parse_bool,
formatter=format_bool,
),
)
)
modal = options.input_modal("scrolloff")

If your option rows come from options.to_items(), selected list items can open their routed input modal directly:

modal = options.input_modal_for_item(selected_item)
if modal is not None:
modal_host.open(modal)

When the modal result returns, apply it back to immutable app state. The modal uses an OptionEditRequest result id internally, so app code does not need to invent and parse tuple-shaped result ids.

state = ModalState().open(modal)
state, result = state.submit_input("4")
assert result is not None
outcome = options.apply_modal_result(result)
assert outcome.updated
assert outcome.option_id == "scrolloff"
options = outcome.options
assert options.option("scrolloff").value == 4

This is intentionally modal-first. Inline insert mode can build on the same option models later without changing how values are parsed, formatted, or validated.

validator checks the raw input string before parsing. value_validator checks the parsed value. Parser and value-validation errors keep the modal open and set ModalState.error, so invalid values are not committed to app state.

Use result_id to connect a modal result back to the intention that opened it. The ID can be a string, enum value, or any other hashable object. This keeps app code from guessing based on modal kind or payload type.

from enum import Enum, auto
from lazy_cuh import ConfirmModalSpec, ModalResult
class AppAction(Enum):
DELETE_SELECTED = auto()
spec = ConfirmModalSpec(
title="Delete",
message="Delete selected item?",
result_id=AppAction.DELETE_SELECTED,
)
def handle_modal_result(result: ModalResult) -> None:
if result.result_id == AppAction.DELETE_SELECTED and result.value is True:
...

Mount ModalWidget once near the app root, open it through ModalHost, and handle ModalResultMessage when the flow completes.

from textual.app import App, ComposeResult
from lazy_cuh import InfoModalSpec
from lazy_cuh.widgets import ModalHost, ModalResultMessage, ModalWidget
class Demo(App):
def __init__(self) -> None:
super().__init__()
self.modals = ModalHost(self)
def compose(self) -> ComposeResult:
yield ModalWidget(id="modal")
def action_show_help(self) -> None:
self.modals.open(
InfoModalSpec(title="Keybindings", message="q: quit\n?: close")
)
def on_modal_result_message(self, message: ModalResultMessage) -> None:
result = message.result

The widget is only a runtime adapter: focus, key handling, and Textual child widgets live there, while modal lifecycle decisions stay in ModalState.