@rlabs-inc/tui 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -13
- package/index.ts +11 -5
- package/package.json +2 -2
- package/src/api/history.ts +451 -0
- package/src/api/mount.ts +66 -31
- 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 +159 -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 +23 -2
- package/src/renderer/input.ts +0 -518
package/src/primitives/text.ts
CHANGED
|
@@ -23,11 +23,12 @@
|
|
|
23
23
|
* ```
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// Using slotArray APIs - no direct signal imports needed
|
|
27
27
|
import { ComponentType, Attr } from '../types'
|
|
28
28
|
import { allocateIndex, releaseIndex, getCurrentParentIndex } from '../engine/registry'
|
|
29
29
|
import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
|
|
30
30
|
import { getVariantStyle } from '../state/theme'
|
|
31
|
+
import { getActiveScope } from './scope'
|
|
31
32
|
|
|
32
33
|
// Import arrays
|
|
33
34
|
import * as core from '../engine/arrays/core'
|
|
@@ -61,31 +62,33 @@ function wrapToNum(wrap: string | undefined): number {
|
|
|
61
62
|
default: return 1 // wrap
|
|
62
63
|
}
|
|
63
64
|
}
|
|
65
|
+
/** Override alignItems for this item: 0=auto, 1=stretch, 2=flex-start, 3=center, 4=flex-end */
|
|
66
|
+
function alignSelfToNum(alignSelf: string | undefined): number {
|
|
67
|
+
switch (alignSelf) {
|
|
68
|
+
case 'auto': return 0
|
|
69
|
+
case 'stretch': return 1
|
|
70
|
+
case 'flex-start': return 2
|
|
71
|
+
case 'center': return 3
|
|
72
|
+
case 'flex-end': return 4
|
|
73
|
+
default: return 0
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// bindEnumProp removed - using enumSource instead
|
|
64
78
|
|
|
65
79
|
/**
|
|
66
|
-
* Create a
|
|
67
|
-
*
|
|
68
|
-
* This creates dependency directly on user's signal, no intermediate objects.
|
|
80
|
+
* Create a slot source for enum props - returns getter for reactive, value for static.
|
|
81
|
+
* For use with slotArray.setSource()
|
|
69
82
|
*/
|
|
70
|
-
function
|
|
83
|
+
function enumSource<T extends string>(
|
|
71
84
|
prop: T | { value: T } | undefined,
|
|
72
85
|
converter: (val: T | undefined) => number
|
|
73
|
-
):
|
|
74
|
-
// If it's reactive (has .value), create binding that converts at read time
|
|
86
|
+
): number | (() => number) {
|
|
75
87
|
if (prop !== undefined && typeof prop === 'object' && prop !== null && 'value' in prop) {
|
|
76
88
|
const reactiveSource = prop as { value: T }
|
|
77
|
-
return
|
|
78
|
-
[BINDING_SYMBOL]: true,
|
|
79
|
-
get value(): number {
|
|
80
|
-
return converter(reactiveSource.value)
|
|
81
|
-
},
|
|
82
|
-
set value(_: number) {
|
|
83
|
-
// Enum props are read-only from number side
|
|
84
|
-
},
|
|
85
|
-
} as unknown as ReturnType<typeof bind<number>>
|
|
89
|
+
return () => converter(reactiveSource.value)
|
|
86
90
|
}
|
|
87
|
-
|
|
88
|
-
return bind(converter(prop as T | undefined))
|
|
91
|
+
return converter(prop as T | undefined)
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
// =============================================================================
|
|
@@ -110,55 +113,56 @@ export function text(props: TextProps): Cleanup {
|
|
|
110
113
|
// CORE - Always needed
|
|
111
114
|
// ==========================================================================
|
|
112
115
|
core.componentType[index] = ComponentType.TEXT
|
|
113
|
-
core.parentIndex
|
|
116
|
+
core.parentIndex.setSource(index, getCurrentParentIndex())
|
|
114
117
|
|
|
115
118
|
// Visible - only bind if passed
|
|
116
119
|
if (props.visible !== undefined) {
|
|
117
|
-
core.visible
|
|
120
|
+
core.visible.setSource(index, props.visible)
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
// ==========================================================================
|
|
121
124
|
// TEXT CONTENT - Always needed (this is a text component!)
|
|
125
|
+
// Uses setSource() for stable slot tracking (fixes bind() replacement bug)
|
|
122
126
|
// ==========================================================================
|
|
123
|
-
textArrays.textContent
|
|
127
|
+
textArrays.textContent.setSource(index, props.content)
|
|
124
128
|
|
|
125
|
-
// Text styling - only
|
|
126
|
-
if (props.attrs !== undefined) textArrays.textAttrs
|
|
127
|
-
if (props.align !== undefined) textArrays.textAlign
|
|
128
|
-
if (props.wrap !== undefined) textArrays.textWrap
|
|
129
|
+
// Text styling - only set if passed
|
|
130
|
+
if (props.attrs !== undefined) textArrays.textAttrs.setSource(index, props.attrs)
|
|
131
|
+
if (props.align !== undefined) textArrays.textAlign.setSource(index, enumSource(props.align, alignToNum))
|
|
132
|
+
if (props.wrap !== undefined) textArrays.textWrap.setSource(index, enumSource(props.wrap, wrapToNum))
|
|
129
133
|
|
|
130
134
|
// ==========================================================================
|
|
131
135
|
// DIMENSIONS - Only bind what's passed (TITAN uses ?? 0 for undefined)
|
|
132
136
|
// ==========================================================================
|
|
133
|
-
if (props.width !== undefined) dimensions.width
|
|
134
|
-
if (props.height !== undefined) dimensions.height
|
|
135
|
-
if (props.minWidth !== undefined) dimensions.minWidth
|
|
136
|
-
if (props.maxWidth !== undefined) dimensions.maxWidth
|
|
137
|
-
if (props.minHeight !== undefined) dimensions.minHeight
|
|
138
|
-
if (props.maxHeight !== undefined) dimensions.maxHeight
|
|
137
|
+
if (props.width !== undefined) dimensions.width.setSource(index, props.width)
|
|
138
|
+
if (props.height !== undefined) dimensions.height.setSource(index, props.height)
|
|
139
|
+
if (props.minWidth !== undefined) dimensions.minWidth.setSource(index, props.minWidth)
|
|
140
|
+
if (props.maxWidth !== undefined) dimensions.maxWidth.setSource(index, props.maxWidth)
|
|
141
|
+
if (props.minHeight !== undefined) dimensions.minHeight.setSource(index, props.minHeight)
|
|
142
|
+
if (props.maxHeight !== undefined) dimensions.maxHeight.setSource(index, props.maxHeight)
|
|
139
143
|
|
|
140
144
|
// ==========================================================================
|
|
141
145
|
// PADDING - Shorthand support
|
|
142
146
|
// ==========================================================================
|
|
143
147
|
if (props.padding !== undefined) {
|
|
144
|
-
spacing.paddingTop
|
|
145
|
-
spacing.paddingRight
|
|
146
|
-
spacing.paddingBottom
|
|
147
|
-
spacing.paddingLeft
|
|
148
|
+
spacing.paddingTop.setSource(index, props.paddingTop ?? props.padding)
|
|
149
|
+
spacing.paddingRight.setSource(index, props.paddingRight ?? props.padding)
|
|
150
|
+
spacing.paddingBottom.setSource(index, props.paddingBottom ?? props.padding)
|
|
151
|
+
spacing.paddingLeft.setSource(index, props.paddingLeft ?? props.padding)
|
|
148
152
|
} else {
|
|
149
|
-
if (props.paddingTop !== undefined) spacing.paddingTop
|
|
150
|
-
if (props.paddingRight !== undefined) spacing.paddingRight
|
|
151
|
-
if (props.paddingBottom !== undefined) spacing.paddingBottom
|
|
152
|
-
if (props.paddingLeft !== undefined) spacing.paddingLeft
|
|
153
|
+
if (props.paddingTop !== undefined) spacing.paddingTop.setSource(index, props.paddingTop)
|
|
154
|
+
if (props.paddingRight !== undefined) spacing.paddingRight.setSource(index, props.paddingRight)
|
|
155
|
+
if (props.paddingBottom !== undefined) spacing.paddingBottom.setSource(index, props.paddingBottom)
|
|
156
|
+
if (props.paddingLeft !== undefined) spacing.paddingLeft.setSource(index, props.paddingLeft)
|
|
153
157
|
}
|
|
154
158
|
|
|
155
159
|
// ==========================================================================
|
|
156
160
|
// FLEX ITEM - Only bind if passed (text can be a flex item)
|
|
157
161
|
// ==========================================================================
|
|
158
|
-
if (props.grow !== undefined) layout.flexGrow
|
|
159
|
-
if (props.shrink !== undefined) layout.flexShrink
|
|
160
|
-
if (props.flexBasis !== undefined) layout.flexBasis
|
|
161
|
-
if (props.alignSelf !== undefined) layout.alignSelf
|
|
162
|
+
if (props.grow !== undefined) layout.flexGrow.setSource(index, props.grow)
|
|
163
|
+
if (props.shrink !== undefined) layout.flexShrink.setSource(index, props.shrink)
|
|
164
|
+
if (props.flexBasis !== undefined) layout.flexBasis.setSource(index, props.flexBasis)
|
|
165
|
+
if (props.alignSelf !== undefined) layout.alignSelf.setSource(index, enumSource(props.alignSelf, alignSelfToNum))
|
|
162
166
|
|
|
163
167
|
// ==========================================================================
|
|
164
168
|
// VISUAL - Colors with variant support (only bind what's needed)
|
|
@@ -167,33 +171,33 @@ export function text(props: TextProps): Cleanup {
|
|
|
167
171
|
// Variant colors - inline bindings that read theme at read time (no deriveds!)
|
|
168
172
|
const variant = props.variant
|
|
169
173
|
if (props.fg !== undefined) {
|
|
170
|
-
visual.fgColor
|
|
174
|
+
visual.fgColor.setSource(index, props.fg)
|
|
171
175
|
} else {
|
|
172
|
-
visual.fgColor
|
|
173
|
-
[BINDING_SYMBOL]: true,
|
|
174
|
-
get value() { return getVariantStyle(variant).fg },
|
|
175
|
-
set value(_) {},
|
|
176
|
-
} as any
|
|
176
|
+
visual.fgColor.setSource(index, () => getVariantStyle(variant).fg)
|
|
177
177
|
}
|
|
178
178
|
if (props.bg !== undefined) {
|
|
179
|
-
visual.bgColor
|
|
179
|
+
visual.bgColor.setSource(index, props.bg)
|
|
180
180
|
} else {
|
|
181
|
-
visual.bgColor
|
|
182
|
-
[BINDING_SYMBOL]: true,
|
|
183
|
-
get value() { return getVariantStyle(variant).bg },
|
|
184
|
-
set value(_) {},
|
|
185
|
-
} as any
|
|
181
|
+
visual.bgColor.setSource(index, () => getVariantStyle(variant).bg)
|
|
186
182
|
}
|
|
187
183
|
} else {
|
|
188
184
|
// Direct colors - only bind if passed
|
|
189
|
-
if (props.fg !== undefined) visual.fgColor
|
|
190
|
-
if (props.bg !== undefined) visual.bgColor
|
|
185
|
+
if (props.fg !== undefined) visual.fgColor.setSource(index, props.fg)
|
|
186
|
+
if (props.bg !== undefined) visual.bgColor.setSource(index, props.bg)
|
|
191
187
|
}
|
|
192
|
-
if (props.opacity !== undefined) visual.opacity
|
|
188
|
+
if (props.opacity !== undefined) visual.opacity.setSource(index, props.opacity)
|
|
193
189
|
|
|
194
190
|
// Cleanup function
|
|
195
|
-
|
|
191
|
+
const cleanup = () => {
|
|
196
192
|
cleanupKeyboardListeners(index)
|
|
197
193
|
releaseIndex(index)
|
|
198
194
|
}
|
|
195
|
+
|
|
196
|
+
// Auto-register with active scope if one exists
|
|
197
|
+
const scope = getActiveScope()
|
|
198
|
+
if (scope) {
|
|
199
|
+
scope.cleanups.push(cleanup)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return cleanup
|
|
199
203
|
}
|
package/src/primitives/types.ts
CHANGED
|
@@ -137,7 +137,7 @@ export interface BoxProps extends StyleProps, BorderProps, DimensionProps, Spaci
|
|
|
137
137
|
// TEXT PROPS
|
|
138
138
|
// =============================================================================
|
|
139
139
|
|
|
140
|
-
export interface TextProps extends StyleProps, DimensionProps, SpacingProps {
|
|
140
|
+
export interface TextProps extends StyleProps, DimensionProps, SpacingProps, LayoutProps {
|
|
141
141
|
/** Text content */
|
|
142
142
|
content: Reactive<string>
|
|
143
143
|
/** Text alignment: 'left' | 'center' | 'right' */
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - When Primitive
|
|
3
|
+
*
|
|
4
|
+
* Async rendering. Shows loading/error/success states based on promise state.
|
|
5
|
+
* When the promise resolves or rejects, the appropriate components are shown.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```ts
|
|
9
|
+
* when(() => fetchData(), {
|
|
10
|
+
* pending: () => text({ content: 'Loading...' }),
|
|
11
|
+
* then: (data) => text({ content: `Got: ${data}` }),
|
|
12
|
+
* catch: (err) => text({ content: `Error: ${err.message}` })
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { effect, effectScope, onScopeDispose } from '@rlabs-inc/signals'
|
|
18
|
+
import { getCurrentParentIndex, pushParentContext, popParentContext } from '../engine/registry'
|
|
19
|
+
import type { Cleanup } from './types'
|
|
20
|
+
|
|
21
|
+
interface WhenOptions<T> {
|
|
22
|
+
pending?: () => Cleanup
|
|
23
|
+
then: (value: T) => Cleanup
|
|
24
|
+
catch?: (error: Error) => Cleanup
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Render based on async promise state.
|
|
29
|
+
*
|
|
30
|
+
* @param promiseGetter - Getter that returns a promise (creates dependency)
|
|
31
|
+
* @param options - Handlers for pending, then, and catch states
|
|
32
|
+
*/
|
|
33
|
+
export function when<T>(
|
|
34
|
+
promiseGetter: () => Promise<T>,
|
|
35
|
+
options: WhenOptions<T>
|
|
36
|
+
): Cleanup {
|
|
37
|
+
let cleanup: Cleanup | null = null
|
|
38
|
+
let currentPromise: Promise<T> | null = null
|
|
39
|
+
const parentIndex = getCurrentParentIndex()
|
|
40
|
+
const scope = effectScope()
|
|
41
|
+
|
|
42
|
+
const render = (fn: () => Cleanup) => {
|
|
43
|
+
if (cleanup) {
|
|
44
|
+
cleanup()
|
|
45
|
+
cleanup = null
|
|
46
|
+
}
|
|
47
|
+
pushParentContext(parentIndex)
|
|
48
|
+
try {
|
|
49
|
+
cleanup = fn()
|
|
50
|
+
} finally {
|
|
51
|
+
popParentContext()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const handlePromise = (promise: Promise<T>) => {
|
|
56
|
+
if (promise !== currentPromise) return
|
|
57
|
+
|
|
58
|
+
promise
|
|
59
|
+
.then((value) => {
|
|
60
|
+
if (promise !== currentPromise) return
|
|
61
|
+
render(() => options.then(value))
|
|
62
|
+
})
|
|
63
|
+
.catch((error) => {
|
|
64
|
+
if (promise !== currentPromise) return
|
|
65
|
+
if (options.catch) {
|
|
66
|
+
render(() => options.catch!(error))
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
scope.run(() => {
|
|
72
|
+
// Initial setup
|
|
73
|
+
const initialPromise = promiseGetter()
|
|
74
|
+
currentPromise = initialPromise
|
|
75
|
+
if (options.pending) {
|
|
76
|
+
render(options.pending)
|
|
77
|
+
}
|
|
78
|
+
handlePromise(initialPromise)
|
|
79
|
+
|
|
80
|
+
// Effect for updates - skip first run
|
|
81
|
+
let initialized = false
|
|
82
|
+
effect(() => {
|
|
83
|
+
const promise = promiseGetter()
|
|
84
|
+
if (initialized) {
|
|
85
|
+
if (promise === currentPromise) return
|
|
86
|
+
currentPromise = promise
|
|
87
|
+
if (options.pending) {
|
|
88
|
+
render(options.pending)
|
|
89
|
+
}
|
|
90
|
+
handlePromise(promise)
|
|
91
|
+
}
|
|
92
|
+
initialized = true
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
onScopeDispose(() => {
|
|
96
|
+
currentPromise = null
|
|
97
|
+
if (cleanup) cleanup()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return () => scope.stop()
|
|
102
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Append Mode Renderer
|
|
3
|
+
*
|
|
4
|
+
* Simple renderer for append mode:
|
|
5
|
+
* - Clears active region (eraseDown from cursor)
|
|
6
|
+
* - Renders active content
|
|
7
|
+
*
|
|
8
|
+
* History content is handled separately via renderToHistory().
|
|
9
|
+
* The app controls what goes to history vs active area.
|
|
10
|
+
*
|
|
11
|
+
* This renderer is essentially inline mode with cursor at the
|
|
12
|
+
* boundary between frozen history and active content.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { FrameBuffer, RGBA, CellAttrs } from '../types'
|
|
16
|
+
import { Attr } from '../types'
|
|
17
|
+
import { rgbaEqual } from '../types/color'
|
|
18
|
+
import * as ansi from './ansi'
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// APPEND MODE RENDERER
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Simple append mode renderer.
|
|
26
|
+
* Erases previous active content and renders fresh.
|
|
27
|
+
*
|
|
28
|
+
* Like InlineRenderer but only erases the active area (preserves history).
|
|
29
|
+
*/
|
|
30
|
+
export class AppendRegionRenderer {
|
|
31
|
+
// Cell rendering state (for ANSI optimization)
|
|
32
|
+
private lastFg: RGBA | null = null
|
|
33
|
+
private lastBg: RGBA | null = null
|
|
34
|
+
private lastAttrs: CellAttrs = Attr.NONE
|
|
35
|
+
|
|
36
|
+
// Track previous height to know how many lines to erase
|
|
37
|
+
private previousHeight = 0
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Render frame buffer as active content.
|
|
41
|
+
* Erases exactly the previous content, then writes fresh.
|
|
42
|
+
*/
|
|
43
|
+
render(buffer: FrameBuffer): void {
|
|
44
|
+
const output = this.buildOutput(buffer)
|
|
45
|
+
|
|
46
|
+
// Erase previous active content (move up and clear each line)
|
|
47
|
+
if (this.previousHeight > 0) {
|
|
48
|
+
process.stdout.write(ansi.eraseLines(this.previousHeight))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Render active content
|
|
52
|
+
process.stdout.write(output)
|
|
53
|
+
|
|
54
|
+
// Track height for next render
|
|
55
|
+
// +1 because buildOutput adds trailing newline which moves cursor down one line
|
|
56
|
+
this.previousHeight = buffer.height + 1
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Erase the current active area.
|
|
61
|
+
* Call this BEFORE writing to history so we clear the screen first.
|
|
62
|
+
*/
|
|
63
|
+
eraseActive(): void {
|
|
64
|
+
if (this.previousHeight > 0) {
|
|
65
|
+
process.stdout.write(ansi.eraseLines(this.previousHeight))
|
|
66
|
+
this.previousHeight = 0
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Call this after writing to history.
|
|
72
|
+
* Resets height tracking so next render doesn't try to erase history.
|
|
73
|
+
*/
|
|
74
|
+
invalidate(): void {
|
|
75
|
+
this.previousHeight = 0
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build output string for the buffer.
|
|
80
|
+
*/
|
|
81
|
+
private buildOutput(buffer: FrameBuffer): string {
|
|
82
|
+
if (buffer.height === 0) return ''
|
|
83
|
+
|
|
84
|
+
const chunks: string[] = []
|
|
85
|
+
|
|
86
|
+
// Reset rendering state
|
|
87
|
+
this.lastFg = null
|
|
88
|
+
this.lastBg = null
|
|
89
|
+
this.lastAttrs = Attr.NONE
|
|
90
|
+
|
|
91
|
+
for (let y = 0; y < buffer.height; y++) {
|
|
92
|
+
if (y > 0) {
|
|
93
|
+
chunks.push('\n')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
97
|
+
const cell = buffer.cells[y]![x]
|
|
98
|
+
this.renderCell(chunks, cell!)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
chunks.push(ansi.reset)
|
|
103
|
+
chunks.push('\n')
|
|
104
|
+
|
|
105
|
+
return chunks.join('')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Render a single cell with ANSI optimization.
|
|
110
|
+
*/
|
|
111
|
+
private renderCell(chunks: string[], cell: { char: number; fg: RGBA; bg: RGBA; attrs: CellAttrs }): void {
|
|
112
|
+
// Attributes changed - reset first
|
|
113
|
+
if (cell.attrs !== this.lastAttrs) {
|
|
114
|
+
chunks.push(ansi.reset)
|
|
115
|
+
if (cell.attrs !== Attr.NONE) {
|
|
116
|
+
chunks.push(ansi.attrs(cell.attrs))
|
|
117
|
+
}
|
|
118
|
+
this.lastFg = null
|
|
119
|
+
this.lastBg = null
|
|
120
|
+
this.lastAttrs = cell.attrs
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Foreground color changed
|
|
124
|
+
if (!this.lastFg || !rgbaEqual(cell.fg, this.lastFg)) {
|
|
125
|
+
chunks.push(ansi.fg(cell.fg))
|
|
126
|
+
this.lastFg = cell.fg
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Background color changed
|
|
130
|
+
if (!this.lastBg || !rgbaEqual(cell.bg, this.lastBg)) {
|
|
131
|
+
chunks.push(ansi.bg(cell.bg))
|
|
132
|
+
this.lastBg = cell.bg
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Output character
|
|
136
|
+
if (cell.char === 0) {
|
|
137
|
+
chunks.push(' ')
|
|
138
|
+
} else {
|
|
139
|
+
chunks.push(String.fromCodePoint(cell.char))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Reset renderer state.
|
|
145
|
+
*/
|
|
146
|
+
reset(): void {
|
|
147
|
+
this.previousHeight = 0
|
|
148
|
+
this.lastFg = null
|
|
149
|
+
this.lastBg = null
|
|
150
|
+
this.lastAttrs = Attr.NONE
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Cleanup (no-op for simplified renderer).
|
|
155
|
+
*/
|
|
156
|
+
cleanup(): void {
|
|
157
|
+
// Nothing to cleanup - we don't own the FileSink
|
|
158
|
+
}
|
|
159
|
+
}
|
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
|