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,165 @@
|
|
|
1
|
+
"""A framed modal body with a row of selectable actions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from ..core.events import KeyEvent
|
|
9
|
+
from ..core.geometry import Constraints, Rect, Size
|
|
10
|
+
from ..core.host import Host
|
|
11
|
+
from ..core.widget import Widget
|
|
12
|
+
from ..layout import Border, Slot, VBox
|
|
13
|
+
from ..render import Canvas, Style
|
|
14
|
+
from ..render.text import display_width
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class DialogAction:
|
|
19
|
+
"""One button in a dialog.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
label: Text drawn on the button.
|
|
23
|
+
run: Called when the action is activated; ``None`` renders a button that
|
|
24
|
+
cannot be committed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
label: str
|
|
28
|
+
run: Callable[[], None] | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _ActionRow(Widget):
|
|
32
|
+
"""Internal row that paints the dialog buttons."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, dialog: Dialog) -> None:
|
|
35
|
+
"""Bind the row to the dialog that owns the actions."""
|
|
36
|
+
super().__init__()
|
|
37
|
+
self.dialog = dialog
|
|
38
|
+
|
|
39
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
40
|
+
"""Claim a minimal cell; the parent box supplies the real width."""
|
|
41
|
+
return constraints.constrain(Size(1, 1))
|
|
42
|
+
|
|
43
|
+
def render(self, canvas: Canvas) -> None:
|
|
44
|
+
"""Paint the actions along the last row of the assigned rectangle."""
|
|
45
|
+
if self.rect.empty:
|
|
46
|
+
return
|
|
47
|
+
theme = self.theme
|
|
48
|
+
y = self.rect.y + self.rect.height - 1
|
|
49
|
+
x = self.rect.x
|
|
50
|
+
for index, action in enumerate(self.dialog.actions):
|
|
51
|
+
if x >= self.rect.x + self.rect.width:
|
|
52
|
+
break
|
|
53
|
+
label = f" {action.label} "
|
|
54
|
+
selected = index == self.dialog.action_index
|
|
55
|
+
style = (
|
|
56
|
+
Style(foreground=theme.surface, background=theme.accent, bold=True)
|
|
57
|
+
if selected
|
|
58
|
+
else Style(foreground=theme.muted)
|
|
59
|
+
)
|
|
60
|
+
canvas.draw_text(x, y, label, style, max_width=self.rect.x + self.rect.width - x)
|
|
61
|
+
x += display_width(label) + 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Dialog(Widget):
|
|
65
|
+
"""A bordered body plus actions, meant to live on a modal screen.
|
|
66
|
+
|
|
67
|
+
Shape::
|
|
68
|
+
|
|
69
|
+
+-- Run this command? --------+ <- frame, title embedded in the top edge
|
|
70
|
+
|rm -rf build/ | <- body, takes the flexible rows
|
|
71
|
+
| |
|
|
72
|
+
| Run Always Abort | <- actions on the last row, one highlighted
|
|
73
|
+
+-----------------------------+
|
|
74
|
+
|
|
75
|
+
The frame uses box-drawing glyphs and clears its own rectangle before
|
|
76
|
+
drawing, so a dialog over another screen is opaque. Left and Right move the
|
|
77
|
+
highlight, Enter runs it, Escape cancels when ``on_cancel`` is given.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
body: Widget | None = None,
|
|
83
|
+
*,
|
|
84
|
+
title: str = "",
|
|
85
|
+
actions: Sequence[DialogAction] = (),
|
|
86
|
+
on_cancel: Callable[[], None] | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Wrap the body and the action row in a border, with or without a body.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
body: Content above the buttons; ``None`` leaves a dialog of pure
|
|
92
|
+
actions, which is still laid out with the same frame.
|
|
93
|
+
title: Drawn into the top border when the dialog is wide enough.
|
|
94
|
+
actions: Buttons in focus order; the first one starts highlighted.
|
|
95
|
+
on_cancel: Called on Escape when one is provided.
|
|
96
|
+
"""
|
|
97
|
+
super().__init__()
|
|
98
|
+
self.body = body
|
|
99
|
+
self.actions = tuple(actions)
|
|
100
|
+
self.action_index = 0
|
|
101
|
+
self.on_cancel = on_cancel
|
|
102
|
+
row = _ActionRow(self)
|
|
103
|
+
inner = VBox([Slot(body, flex=1), Slot(row, size=1)]) if body is not None else VBox([Slot(row, flex=1)])
|
|
104
|
+
self.frame = Border(inner, title=title)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def focusable(self) -> bool:
|
|
108
|
+
"""Take focus so the arrow keys and Enter drive the actions."""
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def children(self) -> tuple[Widget, ...]:
|
|
113
|
+
"""Expose the bordered frame."""
|
|
114
|
+
return (self.frame,)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def current_action(self) -> DialogAction | None:
|
|
118
|
+
"""Return the highlighted action, or None when there are no actions."""
|
|
119
|
+
if 0 <= self.action_index < len(self.actions):
|
|
120
|
+
return self.actions[self.action_index]
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def activate(self) -> bool:
|
|
124
|
+
"""Run the selected action, if it has a handler."""
|
|
125
|
+
action = self.current_action
|
|
126
|
+
if action is None or action.run is None:
|
|
127
|
+
return False
|
|
128
|
+
action.run()
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
132
|
+
"""Measure the frame, which measures the body in turn."""
|
|
133
|
+
return self.frame.measure(constraints)
|
|
134
|
+
|
|
135
|
+
def layout(self, rect: Rect) -> None:
|
|
136
|
+
"""Give the frame the whole assigned rectangle."""
|
|
137
|
+
super().layout(rect)
|
|
138
|
+
self.frame.layout(rect)
|
|
139
|
+
|
|
140
|
+
def render(self, canvas: Canvas) -> None:
|
|
141
|
+
"""Paint the frame and everything inside it."""
|
|
142
|
+
self.frame.render(canvas)
|
|
143
|
+
|
|
144
|
+
def handle(self, event, host: Host) -> bool:
|
|
145
|
+
"""Move between actions, run one, or cancel the dialog."""
|
|
146
|
+
if not isinstance(event, KeyEvent) or host.focused_widget() is not self:
|
|
147
|
+
return False
|
|
148
|
+
match event.key:
|
|
149
|
+
case "left" | "backtab":
|
|
150
|
+
self._move(-1)
|
|
151
|
+
case "right" | "tab":
|
|
152
|
+
self._move(1)
|
|
153
|
+
case "enter":
|
|
154
|
+
return self.activate()
|
|
155
|
+
case "escape" if self.on_cancel is not None:
|
|
156
|
+
self.on_cancel()
|
|
157
|
+
return True
|
|
158
|
+
case _:
|
|
159
|
+
return False
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
def _move(self, amount: int) -> None:
|
|
163
|
+
"""Move the highlight, wrapping at both ends."""
|
|
164
|
+
if self.actions:
|
|
165
|
+
self.action_index = (self.action_index + amount) % len(self.actions)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Unified diff parsing with word-level emphasis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from difflib import SequenceMatcher
|
|
8
|
+
from difflib import unified_diff as _unified_diff
|
|
9
|
+
|
|
10
|
+
from ..core.geometry import Constraints, Size
|
|
11
|
+
from ..core.widget import Widget
|
|
12
|
+
from ..render import Canvas, Style
|
|
13
|
+
from ..render.text import display_width
|
|
14
|
+
|
|
15
|
+
HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
16
|
+
TOKEN = re.compile(r"\w+|\s+|[^\w\s]")
|
|
17
|
+
META_PREFIXES = ("diff ", "index ", "--- ", "+++ ", "\\ No newline", "old mode", "new mode", "similarity index")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class DiffSegment:
|
|
22
|
+
"""One run of a diff line, marked when it is what actually changed."""
|
|
23
|
+
|
|
24
|
+
text: str
|
|
25
|
+
emphasis: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class DiffLine:
|
|
30
|
+
"""One rendered diff row in unified-diff order."""
|
|
31
|
+
|
|
32
|
+
kind: str
|
|
33
|
+
text: str
|
|
34
|
+
segments: tuple[DiffSegment, ...] = ()
|
|
35
|
+
old_number: int | None = None
|
|
36
|
+
new_number: int | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def word_diff(old: str, new: str) -> tuple[tuple[DiffSegment, ...], tuple[DiffSegment, ...]]:
|
|
40
|
+
"""Return both sides tokenized, marking the tokens that differ.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
old: Text of the removed line.
|
|
44
|
+
new: Text of the added line it is paired with.
|
|
45
|
+
"""
|
|
46
|
+
old_tokens = TOKEN.findall(old)
|
|
47
|
+
new_tokens = TOKEN.findall(new)
|
|
48
|
+
matcher = SequenceMatcher(None, old_tokens, new_tokens, autojunk=False)
|
|
49
|
+
old_segments: list[DiffSegment] = []
|
|
50
|
+
new_segments: list[DiffSegment] = []
|
|
51
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
52
|
+
changed = tag != "equal"
|
|
53
|
+
old_segments.append(DiffSegment("".join(old_tokens[i1:i2]), changed))
|
|
54
|
+
new_segments.append(DiffSegment("".join(new_tokens[j1:j2]), changed))
|
|
55
|
+
return _merge(old_segments), _merge(new_segments)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_unified(text: str) -> list[DiffLine]:
|
|
59
|
+
"""Parse unified-diff text, pairing removals with additions by position."""
|
|
60
|
+
lines: list[DiffLine] = []
|
|
61
|
+
removals: list[DiffLine] = []
|
|
62
|
+
additions: list[DiffLine] = []
|
|
63
|
+
old_number: int | None = None
|
|
64
|
+
new_number: int | None = None
|
|
65
|
+
|
|
66
|
+
def flush() -> None:
|
|
67
|
+
"""Pair buffered removals with additions, then emit both blocks."""
|
|
68
|
+
nonlocal removals, additions
|
|
69
|
+
if not removals and not additions:
|
|
70
|
+
return
|
|
71
|
+
for index in range(min(len(removals), len(additions))):
|
|
72
|
+
old_segments, new_segments = word_diff(removals[index].text, additions[index].text)
|
|
73
|
+
removals[index] = replace(removals[index], segments=old_segments)
|
|
74
|
+
additions[index] = replace(additions[index], segments=new_segments)
|
|
75
|
+
for line in removals:
|
|
76
|
+
lines.append(line if line.segments else replace(line, segments=(DiffSegment(line.text, True),)))
|
|
77
|
+
for line in additions:
|
|
78
|
+
lines.append(line if line.segments else replace(line, segments=(DiffSegment(line.text, True),)))
|
|
79
|
+
removals, additions = [], []
|
|
80
|
+
|
|
81
|
+
for raw in text.splitlines():
|
|
82
|
+
if raw.startswith(META_PREFIXES):
|
|
83
|
+
flush()
|
|
84
|
+
lines.append(DiffLine("meta", raw))
|
|
85
|
+
continue
|
|
86
|
+
hunk = HUNK.match(raw)
|
|
87
|
+
if hunk:
|
|
88
|
+
flush()
|
|
89
|
+
old_number = int(hunk.group(1))
|
|
90
|
+
new_number = int(hunk.group(3))
|
|
91
|
+
lines.append(DiffLine("hunk", raw))
|
|
92
|
+
continue
|
|
93
|
+
if raw.startswith("+"):
|
|
94
|
+
additions.append(DiffLine("add", raw[1:], new_number=new_number))
|
|
95
|
+
new_number = None if new_number is None else new_number + 1
|
|
96
|
+
continue
|
|
97
|
+
if raw.startswith("-"):
|
|
98
|
+
removals.append(DiffLine("remove", raw[1:], old_number=old_number))
|
|
99
|
+
old_number = None if old_number is None else old_number + 1
|
|
100
|
+
continue
|
|
101
|
+
flush()
|
|
102
|
+
content = raw[1:] if raw.startswith(" ") else raw
|
|
103
|
+
lines.append(DiffLine("context", content, old_number=old_number, new_number=new_number))
|
|
104
|
+
old_number = None if old_number is None else old_number + 1
|
|
105
|
+
new_number = None if new_number is None else new_number + 1
|
|
106
|
+
flush()
|
|
107
|
+
return lines
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_unified(
|
|
111
|
+
old: str,
|
|
112
|
+
new: str,
|
|
113
|
+
*,
|
|
114
|
+
fromfile: str = "a",
|
|
115
|
+
tofile: str = "b",
|
|
116
|
+
context: int = 3,
|
|
117
|
+
) -> list[DiffLine]:
|
|
118
|
+
"""Diff two texts and return parsed diff rows.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
old: Original text; trailing newlines are ignored.
|
|
122
|
+
new: Replacement text.
|
|
123
|
+
fromfile: Label of the original side in the ``---`` header line.
|
|
124
|
+
tofile: Label of the replacement side in the ``+++`` header line.
|
|
125
|
+
context: Unchanged lines kept around each hunk.
|
|
126
|
+
"""
|
|
127
|
+
# difflib appends its own terminator to header lines, so the terminator has
|
|
128
|
+
# to be suppressed before joining: a stray blank line would otherwise parse
|
|
129
|
+
# as a context row and shift every following line number.
|
|
130
|
+
text = "\n".join(_unified_diff(old.splitlines(), new.splitlines(), fromfile, tofile, n=context, lineterm=""))
|
|
131
|
+
return parse_unified(text)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class DiffView(Widget):
|
|
135
|
+
"""Paint unified-diff rows with a gutter and word-level emphasis.
|
|
136
|
+
|
|
137
|
+
Shape::
|
|
138
|
+
|
|
139
|
+
--- a
|
|
140
|
+
+++ b
|
|
141
|
+
@@ -1,5 +1,2 @@ <- hunk headers, dim
|
|
142
|
+
1 1 def total(items): <- old and new line numbers
|
|
143
|
+
2 - value = 0 <- removed
|
|
144
|
+
2 + return sum(...) <- added
|
|
145
|
+
|
|
146
|
+
The gutter is reserved only when the diff carries line numbers. Removed and
|
|
147
|
+
added lines are paired by position, and only the tokens that actually differ
|
|
148
|
+
get the selection colour, which is what makes a one-word change readable.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, lines: tuple[DiffLine, ...] | list[DiffLine] = ()) -> None:
|
|
152
|
+
"""Store the diff rows; the widget itself only paints them.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
lines: Rows in unified-diff order, as ``parse_unified`` returns them.
|
|
156
|
+
"""
|
|
157
|
+
super().__init__()
|
|
158
|
+
self.lines = tuple(lines)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_text(cls, text: str) -> DiffView:
|
|
162
|
+
"""Build a view from unified-diff text."""
|
|
163
|
+
return cls(parse_unified(text))
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def between(cls, old: str, new: str, *, context: int = 3) -> DiffView:
|
|
167
|
+
"""Diff two texts with difflib and wrap the result.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
old: Original text; trailing newlines are ignored.
|
|
171
|
+
new: Replacement text.
|
|
172
|
+
context: Unchanged lines kept around each hunk.
|
|
173
|
+
"""
|
|
174
|
+
return cls(build_unified(old, new, context=context))
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def gutter(self) -> int:
|
|
178
|
+
"""Reserve line numbers only when the diff actually carries them."""
|
|
179
|
+
if any(line.old_number is not None or line.new_number is not None for line in self.lines):
|
|
180
|
+
return 11
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
184
|
+
"""Ask for the widest row plus the gutter and the sign column."""
|
|
185
|
+
widest = max((display_width(line.text) + self.gutter + 1 for line in self.lines), default=0)
|
|
186
|
+
return constraints.constrain(Size(widest, len(self.lines)))
|
|
187
|
+
|
|
188
|
+
def render(self, canvas: Canvas) -> None:
|
|
189
|
+
"""Paint each row with its line numbers, sign, and emphasised segments."""
|
|
190
|
+
if self.rect.empty:
|
|
191
|
+
return
|
|
192
|
+
theme = self.theme
|
|
193
|
+
styles = {
|
|
194
|
+
"add": Style(foreground=theme.accent, bold=True),
|
|
195
|
+
"remove": Style(foreground=theme.error, bold=True),
|
|
196
|
+
"hunk": Style(foreground=theme.accent_bright, dim=True),
|
|
197
|
+
"meta": Style(foreground=theme.muted, dim=True),
|
|
198
|
+
"context": Style(foreground=theme.subtle),
|
|
199
|
+
}
|
|
200
|
+
gutter = self.gutter
|
|
201
|
+
limit = self.rect.x + self.rect.width
|
|
202
|
+
for row, line in enumerate(self.lines[: self.rect.height]):
|
|
203
|
+
y = self.rect.y + row
|
|
204
|
+
x = self.rect.x
|
|
205
|
+
style = styles.get(line.kind, styles["context"])
|
|
206
|
+
if gutter:
|
|
207
|
+
old = "" if line.old_number is None else str(line.old_number)
|
|
208
|
+
new = "" if line.new_number is None else str(line.new_number)
|
|
209
|
+
x += canvas.draw_text(
|
|
210
|
+
x,
|
|
211
|
+
y,
|
|
212
|
+
f"{old:>4} {new:>4} ",
|
|
213
|
+
Style(foreground=theme.muted, dim=True),
|
|
214
|
+
max_width=gutter,
|
|
215
|
+
)
|
|
216
|
+
sign = {"add": "+", "remove": "-", "context": " "}.get(line.kind, " ")
|
|
217
|
+
x += canvas.draw_text(x, y, sign, style, max_width=max(0, limit - x))
|
|
218
|
+
segments = line.segments or (DiffSegment(line.text),)
|
|
219
|
+
for segment in segments:
|
|
220
|
+
if x >= limit:
|
|
221
|
+
break
|
|
222
|
+
segment_style = replace(style, background=theme.selection, bold=True) if segment.emphasis else style
|
|
223
|
+
x += canvas.draw_text(x, y, segment.text, segment_style, max_width=limit - x)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _merge(segments: list[DiffSegment]) -> tuple[DiffSegment, ...]:
|
|
227
|
+
"""Join adjacent runs that share an emphasis flag."""
|
|
228
|
+
merged: list[DiffSegment] = []
|
|
229
|
+
for segment in segments:
|
|
230
|
+
if not segment.text:
|
|
231
|
+
continue
|
|
232
|
+
if merged and merged[-1].emphasis == segment.emphasis:
|
|
233
|
+
merged[-1] = DiffSegment(merged[-1].text + segment.text, segment.emphasis)
|
|
234
|
+
else:
|
|
235
|
+
merged.append(segment)
|
|
236
|
+
return tuple(merged)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Selectable list with keyboard navigation and a scrolling window."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from zettcode.tui import MARKER
|
|
9
|
+
|
|
10
|
+
from ..core.events import KeyEvent, MouseAction, MouseEvent
|
|
11
|
+
from ..core.geometry import Constraints, Size
|
|
12
|
+
from ..core.host import Host
|
|
13
|
+
from ..core.theme import Theme
|
|
14
|
+
from ..core.widget import Widget
|
|
15
|
+
from ..render import Canvas, Style, truncate
|
|
16
|
+
from ..render.text import display_width
|
|
17
|
+
|
|
18
|
+
#: Text and the colour to draw it in; ``None`` leaves the colour the row would have had.
|
|
19
|
+
Part = tuple[str, str | None]
|
|
20
|
+
|
|
21
|
+
#: Pieces of a line in the active theme's colours, which only the theme can say.
|
|
22
|
+
Parts = Callable[[Theme], Sequence[Part]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def draw_parts(canvas: Canvas, x: int, y: int, parts: Sequence[Part], style: Style, *, max_width: int) -> int:
|
|
26
|
+
"""Draw coloured pieces one after another and return the columns used.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
canvas: Where to paint.
|
|
30
|
+
x: Column of the first piece.
|
|
31
|
+
y: Row.
|
|
32
|
+
parts: Text and foreground colour of each piece, in order; a ``None`` colour keeps ``style``'s.
|
|
33
|
+
style: Everything but the foreground: background, weight.
|
|
34
|
+
max_width: Column budget for all the pieces together; the one that does not fit is cut, the rest dropped.
|
|
35
|
+
"""
|
|
36
|
+
used = 0
|
|
37
|
+
for text, colour in parts:
|
|
38
|
+
room = max_width - used
|
|
39
|
+
if room <= 0:
|
|
40
|
+
break
|
|
41
|
+
piece = style if colour is None else Style(foreground=colour, background=style.background, bold=style.bold)
|
|
42
|
+
used += canvas.draw_text(x + used, y, truncate(text, room), piece, max_width=room)
|
|
43
|
+
return used
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class ListItem:
|
|
48
|
+
"""One row of a ListView.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
value: Opaque payload handed back to the callbacks.
|
|
52
|
+
label: Left-hand text of the row.
|
|
53
|
+
description: Secondary text drawn after the label when space allows.
|
|
54
|
+
disabled: Skipped by navigation and refused by ``activate``.
|
|
55
|
+
parts: The same description in colours. It must read as ``description`` when its pieces are joined, which
|
|
56
|
+
is what sizes the column; ``None`` draws ``description`` in the muted colour.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
value: object = None
|
|
60
|
+
label: str = ""
|
|
61
|
+
description: str = ""
|
|
62
|
+
disabled: bool = False
|
|
63
|
+
parts: Parts | None = field(default=None, compare=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ListView(Widget):
|
|
67
|
+
"""Show rows and let one of them be selected.
|
|
68
|
+
|
|
69
|
+
The window is an absolute row index so the list never rebuilds itself, and
|
|
70
|
+
the selection is always kept inside the visible window.
|
|
71
|
+
|
|
72
|
+
Shape::
|
|
73
|
+
|
|
74
|
+
> alpha first <- selected: marker, bold, selection band
|
|
75
|
+
beta second <- descriptions share one column
|
|
76
|
+
gamma <- disabled: dim, no marker, refuses Enter
|
|
77
|
+
delta fourth
|
|
78
|
+
|
|
79
|
+
Only ``rect.height`` rows are drawn, starting at ``top``; the marker is part
|
|
80
|
+
of the label (U+25B8 selected, two spaces otherwise) and the muted
|
|
81
|
+
description keeps the row's background so the selection band stays
|
|
82
|
+
continuous. Descriptions start in one column, the width of the widest label
|
|
83
|
+
plus a gap, measured in cells so a CJK label counts double; when the row is
|
|
84
|
+
too narrow for that column the description falls back to sitting one cell
|
|
85
|
+
after its own label, and a label with a description is truncated to the
|
|
86
|
+
column so the two can never collide.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
MARKERS = (f"{MARKER} ", " ")
|
|
90
|
+
#: Cells the marker column occupies, and the gap before the description column.
|
|
91
|
+
MARKER_WIDTH = 2
|
|
92
|
+
COLUMN_GAP = 2
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
items: Sequence[ListItem] = (),
|
|
97
|
+
*,
|
|
98
|
+
on_select: Callable[[ListItem], None] | None = None,
|
|
99
|
+
on_highlight: Callable[[ListItem], None] | None = None,
|
|
100
|
+
wrap: bool = True,
|
|
101
|
+
band: bool = False,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Configure the rows, the callbacks, and whether the selection wraps.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
items: Initial rows; ``set_items`` replaces them wholesale.
|
|
107
|
+
on_select: Called with the row when Enter or ``activate`` commits it.
|
|
108
|
+
on_highlight: Called only when the selection actually moves, which
|
|
109
|
+
makes it safe to use for previewing the highlighted row.
|
|
110
|
+
wrap: Wrap from the last row to the first and back.
|
|
111
|
+
band: Fill the entire highlighted row, not just the label.
|
|
112
|
+
"""
|
|
113
|
+
super().__init__()
|
|
114
|
+
self._items = tuple(items)
|
|
115
|
+
self.on_select = on_select
|
|
116
|
+
self.on_highlight = on_highlight
|
|
117
|
+
self.wrap = wrap
|
|
118
|
+
self.band = band
|
|
119
|
+
self.selected = 0 if self._items else -1
|
|
120
|
+
self.top = 0
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def focusable(self) -> bool:
|
|
124
|
+
"""Let Tab land on the list so the arrow keys work without a click."""
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def items(self) -> tuple[ListItem, ...]:
|
|
129
|
+
"""Return the rows currently shown."""
|
|
130
|
+
return self._items
|
|
131
|
+
|
|
132
|
+
def set_items(self, items: Sequence[ListItem]) -> None:
|
|
133
|
+
"""Replace the rows and reset the selection to the top."""
|
|
134
|
+
self._items = tuple(items)
|
|
135
|
+
self.selected = 0 if self._items else -1
|
|
136
|
+
self.top = 0
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def current(self) -> ListItem | None:
|
|
140
|
+
"""Return the selected row, or None when the list is empty."""
|
|
141
|
+
if 0 <= self.selected < len(self._items):
|
|
142
|
+
return self._items[self.selected]
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def select(self, index: int, *, notify: bool = True) -> None:
|
|
146
|
+
"""Move the selection to a row, skipping disabled entries.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
index: Target row, clamped to the list; a disabled row is refused.
|
|
150
|
+
notify: Call ``on_highlight`` when the row actually changes. Pass
|
|
151
|
+
False for programmatic resets that should stay silent.
|
|
152
|
+
"""
|
|
153
|
+
if not self._items:
|
|
154
|
+
self.selected = -1
|
|
155
|
+
return
|
|
156
|
+
index = min(max(0, index), len(self._items) - 1)
|
|
157
|
+
if self._items[index].disabled:
|
|
158
|
+
return
|
|
159
|
+
changed = index != self.selected
|
|
160
|
+
self.selected = index
|
|
161
|
+
if changed and notify and self.on_highlight is not None:
|
|
162
|
+
self.on_highlight(self._items[index])
|
|
163
|
+
|
|
164
|
+
def move(self, amount: int) -> None:
|
|
165
|
+
"""Move the selection, wrapping when configured to."""
|
|
166
|
+
if not self._items:
|
|
167
|
+
return
|
|
168
|
+
count = len(self._items)
|
|
169
|
+
index = self.selected + amount
|
|
170
|
+
if self.wrap:
|
|
171
|
+
index %= count
|
|
172
|
+
else:
|
|
173
|
+
index = min(max(0, index), count - 1)
|
|
174
|
+
step = 1 if amount >= 0 else -1
|
|
175
|
+
for offset in range(count):
|
|
176
|
+
candidate = (index + offset * step) % count
|
|
177
|
+
if not self._items[candidate].disabled:
|
|
178
|
+
self.select(candidate)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
def activate(self) -> bool:
|
|
182
|
+
"""Fire the select callback for the current row."""
|
|
183
|
+
item = self.current
|
|
184
|
+
if item is None or item.disabled or self.on_select is None:
|
|
185
|
+
return False
|
|
186
|
+
self.on_select(item)
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
190
|
+
"""Ask for the label column, the gap, the widest description, and a row each."""
|
|
191
|
+
column = max((display_width(item.label) for item in self._items), default=0)
|
|
192
|
+
description = max((display_width(item.description) for item in self._items), default=0)
|
|
193
|
+
width = self.MARKER_WIDTH + column
|
|
194
|
+
if description:
|
|
195
|
+
width += self.COLUMN_GAP + description
|
|
196
|
+
return constraints.constrain(Size(width, len(self._items)))
|
|
197
|
+
|
|
198
|
+
def render(self, canvas: Canvas) -> None:
|
|
199
|
+
"""Scroll the selection into view, then paint the visible rows."""
|
|
200
|
+
if not self._items or self.rect.height <= 0:
|
|
201
|
+
return
|
|
202
|
+
self._ensure_visible()
|
|
203
|
+
theme = self.theme
|
|
204
|
+
# One column for every label, so descriptions line up down the list
|
|
205
|
+
# instead of starting wherever each label happens to end.
|
|
206
|
+
column = max((display_width(item.label) for item in self._items), default=0)
|
|
207
|
+
for row in range(self.rect.height):
|
|
208
|
+
index = self.top + row
|
|
209
|
+
if index >= len(self._items):
|
|
210
|
+
break
|
|
211
|
+
item = self._items[index]
|
|
212
|
+
selected = index == self.selected
|
|
213
|
+
marker = self.MARKERS[0] if selected else self.MARKERS[1]
|
|
214
|
+
if item.disabled:
|
|
215
|
+
style = Style(foreground=theme.muted, dim=True)
|
|
216
|
+
elif selected:
|
|
217
|
+
style = Style(foreground=theme.text, background=theme.selection, bold=True)
|
|
218
|
+
else:
|
|
219
|
+
style = Style(foreground=theme.text, background=theme.surface_alt if self.band else None)
|
|
220
|
+
if selected and self.band:
|
|
221
|
+
canvas.fill(self.rect.x, self.rect.y + row, self.rect.width, 1, style)
|
|
222
|
+
aligned = self.MARKER_WIDTH + column + self.COLUMN_GAP
|
|
223
|
+
room = self.rect.width - aligned
|
|
224
|
+
# Without room for a second column, the description falls back to
|
|
225
|
+
# one space after the label, as it did before the column existed.
|
|
226
|
+
inline = display_width(marker + item.label) + 1
|
|
227
|
+
start = aligned if room > 3 else inline
|
|
228
|
+
limit = self.rect.width if not item.description else max(0, start - 1)
|
|
229
|
+
label = truncate(marker + item.label, limit)
|
|
230
|
+
canvas.draw_text(self.rect.x, self.rect.y + row, label, style, max_width=self.rect.width)
|
|
231
|
+
if item.description:
|
|
232
|
+
# Columns, not code points: a label of CJK characters is twice
|
|
233
|
+
# as wide as it is long, and counting its length would pull the
|
|
234
|
+
# description left by one column per wide glyph.
|
|
235
|
+
room = self.rect.width - start
|
|
236
|
+
if room > 3:
|
|
237
|
+
quiet = Style(foreground=theme.muted, background=style.background)
|
|
238
|
+
if item.parts is None or item.disabled:
|
|
239
|
+
canvas.draw_text(
|
|
240
|
+
self.rect.x + start,
|
|
241
|
+
self.rect.y + row,
|
|
242
|
+
truncate(item.description, room),
|
|
243
|
+
quiet,
|
|
244
|
+
max_width=room,
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
draw_parts(
|
|
248
|
+
canvas,
|
|
249
|
+
self.rect.x + start,
|
|
250
|
+
self.rect.y + row,
|
|
251
|
+
item.parts(theme),
|
|
252
|
+
Style(background=style.background, bold=False),
|
|
253
|
+
max_width=room,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def handle(self, event, host: Host) -> bool:
|
|
257
|
+
"""Select the clicked row, or move with the arrow and page keys."""
|
|
258
|
+
if isinstance(event, MouseEvent) and self.rect.contains(event.x, event.y):
|
|
259
|
+
if event.action is MouseAction.DOWN:
|
|
260
|
+
self.select(self.top + event.y - self.rect.y)
|
|
261
|
+
host.focus(self)
|
|
262
|
+
return True
|
|
263
|
+
return False
|
|
264
|
+
if not isinstance(event, KeyEvent) or host.focused_widget() is not self:
|
|
265
|
+
return False
|
|
266
|
+
page = max(1, self.rect.height - 1)
|
|
267
|
+
match event.key:
|
|
268
|
+
case "up":
|
|
269
|
+
self.move(-1)
|
|
270
|
+
case "down":
|
|
271
|
+
self.move(1)
|
|
272
|
+
case "page_up":
|
|
273
|
+
self.move(-page)
|
|
274
|
+
case "page_down":
|
|
275
|
+
self.move(page)
|
|
276
|
+
case "home":
|
|
277
|
+
self.select(0)
|
|
278
|
+
case "end":
|
|
279
|
+
self.select(len(self._items) - 1)
|
|
280
|
+
case "enter":
|
|
281
|
+
return self.activate()
|
|
282
|
+
case _:
|
|
283
|
+
return False
|
|
284
|
+
return True
|
|
285
|
+
|
|
286
|
+
def _ensure_visible(self) -> None:
|
|
287
|
+
"""Scroll the window so the selected row stays on screen."""
|
|
288
|
+
height = max(1, self.rect.height)
|
|
289
|
+
if self.selected < self.top:
|
|
290
|
+
self.top = self.selected
|
|
291
|
+
elif self.selected >= self.top + height:
|
|
292
|
+
self.top = self.selected - height + 1
|
|
293
|
+
self.top = min(max(0, self.top), max(0, len(self._items) - height))
|