Skip to content
Merged
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
30 changes: 29 additions & 1 deletion packages/hqtui/src/richtext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/
import { Attr, type Style } from "./buffer.ts";
import type { Color } from "./color.ts";
import { cellText, graphemes, stringWidth } from "./unicode.ts";
import { cellText, dropColumns, graphemes, stringWidth } from "./unicode.ts";

export interface Span {
text: string;
Expand Down Expand Up @@ -148,6 +148,34 @@ export function truncateSpans(line: SpanLine, max: number, ellipsis = "…"): Sp
}

/** Pad or truncate a line to exactly `width` columns. */
/**
* A span line with its first `columns` display columns removed.
*
* The string form of this is `dropColumns`; a styled line has to drop the same
* columns without losing the styles of the runs that survive, so it walks the
* spans and cuts inside whichever one straddles the offset.
*/
export function dropSpanColumns(line: SpanLine, columns: number): SpanLine {
if (columns <= 0) return line;
const out: Span[] = [];
let skipped = 0;
for (const span of line) {
const width = stringWidth(span.text);
if (skipped >= columns) {
out.push(span);
continue;
}
if (skipped + width <= columns) {
skipped += width;
continue;
}
// The cut lands inside this span: keep its style, drop its first columns.
out.push({ ...span, text: dropColumns(span.text, columns - skipped) });
skipped = columns;
}
return out;
}

export function fitSpans(
line: SpanLine,
width: number,
Expand Down
15 changes: 15 additions & 0 deletions packages/hqtui/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,21 @@ export class Container {
return this.add((s) => W.drawChart(s, options), this.sizeOfData(options, "fill", "min-max"));
}

/**
* Reset a region so an overlay can own it.
*
* Anything drawn into a region without clearing it first shows whatever was
* underneath through the cells it does not touch.
*/
clear(options: W.ClearOptions & ContainerOptions = {}): this {
return this.add((s) => W.drawClear(s, options), this.sizeOf(options, "fill"));
}

/** Flood a region with one repeated symbol and style. */
fill(options: W.FillOptions & ContainerOptions = {}): this {
return this.add((s) => W.drawFill(s, options), this.sizeOf(options, "fill"));
}

/** A filled area graph — `graph` with `fill` on. */
areaGraph(options: W.GraphOptions & ContainerOptions): this {
return this.graph({ fill: true, ...options });
Expand Down
24 changes: 24 additions & 0 deletions packages/hqtui/src/unicode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,30 @@ export function truncate(text: string, max: number, ellipsis = "…"): string {
return out + ellipsis;
}

/**
* `text` with its first `columns` display columns removed.
*
* For scrolling a line sideways. Slicing by code units would cut inside a
* grapheme and corrupt it, and a scroll that lands in the middle of a wide
* character cannot draw half of it -- what is left of that character is a
* space, which is what a terminal shows when a double-width cell is clipped.
*/
export function dropColumns(text: string, columns: number): string {
if (columns <= 0) return text;
let out = "";
let skipped = 0;
for (const g of graphemes(text)) {
if (skipped >= columns) {
out += cellText(g.value);
continue;
}
skipped += g.width;
// A wide character straddling the cut leaves its trailing half behind.
if (skipped > columns) out += " ".repeat(skipped - columns);
}
return out;
}

/** Pad or truncate to exactly `width` columns. */
export function fit(text: string, width: number, align: "left" | "right" | "center" = "left"): string {
const t = truncate(text, width);
Expand Down
1 change: 1 addition & 0 deletions packages/hqtui/src/widgets/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./text.ts";
export * from "./chart.ts";
export * from "./surface.ts";
export * from "./scrollbar.ts";
export * from "./table.ts";
export * from "./meters.ts";
Expand Down
60 changes: 60 additions & 0 deletions packages/hqtui/src/widgets/surface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Two primitives for the space behind a widget rather than the widget itself.
*
* `modal` already blanks the region it is about to draw into, but it does it
* privately, so anything else that floats -- a custom overlay, a popover, a
* tooltip somebody wrote themselves -- has no way to say "this region is mine
* now". These make that sayable.
*/
import type { Surface } from "../surface.ts";
import type { Style } from "../buffer.ts";
import type { Color } from "../color.ts";
import { stringWidth } from "../unicode.ts";

export interface ClearOptions {
/** What to leave behind. Defaults to the theme's background. */
background?: Color;
}

/**
* Reset a region to empty, so an overlay can draw over what was there.
*
* Without this an overlay is drawn *into* whatever it lands on: the cells it
* does not touch keep the widget underneath, and a dialog ends up with someone
* else's table showing through the gaps between its words.
*/
export function drawClear(surface: Surface, options: ClearOptions = {}): void {
if (surface.empty) return;
const theme = surface.theme;
surface.fill({ bg: options.background ?? theme.background, fg: theme.foreground, attrs: 0 });
}

export interface FillOptions extends Style {
/**
* The symbol to repeat. One cell's worth: anything wider is cut to its first
* grapheme, because a fill has to tile the region exactly.
*/
symbol?: string;
}

/** Flood a region with one repeated symbol and style. */
export function drawFill(surface: Surface, options: FillOptions = {}): void {
if (surface.empty) return;
const symbol = options.symbol ?? " ";
const style: Style = { fg: options.fg, bg: options.bg, attrs: options.attrs };
const width = Math.max(1, stringWidth(symbol));
// A one-cell symbol is what `fill` is for. Anything wider has to be stepped
// over rather than written per column: each glyph owns a continuation cell,
// and writing the next one on top of it leaves a row of half-characters.
if (width === 1 && [...symbol].length === 1) {
surface.fill(style, symbol.codePointAt(0) ?? 32);
return;
}
for (let y = 0; y < surface.height; y++) {
// The last glyph is dropped rather than clipped when the region does not
// divide evenly: half a wide character is not a fill, it is damage.
for (let x = 0; x + width <= surface.width; x += width) {
surface.char(x, y, symbol, style);
}
}
}
35 changes: 29 additions & 6 deletions packages/hqtui/src/widgets/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import type { Style } from "../buffer.ts";
import { Attr } from "../buffer.ts";
import type { Color } from "../color.ts";
import { mix } from "../color.ts";
import { fit, stringWidth, truncate, wrap } from "../unicode.ts";
import { fitSpans, isRich, toSpanLines, wrapRich, type RichText, type SpanLine } from "../richtext.ts";
import { dropColumns, fit, stringWidth, truncate, wrap } from "../unicode.ts";
import {
dropSpanColumns, fitSpans, isRich, toSpanLines, wrapRich, type RichText, type SpanLine,
} from "../richtext.ts";
import { elevate } from "../theme.ts";

export interface TextOptions extends Style {
Expand All @@ -14,6 +16,16 @@ export interface TextOptions extends Style {
dim?: boolean;
italic?: boolean;
underline?: boolean;
/**
* First line to show, counted after wrapping.
*
* After wrapping is the only place this can be correct: the caller does not
* know how many lines their text became, and pre-slicing the string means
* re-deciding every time the width changes.
*/
scroll?: number;
/** Columns to shift the text left by, for lines wider than the surface. */
scrollX?: number;
}

function attrsOf(o: TextOptions): number {
Expand All @@ -36,18 +48,29 @@ export function drawText(surface: Surface, content: RichText, options: TextOptio
// the span code. The two agree — richtext.test.ts holds them to the same
// columns over a corpus — but "agree" and "emit identical cells" are not the
// same claim, and every committed fixture depends on the second one.
// Both offsets are clamped to zero: a negative scroll would otherwise read
// as "start before the beginning" and silently drop the first rows.
const scroll = Math.max(0, Math.floor(options.scroll ?? 0));
const scrollX = Math.max(0, Math.floor(options.scrollX ?? 0));

if (!isRich(content)) {
const lines = options.wrap ? wrap(content, surface.width) : content.split("\n");
const wrapped = options.wrap ? wrap(content, surface.width) : content.split("\n");
const lines = scroll > 0 ? wrapped.slice(scroll) : wrapped;
for (let i = 0; i < lines.length && i < surface.height; i++) {
surface.text(0, i, fit(truncate(lines[i], surface.width), surface.width, options.align ?? "left"), style);
const line = scrollX > 0 ? dropColumns(lines[i], scrollX) : lines[i];
surface.text(0, i, fit(truncate(line, surface.width), surface.width, options.align ?? "left"), style);
}
return;
}
const lines = options.wrap
const wrapped = options.wrap
? wrapRich(content, surface.width)
: toSpanLines(content);
const lines = scroll > 0 ? wrapped.slice(scroll) : wrapped;
for (let i = 0; i < lines.length && i < surface.height; i++) {
surface.spans(0, i, fitSpans(lines[i] as SpanLine, surface.width, options.align ?? "left"), style);
const line = scrollX > 0
? dropSpanColumns(lines[i] as SpanLine, scrollX)
: (lines[i] as SpanLine);
surface.spans(0, i, fitSpans(line, surface.width, options.align ?? "left"), style);
}
}

Expand Down
109 changes: 109 additions & 0 deletions packages/hqtui/test/paragraph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { renderToScreen } from "../src/index.ts";
import { dropColumns } from "../src/unicode.ts";
import { dropSpanColumns } from "../src/richtext.ts";
import { drawClear } from "../src/widgets/surface.ts";

const lines = (view: Parameters<typeof renderToScreen>[0], width = 20, height = 4): string[] =>
renderToScreen(view, { width, height }).text().split("\n").map((l) => l.trimEnd());

const PROSE = "one two three four five six seven eight nine ten eleven twelve";

test("paragraph: scroll counts wrapped lines, not source lines", () => {
// The whole point: the caller does not know how many lines their text became,
// so an offset that meant "source lines" would be unusable on wrapped text.
const top = lines(({ ui }) => ui.text(PROSE, { wrap: true }));
const down = lines(({ ui }) => ui.text(PROSE, { wrap: true, scroll: 1 }));
assert.notDeepEqual(top, down);
assert.deepEqual(down.slice(0, 3), top.slice(1, 4));
});

test("paragraph: scrolling past the end leaves the surface blank, not broken", () => {
const past = lines(({ ui }) => ui.text(PROSE, { wrap: true, scroll: 999 }));
assert.deepEqual(past, ["", "", "", ""]);
});

test("paragraph: a negative scroll is not a scroll backwards past the start", () => {
const top = lines(({ ui }) => ui.text(PROSE, { wrap: true }));
const negative = lines(({ ui }) => ui.text(PROSE, { wrap: true, scroll: -3 }));
assert.deepEqual(negative, top);
});

test("paragraph: horizontal scroll shifts a line that is wider than the surface", () => {
const at0 = lines(({ ui }) => ui.text("abcdefghijklmnopqrstuvwxyz"), 10, 1);
const at5 = lines(({ ui }) => ui.text("abcdefghijklmnopqrstuvwxyz", { scrollX: 5 }), 10, 1);
assert.equal(at0[0], "abcdefghi…");
assert.equal(at5[0], "fghijklmn…");
});

test("paragraph: horizontal scroll works on styled text too", () => {
const at3 = renderToScreen(
({ ui, theme }) => ui.text([{ text: "abc", fg: theme.primary }, { text: "defgh" }], { scrollX: 3 }),
{ width: 8, height: 1 },
).text().trimEnd();
assert.equal(at3, "defgh");
});

test("dropColumns: a wide character cut in half leaves a space, not half a glyph", () => {
// Two columns each. Cutting between them cannot draw half of one.
assert.equal(dropColumns("日本語", 2), "本語");
assert.equal(dropColumns("日本語", 1), " 本語");
assert.equal(dropColumns("日本語", 0), "日本語");
assert.equal(dropColumns("日本語", 99), "");
});

test("dropColumns: never slices inside a grapheme", () => {
// A family emoji is one cell made of several codepoints; a code-unit slice
// would leave fragments of it behind.
const family = "👨‍👩‍👧";
assert.equal(dropColumns(`${family}ab`, 2), "ab");
});

test("dropSpanColumns: the runs that survive keep their styles", () => {
const line = [{ text: "red", fg: 0xff0000 }, { text: "blue", fg: 0x0000ff }];
assert.deepEqual(dropSpanColumns(line, 3), [{ text: "blue", fg: 0x0000ff }]);
// A cut inside a run keeps that run's style for what is left of it.
assert.deepEqual(dropSpanColumns(line, 1), [
{ text: "ed", fg: 0xff0000 },
{ text: "blue", fg: 0x0000ff },
]);
assert.deepEqual(dropSpanColumns(line, 0), line);
});

test("clear: an overlay stops showing what was underneath", () => {
const before = lines(({ ui }) => {
ui.text("aaaaaaaaaaaaaaaaaaaa");
ui.text("bbbbbbbbbbbbbbbbbbbb");
});
assert.equal(before[0], "aaaaaaaaaaaaaaaaaaaa");

const after = renderToScreen(({ ui }) => {
ui.text("aaaaaaaaaaaaaaaaaaaa");
ui.text("bbbbbbbbbbbbbbbbbbbb");
// An overlay lands on top of a finished frame, which is exactly the case
// that needs a region reset before it draws.
ui.ctx.overlay((root) => drawClear(root.sub(2, 0, 6, 1)));
}, { width: 20, height: 4 }).text().split("\n");
assert.equal(after[0], "aa aaaaaaaaaaaa");
});

test("fill: a region floods with the symbol it is given", () => {
const out = lines(({ ui }) => ui.fill({ symbol: "·" }), 6, 2);
assert.deepEqual(out, ["······", "······"]);
});

test("fill: a wide symbol tiles without leaving its other half behind", () => {
// A double-width glyph occupies two cells; the second is its continuation,
// so the fill steps over both rather than writing one glyph per column.
const out = lines(({ ui }) => ui.fill({ symbol: "日" }), 4, 1);
assert.equal(out[0], "日日");
// An odd width cannot fit a third glyph, and half of one is damage.
const odd = lines(({ ui }) => ui.fill({ symbol: "日" }), 5, 1);
assert.equal(odd[0], "日日");
});

test("fill: an empty region is not an error", () => {
const out = lines(({ ui }) => ui.fill({ symbol: "x", height: 0 }), 6, 2);
assert.deepEqual(out, ["", ""]);
});
Loading