@rlabs-inc/tui 0.1.0 → 0.2.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/README.md +126 -13
- package/index.ts +11 -5
- package/package.json +2 -2
- package/src/api/mount.ts +42 -27
- package/src/engine/arrays/core.ts +13 -21
- package/src/engine/arrays/dimensions.ts +22 -32
- package/src/engine/arrays/index.ts +88 -86
- package/src/engine/arrays/interaction.ts +34 -48
- package/src/engine/arrays/layout.ts +67 -92
- package/src/engine/arrays/spacing.ts +37 -52
- package/src/engine/arrays/text.ts +23 -31
- package/src/engine/arrays/visual.ts +56 -75
- package/src/engine/inheritance.ts +18 -18
- package/src/engine/registry.ts +15 -0
- package/src/pipeline/frameBuffer.ts +26 -26
- package/src/pipeline/layout/index.ts +2 -2
- package/src/pipeline/layout/titan-engine.ts +112 -84
- package/src/primitives/animation.ts +194 -0
- package/src/primitives/box.ts +74 -86
- package/src/primitives/each.ts +87 -0
- package/src/primitives/index.ts +7 -0
- package/src/primitives/scope.ts +215 -0
- package/src/primitives/show.ts +77 -0
- package/src/primitives/text.ts +63 -59
- package/src/primitives/types.ts +1 -1
- package/src/primitives/when.ts +102 -0
- package/src/renderer/append-region.ts +303 -0
- package/src/renderer/index.ts +4 -2
- package/src/renderer/output.ts +11 -34
- package/src/state/focus.ts +16 -5
- package/src/state/global-keys.ts +184 -0
- package/src/state/index.ts +44 -8
- package/src/state/input.ts +534 -0
- package/src/state/keyboard.ts +98 -674
- package/src/state/mouse.ts +163 -340
- package/src/state/scroll.ts +7 -9
- package/src/types/index.ts +6 -0
- package/src/renderer/input.ts +0 -518
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Two-Region Append Renderer
|
|
3
|
+
*
|
|
4
|
+
* Implements a hybrid static/reactive rendering mode optimized for chat-like UIs:
|
|
5
|
+
*
|
|
6
|
+
* STATIC REGION (Terminal History):
|
|
7
|
+
* - Completed messages written once via Bun.file(1).writer()
|
|
8
|
+
* - No re-rendering, pure append-only
|
|
9
|
+
* - Native terminal scroll, copy/paste, search
|
|
10
|
+
* - O(1) cost after initial write
|
|
11
|
+
*
|
|
12
|
+
* REACTIVE REGION (Active TUI):
|
|
13
|
+
* - Last N messages + input area
|
|
14
|
+
* - Full reactive rendering pipeline
|
|
15
|
+
* - Interactive (focus, scroll, mouse)
|
|
16
|
+
* - Fixed-size = O(1) render time
|
|
17
|
+
*
|
|
18
|
+
* This architecture enables:
|
|
19
|
+
* - Infinite conversation length with constant performance
|
|
20
|
+
* - Rich interactive UI for active content
|
|
21
|
+
* - Graceful degradation to static output
|
|
22
|
+
* - CLI-like feel with TUI interactivity
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Cell, RGBA, CellAttrs, FrameBuffer } from '../types'
|
|
26
|
+
import { Attr } from '../types'
|
|
27
|
+
import { rgbaEqual } from '../types/color'
|
|
28
|
+
import * as ansi from './ansi'
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// FileSink Writer for Static Region
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Writer for appending to terminal stdout using Bun's FileSink API.
|
|
36
|
+
* Buffers writes and flushes efficiently.
|
|
37
|
+
*/
|
|
38
|
+
class StdoutWriter {
|
|
39
|
+
private writer: any // FileSink type
|
|
40
|
+
private hasContent = false
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
// Create writer for stdout (file descriptor 1)
|
|
44
|
+
const stdoutFile = Bun.file(1)
|
|
45
|
+
this.writer = stdoutFile.writer({ highWaterMark: 1024 * 1024 }) // 1MB buffer
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
write(content: string): void {
|
|
49
|
+
if (content.length === 0) return
|
|
50
|
+
this.writer.write(content)
|
|
51
|
+
this.hasContent = true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
flush(): void {
|
|
55
|
+
if (this.hasContent) {
|
|
56
|
+
this.writer.flush()
|
|
57
|
+
this.hasContent = false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
end(): void {
|
|
62
|
+
this.writer.end()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// =============================================================================
|
|
67
|
+
// Message Tracking
|
|
68
|
+
// =============================================================================
|
|
69
|
+
|
|
70
|
+
interface MessageMetadata {
|
|
71
|
+
id: string
|
|
72
|
+
lineCount: number
|
|
73
|
+
content: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// =============================================================================
|
|
77
|
+
// Two-Region Append Renderer
|
|
78
|
+
// =============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Renders frame buffer in two distinct regions:
|
|
82
|
+
* 1. Static region: Frozen completed content (terminal history)
|
|
83
|
+
* 2. Reactive region: Live updating content (last N lines)
|
|
84
|
+
*
|
|
85
|
+
* The boundary between regions shifts as content completes.
|
|
86
|
+
*/
|
|
87
|
+
export class AppendRegionRenderer {
|
|
88
|
+
private staticWriter = new StdoutWriter()
|
|
89
|
+
private frozenMessages = new Set<string>()
|
|
90
|
+
private totalStaticLines = 0
|
|
91
|
+
|
|
92
|
+
// Cell rendering state (for ANSI optimization)
|
|
93
|
+
private lastFg: RGBA | null = null
|
|
94
|
+
private lastBg: RGBA | null = null
|
|
95
|
+
private lastAttrs: CellAttrs = Attr.NONE
|
|
96
|
+
|
|
97
|
+
// Previous reactive output for change detection
|
|
98
|
+
private previousReactiveOutput = ''
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Render frame buffer with two-region strategy.
|
|
102
|
+
*
|
|
103
|
+
* @param buffer - Full frame buffer
|
|
104
|
+
* @param options.staticHeight - Number of lines to freeze into static region
|
|
105
|
+
* @param options.messageIds - Optional array of message IDs for tracking
|
|
106
|
+
*/
|
|
107
|
+
render(
|
|
108
|
+
buffer: FrameBuffer,
|
|
109
|
+
options: {
|
|
110
|
+
staticHeight: number
|
|
111
|
+
messageIds?: string[]
|
|
112
|
+
} = { staticHeight: 0 }
|
|
113
|
+
): void {
|
|
114
|
+
const { staticHeight, messageIds = [] } = options
|
|
115
|
+
|
|
116
|
+
// Split buffer into two regions
|
|
117
|
+
const staticBuffer = this.extractRegion(buffer, 0, staticHeight)
|
|
118
|
+
const reactiveBuffer = this.extractRegion(buffer, staticHeight, buffer.height - staticHeight)
|
|
119
|
+
|
|
120
|
+
// STATIC REGION: Freeze new content to terminal history
|
|
121
|
+
if (staticHeight > this.totalStaticLines) {
|
|
122
|
+
const newStaticLines = staticHeight - this.totalStaticLines
|
|
123
|
+
const newStaticBuffer = this.extractRegion(
|
|
124
|
+
buffer,
|
|
125
|
+
this.totalStaticLines,
|
|
126
|
+
newStaticLines
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const staticOutput = this.buildStaticOutput(newStaticBuffer)
|
|
130
|
+
this.staticWriter.write(staticOutput)
|
|
131
|
+
this.staticWriter.flush()
|
|
132
|
+
|
|
133
|
+
this.totalStaticLines = staticHeight
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// REACTIVE REGION: Clear and re-render active content
|
|
137
|
+
const reactiveOutput = this.buildReactiveOutput(reactiveBuffer)
|
|
138
|
+
|
|
139
|
+
// Only update if changed
|
|
140
|
+
if (reactiveOutput !== this.previousReactiveOutput) {
|
|
141
|
+
// Clear from current position down
|
|
142
|
+
process.stdout.write(ansi.eraseDown)
|
|
143
|
+
|
|
144
|
+
// Render reactive content
|
|
145
|
+
process.stdout.write(reactiveOutput)
|
|
146
|
+
|
|
147
|
+
this.previousReactiveOutput = reactiveOutput
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Extract a region from the frame buffer.
|
|
153
|
+
*/
|
|
154
|
+
private extractRegion(buffer: FrameBuffer, startY: number, height: number): FrameBuffer {
|
|
155
|
+
if (height <= 0) {
|
|
156
|
+
return {
|
|
157
|
+
width: buffer.width,
|
|
158
|
+
height: 0,
|
|
159
|
+
cells: []
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const endY = Math.min(startY + height, buffer.height)
|
|
164
|
+
const actualHeight = endY - startY
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
width: buffer.width,
|
|
168
|
+
height: actualHeight,
|
|
169
|
+
cells: buffer.cells.slice(startY, endY)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Build output for static region (append once, forget).
|
|
175
|
+
*/
|
|
176
|
+
private buildStaticOutput(buffer: FrameBuffer): string {
|
|
177
|
+
if (buffer.height === 0) return ''
|
|
178
|
+
|
|
179
|
+
const chunks: string[] = []
|
|
180
|
+
|
|
181
|
+
// Reset rendering state
|
|
182
|
+
this.lastFg = null
|
|
183
|
+
this.lastBg = null
|
|
184
|
+
this.lastAttrs = Attr.NONE
|
|
185
|
+
|
|
186
|
+
for (let y = 0; y < buffer.height; y++) {
|
|
187
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
188
|
+
const cell = buffer.cells[y]![x]
|
|
189
|
+
this.renderCell(chunks, cell!)
|
|
190
|
+
}
|
|
191
|
+
chunks.push('\n')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
chunks.push(ansi.reset)
|
|
195
|
+
|
|
196
|
+
return chunks.join('')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Build output for reactive region (cleared and re-rendered each frame).
|
|
201
|
+
*/
|
|
202
|
+
private buildReactiveOutput(buffer: FrameBuffer): string {
|
|
203
|
+
if (buffer.height === 0) return ''
|
|
204
|
+
|
|
205
|
+
const chunks: string[] = []
|
|
206
|
+
|
|
207
|
+
// Reset rendering state
|
|
208
|
+
this.lastFg = null
|
|
209
|
+
this.lastBg = null
|
|
210
|
+
this.lastAttrs = Attr.NONE
|
|
211
|
+
|
|
212
|
+
for (let y = 0; y < buffer.height; y++) {
|
|
213
|
+
if (y > 0) {
|
|
214
|
+
chunks.push('\n')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
218
|
+
const cell = buffer.cells[y]![x]
|
|
219
|
+
this.renderCell(chunks, cell!)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
chunks.push(ansi.reset)
|
|
224
|
+
chunks.push('\n')
|
|
225
|
+
|
|
226
|
+
return chunks.join('')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Render a single cell with ANSI optimization.
|
|
231
|
+
*/
|
|
232
|
+
private renderCell(chunks: string[], cell: Cell): void {
|
|
233
|
+
// Attributes changed - reset first
|
|
234
|
+
if (cell.attrs !== this.lastAttrs) {
|
|
235
|
+
chunks.push(ansi.reset)
|
|
236
|
+
if (cell.attrs !== Attr.NONE) {
|
|
237
|
+
chunks.push(ansi.attrs(cell.attrs))
|
|
238
|
+
}
|
|
239
|
+
this.lastFg = null
|
|
240
|
+
this.lastBg = null
|
|
241
|
+
this.lastAttrs = cell.attrs
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Foreground color changed
|
|
245
|
+
if (!this.lastFg || !rgbaEqual(cell.fg, this.lastFg)) {
|
|
246
|
+
chunks.push(ansi.fg(cell.fg))
|
|
247
|
+
this.lastFg = cell.fg
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Background color changed
|
|
251
|
+
if (!this.lastBg || !rgbaEqual(cell.bg, this.lastBg)) {
|
|
252
|
+
chunks.push(ansi.bg(cell.bg))
|
|
253
|
+
this.lastBg = cell.bg
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Output character
|
|
257
|
+
if (cell.char === 0) {
|
|
258
|
+
chunks.push(' ')
|
|
259
|
+
} else {
|
|
260
|
+
chunks.push(String.fromCodePoint(cell.char))
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Mark a message as frozen (moved to static region).
|
|
266
|
+
*/
|
|
267
|
+
freezeMessage(messageId: string): void {
|
|
268
|
+
this.frozenMessages.add(messageId)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Check if a message has been frozen.
|
|
273
|
+
*/
|
|
274
|
+
isFrozen(messageId: string): boolean {
|
|
275
|
+
return this.frozenMessages.has(messageId)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get total number of static lines rendered.
|
|
280
|
+
*/
|
|
281
|
+
getStaticLineCount(): number {
|
|
282
|
+
return this.totalStaticLines
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Reset the renderer state.
|
|
287
|
+
*/
|
|
288
|
+
reset(): void {
|
|
289
|
+
this.frozenMessages.clear()
|
|
290
|
+
this.totalStaticLines = 0
|
|
291
|
+
this.previousReactiveOutput = ''
|
|
292
|
+
this.lastFg = null
|
|
293
|
+
this.lastBg = null
|
|
294
|
+
this.lastAttrs = Attr.NONE
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Cleanup when unmounting.
|
|
299
|
+
*/
|
|
300
|
+
cleanup(): void {
|
|
301
|
+
this.staticWriter.end()
|
|
302
|
+
}
|
|
303
|
+
}
|
package/src/renderer/index.ts
CHANGED
|
@@ -36,5 +36,7 @@ export {
|
|
|
36
36
|
finalizeAppendMode,
|
|
37
37
|
} from './output'
|
|
38
38
|
|
|
39
|
-
//
|
|
40
|
-
export {
|
|
39
|
+
// Two-region append renderer
|
|
40
|
+
export { AppendRegionRenderer } from './append-region'
|
|
41
|
+
|
|
42
|
+
// Input parsing moved to src/state/input.ts
|
package/src/renderer/output.ts
CHANGED
|
@@ -312,12 +312,11 @@ export function finalizeAppendMode(output: OutputBuffer, height: number): void {
|
|
|
312
312
|
// =============================================================================
|
|
313
313
|
|
|
314
314
|
/**
|
|
315
|
-
* Inline renderer
|
|
316
|
-
* Uses
|
|
315
|
+
* Inline renderer for non-fullscreen mode.
|
|
316
|
+
* Uses clearTerminal for reliable rendering without ghost lines.
|
|
317
317
|
*/
|
|
318
318
|
export class InlineRenderer {
|
|
319
319
|
private output = new OutputBuffer()
|
|
320
|
-
private previousLineCount = 0
|
|
321
320
|
private previousOutput = ''
|
|
322
321
|
|
|
323
322
|
// Cell rendering state (for ANSI optimization)
|
|
@@ -327,18 +326,14 @@ export class InlineRenderer {
|
|
|
327
326
|
|
|
328
327
|
/**
|
|
329
328
|
* Render a frame buffer for inline mode.
|
|
330
|
-
* Follows log-update's algorithm:
|
|
331
|
-
* 1. Build output with trailing newline
|
|
332
|
-
* 2. eraseLines(previousLineCount) + output
|
|
333
|
-
* 3. Track new line count
|
|
334
329
|
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
330
|
+
* Uses clearTerminal (clears screen + scrollback) before each render.
|
|
331
|
+
* This is simpler and more reliable than tracking line counts:
|
|
332
|
+
* - No ghost lines from eraseLines edge cases
|
|
333
|
+
* - Once content exceeds terminal height, scrollback is cleared anyway
|
|
334
|
+
* - The slight overhead of clearing is negligible with fast renders
|
|
339
335
|
*/
|
|
340
336
|
render(buffer: FrameBuffer): void {
|
|
341
|
-
// Build the output string
|
|
342
337
|
const output = this.buildOutput(buffer)
|
|
343
338
|
|
|
344
339
|
// Skip if output unchanged
|
|
@@ -346,26 +341,11 @@ export class InlineRenderer {
|
|
|
346
341
|
return
|
|
347
342
|
}
|
|
348
343
|
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
// When content height >= terminal rows, eraseLines can't reach content
|
|
353
|
-
// that scrolled off into scrollback. Use clearTerminal instead.
|
|
354
|
-
if (this.previousLineCount >= terminalRows) {
|
|
355
|
-
this.output.write(ansi.clearTerminal + output)
|
|
356
|
-
} else {
|
|
357
|
-
this.output.write(ansi.eraseLines(this.previousLineCount) + output)
|
|
358
|
-
}
|
|
344
|
+
// Clear everything and render fresh
|
|
345
|
+
this.output.write(ansi.clearTerminal + output)
|
|
359
346
|
this.output.flushSync()
|
|
360
347
|
|
|
361
|
-
// Track for next render
|
|
362
348
|
this.previousOutput = output
|
|
363
|
-
// buffer.height + 1 because:
|
|
364
|
-
// - We output buffer.height lines of content
|
|
365
|
-
// - Plus a trailing newline that puts cursor on the next line
|
|
366
|
-
// - eraseLines works from cursor position upward, so we need to erase
|
|
367
|
-
// buffer.height lines PLUS the empty line we're currently on
|
|
368
|
-
this.previousLineCount = buffer.height + 1
|
|
369
349
|
}
|
|
370
350
|
|
|
371
351
|
/**
|
|
@@ -431,10 +411,8 @@ export class InlineRenderer {
|
|
|
431
411
|
* Clear all rendered content and reset state.
|
|
432
412
|
*/
|
|
433
413
|
clear(): void {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
this.output.flushSync()
|
|
437
|
-
}
|
|
414
|
+
this.output.write(ansi.clearTerminal)
|
|
415
|
+
this.output.flushSync()
|
|
438
416
|
this.reset()
|
|
439
417
|
}
|
|
440
418
|
|
|
@@ -442,7 +420,6 @@ export class InlineRenderer {
|
|
|
442
420
|
* Reset the renderer state.
|
|
443
421
|
*/
|
|
444
422
|
reset(): void {
|
|
445
|
-
this.previousLineCount = 0
|
|
446
423
|
this.previousOutput = ''
|
|
447
424
|
this.lastFg = null
|
|
448
425
|
this.lastBg = null
|
package/src/state/focus.ts
CHANGED
|
@@ -65,7 +65,10 @@ export function restoreFocusFromHistory(): boolean {
|
|
|
65
65
|
while (focusHistory.length > 0) {
|
|
66
66
|
const index = focusHistory.pop()!
|
|
67
67
|
// Check if component is still valid and focusable
|
|
68
|
-
|
|
68
|
+
// Match TITAN's logic: undefined means visible, only 0/false means hidden
|
|
69
|
+
const isVisible = unwrap(visible[index])
|
|
70
|
+
const isActuallyVisible = isVisible !== 0 && isVisible !== false
|
|
71
|
+
if (unwrap(focusable[index]) && isActuallyVisible) {
|
|
69
72
|
focusedIndex.value = index
|
|
70
73
|
return true
|
|
71
74
|
}
|
|
@@ -83,7 +86,11 @@ export function getFocusableIndices(): number[] {
|
|
|
83
86
|
const result: number[] = []
|
|
84
87
|
|
|
85
88
|
for (const i of indices) {
|
|
86
|
-
|
|
89
|
+
const isFocusable = unwrap(focusable[i])
|
|
90
|
+
const isVisible = unwrap(visible[i])
|
|
91
|
+
// Match TITAN's logic: undefined means visible, only 0/false means hidden
|
|
92
|
+
const isActuallyVisible = isVisible !== 0 && isVisible !== false
|
|
93
|
+
if (isFocusable && isActuallyVisible) {
|
|
87
94
|
result.push(i)
|
|
88
95
|
}
|
|
89
96
|
}
|
|
@@ -141,8 +148,9 @@ function findNextFocusable(fromIndex: number, direction: 1 | -1): number {
|
|
|
141
148
|
|
|
142
149
|
/** Move focus to next focusable component */
|
|
143
150
|
export function focusNext(): boolean {
|
|
144
|
-
const
|
|
145
|
-
|
|
151
|
+
const current = focusedIndex.value
|
|
152
|
+
const next = findNextFocusable(current, 1)
|
|
153
|
+
if (next !== -1 && next !== current) {
|
|
146
154
|
saveFocusToHistory()
|
|
147
155
|
focusedIndex.value = next
|
|
148
156
|
return true
|
|
@@ -163,7 +171,10 @@ export function focusPrevious(): boolean {
|
|
|
163
171
|
|
|
164
172
|
/** Focus a specific component by index */
|
|
165
173
|
export function focus(index: number): boolean {
|
|
166
|
-
|
|
174
|
+
// Match TITAN's logic: undefined means visible, only 0/false means hidden
|
|
175
|
+
const isVisible = unwrap(visible[index])
|
|
176
|
+
const isActuallyVisible = isVisible !== 0 && isVisible !== false
|
|
177
|
+
if (unwrap(focusable[index]) && isActuallyVisible) {
|
|
167
178
|
if (focusedIndex.value !== index) {
|
|
168
179
|
saveFocusToHistory()
|
|
169
180
|
focusedIndex.value = index
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Global Keys Module
|
|
3
|
+
*
|
|
4
|
+
* Central location for ALL global keyboard and mouse shortcuts.
|
|
5
|
+
* This is the ONLY place where global input behaviors are defined.
|
|
6
|
+
*
|
|
7
|
+
* Wires together: input, keyboard, mouse, focus, scroll
|
|
8
|
+
*
|
|
9
|
+
* Global behaviors:
|
|
10
|
+
* - Ctrl+C: Exit application
|
|
11
|
+
* - Tab/Shift+Tab: Focus navigation
|
|
12
|
+
* - Arrow keys: Scroll focused scrollable
|
|
13
|
+
* - Page Up/Down: Large scroll jumps
|
|
14
|
+
* - Home/End: Scroll to start/end
|
|
15
|
+
* - Mouse wheel: Scroll hovered or focused
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { input } from './input'
|
|
19
|
+
import { keyboard, dispatch as dispatchKeyboard, dispatchFocused } from './keyboard'
|
|
20
|
+
import { mouse, dispatch as dispatchMouse } from './mouse'
|
|
21
|
+
import { focusNext, focusPrevious, focusedIndex } from './focus'
|
|
22
|
+
import { handleArrowScroll, handlePageScroll, handleHomeEnd, handleWheelScroll } from './scroll'
|
|
23
|
+
import type { KeyboardEvent } from './keyboard'
|
|
24
|
+
import type { MouseEvent } from './mouse'
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// STATE
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
let initialized = false
|
|
31
|
+
let cleanupCallback: (() => void) | null = null
|
|
32
|
+
let exitOnCtrlC = true
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// EVENT HANDLERS
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
function handleKeyboardEvent(event: KeyboardEvent): void {
|
|
39
|
+
try {
|
|
40
|
+
// Only process press events for most shortcuts
|
|
41
|
+
const isPress = event.state === 'press'
|
|
42
|
+
|
|
43
|
+
// Ctrl+C - exit (always, even on release for safety)
|
|
44
|
+
if (exitOnCtrlC && event.key === 'c' && event.modifiers.ctrl) {
|
|
45
|
+
cleanup()
|
|
46
|
+
process.exit(0)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Skip non-press events for other shortcuts
|
|
50
|
+
if (!isPress) {
|
|
51
|
+
// Still dispatch to handlers (they may want release events)
|
|
52
|
+
dispatchFocused(focusedIndex.value, event)
|
|
53
|
+
dispatchKeyboard(event)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Tab - focus navigation
|
|
58
|
+
if (event.key === 'Tab' && !event.modifiers.ctrl && !event.modifiers.alt) {
|
|
59
|
+
if (event.modifiers.shift) {
|
|
60
|
+
focusPrevious()
|
|
61
|
+
} else {
|
|
62
|
+
focusNext()
|
|
63
|
+
}
|
|
64
|
+
return // Tab is always consumed
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Dispatch to focused component handlers first
|
|
68
|
+
if (dispatchFocused(focusedIndex.value, event)) {
|
|
69
|
+
return // Consumed by focused handler
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Dispatch to user handlers (keyboard.onKey)
|
|
73
|
+
// If user returns true, they handled it - skip framework defaults
|
|
74
|
+
if (dispatchKeyboard(event)) {
|
|
75
|
+
return // User handled it
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// =========================================================================
|
|
79
|
+
// FRAMEWORK DEFAULTS - only run if user didn't handle
|
|
80
|
+
// =========================================================================
|
|
81
|
+
|
|
82
|
+
// Arrow keys - scroll focused scrollable
|
|
83
|
+
if (event.key === 'ArrowUp' && handleArrowScroll('up')) return
|
|
84
|
+
if (event.key === 'ArrowDown' && handleArrowScroll('down')) return
|
|
85
|
+
if (event.key === 'ArrowLeft' && handleArrowScroll('left')) return
|
|
86
|
+
if (event.key === 'ArrowRight' && handleArrowScroll('right')) return
|
|
87
|
+
|
|
88
|
+
// Page Up/Down
|
|
89
|
+
if (event.key === 'PageUp' && handlePageScroll('up')) return
|
|
90
|
+
if (event.key === 'PageDown' && handlePageScroll('down')) return
|
|
91
|
+
|
|
92
|
+
// Home/End (without Ctrl - Ctrl+Home/End could be used for something else)
|
|
93
|
+
if (event.key === 'Home' && !event.modifiers.ctrl && handleHomeEnd('home')) return
|
|
94
|
+
if (event.key === 'End' && !event.modifiers.ctrl && handleHomeEnd('end')) return
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error('[TUI] Keyboard handler error:', err)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handleMouseEvent(event: MouseEvent): void {
|
|
101
|
+
// Mouse wheel - scroll hovered or focused
|
|
102
|
+
if (event.action === 'scroll' && event.scroll) {
|
|
103
|
+
handleWheelScroll(event.x, event.y, event.scroll.direction)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Dispatch to mouse module for hover/click handling
|
|
107
|
+
dispatchMouse(event)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// =============================================================================
|
|
111
|
+
// PUBLIC API
|
|
112
|
+
// =============================================================================
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Initialize the global input system.
|
|
116
|
+
* Sets up stdin handling and wires all global shortcuts.
|
|
117
|
+
*/
|
|
118
|
+
export function initialize(options?: {
|
|
119
|
+
onCleanup?: () => void
|
|
120
|
+
exitOnCtrlC?: boolean
|
|
121
|
+
enableMouse?: boolean
|
|
122
|
+
}): void {
|
|
123
|
+
if (initialized) return
|
|
124
|
+
|
|
125
|
+
initialized = true
|
|
126
|
+
cleanupCallback = options?.onCleanup ?? null
|
|
127
|
+
exitOnCtrlC = options?.exitOnCtrlC ?? true
|
|
128
|
+
|
|
129
|
+
// Initialize input system with our handlers
|
|
130
|
+
input.initialize(handleKeyboardEvent, handleMouseEvent)
|
|
131
|
+
|
|
132
|
+
// Enable mouse tracking if requested
|
|
133
|
+
if (options?.enableMouse !== false) {
|
|
134
|
+
mouse.enableTracking()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Clean up the global input system.
|
|
140
|
+
*/
|
|
141
|
+
export function cleanup(): void {
|
|
142
|
+
if (!initialized) return
|
|
143
|
+
|
|
144
|
+
initialized = false
|
|
145
|
+
|
|
146
|
+
// Clean up all modules
|
|
147
|
+
mouse.cleanup()
|
|
148
|
+
keyboard.cleanup()
|
|
149
|
+
input.cleanup()
|
|
150
|
+
|
|
151
|
+
// Show cursor
|
|
152
|
+
process.stdout.write('\x1b[?25h')
|
|
153
|
+
|
|
154
|
+
// Call user cleanup
|
|
155
|
+
if (cleanupCallback) {
|
|
156
|
+
cleanupCallback()
|
|
157
|
+
cleanupCallback = null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Set whether Ctrl+C exits the application.
|
|
163
|
+
*/
|
|
164
|
+
export function setExitOnCtrlC(enabled: boolean): void {
|
|
165
|
+
exitOnCtrlC = enabled
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check if global keys system is initialized.
|
|
170
|
+
*/
|
|
171
|
+
export function isInitialized(): boolean {
|
|
172
|
+
return initialized
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// =============================================================================
|
|
176
|
+
// EXPORT
|
|
177
|
+
// =============================================================================
|
|
178
|
+
|
|
179
|
+
export const globalKeys = {
|
|
180
|
+
initialize,
|
|
181
|
+
cleanup,
|
|
182
|
+
setExitOnCtrlC,
|
|
183
|
+
isInitialized,
|
|
184
|
+
}
|
package/src/state/index.ts
CHANGED
|
@@ -11,9 +11,45 @@
|
|
|
11
11
|
* - cursor: Cursor visibility, shape, position
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
// Keyboard
|
|
15
|
-
export
|
|
16
|
-
|
|
14
|
+
// Keyboard - explicit exports to avoid conflicts
|
|
15
|
+
export {
|
|
16
|
+
keyboard,
|
|
17
|
+
lastKey,
|
|
18
|
+
lastEvent,
|
|
19
|
+
on,
|
|
20
|
+
onKey,
|
|
21
|
+
onFocused,
|
|
22
|
+
cleanupIndex,
|
|
23
|
+
dispatch as dispatchKeyboard,
|
|
24
|
+
dispatchFocused,
|
|
25
|
+
cleanup as cleanupKeyboard,
|
|
26
|
+
} from './keyboard'
|
|
27
|
+
export type { Modifiers, KeyState, KeyboardEvent, KeyHandler } from './keyboard'
|
|
28
|
+
|
|
29
|
+
// Mouse - explicit exports to avoid conflicts
|
|
30
|
+
export {
|
|
31
|
+
mouse,
|
|
32
|
+
hitGrid,
|
|
33
|
+
lastMouseEvent,
|
|
34
|
+
mouseX,
|
|
35
|
+
mouseY,
|
|
36
|
+
isMouseDown,
|
|
37
|
+
HitGrid,
|
|
38
|
+
MouseButton,
|
|
39
|
+
onMouseDown,
|
|
40
|
+
onMouseUp,
|
|
41
|
+
onClick,
|
|
42
|
+
onScroll,
|
|
43
|
+
onComponent,
|
|
44
|
+
resize,
|
|
45
|
+
clearHitGrid,
|
|
46
|
+
enableTracking,
|
|
47
|
+
disableTracking,
|
|
48
|
+
isTrackingEnabled,
|
|
49
|
+
dispatch as dispatchMouse,
|
|
50
|
+
cleanup as cleanupMouse,
|
|
51
|
+
} from './mouse'
|
|
52
|
+
export type { MouseAction, ScrollInfo, MouseEvent, MouseHandlers, MouseHandler } from './mouse'
|
|
17
53
|
// Focus exports - exclude duplicates that are in keyboard
|
|
18
54
|
export {
|
|
19
55
|
focusedIndex,
|
|
@@ -36,8 +72,8 @@ export {
|
|
|
36
72
|
export * from './scroll'
|
|
37
73
|
export * from './cursor'
|
|
38
74
|
|
|
39
|
-
//
|
|
40
|
-
export {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
export {
|
|
75
|
+
// Global keys - all shortcuts wired together
|
|
76
|
+
export { globalKeys } from './global-keys'
|
|
77
|
+
|
|
78
|
+
// Input - stdin ownership
|
|
79
|
+
export { input } from './input'
|