Skip to content
Open
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
155 changes: 155 additions & 0 deletions measure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
const COMBINING_MARK_RE = /\p{Mark}/u;
const EXTENDED_PICTOGRAPHIC_RE = /\p{Extended_Pictographic}/u;

export type WrapTextMode = "words" | "newlines" | "none";

export interface WrapTextOptions {
mode?: WrapTextMode;
}

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

export interface TextMeasureApi {
measureCellWidth(text: string): number;
wrapText(text: string, width: number, options?: WrapTextOptions): WrappedLine[];
measureWrappedHeight(text: string, width: number, options?: WrapTextOptions): number;
}

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

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

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

const mode = options.mode ?? "words";

switch (mode) {
case "newlines":
return text.split("\n").map((line) => ({ text: line, width: measureCellWidth(line) }));
case "none": {
const collapsed = text.replace(/\n/g, "");
return collapsed.length === 0
? []
: [{ text: collapsed, width: measureCellWidth(collapsed) }];
}
case "words":
return wrapWords(text, width);
default:
return wrapWords(text, width);
}
}

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

function wrapWords(text: string, width: number): WrappedLine[] {
const paragraphs = text.split("\n");
const lines: WrappedLine[] = [];

for (const paragraph of paragraphs) {
if (paragraph.length === 0) {
lines.push({ text: "", width: 0 });
continue;
}

const tokens = paragraph.match(/\S+|\s+/g) ?? [paragraph];
let currentText = "";
let currentWidth = 0;

for (const token of tokens) {
const tokenWidth = measureCellWidth(token);
if (currentText.length === 0) {
currentText = token;
currentWidth = tokenWidth;
continue;
}

if (currentWidth + tokenWidth <= width) {
currentText += token;
currentWidth += tokenWidth;
continue;
}

lines.push({ text: currentText, width: currentWidth });
currentText = token;
currentWidth = tokenWidth;
}

if (currentText.length > 0) {
lines.push({ text: currentText, width: currentWidth });
}
}

return lines;
}

function codePointWidth(symbol: string): number {
const codePoint = symbol.codePointAt(0);
if (codePoint === undefined) return 0;

if (isControl(codePoint)) return 0;
if (isZeroWidth(codePoint, symbol)) return 0;
if (isWide(codePoint, symbol)) return 2;
return 1;
}

function isControl(codePoint: number): boolean {
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint < 0xa0);
}

function isZeroWidth(codePoint: number, symbol: string): boolean {
return (
codePoint === 0x200d ||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
(codePoint >= 0xe0100 && codePoint <= 0xe01ef) ||
(codePoint >= 0x1f3fb && codePoint <= 0x1f3ff) ||
COMBINING_MARK_RE.test(symbol)
);
}

function isWide(codePoint: number, symbol: string): boolean {
return (
EXTENDED_PICTOGRAPHIC_RE.test(symbol) ||
codePoint >= 0x1100 && (
codePoint <= 0x115f ||
codePoint === 0x2329 ||
codePoint === 0x232a ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
(codePoint >= 0x1f300 && codePoint <= 0x1f64f) ||
(codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
(codePoint >= 0x20000 && codePoint <= 0x3fffd)
)
);
}
1 change: 1 addition & 0 deletions mod.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./ops.ts";
export * from "./term.ts";
export * from "./input.ts";
export * from "./measure.ts";
153 changes: 149 additions & 4 deletions ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const PROP_CORNER_RADIUS = 0x04;
const PROP_BORDER = 0x08;
const PROP_CLIP = 0x10;
const PROP_FLOATING = 0x20;
const PROP_TRANSITION = 0x40;

/* ── Packing ──────────────────────────────────────────────────────── */

Expand Down Expand Up @@ -93,6 +94,7 @@ export function pack(
if (op.border) mask |= PROP_BORDER;
if (op.clip) mask |= PROP_CLIP;
if (op.floating) mask |= PROP_FLOATING;
if (op.transition) mask |= PROP_TRANSITION;
view.setUint32(o, mask, true);
o += 4;

Expand Down Expand Up @@ -157,6 +159,10 @@ export function pack(
true,
);
o += 4;
view.setFloat32(o, op.clip.childOffset?.x ?? 0, true);
o += 4;
view.setFloat32(o, op.clip.childOffset?.y ?? 0, true);
o += 4;
}

if (op.floating) {
Expand All @@ -165,12 +171,49 @@ export function pack(
o += 4;
view.setFloat32(o, f.y ?? 0, true);
o += 4;
view.setFloat32(o, f.expand?.width ?? 0, true);
o += 4;
view.setFloat32(o, f.expand?.height ?? 0, true);
o += 4;
view.setUint32(o, f.parent ?? 0, true);
o += 4;
view.setUint32(
o,
(f.attachTo ?? 0) | ((f.attachPoints ?? 0) << 8) |
((f.zIndex ?? 0) << 16),
(f.attachTo ?? 0) |
((f.attachPoints?.element ?? 0) << 8) |
((f.attachPoints?.parent ?? 0) << 16) |
((f.pointerCaptureMode ?? 0) << 24),
true,
);
o += 4;
view.setUint32(
o,
(f.clipTo ?? 0) | (((f.zIndex ?? 0) & 0xffff) << 8),
true,
);
o += 4;
}

if (op.transition) {
let t = op.transition;
view.setFloat32(o, t.duration ?? 0.25, true);
o += 4;
view.setUint32(o, t.properties ?? 0, true);
o += 4;
view.setUint32(
o,
(t.handler ?? 0) |
((t.interactionHandling ?? 0) << 8) |
((t.enter?.preset ?? 0) << 16) |
((t.enter?.trigger ?? 0) << 24),
true,
);
o += 4;
view.setUint32(
o,
(t.exit?.preset ?? 0) |
((t.exit?.trigger ?? 0) << 8) |
((t.exit?.siblingOrdering ?? 0) << 16),
true,
);
o += 4;
Expand Down Expand Up @@ -268,17 +311,119 @@ export interface OpenElement {
top?: number;
bottom?: number;
};
clip?: { horizontal?: boolean; vertical?: boolean };
clip?: {
horizontal?: boolean;
vertical?: boolean;
childOffset?: { x?: number; y?: number };
};
floating?: {
x?: number;
y?: number;
expand?: { width?: number; height?: number };
parent?: number;
attachTo?: number;
attachPoints?: number;
attachPoints?: { element?: number; parent?: number };
pointerCaptureMode?: number;
clipTo?: number;
zIndex?: number;
};
transition?: {
duration?: number;
properties?: number;
handler?: number;
interactionHandling?: number;
enter?: {
preset?: number;
trigger?: number;
};
exit?: {
preset?: number;
trigger?: number;
siblingOrdering?: number;
};
};
}

export const ATTACH_POINT = {
LEFT_TOP: 0,
LEFT_CENTER: 1,
LEFT_BOTTOM: 2,
CENTER_TOP: 3,
CENTER_CENTER: 4,
CENTER_BOTTOM: 5,
RIGHT_TOP: 6,
RIGHT_CENTER: 7,
RIGHT_BOTTOM: 8,
} as const;

export const ATTACH_TO = {
NONE: 0,
PARENT: 1,
ELEMENT_WITH_ID: 2,
ROOT: 3,
} as const;

export const POINTER_CAPTURE_MODE = {
CAPTURE: 0,
PASSTHROUGH: 1,
} as const;

export const CLIP_TO = {
NONE: 0,
ATTACHED_PARENT: 1,
} as const;

export const TRANSITION_HANDLER = {
NONE: 0,
EASE_OUT: 1,
} as const;

export const TRANSITION_PROPERTY = {
NONE: 0,
X: 1,
Y: 2,
POSITION: 3,
WIDTH: 4,
HEIGHT: 8,
DIMENSIONS: 12,
BOUNDING_BOX: 15,
BACKGROUND_COLOR: 16,
OVERLAY_COLOR: 32,
CORNER_RADIUS: 64,
BORDER_COLOR: 128,
BORDER_WIDTH: 256,
BORDER: 384,
} as const;

export const TRANSITION_INTERACTION_HANDLING = {
DISABLE_WHILE_POSITIONING: 0,
ALLOW_WHILE_POSITIONING: 1,
} as const;

export const TRANSITION_ENTER_TRIGGER = {
SKIP_ON_FIRST_PARENT_FRAME: 0,
TRIGGER_ON_FIRST_PARENT_FRAME: 1,
} as const;

export const TRANSITION_EXIT_TRIGGER = {
SKIP_WHEN_PARENT_EXITS: 0,
TRIGGER_WHEN_PARENT_EXITS: 1,
} as const;

export const EXIT_TRANSITION_SIBLING_ORDERING = {
UNDERNEATH_SIBLINGS: 0,
NATURAL_ORDER: 1,
ABOVE_SIBLINGS: 2,
} as const;

export const TRANSITION_PRESET = {
NONE: 0,
ENTER_FROM_LEFT: 1,
ENTER_FROM_RIGHT: 2,
EXIT_TO_LEFT: 3,
EXIT_TO_RIGHT: 4,
} as const;

export interface Text {
id: typeof OP_TEXT;
content: string;
Expand Down
Loading