Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ Do not include any agent marketing material (e.g. "Generated with...",

- The renderer MUST NOT perform IO. It produces bytes; the caller writes them.

- The renderer MUST NOT manage terminal state (alternate buffer, cursor
visibility, mouse reporting, keyboard protocol modes).
- The renderer MUST NOT manage terminal state (alternate buffer, mouse
reporting, keyboard protocol modes). Cursor visibility and positioning are
renderer-managed only when a `caret` declaration is present on a `text()`
directive (see renderer-spec.md §7.6).

- Each frame is a complete snapshot. The renderer carries no UI tree state
between frames — only cell buffers for diffing.
Expand Down
148 changes: 148 additions & 0 deletions examples/text-input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Buffer } from "node:buffer";
import process from "node:process";
import { each, ensure, main, until } from "effection";
import {
close,
createTerm,
fixed,
grow,
type KeyEvent,
type Op,
open,
rgba,
text,
} from "../../mod.ts";
import { alternateBuffer, progressiveInput, settings } from "../../settings.ts";
import { useInput } from "../use-input.ts";
import { useStdin } from "../use-stdin.ts";

const bg = rgba(20, 20, 30);
const inputBg = rgba(35, 35, 50);
const label = rgba(180, 180, 200);
const hint = rgba(80, 80, 100);

await main(function* () {
let { columns, rows } = terminalSize();

setRawMode(true);

let stdin = yield* useStdin();
let input = useInput(stdin);

let term = yield* until(createTerm({ width: columns, height: rows }));

let tty = settings(alternateBuffer(), progressiveInput(1));
writeStdout(tty.apply);

let value = "";
let caret = 0;

yield* ensure(() => {
setRawMode(false);
writeStdout(tty.revert);
});

let { output } = term.render(frame(value, caret));
writeStdout(output);

for (let event of yield* each(input)) {
if (event.type === "keydown") {
let key = event as KeyEvent;

if (key.ctrl && key.key === "c") {
break;
}

if (key.key === "Escape") {
break;
}

if (key.key === "ArrowLeft") {
if (caret > 0) {
caret--;
}
} else if (key.key === "ArrowRight") {
if (caret < [...value].length) {
caret++;
}
} else if (key.key === "Backspace") {
if (caret > 0) {
let chars = [...value];
chars.splice(caret - 1, 1);
value = chars.join("");
caret--;
}
} else if (
key.key.length === 1 &&
!key.ctrl &&
!key.alt
) {
let chars = [...value];
chars.splice(caret, 0, key.key);
value = chars.join("");
caret++;
}

({ output } = term.render(frame(value, caret)));
writeStdout(output);
}

yield* each.next();
}
});

function frame(value: string, caret: number): Op[] {
return [
open("root", {
layout: {
width: grow(),
height: grow(),
direction: "ttb",
padding: { left: 2, right: 2, top: 1, bottom: 1 },
gap: 1,
},
bg,
}),
open("label", { layout: { height: fixed(1) } }),
text("Name:", { color: label }),
close(),
open("input-box", {
layout: {
width: fixed(40),
height: fixed(1),
padding: { left: 1, right: 1 },
},
bg: inputBg,
}),
// A single space stands in for an empty value: Clay does not emit a
// text render command for an empty string, which means the renderer
// cannot resolve the caret's cell when the field is empty. The space
// is invisible against the input background. When the renderer
// gains a fallback for empty-text carets, drop the `|| " "`.
text(value || " ", { color: label, caret }),
close(),
open("hint", { layout: { height: fixed(1) } }),
text("← → move Backspace delete Esc or Ctrl+C exit", { color: hint }),
close(),
close(),
];
}

function terminalSize(): { columns: number; rows: number } {
return process.stdout.isTTY
? {
columns: process.stdout.columns ?? 80,
rows: process.stdout.rows ?? 24,
}
: { columns: 80, rows: 24 };
}

function setRawMode(enabled: boolean): void {
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
process.stdin.setRawMode(enabled);
}
}

function writeStdout(bytes: Uint8Array): void {
process.stdout.write(Buffer.from(bytes));
}
7 changes: 6 additions & 1 deletion ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,10 @@ export function pack(
);
o += 4;

// Caret offset (code points), or 0xFFFFFFFF when absent.
view.setUint32(o, op.caret ?? 0xFFFFFFFF, true);
o += 4;

let str = encoder.encode(op.content);
o = packString(view, str, o, end, "text content");
break;
Expand Down Expand Up @@ -481,6 +485,7 @@ export interface Text {
fontId?: number;
wrap?: number;
attrs?: number;
caret?: number;
}

interface Snapshot {
Expand Down Expand Up @@ -533,7 +538,7 @@ function packSize(ops: Op[]): number {
break;
}
case OP_TEXT: {
n += 4 + 4 + 4 + 4; // opcode + color + bg + cfg
n += 4 + 4 + 4 + 4 + 4; // opcode + color + bg + cfg + caret
n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string
break;
}
Expand Down
59 changes: 57 additions & 2 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,60 @@ nests clip regions more deeply than the renderer can track:
(see §12.3) before returning, so the caller can detect that some clipping was
not applied.

### 7.6 Hardware cursor visibility and positioning

A `text()` directive MAY declare a `caret` property whose value is a
non-negative integer code-point offset into the directive's `content`. `0` means
"before the first code point"; `[...content].length` means "after the last."
Offsets in between sit immediately before the cell where the corresponding code
point would render.

The cell where a declared caret sits is the cell at which the code point at the
caret's offset would be drawn given the layout engine's text wrapping. For an
offset `N`:

- If `N < [...content].length`, the caret's cell is the display position of the
`N`-th code point (zero-indexed) within the rendered text.
- If `N == [...content].length`, the caret's cell is one display position past
the last rendered code point: on the same wrapped line if there is room, or at
the start of the next line if the layout wraps at the end.
- If `N > [...content].length` or `N < 0`, behavior is unspecified; callers must
keep offsets within bounds.

Display position accounts for code points wider than one cell (CJK, fullwidth
forms, some emoji): such code points occupy two cells, and the caret sits at the
first cell of the pair. Cell widths are determined by the same measurement the
renderer uses to lay out the text itself.

The renderer manages the terminal's hardware cursor based on the presence of
`caret:` declarations:

- When the current frame contains one or more `caret:` declarations, the
rendered `output` MUST, when written to the terminal, leave the hardware
cursor visible at the cell where the first declared caret (in directive order)
sits.

- When the current frame contains no `caret:` declarations:
- If the previous frame contained one, the rendered `output` MUST leave the
hardware cursor hidden.
- Otherwise, the rendered `output` MUST NOT include cursor-positioning or
cursor-visibility bytes; the caller's cursor state is preserved.

The byte-level path the renderer takes to satisfy these outcomes is
implementation-defined. The renderer MAY hide the cursor before cell writes to
prevent flicker, MAY omit such a hide for in-place edits where flicker is
acceptable, MAY emit only a CUP when the caret has moved within an
already-visible state, MAY use whatever escape-sequence path it judges
appropriate. Only the post-frame cursor state above is normative.

If more than one `caret:` declaration is present in a frame, the renderer SHOULD
use the first in directive order for the hardware cursor. Behavior for
additional declarations is intentionally unspecified, leaving room for a future
multi-cursor extension without breaking this contract.

This responsibility is limited to the hardware cursor's position and visibility.
Cursor shape and blink rate remain caller-managed.

---

## 8. Public Rendering API
Expand Down Expand Up @@ -604,7 +658,6 @@ The renderer MUST NOT emit escape sequences for any of the following
terminal-management operations:

- Entering or leaving the alternate screen buffer
- Hiding or showing the cursor
- Setting the cursor shape or blink state
- Enabling or disabling mouse reporting
- Enabling or disabling keyboard protocol modes (e.g., Kitty progressive
Expand All @@ -613,7 +666,9 @@ terminal-management operations:

These are the caller's responsibility. The renderer's output contains only the
escape sequences needed to render the frame content (cursor positioning for cell
writes, SGR attributes for styling, and UTF-8 text).
writes, SGR attributes for styling, and UTF-8 text) and, when a `caret`
declaration is present, the cursor-positioning and cursor-visibility sequences
specified in §7.6.

### 11.3 The renderer does not own application lifecycle

Expand Down
Loading
Loading