@effect-tui/react 0.1.4 → 0.1.5
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/dist/src/components/TextInput.d.ts +5 -3
- package/dist/src/components/TextInput.d.ts.map +1 -1
- package/dist/src/components/TextInput.js +79 -12
- package/dist/src/components/TextInput.js.map +1 -1
- package/dist/src/hosts/canvas.d.ts +8 -0
- package/dist/src/hosts/canvas.d.ts.map +1 -1
- package/dist/src/hosts/canvas.js +17 -3
- package/dist/src/hosts/canvas.js.map +1 -1
- package/dist/src/renderer/modes/InlineRenderer.d.ts +3 -0
- package/dist/src/renderer/modes/InlineRenderer.d.ts.map +1 -1
- package/dist/src/renderer/modes/InlineRenderer.js +8 -1
- package/dist/src/renderer/modes/InlineRenderer.js.map +1 -1
- package/dist/src/renderer.d.ts.map +1 -1
- package/dist/src/renderer.js +1 -0
- package/dist/src/renderer.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/components/TextInput.tsx +115 -44
- package/src/hosts/canvas.ts +38 -5
- package/src/renderer/modes/InlineRenderer.ts +9 -1
- package/src/renderer.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-tui/react",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "React bindings for @effect-tui/core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"prepublishOnly": "bun run typecheck && bun run build"
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"@effect-tui/core": "
|
|
86
|
+
"@effect-tui/core": "^0.1.5",
|
|
87
87
|
"@effect/platform": "^0.94.0",
|
|
88
88
|
"@effect/platform-bun": "^0.87.0",
|
|
89
89
|
"@effect/rpc": "^0.73.0",
|
|
@@ -28,9 +28,9 @@ export interface TextInputProps {
|
|
|
28
28
|
fg?: Color
|
|
29
29
|
/** Background color */
|
|
30
30
|
bg?: Color
|
|
31
|
-
/** Cursor foreground color */
|
|
31
|
+
/** Cursor foreground color (for block style) */
|
|
32
32
|
cursorFg?: Color
|
|
33
|
-
/** Cursor background color */
|
|
33
|
+
/** Cursor background color (for block style) or underline color (for underline style) */
|
|
34
34
|
cursorBg?: Color
|
|
35
35
|
/** Placeholder foreground color */
|
|
36
36
|
placeholderFg?: Color
|
|
@@ -42,6 +42,8 @@ export interface TextInputProps {
|
|
|
42
42
|
focused?: boolean
|
|
43
43
|
/** Show cursor even when not focused */
|
|
44
44
|
showCursor?: boolean
|
|
45
|
+
/** Cursor style: block (inverted colors) or underline */
|
|
46
|
+
cursorStyle?: "block" | "underline"
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
/**
|
|
@@ -80,9 +82,11 @@ export function TextInput({
|
|
|
80
82
|
onCancel,
|
|
81
83
|
focused = true,
|
|
82
84
|
showCursor = true,
|
|
85
|
+
cursorStyle = "block",
|
|
83
86
|
}: TextInputProps) {
|
|
84
87
|
const [cursorPos, setCursorPos] = useState(value.length)
|
|
85
88
|
const [killRing, setKillRing] = useState("")
|
|
89
|
+
const [scrollOffset, setScrollOffset] = useState(0)
|
|
86
90
|
|
|
87
91
|
// Keep cursor in bounds when value changes externally
|
|
88
92
|
useEffect(() => {
|
|
@@ -108,18 +112,18 @@ export function TextInput({
|
|
|
108
112
|
}
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
// Helper for word boundary movement
|
|
116
|
+
const moveToPrevWord = () => {
|
|
117
|
+
const beforeCursor = value.slice(0, cursorPos)
|
|
118
|
+
const match = matchPrevWord(beforeCursor)
|
|
119
|
+
setCursorPos(match ? cursorPos - match.length : 0)
|
|
120
|
+
}
|
|
117
121
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
const moveToNextWord = () => {
|
|
123
|
+
const afterCursor = value.slice(cursorPos)
|
|
124
|
+
const match = matchNextWord(afterCursor)
|
|
125
|
+
setCursorPos(match ? cursorPos + match.length : value.length)
|
|
126
|
+
}
|
|
123
127
|
|
|
124
128
|
switch (key.name) {
|
|
125
129
|
case "left":
|
|
@@ -146,16 +150,16 @@ export function TextInput({
|
|
|
146
150
|
setCursorPos(value.length)
|
|
147
151
|
break
|
|
148
152
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
153
|
+
case "backspace":
|
|
154
|
+
if (key.meta) {
|
|
155
|
+
// Option+Backspace: Delete to previous word boundary
|
|
156
|
+
const beforeCursor = value.slice(0, cursorPos)
|
|
157
|
+
const match = matchPrevWord(beforeCursor)
|
|
158
|
+
if (match) {
|
|
159
|
+
const newPos = cursorPos - match.length
|
|
160
|
+
onChange(value.slice(0, newPos) + value.slice(cursorPos))
|
|
161
|
+
setCursorPos(newPos)
|
|
162
|
+
} else if (cursorPos > 0) {
|
|
159
163
|
onChange(value.slice(cursorPos))
|
|
160
164
|
setCursorPos(0)
|
|
161
165
|
}
|
|
@@ -164,16 +168,16 @@ export function TextInput({
|
|
|
164
168
|
}
|
|
165
169
|
break
|
|
166
170
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
171
|
+
case "delete":
|
|
172
|
+
if (key.meta) {
|
|
173
|
+
// Option+Delete: Delete to next word boundary
|
|
174
|
+
const afterCursor = value.slice(cursorPos)
|
|
175
|
+
const match = matchNextWord(afterCursor)
|
|
176
|
+
if (match) {
|
|
177
|
+
onChange(value.slice(0, cursorPos) + value.slice(cursorPos + match.length))
|
|
178
|
+
} else if (cursorPos < value.length) {
|
|
179
|
+
onChange(value.slice(0, cursorPos))
|
|
180
|
+
}
|
|
177
181
|
} else {
|
|
178
182
|
applyEdit(deleteCharForward(state))
|
|
179
183
|
}
|
|
@@ -242,43 +246,110 @@ export function TextInput({
|
|
|
242
246
|
const displayText = value || placeholder
|
|
243
247
|
const isPlaceholder = !value
|
|
244
248
|
const textColor = isPlaceholder ? placeholderFg : fg
|
|
249
|
+
const maxLen = width ?? ctx.width
|
|
245
250
|
|
|
246
251
|
// Clear the line with background color
|
|
247
252
|
if (bg !== undefined) {
|
|
248
253
|
ctx.fill(0, 0, ctx.width, 1, " ", { bg })
|
|
249
254
|
}
|
|
250
255
|
|
|
251
|
-
//
|
|
252
|
-
|
|
256
|
+
// Calculate scroll offset to keep cursor visible
|
|
257
|
+
let effectiveScrollOffset = scrollOffset
|
|
258
|
+
|
|
259
|
+
// Compute character positions (for variable-width chars)
|
|
260
|
+
const charPositions: number[] = []
|
|
261
|
+
let totalWidth = 0
|
|
262
|
+
for (let i = 0; i < value.length; i++) {
|
|
263
|
+
charPositions.push(totalWidth)
|
|
264
|
+
totalWidth += displayWidth(value[i])
|
|
265
|
+
}
|
|
266
|
+
charPositions.push(totalWidth) // Position after last char (for cursor at end)
|
|
267
|
+
|
|
268
|
+
if (!isPlaceholder) {
|
|
269
|
+
const cursorX = charPositions[cursorPos] ?? totalWidth
|
|
270
|
+
|
|
271
|
+
// Scroll right if cursor is past visible area
|
|
272
|
+
if (cursorX >= effectiveScrollOffset + maxLen) {
|
|
273
|
+
effectiveScrollOffset = cursorX - maxLen + 1
|
|
274
|
+
}
|
|
275
|
+
// Scroll left if cursor is before visible area
|
|
276
|
+
if (cursorX < effectiveScrollOffset) {
|
|
277
|
+
effectiveScrollOffset = cursorX
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Update scroll state if changed
|
|
281
|
+
if (effectiveScrollOffset !== scrollOffset) {
|
|
282
|
+
setScrollOffset(effectiveScrollOffset)
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
effectiveScrollOffset = 0
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Draw text starting from scroll offset
|
|
253
289
|
let x = 0
|
|
254
290
|
for (let i = 0; i < displayText.length && x < maxLen; i++) {
|
|
255
291
|
const ch = displayText[i]
|
|
256
292
|
const charWidth = displayWidth(ch)
|
|
293
|
+
const charX = isPlaceholder ? x : charPositions[i] - effectiveScrollOffset
|
|
257
294
|
|
|
258
|
-
|
|
295
|
+
// Skip characters before visible area
|
|
296
|
+
if (!isPlaceholder && charPositions[i] + charWidth <= effectiveScrollOffset) {
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
// Stop if we're past the visible area
|
|
300
|
+
if (charX >= maxLen) break
|
|
259
301
|
|
|
260
|
-
// Draw cursor
|
|
302
|
+
// Draw cursor at cursor position when focused
|
|
261
303
|
const isCursor = !isPlaceholder && i === cursorPos && focused && showCursor
|
|
262
304
|
if (isCursor) {
|
|
263
|
-
|
|
305
|
+
if (cursorStyle === "underline") {
|
|
306
|
+
ctx.text(charX, 0, ch, { fg: cursorBg, bg, underline: true })
|
|
307
|
+
} else {
|
|
308
|
+
ctx.text(charX, 0, ch, { fg: cursorFg, bg: cursorBg })
|
|
309
|
+
}
|
|
264
310
|
} else {
|
|
265
|
-
ctx.text(
|
|
311
|
+
ctx.text(charX, 0, ch, { fg: textColor, bg })
|
|
266
312
|
}
|
|
267
313
|
|
|
268
|
-
x
|
|
314
|
+
x = charX + charWidth
|
|
269
315
|
}
|
|
270
316
|
|
|
271
317
|
// Draw cursor at end if cursor is at end of text
|
|
272
|
-
if (!isPlaceholder && cursorPos >= value.length && focused && showCursor
|
|
273
|
-
|
|
318
|
+
if (!isPlaceholder && cursorPos >= value.length && focused && showCursor) {
|
|
319
|
+
const cursorX = (charPositions[cursorPos] ?? totalWidth) - effectiveScrollOffset
|
|
320
|
+
if (cursorX >= 0 && cursorX < maxLen) {
|
|
321
|
+
if (cursorStyle === "underline") {
|
|
322
|
+
ctx.text(cursorX, 0, "_", { fg: cursorBg, bg })
|
|
323
|
+
} else {
|
|
324
|
+
ctx.text(cursorX, 0, " ", { fg: cursorFg, bg: cursorBg })
|
|
325
|
+
}
|
|
326
|
+
}
|
|
274
327
|
}
|
|
275
328
|
|
|
276
329
|
// If placeholder and focused, show cursor at start
|
|
277
330
|
if (isPlaceholder && focused && showCursor) {
|
|
278
|
-
|
|
331
|
+
if (cursorStyle === "underline") {
|
|
332
|
+
ctx.text(0, 0, placeholder[0] || "_", { fg: cursorBg, bg, underline: placeholder.length > 0 })
|
|
333
|
+
} else {
|
|
334
|
+
ctx.text(0, 0, placeholder[0] || " ", { fg: cursorFg, bg: cursorBg })
|
|
335
|
+
}
|
|
279
336
|
}
|
|
280
337
|
},
|
|
281
|
-
[
|
|
338
|
+
[
|
|
339
|
+
value,
|
|
340
|
+
placeholder,
|
|
341
|
+
cursorPos,
|
|
342
|
+
focused,
|
|
343
|
+
showCursor,
|
|
344
|
+
fg,
|
|
345
|
+
bg,
|
|
346
|
+
cursorFg,
|
|
347
|
+
cursorBg,
|
|
348
|
+
placeholderFg,
|
|
349
|
+
width,
|
|
350
|
+
cursorStyle,
|
|
351
|
+
scrollOffset,
|
|
352
|
+
],
|
|
282
353
|
)
|
|
283
354
|
|
|
284
355
|
return <canvas draw={draw} width={width} height={1} />
|
package/src/hosts/canvas.ts
CHANGED
|
@@ -2,7 +2,14 @@ import type { CellBuffer, Palette, Color } from "@effect-tui/core"
|
|
|
2
2
|
import { Colors } from "@effect-tui/core"
|
|
3
3
|
import type { HostContext, Size, CommonProps } from "../reconciler/types.js"
|
|
4
4
|
import { BaseHost } from "./base.js"
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type BorderKind,
|
|
7
|
+
borderChars,
|
|
8
|
+
drawBorder,
|
|
9
|
+
resolveBgStyle,
|
|
10
|
+
styleIdFromProps,
|
|
11
|
+
toColorValue,
|
|
12
|
+
} from "../utils/index.js"
|
|
6
13
|
|
|
7
14
|
export type { BorderKind }
|
|
8
15
|
|
|
@@ -13,10 +20,22 @@ export interface DrawContext {
|
|
|
13
20
|
height: number
|
|
14
21
|
|
|
15
22
|
/** Draw text at position */
|
|
16
|
-
text(
|
|
23
|
+
text(
|
|
24
|
+
x: number,
|
|
25
|
+
y: number,
|
|
26
|
+
str: string,
|
|
27
|
+
opts?: { fg?: Color; bg?: Color; bold?: boolean; italic?: boolean; underline?: boolean; inverse?: boolean },
|
|
28
|
+
): void
|
|
17
29
|
|
|
18
30
|
/** Fill rectangle with character */
|
|
19
|
-
fill(
|
|
31
|
+
fill(
|
|
32
|
+
x: number,
|
|
33
|
+
y: number,
|
|
34
|
+
w: number,
|
|
35
|
+
h: number,
|
|
36
|
+
char?: string,
|
|
37
|
+
opts?: { fg?: Color; bg?: Color; bold?: boolean; italic?: boolean; underline?: boolean; inverse?: boolean },
|
|
38
|
+
): void
|
|
20
39
|
|
|
21
40
|
/** Draw box with optional border */
|
|
22
41
|
box(
|
|
@@ -74,7 +93,14 @@ export class CanvasHost extends BaseHost {
|
|
|
74
93
|
text: (x, y, str, opts) => {
|
|
75
94
|
const px = Math.round(ox + x)
|
|
76
95
|
const py = Math.round(oy + y)
|
|
77
|
-
const style = styleIdFromProps(palette, {
|
|
96
|
+
const style = styleIdFromProps(palette, {
|
|
97
|
+
fg: opts?.fg,
|
|
98
|
+
bg: opts?.bg,
|
|
99
|
+
bold: opts?.bold,
|
|
100
|
+
italic: opts?.italic,
|
|
101
|
+
underline: opts?.underline,
|
|
102
|
+
inverse: opts?.inverse,
|
|
103
|
+
})
|
|
78
104
|
let col = px
|
|
79
105
|
for (const char of str) {
|
|
80
106
|
if (col >= ox + w) break
|
|
@@ -87,7 +113,14 @@ export class CanvasHost extends BaseHost {
|
|
|
87
113
|
const px = Math.round(ox + x)
|
|
88
114
|
const py = Math.round(oy + y)
|
|
89
115
|
const cp = char.codePointAt(0)!
|
|
90
|
-
const style = styleIdFromProps(palette, {
|
|
116
|
+
const style = styleIdFromProps(palette, {
|
|
117
|
+
fg: opts?.fg,
|
|
118
|
+
bg: opts?.bg,
|
|
119
|
+
bold: opts?.bold,
|
|
120
|
+
italic: opts?.italic,
|
|
121
|
+
underline: opts?.underline,
|
|
122
|
+
inverse: opts?.inverse,
|
|
123
|
+
})
|
|
91
124
|
for (let row = 0; row < fh; row++) {
|
|
92
125
|
const yy = py + row
|
|
93
126
|
for (let col = 0; col < fw; col++) {
|
|
@@ -10,6 +10,7 @@ export class InlineRenderer implements RendererMode {
|
|
|
10
10
|
private previousStartRow = 0 // Track which row we started from (for truncation)
|
|
11
11
|
private _needsFullRerender = false
|
|
12
12
|
private printedWidths: number[] = []
|
|
13
|
+
private _forceFullOutput = false
|
|
13
14
|
|
|
14
15
|
generateOutput(ctx: RenderContext): RenderOutput {
|
|
15
16
|
const { prevBuffer, frameHeight, contentHeight } = ctx
|
|
@@ -40,10 +41,12 @@ export class InlineRenderer implements RendererMode {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
// Generate output for visible region
|
|
43
|
-
|
|
44
|
+
// Force full output after static content handling (diff mode cursor tracking gets desynchronized)
|
|
45
|
+
if (prevBuffer && !needsFullRedraw && !this._forceFullOutput) {
|
|
44
46
|
output += this.generateDiffOutput(ctx, visibleHeight, startRow)
|
|
45
47
|
} else {
|
|
46
48
|
output += this.generateFullOutput(ctx, visibleHeight, startRow, startRow + visibleHeight)
|
|
49
|
+
this._forceFullOutput = false
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
this.previousHeight = visibleHeight
|
|
@@ -172,6 +175,11 @@ export class InlineRenderer implements RendererMode {
|
|
|
172
175
|
this.printedWidths = []
|
|
173
176
|
}
|
|
174
177
|
|
|
178
|
+
/** Force full output on the next frame (needed after static content to resync cursor tracking) */
|
|
179
|
+
forceFullOutputOnce(): void {
|
|
180
|
+
this._forceFullOutput = true
|
|
181
|
+
}
|
|
182
|
+
|
|
175
183
|
getPreviousHeight(): number {
|
|
176
184
|
return this.previousHeight
|
|
177
185
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -124,6 +124,7 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
|
|
|
124
124
|
|
|
125
125
|
// Step 3: Reset previousHeight to 0 (we cleared dynamic, starting fresh)
|
|
126
126
|
inlineMode.reset()
|
|
127
|
+
inlineMode.forceFullOutputOnce() // Force full output to resync cursor tracking after static
|
|
127
128
|
state.invalidateBuffers()
|
|
128
129
|
container.staticDirty = false
|
|
129
130
|
}
|