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.
- textual_drivers-0.1.0/PKG-INFO +53 -0
- textual_drivers-0.1.0/README.md +42 -0
- textual_drivers-0.1.0/pyproject.toml +69 -0
- textual_drivers-0.1.0/src/textual_drivers/__init__.py +29 -0
- textual_drivers-0.1.0/src/textual_drivers/_dnd_app.py +293 -0
- textual_drivers-0.1.0/src/textual_drivers/_mixin.py +207 -0
- textual_drivers-0.1.0/src/textual_drivers/_utils.py +119 -0
- textual_drivers-0.1.0/src/textual_drivers/demo/__main__.py +33 -0
- textual_drivers-0.1.0/src/textual_drivers/demo/capability_check_app.py +231 -0
- textual_drivers-0.1.0/src/textual_drivers/demo/drag_in.py +94 -0
- textual_drivers-0.1.0/src/textual_drivers/demo/drag_out.py +91 -0
- textual_drivers-0.1.0/src/textual_drivers/demo/test_app.py +184 -0
- textual_drivers-0.1.0/src/textual_drivers/dnd.py +23 -0
- textual_drivers-0.1.0/src/textual_drivers/headless_driver.py +22 -0
- textual_drivers-0.1.0/src/textual_drivers/linux_driver.py +61 -0
- textual_drivers-0.1.0/src/textual_drivers/linux_inline_driver.py +68 -0
- textual_drivers-0.1.0/src/textual_drivers/windows_driver.py +152 -0
|
@@ -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
|
+
"""
|