@gajae-code/tui 0.13.2 → 0.13.3

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.
@@ -39,6 +39,8 @@ export interface SelectListTheme {
39
39
  symbols: SymbolTheme;
40
40
  }
41
41
 
42
+ export type SelectListThemeSource = SelectListTheme | (() => SelectListTheme);
43
+
42
44
  export interface SelectListTruncatePrimaryContext {
43
45
  text: string;
44
46
  maxWidth: number;
@@ -67,7 +69,7 @@ export class SelectList implements Component {
67
69
  constructor(
68
70
  private readonly items: ReadonlyArray<SelectItem>,
69
71
  private readonly maxVisible: number,
70
- private readonly theme: SelectListTheme,
72
+ private readonly themeSource: SelectListThemeSource,
71
73
  private readonly layout: SelectListLayoutOptions = {},
72
74
  ) {
73
75
  this.#filteredItems = items;
@@ -114,11 +116,12 @@ export class SelectList implements Component {
114
116
  }
115
117
 
116
118
  render(width: number): string[] {
119
+ const theme = this.#theme();
117
120
  const lines: string[] = [];
118
121
 
119
122
  // If no items match filter, show message
120
123
  if (this.#filteredItems.length === 0) {
121
- lines.push(truncateToWidth(this.theme.noMatch(" No matching commands"), Math.max(0, width), Ellipsis.Omit));
124
+ lines.push(truncateToWidth(theme.noMatch(" No matching commands"), Math.max(0, width), Ellipsis.Omit));
122
125
  return lines;
123
126
  }
124
127
 
@@ -146,12 +149,16 @@ export class SelectList implements Component {
146
149
  const position = this.#selectedIndex >= 0 ? `${this.#selectedIndex + 1}` : "-";
147
150
  const scrollText = ` (${position}/${this.#filteredItems.length})`;
148
151
  // Truncate if too long for terminal
149
- lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
152
+ lines.push(theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
150
153
  }
151
154
 
152
155
  return lines;
153
156
  }
154
157
 
158
+ #theme(): SelectListTheme {
159
+ return typeof this.themeSource === "function" ? this.themeSource() : this.themeSource;
160
+ }
161
+
155
162
  handleInput(keyData: string): void {
156
163
  const kb = getKeybindings();
157
164
  if (this.#filteredItems.length === 0) {
@@ -193,9 +200,8 @@ export class SelectList implements Component {
193
200
  descriptionSingleLine: string | undefined,
194
201
  primaryColumnWidth: number,
195
202
  ): string {
196
- const prefix = isSelected
197
- ? `${this.theme.symbols.cursor} `
198
- : padding(visibleWidth(this.theme.symbols.cursor) + 1);
203
+ const theme = this.#theme();
204
+ const prefix = isSelected ? `${theme.symbols.cursor} ` : padding(visibleWidth(theme.symbols.cursor) + 1);
199
205
  const prefixWidth = visibleWidth(prefix);
200
206
 
201
207
  if (descriptionSingleLine && width > 40) {
@@ -210,13 +216,13 @@ export class SelectList implements Component {
210
216
  if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
211
217
  const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
212
218
  if (item.disabled) {
213
- return this.theme.description(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
219
+ return theme.description(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
214
220
  }
215
221
  if (isSelected) {
216
- return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
222
+ return theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
217
223
  }
218
224
 
219
- const descText = this.theme.description(spacing + truncatedDesc);
225
+ const descText = theme.description(spacing + truncatedDesc);
220
226
  return prefix + truncatedValue + descText;
221
227
  }
222
228
  }
@@ -224,10 +230,10 @@ export class SelectList implements Component {
224
230
  const maxWidth = width - prefixWidth - 2;
225
231
  const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth);
226
232
  if (item.disabled) {
227
- return this.theme.description(`${prefix}${truncatedValue}`);
233
+ return theme.description(`${prefix}${truncatedValue}`);
228
234
  }
229
235
  if (isSelected) {
230
- return this.theme.selectedText(`${prefix}${truncatedValue}`);
236
+ return theme.selectedText(`${prefix}${truncatedValue}`);
231
237
  }
232
238
 
233
239
  return prefix + truncatedValue;
package/src/tui.ts CHANGED
@@ -349,15 +349,26 @@ export type SizeValue = number | `${number}%`;
349
349
  /** Parse a SizeValue into absolute value given a reference size */
350
350
  function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {
351
351
  if (value === undefined) return undefined;
352
- if (typeof value === "number") return value;
352
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
353
353
  // Parse percentage string like "50%"
354
354
  const match = value.match(/^(\d+(?:\.\d+)?)%$/);
355
355
  if (match) {
356
- return Math.floor((referenceSize * parseFloat(match[1])) / 100);
356
+ const percent = Number.parseFloat(match[1]);
357
+ if (!Number.isFinite(percent)) return undefined;
358
+ const parsed = Math.floor((referenceSize * percent) / 100);
359
+ return Number.isFinite(parsed) ? parsed : undefined;
357
360
  }
358
361
  return undefined;
359
362
  }
360
363
 
364
+ function finiteNumber(value: number | undefined, fallback: number): number {
365
+ return value !== undefined && Number.isFinite(value) ? value : fallback;
366
+ }
367
+
368
+ function finiteNonNegative(value: number | undefined, fallback = 0): number {
369
+ return Math.max(0, finiteNumber(value, fallback));
370
+ }
371
+
361
372
  const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
362
373
 
363
374
  function envIsEnabled(value: string | undefined): boolean {
@@ -2480,10 +2491,10 @@ export class TUI extends Container {
2480
2491
  typeof opt.margin === "number"
2481
2492
  ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
2482
2493
  : (opt.margin ?? {});
2483
- const marginTop = Math.max(0, margin.top ?? 0);
2484
- const marginRight = Math.max(0, margin.right ?? 0);
2485
- const marginBottom = Math.max(0, margin.bottom ?? 0);
2486
- const marginLeft = Math.max(0, margin.left ?? 0);
2494
+ const marginTop = Math.min(finiteNonNegative(margin.top), Math.max(0, termHeight - 1));
2495
+ const marginRight = Math.min(finiteNonNegative(margin.right), Math.max(0, termWidth - 1));
2496
+ const marginBottom = Math.min(finiteNonNegative(margin.bottom), Math.max(0, termHeight - 1 - marginTop));
2497
+ const marginLeft = Math.min(finiteNonNegative(margin.left), Math.max(0, termWidth - 1 - marginRight));
2487
2498
 
2488
2499
  // Available space after margins
2489
2500
  const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
@@ -2492,14 +2503,15 @@ export class TUI extends Container {
2492
2503
  // === Resolve width ===
2493
2504
  let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
2494
2505
  // Apply minWidth
2495
- if (opt.minWidth !== undefined) {
2506
+ if (opt.minWidth !== undefined && Number.isFinite(opt.minWidth)) {
2496
2507
  width = Math.max(width, opt.minWidth);
2497
2508
  }
2498
2509
  // Clamp to available space
2499
2510
  width = Math.max(1, Math.min(width, availWidth));
2500
2511
 
2501
2512
  // === Resolve maxHeight ===
2502
- let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
2513
+ const parsedMaxHeight = parseSizeValue(opt.maxHeight, termHeight);
2514
+ let maxHeight = opt.maxHeight !== undefined && parsedMaxHeight === undefined ? availHeight : parsedMaxHeight;
2503
2515
  // Clamp to available space
2504
2516
  if (maxHeight !== undefined) {
2505
2517
  maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
@@ -2519,14 +2531,18 @@ export class TUI extends Container {
2519
2531
  if (match) {
2520
2532
  const maxRow = Math.max(0, availHeight - effectiveHeight);
2521
2533
  const percent = parseFloat(match[1]) / 100;
2522
- row = marginTop + Math.floor(maxRow * percent);
2534
+ row = Number.isFinite(percent)
2535
+ ? marginTop + Math.floor(maxRow * percent)
2536
+ : this.#resolveAnchorRow(opt.anchor ?? "center", effectiveHeight, availHeight, marginTop);
2523
2537
  } else {
2524
2538
  // Invalid format, fall back to center
2525
2539
  row = this.#resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
2526
2540
  }
2527
- } else {
2541
+ } else if (Number.isFinite(opt.row)) {
2528
2542
  // Absolute row position
2529
2543
  row = opt.row;
2544
+ } else {
2545
+ row = this.#resolveAnchorRow(opt.anchor ?? "center", effectiveHeight, availHeight, marginTop);
2530
2546
  }
2531
2547
  } else {
2532
2548
  // Anchor-based (default: center)
@@ -2541,14 +2557,18 @@ export class TUI extends Container {
2541
2557
  if (match) {
2542
2558
  const maxCol = Math.max(0, availWidth - width);
2543
2559
  const percent = parseFloat(match[1]) / 100;
2544
- col = marginLeft + Math.floor(maxCol * percent);
2560
+ col = Number.isFinite(percent)
2561
+ ? marginLeft + Math.floor(maxCol * percent)
2562
+ : this.#resolveAnchorCol(opt.anchor ?? "center", width, availWidth, marginLeft);
2545
2563
  } else {
2546
2564
  // Invalid format, fall back to center
2547
2565
  col = this.#resolveAnchorCol("center", width, availWidth, marginLeft);
2548
2566
  }
2549
- } else {
2567
+ } else if (Number.isFinite(opt.col)) {
2550
2568
  // Absolute column position
2551
2569
  col = opt.col;
2570
+ } else {
2571
+ col = this.#resolveAnchorCol(opt.anchor ?? "center", width, availWidth, marginLeft);
2552
2572
  }
2553
2573
  } else {
2554
2574
  // Anchor-based (default: center)
@@ -2557,8 +2577,8 @@ export class TUI extends Container {
2557
2577
  }
2558
2578
 
2559
2579
  // Apply offsets
2560
- if (opt.offsetY !== undefined) row += opt.offsetY;
2561
- if (opt.offsetX !== undefined) col += opt.offsetX;
2580
+ row += finiteNumber(opt.offsetY, 0);
2581
+ col += finiteNumber(opt.offsetX, 0);
2562
2582
 
2563
2583
  // Clamp to terminal bounds (respecting margins)
2564
2584
  row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
@@ -2648,6 +2668,9 @@ export class TUI extends Container {
2648
2668
  // than the current content. Padding to it can cause the renderer to output hundreds/thousands of blank
2649
2669
  // lines, effectively scrolling the terminal when an overlay is shown.
2650
2670
  const workingHeight = Math.max(result.length, minLinesNeeded);
2671
+ if (!Number.isFinite(workingHeight)) {
2672
+ throw new Error("Overlay layout produced a non-finite working height");
2673
+ }
2651
2674
 
2652
2675
  // Extend result with empty lines if content is too short for overlay placement
2653
2676
  while (result.length < workingHeight) {
@@ -4040,21 +4063,6 @@ export class TUI extends Container {
4040
4063
  viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
4041
4064
  return;
4042
4065
  }
4043
- if (
4044
- appendedLines &&
4045
- nextLiveViewportTop > prevViewportTop &&
4046
- previousKittyPlacementSpans.some(placement =>
4047
- this.#kittyPlacementIntersectsRegion(placement, {
4048
- top: prevViewportTop,
4049
- bottom: prevViewportTop + height,
4050
- }),
4051
- )
4052
- ) {
4053
- viewportRepaint(
4054
- `content append moved a Kitty placement viewport (${prevViewportTop} -> ${nextLiveViewportTop})`,
4055
- );
4056
- return;
4057
- }
4058
4066
  if (distinctPostContractionRows) this.#scrollbackResumeViewportTop = undefined;
4059
4067
  if (
4060
4068
  appendedLines &&