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