@fractaal/pi-tui 0.85.3 → 0.86.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/README.md +175 -19
- package/dist/alt-screen-search.d.ts +46 -0
- package/dist/alt-screen-search.d.ts.map +1 -0
- package/dist/alt-screen-search.js +282 -0
- package/dist/alt-screen-search.js.map +1 -0
- package/dist/autocomplete.d.ts +2 -1
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +85 -24
- package/dist/autocomplete.js.map +1 -1
- package/dist/colors.d.ts +73 -0
- package/dist/colors.d.ts.map +1 -0
- package/dist/colors.js +273 -0
- package/dist/colors.js.map +1 -0
- package/dist/components/alt-screen-flash.d.ts +13 -0
- package/dist/components/alt-screen-flash.d.ts.map +1 -0
- package/dist/components/alt-screen-flash.js +37 -0
- package/dist/components/alt-screen-flash.js.map +1 -0
- package/dist/components/box.d.ts +3 -1
- package/dist/components/box.d.ts.map +1 -1
- package/dist/components/box.js +38 -8
- package/dist/components/box.js.map +1 -1
- package/dist/components/cancellable-loader.d.ts.map +1 -1
- package/dist/components/cancellable-loader.js.map +1 -1
- package/dist/components/editor.d.ts +6 -1
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +118 -24
- package/dist/components/editor.js.map +1 -1
- package/dist/components/h-stack.d.ts +7 -0
- package/dist/components/h-stack.d.ts.map +1 -0
- package/dist/components/h-stack.js +43 -0
- package/dist/components/h-stack.js.map +1 -0
- package/dist/components/image.d.ts +12 -0
- package/dist/components/image.d.ts.map +1 -1
- package/dist/components/image.js +36 -3
- package/dist/components/image.js.map +1 -1
- package/dist/components/input.d.ts +12 -1
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +44 -5
- package/dist/components/input.js.map +1 -1
- package/dist/components/loader.d.ts +2 -0
- package/dist/components/loader.d.ts.map +1 -1
- package/dist/components/loader.js +10 -3
- package/dist/components/loader.js.map +1 -1
- package/dist/components/markdown.d.ts +5 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +150 -9
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/mouse-region.d.ts +12 -0
- package/dist/components/mouse-region.d.ts.map +1 -0
- package/dist/components/mouse-region.js +21 -0
- package/dist/components/mouse-region.js.map +1 -0
- package/dist/components/scroll-view.d.ts +59 -0
- package/dist/components/scroll-view.d.ts.map +1 -0
- package/dist/components/scroll-view.js +195 -0
- package/dist/components/scroll-view.js.map +1 -0
- package/dist/components/select-list.d.ts +4 -1
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js +49 -2
- package/dist/components/select-list.js.map +1 -1
- package/dist/components/settings-list.d.ts +13 -3
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js +82 -15
- package/dist/components/settings-list.js.map +1 -1
- package/dist/components/spacer.d.ts.map +1 -1
- package/dist/components/spacer.js.map +1 -1
- package/dist/components/stack.d.ts +32 -0
- package/dist/components/stack.d.ts.map +1 -0
- package/dist/components/stack.js +111 -0
- package/dist/components/stack.js.map +1 -0
- package/dist/components/text.d.ts.map +1 -1
- package/dist/components/text.js +7 -5
- package/dist/components/text.js.map +1 -1
- package/dist/components/truncated-text.d.ts.map +1 -1
- package/dist/components/truncated-text.js.map +1 -1
- package/dist/components/v-stack.d.ts +8 -0
- package/dist/components/v-stack.d.ts.map +1 -0
- package/dist/components/v-stack.js +26 -0
- package/dist/components/v-stack.js.map +1 -0
- package/dist/editor-component.d.ts.map +1 -1
- package/dist/fuzzy.d.ts.map +1 -1
- package/dist/fuzzy.js +23 -22
- package/dist/fuzzy.js.map +1 -1
- package/dist/index.d.ts +18 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -6
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +82 -2
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +61 -2
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js.map +1 -1
- package/dist/kill-ring.d.ts.map +1 -1
- package/dist/kill-ring.js.map +1 -1
- package/dist/latex.d.ts +10 -0
- package/dist/latex.d.ts.map +1 -0
- package/dist/latex.js +1378 -0
- package/dist/latex.js.map +1 -0
- package/dist/layout-node.d.ts +40 -0
- package/dist/layout-node.d.ts.map +1 -0
- package/dist/layout-node.js +6 -0
- package/dist/layout-node.js.map +1 -0
- package/dist/layout.d.ts +42 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +344 -0
- package/dist/layout.js.map +1 -0
- package/dist/native-modifiers.d.ts +2 -1
- package/dist/native-modifiers.d.ts.map +1 -1
- package/dist/native-modifiers.js +3 -43
- package/dist/native-modifiers.js.map +1 -1
- package/dist/native-module-path.d.ts +7 -0
- package/dist/native-module-path.d.ts.map +1 -0
- package/dist/native-module-path.js +19 -0
- package/dist/native-module-path.js.map +1 -0
- package/dist/native-platform.d.ts +20 -0
- package/dist/native-platform.d.ts.map +1 -0
- package/dist/native-platform.js +42 -0
- package/dist/native-platform.js.map +1 -0
- package/dist/oklab.d.ts +43 -0
- package/dist/oklab.d.ts.map +1 -0
- package/dist/oklab.js +210 -0
- package/dist/oklab.js.map +1 -0
- package/dist/stdin-buffer.d.ts +8 -2
- package/dist/stdin-buffer.d.ts.map +1 -1
- package/dist/stdin-buffer.js +7 -2
- package/dist/stdin-buffer.js.map +1 -1
- package/dist/terminal-colors.d.ts +19 -2
- package/dist/terminal-colors.d.ts.map +1 -1
- package/dist/terminal-colors.js +13 -8
- package/dist/terminal-colors.js.map +1 -1
- package/dist/terminal-image.d.ts +29 -1
- package/dist/terminal-image.d.ts.map +1 -1
- package/dist/terminal-image.js +223 -18
- package/dist/terminal-image.js.map +1 -1
- package/dist/terminal.d.ts +16 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +62 -46
- package/dist/terminal.js.map +1 -1
- package/dist/tui-alt-screen.d.ts +166 -0
- package/dist/tui-alt-screen.d.ts.map +1 -0
- package/dist/tui-alt-screen.js +1546 -0
- package/dist/tui-alt-screen.js.map +1 -0
- package/dist/tui-main-screen.d.ts +39 -0
- package/dist/tui-main-screen.d.ts.map +1 -0
- package/dist/tui-main-screen.js +594 -0
- package/dist/tui-main-screen.js.map +1 -0
- package/dist/tui.d.ts +178 -60
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +303 -641
- package/dist/tui.js.map +1 -1
- package/dist/undo-stack.d.ts.map +1 -1
- package/dist/undo-stack.js.map +1 -1
- package/dist/utils.d.ts +22 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +245 -63
- package/dist/utils.js.map +1 -1
- package/dist/wheel-scroll.d.ts +23 -0
- package/dist/wheel-scroll.d.ts.map +1 -0
- package/dist/wheel-scroll.js +73 -0
- package/dist/wheel-scroll.js.map +1 -0
- package/dist/word-navigation.d.ts.map +1 -1
- package/dist/word-navigation.js.map +1 -1
- package/native/clipboard.h +208 -0
- package/native/darwin/README.md +22 -0
- package/native/darwin/build.sh +69 -0
- package/native/darwin/prebuilds/darwin-arm64/{darwin-modifiers.node → darwin-platform.node} +0 -0
- package/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/native/darwin/src/darwin-platform.m +132 -0
- package/native/linux/README.md +23 -0
- package/native/linux/build.sh +65 -0
- package/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/native/linux/src/clipboard-worker.h +70 -0
- package/native/linux/src/linux-platform-x11.c +274 -0
- package/native/napi.h +91 -0
- package/native/win32/README.md +31 -0
- package/native/win32/build.mjs +232 -0
- package/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/native/win32/src/win32-platform.c +241 -0
- package/package.json +19 -4
- package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
- package/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
- package/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
package/README.md
CHANGED
|
@@ -4,25 +4,27 @@ Minimal terminal UI framework with differential rendering and synchronized outpu
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **
|
|
7
|
+
- **Interchangeable Renderers**: Shared `TUI` interface with main-screen and alternate-screen implementations
|
|
8
|
+
- **Differential Rendering**: Updates only changed lines or viewport rows
|
|
9
|
+
- **Application-owned Scrolling**: Alternate-screen viewport supports mouse, trackpad, and keyboard navigation
|
|
8
10
|
- **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker)
|
|
9
11
|
- **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes
|
|
10
12
|
- **Component-based**: Simple Component interface with render() method
|
|
11
13
|
- **Theme Support**: Components accept theme interfaces for customizable styling
|
|
12
|
-
- **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container
|
|
14
|
+
- **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, MouseRegion, Spacer, Image, Box, Container, VStack, HStack, ScrollView
|
|
13
15
|
- **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols
|
|
14
16
|
- **Autocomplete Support**: File paths and slash commands
|
|
15
17
|
|
|
16
18
|
## Quick Start
|
|
17
19
|
|
|
18
20
|
```typescript
|
|
19
|
-
import { TUI, Text, Editor, ProcessTerminal, matchesKey } from "@earendil-works/pi-tui";
|
|
21
|
+
import { type TUI, Text, Editor, ProcessTerminal, TuiMainScreen, matchesKey } from "@earendil-works/pi-tui";
|
|
20
22
|
|
|
21
23
|
// Create terminal
|
|
22
24
|
const terminal = new ProcessTerminal();
|
|
23
25
|
|
|
24
|
-
// Create TUI
|
|
25
|
-
const tui = new
|
|
26
|
+
// Create the default main-screen renderer through the shared TUI interface
|
|
27
|
+
const tui: TUI = new TuiMainScreen(terminal);
|
|
26
28
|
|
|
27
29
|
// Add components
|
|
28
30
|
tui.addChild(new Text("Welcome to my app!"));
|
|
@@ -52,12 +54,20 @@ tui.start();
|
|
|
52
54
|
|
|
53
55
|
## Core API
|
|
54
56
|
|
|
55
|
-
### TUI
|
|
57
|
+
### TUI interface and renderers
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
`TUI` is the shared interface for component management, focus, overlays, input, lifecycle, terminal queries, and rendering. Choose a concrete renderer only when constructing the application:
|
|
60
|
+
|
|
61
|
+
- `TuiMainScreen` renders into the main terminal buffer and preserves terminal scrollback.
|
|
62
|
+
- `TuiAltScreen` renders a fixed-height viewport in the alternate terminal buffer with application-owned scrolling. When stopped, it restores the main buffer and prints the complete final document.
|
|
58
63
|
|
|
59
64
|
```typescript
|
|
60
|
-
|
|
65
|
+
import { type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui";
|
|
66
|
+
|
|
67
|
+
const tui: TUI = new TuiMainScreen(terminal);
|
|
68
|
+
// To use an application-owned viewport in the alternate terminal buffer instead:
|
|
69
|
+
// const tui: TUI = new TuiAltScreen(terminal);
|
|
70
|
+
|
|
61
71
|
tui.addChild(component);
|
|
62
72
|
tui.removeChild(component);
|
|
63
73
|
tui.start();
|
|
@@ -68,6 +78,91 @@ tui.requestRender(); // Request a re-render
|
|
|
68
78
|
tui.onDebug = () => console.log("Debug triggered");
|
|
69
79
|
```
|
|
70
80
|
|
|
81
|
+
### Colors and terminal styles
|
|
82
|
+
|
|
83
|
+
Colors are values that can be converted or mixed before terminal rendering:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
import {
|
|
87
|
+
colorToRgb,
|
|
88
|
+
foregroundAnsi,
|
|
89
|
+
getTerminalColorMode,
|
|
90
|
+
mixColors,
|
|
91
|
+
parseColor,
|
|
92
|
+
rgbColor,
|
|
93
|
+
styleText,
|
|
94
|
+
} from "@earendil-works/pi-tui";
|
|
95
|
+
|
|
96
|
+
const accent = parseColor("oklch(70% 0.12 220)");
|
|
97
|
+
const background = parseColor("#20242a");
|
|
98
|
+
const foreground = mixColors(accent, background, 0.2);
|
|
99
|
+
|
|
100
|
+
const text = styleText(
|
|
101
|
+
"Ready",
|
|
102
|
+
{ fg: foreground, bg: background, bold: true },
|
|
103
|
+
getTerminalColorMode(),
|
|
104
|
+
);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`Color` is an indexed ANSI color, an sRGB color, or an OKLCH color. Every color converts to sRGB, so color math such as `mixColors()` always works. Indices 0-15 follow the user's terminal palette, so their sRGB values are approximations. `styleText()` converts colors to truecolor or 256-color output based on the requested terminal mode.
|
|
108
|
+
|
|
109
|
+
`parseColor()` also accepts OKHSL, as in `okhsl(250 60% 55%)`; `okhslColor()` builds it in code and `colorToOkhsl()` reads any color's OKHSL channels. OKHSL saturation is relative to the most the sRGB gamut allows at the hue and lightness, so every value is in gamut and equal saturation looks equally colorful across hues. OKHSL colors are converted to sRGB when created.
|
|
110
|
+
|
|
111
|
+
Conversions are not cached. OKLCH colors, especially ones outside the sRGB gamut, are more expensive to convert than sRGB or indexed colors. For colors used on every render, convert once and reuse the result:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
const { r, g, b } = colorToRgb(mixColors(accent, background, 0.2));
|
|
115
|
+
const foreground = rgbColor(r, g, b); // cheap to render repeatedly
|
|
116
|
+
const foregroundCode = foregroundAnsi(foreground, getTerminalColorMode());
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Alternate-screen viewport layouts
|
|
120
|
+
|
|
121
|
+
`TuiAltScreen` can render an explicit terminal-height layout. `VStack` and `HStack` allocate constrained regions, while `ScrollView` owns scrolling for one region. These semantics are intentionally unavailable on `TuiMainScreen`, where the terminal owns scrollback.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import {
|
|
125
|
+
Container,
|
|
126
|
+
isViewportTUI,
|
|
127
|
+
ScrollView,
|
|
128
|
+
Text,
|
|
129
|
+
VStack,
|
|
130
|
+
} from "@earendil-works/pi-tui";
|
|
131
|
+
|
|
132
|
+
const transcript = new Container();
|
|
133
|
+
transcript.addChild(new Text("History"));
|
|
134
|
+
|
|
135
|
+
const editorAndFooter = new VStack([
|
|
136
|
+
editor,
|
|
137
|
+
new Text("status"),
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
if (isViewportTUI(tui)) {
|
|
141
|
+
tui.setLayoutRoot(new VStack([
|
|
142
|
+
{
|
|
143
|
+
component: new ScrollView(transcript, {
|
|
144
|
+
follow: "end",
|
|
145
|
+
primary: true,
|
|
146
|
+
overscroll: "chain",
|
|
147
|
+
}),
|
|
148
|
+
basis: 0,
|
|
149
|
+
grow: 1,
|
|
150
|
+
minSize: 1,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
component: editorAndFooter,
|
|
154
|
+
basis: "auto",
|
|
155
|
+
shrink: 1,
|
|
156
|
+
minSize: 1,
|
|
157
|
+
},
|
|
158
|
+
]));
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Stack entries support `basis`, `grow`, `shrink`, `minSize`, `maxSize`, and responsive `visible` callbacks. Mouse-wheel input targets the scroll view under the pointer and unused delta chains to outer scroll views by default. The primary scroll view receives the alternate-screen keyboard navigation actions and wheel input over non-scrollable regions. It can also jump between OSC 133 semantic prompt markers, matching common terminal prompt-navigation shortcuts. Press `Ctrl+Shift+F` to open or close its bordered search panel. The panel shows the configured previous/next shortcuts and provides clickable arrow controls; by default, `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G` move between matches, and `Escape` also closes search. `TuiAltScreenOptions.searchMatchStyle` and `searchCurrentMatchStyle` customize match highlighting, while `searchNavigationButtonStyle` styles each arrow button and receives its hover state. `TuiAltScreenOptions.scrollToEndIndicator` renders a clickable label centered on the last row of a `follow: "end"` primary scroll view while it is scrolled away from the end; clicking it resumes end-following.
|
|
163
|
+
|
|
164
|
+
Layout geometry is rebuilt for each requested frame. Stateful components are retained, and their existing rendered-line caches remain effective. Calling `render(width)` directly on these layout components produces an unbounded document, which is also used when alt mode restores the main screen.
|
|
165
|
+
|
|
71
166
|
### Overlays
|
|
72
167
|
|
|
73
168
|
Overlays render components on top of existing content without replacing it. Useful for dialogs, menus, and modal UI.
|
|
@@ -120,6 +215,7 @@ handle.unfocus(); // Release focus to normal fallback
|
|
|
120
215
|
handle.unfocus({ target: baseComponent }); // Release this overlay to a specific component
|
|
121
216
|
handle.unfocus({ target: null }); // Release this overlay and leave focus empty
|
|
122
217
|
handle.isFocused(); // Check if overlay has focus
|
|
218
|
+
handle.getBounds(); // Get last rendered terminal-relative bounds
|
|
123
219
|
|
|
124
220
|
handle.unfocus();
|
|
125
221
|
// Overlay loses focus; TUI falls back to another visible capturing overlay or the previous focus target.
|
|
@@ -154,7 +250,8 @@ All components implement:
|
|
|
154
250
|
interface Component {
|
|
155
251
|
render(width: number): string[];
|
|
156
252
|
handleInput?(data: string): void;
|
|
157
|
-
|
|
253
|
+
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
|
254
|
+
invalidate(): void;
|
|
158
255
|
}
|
|
159
256
|
```
|
|
160
257
|
|
|
@@ -162,10 +259,50 @@ interface Component {
|
|
|
162
259
|
|--------|-------------|
|
|
163
260
|
| `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. |
|
|
164
261
|
| `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). |
|
|
165
|
-
| `
|
|
262
|
+
| `handleMouse?(event)` | Called by `TuiAltScreen` for normalized pointer input targeted at the component. |
|
|
263
|
+
| `invalidate()` | Required. Clear any cached render state so the next `render()` starts from scratch. Components without cached render state can use an empty implementation. |
|
|
166
264
|
|
|
167
265
|
The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line.
|
|
168
266
|
|
|
267
|
+
### Mouse Input
|
|
268
|
+
|
|
269
|
+
`TuiAltScreen` normalizes SGR mouse input and hit-tests components and overlays. Events contain component-local `x`/`y`, absolute `screenX`/`screenY`, bounds, button, modifiers, click count, and wheel delta. `TuiMainScreen` does not capture mouse input because the terminal owns its scrollback.
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
import type { TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
273
|
+
|
|
274
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
275
|
+
if (event.type === "click" && event.button === "left") {
|
|
276
|
+
this.expanded = !this.expanded;
|
|
277
|
+
return { handled: true };
|
|
278
|
+
}
|
|
279
|
+
if (event.type === "press" && event.button === "left") {
|
|
280
|
+
return { handled: true, capture: true, focus: true };
|
|
281
|
+
}
|
|
282
|
+
if (event.type === "drag") {
|
|
283
|
+
this.updateFromPointer(event.x, event.y);
|
|
284
|
+
return { handled: true, render: true };
|
|
285
|
+
}
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Returning `handled` suppresses renderer-level fallback behavior. `capture` keeps subsequent drag and release events routed to the same component. `focus` requests keyboard focus. The optional `render` flag controls repainting: press, click, drag, and wheel default to rendering; move and release do not. Set `render: true` for a hover state that visibly changed, or `render: false` for a handled no-op. Render requests are coalesced and terminal output remains differential.
|
|
291
|
+
|
|
292
|
+
Unhandled gestures retain alternate-screen defaults: wheel input scrolls the nearest `ScrollView` and chains unused delta, primary-button drags select text, OSC 8 links open before parent click handlers, and unhandled right-click preserves configured paste behavior. A click is emitted only when press/release completes without a drag.
|
|
293
|
+
|
|
294
|
+
Use `MouseRegion` to add mouse behavior without changing a component's rendering:
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
const collapsible = new MouseRegion(content, (event) => {
|
|
298
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
299
|
+
expanded = !expanded;
|
|
300
|
+
return { handled: true };
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
`Container` and `Box` route events to nested children using geometry recorded by the last rendered frame, so pointer motion does not rerender children merely to hit-test them. Explicit `VStack`, `HStack`, and `ScrollView` layouts use the alternate-screen layout frame directly.
|
|
305
|
+
|
|
169
306
|
### Focusable Interface (IME Support)
|
|
170
307
|
|
|
171
308
|
Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:
|
|
@@ -181,6 +318,8 @@ class MyInput implements Component, Focusable {
|
|
|
181
318
|
// Emit marker right before the fake cursor
|
|
182
319
|
return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
|
|
183
320
|
}
|
|
321
|
+
|
|
322
|
+
invalidate(): void {}
|
|
184
323
|
}
|
|
185
324
|
```
|
|
186
325
|
|
|
@@ -190,7 +329,7 @@ When a `Focusable` component has focus, TUI:
|
|
|
190
329
|
3. Positions the hardware terminal cursor at that location
|
|
191
330
|
4. Shows the hardware cursor only when `showHardwareCursor` is enabled
|
|
192
331
|
|
|
193
|
-
The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the `
|
|
332
|
+
The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the renderer constructor's `showHardwareCursor` argument or `setShowHardwareCursor(true)`. The `Editor` and `Input` built-in components already implement this interface.
|
|
194
333
|
|
|
195
334
|
**Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child:
|
|
196
335
|
|
|
@@ -282,6 +421,8 @@ input.setValue("initial");
|
|
|
282
421
|
input.getValue();
|
|
283
422
|
```
|
|
284
423
|
|
|
424
|
+
Clicking positions the cursor and gives the input keyboard focus in alternate-screen mode.
|
|
425
|
+
|
|
285
426
|
**Key Bindings:**
|
|
286
427
|
- `Enter` - Submit
|
|
287
428
|
- `Ctrl+A` / `Ctrl+E` - Line start/end
|
|
@@ -317,6 +458,7 @@ editor.getPaddingX(); // Get current padding
|
|
|
317
458
|
```
|
|
318
459
|
|
|
319
460
|
**Features:**
|
|
461
|
+
- Click-to-position cursor and clickable autocomplete rows in alternate-screen mode
|
|
320
462
|
- Multi-line editing with word wrap
|
|
321
463
|
- Slash command autocomplete (type `/`)
|
|
322
464
|
- File path autocomplete (press `Tab`)
|
|
@@ -457,6 +599,8 @@ list.setFilter("opt"); // Filter items
|
|
|
457
599
|
```
|
|
458
600
|
|
|
459
601
|
**Controls:**
|
|
602
|
+
- Mouse move/wheel: Highlight rows in alternate-screen mode
|
|
603
|
+
- Click: Select a row
|
|
460
604
|
- Arrow keys: Navigate
|
|
461
605
|
- Enter: Select
|
|
462
606
|
- Escape: Cancel
|
|
@@ -497,6 +641,8 @@ settings.updateValue("theme", "light");
|
|
|
497
641
|
```
|
|
498
642
|
|
|
499
643
|
**Controls:**
|
|
644
|
+
- Mouse move/wheel: Highlight rows in alternate-screen mode
|
|
645
|
+
- Click: Activate a row
|
|
500
646
|
- Arrow keys: Navigate
|
|
501
647
|
- Enter/Space: Activate (cycle value or open submenu)
|
|
502
648
|
- Escape: Cancel
|
|
@@ -535,6 +681,10 @@ tui.addChild(image);
|
|
|
535
681
|
|
|
536
682
|
Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically.
|
|
537
683
|
|
|
684
|
+
#### Alternate-screen image compatibility
|
|
685
|
+
|
|
686
|
+
`TuiAltScreen` supports inline images and partial viewport cropping in terminals that implement the Kitty graphics protocol, including Kitty and Ghostty. iTerm2's inline-image protocol does not provide operations to delete an existing placement or crop its source while scrolling. To prevent stale images from remaining over repainted content, `TuiAltScreen` renders image components as text placeholders in iTerm2. `TuiMainScreen` continues to render iTerm2 inline images normally.
|
|
687
|
+
|
|
538
688
|
## Autocomplete
|
|
539
689
|
|
|
540
690
|
### CombinedAutocompleteProvider
|
|
@@ -588,15 +738,17 @@ if (matchesKey(data, Key.enter)) {
|
|
|
588
738
|
- With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")`
|
|
589
739
|
- String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"`
|
|
590
740
|
|
|
591
|
-
##
|
|
741
|
+
## Rendering modes
|
|
592
742
|
|
|
593
|
-
|
|
743
|
+
`TuiMainScreen` uses three rendering strategies:
|
|
594
744
|
|
|
595
745
|
1. **First Render**: Output all lines without clearing scrollback
|
|
596
|
-
2. **Width Changed or Change Above Viewport**: Clear screen and
|
|
597
|
-
3. **Normal Update**: Move cursor to first changed line, clear to end, render changed lines
|
|
746
|
+
2. **Width Changed or Change Above Viewport**: Clear screen and fully re-render
|
|
747
|
+
3. **Normal Update**: Move the cursor to the first changed line, clear to the end, and render changed lines
|
|
598
748
|
|
|
599
|
-
|
|
749
|
+
`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Scrollbars support hover expansion, thumb dragging, and track-click jumping. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless `TuiAltScreenOptions.copyOnSelect` is `false`, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting.
|
|
750
|
+
|
|
751
|
+
Both renderers wrap updates in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.
|
|
600
752
|
|
|
601
753
|
## Terminal Interface
|
|
602
754
|
|
|
@@ -678,6 +830,8 @@ class MyInteractiveComponent implements Component {
|
|
|
678
830
|
return truncateToWidth(prefix + item, width);
|
|
679
831
|
});
|
|
680
832
|
}
|
|
833
|
+
|
|
834
|
+
invalidate(): void {}
|
|
681
835
|
}
|
|
682
836
|
```
|
|
683
837
|
|
|
@@ -709,6 +863,8 @@ class MyComponent implements Component {
|
|
|
709
863
|
// Pad to exact width (optional, for backgrounds)
|
|
710
864
|
return [line + " ".repeat(width - visible)];
|
|
711
865
|
}
|
|
866
|
+
|
|
867
|
+
invalidate(): void {}
|
|
712
868
|
}
|
|
713
869
|
```
|
|
714
870
|
|
|
@@ -766,7 +922,7 @@ See `test/chat-simple.ts` for a complete chat interface example with:
|
|
|
766
922
|
|
|
767
923
|
Run it:
|
|
768
924
|
```bash
|
|
769
|
-
|
|
925
|
+
node test/chat-simple.ts
|
|
770
926
|
```
|
|
771
927
|
|
|
772
928
|
## Development
|
|
@@ -779,7 +935,7 @@ npm install
|
|
|
779
935
|
npm run check
|
|
780
936
|
|
|
781
937
|
# Run the demo
|
|
782
|
-
|
|
938
|
+
node test/chat-simple.ts
|
|
783
939
|
```
|
|
784
940
|
|
|
785
941
|
### Debug logging
|
|
@@ -787,5 +943,5 @@ npx tsx test/chat-simple.ts
|
|
|
787
943
|
Set `PI_TUI_WRITE_LOG` to capture the raw ANSI stream written to stdout.
|
|
788
944
|
|
|
789
945
|
```bash
|
|
790
|
-
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log
|
|
946
|
+
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log node test/chat-simple.ts
|
|
791
947
|
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Component, Focusable } from "./tui.ts";
|
|
2
|
+
export interface AltScreenSearchSegment {
|
|
3
|
+
row: number;
|
|
4
|
+
startCol: number;
|
|
5
|
+
endCol: number;
|
|
6
|
+
}
|
|
7
|
+
export interface AltScreenSearchMatch {
|
|
8
|
+
segments: AltScreenSearchSegment[];
|
|
9
|
+
}
|
|
10
|
+
export interface AltScreenSearchResult {
|
|
11
|
+
matches: AltScreenSearchMatch[];
|
|
12
|
+
changed: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** Cache the searchable corpus and matches while rendered transcript lines remain unchanged. */
|
|
15
|
+
export declare class AltScreenSearchIndex {
|
|
16
|
+
private sourceLines;
|
|
17
|
+
private corpus;
|
|
18
|
+
private normalizedQuery;
|
|
19
|
+
private matches;
|
|
20
|
+
search(lines: readonly string[], query: string): AltScreenSearchResult;
|
|
21
|
+
}
|
|
22
|
+
export declare function findAltScreenSearchMatches(lines: readonly string[], query: string): AltScreenSearchMatch[];
|
|
23
|
+
export declare function getAltScreenSearchMatchKey(match: AltScreenSearchMatch): string;
|
|
24
|
+
export declare class AltScreenSearchComponent implements Component, Focusable {
|
|
25
|
+
private readonly input;
|
|
26
|
+
private readonly onQueryChange;
|
|
27
|
+
private readonly navigationButtonStyle;
|
|
28
|
+
private resultCount;
|
|
29
|
+
private resultIndex;
|
|
30
|
+
private previousButtonStart;
|
|
31
|
+
private previousButtonEnd;
|
|
32
|
+
private nextButtonStart;
|
|
33
|
+
private nextButtonEnd;
|
|
34
|
+
private hoveredNavigationDirection;
|
|
35
|
+
private _focused;
|
|
36
|
+
constructor(onQueryChange: (query: string) => void, navigationButtonStyle?: (text: string, hovered: boolean) => string);
|
|
37
|
+
get focused(): boolean;
|
|
38
|
+
set focused(value: boolean);
|
|
39
|
+
setResult(index: number, count: number): void;
|
|
40
|
+
getNavigationDirectionAt(row: number, column: number): -1 | 1 | undefined;
|
|
41
|
+
setHoveredNavigationDirection(direction: -1 | 1 | undefined): boolean;
|
|
42
|
+
handleInput(data: string): void;
|
|
43
|
+
invalidate(): void;
|
|
44
|
+
render(width: number): string[];
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=alt-screen-search.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"alt-screen-search.d.ts","sourceRoot":"","sources":["../src/alt-screen-search.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAmBrD,MAAM,WAAW,sBAAsB;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,oBAAoB;IACpC,QAAQ,EAAE,sBAAsB,EAAE,CAAC;CACnC;AAwHD,MAAM,WAAW,qBAAqB;IACrC,OAAO,EAAE,oBAAoB,EAAE,CAAC;IAChC,OAAO,EAAE,OAAO,CAAC;CACjB;AAED,gGAAgG;AAChG,qBAAa,oBAAoB;IAChC,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,MAAM,CAA2B;IACzC,OAAO,CAAC,eAAe,CAAqB;IAC5C,OAAO,CAAC,OAAO,CAA8B;IAE7C,MAAM,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,qBAAqB,CAqBrE;CACD;AAED,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,oBAAoB,EAAE,CAG1G;AAED,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,oBAAoB,GAAG,MAAM,CAI9E;AAED,qBAAa,wBAAyB,YAAW,SAAS,EAAE,SAAS;IACpE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAInB;IACH,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA0B;IACxD,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAA6C;IACnF,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,WAAW,CAAM;IACzB,OAAO,CAAC,mBAAmB,CAAM;IACjC,OAAO,CAAC,iBAAiB,CAAM;IAC/B,OAAO,CAAC,eAAe,CAAM;IAC7B,OAAO,CAAC,aAAa,CAAM;IAC3B,OAAO,CAAC,0BAA0B,CAAqB;IACvD,OAAO,CAAC,QAAQ,CAAS;IAEzB,YACC,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,EACtC,qBAAqB,GAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,MAAuB,EAIlF;IAED,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,IAAI,OAAO,CAAC,KAAK,EAAE,OAAO,EAGzB;IAED,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAG5C;IAED,wBAAwB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,SAAS,CAKxE;IAED,6BAA6B,CAAC,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,SAAS,GAAG,OAAO,CAIpE;IAED,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAK9B;IAED,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAkE9B;CACD"}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { Input } from "./components/input.js";
|
|
2
|
+
import { getKeybindings } from "./keybindings.js";
|
|
3
|
+
import { getGraphemeSegmenter, stripTerminalSequences, truncateToWidth, visibleWidth } from "./utils.js";
|
|
4
|
+
const segmenter = getGraphemeSegmenter();
|
|
5
|
+
const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
|
|
6
|
+
function buildSearchCorpus(lines) {
|
|
7
|
+
const chunks = [];
|
|
8
|
+
const spans = [];
|
|
9
|
+
let textLength = 0;
|
|
10
|
+
let pendingSeparator = false;
|
|
11
|
+
const appendSeparator = () => {
|
|
12
|
+
if (!pendingSeparator)
|
|
13
|
+
return;
|
|
14
|
+
chunks.push(" ");
|
|
15
|
+
textLength += 1;
|
|
16
|
+
pendingSeparator = false;
|
|
17
|
+
};
|
|
18
|
+
for (let row = 0; row < lines.length; row++) {
|
|
19
|
+
const line = stripTerminalSequences(lines[row] ?? "");
|
|
20
|
+
let column = 0;
|
|
21
|
+
// Rendered transcripts are overwhelmingly ASCII. Index complete non-space
|
|
22
|
+
// runs at once instead of segmenting and allocating one mapping per cell.
|
|
23
|
+
if (PRINTABLE_ASCII.test(line)) {
|
|
24
|
+
let index = 0;
|
|
25
|
+
while (index < line.length) {
|
|
26
|
+
if (line.charCodeAt(index) === 0x20) {
|
|
27
|
+
if (textLength > 0)
|
|
28
|
+
pendingSeparator = true;
|
|
29
|
+
column += 1;
|
|
30
|
+
index += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
let end = index + 1;
|
|
34
|
+
while (end < line.length && line.charCodeAt(end) !== 0x20)
|
|
35
|
+
end += 1;
|
|
36
|
+
appendSeparator();
|
|
37
|
+
const text = line.slice(index, end);
|
|
38
|
+
chunks.push(text);
|
|
39
|
+
spans.push({
|
|
40
|
+
textStart: textLength,
|
|
41
|
+
textEnd: textLength + text.length,
|
|
42
|
+
row,
|
|
43
|
+
startCol: column,
|
|
44
|
+
endCol: column + text.length,
|
|
45
|
+
linearColumns: true,
|
|
46
|
+
});
|
|
47
|
+
textLength += text.length;
|
|
48
|
+
column += text.length;
|
|
49
|
+
index = end;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
for (const grapheme of segmenter.segment(line)) {
|
|
54
|
+
const text = grapheme.segment;
|
|
55
|
+
const width = visibleWidth(text);
|
|
56
|
+
if (/^\s+$/u.test(text)) {
|
|
57
|
+
if (textLength > 0)
|
|
58
|
+
pendingSeparator = true;
|
|
59
|
+
column += width;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
appendSeparator();
|
|
63
|
+
chunks.push(text);
|
|
64
|
+
spans.push({
|
|
65
|
+
textStart: textLength,
|
|
66
|
+
textEnd: textLength + text.length,
|
|
67
|
+
row,
|
|
68
|
+
startCol: column,
|
|
69
|
+
endCol: column + width,
|
|
70
|
+
linearColumns: false,
|
|
71
|
+
});
|
|
72
|
+
textLength += text.length;
|
|
73
|
+
column += width;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (textLength > 0)
|
|
77
|
+
pendingSeparator = true;
|
|
78
|
+
}
|
|
79
|
+
return { text: chunks.join(""), spans };
|
|
80
|
+
}
|
|
81
|
+
function normalizeQuery(query) {
|
|
82
|
+
return query.replace(/\s+/gu, " ").trim();
|
|
83
|
+
}
|
|
84
|
+
function escapeRegExp(text) {
|
|
85
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
86
|
+
}
|
|
87
|
+
function findSearchCorpusMatches(corpus, normalizedQuery) {
|
|
88
|
+
if (!normalizedQuery)
|
|
89
|
+
return [];
|
|
90
|
+
const expression = new RegExp(escapeRegExp(normalizedQuery), "giu");
|
|
91
|
+
const matches = [];
|
|
92
|
+
let spanIndex = 0;
|
|
93
|
+
for (const match of corpus.text.matchAll(expression)) {
|
|
94
|
+
const start = match.index;
|
|
95
|
+
const end = start + match[0].length;
|
|
96
|
+
while (spanIndex < corpus.spans.length && corpus.spans[spanIndex].textEnd <= start)
|
|
97
|
+
spanIndex += 1;
|
|
98
|
+
const segments = [];
|
|
99
|
+
for (let index = spanIndex; index < corpus.spans.length; index++) {
|
|
100
|
+
const span = corpus.spans[index];
|
|
101
|
+
if (span.textStart >= end)
|
|
102
|
+
break;
|
|
103
|
+
if (span.textEnd <= start)
|
|
104
|
+
continue;
|
|
105
|
+
const startCol = span.linearColumns
|
|
106
|
+
? span.startCol + Math.max(start, span.textStart) - span.textStart
|
|
107
|
+
: span.startCol;
|
|
108
|
+
const endCol = span.linearColumns ? span.startCol + Math.min(end, span.textEnd) - span.textStart : span.endCol;
|
|
109
|
+
const previous = segments[segments.length - 1];
|
|
110
|
+
if (previous && previous.row === span.row && startCol <= previous.endCol) {
|
|
111
|
+
previous.endCol = Math.max(previous.endCol, endCol);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
segments.push({ row: span.row, startCol, endCol });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
while (spanIndex < corpus.spans.length && corpus.spans[spanIndex].textEnd <= end)
|
|
118
|
+
spanIndex += 1;
|
|
119
|
+
if (segments.length > 0)
|
|
120
|
+
matches.push({ segments });
|
|
121
|
+
}
|
|
122
|
+
return matches;
|
|
123
|
+
}
|
|
124
|
+
/** Cache the searchable corpus and matches while rendered transcript lines remain unchanged. */
|
|
125
|
+
export class AltScreenSearchIndex {
|
|
126
|
+
sourceLines;
|
|
127
|
+
corpus;
|
|
128
|
+
normalizedQuery;
|
|
129
|
+
matches = [];
|
|
130
|
+
search(lines, query) {
|
|
131
|
+
let sourceChanged = this.sourceLines?.length !== lines.length;
|
|
132
|
+
if (!sourceChanged && this.sourceLines) {
|
|
133
|
+
for (let index = 0; index < lines.length; index++) {
|
|
134
|
+
if (this.sourceLines[index] === lines[index])
|
|
135
|
+
continue;
|
|
136
|
+
sourceChanged = true;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (sourceChanged || !this.corpus) {
|
|
141
|
+
this.sourceLines = Array.from(lines);
|
|
142
|
+
this.corpus = buildSearchCorpus(lines);
|
|
143
|
+
}
|
|
144
|
+
const normalizedQuery = normalizeQuery(query);
|
|
145
|
+
const changed = sourceChanged || normalizedQuery !== this.normalizedQuery;
|
|
146
|
+
if (changed) {
|
|
147
|
+
this.normalizedQuery = normalizedQuery;
|
|
148
|
+
this.matches = findSearchCorpusMatches(this.corpus, normalizedQuery);
|
|
149
|
+
}
|
|
150
|
+
return { matches: this.matches, changed };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function findAltScreenSearchMatches(lines, query) {
|
|
154
|
+
const normalizedQuery = normalizeQuery(query);
|
|
155
|
+
return normalizedQuery ? findSearchCorpusMatches(buildSearchCorpus(lines), normalizedQuery) : [];
|
|
156
|
+
}
|
|
157
|
+
export function getAltScreenSearchMatchKey(match) {
|
|
158
|
+
const first = match.segments[0];
|
|
159
|
+
const last = match.segments[match.segments.length - 1];
|
|
160
|
+
return first && last ? `${first.row}:${first.startCol}:${last.row}:${last.endCol}` : "";
|
|
161
|
+
}
|
|
162
|
+
export class AltScreenSearchComponent {
|
|
163
|
+
input = new Input({
|
|
164
|
+
prompt: " ",
|
|
165
|
+
placeholder: "Find in transcript",
|
|
166
|
+
placeholderStyle: (text) => `\x1b[2m${text}\x1b[22m`,
|
|
167
|
+
});
|
|
168
|
+
onQueryChange;
|
|
169
|
+
navigationButtonStyle;
|
|
170
|
+
resultCount = 0;
|
|
171
|
+
resultIndex = -1;
|
|
172
|
+
previousButtonStart = -1;
|
|
173
|
+
previousButtonEnd = -1;
|
|
174
|
+
nextButtonStart = -1;
|
|
175
|
+
nextButtonEnd = -1;
|
|
176
|
+
hoveredNavigationDirection;
|
|
177
|
+
_focused = false;
|
|
178
|
+
constructor(onQueryChange, navigationButtonStyle = (text) => text) {
|
|
179
|
+
this.onQueryChange = onQueryChange;
|
|
180
|
+
this.navigationButtonStyle = navigationButtonStyle;
|
|
181
|
+
}
|
|
182
|
+
get focused() {
|
|
183
|
+
return this._focused;
|
|
184
|
+
}
|
|
185
|
+
set focused(value) {
|
|
186
|
+
this._focused = value;
|
|
187
|
+
this.input.focused = value;
|
|
188
|
+
}
|
|
189
|
+
setResult(index, count) {
|
|
190
|
+
this.resultIndex = index;
|
|
191
|
+
this.resultCount = count;
|
|
192
|
+
}
|
|
193
|
+
getNavigationDirectionAt(row, column) {
|
|
194
|
+
if (row !== 2)
|
|
195
|
+
return undefined;
|
|
196
|
+
if (column >= this.previousButtonStart && column < this.previousButtonEnd)
|
|
197
|
+
return -1;
|
|
198
|
+
if (column >= this.nextButtonStart && column < this.nextButtonEnd)
|
|
199
|
+
return 1;
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
setHoveredNavigationDirection(direction) {
|
|
203
|
+
if (direction === this.hoveredNavigationDirection)
|
|
204
|
+
return false;
|
|
205
|
+
this.hoveredNavigationDirection = direction;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
handleInput(data) {
|
|
209
|
+
const previous = this.input.getValue();
|
|
210
|
+
this.input.handleInput(data);
|
|
211
|
+
const query = this.input.getValue();
|
|
212
|
+
if (query !== previous)
|
|
213
|
+
this.onQueryChange(query);
|
|
214
|
+
}
|
|
215
|
+
invalidate() {
|
|
216
|
+
this.input.invalidate();
|
|
217
|
+
}
|
|
218
|
+
render(width) {
|
|
219
|
+
const safeWidth = Math.max(1, width);
|
|
220
|
+
const innerWidth = Math.max(0, safeWidth - 2);
|
|
221
|
+
const formatKey = (key) => key
|
|
222
|
+
? key
|
|
223
|
+
.split("+")
|
|
224
|
+
.map((part) => {
|
|
225
|
+
if (process.platform === "darwin" && part.toLowerCase() === "alt")
|
|
226
|
+
return "Option";
|
|
227
|
+
return part.charAt(0).toUpperCase() + part.slice(1);
|
|
228
|
+
})
|
|
229
|
+
.join("+")
|
|
230
|
+
: "Unbound";
|
|
231
|
+
const keybindings = getKeybindings();
|
|
232
|
+
const previousKey = formatKey(keybindings.getKeys("tui.altScreen.searchPrevious")[0]);
|
|
233
|
+
const nextKey = formatKey(keybindings.getKeys("tui.altScreen.searchNext")[0]);
|
|
234
|
+
const query = this.input.getValue();
|
|
235
|
+
const result = !query
|
|
236
|
+
? ""
|
|
237
|
+
: this.resultCount === 0
|
|
238
|
+
? "No matches"
|
|
239
|
+
: `${this.resultIndex + 1}/${this.resultCount}`;
|
|
240
|
+
const resultSpace = Math.max(0, innerWidth - 3);
|
|
241
|
+
const visibleResult = truncateToWidth(result, resultSpace, "");
|
|
242
|
+
const resultText = visibleResult ? `\x1b[2m ${visibleResult} \x1b[22m` : "";
|
|
243
|
+
const inputWidth = Math.max(0, innerWidth - visibleWidth(resultText));
|
|
244
|
+
const inputLine = truncateToWidth(this.input.render(Math.max(1, inputWidth))[0] ?? "", inputWidth, "");
|
|
245
|
+
const inputPadding = " ".repeat(Math.max(0, inputWidth - visibleWidth(inputLine)));
|
|
246
|
+
const content = `${inputLine}${inputPadding}${resultText}`;
|
|
247
|
+
let previousButton = `↑ ${previousKey}`;
|
|
248
|
+
let nextButton = `↓ ${nextKey}`;
|
|
249
|
+
let separator = " · ";
|
|
250
|
+
const outerGapWidth = 1;
|
|
251
|
+
const availableControlsWidth = Math.max(0, innerWidth - outerGapWidth * 2 - 1);
|
|
252
|
+
let controlsWidth = visibleWidth(previousButton) + visibleWidth(separator) + visibleWidth(nextButton);
|
|
253
|
+
if (controlsWidth > availableControlsWidth) {
|
|
254
|
+
previousButton = "↑";
|
|
255
|
+
nextButton = "↓";
|
|
256
|
+
separator = " ";
|
|
257
|
+
controlsWidth = visibleWidth(previousButton) + visibleWidth(separator) + visibleWidth(nextButton);
|
|
258
|
+
}
|
|
259
|
+
const showButtons = controlsWidth <= availableControlsWidth;
|
|
260
|
+
const renderedButtons = showButtons
|
|
261
|
+
? this.navigationButtonStyle(previousButton, this.hoveredNavigationDirection === -1) +
|
|
262
|
+
separator +
|
|
263
|
+
this.navigationButtonStyle(nextButton, this.hoveredNavigationDirection === 1)
|
|
264
|
+
: "";
|
|
265
|
+
const outerGapsWidth = showButtons ? outerGapWidth * 2 : 0;
|
|
266
|
+
const rightRuleWidth = renderedButtons && innerWidth > controlsWidth + outerGapsWidth ? 1 : 0;
|
|
267
|
+
const leftRuleWidth = Math.max(0, innerWidth - (showButtons ? controlsWidth : 0) - outerGapsWidth - rightRuleWidth);
|
|
268
|
+
const previousStart = 1 + leftRuleWidth + outerGapWidth;
|
|
269
|
+
this.previousButtonStart = showButtons ? previousStart : -1;
|
|
270
|
+
this.previousButtonEnd = showButtons ? previousStart + visibleWidth(previousButton) : -1;
|
|
271
|
+
this.nextButtonStart = showButtons ? this.previousButtonEnd + visibleWidth(separator) : -1;
|
|
272
|
+
this.nextButtonEnd = showButtons ? this.nextButtonStart + visibleWidth(nextButton) : -1;
|
|
273
|
+
if (safeWidth === 1)
|
|
274
|
+
return ["┌", "│", "└"];
|
|
275
|
+
return [
|
|
276
|
+
`┌${"─".repeat(innerWidth)}┐`,
|
|
277
|
+
`│${content}│`,
|
|
278
|
+
`└${"─".repeat(leftRuleWidth)}${renderedButtons ? " " : ""}${renderedButtons}${renderedButtons ? " " : ""}${"─".repeat(rightRuleWidth)}┘`,
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
//# sourceMappingURL=alt-screen-search.js.map
|