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
177 changes: 177 additions & 0 deletions measure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
export interface WrapTextOptions {
mode?: "words" | "newlines" | "none";
}

export interface WrappedLine {
text: string;
width: number;
}

export function measureCellWidth(text: string): number {
let width = 0;
for (let char of text) width += cellWidth(char);
return width;
}

export function wrapText(
text: string,
width: number,
options: WrapTextOptions = {},
): WrappedLine[] {
assertValidWidth(width);
if (text.length === 0) return [];

let mode = options.mode ?? "words";
switch (mode) {
case "none": {
let collapsed = text.replaceAll("\n", "");
return collapsed === "" ? [] : line(collapsed);
}
case "newlines":
return text.split("\n").flatMap((part) => part === "" ? [] : line(part));
case "words":
return wrapWords(text, width);
}
}

export function measureWrappedHeight(
text: string,
width: number,
options: WrapTextOptions = {},
): number {
assertValidWidth(width);
if (text.length === 0) return 0;

let mode = options.mode ?? "words";
switch (mode) {
case "none":
return text.replaceAll("\n", "") === "" ? 0 : 1;
case "newlines":
return countNonEmptyNewlineParts(text);
case "words":
return countWrappedWords(text, width);
}
}

function line(text: string): WrappedLine[] {
return [{ text, width: measureCellWidth(text) }];
}

function assertValidWidth(width: number): void {
if (!Number.isFinite(width) || width < 0) {
throw new RangeError(
`width must be a finite non-negative number: ${width}`,
);
}
}

function wrapWords(text: string, maxWidth: number): WrappedLine[] {
let out: WrappedLine[] = [];
for (let paragraph of text.split("\n")) {
if (paragraph === "") continue;
let current = "";
let currentWidth = 0;

for (let token of tokens(paragraph)) {
let tokenWidth = measureCellWidth(token);
if (current !== "" && currentWidth + tokenWidth > maxWidth) {
out.push({
text: current.trimEnd(),
width: measureCellWidth(current.trimEnd()),
});
current = token.trimStart();
currentWidth = measureCellWidth(current);
} else {
current += token;
currentWidth += tokenWidth;
}

if (current !== "" && currentWidth > maxWidth && token.trim() !== "") {
out.push({
text: current.trimEnd(),
width: measureCellWidth(current.trimEnd()),
});
current = "";
currentWidth = 0;
}
}

if (current !== "") {
let text = current.trimEnd();
if (text !== "") out.push({ text, width: measureCellWidth(text) });
}
}
return out;
}

function countWrappedWords(text: string, maxWidth: number): number {
let count = 0;
for (let paragraph of text.split("\n")) {
if (paragraph === "") continue;
let current = "";
let currentWidth = 0;

for (let token of tokens(paragraph)) {
let tokenWidth = measureCellWidth(token);
if (current !== "" && currentWidth + tokenWidth > maxWidth) {
let trimmed = current.trimEnd();
if (trimmed !== "") count++;
current = token.trimStart();
currentWidth = measureCellWidth(current);
} else {
current += token;
currentWidth += tokenWidth;
}

if (current !== "" && currentWidth > maxWidth && token.trim() !== "") {
count++;
current = "";
currentWidth = 0;
}
}

if (current.trimEnd() !== "") count++;
}
return count;
}

function countNonEmptyNewlineParts(text: string): number {
let count = 0;
for (let part of text.split("\n")) if (part !== "") count++;
return count;
}

function* tokens(text: string): IterableIterator<string> {
let re = /\S+\s*/g;
for (let match of text.matchAll(re)) yield match[0];
}

function cellWidth(char: string): number {
let code = char.codePointAt(0)!;
if (code === 0) return 0;
if (code < 32 || (code >= 0x7F && code < 0xA0)) return 0;
if (isCombining(code)) return 0;
return isWide(code) ? 2 : 1;
}

function isCombining(code: number): boolean {
return (code >= 0x0300 && code <= 0x036F) ||
(code >= 0x1AB0 && code <= 0x1AFF) ||
(code >= 0x1DC0 && code <= 0x1DFF) ||
(code >= 0x20D0 && code <= 0x20FF) ||
(code >= 0xFE20 && code <= 0xFE2F);
}

function isWide(code: number): boolean {
return (code >= 0x1100 && code <= 0x115F) ||
code === 0x2329 || code === 0x232A ||
(code >= 0x2E80 && code <= 0xA4CF && code !== 0x303F) ||
(code >= 0xAC00 && code <= 0xD7A3) ||
(code >= 0xF900 && code <= 0xFAFF) ||
(code >= 0xFE10 && code <= 0xFE19) ||
(code >= 0xFE30 && code <= 0xFE6F) ||
(code >= 0xFF00 && code <= 0xFF60) ||
(code >= 0xFFE0 && code <= 0xFFE6) ||
(code >= 0x1F300 && code <= 0x1FAFF) ||
(code >= 0x20000 && code <= 0x3FFFD);
}
1 change: 1 addition & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from "./term.ts";
export * from "./input.ts";
export * from "./settings.ts";
export * from "./termcodes.ts";
export * from "./measure.ts";
24 changes: 20 additions & 4 deletions ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,27 @@ function packAxis(view: DataView, offset: number, axis: SizingAxis): number {
return o;
}

function packString(view: DataView, bytes: Uint8Array, o: number): number {
function packString(
view: DataView,
bytes: Uint8Array,
o: number,
end: number,
context: string,
): number {
let paddedLength = Math.ceil(bytes.length / 4) * 4;
let next = o + 4 + paddedLength;
if (next > end) {
throw new RangeError(
`clayterm transfer buffer capacity exceeded while packing ${context} ` +
`(${next} byte offset, ${end} byte limit). ` +
`Render a smaller visible slice or reduce frame content.`,
);
}

view.setUint32(o, bytes.length, true);
o += 4;
new Uint8Array(view.buffer).set(bytes, o);
o += Math.ceil(bytes.length / 4) * 4;
o += paddedLength;
return o;
}

Expand All @@ -82,7 +98,7 @@ export function pack(
o += 4;

let bytes = encoder.encode(op.id);
o = packString(view, bytes, o);
o = packString(view, bytes, o, end, "element id");

let mask = 0;
if (op.layout) mask |= PROP_LAYOUT;
Expand Down Expand Up @@ -192,7 +208,7 @@ export function pack(
o += 4;

let str = encoder.encode(op.content);
o = packString(view, str, o);
o = packString(view, str, o, end, "text content");
break;
}
}
Expand Down
65 changes: 65 additions & 0 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,71 @@ and used in tests.
array into the transfer encoding described in Section 12.1. Currently exported
but not public API; its exposure is incidental to the module structure.

### 12.6 Text measurement helpers

The module may also expose pure TypeScript text-measurement helpers for callers
that need pre-layout estimates without instantiating a `Term`:

```ts
interface WrapTextOptions {
mode?: "words" | "newlines" | "none";
}

interface WrappedLine {
text: string;
width: number;
}

measureCellWidth(text: string): number;
wrapText(
text: string,
width: number,
options?: WrapTextOptions,
): WrappedLine[];
measureWrappedHeight(
text: string,
width: number,
options?: WrapTextOptions,
): number;
```

The current intended behavior is:

- `measureCellWidth()` returns the terminal cell width of the full string using
the same Unicode-width model described in Section 13.
- `wrapText()` returns line records with both the emitted text and its measured
width.
- `measureWrappedHeight()` returns the number of wrapped lines that `wrapText()`
would produce for the same inputs.
- `mode: "words"` wraps on token boundaries while preserving explicit newline
breaks.
- `mode: "newlines"` splits only on explicit `\n` characters and does not
perform width-based wrapping.
- `mode: "none"` collapses explicit newlines and returns at most one line.
- The helpers operate on JavaScript strings directly. They do not require the
caller's text to be copied into WASM linear memory or encoded into a full
UTF-8 byte buffer as a precondition for measurement.
- Large-input behavior is bounded by host JavaScript memory, not by Clayterm's
WASM linear-memory capacity. Inputs materially larger than the renderer's
initial WASM memory footprint are intended to remain valid helper inputs.
- `measureCellWidth()` and `measureWrappedHeight()` are intended to process
large inputs in a single pass over the string without allocating auxiliary
storage proportional to the UTF-8 byte length of the entire input.
`wrapText()` necessarily allocates output proportional to the number of
wrapped lines it returns, but it likewise should not require a second
full-input UTF-8 buffer.
- Rendering oversized whole-document input remains constrained by the renderer's
transfer buffer. If a frame exceeds transfer-buffer capacity while packing
text, Clayterm MUST throw a descriptive `RangeError` identifying the capacity
failure and SHOULD direct callers to render a smaller visible slice or reduce
frame content. Clayterm MUST NOT expose only the raw host-level TypedArray
message `"offset is out of bounds"` for this condition.

These helpers are independent of the renderer's frame lifecycle and perform no
IO or WASM interaction. They exist as convenience APIs for higher-level
frameworks and virtualized views that need width and height estimation before
building directive arrays.

---

## 13. Implementation Notes
Expand Down
Loading
Loading