Skip to content

Commit e735762

Browse files
authored
Merge branch 'main' into themes
2 parents e3badea + 841c08b commit e735762

File tree

12 files changed

+206
-55
lines changed

12 files changed

+206
-55
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2727
- Added `App.search_themes` which allows bringing up a fuzzy search list of themes on-demand https://github.com/Textualize/textual/pull/5087
2828
- Added `textual.theme.ThemeProvider`, a command palette provider which returns all registered themes https://github.com/Textualize/textual/pull/5087
2929
- Added several new built-in CSS variables https://github.com/Textualize/textual/pull/5087
30+
- Added support for in-band terminal resize protocol https://github.com/Textualize/textual/pull/5217
31+
32+
### Changed
33+
34+
- `Driver.process_event` is now `Driver.process_message` https://github.com/Textualize/textual/pull/5217
35+
- `Driver.send_event` is now `Driver.send_message` https://github.com/Textualize/textual/pull/5217
36+
- Added `can_focus` and `can_focus_children` parameters to scrollable container types. https://github.com/Textualize/textual/pull/5226
3037
- Added `textual.lazy.Reveal` https://github.com/Textualize/textual/pull/5226
3138
- Added `Screen.action_blur` https://github.com/Textualize/textual/pull/5226
3239

src/textual/_xterm_parser.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import os
34
import re
45
from typing import Any, Generator, Iterable
56

@@ -15,7 +16,7 @@
1516
# When trying to determine whether the current sequence is a supported/valid
1617
# escape sequence, at which length should we give up and consider our search
1718
# to be unsuccessful?
18-
_MAX_SEQUENCE_SEARCH_THRESHOLD = 20
19+
_MAX_SEQUENCE_SEARCH_THRESHOLD = 32
1920

2021
_re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z")
2122
_re_terminal_mode_response = re.compile(
@@ -37,6 +38,12 @@
3738
"""Set of special sequences."""
3839

3940
_re_extended_key: Final = re.compile(r"\x1b\[(?:(\d+)(?:;(\d+))?)?([u~ABCDEFHPQRS])")
41+
_re_in_band_window_resize: Final = re.compile(
42+
r"\x1b\[48;(\d+(?:\:.*?)?);(\d+(?:\:.*?)?);(\d+(?:\:.*?)?);(\d+(?:\:.*?)?)t"
43+
)
44+
45+
46+
IS_ITERM = os.environ.get("TERM_PROGRAM", "") == "iTerm.app"
4047

4148

4249
class XTermParser(Parser[Message]):
@@ -212,6 +219,16 @@ def send_escape() -> None:
212219
elif sequence == BRACKETED_PASTE_END:
213220
bracketed_paste = False
214221
break
222+
if match := _re_in_band_window_resize.fullmatch(sequence):
223+
height, width, pixel_height, pixel_width = [
224+
group.partition(":")[0] for group in match.groups()
225+
]
226+
resize_event = events.Resize.from_dimensions(
227+
(int(width), int(height)),
228+
(int(pixel_width), int(pixel_height)),
229+
)
230+
on_token(resize_event)
231+
break
215232

216233
if not bracketed_paste:
217234
# Check cursor position report
@@ -246,9 +263,15 @@ def send_escape() -> None:
246263
mode_report_match = _re_terminal_mode_response.match(sequence)
247264
if mode_report_match is not None:
248265
mode_id = mode_report_match["mode_id"]
249-
setting_parameter = mode_report_match["setting_parameter"]
250-
if mode_id == "2026" and int(setting_parameter) > 0:
266+
setting_parameter = int(mode_report_match["setting_parameter"])
267+
if mode_id == "2026" and setting_parameter > 0:
251268
on_token(messages.TerminalSupportsSynchronizedOutput())
269+
elif mode_id == "2048" and not IS_ITERM:
270+
# TODO: remove "and not IS_ITERM" when https://gitlab.com/gnachman/iterm2/-/issues/11961 is fixed
271+
in_band_event = messages.TerminalSupportInBandWindowResize.from_setting_parameter(
272+
setting_parameter
273+
)
274+
on_token(in_band_event)
252275
break
253276

254277
if self._debug_log_file is not None:
@@ -265,7 +288,7 @@ def _sequence_to_key_events(self, sequence: str) -> Iterable[events.Key]:
265288
Keys
266289
"""
267290

268-
if (match := _re_extended_key.match(sequence)) is not None:
291+
if (match := _re_extended_key.fullmatch(sequence)) is not None:
269292
number, modifiers, end = match.groups()
270293
number = number or 1
271294
if not (key := FUNCTIONAL_KEYS.get(f"{number}{end}", "")):

src/textual/app.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,9 @@ def __init__(
761761

762762
self._hover_effects_timer: Timer | None = None
763763

764+
self._resize_event: events.Resize | None = None
765+
"""A pending resize event, sent on idle."""
766+
764767
self._css_update_count: int = 0
765768
"""Incremented when CSS is invalidated."""
766769

@@ -1765,7 +1768,7 @@ async def _press_keys(self, keys: Iterable[str]) -> None:
17651768
char = key if len(key) == 1 else None
17661769
key_event = events.Key(key, char)
17671770
key_event.set_sender(app)
1768-
driver.send_event(key_event)
1771+
driver.send_message(key_event)
17691772
await wait_for_idle(0)
17701773
await app._animator.wait_until_complete()
17711774
await wait_for_idle(0)
@@ -3921,9 +3924,7 @@ async def _on_key(self, event: events.Key) -> None:
39213924

39223925
async def _on_resize(self, event: events.Resize) -> None:
39233926
event.stop()
3924-
self.screen.post_message(event)
3925-
for screen in self._background_screens:
3926-
screen.post_message(event)
3927+
self._resize_event = event
39273928

39283929
async def _on_app_focus(self, event: events.AppFocus) -> None:
39293930
"""App has focus."""
@@ -4562,3 +4563,21 @@ def _on_delivery_failed(self, event: events.DeliveryComplete) -> None:
45624563
self.notify(
45634564
"Failed to save screenshot", title="Screenshot", severity="error"
45644565
)
4566+
4567+
@on(messages.TerminalSupportInBandWindowResize)
4568+
def _on_terminal_supports_in_band_window_resize(
4569+
self, message: messages.TerminalSupportInBandWindowResize
4570+
) -> None:
4571+
"""There isn't much we can do with this information currently, so
4572+
we will just log it.
4573+
"""
4574+
self.log.debug(message)
4575+
4576+
def _on_idle(self) -> None:
4577+
"""Send app resize events on idle, so we don't do more resizing that necessary."""
4578+
event = self._resize_event
4579+
if event is not None:
4580+
self._resize_event = None
4581+
self.screen.post_message(event)
4582+
for screen in self._background_screens:
4583+
screen.post_message(event)

src/textual/driver.py

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pathlib import Path
88
from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Literal, TextIO
99

10-
from textual import events, log
10+
from textual import events, log, messages
1111
from textual.events import MouseUp
1212

1313
if TYPE_CHECKING:
@@ -64,71 +64,71 @@ def can_suspend(self) -> bool:
6464
"""Can this driver be suspended?"""
6565
return False
6666

67-
def send_event(self, event: events.Event) -> None:
68-
"""Send an event to the target app.
67+
def send_message(self, message: messages.Message) -> None:
68+
"""Send a message to the target app.
6969
7070
Args:
71-
event: An event.
71+
message: A message.
7272
"""
7373
asyncio.run_coroutine_threadsafe(
74-
self._app._post_message(event), loop=self._loop
74+
self._app._post_message(message), loop=self._loop
7575
)
7676

77-
def process_event(self, event: events.Event) -> None:
78-
"""Perform additional processing on an event, prior to sending.
77+
def process_message(self, message: messages.Message) -> None:
78+
"""Perform additional processing on a message, prior to sending.
7979
8080
Args:
81-
event: An event to send.
81+
event: A message to process.
8282
"""
8383
# NOTE: This runs in a thread.
8484
# Avoid calling methods on the app.
85-
event.set_sender(self._app)
85+
message.set_sender(self._app)
8686
if self.cursor_origin is None:
8787
offset_x = 0
8888
offset_y = 0
8989
else:
9090
offset_x, offset_y = self.cursor_origin
91-
if isinstance(event, events.MouseEvent):
92-
event.x -= offset_x
93-
event.y -= offset_y
94-
event.screen_x -= offset_x
95-
event.screen_y -= offset_y
96-
97-
if isinstance(event, events.MouseDown):
98-
if event.button:
99-
self._down_buttons.append(event.button)
100-
elif isinstance(event, events.MouseUp):
101-
if event.button and event.button in self._down_buttons:
102-
self._down_buttons.remove(event.button)
103-
elif isinstance(event, events.MouseMove):
91+
if isinstance(message, events.MouseEvent):
92+
message.x -= offset_x
93+
message.y -= offset_y
94+
message.screen_x -= offset_x
95+
message.screen_y -= offset_y
96+
97+
if isinstance(message, events.MouseDown):
98+
if message.button:
99+
self._down_buttons.append(message.button)
100+
elif isinstance(message, events.MouseUp):
101+
if message.button and message.button in self._down_buttons:
102+
self._down_buttons.remove(message.button)
103+
elif isinstance(message, events.MouseMove):
104104
if (
105105
self._down_buttons
106-
and not event.button
106+
and not message.button
107107
and self._last_move_event is not None
108108
):
109109
# Deduplicate self._down_buttons while preserving order.
110110
buttons = list(dict.fromkeys(self._down_buttons).keys())
111111
self._down_buttons.clear()
112112
move_event = self._last_move_event
113113
for button in buttons:
114-
self.send_event(
114+
self.send_message(
115115
MouseUp(
116116
x=move_event.x,
117117
y=move_event.y,
118118
delta_x=0,
119119
delta_y=0,
120120
button=button,
121-
shift=event.shift,
122-
meta=event.meta,
123-
ctrl=event.ctrl,
121+
shift=message.shift,
122+
meta=message.meta,
123+
ctrl=message.ctrl,
124124
screen_x=move_event.screen_x,
125125
screen_y=move_event.screen_y,
126-
style=event.style,
126+
style=message.style,
127127
)
128128
)
129-
self._last_move_event = event
129+
self._last_move_event = message
130130

131-
self.send_event(event)
131+
self.send_message(message)
132132

133133
@abstractmethod
134134
def write(self, data: str) -> None:

src/textual/drivers/linux_driver.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from textual.driver import Driver
2121
from textual.drivers._writer_thread import WriterThread
2222
from textual.geometry import Size
23+
from textual.message import Message
24+
from textual.messages import TerminalSupportInBandWindowResize
2325

2426
if TYPE_CHECKING:
2527
from textual.app import App
@@ -59,6 +61,7 @@ def __init__(
5961
# need to know that we came in here via a SIGTSTP; this flag helps
6062
# keep track of this.
6163
self._must_signal_resume = False
64+
self._in_band_window_resize = False
6265

6366
# Put handlers for SIGTSTP and SIGCONT in place. These are necessary
6467
# to support the user pressing Ctrl+Z (or whatever the dev might
@@ -135,6 +138,22 @@ def _enable_bracketed_paste(self) -> None:
135138
"""Enable bracketed paste mode."""
136139
self.write("\x1b[?2004h")
137140

141+
def _query_in_band_window_resize(self) -> None:
142+
self.write("\x1b[?2048$p")
143+
144+
def _enable_in_band_window_resize(self) -> None:
145+
self.write("\x1b[?2048h")
146+
147+
def _enable_line_wrap(self) -> None:
148+
self.write("\x1b[?7h")
149+
150+
def _disable_line_wrap(self) -> None:
151+
self.write("\x1b[?7l")
152+
153+
def _disable_in_band_window_resize(self) -> None:
154+
if self._in_band_window_resize:
155+
self.write("\x1b[?2048l")
156+
138157
def _disable_bracketed_paste(self) -> None:
139158
"""Disable bracketed paste mode."""
140159
self.write("\x1b[?2004l")
@@ -197,6 +216,8 @@ def _stop_again(*_) -> None:
197216
loop = asyncio.get_running_loop()
198217

199218
def send_size_event() -> None:
219+
if self._in_band_window_resize:
220+
return
200221
terminal_size = self._get_terminal_size()
201222
width, height = terminal_size
202223
textual_size = Size(width, height)
@@ -253,7 +274,9 @@ def on_terminal_resize(signum, stack) -> None:
253274
send_size_event()
254275
self._key_thread.start()
255276
self._request_terminal_sync_mode_support()
277+
self._query_in_band_window_resize()
256278
self._enable_bracketed_paste()
279+
self._disable_line_wrap()
257280

258281
# Appears to fix an issue enabling mouse support in iTerm 3.5.0
259282
self._enable_mouse_support()
@@ -330,6 +353,8 @@ def disable_input(self) -> None:
330353
def stop_application_mode(self) -> None:
331354
"""Stop application mode, restore state."""
332355
self._disable_bracketed_paste()
356+
self._enable_line_wrap()
357+
self._disable_in_band_window_resize()
333358
self.disable_input()
334359

335360
if self.attrs_before is not None:
@@ -401,9 +426,9 @@ def process_selector_events(
401426
# This can occur if the stdin is piped
402427
break
403428
for event in feed(unicode_data):
404-
self.process_event(event)
429+
self.process_message(event)
405430
for event in tick():
406-
self.process_event(event)
431+
self.process_message(event)
407432

408433
try:
409434
while not self.exit_event.is_set():
@@ -418,3 +443,22 @@ def process_selector_events(
418443
pass
419444
except ParseError:
420445
pass
446+
447+
def process_message(self, message: Message) -> None:
448+
# intercept in-band window resize
449+
if isinstance(message, TerminalSupportInBandWindowResize):
450+
# If it is supported, enabled it
451+
if message.supported and not message.enabled:
452+
self._enable_in_band_window_resize()
453+
self._in_band_window_resize = message.supported
454+
elif message.enabled:
455+
self._in_band_window_resize = message.supported
456+
# Send up-to-date message
457+
super().process_message(
458+
TerminalSupportInBandWindowResize(
459+
message.supported, self._in_band_window_resize
460+
)
461+
)
462+
return
463+
464+
super().process_message(message)

src/textual/drivers/linux_inline_driver.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,12 @@ def process_selector_events(
155155
if isinstance(event, events.CursorPosition):
156156
self.cursor_origin = (event.x, event.y)
157157
else:
158-
self.process_event(event)
158+
self.process_message(event)
159159
for event in tick():
160160
if isinstance(event, events.CursorPosition):
161161
self.cursor_origin = (event.x, event.y)
162162
else:
163-
self.process_event(event)
163+
self.process_message(event)
164164

165165
try:
166166
while not self.exit_event.is_set():

src/textual/drivers/web_driver.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,12 +195,12 @@ def run_input_thread(self) -> None:
195195
if packet_type == "D":
196196
# Treat as stdin
197197
for event in parser.feed(decode(payload)):
198-
self.process_event(event)
198+
self.process_message(event)
199199
else:
200200
# Process meta information separately
201201
self._on_meta(packet_type, payload)
202202
for event in parser.tick():
203-
self.process_event(event)
203+
self.process_message(event)
204204
except _ExitInput:
205205
pass
206206
except Exception:

src/textual/drivers/windows_driver.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def start_application_mode(self) -> None:
101101
self._enable_bracketed_paste()
102102

103103
self._event_thread = win32.EventMonitor(
104-
loop, self._app, self.exit_event, self.process_event
104+
loop, self._app, self.exit_event, self.process_message
105105
)
106106
self._event_thread.start()
107107

0 commit comments

Comments
 (0)