Skip to content

Commit

Permalink
Merge pull request #4265 from davep/more-app-focus-blur
Browse files Browse the repository at this point in the history
Enable `AppFocus` and `AppBlur` in terminal emulators
  • Loading branch information
willmcgugan authored Mar 11, 2024
2 parents a5a008b + c768beb commit a5bcbc6
Show file tree
Hide file tree
Showing 10 changed files with 336 additions and 14 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 21 additions & 8 deletions src/textual/_xterm_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,8 +20,15 @@
_re_terminal_mode_response = re.compile(
"^" + re.escape("\x1b[") + r"\?(?P<mode_id>\d+);(?P<setting_parameter>\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"
"""Sequence received when focus is lost from the terminal."""


class XTermParser(Parser[events.Event]):
Expand Down Expand Up @@ -202,15 +211,19 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None:

self.debug_log(f"sequence={sequence!r}")

bracketed_paste_start_match = _re_bracketed_paste_start.match(
sequence
)
if bracketed_paste_start_match is not None:
if sequence == FOCUSIN:
on_token(events.AppFocus())
break

if sequence == FOCUSOUT:
on_token(events.AppBlur())
break

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

Expand Down
32 changes: 28 additions & 4 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -3197,10 +3204,27 @@ 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)
# 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 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)
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
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:
Expand Down
2 changes: 2 additions & 0 deletions src/textual/drivers/linux_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -316,6 +317,7 @@ def stop_application_mode(self) -> None:

# Alt screen false, show cursor
self.write("\x1b[?1049l" + "\x1b[?25h")
self.write("\033[?1004l\n") # Disable FocusIn/FocusOut.
self.flush()

def close(self) -> None:
Expand Down
2 changes: 2 additions & 0 deletions src/textual/drivers/windows_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions src/textual/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading

0 comments on commit a5bcbc6

Please sign in to comment.