Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.


Expand Down
2 changes: 1 addition & 1 deletion docs/controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Static tab views use native `TabbedContent`:
</tabbed-content>
```

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

Expand Down
2 changes: 1 addition & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<component src="components/card.ui" as="agent-card"/>`. The component definition has a `<component>` root, optional literal string `<prop>` declarations, and one widget root. Use `{property}` in template text or attribute values, and use named `<slot>` 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

Expand Down
84 changes: 84 additions & 0 deletions tests/test_display_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('''<ui>
<button id="open">Open accounts</button>
<vertical id="overlay" style="display: none;">
<list id="accounts" item-label="{label}" autofocus="true" on-selected="select_account" />
</vertical>
</ui>'''), 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('''<ui>
<button id="open">Open</button>
<input id="prompt" autofocus="true" disabled="true" style="display: none;" />
</ui>'''))
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('''<ui>
Expand All @@ -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('''<ui>
<button id="open" on-pressed="open_modal">Open</button>
<modal id="dialog"><input autofocus="true" /></modal>
</ui>'''), 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('''<ui>
<vertical id="overlay" style="display: none;"><input id="background" autofocus="true" /></vertical>
<modal id="dialog"><input id="foreground" autofocus="true" /></modal>
</ui>'''))
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('''<ui>
Expand Down
55 changes: 50 additions & 5 deletions textui/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}
Expand All @@ -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()
Expand All @@ -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)

Expand All @@ -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():
Expand Down
Loading