From 775d1b4615f34eefd60332814ec20fc093bd499a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 6 Mar 2024 16:32:54 +0000 Subject: [PATCH 01/12] Add support for XTerm FocusIn/FocusOut detection This enables support for receiving and handling FocusIn and FocusOut sequences, and turns then into AppFocus and AppBlur events. --- src/textual/_xterm_parser.py | 10 ++++++++++ src/textual/drivers/linux_driver.py | 2 ++ 2 files changed, 12 insertions(+) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 0a2ba5b045..297cac0857 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -20,6 +20,8 @@ ) _re_bracketed_paste_start = re.compile(r"^\x1b\[200~$") _re_bracketed_paste_end = re.compile(r"^\x1b\[201~$") +_re_focusin = re.compile(r"^\x1b\[I$") +_re_focusout = re.compile(r"^\x1b\[O$") class XTermParser(Parser[events.Event]): @@ -202,6 +204,14 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None: self.debug_log(f"sequence={sequence!r}") + if _re_focusin.match(sequence): + on_token(events.AppFocus()) + break + + if _re_focusout.match(sequence): + on_token(events.AppBlur()) + break + bracketed_paste_start_match = _re_bracketed_paste_start.match( sequence ) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 275075ec1c..246b04961e 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -232,6 +232,7 @@ def on_terminal_resize(signum, stack) -> None: self.write("\x1b[?25l") # Hide cursor self.write("\033[?1003h\n") + self.write("\033[?1004h\n") # Enable FocusIn/FocusOut. self.flush() self._key_thread = Thread(target=self._run_input_thread) send_size_event() @@ -316,6 +317,7 @@ def stop_application_mode(self) -> None: # Alt screen false, show cursor self.write("\x1b[?1049l" + "\x1b[?25h") + self.write("\033[?1004h\n") # Disable FocusIn/FocusOut. self.flush() def close(self) -> None: From 2f13d7f17669f8df17d038ac4dbe4afbea66d890 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 6 Mar 2024 16:38:37 +0000 Subject: [PATCH 02/12] Fix a copy/paste snafu --- src/textual/drivers/linux_driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 246b04961e..3e7958837c 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -317,7 +317,7 @@ def stop_application_mode(self) -> None: # Alt screen false, show cursor self.write("\x1b[?1049l" + "\x1b[?25h") - self.write("\033[?1004h\n") # Disable FocusIn/FocusOut. + self.write("\033[?1004l\n") # Disable FocusIn/FocusOut. self.flush() def close(self) -> None: From 67b4d453753ea945157e6abf2e993ea7f7efd846 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 7 Mar 2024 09:16:07 +0000 Subject: [PATCH 03/12] Simply use string comparison for FocusIn/Out checks --- src/textual/_xterm_parser.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 297cac0857..b14ae92b89 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -4,6 +4,8 @@ import unicodedata from typing import Any, Callable, Generator, Iterable +from typing_extensions import Final + from . import events, messages from ._ansi_sequences import ANSI_SEQUENCES_KEYS, IGNORE_SEQUENCE from ._parser import Awaitable, Parser, TokenCallback @@ -20,8 +22,11 @@ ) _re_bracketed_paste_start = re.compile(r"^\x1b\[200~$") _re_bracketed_paste_end = re.compile(r"^\x1b\[201~$") -_re_focusin = re.compile(r"^\x1b\[I$") -_re_focusout = re.compile(r"^\x1b\[O$") + +FOCUSIN: Final[str] = "\x1b[I" +"""Sequence received when the terminal receives focus.""" +FOCUSOUT: Final[str] = "\x1b[O" +"""Sequence received when focus is lost from the terminal.""" class XTermParser(Parser[events.Event]): @@ -204,11 +209,11 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None: self.debug_log(f"sequence={sequence!r}") - if _re_focusin.match(sequence): + if sequence == FOCUSIN: on_token(events.AppFocus()) break - if _re_focusout.match(sequence): + if sequence == FOCUSOUT: on_token(events.AppBlur()) break From b96bc9a921e9ed7570bc5c9089845dd55eb35980 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 7 Mar 2024 09:20:08 +0000 Subject: [PATCH 04/12] Simply use string comparison for bracketed paste checks --- src/textual/_xterm_parser.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index b14ae92b89..78a4fce4ac 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -20,9 +20,11 @@ _re_terminal_mode_response = re.compile( "^" + re.escape("\x1b[") + r"\?(?P\d+);(?P\d)\$y" ) -_re_bracketed_paste_start = re.compile(r"^\x1b\[200~$") -_re_bracketed_paste_end = re.compile(r"^\x1b\[201~$") +BRACKETED_PASTE_START: Final[str] = "\x1b[200~" +"""Sequence received when a bracketed paste event starts.""" +BRACKETED_PASTE_END: Final[str] = "\x1b[201~" +"""Sequence received when a bracketed paste event ends.""" FOCUSIN: Final[str] = "\x1b[I" """Sequence received when the terminal receives focus.""" FOCUSOUT: Final[str] = "\x1b[O" @@ -217,15 +219,11 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None: on_token(events.AppBlur()) break - bracketed_paste_start_match = _re_bracketed_paste_start.match( - sequence - ) - if bracketed_paste_start_match is not None: + if sequence == BRACKETED_PASTE_START: bracketed_paste = True break - bracketed_paste_end_match = _re_bracketed_paste_end.match(sequence) - if bracketed_paste_end_match is not None: + if sequence == BRACKETED_PASTE_END: bracketed_paste = False break From d28596a22dbc53c29786a7de98192ccbab9f18d4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 7 Mar 2024 09:28:23 +0000 Subject: [PATCH 05/12] Add FocusIn/Out enable/disable support to the Windows driver --- src/textual/drivers/windows_driver.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/drivers/windows_driver.py b/src/textual/drivers/windows_driver.py index 455a3d4fe6..1df31728ac 100644 --- a/src/textual/drivers/windows_driver.py +++ b/src/textual/drivers/windows_driver.py @@ -90,6 +90,7 @@ def start_application_mode(self) -> None: self._enable_mouse_support() self.write("\x1b[?25l") # Hide cursor self.write("\033[?1003h\n") + self.write("\033[?1004h\n") # Enable FocusIn/FocusOut. self._enable_bracketed_paste() self._event_thread = win32.EventMonitor( @@ -118,6 +119,7 @@ def stop_application_mode(self) -> None: # Disable alt screen, show cursor self.write("\x1b[?1049l" + "\x1b[?25h") + self.write("\033[?1004l\n") # Disable FocusIn/FocusOut. self.flush() def close(self) -> None: From 497dc544c30eff7f50c24b96eed8577fa11ff71c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 7 Mar 2024 10:21:26 +0000 Subject: [PATCH 06/12] Fix the problem of the focused widget being lost when app focus goes --- src/textual/app.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 124f2d25c2..7141e71663 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -629,6 +629,13 @@ def __init__( See [`textual.constants.TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS]. """ + self._last_focused_on_app_blur: Widget | None = None + """The widget that had focus when the last `AppBlur` happened. + + This will be used to restore correct focus when an `AppFocus` + happens. + """ + def validate_title(self, title: Any) -> str: """Make sure the title is set to a string.""" return str(title) @@ -3197,10 +3204,20 @@ async def _prune_node(self, root: Widget) -> None: def _watch_app_focus(self, focus: bool) -> None: """Respond to changes in app focus.""" if focus: - focused = self.screen.focused - self.screen.set_focus(None) - self.screen.set_focus(focused) + # Attempt to focus the widget that last had focus. While it is + # possible that the widget, or even the screen that owned it, + # have gone away while the app was without focus, this is safe + # to do. It just means that a focus message will be sent to a + # now-orphaned widget reference. + self.screen.set_focus(self._last_focused_on_app_blur) + # Now that we have focus back on the app and we don't need the + # widget reference any more, don't keep it hanging around here. + self._last_focused_on_app_blur = None else: + # Remember which widget has focus, when the app gets focus back + # we'll want to try and focus it again. + self._last_focused_on_app_blur = self.screen.focused + # Remove focus for now. self.screen.set_focus(None) async def action_check_bindings(self, key: str) -> None: From faea8faec93919784efc287e4e4dd0477e487899 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 7 Mar 2024 11:24:31 +0000 Subject: [PATCH 07/12] Only restore widget focus if the widget belongs to the current screen While the terminal window didn't have focus, anything can could happen. The widget could be removed, the screen could change, etc. So by the time AppFocus happens the widget might not be one to focus any more. Initially I was just making it the focused widget anyway and letting the focus-handling code do what it needed to do. Sending focus to a widget that isn't part of the DOM any more isn't exactly a breaking problem; but... One issue is that you can end up with App.focused saying that a widget is focused that isn't in the DOM any more. We don't want that. So here I'm a bit more defensive. This changes things so that we check that the widget's screen is still the screen that's in play. If the widget has been removed it won't have a parent and so can't find its screen. All of this means that if the screen has changed *or* if the widget has been removed, we're covered. --- src/textual/app.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 310ea4403d..c94bb5b802 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -90,7 +90,7 @@ from .css.query import NoMatches from .css.stylesheet import RulesMap, Stylesheet from .design import ColorSystem -from .dom import DOMNode +from .dom import DOMNode, NoScreen from .driver import Driver from .drivers.headless_driver import HeadlessDriver from .errors import NoWidget @@ -3204,12 +3204,17 @@ async def _prune_node(self, root: Widget) -> None: def _watch_app_focus(self, focus: bool) -> None: """Respond to changes in app focus.""" if focus: - # Attempt to focus the widget that last had focus. While it is - # possible that the widget, or even the screen that owned it, - # have gone away while the app was without focus, this is safe - # to do. It just means that a focus message will be sent to a - # now-orphaned widget reference. - self.screen.set_focus(self._last_focused_on_app_blur) + # If we've got a last-focused widget, if it still has a screen, + # and if the screen is still the current screen... + try: + if ( + self._last_focused_on_app_blur is not None + and self._last_focused_on_app_blur.screen is self.screen + ): + # ...settle focus back on that widget. + self.screen.set_focus(self._last_focused_on_app_blur) + except NoScreen: + pass # Now that we have focus back on the app and we don't need the # widget reference any more, don't keep it hanging around here. self._last_focused_on_app_blur = None From 8c18c9e4b6733d12a6e2e07bf9c378efe24d92c1 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 7 Mar 2024 11:28:47 +0000 Subject: [PATCH 08/12] Add tests for AppFocus and AppBlur These tests don't test the actual act of blurring or focusing the application (that's kind of hard to do in tests, really). What it does do is test that widget focus does the right thing after each of those app-level events. --- tests/test_app_focus_blur.py | 64 ++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/test_app_focus_blur.py diff --git a/tests/test_app_focus_blur.py b/tests/test_app_focus_blur.py new file mode 100644 index 0000000000..972cd69e1a --- /dev/null +++ b/tests/test_app_focus_blur.py @@ -0,0 +1,64 @@ +"""Test the workings of reacting to AppFocus and AppBlur.""" + +from textual.app import App, ComposeResult +from textual.events import AppBlur, AppFocus +from textual.widgets import Input + + +class FocusBlurApp(App[None]): + + AUTO_FOCUS = "#input-4" + + def compose(self) -> ComposeResult: + for n in range(10): + yield Input(id=f"input-{n}") + + +async def test_app_blur() -> None: + """Test that AppBlur removes focus.""" + async with FocusBlurApp().run_test() as pilot: + assert pilot.app.focused is not None + assert pilot.app.focused.id == "input-4" + pilot.app.post_message(AppBlur()) + await pilot.pause() + assert pilot.app.focused is None + + +async def test_app_focus_restores_focus() -> None: + """Test that AppFocus restores the correct focus.""" + async with FocusBlurApp().run_test() as pilot: + assert pilot.app.focused is not None + assert pilot.app.focused.id == "input-4" + pilot.app.post_message(AppBlur()) + await pilot.pause() + assert pilot.app.focused is None + pilot.app.post_message(AppFocus()) + await pilot.pause() + assert pilot.app.focused is not None + assert pilot.app.focused.id == "input-4" + + +async def test_app_focus_restores_none_focus() -> None: + """Test that AppFocus doesn't set focus if nothing was focused.""" + async with FocusBlurApp().run_test() as pilot: + pilot.app.screen.focused = None + pilot.app.post_message(AppBlur()) + await pilot.pause() + assert pilot.app.focused is None + pilot.app.post_message(AppFocus()) + await pilot.pause() + assert pilot.app.focused is None + + +async def test_app_focus_handles_missing_widget() -> None: + """Test that AppFocus works even when the last-focused widget has gone away.""" + async with FocusBlurApp().run_test() as pilot: + assert pilot.app.focused is not None + assert pilot.app.focused.id == "input-4" + pilot.app.post_message(AppBlur()) + await pilot.pause() + assert pilot.app.focused is None + await pilot.app.query_one("#input-4").remove() + pilot.app.post_message(AppFocus()) + await pilot.pause() + assert pilot.app.focused is None From 11fbf4f7face89281c22b481f2b7edf8211b115c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 7 Mar 2024 14:29:08 +0000 Subject: [PATCH 09/12] Update the docstrings of AppFocus and AppBlur Remove the text so say they're only for textual-web, but make it clear textual-web is supported, as are any other terminals that support the required sequences. --- src/textual/events.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/events.py b/src/textual/events.py index eb53d4a5db..a6b18f1300 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -563,7 +563,8 @@ class Blur(Event, bubble=False): class AppFocus(Event, bubble=False): """Sent when the app has focus. - Used by textual-web. + Only available when running within a terminal that supports `FocusIn`, + or when running via textual-web. - [ ] Bubbles - [ ] Verbose @@ -573,7 +574,8 @@ class AppFocus(Event, bubble=False): class AppBlur(Event, bubble=False): """Sent when the app loses focus. - Used by textual-web. + Only available when running within a terminal that supports `FocusOut`, + or when running via textual-web. - [ ] Bubbles - [ ] Verbose From a687be955f7fc8fb4a3d40d7fa08216ddced7af0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 7 Mar 2024 15:23:02 +0000 Subject: [PATCH 10/12] Update the ChangeLog Co-authored-by: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Co-authored-by: Will McGugan --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b45d4618..f300262eba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Changed `Tabs` - Changed `TextArea` - Changed `Tree` +- BREAKING: `AppFocus` and `AppBlur` are now posted when the terminal window gains or loses focus, if the terminal supports this https://github.com/Textualize/textual/pull/4265 + - When the terminal window loses focus, the currently-focused widget will also lose focus. + - When the terminal window regains focus, the previously-focused widget will regain focus. ## [0.52.1] - 2024-02-20 From 4bb9b5947335ca526234557d9919264fda519e93 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 7 Mar 2024 15:31:05 +0000 Subject: [PATCH 11/12] Don't restore focus on AppFocus if something has focus While the application is in an AppBlur state, it's possible that some code could have been running that updated what's focused. It doesn't make sense to have Textual itself override the dev's choice to have focus be somewhere else (perhaps the result of some long-running background process, that they've tabbed away from, and when they tab back they expect to be in a specific control). So here I tweak the code that restores the focused widget so that it only restores if it's still the case that nothing has focus. --- src/textual/app.py | 4 +++- tests/test_app_focus_blur.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index c94bb5b802..d38e906874 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3205,11 +3205,13 @@ def _watch_app_focus(self, focus: bool) -> None: """Respond to changes in app focus.""" if focus: # If we've got a last-focused widget, if it still has a screen, - # and if the screen is still the current screen... + # and if the screen is still the current screen and if nothing + # is focused right now... try: if ( self._last_focused_on_app_blur is not None and self._last_focused_on_app_blur.screen is self.screen + and self.screen.focused is None ): # ...settle focus back on that widget. self.screen.set_focus(self._last_focused_on_app_blur) diff --git a/tests/test_app_focus_blur.py b/tests/test_app_focus_blur.py index 972cd69e1a..bbeea8ebce 100644 --- a/tests/test_app_focus_blur.py +++ b/tests/test_app_focus_blur.py @@ -62,3 +62,18 @@ async def test_app_focus_handles_missing_widget() -> None: pilot.app.post_message(AppFocus()) await pilot.pause() assert pilot.app.focused is None + + +async def test_app_focus_defers_to_new_focus() -> None: + """Test that AppFocus doesn't undo a fresh focus done while the app is in AppBlur state.""" + async with FocusBlurApp().run_test() as pilot: + assert pilot.app.focused is not None + assert pilot.app.focused.id == "input-4" + pilot.app.post_message(AppBlur()) + await pilot.pause() + assert pilot.app.focused is None + pilot.app.query_one("#input-1").focus() + await pilot.pause() + pilot.app.post_message(AppFocus()) + await pilot.pause() + assert pilot.app.focused.id == "input-1" From c768beb6880a1364f637f2e353e1121645397332 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 11 Mar 2024 10:17:37 +0000 Subject: [PATCH 12/12] Add a snapshot test for AppBlur --- .../__snapshots__/test_snapshots.ambr | 158 ++++++++++++++++++ .../snapshot_tests/snapshot_apps/app_blur.py | 32 ++++ tests/snapshot_tests/test_snapshots.py | 7 + 3 files changed, 197 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/app_blur.py diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 04502cde5c..d1a698dca2 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -501,6 +501,164 @@ ''' # --- +# name: test_app_blur + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AppBlurApp + + + + + + + + + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This should be the blur style + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This should also be the blur style + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + ''' +# --- # name: test_auto_fr ''' diff --git a/tests/snapshot_tests/snapshot_apps/app_blur.py b/tests/snapshot_tests/snapshot_apps/app_blur.py new file mode 100644 index 0000000000..37079f0cc5 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/app_blur.py @@ -0,0 +1,32 @@ +from textual.app import App, ComposeResult +from textual.events import AppBlur +from textual.widgets import Input + +class AppBlurApp(App[None]): + + CSS = """ + Screen { + align: center middle; + } + + Input { + width: 50%; + margin-bottom: 1; + + &:focus { + width: 75%; + border: thick green; + background: pink; + } + } + """ + + def compose(self) -> ComposeResult: + yield Input("This should be the blur style") + yield Input("This should also be the blur style") + + def on_mount(self) -> None: + self.post_message(AppBlur()) + +if __name__ == "__main__": + AppBlurApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 4bda4fc459..f5ba8f48f3 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1133,3 +1133,10 @@ def test_pretty_grid_gutter_interaction(snap_compare): def test_sort_children(snap_compare): """Test sort_children method.""" assert snap_compare(SNAPSHOT_APPS_DIR / "sort_children.py", terminal_size=(80, 25)) + + +def test_app_blur(snap_compare): + """Test Styling after receiving an AppBlur message.""" + async def run_before(pilot) -> None: + await pilot.pause() # Allow the AppBlur message to get processed. + assert snap_compare(SNAPSHOT_APPS_DIR / "app_blur.py", run_before=run_before)