@rlabs-inc/tui 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/README.md +141 -0
- package/index.ts +45 -0
- package/package.json +59 -0
- package/src/api/index.ts +7 -0
- package/src/api/mount.ts +230 -0
- package/src/engine/arrays/core.ts +60 -0
- package/src/engine/arrays/dimensions.ts +68 -0
- package/src/engine/arrays/index.ts +166 -0
- package/src/engine/arrays/interaction.ts +112 -0
- package/src/engine/arrays/layout.ts +175 -0
- package/src/engine/arrays/spacing.ts +100 -0
- package/src/engine/arrays/text.ts +55 -0
- package/src/engine/arrays/visual.ts +140 -0
- package/src/engine/index.ts +25 -0
- package/src/engine/inheritance.ts +138 -0
- package/src/engine/registry.ts +180 -0
- package/src/pipeline/frameBuffer.ts +473 -0
- package/src/pipeline/layout/index.ts +105 -0
- package/src/pipeline/layout/titan-engine.ts +798 -0
- package/src/pipeline/layout/types.ts +194 -0
- package/src/pipeline/layout/utils/hierarchy.ts +202 -0
- package/src/pipeline/layout/utils/math.ts +134 -0
- package/src/pipeline/layout/utils/text-measure.ts +160 -0
- package/src/pipeline/layout.ts +30 -0
- package/src/primitives/box.ts +312 -0
- package/src/primitives/index.ts +12 -0
- package/src/primitives/text.ts +199 -0
- package/src/primitives/types.ts +222 -0
- package/src/primitives/utils.ts +37 -0
- package/src/renderer/ansi.ts +625 -0
- package/src/renderer/buffer.ts +667 -0
- package/src/renderer/index.ts +40 -0
- package/src/renderer/input.ts +518 -0
- package/src/renderer/output.ts +451 -0
- package/src/state/cursor.ts +176 -0
- package/src/state/focus.ts +241 -0
- package/src/state/index.ts +43 -0
- package/src/state/keyboard.ts +771 -0
- package/src/state/mouse.ts +524 -0
- package/src/state/scroll.ts +341 -0
- package/src/state/theme.ts +687 -0
- package/src/types/color.ts +401 -0
- package/src/types/index.ts +316 -0
- package/src/utils/text.ts +471 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Output & Differential Rendering
|
|
3
|
+
*
|
|
4
|
+
* The DiffRenderer is the "Terminal GPU" - it takes FrameBuffers
|
|
5
|
+
* and efficiently outputs only what changed since the last frame.
|
|
6
|
+
*
|
|
7
|
+
* Key optimizations:
|
|
8
|
+
* - Stateful cell renderer (only emits changed attributes)
|
|
9
|
+
* - Cursor position tracking (skips redundant moves)
|
|
10
|
+
* - Synchronized output (prevents flicker)
|
|
11
|
+
* - Batched writing (single syscall per frame)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Cell, RGBA, CellAttrs, FrameBuffer } from '../types'
|
|
15
|
+
import { Attr } from '../types'
|
|
16
|
+
import { rgbaEqual } from '../types/color'
|
|
17
|
+
import { cellEqual } from './buffer'
|
|
18
|
+
import * as ansi from './ansi'
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Output Buffer
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Batched output buffer.
|
|
26
|
+
* Accumulates writes and flushes in a single syscall.
|
|
27
|
+
*/
|
|
28
|
+
export class OutputBuffer {
|
|
29
|
+
private chunks: string[] = []
|
|
30
|
+
private totalLength = 0
|
|
31
|
+
|
|
32
|
+
write(str: string): void {
|
|
33
|
+
if (str.length === 0) return
|
|
34
|
+
this.chunks.push(str)
|
|
35
|
+
this.totalLength += str.length
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
clear(): void {
|
|
39
|
+
this.chunks = []
|
|
40
|
+
this.totalLength = 0
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get length(): number {
|
|
44
|
+
return this.totalLength
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
toString(): string {
|
|
48
|
+
return this.chunks.join('')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async flush(): Promise<void> {
|
|
52
|
+
if (this.totalLength === 0) return
|
|
53
|
+
|
|
54
|
+
const output = this.chunks.join('')
|
|
55
|
+
this.clear()
|
|
56
|
+
|
|
57
|
+
// Use Bun.write for performance
|
|
58
|
+
await Bun.write(Bun.stdout, output)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
flushSync(): void {
|
|
62
|
+
if (this.totalLength === 0) return
|
|
63
|
+
|
|
64
|
+
const output = this.chunks.join('')
|
|
65
|
+
this.clear()
|
|
66
|
+
|
|
67
|
+
process.stdout.write(output)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// =============================================================================
|
|
72
|
+
// Stateful Cell Renderer
|
|
73
|
+
// =============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Renders cells while tracking state to minimize output.
|
|
77
|
+
* Only emits ANSI codes when values actually change.
|
|
78
|
+
*/
|
|
79
|
+
class StatefulCellRenderer {
|
|
80
|
+
private lastFg: RGBA | null = null
|
|
81
|
+
private lastBg: RGBA | null = null
|
|
82
|
+
private lastAttrs: CellAttrs = Attr.NONE
|
|
83
|
+
private lastX = -1
|
|
84
|
+
private lastY = -1
|
|
85
|
+
|
|
86
|
+
reset(): void {
|
|
87
|
+
this.lastFg = null
|
|
88
|
+
this.lastBg = null
|
|
89
|
+
this.lastAttrs = Attr.NONE
|
|
90
|
+
this.lastX = -1
|
|
91
|
+
this.lastY = -1
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
render(output: OutputBuffer, x: number, y: number, cell: Cell): void {
|
|
95
|
+
// Move cursor if not sequential
|
|
96
|
+
if (y !== this.lastY || x !== this.lastX + 1) {
|
|
97
|
+
output.write(ansi.moveTo(x + 1, y + 1)) // ANSI is 1-indexed
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Attributes changed - need to reset first
|
|
101
|
+
if (cell.attrs !== this.lastAttrs) {
|
|
102
|
+
// Reset then apply new attributes
|
|
103
|
+
output.write(ansi.reset)
|
|
104
|
+
if (cell.attrs !== Attr.NONE) {
|
|
105
|
+
output.write(ansi.attrs(cell.attrs))
|
|
106
|
+
}
|
|
107
|
+
// After reset, colors need to be re-emitted
|
|
108
|
+
this.lastFg = null
|
|
109
|
+
this.lastBg = null
|
|
110
|
+
this.lastAttrs = cell.attrs
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Foreground color changed
|
|
114
|
+
if (!this.lastFg || !rgbaEqual(cell.fg, this.lastFg)) {
|
|
115
|
+
output.write(ansi.fg(cell.fg))
|
|
116
|
+
this.lastFg = cell.fg
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Background color changed
|
|
120
|
+
if (!this.lastBg || !rgbaEqual(cell.bg, this.lastBg)) {
|
|
121
|
+
output.write(ansi.bg(cell.bg))
|
|
122
|
+
this.lastBg = cell.bg
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Output character
|
|
126
|
+
if (cell.char === 0) {
|
|
127
|
+
// Continuation of wide character - skip
|
|
128
|
+
} else {
|
|
129
|
+
output.write(String.fromCodePoint(cell.char))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.lastX = x
|
|
133
|
+
this.lastY = y
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Render a cell for inline mode (no absolute positioning).
|
|
138
|
+
* Writes attributes, colors, and character sequentially.
|
|
139
|
+
*/
|
|
140
|
+
renderInline(output: OutputBuffer, cell: Cell): void {
|
|
141
|
+
// Attributes changed - need to reset first
|
|
142
|
+
if (cell.attrs !== this.lastAttrs) {
|
|
143
|
+
output.write(ansi.reset)
|
|
144
|
+
if (cell.attrs !== Attr.NONE) {
|
|
145
|
+
output.write(ansi.attrs(cell.attrs))
|
|
146
|
+
}
|
|
147
|
+
this.lastFg = null
|
|
148
|
+
this.lastBg = null
|
|
149
|
+
this.lastAttrs = cell.attrs
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Foreground color changed
|
|
153
|
+
if (!this.lastFg || !rgbaEqual(cell.fg, this.lastFg)) {
|
|
154
|
+
output.write(ansi.fg(cell.fg))
|
|
155
|
+
this.lastFg = cell.fg
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Background color changed
|
|
159
|
+
if (!this.lastBg || !rgbaEqual(cell.bg, this.lastBg)) {
|
|
160
|
+
output.write(ansi.bg(cell.bg))
|
|
161
|
+
this.lastBg = cell.bg
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Output character (space for null/continuation)
|
|
165
|
+
if (cell.char === 0) {
|
|
166
|
+
output.write(' ') // Continuation - write space to maintain grid
|
|
167
|
+
} else {
|
|
168
|
+
output.write(String.fromCodePoint(cell.char))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// =============================================================================
|
|
174
|
+
// Diff Renderer
|
|
175
|
+
// =============================================================================
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Differential renderer - only renders cells that changed.
|
|
179
|
+
* Wraps output in synchronized block to prevent flicker.
|
|
180
|
+
*/
|
|
181
|
+
export class DiffRenderer {
|
|
182
|
+
private output = new OutputBuffer()
|
|
183
|
+
private cellRenderer = new StatefulCellRenderer()
|
|
184
|
+
private previousBuffer: FrameBuffer | null = null
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Render a frame buffer, diffing against previous frame.
|
|
188
|
+
* Returns true if anything was rendered.
|
|
189
|
+
*/
|
|
190
|
+
render(buffer: FrameBuffer): boolean {
|
|
191
|
+
const prev = this.previousBuffer
|
|
192
|
+
let hasChanges = false
|
|
193
|
+
|
|
194
|
+
// NOTE: Synchronized output (CSI?2026h) disabled - was causing row 0 issues
|
|
195
|
+
// this.output.write(ansi.beginSync)
|
|
196
|
+
|
|
197
|
+
// Reset cell renderer state at start of frame
|
|
198
|
+
this.cellRenderer.reset()
|
|
199
|
+
|
|
200
|
+
// Render changed cells
|
|
201
|
+
for (let y = 0; y < buffer.height; y++) {
|
|
202
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
203
|
+
const cell = buffer.cells[y]![x]
|
|
204
|
+
|
|
205
|
+
// Skip if unchanged from previous frame
|
|
206
|
+
if (prev && y < prev.height && x < prev.width) {
|
|
207
|
+
const prevCell = prev.cells[y]![x]
|
|
208
|
+
if (cellEqual(cell!, prevCell!)) continue
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
hasChanges = true
|
|
212
|
+
this.cellRenderer.render(this.output, x, y, cell!)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// End synchronized output
|
|
217
|
+
this.output.write(ansi.endSync)
|
|
218
|
+
|
|
219
|
+
// Flush to terminal
|
|
220
|
+
this.output.flushSync()
|
|
221
|
+
|
|
222
|
+
// Store for next diff
|
|
223
|
+
this.previousBuffer = buffer
|
|
224
|
+
|
|
225
|
+
return hasChanges
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Force full redraw (no diffing).
|
|
230
|
+
*/
|
|
231
|
+
renderFull(buffer: FrameBuffer): void {
|
|
232
|
+
// Begin synchronized output
|
|
233
|
+
this.output.write(ansi.beginSync)
|
|
234
|
+
|
|
235
|
+
// Clear and reset
|
|
236
|
+
this.output.write(ansi.moveTo(1, 1))
|
|
237
|
+
this.cellRenderer.reset()
|
|
238
|
+
|
|
239
|
+
// Render all cells
|
|
240
|
+
for (let y = 0; y < buffer.height; y++) {
|
|
241
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
242
|
+
const cell = buffer.cells[y]![x]
|
|
243
|
+
this.cellRenderer.render(this.output, x, y, cell!)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// End synchronized output
|
|
248
|
+
this.output.write(ansi.endSync)
|
|
249
|
+
|
|
250
|
+
// Flush to terminal
|
|
251
|
+
this.output.flushSync()
|
|
252
|
+
|
|
253
|
+
// Store for next diff
|
|
254
|
+
this.previousBuffer = buffer
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Clear stored previous buffer (force full render on next call).
|
|
259
|
+
*/
|
|
260
|
+
invalidate(): void {
|
|
261
|
+
this.previousBuffer = null
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get output buffer for additional writes.
|
|
266
|
+
*/
|
|
267
|
+
getOutput(): OutputBuffer {
|
|
268
|
+
return this.output
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// =============================================================================
|
|
273
|
+
// Render Mode Helpers
|
|
274
|
+
// =============================================================================
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Setup for inline render mode.
|
|
278
|
+
* Saves cursor position before first render.
|
|
279
|
+
*/
|
|
280
|
+
export function setupInlineMode(output: OutputBuffer): void {
|
|
281
|
+
output.write(ansi.saveCursor)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Position cursor for inline mode update.
|
|
286
|
+
*/
|
|
287
|
+
export function positionInlineMode(output: OutputBuffer): void {
|
|
288
|
+
output.write(ansi.restoreCursor)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Position cursor for append mode update.
|
|
293
|
+
* Moves up to overwrite previous content.
|
|
294
|
+
*/
|
|
295
|
+
export function positionAppendMode(output: OutputBuffer, previousHeight: number): void {
|
|
296
|
+
if (previousHeight > 0) {
|
|
297
|
+
output.write(ansi.moveUp(previousHeight))
|
|
298
|
+
output.write(ansi.carriageReturn)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Position cursor after append mode render.
|
|
304
|
+
* Moves to line after content.
|
|
305
|
+
*/
|
|
306
|
+
export function finalizeAppendMode(output: OutputBuffer, height: number): void {
|
|
307
|
+
output.write(ansi.moveTo(1, height + 1))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// =============================================================================
|
|
311
|
+
// Inline Renderer
|
|
312
|
+
// =============================================================================
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Inline renderer matching Ink's log-update approach.
|
|
316
|
+
* Uses our own ansi.ts (zero dependencies).
|
|
317
|
+
*/
|
|
318
|
+
export class InlineRenderer {
|
|
319
|
+
private output = new OutputBuffer()
|
|
320
|
+
private previousLineCount = 0
|
|
321
|
+
private previousOutput = ''
|
|
322
|
+
|
|
323
|
+
// Cell rendering state (for ANSI optimization)
|
|
324
|
+
private lastFg: RGBA | null = null
|
|
325
|
+
private lastBg: RGBA | null = null
|
|
326
|
+
private lastAttrs: CellAttrs = Attr.NONE
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 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
|
+
*
|
|
335
|
+
* KEY INSIGHT FROM INK:
|
|
336
|
+
* When content height >= terminal rows, eraseLines can't reach content
|
|
337
|
+
* that scrolled off the top into scrollback. In this case, use clearTerminal
|
|
338
|
+
* to wipe everything including scrollback, then redraw.
|
|
339
|
+
*/
|
|
340
|
+
render(buffer: FrameBuffer): void {
|
|
341
|
+
// Build the output string
|
|
342
|
+
const output = this.buildOutput(buffer)
|
|
343
|
+
|
|
344
|
+
// Skip if output unchanged
|
|
345
|
+
if (output === this.previousOutput) {
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Get terminal viewport height
|
|
350
|
+
const terminalRows = process.stdout.rows || 24
|
|
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
|
+
}
|
|
359
|
+
this.output.flushSync()
|
|
360
|
+
|
|
361
|
+
// Track for next render
|
|
362
|
+
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
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Build output string from frame buffer.
|
|
373
|
+
*/
|
|
374
|
+
private buildOutput(buffer: FrameBuffer): string {
|
|
375
|
+
const chunks: string[] = []
|
|
376
|
+
|
|
377
|
+
// Reset cell rendering state
|
|
378
|
+
this.lastFg = null
|
|
379
|
+
this.lastBg = null
|
|
380
|
+
this.lastAttrs = Attr.NONE
|
|
381
|
+
|
|
382
|
+
for (let y = 0; y < buffer.height; y++) {
|
|
383
|
+
if (y > 0) {
|
|
384
|
+
chunks.push('\n')
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
388
|
+
const cell = buffer.cells[y]![x]
|
|
389
|
+
this.renderCell(chunks, cell!)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
chunks.push(ansi.reset)
|
|
394
|
+
chunks.push('\n') // Trailing newline positions cursor for next eraseLines
|
|
395
|
+
|
|
396
|
+
return chunks.join('')
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Render a single cell to chunks array.
|
|
401
|
+
*/
|
|
402
|
+
private renderCell(chunks: string[], cell: Cell): void {
|
|
403
|
+
if (cell.attrs !== this.lastAttrs) {
|
|
404
|
+
chunks.push(ansi.reset)
|
|
405
|
+
if (cell.attrs !== Attr.NONE) {
|
|
406
|
+
chunks.push(ansi.attrs(cell.attrs))
|
|
407
|
+
}
|
|
408
|
+
this.lastFg = null
|
|
409
|
+
this.lastBg = null
|
|
410
|
+
this.lastAttrs = cell.attrs
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!this.lastFg || !rgbaEqual(cell.fg, this.lastFg)) {
|
|
414
|
+
chunks.push(ansi.fg(cell.fg))
|
|
415
|
+
this.lastFg = cell.fg
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!this.lastBg || !rgbaEqual(cell.bg, this.lastBg)) {
|
|
419
|
+
chunks.push(ansi.bg(cell.bg))
|
|
420
|
+
this.lastBg = cell.bg
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (cell.char === 0) {
|
|
424
|
+
chunks.push(' ')
|
|
425
|
+
} else {
|
|
426
|
+
chunks.push(String.fromCodePoint(cell.char))
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Clear all rendered content and reset state.
|
|
432
|
+
*/
|
|
433
|
+
clear(): void {
|
|
434
|
+
if (this.previousLineCount > 0) {
|
|
435
|
+
this.output.write(ansi.eraseLines(this.previousLineCount))
|
|
436
|
+
this.output.flushSync()
|
|
437
|
+
}
|
|
438
|
+
this.reset()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Reset the renderer state.
|
|
443
|
+
*/
|
|
444
|
+
reset(): void {
|
|
445
|
+
this.previousLineCount = 0
|
|
446
|
+
this.previousOutput = ''
|
|
447
|
+
this.lastFg = null
|
|
448
|
+
this.lastBg = null
|
|
449
|
+
this.lastAttrs = Attr.NONE
|
|
450
|
+
}
|
|
451
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Cursor State
|
|
3
|
+
*
|
|
4
|
+
* Reactive cursor control for terminal applications.
|
|
5
|
+
* Handles visibility, shape, position, and blinking.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { cursor } from './state/cursor'
|
|
10
|
+
*
|
|
11
|
+
* // Show/hide
|
|
12
|
+
* cursor.show()
|
|
13
|
+
* cursor.hide()
|
|
14
|
+
*
|
|
15
|
+
* // Shape
|
|
16
|
+
* cursor.setShape('bar') // bar, block, underline
|
|
17
|
+
* cursor.setShape('block', false) // non-blinking block
|
|
18
|
+
*
|
|
19
|
+
* // Position
|
|
20
|
+
* cursor.moveTo(10, 5)
|
|
21
|
+
* cursor.moveBy(1, 0) // move right 1
|
|
22
|
+
*
|
|
23
|
+
* // Save/restore
|
|
24
|
+
* cursor.save()
|
|
25
|
+
* cursor.restore()
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { signal, effect } from '@rlabs-inc/signals'
|
|
30
|
+
import type { CursorShape } from '../types'
|
|
31
|
+
import {
|
|
32
|
+
cursorShow,
|
|
33
|
+
cursorHide,
|
|
34
|
+
cursorTo,
|
|
35
|
+
cursorMove,
|
|
36
|
+
cursorSavePosition,
|
|
37
|
+
cursorRestorePosition,
|
|
38
|
+
setCursorShape,
|
|
39
|
+
} from '../renderer/ansi'
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// CURSOR STATE
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
/** Current cursor visibility */
|
|
46
|
+
export const visible = signal(true)
|
|
47
|
+
|
|
48
|
+
/** Current cursor shape */
|
|
49
|
+
export const shape = signal<CursorShape>('block')
|
|
50
|
+
|
|
51
|
+
/** Cursor blinking */
|
|
52
|
+
export const blinking = signal(true)
|
|
53
|
+
|
|
54
|
+
/** Cursor X position (column, 0-indexed) */
|
|
55
|
+
export const x = signal(0)
|
|
56
|
+
|
|
57
|
+
/** Cursor Y position (row, 0-indexed) */
|
|
58
|
+
export const y = signal(0)
|
|
59
|
+
|
|
60
|
+
// =============================================================================
|
|
61
|
+
// CURSOR CONTROL FUNCTIONS
|
|
62
|
+
// =============================================================================
|
|
63
|
+
|
|
64
|
+
/** Show the cursor */
|
|
65
|
+
export function show(): void {
|
|
66
|
+
visible.value = true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Hide the cursor */
|
|
70
|
+
export function hide(): void {
|
|
71
|
+
visible.value = false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Toggle cursor visibility */
|
|
75
|
+
export function toggle(): void {
|
|
76
|
+
visible.value = !visible.value
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Set cursor shape.
|
|
81
|
+
* @param newShape 'block' | 'underline' | 'bar'
|
|
82
|
+
* @param blink Whether cursor should blink (default: true)
|
|
83
|
+
*/
|
|
84
|
+
export function setShape(newShape: CursorShape, blink: boolean = true): void {
|
|
85
|
+
shape.value = newShape
|
|
86
|
+
blinking.value = blink
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Move cursor to absolute position.
|
|
91
|
+
* @param col Column (0-indexed)
|
|
92
|
+
* @param row Row (0-indexed)
|
|
93
|
+
*/
|
|
94
|
+
export function moveTo(col: number, row: number): void {
|
|
95
|
+
x.value = col
|
|
96
|
+
y.value = row
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Move cursor relative to current position.
|
|
101
|
+
* @param dx Columns to move (negative = left)
|
|
102
|
+
* @param dy Rows to move (negative = up)
|
|
103
|
+
*/
|
|
104
|
+
export function moveBy(dx: number, dy: number): void {
|
|
105
|
+
x.value += dx
|
|
106
|
+
y.value += dy
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Save current cursor position */
|
|
110
|
+
export function save(): string {
|
|
111
|
+
return cursorSavePosition
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Restore saved cursor position */
|
|
115
|
+
export function restore(): string {
|
|
116
|
+
return cursorRestorePosition
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// =============================================================================
|
|
120
|
+
// ANSI OUTPUT GENERATORS
|
|
121
|
+
// =============================================================================
|
|
122
|
+
|
|
123
|
+
/** Get ANSI sequence for current visibility */
|
|
124
|
+
export function getVisibilitySequence(): string {
|
|
125
|
+
return visible.value ? cursorShow : cursorHide
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Get ANSI sequence for current shape */
|
|
129
|
+
export function getShapeSequence(): string {
|
|
130
|
+
return setCursorShape(shape.value, blinking.value)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Get ANSI sequence to move to current position */
|
|
134
|
+
export function getPositionSequence(): string {
|
|
135
|
+
return cursorTo(x.value, y.value)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// =============================================================================
|
|
139
|
+
// REACTIVE CURSOR OBJECT (for convenience)
|
|
140
|
+
// =============================================================================
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Cursor control object with all functions.
|
|
144
|
+
* Import this for a cleaner API:
|
|
145
|
+
*
|
|
146
|
+
* ```ts
|
|
147
|
+
* import { cursor } from './state/cursor'
|
|
148
|
+
* cursor.show()
|
|
149
|
+
* cursor.setShape('bar')
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export const cursor = {
|
|
153
|
+
// State (readable)
|
|
154
|
+
get visible() { return visible.value },
|
|
155
|
+
get shape() { return shape.value },
|
|
156
|
+
get blinking() { return blinking.value },
|
|
157
|
+
get x() { return x.value },
|
|
158
|
+
get y() { return y.value },
|
|
159
|
+
|
|
160
|
+
// Control functions
|
|
161
|
+
show,
|
|
162
|
+
hide,
|
|
163
|
+
toggle,
|
|
164
|
+
setShape,
|
|
165
|
+
moveTo,
|
|
166
|
+
moveBy,
|
|
167
|
+
save,
|
|
168
|
+
restore,
|
|
169
|
+
|
|
170
|
+
// ANSI sequences
|
|
171
|
+
getVisibilitySequence,
|
|
172
|
+
getShapeSequence,
|
|
173
|
+
getPositionSequence,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export default cursor
|