textual-drivers 0.1.0__py3-none-any.whl
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.
- textual_drivers/__init__.py +29 -0
- textual_drivers/_dnd_app.py +293 -0
- textual_drivers/_mixin.py +207 -0
- textual_drivers/_utils.py +119 -0
- textual_drivers/demo/__main__.py +33 -0
- textual_drivers/demo/capability_check_app.py +231 -0
- textual_drivers/demo/drag_in.py +94 -0
- textual_drivers/demo/drag_out.py +91 -0
- textual_drivers/demo/test_app.py +184 -0
- textual_drivers/dnd.py +23 -0
- textual_drivers/headless_driver.py +22 -0
- textual_drivers/linux_driver.py +61 -0
- textual_drivers/linux_inline_driver.py +68 -0
- textual_drivers/windows_driver.py +152 -0
- textual_drivers-0.1.0.dist-info/METADATA +53 -0
- textual_drivers-0.1.0.dist-info/RECORD +18 -0
- textual_drivers-0.1.0.dist-info/WHEEL +4 -0
- textual_drivers-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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
|
+
"""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from inspect import isawaitable, iscoroutinefunction
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
from textual.dom import DOMNode
|
|
8
|
+
from textual.message import Message
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def throttle(delay: float) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
12
|
+
"""Leading-edge throttle with trailing-edge debounce for Textual message handlers.
|
|
13
|
+
|
|
14
|
+
The first event in a burst passes immediately. Events within `delay` seconds
|
|
15
|
+
are blocked. The last blocked event fires after `delay` seconds from the last allowed event.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
A decorator that wraps a Textual message handler (sync or async) with debounce logic.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
22
|
+
attr_last = f"_deb_last_{func.__name__}"
|
|
23
|
+
attr_timer = f"_deb_timer_{func.__name__}"
|
|
24
|
+
|
|
25
|
+
if iscoroutinefunction(func):
|
|
26
|
+
@wraps(func)
|
|
27
|
+
async def wrapper(self: DOMNode, event: Message, *args, **kwargs) -> None:
|
|
28
|
+
now = time.monotonic()
|
|
29
|
+
last_passed = getattr(self, attr_last, 0.0)
|
|
30
|
+
trailing_timer = getattr(self, attr_timer, None)
|
|
31
|
+
|
|
32
|
+
if now - last_passed >= delay:
|
|
33
|
+
# run func
|
|
34
|
+
if trailing_timer is not None:
|
|
35
|
+
trailing_timer.stop()
|
|
36
|
+
setattr(self, attr_timer, None)
|
|
37
|
+
setattr(self, attr_last, now)
|
|
38
|
+
# not sure how workers are handled
|
|
39
|
+
obj = func(self, event, *args, **kwargs)
|
|
40
|
+
if isawaitable(obj):
|
|
41
|
+
await obj
|
|
42
|
+
else:
|
|
43
|
+
if trailing_timer is not None:
|
|
44
|
+
trailing_timer.stop()
|
|
45
|
+
|
|
46
|
+
captured = event
|
|
47
|
+
|
|
48
|
+
async def trailing() -> None:
|
|
49
|
+
setattr(self, attr_timer, None)
|
|
50
|
+
setattr(self, attr_last, time.monotonic())
|
|
51
|
+
await func(self, captured, *args, **kwargs)
|
|
52
|
+
|
|
53
|
+
# Calculate remaining time until next allowed event
|
|
54
|
+
time_since_last = now - last_passed
|
|
55
|
+
remaining_delay = delay - time_since_last
|
|
56
|
+
setattr(self, attr_timer, self.set_timer(remaining_delay, trailing))
|
|
57
|
+
|
|
58
|
+
return wrapper
|
|
59
|
+
else:
|
|
60
|
+
@wraps(func)
|
|
61
|
+
def wrapper(self: DOMNode, event: Message, *args, **kwargs) -> None:
|
|
62
|
+
now = time.monotonic()
|
|
63
|
+
last_passed = getattr(self, attr_last, 0.0)
|
|
64
|
+
trailing_timer = getattr(self, attr_timer, None)
|
|
65
|
+
|
|
66
|
+
if now - last_passed >= delay:
|
|
67
|
+
# run func
|
|
68
|
+
if trailing_timer is not None:
|
|
69
|
+
trailing_timer.stop()
|
|
70
|
+
setattr(self, attr_timer, None)
|
|
71
|
+
setattr(self, attr_last, now)
|
|
72
|
+
func(self, event, *args, **kwargs)
|
|
73
|
+
else:
|
|
74
|
+
if trailing_timer is not None:
|
|
75
|
+
trailing_timer.stop()
|
|
76
|
+
|
|
77
|
+
captured = event
|
|
78
|
+
|
|
79
|
+
def trailing() -> None:
|
|
80
|
+
setattr(self, attr_timer, None)
|
|
81
|
+
setattr(self, attr_last, time.monotonic())
|
|
82
|
+
func(self, captured, *args, **kwargs)
|
|
83
|
+
|
|
84
|
+
# Calculate remaining time until next allowed event
|
|
85
|
+
time_since_last = now - last_passed
|
|
86
|
+
remaining_delay = delay - time_since_last
|
|
87
|
+
setattr(self, attr_timer, self.set_timer(remaining_delay, trailing))
|
|
88
|
+
|
|
89
|
+
return wrapper
|
|
90
|
+
|
|
91
|
+
return decorator
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class MessageLike(Protocol):
|
|
95
|
+
def __init__(self, data: str) -> None: ...
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def safe(cls: type[MessageLike]) -> Callable[[str], Message | None]:
|
|
99
|
+
def factory(data: str) -> Message | None:
|
|
100
|
+
try:
|
|
101
|
+
return cls(data)
|
|
102
|
+
except (ValueError, NotImplementedError):
|
|
103
|
+
return None
|
|
104
|
+
return factory
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def b64decode(data: str | bytes) -> str:
|
|
108
|
+
import base64
|
|
109
|
+
if isinstance(data, str):
|
|
110
|
+
data = data.encode()
|
|
111
|
+
data += b"=" * (-len(data) % 4)
|
|
112
|
+
return base64.b64decode(data).decode()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def b64encode(data: str | bytes) -> str:
|
|
116
|
+
import base64
|
|
117
|
+
if isinstance(data, str):
|
|
118
|
+
data = data.encode()
|
|
119
|
+
return base64.b64encode(data).decode()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def main() -> None:
|
|
5
|
+
parser = argparse.ArgumentParser(description="Textual Drivers Demo")
|
|
6
|
+
parser.add_argument(
|
|
7
|
+
"--demo",
|
|
8
|
+
choices=["check_image_support", "try_both", "kitty_drag_out", "kitty_drag_in"],
|
|
9
|
+
default="check_image_support",
|
|
10
|
+
help="Choose a demo to run",
|
|
11
|
+
)
|
|
12
|
+
args = parser.parse_args()
|
|
13
|
+
|
|
14
|
+
if args.demo == "check_image_support":
|
|
15
|
+
from .capability_check_app import CapabilityCheckApp
|
|
16
|
+
|
|
17
|
+
CapabilityCheckApp().run()
|
|
18
|
+
elif args.demo == "try_both":
|
|
19
|
+
from .test_app import DriverTestApp
|
|
20
|
+
|
|
21
|
+
DriverTestApp().run()
|
|
22
|
+
elif args.demo == "kitty_drag_out":
|
|
23
|
+
from .drag_out import DragOutApp
|
|
24
|
+
|
|
25
|
+
DragOutApp().run()
|
|
26
|
+
elif args.demo == "kitty_drag_in":
|
|
27
|
+
from .drag_in import DragInApp
|
|
28
|
+
|
|
29
|
+
DragInApp().run()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
main()
|