@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.1.4",
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": "workspace:^",
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
- // Helper for word boundary movement
112
- const moveToPrevWord = () => {
113
- const beforeCursor = value.slice(0, cursorPos)
114
- const match = matchPrevWord(beforeCursor)
115
- setCursorPos(match ? cursorPos - match.length : 0)
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
- const moveToNextWord = () => {
119
- const afterCursor = value.slice(cursorPos)
120
- const match = matchNextWord(afterCursor)
121
- setCursorPos(match ? cursorPos + match.length : value.length)
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
- case "backspace":
150
- if (key.meta) {
151
- // Option+Backspace: Delete to previous word boundary
152
- const beforeCursor = value.slice(0, cursorPos)
153
- const match = matchPrevWord(beforeCursor)
154
- if (match) {
155
- const newPos = cursorPos - match.length
156
- onChange(value.slice(0, newPos) + value.slice(cursorPos))
157
- setCursorPos(newPos)
158
- } else if (cursorPos > 0) {
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
- case "delete":
168
- if (key.meta) {
169
- // Option+Delete: Delete to next word boundary
170
- const afterCursor = value.slice(cursorPos)
171
- const match = matchNextWord(afterCursor)
172
- if (match) {
173
- onChange(value.slice(0, cursorPos) + value.slice(cursorPos + match.length))
174
- } else if (cursorPos < value.length) {
175
- onChange(value.slice(0, cursorPos))
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
- // Draw text up to width
252
- const maxLen = width ?? ctx.width
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
- if (x + charWidth > maxLen) break
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 (inverted) at cursor position when focused
302
+ // Draw cursor at cursor position when focused
261
303
  const isCursor = !isPlaceholder && i === cursorPos && focused && showCursor
262
304
  if (isCursor) {
263
- ctx.text(x, 0, ch, { fg: cursorFg, bg: cursorBg })
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(x, 0, ch, { fg: textColor, bg })
311
+ ctx.text(charX, 0, ch, { fg: textColor, bg })
266
312
  }
267
313
 
268
- x += charWidth
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 && x < maxLen) {
273
- ctx.text(x, 0, " ", { fg: cursorFg, bg: cursorBg })
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
- ctx.text(0, 0, placeholder[0] || " ", { fg: cursorFg, bg: cursorBg })
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
- [value, placeholder, cursorPos, focused, showCursor, fg, bg, cursorFg, cursorBg, placeholderFg, width],
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} />
@@ -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 { type BorderKind, borderChars, drawBorder, resolveBgStyle, styleIdFromProps, toColorValue } from "../utils/index.js"
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(x: number, y: number, str: string, opts?: { fg?: Color; bg?: Color }): void
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(x: number, y: number, w: number, h: number, char?: string, opts?: { fg?: Color; bg?: Color }): void
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, { fg: opts?.fg, bg: opts?.bg })
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, { fg: opts?.fg, bg: opts?.bg })
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
- if (prevBuffer && !needsFullRedraw) {
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
  }