From d59c0bb001dfde356030ce1e6d7bc797d582ed7d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 8 Sep 2026 22:23:09 +0000 Subject: [PATCH] A scrollbar you can put anywhere, in every language The renderer already existed. You could only get one by being a table, list, tree or log, each of which owns its own offset -- so a wrapped paragraph, a canvas or a draw() of your own could not show a scrollbar at all. This lifts drawScrollbar out of table.ts into its own module, gives it the four edges and state the caller owns, and adds ui.scrollbar(...). The dense widgets keep `scrollbar: true` and route through the same code, so there is one implementation and one appearance. Their fixtures are untouched: the 1046 lines added to widgets.json are all new cases, none moved. Two things are not just a move. The thumb is sized from the viewport, not 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 five-argument form never needed to ask. A bar you place yourself has no such guarantee: twenty cells of track can describe an eight-line window, and sizing from the track then reports the wrong fraction. `thumb` takes the viewport and the legacy entry point passes the track, so every existing caller renders identically. Two fixtures pin the case where they differ. A horizontal bar uses the half-height glyphs rather than the full block. A run of solid blocks across a row reads as a rule, not a thumb. Clicking the track moves you. offsetForPosition returns the offset that centres the thumb on the click, and the container reports it as an onScroll delta, so the handler already driving the content drives the bar too -- no second callback to wire up. Six ports carry all of it, plus container methods on each builder. C has no widget layer, so it has nothing here. The Ruby, PHP and Perl bridges gained a scrollbar node and COBOL a SCROLLBAR verb, which both COBOL bridges render identically. That was the price of the site's promise that every language has a runnable example for every widget; the widget gallery now has 29. Verified: 275 TS tests under bun and node; 61 widget scenes matching the reference in Rust, Go, Python, Zig and C++; 11/11 ctest; the bindings ABI test and check.py; all eleven galleries run; both COBOL bridges diff clean; the site builds. Closes #62 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy --- apps/web/content/widgets.json | 55 + apps/web/test/widgets.test.ts | 2 +- examples/widgets/build-catalog.ts | 2 + examples/widgets/gallery.js | 11 + examples/widgets/gallery.ts | 15 + packages/hqtui/src/ui.ts | 29 + packages/hqtui/src/widgets/index.ts | 1 + packages/hqtui/src/widgets/scrollbar.ts | 136 +++ packages/hqtui/src/widgets/table.ts | 25 +- packages/hqtui/test/scrollbar.test.ts | 139 +++ ports/bindings/src/bridge.cpp | 13 +- ports/cobol/adapter/render.ts | 14 + ports/cobol/examples/widgets.cbl | 20 + ports/conformance/fixtures/widgets.json | 1046 +++++++++++++++++ ports/conformance/generate.ts | Bin 43030 -> 43974 bytes ports/cpp/CMakeLists.txt | 2 +- ports/cpp/examples/widgets.cpp | 16 + ports/cpp/include/hqtui/widgets.hpp | 44 + ports/cpp/src/scrollbar.cpp | 80 ++ ports/cpp/src/widgets.cpp | 12 - ports/cpp/tests/conformance_widgets.cpp | 28 + ports/go/conformance_widgets_test.go | 14 + ports/go/examples/widgets/main.go | 16 + ports/go/ui.go | 14 + ports/go/widgets_scrollbar.go | 159 +++ ports/go/widgets_table.go | 19 - ports/perl/examples/widgets.pl | 11 + ports/perl/lib/Hqtui.pm | 1 + ports/php/examples/widgets.php | 11 + ports/php/src/Hqtui.php | 1 + ports/python/examples/widgets.py | 16 + ports/python/hqtui/ui.py | 16 + ports/python/hqtui/widgets/__init__.py | 13 +- ports/python/hqtui/widgets/scrollbar.py | 127 ++ ports/python/hqtui/widgets/table.py | 18 +- .../python/tests/test_conformance_widgets.py | 22 + ports/ruby/examples/widgets.rb | 10 + ports/ruby/lib/hqtui.rb | 1 + ports/rust/examples/cobol-bridge.rs | 18 + ports/rust/examples/widgets.rs | 18 + ports/rust/src/ui.rs | 16 + ports/rust/src/widgets/mod.rs | 7 +- ports/rust/src/widgets/scrollbar.rs | 157 +++ ports/rust/src/widgets/table.rs | 30 +- ports/rust/tests/conformance_widgets.rs | 48 + ports/zig/examples/widgets.zig | 17 + ports/zig/src/conformance_widgets.zig | 14 + ports/zig/src/ui.zig | 13 + ports/zig/src/widgets.zig | 8 +- ports/zig/src/widgets/scrollbar.zig | 153 +++ ports/zig/src/widgets/table.zig | 34 +- 51 files changed, 2555 insertions(+), 137 deletions(-) create mode 100644 packages/hqtui/src/widgets/scrollbar.ts create mode 100644 packages/hqtui/test/scrollbar.test.ts create mode 100644 ports/cpp/src/scrollbar.cpp create mode 100644 ports/go/widgets_scrollbar.go create mode 100644 ports/python/hqtui/widgets/scrollbar.py create mode 100644 ports/rust/src/widgets/scrollbar.rs create mode 100644 ports/zig/src/widgets/scrollbar.zig 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 434d296c4756e88fd414e676e67ffa85089f3084..3403f80f8fb1bb4bf1f554a413e26dc8d6b4afbe 100644 GIT binary patch delta 890 zcmb`FKT88K7{;-q2<|Sv+|)w)zm{^K-@r+si%WWWuLfgMlHegyZgn}Af}7t#@H;rW z`E^X%o@e3iKrvH>yzldS@;uKUjrY&S>q~ZMBCUj&QQg*jer;D*+41?5vpL$PA@BfJ zSt>#p^$6T?OcqMp5GDjv92<1k{s5M(7TE`POoS4fL9tR`yxy4pZll|-IZ8_{S2Bda zuL){oX3AQ18xR;c$^n6iJ*l^PDK7UdA> zn&LkoyhIH_%&l<Lb!rPP5fUz$N|R1`N%C54xP1l%_%^?)pX>YA`TR~ThvUM+rk3fC dUgjh1gfn!yV^^B8-uy+ksy2Jex2Np!=nJe?A2k2~ delta 14 WcmX?hooU(yrVVp@H~UVklmY-b6bA+X 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,