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