diff --git a/apps/web/content/widgets.json b/apps/web/content/widgets.json
index 399216d..af719a8 100644
--- a/apps/web/content/widgets.json
+++ b/apps/web/content/widgets.json
@@ -674,6 +674,61 @@
}
}
},
+ {
+ "id": "scrollbar",
+ "title": "Scrollbar",
+ "category": "Data",
+ "width": 46,
+ "height": 4,
+ "blurb": "A bar over state you own, on any of the four edges, for anything that scrolls.",
+ "preview": "
A scrollbar you drive yourself. It has no │\nidea what is beside it, only how much there █\nis, how much fits, and where you are. │\n │
",
+ "examples": {
+ "typescript": {
+ "code": "export function scrollbar(ui: Container, theme: Theme): void {\n // The bar is over state you own, so it works beside anything that scrolls:\n // wrapped prose, a canvas, a draw() of your own.\n ui.row({ gap: 1 }, (r) => {\n r.text(\n \"A scrollbar you drive yourself. It has no idea what is beside it, only \" +\n \"how much there is, how much fits, and where you are.\",\n { wrap: true, fg: theme.foreground },\n );\n r.scrollbar({ total: 40, viewport: 5, offset: 12, size: 1 });\n });\n}",
+ "syntax": "ts"
+ },
+ "javascript": {
+ "code": "export function scrollbar(ui, theme) {\n // The bar is over state you own, so it works beside anything that scrolls:\n // wrapped prose, a canvas, a draw() of your own.\n ui.row({ gap: 1 }, (r) => {\n r.text(\"A scrollbar you drive yourself. It has no idea what is beside it, only how much there is, how much fits, and where you are.\", { wrap: true, fg: theme.foreground });\n r.scrollbar({ total: 40, viewport: 5, offset: 12, size: 1 });\n });\n}",
+ "syntax": "js"
+ },
+ "rust": {
+ "code": "pub fn scrollbar(ui: &mut Container) {\n // The bar is over state you own, so it works beside anything that scrolls:\n // wrapped prose, a canvas, a `draw` of your own.\n ui.row(Row::new().gap(1), |r| {\n r.styled_text(\n \"A scrollbar you drive yourself. It has no idea what is beside it, only how much there is, how much fits, and where you are.\",\n TextStyle::new().wrapped(),\n );\n r.scrollbar(\n ScrollbarOptions { total: 40, viewport: 5, offset: 12, ..Default::default() },\n \"\",\n );\n });\n}",
+ "syntax": "rust"
+ },
+ "go": {
+ "code": "func Scrollbar(ui *hqtui.Container) {\n\t// The bar is over state you own, so it works beside anything that scrolls:\n\t// wrapped prose, a canvas, a Draw of your own.\n\tui.Row(hqtui.RowOptions{Layout: hqtui.Layout{Gap: 1}}, func(r *hqtui.Container) {\n\t\tr.StyledText(\n\t\t\t\"A scrollbar you drive yourself. It has no idea what is beside it, only how much there is, how much fits, and where you are.\",\n\t\t\thqtui.TextStyle{Wrap: true},\n\t\t)\n\t\tr.Scrollbar(hqtui.ScrollbarOptions{Total: 40, Viewport: 5, Offset: 12}, hqtui.ScrollHandlers{})\n\t})\n}",
+ "syntax": "go"
+ },
+ "python": {
+ "code": "def scrollbar(ui: Container) -> None:\n # The bar is over state you own, so it works beside anything that scrolls:\n # wrapped prose, a canvas, a ``draw`` of your own.\n def row(r: Container) -> None:\n r.text(\n \"A scrollbar you drive yourself. It has no idea what is beside it, only how much there is, how much fits, and where you are.\",\n w.TextStyle(wrap=True),\n )\n r.scrollbar(w.ScrollbarOptions(total=40, viewport=5, offset=12))\n\n ui.row(Layout(gap=1), row)",
+ "syntax": "python"
+ },
+ "zig": {
+ "code": "fn scrollbar(ui: *Container) anyerror!void {\n // The bar is over state you own, so it works beside anything that scrolls:\n // wrapped prose, a canvas, a `draw` of your own.\n try ui.row(.{ .layout = .{ .gap = 1 } }, hqtui.Body.plain(scrollbarRow));\n}\n\nfn scrollbarRow(r: *Container) anyerror!void {\n try r.text(\n \"A scrollbar you drive yourself. It has no idea what is beside it, only how much there is, how much fits, and where you are.\",\n .{ .wrap = true },\n );\n try r.scrollbar(.{ .total = 40, .viewport = 5, .offset = 12 }, \"\");\n}",
+ "syntax": "zig"
+ },
+ "cpp": {
+ "code": "void widget_scrollbar(Surface s) {\n // The bar is over state you own, so it works beside anything that scrolls:\n // wrapped prose, a canvas, a draw() of your own. Here it takes the rightmost\n // column and the prose takes the rest.\n int w = s.rect().width, h = s.rect().height;\n const auto &t = theme(s);\n draw_text(s.sub({0, 0, w - 2, h}),\n \"A scrollbar you drive yourself. It has no idea what is beside it, \"\n \"only how much there is, how much fits, and where you are.\",\n TextStyle{t.foreground, std::nullopt, HQ_LEFT, 0, true});\n draw_scrollbar(s.sub({w - 1, 0, 1, h}), Scrollbar{40, 5, 12, HQ_SCROLLBAR_RIGHT});\n}",
+ "syntax": "cpp"
+ },
+ "ruby": {
+ "code": "def scrollbar(ui)\n # The bar is over state you own: it knows how much there is, how much fits\n # and where you are, and nothing about what it sits beside.\n ui.text('120 lines, 8 of them on screen, starting at 36.')\n ui.scrollbar(120, viewport: 8, offset: 36, orientation: 'bottom')\nend",
+ "syntax": "ruby"
+ },
+ "php": {
+ "code": "function widget_scrollbar(UI $ui): void\n{\n // The bar is over state you own: it knows how much there is, how much fits\n // and where you are, and nothing about what it sits beside.\n $ui->text('120 lines, 8 of them on screen, starting at 36.');\n $ui->scrollbar(120, ['viewport' => 8, 'offset' => 36, 'orientation' => 'bottom']);\n}",
+ "syntax": "php"
+ },
+ "perl": {
+ "code": "sub widget_scrollbar {\n my ($ui) = @_;\n # The bar is over state you own: it knows how much there is, how much fits\n # and where you are, and nothing about what it sits beside.\n $ui->text('120 lines, 8 of them on screen, starting at 36.');\n $ui->scrollbar(120, viewport => 8, offset => 36, orientation => 'bottom');\n}",
+ "syntax": "perl"
+ },
+ "cobol": {
+ "code": "SCROLLBAR-WIDGET.\n MOVE \"scrollbar\" TO SR-KEY\n PERFORM START-WIDGET\n\n MOVE \"TEXT\" TO SR-VERB\n MOVE \"LEFT\" TO SR-KEY\n MOVE \"120 lines, 8 of them on screen, starting at 36.\" TO SR-TEXT\n PERFORM EMIT-RECORD\n\n *> The bar is over state you own: key is the edge it sits on, num the\n *> offset, and the text carries total and viewport.\n MOVE \"SCROLLBAR\" TO SR-VERB\n MOVE \"BOTTOM\" TO SR-KEY\n MOVE \"36\" TO SR-NUM\n MOVE \"120|8\" TO SR-TEXT\n PERFORM EMIT-RECORD.",
+ "syntax": "cobol"
+ }
+ }
+ },
{
"id": "meter",
"title": "Meter",
diff --git a/apps/web/test/widgets.test.ts b/apps/web/test/widgets.test.ts
index d531732..c929c87 100644
--- a/apps/web/test/widgets.test.ts
+++ b/apps/web/test/widgets.test.ts
@@ -26,7 +26,7 @@ test("every language has a runnable example for every widget", () => {
});
test("the catalog covers the widgets and languages the site promises", () => {
- assert.equal(catalog.widgets.length, 28);
+ assert.equal(catalog.widgets.length, 29);
assert.equal(catalog.languages.length, 11);
assert.ok(catalog.languages.some((language) => language.id === "cobol"));
});
diff --git a/examples/widgets/build-catalog.ts b/examples/widgets/build-catalog.ts
index 86c3250..bd674d4 100644
--- a/examples/widgets/build-catalog.ts
+++ b/examples/widgets/build-catalog.ts
@@ -95,6 +95,8 @@ export const WIDGETS: WidgetSpec[] = [
blurb: "Nested rows with expand state and per-node value columns." },
{ id: "log", title: "Log", category: "Data", width: 62, height: 5,
blurb: "Levelled log lines that tail by default and scroll back from the end." },
+ { id: "scrollbar", title: "Scrollbar", category: "Data", width: 46, height: 4,
+ blurb: "A bar over state you own, on any of the four edges, for anything that scrolls." },
{ id: "meter", title: "Meter", category: "Meters", width: 48, height: 3,
blurb: "A labelled bar. Smooth or segmented, heat-colored by default." },
diff --git a/examples/widgets/gallery.js b/examples/widgets/gallery.js
index 8422d25..660f930 100644
--- a/examples/widgets/gallery.js
+++ b/examples/widgets/gallery.js
@@ -178,6 +178,17 @@ export function log(ui) {
}
// @end
+// @widget scrollbar
+export function scrollbar(ui, theme) {
+ // The bar is over state you own, so it works beside anything that scrolls:
+ // wrapped prose, a canvas, a draw() of your own.
+ ui.row({ gap: 1 }, (r) => {
+ r.text("A scrollbar you drive yourself. It has no idea what is beside it, only how much there is, how much fits, and where you are.", { wrap: true, fg: theme.foreground });
+ r.scrollbar({ total: 40, viewport: 5, offset: 12, size: 1 });
+ });
+}
+// @end
+
// ----------------------------------------------------------------- meters
// @widget meter
diff --git a/examples/widgets/gallery.ts b/examples/widgets/gallery.ts
index 6631437..42fe5c9 100644
--- a/examples/widgets/gallery.ts
+++ b/examples/widgets/gallery.ts
@@ -176,6 +176,21 @@ export function log(ui: Container, _theme: Theme): void {
}
// @end
+// @widget scrollbar
+export function scrollbar(ui: Container, theme: Theme): void {
+ // The bar is over state you own, so it works beside anything that scrolls:
+ // wrapped prose, a canvas, a draw() of your own.
+ ui.row({ gap: 1 }, (r) => {
+ r.text(
+ "A scrollbar you drive yourself. It has no idea what is beside it, only " +
+ "how much there is, how much fits, and where you are.",
+ { wrap: true, fg: theme.foreground },
+ );
+ r.scrollbar({ total: 40, viewport: 5, offset: 12, size: 1 });
+ });
+}
+// @end
+
// ----------------------------------------------------------------- meters
// @widget meter
diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts
index 7dfb462..102940e 100644
--- a/packages/hqtui/src/ui.ts
+++ b/packages/hqtui/src/ui.ts
@@ -365,6 +365,35 @@ export class Container {
}, this.sizeOf(options, "fill", options.items.length));
}
+ /**
+ * A scrollbar over state you own, for anything that scrolls and is not a
+ * table: a wrapped paragraph, a canvas, a `draw()` of your own.
+ *
+ * The wheel and a click on the track both arrive as `onScroll`, so the same
+ * handler that drives the content drives the bar. A click reports the delta
+ * that would land the thumb where you clicked, which makes the bar a way to
+ * move rather than a picture of where you are.
+ */
+ scrollbar(options: W.ScrollbarOptions & ContainerOptions & ScrollHandlers): this {
+ const vertical = W.isVertical(options.orientation ?? "right");
+ return this.add((s) => {
+ W.drawScrollbarWidget(s, options);
+ if (!options.onScroll && !options.onFocus) return;
+ const track = vertical ? s.height : s.width;
+ this.ctx.hit({
+ rect: s.hitRect(),
+ onScroll: options.onScroll ? (delta) => options.onScroll?.(delta) : undefined,
+ onClick: (x, y) => {
+ options.onFocus?.();
+ const at = W.offsetForPosition(vertical ? y : x, track, options.total, options.viewport);
+ options.onScroll?.(at - options.offset);
+ },
+ });
+ // A bar is one cell across its short axis; along its long one it takes
+ // whatever it is given.
+ }, this.sizeOf(options, vertical ? "fill" : 1, vertical ? undefined : 1));
+ }
+
tree(options: W.TreeOptions & ContainerOptions & ScrollHandlers): this {
return this.add((s) => {
W.drawTree(s, options);
diff --git a/packages/hqtui/src/widgets/index.ts b/packages/hqtui/src/widgets/index.ts
index 3fe7d57..ca92b8c 100644
--- a/packages/hqtui/src/widgets/index.ts
+++ b/packages/hqtui/src/widgets/index.ts
@@ -1,4 +1,5 @@
export * from "./text.ts";
+export * from "./scrollbar.ts";
export * from "./table.ts";
export * from "./meters.ts";
export * from "./controls.ts";
diff --git a/packages/hqtui/src/widgets/scrollbar.ts b/packages/hqtui/src/widgets/scrollbar.ts
new file mode 100644
index 0000000..a4d1e69
--- /dev/null
+++ b/packages/hqtui/src/widgets/scrollbar.ts
@@ -0,0 +1,136 @@
+/**
+ * A scrollbar, on its own.
+ *
+ * The renderer used to live inside the table and was reachable only by being a
+ * table, list, tree or log. Anything else that scrolls -- a wrapped paragraph,
+ * a canvas, a `draw()` somebody wrote themselves -- could not show one.
+ *
+ * This is the same drawing, lifted out and given the four edges plus state the
+ * caller owns. The widgets that had `scrollbar: true` route through it, so
+ * there is one implementation and one appearance.
+ */
+import type { Surface } from "../surface.ts";
+import { mix } from "../color.ts";
+
+/** Which edge the bar sits on, and therefore which way it runs. */
+export type ScrollbarOrientation = "right" | "left" | "bottom" | "top";
+
+export interface ScrollbarState {
+ /** How much there is to scroll through. */
+ total: number;
+ /** How much of it is visible at once. */
+ viewport: number;
+ /** How far down (or across) we are. */
+ offset: number;
+}
+
+export interface ScrollbarOptions extends ScrollbarState {
+ orientation?: ScrollbarOrientation;
+}
+
+export function isVertical(orientation: ScrollbarOrientation): boolean {
+ return orientation === "right" || orientation === "left";
+}
+
+/**
+ * Where the thumb sits and how long it is, in cells along the track.
+ *
+ * Split out because it is the whole of the behaviour: everything else is
+ * putting characters in a line. A thumb is never shorter than one cell, or it
+ * would vanish on a long document, and never starts past the end of the track.
+ *
+ * The thumb is as long as the visible fraction, so it needs the viewport as
+ * well as the track. For a table those are the same number -- the bar is
+ * exactly as tall as the rows it describes -- which is why the old signature
+ * did without it. A bar you place yourself has no such guarantee: twenty cells
+ * of track can describe an eight-line window, and sizing the thumb from the
+ * track would then report the wrong fraction. `viewport` defaults to the track
+ * so the widgets that call the five-argument form are unaffected.
+ */
+export function thumb(
+ track: number,
+ total: number,
+ offset: number,
+ viewport: number = track,
+): { start: number; size: number } {
+ if (track <= 0 || total <= 0) return { start: 0, size: 0 };
+ const visible = viewport > 0 ? viewport : track;
+ if (total <= visible) return { start: 0, size: track };
+
+ const size = Math.max(1, Math.min(track, Math.round((visible / total) * track)));
+ const maxOffset = Math.max(1, total - visible);
+ const clamped = Math.max(0, Math.min(offset, maxOffset));
+ const start = Math.round((clamped / maxOffset) * (track - size));
+ return { start: Math.max(0, Math.min(start, track - size)), size };
+}
+
+/**
+ * The original signature, kept because the table, list, tree and log all call
+ * it this way and their fixtures pin the result.
+ */
+export function drawScrollbar(
+ surface: Surface,
+ x: number,
+ y: number,
+ height: number,
+ total: number,
+ offset: number,
+): void {
+ const theme = surface.theme;
+ const track = mix(theme.background, theme.border, 0.7);
+ const { start, size } = thumb(height, total, offset);
+ for (let i = 0; i < height; i++) {
+ const inThumb = i >= start && i < start + size;
+ surface.char(x, y + i, inThumb ? "█" : "│", { fg: inThumb ? theme.accent : track });
+ }
+}
+
+/**
+ * A scrollbar filling the surface it is given, on whichever edge.
+ *
+ * A horizontal bar uses the half-height glyphs rather than the full block: a
+ * run of █ across a row reads as a solid rule, which is not what a thumb is
+ * meant to look like.
+ */
+export function drawScrollbarWidget(surface: Surface, options: ScrollbarOptions): void {
+ if (surface.empty) return;
+ const orientation = options.orientation ?? "right";
+ const vertical = isVertical(orientation);
+ const theme = surface.theme;
+ const trackColor = mix(theme.background, theme.border, 0.7);
+
+ const length = vertical ? surface.height : surface.width;
+ const viewport = options.viewport > 0 ? options.viewport : length;
+ const { start, size } = thumb(length, options.total, options.offset, viewport);
+
+ const line = vertical ? (orientation === "right" ? surface.width - 1 : 0)
+ : (orientation === "bottom" ? surface.height - 1 : 0);
+
+ for (let i = 0; i < length; i++) {
+ const inThumb = i >= start && i < start + size;
+ const glyph = vertical ? (inThumb ? "█" : "│") : (inThumb ? "━" : "─");
+ const style = { fg: inThumb ? theme.accent : trackColor };
+ if (vertical) surface.char(line, i, glyph, style);
+ else surface.char(i, line, glyph, style);
+ }
+}
+
+/**
+ * Which offset a click at `position` along the track means.
+ *
+ * The thumb centres on the click, which is what every scrollbar does and what
+ * makes dragging feel like dragging rather than nudging.
+ */
+export function offsetForPosition(
+ position: number,
+ track: number,
+ total: number,
+ viewport: number,
+): number {
+ const visible = viewport > 0 ? viewport : track;
+ if (track <= 0 || total <= visible) return 0;
+ const { size } = thumb(track, total, 0, visible);
+ const usable = Math.max(1, track - size);
+ const at = Math.max(0, Math.min(position - Math.floor(size / 2), usable));
+ return Math.round((at / usable) * (total - visible));
+}
diff --git a/packages/hqtui/src/widgets/table.ts b/packages/hqtui/src/widgets/table.ts
index 50829f6..df25ba9 100644
--- a/packages/hqtui/src/widgets/table.ts
+++ b/packages/hqtui/src/widgets/table.ts
@@ -3,6 +3,11 @@ import { Attr, type Style } from "../buffer.ts";
import type { Color } from "../color.ts";
import { mix } from "../color.ts";
import { fit, stringWidth, truncate } from "../unicode.ts";
+import { drawScrollbar } from "./scrollbar.ts";
+
+// Re-exported: it was part of this module's surface before it had one of
+// its own, and the widgets here still draw with it.
+export { drawScrollbar } from "./scrollbar.ts";
import { solve } from "../layout.ts";
import { elevate } from "../theme.ts";
@@ -148,26 +153,6 @@ export function drawTable(surface: Surface, options: TableOptions): vo
}
}
-/** A one-column scrollbar. Thumb size reflects the visible fraction. */
-export function drawScrollbar(
- surface: Surface,
- x: number,
- y: number,
- height: number,
- total: number,
- offset: number,
-): void {
- const theme = surface.theme;
- const track = mix(theme.background, theme.border, 0.7);
- const thumbSize = Math.max(1, Math.round((height / total) * height));
- const maxOffset = Math.max(1, total - height);
- const thumbPos = Math.round((offset / maxOffset) * (height - thumbSize));
- for (let i = 0; i < height; i++) {
- const inThumb = i >= thumbPos && i < thumbPos + thumbSize;
- surface.char(x, y + i, inThumb ? "█" : "│", { fg: inThumb ? theme.accent : track });
- }
-}
-
export interface ListOptions {
items: (string | { label: string; color?: Color; badge?: string })[];
selected?: number;
diff --git a/packages/hqtui/test/scrollbar.test.ts b/packages/hqtui/test/scrollbar.test.ts
new file mode 100644
index 0000000..5fa9df2
--- /dev/null
+++ b/packages/hqtui/test/scrollbar.test.ts
@@ -0,0 +1,139 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { renderToScreen } from "../src/index.ts";
+import { isVertical, offsetForPosition, thumb } from "../src/widgets/scrollbar.ts";
+
+/** The bar as a string, read down a column or across a row. */
+const bar = (
+ options: { total: number; viewport: number; offset: number; orientation?: "right" | "left" | "bottom" | "top" },
+ width = 3,
+ height = 8,
+): string => {
+ const frame = renderToScreen(({ ui }) => ui.scrollbar(options), { width, height });
+ const lines = frame.text().split("\n");
+ const vertical = isVertical(options.orientation ?? "right");
+ // A horizontal bar is a one-row container, so it draws on the first row
+ // whichever edge of that row it was asked for.
+ if (!vertical) return (lines[0] ?? "").trimEnd();
+ const column = options.orientation === "left" ? 0 : width - 1;
+ return lines.map((line) => line[column] ?? " ").join("");
+};
+
+test("scrollbar: the thumb is as long as the visible fraction", () => {
+ // Half the content visible, so half the track.
+ assert.deepEqual(thumb(10, 20, 0), { start: 0, size: 5 });
+ // A quarter visible.
+ assert.deepEqual(thumb(10, 40, 0), { start: 0, size: 3 });
+});
+
+test("scrollbar: a thumb never vanishes, however long the document", () => {
+ // A million lines in ten cells still has to be grabbable.
+ const { size } = thumb(10, 1_000_000, 0);
+ assert.equal(size, 1);
+});
+
+test("scrollbar: content that fits fills the track", () => {
+ // Nothing to scroll: a thumb over part of it would suggest otherwise.
+ assert.deepEqual(thumb(10, 10, 0), { start: 0, size: 10 });
+ assert.deepEqual(thumb(10, 4, 0), { start: 0, size: 10 });
+});
+
+test("scrollbar: the thumb reaches the bottom at the last offset, and no further", () => {
+ const track = 10;
+ const total = 30;
+ const { size } = thumb(track, total, 0);
+ const last = thumb(track, total, total - track);
+ assert.equal(last.start + size, track);
+
+ // Past the end is clamped rather than drawn off the track.
+ const beyond = thumb(track, total, 9999);
+ assert.deepEqual(beyond, last);
+ // And before the start.
+ assert.deepEqual(thumb(track, total, -5), thumb(track, total, 0));
+});
+
+test("scrollbar: nothing to draw is nothing, not a division by zero", () => {
+ assert.deepEqual(thumb(0, 10, 0), { start: 0, size: 0 });
+ assert.deepEqual(thumb(10, 0, 0), { start: 0, size: 0 });
+});
+
+test("scrollbar: it draws down the right edge by default", () => {
+ // 8 cells, 32 lines, so a 2-cell thumb at the top.
+ assert.equal(bar({ total: 32, viewport: 8, offset: 0 }), "██││││││");
+ assert.equal(bar({ total: 32, viewport: 8, offset: 24 }), "││││││██");
+});
+
+test("scrollbar: and down the left when asked", () => {
+ assert.equal(bar({ total: 32, viewport: 8, offset: 0, orientation: "left" }), "██││││││");
+});
+
+test("scrollbar: a horizontal bar uses a rule, not a wall of blocks", () => {
+ // A run of █ across a row reads as a solid rule rather than a thumb.
+ const top = bar({ total: 40, viewport: 10, offset: 0, orientation: "top" }, 10, 4);
+ assert.match(top, /^━+─+$/);
+ const bottom = bar({ total: 40, viewport: 10, offset: 30, orientation: "bottom" }, 10, 4);
+ assert.match(bottom, /^─+━+$/);
+});
+
+test("scrollbar: a click centres the thumb on it", () => {
+ const track = 10;
+ const total = 100;
+ const viewport = 10;
+ // Clicking the top puts us at the top, the bottom at the bottom.
+ assert.equal(offsetForPosition(0, track, total, viewport), 0);
+ assert.equal(offsetForPosition(track - 1, track, total, viewport), total - viewport);
+ // And the middle somewhere in the middle.
+ const middle = offsetForPosition(5, track, total, viewport);
+ assert.ok(middle > 0 && middle < total - viewport, `${middle}`);
+});
+
+test("scrollbar: nothing to scroll means every click is offset zero", () => {
+ assert.equal(offsetForPosition(5, 10, 10, 10), 0);
+ assert.equal(offsetForPosition(5, 10, 3, 10), 0);
+ assert.equal(offsetForPosition(5, 0, 100, 10), 0);
+});
+
+test("scrollbar: the extracted renderer still draws what the table drew", () => {
+ // The table, list, tree and log route through the same code, so their
+ // appearance is pinned by their own fixtures; this checks the shape directly.
+ const withList = renderToScreen(
+ ({ ui }) => ui.list({ items: Array.from({ length: 40 }, (_, i) => `row ${i}`), scrollbar: true }),
+ { width: 12, height: 6 },
+ ).text().split("\n");
+ const column = withList.map((line) => line[11] ?? " ").join("");
+ assert.match(column, /^█+│+$/);
+});
+
+test("scrollbar: clicking the track moves you there", () => {
+ // 100 lines, 10 visible, 20 cells of track.
+ let offset = 0;
+ const screen = renderToScreen(
+ ({ ui }) => ui.scrollbar({ total: 100, viewport: 10, offset, onScroll: (d) => { offset += d; } }),
+ { width: 2, height: 20 },
+ );
+ const region = screen.regions[0];
+ assert.ok(region, "the bar registers a hit region");
+
+ // The wheel is a plain delta, as it is for every other scrollable widget.
+ region.onScroll?.(1);
+ assert.equal(offset, 1);
+
+ // A click reports the delta that lands the thumb where you clicked, so one
+ // handler serves both.
+ offset = 0;
+ region.onClick?.(0, 19, "left");
+ assert.equal(offset, 90);
+ offset = 0;
+ region.onClick?.(0, 0, "left");
+ assert.equal(offset, 0);
+});
+
+test("scrollbar: a decorative bar registers nothing to click", () => {
+ // No handlers means no hit region, so it cannot eat a click meant for what
+ // is underneath it.
+ const screen = renderToScreen(({ ui }) => ui.scrollbar({ total: 100, viewport: 10, offset: 0 }), {
+ width: 2,
+ height: 20,
+ });
+ assert.equal(screen.regions.length, 0);
+});
diff --git a/ports/bindings/src/bridge.cpp b/ports/bindings/src/bridge.cpp
index 11af52f..798ddb7 100644
--- a/ports/bindings/src/bridge.cpp
+++ b/ports/bindings/src/bridge.cpp
@@ -79,7 +79,7 @@ void validate(const Json &n, int depth, int &count) {
"badge", "progress", "sparkline", "heatbar", "columns", "donut",
"list", "tree", "button", "checkbox", "select", "input",
"tabs", "statusbar", "label", "heading", "meters", "modal",
- "commandpalette", "tooltip"};
+ "commandpalette", "tooltip", "scrollbar"};
if (std::find(types.begin(), types.end(), type) == types.end())
throw std::runtime_error("unknown widget: " + type);
if (!n["children"].null() &&
@@ -271,6 +271,17 @@ void node(UI &ui, const Json &n, std::vector &overlays) {
if (d.segments.size() > 64)
throw std::runtime_error("too many donut segments");
ui.donut(d, size(n));
+ } else if (type == "scrollbar") {
+ Scrollbar bar;
+ bar.total = integer(n["total"], 0, 0, 1000000);
+ bar.viewport = integer(n["viewport"], 0, 0, 1000000);
+ bar.offset = integer(n["offset"], 0, 0, 1000000);
+ auto edge = n["orientation"].s("right");
+ bar.orientation = edge == "left" ? HQ_SCROLLBAR_LEFT
+ : edge == "bottom" ? HQ_SCROLLBAR_BOTTOM
+ : edge == "top" ? HQ_SCROLLBAR_TOP
+ : HQ_SCROLLBAR_RIGHT;
+ ui.scrollbar(bar, n["id"].s(""));
} else if (type == "list") {
List l;
for (auto &item : n["items"].array())
diff --git a/ports/cobol/adapter/render.ts b/ports/cobol/adapter/render.ts
index e5f340f..3442c90 100644
--- a/ports/cobol/adapter/render.ts
+++ b/ports/cobol/adapter/render.ts
@@ -67,6 +67,9 @@ export function parseScenes(input: string): Scene[] {
const ALIGN: Record = { LEFT: "left", CENTER: "center", RIGHT: "right" };
+type Edge = "right" | "left" | "bottom" | "top";
+const EDGE: Record = { RIGHT: "right", LEFT: "left", BOTTOM: "bottom", TOP: "top" };
+
type ButtonVariant = "primary" | "success" | "warning" | "danger" | "ghost";
const VARIANT: Record = {
PRIMARY: "primary",
@@ -136,6 +139,17 @@ export function draw(scene: Scene, ui: Container, theme: Theme): void {
case "SELECT":
selected = Number(record.num) || 0;
break;
+ case "SCROLLBAR": {
+ // key is the edge, num the offset, text "total|viewport".
+ const [total = "", viewport = ""] = record.text.split("|");
+ ui.scrollbar({
+ total: Number(total) || 0,
+ viewport: Number(viewport) || 0,
+ offset: Number(record.num) || 0,
+ orientation: EDGE[record.key] ?? "right",
+ });
+ break;
+ }
case "LOG": {
const [time = "", message = "", meta = ""] = record.text.split("|");
entries.push({ time, level: record.key.toLowerCase(), message, meta: meta || undefined });
diff --git a/ports/cobol/examples/widgets.cbl b/ports/cobol/examples/widgets.cbl
index 0b492c0..cd484ea 100644
--- a/ports/cobol/examples/widgets.cbl
+++ b/ports/cobol/examples/widgets.cbl
@@ -68,6 +68,7 @@ MAIN-PARAGRAPH.
PERFORM HEATBAR-WIDGET
PERFORM DONUT-WIDGET
PERFORM LIST-WIDGET
+ PERFORM SCROLLBAR-WIDGET
PERFORM TREE-WIDGET
PERFORM BUTTON-WIDGET
PERFORM CHECKBOX-WIDGET
@@ -383,6 +384,25 @@ LIST-WIDGET.
PERFORM EMIT-RECORD.
*> @end
+*> @widget scrollbar
+SCROLLBAR-WIDGET.
+ MOVE "scrollbar" TO SR-KEY
+ PERFORM START-WIDGET
+
+ MOVE "TEXT" TO SR-VERB
+ MOVE "LEFT" TO SR-KEY
+ MOVE "120 lines, 8 of them on screen, starting at 36." TO SR-TEXT
+ PERFORM EMIT-RECORD
+
+ *> The bar is over state you own: key is the edge it sits on, num the
+ *> offset, and the text carries total and viewport.
+ MOVE "SCROLLBAR" TO SR-VERB
+ MOVE "BOTTOM" TO SR-KEY
+ MOVE "36" TO SR-NUM
+ MOVE "120|8" TO SR-TEXT
+ PERFORM EMIT-RECORD.
+*> @end
+
*> @widget tree
TREE-WIDGET.
MOVE "tree" TO SR-KEY
diff --git a/ports/conformance/fixtures/widgets.json b/ports/conformance/fixtures/widgets.json
index 7421db6..3376bad 100644
--- a/ports/conformance/fixtures/widgets.json
+++ b/ports/conformance/fixtures/widgets.json
@@ -13251,6 +13251,1052 @@
]
}
},
+ {
+ "name": "scrollbar-right",
+ "width": 4,
+ "height": 8,
+ "result": {
+ "width": 4,
+ "height": 8,
+ "chars": [
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ]
+ ],
+ "fg": [
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ]
+ ],
+ "bg": [
+ [
+ 32,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 32,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " │",
+ " │",
+ " █",
+ " █",
+ " │",
+ " │",
+ " │",
+ " │"
+ ]
+ }
+ },
+ {
+ "name": "scrollbar-left",
+ "width": 4,
+ "height": 8,
+ "result": {
+ "width": 4,
+ "height": 8,
+ "chars": [
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ]
+ ],
+ "fg": [
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ]
+ ],
+ "bg": [
+ [
+ 32,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 32,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ "│ ",
+ "│ ",
+ "█ ",
+ "█ ",
+ "│ ",
+ "│ ",
+ "│ ",
+ "│ "
+ ]
+ }
+ },
+ {
+ "name": "scrollbar-bottom",
+ "width": 20,
+ "height": 2,
+ "result": {
+ "width": 20,
+ "height": 2,
+ "chars": [
+ [
+ 20,
+ 32
+ ],
+ [
+ 8,
+ 9472
+ ],
+ [
+ 5,
+ 9473
+ ],
+ [
+ 7,
+ 9472
+ ]
+ ],
+ "fg": [
+ [
+ 20,
+ 29806811
+ ],
+ [
+ 8,
+ 18555952
+ ],
+ [
+ 5,
+ 22467805
+ ],
+ [
+ 7,
+ 18555952
+ ]
+ ],
+ "bg": [
+ [
+ 40,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 40,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " ",
+ "────────━━━━━───────"
+ ]
+ }
+ },
+ {
+ "name": "scrollbar-top",
+ "width": 20,
+ "height": 2,
+ "result": {
+ "width": 20,
+ "height": 2,
+ "chars": [
+ [
+ 8,
+ 9472
+ ],
+ [
+ 5,
+ 9473
+ ],
+ [
+ 7,
+ 9472
+ ],
+ [
+ 20,
+ 32
+ ]
+ ],
+ "fg": [
+ [
+ 8,
+ 18555952
+ ],
+ [
+ 5,
+ 22467805
+ ],
+ [
+ 7,
+ 18555952
+ ],
+ [
+ 20,
+ 29806811
+ ]
+ ],
+ "bg": [
+ [
+ 40,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 40,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ "────────━━━━━───────",
+ " "
+ ]
+ }
+ },
+ {
+ "name": "scrollbar-fits",
+ "width": 4,
+ "height": 8,
+ "result": {
+ "width": 4,
+ "height": 8,
+ "chars": [
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ]
+ ],
+ "fg": [
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ]
+ ],
+ "bg": [
+ [
+ 32,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 32,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " █",
+ " █",
+ " █",
+ " █",
+ " █",
+ " █",
+ " █",
+ " █"
+ ]
+ }
+ },
+ {
+ "name": "scrollbar-viewport",
+ "width": 4,
+ "height": 20,
+ "result": {
+ "width": 4,
+ "height": 20,
+ "chars": [
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 9474
+ ]
+ ],
+ "fg": [
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 22467805
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 1,
+ 18555952
+ ]
+ ],
+ "bg": [
+ [
+ 80,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 80,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " │",
+ " │",
+ " │",
+ " │",
+ " │",
+ " │",
+ " █",
+ " │",
+ " │",
+ " │",
+ " │",
+ " │",
+ " │",
+ " │",
+ " │",
+ " │",
+ " │",
+ " │",
+ " │",
+ " │"
+ ]
+ }
+ },
+ {
+ "name": "scrollbar-viewport-wide",
+ "width": 62,
+ "height": 2,
+ "result": {
+ "width": 62,
+ "height": 2,
+ "chars": [
+ [
+ 62,
+ 32
+ ],
+ [
+ 19,
+ 9472
+ ],
+ [
+ 4,
+ 9473
+ ],
+ [
+ 39,
+ 9472
+ ]
+ ],
+ "fg": [
+ [
+ 62,
+ 29806811
+ ],
+ [
+ 19,
+ 18555952
+ ],
+ [
+ 4,
+ 22467805
+ ],
+ [
+ 39,
+ 18555952
+ ]
+ ],
+ "bg": [
+ [
+ 124,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 124,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " ",
+ "───────────────────━━━━───────────────────────────────────────"
+ ]
+ }
+ },
{
"name": "button",
"width": 20,
diff --git a/ports/conformance/generate.ts b/ports/conformance/generate.ts
index 434d296..3403f80 100644
Binary files a/ports/conformance/generate.ts and b/ports/conformance/generate.ts differ
diff --git a/ports/cpp/CMakeLists.txt b/ports/cpp/CMakeLists.txt
index 025e3db..d812c58 100644
--- a/ports/cpp/CMakeLists.txt
+++ b/ports/cpp/CMakeLists.txt
@@ -14,7 +14,7 @@ if(HQTUI_LTO)
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)
endif()
add_library(hqtui_cpp INTERFACE)
-add_library(hqtui_cpp_widgets src/widgets.cpp)
+add_library(hqtui_cpp_widgets src/widgets.cpp src/scrollbar.cpp)
set_target_properties(hqtui_cpp_widgets PROPERTIES POSITION_INDEPENDENT_CODE ON)
target_compile_features(hqtui_cpp_widgets PUBLIC cxx_std_17)
if(MSVC)
diff --git a/ports/cpp/examples/widgets.cpp b/ports/cpp/examples/widgets.cpp
index f6e184f..95a6352 100644
--- a/ports/cpp/examples/widgets.cpp
+++ b/ports/cpp/examples/widgets.cpp
@@ -84,6 +84,21 @@ void widget_log(Surface s) {
}
// @end
+// @widget scrollbar
+void widget_scrollbar(Surface s) {
+ // The bar is over state you own, so it works beside anything that scrolls:
+ // wrapped prose, a canvas, a draw() of your own. Here it takes the rightmost
+ // column and the prose takes the rest.
+ int w = s.rect().width, h = s.rect().height;
+ const auto &t = theme(s);
+ draw_text(s.sub({0, 0, w - 2, h}),
+ "A scrollbar you drive yourself. It has no idea what is beside it, "
+ "only how much there is, how much fits, and where you are.",
+ TextStyle{t.foreground, std::nullopt, HQ_LEFT, 0, true});
+ draw_scrollbar(s.sub({w - 1, 0, 1, h}), Scrollbar{40, 5, 12, HQ_SCROLLBAR_RIGHT});
+}
+// @end
+
// @widget meter
void widget_meter(Surface s) {
const int width = s.rect().width;
@@ -492,6 +507,7 @@ int main() {
{"list", widget_list},
{"tree", widget_tree},
{"log", widget_log},
+ {"scrollbar", widget_scrollbar},
{"meter", widget_meter},
{"meters", widget_meters},
{"progress", widget_progress},
diff --git a/ports/cpp/include/hqtui/widgets.hpp b/ports/cpp/include/hqtui/widgets.hpp
index 96eac02..d9a25c7 100644
--- a/ports/cpp/include/hqtui/widgets.hpp
+++ b/ports/cpp/include/hqtui/widgets.hpp
@@ -331,7 +331,38 @@ void draw_progress(Surface, const Progress &);
void draw_graph(Surface, const Graph &);
void draw_gauge(Surface, double, std::string_view);
void draw_keys(Surface, const std::vector &, bool spread = true);
+/// Which edge a scrollbar sits on, and therefore which way it runs.
+enum ScrollbarOrientation {
+ HQ_SCROLLBAR_RIGHT,
+ HQ_SCROLLBAR_LEFT,
+ HQ_SCROLLBAR_BOTTOM,
+ HQ_SCROLLBAR_TOP,
+};
+inline bool is_vertical(ScrollbarOrientation o) {
+ return o == HQ_SCROLLBAR_RIGHT || o == HQ_SCROLLBAR_LEFT;
+}
+/// How much there is, how much of it is visible, and how far through it we are
+/// — the state the caller owns.
+struct Scrollbar {
+ int total = 0;
+ int viewport = 0;
+ int offset = 0;
+ ScrollbarOrientation orientation = HQ_SCROLLBAR_RIGHT;
+};
+/// Where the thumb starts and how long it is, in cells along the track.
+struct Thumb {
+ int start = 0;
+ int size = 0;
+};
+/// The thumb is as long as the visible fraction, so it needs the viewport as
+/// well as the track. For a table those are the same number, which is why
+/// `viewport` defaults to the track.
+Thumb thumb(int track, int total, int offset, int viewport = 0);
void draw_scrollbar(Surface, int, int, int, int, int);
+/// A scrollbar filling the surface it is given, on whichever edge.
+void draw_scrollbar(Surface, const Scrollbar &);
+/// Which offset a click at `position` along the track means.
+int offset_for_position(int position, int track, int total, int viewport);
/// Surface tinted one step above the theme's surface, for a control's chrome.
inline Color elevate(const hq_theme &t, double amount = .06) {
return hq_mix(t.surface, t.dark ? 0xffffffu : 0x000000u, amount);
@@ -756,6 +787,19 @@ class UI {
void donut(Donut o, Constraint size = fr()) {
draw([=](Surface s) { draw_donut(s, o); }, size);
}
+ /// A scrollbar over state you own, for anything that scrolls and is not a
+ /// table. A vertical bar fills the space it is given; a horizontal one is a
+ /// single row.
+ void scrollbar(Scrollbar o, std::string id = {}) {
+ auto regions_ = regions;
+ draw(
+ [=](Surface s) {
+ draw_scrollbar(s, o);
+ if (regions_ && !id.empty())
+ regions_->push_back({s.rect(), id, 0});
+ },
+ is_vertical(o.orientation) ? fr() : cells(1));
+ }
void list(List o, std::string id = {}) {
auto regions_ = regions;
draw([=](Surface s) {
diff --git a/ports/cpp/src/scrollbar.cpp b/ports/cpp/src/scrollbar.cpp
new file mode 100644
index 0000000..45ca4d8
--- /dev/null
+++ b/ports/cpp/src/scrollbar.cpp
@@ -0,0 +1,80 @@
+/// A scrollbar, on its own.
+///
+/// The renderer used to live inside the table and was reachable only by being a
+/// table, list, tree or log. Anything else that scrolls — a wrapped paragraph, a
+/// canvas, a `draw()` somebody wrote themselves — could not show one.
+///
+/// This is the same drawing, lifted out and given the four edges plus state the
+/// caller owns. The dense widgets route through it, so there is one
+/// implementation and one appearance.
+#include
+
+namespace hqtui {
+
+Thumb thumb(int track, int total, int offset, int viewport) {
+ if (track <= 0 || total <= 0)
+ return {0, 0};
+ int visible = viewport > 0 ? viewport : track;
+ if (total <= visible)
+ return {0, track};
+ int size = std::min(track, std::max(1, iround(double(visible) / total * track)));
+ int max_offset = std::max(1, total - visible);
+ int clamped = std::clamp(offset, 0, max_offset);
+ int start = iround(double(clamped) / max_offset * (track - size));
+ return {std::clamp(start, 0, track - size), size};
+}
+
+void draw_scrollbar(Surface s, int x, int y, int h, int total, int offset) {
+ // The table draws its bar unconditionally and leans on this guard to keep it
+ // off screen when everything already fits, so the early return has to stay.
+ if (h <= 0 || total <= h)
+ return;
+ auto &t = theme(s);
+ Thumb bar = thumb(h, total, offset, h);
+ for (int i = 0; i < h; i++) {
+ bool in_thumb = i >= bar.start && i < bar.start + bar.size;
+ s.set(x, y + i, in_thumb ? 0x2588 : 0x2502,
+ Style().foreground(in_thumb ? t.accent : hq_mix(t.background, t.border, .7)));
+ }
+}
+
+void draw_scrollbar(Surface s, const Scrollbar &o) {
+ int w = s.rect().width, h = s.rect().height;
+ if (w <= 0 || h <= 0)
+ return;
+ bool vertical = is_vertical(o.orientation);
+ auto &t = theme(s);
+ Color track_color = hq_mix(t.background, t.border, .7);
+
+ int length = vertical ? h : w;
+ int viewport = o.viewport > 0 ? o.viewport : length;
+ Thumb bar = thumb(length, o.total, o.offset, viewport);
+
+ int line = vertical ? (o.orientation == HQ_SCROLLBAR_RIGHT ? w - 1 : 0)
+ : (o.orientation == HQ_SCROLLBAR_BOTTOM ? h - 1 : 0);
+
+ for (int i = 0; i < length; i++) {
+ bool in_thumb = i >= bar.start && i < bar.start + bar.size;
+ // A horizontal bar uses the half-height glyphs rather than the full block:
+ // a run of full blocks across a row reads as a solid rule, which is not
+ // what a thumb is meant to look like.
+ uint32_t glyph = vertical ? (in_thumb ? 0x2588 : 0x2502) : (in_thumb ? 0x2501 : 0x2500);
+ Style style = Style().foreground(in_thumb ? t.accent : track_color);
+ if (vertical)
+ s.set(line, i, glyph, style);
+ else
+ s.set(i, line, glyph, style);
+ }
+}
+
+int offset_for_position(int position, int track, int total, int viewport) {
+ int visible = viewport > 0 ? viewport : track;
+ if (track <= 0 || total <= visible)
+ return 0;
+ int size = thumb(track, total, 0, visible).size;
+ int usable = std::max(1, track - size);
+ int at = std::clamp(position - size / 2, 0, usable);
+ return iround(double(at) / usable * (total - visible));
+}
+
+} // namespace hqtui
diff --git a/ports/cpp/src/widgets.cpp b/ports/cpp/src/widgets.cpp
index ba0dc12..d429c72 100644
--- a/ports/cpp/src/widgets.cpp
+++ b/ports/cpp/src/widgets.cpp
@@ -729,18 +729,6 @@ void draw_gauge(Surface s, double value, std::string_view label) {
if (!label.empty())
aligned(s, h - 1, label, color, HQ_CENTER, HQ_BOLD);
}
-void draw_scrollbar(Surface s, int x, int y, int h, int total, int offset) {
- if (h <= 0 || total <= h)
- return;
- auto &t = theme(s);
- int thumb = std::max(1, iround(double(h) / total * h)),
- pos = iround(double(offset) / std::max(1, total - h) * (h - thumb));
- for (int i = 0; i < h; i++)
- s.set(x, y + i, i >= pos && i < pos + thumb ? 0x2588 : 0x2502,
- Style().foreground(i >= pos && i < pos + thumb
- ? t.accent
- : hq_mix(t.background, t.border, .7)));
-}
void draw_table(Surface s, const Table &o) {
int w = s.rect().width, h = s.rect().height,
bodyw = std::max(0, w - int(o.scrollbar)),
diff --git a/ports/cpp/tests/conformance_widgets.cpp b/ports/cpp/tests/conformance_widgets.cpp
index dbaaf84..a803c8d 100644
--- a/ports/cpp/tests/conformance_widgets.cpp
+++ b/ports/cpp/tests/conformance_widgets.cpp
@@ -224,6 +224,34 @@ bool draw_scene(const std::string &name, Surface s) {
draw_scrollbar(s, 1, 0, 8, 40, 12);
return true;
}
+ if (name == "scrollbar-right") {
+ draw_scrollbar(s, Scrollbar{40, 8, 12, HQ_SCROLLBAR_RIGHT});
+ return true;
+ }
+ if (name == "scrollbar-left") {
+ draw_scrollbar(s, Scrollbar{40, 8, 12, HQ_SCROLLBAR_LEFT});
+ return true;
+ }
+ if (name == "scrollbar-bottom") {
+ draw_scrollbar(s, Scrollbar{80, 20, 30, HQ_SCROLLBAR_BOTTOM});
+ return true;
+ }
+ if (name == "scrollbar-top") {
+ draw_scrollbar(s, Scrollbar{80, 20, 30, HQ_SCROLLBAR_TOP});
+ return true;
+ }
+ if (name == "scrollbar-fits") {
+ draw_scrollbar(s, Scrollbar{5, 8, 0, HQ_SCROLLBAR_RIGHT});
+ return true;
+ }
+ if (name == "scrollbar-viewport") {
+ draw_scrollbar(s, Scrollbar{120, 8, 36, HQ_SCROLLBAR_RIGHT});
+ return true;
+ }
+ if (name == "scrollbar-viewport-wide") {
+ draw_scrollbar(s, Scrollbar{120, 8, 36, HQ_SCROLLBAR_BOTTOM});
+ return true;
+ }
if (name == "table") {
Table table;
table.columns = {{"PID", -1, 1, 0, HQ_RIGHT}, {"NAME"}, {"CPU%", -1, 1, 0, HQ_RIGHT}};
diff --git a/ports/go/conformance_widgets_test.go b/ports/go/conformance_widgets_test.go
index cc71bce..4696475 100644
--- a/ports/go/conformance_widgets_test.go
+++ b/ports/go/conformance_widgets_test.go
@@ -161,6 +161,20 @@ func drawWidgetScene(t *testing.T, name string, s Surface) {
}})
case "scrollbar":
DrawScrollbar(s, 1, 0, 8, 40, 12)
+ case "scrollbar-right":
+ DrawScrollbarWidget(s, ScrollbarOptions{Total: 40, Viewport: 8, Offset: 12})
+ case "scrollbar-left":
+ DrawScrollbarWidget(s, ScrollbarOptions{Total: 40, Viewport: 8, Offset: 12, Orientation: ScrollbarLeft})
+ case "scrollbar-bottom":
+ DrawScrollbarWidget(s, ScrollbarOptions{Total: 80, Viewport: 20, Offset: 30, Orientation: ScrollbarBottom})
+ case "scrollbar-top":
+ DrawScrollbarWidget(s, ScrollbarOptions{Total: 80, Viewport: 20, Offset: 30, Orientation: ScrollbarTop})
+ case "scrollbar-fits":
+ DrawScrollbarWidget(s, ScrollbarOptions{Total: 5, Viewport: 8, Offset: 0})
+ case "scrollbar-viewport":
+ DrawScrollbarWidget(s, ScrollbarOptions{Total: 120, Viewport: 8, Offset: 36})
+ case "scrollbar-viewport-wide":
+ DrawScrollbarWidget(s, ScrollbarOptions{Total: 120, Viewport: 8, Offset: 36, Orientation: ScrollbarBottom})
case "button":
DrawButton(s, ButtonOptions{Label: "OK"})
case "button-focused":
diff --git a/ports/go/examples/widgets/main.go b/ports/go/examples/widgets/main.go
index 0afbe18..3a6c34f 100644
--- a/ports/go/examples/widgets/main.go
+++ b/ports/go/examples/widgets/main.go
@@ -189,6 +189,21 @@ func Log(ui *hqtui.Container) {
// ----------------------------------------------------------------- meters
+// @widget scrollbar
+func Scrollbar(ui *hqtui.Container) {
+ // The bar is over state you own, so it works beside anything that scrolls:
+ // wrapped prose, a canvas, a Draw of your own.
+ ui.Row(hqtui.RowOptions{Layout: hqtui.Layout{Gap: 1}}, func(r *hqtui.Container) {
+ r.StyledText(
+ "A scrollbar you drive yourself. It has no idea what is beside it, only how much there is, how much fits, and where you are.",
+ hqtui.TextStyle{Wrap: true},
+ )
+ r.Scrollbar(hqtui.ScrollbarOptions{Total: 40, Viewport: 5, Offset: 12}, hqtui.ScrollHandlers{})
+ })
+}
+
+// @end
+
// @widget meter
func Meter(ui *hqtui.Container) {
ui.Meter(hqtui.MeterOptions{Value: 0.62, Label: "CPU"})
@@ -391,6 +406,7 @@ func main() {
{"text", Text}, {"label", Label}, {"heading", Heading}, {"badge", Badge},
{"divider", Divider}, {"keyValues", KeyValues}, {"statusBar", StatusBar},
{"table", Table}, {"list", List}, {"tree", Tree}, {"log", Log},
+ {"scrollbar", Scrollbar},
{"meter", Meter}, {"meters", Meters}, {"progress", Progress}, {"graph", Graph},
{"sparkline", Sparkline}, {"histogram", Histogram}, {"heatBar", HeatBar},
{"gauge", Gauge}, {"donut", Donut},
diff --git a/ports/go/ui.go b/ports/go/ui.go
index 1b1a293..2fe39c0 100644
--- a/ports/go/ui.go
+++ b/ports/go/ui.go
@@ -426,6 +426,20 @@ func (c *Container) List(o ListOptions, h ScrollHandlers, layout ...Layout) *Con
})
}
+// Scrollbar draws a bar over state you own, for anything that scrolls and is
+// not a table: wrapped prose, a canvas, a Draw of your own. A vertical bar
+// fills the space it is given; a horizontal one is a single row.
+func (c *Container) Scrollbar(o ScrollbarOptions, h ScrollHandlers, layout ...Layout) *Container {
+ fallback := Fill()
+ if !o.Orientation.IsVertical() {
+ fallback = Cells(1)
+ }
+ return c.add(c.constraintOfLayout(firstLayout(layout), fallback, nil), func(s Surface) {
+ DrawScrollbarWidget(s, o)
+ c.attachScroll(s, h, 0)
+ })
+}
+
func (c *Container) Tree(o TreeOptions, h ScrollHandlers, layout ...Layout) *Container {
return c.add(c.filling(firstLayout(layout)), func(s Surface) {
DrawTree(s, o)
diff --git a/ports/go/widgets_scrollbar.go b/ports/go/widgets_scrollbar.go
new file mode 100644
index 0000000..0b86fd9
--- /dev/null
+++ b/ports/go/widgets_scrollbar.go
@@ -0,0 +1,159 @@
+package hqtui
+
+// A scrollbar, on its own.
+//
+// The renderer used to live inside the table and was reachable only by being a
+// table, list, tree or log. Anything else that scrolls — a wrapped paragraph, a
+// canvas, a Draw somebody wrote themselves — could not show one.
+//
+// This is the same drawing, lifted out and given the four edges plus state the
+// caller owns. The dense widgets route through it, so there is one
+// implementation and one appearance.
+
+// ScrollbarOrientation is which edge the bar sits on, and therefore which way
+// it runs.
+type ScrollbarOrientation int
+
+const (
+ ScrollbarRight ScrollbarOrientation = iota
+ ScrollbarLeft
+ ScrollbarBottom
+ ScrollbarTop
+)
+
+// IsVertical reports whether the bar runs down a column rather than across a
+// row.
+func (o ScrollbarOrientation) IsVertical() bool {
+ return o == ScrollbarRight || o == ScrollbarLeft
+}
+
+// ScrollbarOptions is the state the caller owns: how much there is, how much of
+// it is visible, and how far through it we are.
+type ScrollbarOptions struct {
+ Total int
+ Viewport int
+ Offset int
+ Orientation ScrollbarOrientation
+}
+
+// Thumb returns where the thumb sits and how long it is, in cells along the
+// track.
+//
+// Split out because it is the whole of the behaviour: everything else is
+// putting characters in a line. A thumb is never shorter than one cell, or it
+// would vanish on a long document, and never starts past the end of the track.
+//
+// The thumb is as long as the visible fraction, so it needs the viewport as
+// well as the track. For a table those are the same number — the bar is
+// exactly as tall as the rows it describes. A bar you place yourself has no
+// such guarantee, so pass the window it describes; ThumbOf keeps the
+// track-is-the-viewport form the dense widgets use.
+func Thumb(track, total, offset, viewport int) (start, size int) {
+ if track <= 0 || total <= 0 {
+ return 0, 0
+ }
+ visible := viewport
+ if visible <= 0 {
+ visible = track
+ }
+ if total <= visible {
+ return 0, track
+ }
+ size = min(track, max(1, int(roundHalfUp(float64(visible)/float64(total)*float64(track)))))
+ maxOffset := max(1, total-visible)
+ clamped := max(0, min(offset, maxOffset))
+ start = int(roundHalfUp(float64(clamped) / float64(maxOffset) * float64(track-size)))
+ return max(0, min(start, track-size)), size
+}
+
+// ThumbOf is Thumb for a bar whose track is exactly the window it describes.
+func ThumbOf(track, total, offset int) (start, size int) {
+ return Thumb(track, total, offset, track)
+}
+
+// DrawScrollbar keeps the original signature, because the table, list, tree and
+// log all call it this way and their fixtures pin the result.
+func DrawScrollbar(s Surface, x, y, height, total, offset int) {
+ theme := s.Theme
+ track := theme.Background.Mix(theme.Border, 0.7)
+ start, size := ThumbOf(height, total, offset)
+ for i := 0; i < height; i++ {
+ inThumb := i >= start && i < start+size
+ ch := '│'
+ color := track
+ if inThumb {
+ ch, color = '█', theme.Accent
+ }
+ s.Glyph(x, y+i, ch, Style{Fg: &color})
+ }
+}
+
+// DrawScrollbarWidget fills the surface it is given, on whichever edge.
+//
+// A horizontal bar uses the half-height glyphs rather than the full block: a
+// run of full blocks across a row reads as a solid rule, which is not what a
+// thumb is meant to look like.
+func DrawScrollbarWidget(s Surface, o ScrollbarOptions) {
+ if s.Width() == 0 || s.Height() == 0 {
+ return
+ }
+ vertical := o.Orientation.IsVertical()
+ theme := s.Theme
+ trackColor := theme.Background.Mix(theme.Border, 0.7)
+
+ length := s.Width()
+ if vertical {
+ length = s.Height()
+ }
+ viewport := o.Viewport
+ if viewport <= 0 {
+ viewport = length
+ }
+ start, size := Thumb(length, o.Total, o.Offset, viewport)
+
+ line := 0
+ if vertical && o.Orientation == ScrollbarRight {
+ line = s.Width() - 1
+ } else if !vertical && o.Orientation == ScrollbarBottom {
+ line = s.Height() - 1
+ }
+
+ for i := 0; i < length; i++ {
+ inThumb := i >= start && i < start+size
+ ch := '─'
+ if vertical {
+ ch = '│'
+ }
+ color := trackColor
+ if inThumb {
+ ch, color = '━', theme.Accent
+ if vertical {
+ ch = '█'
+ }
+ }
+ if vertical {
+ s.Glyph(line, i, ch, Style{Fg: &color})
+ } else {
+ s.Glyph(i, line, ch, Style{Fg: &color})
+ }
+ }
+}
+
+// OffsetForPosition reports which offset a click at position along the track
+// means.
+//
+// The thumb centres on the click, which is what every scrollbar does and what
+// makes dragging feel like dragging rather than nudging.
+func OffsetForPosition(position, track, total, viewport int) int {
+ visible := viewport
+ if visible <= 0 {
+ visible = track
+ }
+ if track <= 0 || total <= visible {
+ return 0
+ }
+ _, size := Thumb(track, total, 0, visible)
+ usable := max(1, track-size)
+ at := max(0, min(position-size/2, usable))
+ return int(roundHalfUp(float64(at) / float64(usable) * float64(total-visible)))
+}
diff --git a/ports/go/widgets_table.go b/ports/go/widgets_table.go
index 6eb17b4..15c5e4d 100644
--- a/ports/go/widgets_table.go
+++ b/ports/go/widgets_table.go
@@ -213,25 +213,6 @@ func DrawTable(s Surface, o TableOptions) {
}
}
-// DrawScrollbar draws a one-column scrollbar. Thumb size reflects the visible
-// fraction.
-func DrawScrollbar(s Surface, x, y, height, total, offset int) {
- theme := s.Theme
- track := theme.Background.Mix(theme.Border, 0.7)
- thumbSize := max(1, int(roundHalfUp(float64(height)/float64(total)*float64(height))))
- maxOffset := max(1, total-height)
- thumbPos := int(roundHalfUp(float64(offset) / float64(maxOffset) * float64(height-thumbSize)))
- for i := 0; i < height; i++ {
- inThumb := i >= thumbPos && i < thumbPos+thumbSize
- ch := '│'
- color := track
- if inThumb {
- ch, color = '█', theme.Accent
- }
- s.Glyph(x, y+i, ch, Style{Fg: &color})
- }
-}
-
type ListItem struct {
Label string
Color *Color
diff --git a/ports/perl/examples/widgets.pl b/ports/perl/examples/widgets.pl
index 816a038..4baa181 100644
--- a/ports/perl/examples/widgets.pl
+++ b/ports/perl/examples/widgets.pl
@@ -84,6 +84,16 @@ sub widget_log {
}
# @end
+# @widget scrollbar
+sub widget_scrollbar {
+ my ($ui) = @_;
+ # The bar is over state you own: it knows how much there is, how much fits
+ # and where you are, and nothing about what it sits beside.
+ $ui->text('120 lines, 8 of them on screen, starting at 36.');
+ $ui->scrollbar(120, viewport => 8, offset => 36, orientation => 'bottom');
+}
+# @end
+
# @widget meter
sub widget_meter {
my ($ui) = @_;
@@ -294,6 +304,7 @@ sub widget_tooltip {
['keyValues', \&widget_key_values],
['table', \&widget_table],
['log', \&widget_log],
+ ['scrollbar', \&widget_scrollbar],
['meter', \&widget_meter],
['graph', \&widget_graph],
['gauge', \&widget_gauge],
diff --git a/ports/perl/lib/Hqtui.pm b/ports/perl/lib/Hqtui.pm
index 451791d..6703fff 100644
--- a/ports/perl/lib/Hqtui.pm
+++ b/ports/perl/lib/Hqtui.pm
@@ -63,6 +63,7 @@ sub heatbar { my ($s,$value,%o)=@_; $s->add('heatbar',value=>$value,%o); }
sub columns { my ($s,$values,%o)=@_; $s->add('columns',values=>$values,%o); }
sub donut { my ($s,$segments,%o)=@_; $s->add('donut',segments=>$segments,%o); }
sub list { my ($s,$items,%o)=@_; $s->add('list',items=>$items,%o); }
+sub scrollbar { my ($s,$total,%o)=@_; $s->add('scrollbar',total=>$total,%o); }
sub tree { my ($s,$nodes,%o)=@_; $s->add('tree',nodes=>$nodes,%o); }
sub button { my ($s,$label,%o)=@_; $s->add('button',label=>$label,%o); }
sub checkbox { my ($s,$label,%o)=@_; $s->add('checkbox',label=>$label,%o); }
diff --git a/ports/php/examples/widgets.php b/ports/php/examples/widgets.php
index 7b280d1..349573d 100644
--- a/ports/php/examples/widgets.php
+++ b/ports/php/examples/widgets.php
@@ -87,6 +87,16 @@ function widget_log(UI $ui): void
}
// @end
+// @widget scrollbar
+function widget_scrollbar(UI $ui): void
+{
+ // The bar is over state you own: it knows how much there is, how much fits
+ // and where you are, and nothing about what it sits beside.
+ $ui->text('120 lines, 8 of them on screen, starting at 36.');
+ $ui->scrollbar(120, ['viewport' => 8, 'offset' => 36, 'orientation' => 'bottom']);
+}
+// @end
+
// @widget meter
function widget_meter(UI $ui): void
{
@@ -298,6 +308,7 @@ function widget_tooltip(UI $ui): void
'keyValues' => 'widget_key_values',
'table' => 'widget_table',
'log' => 'widget_log',
+ 'scrollbar' => 'widget_scrollbar',
'meter' => 'widget_meter',
'graph' => 'widget_graph',
'gauge' => 'widget_gauge',
diff --git a/ports/php/src/Hqtui.php b/ports/php/src/Hqtui.php
index 1ea8902..437843c 100644
--- a/ports/php/src/Hqtui.php
+++ b/ports/php/src/Hqtui.php
@@ -78,6 +78,7 @@ public function heatbar(float $value, array $o = []): self { return $this->add('
public function columns(array $values, array $o = []): self { return $this->add('columns', ['values'=>$values, ...$o]); }
public function donut(array $segments, array $o = []): self { return $this->add('donut', ['segments'=>$segments, ...$o]); }
public function list(array $items, array $o = []): self { return $this->add('list', ['items'=>$items, ...$o]); }
+ public function scrollbar(int $total, array $o = []): self { return $this->add('scrollbar', ['total'=>$total, ...$o]); }
public function tree(array $nodes, array $o = []): self { return $this->add('tree', ['nodes'=>$nodes, ...$o]); }
public function button(string $label, array $o = []): self { return $this->add('button', ['label'=>$label, ...$o]); }
public function checkbox(string $label, array $o = []): self { return $this->add('checkbox', ['label'=>$label, ...$o]); }
diff --git a/ports/python/examples/widgets.py b/ports/python/examples/widgets.py
index 4640c13..69eca2f 100644
--- a/ports/python/examples/widgets.py
+++ b/ports/python/examples/widgets.py
@@ -190,6 +190,21 @@ def log(ui: Container) -> None:
# ----------------------------------------------------------------- meters
+# @widget scrollbar
+def scrollbar(ui: Container) -> None:
+ # The bar is over state you own, so it works beside anything that scrolls:
+ # wrapped prose, a canvas, a ``draw`` of your own.
+ def row(r: Container) -> None:
+ r.text(
+ "A scrollbar you drive yourself. It has no idea what is beside it, only how much there is, how much fits, and where you are.",
+ w.TextStyle(wrap=True),
+ )
+ r.scrollbar(w.ScrollbarOptions(total=40, viewport=5, offset=12))
+
+ ui.row(Layout(gap=1), row)
+# @end
+
+
# @widget meter
def meter(ui: Container) -> None:
ui.meter(w.MeterOptions(value=0.62, label="CPU"))
@@ -376,6 +391,7 @@ def tooltip(ui: Container) -> None:
("text", text), ("label", label), ("heading", heading), ("badge", badge),
("divider", divider), ("keyValues", key_values), ("statusBar", status_bar),
("table", table), ("list", list_), ("tree", tree), ("log", log),
+ ("scrollbar", scrollbar),
("meter", meter), ("meters", meters), ("progress", progress), ("graph", graph),
("sparkline", sparkline), ("histogram", histogram), ("heatBar", heat_bar),
("gauge", gauge), ("donut", donut),
diff --git a/ports/python/hqtui/ui.py b/ports/python/hqtui/ui.py
index 2b64fd1..b503c03 100644
--- a/ports/python/hqtui/ui.py
+++ b/ports/python/hqtui/ui.py
@@ -412,6 +412,22 @@ def draw(s: Surface) -> None:
return self._add(self._constraint(layout or Layout(), "fill", len(options.items)), draw)
+ def scrollbar(
+ self, options: w.ScrollbarOptions, handlers: ScrollHandlers | None = None,
+ layout: Layout | None = None,
+ ):
+ """A scrollbar over state you own, for anything that scrolls and is not
+ a table: wrapped prose, a canvas, a ``draw`` of your own. A vertical bar
+ fills the space it is given; a horizontal one is a single row."""
+ h = handlers or ScrollHandlers()
+
+ def draw(s: Surface) -> None:
+ w.draw_scrollbar_widget(s, options)
+ self._attach_scroll(s, h)
+
+ fallback = "fill" if w.is_vertical(options.orientation) else 1
+ return self._add(self._constraint(layout or Layout(), fallback), draw)
+
def tree(
self, options: w.TreeOptions, handlers: ScrollHandlers | None = None,
layout: Layout | None = None,
diff --git a/ports/python/hqtui/widgets/__init__.py b/ports/python/hqtui/widgets/__init__.py
index 680d957..856ec5f 100644
--- a/ports/python/hqtui/widgets/__init__.py
+++ b/ports/python/hqtui/widgets/__init__.py
@@ -61,11 +61,19 @@
TreeValue,
draw_list,
draw_log,
- draw_scrollbar,
draw_table,
draw_tree,
resolve_offset,
)
+from .scrollbar import (
+ ScrollbarOptions,
+ ScrollbarOrientation,
+ draw_scrollbar,
+ draw_scrollbar_widget,
+ is_vertical,
+ offset_for_position,
+ thumb,
+)
from .text import (
BadgeOptions,
BadgeVariant,
@@ -97,7 +105,8 @@
"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",
- "draw_scrollbar", "draw_select", "draw_sparkline", "draw_status_bar",
+ "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",
"draw_tree", "nice_label", "resolve_offset",
]
diff --git a/ports/python/hqtui/widgets/scrollbar.py b/ports/python/hqtui/widgets/scrollbar.py
new file mode 100644
index 0000000..307995c
--- /dev/null
+++ b/ports/python/hqtui/widgets/scrollbar.py
@@ -0,0 +1,127 @@
+"""A scrollbar, on its own.
+
+The renderer used to live inside the table and was reachable only by being a
+table, list, tree or log. Anything else that scrolls — a wrapped paragraph, a
+canvas, a ``draw()`` somebody wrote themselves — could not show one.
+
+This is the same drawing, lifted out and given the four edges plus state the
+caller owns. The dense widgets route through it, so there is one implementation
+and one appearance.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+from ..buffer import Style
+from ..color import round_half_up
+from ..surface import Surface
+
+#: Which edge the bar sits on, and therefore which way it runs.
+ScrollbarOrientation = Literal["right", "left", "bottom", "top"]
+
+
+@dataclass(frozen=True, slots=True)
+class ScrollbarOptions:
+ """How much there is, how much of it is visible, and how far through it we
+ are — the state the caller owns."""
+
+ total: int = 0
+ viewport: int = 0
+ offset: int = 0
+ orientation: ScrollbarOrientation = "right"
+
+
+def is_vertical(orientation: ScrollbarOrientation) -> bool:
+ return orientation in ("right", "left")
+
+
+def thumb(track: int, total: int, offset: int, viewport: int | None = None) -> tuple[int, int]:
+ """Where the thumb starts and how long it is, in cells along the track.
+
+ Split out because it is the whole of the behaviour: everything else is
+ putting characters in a line. A thumb is never shorter than one cell, or it
+ would vanish on a long document, and never starts past the end of the track.
+
+ The thumb is as long as the visible fraction, so it needs the viewport as
+ well as the track. For a table those are the same number — the bar is
+ exactly as tall as the rows it describes — which is why ``viewport``
+ defaults to the track. A bar you place yourself has no such guarantee.
+ """
+ if track <= 0 or total <= 0:
+ return (0, 0)
+ visible = viewport if viewport and viewport > 0 else track
+ if total <= visible:
+ return (0, track)
+ size = min(track, max(1, int(round_half_up(visible / total * track))))
+ max_offset = max(1, total - visible)
+ clamped = max(0, min(offset, max_offset))
+ start = int(round_half_up(clamped / max_offset * (track - size)))
+ return (max(0, min(start, track - size)), size)
+
+
+def draw_scrollbar(
+ surface: Surface, x: int, y: int, height: int, total: int, offset: int
+) -> None:
+ """The original signature, kept because the table, list, tree and log all
+ call it this way and their fixtures pin the result."""
+ theme = surface.theme
+ track = theme.background.mix(theme.border, 0.7)
+ start, size = thumb(height, total, offset)
+ for i in range(height):
+ in_thumb = start <= i < start + size
+ surface.char(
+ x, y + i, "█" if in_thumb else "│",
+ Style(fg=theme.accent if in_thumb else track),
+ )
+
+
+def draw_scrollbar_widget(surface: Surface, options: ScrollbarOptions) -> None:
+ """A scrollbar filling the surface it is given, on whichever edge.
+
+ A horizontal bar uses the half-height glyphs rather than the full block: a
+ run of full blocks across a row reads as a solid rule, which is not what a
+ thumb is meant to look like.
+ """
+ if surface.width == 0 or surface.height == 0:
+ return
+ vertical = is_vertical(options.orientation)
+ theme = surface.theme
+ track_color = theme.background.mix(theme.border, 0.7)
+
+ length = surface.height if vertical else surface.width
+ viewport = options.viewport if options.viewport > 0 else length
+ start, size = thumb(length, options.total, options.offset, viewport)
+
+ if vertical:
+ line = surface.width - 1 if options.orientation == "right" else 0
+ else:
+ line = surface.height - 1 if options.orientation == "bottom" else 0
+
+ for i in range(length):
+ in_thumb = start <= i < start + size
+ if vertical:
+ glyph = "█" if in_thumb else "│"
+ else:
+ glyph = "━" if in_thumb else "─"
+ style = Style(fg=theme.accent if in_thumb else track_color)
+ if vertical:
+ surface.char(line, i, glyph, style)
+ else:
+ surface.char(i, line, glyph, style)
+
+
+def offset_for_position(position: int, track: int, total: int, viewport: int) -> int:
+ """Which offset a click at ``position`` along the track means.
+
+ The thumb centres on the click, which is what every scrollbar does and what
+ makes dragging feel like dragging rather than nudging.
+ """
+ visible = viewport if viewport > 0 else track
+ if track <= 0 or total <= visible:
+ return 0
+ _, size = thumb(track, total, 0, visible)
+ usable = max(1, track - size)
+ at = max(0, min(position - size // 2, usable))
+ return int(round_half_up(at / usable * (total - visible)))
diff --git a/ports/python/hqtui/widgets/table.py b/ports/python/hqtui/widgets/table.py
index 8adac1e..47cc65f 100644
--- a/ports/python/hqtui/widgets/table.py
+++ b/ports/python/hqtui/widgets/table.py
@@ -18,6 +18,7 @@
from ..surface import Surface, TextOptions
from ..theme import elevate
from ..unicode import Align, fit, string_width, truncate
+from .scrollbar import draw_scrollbar
__all__ = [
"ListItem",
@@ -198,23 +199,6 @@ def draw_table(surface: Surface, options: TableOptions) -> None:
)
-def draw_scrollbar(
- surface: Surface, x: int, y: int, height: int, total: int, offset: int
-) -> None:
- """A one-column scrollbar. Thumb size reflects the visible fraction."""
- theme = surface.theme
- track = theme.background.mix(theme.border, 0.7)
- thumb_size = max(1, int(round_half_up(height / total * height)))
- max_offset = max(1, total - height)
- thumb_pos = int(round_half_up(offset / max_offset * (height - thumb_size)))
- for i in range(height):
- in_thumb = thumb_pos <= i < thumb_pos + thumb_size
- surface.char(
- x, y + i, "█" if in_thumb else "│",
- Style(fg=theme.accent if in_thumb else track),
- )
-
-
@dataclass(frozen=True, slots=True)
class ListItem:
label: str = ""
diff --git a/ports/python/tests/test_conformance_widgets.py b/ports/python/tests/test_conformance_widgets.py
index cd89678..bd23ce2 100644
--- a/ports/python/tests/test_conformance_widgets.py
+++ b/ports/python/tests/test_conformance_widgets.py
@@ -218,6 +218,28 @@ def draw_scene(case, name: str, s: Surface) -> None:
)
elif name == "scrollbar":
w.draw_scrollbar(s, 1, 0, 8, 40, 12)
+ elif name == "scrollbar-right":
+ w.draw_scrollbar_widget(s, w.ScrollbarOptions(total=40, viewport=8, offset=12))
+ elif name == "scrollbar-left":
+ w.draw_scrollbar_widget(
+ s, w.ScrollbarOptions(total=40, viewport=8, offset=12, orientation="left")
+ )
+ elif name == "scrollbar-bottom":
+ w.draw_scrollbar_widget(
+ s, w.ScrollbarOptions(total=80, viewport=20, offset=30, orientation="bottom")
+ )
+ elif name == "scrollbar-top":
+ w.draw_scrollbar_widget(
+ s, w.ScrollbarOptions(total=80, viewport=20, offset=30, orientation="top")
+ )
+ elif name == "scrollbar-fits":
+ w.draw_scrollbar_widget(s, w.ScrollbarOptions(total=5, viewport=8, offset=0))
+ elif name == "scrollbar-viewport":
+ w.draw_scrollbar_widget(s, w.ScrollbarOptions(total=120, viewport=8, offset=36))
+ elif name == "scrollbar-viewport-wide":
+ w.draw_scrollbar_widget(
+ s, w.ScrollbarOptions(total=120, viewport=8, offset=36, orientation="bottom")
+ )
elif name == "button":
w.draw_button(s, w.ButtonOptions(label="OK"))
elif name == "button-focused":
diff --git a/ports/ruby/examples/widgets.rb b/ports/ruby/examples/widgets.rb
index e61f287..72e9037 100644
--- a/ports/ruby/examples/widgets.rb
+++ b/ports/ruby/examples/widgets.rb
@@ -77,6 +77,15 @@ def log(ui)
end
# @end
+# @widget scrollbar
+def scrollbar(ui)
+ # The bar is over state you own: it knows how much there is, how much fits
+ # and where you are, and nothing about what it sits beside.
+ ui.text('120 lines, 8 of them on screen, starting at 36.')
+ ui.scrollbar(120, viewport: 8, offset: 36, orientation: 'bottom')
+end
+# @end
+
# @widget meter
def meter(ui)
ui.meter(0.62, label: 'CPU')
@@ -264,6 +273,7 @@ def tooltip(ui)
'keyValues' => method(:key_values),
'table' => method(:table),
'log' => method(:log),
+ 'scrollbar' => method(:scrollbar),
'meter' => method(:meter),
'graph' => method(:graph),
'gauge' => method(:gauge),
diff --git a/ports/ruby/lib/hqtui.rb b/ports/ruby/lib/hqtui.rb
index 2f40bbd..33c72db 100644
--- a/ports/ruby/lib/hqtui.rb
+++ b/ports/ruby/lib/hqtui.rb
@@ -71,6 +71,7 @@ def heatbar(value, **options) = add('heatbar', value: value, **options)
def columns(values, **options) = add('columns', values: values, **options)
def donut(segments, **options) = add('donut', segments: segments, **options)
def list(items, **options) = add('list', items: items, **options)
+ def scrollbar(total, **options) = add('scrollbar', total: total, **options)
def tree(nodes, **options) = add('tree', nodes: nodes, **options)
def button(label, **options) = add('button', label: label, **options)
def checkbox(label, **options) = add('checkbox', label: label, **options)
diff --git a/ports/rust/examples/cobol-bridge.rs b/ports/rust/examples/cobol-bridge.rs
index 0fbb935..7e3e8e5 100644
--- a/ports/rust/examples/cobol-bridge.rs
+++ b/ports/rust/examples/cobol-bridge.rs
@@ -148,6 +148,24 @@ fn draw(scene: &Scene, ui: &mut Container) {
"METER" => {
ui.meter(MeterOptions::new(record.num.parse().unwrap_or(0.0)).label(&record.key));
}
+ "SCROLLBAR" => {
+ // key is the edge, num the offset, text "total|viewport".
+ let mut parts = record.text.split('|');
+ ui.scrollbar(
+ ScrollbarOptions {
+ total: parts.next().unwrap_or("").parse().unwrap_or(0),
+ viewport: parts.next().unwrap_or("").parse().unwrap_or(0),
+ offset: record.num.parse().unwrap_or(0),
+ orientation: match record.key.as_str() {
+ "LEFT" => ScrollbarOrientation::Left,
+ "BOTTOM" => ScrollbarOrientation::Bottom,
+ "TOP" => ScrollbarOrientation::Top,
+ _ => ScrollbarOrientation::Right,
+ },
+ },
+ "",
+ );
+ }
"GRAPHPT" => points.push(record.num.parse().unwrap_or(0.0)),
"GAUGE" => {
ui.gauge(GaugeOptions {
diff --git a/ports/rust/examples/widgets.rs b/ports/rust/examples/widgets.rs
index 64c35cc..d1efcce 100644
--- a/ports/rust/examples/widgets.rs
+++ b/ports/rust/examples/widgets.rs
@@ -413,6 +413,23 @@ pub fn tooltip(ui: &mut Container) {
}
// @end
+// @widget scrollbar
+pub fn scrollbar(ui: &mut Container) {
+ // The bar is over state you own, so it works beside anything that scrolls:
+ // wrapped prose, a canvas, a `draw` of your own.
+ ui.row(Row::new().gap(1), |r| {
+ r.styled_text(
+ "A scrollbar you drive yourself. It has no idea what is beside it, only how much there is, how much fits, and where you are.",
+ TextStyle::new().wrapped(),
+ );
+ r.scrollbar(
+ ScrollbarOptions { total: 40, viewport: 5, offset: 12, ..Default::default() },
+ "",
+ );
+ });
+}
+// @end
+
/// Renders each widget on its own small screen and prints the lot.
fn main() {
let examples: Vec<(&str, fn(&mut Container))> = vec![
@@ -427,6 +444,7 @@ fn main() {
("list", list),
("tree", tree),
("log", log),
+ ("scrollbar", scrollbar),
("meter", meter),
("meters", meters),
("progress", progress),
diff --git a/ports/rust/src/ui.rs b/ports/rust/src/ui.rs
index f1f6f52..0ea3ad4 100644
--- a/ports/rust/src/ui.rs
+++ b/ports/rust/src/ui.rs
@@ -682,6 +682,22 @@ impl<'a> Container<'a> {
})
}
+ /// A scrollbar over state you own, for anything that scrolls and is not a
+ /// table: wrapped prose, a canvas, a `draw` of your own. A vertical bar
+ /// fills the space it is given; a horizontal one is a single row.
+ pub fn scrollbar(&mut self, options: w::ScrollbarOptions, id: &str) -> &mut Self {
+ let constraint =
+ if options.orientation.is_vertical() { self.filling() } else { self.leaf(1) };
+ let ctx = self.ctx.clone();
+ let id = id.to_string();
+ self.add(constraint, move |s| {
+ w::draw_scrollbar_widget(&s, &options);
+ if !id.is_empty() {
+ ctx.hit(&id, s.hit_rect(), 0);
+ }
+ })
+ }
+
pub fn tree(&mut self, options: w::TreeOptions, id: &str) -> &mut Self {
let constraint = self.filling();
let ctx = self.ctx.clone();
diff --git a/ports/rust/src/widgets/mod.rs b/ports/rust/src/widgets/mod.rs
index 2736927..d8b9c34 100644
--- a/ports/rust/src/widgets/mod.rs
+++ b/ports/rust/src/widgets/mod.rs
@@ -4,6 +4,7 @@
pub mod controls;
pub mod meters;
+pub mod scrollbar;
pub mod table;
pub mod text;
@@ -19,8 +20,12 @@ pub use meters::{
DonutSegment, GaugeOptions, GraphOptions, HeatBarOptions, MeterItem, MeterOptions,
MetersOptions, ProgressOptions, SparklineWidgetOptions,
};
+pub use scrollbar::{
+ draw_scrollbar, draw_scrollbar_widget, offset_for_position, thumb, thumb_of, ScrollbarOptions,
+ ScrollbarOrientation,
+};
pub use table::{
- draw_list, draw_log, draw_scrollbar, draw_table, draw_tree, resolve_offset, TableColumn,
+ draw_list, draw_log, draw_table, draw_tree, resolve_offset, TableColumn,
ListItem, ListOptions, LogEntry, LogOptions, TableOptions, TableRow, TreeNode, TreeOptions,
TreeValue,
};
diff --git a/ports/rust/src/widgets/scrollbar.rs b/ports/rust/src/widgets/scrollbar.rs
new file mode 100644
index 0000000..201d6fd
--- /dev/null
+++ b/ports/rust/src/widgets/scrollbar.rs
@@ -0,0 +1,157 @@
+//! A scrollbar, on its own.
+//!
+//! The renderer used to live inside the table and was reachable only by being a
+//! table, list, tree or log. Anything else that scrolls -- a wrapped paragraph,
+//! a canvas, a `draw()` somebody wrote themselves -- could not show one.
+//!
+//! This is the same drawing, lifted out and given the four edges plus state the
+//! caller owns. The dense widgets route through it, so there is one
+//! implementation and one appearance.
+
+use crate::buffer::Style;
+use crate::color::round_half_up;
+use crate::surface::Surface;
+
+/// Which edge the bar sits on, and therefore which way it runs.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum ScrollbarOrientation {
+ #[default]
+ Right,
+ Left,
+ Bottom,
+ Top,
+}
+
+impl ScrollbarOrientation {
+ pub fn is_vertical(self) -> bool {
+ matches!(self, ScrollbarOrientation::Right | ScrollbarOrientation::Left)
+ }
+}
+
+/// The state the caller owns: how much there is, how much of it is visible, and
+/// how far through it we are.
+#[derive(Clone, Copy, Debug, Default)]
+pub struct ScrollbarOptions {
+ pub total: usize,
+ pub viewport: usize,
+ pub offset: usize,
+ pub orientation: ScrollbarOrientation,
+}
+
+/// Where the thumb sits and how long it is, in cells along the track.
+///
+/// Split out because it is the whole of the behaviour: everything else is
+/// putting characters in a line. A thumb is never shorter than one cell, or it
+/// would vanish on a long document, and never starts past the end of the track.
+///
+/// The thumb is as long as the visible fraction, so it needs the viewport as
+/// well as the track. For a table those are the same number -- the bar is
+/// exactly as tall as the rows it describes. A bar you place yourself has no
+/// such guarantee, so pass the window it describes; `thumb_of` keeps the
+/// track-is-the-viewport form the dense widgets use.
+pub fn thumb(track: usize, total: usize, offset: usize, viewport: usize) -> (usize, usize) {
+ if track == 0 || total == 0 {
+ return (0, 0);
+ }
+ let visible = if viewport > 0 { viewport } else { track };
+ if total <= visible {
+ return (0, track);
+ }
+ let size = ((round_half_up(visible as f64 / total as f64 * track as f64) as i64).max(1)
+ as usize)
+ .min(track);
+ let max_offset = (total - visible).max(1);
+ let clamped = offset.min(max_offset);
+ let start = round_half_up(clamped as f64 / max_offset as f64 * (track - size) as f64) as i64;
+ (start.clamp(0, (track - size) as i64) as usize, size)
+}
+
+/// `thumb` for a bar whose track is exactly the window it describes.
+pub fn thumb_of(track: usize, total: usize, offset: usize) -> (usize, usize) {
+ thumb(track, total, offset, track)
+}
+
+/// The original signature, kept because the table, list, tree and log all call
+/// it this way and their fixtures pin the result.
+pub fn draw_scrollbar(
+ surface: &Surface,
+ x: isize,
+ y: isize,
+ height: usize,
+ total: usize,
+ offset: usize,
+) {
+ let theme = surface.theme.clone();
+ let track = theme.background.mix(theme.border, 0.7);
+ let (start, size) = thumb_of(height, total, offset);
+ for i in 0..height {
+ let in_thumb = i >= start && i < start + size;
+ surface.glyph(
+ x,
+ y + i as isize,
+ if in_thumb { '█' } else { '│' },
+ &Style::new().with_fg(if in_thumb { theme.accent } else { track }),
+ );
+ }
+}
+
+/// A scrollbar filling the surface it is given, on whichever edge.
+///
+/// A horizontal bar uses the half-height glyphs rather than the full block: a
+/// run of full blocks across a row reads as a solid rule, which is not what a
+/// thumb is meant to look like.
+pub fn draw_scrollbar_widget(surface: &Surface, options: &ScrollbarOptions) {
+ if surface.width() == 0 || surface.height() == 0 {
+ return;
+ }
+ let vertical = options.orientation.is_vertical();
+ let theme = surface.theme.clone();
+ let track_color = theme.background.mix(theme.border, 0.7);
+
+ let length = if vertical { surface.height() } else { surface.width() };
+ let viewport = if options.viewport > 0 { options.viewport } else { length };
+ let (start, size) = thumb(length, options.total, options.offset, viewport);
+
+ let line = if vertical {
+ if options.orientation == ScrollbarOrientation::Right {
+ surface.width() as isize - 1
+ } else {
+ 0
+ }
+ } else if options.orientation == ScrollbarOrientation::Bottom {
+ surface.height() as isize - 1
+ } else {
+ 0
+ };
+
+ for i in 0..length {
+ let in_thumb = i >= start && i < start + size;
+ let glyph = match (vertical, in_thumb) {
+ (true, true) => '█',
+ (true, false) => '│',
+ (false, true) => '━',
+ (false, false) => '─',
+ };
+ let style = Style::new().with_fg(if in_thumb { theme.accent } else { track_color });
+ if vertical {
+ surface.glyph(line, i as isize, glyph, &style);
+ } else {
+ surface.glyph(i as isize, line, glyph, &style);
+ }
+ }
+}
+
+/// Which offset a click at `position` along the track means.
+///
+/// The thumb centres on the click, which is what every scrollbar does and what
+/// makes dragging feel like dragging rather than nudging.
+pub fn offset_for_position(position: usize, track: usize, total: usize, viewport: usize) -> usize {
+ let visible = if viewport > 0 { viewport } else { track };
+ if track == 0 || total <= visible {
+ return 0;
+ }
+ let (_, size) = thumb(track, total, 0, visible);
+ let usable = track.saturating_sub(size).max(1);
+ let at = position.saturating_sub(size / 2).min(usable);
+ round_half_up(at as f64 / usable as f64 * (total - visible) as f64) as usize
+}
diff --git a/ports/rust/src/widgets/table.rs b/ports/rust/src/widgets/table.rs
index d40786c..34b0e22 100644
--- a/ports/rust/src/widgets/table.rs
+++ b/ports/rust/src/widgets/table.rs
@@ -10,6 +10,7 @@ use crate::buffer::{Attrs, Style};
use crate::color::Color;
use crate::layout::{solve, Constraint, Size};
use crate::surface::{Surface, TextOptions};
+use crate::widgets::scrollbar::draw_scrollbar;
use crate::theme::elevate;
use crate::unicode::{fit, string_width, truncate, Align};
@@ -267,35 +268,6 @@ pub fn draw_table(surface: &Surface, options: &TableOptions) {
}
}
-/// A one-column scrollbar. Thumb size reflects the visible fraction.
-pub fn draw_scrollbar(
- surface: &Surface,
- x: isize,
- y: isize,
- height: usize,
- total: usize,
- offset: usize,
-) {
- use crate::color::round_half_up;
- let theme = surface.theme.clone();
- let track = theme.background.mix(theme.border, 0.7);
- let thumb_size =
- (round_half_up(height as f64 / total as f64 * height as f64) as i64).max(1);
- let max_offset = total.saturating_sub(height).max(1);
- let thumb_pos = round_half_up(
- offset as f64 / max_offset as f64 * (height as f64 - thumb_size as f64),
- ) as i64;
- for i in 0..height as i64 {
- let in_thumb = i >= thumb_pos && i < thumb_pos + thumb_size;
- surface.glyph(
- x,
- y + i as isize,
- if in_thumb { '█' } else { '│' },
- &Style::new().with_fg(if in_thumb { theme.accent } else { track }),
- );
- }
-}
-
#[derive(Clone, Debug, Default)]
pub struct ListItem {
pub label: String,
diff --git a/ports/rust/tests/conformance_widgets.rs b/ports/rust/tests/conformance_widgets.rs
index b8d1c6f..1a55d4f 100644
--- a/ports/rust/tests/conformance_widgets.rs
+++ b/ports/rust/tests/conformance_widgets.rs
@@ -248,6 +248,54 @@ fn draw_scene(name: &str, s: &Surface) {
},
),
"scrollbar" => draw_scrollbar(s, 1, 0, 8, 40, 12),
+ "scrollbar-right" => draw_scrollbar_widget(
+ s,
+ &ScrollbarOptions { total: 40, viewport: 8, offset: 12, ..Default::default() },
+ ),
+ "scrollbar-left" => draw_scrollbar_widget(
+ s,
+ &ScrollbarOptions {
+ total: 40,
+ viewport: 8,
+ offset: 12,
+ orientation: ScrollbarOrientation::Left,
+ },
+ ),
+ "scrollbar-bottom" => draw_scrollbar_widget(
+ s,
+ &ScrollbarOptions {
+ total: 80,
+ viewport: 20,
+ offset: 30,
+ orientation: ScrollbarOrientation::Bottom,
+ },
+ ),
+ "scrollbar-top" => draw_scrollbar_widget(
+ s,
+ &ScrollbarOptions {
+ total: 80,
+ viewport: 20,
+ offset: 30,
+ orientation: ScrollbarOrientation::Top,
+ },
+ ),
+ "scrollbar-fits" => draw_scrollbar_widget(
+ s,
+ &ScrollbarOptions { total: 5, viewport: 8, offset: 0, ..Default::default() },
+ ),
+ "scrollbar-viewport" => draw_scrollbar_widget(
+ s,
+ &ScrollbarOptions { total: 120, viewport: 8, offset: 36, ..Default::default() },
+ ),
+ "scrollbar-viewport-wide" => draw_scrollbar_widget(
+ s,
+ &ScrollbarOptions {
+ total: 120,
+ viewport: 8,
+ offset: 36,
+ orientation: ScrollbarOrientation::Bottom,
+ },
+ ),
"button" => {
draw_button(s, &ButtonOptions::new("OK"));
}
diff --git a/ports/zig/examples/widgets.zig b/ports/zig/examples/widgets.zig
index 7811434..2b8ddde 100644
--- a/ports/zig/examples/widgets.zig
+++ b/ports/zig/examples/widgets.zig
@@ -171,6 +171,22 @@ fn log(ui: *Container) anyerror!void {
// ----------------------------------------------------------------- meters
+// @widget scrollbar
+fn scrollbar(ui: *Container) anyerror!void {
+ // The bar is over state you own, so it works beside anything that scrolls:
+ // wrapped prose, a canvas, a `draw` of your own.
+ try ui.row(.{ .layout = .{ .gap = 1 } }, hqtui.Body.plain(scrollbarRow));
+}
+
+fn scrollbarRow(r: *Container) anyerror!void {
+ try r.text(
+ "A scrollbar you drive yourself. It has no idea what is beside it, only how much there is, how much fits, and where you are.",
+ .{ .wrap = true },
+ );
+ try r.scrollbar(.{ .total = 40, .viewport = 5, .offset = 12 }, "");
+}
+// @end
+
// @widget meter
fn meter(ui: *Container) anyerror!void {
try ui.meter(.{ .value = 0.62, .label = "CPU" });
@@ -358,6 +374,7 @@ const examples = [_]Example{
.{ .name = "list", .body = hqtui.Body.plain(list) },
.{ .name = "tree", .body = hqtui.Body.plain(tree) },
.{ .name = "log", .body = hqtui.Body.plain(log) },
+ .{ .name = "scrollbar", .body = hqtui.Body.plain(scrollbar) },
.{ .name = "meter", .body = hqtui.Body.plain(meter) },
.{ .name = "meters", .body = hqtui.Body.plain(meters) },
.{ .name = "progress", .body = hqtui.Body.plain(progress) },
diff --git a/ports/zig/src/conformance_widgets.zig b/ports/zig/src/conformance_widgets.zig
index 5e86f27..394230b 100644
--- a/ports/zig/src/conformance_widgets.zig
+++ b/ports/zig/src/conformance_widgets.zig
@@ -188,6 +188,20 @@ fn drawScene(allocator: std.mem.Allocator, name: []const u8, s: Surface) !void {
} });
} else if (eq(u8, name, "scrollbar")) {
w.drawScrollbar(s, 1, 0, 8, 40, 12);
+ } else if (eq(u8, name, "scrollbar-right")) {
+ w.drawScrollbarWidget(s, .{ .total = 40, .viewport = 8, .offset = 12 });
+ } else if (eq(u8, name, "scrollbar-left")) {
+ w.drawScrollbarWidget(s, .{ .total = 40, .viewport = 8, .offset = 12, .orientation = .left });
+ } else if (eq(u8, name, "scrollbar-bottom")) {
+ w.drawScrollbarWidget(s, .{ .total = 80, .viewport = 20, .offset = 30, .orientation = .bottom });
+ } else if (eq(u8, name, "scrollbar-top")) {
+ w.drawScrollbarWidget(s, .{ .total = 80, .viewport = 20, .offset = 30, .orientation = .top });
+ } else if (eq(u8, name, "scrollbar-fits")) {
+ w.drawScrollbarWidget(s, .{ .total = 5, .viewport = 8, .offset = 0 });
+ } else if (eq(u8, name, "scrollbar-viewport")) {
+ w.drawScrollbarWidget(s, .{ .total = 120, .viewport = 8, .offset = 36 });
+ } else if (eq(u8, name, "scrollbar-viewport-wide")) {
+ w.drawScrollbarWidget(s, .{ .total = 120, .viewport = 8, .offset = 36, .orientation = .bottom });
} else if (eq(u8, name, "button")) {
_ = w.drawButton(s, .{ .label = "OK" });
} else if (eq(u8, name, "button-focused")) {
diff --git a/ports/zig/src/ui.zig b/ports/zig/src/ui.zig
index dde0d85..ab2f68f 100644
--- a/ports/zig/src/ui.zig
+++ b/ports/zig/src/ui.zig
@@ -380,6 +380,7 @@ const Node = union(enum) {
table: struct { options: w.TableOptions, id: []const u8 },
list: struct { options: w.ListOptions, id: []const u8 },
+ scrollbar: struct { options: w.ScrollbarOptions, id: []const u8 },
tree: struct { options: w.TreeOptions, id: []const u8 },
log: struct { options: w.LogOptions, id: []const u8 },
@@ -474,6 +475,10 @@ fn drawNode(ctx: *Ctx, s: Surface, node: Node) anyerror!void {
w.drawList(s, n.options);
if (n.id.len > 0) ctx.hit(n.id, s.hitRect(), 0);
},
+ .scrollbar => |n| {
+ w.drawScrollbarWidget(s, n.options);
+ if (n.id.len > 0) ctx.hit(n.id, s.hitRect(), 0);
+ },
.tree => |n| {
try w.drawTree(allocator, s, n.options);
if (n.id.len > 0) ctx.hit(n.id, s.hitRect(), 0);
@@ -794,6 +799,14 @@ pub const Container = struct {
try self.add(self.filling(), .{ .list = .{ .options = options, .id = id } });
}
+ /// A scrollbar over state you own, for anything that scrolls and is not a
+ /// table: wrapped prose, a canvas, a `draw` of your own. A vertical bar
+ /// fills the space it is given; a horizontal one is a single row.
+ pub fn scrollbar(self: *Container, options: w.ScrollbarOptions, id: []const u8) !void {
+ const size = if (options.orientation.isVertical()) self.filling() else self.leaf(1);
+ try self.add(size, .{ .scrollbar = .{ .options = options, .id = id } });
+ }
+
pub fn tree(self: *Container, options: w.TreeOptions, id: []const u8) !void {
try self.add(self.filling(), .{ .tree = .{ .options = options, .id = id } });
}
diff --git a/ports/zig/src/widgets.zig b/ports/zig/src/widgets.zig
index 3b7a127..a349c72 100644
--- a/ports/zig/src/widgets.zig
+++ b/ports/zig/src/widgets.zig
@@ -4,6 +4,7 @@
pub const controls = @import("widgets/controls.zig");
pub const meters = @import("widgets/meters.zig");
+pub const scrollbar = @import("widgets/scrollbar.zig");
pub const table = @import("widgets/table.zig");
pub const text = @import("widgets/text.zig");
@@ -58,7 +59,12 @@ pub const TreeOptions = table.TreeOptions;
pub const TreeValue = table.TreeValue;
pub const drawList = table.drawList;
pub const drawLog = table.drawLog;
-pub const drawScrollbar = table.drawScrollbar;
+pub const ScrollbarOptions = scrollbar.ScrollbarOptions;
+pub const ScrollbarOrientation = scrollbar.ScrollbarOrientation;
+pub const drawScrollbar = scrollbar.drawScrollbar;
+pub const drawScrollbarWidget = scrollbar.drawScrollbarWidget;
+pub const offsetForPosition = scrollbar.offsetForPosition;
+pub const scrollbarThumb = scrollbar.thumb;
pub const drawTable = table.drawTable;
pub const drawTree = table.drawTree;
pub const resolveOffset = table.resolveOffset;
diff --git a/ports/zig/src/widgets/scrollbar.zig b/ports/zig/src/widgets/scrollbar.zig
new file mode 100644
index 0000000..f9ee12f
--- /dev/null
+++ b/ports/zig/src/widgets/scrollbar.zig
@@ -0,0 +1,153 @@
+//! A scrollbar, on its own.
+//!
+//! The renderer used to live inside the table and was reachable only by being a
+//! table, list, tree or log. Anything else that scrolls — a wrapped paragraph, a
+//! canvas, a `draw()` somebody wrote themselves — could not show one.
+//!
+//! This is the same drawing, lifted out and given the four edges plus state the
+//! caller owns. The dense widgets route through it, so there is one
+//! implementation and one appearance.
+
+const std = @import("std");
+
+const buffer_mod = @import("../buffer.zig");
+const color_mod = @import("../color.zig");
+const surface_mod = @import("../surface.zig");
+
+const Style = buffer_mod.Style;
+const Surface = surface_mod.Surface;
+const roundHalfUp = color_mod.roundHalfUp;
+
+/// Which edge the bar sits on, and therefore which way it runs.
+pub const ScrollbarOrientation = enum {
+ right,
+ left,
+ bottom,
+ top,
+
+ pub fn isVertical(self: ScrollbarOrientation) bool {
+ return self == .right or self == .left;
+ }
+};
+
+/// How much there is, how much of it is visible, and how far through it we are
+/// — the state the caller owns.
+pub const ScrollbarOptions = struct {
+ total: usize = 0,
+ viewport: usize = 0,
+ offset: usize = 0,
+ orientation: ScrollbarOrientation = .right,
+};
+
+pub const Thumb = struct { start: usize, size: usize };
+
+/// Where the thumb sits and how long it is, in cells along the track.
+///
+/// Split out because it is the whole of the behaviour: everything else is
+/// putting characters in a line. A thumb is never shorter than one cell, or it
+/// would vanish on a long document, and never starts past the end of the track.
+///
+/// The thumb is as long as the visible fraction, so it needs the viewport as
+/// well as the track. For a table those are the same number — the bar is
+/// exactly as tall as the rows it describes. A bar you place yourself has no
+/// such guarantee, so pass the window it describes; `thumbOf` keeps the
+/// track-is-the-viewport form the dense widgets use.
+pub fn thumb(track: usize, total: usize, offset: usize, viewport: usize) Thumb {
+ if (track == 0 or total == 0) return .{ .start = 0, .size = 0 };
+ const visible = if (viewport > 0) viewport else track;
+ if (total <= visible) return .{ .start = 0, .size = track };
+
+ const ft: f64 = @floatFromInt(track);
+ const scaled: i64 = @intFromFloat(roundHalfUp(
+ @as(f64, @floatFromInt(visible)) / @as(f64, @floatFromInt(total)) * ft,
+ ));
+ const size: usize = @min(track, @as(usize, @intCast(@max(1, scaled))));
+ const max_offset: usize = @max(1, total - visible);
+ const clamped = @min(offset, max_offset);
+ const start: i64 = @intFromFloat(roundHalfUp(
+ @as(f64, @floatFromInt(clamped)) / @as(f64, @floatFromInt(max_offset)) *
+ @as(f64, @floatFromInt(track - size)),
+ ));
+ return .{
+ .start = @intCast(std.math.clamp(start, 0, @as(i64, @intCast(track - size)))),
+ .size = size,
+ };
+}
+
+/// The original signature, kept because the table, list, tree and log all call
+/// it this way and their fixtures pin the result.
+/// `thumb` for a bar whose track is exactly the window it describes.
+pub fn thumbOf(track: usize, total: usize, offset: usize) Thumb {
+ return thumb(track, total, offset, track);
+}
+
+pub fn drawScrollbar(
+ s: Surface,
+ x: isize,
+ y: isize,
+ height: usize,
+ total: usize,
+ offset: usize,
+) void {
+ const theme = s.theme;
+ const track = theme.background.mix(theme.border, 0.7);
+ const t = thumbOf(height, total, offset);
+
+ for (0..height) |i| {
+ const in_thumb = i >= t.start and i < t.start + t.size;
+ s.glyph(
+ x,
+ y + @as(isize, @intCast(i)),
+ if (in_thumb) '█' else '│',
+ .{ .fg = if (in_thumb) theme.accent else track },
+ );
+ }
+}
+
+/// A scrollbar filling the surface it is given, on whichever edge.
+///
+/// A horizontal bar uses the half-height glyphs rather than the full block: a
+/// run of full blocks across a row reads as a solid rule, which is not what a
+/// thumb is meant to look like.
+pub fn drawScrollbarWidget(s: Surface, options: ScrollbarOptions) void {
+ if (s.width() == 0 or s.height() == 0) return;
+ const vertical = options.orientation.isVertical();
+ const theme = s.theme;
+ const track_color = theme.background.mix(theme.border, 0.7);
+
+ const length = if (vertical) s.height() else s.width();
+ const viewport = if (options.viewport > 0) options.viewport else length;
+ const t = thumb(length, options.total, options.offset, viewport);
+
+ const line: isize = if (vertical)
+ (if (options.orientation == .right) @as(isize, @intCast(s.width())) - 1 else 0)
+ else
+ (if (options.orientation == .bottom) @as(isize, @intCast(s.height())) - 1 else 0);
+
+ for (0..length) |i| {
+ const in_thumb = i >= t.start and i < t.start + t.size;
+ const glyph: u21 = if (vertical)
+ (if (in_thumb) '█' else '│')
+ else
+ (if (in_thumb) '━' else '─');
+ const style = Style{ .fg = if (in_thumb) theme.accent else track_color };
+ const at: isize = @intCast(i);
+ if (vertical) s.glyph(line, at, glyph, style) else s.glyph(at, line, glyph, style);
+ }
+}
+
+/// Which offset a click at `position` along the track means.
+///
+/// The thumb centres on the click, which is what every scrollbar does and what
+/// makes dragging feel like dragging rather than nudging.
+pub fn offsetForPosition(position: usize, track: usize, total: usize, viewport: usize) usize {
+ const visible = if (viewport > 0) viewport else track;
+ if (track == 0 or total <= visible) return 0;
+ const t = thumb(track, total, 0, visible);
+ const usable: usize = @max(1, track -| t.size);
+ const at = @min(position -| (t.size / 2), usable);
+ return @intFromFloat(roundHalfUp(
+ @as(f64, @floatFromInt(at)) / @as(f64, @floatFromInt(usable)) *
+ @as(f64, @floatFromInt(total - visible)),
+ ));
+}
diff --git a/ports/zig/src/widgets/table.zig b/ports/zig/src/widgets/table.zig
index 5462ddf..54a3459 100644
--- a/ports/zig/src/widgets/table.zig
+++ b/ports/zig/src/widgets/table.zig
@@ -22,6 +22,7 @@ const Constraint = layout.Constraint;
const Style = buffer_mod.Style;
const Surface = surface_mod.Surface;
const roundHalfUp = color_mod.roundHalfUp;
+const drawScrollbar = @import("scrollbar.zig").drawScrollbar;
pub const TableColumn = struct {
title: []const u8 = "",
@@ -204,39 +205,6 @@ pub fn drawTable(allocator: std.mem.Allocator, s: Surface, options: TableOptions
}
}
-/// A one-column scrollbar. Thumb size reflects the visible fraction.
-pub fn drawScrollbar(
- s: Surface,
- x: isize,
- y: isize,
- height: usize,
- total: usize,
- offset: usize,
-) void {
- const theme = s.theme;
- const track = theme.background.mix(theme.border, 0.7);
- const fh: f64 = @floatFromInt(height);
- const thumb_size: i64 = @max(1, @as(i64, @intFromFloat(
- roundHalfUp(fh / @as(f64, @floatFromInt(total)) * fh),
- )));
- const max_offset: usize = @max(1, total -| height);
- const thumb_pos: i64 = @intFromFloat(roundHalfUp(
- @as(f64, @floatFromInt(offset)) / @as(f64, @floatFromInt(max_offset)) *
- (fh - @as(f64, @floatFromInt(thumb_size))),
- ));
-
- for (0..height) |i| {
- const at: i64 = @intCast(i);
- const in_thumb = at >= thumb_pos and at < thumb_pos + thumb_size;
- s.glyph(
- x,
- y + @as(isize, @intCast(i)),
- if (in_thumb) '█' else '│',
- .{ .fg = if (in_thumb) theme.accent else track },
- );
- }
-}
-
pub const ListItem = struct {
label: []const u8 = "",
color: ?Color = null,