@luan.sh/pi-libtui 0.3.8

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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/native-revision +1 -0
  4. package/package.json +58 -0
  5. package/src/activity-presentation.ts +75 -0
  6. package/src/appearance.ts +632 -0
  7. package/src/background-surface.ts +125 -0
  8. package/src/color/palette.ts +186 -0
  9. package/src/color/preview.ts +157 -0
  10. package/src/color/resolver.ts +75 -0
  11. package/src/color/theme.ts +531 -0
  12. package/src/component-stack.ts +247 -0
  13. package/src/content/markdown-code.ts +60 -0
  14. package/src/content/terminal-text.ts +135 -0
  15. package/src/content/text.ts +105 -0
  16. package/src/controls/action-panel.ts +420 -0
  17. package/src/controls/dialog-button-bar.ts +287 -0
  18. package/src/controls/multi-select.ts +467 -0
  19. package/src/controls/picker-panel.ts +447 -0
  20. package/src/controls/screen-icon-actions.ts +134 -0
  21. package/src/controls/searchable-select.ts +394 -0
  22. package/src/controls/select-box.ts +224 -0
  23. package/src/controls/selectable-list.ts +433 -0
  24. package/src/controls/selection-action-bar.ts +382 -0
  25. package/src/controls/semantic-input.ts +23 -0
  26. package/src/controls/tab.ts +189 -0
  27. package/src/cursor.ts +288 -0
  28. package/src/decoration/editor-pills.ts +156 -0
  29. package/src/decoration/glyphs.ts +326 -0
  30. package/src/decoration/pointer-interaction.ts +123 -0
  31. package/src/decoration/powerline-pill.ts +104 -0
  32. package/src/decoration/status.ts +185 -0
  33. package/src/decoration/transient-pill.ts +176 -0
  34. package/src/diff/index.ts +21 -0
  35. package/src/diff/model.ts +76 -0
  36. package/src/diff/parse.ts +310 -0
  37. package/src/diff/pierre-syntax.ts +86 -0
  38. package/src/diff/render.ts +504 -0
  39. package/src/diff/structured.ts +264 -0
  40. package/src/diff/view.ts +75 -0
  41. package/src/editor/chrome.ts +341 -0
  42. package/src/editor/composition.ts +53 -0
  43. package/src/editor/layout.ts +81 -0
  44. package/src/editor/presentation.ts +35 -0
  45. package/src/editor/protocol.ts +188 -0
  46. package/src/editor.ts +41 -0
  47. package/src/extension.ts +30 -0
  48. package/src/folding.ts +192 -0
  49. package/src/host/cursor-bridge.ts +210 -0
  50. package/src/host/editor-bridge.ts +148 -0
  51. package/src/host/extension-host.ts +231 -0
  52. package/src/host/mouse-bridge.ts +212 -0
  53. package/src/host/mouse-dispatch.ts +400 -0
  54. package/src/host/mouse-input.ts +85 -0
  55. package/src/host/mouse-targets.ts +238 -0
  56. package/src/host/pi-layout-adapter.ts +298 -0
  57. package/src/host/pi-native-selection.ts +227 -0
  58. package/src/host/split-pane-bridge.ts +720 -0
  59. package/src/host/transcript-bridge.ts +236 -0
  60. package/src/index.ts +334 -0
  61. package/src/line-layout.ts +6 -0
  62. package/src/motion.ts +1409 -0
  63. package/src/mouse/events.ts +55 -0
  64. package/src/mouse/fullscreen-layout.ts +260 -0
  65. package/src/mouse/registry.ts +348 -0
  66. package/src/mouse.ts +37 -0
  67. package/src/native-binary.ts +189 -0
  68. package/src/overlay/anchored.ts +91 -0
  69. package/src/overlay/detail-card.ts +158 -0
  70. package/src/overlay/dialog.ts +266 -0
  71. package/src/overlay/floating.ts +106 -0
  72. package/src/overlay/framed-editor.ts +236 -0
  73. package/src/overlay/fullscreen.ts +170 -0
  74. package/src/overlay/hover-tooltip.ts +109 -0
  75. package/src/overlay/modal-mount.ts +118 -0
  76. package/src/panels.ts +207 -0
  77. package/src/render-cache.ts +80 -0
  78. package/src/render-epoch.ts +48 -0
  79. package/src/request-animation.ts +160 -0
  80. package/src/scrollbar.ts +27 -0
  81. package/src/selection.ts +148 -0
  82. package/src/split-pane.ts +232 -0
  83. package/src/status-presentation.ts +411 -0
  84. package/src/stream.ts +543 -0
  85. package/src/syntax.ts +316 -0
  86. package/src/terminal/bridge-client.ts +243 -0
  87. package/src/terminal/embedding.ts +6 -0
  88. package/src/terminal/output.ts +151 -0
  89. package/src/terminal/projection.ts +362 -0
  90. package/src/terminal/pty-host.ts +263 -0
  91. package/src/terminal/pty-pane.ts +308 -0
  92. package/src/terminal-colors.ts +334 -0
  93. package/src/tool/action.ts +170 -0
  94. package/src/tool/activity.ts +500 -0
  95. package/src/tool/call-preview.ts +44 -0
  96. package/src/tool/disclosure-action.ts +89 -0
  97. package/src/tool/expanded-region-viewport.ts +103 -0
  98. package/src/tool/index.ts +26 -0
  99. package/src/tool/output.ts +271 -0
  100. package/src/tool/transcript.ts +150 -0
  101. package/src/tool/view-region.ts +581 -0
  102. package/test/appearance.test.ts +183 -0
  103. package/test/bounded-stream.test.ts +150 -0
  104. package/test/bridge-client.test.ts +42 -0
  105. package/test/color-preview.test.ts +23 -0
  106. package/test/component-stack.test.ts +206 -0
  107. package/test/components.test.ts +1165 -0
  108. package/test/content-text.test.ts +21 -0
  109. package/test/cursor-bridge.test.ts +151 -0
  110. package/test/cursor.test.ts +147 -0
  111. package/test/editor-chrome.test.ts +119 -0
  112. package/test/editor-layout.test.ts +30 -0
  113. package/test/editor-registry.test.ts +203 -0
  114. package/test/editor-theme.test.ts +33 -0
  115. package/test/editor-token-pill.test.ts +72 -0
  116. package/test/floating-overlay.test.ts +57 -0
  117. package/test/folding.test.ts +147 -0
  118. package/test/harmonious-theme.test.ts +23 -0
  119. package/test/interaction.test.ts +104 -0
  120. package/test/key-icons.test.ts +62 -0
  121. package/test/markdown-code.test.ts +24 -0
  122. package/test/modal-overlay-mount.test.ts +94 -0
  123. package/test/motion.test.ts +649 -0
  124. package/test/mouse/bridge.test.ts +1302 -0
  125. package/test/mouse/extension.test.ts +205 -0
  126. package/test/mouse/registry.test.ts +140 -0
  127. package/test/mouse/screen-decoration.test.ts +167 -0
  128. package/test/native-binary.test.ts +52 -0
  129. package/test/palette.test.ts +32 -0
  130. package/test/panels.test.ts +74 -0
  131. package/test/pointer-components.test.ts +151 -0
  132. package/test/progress.test.ts +54 -0
  133. package/test/pty-host.test.ts +126 -0
  134. package/test/pty-pane.test.ts +62 -0
  135. package/test/public-surface.test.ts +75 -0
  136. package/test/render-cache.test.ts +25 -0
  137. package/test/render-epoch.test.ts +119 -0
  138. package/test/request-animation.test.ts +193 -0
  139. package/test/screen-icon-actions.test.ts +69 -0
  140. package/test/select-box.test.ts +86 -0
  141. package/test/select-pointer.test.ts +369 -0
  142. package/test/selectable-list.test.ts +258 -0
  143. package/test/selection/registry.test.ts +44 -0
  144. package/test/selection-action-bar.test.ts +204 -0
  145. package/test/split-pane-bridge.test.ts +944 -0
  146. package/test/split-pane.test.ts +134 -0
  147. package/test/status-presentation.test.ts +39 -0
  148. package/test/syntax.test.ts +59 -0
  149. package/test/terminal-colors.test.ts +115 -0
  150. package/test/terminal-embedding.test.ts +12 -0
  151. package/test/terminal-projection.test.ts +312 -0
  152. package/test/theme-colors.test.ts +175 -0
  153. package/test/tool-output.test.ts +160 -0
  154. package/test/tool-transcript.test.ts +1190 -0
  155. package/test/unified-diff.test.ts +691 -0
  156. package/themes/harmonious.json +61 -0
  157. package/tsconfig.json +16 -0
@@ -0,0 +1,369 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { initTheme, type Theme } from "@earendil-works/pi-coding-agent";
3
+ import { KeybindingsManager, setKeybindings, stripTerminalSequences, TUI_KEYBINDINGS } from "@earendil-works/pi-tui";
4
+ import { configureTuiAppearance, DEFAULT_TUI_APPEARANCE } from "../src/appearance.ts";
5
+ import { tuiTheme } from "../src/color/theme.ts";
6
+ import { MultiSelect } from "../src/controls/multi-select.ts";
7
+ import { PickerPanel } from "../src/controls/picker-panel.ts";
8
+ import { SearchableSelect } from "../src/controls/searchable-select.ts";
9
+ import type { TuiMouseEvent, TuiMouseEventType } from "../src/mouse.ts";
10
+
11
+ const theme = {
12
+ bold: (text: string) => text,
13
+ fg: (_color: string, text: string) => text,
14
+ bg: (_color: string, text: string) => text,
15
+ underline: (text: string) => text,
16
+ } as Theme;
17
+
18
+ function initializeTui(): void {
19
+ initTheme("dark", false);
20
+ setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
21
+ }
22
+
23
+ function mouse(
24
+ type: TuiMouseEventType,
25
+ row: number,
26
+ col = 0,
27
+ properties: Partial<Pick<TuiMouseEvent, "button" | "wheel">> = {},
28
+ ): TuiMouseEvent {
29
+ return {
30
+ type,
31
+ row,
32
+ col,
33
+ screenRow: row,
34
+ screenCol: col,
35
+ button: undefined,
36
+ wheel: undefined,
37
+ shift: false,
38
+ alt: false,
39
+ ctrl: false,
40
+ ...properties,
41
+ };
42
+ }
43
+
44
+ function click(component: { onMouse(event: TuiMouseEvent): boolean }, row: number, col = 0): void {
45
+ component.onMouse(mouse("press", row, col, { button: 0 }));
46
+ component.onMouse(mouse("release", row, col, { button: 0 }));
47
+ }
48
+
49
+ describe("shared select pointer behavior", () => {
50
+ test("searchable select clicks select without confirming and Save confirms", () => {
51
+ initializeTui();
52
+ const selected: string[] = [];
53
+ const select = new SearchableSelect({
54
+ title: "Mode",
55
+ description: "Choose a mode",
56
+ options: Array.from({ length: 15 }, (_, index) => ({ value: `mode-${index}`, label: `Mode ${index}` })),
57
+ theme,
58
+ onSelect: (value) => selected.push(value),
59
+ onCancel() {},
60
+ });
61
+
62
+ select.render(60);
63
+ // Title, description, and the persistent search field occupy rows 0-2.
64
+ expect(select.onMouse(mouse("move", 5))).toBe(true);
65
+ const rendered = select.render(60);
66
+ expect(stripTerminalSequences(rendered[3]!)).toStartWith("→ Mode 0");
67
+ expect(stripTerminalSequences(rendered[5]!)).toStartWith(" Mode 2");
68
+ expect(rendered[5]).toContain("\x1b[48;");
69
+ // Clicking the last visible row must not recenter or confirm it.
70
+ click(select, 14);
71
+ expect(selected).toEqual([]);
72
+ const lines = select.render(60);
73
+ expect(stripTerminalSequences(lines[14]!)).toStartWith("→ Mode 11");
74
+ const saveRow = lines.length - 1;
75
+ const saveColumn = stripTerminalSequences(lines[saveRow]!).indexOf("Save");
76
+ click(select, saveRow, saveColumn);
77
+ expect(selected).toEqual(["mode-11"]);
78
+
79
+ select.onMouse(mouse("wheel", 14, 0, { wheel: 1 }));
80
+ select.handleInput("\r");
81
+ expect(selected).toEqual(["mode-11", "mode-11"]);
82
+ });
83
+
84
+ test("searchable select keeps fixed pointer rows while filtering", () => {
85
+ initializeTui();
86
+ const selected: string[] = [];
87
+ const select = new SearchableSelect({
88
+ title: "Mode",
89
+ options: [
90
+ { value: "default", label: "Default" },
91
+ { value: "minimal", label: "Minimal minimum" },
92
+ ],
93
+ theme,
94
+ onSelect: (value) => selected.push(value),
95
+ onCancel() {},
96
+ });
97
+ select.render(40);
98
+ click(select, 1);
99
+ for (const character of "min") select.handleInput(character);
100
+ const filtered = select.render(40);
101
+ // The search field remains row 1 and the filtered option remains row 2.
102
+ expect(stripTerminalSequences(filtered[1]!)).toContain("min");
103
+ expect(stripTerminalSequences(filtered[1]!)).not.toContain("> ");
104
+ expect(filtered[2]).toContain(tuiTheme(theme).fg("warning", "Min"));
105
+ expect(filtered[2]).toContain(tuiTheme(theme).fg("warning", "min"));
106
+ click(select, 2);
107
+ expect(selected).toEqual([]);
108
+ const lines = select.render(40);
109
+ const saveRow = lines.length - 1;
110
+ click(select, saveRow, stripTerminalSequences(lines[saveRow]!).indexOf("Save"));
111
+ expect(selected).toEqual(["minimal"]);
112
+ });
113
+
114
+ test("searchable select exposes a mouseable Cancel action", () => {
115
+ initializeTui();
116
+ let cancelled = 0;
117
+ const select = new SearchableSelect({
118
+ title: "Mode",
119
+ options: [{ value: "default", label: "Default" }],
120
+ theme,
121
+ onSelect() {},
122
+ onCancel: () => {
123
+ cancelled += 1;
124
+ },
125
+ });
126
+ const lines = select.render(40);
127
+ const buttonRow = lines.length - 1;
128
+ const cancelColumn = stripTerminalSequences(lines[buttonRow]!).indexOf("Cancel");
129
+ click(select, buttonRow, cancelColumn);
130
+ expect(cancelled).toBe(1);
131
+ });
132
+
133
+ test("picker panel delegates clipped row geometry, hover, wheel, and explicit Save", () => {
134
+ initializeTui();
135
+ const selected: string[] = [];
136
+ let cancelled = 0;
137
+ const picker = new PickerPanel({
138
+ tui: { terminal: { rows: 8 }, requestRender() {} },
139
+ theme,
140
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
141
+ title: "Roles",
142
+ options: Array.from({ length: 5 }, (_, index) => ({
143
+ value: `role-${index}`,
144
+ label: `Role ${index}`,
145
+ description: `Description ${index}`,
146
+ })),
147
+ maxVisible: 2,
148
+ onSelect: (value) => selected.push(value),
149
+ onCancel: () => {
150
+ cancelled += 1;
151
+ },
152
+ });
153
+
154
+ let lines = picker.render(48);
155
+ expect(lines).toHaveLength(6);
156
+ expect(stripTerminalSequences(lines[2]!)).toContain("│ › Role 0 Description 0");
157
+ expect(stripTerminalSequences(lines[3]!)).toContain("│ Role 1 Description 1");
158
+ const normalSecondRow = lines[3]!;
159
+ expect(picker.onMouse(mouse("move", 3, 20))).toBe(true);
160
+ lines = picker.render(48);
161
+ expect(lines[3]).not.toBe(normalSecondRow);
162
+
163
+ // A secondary-button release never activates a row, and a primary click only selects it.
164
+ picker.onMouse(mouse("press", 3, 20, { button: 1 }));
165
+ picker.onMouse(mouse("release", 3, 20, { button: 1 }));
166
+ expect(selected).toEqual([]);
167
+ click(picker, 3, 20);
168
+ expect(selected).toEqual([]);
169
+ expect(stripTerminalSequences(picker.render(48)[3]!)).toContain("│ › Role 1 Description 1");
170
+
171
+ // Wheel scrolls the viewport but leaves the clicked selection unchanged.
172
+ picker.onMouse(mouse("wheel", 3, 20, { wheel: 1 }));
173
+ lines = picker.render(48);
174
+ expect(stripTerminalSequences(lines[2]!)).toContain("│ › Role 1 Description 1");
175
+ expect(stripTerminalSequences(lines[3]!)).toContain("│ Role 2 Description 2");
176
+
177
+ const buttonRow = lines.length - 2;
178
+ const saveColumn = stripTerminalSequences(lines[buttonRow]!).indexOf("Save");
179
+ click(picker, buttonRow, saveColumn);
180
+ expect(selected).toEqual(["role-1"]);
181
+
182
+ const cancelColumn = stripTerminalSequences(lines[buttonRow]!).indexOf("Cancel");
183
+ click(picker, buttonRow, cancelColumn);
184
+ expect(cancelled).toBe(1);
185
+ });
186
+
187
+ test("picker panel keeps search geometry fixed and clears search before cancelling", () => {
188
+ initializeTui();
189
+ const selected: string[] = [];
190
+ let cancelled = 0;
191
+ const picker = new PickerPanel({
192
+ tui: { terminal: { rows: 12 }, requestRender() {} },
193
+ theme,
194
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
195
+ title: "Roles",
196
+ options: [
197
+ { value: "default", label: "Default", description: "General purpose", searchText: "default general" },
198
+ { value: "quick", label: "Quick", description: "Time-sensitive", searchText: "quick time-sensitive" },
199
+ ],
200
+ onSelect: (value) => selected.push(value),
201
+ onCancel: () => {
202
+ cancelled += 1;
203
+ },
204
+ });
205
+ picker.focused = true;
206
+
207
+ let lines = picker.render(48);
208
+ click(picker, 1, 4);
209
+ for (const character of "time-sensitive") picker.handleInput(character);
210
+ lines = picker.render(48);
211
+ expect(stripTerminalSequences(lines[1]!)).toContain("time-sensitive");
212
+ expect(stripTerminalSequences(lines[2]!)).toContain("Quick");
213
+ expect(stripTerminalSequences(lines[2]!)).not.toContain("Default");
214
+ click(picker, 2, 20);
215
+ expect(selected).toEqual([]);
216
+
217
+ picker.handleInput("\x1b");
218
+ expect(stripTerminalSequences(picker.render(48)[2]!)).toContain("Default");
219
+ expect(cancelled).toBe(0);
220
+ picker.handleInput("\x1b");
221
+ expect(cancelled).toBe(1);
222
+ });
223
+
224
+ test("picker panel preserves keyboard parity and restores button destinations after keycaps", () => {
225
+ initializeTui();
226
+ const selected: string[] = [];
227
+ const picker = new PickerPanel({
228
+ tui: { terminal: { rows: 12 }, requestRender() {} },
229
+ theme,
230
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
231
+ title: "Roles",
232
+ options: [
233
+ { value: "a", label: "Alpha" },
234
+ { value: "b", label: "Beta" },
235
+ { value: "c", label: "Gamma" },
236
+ ],
237
+ onSelect: (value) => selected.push(value),
238
+ onCancel() {},
239
+ });
240
+
241
+ picker.handleInput("j");
242
+ picker.handleInput("\x1b[B");
243
+ picker.handleInput("\r");
244
+ expect(selected).toEqual(["c"]);
245
+
246
+ const lines = picker.render(48);
247
+ const buttonLine = lines.at(-2)!;
248
+ const colors = tuiTheme(theme);
249
+ expect(buttonLine).toContain(colors.bgAnsi("action.neutral"));
250
+ expect(buttonLine).toContain(colors.bgAnsi("action.positive"));
251
+ });
252
+
253
+ test("searchable select keeps help and semantic actions on one compact row", () => {
254
+ initializeTui();
255
+ configureTuiAppearance({ iconPack: "nerd-fonts" });
256
+ const select = new SearchableSelect({
257
+ title: "Mode",
258
+ showTitle: false,
259
+ options: [{ value: "default", label: "Default" }],
260
+ theme,
261
+ onSelect() {},
262
+ onCancel() {},
263
+ });
264
+ const lines = select.render(48).map(stripTerminalSequences);
265
+ expect(lines[0]).toContain(" Filter…");
266
+ expect(lines.at(-1)).toContain("󰜺 Cancel");
267
+ expect(lines.at(-1)).toContain(" Save");
268
+ configureTuiAppearance(DEFAULT_TUI_APPEARANCE);
269
+ });
270
+
271
+ test("multi-select hovers and toggles rendered rows, scrolls, and exposes Save", () => {
272
+ initializeTui();
273
+ const saved: string[][] = [];
274
+ const select = new MultiSelect({
275
+ title: "Models",
276
+ options: [
277
+ { value: "a", label: "A" },
278
+ { value: "b", label: "B" },
279
+ { value: "c", label: "C" },
280
+ { value: "d", label: "D" },
281
+ { value: "e", label: "E" },
282
+ ],
283
+ value: ["a"],
284
+ maxHeight: 7,
285
+ theme,
286
+ onSave: (value) => saved.push(value),
287
+ onCancel() {},
288
+ });
289
+
290
+ let lines = select.render(50);
291
+ const secondOptionRow = lines.findIndex((line) => stripTerminalSequences(line).includes("B"));
292
+ expect(select.onMouse(mouse("move", secondOptionRow))).toBe(true);
293
+ expect(stripTerminalSequences(select.render(50)[secondOptionRow]!)).toStartWith("›");
294
+ click(select, secondOptionRow);
295
+ select.onMouse(mouse("wheel", secondOptionRow, 0, { wheel: 1 }));
296
+ const scrolledOptions = select.render(50).slice(2, 5).map(stripTerminalSequences);
297
+ expect(scrolledOptions[0]).toStartWith("›");
298
+ expect(scrolledOptions[0]).toContain("B");
299
+ expect(scrolledOptions.join("\n")).not.toContain(" A");
300
+
301
+ lines = select.render(50);
302
+ const buttonRow = lines.length - 1;
303
+ const saveColumn = stripTerminalSequences(lines[buttonRow]!).indexOf("Save");
304
+ click(select, buttonRow, saveColumn);
305
+ expect(saved).toEqual([["a", "b"]]);
306
+ });
307
+
308
+ test("multi-select description lines share their option's pointer target", () => {
309
+ initializeTui();
310
+ const saved: string[][] = [];
311
+ const select = new MultiSelect({
312
+ title: "Tools",
313
+ options: [
314
+ { value: "a", label: "Alpha", description: "First tool description." },
315
+ { value: "b", label: "Beta", description: "Second tool description that wraps." },
316
+ ],
317
+ value: [],
318
+ descriptionLayout: "below",
319
+ theme,
320
+ onSave: (value) => saved.push(value),
321
+ onCancel() {},
322
+ });
323
+
324
+ const lines = select.render(28).map(stripTerminalSequences);
325
+ const betaRow = lines.findIndex((line) => line.includes("Beta"));
326
+ const descriptionRow = betaRow + 1;
327
+ expect(descriptionRow).toBeGreaterThanOrEqual(0);
328
+ expect(lines.slice(descriptionRow, descriptionRow + 3).join(" ")).toContain("Second tool");
329
+ click(select, descriptionRow, 8);
330
+ select.handleInput("\r");
331
+ expect(saved).toEqual([["b"]]);
332
+ });
333
+
334
+ test("multi-select Cancel button preserves the unsaved-change warning", () => {
335
+ initializeTui();
336
+ let cancelled = 0;
337
+ const select = new MultiSelect({
338
+ title: "Models",
339
+ options: [
340
+ { value: "a", label: "A" },
341
+ { value: "b", label: "B" },
342
+ ],
343
+ value: ["a"],
344
+ theme,
345
+ onSave() {},
346
+ onCancel: () => {
347
+ cancelled += 1;
348
+ },
349
+ });
350
+ const initial = select.render(50);
351
+ click(
352
+ select,
353
+ initial.findIndex((line) => stripTerminalSequences(line).includes("B")),
354
+ );
355
+
356
+ let lines = select.render(50);
357
+ let buttonRow = lines.length - 1;
358
+ let cancelColumn = stripTerminalSequences(lines[buttonRow]!).indexOf("Cancel");
359
+ click(select, buttonRow, cancelColumn);
360
+ expect(cancelled).toBe(0);
361
+ expect(stripTerminalSequences(select.render(50).join("\n"))).toContain("Unsaved changes");
362
+
363
+ lines = select.render(50);
364
+ buttonRow = lines.length - 1;
365
+ cancelColumn = stripTerminalSequences(lines[buttonRow]!).indexOf("Cancel");
366
+ click(select, buttonRow, cancelColumn);
367
+ expect(cancelled).toBe(1);
368
+ });
369
+ });
@@ -0,0 +1,258 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import { KeybindingsManager, setKeybindings, stripTerminalSequences, TUI_KEYBINDINGS } from "@earendil-works/pi-tui";
3
+ import { SelectableList } from "../src/controls/selectable-list.ts";
4
+ import type { TuiMouseEvent } from "../src/mouse.ts";
5
+
6
+ function event(overrides: Partial<TuiMouseEvent>): TuiMouseEvent {
7
+ return {
8
+ type: "move",
9
+ row: 0,
10
+ col: 0,
11
+ screenRow: 0,
12
+ screenCol: 0,
13
+ button: undefined,
14
+ wheel: undefined,
15
+ shift: false,
16
+ alt: false,
17
+ ctrl: false,
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ describe("SelectableList", () => {
23
+ beforeEach(() => setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS)));
24
+
25
+ test("renders multi-line items as complete targets with exact visible geometry", () => {
26
+ const list = new SelectableList({
27
+ items: ["Alpha", "Beta", "Gamma"],
28
+ selectedIndex: 1,
29
+ maxVisible: 3,
30
+ renderItem: (item, context) =>
31
+ context.index === 1 ? [`heading:${context.width}`, `${context.selected ? ">" : " "}${item}`] : item,
32
+ requestRender() {},
33
+ onActivate() {},
34
+ });
35
+
36
+ expect(list.render(24)).toEqual(["Alpha", "heading:24", ">Beta"]);
37
+ expect(list.getGeometry()).toEqual({
38
+ x: 0,
39
+ y: 0,
40
+ width: 24,
41
+ height: 3,
42
+ startIndex: 0,
43
+ startRow: 0,
44
+ totalRows: 4,
45
+ items: [
46
+ { x: 0, y: 0, width: 24, height: 1, index: 0 },
47
+ { x: 0, y: 1, width: 24, height: 2, index: 1 },
48
+ ],
49
+ });
50
+ });
51
+
52
+ test("bounds rendered lines to the available width", () => {
53
+ const list = new SelectableList({
54
+ items: ["long"],
55
+ renderItem: () => ["123456", "abcdef"],
56
+ requestRender() {},
57
+ onActivate() {},
58
+ });
59
+
60
+ expect(list.render(3).map(stripTerminalSequences)).toEqual(["123", "abc"]);
61
+ });
62
+
63
+ test("keyboard wraps selection and confirms the selected item", () => {
64
+ const changes: Array<[string, number]> = [];
65
+ const activations: Array<[string, number]> = [];
66
+ let renders = 0;
67
+ const list = new SelectableList({
68
+ items: ["Alpha", "Beta", "Gamma"],
69
+ renderItem: (item) => item,
70
+ requestRender: () => {
71
+ renders += 1;
72
+ },
73
+ onSelectionChange: (item, index) => changes.push([item, index]),
74
+ onActivate: (item, index) => activations.push([item, index]),
75
+ });
76
+
77
+ list.handleInput("k");
78
+ list.handleInput("\r");
79
+ list.handleInput("j");
80
+ expect(changes).toEqual([
81
+ ["Gamma", 2],
82
+ ["Alpha", 0],
83
+ ]);
84
+ expect(activations).toEqual([["Gamma", 2]]);
85
+ expect(renders).toBe(2);
86
+ });
87
+
88
+ test("hover and primary click use the full rendered item height", () => {
89
+ const contexts: Array<{ index: number; hovered: boolean; selected: boolean }> = [];
90
+ const activations: string[] = [];
91
+ const changes: string[] = [];
92
+ const list = new SelectableList({
93
+ items: ["Alpha", "Beta"],
94
+ renderItem: (item, context) => {
95
+ contexts.push({ index: context.index, hovered: context.hovered, selected: context.selected });
96
+ return item === "Alpha" ? ["Section", item] : item;
97
+ },
98
+ requestRender() {},
99
+ onSelectionChange: (item) => changes.push(item),
100
+ onActivate: (item) => activations.push(item),
101
+ });
102
+
103
+ list.render(20);
104
+ expect(list.onMouse(event({ type: "move", row: 1, col: 4 }))).toBe(true);
105
+ list.render(20);
106
+ expect(contexts.some((context) => context.index === 0 && context.hovered)).toBe(true);
107
+ expect(list.onMouse(event({ type: "press", row: 2, col: 4, button: 0 }))).toBe(true);
108
+ list.render(20);
109
+ expect(list.onMouse(event({ type: "release", row: 2, col: 4, button: 0 }))).toBe(true);
110
+ expect(changes).toEqual(["Beta"]);
111
+ expect(activations).toEqual(["Beta"]);
112
+ });
113
+
114
+ test("can make pointer clicks select-only while Enter still activates", () => {
115
+ const changes: string[] = [];
116
+ const activations: string[] = [];
117
+ const list = new SelectableList({
118
+ items: ["Alpha", "Beta"],
119
+ activateOnClick: false,
120
+ renderItem: (item) => item,
121
+ requestRender() {},
122
+ onSelectionChange: (item) => changes.push(item),
123
+ onActivate: (item) => activations.push(item),
124
+ });
125
+
126
+ list.render(20);
127
+ list.onMouse(event({ type: "press", row: 1, col: 4, button: 0 }));
128
+ list.onMouse(event({ type: "release", row: 1, col: 4, button: 0 }));
129
+ expect(changes).toEqual(["Beta"]);
130
+ expect(activations).toEqual([]);
131
+ list.handleInput("\r");
132
+ expect(activations).toEqual(["Beta"]);
133
+ });
134
+
135
+ test("excludes decorative lines and leading columns from a semantic row target", () => {
136
+ const activations: string[] = [];
137
+ const list = new SelectableList({
138
+ items: ["Terminal images"],
139
+ renderItem: (item) => ({
140
+ before: ["Terminal & Images | Terminal & Images"],
141
+ leading: " | ",
142
+ content: item,
143
+ }),
144
+ requestRender() {},
145
+ onActivate: (item) => activations.push(item),
146
+ });
147
+
148
+ expect(list.render(60)).toEqual(["Terminal & Images | Terminal & Images", " | Terminal images"]);
149
+ expect(list.getGeometry()?.items).toEqual([{ x: 22, y: 1, width: 38, height: 1, index: 0 }]);
150
+ expect(list.onMouse(event({ type: "move", row: 0, col: 2 }))).toBe(false);
151
+ expect(list.onMouse(event({ type: "press", row: 1, col: 2, button: 0 }))).toBe(false);
152
+ list.onMouse(event({ type: "press", row: 1, col: 24, button: 0 }));
153
+ list.onMouse(event({ type: "release", row: 1, col: 24, button: 0 }));
154
+ expect(activations).toEqual(["Terminal images"]);
155
+ });
156
+
157
+ test("keeps every content line in a structured row selectable", () => {
158
+ const activations: string[] = [];
159
+ const list = new SelectableList({
160
+ items: ["Theme"],
161
+ renderItem: (item) => ({
162
+ before: ["Appearance", "──────────"],
163
+ content: [item, "Color theme used by Pi."],
164
+ }),
165
+ requestRender() {},
166
+ onActivate: (item) => activations.push(item),
167
+ });
168
+
169
+ expect(list.render(30)).toEqual(["Appearance", "──────────", "Theme", "Color theme used by Pi."]);
170
+ expect(list.getGeometry()?.items).toEqual([{ x: 0, y: 2, width: 30, height: 2, index: 0 }]);
171
+ expect(list.onMouse(event({ type: "move", row: 1, col: 2 }))).toBe(false);
172
+ list.onMouse(event({ type: "press", row: 3, col: 2, button: 0 }));
173
+ list.onMouse(event({ type: "release", row: 3, col: 2, button: 0 }));
174
+ expect(activations).toEqual(["Theme"]);
175
+ });
176
+
177
+ test("can restrict pointer interaction to one control inside a multi-line row", () => {
178
+ const activations: string[] = [];
179
+ const list = new SelectableList({
180
+ items: ["Theme"],
181
+ renderItem: (item) => ({
182
+ before: ["──────────"],
183
+ content: [`${item} Dark`, "Color theme used by Pi."],
184
+ target: { x: 18, y: 0, width: 4, height: 1 },
185
+ }),
186
+ requestRender() {},
187
+ onActivate: (item) => activations.push(item),
188
+ });
189
+
190
+ list.render(30);
191
+ expect(list.getGeometry()?.items).toEqual([{ x: 18, y: 1, width: 4, height: 1, index: 0 }]);
192
+ expect(list.onMouse(event({ type: "move", row: 2, col: 2 }))).toBe(false);
193
+ list.onMouse(event({ type: "press", row: 1, col: 19, button: 0 }));
194
+ list.onMouse(event({ type: "release", row: 1, col: 19, button: 0 }));
195
+ expect(activations).toEqual(["Theme"]);
196
+ });
197
+
198
+ test("wheel scrolls the line-budget viewport without changing selection", () => {
199
+ const changes: number[] = [];
200
+ const list = new SelectableList({
201
+ items: ["A", "B", "C", "D"],
202
+ maxVisible: 2,
203
+ renderItem: (item) => item,
204
+ requestRender() {},
205
+ onSelectionChange: (_item, index) => changes.push(index),
206
+ onActivate() {},
207
+ });
208
+
209
+ list.render(10);
210
+ list.onMouse(event({ type: "wheel", row: 0, col: 0, wheel: 1 }));
211
+ expect(list.render(10)).toEqual(["B", "C"]);
212
+ expect(list.getGeometry()?.startIndex).toBe(1);
213
+ list.onMouse(event({ type: "wheel", row: 1, col: 0, wheel: 1 }));
214
+ expect(list.render(10)).toEqual(["C", "D"]);
215
+ expect(list.getSelectedIndex()).toBe(0);
216
+ expect(changes).toEqual([]);
217
+
218
+ list.handleInput("j");
219
+ expect(list.render(10)).toEqual(["B", "C"]);
220
+ expect(list.getSelectedIndex()).toBe(1);
221
+ expect(changes).toEqual([1]);
222
+ });
223
+
224
+ test("external item and selection synchronization is silent", () => {
225
+ const changes: string[] = [];
226
+ let renders = 0;
227
+ const list = new SelectableList({
228
+ items: ["A", "B", "C"],
229
+ renderItem: (item) => item,
230
+ requestRender: () => {
231
+ renders += 1;
232
+ },
233
+ onSelectionChange: (item) => changes.push(item),
234
+ onActivate() {},
235
+ });
236
+
237
+ list.setSelectedIndex(2);
238
+ list.setItems(["X", "Y"], 1);
239
+ expect(list.getSelectedIndex()).toBe(1);
240
+ expect(list.getSelectedItem()).toBe("Y");
241
+ expect(changes).toEqual([]);
242
+ expect(renders).toBe(2);
243
+ });
244
+
245
+ test("updates its line budget for responsive parent layouts", () => {
246
+ const list = new SelectableList({
247
+ items: ["A", "B", "C"],
248
+ maxVisible: 3,
249
+ renderItem: (item) => item,
250
+ requestRender() {},
251
+ onActivate() {},
252
+ });
253
+
254
+ expect(list.render(10)).toEqual(["A", "B", "C"]);
255
+ list.setMaxVisible(2);
256
+ expect(list.render(10)).toEqual(["A", "B"]);
257
+ });
258
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ensureSelectionRegistry } from "../../src/selection.ts";
3
+
4
+ describe("selection registry", () => {
5
+ test("publishes to optional listeners and disposes by identity", () => {
6
+ const scope = Object.create(null) as typeof globalThis;
7
+ const registry = ensureSelectionRegistry(scope);
8
+ const rows: number[] = [];
9
+ const remove = registry.onSelectionCompleted((selection) => rows.push(selection.logical.start.row));
10
+ registry.publishSelectionCompleted({
11
+ text: "selected",
12
+ shape: "character",
13
+ logical: { start: { row: 3, col: 0 }, end: { row: 3, col: 8 } },
14
+ screen: { start: { row: 3, col: 0 }, end: { row: 3, col: 8 } },
15
+ });
16
+ expect(rows).toEqual([3]);
17
+ remove();
18
+ registry.publishSelectionCompleted({
19
+ text: "ignored",
20
+ shape: "line",
21
+ logical: { start: { row: 4, col: 0 }, end: { row: 4, col: 8 } },
22
+ screen: { start: { row: 4, col: 0 }, end: { row: 4, col: 8 } },
23
+ });
24
+ expect(rows).toEqual([3]);
25
+ });
26
+
27
+ test("isolates action listener failures and reports whether one handled the request", async () => {
28
+ const registry = ensureSelectionRegistry(Object.create(null) as typeof globalThis);
29
+ registry.onSelectionAction(() => {
30
+ throw new Error("optional listener failed");
31
+ });
32
+ registry.onSelectionAction(async () => true);
33
+
34
+ await expect(
35
+ registry.publishSelectionAction({
36
+ action: "selection.comment",
37
+ text: "selected",
38
+ shape: "character",
39
+ logical: { start: { row: 1, col: 0 }, end: { row: 1, col: 8 } },
40
+ screen: { start: { row: 1, col: 0 }, end: { row: 1, col: 8 } },
41
+ }),
42
+ ).resolves.toBe(true);
43
+ });
44
+ });