turbodesk 0.3.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.
turbodesk/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """turbodesk — terminal UIs as pure functions from state to an immutable view tree.
2
+
3
+ An app is a function `(UI) -> View`. It re-runs whenever something marks the frame
4
+ dirty; state lives in hooks on the `UI` handle.
5
+
6
+ from turbodesk import View, run
7
+
8
+ def app(ui):
9
+ return View.text("Hello world!").center(within=ui.size)
10
+
11
+ run(app)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .runtime import UI, Drag, run
17
+ from .theme import Theme
18
+ from .view import Color, Pos, Region, Size, Style, View
19
+
20
+ __all__ = [
21
+ "UI",
22
+ "Color",
23
+ "Drag",
24
+ "Pos",
25
+ "Region",
26
+ "Size",
27
+ "Style",
28
+ "Theme",
29
+ "View",
30
+ "run",
31
+ ]
turbodesk/ansi.py ADDED
@@ -0,0 +1,124 @@
1
+ """Parse ANSI-escaped text back into a `View`.
2
+
3
+ The inverse of the renderer: takes what another program wrote to a terminal and turns
4
+ it into styled cells. Useful for showing the output of a subprocess (`ls --color`,
5
+ `git diff`, a captured tmux pane) inside a turbodesk app.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from .view import Color, Style, View
13
+
14
+ SGR = re.compile(r"\x1b\[([0-9;]*)m")
15
+ OTHER_ESCAPES = re.compile(
16
+ r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[\[\(][0-9;?]*[A-Za-z]|\x1b."
17
+ )
18
+
19
+
20
+ def render(
21
+ text: str, *, base: Style | None = None, fill_width: int | None = None
22
+ ) -> View:
23
+ """Render escape-coded `text`.
24
+
25
+ `base` supplies the colours for spans that don't set their own. `fill_width` pads
26
+ every line out to that width, so a background colour covers the whole line.
27
+ """
28
+ base = base or Style()
29
+ lines = [_line(line, base, fill_width) for line in _clean(text).split("\n")]
30
+ return View.vcat(lines)
31
+
32
+
33
+ def _clean(text: str) -> str:
34
+ """Drop escape sequences we don't interpret, keeping the SGR ones."""
35
+ return OTHER_ESCAPES.sub(
36
+ lambda m: m.group(0) if SGR.fullmatch(m.group(0)) else "", text
37
+ )
38
+
39
+
40
+ def _line(line: str, base: Style, fill_width: int | None) -> View:
41
+ spans: list[View] = []
42
+ style = base
43
+ position = 0
44
+ width = 0
45
+
46
+ for match in SGR.finditer(line):
47
+ chunk = line[position : match.start()]
48
+ if chunk:
49
+ spans.append(View.text(chunk, style))
50
+ width += spans[-1].width
51
+ style = _apply(style, base, match.group(1))
52
+ position = match.end()
53
+
54
+ tail = line[position:]
55
+ if tail:
56
+ spans.append(View.text(tail, style))
57
+ width += spans[-1].width
58
+ if fill_width is not None and width < fill_width:
59
+ spans.append(View.text(" " * (fill_width - width), style))
60
+ return View.hcat(spans) if spans else View.text("", base)
61
+
62
+
63
+ def _apply(style: Style, base: Style, params: str) -> Style:
64
+ """Fold one SGR sequence into `style`.
65
+
66
+ One case per code, spelled out, so this reads against the ANSI table it implements.
67
+ Codes we don't know fall through and change nothing, which is what a terminal does.
68
+ """
69
+ codes = [int(p) for p in params.split(";") if p] or [0]
70
+ index = 0
71
+ while index < len(codes):
72
+ code = codes[index]
73
+ match code:
74
+ case 0:
75
+ style = base
76
+ case 1:
77
+ style = style._replace(bold=True)
78
+ case 3:
79
+ style = style._replace(italic=True)
80
+ case 4:
81
+ style = style._replace(underline=True)
82
+ case 5:
83
+ style = style._replace(blink=True)
84
+ case 7:
85
+ style = style._replace(invert=True)
86
+ case 22:
87
+ style = style._replace(bold=False)
88
+ case 23:
89
+ style = style._replace(italic=False)
90
+ case 24:
91
+ style = style._replace(underline=False)
92
+ case 25:
93
+ style = style._replace(blink=False)
94
+ case 27:
95
+ style = style._replace(invert=False)
96
+ case 38:
97
+ color, index = _extended(codes, index)
98
+ style = style._replace(fg=color)
99
+ case 48:
100
+ color, index = _extended(codes, index)
101
+ style = style._replace(bg=color)
102
+ case 39:
103
+ style = style._replace(fg=base.fg)
104
+ case 49:
105
+ style = style._replace(bg=base.bg)
106
+ case _ if 30 <= code <= 37:
107
+ style = style._replace(fg=Color.ansi16(code - 30))
108
+ case _ if 40 <= code <= 47:
109
+ style = style._replace(bg=Color.ansi16(code - 40))
110
+ case _ if 90 <= code <= 97:
111
+ style = style._replace(fg=Color.ansi16(code - 82))
112
+ case _ if 100 <= code <= 107:
113
+ style = style._replace(bg=Color.ansi16(code - 92))
114
+ index += 1
115
+ return style
116
+
117
+
118
+ def _extended(codes: list[int], index: int) -> tuple[Color | None, int]:
119
+ """Read a `38;5;n` or `38;2;r;g;b` colour, with the index it ended on."""
120
+ if index + 1 < len(codes) and codes[index + 1] == 5 and index + 2 < len(codes):
121
+ return Color.xterm256(codes[index + 2]), index + 2
122
+ if index + 1 < len(codes) and codes[index + 1] == 2 and index + 4 < len(codes):
123
+ return Color(*codes[index + 2 : index + 5]), index + 4
124
+ return None, index
turbodesk/clipboard.py ADDED
@@ -0,0 +1,89 @@
1
+ """Reading and writing the system clipboard.
2
+
3
+ Copying goes out over OSC 52 *and* a local helper, because neither works everywhere:
4
+ OSC 52 is the only thing that crosses an ssh session, but several terminals ignore it
5
+ (PuTTY, the VS Code integrated terminal), and a local helper can't reach the machine
6
+ you're actually sitting at. Doing both covers more ground than either.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import base64
13
+ import os
14
+ import shutil
15
+ import sys
16
+
17
+ PRIMARY = "primary"
18
+ CLIPBOARD = "clipboard"
19
+
20
+ # Each entry is (copy command, paste command); the first available one wins.
21
+ HELPERS = (
22
+ (["pbcopy"], ["pbpaste"]),
23
+ (["wl-copy"], ["wl-paste", "--no-newline"]),
24
+ (["xclip", "-selection", "clipboard"], ["xclip", "-selection", "clipboard", "-o"]),
25
+ (["xsel", "--clipboard", "--input"], ["xsel", "--clipboard", "--output"]),
26
+ )
27
+
28
+
29
+ def osc52(text: str, selection: str = CLIPBOARD) -> str:
30
+ """The escape sequence that asks the terminal to set the clipboard."""
31
+ target = "p" if selection == PRIMARY else "c"
32
+ encoded = base64.b64encode(text.encode()).decode()
33
+ return f"\x1b]52;{target};{encoded}\x07"
34
+
35
+
36
+ async def copy(text: str, selection: str = CLIPBOARD) -> bool:
37
+ """Copy `text`. True if a local helper took it; OSC 52 is always attempted."""
38
+ _write_escape(osc52(text, selection))
39
+ command = _helper(selection, index=0)
40
+ if command is None:
41
+ return False
42
+ process = await asyncio.create_subprocess_exec(
43
+ *command, stdin=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL
44
+ )
45
+ await process.communicate(text.encode())
46
+ return process.returncode == 0
47
+
48
+
49
+ async def paste(selection: str = CLIPBOARD) -> str:
50
+ """Read the clipboard. Empty if no helper is installed.
51
+
52
+ There is no OSC 52 fallback: reading back requires the terminal to answer, and
53
+ enough of them refuse (it lets a remote host read your clipboard) that relying on
54
+ it would be worse than returning nothing.
55
+ """
56
+ command = _helper(selection, index=1)
57
+ if command is None:
58
+ return ""
59
+ process = await asyncio.create_subprocess_exec(
60
+ *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL
61
+ )
62
+ stdout, _ = await process.communicate()
63
+ return stdout.decode(errors="replace") if process.returncode == 0 else ""
64
+
65
+
66
+ def _helper(selection: str, index: int) -> list[str] | None:
67
+ for pair in HELPERS:
68
+ command = list(pair[index])
69
+ if shutil.which(command[0]) is None:
70
+ continue
71
+ if selection == PRIMARY and command[0] in ("xclip", "xsel"):
72
+ command = [part.replace("clipboard", "primary") for part in command]
73
+ return command
74
+ return None
75
+
76
+
77
+ def _write_escape(sequence: str) -> None:
78
+ """Escape sequences have to reach the terminal, not a redirected stdout."""
79
+ try:
80
+ fd = os.open("/dev/tty", os.O_WRONLY)
81
+ except OSError:
82
+ if sys.stdout.isatty():
83
+ sys.stdout.write(sequence)
84
+ sys.stdout.flush()
85
+ return
86
+ try:
87
+ os.write(fd, sequence.encode())
88
+ finally:
89
+ os.close(fd)
turbodesk/commands.py ADDED
@@ -0,0 +1,42 @@
1
+ """A command table: what the app can do right now, keyed by id.
2
+
3
+ A Turbo Vision-style registry, stripped to the shape two callers already share. One
4
+ table is read by the menu (to grey items and to run them), by the keyboard and by the
5
+ status line, so a shortcut can never do something its own menu item says is
6
+ unavailable. An id that is absent from the table greys; an id that is present runs.
7
+
8
+ `dispatch` is the one piece of logic `examples/turbo_python` and `examples/windows.py`
9
+ both hand-rolled identically: dismiss whatever is open, look the command up, run it.
10
+ There is deliberately no `Command` record — the id is the dict key, and the label lives
11
+ in the menu data, so nothing is duplicated.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+
18
+ Command = Callable[[], None]
19
+ """A command's action. Takes and returns nothing: it closes over its own application
20
+ state, which is what lets the same table drive a menu, a keyboard handler and a status
21
+ line."""
22
+
23
+
24
+ def dispatch(
25
+ table: dict[str, Command],
26
+ name: str,
27
+ *,
28
+ close: Callable[[], None],
29
+ ) -> bool:
30
+ """Run the command `name`, returning True if one existed.
31
+
32
+ `close` dismisses whatever was open — a pull-down, a popup — and runs first, because
33
+ picking a command always closes the thing it was picked from. Returns False when
34
+ `name` is absent, so a keyboard handler can leave the event to someone else without
35
+ pre-checking membership itself.
36
+ """
37
+ close()
38
+ action = table.get(name)
39
+ if action is None:
40
+ return False
41
+ action()
42
+ return True
turbodesk/debug.py ADDED
@@ -0,0 +1,44 @@
1
+ """Print debugging for a program that owns the screen.
2
+
3
+ `print` inside a turbodesk app writes into the alternate screen and is destroyed on the
4
+ next frame. This writes to a file instead:
5
+
6
+ from turbodesk import debug
7
+ debug.log("visited", location, len(history))
8
+
9
+ and in another terminal:
10
+
11
+ tail -f turbodesk.log
12
+
13
+ Set `$TURBODESK_LOG` to put it somewhere else. Logging is off when the file cannot be
14
+ opened, so leaving calls in never breaks anything.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import contextlib
20
+ import os
21
+ import time
22
+ from pathlib import Path
23
+
24
+ DEFAULT_FILE = "turbodesk.log"
25
+ _started = time.monotonic()
26
+
27
+
28
+ def log_file() -> Path:
29
+ return Path(os.environ.get("TURBODESK_LOG", DEFAULT_FILE))
30
+
31
+
32
+ def log(*parts: object) -> None:
33
+ """Append a timestamped line. Never raises."""
34
+ stamp = f"{time.monotonic() - _started:8.3f}"
35
+ line = f"{stamp} {' '.join(str(part) for part in parts)}\n"
36
+ # A log that cannot be written is not worth an exception.
37
+ with contextlib.suppress(OSError), log_file().open("a") as handle:
38
+ handle.write(line)
39
+
40
+
41
+ def clear() -> None:
42
+ """Start a fresh log, so a run's output is not buried under the last one's."""
43
+ with contextlib.suppress(OSError):
44
+ log_file().write_text("")
turbodesk/events.py ADDED
@@ -0,0 +1,261 @@
1
+ """Input events, and the terminal escape-sequence parser that produces them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ import re
7
+ import time
8
+ from enum import Enum
9
+ from typing import Literal, NamedTuple
10
+
11
+ from .view import Pos
12
+
13
+
14
+ class Mod(Enum):
15
+ META = "meta"
16
+ CTRL = "ctrl"
17
+ SHIFT = "shift"
18
+
19
+
20
+ class Key(Enum):
21
+ ESCAPE = "escape"
22
+ ENTER = "enter"
23
+ TAB = "tab"
24
+ BACKSPACE = "backspace"
25
+ INSERT = "insert"
26
+ DELETE = "delete"
27
+ HOME = "home"
28
+ END = "end"
29
+ UP = "up"
30
+ DOWN = "down"
31
+ LEFT = "left"
32
+ RIGHT = "right"
33
+ PAGE_UP = "page_up"
34
+ PAGE_DOWN = "page_down"
35
+ F1 = "f1"
36
+ F2 = "f2"
37
+ F3 = "f3"
38
+ F4 = "f4"
39
+ F5 = "f5"
40
+ F6 = "f6"
41
+ F7 = "f7"
42
+ F8 = "f8"
43
+ F9 = "f9"
44
+ F10 = "f10"
45
+ F11 = "f11"
46
+ F12 = "f12"
47
+
48
+
49
+ class MouseKind(Enum):
50
+ LEFT = "left"
51
+ MIDDLE = "middle"
52
+ RIGHT = "right"
53
+ SCROLL_UP = "scroll_up"
54
+ SCROLL_DOWN = "scroll_down"
55
+ DRAG = "drag"
56
+ HOVER = "hover"
57
+ RELEASE = "release"
58
+
59
+
60
+ class KeyPress(NamedTuple):
61
+ key: str | Key
62
+ mods: frozenset[Mod] = frozenset()
63
+
64
+
65
+ class Mouse(NamedTuple):
66
+ kind: MouseKind
67
+ pos: Pos
68
+ mods: frozenset[Mod] = frozenset()
69
+
70
+
71
+ PastePhase = Literal["start", "end"]
72
+
73
+
74
+ class Paste(NamedTuple):
75
+ phase: PastePhase
76
+
77
+
78
+ Event = KeyPress | Mouse | Paste
79
+
80
+ _CSI = re.compile(r"\x1b\[([0-9;<]*)([A-Za-z~])")
81
+ _SGR_MOUSE = re.compile(r"\x1b\[<([0-9]+);([0-9]+);([0-9]+)([Mm])")
82
+
83
+ _CSI_KEYS = {
84
+ "A": Key.UP,
85
+ "B": Key.DOWN,
86
+ "C": Key.RIGHT,
87
+ "D": Key.LEFT,
88
+ "H": Key.HOME,
89
+ "F": Key.END,
90
+ "P": Key.F1,
91
+ "Q": Key.F2,
92
+ "R": Key.F3,
93
+ "S": Key.F4,
94
+ }
95
+ _TILDE_KEYS = {
96
+ 1: Key.HOME,
97
+ 2: Key.INSERT,
98
+ 3: Key.DELETE,
99
+ 4: Key.END,
100
+ 5: Key.PAGE_UP,
101
+ 6: Key.PAGE_DOWN,
102
+ 15: Key.F5,
103
+ 17: Key.F6,
104
+ 18: Key.F7,
105
+ 19: Key.F8,
106
+ 20: Key.F9,
107
+ 21: Key.F10,
108
+ 23: Key.F11,
109
+ 24: Key.F12,
110
+ }
111
+ _CONTROL_KEYS = {"\r": Key.ENTER, "\n": Key.ENTER, "\t": Key.TAB, "\x7f": Key.BACKSPACE}
112
+
113
+
114
+ def _xterm_mods(param: int) -> frozenset[Mod]:
115
+ """xterm encodes modifiers as 1 + bitmask in the second CSI parameter."""
116
+ bits = param - 1
117
+ mods = set()
118
+ if bits & 1:
119
+ mods.add(Mod.SHIFT)
120
+ if bits & 2:
121
+ mods.add(Mod.META)
122
+ if bits & 4:
123
+ mods.add(Mod.CTRL)
124
+ return frozenset(mods)
125
+
126
+
127
+ def _with_meta(event: Event | None) -> Event | None:
128
+ """Add Alt to a key press that arrived behind an ESC prefix."""
129
+ if not isinstance(event, KeyPress):
130
+ return event
131
+ return event._replace(mods=event.mods | {Mod.META})
132
+
133
+
134
+ ESCAPE_TIMEOUT = 0.03
135
+
136
+
137
+ class Parser:
138
+ """Incremental parser: feed it bytes, get events back.
139
+
140
+ A lone ESC is ambiguous — it may be the Escape key, or the start of an arrow key or
141
+ an Alt combination — so it is held back until `flush()` sees that nothing followed
142
+ it for `ESCAPE_TIMEOUT`. Every terminal application needs this timer; vim calls it
143
+ `ttimeoutlen` and defaults it to about the same.
144
+
145
+ A key that genuinely arrives within the timeout reads as Alt, which is also what
146
+ vim does, and is why pasting into a terminal editor without bracketed paste goes
147
+ wrong in the same way.
148
+ """
149
+
150
+ def __init__(self) -> None:
151
+ self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
152
+ self._buffer = ""
153
+ self._escape_since: float | None = None
154
+
155
+ def feed(self, data: bytes) -> list[Event]:
156
+ self._buffer += self._decoder.decode(data)
157
+ events = self._drain()
158
+ pending = self._buffer in ("\x1b", "\x1b\x1b")
159
+ self._escape_since = time.monotonic() if pending else None
160
+ return events
161
+
162
+ def flush(self) -> list[Event]:
163
+ """Resolve a dangling ESC into an Escape key press once its timeout expires.
164
+
165
+ `ESC ESC` waits too, because it is the start of a meta-prefixed sequence as
166
+ often as it is Alt-Escape; nothing more arriving settles which.
167
+ """
168
+ if self._buffer not in ("\x1b", "\x1b\x1b") or self._escape_since is None:
169
+ return []
170
+ if time.monotonic() - self._escape_since < ESCAPE_TIMEOUT:
171
+ return []
172
+ mods = frozenset({Mod.META}) if self._buffer == "\x1b\x1b" else frozenset()
173
+ self._buffer = ""
174
+ self._escape_since = None
175
+ return [KeyPress(Key.ESCAPE, mods)]
176
+
177
+ def _drain(self) -> list[Event]:
178
+ events: list[Event] = []
179
+ while self._buffer:
180
+ event, consumed = self._parse_one(self._buffer)
181
+ if consumed == 0: # incomplete sequence; wait for more bytes
182
+ break
183
+ self._buffer = self._buffer[consumed:]
184
+ if event is not None:
185
+ events.append(event)
186
+ return events
187
+
188
+ def _parse_one(self, buf: str) -> tuple[Event | None, int]:
189
+ if buf[0] != "\x1b":
190
+ return self._parse_char(buf[0]), 1
191
+
192
+ if len(buf) == 1:
193
+ return None, 0
194
+ if buf[1] == "\x1b":
195
+ # `ESC ESC …`: a terminal in metaSendsEscape mode prefixes the *whole*
196
+ # sequence, so Alt-F9 arrives as ESC + F9's own escape rather than as
197
+ # `CSI 20;3~`. Parse what follows and put the Alt back on it. Without
198
+ # this the inner sequence is taken as literal text and `[20~` is typed
199
+ # into whatever has focus.
200
+ inner, consumed = self._parse_one(buf[1:])
201
+ if consumed == 0:
202
+ return None, 0
203
+ return _with_meta(inner), consumed + 1
204
+ if buf[1] == "O" and len(buf) >= 3:
205
+ key = _CSI_KEYS.get(buf[2])
206
+ return (KeyPress(key) if key else None), 3
207
+ if buf[1] != "[":
208
+ return KeyPress(buf[1], frozenset({Mod.META})), 2
209
+
210
+ mouse = _SGR_MOUSE.match(buf)
211
+ if mouse:
212
+ return self._parse_mouse(mouse), mouse.end()
213
+
214
+ csi = _CSI.match(buf)
215
+ if not csi:
216
+ return (None, 0) if len(buf) < 16 else (None, 1) # give up on garbage
217
+ params, final = csi.group(1), csi.group(2)
218
+ return self._parse_csi(params, final), csi.end()
219
+
220
+ def _parse_csi(self, params: str, final: str) -> Event | None:
221
+ numbers = [int(p) for p in params.split(";") if p.isdigit()]
222
+ mods = _xterm_mods(numbers[1]) if len(numbers) > 1 else frozenset()
223
+
224
+ if final == "Z": # xterm sends CSI Z for a back-tab
225
+ return KeyPress(Key.TAB, frozenset({Mod.SHIFT}))
226
+ if final == "~":
227
+ if numbers and numbers[0] in (200, 201):
228
+ return Paste("start" if numbers[0] == 200 else "end")
229
+ key = _TILDE_KEYS.get(numbers[0]) if numbers else None
230
+ return KeyPress(key, mods) if key else None
231
+ key = _CSI_KEYS.get(final)
232
+ return KeyPress(key, mods) if key else None
233
+
234
+ def _parse_mouse(self, match: re.Match[str]) -> Mouse:
235
+ button, x, y = (int(match.group(i)) for i in (1, 2, 3))
236
+ final = match.group(4)
237
+ mods = set()
238
+ if button & 4:
239
+ mods.add(Mod.SHIFT)
240
+ if button & 8:
241
+ mods.add(Mod.META)
242
+ if button & 16:
243
+ mods.add(Mod.CTRL)
244
+
245
+ if button & 64:
246
+ kind = MouseKind.SCROLL_UP if button & 1 == 0 else MouseKind.SCROLL_DOWN
247
+ elif final == "m":
248
+ kind = MouseKind.RELEASE
249
+ elif button & 32:
250
+ kind = MouseKind.HOVER if button & 3 == 3 else MouseKind.DRAG
251
+ else:
252
+ kind = (MouseKind.LEFT, MouseKind.MIDDLE, MouseKind.RIGHT)[button & 3]
253
+ return Mouse(kind, Pos(x - 1, y - 1), frozenset(mods))
254
+
255
+ def _parse_char(self, char: str) -> Event:
256
+ if char in _CONTROL_KEYS:
257
+ return KeyPress(_CONTROL_KEYS[char])
258
+ code = ord(char)
259
+ if code < 0x20: # Ctrl-<letter>
260
+ return KeyPress(chr(code + 0x60), frozenset({Mod.CTRL}))
261
+ return KeyPress(char)