turbodesk 0.3.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.
Files changed (44) hide show
  1. turbodesk-0.3.0/PKG-INFO +103 -0
  2. turbodesk-0.3.0/README.md +77 -0
  3. turbodesk-0.3.0/pyproject.toml +101 -0
  4. turbodesk-0.3.0/src/turbodesk/__init__.py +31 -0
  5. turbodesk-0.3.0/src/turbodesk/ansi.py +124 -0
  6. turbodesk-0.3.0/src/turbodesk/clipboard.py +89 -0
  7. turbodesk-0.3.0/src/turbodesk/commands.py +42 -0
  8. turbodesk-0.3.0/src/turbodesk/debug.py +44 -0
  9. turbodesk-0.3.0/src/turbodesk/events.py +261 -0
  10. turbodesk-0.3.0/src/turbodesk/fuzzy.py +138 -0
  11. turbodesk-0.3.0/src/turbodesk/keys.py +155 -0
  12. turbodesk-0.3.0/src/turbodesk/layout.py +64 -0
  13. turbodesk-0.3.0/src/turbodesk/markdown.py +492 -0
  14. turbodesk-0.3.0/src/turbodesk/markup.py +136 -0
  15. turbodesk-0.3.0/src/turbodesk/py.typed +0 -0
  16. turbodesk-0.3.0/src/turbodesk/rich.py +150 -0
  17. turbodesk-0.3.0/src/turbodesk/runtime.py +888 -0
  18. turbodesk-0.3.0/src/turbodesk/shell.py +93 -0
  19. turbodesk-0.3.0/src/turbodesk/syntax.py +178 -0
  20. turbodesk-0.3.0/src/turbodesk/terminal.py +249 -0
  21. turbodesk-0.3.0/src/turbodesk/testing.py +88 -0
  22. turbodesk-0.3.0/src/turbodesk/theme.py +327 -0
  23. turbodesk-0.3.0/src/turbodesk/typography.py +92 -0
  24. turbodesk-0.3.0/src/turbodesk/view.py +771 -0
  25. turbodesk-0.3.0/src/turbodesk/widgets/__init__.py +62 -0
  26. turbodesk-0.3.0/src/turbodesk/widgets/basic.py +93 -0
  27. turbodesk-0.3.0/src/turbodesk/widgets/border.py +123 -0
  28. turbodesk-0.3.0/src/turbodesk/widgets/chart.py +420 -0
  29. turbodesk-0.3.0/src/turbodesk/widgets/controls.py +225 -0
  30. turbodesk-0.3.0/src/turbodesk/widgets/dialog.py +618 -0
  31. turbodesk-0.3.0/src/turbodesk/widgets/directory.py +156 -0
  32. turbodesk-0.3.0/src/turbodesk/widgets/editor.py +1084 -0
  33. turbodesk-0.3.0/src/turbodesk/widgets/frame.py +314 -0
  34. turbodesk-0.3.0/src/turbodesk/widgets/less_keys.py +52 -0
  35. turbodesk-0.3.0/src/turbodesk/widgets/listbox.py +155 -0
  36. turbodesk-0.3.0/src/turbodesk/widgets/menu.py +308 -0
  37. turbodesk-0.3.0/src/turbodesk/widgets/ncdu.py +136 -0
  38. turbodesk-0.3.0/src/turbodesk/widgets/scroller.py +144 -0
  39. turbodesk-0.3.0/src/turbodesk/widgets/statusline.py +68 -0
  40. turbodesk-0.3.0/src/turbodesk/widgets/syntax.py +22 -0
  41. turbodesk-0.3.0/src/turbodesk/widgets/table.py +195 -0
  42. turbodesk-0.3.0/src/turbodesk/widgets/tmux.py +212 -0
  43. turbodesk-0.3.0/src/turbodesk/widgets/tree.py +52 -0
  44. turbodesk-0.3.0/src/turbodesk/windows.py +316 -0
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: turbodesk
3
+ Version: 0.3.0
4
+ Summary: Terminal UIs as pure functions from state to an immutable view tree
5
+ Keywords: terminal,tui,console,immediate-mode,widgets,ansi,curses
6
+ License-Expression: Apache-2.0
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: POSIX
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Operating System :: MacOS :: MacOS X
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: User Interfaces
17
+ Classifier: Topic :: Terminals
18
+ Classifier: Typing :: Typed
19
+ Requires-Dist: rich>=13.0
20
+ Requires-Dist: pygments>=2.0 ; extra == 'syntax'
21
+ Requires-Python: >=3.12
22
+ Project-URL: Homepage, https://turbodesk.lab.abilian.com
23
+ Project-URL: Documentation, https://turbodesk.lab.abilian.com
24
+ Provides-Extra: syntax
25
+ Description-Content-Type: text/markdown
26
+
27
+ # turbodesk
28
+
29
+ Terminal UIs as pure functions from state to an immutable view tree.
30
+
31
+ ```python
32
+ from turbodesk import View, run
33
+
34
+ def app(ui):
35
+ return View.text("Hello world!").center(within=ui.size)
36
+
37
+ run(app)
38
+ ```
39
+
40
+ An app is a function `(UI) -> View`. It re-runs whenever a frame is marked dirty (state changed, an event arrived, the terminal resized, a timer fired), and the renderer diffs by row before writing escape codes. State lives in hooks on the `ui` handle.
41
+
42
+ ```sh
43
+ uv sync # or: pip install -e .
44
+ python examples/hello_world.py
45
+ python examples/theme_gallery.py # 19 colour flavours, ← → to browse
46
+ python examples/charts.py # line, scatter and bar charts
47
+ python examples/focus.py # three key-hungry widgets, one keyboard
48
+ python examples/filter.py # type to filter a list
49
+ python examples/logs.py # colourised output, tail -f style
50
+ python examples/embed.py htop # another TUI, embedded (needs tmux)
51
+ python examples/vi somefile.py # a vi clone, built on the core alone
52
+ python examples/frogmouth README.md # a Markdown browser, ported from Textual
53
+ ```
54
+
55
+ One dependency ([Rich](https://rich.readthedocs.io/), for glyph-width measurement and for drawing Rich renderables into a view), Python 3.12+, POSIX only.
56
+
57
+ Version 0.3. [What is stable](https://turbodesk.lab.abilian.com/stability/) says which parts of the API are safe to build on now and which are still moving.
58
+
59
+ ## What's here
60
+
61
+ - `src/turbodesk/`: the core — views, styles, events, terminal, render loop, themes, layout arithmetic, overlapping windows, a command table, key naming, syntax spans, Markdown, ANSI parsing, clipboard
62
+ - `src/turbodesk/widgets/`: dialogs, button, checkbox, radio set, selection list, textbox, listbox, table, tabs, progress bar, spinner, rule, border, frame, scroller, tree, directory tree, menu bar, status line, charts, editor, ncdu, tmux pane
63
+ - `examples/`: all 17 bonsai_term examples, ported and running, plus demos, a vi clone and a Markdown browser. **[Start here](examples/README.md)**, inventory, API and design notes.
64
+ - **[COMPONENTS.md](notes/components.md)**: the bonsai_term_components port, what moved, what changed shape, what was left out.
65
+
66
+ ## Documentation
67
+
68
+ ```bash
69
+ make docs-serve # live reload
70
+ make docs # build to docs/site/
71
+ make docs-check # strict: a broken cross-reference fails the build
72
+ ```
73
+
74
+ The docs are their own project under `docs/`, with their own Makefile (`cd docs && make`
75
+ works too).
76
+
77
+ Built with [Zensical](https://pypi.org/project/zensical/). `docs/` has a guide, a
78
+ comparison with Textual and other frameworks grounded in two real ports, and notes on
79
+ the internals.
80
+
81
+ ## Tests
82
+
83
+ A pyramid, run with `make test` or `uv run pytest`:
84
+
85
+ ```sh
86
+ uv run pytest -m unit # 2,008 — pure functions, no I/O, about a second
87
+ uv run pytest -m integration # 432 — widgets and apps through a UI
88
+ uv run pytest -m e2e # 7 — real programs on a pty
89
+ uv run pytest -m "not slow" # skips the pty and tmux tests
90
+ ```
91
+
92
+ ```
93
+ tests/
94
+ ├── a_unit/ views, styles, events, markdown, typography, fuzzy matching,
95
+ │ and the vi showcase's grammar
96
+ ├── b_integration/ the runtime, widgets, dialogs, every example, the whole apps
97
+ ├── c_e2e/ pty-driven: alternate screen, keys, vi saving, a modal
98
+ └── conftest.py ui / render / screen / scratch_data_dir fixtures
99
+ ```
100
+
101
+ The two products built on turbodesk live in their own repositories and have their own
102
+ suites — Turbo Python (594 tests) and TurboVI (395) — and a library change is not
103
+ verified until those pass too.
@@ -0,0 +1,77 @@
1
+ # turbodesk
2
+
3
+ Terminal UIs as pure functions from state to an immutable view tree.
4
+
5
+ ```python
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
+ An app is a function `(UI) -> View`. It re-runs whenever a frame is marked dirty (state changed, an event arrived, the terminal resized, a timer fired), and the renderer diffs by row before writing escape codes. State lives in hooks on the `ui` handle.
15
+
16
+ ```sh
17
+ uv sync # or: pip install -e .
18
+ python examples/hello_world.py
19
+ python examples/theme_gallery.py # 19 colour flavours, ← → to browse
20
+ python examples/charts.py # line, scatter and bar charts
21
+ python examples/focus.py # three key-hungry widgets, one keyboard
22
+ python examples/filter.py # type to filter a list
23
+ python examples/logs.py # colourised output, tail -f style
24
+ python examples/embed.py htop # another TUI, embedded (needs tmux)
25
+ python examples/vi somefile.py # a vi clone, built on the core alone
26
+ python examples/frogmouth README.md # a Markdown browser, ported from Textual
27
+ ```
28
+
29
+ One dependency ([Rich](https://rich.readthedocs.io/), for glyph-width measurement and for drawing Rich renderables into a view), Python 3.12+, POSIX only.
30
+
31
+ Version 0.3. [What is stable](https://turbodesk.lab.abilian.com/stability/) says which parts of the API are safe to build on now and which are still moving.
32
+
33
+ ## What's here
34
+
35
+ - `src/turbodesk/`: the core — views, styles, events, terminal, render loop, themes, layout arithmetic, overlapping windows, a command table, key naming, syntax spans, Markdown, ANSI parsing, clipboard
36
+ - `src/turbodesk/widgets/`: dialogs, button, checkbox, radio set, selection list, textbox, listbox, table, tabs, progress bar, spinner, rule, border, frame, scroller, tree, directory tree, menu bar, status line, charts, editor, ncdu, tmux pane
37
+ - `examples/`: all 17 bonsai_term examples, ported and running, plus demos, a vi clone and a Markdown browser. **[Start here](examples/README.md)**, inventory, API and design notes.
38
+ - **[COMPONENTS.md](notes/components.md)**: the bonsai_term_components port, what moved, what changed shape, what was left out.
39
+
40
+ ## Documentation
41
+
42
+ ```bash
43
+ make docs-serve # live reload
44
+ make docs # build to docs/site/
45
+ make docs-check # strict: a broken cross-reference fails the build
46
+ ```
47
+
48
+ The docs are their own project under `docs/`, with their own Makefile (`cd docs && make`
49
+ works too).
50
+
51
+ Built with [Zensical](https://pypi.org/project/zensical/). `docs/` has a guide, a
52
+ comparison with Textual and other frameworks grounded in two real ports, and notes on
53
+ the internals.
54
+
55
+ ## Tests
56
+
57
+ A pyramid, run with `make test` or `uv run pytest`:
58
+
59
+ ```sh
60
+ uv run pytest -m unit # 2,008 — pure functions, no I/O, about a second
61
+ uv run pytest -m integration # 432 — widgets and apps through a UI
62
+ uv run pytest -m e2e # 7 — real programs on a pty
63
+ uv run pytest -m "not slow" # skips the pty and tmux tests
64
+ ```
65
+
66
+ ```
67
+ tests/
68
+ ├── a_unit/ views, styles, events, markdown, typography, fuzzy matching,
69
+ │ and the vi showcase's grammar
70
+ ├── b_integration/ the runtime, widgets, dialogs, every example, the whole apps
71
+ ├── c_e2e/ pty-driven: alternate screen, keys, vi saving, a modal
72
+ └── conftest.py ui / render / screen / scratch_data_dir fixtures
73
+ ```
74
+
75
+ The two products built on turbodesk live in their own repositories and have their own
76
+ suites — Turbo Python (594 tests) and TurboVI (395) — and a library change is not
77
+ verified until those pass too.
@@ -0,0 +1,101 @@
1
+ [project]
2
+ name = "turbodesk"
3
+ version = "0.3.0"
4
+ description = "Terminal UIs as pure functions from state to an immutable view tree"
5
+ requires-python = ">=3.12"
6
+ readme = "README.md"
7
+ license = "Apache-2.0"
8
+ keywords = ["terminal", "tui", "console", "immediate-mode", "widgets", "ansi", "curses"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "Operating System :: POSIX",
14
+ "Operating System :: POSIX :: Linux",
15
+ "Operating System :: MacOS :: MacOS X",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Topic :: Software Development :: User Interfaces",
20
+ "Topic :: Terminals",
21
+ "Typing :: Typed",
22
+ ]
23
+ # No `License ::` classifier: `license` above is an SPDX expression (PEP 639), and
24
+ # PyPI rejects a distribution that carries both.
25
+
26
+ # Rich renders content — measuring glyph widths, and `turbodesk.rich` for anyone with
27
+ # renderables already. It does not render *views*: handlers, tags and hit-testing are
28
+ # turbodesk's, and Rich's segment stream has no notion of them. Its heavy parts
29
+ # (pygments, markdown-it) are lazy — importing the core costs about 20ms.
30
+ dependencies = ["rich>=13.0"]
31
+
32
+ [project.optional-dependencies]
33
+ # Syntax colouring for `editor()`, via `turbodesk.widgets.syntax`.
34
+ syntax = ["pygments>=2.0"]
35
+
36
+ [project.urls]
37
+ Homepage = "https://turbodesk.lab.abilian.com"
38
+ Documentation = "https://turbodesk.lab.abilian.com"
39
+ # The sourcehut repository is not public yet: `https://git.sr.ht/~sfermigier/turbodesk`
40
+ # answers 404 today, and a project URL that 404s is worse than an absent one. Uncomment
41
+ # both on the day the repository is published, and before the release that follows.
42
+ # Repository = "https://git.sr.ht/~sfermigier/turbodesk"
43
+ # Issues = "https://todo.sr.ht/~sfermigier/turbodesk"
44
+
45
+ [build-system]
46
+ requires = ["uv_build>=0.11.33,<0.12.0"]
47
+ build-backend = "uv_build"
48
+
49
+ # Linting lives in ruff.toml.
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ # The library from src/; the example apps by the flat names they import each other by.
54
+ pythonpath = ["src", "examples", "examples/vi", "examples/frogmouth", "examples/termon", "examples/fm", "examples/browser"]
55
+ addopts = "--strict-markers --import-mode=importlib"
56
+ markers = [
57
+ "unit: isolated and fast — no I/O, no event loop",
58
+ "integration: components wired together, may touch the filesystem",
59
+ "e2e: drives a real terminal through a pty",
60
+ "slow: needs an external program or wall-clock time",
61
+ ]
62
+
63
+ [tool.mypy]
64
+ # Read by zuban too. Same search path as pytest and the other checkers.
65
+ mypy_path = "src:examples:examples/vi:examples/frogmouth:examples/termon:examples/fm:examples/browser"
66
+
67
+ [tool.ty.src]
68
+ # Vendored Textual apps awaiting a port. They are someone else's code with their own
69
+ # toolchain; checking them here only reports on Textual, not on turbodesk.
70
+ exclude = ["porting/"]
71
+
72
+ [tool.ty.environment]
73
+ # The same search path pytest gets above: the example apps import each other by flat
74
+ # name, so a checker looking only at src/ cannot resolve `state`, `keymap`, `store`…
75
+ extra-paths = ["src", "examples", "examples/vi", "examples/frogmouth", "examples/termon", "examples/fm", "examples/browser"]
76
+
77
+ [tool.pyrefly]
78
+ project-includes = [
79
+ "**/*.py*",
80
+ "**/*.ipynb",
81
+ ]
82
+ project-excludes = [
83
+ "**/.venv/**",
84
+ "porting/**", # see [tool.ty.src] above
85
+ ]
86
+ search-path = ["src", "examples", "examples/vi", "examples/frogmouth", "examples/termon", "examples/fm", "examples/browser", "tests"]
87
+
88
+ [dependency-groups]
89
+ dev = [
90
+ "mypy>=2.3.0",
91
+ "nox>=2026.7.11",
92
+ "pre-commit>=4.6.1",
93
+ "pyrefly>=1.2.0",
94
+ "pytest>=9.1.1",
95
+ "pytest-cov>=7.1.0",
96
+ "ruff>=0.16.1",
97
+ "ty>=0.0.66",
98
+ "types-pygments>=2.20.0.20260728",
99
+ "zensical>=0.0.53",
100
+ "zuban>=0.9.1",
101
+ ]
@@ -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
+ ]
@@ -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
@@ -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)
@@ -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
@@ -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("")