@timmo001/groundwork-tui 0.0.0-stage → 0.0.0

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.
@@ -0,0 +1,684 @@
1
+ /**
2
+ * The menu list: two-line rows with icons, group headings, paging and fuzzy
3
+ * filtering, either as you type or after `/`. Rows update in place when the
4
+ * items change but the layout doesn't, so the list never jumps.
5
+ *
6
+ * @module
7
+ */
8
+ import {
9
+ BoxRenderable,
10
+ ScrollBoxRenderable,
11
+ TextRenderable,
12
+ bold,
13
+ fg,
14
+ t,
15
+ } from "@opentui/core";
16
+ import type { CliRenderer, KeyEvent } from "@opentui/core";
17
+ import Fuse from "fuse.js";
18
+ import type { Theme } from "./theme.ts";
19
+
20
+ /** What a menu row shows; apps extend it with their own action fields. */
21
+ export interface MenuListItem {
22
+ /** Stable id, used to keep the selection across updates */
23
+ readonly id: string;
24
+ /** Primary display text */
25
+ readonly title: string;
26
+ /** Secondary text shown below the title */
27
+ readonly description: string;
28
+ /** Nerd Font icon character */
29
+ readonly icon: string;
30
+ /** A heading is drawn above the first item of each run of the same group */
31
+ readonly group?: string;
32
+ /** Extra words the filter matches */
33
+ readonly keywords?: readonly string[];
34
+ }
35
+
36
+ /** Width of the left icon column in characters */
37
+ const ICON_COLUMN_WIDTH = 4;
38
+
39
+ /** What a row stands for: an item, a group heading or a page step. */
40
+ type RowEntry<Item> =
41
+ | { readonly kind: "item"; readonly item: Item }
42
+ | { readonly kind: "group"; readonly title: string }
43
+ | { readonly kind: "previous" }
44
+ | { readonly kind: "next" };
45
+
46
+ interface MenuRow<Item> {
47
+ readonly container: BoxRenderable;
48
+ readonly iconText: TextRenderable;
49
+ readonly titleText: TextRenderable;
50
+ readonly descText: TextRenderable;
51
+ /** Replaced when rows update in place */
52
+ entry: RowEntry<Item>;
53
+ }
54
+
55
+ /** Configuration for the {@link MenuList} component */
56
+ export interface MenuListOptions<Item extends MenuListItem> {
57
+ /** Unique renderable ID */
58
+ readonly id: string;
59
+ /** Menu items to display */
60
+ readonly items: readonly Item[];
61
+ /** Active color theme */
62
+ readonly theme: Theme;
63
+ /** Called when the user presses Enter on an item */
64
+ readonly onSelect: (item: Item) => void;
65
+ /** Called when the highlighted item changes */
66
+ readonly onSelectionChanged?: (item: Item) => void;
67
+ /** Called when the filter text changes, or `/` filtering starts or ends */
68
+ readonly onFilterChange?: (filter: string) => void;
69
+ /** Called when Escape is pressed with an empty filter */
70
+ readonly onEscape?: () => void;
71
+ /** Called when Backspace is pressed with an empty filter */
72
+ readonly onBack?: () => void;
73
+ /** Index of the initially selected row */
74
+ readonly initialSelectedIndex?: number;
75
+ /** Whether navigation wraps around (default: true) */
76
+ readonly wrapSelection?: boolean;
77
+ /**
78
+ * The most items on a page. Longer lists get "Previous page" and "Next
79
+ * page" rows, and Page Up and Page Down step through them. No paging when
80
+ * unset.
81
+ */
82
+ readonly pageSize?: number;
83
+ /** Called when the page changes, for a page indicator */
84
+ readonly onPageChange?: (page: number, totalPages: number) => void;
85
+ /**
86
+ * Leave filtering to the app: the list only collects the filter text and
87
+ * reports it through `onFilterChange`, and the app calls
88
+ * `setFilteredItems` with the results.
89
+ */
90
+ readonly externalFilter?: boolean;
91
+ /** Sees each key first; return true when it handled the key. */
92
+ readonly onKeyPress?: (key: KeyEvent) => boolean;
93
+ /** `type` filters on any printable key (the default), `slash` after `/`. */
94
+ readonly filterActivation?: "type" | "slash";
95
+ }
96
+
97
+ const isPrintable = (key: KeyEvent): boolean =>
98
+ key.sequence.length === 1 && !key.ctrl && !key.meta && key.sequence >= " ";
99
+
100
+ /**
101
+ * A scrolling menu of two-line rows: the icon and title, then the
102
+ * description. Up and Down move, Enter selects, Escape clears the filter or
103
+ * goes back, and Backspace removes a filter character or goes back.
104
+ */
105
+ export class MenuList<
106
+ Item extends MenuListItem = MenuListItem,
107
+ > extends ScrollBoxRenderable {
108
+ private allItems: readonly Item[];
109
+ private items: readonly Item[];
110
+ private selectedIndex: number;
111
+ private rows: MenuRow<Item>[] = [];
112
+ private filterText = "";
113
+ private slashFilterOpen = false;
114
+ private fuse: Fuse<Item>;
115
+ private page = 0;
116
+ private readonly menuRenderer: CliRenderer;
117
+ private readonly menuTheme: Theme;
118
+ private readonly options: MenuListOptions<Item>;
119
+ private readonly wrapSelection: boolean;
120
+ private readonly filterActivation: "type" | "slash";
121
+
122
+ constructor(renderer: CliRenderer, options: MenuListOptions<Item>) {
123
+ super(renderer, {
124
+ id: options.id,
125
+ flexGrow: 1,
126
+ width: "100%",
127
+ scrollY: true,
128
+ scrollX: false,
129
+ viewportCulling: true,
130
+ backgroundColor: options.theme.bgElevated,
131
+ focusable: true,
132
+ });
133
+
134
+ this.menuRenderer = renderer;
135
+ this.menuTheme = options.theme;
136
+ this.options = options;
137
+ this.allItems = options.items;
138
+ this.items = options.items;
139
+ this.selectedIndex = options.initialSelectedIndex ?? 0;
140
+ this.wrapSelection = options.wrapSelection ?? true;
141
+ this.filterActivation = options.filterActivation ?? "type";
142
+ this.fuse = this.createFuse(options.items);
143
+ this.buildRows();
144
+ }
145
+
146
+ /** Replaces the items and resets the selection, filter and page. */
147
+ setItems(items: readonly Item[]): void {
148
+ this.clearRows();
149
+ this.allItems = items;
150
+ this.items = items;
151
+ this.filterText = "";
152
+ this.slashFilterOpen = false;
153
+ this.page = 0;
154
+ this.fuse = this.createFuse(items);
155
+ this.selectedIndex = 0;
156
+ this.buildRows();
157
+ this.options.onFilterChange?.("");
158
+ this.emitPageChange();
159
+ }
160
+
161
+ /**
162
+ * Shows items filtered by the app, keeping the filter text, page and
163
+ * selected item. Rows update in place when the layout is unchanged.
164
+ */
165
+ setFilteredItems(
166
+ items: readonly Item[],
167
+ options?: { readonly resetSelection?: boolean },
168
+ ): void {
169
+ const previousId = options?.resetSelection
170
+ ? undefined
171
+ : this.getSelectedItem()?.id;
172
+
173
+ this.items = items;
174
+ let targetPage = this.page;
175
+ const pageSize = this.options.pageSize;
176
+
177
+ if (items.length === 0) targetPage = 0;
178
+ else if (previousId && pageSize !== undefined && this.isPaginated()) {
179
+ const index = items.findIndex((item) => item.id === previousId);
180
+ targetPage = index >= 0 ? Math.floor(index / pageSize) : 0;
181
+ }
182
+
183
+ if (
184
+ targetPage === this.page &&
185
+ this.rows.length > 0 &&
186
+ this.canPatchInPlace(this.pageItemsFor(targetPage))
187
+ ) {
188
+ this.patchRowsInPlace();
189
+ this.emitPageChange();
190
+
191
+ return;
192
+ }
193
+
194
+ this.clearRows();
195
+ this.page = targetPage;
196
+ this.selectedIndex = 0;
197
+ this.buildRows();
198
+
199
+ if (previousId) this.selectItemRow(previousId);
200
+
201
+ this.emitPageChange();
202
+ }
203
+
204
+ /** Selects a row by index. */
205
+ setSelectedIndex(index: number): void {
206
+ if (index < 0 || index >= this.rows.length || index === this.selectedIndex)
207
+ return;
208
+
209
+ this.applySelection(index);
210
+ }
211
+
212
+ /** Whether the first selectable row is selected. */
213
+ isFirstSelectableSelected(): boolean {
214
+ if (this.rows.length === 0) return true;
215
+
216
+ return this.selectedIndex === this.nextSelectableIndex(-1, 1);
217
+ }
218
+
219
+ /** The highlighted item, or nothing on a heading or page row. */
220
+ getSelectedItem(): Item | undefined {
221
+ const entry = this.rows[this.selectedIndex]?.entry;
222
+
223
+ return entry?.kind === "item" ? entry.item : undefined;
224
+ }
225
+
226
+ /** Clears the filter and shows every item. */
227
+ resetFilter(): void {
228
+ if (this.filterText.length === 0) return;
229
+
230
+ this.filterText = "";
231
+ this.slashFilterOpen = false;
232
+ this.page = 0;
233
+ this.applyFilter();
234
+ }
235
+
236
+ /** Selects the first item. */
237
+ resetSelection(): void {
238
+ const first = this.rows.findIndex((row) => row.entry.kind === "item");
239
+
240
+ if (first >= 0 && first !== this.selectedIndex) this.applySelection(first);
241
+ }
242
+
243
+ /** The page shown, from 0 */
244
+ get currentPage(): number {
245
+ return this.page;
246
+ }
247
+
248
+ /** How many pages there are, 1 without paging */
249
+ get totalPages(): number {
250
+ if (!this.options.pageSize || !this.isPaginated()) return 1;
251
+
252
+ return Math.ceil(this.items.length / this.options.pageSize);
253
+ }
254
+
255
+ /** How many items match the filter */
256
+ get filteredCount(): number {
257
+ return this.items.length;
258
+ }
259
+
260
+ /** Whether there's filter text */
261
+ get hasFilter(): boolean {
262
+ return this.filterText.length > 0;
263
+ }
264
+
265
+ /** Whether `/` filtering is taking keys */
266
+ get filterActive(): boolean {
267
+ return this.slashFilterOpen;
268
+ }
269
+
270
+ /**
271
+ * Changes one item's title or description in place, keeping the
272
+ * selection, scroll position and filter. Filtered-out items change too, so
273
+ * the next filter sees the new text.
274
+ */
275
+ patchItemById(
276
+ id: string,
277
+ patch: Partial<Pick<Item, "title" | "description">>,
278
+ ): void {
279
+ const current = this.allItems.find((item) => item.id === id);
280
+
281
+ if (current === undefined) return;
282
+
283
+ const updated: Item = { ...current, ...patch };
284
+ const swap = (item: Item): Item => (item.id === id ? updated : item);
285
+
286
+ this.allItems = this.allItems.map(swap);
287
+ this.items = this.items.map(swap);
288
+ this.fuse = this.createFuse(this.allItems);
289
+
290
+ const index = this.rows.findIndex(
291
+ (row) => row.entry.kind === "item" && row.entry.item.id === id,
292
+ );
293
+
294
+ const row = this.rows[index];
295
+
296
+ if (row === undefined) return;
297
+
298
+ row.entry = { kind: "item", item: updated };
299
+ this.paintRow(row, index === this.selectedIndex);
300
+ }
301
+
302
+ override handleKeyPress(key: KeyEvent): boolean {
303
+ if (this.filterActivation === "slash" && this.handleSlashFilter(key))
304
+ return true;
305
+
306
+ if (this.options.onKeyPress?.(key)) return true;
307
+
308
+ if (key.name === "escape") {
309
+ if (this.filterText.length > 0) {
310
+ this.filterText = "";
311
+ this.page = 0;
312
+ this.applyFilter();
313
+
314
+ return true;
315
+ }
316
+
317
+ this.options.onEscape?.();
318
+
319
+ return this.options.onEscape !== undefined;
320
+ }
321
+
322
+ if (key.name === "backspace") {
323
+ if (this.filterText.length > 0) {
324
+ this.filterText = this.filterText.slice(0, -1);
325
+ this.page = 0;
326
+ this.applyFilter();
327
+
328
+ return true;
329
+ }
330
+
331
+ this.options.onBack?.();
332
+
333
+ return this.options.onBack !== undefined;
334
+ }
335
+
336
+ if (key.name === "pagedown" || key.name === "pageup") {
337
+ this.turnPage(key.name === "pagedown" ? 1 : -1);
338
+
339
+ return true;
340
+ }
341
+
342
+ if (key.name === "up" || key.name === "down") {
343
+ this.moveSelection(key.name === "up" ? -1 : 1);
344
+
345
+ return true;
346
+ }
347
+
348
+ if (key.name === "return") {
349
+ const entry = this.rows[this.selectedIndex]?.entry;
350
+
351
+ if (entry?.kind === "item") this.options.onSelect(entry.item);
352
+ else if (entry?.kind === "next") this.turnPage(1);
353
+ else if (entry?.kind === "previous") this.turnPage(-1);
354
+
355
+ return true;
356
+ }
357
+
358
+ if (this.filterActivation === "type" && isPrintable(key)) {
359
+ this.filterText += key.sequence;
360
+ this.page = 0;
361
+ this.applyFilter();
362
+
363
+ return true;
364
+ }
365
+
366
+ return super.handleKeyPress(key);
367
+ }
368
+
369
+ /** `/` opens the filter, which then takes every key until Escape or Enter. */
370
+ private handleSlashFilter(key: KeyEvent): boolean {
371
+ if (!this.slashFilterOpen) {
372
+ if (key.sequence !== "/" || key.ctrl || key.meta) return false;
373
+
374
+ this.slashFilterOpen = true;
375
+ this.options.onFilterChange?.(this.filterText);
376
+
377
+ return true;
378
+ }
379
+
380
+ if (
381
+ key.name === "escape" ||
382
+ key.name === "return" ||
383
+ (key.name === "backspace" && this.filterText.length === 0)
384
+ ) {
385
+ this.slashFilterOpen = false;
386
+ this.options.onFilterChange?.(this.filterText);
387
+
388
+ return true;
389
+ }
390
+
391
+ if (key.name === "backspace") {
392
+ this.filterText = this.filterText.slice(0, -1);
393
+ this.page = 0;
394
+ this.applyFilter();
395
+
396
+ return true;
397
+ }
398
+
399
+ if (!isPrintable(key)) return false;
400
+
401
+ this.filterText += key.sequence;
402
+ this.page = 0;
403
+ this.applyFilter();
404
+
405
+ return true;
406
+ }
407
+
408
+ private isPaginated(): boolean {
409
+ const pageSize = this.options.pageSize;
410
+
411
+ return pageSize !== undefined && this.items.length > pageSize;
412
+ }
413
+
414
+ private pageItemsFor(page: number): readonly Item[] {
415
+ const pageSize = this.options.pageSize;
416
+
417
+ if (pageSize === undefined || !this.isPaginated()) return this.items;
418
+
419
+ return this.items.slice(page * pageSize, (page + 1) * pageSize);
420
+ }
421
+
422
+ private turnPage(direction: 1 | -1): void {
423
+ const target = this.page + direction;
424
+
425
+ if (!this.isPaginated() || target < 0 || target >= this.totalPages) return;
426
+
427
+ this.page = target;
428
+ this.clearRows();
429
+ this.selectedIndex = 0;
430
+ this.buildRows();
431
+
432
+ if (direction === -1) {
433
+ const last = this.rows.findLastIndex((row) => row.entry.kind === "item");
434
+
435
+ if (last >= 0 && last !== this.selectedIndex) this.applySelection(last);
436
+ }
437
+
438
+ this.emitPageChange();
439
+ }
440
+
441
+ private emitPageChange(): void {
442
+ if (this.isPaginated())
443
+ this.options.onPageChange?.(this.page, this.totalPages);
444
+ }
445
+
446
+ private createFuse(items: readonly Item[]): Fuse<Item> {
447
+ return new Fuse([...items], {
448
+ keys: [
449
+ { name: "title", weight: 4 },
450
+ { name: "keywords", weight: 1.5 },
451
+ { name: "description", weight: 1 },
452
+ ],
453
+ threshold: 0.4,
454
+ ignoreLocation: true,
455
+ });
456
+ }
457
+
458
+ /** Re-filters the items, keeping the selected item when the filter clears. */
459
+ private applyFilter(): void {
460
+ if (this.options.externalFilter) {
461
+ this.options.onFilterChange?.(this.filterText);
462
+
463
+ return;
464
+ }
465
+
466
+ const selectedId = this.getSelectedItem()?.id;
467
+ this.clearRows();
468
+ this.selectedIndex = 0;
469
+
470
+ if (this.filterText.length === 0) {
471
+ this.items = this.allItems;
472
+ this.buildRows();
473
+
474
+ if (selectedId) this.selectItemRow(selectedId);
475
+ } else {
476
+ this.items = this.fuse.search(this.filterText).map(({ item }) => item);
477
+ this.buildRows();
478
+ }
479
+
480
+ this.options.onFilterChange?.(this.filterText);
481
+ this.emitPageChange();
482
+ }
483
+
484
+ /** Whether the rows on screen have the same layout as these items. */
485
+ private canPatchInPlace(pageItems: readonly Item[]): boolean {
486
+ let index = 0;
487
+
488
+ for (const { entry } of this.rows) {
489
+ const next = pageItems[index];
490
+
491
+ if (entry.kind === "group") {
492
+ if (next?.group !== entry.title) return false;
493
+ } else if (entry.kind === "item") {
494
+ if (next?.id !== entry.item.id || next.group !== entry.item.group)
495
+ return false;
496
+
497
+ index++;
498
+ }
499
+ }
500
+
501
+ return index === pageItems.length;
502
+ }
503
+
504
+ private patchRowsInPlace(): void {
505
+ const pageItems = this.pageItemsFor(this.page);
506
+ let index = 0;
507
+
508
+ for (const [rowIndex, row] of this.rows.entries()) {
509
+ if (row.entry.kind !== "item") continue;
510
+
511
+ const item = pageItems[index];
512
+
513
+ if (item === undefined) break;
514
+
515
+ row.entry = { kind: "item", item };
516
+ this.paintRow(row, rowIndex === this.selectedIndex);
517
+ index++;
518
+ }
519
+ }
520
+
521
+ private selectItemRow(id: string): void {
522
+ const index = this.rows.findIndex(
523
+ (row) => row.entry.kind === "item" && row.entry.item.id === id,
524
+ );
525
+
526
+ if (index >= 0 && index !== this.selectedIndex) this.applySelection(index);
527
+ }
528
+
529
+ /** Moves by `delta` selectable rows, skipping group headings. */
530
+ private moveSelection(delta: number): void {
531
+ let next = this.selectedIndex;
532
+
533
+ for (let step = 0; step < Math.abs(delta); step++)
534
+ next = this.nextSelectableIndex(next, delta > 0 ? 1 : -1);
535
+
536
+ if (next !== this.selectedIndex) this.applySelection(next);
537
+ }
538
+
539
+ /** The next row that isn't a group heading, or `from` when there's none. */
540
+ private nextSelectableIndex(from: number, direction: 1 | -1): number {
541
+ const length = this.rows.length;
542
+ let index = from;
543
+
544
+ for (let attempt = 0; attempt < length; attempt++) {
545
+ index += direction;
546
+
547
+ if (this.wrapSelection) index = (index + length) % length;
548
+ else if (index < 0 || index >= length) return from;
549
+
550
+ if (this.rows[index]?.entry.kind !== "group") return index;
551
+ }
552
+
553
+ return from;
554
+ }
555
+
556
+ private applySelection(index: number): void {
557
+ const previous = this.rows[this.selectedIndex];
558
+ const row = this.rows[index];
559
+
560
+ if (previous) this.paintRow(previous, false);
561
+
562
+ this.selectedIndex = index;
563
+
564
+ if (row === undefined) return;
565
+
566
+ this.paintRow(row, true);
567
+ this.scrollChildIntoView(row.container.id);
568
+
569
+ if (row.entry.kind === "item")
570
+ this.options.onSelectionChanged?.(row.entry.item);
571
+ }
572
+
573
+ private clearRows(): void {
574
+ for (const row of this.rows) this.remove(row.container);
575
+
576
+ this.rows = [];
577
+ }
578
+
579
+ private buildRows(): void {
580
+ const entries: RowEntry<Item>[] = [];
581
+
582
+ if (this.isPaginated() && this.page > 0) entries.push({ kind: "previous" });
583
+
584
+ let lastGroup: string | undefined;
585
+
586
+ for (const item of this.pageItemsFor(this.page)) {
587
+ if (item.group !== undefined && item.group !== lastGroup)
588
+ entries.push({ kind: "group", title: item.group });
589
+
590
+ lastGroup = item.group;
591
+ entries.push({ kind: "item", item });
592
+ }
593
+
594
+ if (this.isPaginated() && this.page < this.totalPages - 1)
595
+ entries.push({ kind: "next" });
596
+
597
+ for (const [index, entry] of entries.entries()) {
598
+ const row = this.createRow(entry, index);
599
+ this.rows.push(row);
600
+ this.add(row.container);
601
+ }
602
+
603
+ if (this.rows[this.selectedIndex]?.entry.kind === "group")
604
+ this.selectedIndex = this.nextSelectableIndex(this.selectedIndex, 1);
605
+
606
+ for (const [index, row] of this.rows.entries())
607
+ this.paintRow(row, index === this.selectedIndex);
608
+ }
609
+
610
+ private createRow(entry: RowEntry<Item>, index: number): MenuRow<Item> {
611
+ const id = `${this.id}-row-${index}`;
612
+
613
+ const container = new BoxRenderable(this.menuRenderer, {
614
+ id,
615
+ flexDirection: "row",
616
+ width: "100%",
617
+ flexShrink: 0,
618
+ paddingTop: entry.kind === "group" && index > 0 ? 1 : 0,
619
+ });
620
+
621
+ const iconCol = new BoxRenderable(this.menuRenderer, {
622
+ id: `${id}-icol`,
623
+ width: ICON_COLUMN_WIDTH,
624
+ paddingLeft: 1,
625
+ });
626
+
627
+ const textCol = new BoxRenderable(this.menuRenderer, {
628
+ id: `${id}-tcol`,
629
+ flexGrow: 1,
630
+ flexDirection: "column",
631
+ });
632
+
633
+ const text = (part: string): TextRenderable =>
634
+ new TextRenderable(this.menuRenderer, {
635
+ id: `${id}-${part}`,
636
+ content: "",
637
+ });
638
+
639
+ const iconText = text("icon");
640
+ const titleText = text("title");
641
+ const descText = text("desc");
642
+
643
+ iconCol.add(iconText);
644
+ textCol.add(titleText);
645
+ textCol.add(descText);
646
+ container.add(iconCol);
647
+ container.add(textCol);
648
+
649
+ return { container, iconText, titleText, descText, entry };
650
+ }
651
+
652
+ private paintRow(row: MenuRow<Item>, selected: boolean): void {
653
+ const theme = this.menuTheme;
654
+ const { entry } = row;
655
+
656
+ row.container.backgroundColor =
657
+ selected && entry.kind !== "group" ? theme.bgSelected : theme.bgElevated;
658
+
659
+ if (entry.kind === "group") {
660
+ row.iconText.content = "";
661
+ row.titleText.content = t`${bold(fg(theme.fgSubtle)(entry.title))}`;
662
+ row.descText.content = "";
663
+
664
+ return;
665
+ }
666
+
667
+ if (entry.kind !== "item") {
668
+ const color = selected ? theme.accent : theme.fgSubtle;
669
+ const next = entry.kind === "next";
670
+
671
+ row.iconText.content = t`${fg(color)(next ? "→" : "←")}`;
672
+ row.titleText.content = t`${fg(color)(next ? "Next page" : "Previous page")}`;
673
+ row.descText.content = "";
674
+
675
+ return;
676
+ }
677
+
678
+ const color = selected ? theme.accent : theme.fg;
679
+
680
+ row.iconText.content = t`${fg(color)(entry.item.icon)}`;
681
+ row.titleText.content = t`${fg(color)(entry.item.title)}`;
682
+ row.descText.content = t`${fg(theme.fgMuted)(entry.item.description)}`;
683
+ }
684
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The pane header: one fixed row naming a pane, marked when the pane has
3
+ * focus. Focus changes only the colors, never the row's size.
4
+ *
5
+ * @module
6
+ */
7
+ import { TextRenderable, bold, fg, t } from "@opentui/core";
8
+ import type { CliRenderer } from "@opentui/core";
9
+ import type { Theme } from "./theme.ts";
10
+
11
+ /** One fixed row naming a pane, with a detail and a focus marker. */
12
+ export class PaneHeader extends TextRenderable {
13
+ private readonly theme: Theme;
14
+
15
+ constructor(renderer: CliRenderer, id: string, theme: Theme) {
16
+ super(renderer, {
17
+ id,
18
+ height: 1,
19
+ flexShrink: 0,
20
+ wrapMode: "none",
21
+ truncate: true,
22
+ bg: "transparent",
23
+ content: "",
24
+ });
25
+ this.theme = theme;
26
+ }
27
+
28
+ /** Shows the label and detail, marked as focused when `active`. */
29
+ update(label: string, detail: string, active: boolean): void {
30
+ const theme = this.theme;
31
+ this.bg = active ? theme.bgSelected : "transparent";
32
+ this.content = t`${fg(active ? theme.accent : theme.fgSubtle)(active ? "▶" : "·")} ${bold(fg(active ? theme.fg : theme.fgMuted)(label))} ${fg(active ? theme.fgMuted : theme.fgSubtle)(detail)}`;
33
+ }
34
+ }