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.
- package/dist/api.d.ts +23 -5
- package/dist/api.js +16 -4
- package/dist/component-factories.d.ts +59 -0
- package/dist/component-factories.js +141 -0
- package/dist/components/display/header-v2.d.ts +13 -0
- package/dist/components/display/header-v2.js +43 -0
- package/dist/components/display/header.d.ts +40 -0
- package/dist/components/display/header.js +331 -18
- package/dist/components/display/headers.d.ts +1 -0
- package/dist/components/display/headers.js +15 -5
- package/dist/components/display/hints-v2.d.ts +10 -0
- package/dist/components/display/hints-v2.js +34 -0
- package/dist/components/display/hints.d.ts +56 -0
- package/dist/components/display/hints.js +81 -0
- package/dist/components/display/index.d.ts +4 -1
- package/dist/components/display/index.js +17 -1
- package/dist/components/display/input-prompt.d.ts +35 -0
- package/dist/components/display/input-prompt.js +36 -0
- package/dist/components/display/list.d.ts +49 -0
- package/dist/components/display/list.js +86 -0
- package/dist/components/display/messages.js +5 -5
- package/dist/components/display/progress.d.ts +17 -0
- package/dist/components/display/progress.js +18 -0
- package/dist/components/display/summary.js +72 -10
- package/dist/components/display/table.d.ts +44 -0
- package/dist/components/display/table.js +108 -0
- package/dist/components/inputs/language-input.js +8 -5
- package/dist/components/inputs/number-input.js +19 -14
- package/dist/components/inputs/text-input.js +50 -13
- package/dist/components/menus/boolean-menu.js +34 -20
- package/dist/components/menus/checkbox-menu.d.ts +2 -1
- package/dist/components/menus/checkbox-menu.js +35 -61
- package/dist/components/menus/checkbox-table-menu.d.ts +12 -0
- package/dist/components/menus/checkbox-table-menu.js +398 -0
- package/dist/components/menus/index.d.ts +1 -0
- package/dist/components/menus/index.js +3 -1
- package/dist/components/menus/radio-menu-split.d.ts +34 -0
- package/dist/components/menus/radio-menu-split.js +258 -0
- package/dist/components/menus/radio-menu-v2.d.ts +11 -0
- package/dist/components/menus/radio-menu-v2.js +150 -0
- package/dist/components/menus/radio-menu.d.ts +2 -1
- package/dist/components/menus/radio-menu.js +100 -134
- package/dist/components.js +3 -3
- package/dist/config/index.d.ts +5 -0
- package/dist/config/index.js +21 -0
- package/dist/config/language-config.d.ts +73 -0
- package/dist/config/language-config.js +157 -0
- package/dist/config/user-config.d.ts +83 -0
- package/dist/config/user-config.js +185 -0
- package/dist/core/colors.d.ts +24 -18
- package/dist/core/colors.js +74 -7
- package/dist/core/hint-manager.d.ts +29 -0
- package/dist/core/hint-manager.js +65 -0
- package/dist/core/renderer.d.ts +2 -1
- package/dist/core/renderer.js +46 -22
- package/dist/core/screen-manager.d.ts +54 -0
- package/dist/core/screen-manager.js +119 -0
- package/dist/core/state-manager.d.ts +27 -0
- package/dist/core/state-manager.js +56 -0
- package/dist/core/terminal.d.ts +17 -1
- package/dist/core/terminal.js +124 -4
- package/dist/core/virtual-scroll.d.ts +65 -0
- package/dist/core/virtual-scroll.js +120 -0
- package/dist/features/commands.js +23 -22
- package/dist/i18n/languages/en.js +4 -1
- package/dist/i18n/languages/zh.js +4 -1
- package/dist/i18n/registry.d.ts +4 -3
- package/dist/i18n/registry.js +12 -4
- package/dist/i18n/types.d.ts +3 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +49 -4
- package/dist/layout.d.ts +67 -0
- package/dist/layout.js +86 -0
- package/dist/page-layout.d.ts +123 -0
- package/dist/page-layout.js +195 -0
- package/dist/types/input.types.d.ts +8 -0
- package/dist/types/menu.types.d.ts +61 -5
- 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();
|
package/dist/core/terminal.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/terminal.js
CHANGED
|
@@ -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
|
|
63
|
-
|
|
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
|
+
}
|