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.
@@ -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()