cli-menu-kit 0.1.26 → 0.2.1

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 (78) hide show
  1. package/dist/api.d.ts +23 -5
  2. package/dist/api.js +16 -4
  3. package/dist/component-factories.d.ts +59 -0
  4. package/dist/component-factories.js +141 -0
  5. package/dist/components/display/header-v2.d.ts +13 -0
  6. package/dist/components/display/header-v2.js +43 -0
  7. package/dist/components/display/header.d.ts +40 -0
  8. package/dist/components/display/header.js +331 -18
  9. package/dist/components/display/headers.d.ts +1 -0
  10. package/dist/components/display/headers.js +15 -5
  11. package/dist/components/display/hints-v2.d.ts +10 -0
  12. package/dist/components/display/hints-v2.js +34 -0
  13. package/dist/components/display/hints.d.ts +56 -0
  14. package/dist/components/display/hints.js +81 -0
  15. package/dist/components/display/index.d.ts +4 -1
  16. package/dist/components/display/index.js +17 -1
  17. package/dist/components/display/input-prompt.d.ts +35 -0
  18. package/dist/components/display/input-prompt.js +36 -0
  19. package/dist/components/display/list.d.ts +49 -0
  20. package/dist/components/display/list.js +86 -0
  21. package/dist/components/display/messages.js +5 -5
  22. package/dist/components/display/progress.d.ts +17 -0
  23. package/dist/components/display/progress.js +18 -0
  24. package/dist/components/display/summary.js +72 -10
  25. package/dist/components/display/table.d.ts +44 -0
  26. package/dist/components/display/table.js +108 -0
  27. package/dist/components/inputs/language-input.js +8 -5
  28. package/dist/components/inputs/number-input.js +19 -14
  29. package/dist/components/inputs/text-input.js +50 -13
  30. package/dist/components/menus/boolean-menu.js +34 -20
  31. package/dist/components/menus/checkbox-menu.d.ts +2 -1
  32. package/dist/components/menus/checkbox-menu.js +35 -61
  33. package/dist/components/menus/checkbox-table-menu.d.ts +12 -0
  34. package/dist/components/menus/checkbox-table-menu.js +398 -0
  35. package/dist/components/menus/index.d.ts +1 -0
  36. package/dist/components/menus/index.js +3 -1
  37. package/dist/components/menus/radio-menu-split.d.ts +34 -0
  38. package/dist/components/menus/radio-menu-split.js +258 -0
  39. package/dist/components/menus/radio-menu-v2.d.ts +11 -0
  40. package/dist/components/menus/radio-menu-v2.js +150 -0
  41. package/dist/components/menus/radio-menu.d.ts +2 -1
  42. package/dist/components/menus/radio-menu.js +100 -134
  43. package/dist/components.js +3 -3
  44. package/dist/config/index.d.ts +5 -0
  45. package/dist/config/index.js +21 -0
  46. package/dist/config/language-config.d.ts +73 -0
  47. package/dist/config/language-config.js +157 -0
  48. package/dist/config/user-config.d.ts +83 -0
  49. package/dist/config/user-config.js +185 -0
  50. package/dist/core/colors.d.ts +24 -18
  51. package/dist/core/colors.js +74 -7
  52. package/dist/core/hint-manager.d.ts +29 -0
  53. package/dist/core/hint-manager.js +65 -0
  54. package/dist/core/renderer.d.ts +2 -1
  55. package/dist/core/renderer.js +46 -22
  56. package/dist/core/screen-manager.d.ts +54 -0
  57. package/dist/core/screen-manager.js +119 -0
  58. package/dist/core/state-manager.d.ts +27 -0
  59. package/dist/core/state-manager.js +56 -0
  60. package/dist/core/terminal.d.ts +17 -1
  61. package/dist/core/terminal.js +124 -4
  62. package/dist/core/virtual-scroll.d.ts +65 -0
  63. package/dist/core/virtual-scroll.js +120 -0
  64. package/dist/features/commands.js +23 -22
  65. package/dist/i18n/languages/en.js +4 -1
  66. package/dist/i18n/languages/zh.js +4 -1
  67. package/dist/i18n/registry.d.ts +4 -3
  68. package/dist/i18n/registry.js +12 -4
  69. package/dist/i18n/types.d.ts +3 -0
  70. package/dist/index.d.ts +7 -4
  71. package/dist/index.js +49 -4
  72. package/dist/layout.d.ts +67 -0
  73. package/dist/layout.js +86 -0
  74. package/dist/page-layout.d.ts +123 -0
  75. package/dist/page-layout.js +195 -0
  76. package/dist/types/input.types.d.ts +8 -0
  77. package/dist/types/menu.types.d.ts +61 -5
  78. package/package.json +4 -1
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Screen Manager - Manages screen regions with fixed-height layout
3
+ *
4
+ * Uses absolute cursor positioning and per-region caching for efficient updates.
5
+ * Only updates regions that have changed (diff-based rendering).
6
+ */
7
+ export interface Rect {
8
+ top: number;
9
+ left: number;
10
+ width: number;
11
+ height: number;
12
+ }
13
+ export declare class ScreenManager {
14
+ private cache;
15
+ private regions;
16
+ private isAltScreen;
17
+ /**
18
+ * Enter alternate screen buffer and hide cursor
19
+ */
20
+ enter(): void;
21
+ /**
22
+ * Exit alternate screen buffer and show cursor
23
+ */
24
+ exit(): void;
25
+ /**
26
+ * Move cursor to absolute position (1-based)
27
+ */
28
+ moveTo(row: number, col: number): void;
29
+ /**
30
+ * Register a fixed-height region
31
+ */
32
+ registerRegion(id: string, rect: Rect): void;
33
+ /**
34
+ * Render a region with diff-based updates
35
+ * Only updates lines that have changed
36
+ */
37
+ renderRegion(id: string, lines: string[]): void;
38
+ /**
39
+ * Clear a region (fill with spaces)
40
+ */
41
+ clearRegion(id: string): void;
42
+ /**
43
+ * Invalidate all cached content (forces full re-render on next update)
44
+ */
45
+ invalidateAll(): void;
46
+ /**
47
+ * Reset manager state
48
+ */
49
+ reset(): void;
50
+ /**
51
+ * Get region rect
52
+ */
53
+ getRegion(id: string): Rect | undefined;
54
+ }
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ /**
3
+ * Screen Manager - Manages screen regions with fixed-height layout
4
+ *
5
+ * Uses absolute cursor positioning and per-region caching for efficient updates.
6
+ * Only updates regions that have changed (diff-based rendering).
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.ScreenManager = void 0;
10
+ const CSI = '\x1b[';
11
+ /**
12
+ * Fit text to exact width by padding or truncating
13
+ */
14
+ function fitText(text, width) {
15
+ // Simple implementation - can be enhanced with ANSI-aware width calculation
16
+ const stripped = text.replace(/\x1b\[[0-9;]*m/g, ''); // Strip ANSI codes for length calc
17
+ const len = stripped.length;
18
+ if (len === width)
19
+ return text;
20
+ if (len < width)
21
+ return text + ' '.repeat(width - len);
22
+ // Truncate - preserve ANSI codes at start if present
23
+ const ansiMatch = text.match(/^(\x1b\[[0-9;]*m)*/);
24
+ const prefix = ansiMatch ? ansiMatch[0] : '';
25
+ const content = text.slice(prefix.length);
26
+ return prefix + content.slice(0, width);
27
+ }
28
+ class ScreenManager {
29
+ constructor() {
30
+ this.cache = new Map();
31
+ this.regions = new Map();
32
+ this.isAltScreen = false;
33
+ }
34
+ /**
35
+ * Enter alternate screen buffer and hide cursor
36
+ */
37
+ enter() {
38
+ if (!this.isAltScreen) {
39
+ process.stdout.write(`${CSI}?1049h${CSI}?25l`);
40
+ this.isAltScreen = true;
41
+ }
42
+ }
43
+ /**
44
+ * Exit alternate screen buffer and show cursor
45
+ */
46
+ exit() {
47
+ if (this.isAltScreen) {
48
+ process.stdout.write(`${CSI}?25h${CSI}?1049l`);
49
+ this.isAltScreen = false;
50
+ }
51
+ }
52
+ /**
53
+ * Move cursor to absolute position (1-based)
54
+ */
55
+ moveTo(row, col) {
56
+ process.stdout.write(`${CSI}${row};${col}H`);
57
+ }
58
+ /**
59
+ * Register a fixed-height region
60
+ */
61
+ registerRegion(id, rect) {
62
+ this.regions.set(id, rect);
63
+ }
64
+ /**
65
+ * Render a region with diff-based updates
66
+ * Only updates lines that have changed
67
+ */
68
+ renderRegion(id, lines) {
69
+ const rect = this.regions.get(id);
70
+ if (!rect) {
71
+ throw new Error(`Region ${id} not registered`);
72
+ }
73
+ const prev = this.cache.get(id) ?? [];
74
+ const next = Array.from({ length: rect.height }, (_, i) => fitText(lines[i] ?? '', rect.width));
75
+ // Update only changed lines
76
+ for (let i = 0; i < rect.height; i++) {
77
+ if (next[i] === prev[i])
78
+ continue;
79
+ this.moveTo(rect.top + i, rect.left);
80
+ process.stdout.write(next[i]);
81
+ }
82
+ this.cache.set(id, next);
83
+ }
84
+ /**
85
+ * Clear a region (fill with spaces)
86
+ */
87
+ clearRegion(id) {
88
+ const rect = this.regions.get(id);
89
+ if (!rect) {
90
+ throw new Error(`Region ${id} not registered`);
91
+ }
92
+ const blank = ' '.repeat(rect.width);
93
+ for (let i = 0; i < rect.height; i++) {
94
+ this.moveTo(rect.top + i, rect.left);
95
+ process.stdout.write(blank);
96
+ }
97
+ this.cache.set(id, Array(rect.height).fill(blank));
98
+ }
99
+ /**
100
+ * Invalidate all cached content (forces full re-render on next update)
101
+ */
102
+ invalidateAll() {
103
+ this.cache.clear();
104
+ }
105
+ /**
106
+ * Reset manager state
107
+ */
108
+ reset() {
109
+ this.cache.clear();
110
+ this.regions.clear();
111
+ }
112
+ /**
113
+ * Get region rect
114
+ */
115
+ getRegion(id) {
116
+ return this.regions.get(id);
117
+ }
118
+ }
119
+ exports.ScreenManager = ScreenManager;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared State Manager
3
+ * Allows components to share state without direct coupling
4
+ */
5
+ type StateListener<T> = (value: T) => void;
6
+ export declare class StateManager {
7
+ private states;
8
+ private listeners;
9
+ /**
10
+ * Set a state value and notify listeners
11
+ */
12
+ setState<T>(key: string, value: T): void;
13
+ /**
14
+ * Get a state value
15
+ */
16
+ getState<T>(key: string): T | undefined;
17
+ /**
18
+ * Subscribe to state changes
19
+ */
20
+ subscribe<T>(key: string, listener: StateListener<T>): () => void;
21
+ /**
22
+ * Clear all state
23
+ */
24
+ clear(): void;
25
+ }
26
+ export declare const globalState: StateManager;
27
+ export {};
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ /**
3
+ * Shared State Manager
4
+ * Allows components to share state without direct coupling
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.globalState = exports.StateManager = void 0;
8
+ class StateManager {
9
+ constructor() {
10
+ this.states = new Map();
11
+ this.listeners = new Map();
12
+ }
13
+ /**
14
+ * Set a state value and notify listeners
15
+ */
16
+ setState(key, value) {
17
+ this.states.set(key, value);
18
+ // Notify all listeners
19
+ const listeners = this.listeners.get(key);
20
+ if (listeners) {
21
+ listeners.forEach(listener => listener(value));
22
+ }
23
+ }
24
+ /**
25
+ * Get a state value
26
+ */
27
+ getState(key) {
28
+ return this.states.get(key);
29
+ }
30
+ /**
31
+ * Subscribe to state changes
32
+ */
33
+ subscribe(key, listener) {
34
+ if (!this.listeners.has(key)) {
35
+ this.listeners.set(key, new Set());
36
+ }
37
+ this.listeners.get(key).add(listener);
38
+ // Return unsubscribe function
39
+ return () => {
40
+ const listeners = this.listeners.get(key);
41
+ if (listeners) {
42
+ listeners.delete(listener);
43
+ }
44
+ };
45
+ }
46
+ /**
47
+ * Clear all state
48
+ */
49
+ clear() {
50
+ this.states.clear();
51
+ this.listeners.clear();
52
+ }
53
+ }
54
+ exports.StateManager = StateManager;
55
+ // Global state manager instance
56
+ exports.globalState = new StateManager();
@@ -9,12 +9,27 @@ export interface TerminalState {
9
9
  stdin: NodeJS.ReadStream;
10
10
  renderedLines: number;
11
11
  isRawMode: boolean;
12
+ useAltScreen: boolean;
12
13
  }
14
+ /**
15
+ * Remove ANSI escape sequences from a string.
16
+ * This is required when calculating the visible width in terminal cells.
17
+ */
18
+ export declare function stripAnsi(text: string): string;
19
+ /**
20
+ * Calculate the visible width of a string in terminal cells.
21
+ */
22
+ export declare function getDisplayWidth(text: string): number;
23
+ /**
24
+ * Count how many visual rows the text occupies after terminal wrapping.
25
+ */
26
+ export declare function countVisualLines(text: string, terminalWidth?: number): number;
13
27
  /**
14
28
  * Initialize terminal for interactive mode
29
+ * @param useAltScreen - Whether to use alternate screen buffer (prevents scroll issues)
15
30
  * @returns Terminal state object
16
31
  */
17
- export declare function initTerminal(): TerminalState;
32
+ export declare function initTerminal(useAltScreen?: boolean): TerminalState;
18
33
  /**
19
34
  * Restore terminal to normal mode
20
35
  * @param state - Terminal state
@@ -22,6 +37,7 @@ export declare function initTerminal(): TerminalState;
22
37
  export declare function restoreTerminal(state: TerminalState): void;
23
38
  /**
24
39
  * Clear the current menu display
40
+ * Only clears the lines that were rendered by this menu
25
41
  * @param state - Terminal state
26
42
  */
27
43
  export declare function clearMenu(state: TerminalState): void;
@@ -4,6 +4,9 @@
4
4
  * Handles cursor movement, screen clearing, and terminal state
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.stripAnsi = stripAnsi;
8
+ exports.getDisplayWidth = getDisplayWidth;
9
+ exports.countVisualLines = countVisualLines;
7
10
  exports.initTerminal = initTerminal;
8
11
  exports.restoreTerminal = restoreTerminal;
9
12
  exports.clearMenu = clearMenu;
@@ -20,22 +23,122 @@ exports.getTerminalWidth = getTerminalWidth;
20
23
  exports.getTerminalHeight = getTerminalHeight;
21
24
  exports.clearScreen = clearScreen;
22
25
  exports.exitWithGoodbye = exitWithGoodbye;
26
+ const ANSI_ESCAPE_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007|(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~])/g;
27
+ const TAB_WIDTH = 4;
28
+ /**
29
+ * Remove ANSI escape sequences from a string.
30
+ * This is required when calculating the visible width in terminal cells.
31
+ */
32
+ function stripAnsi(text) {
33
+ return text.replace(ANSI_ESCAPE_PATTERN, '');
34
+ }
35
+ function isCombiningCodePoint(codePoint) {
36
+ return ((codePoint >= 0x0300 && codePoint <= 0x036F) || // Combining Diacritical Marks
37
+ (codePoint >= 0x1AB0 && codePoint <= 0x1AFF) || // Combining Diacritical Marks Extended
38
+ (codePoint >= 0x1DC0 && codePoint <= 0x1DFF) || // Combining Diacritical Marks Supplement
39
+ (codePoint >= 0x20D0 && codePoint <= 0x20FF) || // Combining Diacritical Marks for Symbols
40
+ (codePoint >= 0xFE20 && codePoint <= 0xFE2F) // Combining Half Marks
41
+ );
42
+ }
43
+ function isFullWidthCodePoint(codePoint) {
44
+ if (codePoint < 0x1100) {
45
+ return false;
46
+ }
47
+ return (codePoint <= 0x115F ||
48
+ codePoint === 0x2329 ||
49
+ codePoint === 0x232A ||
50
+ ((codePoint >= 0x2E80 && codePoint <= 0x3247) && codePoint !== 0x303F) ||
51
+ (codePoint >= 0x3250 && codePoint <= 0x4DBF) ||
52
+ (codePoint >= 0x4E00 && codePoint <= 0xA4C6) ||
53
+ (codePoint >= 0xA960 && codePoint <= 0xA97C) ||
54
+ (codePoint >= 0xAC00 && codePoint <= 0xD7A3) ||
55
+ (codePoint >= 0xF900 && codePoint <= 0xFAFF) ||
56
+ (codePoint >= 0xFE10 && codePoint <= 0xFE19) ||
57
+ (codePoint >= 0xFE30 && codePoint <= 0xFE6B) ||
58
+ (codePoint >= 0xFF01 && codePoint <= 0xFF60) ||
59
+ (codePoint >= 0xFFE0 && codePoint <= 0xFFE6) ||
60
+ (codePoint >= 0x1B000 && codePoint <= 0x1B001) ||
61
+ (codePoint >= 0x1F200 && codePoint <= 0x1F251) ||
62
+ (codePoint >= 0x20000 && codePoint <= 0x3FFFD));
63
+ }
64
+ function getCharacterWidth(char, currentLineWidth) {
65
+ if (char === '\t') {
66
+ const remainder = currentLineWidth % TAB_WIDTH;
67
+ return remainder === 0 ? TAB_WIDTH : TAB_WIDTH - remainder;
68
+ }
69
+ const codePoint = char.codePointAt(0);
70
+ if (codePoint === undefined) {
71
+ return 0;
72
+ }
73
+ // Control characters and zero-width joiners/modifiers.
74
+ if (codePoint <= 0x001F ||
75
+ (codePoint >= 0x007F && codePoint <= 0x009F) ||
76
+ codePoint === 0x200D ||
77
+ (codePoint >= 0xFE00 && codePoint <= 0xFE0F) ||
78
+ isCombiningCodePoint(codePoint)) {
79
+ return 0;
80
+ }
81
+ if (isFullWidthCodePoint(codePoint) || (codePoint >= 0x1F300 && codePoint <= 0x1FAFF)) {
82
+ return 2;
83
+ }
84
+ return 1;
85
+ }
86
+ /**
87
+ * Calculate the visible width of a string in terminal cells.
88
+ */
89
+ function getDisplayWidth(text) {
90
+ const plain = stripAnsi(text);
91
+ let width = 0;
92
+ for (const char of plain) {
93
+ width += getCharacterWidth(char, width);
94
+ }
95
+ return width;
96
+ }
97
+ /**
98
+ * Count how many visual rows the text occupies after terminal wrapping.
99
+ */
100
+ function countVisualLines(text, terminalWidth = getTerminalWidth()) {
101
+ const width = Math.max(1, terminalWidth);
102
+ const lines = text.split(/\r\n|\r|\n/);
103
+ let lineCount = 0;
104
+ for (const line of lines) {
105
+ const visualWidth = getDisplayWidth(line);
106
+ lineCount += Math.max(1, Math.ceil(visualWidth / width));
107
+ }
108
+ return lineCount;
109
+ }
23
110
  /**
24
111
  * Initialize terminal for interactive mode
112
+ * @param useAltScreen - Whether to use alternate screen buffer (prevents scroll issues)
25
113
  * @returns Terminal state object
26
114
  */
27
- function initTerminal() {
115
+ function initTerminal(useAltScreen = false) {
28
116
  const stdin = process.stdin;
117
+ // Disable all mouse tracking modes BEFORE enabling raw mode
118
+ process.stdout.write('\x1b[?1000l'); // Disable normal mouse tracking
119
+ process.stdout.write('\x1b[?1001l'); // Disable highlight mouse tracking
120
+ process.stdout.write('\x1b[?1002l'); // Disable button event tracking
121
+ process.stdout.write('\x1b[?1003l'); // Disable any event tracking
122
+ process.stdout.write('\x1b[?1004l'); // Disable focus events
123
+ process.stdout.write('\x1b[?1005l'); // Disable UTF-8 mouse mode
124
+ process.stdout.write('\x1b[?1006l'); // Disable SGR extended mouse mode
125
+ process.stdout.write('\x1b[?1015l'); // Disable urxvt mouse mode
29
126
  // Enable raw mode for character-by-character input
30
127
  stdin.setRawMode(true);
31
128
  stdin.resume();
32
129
  stdin.setEncoding('utf8');
130
+ // Use alternate screen buffer if requested
131
+ if (useAltScreen) {
132
+ process.stdout.write('\x1b[?1049h'); // Enable alternate screen
133
+ process.stdout.write('\x1b[H'); // Move cursor to home
134
+ }
33
135
  // Hide cursor
34
136
  process.stdout.write('\x1b[?25l');
35
137
  return {
36
138
  stdin,
37
139
  renderedLines: 0,
38
- isRawMode: true
140
+ isRawMode: true,
141
+ useAltScreen
39
142
  };
40
143
  }
41
144
  /**
@@ -47,20 +150,37 @@ function restoreTerminal(state) {
47
150
  state.stdin.setRawMode(false);
48
151
  state.isRawMode = false;
49
152
  }
153
+ // Restore alternate screen if it was used
154
+ if (state.useAltScreen) {
155
+ process.stdout.write('\x1b[?1049l'); // Disable alternate screen
156
+ }
50
157
  // Show cursor
51
158
  process.stdout.write('\x1b[?25h');
52
159
  state.stdin.pause();
53
160
  }
54
161
  /**
55
162
  * Clear the current menu display
163
+ * Only clears the lines that were rendered by this menu
56
164
  * @param state - Terminal state
57
165
  */
58
166
  function clearMenu(state) {
59
167
  if (state.renderedLines > 0) {
60
168
  // Move cursor up to the start of the menu
61
169
  process.stdout.write(`\x1b[${state.renderedLines}A`);
62
- // Clear from cursor to end of screen
63
- process.stdout.write('\x1b[J');
170
+ // Clear each line individually
171
+ for (let i = 0; i < state.renderedLines; i++) {
172
+ process.stdout.write('\x1b[2K'); // Clear entire line
173
+ if (i < state.renderedLines - 1) {
174
+ process.stdout.write('\x1b[1B'); // Move down one line
175
+ }
176
+ }
177
+ // Move cursor back to start position
178
+ // After loop, cursor is at the last rendered line
179
+ // To get back to line 1, move up (renderedLines - 1)
180
+ // Note: \x1b[0A defaults to 1 in ANSI spec, so skip when renderedLines === 1
181
+ if (state.renderedLines > 1) {
182
+ process.stdout.write(`\x1b[${state.renderedLines - 1}A`);
183
+ }
64
184
  state.renderedLines = 0;
65
185
  }
66
186
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Virtual scrolling utilities for rendering large lists efficiently
3
+ * by only displaying a visible window of items.
4
+ */
5
+ export interface VirtualScrollOptions<T> {
6
+ /** All items in the list */
7
+ items: T[];
8
+ /** Current cursor/focus position (index in items array) */
9
+ cursorIndex: number;
10
+ /** Target number of lines to display */
11
+ targetLines: number;
12
+ /** Function to calculate how many lines each item will occupy when rendered */
13
+ getItemLineCount: (item: T, index: number) => number;
14
+ }
15
+ export interface VirtualScrollResult {
16
+ /** Start index of visible range (inclusive) */
17
+ visibleStart: number;
18
+ /** End index of visible range (exclusive) */
19
+ visibleEnd: number;
20
+ /** Actual number of lines that will be rendered */
21
+ actualLines: number;
22
+ /** Whether virtual scrolling is active (content exceeds target) */
23
+ isScrolled: boolean;
24
+ /** Whether there are items before the visible range */
25
+ hasItemsBefore: boolean;
26
+ /** Whether there are items after the visible range */
27
+ hasItemsAfter: boolean;
28
+ }
29
+ /**
30
+ * Calculate visible range for virtual scrolling based on line count.
31
+ *
32
+ * This function maintains a stable viewport height by calculating which items
33
+ * to display based on their actual line count, not just item count. This prevents
34
+ * height jumping when items have varying heights (e.g., separators with descriptions).
35
+ *
36
+ * Algorithm:
37
+ * 1. Calculate total lines needed for all items
38
+ * 2. If total <= targetLines, show everything (no scrolling)
39
+ * 3. Otherwise, create a window centered on cursor:
40
+ * - Start from cursor position
41
+ * - Expand downward until reaching target or end
42
+ * - Expand upward to fill remaining space
43
+ * - Expand downward again if space remains
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * const result = calculateVirtualScroll({
48
+ * items: menuOptions,
49
+ * cursorIndex: 10,
50
+ * targetLines: 30,
51
+ * getItemLineCount: (item, index) => {
52
+ * if (item.type === 'separator') {
53
+ * return 1 + (item.description ? 1 : 0) + (index > 0 ? 1 : 0);
54
+ * }
55
+ * return 1;
56
+ * }
57
+ * });
58
+ *
59
+ * // Render only visible items
60
+ * for (let i = result.visibleStart; i < result.visibleEnd; i++) {
61
+ * renderItem(items[i]);
62
+ * }
63
+ * ```
64
+ */
65
+ export declare function calculateVirtualScroll<T>(options: VirtualScrollOptions<T>): VirtualScrollResult;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ /**
3
+ * Virtual scrolling utilities for rendering large lists efficiently
4
+ * by only displaying a visible window of items.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.calculateVirtualScroll = calculateVirtualScroll;
8
+ /**
9
+ * Calculate visible range for virtual scrolling based on line count.
10
+ *
11
+ * This function maintains a stable viewport height by calculating which items
12
+ * to display based on their actual line count, not just item count. This prevents
13
+ * height jumping when items have varying heights (e.g., separators with descriptions).
14
+ *
15
+ * Algorithm:
16
+ * 1. Calculate total lines needed for all items
17
+ * 2. If total <= targetLines, show everything (no scrolling)
18
+ * 3. Otherwise, create a window centered on cursor:
19
+ * - Start from cursor position
20
+ * - Expand downward until reaching target or end
21
+ * - Expand upward to fill remaining space
22
+ * - Expand downward again if space remains
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * const result = calculateVirtualScroll({
27
+ * items: menuOptions,
28
+ * cursorIndex: 10,
29
+ * targetLines: 30,
30
+ * getItemLineCount: (item, index) => {
31
+ * if (item.type === 'separator') {
32
+ * return 1 + (item.description ? 1 : 0) + (index > 0 ? 1 : 0);
33
+ * }
34
+ * return 1;
35
+ * }
36
+ * });
37
+ *
38
+ * // Render only visible items
39
+ * for (let i = result.visibleStart; i < result.visibleEnd; i++) {
40
+ * renderItem(items[i]);
41
+ * }
42
+ * ```
43
+ */
44
+ function calculateVirtualScroll(options) {
45
+ const { items, cursorIndex, targetLines, getItemLineCount } = options;
46
+ // Validate inputs
47
+ if (items.length === 0) {
48
+ return {
49
+ visibleStart: 0,
50
+ visibleEnd: 0,
51
+ actualLines: 0,
52
+ isScrolled: false,
53
+ hasItemsBefore: false,
54
+ hasItemsAfter: false
55
+ };
56
+ }
57
+ if (cursorIndex < 0 || cursorIndex >= items.length) {
58
+ throw new Error(`cursorIndex ${cursorIndex} is out of bounds [0, ${items.length})`);
59
+ }
60
+ // Calculate total lines for all items
61
+ const estimatedTotalLines = items.reduce((sum, item, idx) => {
62
+ return sum + getItemLineCount(item, idx);
63
+ }, 0);
64
+ // If content fits within target, show everything
65
+ if (estimatedTotalLines <= targetLines) {
66
+ return {
67
+ visibleStart: 0,
68
+ visibleEnd: items.length,
69
+ actualLines: estimatedTotalLines,
70
+ isScrolled: false,
71
+ hasItemsBefore: false,
72
+ hasItemsAfter: false
73
+ };
74
+ }
75
+ // Virtual scrolling: maintain constant line count
76
+ let visibleStart = cursorIndex;
77
+ let visibleEnd = cursorIndex + 1;
78
+ let currentLines = getItemLineCount(items[cursorIndex], cursorIndex);
79
+ // Phase 1: Expand downward from cursor
80
+ while (visibleEnd < items.length && currentLines < targetLines) {
81
+ const nextLines = getItemLineCount(items[visibleEnd], visibleEnd);
82
+ if (currentLines + nextLines <= targetLines) {
83
+ currentLines += nextLines;
84
+ visibleEnd++;
85
+ }
86
+ else {
87
+ break;
88
+ }
89
+ }
90
+ // Phase 2: Expand upward to fill remaining space
91
+ while (visibleStart > 0 && currentLines < targetLines) {
92
+ const prevLines = getItemLineCount(items[visibleStart - 1], visibleStart - 1);
93
+ if (currentLines + prevLines <= targetLines) {
94
+ visibleStart--;
95
+ currentLines += prevLines;
96
+ }
97
+ else {
98
+ break;
99
+ }
100
+ }
101
+ // Phase 3: Try expanding downward again if space remains
102
+ while (visibleEnd < items.length && currentLines < targetLines) {
103
+ const nextLines = getItemLineCount(items[visibleEnd], visibleEnd);
104
+ if (currentLines + nextLines <= targetLines) {
105
+ currentLines += nextLines;
106
+ visibleEnd++;
107
+ }
108
+ else {
109
+ break;
110
+ }
111
+ }
112
+ return {
113
+ visibleStart,
114
+ visibleEnd,
115
+ actualLines: currentLines,
116
+ isScrolled: true,
117
+ hasItemsBefore: visibleStart > 0,
118
+ hasItemsAfter: visibleEnd < items.length
119
+ };
120
+ }