textual-drivers 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.3
2
+ Name: textual-drivers
3
+ Version: 0.1.0
4
+ Summary: Drop-in Textual drivers with lock_stdin, register_event_handler, and kitty DnD support
5
+ Author: NSPC911
6
+ Author-email: NSPC911 <87571998+NSPC911@users.noreply.github.com>
7
+ Requires-Dist: textual>=8.2.7
8
+ Requires-Dist: wrapt>=2.2.1
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+
12
+ # textual-drivers
13
+
14
+ Drop-in subclasses of Textual's built-in terminal drivers with two extra capabilities:
15
+
16
+ - **`lock_stdin`** — pause the driver's stdin thread and silence terminal events so you can run terminal queries or subprocesses without interference
17
+ - **`register_event_handler`** — bind a pattern against raw stdin; when it matches, a `Message` is posted into Textual's event system
18
+
19
+ A higher-level **`DNDApp`** base class builds on these to implement the full kitty drag-and-drop protocol (drag-in and drag-out).
20
+
21
+ ## Installation
22
+
23
+ ```
24
+ uv add textual-drivers
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```python
30
+ from textual_drivers import DrivenApp
31
+
32
+ class MyApp(DrivenApp):
33
+ ...
34
+
35
+ MyApp().run()
36
+ ```
37
+
38
+ `DrivenApp` picks the right platform driver automatically.
39
+
40
+ ## Documentation
41
+
42
+ Full docs are on the [wiki](../../wiki):
43
+
44
+ - [Drivers](../../wiki/drivers) — driver classes, `DrivenApp`, and mixin usage
45
+ - [lock_stdin](../../wiki/lock-stdin) — exclusive stdin ownership for terminal queries and subprocesses
46
+ - [register_event_handler](../../wiki/register-event-handler) — pattern-based raw stdin → Textual message routing
47
+ - [DnD](../../wiki/dnd) — kitty drag-and-drop protocol via `DNDApp`
48
+
49
+ The `docs/` folder in this repo mirrors the wiki and can be pushed to it with:
50
+
51
+ ```
52
+ ./sync-wiki.sh
53
+ ```
@@ -0,0 +1,42 @@
1
+ # textual-drivers
2
+
3
+ Drop-in subclasses of Textual's built-in terminal drivers with two extra capabilities:
4
+
5
+ - **`lock_stdin`** — pause the driver's stdin thread and silence terminal events so you can run terminal queries or subprocesses without interference
6
+ - **`register_event_handler`** — bind a pattern against raw stdin; when it matches, a `Message` is posted into Textual's event system
7
+
8
+ A higher-level **`DNDApp`** base class builds on these to implement the full kitty drag-and-drop protocol (drag-in and drag-out).
9
+
10
+ ## Installation
11
+
12
+ ```
13
+ uv add textual-drivers
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```python
19
+ from textual_drivers import DrivenApp
20
+
21
+ class MyApp(DrivenApp):
22
+ ...
23
+
24
+ MyApp().run()
25
+ ```
26
+
27
+ `DrivenApp` picks the right platform driver automatically.
28
+
29
+ ## Documentation
30
+
31
+ Full docs are on the [wiki](../../wiki):
32
+
33
+ - [Drivers](../../wiki/drivers) — driver classes, `DrivenApp`, and mixin usage
34
+ - [lock_stdin](../../wiki/lock-stdin) — exclusive stdin ownership for terminal queries and subprocesses
35
+ - [register_event_handler](../../wiki/register-event-handler) — pattern-based raw stdin → Textual message routing
36
+ - [DnD](../../wiki/dnd) — kitty drag-and-drop protocol via `DNDApp`
37
+
38
+ The `docs/` folder in this repo mirrors the wiki and can be pushed to it with:
39
+
40
+ ```
41
+ ./sync-wiki.sh
42
+ ```
@@ -0,0 +1,69 @@
1
+ [project]
2
+ name = "textual-drivers"
3
+ version = "0.1.0"
4
+ description = "Drop-in Textual drivers with lock_stdin, register_event_handler, and kitty DnD support"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "NSPC911", email = "87571998+NSPC911@users.noreply.github.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "textual>=8.2.7",
12
+ "wrapt>=2.2.1",
13
+ ]
14
+
15
+ [project.scripts]
16
+ textual-drivers = "textual_drivers:main"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.11.16,<0.12.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "ruff>=0.15.15",
25
+ "textual-dev>=1.8.0",
26
+ "ty==0.0.32",
27
+ ]
28
+
29
+ [tool.ruff]
30
+ force-exclude = true
31
+ exclude = []
32
+
33
+ [tool.ruff.lint]
34
+ select = [
35
+ "ASYNC220",
36
+ "ASYNC221",
37
+ "ASYNC251",
38
+ "ANN",
39
+ "COM819",
40
+ "C400",
41
+ "DOC",
42
+ "D404",
43
+ "E",
44
+ "F",
45
+ "I",
46
+ "N801", "N802", "N805",
47
+ "PLE1142",
48
+ "Q",
49
+ "SIM",
50
+ "TD",
51
+ "W",
52
+ ]
53
+ ignore = ["W505", "E501", "ANN002", "ANN003", "TD002", "TD003", "ANN401"]
54
+ preview = true
55
+
56
+ [tool.ruff.format]
57
+ quote-style = "double"
58
+ indent-style = "space"
59
+ line-ending = "auto"
60
+ preview = true
61
+
62
+ [tool.ty.rules]
63
+ # possibly-missing-import = "ignore"
64
+ unresolved-reference = "ignore" # handled by ruff
65
+ unresolved-attribute = "ignore"
66
+ no-matching-overload = "ignore" # not that accurate
67
+ possibly-missing-attribute = "ignore" # no it is not missing
68
+ invalid-assignment = "warn" # probably already but warn for future
69
+ unused-ignore-comment = "ignore"
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.app import App
4
+ from textual.types import CSSPathType
5
+
6
+ from textual_drivers._mixin import BoundedPattern, CustomDriverMixin, EventHandlerMixin, LockStdinMixin, Pattern
7
+ from textual_drivers.headless_driver import CustomHeadlessDriver
8
+
9
+
10
+ class DrivenApp(App):
11
+ def __init__(self, css_path: CSSPathType | None = None, watch_css: bool = False, ansi_color: bool | None = None) -> None:
12
+ import sys
13
+ if sys.platform == "win32":
14
+ from textual_drivers.windows_driver import CustomWindowsDriver as _Driver
15
+ else:
16
+ from textual_drivers.linux_driver import CustomLinuxDriver as _Driver
17
+ super().__init__(driver_class=_Driver, css_path=css_path, watch_css=watch_css, ansi_color=ansi_color)
18
+ self._driver: _Driver
19
+
20
+
21
+ __all__ = [
22
+ "DrivenApp",
23
+ "BoundedPattern",
24
+ "Pattern",
25
+ "CustomDriverMixin",
26
+ "EventHandlerMixin",
27
+ "LockStdinMixin",
28
+ "CustomHeadlessDriver",
29
+ ]
@@ -0,0 +1,293 @@
1
+ """Base app with kitty drag-in and drag-out protocol support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import re
7
+ from typing import Literal, NamedTuple
8
+
9
+ from textual.message import Message
10
+
11
+ from textual_drivers import BoundedPattern, DrivenApp
12
+ from textual_drivers._utils import b64encode, safe
13
+
14
+ _OSC = "\x1b]"
15
+ _ST = "\x1b\\"
16
+
17
+
18
+ def _osc72(meta: str, payload: str = "") -> str:
19
+ if payload:
20
+ return f"{_OSC}72;{meta};{payload}{_ST}"
21
+ return f"{_OSC}72;{meta}{_ST}"
22
+
23
+
24
+ # -- Internal messages ---------------------------------------------------------
25
+
26
+
27
+ class DNDDragIn(Message):
28
+ """Kitty reports a drag is hovering over the app.
29
+
30
+ Handler: on_dnddrag_in (DNDApp internal — calls dnd_drag_in_operation).
31
+ pos is (-1, -1) when the drag leaves the window.
32
+ """
33
+
34
+ def __init__(self, data: str) -> None:
35
+ super().__init__()
36
+ m = re.search(
37
+ r"t=m:x=(?P<x>-?\d+):y=(?P<y>-?\d+)"
38
+ r"(?::X=(?P<X>-?\d+):Y=(?P<Y>-?\d+):o=(?P<o>\d+)[^;]*;(?P<mimes>[^\x1b]*))?",
39
+ data,
40
+ )
41
+ if not m:
42
+ raise ValueError(f"Invalid t=m: {data!r}")
43
+ self.pos: tuple[int, int] = (int(m.group("x")), int(m.group("y")))
44
+ o = int(m.group("o")) if m.group("o") else 0
45
+ self.op: Literal["copy", "move", "either"] = (
46
+ "copy" if o == 1 else "move" if o == 2 else "either"
47
+ )
48
+ self.mimes: list[str] = m.group("mimes").split() if m.group("mimes") else []
49
+
50
+
51
+ class DragOut(Message):
52
+ """Kitty reports the user started a drag-out gesture.
53
+
54
+ Handler: on_drag_out (DNDApp internal — calls dnd_drag_out_operation).
55
+ """
56
+
57
+ def __init__(self, data: str) -> None:
58
+ super().__init__()
59
+ m = re.search(r"t=o:x=(?P<x>-?\d+):y=(?P<y>-?\d+)", data)
60
+ if not m:
61
+ raise ValueError(f"Invalid t=o gesture: {data!r}")
62
+ self.pos: tuple[int, int] = (int(m.group("x")), int(m.group("y")))
63
+
64
+
65
+ class DNDDropData(Message):
66
+ """One t=r data chunk from kitty. Internal — accumulated by on_dnddrop_data."""
67
+
68
+ def __init__(self, data: str) -> None:
69
+ super().__init__()
70
+ m = re.search(
71
+ r"t=r:x=(?P<idx>\d+):m=(?P<more>[01]);(?P<b64>[^\x1b]*)",
72
+ data,
73
+ )
74
+ if not m:
75
+ raise ValueError(f"Invalid t=r chunk: {data!r}")
76
+ self.idx: int = int(m.group("idx"))
77
+ self.more: bool = m.group("more") == "1"
78
+ b64 = m.group("b64")
79
+ b64 += "=" * (-len(b64) % 4)
80
+ self.chunk: bytes = base64.b64decode(b64.encode())
81
+
82
+
83
+ # -- User-facing messages ------------------------------------------------------
84
+
85
+
86
+ class Drop(Message):
87
+ """Posted when the user drops content onto the terminal window.
88
+
89
+ Call request_data(event, index) from on_drop to fetch the actual content.
90
+ index is 0-based into event.mimes.
91
+ """
92
+
93
+ def __init__(self, data: str) -> None:
94
+ super().__init__()
95
+ m = re.search(
96
+ r"t=M:x=(?P<x>\d+):y=(?P<y>\d+):X=(?P<X>\d+):Y=(?P<Y>\d+)"
97
+ r":o=(?P<o>\d+)[^;]*;(?P<mimes>[^\x1b]*)",
98
+ data,
99
+ )
100
+ if not m:
101
+ raise ValueError(f"Invalid t=M: {data!r}")
102
+ self.pos: tuple[int, int] = (int(m.group("x")), int(m.group("y")))
103
+ o = int(m.group("o"))
104
+ self.op: Literal["copy", "move"] = "copy" if o == 1 else "move"
105
+ self.mimes: list[str] = m.group("mimes").split() if m.group("mimes") else []
106
+
107
+
108
+ class DropData(Message):
109
+ """Posted once all requested MIME data has been received and assembled.
110
+
111
+ data is list[str] (URI entries) when the requested MIME is text/uri-list,
112
+ bytes for everything else.
113
+ """
114
+
115
+ def __init__(self, drop_event: Drop, data: list[str] | bytes) -> None:
116
+ super().__init__()
117
+ self.drop_event = drop_event
118
+ self.data = data
119
+
120
+
121
+ class DragOutFinished(Message):
122
+ """Posted when a drag-out operation fully completes or is cancelled."""
123
+
124
+ def __init__(self, cancelled: bool) -> None:
125
+ super().__init__()
126
+ self.cancelled = cancelled
127
+
128
+
129
+ # -- Return Types --------------------------------------------------------------
130
+
131
+
132
+ class DragOutOperation(NamedTuple):
133
+ uris: list[str]
134
+ """URIs to offer for dragging out. Must be file://"""
135
+ op: Literal["copy", "move"]
136
+ popup_text: str
137
+ """Text to show in the drag icon popup. Should be short and descriptive."""
138
+ popup_size: int = 3
139
+ """Size of the popup text. The popup text's size is inversely proportional to this value."""
140
+
141
+
142
+ # -- App -----------------------------------------------------------------------
143
+
144
+
145
+ class DNDApp(DrivenApp):
146
+ """DrivenApp subclass with kitty drag-in and drag-out support.
147
+
148
+ Override dnd_drag_out_operation and dnd_drag_in_operation to customise
149
+ behaviour. Handle Drop, DropData, and DragOutFinished messages for events.
150
+ """
151
+
152
+ def on_mount(self) -> None:
153
+ driver = self._driver
154
+ driver.register_event_handler(
155
+ BoundedPattern(start="\x1b]72;t=m:", end=_ST), safe(DNDDragIn)
156
+ )
157
+ driver.register_event_handler(
158
+ BoundedPattern(start="\x1b]72;t=o:", end=_ST), safe(DragOut)
159
+ )
160
+ driver.register_event_handler(
161
+ BoundedPattern(start="\x1b]72;t=M:", end=_ST), safe(Drop)
162
+ )
163
+ driver.register_event_handler(
164
+ BoundedPattern(start="\x1b]72;t=r:", end=_ST), safe(DNDDropData)
165
+ )
166
+ driver.register_event_handler(
167
+ BoundedPattern(start="\x1b]72;t=e:", end=_ST), self._handle_drag_progress
168
+ )
169
+ self._drag_active: bool = False
170
+ self._drag_uris: list[str] = []
171
+ self._drag_op: Literal["copy", "move"] = "copy"
172
+ self._current_drop: Drop | None = None
173
+ self._data_buf: bytes = b""
174
+ self._data_mime_idx: int = 0
175
+ self._write(_osc72("t=o:x=1"))
176
+ self._write(_osc72("t=a", "*/*"))
177
+
178
+ # -- Internal handlers -----------------------------------------------------
179
+
180
+ def on_dnddrag_in(self, event: DNDDragIn) -> None:
181
+ x, y = event.pos
182
+ if x == -1 and y == -1:
183
+ self._write(_osc72("t=m:o=0"))
184
+ return
185
+ if not self.dnd_drag_in_operation(event):
186
+ self._write(_osc72("t=m:o=0"))
187
+ return
188
+ op_int = 1 if event.op in ("copy", "either") else 2
189
+ self._write(_osc72(f"t=m:o={op_int}", " ".join(event.mimes)))
190
+
191
+ def on_drag_out(self, event: DragOut) -> None:
192
+ result = self.dnd_drag_out_operation(event.pos)
193
+ if result is None:
194
+ self._write(_osc72("t=E:y=-1"))
195
+ return
196
+ self._drag_uris = result.uris
197
+ self._drag_op = result.op
198
+ self._drag_active = True
199
+ op_int = 1 if result.op == "copy" else 2
200
+ self._write(_osc72(f"t=o:o={op_int}", "text/uri-list text/plain"))
201
+ uri_list = "\r\n".join(result.uris) + "\r\n"
202
+ self._write(_osc72("t=p:x=0", b64encode(uri_list)))
203
+ plain = "\n".join(u.removeprefix("file://") for u in result.uris) + "\n"
204
+ self._write(_osc72("t=p:x=1", b64encode(plain)))
205
+ self._write(
206
+ _osc72(
207
+ f"t=p:x=-1:y=0:X={len(result.popup_text)}:Y={result.popup_size}:o=0",
208
+ b64encode(result.popup_text),
209
+ )
210
+ )
211
+ self._write(_osc72("t=P:x=-1"))
212
+
213
+ def on_dnddrop_data(self, event: DNDDropData) -> None:
214
+ if event.idx != self._data_mime_idx + 1: # ignore unrequested MIMEs
215
+ return
216
+ self._data_buf += event.chunk
217
+ if event.more:
218
+ return
219
+ if self._current_drop is None:
220
+ self._data_buf = b""
221
+ return
222
+ mime = self._current_drop.mimes[self._data_mime_idx]
223
+ assembled: list[str] | bytes
224
+ if mime == "text/uri-list":
225
+ assembled = [
226
+ line
227
+ for line in self._data_buf.decode().splitlines()
228
+ if line and not line.startswith("#")
229
+ ]
230
+ else:
231
+ assembled = self._data_buf
232
+ self.post_message(DropData(self._current_drop, assembled))
233
+ self._data_buf = b""
234
+ self._write(_osc72("t=r:o=1"))
235
+
236
+ def _handle_drag_progress(self, data: str) -> None:
237
+ m = re.search(r"t=e:x=(?P<code>\d+)(?::y=(?P<y>-?\d+))?", data)
238
+ if not m:
239
+ return
240
+ code = int(m.group("code"))
241
+ if code == 4:
242
+ self._drag_active = False
243
+ self._drag_uris = []
244
+ self.post_message(DragOutFinished(cancelled=m.group("y") == "1"))
245
+ elif code == 5:
246
+ y = m.group("y")
247
+ if y is not None:
248
+ self._send_drag_data(int(y))
249
+
250
+ def _send_drag_data(self, idx: int) -> None:
251
+ if idx == 0:
252
+ self._write(
253
+ _osc72("t=e:y=0:m=0", b64encode("\r\n".join(self._drag_uris) + "\r\n"))
254
+ )
255
+ elif idx == 1:
256
+ plain = "\n".join(u.removeprefix("file://") for u in self._drag_uris) + "\n"
257
+ self._write(_osc72("t=e:y=1:m=0", b64encode(plain)))
258
+
259
+ # -- User-facing stubs -----------------------------------------------------
260
+
261
+ def on_drop(self, event: Drop) -> None: ...
262
+
263
+ def on_drag_out_finished(self, event: DragOutFinished) -> None: ...
264
+
265
+ # -- User override methods -------------------------------------------------
266
+
267
+ def dnd_drag_out_operation(self, pos: tuple[int, int]) -> DragOutOperation | None:
268
+ """Return DragOutOperation to start a drag-out, or None to cancel."""
269
+ return None
270
+
271
+ def dnd_drag_in_operation(self, event: DNDDragIn) -> bool:
272
+ """Return True to accept the incoming drag, False to reject."""
273
+ return True
274
+
275
+ def request_data(self, event: Drop, index: int) -> None:
276
+ """Request MIME data for a drop. index is 0-based into event.mimes."""
277
+ self._current_drop = event
278
+ self._data_mime_idx = index
279
+ self._data_buf = b""
280
+ self._write(_osc72(f"t=r:x={index + 1}"))
281
+
282
+ async def action_quit(self) -> None:
283
+ if self._drag_active:
284
+ self._write(_osc72("t=E:y=-1"))
285
+ self._write(_osc72("t=o:x=2"))
286
+ self._write(_osc72("t=a"))
287
+ await super().action_quit()
288
+
289
+ # -- Helpers ---------------------------------------------------------------
290
+
291
+ def _write(self, seq: str) -> None:
292
+ self._driver.write(seq)
293
+ self._driver.flush()
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import re
5
+ import threading
6
+ import time
7
+ from contextlib import contextmanager
8
+ from typing import Any, Callable, Generator, NamedTuple, TypeAlias
9
+
10
+ from textual.message import Message
11
+
12
+ # Every terminal event mode Textual enables on start-up that can be toggled:
13
+ # mouse (1000/1002/1003/1006), focus tracking (1004),
14
+ # kitty key protocol (>1u), bracketed paste (2004).
15
+ # Plain key events have no toggle - see LockStdinMixin.lock_stdin docstring.
16
+ _EVENTS_DISABLE = (
17
+ "\x1b[?1003l" # mouse: all-motion off
18
+ "\x1b[?1002l" # mouse: drag off
19
+ "\x1b[?1000l" # mouse: button off
20
+ "\x1b[?1006l" # mouse: SGR extension off
21
+ "\x1b[?1004l" # focus tracking off
22
+ "\x1b[>0u" # kitty key protocol: reset to legacy encoding
23
+ "\x1b[?2004l" # bracketed paste off
24
+ )
25
+ _EVENTS_ENABLE = (
26
+ "\x1b[?1000h"
27
+ "\x1b[?1002h"
28
+ "\x1b[?1003h"
29
+ "\x1b[?1006h"
30
+ "\x1b[?1004h"
31
+ "\x1b[>1u"
32
+ "\x1b[?2004h"
33
+ )
34
+
35
+
36
+ class LockStdinMixin:
37
+ """Mixin that adds lock_stdin to Textual drivers.
38
+
39
+ Provides cooperative stdin thread pausing and automatic terminal event
40
+ management. Mix in before the driver class in the MRO and call
41
+ self._stdin_pause_point() at the top of the input-thread loop.
42
+ """
43
+
44
+ def __init__(self, *args, **kwargs) -> None:
45
+ super().__init__(*args, **kwargs)
46
+ self._pause_cond: threading.Condition = threading.Condition()
47
+ self._pause_lock_count: int = 0
48
+ self._stdin_is_paused: bool = False
49
+
50
+ @contextmanager
51
+ def lock_stdin(self) -> Generator[None, None, None]:
52
+ """Pause the stdin input thread and disable terminal event reporting.
53
+
54
+ The input thread voluntarily stops at its next pause point (at most one
55
+ read cycle, ≤ ~100 ms) and confirms via _stdin_is_paused before this
56
+ yields. All terminal event modes Textual enables (mouse, focus tracking,
57
+ kitty key protocol, bracketed paste) are disabled for the duration so
58
+ that no unsolicited escape sequences can arrive in stdin. A 50 ms settle
59
+ delay after disabling gives any already-in-transit events time to arrive
60
+ before the caller drains the buffer.
61
+
62
+ Plain key events cannot be disabled via escape sequences; drain stdin
63
+ after entering the context to discard any buffered keypresses.
64
+
65
+ Nesting multiple concurrent lock_stdin() calls is supported; event
66
+ reporting is disabled once on the outermost entry and re-enabled once on
67
+ the outermost exit.
68
+ """
69
+ with self._pause_cond:
70
+ is_outermost = self._pause_lock_count == 0
71
+ self._pause_lock_count += 1
72
+ # Wait for the input thread to reach the pause point and acknowledge.
73
+ # Timeout guards against callers that run before the thread starts.
74
+ self._pause_cond.wait_for(lambda: self._stdin_is_paused, timeout=0.5)
75
+
76
+ if is_outermost:
77
+ self.write(_EVENTS_DISABLE) # type: ignore[attr-defined]
78
+ self.flush() # type: ignore[attr-defined]
79
+ # Give any in-transit events time to arrive so the caller can drain them.
80
+ time.sleep(0.05)
81
+
82
+ try:
83
+ yield
84
+ finally:
85
+ with self._pause_cond:
86
+ self._pause_lock_count -= 1
87
+ outermost_releasing = self._pause_lock_count == 0
88
+ if outermost_releasing:
89
+ self._pause_cond.notify_all()
90
+
91
+ if outermost_releasing:
92
+ self.write(_EVENTS_ENABLE) # type: ignore[attr-defined]
93
+ self.flush() # type: ignore[attr-defined]
94
+
95
+ def _stdin_pause_point(self) -> None:
96
+ """Call at the start of each input-thread loop iteration.
97
+
98
+ Blocks the input thread while any lock_stdin() context is active, then
99
+ resumes when all callers have exited.
100
+ """
101
+ with self._pause_cond:
102
+ if self._pause_lock_count > 0:
103
+ self._stdin_is_paused = True
104
+ self._pause_cond.notify_all()
105
+ while self._pause_lock_count > 0:
106
+ self._pause_cond.wait(timeout=0.05)
107
+ exit_event: threading.Event | None = getattr(
108
+ self, "exit_event", None
109
+ )
110
+ if exit_event is not None and exit_event.is_set():
111
+ break
112
+ self._stdin_is_paused = False
113
+
114
+
115
+ class BoundedPattern(NamedTuple):
116
+ """Match raw stdin data that contains a substring starting with *start* and ending with *end*.
117
+
118
+ Useful for terminal sequences with known delimiters, e.g.
119
+ ``BoundedPattern(start="\\x1b]72;t=o:", end="\\x1b\\\\")``.
120
+ All non-overlapping matches within the incoming data chunk are dispatched.
121
+ """
122
+
123
+ start: str
124
+ end: str
125
+
126
+
127
+ # Pattern accepted by register_event_handler:
128
+ # str – glob matched against tokenised stdin chunks (fnmatch)
129
+ # BoundedPattern – greedy scan for start/end-delimited substrings
130
+ # re.Pattern – finditer over the raw data string
131
+ Pattern: TypeAlias = str | BoundedPattern | re.Pattern[str]
132
+
133
+
134
+ def _find_bounded(data: str, start: str, end: str) -> list[str]:
135
+ """Return all non-overlapping substrings of *data* delimited by *start*…*end*."""
136
+ results: list[str] = []
137
+ pos = 0
138
+ while True:
139
+ s = data.find(start, pos)
140
+ if s == -1:
141
+ break
142
+ e = data.find(end, s + len(start))
143
+ if e == -1:
144
+ break
145
+ results.append(data[s : e + len(end)])
146
+ pos = e + len(end)
147
+ return results
148
+
149
+
150
+ class EventHandlerMixin:
151
+ """Mixin that adds register_event_handler to Textual drivers.
152
+
153
+ Provides glob-pattern matching against raw stdin chunks and posting of
154
+ matched events into Textual's event system. Mix in before the driver
155
+ class in the MRO and call self._dispatch_custom_handlers(data) for each
156
+ decoded stdin chunk in the input-thread loop.
157
+ """
158
+
159
+ def __init__(self, *args, **kwargs) -> None:
160
+ super().__init__(*args, **kwargs)
161
+ self._event_handlers: list[tuple[Pattern, Callable[[str], object]]] = []
162
+
163
+ def register_event_handler(
164
+ self, pattern: Pattern, event_constructor: Callable[[str], Message | Any]
165
+ ) -> None:
166
+ """Register a handler fired when raw stdin input matches *pattern*.
167
+
168
+ Args:
169
+ pattern: One of three forms —
170
+ ``str``: glob matched against tokenised stdin chunks (fnmatch);
171
+ ``BoundedPattern(start, end)``: fires for every non-overlapping
172
+ substring in the chunk that begins with *start* and ends with *end*;
173
+ ``re.Pattern``: fires for every match of ``pattern.finditer(data)``.
174
+ event_constructor: Called with the matched data string; if the
175
+ result is a Message instance it is posted to the app.
176
+ """
177
+ self._event_handlers.append((pattern, event_constructor))
178
+
179
+ def _dispatch_custom_handlers(self, data: str) -> None:
180
+ for pattern, constructor in self._event_handlers:
181
+ if isinstance(pattern, BoundedPattern):
182
+ chunks = _find_bounded(data, pattern.start, pattern.end)
183
+ elif isinstance(pattern, re.Pattern):
184
+ chunks = [m.group() for m in pattern.finditer(data)]
185
+ else:
186
+ # str glob: split on ESC so each escape sequence is checked individually
187
+ chunks = []
188
+ for part in data.split("\x1b"):
189
+ if not part:
190
+ continue
191
+ chunks.append("\x1b" + part)
192
+
193
+ for chunk in chunks:
194
+ if isinstance(pattern, str) and not fnmatch.fnmatch(chunk, pattern):
195
+ continue
196
+ event = constructor(chunk)
197
+ if isinstance(event, Message):
198
+ event.set_sender(self._app) # type: ignore[attr-defined]
199
+ self.send_message(event) # type: ignore[attr-defined]
200
+
201
+
202
+ class CustomDriverMixin(LockStdinMixin, EventHandlerMixin):
203
+ """Convenience mixin combining LockStdinMixin and EventHandlerMixin.
204
+
205
+ Equivalent to subclassing both individually. Use the individual mixins
206
+ if you only need one of the two features.
207
+ """