@effect-tui/react 0.16.0 → 2.0.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 +9 -0
- package/dist/src/codeblock.d.ts +1 -1
- package/dist/src/codeblock.d.ts.map +1 -1
- package/dist/src/codeblock.js +2 -2
- package/dist/src/codeblock.js.map +1 -1
- package/dist/src/components/Markdown.js +3 -3
- package/dist/src/components/Markdown.js.map +1 -1
- package/dist/src/components/MultilineTextInput.d.ts.map +1 -1
- package/dist/src/components/MultilineTextInput.js +133 -305
- package/dist/src/components/MultilineTextInput.js.map +1 -1
- package/dist/src/components/TextInput.d.ts.map +1 -1
- package/dist/src/components/TextInput.js +51 -98
- package/dist/src/components/TextInput.js.map +1 -1
- package/dist/src/components/text-editing.d.ts +61 -0
- package/dist/src/components/text-editing.d.ts.map +1 -1
- package/dist/src/components/text-editing.js +131 -0
- package/dist/src/components/text-editing.js.map +1 -1
- package/dist/src/hosts/base.d.ts +13 -2
- package/dist/src/hosts/base.d.ts.map +1 -1
- package/dist/src/hosts/base.js +74 -2
- package/dist/src/hosts/base.js.map +1 -1
- package/dist/src/hosts/box.d.ts +2 -2
- package/dist/src/hosts/box.d.ts.map +1 -1
- package/dist/src/hosts/box.js +29 -2
- package/dist/src/hosts/box.js.map +1 -1
- package/dist/src/hosts/canvas.d.ts +22 -2
- package/dist/src/hosts/canvas.d.ts.map +1 -1
- package/dist/src/hosts/canvas.js +99 -31
- package/dist/src/hosts/canvas.js.map +1 -1
- package/dist/src/hosts/codeblock.d.ts +8 -10
- package/dist/src/hosts/codeblock.d.ts.map +1 -1
- package/dist/src/hosts/codeblock.js +36 -33
- package/dist/src/hosts/codeblock.js.map +1 -1
- package/dist/src/hosts/flex-container.d.ts +2 -2
- package/dist/src/hosts/flex-container.d.ts.map +1 -1
- package/dist/src/hosts/flex-container.js +17 -2
- package/dist/src/hosts/flex-container.js.map +1 -1
- package/dist/src/hosts/index.d.ts +1 -1
- package/dist/src/hosts/index.d.ts.map +1 -1
- package/dist/src/hosts/index.js.map +1 -1
- package/dist/src/hosts/overlay-item.d.ts +2 -2
- package/dist/src/hosts/overlay-item.d.ts.map +1 -1
- package/dist/src/hosts/overlay-item.js +7 -2
- package/dist/src/hosts/overlay-item.js.map +1 -1
- package/dist/src/hosts/overlay.d.ts +2 -2
- package/dist/src/hosts/overlay.d.ts.map +1 -1
- package/dist/src/hosts/overlay.js +2 -2
- package/dist/src/hosts/overlay.js.map +1 -1
- package/dist/src/hosts/scroll.d.ts +7 -2
- package/dist/src/hosts/scroll.d.ts.map +1 -1
- package/dist/src/hosts/scroll.js +126 -45
- package/dist/src/hosts/scroll.js.map +1 -1
- package/dist/src/hosts/single-child.d.ts.map +1 -1
- package/dist/src/hosts/single-child.js +2 -0
- package/dist/src/hosts/single-child.js.map +1 -1
- package/dist/src/hosts/spacer.d.ts +1 -1
- package/dist/src/hosts/spacer.d.ts.map +1 -1
- package/dist/src/hosts/spacer.js +6 -1
- package/dist/src/hosts/spacer.js.map +1 -1
- package/dist/src/hosts/text.d.ts +20 -15
- package/dist/src/hosts/text.d.ts.map +1 -1
- package/dist/src/hosts/text.js +104 -71
- package/dist/src/hosts/text.js.map +1 -1
- package/dist/src/hosts/zstack.d.ts +2 -2
- package/dist/src/hosts/zstack.d.ts.map +1 -1
- package/dist/src/hosts/zstack.js +7 -2
- package/dist/src/hosts/zstack.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/internal/renderer/index.d.ts.map +1 -1
- package/dist/src/internal/renderer/index.js +41 -16
- package/dist/src/internal/renderer/index.js.map +1 -1
- package/dist/src/internal/renderer/types.d.ts +4 -0
- package/dist/src/internal/renderer/types.d.ts.map +1 -1
- package/dist/src/motion/hooks.d.ts +1 -1
- package/dist/src/motion/hooks.js +1 -1
- package/dist/src/reconciler/host-config.js +2 -2
- package/dist/src/reconciler/host-config.js.map +1 -1
- package/dist/src/reconciler/types.d.ts +5 -1
- package/dist/src/reconciler/types.d.ts.map +1 -1
- package/dist/src/utils/border.d.ts +1 -1
- package/dist/src/utils/border.d.ts.map +1 -1
- package/dist/src/utils/border.js +2 -0
- package/dist/src/utils/border.js.map +1 -1
- package/dist/src/utils/index.d.ts +2 -1
- package/dist/src/utils/index.d.ts.map +1 -1
- package/dist/src/utils/index.js +2 -1
- package/dist/src/utils/index.js.map +1 -1
- package/dist/src/utils/text-layout.d.ts +22 -0
- package/dist/src/utils/text-layout.d.ts.map +1 -0
- package/dist/src/utils/text-layout.js +37 -0
- package/dist/src/utils/text-layout.js.map +1 -0
- package/dist/src/utils/text-wrap.d.ts +26 -1
- package/dist/src/utils/text-wrap.d.ts.map +1 -1
- package/dist/src/utils/text-wrap.js +106 -11
- package/dist/src/utils/text-wrap.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/codeblock.tsx +2 -2
- package/src/components/Markdown.tsx +3 -3
- package/src/components/MultilineTextInput.tsx +138 -344
- package/src/components/TextInput.tsx +54 -99
- package/src/components/text-editing.ts +180 -0
- package/src/hosts/base.ts +86 -3
- package/src/hosts/box.ts +37 -2
- package/src/hosts/canvas.ts +120 -31
- package/src/hosts/codeblock.ts +46 -33
- package/src/hosts/flex-container.ts +21 -2
- package/src/hosts/index.ts +1 -1
- package/src/hosts/overlay-item.ts +8 -2
- package/src/hosts/overlay.ts +2 -2
- package/src/hosts/scroll.ts +142 -45
- package/src/hosts/single-child.ts +2 -0
- package/src/hosts/spacer.ts +6 -1
- package/src/hosts/text.ts +122 -75
- package/src/hosts/zstack.ts +7 -2
- package/src/index.ts +1 -1
- package/src/internal/renderer/index.ts +53 -20
- package/src/internal/renderer/types.ts +4 -0
- package/src/motion/hooks.ts +1 -1
- package/src/reconciler/host-config.ts +2 -2
- package/src/reconciler/types.ts +7 -1
- package/src/utils/border.ts +11 -1
- package/src/utils/index.ts +15 -1
- package/src/utils/text-layout.ts +65 -0
- package/src/utils/text-wrap.ts +135 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-tui/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.1",
|
|
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": "^0.
|
|
86
|
+
"@effect-tui/core": "^2.0.1",
|
|
87
87
|
"@effect/platform": "^0.94.1",
|
|
88
88
|
"@effect/platform-bun": "^0.87.0",
|
|
89
89
|
"@effect/rpc": "^0.73.0",
|
package/src/codeblock.tsx
CHANGED
|
@@ -15,7 +15,7 @@ export function CodeBlock({
|
|
|
15
15
|
theme = "nord",
|
|
16
16
|
lineNumbers = true,
|
|
17
17
|
padding = 1,
|
|
18
|
-
|
|
18
|
+
bg,
|
|
19
19
|
...rest
|
|
20
20
|
}: CodeBlockProps) {
|
|
21
21
|
const [lines, setLines] = useState<HighlightLine[]>(() => toPlainLines(code))
|
|
@@ -41,7 +41,7 @@ export function CodeBlock({
|
|
|
41
41
|
lines={lines}
|
|
42
42
|
lineNumbers={lineNumbers as boolean}
|
|
43
43
|
padding={padding as HostCodeBlockProps["padding"]}
|
|
44
|
-
|
|
44
|
+
bg={bg as HostCodeBlockProps["bg"]}
|
|
45
45
|
/>
|
|
46
46
|
)
|
|
47
47
|
}
|
|
@@ -377,7 +377,7 @@ export function Markdown({ content, theme: themeOverrides, codeTheme = "nord", w
|
|
|
377
377
|
<hstack key={i}>
|
|
378
378
|
<text fg={theme.quoteBorder}>{"│ "}</text>
|
|
379
379
|
{wrap ? (
|
|
380
|
-
<text spans={toStyledSpans(el.spans, theme)} wrap />
|
|
380
|
+
<text spans={toStyledSpans(el.spans, theme)} wrap greedy />
|
|
381
381
|
) : (
|
|
382
382
|
renderSpans(el.spans, theme)
|
|
383
383
|
)}
|
|
@@ -390,7 +390,7 @@ export function Markdown({ content, theme: themeOverrides, codeTheme = "nord", w
|
|
|
390
390
|
<hstack key={j}>
|
|
391
391
|
<text fg={theme.listMarker}>{" • "}</text>
|
|
392
392
|
{wrap ? (
|
|
393
|
-
<text spans={toStyledSpans(item, theme)} wrap />
|
|
393
|
+
<text spans={toStyledSpans(item, theme)} wrap greedy />
|
|
394
394
|
) : (
|
|
395
395
|
renderSpans(item, theme)
|
|
396
396
|
)}
|
|
@@ -405,7 +405,7 @@ export function Markdown({ content, theme: themeOverrides, codeTheme = "nord", w
|
|
|
405
405
|
<hstack key={j}>
|
|
406
406
|
<text fg={theme.listMarker}>{` ${el.start + j}. `}</text>
|
|
407
407
|
{wrap ? (
|
|
408
|
-
<text spans={toStyledSpans(item, theme)} wrap />
|
|
408
|
+
<text spans={toStyledSpans(item, theme)} wrap greedy />
|
|
409
409
|
) : (
|
|
410
410
|
renderSpans(item, theme)
|
|
411
411
|
)}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { type Color, Colors,
|
|
1
|
+
import { type Color, Colors, graphemes } from "@effect-tui/core"
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
3
3
|
import { useKeyboard } from "../hooks/use-keyboard.js"
|
|
4
4
|
import { usePaste } from "../hooks/use-paste.js"
|
|
5
|
-
import {
|
|
5
|
+
import { buildTextLayout, type TextLayout } from "../utils/index.js"
|
|
6
6
|
import type { DrawContext } from "../hosts/canvas.js"
|
|
7
7
|
import {
|
|
8
8
|
deleteCharBackwardMultiline,
|
|
9
9
|
deleteCharForwardMultiline,
|
|
10
10
|
deleteWordBackwardMultiline,
|
|
11
|
+
deleteWordForwardMultiline,
|
|
11
12
|
graphemeColToCharIdx,
|
|
12
13
|
insertTextMultiline,
|
|
13
14
|
killToEndMultiline,
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
type MultilineState,
|
|
16
17
|
matchNextWord,
|
|
17
18
|
matchPrevWord,
|
|
19
|
+
resolveTextInputAction,
|
|
20
|
+
type TextKeyEvent,
|
|
18
21
|
transposeCharsMultiline,
|
|
19
22
|
} from "./text-editing.js"
|
|
20
23
|
|
|
@@ -61,108 +64,11 @@ interface CursorPos {
|
|
|
61
64
|
col: number // grapheme index within logical line
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
/** A visual line that maps back to a logical line */
|
|
65
|
-
interface VisualLine {
|
|
66
|
-
logicalRow: number
|
|
67
|
-
startCol: number // grapheme index in logical line (inclusive)
|
|
68
|
-
endCol: number // grapheme index in logical line (exclusive)
|
|
69
|
-
text: string // the actual text for this visual line
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Layout for a single logical line */
|
|
73
|
-
interface LineLayout {
|
|
74
|
-
graphemeList: string[]
|
|
75
|
-
widths: number[]
|
|
76
|
-
prefixWidths: number[] // cumulative widths, length = graphemes.length + 1
|
|
77
|
-
visualLines: VisualLine[]
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Complete layout for all text */
|
|
81
|
-
interface Layout {
|
|
82
|
-
lines: LineLayout[]
|
|
83
|
-
allVisualLines: VisualLine[]
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Wrap a single logical line into visual lines.
|
|
88
|
-
* Uses word boundaries with fallback to character wrap.
|
|
89
|
-
*/
|
|
90
|
-
function wrapLogicalLine(line: string, logicalRow: number, maxWidth: number): LineLayout {
|
|
91
|
-
const graphemeList = graphemes(line)
|
|
92
|
-
const widths = graphemeList.map((g) => displayWidth(g))
|
|
93
|
-
|
|
94
|
-
// Build prefix widths for O(1) range width queries
|
|
95
|
-
const prefixWidths: number[] = [0]
|
|
96
|
-
for (let i = 0; i < widths.length; i++) {
|
|
97
|
-
prefixWidths.push(prefixWidths[i] + widths[i])
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Find break opportunities (after spaces)
|
|
101
|
-
const isBreakable = graphemeList.map((g) => isWhitespace(g))
|
|
102
|
-
|
|
103
|
-
const visualLines: VisualLine[] = []
|
|
104
|
-
let start = 0
|
|
105
|
-
let currentWidth = 0
|
|
106
|
-
let lastBreak = -1
|
|
107
|
-
|
|
108
|
-
for (let i = 0; i < graphemeList.length; i++) {
|
|
109
|
-
const w = widths[i]
|
|
110
|
-
|
|
111
|
-
if (currentWidth + w > maxWidth && start < i) {
|
|
112
|
-
// Need to wrap - backtrack to last break if possible
|
|
113
|
-
const breakAt = lastBreak >= start ? lastBreak + 1 : i
|
|
114
|
-
|
|
115
|
-
visualLines.push({
|
|
116
|
-
logicalRow,
|
|
117
|
-
startCol: start,
|
|
118
|
-
endCol: breakAt,
|
|
119
|
-
text: graphemeList.slice(start, breakAt).join(""),
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
start = breakAt
|
|
123
|
-
currentWidth = prefixWidths[i + 1] - prefixWidths[start]
|
|
124
|
-
lastBreak = -1
|
|
125
|
-
} else {
|
|
126
|
-
currentWidth += w
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (isBreakable[i]) {
|
|
130
|
-
lastBreak = i
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Final segment
|
|
135
|
-
visualLines.push({
|
|
136
|
-
logicalRow,
|
|
137
|
-
startCol: start,
|
|
138
|
-
endCol: graphemeList.length,
|
|
139
|
-
text: graphemeList.slice(start).join(""),
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
return { graphemeList, widths, prefixWidths, visualLines }
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Build complete layout for text with word wrap.
|
|
147
|
-
*/
|
|
148
|
-
function buildLayout(text: string, maxWidth: number): Layout {
|
|
149
|
-
const logicalLines = text.split("\n")
|
|
150
|
-
const lines: LineLayout[] = []
|
|
151
|
-
const allVisualLines: VisualLine[] = []
|
|
152
|
-
|
|
153
|
-
for (let row = 0; row < logicalLines.length; row++) {
|
|
154
|
-
const lineLayout = wrapLogicalLine(logicalLines[row], row, maxWidth)
|
|
155
|
-
lines.push(lineLayout)
|
|
156
|
-
allVisualLines.push(...lineLayout.visualLines)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return { lines, allVisualLines }
|
|
160
|
-
}
|
|
161
67
|
|
|
162
68
|
/**
|
|
163
69
|
* Convert logical cursor to visual row/col.
|
|
164
70
|
*/
|
|
165
|
-
function logicalToVisual(layout:
|
|
71
|
+
function logicalToVisual(layout: TextLayout, cursor: CursorPos): { visualRow: number; visualCol: number } {
|
|
166
72
|
const lineLayout = layout.lines[cursor.row]
|
|
167
73
|
if (!lineLayout) {
|
|
168
74
|
return { visualRow: 0, visualCol: 0 }
|
|
@@ -196,7 +102,7 @@ function logicalToVisual(layout: Layout, cursor: CursorPos): { visualRow: number
|
|
|
196
102
|
* Convert visual row to logical cursor (for up/down navigation).
|
|
197
103
|
* Tries to preserve the visual X position.
|
|
198
104
|
*/
|
|
199
|
-
function visualToLogical(layout:
|
|
105
|
+
function visualToLogical(layout: TextLayout, visualRow: number, targetVisualX: number): CursorPos {
|
|
200
106
|
if (visualRow < 0) {
|
|
201
107
|
return { row: 0, col: 0 }
|
|
202
108
|
}
|
|
@@ -294,38 +200,9 @@ export function MultilineTextInput({
|
|
|
294
200
|
|
|
295
201
|
// Build layout with word wrap
|
|
296
202
|
const layout = useMemo(() => {
|
|
297
|
-
if (!wordWrap) {
|
|
298
|
-
// No wrap - each logical line is one visual line
|
|
299
|
-
const lines: LineLayout[] = logicalLines.map((line, row) => {
|
|
300
|
-
const graphemeList = graphemes(line)
|
|
301
|
-
const widths = graphemeList.map((g) => displayWidth(g))
|
|
302
|
-
const prefixWidths = [0]
|
|
303
|
-
for (let i = 0; i < widths.length; i++) {
|
|
304
|
-
prefixWidths.push(prefixWidths[i] + widths[i])
|
|
305
|
-
}
|
|
306
|
-
return {
|
|
307
|
-
graphemeList,
|
|
308
|
-
widths,
|
|
309
|
-
prefixWidths,
|
|
310
|
-
visualLines: [
|
|
311
|
-
{
|
|
312
|
-
logicalRow: row,
|
|
313
|
-
startCol: 0,
|
|
314
|
-
endCol: graphemeList.length,
|
|
315
|
-
text: line,
|
|
316
|
-
},
|
|
317
|
-
],
|
|
318
|
-
}
|
|
319
|
-
})
|
|
320
|
-
return {
|
|
321
|
-
lines,
|
|
322
|
-
allVisualLines: lines.flatMap((l) => l.visualLines),
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
203
|
const effectiveWidth = Math.max(10, contentWidth - lineNumGutterWidth)
|
|
327
|
-
return
|
|
328
|
-
}, [value, contentWidth, wordWrap, lineNumGutterWidth
|
|
204
|
+
return buildTextLayout(value, effectiveWidth, { wrap: wordWrap, preserveWhitespace: true })
|
|
205
|
+
}, [value, contentWidth, wordWrap, lineNumGutterWidth])
|
|
329
206
|
|
|
330
207
|
// Keep cursor in bounds when value changes externally
|
|
331
208
|
useEffect(() => {
|
|
@@ -364,9 +241,22 @@ export function MultilineTextInput({
|
|
|
364
241
|
const currentLineLayout = layout.lines[cursor.row]
|
|
365
242
|
|
|
366
243
|
const handleKey = useCallback(
|
|
367
|
-
(key:
|
|
244
|
+
(key: TextKeyEvent) => {
|
|
368
245
|
if (!focused) return
|
|
369
246
|
|
|
247
|
+
const action = resolveTextInputAction(key)
|
|
248
|
+
if (!action) return
|
|
249
|
+
|
|
250
|
+
const applyEdit = (result: { state: MultilineState; changed: boolean }, options?: { keepKillRing?: boolean }) => {
|
|
251
|
+
if (result.changed) {
|
|
252
|
+
onChange(result.state.lines.join("\n"))
|
|
253
|
+
setCursor(result.state.cursor)
|
|
254
|
+
if (!options?.keepKillRing && result.state.killRing !== killRing) {
|
|
255
|
+
setKillRing(result.state.killRing)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
370
260
|
const moveCursor = (row: number, col: number) => {
|
|
371
261
|
const newRow = Math.max(0, Math.min(logicalLines.length - 1, row))
|
|
372
262
|
const lineLayout = layout.lines[newRow]
|
|
@@ -385,92 +275,93 @@ export function MultilineTextInput({
|
|
|
385
275
|
setCursor(newCursor)
|
|
386
276
|
}
|
|
387
277
|
|
|
388
|
-
switch (
|
|
389
|
-
case "up":
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
278
|
+
switch (action.type) {
|
|
279
|
+
case "move-up":
|
|
280
|
+
moveVisual(-1)
|
|
281
|
+
break
|
|
282
|
+
case "move-down":
|
|
283
|
+
moveVisual(1)
|
|
284
|
+
break
|
|
285
|
+
case "move-doc-start":
|
|
286
|
+
moveCursor(0, 0)
|
|
287
|
+
break
|
|
288
|
+
case "move-doc-end": {
|
|
289
|
+
const lastRow = logicalLines.length - 1
|
|
290
|
+
const lastLineLen = layout.lines[lastRow]?.graphemeList.length ?? 0
|
|
291
|
+
moveCursor(lastRow, lastLineLen)
|
|
292
|
+
break
|
|
293
|
+
}
|
|
294
|
+
case "move-left":
|
|
295
|
+
if (cursor.col > 0) {
|
|
296
|
+
moveCursor(cursor.row, cursor.col - 1)
|
|
297
|
+
} else if (cursor.row > 0) {
|
|
298
|
+
const prevLineLen = layout.lines[cursor.row - 1]?.graphemeList.length ?? 0
|
|
299
|
+
moveCursor(cursor.row - 1, prevLineLen)
|
|
395
300
|
}
|
|
396
301
|
break
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
moveCursor(lastRow, lastLineLen)
|
|
404
|
-
} else {
|
|
405
|
-
moveVisual(1)
|
|
302
|
+
case "move-right": {
|
|
303
|
+
const lineLen = currentLineLayout?.graphemeList.length ?? 0
|
|
304
|
+
if (cursor.col < lineLen) {
|
|
305
|
+
moveCursor(cursor.row, cursor.col + 1)
|
|
306
|
+
} else if (cursor.row < logicalLines.length - 1) {
|
|
307
|
+
moveCursor(cursor.row + 1, 0)
|
|
406
308
|
}
|
|
407
309
|
break
|
|
408
|
-
|
|
409
|
-
case "left":
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
moveCursor(cursor.row, cursor.col - matchGraphemes)
|
|
420
|
-
} else if (cursor.col > 0) {
|
|
421
|
-
moveCursor(cursor.row, 0)
|
|
422
|
-
} else if (cursor.row > 0) {
|
|
423
|
-
const prevLineLen = layout.lines[cursor.row - 1]?.graphemeList.length ?? 0
|
|
424
|
-
moveCursor(cursor.row - 1, prevLineLen)
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
} else {
|
|
428
|
-
if (cursor.col > 0) {
|
|
429
|
-
moveCursor(cursor.row, cursor.col - 1)
|
|
310
|
+
}
|
|
311
|
+
case "move-word-left": {
|
|
312
|
+
const lineLayout = layout.lines[cursor.row]
|
|
313
|
+
if (lineLayout) {
|
|
314
|
+
const beforeCursor = lineLayout.graphemeList.slice(0, cursor.col).join("")
|
|
315
|
+
const match = matchPrevWord(beforeCursor)
|
|
316
|
+
if (match) {
|
|
317
|
+
const matchGraphemes = graphemes(match).length
|
|
318
|
+
moveCursor(cursor.row, cursor.col - matchGraphemes)
|
|
319
|
+
} else if (cursor.col > 0) {
|
|
320
|
+
moveCursor(cursor.row, 0)
|
|
430
321
|
} else if (cursor.row > 0) {
|
|
431
322
|
const prevLineLen = layout.lines[cursor.row - 1]?.graphemeList.length ?? 0
|
|
432
323
|
moveCursor(cursor.row - 1, prevLineLen)
|
|
433
324
|
}
|
|
434
325
|
}
|
|
435
326
|
break
|
|
436
|
-
|
|
437
|
-
case "right":
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
} else if (cursor.col < lineLayout.graphemeList.length) {
|
|
448
|
-
moveCursor(cursor.row, lineLayout.graphemeList.length)
|
|
449
|
-
} else if (cursor.row < logicalLines.length - 1) {
|
|
450
|
-
moveCursor(cursor.row + 1, 0)
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
} else {
|
|
454
|
-
const lineLen = currentLineLayout?.graphemeList.length ?? 0
|
|
455
|
-
if (cursor.col < lineLen) {
|
|
456
|
-
moveCursor(cursor.row, cursor.col + 1)
|
|
327
|
+
}
|
|
328
|
+
case "move-word-right": {
|
|
329
|
+
const lineLayout = layout.lines[cursor.row]
|
|
330
|
+
if (lineLayout) {
|
|
331
|
+
const afterCursor = lineLayout.graphemeList.slice(cursor.col).join("")
|
|
332
|
+
const match = matchNextWord(afterCursor)
|
|
333
|
+
if (match) {
|
|
334
|
+
const matchGraphemes = graphemes(match).length
|
|
335
|
+
moveCursor(cursor.row, cursor.col + matchGraphemes)
|
|
336
|
+
} else if (cursor.col < lineLayout.graphemeList.length) {
|
|
337
|
+
moveCursor(cursor.row, lineLayout.graphemeList.length)
|
|
457
338
|
} else if (cursor.row < logicalLines.length - 1) {
|
|
458
339
|
moveCursor(cursor.row + 1, 0)
|
|
459
340
|
}
|
|
460
341
|
}
|
|
461
342
|
break
|
|
462
|
-
|
|
463
|
-
case "
|
|
343
|
+
}
|
|
344
|
+
case "move-start":
|
|
464
345
|
moveCursor(cursor.row, 0)
|
|
465
346
|
break
|
|
466
|
-
|
|
467
|
-
case "end":
|
|
347
|
+
case "move-end":
|
|
468
348
|
moveCursor(cursor.row, currentLineLayout?.graphemeList.length ?? 0)
|
|
469
349
|
break
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
350
|
+
case "delete-backward": {
|
|
351
|
+
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
352
|
+
applyEdit(deleteCharBackwardMultiline(state))
|
|
353
|
+
break
|
|
354
|
+
}
|
|
355
|
+
case "delete-forward": {
|
|
356
|
+
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
357
|
+
applyEdit(deleteCharForwardMultiline(state))
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
case "delete-word-backward":
|
|
361
|
+
if (action.scope === "document") {
|
|
362
|
+
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
363
|
+
applyEdit(deleteWordBackwardMultiline(state))
|
|
364
|
+
} else if (cursor.col > 0) {
|
|
474
365
|
const charIdx = graphemeColToCharIdx(currentLine, cursor.col)
|
|
475
366
|
const beforeCursor = currentLine.slice(0, charIdx)
|
|
476
367
|
const match = matchPrevWord(beforeCursor)
|
|
@@ -484,157 +375,55 @@ export function MultilineTextInput({
|
|
|
484
375
|
setCursor({ row: cursor.row, col: newCol })
|
|
485
376
|
}
|
|
486
377
|
} else {
|
|
487
|
-
// Delete character before cursor (or join with previous line)
|
|
488
378
|
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
489
|
-
|
|
490
|
-
if (result.changed) {
|
|
491
|
-
onChange(result.state.lines.join("\n"))
|
|
492
|
-
setCursor(result.state.cursor)
|
|
493
|
-
}
|
|
379
|
+
applyEdit(deleteCharBackwardMultiline(state), { keepKillRing: true })
|
|
494
380
|
}
|
|
495
381
|
break
|
|
496
|
-
|
|
497
|
-
case "delete": {
|
|
382
|
+
case "delete-word-forward": {
|
|
498
383
|
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
499
|
-
const
|
|
500
|
-
if (
|
|
501
|
-
|
|
384
|
+
const lineLen = currentLineLayout?.graphemeList.length ?? 0
|
|
385
|
+
if (cursor.col >= lineLen) {
|
|
386
|
+
applyEdit(deleteCharForwardMultiline(state), { keepKillRing: true })
|
|
387
|
+
break
|
|
502
388
|
}
|
|
389
|
+
applyEdit(deleteWordForwardMultiline(state), { keepKillRing: action.scope === "line" })
|
|
503
390
|
break
|
|
504
391
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
onSubmit?.(value)
|
|
509
|
-
} else {
|
|
510
|
-
// Insert newline
|
|
511
|
-
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
512
|
-
const result = insertTextMultiline(state, "\n")
|
|
513
|
-
if (result.changed) {
|
|
514
|
-
onChange(result.state.lines.join("\n"))
|
|
515
|
-
setCursor(result.state.cursor)
|
|
516
|
-
}
|
|
517
|
-
}
|
|
392
|
+
case "kill-to-end": {
|
|
393
|
+
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
394
|
+
applyEdit(killToEndMultiline(state))
|
|
518
395
|
break
|
|
519
|
-
|
|
520
|
-
case "
|
|
521
|
-
|
|
396
|
+
}
|
|
397
|
+
case "kill-to-start": {
|
|
398
|
+
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
399
|
+
applyEdit(killToStartMultiline(state))
|
|
522
400
|
break
|
|
523
|
-
|
|
524
|
-
case "
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
break
|
|
550
|
-
case "n":
|
|
551
|
-
moveVisual(1)
|
|
552
|
-
break
|
|
553
|
-
case "p":
|
|
554
|
-
moveVisual(-1)
|
|
555
|
-
break
|
|
556
|
-
case "d": {
|
|
557
|
-
// Delete character at cursor
|
|
558
|
-
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
559
|
-
const result = deleteCharForwardMultiline(state)
|
|
560
|
-
if (result.changed) {
|
|
561
|
-
onChange(result.state.lines.join("\n"))
|
|
562
|
-
}
|
|
563
|
-
break
|
|
564
|
-
}
|
|
565
|
-
case "h": {
|
|
566
|
-
// Backspace
|
|
567
|
-
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
568
|
-
const result = deleteCharBackwardMultiline(state)
|
|
569
|
-
if (result.changed) {
|
|
570
|
-
onChange(result.state.lines.join("\n"))
|
|
571
|
-
setCursor(result.state.cursor)
|
|
572
|
-
}
|
|
573
|
-
break
|
|
574
|
-
}
|
|
575
|
-
case "k": {
|
|
576
|
-
// Kill to end of line
|
|
577
|
-
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
578
|
-
const result = killToEndMultiline(state)
|
|
579
|
-
if (result.changed) {
|
|
580
|
-
onChange(result.state.lines.join("\n"))
|
|
581
|
-
setCursor(result.state.cursor)
|
|
582
|
-
setKillRing(result.state.killRing)
|
|
583
|
-
}
|
|
584
|
-
break
|
|
585
|
-
}
|
|
586
|
-
case "u": {
|
|
587
|
-
// Kill to start of line
|
|
588
|
-
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
589
|
-
const result = killToStartMultiline(state)
|
|
590
|
-
if (result.changed) {
|
|
591
|
-
onChange(result.state.lines.join("\n"))
|
|
592
|
-
setCursor(result.state.cursor)
|
|
593
|
-
setKillRing(result.state.killRing)
|
|
594
|
-
}
|
|
595
|
-
break
|
|
596
|
-
}
|
|
597
|
-
case "w": {
|
|
598
|
-
// Delete word backward (crosses line boundaries)
|
|
599
|
-
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
600
|
-
const result = deleteWordBackwardMultiline(state)
|
|
601
|
-
if (result.changed) {
|
|
602
|
-
onChange(result.state.lines.join("\n"))
|
|
603
|
-
setCursor(result.state.cursor)
|
|
604
|
-
setKillRing(result.state.killRing)
|
|
605
|
-
}
|
|
606
|
-
break
|
|
607
|
-
}
|
|
608
|
-
case "t": {
|
|
609
|
-
// Transpose characters
|
|
610
|
-
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
611
|
-
const result = transposeCharsMultiline(state)
|
|
612
|
-
if (result.changed) {
|
|
613
|
-
onChange(result.state.lines.join("\n"))
|
|
614
|
-
setCursor(result.state.cursor)
|
|
615
|
-
}
|
|
616
|
-
break
|
|
617
|
-
}
|
|
618
|
-
case "y": {
|
|
619
|
-
// Yank from kill ring
|
|
620
|
-
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
621
|
-
const result = insertTextMultiline(state, killRing)
|
|
622
|
-
if (result.changed) {
|
|
623
|
-
onChange(result.state.lines.join("\n"))
|
|
624
|
-
setCursor(result.state.cursor)
|
|
625
|
-
}
|
|
626
|
-
break
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
} else if (key.text && !key.meta) {
|
|
630
|
-
// Insert character at cursor
|
|
631
|
-
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
632
|
-
const result = insertTextMultiline(state, key.text)
|
|
633
|
-
if (result.changed) {
|
|
634
|
-
onChange(result.state.lines.join("\n"))
|
|
635
|
-
setCursor(result.state.cursor)
|
|
636
|
-
}
|
|
637
|
-
}
|
|
401
|
+
}
|
|
402
|
+
case "transpose": {
|
|
403
|
+
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
404
|
+
applyEdit(transposeCharsMultiline(state))
|
|
405
|
+
break
|
|
406
|
+
}
|
|
407
|
+
case "yank": {
|
|
408
|
+
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
409
|
+
applyEdit(insertTextMultiline(state, killRing))
|
|
410
|
+
break
|
|
411
|
+
}
|
|
412
|
+
case "insert": {
|
|
413
|
+
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
414
|
+
applyEdit(insertTextMultiline(state, action.text))
|
|
415
|
+
break
|
|
416
|
+
}
|
|
417
|
+
case "enter": {
|
|
418
|
+
const state: MultilineState = { lines: logicalLines, cursor, killRing }
|
|
419
|
+
applyEdit(insertTextMultiline(state, "\n"))
|
|
420
|
+
break
|
|
421
|
+
}
|
|
422
|
+
case "submit":
|
|
423
|
+
onSubmit?.(value)
|
|
424
|
+
break
|
|
425
|
+
case "cancel":
|
|
426
|
+
onCancel?.()
|
|
638
427
|
break
|
|
639
428
|
}
|
|
640
429
|
},
|
|
@@ -684,11 +473,16 @@ export function MultilineTextInput({
|
|
|
684
473
|
|
|
685
474
|
// Clear with background
|
|
686
475
|
if (bg !== undefined) {
|
|
687
|
-
ctx.
|
|
476
|
+
ctx.fillRect(0, 0, ctx.width, ctx.height, " ", { bg })
|
|
688
477
|
}
|
|
689
478
|
|
|
690
479
|
// Use placeholder layout if empty
|
|
691
|
-
const displayLayout = isPlaceholder
|
|
480
|
+
const displayLayout = isPlaceholder
|
|
481
|
+
? buildTextLayout(placeholder, Math.max(10, ctx.width - lineNumWidth), {
|
|
482
|
+
wrap: true,
|
|
483
|
+
preserveWhitespace: true,
|
|
484
|
+
})
|
|
485
|
+
: layout
|
|
692
486
|
|
|
693
487
|
const { visualRow: cursorVisualRow } = isPlaceholder ? { visualRow: 0 } : logicalToVisual(layout, cursor)
|
|
694
488
|
|