Skip to content

Commit 69b32ab

Browse files
fix(textarea): preserve cursor visibility for long unwrapped lines (#306)
1 parent 890337d commit 69b32ab

File tree

4 files changed

+220
-3
lines changed

4 files changed

+220
-3
lines changed

docs/widgets/catalog.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,19 @@
206206
{
207207
"name": "textarea",
208208
"requiredProps": ["id", "value"],
209-
"commonProps": ["rows", "onInput"],
209+
"commonProps": [
210+
"rows",
211+
"wordWrap",
212+
"onInput",
213+
"onBlur",
214+
"disabled",
215+
"readOnly",
216+
"focusable",
217+
"accessibleLabel",
218+
"placeholder",
219+
"style",
220+
"focusConfig"
221+
],
210222
"example": "ui.textarea({ id: 'notes', value: state.notes, rows: 5 })"
211223
},
212224
{

docs/widgets/textarea.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ ui.textarea({
2323
| `wordWrap` | `boolean` | `true` | Wrap long lines |
2424
| `accessibleLabel` | `string` | - | Optional semantic label for focus announcements/debugging |
2525
| `disabled` | `boolean` | `false` | Disable editing and dim appearance |
26+
| `readOnly` | `boolean` | `false` | Keep the textarea focusable while preventing edits |
27+
| `focusable` | `boolean` | `true` | Opt out of Tab order while keeping id-based routing available |
28+
| `placeholder` | `string` | - | Placeholder text shown when the value is empty |
2629
| `style` | `TextStyle` | - | Custom styling (merged with focus/disabled state) |
2730
| `onInput` | `(value: string, cursor: number) => void` | - | Callback when value changes |
2831
| `onBlur` | `() => void` | - | Callback when textarea loses focus |
@@ -48,6 +51,8 @@ When focused:
4851

4952
Textarea is controlled: `value` is always the source of truth.
5053

54+
When `wordWrap: false`, long lines stay unwrapped and the focused viewport shifts horizontally to keep the active cursor column visible instead of clamping the cursor to the left-most window.
55+
5156
Runtime note: `ui.textarea(...)` is represented as VNode kind `"input"` with `multiline: true`.
5257
Use this mapping when writing low-level kind assertions.
5358

packages/core/src/renderer/__tests__/inputRecipeRendering.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { assert, describe, test } from "@rezi-ui/testkit";
2+
import type { DrawlistBuilder } from "../../drawlist/types.js";
3+
import { layout } from "../../layout/layout.js";
4+
import { renderToDrawlist } from "../../renderer/renderToDrawlist.js";
5+
import { type RuntimeInstance, commitVNodeTree } from "../../runtime/commit.js";
6+
import { createInstanceIdAllocator } from "../../runtime/instance.js";
27
import { defaultTheme } from "../../theme/defaultTheme.js";
38
import { darkTheme } from "../../theme/presets.js";
49
import { compileTheme } from "../../theme/theme.js";
10+
import type { TextStyle } from "../../widgets/style.js";
511
import { ui } from "../../widgets/ui.js";
612
import { type DrawOp, renderOps } from "./recipeRendering.test-utils.js";
713

@@ -14,6 +20,114 @@ function firstDrawText(
1420
return ops.find((op) => op.kind === "drawText" && match(op.text));
1521
}
1622

23+
class CursorRecordingBuilder implements DrawlistBuilder {
24+
readonly ops: DrawOp[] = [];
25+
cursor: Parameters<DrawlistBuilder["setCursor"]>[0] | null = null;
26+
27+
clear(): void {}
28+
clearTo(): void {}
29+
fillRect(x: number, y: number, w: number, h: number, style?: TextStyle): void {
30+
this.ops.push(
31+
style ? { kind: "fillRect", x, y, w, h, style } : { kind: "fillRect", x, y, w, h },
32+
);
33+
}
34+
drawText(x: number, y: number, text: string, style?: TextStyle): void {
35+
this.ops.push(
36+
style ? { kind: "drawText", x, y, text, style } : { kind: "drawText", x, y, text },
37+
);
38+
}
39+
pushClip(x: number, y: number, w: number, h: number): void {
40+
this.ops.push({ kind: "pushClip", x, y, w, h });
41+
}
42+
popClip(): void {
43+
this.ops.push({ kind: "popClip" });
44+
}
45+
addBlob(): number | null {
46+
return null;
47+
}
48+
addTextRunBlob(): number | null {
49+
return null;
50+
}
51+
drawTextRun(): void {}
52+
setCursor(state: Parameters<DrawlistBuilder["setCursor"]>[0]): void {
53+
this.cursor = state;
54+
}
55+
hideCursor(): void {}
56+
setLink(): void {}
57+
drawCanvas(): void {}
58+
drawImage(): void {}
59+
blitRect(): void {}
60+
build() {
61+
return { ok: true, bytes: new Uint8Array(0) } as const;
62+
}
63+
buildInto(_dst: Uint8Array): ReturnType<DrawlistBuilder["buildInto"]> {
64+
return this.build();
65+
}
66+
reset(): void {
67+
this.ops.length = 0;
68+
this.cursor = null;
69+
}
70+
}
71+
72+
function findInstanceIdById(node: RuntimeInstance, id: string): number | null {
73+
const props = node.vnode.props as { id?: unknown } | undefined;
74+
if (props?.id === id) return node.instanceId;
75+
for (const child of node.children) {
76+
if (!child) continue;
77+
const found = findInstanceIdById(child, id);
78+
if (found !== null) return found;
79+
}
80+
return null;
81+
}
82+
83+
function renderTextareaWithCursor(
84+
value: string,
85+
cursor: number,
86+
): Readonly<{
87+
ops: readonly DrawOp[];
88+
cursor: Parameters<DrawlistBuilder["setCursor"]>[0] | null;
89+
}> {
90+
const vnode = ui.row({ height: 5, items: "stretch" }, [
91+
ui.textarea({ id: "ta", value, rows: 3, wordWrap: false }),
92+
]);
93+
const committed = commitVNodeTree(null, vnode, { allocator: createInstanceIdAllocator(1) });
94+
assert.equal(committed.ok, true);
95+
if (!committed.ok) {
96+
return Object.freeze({ ops: Object.freeze([]), cursor: null });
97+
}
98+
99+
const textareaInstanceId = findInstanceIdById(committed.value.root, "ta");
100+
assert.ok(textareaInstanceId !== null, "textarea instance should exist");
101+
if (textareaInstanceId === null) {
102+
return Object.freeze({ ops: Object.freeze([]), cursor: null });
103+
}
104+
105+
const laidOut = layout(committed.value.root.vnode, 0, 0, 16, 6, "column");
106+
assert.equal(laidOut.ok, true);
107+
if (!laidOut.ok) {
108+
return Object.freeze({ ops: Object.freeze([]), cursor: null });
109+
}
110+
111+
const builder = new CursorRecordingBuilder();
112+
renderToDrawlist({
113+
tree: committed.value.root,
114+
layout: laidOut.value,
115+
viewport: { cols: 16, rows: 6 },
116+
focusState: Object.freeze({ focusedId: "ta" }),
117+
cursorInfo: Object.freeze({
118+
cursorByInstanceId: new Map([[textareaInstanceId, cursor]]),
119+
shape: 0,
120+
blink: true,
121+
}),
122+
builder,
123+
});
124+
125+
return Object.freeze({
126+
ops: Object.freeze(builder.ops.slice()),
127+
cursor: builder.cursor,
128+
});
129+
}
130+
17131
describe("input recipe rendering", () => {
18132
test("uses recipe colors with semantic-token themes", () => {
19133
const ops = renderOps(
@@ -63,6 +177,51 @@ describe("input recipe rendering", () => {
63177
assert.equal(text.text.includes("Enter text..."), true);
64178
});
65179

180+
test("focused textarea keeps the tail of a long no-wrap line visible", () => {
181+
const ops = renderOps(
182+
ui.row({ height: 5, items: "stretch" }, [
183+
ui.textarea({
184+
id: "ta",
185+
value: "abcdefghijklmnopqrstuvwxyz",
186+
rows: 3,
187+
wordWrap: false,
188+
}),
189+
]),
190+
{ viewport: { cols: 16, rows: 6 }, theme: defaultTheme, focusedId: "ta" },
191+
);
192+
193+
assert.equal(
194+
ops.some((op) => op.kind === "drawText" && op.text.includes("uvwxyz")),
195+
true,
196+
);
197+
assert.equal(
198+
ops.some((op) => op.kind === "drawText" && op.text.includes("abcdef")),
199+
false,
200+
);
201+
});
202+
203+
test("no-wrap textarea viewport follows cursor movement on long lines", () => {
204+
const start = renderTextareaWithCursor("abcdefghijklmnopqrstuvwxyz", 4);
205+
const end = renderTextareaWithCursor("abcdefghijklmnopqrstuvwxyz", 26);
206+
207+
assert.ok(start.cursor, "early cursor should resolve");
208+
assert.ok(end.cursor, "late cursor should resolve");
209+
if (!start.cursor || !end.cursor) return;
210+
211+
const startText = start.ops
212+
.filter((op): op is Extract<DrawOp, { kind: "drawText" }> => op.kind === "drawText")
213+
.map((op) => op.text)
214+
.join("\n");
215+
const endText = end.ops
216+
.filter((op): op is Extract<DrawOp, { kind: "drawText" }> => op.kind === "drawText")
217+
.map((op) => op.text)
218+
.join("\n");
219+
220+
assert.equal(startText.includes("abcdef"), true);
221+
assert.equal(endText.includes("uvwxyz"), true);
222+
assert.ok(end.cursor.x > start.cursor.x, "cursor should remain visible after viewport shift");
223+
});
224+
66225
test("increases left padding when dsSize is lg", () => {
67226
const mdOps = renderOps(
68227
ui.column({ width: 20, items: "stretch" }, [

packages/core/src/renderer/renderToDrawlist/widgets/renderFormWidgets.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,42 @@ function wrapLineByCells(line: string, width: number): readonly string[] {
134134
return Object.freeze(out.length > 0 ? out : [""]);
135135
}
136136

137+
function sliceTextToCellWindow(
138+
text: string,
139+
startCell: number,
140+
width: number,
141+
): Readonly<{ text: string; startCell: number }> {
142+
const safeStart = Math.max(0, Math.trunc(startCell));
143+
if (width <= 0 || text.length === 0) {
144+
return Object.freeze({ text: "", startCell: safeStart });
145+
}
146+
147+
const codepoints = Array.from(text);
148+
let actualStart = 0;
149+
let index = 0;
150+
151+
while (index < codepoints.length) {
152+
const cp = codepoints[index] ?? "";
153+
const cpWidth = Math.max(0, measureTextCells(cp));
154+
if (actualStart + cpWidth > safeStart) break;
155+
actualStart += cpWidth;
156+
index++;
157+
}
158+
159+
let visibleText = "";
160+
let visibleWidth = 0;
161+
while (index < codepoints.length) {
162+
const cp = codepoints[index] ?? "";
163+
const cpWidth = Math.max(0, measureTextCells(cp));
164+
if (cpWidth > 0 && visibleWidth + cpWidth > width) break;
165+
visibleText += cp;
166+
visibleWidth += cpWidth;
167+
index++;
168+
}
169+
170+
return Object.freeze({ text: visibleText, startCell: actualStart });
171+
}
172+
137173
type InputLineMeta = Readonly<{
138174
lines: readonly string[];
139175
starts: readonly number[];
@@ -572,6 +608,8 @@ export function renderFormWidgets(
572608
focused && !disabled
573609
? Math.max(0, Math.min(maxStartVisual, wrapped.visualLine - contentH + 1))
574610
: 0;
611+
const horizontalScroll =
612+
focused && !disabled && !wordWrap ? Math.max(0, wrapped.visualX - contentW + 1) : 0;
575613

576614
builder.pushClip(textX, textY, contentW, contentH);
577615
for (let row = 0; row < contentH; row++) {
@@ -580,7 +618,9 @@ export function renderFormWidgets(
580618
? placeholder
581619
: ""
582620
: (wrapped.visualLines[startVisual + row] ?? "");
583-
const line = wordWrap ? rawLine : truncateToWidth(rawLine, contentW);
621+
const line = wordWrap
622+
? rawLine
623+
: sliceTextToCellWindow(rawLine, horizontalScroll, contentW).text;
584624
if (line.length === 0) continue;
585625
builder.drawText(textX, textY + row, line, showPlaceholder ? placeholderStyle : style);
586626
}
@@ -590,8 +630,9 @@ export function renderFormWidgets(
590630
const localY = wrapped.visualLine - startVisual;
591631
if (localY >= 0 && localY < contentH) {
592632
const maxCursorX = Math.max(0, contentW - 1);
633+
const localX = wordWrap ? wrapped.visualX : wrapped.visualX - horizontalScroll;
593634
resolvedCursor = {
594-
x: textX + clampInt(wrapped.visualX, 0, maxCursorX),
635+
x: textX + clampInt(localX, 0, maxCursorX),
595636
y: textY + localY,
596637
shape: cursorInfo.shape,
597638
blink: cursorInfo.blink,

0 commit comments

Comments
 (0)