From fec3466dc83d69c2c2969129d2d2cfe6da9371cd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 9 Nov 2025 17:22:36 +0000 Subject: [PATCH 1/5] Focus on click and moount_compose --- CHANGELOG.md | 1 + src/textual/app.py | 8 +++--- src/textual/screen.py | 2 +- src/textual/widget.py | 64 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c45cbb4163..36ff4adcd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `grid_size` property to `GridLayout` https://github.com/Textualize/textual/pull/6210 - Exposed `NoSelection` and `BLANK` via `textual.widgets.select` https://github.com/Textualize/textual/pull/6214 +- Added `Widget.FOCUS_ON_CLICK` classvar amd `Widget.focus_on_click` method ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index 534f38e6a9..9bf7ad11a1 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2437,8 +2437,8 @@ def mount( MountError: If there is a problem with the mount request. Note: - Only one of ``before`` or ``after`` can be provided. If both are - provided a ``MountError`` will be raised. + Only one of `before` or `after` can be provided. If both are + provided a `MountError` will be raised. """ return self.screen.mount(*widgets, before=before, after=after) @@ -2467,8 +2467,8 @@ def mount_all( MountError: If there is a problem with the mount request. Note: - Only one of ``before`` or ``after`` can be provided. If both are - provided a ``MountError`` will be raised. + Only one of `before` or `after` can be provided. If both are + provided a `MountError` will be raised. """ return self.mount(*widgets, before=before, after=after) diff --git a/src/textual/screen.py b/src/textual/screen.py index 8af7c2f695..29a0be5ac6 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1722,7 +1722,7 @@ def _forward_event(self, event: events.Event) -> None: else: if isinstance(event, events.MouseDown): focusable_widget = self.get_focusable_widget_at(event.x, event.y) - if focusable_widget: + if focusable_widget and focusable_widget.focus_on_click(): self.set_focus(focusable_widget, scroll_visible=False) event.style = self.get_style_at(event.screen_x, event.screen_y) if widget.loading: diff --git a/src/textual/widget.py b/src/textual/widget.py index d02bb237fe..ad526e606e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -324,7 +324,10 @@ class Widget(DOMNode): """ ALLOW_SELECT: ClassVar[bool] = True - """Does this widget support automatic text selection? May be further refined with [Widget.allow_select][textual.widget.Widget.allow_select]""" + """Does this widget support automatic text selection? May be further refined with [Widget.allow_select][textual.widget.Widget.allow_select].""" + + FOCUS_ON_CLICK: ClassVar[bool] = True + """Should focusable widgets be automatically focused on click? Default return value of [Widget.focus_on_click][textual.widget.Widget.focus_on_click].""" can_focus: bool = False """Widget may receive focus.""" @@ -679,6 +682,17 @@ def text_selection(self) -> Selection | None: """Text selection information, or `None` if no text is selected in this widget.""" return self.screen.selections.get(self, None) + def focus_on_click(self) -> bool: + """Automatically focus the widget on click? + + Implement this if you want to change the default click to focus behavior. + The default will return the classvar `FOCUS_ON_CLICK`. + + Returns: + `True` if Textual should set focus automatically on a click, or `False` if it shouldn't. + """ + return self.FOCUS_ON_CLICK + def get_line_filters(self) -> Sequence[LineFilter]: """Get the line filters enabled for this widget. @@ -1470,14 +1484,58 @@ def mount_all( MountError: If there is a problem with the mount request. Note: - Only one of ``before`` or ``after`` can be provided. If both are - provided a ``MountError`` will be raised. + Only one of `before` or `after` can be provided. If both are + provided a `MountError` will be raised. """ if self.app._exit: return AwaitMount(self, []) await_mount = self.mount(*widgets, before=before, after=after) return await_mount + def mount_compose( + self, + compose_result: ComposeResult, + *, + before: int | str | Widget | None = None, + after: int | str | Widget | None = None, + ) -> AwaitMount: + """Mount widgets from the result of a compose method. + + Example: + ```python + def on_key(self, event:events.Key) -> None: + + def add_key(key:str) -> ComposeResult: + '''Compose key information widgets''' + with containers.HorizontalGroup(): + yield Label("You pressed:") + yield Label(key) + + self.mount_compose(add_key(event.key)) + + ``` + + Args: + compose_result: The result of a compose method. + before: Optional location to mount before. An `int` is the index + of the child to mount before, a `str` is a `query_one` query to + find the widget to mount before. + after: Optional location to mount after. An `int` is the index + of the child to mount after, a `str` is a `query_one` query to + find the widget to mount after. + + Returns: + An awaitable object that waits for widgets to be mounted. + + Raises: + MountError: If there is a problem with the mount request. + + Note: + Only one of `before` or `after` can be provided. If both are + provided a `MountError` will be raised. + """ + return self.mount_all(compose(self, compose_result), before=before, after=after) + if TYPE_CHECKING: @overload From 54476d65919a4fd3e0f6e1000b3fe91e071a818d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 9 Nov 2025 17:23:56 +0000 Subject: [PATCH 2/5] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ff4adcd7..49fc6e7f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `grid_size` property to `GridLayout` https://github.com/Textualize/textual/pull/6210 - Exposed `NoSelection` and `BLANK` via `textual.widgets.select` https://github.com/Textualize/textual/pull/6214 -- Added `Widget.FOCUS_ON_CLICK` classvar amd `Widget.focus_on_click` method +- Added `Widget.FOCUS_ON_CLICK` classvar amd `Widget.focus_on_click` method https://github.com/Textualize/textual/pull/6216 ### Changed From b99058d0b5a76ca43c457f104309a827fc2a0333 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 9 Nov 2025 17:29:26 +0000 Subject: [PATCH 3/5] snapshot test for focus on click --- tests/snapshot_tests/test_snapshots.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 0f99dbaf51..74fc7360de 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4739,3 +4739,26 @@ async def action_clear(self) -> None: await vs.remove_children() assert snap_compare(PruneApp(), press=["c"]) + + +def test_focus_on_click(snap_compare) -> None: + """Test focus on click may be prevented. + + You should see a button in a non-focused stated + + """ + + class NonFocusButton(Button): + FOCUS_ON_CLICK = False + + class FocusApp(App): + AUTO_FOCUS = None + + def compose(self) -> ComposeResult: + yield NonFocusButton("Click") + + async def run_before(pilot: Pilot) -> None: + await pilot.pause() + await pilot.click(NonFocusButton) + + assert snap_compare(FocusApp(), run_before=run_before) From fbc38ea0cead42cdf780e5cd7e8b9dda0585e6aa Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 9 Nov 2025 17:34:37 +0000 Subject: [PATCH 4/5] snapshits --- .../test_snapshots/test_focus_on_click.svg | 152 ++++++++++++++++++ .../test_snapshots/test_mount_compose.svg | 150 +++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 19 +++ 3 files changed, 321 insertions(+) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_focus_on_click.svg create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_mount_compose.svg diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_focus_on_click.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_focus_on_click.svg new file mode 100644 index 0000000000..fcf89ec8b1 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_focus_on_click.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FocusApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Click  +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_mount_compose.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_mount_compose.svg new file mode 100644 index 0000000000..09a169e4d8 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_mount_compose.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ComposeApp + + + + + + + + + + Hello, World! + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 74fc7360de..bcb3cb93fb 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4762,3 +4762,22 @@ async def run_before(pilot: Pilot) -> None: await pilot.click(NonFocusButton) assert snap_compare(FocusApp(), run_before=run_before) + + +def test_mount_compose(snap_compare) -> None: + """Test the `Widget.mount_compose` method. + + You should see a Hello World message. + """ + + class ComposeApp(App): + + async def on_mount(self) -> None: + # Perform compose outside of usual compose method. + def compose_things() -> ComposeResult: + """Add a label.""" + yield Label("Hello, World!") + + await self.screen.mount_compose(compose_things()) + + assert snap_compare(ComposeApp()) From 35e61185138779b6cf3db53f6d31c7dceb824054 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 9 Nov 2025 17:44:15 +0000 Subject: [PATCH 5/5] explicit None check --- src/textual/screen.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 29a0be5ac6..bc6f14207f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1722,7 +1722,10 @@ def _forward_event(self, event: events.Event) -> None: else: if isinstance(event, events.MouseDown): focusable_widget = self.get_focusable_widget_at(event.x, event.y) - if focusable_widget and focusable_widget.focus_on_click(): + if ( + focusable_widget is not None + and focusable_widget.focus_on_click() + ): self.set_focus(focusable_widget, scroll_visible=False) event.style = self.get_style_at(event.screen_x, event.screen_y) if widget.loading: