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.
- zettcode/tui/__init__.py +422 -0
- zettcode/tui/_compat.py +32 -0
- zettcode/tui/capabilities.py +107 -0
- zettcode/tui/clipboard.py +91 -0
- zettcode/tui/console.py +229 -0
- zettcode/tui/core/__init__.py +63 -0
- zettcode/tui/core/app.py +658 -0
- zettcode/tui/core/events.py +134 -0
- zettcode/tui/core/focus.py +85 -0
- zettcode/tui/core/geometry.py +212 -0
- zettcode/tui/core/host.py +66 -0
- zettcode/tui/core/keymap.py +218 -0
- zettcode/tui/core/scheduler.py +105 -0
- zettcode/tui/core/screen.py +80 -0
- zettcode/tui/core/theme.py +248 -0
- zettcode/tui/core/widget.py +204 -0
- zettcode/tui/gallery.py +588 -0
- zettcode/tui/glyphs.py +73 -0
- zettcode/tui/input/__init__.py +18 -0
- zettcode/tui/input/bridge.py +46 -0
- zettcode/tui/input/decoder.py +234 -0
- zettcode/tui/input/events.py +64 -0
- zettcode/tui/input/keys.py +289 -0
- zettcode/tui/input/reader.py +147 -0
- zettcode/tui/layout/__init__.py +24 -0
- zettcode/tui/layout/box.py +153 -0
- zettcode/tui/layout/overlay.py +164 -0
- zettcode/tui/layout/scroll.py +477 -0
- zettcode/tui/layout/solver.py +95 -0
- zettcode/tui/layout/spacing.py +137 -0
- zettcode/tui/py.typed +0 -0
- zettcode/tui/render/__init__.py +71 -0
- zettcode/tui/render/ansi.py +371 -0
- zettcode/tui/render/canvas.py +153 -0
- zettcode/tui/render/code.py +456 -0
- zettcode/tui/render/color.py +177 -0
- zettcode/tui/render/renderer.py +151 -0
- zettcode/tui/render/rich_text.py +80 -0
- zettcode/tui/render/style.py +121 -0
- zettcode/tui/render/sweep.py +87 -0
- zettcode/tui/render/text.py +319 -0
- zettcode/tui/runner.py +316 -0
- zettcode/tui/terminal.py +222 -0
- zettcode/tui/testing/__init__.py +6 -0
- zettcode/tui/testing/harness.py +220 -0
- zettcode/tui/testing/snapshot.py +64 -0
- zettcode/tui/theme_file.py +300 -0
- zettcode/tui/themes/ayu-mirage.toml +44 -0
- zettcode/tui/themes/catppuccin-latte.toml +44 -0
- zettcode/tui/themes/catppuccin-mocha.toml +44 -0
- zettcode/tui/themes/dracula.toml +44 -0
- zettcode/tui/themes/github-dimmed.toml +44 -0
- zettcode/tui/themes/github-light.toml +44 -0
- zettcode/tui/themes/gruvbox-dark.toml +44 -0
- zettcode/tui/themes/gruvbox-light.toml +44 -0
- zettcode/tui/themes/kanagawa.toml +44 -0
- zettcode/tui/themes/nord.toml +44 -0
- zettcode/tui/themes/one-dark.toml +44 -0
- zettcode/tui/themes/rose-pine-dawn.toml +44 -0
- zettcode/tui/themes/rose-pine.toml +44 -0
- zettcode/tui/themes/solarized-dark.toml +44 -0
- zettcode/tui/themes/solarized-light.toml +44 -0
- zettcode/tui/themes/tokyo-night.toml +44 -0
- zettcode/tui/widgets/__init__.py +121 -0
- zettcode/tui/widgets/collapsible.py +116 -0
- zettcode/tui/widgets/completion.py +211 -0
- zettcode/tui/widgets/console.py +442 -0
- zettcode/tui/widgets/dialog.py +165 -0
- zettcode/tui/widgets/diff.py +236 -0
- zettcode/tui/widgets/list.py +293 -0
- zettcode/tui/widgets/list_page.py +121 -0
- zettcode/tui/widgets/markdown.py +657 -0
- zettcode/tui/widgets/progress.py +166 -0
- zettcode/tui/widgets/rich_text.py +84 -0
- zettcode/tui/widgets/status.py +106 -0
- zettcode/tui/widgets/table.py +101 -0
- zettcode/tui/widgets/tasks.py +130 -0
- zettcode/tui/widgets/text.py +111 -0
- zettcode/tui/widgets/textarea.py +671 -0
- zettcode/tui/widgets/toast.py +137 -0
- zettcode_tui-0.4.0.dist-info/METADATA +20 -0
- zettcode_tui-0.4.0.dist-info/RECORD +83 -0
- 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."""
|