From d42dffe5e2616ec0012bf17d5c3df8283ab8524f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 8 Sep 2026 23:16:31 +0000 Subject: [PATCH] Paragraph scroll, Clear and Fill Three of the smaller gaps in #64, taken together because they are all about the surface a widget sits on rather than the widget. `drawText` wrapped and then drew from the first line. Long wrapped text could be displayed but not scrolled: you had to pre-slice the string, which is wrong the moment the width changes and the line count changes with it. `scroll` and `scrollX` are applied after wrapping, which is the only place either can be correct -- the caller does not know how many lines their text became. `clear` resets a region so an overlay can own it. `modal` already did this privately; without it in the open, anything else that floats is drawn *into* whatever it lands on and shows the widget underneath through the gaps between its words. `fill` floods a region with one repeated symbol. Two details that needed deciding rather than assuming. A horizontal scroll can land in the middle of a double-width character. It cannot draw half of one, so what is left of that character is a space -- which is what a terminal shows when a wide cell is clipped. That is `dropColumns`, new in every port's unicode layer, and it walks graphemes because a byte slice would cut inside one and corrupt it. Styled text needed the same thing without losing the styles of the runs that survive, so `dropSpanColumns` cuts inside whichever run straddles the offset and keeps its style for the remainder. A fill with a wide symbol steps over both cells rather than writing one glyph per column: each glyph owns a continuation cell, and writing the next on top of it leaves a row of half-characters. Where the region does not divide evenly the last glyph is dropped rather than clipped, because half a wide character is not a fill, it is damage. Six ports and seven new fixtures, all additive -- nothing existing moved. Verified: 310 TS tests under bun and node; 76 widget scenes matching the reference in Rust, Go, Python, Zig and C++; 11/11 ctest; every gallery; the site builds. Part of #64 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy --- packages/hqtui/src/richtext.ts | 30 +- packages/hqtui/src/ui.ts | 15 + packages/hqtui/src/unicode.ts | 24 + packages/hqtui/src/widgets/index.ts | 1 + packages/hqtui/src/widgets/surface.ts | 60 ++ packages/hqtui/src/widgets/text.ts | 35 +- packages/hqtui/test/paragraph.test.ts | 109 ++++ ports/conformance/fixtures/widgets.json | 517 ++++++++++++++++++ ports/conformance/generate.ts | Bin 45914 -> 46957 bytes ports/cpp/include/hqtui/widgets.hpp | 68 +++ ports/cpp/src/widgets.cpp | 57 +- ports/cpp/tests/conformance_widgets.cpp | 47 ++ ports/go/conformance_widgets_test.go | 18 + ports/go/ui.go | 13 + ports/go/unicode.go | 26 + ports/go/widgets_surface.go | 72 +++ ports/go/widgets_text.go | 20 + ports/python/hqtui/ui.py | 20 + ports/python/hqtui/unicode.py | 23 + ports/python/hqtui/widgets/__init__.py | 3 +- ports/python/hqtui/widgets/surface.py | 69 +++ ports/python/hqtui/widgets/text.py | 14 +- .../python/tests/test_conformance_widgets.py | 19 + ports/rust/src/ui.rs | 15 + ports/rust/src/unicode.rs | 26 + ports/rust/src/widgets/mod.rs | 2 + ports/rust/src/widgets/surface.rs | 76 +++ ports/rust/src/widgets/text.rs | 20 +- ports/rust/tests/conformance_widgets.rs | 13 + ports/zig/src/conformance_widgets.zig | 18 + ports/zig/src/ui.zig | 17 + ports/zig/src/unicode.zig | 36 ++ ports/zig/src/widgets.zig | 5 + ports/zig/src/widgets/surface.zig | 77 +++ ports/zig/src/widgets/text.zig | 30 +- 35 files changed, 1579 insertions(+), 16 deletions(-) create mode 100644 packages/hqtui/src/widgets/surface.ts create mode 100644 packages/hqtui/test/paragraph.test.ts create mode 100644 ports/go/widgets_surface.go create mode 100644 ports/python/hqtui/widgets/surface.py create mode 100644 ports/rust/src/widgets/surface.rs create mode 100644 ports/zig/src/widgets/surface.zig diff --git a/packages/hqtui/src/richtext.ts b/packages/hqtui/src/richtext.ts index d5ac28e..eaf41bf 100644 --- a/packages/hqtui/src/richtext.ts +++ b/packages/hqtui/src/richtext.ts @@ -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; @@ -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, diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts index 1d22818..d7adf1b 100644 --- a/packages/hqtui/src/ui.ts +++ b/packages/hqtui/src/ui.ts @@ -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 }); diff --git a/packages/hqtui/src/unicode.ts b/packages/hqtui/src/unicode.ts index b87d90e..dfe90ca 100644 --- a/packages/hqtui/src/unicode.ts +++ b/packages/hqtui/src/unicode.ts @@ -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); diff --git a/packages/hqtui/src/widgets/index.ts b/packages/hqtui/src/widgets/index.ts index 697df72..cd51fc7 100644 --- a/packages/hqtui/src/widgets/index.ts +++ b/packages/hqtui/src/widgets/index.ts @@ -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"; diff --git a/packages/hqtui/src/widgets/surface.ts b/packages/hqtui/src/widgets/surface.ts new file mode 100644 index 0000000..a6909b7 --- /dev/null +++ b/packages/hqtui/src/widgets/surface.ts @@ -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); + } + } +} diff --git a/packages/hqtui/src/widgets/text.ts b/packages/hqtui/src/widgets/text.ts index 180d74b..e709f74 100644 --- a/packages/hqtui/src/widgets/text.ts +++ b/packages/hqtui/src/widgets/text.ts @@ -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 { @@ -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 { @@ -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); } } diff --git a/packages/hqtui/test/paragraph.test.ts b/packages/hqtui/test/paragraph.test.ts new file mode 100644 index 0000000..f43d62a --- /dev/null +++ b/packages/hqtui/test/paragraph.test.ts @@ -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[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, ["", ""]); +}); diff --git a/ports/conformance/fixtures/widgets.json b/ports/conformance/fixtures/widgets.json index 832063f..cc7573a 100644 --- a/ports/conformance/fixtures/widgets.json +++ b/ports/conformance/fixtures/widgets.json @@ -333,6 +333,523 @@ ] } }, + { + "name": "text-scrolled", + "width": 20, + "height": 3, + "result": { + "width": 20, + "height": 3, + "chars": [ + [ + 1, + 110 + ], + [ + 1, + 105 + ], + [ + 1, + 110 + ], + [ + 1, + 101 + ], + [ + 1, + 32 + ], + [ + 1, + 116 + ], + [ + 1, + 101 + ], + [ + 1, + 110 + ], + [ + 1, + 32 + ], + [ + 1, + 101 + ], + [ + 1, + 108 + ], + [ + 1, + 101 + ], + [ + 1, + 118 + ], + [ + 1, + 101 + ], + [ + 1, + 110 + ], + [ + 5, + 32 + ], + [ + 1, + 116 + ], + [ + 1, + 119 + ], + [ + 1, + 101 + ], + [ + 1, + 108 + ], + [ + 1, + 118 + ], + [ + 1, + 101 + ], + [ + 34, + 32 + ] + ], + "fg": [ + [ + 60, + 29806811 + ] + ], + "bg": [ + [ + 60, + 17106698 + ] + ], + "attrs": [ + [ + 60, + 0 + ] + ], + "clusters": [], + "text": [ + "nine ten eleven ", + "twelve ", + " " + ] + } + }, + { + "name": "text-scrolled-past", + "width": 20, + "height": 3, + "result": { + "width": 20, + "height": 3, + "chars": [ + [ + 60, + 32 + ] + ], + "fg": [ + [ + 60, + 29806811 + ] + ], + "bg": [ + [ + 60, + 17106698 + ] + ], + "attrs": [ + [ + 60, + 0 + ] + ], + "clusters": [], + "text": [ + " ", + " ", + " " + ] + } + }, + { + "name": "text-scrolled-x", + "width": 14, + "height": 2, + "result": { + "width": 14, + "height": 2, + "chars": [ + [ + 1, + 103 + ], + [ + 1, + 104 + ], + [ + 1, + 105 + ], + [ + 1, + 106 + ], + [ + 1, + 107 + ], + [ + 1, + 108 + ], + [ + 1, + 109 + ], + [ + 1, + 110 + ], + [ + 1, + 111 + ], + [ + 1, + 112 + ], + [ + 1, + 113 + ], + [ + 1, + 114 + ], + [ + 1, + 115 + ], + [ + 1, + 8230 + ], + [ + 14, + 32 + ] + ], + "fg": [ + [ + 28, + 29806811 + ] + ], + "bg": [ + [ + 28, + 17106698 + ] + ], + "attrs": [ + [ + 28, + 0 + ] + ], + "clusters": [], + "text": [ + "ghijklmnopqrs…", + " " + ] + } + }, + { + "name": "text-scrolled-wide", + "width": 10, + "height": 1, + "result": { + "width": 10, + "height": 1, + "chars": [ + [ + 1, + 32 + ], + [ + 1, + 35486 + ], + [ + 1, + 4294967295 + ], + [ + 1, + 12391 + ], + [ + 1, + 4294967295 + ], + [ + 1, + 12377 + ], + [ + 1, + 4294967295 + ], + [ + 3, + 32 + ] + ], + "fg": [ + [ + 10, + 29806811 + ] + ], + "bg": [ + [ + 10, + 17106698 + ] + ], + "attrs": [ + [ + 10, + 0 + ] + ], + "clusters": [], + "text": [ + " 語です " + ] + } + }, + { + "name": "clear", + "width": 16, + "height": 3, + "result": { + "width": 16, + "height": 3, + "chars": [ + [ + 20, + 120 + ], + [ + 8, + 32 + ], + [ + 20, + 120 + ] + ], + "fg": [ + [ + 48, + 29806811 + ] + ], + "bg": [ + [ + 48, + 17106698 + ] + ], + "attrs": [ + [ + 48, + 0 + ] + ], + "clusters": [], + "text": [ + "xxxxxxxxxxxxxxxx", + "xxxx xxxx", + "xxxxxxxxxxxxxxxx" + ] + } + }, + { + "name": "fill", + "width": 12, + "height": 3, + "result": { + "width": 12, + "height": 3, + "chars": [ + [ + 36, + 183 + ] + ], + "fg": [ + [ + 36, + 29806811 + ] + ], + "bg": [ + [ + 36, + 17106698 + ] + ], + "attrs": [ + [ + 36, + 0 + ] + ], + "clusters": [], + "text": [ + "············", + "············", + "············" + ] + } + }, + { + "name": "fill-wide", + "width": 9, + "height": 2, + "result": { + "width": 9, + "height": 2, + "chars": [ + [ + 1, + 26085 + ], + [ + 1, + 4294967295 + ], + [ + 1, + 26085 + ], + [ + 1, + 4294967295 + ], + [ + 1, + 26085 + ], + [ + 1, + 4294967295 + ], + [ + 1, + 26085 + ], + [ + 1, + 4294967295 + ], + [ + 1, + 32 + ], + [ + 1, + 26085 + ], + [ + 1, + 4294967295 + ], + [ + 1, + 26085 + ], + [ + 1, + 4294967295 + ], + [ + 1, + 26085 + ], + [ + 1, + 4294967295 + ], + [ + 1, + 26085 + ], + [ + 1, + 4294967295 + ], + [ + 1, + 32 + ] + ], + "fg": [ + [ + 18, + 29806811 + ] + ], + "bg": [ + [ + 18, + 17106698 + ] + ], + "attrs": [ + [ + 18, + 0 + ] + ], + "clusters": [], + "text": [ + "日日日日 ", + "日日日日 " + ] + } + }, { "name": "badge", "width": 20, diff --git a/ports/conformance/generate.ts b/ports/conformance/generate.ts index 8bb1d6dae5206c8df42c14499fa6b744eb7d7945..948a1ef3df62c8c3e3a84bb749aa1ed36e551a9e 100644 GIT binary patch delta 900 zcmaJ(C>f)ZpRDx`vic6?0Lb=YIag)Bip2&GaKnpU0GAtluC z43%b6*KjoPe0182PvyP)^-4aDYoHN~%E-rQ*$AD*t!n+os%bN2+AQYf#>SOR!=dZC zeb!Z4u96+MN5Lm{ zub3$oI$6TZfG3EVh!9j@h^68)nHVOPGa!iNWF>`8txQWT@EdMdztKpqbkB^S)+IIUzJS}UBXas@B<1di9q&DpczAIU8!`v3p{ delta 19 bcmaF+j_KAjrVRyUn-7&OW8SP;=VA!}ZF&iz diff --git a/ports/cpp/include/hqtui/widgets.hpp b/ports/cpp/include/hqtui/widgets.hpp index 7c4acf8..33da895 100644 --- a/ports/cpp/include/hqtui/widgets.hpp +++ b/ports/cpp/include/hqtui/widgets.hpp @@ -32,6 +32,36 @@ inline std::string utf8(uint32_t c) { } return s; } +/// `s` with its first `columns` display columns removed. +/// +/// For scrolling a line sideways. Slicing by bytes 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. +inline std::string drop_columns(std::string_view s, int columns) { + if (columns <= 0) + return std::string(s); + std::string out; + int skipped = 0; + std::size_t i = 0; + while (i < s.size()) { + unsigned char c = s[i]; + std::size_t len = c < 128 ? 1 : c < 224 ? 2 : c < 240 ? 3 : 4; + len = std::min(len, s.size() - i); + auto cell = s.substr(i, len); + int cw = int(width(cell)); + if (skipped >= columns) { + out.append(cell); + } else { + skipped += cw; + // A wide character straddling the cut leaves its trailing half behind. + if (skipped > columns) + out.append(std::size_t(skipped - columns), ' '); + } + i += len; + } + return out; +} inline std::string fit(std::string_view s, int columns, int align = HQ_LEFT, bool ellipsis = true) { if (columns <= 0) @@ -97,9 +127,39 @@ struct TextStyle { int attrs = 0; /// Wrap on the surface width rather than truncating at the edge. bool wrap = false; + /// 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. + int scroll = 0; + /// Columns to shift the text left by, for lines wider than the surface. + int scroll_x = 0; }; void draw_text(Surface, std::string_view content, const TextStyle & = {}); +struct Clear { + /// What to leave behind. Zero means the theme's background. + Color background = 0; +}; +/// 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. +void draw_clear(Surface, const Clear & = {}); + +struct Fill { + /// The symbol to repeat. A wide one is stepped over rather than written per + /// column, since each glyph owns a continuation cell. + std::string symbol = " "; + Color fg = 0; + std::optional bg; + int attrs = 0; +}; +/// Flood a region with one repeated symbol and style. +void draw_fill(Surface, const Fill & = {}); + enum BadgeVariant { HQ_BADGE_FILLED, HQ_BADGE_OUTLINE, HQ_BADGE_SUBTLE }; struct Badge { std::string text; @@ -837,6 +897,14 @@ class UI { void chart(Chart o, Constraint size = fr()) { draw([=](Surface s) { draw_chart(s, o); }, size); } + /// Reset a region so an overlay can own it. + void clear(Clear o = {}, Constraint size = fr()) { + draw([=](Surface s) { draw_clear(s, o); }, size); + } + /// Flood a region with one repeated symbol and style. + void fill(Fill o = {}, Constraint size = fr()) { + draw([=](Surface s) { draw_fill(s, o); }, size); + } void sparkline(Sparkline o) { draw([=](Surface s) { draw_sparkline(s, o); }, cells(1)); } diff --git a/ports/cpp/src/widgets.cpp b/ports/cpp/src/widgets.cpp index e2a4854..48a6f74 100644 --- a/ports/cpp/src/widgets.cpp +++ b/ports/cpp/src/widgets.cpp @@ -113,8 +113,61 @@ void draw_text(Surface s, std::string_view content, const TextStyle &o) { return; Color fg = o.fg ? o.fg : t.foreground; auto lines = lines_of(content, w, o.wrap); - for (int i = 0; i < h && i < int(lines.size()); i++) - text(s, 0, i, fit(lines[std::size_t(i)], w, o.align), fg, o.attrs, o.bg); + const int scroll = std::max(0, o.scroll); + const int scroll_x = std::max(0, o.scroll_x); + for (int i = 0; i + scroll < int(lines.size()) && i < h; i++) { + std::string line = lines[std::size_t(i + scroll)]; + if (scroll_x > 0) + line = drop_columns(line, scroll_x); + text(s, 0, i, fit(line, w, o.align), fg, o.attrs, o.bg); + } +} + +/// The first codepoint of a symbol, which is the whole of it for a fill. +static std::uint32_t codepoint_of(std::string_view s) { + if (s.empty()) + return ' '; + unsigned char c = s[0]; + std::size_t len = c < 128 ? 1 : c < 224 ? 2 : c < 240 ? 3 : 4; + if (len == 1) + return c; + if (s.size() < len) + return ' '; + std::uint32_t cp = c & (0xff >> (len + 1)); + for (std::size_t i = 1; i < len; i++) + cp = (cp << 6) | (static_cast(s[i]) & 0x3f); + return cp; +} + +void draw_clear(Surface s, const Clear &o) { + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0) + return; + auto &t = theme(s); + s.fill(' ', Style().foreground(t.foreground).background(o.background ? o.background + : t.background)); +} + +void draw_fill(Surface s, const Fill &o) { + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0) + return; + auto style = Style().foreground(o.fg ? o.fg : theme(s).foreground).attributes(o.attrs); + if (o.bg) + style = style.background(*o.bg); + const int glyph_width = std::max(1, int(width(o.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 (glyph_width == 1) { + s.fill(codepoint_of(o.symbol), style); + return; + } + for (int y = 0; y < h; 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 (int x = 0; x + glyph_width <= w; x += glyph_width) + s.set(x, y, codepoint_of(o.symbol), style); } int draw_badge(Surface s, const Badge &o) { diff --git a/ports/cpp/tests/conformance_widgets.cpp b/ports/cpp/tests/conformance_widgets.cpp index f642177..6808b66 100644 --- a/ports/cpp/tests/conformance_widgets.cpp +++ b/ports/cpp/tests/conformance_widgets.cpp @@ -33,6 +33,10 @@ const std::vector kSeries{3, 7, 2, 9, 4, 8, 6, 1, 5, 9, /// Draws one named scene. Returns false when C++ has no implementation yet. +/// The paragraph every scroll fixture pins. +static constexpr const char *PROSE = + "one two three four five six seven eight nine ten eleven twelve"; + /// The axis bounds every chart fixture pins, without the ceremony. static Axis axis_of(double min, double max, int ticks) { Axis a; @@ -84,6 +88,49 @@ bool draw_scene(const std::string &name, Surface s) { } return true; } + if (name == "text-scrolled") { + TextStyle style; + style.wrap = true; + style.scroll = 2; + draw_text(s, PROSE, style); + return true; + } + if (name == "text-scrolled-past") { + TextStyle style; + style.wrap = true; + style.scroll = 99; + draw_text(s, PROSE, style); + return true; + } + if (name == "text-scrolled-x") { + TextStyle style; + style.scroll_x = 6; + draw_text(s, "abcdefghijklmnopqrstuvwxyz", style); + return true; + } + if (name == "text-scrolled-wide") { + TextStyle style; + style.scroll_x = 3; + draw_text(s, "日本語です", style); + return true; + } + if (name == "clear") { + draw_text(s, "xxxxxxxxxxxxxxxx\nxxxxxxxxxxxxxxxx\nxxxxxxxxxxxxxxxx", {}); + draw_clear(s.sub({4, 1, 8, 1}), {}); + return true; + } + if (name == "fill") { + Fill f; + f.symbol = "\u00b7"; + draw_fill(s, f); + return true; + } + if (name == "fill-wide") { + Fill f; + f.symbol = "\u65e5"; + draw_fill(s, f); + return true; + } if (name == "badge") { { Badge badge; diff --git a/ports/go/conformance_widgets_test.go b/ports/go/conformance_widgets_test.go index 9fc5a90..4584e48 100644 --- a/ports/go/conformance_widgets_test.go +++ b/ports/go/conformance_widgets_test.go @@ -17,6 +17,9 @@ var widgetSeries = []float64{3, 7, 2, 9, 4, 8, 6, 1, 5, 9, 3, 7, 8, 2, 6, 4, 9, func series() []float64 { return append([]float64(nil), widgetSeries...) } +// The paragraph every scroll fixture pins. +const prose = "one two three four five six seven eight nine ten eleven twelve" + // pts builds a point list from flat x, y pairs, which is all a fixture needs. func pts(values ...float64) []Point { out := make([]Point, 0, len(values)/2) @@ -49,6 +52,21 @@ func drawWidgetScene(t *testing.T, name string, s Surface) { DrawText(s.Sub(0, 0, 20, 1), "left", TextStyle{Align: AlignLeft}) DrawText(s.Sub(0, 1, 20, 1), "center", TextStyle{Align: AlignCenter}) DrawText(s.Sub(0, 2, 20, 1), "right", TextStyle{Align: AlignRight}) + case "text-scrolled": + DrawText(s, prose, TextStyle{Wrap: true, Scroll: 2}) + case "text-scrolled-past": + DrawText(s, prose, TextStyle{Wrap: true, Scroll: 99}) + case "text-scrolled-x": + DrawText(s, "abcdefghijklmnopqrstuvwxyz", TextStyle{ScrollX: 6}) + case "text-scrolled-wide": + DrawText(s, "日本語です", TextStyle{ScrollX: 3}) + case "clear": + DrawText(s, "xxxxxxxxxxxxxxxx\nxxxxxxxxxxxxxxxx\nxxxxxxxxxxxxxxxx", TextStyle{}) + DrawClear(s.Sub(4, 1, 8, 1), ClearOptions{}) + case "fill": + DrawFill(s, FillOptions{Symbol: "\u00b7"}) + case "fill-wide": + DrawFill(s, FillOptions{Symbol: "\u65e5"}) case "badge": DrawBadge(s, BadgeOptions{Text: "LIVE"}) case "badge-outline": diff --git a/ports/go/ui.go b/ports/go/ui.go index 3ffaf6c..37bd5eb 100644 --- a/ports/go/ui.go +++ b/ports/go/ui.go @@ -489,6 +489,19 @@ func (c *Container) Chart(o ChartOptions, layout ...Layout) *Container { }) } +// Clear resets 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. +func (c *Container) Clear(o ClearOptions, layout ...Layout) *Container { + return c.add(c.filling(firstLayout(layout)), func(s Surface) { DrawClear(s, o) }) +} + +// Fill floods a region with one repeated symbol and style. +func (c *Container) Fill(o FillOptions, layout ...Layout) *Container { + return c.add(c.filling(firstLayout(layout)), func(s Surface) { DrawFill(s, o) }) +} + func (c *Container) Sparkline(o SparklineWidgetOptions, layout ...Layout) *Container { return c.add(c.leaf(firstLayout(layout), 1), func(s Surface) { DrawSparkline(s, o) }) } diff --git a/ports/go/unicode.go b/ports/go/unicode.go index a4cc463..05fcb1a 100644 --- a/ports/go/unicode.go +++ b/ports/go/unicode.go @@ -342,6 +342,32 @@ func StringWidth(text string) int { } // Truncate cuts to max columns, appending an ellipsis when it does not fit. +// DropColumns returns text with its first `columns` display columns removed. +// +// For scrolling a line sideways. Slicing by bytes 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. +func DropColumns(text string, columns int) string { + if columns <= 0 { + return text + } + var out strings.Builder + skipped := 0 + for _, g := range Graphemes(text) { + if skipped >= columns { + out.WriteString(CellText(g.Value)) + continue + } + skipped += g.Width + // A wide character straddling the cut leaves its trailing half behind. + if skipped > columns { + out.WriteString(strings.Repeat(" ", skipped-columns)) + } + } + return out.String() +} + func Truncate(text string, max int) string { return TruncateWith(text, max, "…") } func TruncateWith(text string, max int, ellipsis string) string { diff --git a/ports/go/widgets_surface.go b/ports/go/widgets_surface.go new file mode 100644 index 0000000..76c4c8e --- /dev/null +++ b/ports/go/widgets_surface.go @@ -0,0 +1,72 @@ +package hqtui + +// 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. + +type ClearOptions struct { + // Background is what to leave behind. Nil means the theme's background. + Background *Color +} + +// DrawClear resets 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. +func DrawClear(s Surface, o ClearOptions) { + if s.IsEmpty() { + return + } + bg := s.Theme.Background + if o.Background != nil { + bg = *o.Background + } + fg := s.Theme.Foreground + attrs := AttrNone + s.Fill(Style{Fg: &fg, Bg: &bg, Attrs: &attrs}) +} + +type FillOptions struct { + // Symbol is repeated across the region. A wide one is stepped over rather + // than written per column, since each glyph owns a continuation cell. + Symbol string + Fg *Color + Bg *Color + Attrs *Attrs +} + +// DrawFill floods a region with one repeated symbol and style. +func DrawFill(s Surface, o FillOptions) { + if s.IsEmpty() { + return + } + symbol := o.Symbol + if symbol == "" { + symbol = " " + } + style := Style{Fg: o.Fg, Bg: o.Bg, Attrs: o.Attrs} + glyph := []rune(symbol)[0] + glyphWidth := StringWidth(symbol) + if glyphWidth < 1 { + glyphWidth = 1 + } + // 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 glyphWidth == 1 { + s.FillRect(0, 0, s.Width(), s.Height(), style, Cell(glyph)) + return + } + for y := 0; y < s.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 x := 0; x+glyphWidth <= s.Width(); x += glyphWidth { + s.Glyph(x, y, glyph, style) + } + } +} diff --git a/ports/go/widgets_text.go b/ports/go/widgets_text.go index 11d3b2a..81f032c 100644 --- a/ports/go/widgets_text.go +++ b/ports/go/widgets_text.go @@ -15,6 +15,16 @@ type TextStyle struct { Dim bool Italic bool Underline bool + + // Scroll is the 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 int + // ScrollX is the columns to shift the text left by, for lines wider than + // the surface. + ScrollX int } func (o TextStyle) resolvedAttrs() Attrs { @@ -54,10 +64,20 @@ func DrawText(s Surface, content string, o TextStyle) { } else { lines = strings.Split(content, "\n") } + if o.Scroll > 0 { + if o.Scroll >= len(lines) { + lines = nil + } else { + lines = lines[o.Scroll:] + } + } for i, line := range lines { if i >= s.Height() { break } + if o.ScrollX > 0 { + line = DropColumns(line, o.ScrollX) + } s.Text(0, i, Fit(Truncate(line, s.Width()), s.Width(), o.Align), style) } } diff --git a/ports/python/hqtui/ui.py b/ports/python/hqtui/ui.py index 46b276e..9ee03fb 100644 --- a/ports/python/hqtui/ui.py +++ b/ports/python/hqtui/ui.py @@ -483,6 +483,26 @@ def chart(self, options: w.ChartOptions, layout: Layout | None = None): lambda s: w.draw_chart(s, options), ) + def clear(self, options: w.ClearOptions | None = None, layout: Layout | None = None): + """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. + """ + chosen = options or w.ClearOptions() + return self._add( + self._constraint(layout or Layout(), "fill"), + lambda s: w.draw_clear(s, chosen), + ) + + def fill(self, options: w.FillOptions | None = None, layout: Layout | None = None): + """Flood a region with one repeated symbol and style.""" + chosen = options or w.FillOptions() + return self._add( + self._constraint(layout or Layout(), "fill"), + lambda s: w.draw_fill(s, chosen), + ) + def sparkline(self, options: w.SparklineWidgetOptions, layout: Layout | None = None): return self._add(self._leaf(layout or Layout(), 1), lambda s: w.draw_sparkline(s, options)) diff --git a/ports/python/hqtui/unicode.py b/ports/python/hqtui/unicode.py index 5b04648..51d6e69 100644 --- a/ports/python/hqtui/unicode.py +++ b/ports/python/hqtui/unicode.py @@ -319,6 +319,29 @@ def string_width(text: str) -> int: return sum(g.width for g in graphemes(text)) +def drop_columns(text: str, columns: int) -> str: + """``text`` with its first ``columns`` display columns removed. + + For scrolling a line sideways. Slicing by code points 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. + """ + if columns <= 0: + return text + out: list[str] = [] + skipped = 0 + for g in graphemes(text): + if skipped >= columns: + out.append(cell_text(g.value)) + continue + skipped += g.width + # A wide character straddling the cut leaves its trailing half behind. + if skipped > columns: + out.append(" " * (skipped - columns)) + return "".join(out) + + def truncate(text: str, max_width: int, ellipsis: str = "…") -> str: """Truncate to ``max_width`` columns, appending an ellipsis when it does not fit.""" if max_width <= 0: diff --git a/ports/python/hqtui/widgets/__init__.py b/ports/python/hqtui/widgets/__init__.py index 972480e..219913b 100644 --- a/ports/python/hqtui/widgets/__init__.py +++ b/ports/python/hqtui/widgets/__init__.py @@ -66,6 +66,7 @@ resolve_offset, ) from .chart import ChartOptions, draw_chart +from .surface import ClearOptions, FillOptions, draw_clear, draw_fill from .scrollbar import ( ScrollbarOptions, ScrollbarOrientation, @@ -106,7 +107,7 @@ "draw_columns", "draw_command_palette", "draw_divider", "draw_donut", "draw_gauge", "draw_graph", "draw_heat_bar", "draw_key_values", "draw_list", "draw_log", "draw_meter", "draw_meters", "draw_modal", "draw_progress", - "ChartOptions", "draw_chart", + "ChartOptions", "draw_chart", "ClearOptions", "FillOptions", "draw_clear", "draw_fill", "ScrollbarOptions", "ScrollbarOrientation", "is_vertical", "offset_for_position", "thumb", "draw_scrollbar", "draw_scrollbar_widget", "draw_select", "draw_sparkline", "draw_status_bar", "draw_table", "draw_tabs", "draw_text", "draw_text_input", "draw_tooltip", diff --git a/ports/python/hqtui/widgets/surface.py b/ports/python/hqtui/widgets/surface.py new file mode 100644 index 0000000..80e0f6b --- /dev/null +++ b/ports/python/hqtui/widgets/surface.py @@ -0,0 +1,69 @@ +"""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. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..buffer import Attrs, Style +from ..color import Color +from ..surface import Surface +from ..unicode import string_width + +__all__ = ["ClearOptions", "FillOptions", "draw_clear", "draw_fill"] + + +@dataclass(frozen=True, slots=True) +class ClearOptions: + #: What to leave behind. Defaults to the theme's background. + background: Color | None = None + + +def draw_clear(surface: Surface, options: ClearOptions = ClearOptions()) -> None: + """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. + """ + if surface.empty: + return + theme = surface.theme + background = options.background if options.background is not None else theme.background + surface.fill(Style(fg=theme.foreground, bg=background, attrs=Attrs.NONE)) + + +@dataclass(frozen=True, slots=True) +class FillOptions: + #: The symbol to repeat. A wide one is stepped over rather than written per + #: column, since each glyph owns a continuation cell. + symbol: str = " " + fg: Color | None = None + bg: Color | None = None + attrs: Attrs | None = None + + +def draw_fill(surface: Surface, options: FillOptions = FillOptions()) -> None: + """Flood a region with one repeated symbol and style.""" + if surface.empty: + return + symbol = options.symbol or " " + style = Style(fg=options.fg, bg=options.bg, attrs=options.attrs) + glyph_width = max(1, string_width(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 glyph_width == 1: + surface.fill(style, ord(symbol[0])) + return + for y in range(surface.height): + # 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 x in range(0, surface.width - glyph_width + 1, glyph_width): + surface.char(x, y, symbol, style) diff --git a/ports/python/hqtui/widgets/text.py b/ports/python/hqtui/widgets/text.py index 30e851a..5ae2333 100644 --- a/ports/python/hqtui/widgets/text.py +++ b/ports/python/hqtui/widgets/text.py @@ -10,7 +10,7 @@ from ..color import Color from ..surface import Surface, TextOptions from ..theme import elevate -from ..unicode import Align, fit, string_width, truncate, wrap +from ..unicode import Align, drop_columns, fit, string_width, truncate, wrap __all__ = [ "BadgeOptions", @@ -41,6 +41,14 @@ class TextStyle: dim: bool = False italic: bool = False underline: bool = False + #: 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: int = 0 + #: Columns to shift the text left by, for lines wider than the surface. + scroll_x: int = 0 @property def resolved_attrs(self) -> int: @@ -65,7 +73,11 @@ def draw_text(surface: Surface, content: str, options: TextStyle = TextStyle()) attrs=options.resolved_attrs, ) lines = wrap(content, surface.width) if options.wrap else content.split("\n") + if options.scroll > 0: + lines = lines[options.scroll :] for i, line in enumerate(lines[: surface.height]): + if options.scroll_x > 0: + line = drop_columns(line, options.scroll_x) surface.text(0, i, fit(truncate(line, surface.width), surface.width, options.align), style) diff --git a/ports/python/tests/test_conformance_widgets.py b/ports/python/tests/test_conformance_widgets.py index f887ed8..4d7ebfd 100644 --- a/ports/python/tests/test_conformance_widgets.py +++ b/ports/python/tests/test_conformance_widgets.py @@ -34,6 +34,10 @@ from .support import assert_buffer, fixture, scene +#: The paragraph every scroll fixture pins. +PROSE = "one two three four five six seven eight nine ten eleven twelve" + + def _axis(minimum: float, maximum: float, ticks: int = 0) -> g.AxisOptions: """The axis bounds every chart fixture pins, without the ceremony.""" return g.AxisOptions(min=minimum, max=maximum, ticks=ticks) @@ -59,6 +63,21 @@ def draw_scene(case, name: str, s: Surface) -> None: w.draw_text(s.sub(0, 0, 20, 1), "left", w.TextStyle(align="left")) w.draw_text(s.sub(0, 1, 20, 1), "center", w.TextStyle(align="center")) w.draw_text(s.sub(0, 2, 20, 1), "right", w.TextStyle(align="right")) + elif name == "text-scrolled": + w.draw_text(s, PROSE, w.TextStyle(wrap=True, scroll=2)) + elif name == "text-scrolled-past": + w.draw_text(s, PROSE, w.TextStyle(wrap=True, scroll=99)) + elif name == "text-scrolled-x": + w.draw_text(s, "abcdefghijklmnopqrstuvwxyz", w.TextStyle(scroll_x=6)) + elif name == "text-scrolled-wide": + w.draw_text(s, "日本語です", w.TextStyle(scroll_x=3)) + elif name == "clear": + w.draw_text(s, "xxxxxxxxxxxxxxxx\nxxxxxxxxxxxxxxxx\nxxxxxxxxxxxxxxxx", w.TextStyle()) + w.draw_clear(s.sub(4, 1, 8, 1)) + elif name == "fill": + w.draw_fill(s, w.FillOptions(symbol="\u00b7")) + elif name == "fill-wide": + w.draw_fill(s, w.FillOptions(symbol="\u65e5")) elif name == "badge": w.draw_badge(s, w.BadgeOptions(text="LIVE")) elif name == "badge-outline": diff --git a/ports/rust/src/ui.rs b/ports/rust/src/ui.rs index 33586be..8c3a41a 100644 --- a/ports/rust/src/ui.rs +++ b/ports/rust/src/ui.rs @@ -759,6 +759,21 @@ impl<'a> Container<'a> { self.add(constraint, move |s| w::draw_chart(&s, &options)) } + /// 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. + pub fn clear(&mut self, options: w::ClearOptions) -> &mut Self { + let constraint = self.filling(); + self.add(constraint, move |s| w::draw_clear(&s, &options)) + } + + /// Flood a region with one repeated symbol and style. + pub fn fill(&mut self, options: w::FillOptions) -> &mut Self { + let constraint = self.filling(); + self.add(constraint, move |s| w::draw_fill(&s, &options)) + } + pub fn sparkline(&mut self, options: w::SparklineWidgetOptions) -> &mut Self { let constraint = self.leaf(1); self.add(constraint, move |s| w::draw_sparkline(&s, &options)) diff --git a/ports/rust/src/unicode.rs b/ports/rust/src/unicode.rs index 3e91e16..3b3e4d1 100644 --- a/ports/rust/src/unicode.rs +++ b/ports/rust/src/unicode.rs @@ -319,6 +319,32 @@ pub fn string_width(text: &str) -> usize { } /// Truncate to `max` columns, appending an ellipsis when it does not fit. +/// `text` with its first `columns` display columns removed. +/// +/// For scrolling a line sideways. Slicing by bytes 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. +pub fn drop_columns(text: &str, columns: usize) -> String { + if columns == 0 { + return text.to_string(); + } + let mut out = String::new(); + let mut skipped = 0usize; + for g in graphemes(text) { + if skipped >= columns { + out.push_str(&cell_text(g.value)); + continue; + } + skipped += g.width; + // A wide character straddling the cut leaves its trailing half behind. + if skipped > columns { + out.push_str(&" ".repeat(skipped - columns)); + } + } + out +} + pub fn truncate(text: &str, max: usize) -> String { truncate_with(text, max, "…") } diff --git a/ports/rust/src/widgets/mod.rs b/ports/rust/src/widgets/mod.rs index 1848617..c52df5c 100644 --- a/ports/rust/src/widgets/mod.rs +++ b/ports/rust/src/widgets/mod.rs @@ -6,6 +6,7 @@ pub mod chart; pub mod controls; pub mod meters; pub mod scrollbar; +pub mod surface; pub mod table; pub mod text; @@ -26,6 +27,7 @@ pub use scrollbar::{ draw_scrollbar, draw_scrollbar_widget, offset_for_position, thumb, thumb_of, ScrollbarOptions, ScrollbarOrientation, }; +pub use surface::{draw_clear, draw_fill, ClearOptions, FillOptions}; pub use table::{ draw_list, draw_log, draw_table, draw_tree, resolve_offset, TableColumn, ListItem, ListOptions, LogEntry, LogOptions, TableOptions, TableRow, TreeNode, TreeOptions, diff --git a/ports/rust/src/widgets/surface.rs b/ports/rust/src/widgets/surface.rs new file mode 100644 index 0000000..a07acf2 --- /dev/null +++ b/ports/rust/src/widgets/surface.rs @@ -0,0 +1,76 @@ +//! 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. + +use crate::buffer::{Attrs, Style}; +use crate::color::Color; +use crate::surface::Surface; +use crate::unicode::string_width; + +#[derive(Clone, Copy, Debug, Default)] +pub struct ClearOptions { + /// What to leave behind. Defaults to the theme's background. + pub background: Option, +} + +/// 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. +pub fn draw_clear(surface: &Surface, options: &ClearOptions) { + if surface.is_empty() { + return; + } + let theme = surface.theme.clone(); + surface.fill(&Style { + fg: Some(theme.foreground), + bg: Some(options.background.unwrap_or(theme.background)), + attrs: Some(Attrs::default()), + }); +} + +#[derive(Clone, Debug)] +pub struct FillOptions { + /// The symbol to repeat. A wide one is stepped over rather than written per + /// column, since each glyph owns a continuation cell. + pub symbol: String, + pub fg: Option, + pub bg: Option, + pub attrs: Option, +} + +impl Default for FillOptions { + fn default() -> FillOptions { + FillOptions { symbol: " ".into(), fg: None, bg: None, attrs: None } + } +} + +/// Flood a region with one repeated symbol and style. +pub fn draw_fill(surface: &Surface, options: &FillOptions) { + if surface.is_empty() { + return; + } + let style = Style { fg: options.fg, bg: options.bg, attrs: options.attrs }; + let glyph = options.symbol.chars().next().unwrap_or(' '); + let glyph_width = string_width(&options.symbol).max(1); + // 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 glyph_width == 1 { + surface.fill_rect(0, 0, surface.width(), surface.height(), &style, glyph as u32); + return; + } + for y in 0..surface.height() { + // 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. + let mut x = 0usize; + while x + glyph_width <= surface.width() { + surface.glyph(x as isize, y as isize, glyph, &style); + x += glyph_width; + } + } +} diff --git a/ports/rust/src/widgets/text.rs b/ports/rust/src/widgets/text.rs index 1293c40..50c4369 100644 --- a/ports/rust/src/widgets/text.rs +++ b/ports/rust/src/widgets/text.rs @@ -4,7 +4,7 @@ use crate::buffer::{Attrs, Style}; use crate::color::Color; use crate::surface::{Surface, TextOptions}; use crate::theme::elevate; -use crate::unicode::{fit, string_width, truncate, wrap, Align}; +use crate::unicode::{drop_columns, fit, string_width, truncate, wrap, Align}; #[derive(Clone, Debug, Default)] pub struct TextStyle { @@ -17,6 +17,14 @@ pub struct TextStyle { pub dim: bool, pub italic: bool, pub underline: bool, + /// 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. + pub scroll: usize, + /// Columns to shift the text left by, for lines wider than the surface. + pub scroll_x: usize, } impl TextStyle { @@ -97,7 +105,15 @@ pub fn draw_text(surface: &Surface, content: &str, options: &TextStyle) { content.split('\n').map(String::from).collect() }; let align = options.align.unwrap_or(Align::Left); - for (i, line) in lines.iter().enumerate().take(surface.height()) { + let visible = if options.scroll < lines.len() { &lines[options.scroll..] } else { &[][..] }; + for (i, line) in visible.iter().enumerate().take(surface.height()) { + let shifted; + let line = if options.scroll_x > 0 { + shifted = drop_columns(line, options.scroll_x); + &shifted + } else { + line + }; let padded = fit(&truncate(line, surface.width()), surface.width(), align); surface.text(0, i as isize, &padded, &TextOptions::from(style)); } diff --git a/ports/rust/tests/conformance_widgets.rs b/ports/rust/tests/conformance_widgets.rs index 5927047..685e2b2 100644 --- a/ports/rust/tests/conformance_widgets.rs +++ b/ports/rust/tests/conformance_widgets.rs @@ -19,6 +19,9 @@ use hqtui::surface::Surface; use hqtui::unicode::Align; use hqtui::widgets::*; +/// The paragraph every scroll fixture pins. +const PROSE: &str = "one two three four five six seven eight nine ten eleven twelve"; + const SERIES: [f64; 20] = [3., 7., 2., 9., 4., 8., 6., 1., 5., 9., 3., 7., 8., 2., 6., 4., 9., 1., 5., 7.]; @@ -53,6 +56,16 @@ fn draw_scene(name: &str, s: &Surface) { draw_text(&s.sub(0, 1, 20, 1), "center", &TextStyle::new().align(Align::Center)); draw_text(&s.sub(0, 2, 20, 1), "right", &TextStyle::new().align(Align::Right)); } + "text-scrolled" => draw_text(s, PROSE, &TextStyle { wrap: true, scroll: 2, ..Default::default() }), + "text-scrolled-past" => draw_text(s, PROSE, &TextStyle { wrap: true, scroll: 99, ..Default::default() }), + "text-scrolled-x" => draw_text(s, "abcdefghijklmnopqrstuvwxyz", &TextStyle { scroll_x: 6, ..Default::default() }), + "text-scrolled-wide" => draw_text(s, "日本語です", &TextStyle { scroll_x: 3, ..Default::default() }), + "clear" => { + draw_text(s, "xxxxxxxxxxxxxxxx\nxxxxxxxxxxxxxxxx\nxxxxxxxxxxxxxxxx", &TextStyle::default()); + draw_clear(&s.sub(4, 1, 8, 1), &ClearOptions::default()); + } + "fill" => draw_fill(s, &FillOptions { symbol: "\u{b7}".into(), ..Default::default() }), + "fill-wide" => draw_fill(s, &FillOptions { symbol: "\u{65e5}".into(), ..Default::default() }), "badge" => { draw_badge(s, &BadgeOptions::new("LIVE")); } diff --git a/ports/zig/src/conformance_widgets.zig b/ports/zig/src/conformance_widgets.zig index 156eecb..695309b 100644 --- a/ports/zig/src/conformance_widgets.zig +++ b/ports/zig/src/conformance_widgets.zig @@ -20,6 +20,9 @@ const Surface = surface_mod.Surface; const series = [_]f64{ 3, 7, 2, 9, 4, 8, 6, 1, 5, 9, 3, 7, 8, 2, 6, 4, 9, 1, 5, 7 }; +/// The paragraph every scroll fixture pins. +const prose = "one two three four five six seven eight nine ten eleven twelve"; + /// The points every single-series chart fixture pins. const chart_points = [_]graphics.Point{ .{ .x = 0, .y = 1 }, .{ .x = 2, .y = 6 }, .{ .x = 5, .y = 3 }, @@ -50,6 +53,21 @@ fn drawScene(allocator: std.mem.Allocator, name: []const u8, s: Surface) !void { try w.drawText(allocator, s.sub(0, 0, 20, 1), "left", .{ .alignment = .left }); try w.drawText(allocator, s.sub(0, 1, 20, 1), "center", .{ .alignment = .center }); try w.drawText(allocator, s.sub(0, 2, 20, 1), "right", .{ .alignment = .right }); + } else if (eq(u8, name, "text-scrolled")) { + try w.drawText(allocator, s, prose, .{ .wrap = true, .scroll = 2 }); + } else if (eq(u8, name, "text-scrolled-past")) { + try w.drawText(allocator, s, prose, .{ .wrap = true, .scroll = 99 }); + } else if (eq(u8, name, "text-scrolled-x")) { + try w.drawText(allocator, s, "abcdefghijklmnopqrstuvwxyz", .{ .scroll_x = 6 }); + } else if (eq(u8, name, "text-scrolled-wide")) { + try w.drawText(allocator, s, "日本語です", .{ .scroll_x = 3 }); + } else if (eq(u8, name, "clear")) { + try w.drawText(allocator, s, "xxxxxxxxxxxxxxxx\nxxxxxxxxxxxxxxxx\nxxxxxxxxxxxxxxxx", .{}); + w.drawClear(s.sub(4, 1, 8, 1), .{}); + } else if (eq(u8, name, "fill")) { + w.drawFill(s, .{ .symbol = "\u{b7}" }); + } else if (eq(u8, name, "fill-wide")) { + w.drawFill(s, .{ .symbol = "\u{65e5}" }); } else if (eq(u8, name, "badge")) { _ = w.drawBadge(s, .{ .text = "LIVE" }); } else if (eq(u8, name, "badge-outline")) { diff --git a/ports/zig/src/ui.zig b/ports/zig/src/ui.zig index b45b11b..d2ab1d6 100644 --- a/ports/zig/src/ui.zig +++ b/ports/zig/src/ui.zig @@ -373,6 +373,8 @@ const Node = union(enum) { progress: w.ProgressOptions, graph: w.GraphOptions, chart: w.ChartOptions, + clear: w.ClearOptions, + fill: w.FillOptions, sparkline: w.SparklineWidgetOptions, histogram: w.ColumnsOptions, gauge: w.GaugeOptions, @@ -461,6 +463,8 @@ fn drawNode(ctx: *Ctx, s: Surface, node: Node) anyerror!void { .progress => |o| w.drawProgress(s, o), .graph => |o| try w.drawGraph(allocator, s, o), .chart => |o| try w.drawChart(allocator, s, o), + .clear => |o| w.drawClear(s, o), + .fill => |o| w.drawFill(s, o), .sparkline => |o| w.drawSparkline(s, o), .histogram => |o| w.drawColumns(s, o), .gauge => |o| try w.drawGauge(allocator, s, o), @@ -849,6 +853,19 @@ pub const Container = struct { try self.add(self.filling(), .{ .chart = options }); } + /// 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. + pub fn clear(self: *Container, options: w.ClearOptions) !void { + try self.add(self.filling(), .{ .clear = options }); + } + + /// Flood a region with one repeated symbol and style. + pub fn fill(self: *Container, options: w.FillOptions) !void { + try self.add(self.filling(), .{ .fill = options }); + } + pub fn sparkline(self: *Container, options: w.SparklineWidgetOptions) !void { try self.add(self.leaf(1), .{ .sparkline = options }); } diff --git a/ports/zig/src/unicode.zig b/ports/zig/src/unicode.zig index 9ca4efc..cb80b46 100644 --- a/ports/zig/src/unicode.zig +++ b/ports/zig/src/unicode.zig @@ -438,6 +438,42 @@ pub fn truncateInto(out: []u8, text: []const u8, max: usize, ellipsis: []const u return out[0 .. written + room]; } +/// `text` with its first `columns` display columns removed, written into `out`. +/// +/// For scrolling a line sideways. Slicing by bytes 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. +pub fn dropColumns(out: []u8, text: []const u8, columns: usize) []u8 { + if (columns == 0) { + const n = @min(out.len, text.len); + @memcpy(out[0..n], text[0..n]); + return out[0..n]; + } + var written: usize = 0; + var skipped: usize = 0; + var it = graphemes(text); + while (it.next()) |g| { + if (skipped >= columns) { + var buf: [4]u8 = undefined; + const bytes = cellText(g.value, &buf); + if (written + bytes.len > out.len) break; + @memcpy(out[written..][0..bytes.len], bytes); + written += bytes.len; + continue; + } + skipped += g.width; + // A wide character straddling the cut leaves its trailing half behind. + if (skipped > columns) { + const pad = skipped - columns; + if (written + pad > out.len) break; + @memset(out[written..][0..pad], ' '); + written += pad; + } + } + return out[0..written]; +} + pub fn truncate(out: []u8, text: []const u8, max: usize) []u8 { return truncateInto(out, text, max, "…"); } diff --git a/ports/zig/src/widgets.zig b/ports/zig/src/widgets.zig index 2b73e8d..c566dcb 100644 --- a/ports/zig/src/widgets.zig +++ b/ports/zig/src/widgets.zig @@ -5,6 +5,7 @@ pub const controls = @import("widgets/controls.zig"); pub const meters = @import("widgets/meters.zig"); pub const chart = @import("widgets/chart.zig"); +pub const surface_widgets = @import("widgets/surface.zig"); pub const scrollbar = @import("widgets/scrollbar.zig"); pub const table = @import("widgets/table.zig"); pub const text = @import("widgets/text.zig"); @@ -61,6 +62,10 @@ pub const TreeValue = table.TreeValue; pub const drawList = table.drawList; pub const drawLog = table.drawLog; pub const ChartOptions = chart.ChartOptions; +pub const ClearOptions = surface_widgets.ClearOptions; +pub const FillOptions = surface_widgets.FillOptions; +pub const drawClear = surface_widgets.drawClear; +pub const drawFill = surface_widgets.drawFill; pub const drawChart = chart.drawChart; pub const ScrollbarOptions = scrollbar.ScrollbarOptions; pub const ScrollbarOrientation = scrollbar.ScrollbarOrientation; diff --git a/ports/zig/src/widgets/surface.zig b/ports/zig/src/widgets/surface.zig new file mode 100644 index 0000000..369b36b --- /dev/null +++ b/ports/zig/src/widgets/surface.zig @@ -0,0 +1,77 @@ +//! 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. + +const std = @import("std"); + +const buffer_mod = @import("../buffer.zig"); +const color_mod = @import("../color.zig"); +const surface_mod = @import("../surface.zig"); +const unicode = @import("../unicode.zig"); + +const Attrs = buffer_mod.Attrs; +const Color = color_mod.Color; +const Style = buffer_mod.Style; +const Surface = surface_mod.Surface; + +pub const ClearOptions = struct { + /// What to leave behind. Defaults to the theme's background. + background: ?Color = null, +}; + +/// 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. +pub fn drawClear(s: Surface, options: ClearOptions) void { + if (s.isEmpty()) return; + const theme = s.theme; + s.fill(.{ + .fg = theme.foreground, + .bg = options.background orelse theme.background, + .attrs = Attrs.none, + }); +} + +pub const FillOptions = struct { + /// The symbol to repeat. A wide one is stepped over rather than written per + /// column, since each glyph owns a continuation cell. + symbol: []const u8 = " ", + fg: ?Color = null, + bg: ?Color = null, + attrs: ?Attrs = null, +}; + +/// Flood a region with one repeated symbol and style. +pub fn drawFill(s: Surface, options: FillOptions) void { + if (s.isEmpty()) return; + const symbol = if (options.symbol.len > 0) options.symbol else " "; + const style = Style{ .fg = options.fg, .bg = options.bg, .attrs = options.attrs }; + + const len = std.unicode.utf8ByteSequenceLength(symbol[0]) catch 1; + const glyph: u21 = if (len <= symbol.len) + std.unicode.utf8Decode(symbol[0..len]) catch ' ' + else + ' '; + const glyph_width = @max(1, unicode.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 (glyph_width == 1) { + s.fillRect(0, 0, s.width(), s.height(), style, glyph); + return; + } + for (0..s.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. + var x: usize = 0; + while (x + glyph_width <= s.width()) : (x += glyph_width) { + s.glyph(@intCast(x), @intCast(y), glyph, style); + } + } +} diff --git a/ports/zig/src/widgets/text.zig b/ports/zig/src/widgets/text.zig index 000420c..6b0e987 100644 --- a/ports/zig/src/widgets/text.zig +++ b/ports/zig/src/widgets/text.zig @@ -25,6 +25,14 @@ pub const TextStyle = struct { dim: bool = false, italic: bool = false, underline: bool = false, + /// 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: usize = 0, + /// Columns to shift the text left by, for lines wider than the surface. + scroll_x: usize = 0, fn resolvedAttrs(self: TextStyle) Attrs { var a = self.attrs orelse Attrs.none; @@ -54,12 +62,23 @@ pub fn drawText( var scratch: [4096]u8 = undefined; var padded: [4096]u8 = undefined; + var shifted: [4096]u8 = undefined; + // A horizontal scroll drops columns from the front of the line before + // anything else looks at it, so the truncation still measures what is shown. + const shift = struct { + fn apply(buf: []u8, line: []const u8, columns: usize) []const u8 { + return if (columns == 0) line else unicode.dropColumns(buf, line, columns); + } + }.apply; + if (style.wrap) { const lines = try unicode.wrap(allocator, content, s.width()); defer unicode.freeWrapped(allocator, lines); - for (lines, 0..) |line, i| { + const visible = if (style.scroll < lines.len) lines[style.scroll..] else lines[0..0]; + for (visible, 0..) |line, i| { if (i >= s.height()) break; - const cut = unicode.truncate(&scratch, line, s.width()); + const moved = shift(&shifted, line, style.scroll_x); + const cut = unicode.truncate(&scratch, moved, s.width()); const shown = unicode.fit(&padded, cut, s.width(), style.alignment); _ = s.text(0, @intCast(i), shown, options); } @@ -67,10 +86,15 @@ pub fn drawText( } var it = std.mem.splitScalar(u8, content, '\n'); + var skipped: usize = 0; + while (skipped < style.scroll) : (skipped += 1) { + if (it.next() == null) return; + } var i: usize = 0; while (it.next()) |line| : (i += 1) { if (i >= s.height()) break; - const cut = unicode.truncate(&scratch, line, s.width()); + const moved = shift(&shifted, line, style.scroll_x); + const cut = unicode.truncate(&scratch, moved, s.width()); const shown = unicode.fit(&padded, cut, s.width(), style.alignment); _ = s.text(0, @intCast(i), shown, options); }