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 == 250Parser errors keep the modal open. Use parse_error to customize the message.
Editing Options
Section titled “Editing Options”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.updatedassert outcome.option_id == "scrolloff"
options = outcome.optionsassert options.option("scrolloff").value == 4This 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.
Routing Results
Section titled “Routing Results”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: ...Textual Adapter
Section titled “Textual Adapter”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 InfoModalSpecfrom 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.resultThe widget is only a runtime adapter: focus, key handling, and Textual child
widgets live there, while modal lifecycle decisions stay in ModalState.