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 +31 -0
- turbodesk/ansi.py +124 -0
- turbodesk/clipboard.py +89 -0
- turbodesk/commands.py +42 -0
- turbodesk/debug.py +44 -0
- turbodesk/events.py +261 -0
- turbodesk/fuzzy.py +138 -0
- turbodesk/keys.py +155 -0
- turbodesk/layout.py +64 -0
- turbodesk/markdown.py +492 -0
- turbodesk/markup.py +136 -0
- turbodesk/py.typed +0 -0
- turbodesk/rich.py +150 -0
- turbodesk/runtime.py +888 -0
- turbodesk/shell.py +93 -0
- turbodesk/syntax.py +178 -0
- turbodesk/terminal.py +249 -0
- turbodesk/testing.py +88 -0
- turbodesk/theme.py +327 -0
- turbodesk/typography.py +92 -0
- turbodesk/view.py +771 -0
- turbodesk/widgets/__init__.py +62 -0
- turbodesk/widgets/basic.py +93 -0
- turbodesk/widgets/border.py +123 -0
- turbodesk/widgets/chart.py +420 -0
- turbodesk/widgets/controls.py +225 -0
- turbodesk/widgets/dialog.py +618 -0
- turbodesk/widgets/directory.py +156 -0
- turbodesk/widgets/editor.py +1084 -0
- turbodesk/widgets/frame.py +314 -0
- turbodesk/widgets/less_keys.py +52 -0
- turbodesk/widgets/listbox.py +155 -0
- turbodesk/widgets/menu.py +308 -0
- turbodesk/widgets/ncdu.py +136 -0
- turbodesk/widgets/scroller.py +144 -0
- turbodesk/widgets/statusline.py +68 -0
- turbodesk/widgets/syntax.py +22 -0
- turbodesk/widgets/table.py +195 -0
- turbodesk/widgets/tmux.py +212 -0
- turbodesk/widgets/tree.py +52 -0
- turbodesk/windows.py +316 -0
- turbodesk-0.3.0.dist-info/METADATA +103 -0
- turbodesk-0.3.0.dist-info/RECORD +44 -0
- turbodesk-0.3.0.dist-info/WHEEL +4 -0
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)
|