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,134 @@
1
+ """Input events consumed by widgets, independent from the agent runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import ClassVar, TypeAlias
7
+
8
+ from .._compat import StrEnum
9
+
10
+
11
+ class EventKind(StrEnum):
12
+ """The finite set of input a widget can receive."""
13
+
14
+ KEY = "key"
15
+ TEXT = "text"
16
+ PASTE = "paste"
17
+ MOUSE = "mouse"
18
+ RESIZE = "resize"
19
+ FOCUS = "focus"
20
+
21
+
22
+ class MouseAction(StrEnum):
23
+ """The gesture a mouse event describes."""
24
+
25
+ DOWN = "down"
26
+ UP = "up"
27
+ MOVE = "move"
28
+ SCROLL_UP = "scroll_up"
29
+ SCROLL_DOWN = "scroll_down"
30
+
31
+
32
+ @dataclass(frozen=True, slots=True, kw_only=True)
33
+ class Event:
34
+ """Base class carrying the modifiers shared by every input event.
35
+
36
+ Attributes:
37
+ shift: Shift was held, where the terminal reports it.
38
+ alt: Alt was held; a terminal may instead fold this into ``key``.
39
+ control: Ctrl was held; a terminal may instead fold this into ``key``.
40
+ meta: Meta was held.
41
+ """
42
+
43
+ shift: bool = False
44
+ alt: bool = False
45
+ control: bool = False
46
+ meta: bool = False
47
+
48
+
49
+ @dataclass(frozen=True, slots=True, kw_only=True)
50
+ class KeyEvent(Event):
51
+ """A named key press, such as ``enter``, ``ctrl_c``, or ``escape``.
52
+
53
+ Attributes:
54
+ key: Canonical key name, already lower-cased; a modifier may be folded
55
+ into it (``"ctrl_c"``) or reported through the flags above.
56
+ repeat: The terminal repeated the key while it was held down.
57
+ """
58
+
59
+ kind: ClassVar[EventKind] = EventKind.KEY
60
+ key: str = ""
61
+ repeat: bool = False
62
+
63
+
64
+ @dataclass(frozen=True, slots=True, kw_only=True)
65
+ class TextEvent(Event):
66
+ """One printable character or grapheme cluster.
67
+
68
+ Attributes:
69
+ text: The printable text, with no newline.
70
+ """
71
+
72
+ kind: ClassVar[EventKind] = EventKind.TEXT
73
+ text: str = ""
74
+
75
+
76
+ @dataclass(frozen=True, slots=True, kw_only=True)
77
+ class PasteEvent(Event):
78
+ """A whole bracketed-paste payload delivered as one event.
79
+
80
+ Attributes:
81
+ text: The pasted payload, newlines included.
82
+ """
83
+
84
+ kind: ClassVar[EventKind] = EventKind.PASTE
85
+ text: str = ""
86
+
87
+
88
+ @dataclass(frozen=True, slots=True, kw_only=True)
89
+ class MouseEvent(Event):
90
+ """A pointer gesture at an absolute cell coordinate.
91
+
92
+ Attributes:
93
+ x: Column, zero-based from the left edge of the terminal.
94
+ y: Row, zero-based from the top edge of the terminal.
95
+ button: Button index reported by the terminal; 0 is the primary button.
96
+ Wheel events keep the index they arrived with, so read ``action``
97
+ rather than ``button`` to tell scrolling apart from clicking.
98
+ action: Gesture this event describes.
99
+ """
100
+
101
+ kind: ClassVar[EventKind] = EventKind.MOUSE
102
+ x: int = 0
103
+ y: int = 0
104
+ button: int = 0
105
+ action: MouseAction = MouseAction.MOVE
106
+
107
+
108
+ @dataclass(frozen=True, slots=True, kw_only=True)
109
+ class ResizeEvent(Event):
110
+ """The terminal changed size to the given cell dimensions.
111
+
112
+ Attributes:
113
+ width: New width in cells.
114
+ height: New height in cells.
115
+ """
116
+
117
+ kind: ClassVar[EventKind] = EventKind.RESIZE
118
+ width: int = 0
119
+ height: int = 0
120
+
121
+
122
+ @dataclass(frozen=True, slots=True, kw_only=True)
123
+ class FocusEvent(Event):
124
+ """The terminal gained or lost focus.
125
+
126
+ Attributes:
127
+ focused: ``True`` when the terminal gained focus.
128
+ """
129
+
130
+ kind: ClassVar[EventKind] = EventKind.FOCUS
131
+ focused: bool = True
132
+
133
+
134
+ AnyEvent: TypeAlias = KeyEvent | TextEvent | PasteEvent | MouseEvent | ResizeEvent | FocusEvent
@@ -0,0 +1,85 @@
1
+ """Focus ownership and Tab traversal over a widget tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+
7
+ from .widget import Widget
8
+
9
+
10
+ def walk(widget: Widget) -> Iterator[Widget]:
11
+ """Yield a widget and its descendants in depth-first paint order.
12
+
13
+ Args:
14
+ widget: Subtree root; it is yielded before its children.
15
+ """
16
+ yield widget
17
+ for child in widget.children:
18
+ yield from walk(child)
19
+
20
+
21
+ class FocusManager:
22
+ """Track the focused widget and move focus through the tree."""
23
+
24
+ def __init__(self) -> None:
25
+ """Start with nothing focused."""
26
+ self._focused: Widget | None = None
27
+
28
+ @property
29
+ def focused(self) -> Widget | None:
30
+ """Return the widget that currently owns focus, if any."""
31
+ return self._focused
32
+
33
+ def focus(self, widget: Widget | None) -> None:
34
+ """Move focus, running the blur and focus hooks on a real change.
35
+
36
+ Args:
37
+ widget: New focus target, or ``None`` to clear focus. Re-focusing the
38
+ current widget is a no-op, so the hooks never fire twice.
39
+ """
40
+ if widget is self._focused:
41
+ return
42
+ previous, self._focused = self._focused, widget
43
+ if previous is not None:
44
+ previous.focused = False
45
+ previous.on_blur()
46
+ if widget is not None:
47
+ widget.focused = True
48
+ widget.on_focus()
49
+
50
+ def focusables(self, root: Widget) -> list[Widget]:
51
+ """Return every Tab-reachable widget under root, in traversal order.
52
+
53
+ Args:
54
+ root: Subtree to scan; the root itself is included when it opts in
55
+ through ``focusable``.
56
+ """
57
+ return [widget for widget in walk(root) if widget.focusable]
58
+
59
+ def first(self, root: Widget) -> Widget | None:
60
+ """Return the first Tab-reachable widget, or the root as a fallback.
61
+
62
+ Args:
63
+ root: Subtree to scan.
64
+ """
65
+ candidates = self.focusables(root)
66
+ return candidates[0] if candidates else root
67
+
68
+ def move(self, root: Widget, direction: int = 1) -> Widget | None:
69
+ """Move focus by one stop, wrapping at either end.
70
+
71
+ Args:
72
+ root: Subtree that defines the Tab scope, usually the top screen.
73
+ direction: Positive moves forward, negative moves backward. When the
74
+ current widget is outside the scope, forward starts at the first
75
+ stop and backward at the last.
76
+ """
77
+ candidates = self.focusables(root)
78
+ if not candidates:
79
+ return None
80
+ if self._focused in candidates:
81
+ index = (candidates.index(self._focused) + direction) % len(candidates)
82
+ else:
83
+ index = 0 if direction > 0 else len(candidates) - 1
84
+ self.focus(candidates[index])
85
+ return candidates[index]
@@ -0,0 +1,212 @@
1
+ """Geometry primitives shared by layout and widgets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Point:
10
+ """One absolute terminal coordinate measured in cells."""
11
+
12
+ x: int = 0
13
+ y: int = 0
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Size:
18
+ """A width and height measured in terminal cells."""
19
+
20
+ width: int = 0
21
+ height: int = 0
22
+
23
+ @property
24
+ def area(self) -> int:
25
+ """Return the number of cells, treating a negative extent as empty."""
26
+ return max(0, self.width) * max(0, self.height)
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class EdgeInsets:
31
+ """Spacing applied to the four sides of a rectangle."""
32
+
33
+ top: int = 0
34
+ right: int = 0
35
+ bottom: int = 0
36
+ left: int = 0
37
+
38
+ @classmethod
39
+ def all(cls, value: int = 0) -> EdgeInsets:
40
+ """Return equal spacing on every side.
41
+
42
+ Args:
43
+ value: Cells applied to top, right, bottom, and left.
44
+ """
45
+ return cls(value, value, value, value)
46
+
47
+ @classmethod
48
+ def symmetric(cls, *, vertical: int = 0, horizontal: int = 0) -> EdgeInsets:
49
+ """Return spacing mirrored across both axes.
50
+
51
+ Args:
52
+ vertical: Cells for the top and bottom together.
53
+ horizontal: Cells for the left and right together.
54
+ """
55
+ return cls(vertical, horizontal, vertical, horizontal)
56
+
57
+ @property
58
+ def vertical(self) -> int:
59
+ """Return the top and bottom spacing combined."""
60
+ return self.top + self.bottom
61
+
62
+ @property
63
+ def horizontal(self) -> int:
64
+ """Return the left and right spacing combined."""
65
+ return self.left + self.right
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class Rect:
70
+ """An absolute rectangle clamped to the terminal canvas."""
71
+
72
+ x: int
73
+ y: int
74
+ width: int
75
+ height: int
76
+
77
+ @property
78
+ def right(self) -> int:
79
+ """Return the exclusive right edge."""
80
+ return self.x + self.width
81
+
82
+ @property
83
+ def bottom(self) -> int:
84
+ """Return the exclusive bottom edge."""
85
+ return self.y + self.height
86
+
87
+ @property
88
+ def size(self) -> Size:
89
+ """Return the rectangle's extent as a Size."""
90
+ return Size(self.width, self.height)
91
+
92
+ @property
93
+ def empty(self) -> bool:
94
+ """Return whether the rectangle covers no cells."""
95
+ return self.width <= 0 or self.height <= 0
96
+
97
+ def contains(self, x: int, y: int) -> bool:
98
+ """Return whether one cell falls inside this rectangle."""
99
+ return self.x <= x < self.right and self.y <= y < self.bottom
100
+
101
+ def inset(self, edges: EdgeInsets | int) -> Rect:
102
+ """Shrink the rectangle by the given spacing, never below empty.
103
+
104
+ Args:
105
+ edges: Spacing removed on each side; an int is applied to all four,
106
+ and the result is clamped so width and height never go negative.
107
+ """
108
+ if isinstance(edges, int):
109
+ edges = EdgeInsets.all(edges)
110
+ return Rect(
111
+ self.x + edges.left,
112
+ self.y + edges.top,
113
+ max(0, self.width - edges.horizontal),
114
+ max(0, self.height - edges.vertical),
115
+ )
116
+
117
+ def translate(self, dx: int, dy: int) -> Rect:
118
+ """Move the rectangle without resizing it.
119
+
120
+ Args:
121
+ dx: Cells to move right; negative moves left.
122
+ dy: Cells to move down; negative moves up.
123
+ """
124
+ return Rect(self.x + dx, self.y + dy, self.width, self.height)
125
+
126
+ def intersection(self, other: Rect) -> Rect:
127
+ """Return the overlapping area, or an empty rectangle."""
128
+ x = max(self.x, other.x)
129
+ y = max(self.y, other.y)
130
+ width = max(0, min(self.right, other.right) - x)
131
+ height = max(0, min(self.bottom, other.bottom) - y)
132
+ return Rect(x, y, width, height)
133
+
134
+ def union(self, other: Rect) -> Rect:
135
+ """Return the smallest rectangle covering both inputs."""
136
+ if self.empty:
137
+ return other
138
+ if other.empty:
139
+ return self
140
+ x = min(self.x, other.x)
141
+ y = min(self.y, other.y)
142
+ return Rect(x, y, max(self.right, other.right) - x, max(self.bottom, other.bottom) - y)
143
+
144
+
145
+ @dataclass(frozen=True, slots=True)
146
+ class Constraints:
147
+ """The size range a widget may choose from during measurement."""
148
+
149
+ min_width: int = 0
150
+ max_width: int | None = None
151
+ min_height: int = 0
152
+ max_height: int | None = None
153
+
154
+ @classmethod
155
+ def tight(cls, size: Size) -> Constraints:
156
+ """Return constraints that force exactly one size.
157
+
158
+ Args:
159
+ size: The only size a child may report.
160
+ """
161
+ return cls(size.width, size.width, size.height, size.height)
162
+
163
+ @classmethod
164
+ def loose(cls, size: Size) -> Constraints:
165
+ """Return constraints that allow anything up to one size.
166
+
167
+ Args:
168
+ size: Upper bound per axis; both minimums stay at zero.
169
+ """
170
+ return cls(0, size.width, 0, size.height)
171
+
172
+ @classmethod
173
+ def unbounded(cls) -> Constraints:
174
+ """Return constraints with no upper bound."""
175
+ return cls()
176
+
177
+ def constrain(self, size: Size) -> Size:
178
+ """Clamp a desired size into the allowed range.
179
+
180
+ Args:
181
+ size: Size a child asked for; each axis is clamped independently.
182
+ """
183
+ return Size(
184
+ self._clamp(size.width, self.min_width, self.max_width),
185
+ self._clamp(size.height, self.min_height, self.max_height),
186
+ )
187
+
188
+ def deflate(self, edges: EdgeInsets) -> Constraints:
189
+ """Remove space reserved for padding from every bound.
190
+
191
+ Args:
192
+ edges: Space taken by a padding or border, subtracted from the
193
+ minimum and the maximum on each axis; bounds stop at zero and an
194
+ unbounded maximum stays unbounded.
195
+ """
196
+ return Constraints(
197
+ max(0, self.min_width - edges.horizontal),
198
+ self._shrink(self.max_width, edges.horizontal),
199
+ max(0, self.min_height - edges.vertical),
200
+ self._shrink(self.max_height, edges.vertical),
201
+ )
202
+
203
+ @staticmethod
204
+ def _clamp(value: int, minimum: int, maximum: int | None) -> int:
205
+ """Clamp a value into ``minimum..maximum``, where None means unbounded."""
206
+ value = max(value, minimum)
207
+ return value if maximum is None else min(value, max(minimum, maximum))
208
+
209
+ @staticmethod
210
+ def _shrink(value: int | None, amount: int) -> int | None:
211
+ """Subtract an amount from an optional upper bound."""
212
+ return None if value is None else max(0, value - amount)
@@ -0,0 +1,66 @@
1
+ """Services a running widget tree exposes to the widgets inside it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from .widget import Widget
10
+
11
+
12
+ class Host(ABC):
13
+ """The application-side capabilities widgets may call.
14
+
15
+ Widgets never reach for the terminal directly. Everything they need from
16
+ the outside world, from moving focus to requesting a repaint, arrives
17
+ through this interface so the same tree can run headless in tests.
18
+
19
+ This is an abstract base class rather than a protocol: an implementation is
20
+ expected to subclass it and answer every method, which is what `TuiApp`
21
+ does, and what keeps a missing implementation a loud error instead of a
22
+ class that merely looks compatible.
23
+ """
24
+
25
+ @abstractmethod
26
+ def focus(self, widget: Widget | None) -> None:
27
+ """Move keyboard focus to a widget, or clear it with ``None``."""
28
+
29
+ @abstractmethod
30
+ def focused_widget(self) -> Widget | None:
31
+ """Return the widget that currently receives keyboard input."""
32
+
33
+ @abstractmethod
34
+ def invalidate(self) -> None:
35
+ """Ask for a repaint on the next frame."""
36
+
37
+ @abstractmethod
38
+ def request_layout(self) -> None:
39
+ """Ask for a fresh measure before the next paint."""
40
+
41
+ @abstractmethod
42
+ def copy(self, text: str) -> None:
43
+ """Place text on the clipboard."""
44
+
45
+ def screen_selection_text(self) -> str:
46
+ """Return the text a drag has marked on the frame, if any.
47
+
48
+ The default is nothing: a host that does not select the frame itself
49
+ still answers, so a widget can ask without knowing which host it has.
50
+ """
51
+ return ""
52
+
53
+ def clear_screen_selection(self) -> None:
54
+ """Drop the frame selection a drag left behind, if the host keeps one.
55
+
56
+ The default does nothing, which suits a host that never had one.
57
+ """
58
+ return None
59
+
60
+ @abstractmethod
61
+ def refresh(self) -> None:
62
+ """Repaint from scratch, discarding any retained frame."""
63
+
64
+ @abstractmethod
65
+ def exit(self) -> None:
66
+ """Stop the application loop."""