diff --git a/CHANGELOG.md b/CHANGELOG.md index d0d3c71..8a92097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to TextUI are documented here. ### Fixed - Controller resize hooks now receive terminal dimensions even when the active screen has padding or a border. +- Declared autofocus now waits for hidden ancestors to be displayed and runs again when controls are revealed after startup. Disabled controls and background screens do not take focus; anonymous modal autofocus registrations are released on dismissal. - Transcript mouse selections are cleared when bounded history evicts rows, the log is cleared, or a streamed entry is rewritten. Stable append operations and selections in other widgets are preserved. diff --git a/docs/controls.md b/docs/controls.md index 18144f5..b592351 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -27,7 +27,7 @@ Static tab views use native `TabbedContent`: ``` -Each pane needs an ID and title. `initial` must name one of those panes; without it, Textual selects the first. The `tab-activated` event provides `context.event.pane`. Tabs respond to native mouse and keyboard input. Add `autofocus="true"` to a focusable mounted control when it should receive keyboard input after the document or a modal opens. In an inactive tab, autofocus waits for that tab to activate and does not override `initial`. TextUI gives focused native controls an accent outline by default; application TCSS may override it. The [controls example](../examples/controls/app.ui) shows all four controls in a runnable project. +Each pane needs an ID and title. `initial` must name one of those panes; without it, Textual selects the first. The `tab-activated` event provides `context.event.pane`. Tabs respond to native mouse and keyboard input. Add `autofocus="true"` to a focusable control when it should receive keyboard input after the document or a modal opens, or after a hidden control or its parent is shown with `display=True`. Focus waits for layout, skips disabled and hidden controls, and applies only to the active screen. In an inactive tab, autofocus waits for that tab to activate and does not override `initial`. TextUI gives focused native controls an accent background tint by default; application TCSS may override it. The [controls example](../examples/controls/app.ui) shows all four controls in a runnable project. ## Choices, disclosure, and indicators diff --git a/docs/migration.md b/docs/migration.md index d545f49..4863295 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -30,7 +30,7 @@ Files are UTF-8 and retain a resolved source path. `from_string` never treats it The project runtime can import a local `.ui` component directly below its entry root: ``. The component definition has a `` root, optional literal string `` declarations, and one widget root. Use `{property}` in template text or attribute values, and use named `` placeholders for caller-provided widgets with fallback content. Public instance attributes apply to that root, while template IDs are isolated per instance. Scripts and styles remain entry-only. See the [project runtime guide](project-runtime.md#reusable-components). -Use lowercase kebab-case names (`max-length`, not `max_length`). Booleans are exactly `true` and `false`; numbers and enums are validated. `class="primary compact"` means two independently validated class tokens, and duplicates are removed. IDs must be unique and valid; anonymous widgets do not receive generated IDs. Set `autofocus="true"` on a focusable control or component instance to receive focus after it mounts; inside a tab pane, it waits for that pane to activate. Whitespace in leaf text collapses and brackets remain literal rather than becoming Rich markup. +Use lowercase kebab-case names (`max-length`, not `max_length`). Booleans are exactly `true` and `false`; numbers and enums are validated. `class="primary compact"` means two independently validated class tokens, and duplicates are removed. IDs must be unique and valid; anonymous widgets do not receive generated IDs. Set `autofocus="true"` on a focusable control or component instance to receive focus after it mounts or is revealed by a `display` change; inside a tab pane, it waits for that pane to activate. Disabled or hidden controls do not take focus. Whitespace in leaf text collapses and brackets remain literal rather than becoming Rich markup. ## Move behavior into Python diff --git a/tests/test_display_controls.py b/tests/test_display_controls.py index 37ff3ca..3fa4ae5 100644 --- a/tests/test_display_controls.py +++ b/tests/test_display_controls.py @@ -15,6 +15,54 @@ async def test_autofocus_moves_keyboard_focus_to_a_declared_control_after_mount( assert app.focused is app.document.get_by_id("prompt") +@pytest.mark.asyncio +async def test_autofocus_waits_for_a_hidden_parent_and_reacts_to_each_reveal(): + selected = [] + app = TextUI(DocumentLoader().from_string(''' + + + '''), actions={"select_account": lambda context: selected.append(context.event.item)}) + async with app.run_test() as pilot: + overlay = app.document.get_by_id("overlay") + accounts = app.document.get_by_id("accounts") + button = app.document.get_by_id("open") + await accounts.set_items([{"label": "Alpha"}, {"label": "Bravo"}]) + await pilot.pause() + assert app.focused is not accounts + + overlay.display = True + await pilot.pause() + await pilot.pause() + assert app.focused is accounts + await pilot.press("down", "enter") + assert selected == [{"label": "Bravo"}] + + overlay.display = False + button.focus() + await pilot.wait_for_scheduled_animations() + assert not accounts.is_on_screen + overlay.display = True + await pilot.pause() + await pilot.pause() + assert app.focused is accounts + + +@pytest.mark.asyncio +async def test_revealed_disabled_autofocus_does_not_displace_keyboard_focus(): + app = TextUI(DocumentLoader().from_string(''' + + + ''')) + async with app.run_test() as pilot: + button = app.document.get_by_id("open") + button.focus() + app.document.get_by_id("prompt").display = True + await pilot.pause() + assert app.focused is button + + @pytest.mark.asyncio async def test_modal_autofocus_moves_focus_when_the_modal_is_revealed(): app = TextUI(DocumentLoader().from_string(''' @@ -30,6 +78,42 @@ async def test_modal_autofocus_moves_focus_when_the_modal_is_revealed(): assert app.document._autofocus_widgets == [] +@pytest.mark.asyncio +async def test_anonymous_modal_autofocus_is_focused_and_released_on_dismissal(): + app = TextUI(DocumentLoader().from_string(''' + + + '''), actions={"open_modal": lambda context: context.push_modal("dialog")}) + async with app.run_test() as pilot: + for _ in range(2): + # Reopening tests modal focus, independent of Button's timed + # mouse-click suppression while its active effect is visible. + app.document.get_by_id("open").press() + await pilot.pause() + await pilot.pause() + assert app.focused in app.document._autofocus_widgets + app.document.dismiss_modal() + await pilot.pause() + assert app.document._autofocus_widgets == [] + + +@pytest.mark.asyncio +async def test_background_reveal_does_not_displace_modal_focus(): + app = TextUI(DocumentLoader().from_string(''' + + + ''')) + async with app.run_test() as pilot: + modal = app.document.push_modal("dialog") + await modal.mounted + await pilot.pause() + await pilot.pause() + app.document.get_by_id("overlay").display = True + await pilot.pause() + await pilot.pause() + assert app.focused is app.document.get_by_id("foreground") + + @pytest.mark.asyncio async def test_autofocus_does_not_override_the_initial_tab(): app = TextUI(DocumentLoader().from_string(''' diff --git a/textui/document.py b/textui/document.py index 59f2b78..bd8936e 100644 --- a/textui/document.py +++ b/textui/document.py @@ -10,6 +10,7 @@ from textual.app import App from textual.content import Content +from textual.events import Show from textual.message import Message from textual.widget import Widget from textual.widgets import Button, Checkbox, Input, RadioButton, RadioSet, Select, Switch, TabbedContent, TabPane, TextArea @@ -141,6 +142,10 @@ def __init__( self._preset_enabled = {block.preset: True for block in definition.styles if block.preset is not None} self._compact_widgets: list[Widget] = [] self._autofocus_widgets: list[Widget] = [] + self._autofocus_watched: WeakSet[Widget] = WeakSet() + self._autofocus_pending: set[Widget] = set() + self._autofocus_refresh_pending = False + self._autofocus_closed = False self._gradient_backgrounds: tuple[GradientBackground, ...] = () def _apply_compact_preset(self, widget: Widget) -> None: @@ -247,12 +252,43 @@ def _apply_gradient_backgrounds(self) -> None: bar.set_background_gradients(gradients) def _focus_autofocus(self) -> None: + self._watch_autofocus(self._autofocus_widgets) self._focus_widgets(self._autofocus_widgets) - @staticmethod - def _focus_widgets(widgets: Iterable[Widget]) -> None: + def _watch_autofocus(self, widgets: Iterable[Widget]) -> None: + if self._autofocus_closed or not self.app.is_running: + return + for widget in widgets: + if widget in self._autofocus_watched: + continue + widget.message_signal.subscribe( + self.app, + lambda message, widget=widget: self._autofocus_message(widget, message), + immediate=True, + ) + self._autofocus_watched.add(widget) + + def _autofocus_message(self, widget: Widget, message: Message) -> None: + if not isinstance(message, Show) or self._autofocus_closed or widget not in self._autofocus_widgets: + return + self._autofocus_pending.add(widget) + if not self._autofocus_refresh_pending: + self._autofocus_refresh_pending = self.app.call_after_refresh(self._focus_shown_autofocus) + + def _focus_shown_autofocus(self) -> None: + pending = self._autofocus_pending + self._autofocus_pending = set() + self._autofocus_refresh_pending = False + self._focus_widgets(widget for widget in self._autofocus_widgets if widget in pending) + + def _focus_widgets(self, widgets: Iterable[Widget]) -> None: + if self._autofocus_closed or not self.app.is_running: + return for widget in reversed(tuple(widgets)): - if widget.is_mounted and widget.display and BoundDocument._in_active_tabs(widget): + if ( + widget.is_mounted and widget.is_on_screen and widget.focusable + and widget.screen is self.app.screen and self._in_active_tabs(widget) + ): widget.screen.set_focus(widget) return @@ -313,14 +349,18 @@ def push_modal(self, modal_id: str) -> ModalResult: widgets: dict[str, Widget] = {} bindings: dict[type, list[tuple[Widget, EventSpec, str, ElementNode]]] = {} compact_start = len(self._compact_widgets) + autofocus_start = len(self._autofocus_widgets) try: modal = self._build_node(node, widgets, bindings) except BaseException: del self._compact_widgets[compact_start:] + del self._autofocus_widgets[autofocus_start:] raise compact_widgets = tuple(self._compact_widgets[compact_start:]) + autofocus_widgets = tuple(self._autofocus_widgets[autofocus_start:]) if not isinstance(modal, MarkupModal): del self._compact_widgets[compact_start:] + del self._autofocus_widgets[autofocus_start:] raise DocumentStateError(f'Element {modal_id!r} is not a modal') future = ModalResult() owned_entries = {id(entry) for entries in bindings.values() for entry in entries} @@ -338,9 +378,11 @@ def remove_registrations() -> None: for widget in compact_widgets: if widget in self._compact_widgets: self._compact_widgets.remove(widget) - for widget in widgets.values(): + for widget in autofocus_widgets: if widget in self._autofocus_widgets: self._autofocus_widgets.remove(widget) + self._autofocus_pending.discard(widget) + self._autofocus_watched.discard(widget) def finalize_dismissal(value: object | None) -> None: remove_registrations() @@ -355,7 +397,8 @@ def dismissed(value: object | None) -> None: def mounted() -> None: self._apply_gradient_backgrounds() - self._focus_widgets(widget for widget in widgets.values() if getattr(widget, '_textui_autofocus', False)) + self._watch_autofocus(autofocus_widgets) + self.app.call_after_refresh(lambda: self._focus_widgets(autofocus_widgets)) modal.set_mount_callback(mounted) @@ -380,6 +423,8 @@ def dismiss_modal(self, value: object | None = None) -> None: def close(self) -> None: """Cancel lifecycle work and clear loading state during application shutdown.""" + self._autofocus_closed = True + self._autofocus_pending.clear() for (_name, target_id), tasks in tuple(self._lifecycle_tasks.items()): for task in tasks: if not task.done():