Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions apps/web/content/widgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<pre class=\"hqtui-preview\" style=\"background:#05070a;color:#c6d0db;padding:14px;font-size:13px;line-height:15.34px;font-family:ui-monospace,SFMono-Regular,Menlo,&#39;DejaVu Sans Mono&#39;,&#39;Liberation Mono&#39;,Consolas,&#39;Segoe UI Symbol&#39;,monospace;margin:0;overflow-x:auto;border-radius:8px;white-space:pre;font-variant-ligatures:none;-webkit-font-smoothing:antialiased\"><span style=\"color:#c6d0db;background:#05070a\">A scrollbar you drive yourself. It has no </span><span style=\"color:#1b2430;background:#05070a\">│</span>\n<span style=\"color:#c6d0db;background:#05070a\">idea what is beside it, only how much there </span><span style=\"color:#56d4dd;background:#05070a\">█</span>\n<span style=\"color:#c6d0db;background:#05070a\">is, how much fits, and where you are. </span><span style=\"color:#1b2430;background:#05070a\">│</span>\n<span style=\"color:#c6d0db;background:#05070a\"> </span><span style=\"color:#1b2430;background:#05070a\">│</span></pre>",
"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",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/test/widgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
});
2 changes: 2 additions & 0 deletions examples/widgets/build-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand Down
11 changes: 11 additions & 0 deletions examples/widgets/gallery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions examples/widgets/gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions packages/hqtui/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/hqtui/src/widgets/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./text.ts";
export * from "./scrollbar.ts";
export * from "./table.ts";
export * from "./meters.ts";
export * from "./controls.ts";
136 changes: 136 additions & 0 deletions packages/hqtui/src/widgets/scrollbar.ts
Original file line number Diff line number Diff line change
@@ -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));
}
25 changes: 5 additions & 20 deletions packages/hqtui/src/widgets/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -148,26 +153,6 @@ export function drawTable<Row>(surface: Surface, options: TableOptions<Row>): 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;
Expand Down
Loading