Merge pull request #2581 from Textualize/auto-focus-improv

AUTO_FOCUS targets first focusable widget.
This commit is contained in:
Rodrigo Girão Serrão
2023-05-17 15:32:08 +01:00
committed by GitHub
10 changed files with 945 additions and 931 deletions

View File

@@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed `TreeNode.toggle` and `TreeNode.toggle_all` not posting a `Tree.NodeExpanded` or `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535 - Fixed `TreeNode.toggle` and `TreeNode.toggle_all` not posting a `Tree.NodeExpanded` or `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535
- `footer--description` component class was being ignored https://github.com/Textualize/textual/issues/2544 - `footer--description` component class was being ignored https://github.com/Textualize/textual/issues/2544
- Pasting empty selection in `Input` would raise an exception https://github.com/Textualize/textual/issues/2563 - Pasting empty selection in `Input` would raise an exception https://github.com/Textualize/textual/issues/2563
- `Screen.AUTO_FOCUS` now focuses the first _focusable_ widget that matches the selector https://github.com/Textualize/textual/issues/2578
- `Screen.AUTO_FOCUS` now works on the default screen on startup https://github.com/Textualize/textual/pull/2581
- Fix for setting dark in App `__init__` https://github.com/Textualize/textual/issues/2583 - Fix for setting dark in App `__init__` https://github.com/Textualize/textual/issues/2583
- Fix issue with scrolling and docks https://github.com/Textualize/textual/issues/2525 - Fix issue with scrolling and docks https://github.com/Textualize/textual/issues/2525

View File

@@ -2140,6 +2140,7 @@ class App(Generic[ReturnType], DOMNode):
screen = Screen(id="_default") screen = Screen(id="_default")
self._register(self, screen) self._register(self, screen)
self._screen_stack.append(screen) self._screen_stack.append(screen)
screen.post_message(events.ScreenResume())
await super().on_event(event) await super().on_event(event)
elif isinstance(event, events.InputEvent) and not event.is_forwarded: elif isinstance(event, events.InputEvent) and not event.is_forwarded:

View File

@@ -668,15 +668,13 @@ class Screen(Generic[ScreenResultType], Widget):
"""Screen has resumed.""" """Screen has resumed."""
self.stack_updates += 1 self.stack_updates += 1
size = self.app.size size = self.app.size
if self.AUTO_FOCUS is not None and self.focused is None:
try:
to_focus = self.query(self.AUTO_FOCUS).first()
except NoMatches:
pass
else:
self.set_focus(to_focus)
self._refresh_layout(size, full=True) self._refresh_layout(size, full=True)
self.refresh() self.refresh()
if self.AUTO_FOCUS is not None and self.focused is None:
for widget in self.query(self.AUTO_FOCUS):
if widget.focusable:
self.set_focus(widget)
break
def _on_screen_suspend(self) -> None: def _on_screen_suspend(self) -> None:
"""Screen has suspended.""" """Screen has suspended."""

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,4 @@
from pathlib import Path from pathlib import Path
import sys
import pytest import pytest
@@ -78,8 +77,7 @@ def test_switches(snap_compare):
def test_input_and_focus(snap_compare): def test_input_and_focus(snap_compare):
press = [ press = [
"tab", *"Darren", # Write "Darren"
*"Darren", # Focus first input, write "Darren"
"tab", "tab",
*"Burns", # Focus second input, write "Burns" *"Burns", # Focus second input, write "Burns"
] ]
@@ -88,7 +86,7 @@ def test_input_and_focus(snap_compare):
def test_buttons_render(snap_compare): def test_buttons_render(snap_compare):
# Testing button rendering. We press tab to focus the first button too. # Testing button rendering. We press tab to focus the first button too.
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab", "tab"]) assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])
def test_placeholder_render(snap_compare): def test_placeholder_render(snap_compare):
@@ -189,7 +187,7 @@ def test_content_switcher_example_initial(snap_compare):
def test_content_switcher_example_switch(snap_compare): def test_content_switcher_example_switch(snap_compare):
assert snap_compare( assert snap_compare(
WIDGET_EXAMPLES_DIR / "content_switcher.py", WIDGET_EXAMPLES_DIR / "content_switcher.py",
press=["tab", "tab", "enter", "wait:500"], press=["tab", "enter", "wait:500"],
terminal_size=(50, 50), terminal_size=(50, 50),
) )
@@ -315,7 +313,7 @@ def test_programmatic_scrollbar_gutter_change(snap_compare):
def test_borders_preview(snap_compare): def test_borders_preview(snap_compare):
assert snap_compare(CLI_PREVIEWS_DIR / "borders.py", press=["tab", "enter"]) assert snap_compare(CLI_PREVIEWS_DIR / "borders.py", press=["enter"])
def test_colors_preview(snap_compare): def test_colors_preview(snap_compare):
@@ -379,9 +377,7 @@ def test_disabled_widgets(snap_compare):
def test_focus_component_class(snap_compare): def test_focus_component_class(snap_compare):
assert snap_compare( assert snap_compare(SNAPSHOT_APPS_DIR / "focus_component_class.py", press=["tab"])
SNAPSHOT_APPS_DIR / "focus_component_class.py", press=["tab", "tab"]
)
def test_line_api_scrollbars(snap_compare): def test_line_api_scrollbars(snap_compare):
@@ -442,7 +438,7 @@ def test_modal_dialog_bindings_input(snap_compare):
# Check https://github.com/Textualize/textual/issues/2194 # Check https://github.com/Textualize/textual/issues/2194
assert snap_compare( assert snap_compare(
SNAPSHOT_APPS_DIR / "modal_screen_bindings.py", SNAPSHOT_APPS_DIR / "modal_screen_bindings.py",
press=["enter", "tab", "h", "!", "left", "i", "tab"], press=["enter", "h", "!", "left", "i", "tab"],
) )
@@ -518,6 +514,5 @@ def test_select_rebuild(snap_compare):
# https://github.com/Textualize/textual/issues/2557 # https://github.com/Textualize/textual/issues/2557
assert snap_compare( assert snap_compare(
SNAPSHOT_APPS_DIR / "select_rebuild.py", SNAPSHOT_APPS_DIR / "select_rebuild.py",
press=["tab", "space", "escape", "tab", "enter", "tab", "space"] press=["space", "escape", "tab", "enter", "tab", "space"],
) )

View File

@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import Button from textual.widgets import Button, Input
def test_batch_update(): def test_batch_update():
@@ -20,6 +20,7 @@ def test_batch_update():
class MyApp(App): class MyApp(App):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Input()
yield Button("Click me!") yield Button("Click me!")

View File

@@ -36,7 +36,7 @@ async def test_on_button_pressed() -> None:
app = ButtonApp() app = ButtonApp()
async with app.run_test() as pilot: async with app.run_test() as pilot:
await pilot.press("tab", "enter", "tab", "enter", "tab", "enter") await pilot.press("enter", "tab", "enter", "tab", "enter")
await pilot.pause() await pilot.pause()
assert pressed == [ assert pressed == [

View File

@@ -38,6 +38,7 @@ async def test_empty_paste():
app = PasteApp() app = PasteApp()
async with app.run_test() as pilot: async with app.run_test() as pilot:
app.set_focus(None)
await pilot.press("p") await pilot.press("p")
assert app.query_one(MyInput).value == "" assert app.query_one(MyInput).value == ""
assert len(paste_events) == 1 assert len(paste_events) == 1

View File

@@ -6,7 +6,7 @@ import pytest
from textual.app import App, ScreenStackError from textual.app import App, ScreenStackError
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Button, Input from textual.widgets import Button, Input, Label
skip_py310 = pytest.mark.skipif( skip_py310 = pytest.mark.skipif(
sys.version_info.minor == 10 and sys.version_info.major == 3, sys.version_info.minor == 10 and sys.version_info.major == 3,
@@ -155,8 +155,7 @@ async def test_screens():
async def test_auto_focus(): async def test_auto_focus():
class MyScreen(Screen[None]): class MyScreen(Screen[None]):
def compose(self) -> None: def compose(self):
print("composing")
yield Button() yield Button()
yield Input(id="one") yield Input(id="one")
yield Input(id="two") yield Input(id="two")
@@ -194,6 +193,22 @@ async def test_auto_focus():
assert app.focused.id == "two" assert app.focused.id == "two"
async def test_auto_focus_skips_non_focusable_widgets():
class MyScreen(Screen[None]):
def compose(self):
yield Label()
yield Button()
class MyApp(App[None]):
def on_mount(self):
self.push_screen(MyScreen())
app = MyApp()
async with app.run_test():
assert app.focused is not None
assert isinstance(app.focused, Button)
async def test_dismiss_non_top_screen(): async def test_dismiss_non_top_screen():
class MyApp(App[None]): class MyApp(App[None]):
async def key_p(self) -> None: async def key_p(self) -> None:

View File

@@ -39,6 +39,7 @@ async def test_radio_sets_initial_state():
async def test_click_sets_focus(): async def test_click_sets_focus():
"""Clicking within a radio set should set focus.""" """Clicking within a radio set should set focus."""
async with RadioSetApp().run_test() as pilot: async with RadioSetApp().run_test() as pilot:
pilot.app.set_focus(None)
assert pilot.app.screen.focused is None assert pilot.app.screen.focused is None
await pilot.click("#clickme") await pilot.click("#clickme")
assert pilot.app.screen.focused == pilot.app.query_one("#from_buttons") assert pilot.app.screen.focused == pilot.app.query_one("#from_buttons")
@@ -72,8 +73,6 @@ async def test_radioset_same_button_mash():
async def test_radioset_inner_navigation(): async def test_radioset_inner_navigation():
"""Using the cursor keys should navigate between buttons in a set.""" """Using the cursor keys should navigate between buttons in a set."""
async with RadioSetApp().run_test() as pilot: async with RadioSetApp().run_test() as pilot:
assert pilot.app.screen.focused is None
await pilot.press("tab")
for key, landing in ( for key, landing in (
("down", 1), ("down", 1),
("up", 0), ("up", 0),
@@ -88,8 +87,6 @@ async def test_radioset_inner_navigation():
== pilot.app.query_one("#from_buttons").children[landing] == pilot.app.query_one("#from_buttons").children[landing]
) )
async with RadioSetApp().run_test() as pilot: async with RadioSetApp().run_test() as pilot:
assert pilot.app.screen.focused is None
await pilot.press("tab")
assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_buttons") assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_buttons")
await pilot.press("tab") await pilot.press("tab")
assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_strings") assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_strings")
@@ -101,8 +98,6 @@ async def test_radioset_inner_navigation():
async def test_radioset_breakout_navigation(): async def test_radioset_breakout_navigation():
"""Shift/Tabbing while in a radioset should move to the previous/next focsuable after the set itself.""" """Shift/Tabbing while in a radioset should move to the previous/next focsuable after the set itself."""
async with RadioSetApp().run_test() as pilot: async with RadioSetApp().run_test() as pilot:
assert pilot.app.screen.focused is None
await pilot.press("tab")
assert pilot.app.screen.focused is pilot.app.query_one("#from_buttons") assert pilot.app.screen.focused is pilot.app.query_one("#from_buttons")
await pilot.press("tab") await pilot.press("tab")
assert pilot.app.screen.focused is pilot.app.query_one("#from_strings") assert pilot.app.screen.focused is pilot.app.query_one("#from_strings")