zettcode-tui 0.4.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.
Files changed (83) hide show
  1. zettcode/tui/__init__.py +422 -0
  2. zettcode/tui/_compat.py +32 -0
  3. zettcode/tui/capabilities.py +107 -0
  4. zettcode/tui/clipboard.py +91 -0
  5. zettcode/tui/console.py +229 -0
  6. zettcode/tui/core/__init__.py +63 -0
  7. zettcode/tui/core/app.py +658 -0
  8. zettcode/tui/core/events.py +134 -0
  9. zettcode/tui/core/focus.py +85 -0
  10. zettcode/tui/core/geometry.py +212 -0
  11. zettcode/tui/core/host.py +66 -0
  12. zettcode/tui/core/keymap.py +218 -0
  13. zettcode/tui/core/scheduler.py +105 -0
  14. zettcode/tui/core/screen.py +80 -0
  15. zettcode/tui/core/theme.py +248 -0
  16. zettcode/tui/core/widget.py +204 -0
  17. zettcode/tui/gallery.py +588 -0
  18. zettcode/tui/glyphs.py +73 -0
  19. zettcode/tui/input/__init__.py +18 -0
  20. zettcode/tui/input/bridge.py +46 -0
  21. zettcode/tui/input/decoder.py +234 -0
  22. zettcode/tui/input/events.py +64 -0
  23. zettcode/tui/input/keys.py +289 -0
  24. zettcode/tui/input/reader.py +147 -0
  25. zettcode/tui/layout/__init__.py +24 -0
  26. zettcode/tui/layout/box.py +153 -0
  27. zettcode/tui/layout/overlay.py +164 -0
  28. zettcode/tui/layout/scroll.py +477 -0
  29. zettcode/tui/layout/solver.py +95 -0
  30. zettcode/tui/layout/spacing.py +137 -0
  31. zettcode/tui/py.typed +0 -0
  32. zettcode/tui/render/__init__.py +71 -0
  33. zettcode/tui/render/ansi.py +371 -0
  34. zettcode/tui/render/canvas.py +153 -0
  35. zettcode/tui/render/code.py +456 -0
  36. zettcode/tui/render/color.py +177 -0
  37. zettcode/tui/render/renderer.py +151 -0
  38. zettcode/tui/render/rich_text.py +80 -0
  39. zettcode/tui/render/style.py +121 -0
  40. zettcode/tui/render/sweep.py +87 -0
  41. zettcode/tui/render/text.py +319 -0
  42. zettcode/tui/runner.py +316 -0
  43. zettcode/tui/terminal.py +222 -0
  44. zettcode/tui/testing/__init__.py +6 -0
  45. zettcode/tui/testing/harness.py +220 -0
  46. zettcode/tui/testing/snapshot.py +64 -0
  47. zettcode/tui/theme_file.py +300 -0
  48. zettcode/tui/themes/ayu-mirage.toml +44 -0
  49. zettcode/tui/themes/catppuccin-latte.toml +44 -0
  50. zettcode/tui/themes/catppuccin-mocha.toml +44 -0
  51. zettcode/tui/themes/dracula.toml +44 -0
  52. zettcode/tui/themes/github-dimmed.toml +44 -0
  53. zettcode/tui/themes/github-light.toml +44 -0
  54. zettcode/tui/themes/gruvbox-dark.toml +44 -0
  55. zettcode/tui/themes/gruvbox-light.toml +44 -0
  56. zettcode/tui/themes/kanagawa.toml +44 -0
  57. zettcode/tui/themes/nord.toml +44 -0
  58. zettcode/tui/themes/one-dark.toml +44 -0
  59. zettcode/tui/themes/rose-pine-dawn.toml +44 -0
  60. zettcode/tui/themes/rose-pine.toml +44 -0
  61. zettcode/tui/themes/solarized-dark.toml +44 -0
  62. zettcode/tui/themes/solarized-light.toml +44 -0
  63. zettcode/tui/themes/tokyo-night.toml +44 -0
  64. zettcode/tui/widgets/__init__.py +121 -0
  65. zettcode/tui/widgets/collapsible.py +116 -0
  66. zettcode/tui/widgets/completion.py +211 -0
  67. zettcode/tui/widgets/console.py +442 -0
  68. zettcode/tui/widgets/dialog.py +165 -0
  69. zettcode/tui/widgets/diff.py +236 -0
  70. zettcode/tui/widgets/list.py +293 -0
  71. zettcode/tui/widgets/list_page.py +121 -0
  72. zettcode/tui/widgets/markdown.py +657 -0
  73. zettcode/tui/widgets/progress.py +166 -0
  74. zettcode/tui/widgets/rich_text.py +84 -0
  75. zettcode/tui/widgets/status.py +106 -0
  76. zettcode/tui/widgets/table.py +101 -0
  77. zettcode/tui/widgets/tasks.py +130 -0
  78. zettcode/tui/widgets/text.py +111 -0
  79. zettcode/tui/widgets/textarea.py +671 -0
  80. zettcode/tui/widgets/toast.py +137 -0
  81. zettcode_tui-0.4.0.dist-info/METADATA +20 -0
  82. zettcode_tui-0.4.0.dist-info/RECORD +83 -0
  83. zettcode_tui-0.4.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,422 @@
1
+ """ZettCode's internally implemented terminal UI framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from typing import TYPE_CHECKING
7
+
8
+ #: Where each public name lives; read by :func:`__getattr__` on first use.
9
+ _EXPORTS = {
10
+ "ARROW_DOWN": ".glyphs",
11
+ "ARROW_UP": ".glyphs",
12
+ "Anchor": ".layout",
13
+ "AnyEvent": ".core",
14
+ "AsyncInput": ".input",
15
+ "BAR_EMPTY": ".glyphs",
16
+ "BAR_FULL": ".glyphs",
17
+ "Binding": ".core",
18
+ "Border": ".layout",
19
+ "AnsiBuffer": ".render",
20
+ "Canvas": ".render",
21
+ "Collapsible": ".widgets",
22
+ "ColorDepth": ".render",
23
+ "Column": ".widgets",
24
+ "Command": ".core",
25
+ "CommandRegistry": ".core",
26
+ "Completer": ".widgets",
27
+ "CompletionItem": ".widgets",
28
+ "CompletionPopup": ".widgets",
29
+ "ConsolePanel": ".widgets",
30
+ "Constraints": ".core",
31
+ "DARK": ".core",
32
+ "DONE": ".glyphs",
33
+ "Dialog": ".widgets",
34
+ "DialogAction": ".widgets",
35
+ "DiffLine": ".widgets",
36
+ "DiffSegment": ".widgets",
37
+ "DiffView": ".widgets",
38
+ "DifferentialRenderer": ".render",
39
+ "ELLIPSIS": ".glyphs",
40
+ "EXAMPLE": ".theme_file",
41
+ "EXPANDED": ".glyphs",
42
+ "EdgeInsets": ".core",
43
+ "Event": ".core",
44
+ "EventKind": ".core",
45
+ "EventType": ".input",
46
+ "FAILED": ".glyphs",
47
+ "FocusEvent": ".core",
48
+ "FocusManager": ".core",
49
+ "HBox": ".layout",
50
+ "HEADER": ".glyphs",
51
+ "Host": ".core",
52
+ "InputDecoder": ".input",
53
+ "InputEvent": ".input",
54
+ "KeyEvent": ".core",
55
+ "Keymap": ".core",
56
+ "LIGHT": ".core",
57
+ "LineSource": ".layout",
58
+ "ListItem": ".widgets",
59
+ "ListPage": ".widgets",
60
+ "ListView": ".widgets",
61
+ "MARKER": ".glyphs",
62
+ "Markdown": ".widgets",
63
+ "MarkdownSource": ".widgets",
64
+ "MarkdownView": ".widgets",
65
+ "MouseAction": ".core",
66
+ "MouseEvent": ".core",
67
+ "Overlay": ".layout",
68
+ "OverlaySlot": ".layout",
69
+ "PENDING": ".glyphs",
70
+ "PROMPT": ".glyphs",
71
+ "Padding": ".layout",
72
+ "PasteEvent": ".core",
73
+ "Point": ".core",
74
+ "ProgressBar": ".widgets",
75
+ "RULE": ".glyphs",
76
+ "RUNNING": ".glyphs",
77
+ "Rect": ".core",
78
+ "ResizeEvent": ".core",
79
+ "RichText": ".widgets",
80
+ "Rule": ".widgets",
81
+ "SEPARATOR": ".glyphs",
82
+ "OPTION": ".capabilities",
83
+ "SKIPPED": ".glyphs",
84
+ "SPINNER": ".glyphs",
85
+ "STATUS": ".glyphs",
86
+ "STEERING": ".glyphs",
87
+ "Scheduler": ".core",
88
+ "Screen": ".core",
89
+ "ScreenStack": ".core",
90
+ "ScrollView": ".layout",
91
+ "Size": ".core",
92
+ "Slot": ".layout",
93
+ "Span": ".render",
94
+ "Spinner": ".widgets",
95
+ "StaticLines": ".layout",
96
+ "StatusBar": ".widgets",
97
+ "Style": ".render",
98
+ "Table": ".widgets",
99
+ "TaskPanel": ".widgets",
100
+ "Terminal": ".terminal",
101
+ "TerminalCapabilities": ".capabilities",
102
+ "TerminalRunner": ".runner",
103
+ "Text": ".widgets",
104
+ "TextArea": ".widgets",
105
+ "TextEvent": ".core",
106
+ "TextLine": ".render",
107
+ "Theme": ".core",
108
+ "ThemeFileError": ".theme_file",
109
+ "Toast": ".widgets",
110
+ "ToolTheme": ".core",
111
+ "Track": ".layout",
112
+ "TuiApp": ".core",
113
+ "VBox": ".layout",
114
+ "Widget": ".core",
115
+ "build_unified": ".widgets",
116
+ "centered": ".layout",
117
+ "detect_capabilities": ".capabilities",
118
+ "detect_reduced_motion": ".capabilities",
119
+ "bundled_themes": ".theme_file",
120
+ "file_theme": ".theme_file",
121
+ "ThemeCatalog": ".theme_file",
122
+ "ThemeSource": ".theme_file",
123
+ "RESERVED_THEMES": ".theme_file",
124
+ "THEME_NAME": ".theme_file",
125
+ "event_key": ".core",
126
+ "key_id": ".core",
127
+ "load_theme": ".theme_file",
128
+ "normalize_key": ".core",
129
+ "parse_background": ".terminal",
130
+ "parse_unified": ".widgets",
131
+ "resolve_tracks": ".layout",
132
+ "run_app": ".runner",
133
+ "scheme_named": ".core",
134
+ "theme_from_toml": ".theme_file",
135
+ "theme_named": ".core",
136
+ "theme_names": ".core",
137
+ "title_sequence": ".terminal",
138
+ "walk": ".core",
139
+ "word_diff": ".widgets",
140
+ }
141
+
142
+
143
+ if TYPE_CHECKING: # pragma: no cover - for type checkers, not the runtime
144
+ from .capabilities import OPTION, TerminalCapabilities, detect_capabilities, detect_reduced_motion
145
+ from .core import (
146
+ DARK,
147
+ LIGHT,
148
+ AnyEvent,
149
+ Binding,
150
+ Command,
151
+ CommandRegistry,
152
+ Constraints,
153
+ EdgeInsets,
154
+ Event,
155
+ EventKind,
156
+ FocusEvent,
157
+ FocusManager,
158
+ Host,
159
+ KeyEvent,
160
+ Keymap,
161
+ MouseAction,
162
+ MouseEvent,
163
+ PasteEvent,
164
+ Point,
165
+ Rect,
166
+ ResizeEvent,
167
+ Scheduler,
168
+ Screen,
169
+ ScreenStack,
170
+ Size,
171
+ TextEvent,
172
+ Theme,
173
+ ToolTheme,
174
+ TuiApp,
175
+ Widget,
176
+ event_key,
177
+ key_id,
178
+ normalize_key,
179
+ scheme_named,
180
+ theme_named,
181
+ theme_names,
182
+ walk,
183
+ )
184
+ from .glyphs import (
185
+ ARROW_DOWN,
186
+ ARROW_UP,
187
+ BAR_EMPTY,
188
+ BAR_FULL,
189
+ DONE,
190
+ ELLIPSIS,
191
+ EXPANDED,
192
+ FAILED,
193
+ HEADER,
194
+ MARKER,
195
+ PENDING,
196
+ PROMPT,
197
+ RULE,
198
+ RUNNING,
199
+ SEPARATOR,
200
+ SKIPPED,
201
+ SPINNER,
202
+ STATUS,
203
+ STEERING,
204
+ )
205
+ from .input import AsyncInput, EventType, InputDecoder, InputEvent
206
+ from .layout import (
207
+ Anchor,
208
+ Border,
209
+ HBox,
210
+ LineSource,
211
+ Overlay,
212
+ OverlaySlot,
213
+ Padding,
214
+ ScrollView,
215
+ Slot,
216
+ StaticLines,
217
+ Track,
218
+ VBox,
219
+ centered,
220
+ resolve_tracks,
221
+ )
222
+ from .render import AnsiBuffer, Canvas, ColorDepth, DifferentialRenderer, Span, Style, TextLine
223
+ from .runner import TerminalRunner, run_app
224
+ from .terminal import Terminal, parse_background, title_sequence
225
+ from .theme_file import (
226
+ EXAMPLE,
227
+ RESERVED_THEMES,
228
+ THEME_NAME,
229
+ ThemeCatalog,
230
+ ThemeFileError,
231
+ ThemeSource,
232
+ bundled_themes,
233
+ file_theme,
234
+ load_theme,
235
+ theme_from_toml,
236
+ )
237
+ from .widgets import (
238
+ Collapsible,
239
+ Column,
240
+ Completer,
241
+ CompletionItem,
242
+ CompletionPopup,
243
+ ConsolePanel,
244
+ Dialog,
245
+ DialogAction,
246
+ DiffLine,
247
+ DiffSegment,
248
+ DiffView,
249
+ ListItem,
250
+ ListPage,
251
+ ListView,
252
+ Markdown,
253
+ MarkdownSource,
254
+ MarkdownView,
255
+ ProgressBar,
256
+ RichText,
257
+ Rule,
258
+ Spinner,
259
+ StatusBar,
260
+ Table,
261
+ TaskPanel,
262
+ Text,
263
+ TextArea,
264
+ Toast,
265
+ build_unified,
266
+ parse_unified,
267
+ word_diff,
268
+ )
269
+
270
+
271
+ def __getattr__(name: str) -> object:
272
+ """Import the module a public name lives in, the first time it is asked for.
273
+
274
+ This package is a facade over several subpackages, and a caller that wants
275
+ one name should not pay for the rest of them — the TUI alone holds the
276
+ widget library, the Markdown parser, the diff viewer, and the syntax
277
+ scanners. ``__all__`` is still the public surface.
278
+ """
279
+ try:
280
+ module = _EXPORTS[name]
281
+ except KeyError:
282
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
283
+ return getattr(importlib.import_module(module, __name__), name)
284
+
285
+
286
+ def __dir__() -> list[str]:
287
+ """Return the names this package publishes."""
288
+ return sorted(__all__)
289
+
290
+
291
+ __all__ = [
292
+ "AnyEvent",
293
+ "ARROW_DOWN",
294
+ "ARROW_UP",
295
+ "BAR_EMPTY",
296
+ "BAR_FULL",
297
+ "DONE",
298
+ "ELLIPSIS",
299
+ "EXPANDED",
300
+ "FAILED",
301
+ "HEADER",
302
+ "MARKER",
303
+ "OPTION",
304
+ "PENDING",
305
+ "PROMPT",
306
+ "RULE",
307
+ "RUNNING",
308
+ "SEPARATOR",
309
+ "SKIPPED",
310
+ "SPINNER",
311
+ "STATUS",
312
+ "STEERING",
313
+ "Anchor",
314
+ "AsyncInput",
315
+ "TuiApp",
316
+ "Binding",
317
+ "Border",
318
+ "AnsiBuffer",
319
+ "Canvas",
320
+ "Collapsible",
321
+ "ColorDepth",
322
+ "Column",
323
+ "Command",
324
+ "CommandRegistry",
325
+ "Completer",
326
+ "CompletionItem",
327
+ "CompletionPopup",
328
+ "ConsolePanel",
329
+ "Constraints",
330
+ "DARK",
331
+ "DifferentialRenderer",
332
+ "Dialog",
333
+ "DialogAction",
334
+ "DiffLine",
335
+ "DiffSegment",
336
+ "DiffView",
337
+ "EdgeInsets",
338
+ "Event",
339
+ "EventKind",
340
+ "EventType",
341
+ "FocusEvent",
342
+ "FocusManager",
343
+ "HBox",
344
+ "Host",
345
+ "InputDecoder",
346
+ "InputEvent",
347
+ "KeyEvent",
348
+ "Keymap",
349
+ "LIGHT",
350
+ "LineSource",
351
+ "ListItem",
352
+ "ListPage",
353
+ "ListView",
354
+ "Markdown",
355
+ "MarkdownSource",
356
+ "MarkdownView",
357
+ "MouseAction",
358
+ "MouseEvent",
359
+ "Overlay",
360
+ "OverlaySlot",
361
+ "Padding",
362
+ "PasteEvent",
363
+ "Point",
364
+ "ProgressBar",
365
+ "RichText",
366
+ "Rect",
367
+ "ResizeEvent",
368
+ "Rule",
369
+ "Scheduler",
370
+ "Screen",
371
+ "ScreenStack",
372
+ "ScrollView",
373
+ "Size",
374
+ "Slot",
375
+ "Span",
376
+ "Spinner",
377
+ "StaticLines",
378
+ "StatusBar",
379
+ "Style",
380
+ "Table",
381
+ "TaskPanel",
382
+ "Terminal",
383
+ "TerminalRunner",
384
+ "TerminalCapabilities",
385
+ "Text",
386
+ "TextArea",
387
+ "TextEvent",
388
+ "TextLine",
389
+ "Theme",
390
+ "ThemeFileError",
391
+ "Toast",
392
+ "Track",
393
+ "VBox",
394
+ "Widget",
395
+ "build_unified",
396
+ "EXAMPLE",
397
+ "detect_capabilities",
398
+ "bundled_themes",
399
+ "file_theme",
400
+ "RESERVED_THEMES",
401
+ "THEME_NAME",
402
+ "ThemeCatalog",
403
+ "ThemeSource",
404
+ "detect_reduced_motion",
405
+ "centered",
406
+ "event_key",
407
+ "key_id",
408
+ "load_theme",
409
+ "normalize_key",
410
+ "parse_unified",
411
+ "resolve_tracks",
412
+ "run_app",
413
+ "theme_named",
414
+ "title_sequence",
415
+ "scheme_named",
416
+ "parse_background",
417
+ "ToolTheme",
418
+ "theme_names",
419
+ "theme_from_toml",
420
+ "walk",
421
+ "word_diff",
422
+ ]
@@ -0,0 +1,32 @@
1
+ """The two version shims the framework needs, so `zettcode-tui` depends on nothing of ours.
2
+
3
+ The release notes of the framework say what it is: a widget toolkit that knows nothing of the agent. It needs the
4
+ string-valued enum (the standard library's is 3.11's, and its spelling is what the rows and the theme files rely on) and
5
+ a TOML reader for a theme file. Both are ten lines here rather than an import of another distribution, which would put
6
+ `zett-agent` behind a widget.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from enum import Enum
13
+
14
+ if sys.version_info >= (3, 11):
15
+ import tomllib
16
+ else: # pragma: no cover - the branch only the 3.10 runtime takes
17
+ import tomli as tomllib
18
+
19
+ __all__ = ["StrEnum", "tomllib"]
20
+
21
+
22
+ class StrEnum(str, Enum):
23
+ """A string-valued enum, for the versions before ``enum.StrEnum``.
24
+
25
+ The standard library's version formats as its value, so ``f"{member}"`` is ``"ready"`` rather than
26
+ ``"Activity.READY"``; the rows and the theme files rely on that, so every interpreter uses this class instead of the
27
+ standard one and the wording cannot drift with the version.
28
+ """
29
+
30
+ def __str__(self) -> str:
31
+ """Return the member's value, as ``enum.StrEnum`` does."""
32
+ return str(self.value)
@@ -0,0 +1,107 @@
1
+ """Terminal capability detection used to pick conservative rendering options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+
10
+ from .render.color import ColorDepth
11
+
12
+ #: What the modifier that sends ``alt``-prefixed keys is called on this machine: the Option key on a Mac, Alt
13
+ #: elsewhere. The same physical key sends the same bytes; only its name differs, and a hint that says "alt" to
14
+ #: someone looking at a key marked "option" is a hint they have to translate.
15
+ OPTION = "option" if sys.platform == "darwin" else "alt"
16
+
17
+ _TRUECOLOR_COLORTERM = {"truecolor", "24bit"}
18
+ _MOTION_VARIABLES = ("ZETTCODE_REDUCED_MOTION", "NO_MOTION", "REDUCED_MOTION")
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class TerminalCapabilities:
23
+ """What the attached terminal can be trusted to render.
24
+
25
+ Attributes:
26
+ color_depth: Safest depth the environment advertises.
27
+ unicode: The locale looks like it can encode UTF-8. Advisory for now: no
28
+ widget switches to an ASCII glyph set when it is False.
29
+ mouse: The terminal accepts mouse reporting. Advisory, like ``unicode``.
30
+ bracketed_paste: The terminal wraps pastes in ``ESC[200~`` guards.
31
+ Advisory as well; the decoder accepts them either way.
32
+ width: Initial width guess in cells, replaced on the first resize.
33
+ height: Initial height guess in cells.
34
+ """
35
+
36
+ color_depth: ColorDepth = ColorDepth.ANSI16
37
+ unicode: bool = True
38
+ mouse: bool = True
39
+ bracketed_paste: bool = True
40
+ width: int = 80
41
+ height: int = 24
42
+
43
+ @property
44
+ def truecolor(self) -> bool:
45
+ """Return whether the terminal can be trusted with 24-bit colour."""
46
+ return self.color_depth is ColorDepth.TRUECOLOR
47
+
48
+
49
+ def detect_capabilities(environ: Mapping[str, str] | None = None) -> TerminalCapabilities:
50
+ """Derive capabilities from environment variables.
51
+
52
+ Everything here is a heuristic, so each default errs toward the option that
53
+ still looks correct on a weaker terminal.
54
+
55
+ Args:
56
+ environ: Environment to read; defaults to ``os.environ`` and is
57
+ injectable so tests can describe a terminal without owning one.
58
+ """
59
+ env = os.environ if environ is None else environ
60
+ return TerminalCapabilities(
61
+ color_depth=detect_color_depth(env),
62
+ unicode=detect_unicode(env),
63
+ )
64
+
65
+
66
+ def detect_color_depth(environ: Mapping[str, str] | None = None) -> ColorDepth:
67
+ """Return the safest color depth the environment advertises.
68
+
69
+ Args:
70
+ environ: Environment to read; defaults to ``os.environ``.
71
+ """
72
+ env = os.environ if environ is None else environ
73
+ term = env.get("TERM", "")
74
+ if env.get("NO_COLOR") or term == "dumb":
75
+ return ColorDepth.MONO
76
+ colorterm = env.get("COLORTERM", "").lower()
77
+ if colorterm in _TRUECOLOR_COLORTERM or "truecolor" in term or "direct" in term:
78
+ return ColorDepth.TRUECOLOR
79
+ if "256" in term or colorterm:
80
+ return ColorDepth.ANSI256
81
+ return ColorDepth.ANSI16
82
+
83
+
84
+ def detect_unicode(environ: Mapping[str, str] | None = None) -> bool:
85
+ """Return whether the environment looks like it can encode UTF-8.
86
+
87
+ Args:
88
+ environ: Environment to read; defaults to ``os.environ``. An unset
89
+ locale is treated as UTF-8 capable, because that is the common case
90
+ on modern systems.
91
+ """
92
+ env = os.environ if environ is None else environ
93
+ locale = env.get("LC_ALL") or env.get("LC_CTYPE") or env.get("LANG", "")
94
+ if locale:
95
+ return "utf" in locale.lower()
96
+ return True
97
+
98
+
99
+ def detect_reduced_motion(environ: Mapping[str, str] | None = None) -> bool:
100
+ """Return whether decorative animation should be suppressed.
101
+
102
+ Args:
103
+ environ: Environment to read; defaults to ``os.environ``. Any of
104
+ ``_MOTION_VARIABLES`` being set to a non-empty value wins.
105
+ """
106
+ env = os.environ if environ is None else environ
107
+ return any(env.get(name) for name in _MOTION_VARIABLES)
@@ -0,0 +1,91 @@
1
+ """Where a copy goes: the machine's own clipboard, or the terminal's.
2
+
3
+ A program writes to *a* clipboard, and which one the reader means depends on where the
4
+ program is running. On the reader's own machine the two are the same thing. Over ``ssh`` they
5
+ are not: the program runs on the server, the reader sits at a terminal on another machine, and
6
+ the clipboard they will paste from is that machine's. A system clipboard tool on the server
7
+ reaches the server's clipboard — if it reaches anything at all — and the reader finds nothing
8
+ when they paste.
9
+
10
+ The only way across the connection is the terminal itself. OSC 52 (``ESC ] 52 ; c ; <base64>
11
+ BEL``) asks the terminal to put text on *its* clipboard, and it travels over the same byte
12
+ stream as everything else the program prints, so it arrives wherever the reader is. That is
13
+ why it is the route over ``ssh`` and only a fallback on a local machine: where a system tool
14
+ works, using it never needs the terminal's permission — several terminals ask the reader, or
15
+ refuse by default, before letting a program write their clipboard.
16
+
17
+ This module only decides and encodes. Writing is :func:`zettcode.tui.runner.clipboard_writer`'s
18
+ job, and the shell reads :func:`copy_route` to say truthfully where the text went.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import os
25
+ from collections.abc import Mapping
26
+ from typing import Literal
27
+
28
+ #: Environment variable that overrides the choice: ``system``, ``terminal``, or ``auto``.
29
+ #: Detection reads the environment, and the environment can be wrong — ``sudo`` and some
30
+ #: multiplexers drop the variables, and a forwarded X display makes the server's clipboard
31
+ #: the right one — so the reader can say.
32
+ CLIPBOARD_ENV = "ZETTCODE_CLIPBOARD"
33
+
34
+ #: Variables the SSH server sets for the session it starts; any one of them means the program
35
+ #: is running on the far side of a connection.
36
+ SSH_VARIABLES = ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")
37
+
38
+ CopyRoute = Literal["system", "terminal"]
39
+
40
+
41
+ def is_remote(environ: Mapping[str, str] | None = None) -> bool:
42
+ """Return whether this process was started through an SSH session.
43
+
44
+ Args:
45
+ environ: Environment to read; defaults to ``os.environ``.
46
+ """
47
+ env = os.environ if environ is None else environ
48
+ return any(env.get(name) for name in SSH_VARIABLES)
49
+
50
+
51
+ def copy_route(environ: Mapping[str, str] | None = None) -> CopyRoute:
52
+ """Return which clipboard a copy should reach.
53
+
54
+ ``terminal`` over ``ssh``, ``system`` otherwise, unless :data:`CLIPBOARD_ENV` says
55
+ which. A value that is neither ``system`` nor ``terminal`` — ``auto``, or a typo — leaves
56
+ the detection in charge, because a mistyped override that silently disabled copying would
57
+ be worse than one that is ignored.
58
+
59
+ Args:
60
+ environ: Environment to read; defaults to ``os.environ``.
61
+ """
62
+ env = os.environ if environ is None else environ
63
+ override = env.get(CLIPBOARD_ENV, "").strip().lower()
64
+ if override == "system":
65
+ return "system"
66
+ if override == "terminal":
67
+ return "terminal"
68
+ return "terminal" if is_remote(env) else "system"
69
+
70
+
71
+ def osc52(text: str, environ: Mapping[str, str] | None = None) -> str:
72
+ """Return the escape sequence that asks the terminal to copy ``text``.
73
+
74
+ Inside tmux the sequence is sent twice: bare, which tmux honours when its
75
+ ``set-clipboard`` option is ``on``, and wrapped in tmux's passthrough, which it forwards to
76
+ the outer terminal when ``allow-passthrough`` is on. Which of the two a given tmux accepts
77
+ is a setting the program cannot see, and both carry the same text, so a copy that lands
78
+ twice is the same copy.
79
+
80
+ Args:
81
+ text: What to put on the clipboard.
82
+ environ: Environment to read; defaults to ``os.environ``.
83
+ """
84
+ env = os.environ if environ is None else environ
85
+ payload = base64.b64encode(text.encode()).decode()
86
+ sequence = f"\x1b]52;c;{payload}\x07"
87
+ if env.get("TMUX"):
88
+ # DCS tmux ; <sequence with every ESC doubled> ST: the doubling is how a passthrough
89
+ # tells the escapes it carries from the one that ends it.
90
+ return sequence + "\x1bPtmux;" + sequence.replace("\x1b", "\x1b\x1b") + "\x1b\\"
91
+ return sequence