pi-libtui 0.3.2
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.
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +55 -0
- package/src/activity-presentation.ts +75 -0
- package/src/appearance.ts +632 -0
- package/src/background-surface.ts +125 -0
- package/src/color/palette.ts +186 -0
- package/src/color/preview.ts +157 -0
- package/src/color/resolver.ts +75 -0
- package/src/color/theme.ts +531 -0
- package/src/component-stack.ts +247 -0
- package/src/content/markdown-code.ts +60 -0
- package/src/content/terminal-text.ts +135 -0
- package/src/content/text.ts +105 -0
- package/src/controls/action-panel.ts +420 -0
- package/src/controls/dialog-button-bar.ts +287 -0
- package/src/controls/multi-select.ts +467 -0
- package/src/controls/picker-panel.ts +447 -0
- package/src/controls/screen-icon-actions.ts +134 -0
- package/src/controls/searchable-select.ts +394 -0
- package/src/controls/select-box.ts +224 -0
- package/src/controls/selectable-list.ts +433 -0
- package/src/controls/selection-action-bar.ts +382 -0
- package/src/controls/semantic-input.ts +23 -0
- package/src/controls/tab.ts +189 -0
- package/src/cursor.ts +288 -0
- package/src/decoration/editor-pills.ts +156 -0
- package/src/decoration/glyphs.ts +326 -0
- package/src/decoration/pointer-interaction.ts +123 -0
- package/src/decoration/powerline-pill.ts +104 -0
- package/src/decoration/status.ts +185 -0
- package/src/decoration/transient-pill.ts +176 -0
- package/src/diff/index.ts +21 -0
- package/src/diff/model.ts +76 -0
- package/src/diff/parse.ts +310 -0
- package/src/diff/pierre-syntax.ts +86 -0
- package/src/diff/render.ts +504 -0
- package/src/diff/structured.ts +264 -0
- package/src/diff/view.ts +75 -0
- package/src/editor/chrome.ts +341 -0
- package/src/editor/composition.ts +53 -0
- package/src/editor/layout.ts +81 -0
- package/src/editor/presentation.ts +35 -0
- package/src/editor/protocol.ts +188 -0
- package/src/editor.ts +41 -0
- package/src/extension.ts +30 -0
- package/src/folding.ts +192 -0
- package/src/host/cursor-bridge.ts +210 -0
- package/src/host/editor-bridge.ts +148 -0
- package/src/host/extension-host.ts +231 -0
- package/src/host/mouse-bridge.ts +212 -0
- package/src/host/mouse-dispatch.ts +400 -0
- package/src/host/mouse-input.ts +85 -0
- package/src/host/mouse-targets.ts +238 -0
- package/src/host/pi-layout-adapter.ts +298 -0
- package/src/host/pi-native-selection.ts +227 -0
- package/src/host/split-pane-bridge.ts +720 -0
- package/src/host/transcript-bridge.ts +236 -0
- package/src/index.ts +334 -0
- package/src/line-layout.ts +6 -0
- package/src/motion.ts +1409 -0
- package/src/mouse/events.ts +55 -0
- package/src/mouse/fullscreen-layout.ts +260 -0
- package/src/mouse/registry.ts +348 -0
- package/src/mouse.ts +37 -0
- package/src/native-binary.ts +189 -0
- package/src/overlay/anchored.ts +91 -0
- package/src/overlay/detail-card.ts +158 -0
- package/src/overlay/dialog.ts +266 -0
- package/src/overlay/floating.ts +106 -0
- package/src/overlay/framed-editor.ts +236 -0
- package/src/overlay/fullscreen.ts +170 -0
- package/src/overlay/hover-tooltip.ts +109 -0
- package/src/overlay/modal-mount.ts +118 -0
- package/src/panels.ts +207 -0
- package/src/render-cache.ts +80 -0
- package/src/render-epoch.ts +48 -0
- package/src/request-animation.ts +160 -0
- package/src/scrollbar.ts +27 -0
- package/src/selection.ts +148 -0
- package/src/split-pane.ts +232 -0
- package/src/status-presentation.ts +411 -0
- package/src/stream.ts +543 -0
- package/src/syntax.ts +316 -0
- package/src/terminal/bridge-client.ts +243 -0
- package/src/terminal/embedding.ts +6 -0
- package/src/terminal/output.ts +151 -0
- package/src/terminal/projection.ts +362 -0
- package/src/terminal/pty-host.ts +263 -0
- package/src/terminal/pty-pane.ts +308 -0
- package/src/terminal-colors.ts +334 -0
- package/src/tool/action.ts +170 -0
- package/src/tool/activity.ts +500 -0
- package/src/tool/call-preview.ts +44 -0
- package/src/tool/disclosure-action.ts +89 -0
- package/src/tool/expanded-region-viewport.ts +103 -0
- package/src/tool/index.ts +26 -0
- package/src/tool/output.ts +271 -0
- package/src/tool/transcript.ts +150 -0
- package/src/tool/view-region.ts +581 -0
- package/test/appearance.test.ts +183 -0
- package/test/bounded-stream.test.ts +150 -0
- package/test/bridge-client.test.ts +42 -0
- package/test/color-preview.test.ts +23 -0
- package/test/component-stack.test.ts +206 -0
- package/test/components.test.ts +1165 -0
- package/test/content-text.test.ts +21 -0
- package/test/cursor-bridge.test.ts +151 -0
- package/test/cursor.test.ts +147 -0
- package/test/editor-chrome.test.ts +119 -0
- package/test/editor-layout.test.ts +30 -0
- package/test/editor-registry.test.ts +203 -0
- package/test/editor-theme.test.ts +33 -0
- package/test/editor-token-pill.test.ts +72 -0
- package/test/floating-overlay.test.ts +57 -0
- package/test/folding.test.ts +147 -0
- package/test/harmonious-theme.test.ts +23 -0
- package/test/interaction.test.ts +104 -0
- package/test/key-icons.test.ts +62 -0
- package/test/markdown-code.test.ts +24 -0
- package/test/modal-overlay-mount.test.ts +94 -0
- package/test/motion.test.ts +649 -0
- package/test/mouse/bridge.test.ts +1302 -0
- package/test/mouse/extension.test.ts +205 -0
- package/test/mouse/registry.test.ts +140 -0
- package/test/mouse/screen-decoration.test.ts +167 -0
- package/test/native-binary.test.ts +52 -0
- package/test/palette.test.ts +32 -0
- package/test/panels.test.ts +74 -0
- package/test/pointer-components.test.ts +151 -0
- package/test/progress.test.ts +54 -0
- package/test/pty-host.test.ts +126 -0
- package/test/pty-pane.test.ts +62 -0
- package/test/public-surface.test.ts +75 -0
- package/test/render-cache.test.ts +25 -0
- package/test/render-epoch.test.ts +119 -0
- package/test/request-animation.test.ts +193 -0
- package/test/screen-icon-actions.test.ts +69 -0
- package/test/select-box.test.ts +86 -0
- package/test/select-pointer.test.ts +369 -0
- package/test/selectable-list.test.ts +258 -0
- package/test/selection/registry.test.ts +44 -0
- package/test/selection-action-bar.test.ts +204 -0
- package/test/split-pane-bridge.test.ts +944 -0
- package/test/split-pane.test.ts +134 -0
- package/test/status-presentation.test.ts +39 -0
- package/test/syntax.test.ts +59 -0
- package/test/terminal-colors.test.ts +115 -0
- package/test/terminal-embedding.test.ts +12 -0
- package/test/terminal-projection.test.ts +312 -0
- package/test/theme-colors.test.ts +175 -0
- package/test/tool-output.test.ts +160 -0
- package/test/tool-transcript.test.ts +1190 -0
- package/test/unified-diff.test.ts +691 -0
- package/themes/harmonious.json +61 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { type Component, getKeybindings, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { TuiMouseEvent } from "../mouse.ts";
|
|
3
|
+
|
|
4
|
+
/** State and layout supplied to a selectable-list item renderer. */
|
|
5
|
+
export interface SelectableListRenderContext {
|
|
6
|
+
/** Available row width in terminal cells. */
|
|
7
|
+
width: number;
|
|
8
|
+
/** Zero-based item index in the complete list. */
|
|
9
|
+
index: number;
|
|
10
|
+
/** Whether keyboard navigation currently selects this item. */
|
|
11
|
+
selected: boolean;
|
|
12
|
+
/** Whether the pointer currently hovers this item's target. */
|
|
13
|
+
hovered: boolean;
|
|
14
|
+
/** Whether the preceding item is selected, for stable shared boundaries. */
|
|
15
|
+
previousSelected: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Interactive terminal-cell geometry for one visible list item. */
|
|
19
|
+
export interface SelectableListItemGeometry {
|
|
20
|
+
/** Zero-based target column relative to the list. */
|
|
21
|
+
x: number;
|
|
22
|
+
/** Zero-based target row relative to the list. */
|
|
23
|
+
y: number;
|
|
24
|
+
/** Interactive target width in terminal cells. */
|
|
25
|
+
width: number;
|
|
26
|
+
/** Interactive target height in terminal rows. */
|
|
27
|
+
height: number;
|
|
28
|
+
/** Zero-based index in the complete item list. */
|
|
29
|
+
index: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Geometry for the most recent selectable-list render. */
|
|
33
|
+
export interface SelectableListGeometry {
|
|
34
|
+
/** Zero-based list column relative to its owning component. */
|
|
35
|
+
x: number;
|
|
36
|
+
/** Zero-based list row relative to its owning component. */
|
|
37
|
+
y: number;
|
|
38
|
+
/** Rendered list width in terminal cells. */
|
|
39
|
+
width: number;
|
|
40
|
+
/** Rendered list height in terminal rows. */
|
|
41
|
+
height: number;
|
|
42
|
+
/** Index of the first item visible in the current viewport. */
|
|
43
|
+
startIndex: number;
|
|
44
|
+
/** Rendered row offset of the first visible item. */
|
|
45
|
+
startRow: number;
|
|
46
|
+
/** Total rendered rows across every item. */
|
|
47
|
+
totalRows: number;
|
|
48
|
+
/** Interactive geometry for each visible item. */
|
|
49
|
+
items: readonly SelectableListItemGeometry[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One semantic selectable row with optional non-selectable layout around it. */
|
|
53
|
+
export interface SelectableListRow {
|
|
54
|
+
/** Decorative lines rendered before the selectable row. */
|
|
55
|
+
before?: readonly string[];
|
|
56
|
+
/** Decorative columns rendered before, but excluded from, the selectable row. */
|
|
57
|
+
leading?: string;
|
|
58
|
+
/** Selectable row content rendered after any excluded leading columns. */
|
|
59
|
+
content: string | readonly string[];
|
|
60
|
+
/** Optional pointer target relative to the content rows. Defaults to all content after `leading`. */
|
|
61
|
+
target?: { x: number; y: number; width: number; height: number };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Construction options for {@link SelectableList}. */
|
|
65
|
+
export interface SelectableListOptions<Item> {
|
|
66
|
+
/** Complete ordered item list. */
|
|
67
|
+
items: readonly Item[];
|
|
68
|
+
/** Initially selected index; clamped to the available items. */
|
|
69
|
+
selectedIndex?: number;
|
|
70
|
+
/** Maximum rendered lines. One oversized item may exceed this so it remains a complete target. */
|
|
71
|
+
maxVisible?: number;
|
|
72
|
+
/** Keyboard navigation wraps by default. */
|
|
73
|
+
wrap?: boolean;
|
|
74
|
+
/** Pointer release activates by default. Disable when click should only select. */
|
|
75
|
+
activateOnClick?: boolean;
|
|
76
|
+
/** Render one semantic item and identify any decorative layout around it. */
|
|
77
|
+
renderItem(item: Item, context: SelectableListRenderContext): string | string[] | SelectableListRow;
|
|
78
|
+
/** Request a host render after interaction or synchronized state changes. */
|
|
79
|
+
requestRender(): void;
|
|
80
|
+
/** Receive selection changes caused by keyboard or pointer input. */
|
|
81
|
+
onSelectionChange?(item: Item, index: number): void;
|
|
82
|
+
/** Receive item activation from confirmation or pointer release. */
|
|
83
|
+
onActivate(item: Item, index: number): void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A domain-free list with semantic selection and activation. */
|
|
87
|
+
export class SelectableList<Item> implements Component {
|
|
88
|
+
private items: readonly Item[];
|
|
89
|
+
private selectedIndex: number;
|
|
90
|
+
private maxVisible: number | undefined;
|
|
91
|
+
private hoverIndex: number | undefined;
|
|
92
|
+
private pressedIndex: number | undefined;
|
|
93
|
+
private viewportStart = 0;
|
|
94
|
+
private followSelection = true;
|
|
95
|
+
private geometry: SelectableListGeometry | undefined;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create a selectable list.
|
|
99
|
+
* @param config Items, rendering policy, interaction callbacks, and viewport bounds.
|
|
100
|
+
*/
|
|
101
|
+
constructor(private readonly config: SelectableListOptions<Item>) {
|
|
102
|
+
this.items = config.items;
|
|
103
|
+
this.selectedIndex = clampIndex(config.selectedIndex ?? 0, this.items.length);
|
|
104
|
+
this.maxVisible = config.maxVisible;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handle navigation and activation keybindings.
|
|
109
|
+
* @param data Raw terminal input from Pi.
|
|
110
|
+
* @returns True when the list consumes the input.
|
|
111
|
+
*/
|
|
112
|
+
handleInput(data: string): boolean {
|
|
113
|
+
const keybindings = getKeybindings();
|
|
114
|
+
if (data === "j" || keybindings.matches(data, "tui.select.down")) {
|
|
115
|
+
this.move(1, this.config.wrap !== false);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (data === "k" || keybindings.matches(data, "tui.select.up")) {
|
|
119
|
+
this.move(-1, this.config.wrap !== false);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (keybindings.matches(data, "tui.select.confirm")) {
|
|
123
|
+
this.activate(this.selectedIndex);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Handle pointer input when the shared component host routes it to this list.
|
|
131
|
+
* @param event Component-local pointer event.
|
|
132
|
+
* @returns True when the event targets an item or performs wheel navigation.
|
|
133
|
+
*/
|
|
134
|
+
onMouse(event: TuiMouseEvent): boolean {
|
|
135
|
+
if (event.type === "leave") {
|
|
136
|
+
const changed = this.hoverIndex !== undefined || this.pressedIndex !== undefined;
|
|
137
|
+
this.hoverIndex = undefined;
|
|
138
|
+
this.pressedIndex = undefined;
|
|
139
|
+
if (changed) this.config.requestRender();
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const geometry = this.geometry;
|
|
144
|
+
if (!geometry) return false;
|
|
145
|
+
const inside = event.col >= 0 && event.col < geometry.width && event.row >= 0 && event.row < geometry.height;
|
|
146
|
+
if (!inside) {
|
|
147
|
+
this.updateHover(undefined);
|
|
148
|
+
if (event.type === "release") this.pressedIndex = undefined;
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const item = geometry.items.find(
|
|
153
|
+
(candidate) =>
|
|
154
|
+
event.col >= candidate.x &&
|
|
155
|
+
event.col < candidate.x + candidate.width &&
|
|
156
|
+
event.row >= candidate.y &&
|
|
157
|
+
event.row < candidate.y + candidate.height,
|
|
158
|
+
);
|
|
159
|
+
this.updateHover(item?.index);
|
|
160
|
+
if (event.type === "wheel" && event.wheel !== undefined) {
|
|
161
|
+
this.scrollViewport(event.wheel);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (event.type === "press" && event.button === 0) {
|
|
165
|
+
this.pressedIndex = item?.index;
|
|
166
|
+
if (item) this.select(item.index, true);
|
|
167
|
+
} else if (event.type === "release" && event.button === 0) {
|
|
168
|
+
const pressed = this.pressedIndex;
|
|
169
|
+
this.pressedIndex = undefined;
|
|
170
|
+
if (item && pressed === item.index && this.config.activateOnClick !== false) this.activate(item.index);
|
|
171
|
+
}
|
|
172
|
+
return item !== undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Replace list contents and optionally synchronize selection without firing a callback.
|
|
177
|
+
* @param items New complete ordered item list.
|
|
178
|
+
* @param selectedIndex Selected index for the new list; defaults to the current index.
|
|
179
|
+
*/
|
|
180
|
+
setItems(items: readonly Item[], selectedIndex = this.selectedIndex): void {
|
|
181
|
+
this.items = items;
|
|
182
|
+
this.selectedIndex = clampIndex(selectedIndex, items.length);
|
|
183
|
+
this.hoverIndex = undefined;
|
|
184
|
+
this.pressedIndex = undefined;
|
|
185
|
+
this.viewportStart = Math.min(this.viewportStart, Math.max(0, items.length - 1));
|
|
186
|
+
this.followSelection = true;
|
|
187
|
+
this.geometry = undefined;
|
|
188
|
+
this.config.requestRender();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Synchronize external selection without firing a selection callback.
|
|
193
|
+
* @param index New selected index, clamped to the available items.
|
|
194
|
+
*/
|
|
195
|
+
setSelectedIndex(index: number): void {
|
|
196
|
+
const next = clampIndex(index, this.items.length);
|
|
197
|
+
const changed = next !== this.selectedIndex || !this.followSelection;
|
|
198
|
+
this.selectedIndex = next;
|
|
199
|
+
this.followSelection = true;
|
|
200
|
+
if (changed) this.config.requestRender();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Update the rendered line budget when a parent layout gains or loses fixed rows.
|
|
205
|
+
* @param maxVisible Maximum rendered lines, or undefined for no explicit budget.
|
|
206
|
+
*/
|
|
207
|
+
setMaxVisible(maxVisible: number | undefined): void {
|
|
208
|
+
if (this.maxVisible === maxVisible) return;
|
|
209
|
+
this.maxVisible = maxVisible;
|
|
210
|
+
this.geometry = undefined;
|
|
211
|
+
this.config.requestRender();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @returns The current selected index, or -1 when the list is empty. */
|
|
215
|
+
getSelectedIndex(): number {
|
|
216
|
+
return this.selectedIndex;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @returns The selected item, or undefined when the list is empty. */
|
|
220
|
+
getSelectedItem(): Item | undefined {
|
|
221
|
+
return this.items[this.selectedIndex];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @returns A defensive copy of the latest list geometry, if rendered. */
|
|
225
|
+
getGeometry(): SelectableListGeometry | undefined {
|
|
226
|
+
if (!this.geometry) return undefined;
|
|
227
|
+
return {
|
|
228
|
+
...this.geometry,
|
|
229
|
+
items: this.geometry.items.map((item) => ({ ...item })),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Clear cached interactive geometry after external state changes. */
|
|
234
|
+
invalidate(): void {
|
|
235
|
+
this.geometry = undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Render the bounded viewport and update interactive item geometry.
|
|
240
|
+
* @param width Available width in terminal cells.
|
|
241
|
+
* @returns Visible item rows, or no rows for an empty or zero-width list.
|
|
242
|
+
*/
|
|
243
|
+
render(width: number): string[] {
|
|
244
|
+
const boundedWidth = Math.max(0, Math.floor(width));
|
|
245
|
+
if (boundedWidth === 0 || this.items.length === 0) {
|
|
246
|
+
this.geometry = {
|
|
247
|
+
x: 0,
|
|
248
|
+
y: 0,
|
|
249
|
+
width: boundedWidth,
|
|
250
|
+
height: 0,
|
|
251
|
+
startIndex: 0,
|
|
252
|
+
startRow: 0,
|
|
253
|
+
totalRows: 0,
|
|
254
|
+
items: [],
|
|
255
|
+
};
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.selectedIndex = clampIndex(this.selectedIndex, this.items.length);
|
|
260
|
+
const rendered = this.items.map((item, index) =>
|
|
261
|
+
normalizeItem(
|
|
262
|
+
this.config.renderItem(item, {
|
|
263
|
+
width: boundedWidth,
|
|
264
|
+
index,
|
|
265
|
+
selected: index === this.selectedIndex,
|
|
266
|
+
hovered: index === this.hoverIndex,
|
|
267
|
+
previousSelected: index > 0 && index - 1 === this.selectedIndex,
|
|
268
|
+
}),
|
|
269
|
+
boundedWidth,
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
const budget = Math.max(1, Math.floor(this.maxVisible ?? Number.POSITIVE_INFINITY));
|
|
273
|
+
const { start, end } = visibleRange(
|
|
274
|
+
rendered.map((item) => item.lines),
|
|
275
|
+
this.selectedIndex,
|
|
276
|
+
this.viewportStart,
|
|
277
|
+
budget,
|
|
278
|
+
this.followSelection,
|
|
279
|
+
);
|
|
280
|
+
this.viewportStart = start;
|
|
281
|
+
|
|
282
|
+
const lines: string[] = [];
|
|
283
|
+
const items: SelectableListItemGeometry[] = [];
|
|
284
|
+
for (let index = start; index < end; index += 1) {
|
|
285
|
+
const renderedItem = rendered[index]!;
|
|
286
|
+
const y = lines.length;
|
|
287
|
+
lines.push(...renderedItem.lines);
|
|
288
|
+
items.push({
|
|
289
|
+
x: renderedItem.target.x,
|
|
290
|
+
y: y + renderedItem.target.y,
|
|
291
|
+
width: renderedItem.target.width,
|
|
292
|
+
height: renderedItem.target.height,
|
|
293
|
+
index,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
this.geometry = {
|
|
297
|
+
x: 0,
|
|
298
|
+
y: 0,
|
|
299
|
+
width: boundedWidth,
|
|
300
|
+
height: lines.length,
|
|
301
|
+
startIndex: start,
|
|
302
|
+
startRow: rendered.slice(0, start).reduce((height, item) => height + item.lines.length, 0),
|
|
303
|
+
totalRows: rendered.reduce((height, item) => height + item.lines.length, 0),
|
|
304
|
+
items,
|
|
305
|
+
};
|
|
306
|
+
return lines;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private move(delta: number, wrap: boolean): void {
|
|
310
|
+
if (this.items.length === 0) return;
|
|
311
|
+
let next = this.selectedIndex + delta;
|
|
312
|
+
if (wrap) next = (next + this.items.length) % this.items.length;
|
|
313
|
+
this.select(clampIndex(next, this.items.length), true);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private scrollViewport(delta: number): void {
|
|
317
|
+
if (this.items.length === 0 || delta === 0) return;
|
|
318
|
+
this.viewportStart = Math.max(0, Math.min(this.viewportStart + delta, this.items.length - 1));
|
|
319
|
+
this.followSelection = false;
|
|
320
|
+
this.hoverIndex = undefined;
|
|
321
|
+
this.pressedIndex = undefined;
|
|
322
|
+
this.geometry = undefined;
|
|
323
|
+
this.config.requestRender();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private select(index: number, notify: boolean): void {
|
|
327
|
+
if (index < 0) return;
|
|
328
|
+
const changed = index !== this.selectedIndex || !this.followSelection;
|
|
329
|
+
this.followSelection = true;
|
|
330
|
+
if (!changed) return;
|
|
331
|
+
if (index === this.selectedIndex) {
|
|
332
|
+
this.config.requestRender();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this.selectedIndex = index;
|
|
336
|
+
if (notify) {
|
|
337
|
+
this.config.onSelectionChange?.(this.items[index]!, index);
|
|
338
|
+
}
|
|
339
|
+
this.config.requestRender();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private activate(index: number): void {
|
|
343
|
+
if (index >= 0 && index < this.items.length) this.config.onActivate(this.items[index]!, index);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private updateHover(index: number | undefined): void {
|
|
347
|
+
if (this.hoverIndex === index) return;
|
|
348
|
+
this.hoverIndex = index;
|
|
349
|
+
this.config.requestRender();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function clampIndex(index: number, length: number): number {
|
|
354
|
+
if (length === 0) return -1;
|
|
355
|
+
const finiteIndex = Number.isFinite(index) ? Math.floor(index) : 0;
|
|
356
|
+
return Math.max(0, Math.min(finiteIndex, length - 1));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
interface NormalizedItem {
|
|
360
|
+
lines: string[];
|
|
361
|
+
target: { x: number; y: number; width: number; height: number };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function normalizeItem(rendered: string | string[] | SelectableListRow, width: number): NormalizedItem {
|
|
365
|
+
if (typeof rendered === "string" || Array.isArray(rendered)) {
|
|
366
|
+
const lines = (typeof rendered === "string" ? [rendered] : rendered.length > 0 ? [...rendered] : [""]).map((line) =>
|
|
367
|
+
truncateToWidth(line, width, ""),
|
|
368
|
+
);
|
|
369
|
+
return { lines, target: { x: 0, y: 0, width, height: lines.length } };
|
|
370
|
+
}
|
|
371
|
+
const before = (rendered.before ?? []).map((line) => truncateToWidth(line, width, ""));
|
|
372
|
+
const leading = truncateToWidth(rendered.leading ?? "", width, "");
|
|
373
|
+
const x = visibleWidth(leading);
|
|
374
|
+
const content = typeof rendered.content === "string" ? [rendered.content] : rendered.content;
|
|
375
|
+
const rows = (content.length > 0 ? content : [""]).map((line) => truncateToWidth(`${leading}${line}`, width, ""));
|
|
376
|
+
const requested = rendered.target;
|
|
377
|
+
const targetX = requested ? Math.max(0, Math.min(width, Math.floor(requested.x))) : x;
|
|
378
|
+
const targetY = requested ? Math.max(0, Math.min(rows.length, Math.floor(requested.y))) : 0;
|
|
379
|
+
const targetWidth = requested
|
|
380
|
+
? Math.max(0, Math.min(width - targetX, Math.floor(requested.width)))
|
|
381
|
+
: Math.max(0, width - x);
|
|
382
|
+
const targetHeight = requested
|
|
383
|
+
? Math.max(0, Math.min(rows.length - targetY, Math.floor(requested.height)))
|
|
384
|
+
: rows.length;
|
|
385
|
+
return {
|
|
386
|
+
lines: [...before, ...rows],
|
|
387
|
+
target: { x: targetX, y: before.length + targetY, width: targetWidth, height: targetHeight },
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function visibleRange(
|
|
392
|
+
rendered: readonly string[][],
|
|
393
|
+
selected: number,
|
|
394
|
+
preferredStart: number,
|
|
395
|
+
budget: number,
|
|
396
|
+
followSelection: boolean,
|
|
397
|
+
): { start: number; end: number } {
|
|
398
|
+
let start = Math.max(0, Math.min(preferredStart, rendered.length - 1));
|
|
399
|
+
if (!followSelection) {
|
|
400
|
+
let end = fitEnd(rendered, start, budget);
|
|
401
|
+
let used = rendered.slice(start, end).reduce((height, lines) => height + lines.length, 0);
|
|
402
|
+
while (start > 0 && used + rendered[start - 1]!.length <= budget) {
|
|
403
|
+
start -= 1;
|
|
404
|
+
used += rendered[start]!.length;
|
|
405
|
+
}
|
|
406
|
+
end = fitEnd(rendered, start, budget);
|
|
407
|
+
return { start, end };
|
|
408
|
+
}
|
|
409
|
+
if (selected < start) start = selected;
|
|
410
|
+
let end = fitEnd(rendered, start, budget);
|
|
411
|
+
if (selected < end) return { start, end };
|
|
412
|
+
|
|
413
|
+
start = selected;
|
|
414
|
+
let used = rendered[selected]?.length ?? 0;
|
|
415
|
+
while (start > 0 && used + rendered[start - 1]!.length <= budget) {
|
|
416
|
+
start -= 1;
|
|
417
|
+
used += rendered[start]!.length;
|
|
418
|
+
}
|
|
419
|
+
end = fitEnd(rendered, start, budget);
|
|
420
|
+
return { start, end: Math.max(end, selected + 1) };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function fitEnd(rendered: readonly string[][], start: number, budget: number): number {
|
|
424
|
+
let end = start;
|
|
425
|
+
let used = 0;
|
|
426
|
+
while (end < rendered.length) {
|
|
427
|
+
const height = rendered[end]!.length;
|
|
428
|
+
if (end > start && used + height > budget) break;
|
|
429
|
+
used += height;
|
|
430
|
+
end += 1;
|
|
431
|
+
}
|
|
432
|
+
return end;
|
|
433
|
+
}
|