@svelterm/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/src/components/spinner.d.ts +11 -0
- package/dist/src/components/spinner.js +19 -0
- package/dist/src/components/text-buffer.d.ts +21 -0
- package/dist/src/components/text-buffer.js +87 -0
- package/dist/src/css/animation-runner.d.ts +17 -0
- package/dist/src/css/animation-runner.js +72 -0
- package/dist/src/css/animation.d.ts +5 -0
- package/dist/src/css/animation.js +6 -0
- package/dist/src/css/calc.d.ts +5 -0
- package/dist/src/css/calc.js +130 -0
- package/dist/src/css/color.d.ts +1 -0
- package/dist/src/css/color.js +157 -0
- package/dist/src/css/compute.d.ts +63 -0
- package/dist/src/css/compute.js +606 -0
- package/dist/src/css/defaults.d.ts +8 -0
- package/dist/src/css/defaults.js +44 -0
- package/dist/src/css/incremental.d.ts +9 -0
- package/dist/src/css/incremental.js +46 -0
- package/dist/src/css/index.d.ts +5 -0
- package/dist/src/css/index.js +3 -0
- package/dist/src/css/media.d.ts +11 -0
- package/dist/src/css/media.js +59 -0
- package/dist/src/css/parser.d.ts +20 -0
- package/dist/src/css/parser.js +241 -0
- package/dist/src/css/selector.d.ts +17 -0
- package/dist/src/css/selector.js +272 -0
- package/dist/src/css/specificity.d.ts +7 -0
- package/dist/src/css/specificity.js +89 -0
- package/dist/src/css/values.d.ts +17 -0
- package/dist/src/css/values.js +58 -0
- package/dist/src/css/variables.d.ts +6 -0
- package/dist/src/css/variables.js +42 -0
- package/dist/src/debug/console.d.ts +16 -0
- package/dist/src/debug/console.js +65 -0
- package/dist/src/debug/server.d.ts +22 -0
- package/dist/src/debug/server.js +90 -0
- package/dist/src/headless.d.ts +21 -0
- package/dist/src/headless.js +26 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.js +485 -0
- package/dist/src/input/dispatch.d.ts +18 -0
- package/dist/src/input/dispatch.js +70 -0
- package/dist/src/input/focus.d.ts +18 -0
- package/dist/src/input/focus.js +81 -0
- package/dist/src/input/hit.d.ts +3 -0
- package/dist/src/input/hit.js +29 -0
- package/dist/src/input/keyboard.d.ts +9 -0
- package/dist/src/input/keyboard.js +100 -0
- package/dist/src/input/mouse.d.ts +7 -0
- package/dist/src/input/mouse.js +35 -0
- package/dist/src/input/scroll.d.ts +2 -0
- package/dist/src/input/scroll.js +24 -0
- package/dist/src/layout/cache.d.ts +4 -0
- package/dist/src/layout/cache.js +8 -0
- package/dist/src/layout/engine.d.ts +9 -0
- package/dist/src/layout/engine.js +455 -0
- package/dist/src/layout/flex.d.ts +4 -0
- package/dist/src/layout/flex.js +30 -0
- package/dist/src/layout/incremental.d.ts +8 -0
- package/dist/src/layout/incremental.js +58 -0
- package/dist/src/layout/size.d.ts +2 -0
- package/dist/src/layout/size.js +25 -0
- package/dist/src/layout/text.d.ts +7 -0
- package/dist/src/layout/text.js +52 -0
- package/dist/src/render/ansi.d.ts +23 -0
- package/dist/src/render/ansi.js +108 -0
- package/dist/src/render/border.d.ts +4 -0
- package/dist/src/render/border.js +60 -0
- package/dist/src/render/buffer.d.ts +23 -0
- package/dist/src/render/buffer.js +70 -0
- package/dist/src/render/context.d.ts +19 -0
- package/dist/src/render/context.js +98 -0
- package/dist/src/render/diff.d.ts +2 -0
- package/dist/src/render/diff.js +53 -0
- package/dist/src/render/incremental-paint.d.ts +10 -0
- package/dist/src/render/incremental-paint.js +94 -0
- package/dist/src/render/paint-text.d.ts +29 -0
- package/dist/src/render/paint-text.js +120 -0
- package/dist/src/render/paint.d.ts +5 -0
- package/dist/src/render/paint.js +220 -0
- package/dist/src/render/queue.d.ts +24 -0
- package/dist/src/render/queue.js +54 -0
- package/dist/src/render/scrollbar.d.ts +3 -0
- package/dist/src/render/scrollbar.js +19 -0
- package/dist/src/render/snapshot.d.ts +18 -0
- package/dist/src/render/snapshot.js +126 -0
- package/dist/src/renderer/default.d.ts +3 -0
- package/dist/src/renderer/default.js +3 -0
- package/dist/src/renderer/index.d.ts +11 -0
- package/dist/src/renderer/index.js +116 -0
- package/dist/src/renderer/node.d.ts +44 -0
- package/dist/src/renderer/node.js +153 -0
- package/dist/src/terminal/screen.d.ts +10 -0
- package/dist/src/terminal/screen.js +31 -0
- package/dist/src/terminal/stdin-router.d.ts +31 -0
- package/dist/src/terminal/stdin-router.js +133 -0
- package/package.json +64 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { TermNode } from './renderer/index.js';
|
|
2
|
+
import renderer from './renderer/default.js';
|
|
3
|
+
import { CellBuffer } from './render/buffer.js';
|
|
4
|
+
import { diffBuffers } from './render/diff.js';
|
|
5
|
+
import { paint } from './render/paint.js';
|
|
6
|
+
import { parseCSS } from './css/parser.js';
|
|
7
|
+
import { resolveStyles, filterByMedia } from './css/compute.js';
|
|
8
|
+
import { resolveStylesIncremental } from './css/incremental.js';
|
|
9
|
+
import { computeLayout } from './layout/engine.js';
|
|
10
|
+
import { computeLayoutIncremental } from './layout/incremental.js';
|
|
11
|
+
import { syncLayoutCache } from './layout/cache.js';
|
|
12
|
+
import { RenderContext } from './render/context.js';
|
|
13
|
+
import { paintNodes } from './render/incremental-paint.js';
|
|
14
|
+
import { parseKeyEvent } from './input/keyboard.js';
|
|
15
|
+
import { parseMouseEvent } from './input/mouse.js';
|
|
16
|
+
import { hitTest } from './input/hit.js';
|
|
17
|
+
import { FocusManager } from './input/focus.js';
|
|
18
|
+
import { dispatchEvent } from './input/dispatch.js';
|
|
19
|
+
import { TextBuffer } from './components/text-buffer.js';
|
|
20
|
+
import { StdinRouter, matchOSC11, parseOSC11Scheme } from './terminal/stdin-router.js';
|
|
21
|
+
import { DebugServer } from './debug/server.js';
|
|
22
|
+
import { ConsoleDomain } from './debug/console.js';
|
|
23
|
+
import * as ansi from './render/ansi.js';
|
|
24
|
+
import { getTerminalSize, enterFullscreen, exitFullscreen, enableRawMode, disableRawMode, writeOutput, } from './terminal/screen.js';
|
|
25
|
+
export function run(AppComponent, options) {
|
|
26
|
+
const fullscreen = options?.fullscreen ?? true;
|
|
27
|
+
const mouseEnabled = options?.mouse ?? true;
|
|
28
|
+
const debugEnabled = options?.debug ?? false;
|
|
29
|
+
const debugPort = options?.debugPort ?? 9444;
|
|
30
|
+
const stylesheet = options?.css ? parseCSS(options.css) : null;
|
|
31
|
+
// Render context tracks mutations and determines minimum work
|
|
32
|
+
const ctx = new RenderContext();
|
|
33
|
+
const root = new TermNode('element', 'root');
|
|
34
|
+
root.ctx = ctx;
|
|
35
|
+
// Color scheme detection — updated by polling
|
|
36
|
+
let detectedScheme = 'dark';
|
|
37
|
+
// Wire schedule callback (defined below, hoisted via closure)
|
|
38
|
+
ctx.onScheduleRender = () => scheduleRender();
|
|
39
|
+
// Persisted render state
|
|
40
|
+
let prevBuffer = null;
|
|
41
|
+
let lastStyles;
|
|
42
|
+
let lastFilteredStylesheet = null;
|
|
43
|
+
let lastLayout;
|
|
44
|
+
let renderScheduled = false;
|
|
45
|
+
let initialRegistrationDone = false;
|
|
46
|
+
const scheduleRender = () => {
|
|
47
|
+
if (renderScheduled)
|
|
48
|
+
return;
|
|
49
|
+
renderScheduled = true;
|
|
50
|
+
queueMicrotask(() => {
|
|
51
|
+
renderScheduled = false;
|
|
52
|
+
processQueue();
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const processQueue = () => {
|
|
56
|
+
const snap = ctx.queue.snapshot();
|
|
57
|
+
if (snap.fullRecompute || !lastStyles || !lastLayout) {
|
|
58
|
+
// Full recompute — initial render, resize, or CSS reload
|
|
59
|
+
fullRender();
|
|
60
|
+
}
|
|
61
|
+
else if (snap.paintOnly.size > 0 || snap.styleResolve.size > 0
|
|
62
|
+
|| snap.layoutSubtree.size > 0 || snap.layoutBubble.size > 0) {
|
|
63
|
+
// Incremental render
|
|
64
|
+
incrementalRender(snap);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const fullRender = () => {
|
|
68
|
+
const size = getTerminalSize();
|
|
69
|
+
// Set root dimensions so children can use percentage width/height
|
|
70
|
+
root.attributes.set('data-width', String(size.width));
|
|
71
|
+
root.attributes.set('data-height', String(size.height));
|
|
72
|
+
const buffer = new CellBuffer(size.width, size.height);
|
|
73
|
+
const media = { colorScheme: detectedScheme, displayMode: 'terminal', width: size.width, height: size.height };
|
|
74
|
+
lastFilteredStylesheet = stylesheet ? filterByMedia(stylesheet, media) : null;
|
|
75
|
+
lastStyles = lastFilteredStylesheet ? resolveStyles(root, lastFilteredStylesheet) : undefined;
|
|
76
|
+
// Ensure root style has terminal dimensions for percentage resolution
|
|
77
|
+
if (lastStyles) {
|
|
78
|
+
const rootStyle = lastStyles.get(root.id);
|
|
79
|
+
if (rootStyle) {
|
|
80
|
+
rootStyle.width = size.width;
|
|
81
|
+
rootStyle.height = size.height;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
lastLayout = lastStyles ? computeLayout(root, lastStyles, size.width, size.height) : undefined;
|
|
85
|
+
if (lastLayout)
|
|
86
|
+
syncLayoutCache(root, lastLayout);
|
|
87
|
+
paint(root, buffer, lastStyles, lastLayout);
|
|
88
|
+
const output = diffBuffers(prevBuffer, buffer);
|
|
89
|
+
if (output.length > 0)
|
|
90
|
+
writeOutput(output);
|
|
91
|
+
prevBuffer = buffer;
|
|
92
|
+
// Register focusable elements after initial render
|
|
93
|
+
if (!initialRegistrationDone) {
|
|
94
|
+
registerFocusableNodes(root, focusManager);
|
|
95
|
+
initialRegistrationDone = true;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const incrementalRender = (snap) => {
|
|
99
|
+
const size = getTerminalSize();
|
|
100
|
+
// Mutable copies for promoted nodes during processing
|
|
101
|
+
const layoutSubtree = new Set(snap.layoutSubtree);
|
|
102
|
+
const layoutBubble = new Set(snap.layoutBubble);
|
|
103
|
+
// Step 1: Incremental style resolution
|
|
104
|
+
if (snap.styleResolve.size > 0 && lastStyles && lastFilteredStylesheet) {
|
|
105
|
+
lastStyles = resolveStylesIncremental(root, lastFilteredStylesheet, lastStyles, snap.styleResolve, undefined, (node) => { layoutSubtree.add(node); });
|
|
106
|
+
}
|
|
107
|
+
// Step 2: Incremental layout
|
|
108
|
+
if (layoutSubtree.size > 0 || layoutBubble.size > 0) {
|
|
109
|
+
const dirtyLayoutNodes = new Set([...layoutSubtree, ...layoutBubble]);
|
|
110
|
+
if (lastStyles && lastLayout) {
|
|
111
|
+
lastLayout = computeLayoutIncremental(root, lastStyles, lastLayout, dirtyLayoutNodes, size.width, size.height);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
lastLayout = lastStyles ? computeLayout(root, lastStyles, size.width, size.height) : undefined;
|
|
115
|
+
}
|
|
116
|
+
if (lastLayout)
|
|
117
|
+
syncLayoutCache(root, lastLayout);
|
|
118
|
+
}
|
|
119
|
+
// Step 3: Repaint
|
|
120
|
+
const noLayoutChanges = layoutSubtree.size === 0 && layoutBubble.size === 0;
|
|
121
|
+
const dirtyPaintNodes = new Set(snap.paintOnly);
|
|
122
|
+
// Style-resolved nodes that didn't affect layout still need repaint
|
|
123
|
+
if (noLayoutChanges) {
|
|
124
|
+
for (const node of snap.styleResolve)
|
|
125
|
+
dirtyPaintNodes.add(node);
|
|
126
|
+
}
|
|
127
|
+
const hasScroll = hasScrolledNode(root);
|
|
128
|
+
if (noLayoutChanges && !hasScroll && dirtyPaintNodes.size > 0 && prevBuffer && lastStyles && lastLayout) {
|
|
129
|
+
const buffer = prevBuffer.clone();
|
|
130
|
+
paintNodes(dirtyPaintNodes, buffer, lastStyles, lastLayout, root);
|
|
131
|
+
const output = diffBuffers(prevBuffer, buffer);
|
|
132
|
+
if (output.length > 0)
|
|
133
|
+
writeOutput(output);
|
|
134
|
+
prevBuffer = buffer;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
const buffer = new CellBuffer(size.width, size.height);
|
|
138
|
+
paint(root, buffer, lastStyles, lastLayout);
|
|
139
|
+
const output = diffBuffers(prevBuffer, buffer);
|
|
140
|
+
if (output.length > 0)
|
|
141
|
+
writeOutput(output);
|
|
142
|
+
prevBuffer = buffer;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const focusManager = new FocusManager();
|
|
146
|
+
focusManager.onSetAttribute = (node, key, value) => ctx.onSetAttribute(node, key, value);
|
|
147
|
+
focusManager.onRemoveAttribute = (node, key) => ctx.onRemoveAttribute(node, key);
|
|
148
|
+
focusManager.onFocusChange = (focused, previous) => {
|
|
149
|
+
if (previous)
|
|
150
|
+
dispatchEvent(previous, 'blur');
|
|
151
|
+
if (focused) {
|
|
152
|
+
dispatchEvent(focused, 'focus');
|
|
153
|
+
scrollIntoView(focused, lastLayout, lastStyles, ctx);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
// Register focusable nodes on insert, unregister on remove
|
|
157
|
+
const origInsert = ctx.onInsert.bind(ctx);
|
|
158
|
+
ctx.onInsert = (parent, child) => {
|
|
159
|
+
origInsert(parent, child);
|
|
160
|
+
if (initialRegistrationDone) {
|
|
161
|
+
registerFocusableNodes(child, focusManager);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const origRemove = ctx.onRemove.bind(ctx);
|
|
165
|
+
ctx.onRemove = (child, parent) => {
|
|
166
|
+
origRemove(child, parent);
|
|
167
|
+
unregisterFocusableNodes(child, focusManager);
|
|
168
|
+
child.cleanup();
|
|
169
|
+
};
|
|
170
|
+
enableRawMode();
|
|
171
|
+
if (fullscreen)
|
|
172
|
+
enterFullscreen();
|
|
173
|
+
// Write mode sequences directly — sync update wrapping can interfere
|
|
174
|
+
process.stdout.write(ansi.enableBracketedPaste());
|
|
175
|
+
if (mouseEnabled)
|
|
176
|
+
process.stdout.write(ansi.enableMouse());
|
|
177
|
+
// Single stdin router — all input flows through here
|
|
178
|
+
const router = new StdinRouter();
|
|
179
|
+
const handleKeyData = (data) => {
|
|
180
|
+
const key = parseKeyEvent(data);
|
|
181
|
+
if (!key)
|
|
182
|
+
return;
|
|
183
|
+
if (key.ctrl && key.key === 'c') {
|
|
184
|
+
doCleanup();
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
if (key.ctrl && key.key === 'z') {
|
|
188
|
+
doCleanup();
|
|
189
|
+
process.kill(process.pid, 'SIGTSTP');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (key.key === 'Tab' && key.shift) {
|
|
193
|
+
focusManager.focusPrevious();
|
|
194
|
+
scheduleRender();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (key.key === 'Tab') {
|
|
198
|
+
focusManager.focusNext();
|
|
199
|
+
scheduleRender();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (key.key === 'Enter' && focusManager.focused) {
|
|
203
|
+
const target = focusManager.focused;
|
|
204
|
+
const event = dispatchEvent(target, 'click');
|
|
205
|
+
// Default action: open links in browser (unless preventDefault was called)
|
|
206
|
+
if (!event.defaultPrevented && target.tag === 'a') {
|
|
207
|
+
const href = target.attributes.get('href');
|
|
208
|
+
if (href)
|
|
209
|
+
openUrl(href);
|
|
210
|
+
}
|
|
211
|
+
scheduleRender();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Text input for focused input/textarea
|
|
215
|
+
const focused = focusManager.focused;
|
|
216
|
+
if (focused && (focused.tag === 'input' || focused.tag === 'textarea')) {
|
|
217
|
+
if (!focused.textBuffer)
|
|
218
|
+
focused.textBuffer = new TextBuffer(focused.attributes.get('value') ?? '');
|
|
219
|
+
if (focused.textBuffer.handleKey(key)) {
|
|
220
|
+
const newValue = focused.textBuffer.text;
|
|
221
|
+
focused.attributes.set('value', newValue);
|
|
222
|
+
const textChild = focused.children.find(c => c.nodeType === 'text');
|
|
223
|
+
if (textChild)
|
|
224
|
+
ctx.onSetText(textChild, newValue);
|
|
225
|
+
// Enqueue the input element itself for repaint (cursor may have moved)
|
|
226
|
+
ctx.queue.enqueuePaintOnly(focused);
|
|
227
|
+
dispatchEvent(focused, 'input', { value: newValue, cursor: focused.textBuffer.cursor });
|
|
228
|
+
scheduleRender();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const keyTarget = focused ?? findFirstElement(root);
|
|
233
|
+
if (keyTarget) {
|
|
234
|
+
dispatchEvent(keyTarget, 'keydown', key);
|
|
235
|
+
scheduleRender();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const handleMouseData = (data) => {
|
|
239
|
+
const mouse = parseMouseEvent(data);
|
|
240
|
+
if (!mouse)
|
|
241
|
+
return;
|
|
242
|
+
handleMouse(mouse, root, lastLayout, focusManager, scheduleRender, lastStyles, ctx);
|
|
243
|
+
};
|
|
244
|
+
const handlePaste = (text) => {
|
|
245
|
+
const focused = focusManager.focused;
|
|
246
|
+
if (focused && (focused.tag === 'input' || focused.tag === 'textarea')) {
|
|
247
|
+
if (!focused.textBuffer)
|
|
248
|
+
focused.textBuffer = new TextBuffer(focused.attributes.get('value') ?? '');
|
|
249
|
+
focused.textBuffer.insert(text);
|
|
250
|
+
const newValue = focused.textBuffer.text;
|
|
251
|
+
focused.attributes.set('value', newValue);
|
|
252
|
+
const textChild = focused.children.find(c => c.nodeType === 'text');
|
|
253
|
+
if (textChild)
|
|
254
|
+
ctx.onSetText(textChild, newValue);
|
|
255
|
+
dispatchEvent(focused, 'input', { value: newValue, cursor: focused.textBuffer.cursor });
|
|
256
|
+
scheduleRender();
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const target = focused ?? findFirstElement(root);
|
|
260
|
+
if (target)
|
|
261
|
+
dispatchEvent(target, 'paste', { text });
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
router.start({ onKey: handleKeyData, onMouse: handleMouseData, onPaste: handlePaste });
|
|
265
|
+
// Debug server (opt-in)
|
|
266
|
+
let debugServer = null;
|
|
267
|
+
let consoleDomain = null;
|
|
268
|
+
if (debugEnabled) {
|
|
269
|
+
debugServer = new DebugServer(debugPort);
|
|
270
|
+
consoleDomain = new ConsoleDomain(debugServer);
|
|
271
|
+
debugServer.registerDomain('Console', consoleDomain);
|
|
272
|
+
consoleDomain.start();
|
|
273
|
+
debugServer.start();
|
|
274
|
+
}
|
|
275
|
+
// Serialised color scheme detection via router query
|
|
276
|
+
const detectScheme = async () => {
|
|
277
|
+
const result = await router.query('\x1b]11;?\x07', matchOSC11, 200);
|
|
278
|
+
return result ? parseOSC11Scheme(result) : 'dark';
|
|
279
|
+
};
|
|
280
|
+
// Render immediately with default scheme
|
|
281
|
+
ctx.queue.setFullRecompute();
|
|
282
|
+
const { unmount: svUnmount } = renderer.render(AppComponent, { target: root, props: options?.props ?? {} });
|
|
283
|
+
scheduleRender();
|
|
284
|
+
setupResizeHandler(() => { ctx.onResize(); prevBuffer = null; scheduleRender(); });
|
|
285
|
+
// Detect color scheme in background and re-render if different
|
|
286
|
+
let pollRunning = true;
|
|
287
|
+
const pollScheme = async () => {
|
|
288
|
+
if (!pollRunning)
|
|
289
|
+
return;
|
|
290
|
+
try {
|
|
291
|
+
const scheme = await detectScheme();
|
|
292
|
+
if (scheme !== detectedScheme) {
|
|
293
|
+
detectedScheme = scheme;
|
|
294
|
+
lastFilteredStylesheet = stylesheet ? filterByMedia(stylesheet, { colorScheme: detectedScheme, displayMode: 'terminal', width: getTerminalSize().width, height: getTerminalSize().height }) : null;
|
|
295
|
+
ctx.onResize();
|
|
296
|
+
prevBuffer = null;
|
|
297
|
+
scheduleRender();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// Terminal may not support color scheme queries — ignore
|
|
302
|
+
}
|
|
303
|
+
if (pollRunning)
|
|
304
|
+
setTimeout(pollScheme, 1000);
|
|
305
|
+
};
|
|
306
|
+
pollScheme();
|
|
307
|
+
const appCleanup = createCleanup(svUnmount, fullscreen, mouseEnabled);
|
|
308
|
+
const doCleanup = () => {
|
|
309
|
+
pollRunning = false;
|
|
310
|
+
router.stop();
|
|
311
|
+
consoleDomain?.stop();
|
|
312
|
+
debugServer?.stop();
|
|
313
|
+
appCleanup();
|
|
314
|
+
};
|
|
315
|
+
process.on('SIGINT', () => { doCleanup(); process.exit(0); });
|
|
316
|
+
process.on('SIGTERM', () => { doCleanup(); process.exit(0); });
|
|
317
|
+
return doCleanup;
|
|
318
|
+
}
|
|
319
|
+
function createCleanup(unmountComponent, fullscreen, mouseEnabled) {
|
|
320
|
+
let cleaned = false;
|
|
321
|
+
return () => {
|
|
322
|
+
if (cleaned)
|
|
323
|
+
return;
|
|
324
|
+
cleaned = true;
|
|
325
|
+
unmountComponent();
|
|
326
|
+
if (mouseEnabled)
|
|
327
|
+
writeOutput(ansi.disableMouse());
|
|
328
|
+
writeOutput(ansi.disableBracketedPaste());
|
|
329
|
+
if (fullscreen)
|
|
330
|
+
exitFullscreen();
|
|
331
|
+
disableRawMode();
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function handleMouse(mouse, root, layout, focusManager, scheduleRender, lastStyles, ctx) {
|
|
335
|
+
if (!layout)
|
|
336
|
+
return;
|
|
337
|
+
// Handle hover — set data-hovered on element under cursor
|
|
338
|
+
if (mouse.type === 'motion') {
|
|
339
|
+
const target = hitTest(root, layout, mouse.col, mouse.row);
|
|
340
|
+
const hoveredId = target?.id ?? -1;
|
|
341
|
+
// Walk tree and update data-hovered
|
|
342
|
+
updateHover(root, hoveredId, ctx);
|
|
343
|
+
scheduleRender();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (mouse.type !== 'press' && mouse.type !== 'scroll')
|
|
347
|
+
return;
|
|
348
|
+
if (mouse.button === 'left') {
|
|
349
|
+
const target = hitTest(root, layout, mouse.col, mouse.row);
|
|
350
|
+
if (target) {
|
|
351
|
+
// Focus clicked element if it's focusable
|
|
352
|
+
if (FOCUSABLE_TAGS.has(target.tag ?? '')) {
|
|
353
|
+
focusManager.focusByNode(target);
|
|
354
|
+
}
|
|
355
|
+
const event = dispatchEvent(target, 'click', mouse);
|
|
356
|
+
if (!event.defaultPrevented && target.tag === 'a') {
|
|
357
|
+
const href = target.attributes.get('href');
|
|
358
|
+
if (href)
|
|
359
|
+
openUrl(href);
|
|
360
|
+
}
|
|
361
|
+
scheduleRender();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else if (mouse.button === 'scrollUp' || mouse.button === 'scrollDown') {
|
|
365
|
+
const target = hitTest(root, layout, mouse.col, mouse.row);
|
|
366
|
+
if (target) {
|
|
367
|
+
const scrollTarget = findScrollableAncestor(target, lastStyles);
|
|
368
|
+
if (scrollTarget) {
|
|
369
|
+
const box = layout.get(scrollTarget.id);
|
|
370
|
+
if (box) {
|
|
371
|
+
const contentHeight = scrollTarget.children.reduce((sum, c) => {
|
|
372
|
+
const cBox = layout.get(c.id);
|
|
373
|
+
return cBox ? Math.max(sum, cBox.y - box.y + cBox.height) : sum;
|
|
374
|
+
}, 0);
|
|
375
|
+
const delta = mouse.button === 'scrollUp' ? -3 : 3;
|
|
376
|
+
const maxScroll = Math.max(0, contentHeight - box.height);
|
|
377
|
+
scrollTarget.scrollTop = Math.max(0, Math.min(scrollTarget.scrollTop + delta, maxScroll));
|
|
378
|
+
ctx.onScroll(scrollTarget);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
dispatchEvent(target, 'scroll', mouse);
|
|
382
|
+
scheduleRender();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function setupResizeHandler(onResize) {
|
|
387
|
+
process.stdout.on('resize', onResize);
|
|
388
|
+
}
|
|
389
|
+
const FOCUSABLE_TAGS = new Set(['button', 'input', 'textarea', 'a', 'select']);
|
|
390
|
+
function registerFocusableNodes(node, focusManager) {
|
|
391
|
+
if (node.nodeType === 'element' && FOCUSABLE_TAGS.has(node.tag ?? '')) {
|
|
392
|
+
focusManager.register(node);
|
|
393
|
+
}
|
|
394
|
+
for (const child of node.children) {
|
|
395
|
+
registerFocusableNodes(child, focusManager);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function unregisterFocusableNodes(node, focusManager) {
|
|
399
|
+
if (node.nodeType === 'element' && FOCUSABLE_TAGS.has(node.tag ?? '')) {
|
|
400
|
+
focusManager.unregister(node);
|
|
401
|
+
}
|
|
402
|
+
for (const child of node.children) {
|
|
403
|
+
unregisterFocusableNodes(child, focusManager);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function updateHover(node, hoveredId, ctx) {
|
|
407
|
+
if (node.nodeType !== 'element')
|
|
408
|
+
return;
|
|
409
|
+
const isHovered = node.id === hoveredId;
|
|
410
|
+
const wasHovered = node.attributes.has('data-hovered');
|
|
411
|
+
if (isHovered && !wasHovered) {
|
|
412
|
+
ctx.onSetAttribute(node, 'data-hovered', 'true');
|
|
413
|
+
}
|
|
414
|
+
else if (!isHovered && wasHovered) {
|
|
415
|
+
ctx.onRemoveAttribute(node, 'data-hovered');
|
|
416
|
+
}
|
|
417
|
+
for (const child of node.children) {
|
|
418
|
+
updateHover(child, hoveredId, ctx);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function findScrollableAncestor(node, styles) {
|
|
422
|
+
let current = node;
|
|
423
|
+
while (current) {
|
|
424
|
+
const style = styles?.get(current.id);
|
|
425
|
+
if (style && (style.overflow === 'scroll' || style.overflow === 'auto' || style.overflow === 'hidden')) {
|
|
426
|
+
return current;
|
|
427
|
+
}
|
|
428
|
+
current = current.parent;
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
function scrollIntoView(node, layout, styles, ctx) {
|
|
433
|
+
if (!layout)
|
|
434
|
+
return;
|
|
435
|
+
const nodeBox = layout.get(node.id);
|
|
436
|
+
if (!nodeBox)
|
|
437
|
+
return;
|
|
438
|
+
const scroller = findScrollableAncestor(node, styles);
|
|
439
|
+
if (!scroller)
|
|
440
|
+
return;
|
|
441
|
+
const scrollerBox = layout.get(scroller.id);
|
|
442
|
+
if (!scrollerBox)
|
|
443
|
+
return;
|
|
444
|
+
const borderInset = (styles?.get(scroller.id)?.borderStyle !== 'none' &&
|
|
445
|
+
styles?.get(scroller.id)?.borderStyle !== undefined) ? 1 : 0;
|
|
446
|
+
const viewTop = scrollerBox.y + borderInset + scroller.scrollTop;
|
|
447
|
+
const viewBottom = viewTop + scrollerBox.height - borderInset * 2;
|
|
448
|
+
// Node position relative to scroller content
|
|
449
|
+
if (nodeBox.y < viewTop) {
|
|
450
|
+
scroller.scrollTop = nodeBox.y - scrollerBox.y - borderInset;
|
|
451
|
+
ctx.onScroll(scroller);
|
|
452
|
+
}
|
|
453
|
+
else if (nodeBox.y + nodeBox.height > viewBottom) {
|
|
454
|
+
scroller.scrollTop = nodeBox.y + nodeBox.height - scrollerBox.y - scrollerBox.height + borderInset;
|
|
455
|
+
ctx.onScroll(scroller);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function openUrl(url) {
|
|
459
|
+
const { exec } = require('child_process');
|
|
460
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
461
|
+
: process.platform === 'win32' ? 'start'
|
|
462
|
+
: 'xdg-open';
|
|
463
|
+
exec(`${cmd} ${JSON.stringify(url)}`);
|
|
464
|
+
}
|
|
465
|
+
function hasScrolledNode(node) {
|
|
466
|
+
if (node.scrollTop !== 0 || node.scrollLeft !== 0)
|
|
467
|
+
return true;
|
|
468
|
+
for (const child of node.children) {
|
|
469
|
+
if (hasScrolledNode(child))
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
function findFirstElement(node) {
|
|
475
|
+
for (const child of node.children) {
|
|
476
|
+
if (child.nodeType === 'element')
|
|
477
|
+
return child;
|
|
478
|
+
}
|
|
479
|
+
return node;
|
|
480
|
+
}
|
|
481
|
+
export { TermNode } from './renderer/node.js';
|
|
482
|
+
export { CellBuffer } from './render/buffer.js';
|
|
483
|
+
export { parseCSS } from './css/parser.js';
|
|
484
|
+
export { resolveStyles } from './css/compute.js';
|
|
485
|
+
export { StdinRouter } from './terminal/stdin-router.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { TermNode } from '../renderer/node.js';
|
|
2
|
+
export interface TermEvent {
|
|
3
|
+
type: string;
|
|
4
|
+
target: TermNode;
|
|
5
|
+
data?: any;
|
|
6
|
+
propagationStopped: boolean;
|
|
7
|
+
defaultPrevented: boolean;
|
|
8
|
+
stopPropagation(): void;
|
|
9
|
+
preventDefault(): void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Dispatch an event with W3C-style capture and bubble phases.
|
|
13
|
+
*
|
|
14
|
+
* 1. Capture phase: root → ... → target.parent (type__capture listeners)
|
|
15
|
+
* 2. Target phase: fire listeners on target (both capture and bubble)
|
|
16
|
+
* 3. Bubble phase: target.parent → ... → root (type listeners)
|
|
17
|
+
*/
|
|
18
|
+
export declare function dispatchEvent(target: TermNode, type: string, data?: any): TermEvent;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
function createEvent(type, target, data) {
|
|
2
|
+
const event = {
|
|
3
|
+
type,
|
|
4
|
+
target,
|
|
5
|
+
data,
|
|
6
|
+
propagationStopped: false,
|
|
7
|
+
defaultPrevented: false,
|
|
8
|
+
stopPropagation() { event.propagationStopped = true; },
|
|
9
|
+
preventDefault() { event.defaultPrevented = true; },
|
|
10
|
+
};
|
|
11
|
+
return event;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Dispatch an event with W3C-style capture and bubble phases.
|
|
15
|
+
*
|
|
16
|
+
* 1. Capture phase: root → ... → target.parent (type__capture listeners)
|
|
17
|
+
* 2. Target phase: fire listeners on target (both capture and bubble)
|
|
18
|
+
* 3. Bubble phase: target.parent → ... → root (type listeners)
|
|
19
|
+
*/
|
|
20
|
+
export function dispatchEvent(target, type, data) {
|
|
21
|
+
const event = createEvent(type, target, data);
|
|
22
|
+
// Build ancestor path: [root, ..., parent]
|
|
23
|
+
const path = [];
|
|
24
|
+
let ancestor = target.parent;
|
|
25
|
+
while (ancestor) {
|
|
26
|
+
path.unshift(ancestor);
|
|
27
|
+
ancestor = ancestor.parent;
|
|
28
|
+
}
|
|
29
|
+
// Phase 1: Capture (root → target.parent)
|
|
30
|
+
const captureType = type + '__capture';
|
|
31
|
+
for (const node of path) {
|
|
32
|
+
const handlers = node.listeners.get(captureType);
|
|
33
|
+
if (handlers) {
|
|
34
|
+
for (const handler of handlers) {
|
|
35
|
+
handler(event);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (event.propagationStopped)
|
|
39
|
+
return event;
|
|
40
|
+
}
|
|
41
|
+
// Phase 2: Target (fire both capture and bubble listeners on target)
|
|
42
|
+
const captureHandlers = target.listeners.get(captureType);
|
|
43
|
+
if (captureHandlers) {
|
|
44
|
+
for (const handler of captureHandlers) {
|
|
45
|
+
handler(event);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!event.propagationStopped) {
|
|
49
|
+
const handlers = target.listeners.get(type);
|
|
50
|
+
if (handlers) {
|
|
51
|
+
for (const handler of handlers) {
|
|
52
|
+
handler(event);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (event.propagationStopped)
|
|
57
|
+
return event;
|
|
58
|
+
// Phase 3: Bubble (target.parent → root)
|
|
59
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
60
|
+
const handlers = path[i].listeners.get(type);
|
|
61
|
+
if (handlers) {
|
|
62
|
+
for (const handler of handlers) {
|
|
63
|
+
handler(event);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (event.propagationStopped)
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
return event;
|
|
70
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { TermNode } from '../renderer/node.js';
|
|
2
|
+
export declare class FocusManager {
|
|
3
|
+
private elements;
|
|
4
|
+
private focusIndex;
|
|
5
|
+
onSetAttribute?: (node: TermNode, key: string, value: string) => void;
|
|
6
|
+
onRemoveAttribute?: (node: TermNode, key: string) => void;
|
|
7
|
+
onFocusChange?: (focused: TermNode | null, previous: TermNode | null) => void;
|
|
8
|
+
get focused(): TermNode | null;
|
|
9
|
+
get count(): number;
|
|
10
|
+
register(node: TermNode): void;
|
|
11
|
+
unregister(node: TermNode): void;
|
|
12
|
+
focusNext(): void;
|
|
13
|
+
focusPrevious(): void;
|
|
14
|
+
focusByNode(node: TermNode): void;
|
|
15
|
+
clearFocus(): void;
|
|
16
|
+
private setFocusIndex;
|
|
17
|
+
private clearFocusAttribute;
|
|
18
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export class FocusManager {
|
|
2
|
+
elements = [];
|
|
3
|
+
focusIndex = -1;
|
|
4
|
+
onSetAttribute;
|
|
5
|
+
onRemoveAttribute;
|
|
6
|
+
onFocusChange;
|
|
7
|
+
get focused() {
|
|
8
|
+
if (this.focusIndex < 0 || this.focusIndex >= this.elements.length)
|
|
9
|
+
return null;
|
|
10
|
+
return this.elements[this.focusIndex];
|
|
11
|
+
}
|
|
12
|
+
get count() {
|
|
13
|
+
return this.elements.length;
|
|
14
|
+
}
|
|
15
|
+
register(node) {
|
|
16
|
+
if (!this.elements.includes(node)) {
|
|
17
|
+
this.elements.push(node);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
unregister(node) {
|
|
21
|
+
const idx = this.elements.indexOf(node);
|
|
22
|
+
if (idx === -1)
|
|
23
|
+
return;
|
|
24
|
+
const wasFocused = idx === this.focusIndex;
|
|
25
|
+
this.elements.splice(idx, 1);
|
|
26
|
+
if (wasFocused) {
|
|
27
|
+
this.clearFocusAttribute(node);
|
|
28
|
+
this.focusIndex = -1;
|
|
29
|
+
}
|
|
30
|
+
else if (idx < this.focusIndex) {
|
|
31
|
+
this.focusIndex--;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
focusNext() {
|
|
35
|
+
if (this.elements.length === 0)
|
|
36
|
+
return;
|
|
37
|
+
this.setFocusIndex((this.focusIndex + 1) % this.elements.length);
|
|
38
|
+
}
|
|
39
|
+
focusPrevious() {
|
|
40
|
+
if (this.elements.length === 0)
|
|
41
|
+
return;
|
|
42
|
+
const next = this.focusIndex <= 0
|
|
43
|
+
? this.elements.length - 1
|
|
44
|
+
: this.focusIndex - 1;
|
|
45
|
+
this.setFocusIndex(next);
|
|
46
|
+
}
|
|
47
|
+
focusByNode(node) {
|
|
48
|
+
const idx = this.elements.indexOf(node);
|
|
49
|
+
if (idx !== -1)
|
|
50
|
+
this.setFocusIndex(idx);
|
|
51
|
+
}
|
|
52
|
+
clearFocus() {
|
|
53
|
+
if (this.focused)
|
|
54
|
+
this.clearFocusAttribute(this.focused);
|
|
55
|
+
this.focusIndex = -1;
|
|
56
|
+
}
|
|
57
|
+
setFocusIndex(index) {
|
|
58
|
+
const prev = this.focused;
|
|
59
|
+
if (prev)
|
|
60
|
+
this.clearFocusAttribute(prev);
|
|
61
|
+
this.focusIndex = index;
|
|
62
|
+
const next = this.focused;
|
|
63
|
+
if (next) {
|
|
64
|
+
if (this.onSetAttribute) {
|
|
65
|
+
this.onSetAttribute(next, 'data-focused', 'true');
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
next.attributes.set('data-focused', 'true');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
this.onFocusChange?.(next, prev);
|
|
72
|
+
}
|
|
73
|
+
clearFocusAttribute(node) {
|
|
74
|
+
if (this.onRemoveAttribute) {
|
|
75
|
+
this.onRemoveAttribute(node, 'data-focused');
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
node.attributes.delete('data-focused');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|