diff --git a/apps/web/content/widgets.json b/apps/web/content/widgets.json
index af719a8..4f3d703 100644
--- a/apps/web/content/widgets.json
+++ b/apps/web/content/widgets.json
@@ -729,6 +729,61 @@
}
}
},
+ {
+ "id": "chart",
+ "title": "Chart",
+ "category": "Meters",
+ "width": 48,
+ "height": 9,
+ "blurb": "Arbitrary (x, y) data with a domain on both axes. Lines, scatters and bars.",
+ "preview": "
10 ⢀ \n ⠤⢄⣀⣀ ⢀⡠⠊⠁⠑⢄⡀ \n ⠉⠉⠒⠒⠢⠤⠤⣀⣀ ⡠⠔⠁ ⠈⠢⡀ \n ⢀⠔⠉⠒⠢⢄⠉⠉⠑⠒⠒⠤⠤⣀⣀⡀ ⣀⠔⠉ ⠈⠢⣀ \n ⡠⠊⠁ ⠉⠑⠢⠤⣀⡀ ⠈⠉⠉⠒⠒⠤⠤⢄⣀⣀ ⠑⢄\n ⡠⠊ ⠈⠑⠒⠤⠒⠁ ⠉⠉⠒⠒⠢⠤⠤⣀⣀ \n ⢀⠔⠉ ⠉⠉⠑⠒\n 0■ load ■ limit \n 0s 5s 10s
",
+ "examples": {
+ "typescript": {
+ "code": "export function chart(ui: Container, theme: Theme): void {\n // Points carry their own x, so a sparse series and a dense one line up.\n ui.chart({\n series: [\n { points: [[0, 1], [2, 6], [5, 3], [8, 9], [10, 4]], label: \"load\" },\n { points: [[0, 8], [10, 2]], label: \"limit\", color: theme.muted },\n ],\n axis: true,\n legend: true,\n x: { min: 0, max: 10, ticks: 3, format: (v) => `${v}s` },\n y: { min: 0, max: 10 },\n });\n}",
+ "syntax": "ts"
+ },
+ "javascript": {
+ "code": "export function chart(ui, theme) {\n // Points carry their own x, so a sparse series and a dense one line up.\n ui.chart({\n series: [\n { points: [[0, 1], [2, 6], [5, 3], [8, 9], [10, 4]], label: \"load\" },\n { points: [[0, 8], [10, 2]], label: \"limit\", color: theme.muted },\n ],\n axis: true,\n legend: true,\n x: { min: 0, max: 10, ticks: 3, format: (v) => `${v}s` },\n y: { min: 0, max: 10 },\n });\n}",
+ "syntax": "js"
+ },
+ "rust": {
+ "code": "pub fn chart(ui: &mut Container) {\n // Points carry their own x, so a sparse series and a dense one line up.\n ui.chart(ChartOptions {\n series: vec![\n ChartSeries::new(vec![(0.0, 1.0), (2.0, 6.0), (5.0, 3.0), (8.0, 9.0), (10.0, 4.0)])\n .label(\"load\"),\n ChartSeries::new(vec![(0.0, 8.0), (10.0, 2.0)]).label(\"limit\"),\n ],\n axis: true,\n legend: true,\n plot: ChartPlotOptions {\n x: Some(AxisOptions { min: Some(0.0), max: Some(10.0), ticks: Some(3), format: None }),\n y: Some(AxisOptions { min: Some(0.0), max: Some(10.0), ticks: None, format: None }),\n ..Default::default()\n },\n ..Default::default()\n });\n}",
+ "syntax": "rust"
+ },
+ "go": {
+ "code": "func Chart(ui *hqtui.Container) {\n\t// Points carry their own x, so a sparse series and a dense one line up.\n\tzero, ten, seven := 0.0, 10.0, 3\n\tui.Chart(hqtui.ChartOptions{\n\t\tSeries: []hqtui.ChartSeries{\n\t\t\t{Points: []hqtui.Point{{X: 0, Y: 1}, {X: 2, Y: 6}, {X: 5, Y: 3}, {X: 8, Y: 9}, {X: 10, Y: 4}}, Label: \"load\"},\n\t\t\t{Points: []hqtui.Point{{X: 0, Y: 8}, {X: 10, Y: 2}}, Label: \"limit\"},\n\t\t},\n\t\tAxis: true,\n\t\tLegend: true,\n\t\tPlot: hqtui.ChartPlotOptions{\n\t\t\tX: &hqtui.AxisOptions{Min: &zero, Max: &ten, Ticks: seven},\n\t\t\tY: &hqtui.AxisOptions{Min: &zero, Max: &ten},\n\t\t},\n\t})\n}",
+ "syntax": "go"
+ },
+ "python": {
+ "code": "def chart(ui: Container) -> None:\n # Points carry their own x, so a sparse series and a dense one line up.\n ui.chart(w.ChartOptions(\n series=[\n g.ChartSeries(points=[(0, 1), (2, 6), (5, 3), (8, 9), (10, 4)], label=\"load\"),\n g.ChartSeries(points=[(0, 8), (10, 2)], label=\"limit\"),\n ],\n axis=True,\n legend=True,\n plot=g.ChartPlotOptions(\n x=g.AxisOptions(min=0, max=10, ticks=3),\n y=g.AxisOptions(min=0, max=10),\n ),\n ))",
+ "syntax": "python"
+ },
+ "zig": {
+ "code": "fn chart(ui: *Container) anyerror!void {\n // Points carry their own x, so a sparse series and a dense one line up.\n try ui.chart(.{\n .series = &.{\n .{ .points = &.{\n .{ .x = 0, .y = 1 }, .{ .x = 2, .y = 6 }, .{ .x = 5, .y = 3 },\n .{ .x = 8, .y = 9 }, .{ .x = 10, .y = 4 },\n }, .label = \"load\" },\n .{ .points = &.{ .{ .x = 0, .y = 8 }, .{ .x = 10, .y = 2 } }, .label = \"limit\" },\n },\n .axis = true,\n .legend = true,\n .plot = .{\n .x = .{ .min = 0, .max = 10, .ticks = 3 },\n .y = .{ .min = 0, .max = 10 },\n },\n });\n}",
+ "syntax": "zig"
+ },
+ "cpp": {
+ "code": "void widget_chart(Surface s) {\n // Points carry their own x, so a sparse series and a dense one line up.\n Chart c;\n c.series = {\n {{{0, 1}, {2, 6}, {5, 3}, {8, 9}, {10, 4}}, 0, \"load\"},\n {{{0, 8}, {10, 2}}, 0, \"limit\"},\n };\n c.axis = true;\n c.legend = true;\n Axis x;\n x.min = 0;\n x.max = 10;\n x.ticks = 3;\n Axis y;\n y.min = 0;\n y.max = 10;\n c.plot.x = x;\n c.plot.y = y;\n draw_chart(s, c);\n}",
+ "syntax": "cpp"
+ },
+ "ruby": {
+ "code": "def chart(ui)\n # Points carry their own x, so a sparse series and a dense one line up.\n ui.chart(\n [\n { points: [{ x: 0, y: 1 }, { x: 2, y: 6 }, { x: 5, y: 3 }, { x: 8, y: 9 }, { x: 10, y: 4 }],\n label: 'load' },\n { points: [{ x: 0, y: 8 }, { x: 10, y: 2 }], label: 'limit' }\n ],\n axis: true, legend: true,\n x: { min: 0, max: 10, ticks: 3 }, y: { min: 0, max: 10 }\n )\nend",
+ "syntax": "ruby"
+ },
+ "php": {
+ "code": "function widget_chart(UI $ui): void\n{\n // Points carry their own x, so a sparse series and a dense one line up.\n $ui->chart(\n [\n ['points' => [['x' => 0, 'y' => 1], ['x' => 2, 'y' => 6], ['x' => 5, 'y' => 3],\n ['x' => 8, 'y' => 9], ['x' => 10, 'y' => 4]], 'label' => 'load'],\n ['points' => [['x' => 0, 'y' => 8], ['x' => 10, 'y' => 2]], 'label' => 'limit'],\n ],\n ['axis' => true, 'legend' => true,\n 'x' => ['min' => 0, 'max' => 10, 'ticks' => 3], 'y' => ['min' => 0, 'max' => 10]]\n );\n}",
+ "syntax": "php"
+ },
+ "perl": {
+ "code": "sub widget_chart {\n my ($ui) = @_;\n # Points carry their own x, so a sparse series and a dense one line up.\n $ui->chart(\n [\n { points => [ { x => 0, y => 1 }, { x => 2, y => 6 }, { x => 5, y => 3 },\n { x => 8, y => 9 }, { x => 10, y => 4 } ], label => 'load' },\n { points => [ { x => 0, y => 8 }, { x => 10, y => 2 } ], label => 'limit' },\n ],\n axis => 1, legend => 1,\n x => { min => 0, max => 10, ticks => 3 }, y => { min => 0, max => 10 },\n );\n}",
+ "syntax": "perl"
+ },
+ "cobol": {
+ "code": "CHART-WIDGET.\n MOVE \"chart\" TO SR-KEY\n PERFORM START-WIDGET\n\n *> CHARTPT carries one point; its key names the series it joins, so a\n *> flat record stream can describe several. The text is \"x|y\".\n MOVE \"CHARTPT\" TO SR-VERB\n MOVE \"load\" TO SR-KEY\n MOVE \"0|1\" TO SR-TEXT\n PERFORM EMIT-RECORD\n\n MOVE \"CHARTPT\" TO SR-VERB\n MOVE \"load\" TO SR-KEY\n MOVE \"5|3\" TO SR-TEXT\n PERFORM EMIT-RECORD\n\n MOVE \"CHARTPT\" TO SR-VERB\n MOVE \"load\" TO SR-KEY\n MOVE \"10|4\" TO SR-TEXT\n PERFORM EMIT-RECORD\n\n MOVE \"CHARTPT\" TO SR-VERB\n MOVE \"limit\" TO SR-KEY\n MOVE \"0|8\" TO SR-TEXT\n PERFORM EMIT-RECORD\n\n MOVE \"CHARTPT\" TO SR-VERB\n MOVE \"limit\" TO SR-KEY\n MOVE \"10|2\" TO SR-TEXT\n PERFORM EMIT-RECORD\n\n *> CHART draws what has accumulated. Its key is the mark, and the text\n *> carries both domains as \"xmin|xmax|ymin|ymax\".\n MOVE \"CHART\" TO SR-VERB\n MOVE \"LINE\" TO SR-KEY\n MOVE \"0|10|0|10\" 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 c929c87..a087d30 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, 29);
+ assert.equal(catalog.widgets.length, 30);
assert.equal(catalog.languages.length, 11);
assert.ok(catalog.languages.some((language) => language.id === "cobol"));
});
diff --git a/examples/inline.ts b/examples/inline.ts
new file mode 100644
index 0000000..9d85f54
--- /dev/null
+++ b/examples/inline.ts
@@ -0,0 +1,61 @@
+/**
+ * An inline viewport: `bun examples/inline.ts`.
+ *
+ * The shape `npm`, `cargo`, `docker pull` and every installer use. A few live
+ * rows sit in the normal flow of the command line, finished work scrolls away
+ * above them into the terminal's scrollback, and when the process exits the
+ * shell holds a readable transcript instead of a blanked alternate screen.
+ *
+ * Nothing here clears the screen, and your prompt comes back below the last
+ * frame rather than on top of it. Run it after something else and you will see
+ * that output still there.
+ */
+import { createApp } from "@profullstack/hqtui";
+
+const steps = [
+ "resolve dependencies",
+ "compile core",
+ "compile widgets",
+ "link",
+ "run tests",
+];
+
+const app = await createApp({
+ // Three rows of live UI. Everything else belongs to the shell.
+ viewport: { mode: "inline", height: 3 },
+ quitKeys: [],
+ alwaysRender: true,
+ fps: 20,
+});
+
+let done = 0;
+let spinner = 0;
+const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+
+app.render(({ ui, theme }) => {
+ ui.text(`building ${done}/${steps.length}`, { fg: theme.primary, bold: true });
+ ui.meter({ label: "", value: done / steps.length });
+ ui.text(
+ done < steps.length ? `${frames[spinner % frames.length]} ${steps[done]}` : "done",
+ { fg: theme.muted },
+ );
+});
+
+void app.start();
+
+const timer = setInterval(() => {
+ spinner++;
+ if (spinner % 8 !== 0) return;
+
+ // The finished line goes into the scrollback, permanently. The live rows
+ // below it keep redrawing where they are.
+ app.insertBefore(1, (ui) => {
+ ui.text(` ✓ ${steps[done]}`);
+ });
+ done++;
+
+ if (done >= steps.length) {
+ clearInterval(timer);
+ setTimeout(() => app.stop(), 300);
+ }
+}, 60);
diff --git a/examples/widgets/build-catalog.ts b/examples/widgets/build-catalog.ts
index bd674d4..c630717 100644
--- a/examples/widgets/build-catalog.ts
+++ b/examples/widgets/build-catalog.ts
@@ -97,6 +97,8 @@ export const WIDGETS: WidgetSpec[] = [
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: "chart", title: "Chart", category: "Meters", width: 48, height: 9,
+ blurb: "Arbitrary (x, y) data with a domain on both axes. Lines, scatters and bars." },
{ 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 660f930..f0eed30 100644
--- a/examples/widgets/gallery.js
+++ b/examples/widgets/gallery.js
@@ -189,6 +189,22 @@ export function scrollbar(ui, theme) {
}
// @end
+// @widget chart
+export function chart(ui, theme) {
+ // Points carry their own x, so a sparse series and a dense one line up.
+ ui.chart({
+ series: [
+ { points: [[0, 1], [2, 6], [5, 3], [8, 9], [10, 4]], label: "load" },
+ { points: [[0, 8], [10, 2]], label: "limit", color: theme.muted },
+ ],
+ axis: true,
+ legend: true,
+ x: { min: 0, max: 10, ticks: 3, format: (v) => `${v}s` },
+ y: { min: 0, max: 10 },
+ });
+}
+// @end
+
// ----------------------------------------------------------------- meters
// @widget meter
diff --git a/examples/widgets/gallery.ts b/examples/widgets/gallery.ts
index 42fe5c9..8d3272e 100644
--- a/examples/widgets/gallery.ts
+++ b/examples/widgets/gallery.ts
@@ -191,6 +191,22 @@ export function scrollbar(ui: Container, theme: Theme): void {
}
// @end
+// @widget chart
+export function chart(ui: Container, theme: Theme): void {
+ // Points carry their own x, so a sparse series and a dense one line up.
+ ui.chart({
+ series: [
+ { points: [[0, 1], [2, 6], [5, 3], [8, 9], [10, 4]], label: "load" },
+ { points: [[0, 8], [10, 2]], label: "limit", color: theme.muted },
+ ],
+ axis: true,
+ legend: true,
+ x: { min: 0, max: 10, ticks: 3, format: (v) => `${v}s` },
+ y: { min: 0, max: 10 },
+ });
+}
+// @end
+
// ----------------------------------------------------------------- meters
// @widget meter
diff --git a/packages/hqtui/src/ansi.ts b/packages/hqtui/src/ansi.ts
index 78a8a91..7957047 100644
--- a/packages/hqtui/src/ansi.ts
+++ b/packages/hqtui/src/ansi.ts
@@ -50,6 +50,24 @@ export function moveToColumn(x: number): string {
return `${CSI}${x + 1}G`;
}
+/**
+ * Cursor up and down, in rows.
+ *
+ * An inline viewport cannot use absolute addressing: it does not know which
+ * screen row it starts on, and a scroll moves it without telling anyone. It
+ * returns to its own top-left with a saved cursor and walks from there, which
+ * is what these are for.
+ */
+export function moveUp(n: number): string {
+ if (n <= 0) return "";
+ return n === 1 ? `${CSI}A` : `${CSI}${n}A`;
+}
+
+export function moveDown(n: number): string {
+ if (n <= 0) return "";
+ return n === 1 ? `${CSI}B` : `${CSI}${n}B`;
+}
+
export function setTitle(title: string): string {
// The title is interpolated into an OSC sequence, so anything that could end
// or restart it has to go. `stripUnsafe` is the same policy the grid uses:
diff --git a/packages/hqtui/src/app.ts b/packages/hqtui/src/app.ts
index 40f4bb5..d919536 100644
--- a/packages/hqtui/src/app.ts
+++ b/packages/hqtui/src/app.ts
@@ -1,5 +1,5 @@
import { FrameBuffer } from "./buffer.ts";
-import { Encoder } from "./diff.ts";
+import { Encoder, encodeRows } from "./diff.ts";
import { ansi } from "./ansi.ts";
import { Terminal, type TerminalOptions, emergencyRestore } from "./terminal.ts";
import type { Capabilities } from "./capabilities.ts";
@@ -111,12 +111,14 @@ export class App {
this.capabilities = this.terminal.capabilities;
this.theme = resolveTheme(options.theme);
- const { columns, rows } = this.terminal.size();
- this.current = new FrameBuffer(columns, rows);
- this.previous = new FrameBuffer(columns, rows);
+ const rect = this.terminal.viewportRect();
+ this.current = new FrameBuffer(rect.width, rect.height);
+ this.previous = new FrameBuffer(rect.width, rect.height);
this.encoder = new Encoder({
colors: this.capabilities.colors,
monochrome: options.monochrome ?? this.capabilities.colors === "none",
+ origin: { x: rect.x, y: rect.y },
+ relative: this.terminal.viewport.mode === "inline",
});
}
@@ -200,6 +202,67 @@ export class App {
this.dirty = true;
}
+ /**
+ * Write `height` rows into the terminal's scrollback, above the live view.
+ *
+ * This is what an inline app is for. The live rows stay where they are and
+ * keep redrawing; what you pass here scrolls away above them and is still
+ * there when the process exits, which is how `npm`, `cargo` and every
+ * installer behave and what the alternate screen can never do.
+ *
+ * app.insertBefore(1, ui => ui.text("compiled in 1.2s", { fg: theme.success }));
+ *
+ * It is a no-op for a fullscreen or fixed viewport, where there is no "above"
+ * to write into -- the app owns every row it can see.
+ */
+ insertBefore(height: number, draw: (ui: Container) => void): void {
+ if (this.terminal.viewport.mode !== "inline" || height <= 0) return;
+ const width = this.current.width;
+ if (width <= 0) return;
+
+ const buffer = new FrameBuffer(width, height);
+ // No background: these lines join the user's terminal, and a block of
+ // theme colour across their scrollback is not ours to paint.
+ buffer.clear(undefined, this.theme.foreground);
+ const surface = createSurface(buffer, this.theme);
+ const container = new Container(surface, this.scrollbackContext(), "column");
+ draw(container);
+ container.flush();
+
+ this.terminal.insertBefore(encodeRows(buffer, {
+ colors: this.capabilities.colors,
+ monochrome: this.options.monochrome ?? this.capabilities.colors === "none",
+ }));
+ // Everything below the anchor is now whatever the terminal shifted there.
+ this.forceRepaint = true;
+ this.dirty = true;
+ this.frame();
+ }
+
+ /**
+ * A render context for lines that are printed once and never redrawn.
+ *
+ * Scrollback is not interactive: it cannot take focus, a click cannot reach
+ * it, and nothing about it can ask for another frame -- by the time anyone
+ * looks, it has scrolled away.
+ */
+ private scrollbackContext(): RenderContext {
+ return {
+ theme: this.theme,
+ capabilities: this.capabilities,
+ width: this.current.width,
+ height: 0,
+ frame: this.frameCount,
+ elapsed: Date.now() - this.startedAt,
+ focusIndex: -1,
+ collapseBorders: this.options.collapseBorders ?? false,
+ registerFocus: () => ({ index: -1, focused: false }),
+ hit: () => {},
+ overlay: () => {},
+ invalidate: () => {},
+ };
+ }
+
/** Start the loop. Resolves when the app exits. */
async start(): Promise {
if (this.running) return;
@@ -214,12 +277,14 @@ export class App {
// host, the render loop must not keep drawing into the restored shell.
this.subscriptions.push(this.terminal.onTeardown(() => this.stop()));
this.subscriptions.push(this.terminal.onInput((event) => this.handleInput(event)));
- this.subscriptions.push(this.terminal.onResizeEvent(({ columns, rows }) => {
- this.current.resize(columns, rows);
- this.previous.resize(columns, rows);
+ this.subscriptions.push(this.terminal.onResizeEvent(() => {
+ const rect = this.terminal.viewportRect();
+ this.current.resize(rect.width, rect.height);
+ this.previous.resize(rect.width, rect.height);
+ this.encoder.origin = { x: rect.x, y: rect.y };
this.forceRepaint = true;
this.dirty = true;
- this.emit("resize", { width: columns, height: rows });
+ this.emit("resize", { width: rect.width, height: rect.height });
this.frame();
}));
@@ -312,10 +377,11 @@ export class App {
const started = performance.now();
this.dirty = false;
- const size = this.terminal.size();
- if (size.columns !== this.current.width || size.rows !== this.current.height) {
- this.current.resize(size.columns, size.rows);
- this.previous.resize(size.columns, size.rows);
+ const rect = this.terminal.viewportRect();
+ if (rect.width !== this.current.width || rect.height !== this.current.height) {
+ this.current.resize(rect.width, rect.height);
+ this.previous.resize(rect.width, rect.height);
+ this.encoder.origin = { x: rect.x, y: rect.y };
this.forceRepaint = true;
}
@@ -368,6 +434,10 @@ export class App {
let output = result.output;
if (output.length > 0) {
+ // An inline viewport measures everything from its own top-left, so the
+ // cursor has to be put there before the frame rather than assumed to be
+ // wherever the last one finished.
+ if (this.encoder.relative) output = ansi.cursorRestore + ansi.cursorSave + "\r" + output;
if (this.capabilities.synchronizedOutput) output = ansi.beginSync + output + ansi.endSync;
this.terminal.write(output);
}
diff --git a/packages/hqtui/src/diff.ts b/packages/hqtui/src/diff.ts
index 24f6ca0..6ec26a4 100644
--- a/packages/hqtui/src/diff.ts
+++ b/packages/hqtui/src/diff.ts
@@ -1,9 +1,10 @@
-import type { FrameBuffer } from "./buffer.ts";
+import { FrameBuffer } from "./buffer.ts";
import { Attr } from "./buffer.ts";
import type { Capabilities } from "./capabilities.ts";
import { DEFAULT_COLOR, type Color, blue, green, grayscale, red, to16, to256 } from "./color.ts";
import {
- CSI, bg16, bg256, bgDefault, bgTrue, fg16, fg256, fgDefault, fgTrue, moveTo, moveToColumn, moveRight,
+ CSI, ESC, bg16, bg256, bgDefault, bgTrue, fg16, fg256, fgDefault, fgTrue, moveDown, moveTo,
+ moveToColumn, moveRight, moveUp,
} from "./ansi.ts";
import { CONTINUATION, cellText, cellWidth } from "./unicode.ts";
@@ -35,6 +36,23 @@ export interface EncoderOptions {
colors?: Capabilities["colors"];
/** Drain all color, keeping attributes. */
monochrome?: boolean;
+ /**
+ * Where cell (0, 0) of the buffer sits on the screen. For a viewport that
+ * owns a fixed region of somebody else's terminal rather than the whole of
+ * one.
+ */
+ origin?: { x: number; y: number };
+ /**
+ * Address cells relative to wherever the cursor is when `encode` is called,
+ * rather than by absolute screen coordinates.
+ *
+ * An inline viewport has no absolute coordinates it can trust: it does not
+ * know which screen row it starts on, and the terminal can scroll it upwards
+ * at any moment without saying so. The caller parks the cursor at the
+ * viewport's top-left before each frame and everything is measured from
+ * there.
+ */
+ relative?: boolean;
}
/**
@@ -46,10 +64,15 @@ export class Encoder {
private parts: string[] = [];
colors: Capabilities["colors"];
monochrome: boolean;
+ /** Screen position of buffer cell (0, 0). Ignored when `relative`. */
+ origin: { x: number; y: number };
+ relative: boolean;
constructor(options: EncoderOptions = {}) {
this.colors = options.colors ?? "truecolor";
this.monochrome = options.monochrome ?? false;
+ this.origin = options.origin ?? { x: 0, y: 0 };
+ this.relative = options.relative ?? false;
}
/** Forget what we believe about the terminal; the next write re-states everything. */
@@ -115,18 +138,43 @@ export class Encoder {
private moveCursor(x: number, y: number): void {
const s = this.state;
+ // A carriage return goes to column 0 of the screen, not of the viewport,
+ // so it is only the same thing when the viewport starts there.
+ const home = this.relative ? 0 : this.origin.x;
if (s.known && s.y === y) {
if (s.x === x) return;
if (x > s.x && x - s.x <= 3) {
// Short hop: cheaper than a full CUP, and never repaints cells.
this.parts.push(moveRight(x - s.x));
- } else if (x === 0) {
+ } else if (x === 0 && home === 0) {
this.parts.push("\r");
+ } else if (this.relative) {
+ this.parts.push("\r" + moveRight(x));
+ } else {
+ this.parts.push(moveToColumn(x + this.origin.x));
+ }
+ } else if (this.relative) {
+ // Relative mode has no absolute coordinate to jump to, so it walks: down
+ // or up to the row, then back to the left edge and across. Where the
+ // cursor is no longer trusted -- writing a row's last column leaves it in
+ // the terminal's pending-wrap limbo -- it goes back to the saved anchor
+ // and walks from there, which is exact whatever the terminal did.
+ if (s.known) {
+ this.parts.push(y > s.y ? moveDown(y - s.y) : moveUp(s.y - y));
} else {
- this.parts.push(moveToColumn(x));
+ // Restore, and re-save: in some terminals DECRC pops the saved
+ // position rather than peeking at it, and a second restore against one
+ // save sends the cursor home instead. Restoring also brings back the
+ // saved pen, which is the default one, so the style model has to admit
+ // it no longer knows what is in force.
+ this.parts.push(`${ESC}8${ESC}7`, moveDown(y));
+ s.fg = DEFAULT_COLOR;
+ s.bg = DEFAULT_COLOR;
+ s.attrs = 0;
}
+ this.parts.push(x === 0 ? "\r" : "\r" + moveRight(x));
} else {
- this.parts.push(moveTo(x, y));
+ this.parts.push(moveTo(x + this.origin.x, y + this.origin.y));
}
s.x = x;
s.y = y;
@@ -147,6 +195,17 @@ export class Encoder {
const sameSize = prev.width === w && prev.height === h;
const repaint = full || !sameSize;
if (repaint) this.invalidateState();
+ // The caller parks the cursor at the viewport's top-left before every frame in
+ // relative mode, so that is where the walk starts from.
+ if (this.relative) {
+ this.state.x = 0;
+ this.state.y = 0;
+ this.state.known = true;
+ // Getting there meant a DECRC, which brought the saved pen back with it.
+ this.state.fg = DEFAULT_COLOR;
+ this.state.bg = DEFAULT_COLOR;
+ this.state.attrs = 0;
+ }
const nc = next.chars;
const nf = next.fg;
@@ -215,6 +274,37 @@ export class Encoder {
}
}
+/**
+ * One string per row, styled but never positioned.
+ *
+ * For output that is printed rather than painted: the lines an inline viewport
+ * pushes up into the user's scrollback, which the terminal lays out itself and
+ * which must therefore carry no cursor movement at all. Trailing blanks are
+ * dropped so a finished line does not paint a bar of background across the
+ * width of the terminal, and each row ends by putting the pen back.
+ */
+export function encodeRows(buffer: FrameBuffer, options: EncoderOptions = {}): string[] {
+ const encoder = new Encoder({ ...options, origin: { x: 0, y: 0 }, relative: false });
+ const width = buffer.width;
+ const row = new FrameBuffer(width, 1);
+ const blank = new FrameBuffer(-1, -1);
+ const rows: string[] = [];
+ for (let y = 0; y < buffer.height; y++) {
+ const from = y * width;
+ row.chars.set(buffer.chars.subarray(from, from + width));
+ row.fg.set(buffer.fg.subarray(from, from + width));
+ row.bg.set(buffer.bg.subarray(from, from + width));
+ row.attrs.set(buffer.attrs.subarray(from, from + width));
+ encoder.invalidateState();
+ // The row is encoded as row 0 of a one-row buffer, so the only positioning
+ // in it is the jump home. Dropping that leaves the styling, which is all a
+ // printed line may carry.
+ const line = encoder.encode(blank, row, true).output.replace(`${CSI}1;1H`, "");
+ rows.push(line.replace(/[ ]+$/, "") + `${CSI}0m`);
+ }
+ return rows;
+}
+
/** One-shot encode of a whole buffer, e.g. for `renderToAnsi` in tests. */
export function encodeFull(buffer: FrameBuffer, options: EncoderOptions = {}): string {
const encoder = new Encoder(options);
diff --git a/packages/hqtui/src/graphics/chart.ts b/packages/hqtui/src/graphics/chart.ts
new file mode 100644
index 0000000..fe0a6c0
--- /dev/null
+++ b/packages/hqtui/src/graphics/chart.ts
@@ -0,0 +1,328 @@
+/**
+ * Charts of arbitrary (x, y) data.
+ *
+ * `plot` takes `number[]` and puts one sample per column: the x axis is the
+ * array index. That is the right model for a history buffer and the wrong one
+ * for everything else -- two series of different lengths silently render at
+ * different horizontal scales, a gap in the data is indistinguishable from a
+ * shorter series, and there is no way at all to say where on the x axis a
+ * point belongs.
+ *
+ * This takes points and a domain for each axis, so a series is placed rather
+ * than appended. `plot` is untouched and still means what it meant.
+ */
+import type { Surface } from "../surface.ts";
+import { type Color, mix } from "../color.ts";
+import { BrailleCanvas } from "./braille.ts";
+import { type FillMode, verticalGlyph } from "./blocks.ts";
+import { blit } from "./plot.ts";
+import { seriesColor } from "../theme.ts";
+
+export type Point = [number, number];
+
+/** How a series is marked: joined, dotted, or dropped to the baseline. */
+export type MarkType = "line" | "scatter" | "bar";
+
+export interface ChartSeries {
+ points: Point[];
+ color?: Color;
+ label?: string;
+ type?: MarkType;
+ /** Shade between the line and the baseline. Ignored for scatter. */
+ fill?: boolean;
+}
+
+/** One axis: what it spans and how its numbers read. */
+export interface AxisOptions {
+ min?: number;
+ max?: number;
+ format?: (value: number) => string;
+ /** How many labels to place. Default 2 -- the ends. */
+ ticks?: number;
+}
+
+export interface ChartPlotOptions {
+ /** braille is sharpest; block and ascii are the graceful degradations. */
+ mode?: FillMode;
+ x?: AxisOptions;
+ y?: AxisOptions;
+ background?: Color;
+ grid?: boolean;
+ gridColor?: Color;
+ /** 0-1 opacity of the area fill against the background. */
+ fillAlpha?: number;
+ /** Where a bar or an area is measured from. Defaults to the y minimum. */
+ baseline?: number;
+}
+
+export interface Domain {
+ min: number;
+ max: number;
+}
+
+/** A finite number, or undefined: a caller's bound is data, and data can be NaN. */
+function bound(value: number | undefined): number | undefined {
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
+}
+
+/**
+ * The span an axis covers, from the caller where they said and from the data
+ * where they did not.
+ *
+ * A domain of zero width cannot be mapped -- every point would land in the same
+ * place and a division would blow up -- so a flat series is given a unit of
+ * room around itself rather than being collapsed onto one line.
+ */
+export function domainOf(
+ series: ChartSeries[],
+ axis: AxisOptions | undefined,
+ which: 0 | 1,
+): Domain {
+ let min = bound(axis?.min);
+ let max = bound(axis?.max);
+ if (min === undefined || max === undefined) {
+ let lo = Infinity;
+ let hi = -Infinity;
+ for (const s of series) {
+ for (const p of s.points) {
+ const v = p?.[which];
+ if (typeof v !== "number" || !Number.isFinite(v)) continue;
+ if (v < lo) lo = v;
+ if (v > hi) hi = v;
+ }
+ }
+ if (!Number.isFinite(lo)) {
+ lo = 0;
+ hi = 1;
+ }
+ min = min ?? lo;
+ max = max ?? hi;
+ }
+ if (!(max > min)) {
+ // A flat series still has to be drawn somewhere sensible.
+ const pad = Math.abs(min) > 0 ? Math.abs(min) * 0.5 : 0.5;
+ return { min: min - pad, max: min + pad };
+ }
+ return { min, max };
+}
+
+/** Where a value sits in its domain, 0 at the minimum and 1 at the maximum. */
+function ratio(value: number, domain: Domain): number {
+ return (value - domain.min) / (domain.max - domain.min);
+}
+
+function drawGrid(surface: Surface, color: Color, bg?: Color): void {
+ const { width: w, height: h } = surface;
+ for (let y = 0; y < h; y += Math.max(2, Math.floor(h / 4))) {
+ for (let x = 0; x < w; x += 2) surface.char(x, y, "·", { fg: color, bg });
+ }
+}
+
+/**
+ * Draw point series across the whole surface.
+ *
+ * Points are drawn in the order they are given: a line joins them as they come,
+ * which is what lets a chart draw a loop or a path that doubles back. Sorting
+ * them would quietly make that impossible.
+ */
+export function plotPoints(
+ surface: Surface,
+ series: ChartSeries[],
+ options: ChartPlotOptions = {},
+): void {
+ if (surface.empty || series.length === 0) return;
+ const theme = surface.theme;
+ const mode = options.mode ?? "braille";
+ const bg = options.background;
+ const w = surface.width;
+ const h = surface.height;
+
+ const xd = domainOf(series, options.x, 0);
+ const yd = domainOf(series, options.y, 1);
+ const baseline = bound(options.baseline) ?? yd.min;
+
+ if (options.grid) {
+ drawGrid(surface, options.gridColor ?? mix(theme.border, theme.background, 0.4), bg);
+ }
+
+ if (mode === "block" || mode === "ascii" || mode === "half") {
+ plotCells(surface, series, { mode, xd, yd, baseline, bg, theme });
+ return;
+ }
+
+ const canvas = new BrailleCanvas(w, h);
+ const px = canvas.width;
+ const py = canvas.height;
+ const at = (p: Point): Point => [
+ Math.round(Math.max(0, Math.min(1, ratio(p[0], xd))) * (px - 1)),
+ Math.round((1 - Math.max(0, Math.min(1, ratio(p[1], yd)))) * (py - 1)),
+ ];
+
+ series.forEach((s, si) => {
+ canvas.clear();
+ const color = s.color ?? seriesColor(theme, si);
+ const type = s.type ?? "line";
+ const finite = s.points.filter(
+ (p) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]),
+ );
+ if (finite.length === 0) return;
+ const pixels = finite.map(at);
+
+ if (type === "scatter") {
+ for (const [x, y] of pixels) canvas.pixel(x, y);
+ } else if (type === "bar") {
+ const floor = Math.round((1 - Math.max(0, Math.min(1, ratio(baseline, yd)))) * (py - 1));
+ for (const [x, y] of pixels) canvas.vline(x, Math.min(y, floor), Math.max(y, floor));
+ } else if (pixels.length === 1) {
+ canvas.pixel(pixels[0][0], pixels[0][1]);
+ } else {
+ canvas.polyline(pixels);
+ }
+
+ if (s.fill && type !== "scatter") {
+ fillUnder(surface, finite, { xd, yd, baseline, color, bg, alpha: options.fillAlpha ?? 0.5 });
+ }
+ blit(surface, canvas, () => color, bg);
+ });
+}
+
+/**
+ * The area between a series and its baseline, in block elements.
+ *
+ * Braille would give eight scattered dots per cell, which reads as noise where
+ * an area should read as an area. The line itself stays Braille, so it keeps
+ * the sub-cell resolution.
+ *
+ * The height of each column is interpolated along the line rather than sampled
+ * from the points that happen to land in it. Sampling leaves a gap wherever a
+ * column has no point of its own, which with arbitrary x values is most of
+ * them -- the area comes out striped instead of solid.
+ */
+function fillUnder(
+ surface: Surface,
+ points: Point[],
+ o: { xd: Domain; yd: Domain; baseline: number; color: Color; bg?: Color; alpha: number },
+): void {
+ const w = surface.width;
+ const h = surface.height;
+ if (w <= 0 || h <= 0 || points.length === 0) return;
+ const base = o.bg ?? surface.theme.background;
+ const floor = Math.max(0, Math.min(1, ratio(o.baseline, o.yd)));
+
+ // Column index of a domain x, as a fraction, so a segment can be walked
+ // across the columns it actually spans.
+ const column = (x: number): number => ratio(x, o.xd) * (w - 1);
+
+ const tops = new Array(w).fill(Number.NaN);
+ const record = (col: number, value: number): void => {
+ if (col < 0 || col >= w) return;
+ // A path that doubles back covers a column twice; the outer edge is the
+ // one that bounds the area.
+ const previous = tops[col];
+ const away = Math.abs(value - floor);
+ if (Number.isNaN(previous) || away > Math.abs(previous - floor)) tops[col] = value;
+ };
+
+ if (points.length === 1) {
+ record(Math.round(column(points[0][0])), Math.max(0, Math.min(1, ratio(points[0][1], o.yd))));
+ }
+ for (let i = 0; i + 1 < points.length; i++) {
+ const [x0, y0] = points[i];
+ const [x1, y1] = points[i + 1];
+ const c0 = column(x0);
+ const c1 = column(x1);
+ const from = Math.max(0, Math.floor(Math.min(c0, c1)));
+ const to = Math.min(w - 1, Math.ceil(Math.max(c0, c1)));
+ for (let col = from; col <= to; col++) {
+ const t = c1 === c0 ? 0 : (col - c0) / (c1 - c0);
+ if (t < -0.5 || t > 1.5) continue;
+ const y = y0 + (y1 - y0) * Math.max(0, Math.min(1, t));
+ record(col, Math.max(0, Math.min(1, ratio(y, o.yd))));
+ }
+ }
+
+ for (let x = 0; x < w; x++) {
+ const top = tops[x];
+ if (Number.isNaN(top)) continue;
+ const from01 = Math.min(floor, top);
+ const filled = (Math.max(floor, top) - from01) * h;
+ const bottom = Math.floor(from01 * h);
+ const full = Math.floor(filled);
+ for (let k = 0; k < full && k < h; k++) {
+ const row = h - 1 - bottom - k;
+ if (row < 0 || row >= h) continue;
+ const depth = h <= 1 ? 0 : row / (h - 1);
+ surface.char(x, row, "█", { fg: mix(base, o.color, o.alpha * (1 - depth * 0.3)), bg: o.bg });
+ }
+ if (full < h) {
+ const glyph = verticalGlyph(filled - full, "block");
+ const row = h - 1 - bottom - full;
+ if (glyph !== " " && row >= 0 && row < h) {
+ const depth = h <= 1 ? 0 : row / (h - 1);
+ surface.char(x, row, glyph, {
+ fg: mix(base, o.color, o.alpha * (1 - depth * 0.3) + 0.12),
+ bg: o.bg,
+ });
+ }
+ }
+ }
+}
+
+/**
+ * The block and ascii degradations: one column per cell, tallest point wins.
+ *
+ * A scatter keeps its dots rather than growing columns, because a scatter that
+ * fills to the baseline is a bar chart wearing the wrong name.
+ */
+function plotCells(
+ surface: Surface,
+ series: ChartSeries[],
+ o: {
+ mode: FillMode;
+ xd: Domain;
+ yd: Domain;
+ baseline: number;
+ bg?: Color;
+ theme: Surface["theme"];
+ },
+): void {
+ const w = surface.width;
+ const h = surface.height;
+ const floorRatio = Math.max(0, Math.min(1, ratio(o.baseline, o.yd)));
+
+ series.forEach((s, si) => {
+ const color = s.color ?? seriesColor(o.theme, si);
+ const type = s.type ?? "line";
+ // Highest value per column, so a column shows the peak that fell in it
+ // rather than whichever point happened to be last.
+ const tops = new Array(w).fill(Number.NaN);
+ for (const p of s.points) {
+ if (!Array.isArray(p) || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) continue;
+ const col = Math.min(w - 1, Math.max(0, Math.round(ratio(p[0], o.xd) * (w - 1))));
+ const value = Math.max(0, Math.min(1, ratio(p[1], o.yd)));
+ if (Number.isNaN(tops[col]) || value > tops[col]) tops[col] = value;
+ }
+
+ for (let x = 0; x < w; x++) {
+ const top = tops[x];
+ if (Number.isNaN(top)) continue;
+ if (type === "scatter") {
+ const row = h - 1 - Math.min(h - 1, Math.floor(top * h));
+ surface.char(x, row, o.mode === "ascii" ? "*" : "•", { fg: color, bg: o.bg });
+ continue;
+ }
+ const from = Math.min(floorRatio, top) * h;
+ const filled = (Math.max(floorRatio, top) - Math.min(floorRatio, top)) * h;
+ const full = Math.floor(filled);
+ for (let k = 0; k < full; k++) {
+ const row = h - 1 - Math.floor(from) - k;
+ if (row >= 0 && row < h) surface.char(x, row, "█", { fg: color, bg: o.bg });
+ }
+ const glyph = verticalGlyph(filled - full, o.mode);
+ const row = h - 1 - Math.floor(from) - full;
+ if (glyph !== " " && row >= 0 && row < h) {
+ surface.char(x, row, glyph, { fg: color, bg: o.bg });
+ }
+ }
+ });
+}
diff --git a/packages/hqtui/src/graphics/index.ts b/packages/hqtui/src/graphics/index.ts
index ceb0189..cc14656 100644
--- a/packages/hqtui/src/graphics/index.ts
+++ b/packages/hqtui/src/graphics/index.ts
@@ -1,3 +1,4 @@
export { BrailleCanvas } from "./braille.ts";
export * from "./blocks.ts";
export * from "./plot.ts";
+export * from "./chart.ts";
diff --git a/packages/hqtui/src/index.ts b/packages/hqtui/src/index.ts
index e822de9..37e8187 100644
--- a/packages/hqtui/src/index.ts
+++ b/packages/hqtui/src/index.ts
@@ -20,7 +20,10 @@ export type {
} from "./ui.ts";
// Terminal + capabilities
-export { Terminal, createTerminal, emergencyRestore, type TerminalOptions, type TerminalSize } from "./terminal.ts";
+export {
+ Terminal, createTerminal, emergencyRestore,
+ type TerminalOptions, type TerminalSize, type Viewport, type Rect as ViewportRect,
+} from "./terminal.ts";
export { detectCapabilities, type Capabilities, type ColorDepth, type CapabilityOverrides } from "./capabilities.ts";
// Rendering core
@@ -67,7 +70,10 @@ export { BrailleCanvas } from "./graphics/braille.ts";
export {
plot, blit, sparkline, bar, gauge, donut, histogram,
verticalGlyph, horizontalGlyph, shadeGlyph, bestMode,
+ plotPoints, domainOf,
type Series, type PlotOptions, type FillMode,
+ type Point, type MarkType, type ChartSeries, type AxisOptions, type ChartPlotOptions,
+ type Domain,
} from "./graphics/index.ts";
// Widgets (for drawing straight onto a Surface)
diff --git a/packages/hqtui/src/terminal.ts b/packages/hqtui/src/terminal.ts
index 8ef11ea..ebf0dac 100644
--- a/packages/hqtui/src/terminal.ts
+++ b/packages/hqtui/src/terminal.ts
@@ -1,7 +1,34 @@
-import { ansi, setTitle } from "./ansi.ts";
+import { ansi, moveTo, moveUp, setTitle } from "./ansi.ts";
import { type Capabilities, type CapabilityOverrides, detectCapabilities } from "./capabilities.ts";
import { InputParser, type InputEvent } from "./input.ts";
+/**
+ * How much of the terminal the app owns.
+ *
+ * `fullscreen` is what hqtui has always done: the alternate screen, the whole
+ * grid, and the user's shell handed back untouched at the end.
+ *
+ * `inline` draws a bounded strip in the normal flow of the command line, the
+ * shape every installer and build tool uses -- a few live rows pinned below
+ * output that scrolls away above them, and a readable transcript left behind
+ * when the process exits. It has no absolute coordinates it can trust, because
+ * it does not know which screen row it started on and the terminal can scroll
+ * it up at any moment; it navigates from a saved cursor instead.
+ *
+ * `fixed` claims a rectangle of a terminal something else is driving.
+ */
+export type Viewport =
+ | { mode: "fullscreen" }
+ | { mode: "inline"; height: number }
+ | { mode: "fixed"; x: number; y: number; width: number; height: number };
+
+export interface Rect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
export interface TerminalOptions {
input?: NodeJS.ReadStream;
output?: NodeJS.WriteStream;
@@ -17,6 +44,8 @@ export interface TerminalOptions {
installExitHandlers?: boolean;
/** How long to wait before a lone ESC counts as the Escape key. Default 30ms. */
escapeTimeout?: number;
+ /** How much of the terminal to draw into. Default the whole of it. */
+ viewport?: Viewport;
}
export interface TerminalSize {
@@ -34,7 +63,7 @@ export class Terminal {
readonly input: NodeJS.ReadStream;
readonly output: NodeJS.WriteStream;
readonly capabilities: Capabilities;
- private options: Required> & { title?: string };
+ private options: Required> & { title?: string };
private parser = new InputParser();
private entered = false;
/** Milliseconds to wait before deciding a lone ESC was the Escape key. */
@@ -45,6 +74,13 @@ export class Terminal {
private cleanupHandlers: (() => void)[] = [];
private teardownListeners = new Set<() => void>();
private escapeTimer: NodeJS.Timeout | null = null;
+ private viewportMode: Viewport = { mode: "fullscreen" };
+ /**
+ * Rows an inline viewport has reserved below the anchor. Kept because a
+ * resize can shrink the terminal under it, and because `restore` has to know
+ * how far down to move before handing the shell back.
+ */
+ private reserved = 0;
private onData = (chunk: Buffer | string): void => {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
this.dispatch(this.parser.parse(text));
@@ -75,8 +111,12 @@ export class Terminal {
this.output = options.output ?? process.stdout;
this.capabilities = detectCapabilities(options.capabilities ?? {}, process.env, this.output);
this.escapeTimeout = options.escapeTimeout ?? 30;
+ this.viewportMode = options.viewport ?? { mode: "fullscreen" };
+ // Only a fullscreen app may take the alternate screen. The whole point of
+ // the other two is to leave what is already on the terminal alone.
+ const fullscreen = this.viewportMode.mode === "fullscreen";
this.options = {
- alternateScreen: options.alternateScreen ?? true,
+ alternateScreen: fullscreen ? options.alternateScreen ?? true : false,
mouse: options.mouse ?? this.capabilities.mouse,
hideCursor: options.hideCursor ?? true,
bracketedPaste: options.bracketedPaste ?? this.capabilities.bracketedPaste,
@@ -100,6 +140,40 @@ export class Terminal {
};
}
+ /** How much of the terminal the app owns. */
+ get viewport(): Viewport {
+ return this.viewportMode;
+ }
+
+ /**
+ * The region to draw into, in screen cells.
+ *
+ * For an inline viewport the `y` is a fiction -- it is always 0, because the
+ * strip is addressed from its own saved cursor rather than from the top of
+ * the screen -- but the width and height are real, and they are what the
+ * framebuffer is sized from.
+ */
+ viewportRect(): Rect {
+ const { columns, rows } = this.size();
+ const v = this.viewportMode;
+ if (v.mode === "inline") {
+ // A viewport taller than the terminal would scroll itself off the top
+ // every frame, so it gives up the rows it cannot have.
+ return { x: 0, y: 0, width: columns, height: Math.max(1, Math.min(v.height, rows)) };
+ }
+ if (v.mode === "fixed") {
+ const x = Math.max(0, Math.min(v.x, Math.max(0, columns - 1)));
+ const y = Math.max(0, Math.min(v.y, Math.max(0, rows - 1)));
+ return {
+ x,
+ y,
+ width: Math.max(0, Math.min(v.width, columns - x)),
+ height: Math.max(0, Math.min(v.height, rows - y)),
+ };
+ }
+ return { x: 0, y: 0, width: columns, height: rows };
+ }
+
write(data: string): void {
if (data.length === 0) return;
this.output.write(data);
@@ -117,8 +191,12 @@ export class Terminal {
if (this.options.bracketedPaste) setup += ansi.bracketedPasteOn;
if (this.options.focusEvents) setup += ansi.focusOn;
if (this.options.title) setup += setTitle(this.options.title);
- setup += ansi.clearScreen + ansi.cursorHome;
+ // Only a fullscreen app owns the grid, so only a fullscreen app may wipe
+ // it. An inline strip or a fixed region is a guest on somebody else's
+ // screen and has no business clearing it.
+ if (this.viewportMode.mode === "fullscreen") setup += ansi.clearScreen + ansi.cursorHome;
this.write(setup);
+ if (this.viewportMode.mode === "inline") this.reserveInline();
if (this.input.isTTY && typeof this.input.setRawMode === "function") {
this.input.setRawMode(true);
@@ -148,17 +226,72 @@ export class Terminal {
this.input.pause?.();
let teardown = ansi.reset;
+ // An inline app leaves its last frame behind as part of the transcript, so
+ // the cursor has to come out below it rather than on top of it.
+ if (this.viewportMode.mode === "inline" && this.reserved > 0) {
+ teardown = toAnchor() + "\r" + "\n".repeat(this.reserved) + teardown;
+ this.reserved = 0;
+ }
if (this.options.focusEvents) teardown += ansi.focusOff;
if (this.options.bracketedPaste) teardown += ansi.bracketedPasteOff;
if (this.options.mouse) teardown += ansi.mouseOff;
if (this.options.hideCursor) teardown += ansi.cursorShow;
- teardown += this.options.alternateScreen ? ansi.alternateScreenOff : `\n`;
+ teardown += this.options.alternateScreen
+ ? ansi.alternateScreenOff
+ : this.viewportMode.mode === "inline" ? "" : `\n`;
this.write(teardown);
for (const off of this.cleanupHandlers) off();
this.cleanupHandlers = [];
}
+ /**
+ * Make room for an inline viewport and remember where it starts.
+ *
+ * There is no way to ask where the cursor is without a round trip the caller
+ * would have to await, and no way to trust the answer afterwards -- any
+ * output scrolls the screen and moves the strip without a word. So the
+ * anchor is never a number: it is a saved cursor position, re-saved whenever
+ * the strip moves.
+ *
+ * Printing the newlines first is what reserves the space. If the cursor was
+ * near the bottom the terminal scrolls, which is exactly what should happen;
+ * walking back up then lands on the strip's first row wherever it ended up.
+ */
+ private reserveInline(): void {
+ const height = this.viewportRect().height;
+ this.reserved = height;
+ this.write("\r" + "\n".repeat(Math.max(0, height - 1)) + moveUp(height - 1) + "\r");
+ this.write(anchor());
+ }
+
+ /**
+ * Write lines above an inline viewport, permanently.
+ *
+ * This is the half of inline mode that makes it worth having: finished work
+ * scrolls away into the user's scrollback while the live rows stay put. The
+ * lines are printed where the strip currently begins and the strip is
+ * re-anchored below them, so if that runs off the bottom the terminal scrolls
+ * and the oldest lines leave through the top -- into scrollback, which is
+ * where they were always going.
+ *
+ * The caller repaints the viewport afterwards: everything below the anchor is
+ * now whatever the terminal happened to shift there.
+ */
+ insertBefore(lines: string[]): void {
+ if (this.viewportMode.mode !== "inline" || lines.length === 0) return;
+ const height = this.reserved;
+ let out = toAnchor() + "\r";
+ for (const line of lines) out += ansi.clearLine + line + ansi.reset + "\r\n";
+ // Re-reserve from the new anchor, then walk back to it. Writing the rows
+ // is what forces the terminal to scroll if the strip no longer fits, and
+ // walking back afterwards finds it wherever the scroll left it.
+ out += ansi.clearLine;
+ for (let i = 1; i < height; i++) out += "\r\n" + ansi.clearLine;
+ out += moveUp(height - 1) + "\r" + anchor();
+ this.write(out);
+ }
+
onInput(listener: Listener): () => void {
this.inputListeners.add(listener);
return () => this.inputListeners.delete(listener);
@@ -230,6 +363,30 @@ export class Terminal {
}
}
+/**
+ * Save the anchor an inline viewport measures from.
+ *
+ * The pen is reset first so that the saved graphic rendition is always the
+ * default one. DECRC restores attributes along with the position, so without
+ * that the colour in force at some arbitrary moment would come back with every
+ * jump and quietly desynchronise the renderer's model of the terminal.
+ */
+function anchor(): string {
+ return ansi.reset + ansi.cursorSave;
+}
+
+/**
+ * Go back to the anchor, and immediately save it again.
+ *
+ * DECRC is a pop rather than a peek in some terminals: restore twice against
+ * one save and the second sends the cursor home, which is the top of the
+ * user's screen and not remotely where the viewport is. Re-arming after every
+ * restore makes the sequence mean the same thing on both kinds.
+ */
+function toAnchor(): string {
+ return ansi.cursorRestore + anchor();
+}
+
export function createTerminal(options: TerminalOptions = {}): Terminal {
return new Terminal(options);
}
diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts
index 102940e..1d22818 100644
--- a/packages/hqtui/src/ui.ts
+++ b/packages/hqtui/src/ui.ts
@@ -445,6 +445,17 @@ export class Container {
return this.add((s) => W.drawGraph(s, options), this.sizeOfData(options, "fill", "min-max"));
}
+ /**
+ * A chart of arbitrary (x, y) data, with a domain on both axes.
+ *
+ * `graph` plots a history buffer, one sample per column. Use this when the
+ * data has its own x values: two series of different lengths then line up,
+ * and a point lands where its x says it does.
+ */
+ chart(options: W.ChartOptions & ContainerOptions): this {
+ return this.add((s) => W.drawChart(s, options), this.sizeOfData(options, "fill", "min-max"));
+ }
+
/** A filled area graph — `graph` with `fill` on. */
areaGraph(options: W.GraphOptions & ContainerOptions): this {
return this.graph({ fill: true, ...options });
diff --git a/packages/hqtui/src/widgets/chart.ts b/packages/hqtui/src/widgets/chart.ts
new file mode 100644
index 0000000..047c2b0
--- /dev/null
+++ b/packages/hqtui/src/widgets/chart.ts
@@ -0,0 +1,106 @@
+/**
+ * A chart with two real axes.
+ *
+ * `graph` plots a history buffer: one sample per column, x meaning "position
+ * in the array". This plots data that has its own x values, with a labelled
+ * domain on both axes, so two series of different lengths line up and a point
+ * lands where its x says it does.
+ */
+import type { Align, Surface } from "../surface.ts";
+import type { Color } from "../color.ts";
+import { fit, stringWidth } from "../unicode.ts";
+import { seriesColor } from "../theme.ts";
+import {
+ type AxisOptions, type ChartPlotOptions, type ChartSeries, domainOf, plotPoints,
+} from "../graphics/chart.ts";
+
+export interface ChartOptions extends ChartPlotOptions {
+ series: ChartSeries[];
+ /** Numbers down the left edge. */
+ axis?: boolean;
+ axisColor?: Color;
+ legend?: boolean;
+ legendAlign?: Align;
+}
+
+/** Readable at a glance: 1.2k rather than 1200, 3 rather than 3.0. */
+export function niceLabel(value: number): string {
+ if (!Number.isFinite(value)) return "";
+ if (Math.abs(value) >= 1000) return `${Math.round(value / 100) / 10}k`;
+ if (Number.isInteger(value)) return String(value);
+ return value.toFixed(1);
+}
+
+/**
+ * Evenly spaced values across a domain, ends included.
+ *
+ * Two ticks means the ends and nothing else, which is what an axis wants when
+ * there is no room to say more.
+ */
+function ticksFor(min: number, max: number, count: number): number[] {
+ const n = Math.max(2, Math.floor(count));
+ const out: number[] = [];
+ for (let i = 0; i < n; i++) out.push(min + ((max - min) * i) / (n - 1));
+ return out;
+}
+
+export function drawChart(surface: Surface, options: ChartOptions): void {
+ if (surface.empty) return;
+ const theme = surface.theme;
+ const series = options.series ?? [];
+ const axisColor = options.axisColor ?? theme.muted;
+
+ const xd = domainOf(series, options.x, 0);
+ const yd = domainOf(series, options.y, 1);
+ const xFormat = options.x?.format ?? niceLabel;
+ const yFormat = options.y?.format ?? niceLabel;
+
+ // The x labels take a row, and they can only take one when there is a row to
+ // spare -- a two-row chart is all plot.
+ const xTicks = options.x?.ticks ?? (options.axis ? 2 : 0);
+ const wantXAxis = Boolean(options.axis) && xTicks >= 2 && surface.height > 2;
+
+ let plotSurface = surface;
+ if (options.axis) {
+ const width = Math.max(stringWidth(yFormat(yd.max)), stringWidth(yFormat(yd.min))) + 1;
+ surface.text(0, 0, fit(yFormat(yd.max), width, "right"), { fg: axisColor });
+ if (surface.height > 1) {
+ // The minimum marks the bottom of the plot, which is a row higher when
+ // the x labels have taken the last one.
+ const bottom = wantXAxis ? surface.height - 2 : surface.height - 1;
+ surface.text(0, bottom, fit(yFormat(yd.min), width, "right"), { fg: axisColor });
+ }
+ plotSurface = surface.sub(width, 0, surface.width - width, surface.height);
+ }
+
+ let area = plotSurface;
+ if (wantXAxis && plotSurface.height > 1 && plotSurface.width > 0) {
+ area = plotSurface.sub(0, 0, plotSurface.width, plotSurface.height - 1);
+ const row = plotSurface.height - 1;
+ const labels = ticksFor(xd.min, xd.max, xTicks).map(xFormat);
+ const step = labels.length > 1 ? (plotSurface.width - 1) / (labels.length - 1) : 0;
+ labels.forEach((label, i) => {
+ // The last label is right-aligned to the edge, so it cannot run off it.
+ const x = Math.min(plotSurface.width - stringWidth(label), Math.round(i * step));
+ plotSurface.text(Math.max(0, x), row, label, { fg: axisColor });
+ });
+ }
+
+ plotPoints(area, series, { ...options, x: { ...options.x, ...xd }, y: { ...options.y, ...yd } });
+
+ if (options.legend) {
+ const parts = series
+ .map((s, i) => ({ label: s.label, color: s.color ?? seriesColor(theme, i) }))
+ .filter((p) => p.label);
+ let x = options.legendAlign === "right"
+ ? Math.max(0, area.width - parts.reduce((a, p) => a + stringWidth(p.label!) + 3, 0))
+ : 0;
+ const y = area.height > 3 ? area.height - 1 : 0;
+ for (const part of parts) {
+ x += area.text(x, y, "■ ", { fg: part.color });
+ x += area.text(x, y, `${part.label} `, { fg: theme.muted });
+ }
+ }
+}
+
+export type { AxisOptions, ChartSeries };
diff --git a/packages/hqtui/src/widgets/index.ts b/packages/hqtui/src/widgets/index.ts
index ca92b8c..697df72 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 "./chart.ts";
export * from "./scrollbar.ts";
export * from "./table.ts";
export * from "./meters.ts";
diff --git a/packages/hqtui/test/chart.test.ts b/packages/hqtui/test/chart.test.ts
new file mode 100644
index 0000000..d687023
--- /dev/null
+++ b/packages/hqtui/test/chart.test.ts
@@ -0,0 +1,159 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { renderToScreen } from "../src/index.ts";
+import { domainOf } from "../src/graphics/chart.ts";
+import type { ChartSeries, Point } from "../src/graphics/chart.ts";
+
+import type { ChartOptions } from "../src/widgets/chart.ts";
+
+const draw = (options: ChartOptions, width = 40, height = 8): string[] =>
+ renderToScreen(({ ui }) => ui.chart(options), { width, height }).text().split("\n");
+
+/** Which columns of a rendered chart have any ink in them. */
+const inked = (lines: string[]): number[] => {
+ const columns = new Set();
+ for (const line of lines) {
+ for (let x = 0; x < line.length; x++) if (line[x] !== " ") columns.add(x);
+ }
+ return [...columns].sort((a, b) => a - b);
+};
+
+const series = (points: Point[], extra: Partial = {}): ChartSeries => ({
+ points,
+ ...extra,
+});
+
+test("chart: a domain comes from the data when nobody says otherwise", () => {
+ const s = [series([[0, 5], [10, 15]])];
+ assert.deepEqual(domainOf(s, undefined, 0), { min: 0, max: 10 });
+ assert.deepEqual(domainOf(s, undefined, 1), { min: 5, max: 15 });
+});
+
+test("chart: a stated bound wins, and a broken one does not", () => {
+ const s = [series([[0, 5], [10, 15]])];
+ assert.deepEqual(domainOf(s, { min: -5, max: 20 }, 0), { min: -5, max: 20 });
+ // A caller's bound is data too, and data can be NaN. Falling back to the
+ // extent keeps every plotted coordinate finite.
+ assert.deepEqual(domainOf(s, { min: Number.NaN, max: 20 }, 0), { min: 0, max: 20 });
+});
+
+test("chart: a flat series is given room rather than collapsed onto a line", () => {
+ // Every point at the same y would otherwise divide by a zero-width domain.
+ const flat = domainOf([series([[0, 7], [1, 7]])], undefined, 1);
+ assert.ok(flat.max > flat.min, `${JSON.stringify(flat)}`);
+ assert.ok(flat.min < 7 && flat.max > 7);
+ // And at zero, where a proportional pad would itself be zero.
+ const zero = domainOf([series([[0, 0], [1, 0]])], undefined, 1);
+ assert.ok(zero.max > zero.min, `${JSON.stringify(zero)}`);
+});
+
+test("chart: no points at all is a unit domain, not an infinity", () => {
+ assert.deepEqual(domainOf([series([])], undefined, 0), { min: 0, max: 1 });
+ assert.deepEqual(domainOf([], undefined, 1), { min: 0, max: 1 });
+});
+
+test("chart: a point lands where its x says, not where its index does", () => {
+ // Three points bunched at the left of a domain that runs to 100. Indexed
+ // plotting would spread them across the whole width; this must not.
+ const lines = draw({
+ series: [series([[0, 1], [1, 1], [2, 1]], { type: "scatter" })],
+ x: { min: 0, max: 100 },
+ y: { min: 0, max: 2 },
+ });
+ const columns = inked(lines);
+ assert.ok(columns.length > 0, "something was drawn");
+ assert.ok(columns[columns.length - 1] < 5, `ink reached column ${columns[columns.length - 1]}`);
+});
+
+test("chart: two series of different lengths share one horizontal scale", () => {
+ // The bug this widget exists to fix: with index-based plotting these two
+ // would end at different places despite covering the same x range.
+ const dense: Point[] = [];
+ for (let i = 0; i <= 20; i++) dense.push([i / 2, 5]);
+ const sparse: Point[] = [[0, 5], [10, 5]];
+
+ const a = inked(draw({ series: [series(dense)], x: { min: 0, max: 10 }, y: { min: 0, max: 10 } }));
+ const b = inked(draw({ series: [series(sparse)], x: { min: 0, max: 10 }, y: { min: 0, max: 10 } }));
+ assert.deepEqual([a[0], a[a.length - 1]], [b[0], b[b.length - 1]]);
+});
+
+test("chart: the three marks are actually different", () => {
+ const points: Point[] = [[0, 1], [5, 8], [10, 3]];
+ const common = { x: { min: 0, max: 10 }, y: { min: 0, max: 10 } };
+ const line = draw({ series: [series(points, { type: "line" })], ...common }).join("\n");
+ const scatter = draw({ series: [series(points, { type: "scatter" })], ...common }).join("\n");
+ const bar = draw({ series: [series(points, { type: "bar" })], ...common }).join("\n");
+
+ const ink = (s: string) => s.replace(/[\s\n]/g, "").length;
+ // A scatter is three marks; a line joins them; bars drop to the baseline.
+ assert.ok(ink(scatter) < ink(line), `scatter ${ink(scatter)} vs line ${ink(line)}`);
+ assert.ok(ink(bar) > ink(scatter), `bar ${ink(bar)} vs scatter ${ink(scatter)}`);
+ assert.notEqual(line, bar);
+});
+
+test("chart: bars stand on the baseline, wherever it is put", () => {
+ const points: Point[] = [[5, 8]];
+ const common = { series: [series(points, { type: "bar" })], x: { min: 0, max: 10 }, y: { min: 0, max: 10 } };
+ const fromZero = draw({ ...common }).filter((l) => l.trim()).length;
+ // A baseline at the top means the bar hangs down from it instead.
+ const fromTop = draw({ ...common, baseline: 10 }).filter((l) => l.trim()).length;
+ assert.ok(fromZero > 1 && fromTop > 1);
+ assert.notEqual(
+ draw({ ...common }).join("\n"),
+ draw({ ...common, baseline: 10 }).join("\n"),
+ );
+});
+
+test("chart: a filled area has no gaps between the points", () => {
+ // Sampling the points per column leaves a stripe wherever a column has no
+ // point of its own, which with arbitrary x values is most of them.
+ const points: Point[] = [[0, 2], [5, 8], [10, 2]];
+ const lines = draw({
+ series: [series(points, { fill: true })],
+ x: { min: 0, max: 10 },
+ y: { min: 0, max: 10 },
+ }, 40, 8);
+ const bottom = lines[lines.length - 1];
+ const filled = [...bottom].map((c) => c !== " ");
+ const first = filled.indexOf(true);
+ const last = filled.lastIndexOf(true);
+ assert.ok(first >= 0 && last > first, "the area reached the bottom row");
+ for (let x = first; x <= last; x++) {
+ assert.ok(filled[x], `column ${x} of the area is a gap: ${JSON.stringify(bottom)}`);
+ }
+});
+
+test("chart: both axes get labels, and the x row is not stolen from the plot", () => {
+ const lines = draw({
+ series: [series([[0, 0], [10, 100]])],
+ axis: true,
+ x: { min: 0, max: 10, ticks: 3, format: (v) => `${v}s` },
+ y: { min: 0, max: 100 },
+ }, 40, 8);
+ assert.ok(lines[0].trimStart().startsWith("100"), `y max: ${JSON.stringify(lines[0])}`);
+ const last = lines[lines.length - 1];
+ assert.ok(last.includes("0s") && last.includes("10s"), `x labels: ${JSON.stringify(last)}`);
+ // The y minimum belongs to the bottom of the plot, which is a row above the
+ // x labels rather than on them.
+ assert.ok(lines[lines.length - 2].trimStart().startsWith("0"), JSON.stringify(lines[lines.length - 2]));
+});
+
+test("chart: a chart with no room for an x axis still draws", () => {
+ const lines = draw({ series: [series([[0, 1], [1, 2]])], axis: true }, 20, 2);
+ assert.equal(lines.length, 2);
+ assert.ok(lines.join("").trim().length > 0);
+});
+
+test("chart: points that are not numbers are skipped, not drawn at zero", () => {
+ const good = draw({
+ series: [series([[0, 5], [10, 5]])],
+ x: { min: 0, max: 10 },
+ y: { min: 0, max: 10 },
+ });
+ const withJunk = draw({
+ series: [series([[0, 5], [Number.NaN, 1], [10, 5]] as Point[])],
+ x: { min: 0, max: 10 },
+ y: { min: 0, max: 10 },
+ });
+ assert.deepEqual(withJunk, good);
+});
diff --git a/packages/hqtui/test/viewport.test.ts b/packages/hqtui/test/viewport.test.ts
new file mode 100644
index 0000000..b5063b7
--- /dev/null
+++ b/packages/hqtui/test/viewport.test.ts
@@ -0,0 +1,211 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { PassThrough } from "node:stream";
+import { App } from "../src/app.ts";
+import { Terminal } from "../src/terminal.ts";
+
+/** A terminal whose output we can read back, since the escapes are the contract. */
+function fakeTty(columns = 20, rows = 10) {
+ const input = new PassThrough() as unknown as NodeJS.ReadStream;
+ const output = new PassThrough() as unknown as NodeJS.WriteStream;
+ let written = "";
+ (output as unknown as PassThrough).on("data", (chunk) => { written += String(chunk); });
+ Object.assign(output, { columns, rows });
+ return { input, output, read: () => written, clear: () => { written = ""; } };
+}
+
+const options = {
+ installExitHandlers: false,
+ quitKeys: [] as string[],
+ capabilities: { mouse: false, synchronizedOutput: false },
+ bracketedPaste: false,
+ focusEvents: false,
+} as const;
+
+test("viewport: fullscreen is still the default, alternate screen and all", () => {
+ const tty = fakeTty();
+ const terminal = new Terminal({ ...tty, installExitHandlers: false });
+ assert.deepEqual(terminal.viewport, { mode: "fullscreen" });
+ assert.deepEqual(terminal.viewportRect(), { x: 0, y: 0, width: 20, height: 10 });
+ terminal.enter();
+ assert.ok(tty.read().includes("\x1b[?1049h"), "took the alternate screen");
+ terminal.restore();
+});
+
+test("viewport: inline neither takes the alternate screen nor clears what is there", () => {
+ const tty = fakeTty();
+ const terminal = new Terminal({
+ ...tty,
+ installExitHandlers: false,
+ viewport: { mode: "inline", height: 3 },
+ });
+ terminal.enter();
+ const out = tty.read();
+ // The whole point is that the user's terminal survives.
+ assert.ok(!out.includes("\x1b[?1049h"), "must not take the alternate screen");
+ assert.ok(!out.includes("\x1b[2J"), "must not clear the screen");
+ // Three rows reserved: two newlines to make the room, then back to the top.
+ assert.ok(out.includes("\n\n"), "reserved its rows");
+ assert.ok(out.includes("\x1b[2A"), "walked back to the first of them");
+ assert.ok(out.endsWith("\x1b[0m\x1b7"), "reset the pen, then saved the anchor");
+ terminal.restore();
+});
+
+test("viewport: inline asks for no more rows than the terminal has", () => {
+ const tty = fakeTty(20, 4);
+ const terminal = new Terminal({
+ ...tty,
+ installExitHandlers: false,
+ viewport: { mode: "inline", height: 40 },
+ });
+ // A strip taller than the screen would scroll itself away every frame.
+ assert.equal(terminal.viewportRect().height, 4);
+});
+
+test("viewport: leaving an inline app puts the cursor below its last frame", () => {
+ const tty = fakeTty();
+ const terminal = new Terminal({
+ ...tty,
+ installExitHandlers: false,
+ viewport: { mode: "inline", height: 3 },
+ });
+ terminal.enter();
+ tty.clear();
+ terminal.restore();
+ const out = tty.read();
+ assert.ok(out.startsWith("\x1b8"), "went back to the anchor first");
+ assert.equal((out.match(/\n/g) ?? []).length, 3, "then down past all three rows");
+});
+
+test("viewport: a fixed region is clamped to the terminal it is placed in", () => {
+ const tty = fakeTty(20, 10);
+ const terminal = new Terminal({
+ ...tty,
+ installExitHandlers: false,
+ viewport: { mode: "fixed", x: 15, y: 8, width: 30, height: 30 },
+ });
+ assert.deepEqual(terminal.viewportRect(), { x: 15, y: 8, width: 5, height: 2 });
+});
+
+test("viewport: an app draws into its viewport, not the whole screen", async () => {
+ const tty = fakeTty(20, 10);
+ const app = new App({ ...tty, ...options, viewport: { mode: "inline", height: 3 } });
+ app.render(({ ui }) => ui.text("hello"));
+ assert.equal(app.height, 3, "the buffer is the strip, not the screen");
+ assert.equal(app.width, 20);
+ void app.start();
+ await new Promise((r) => setImmediate(r));
+ app.stop();
+});
+
+test("viewport: an inline frame is addressed from its saved anchor", async () => {
+ const tty = fakeTty(20, 10);
+ const app = new App({ ...tty, ...options, viewport: { mode: "inline", height: 2 } });
+ let label = "hello";
+ app.render(({ ui }) => ui.text(label));
+ void app.start();
+ await new Promise((r) => setImmediate(r));
+
+ tty.clear();
+ label = "goodbye";
+ app.frame();
+ const frame = tty.read();
+ app.stop();
+
+ // Absolute addressing is exactly what an inline strip cannot use: it does not
+ // know its screen row, and a scroll moves it without saying so. Every jump in
+ // a frame is measured from the saved anchor instead.
+ assert.ok(frame.startsWith("\x1b8\x1b7\r"), `frame did not start at the anchor: ${JSON.stringify(frame)}`);
+ assert.ok(!/\x1b\[\d+;\d+H/.test(frame), `frame used absolute addressing: ${JSON.stringify(frame)}`);
+ assert.ok(frame.includes("goodbye"));
+});
+
+test("viewport: a fixed region offsets its addressing instead", async () => {
+ const tty = fakeTty(40, 10);
+ const app = new App({
+ ...tty,
+ ...options,
+ viewport: { mode: "fixed", x: 4, y: 6, width: 10, height: 2 },
+ });
+ app.render(({ ui }) => ui.text("hi"));
+ void app.start();
+ await new Promise((r) => setImmediate(r));
+ const out = tty.read();
+ app.stop();
+ // Row 7, column 5 in one-based terms: the region's own (0, 0).
+ assert.ok(out.includes("\x1b[7;5H"), `expected the region's origin: ${JSON.stringify(out)}`);
+});
+
+test("viewport: insertBefore writes above the strip and leaves it anchored", async () => {
+ const tty = fakeTty(20, 10);
+ const app = new App({ ...tty, ...options, viewport: { mode: "inline", height: 2 } });
+ app.render(({ ui }) => ui.text("live"));
+ void app.start();
+ await new Promise((r) => setImmediate(r));
+ tty.clear();
+
+ app.insertBefore(1, (ui) => ui.text("done"));
+ const out = tty.read();
+ app.stop();
+
+ assert.ok(out.includes("done"), "the line was written");
+ assert.ok(out.includes("live"), "and the strip was repainted after it");
+ assert.ok(out.indexOf("done") < out.indexOf("live"), "the finished line goes above");
+ // Re-anchored: the strip has to be findable again after the terminal may
+ // have scrolled it.
+ assert.ok(out.includes("\x1b7"), "saved the new anchor");
+});
+
+test("viewport: insertBefore is a no-op where there is no above", async () => {
+ const tty = fakeTty(20, 10);
+ const app = new App({ ...tty, ...options });
+ app.render(({ ui }) => ui.text("live"));
+ void app.start();
+ await new Promise((r) => setImmediate(r));
+ tty.clear();
+
+ // A fullscreen app owns every row it can see; there is nothing to insert into.
+ app.insertBefore(1, (ui) => ui.text("done"));
+ assert.ok(!tty.read().includes("done"));
+ app.stop();
+});
+
+test("viewport: every restore re-arms the save it just spent", async () => {
+ const tty = fakeTty(24, 10);
+ const app = new App({ ...tty, ...options, viewport: { mode: "inline", height: 3 } });
+ let n = 0;
+ app.render(({ ui }) => {
+ ui.text(`step ${n}`);
+ ui.meter({ label: "x", value: n / 5 });
+ ui.text("working");
+ });
+ void app.start();
+ await new Promise((r) => setImmediate(r));
+
+ for (; n < 4; n++) {
+ app.insertBefore(1, (ui) => ui.text(`ok ${n}`));
+ app.frame();
+ }
+ const out = tty.read();
+ app.stop();
+
+ // DECRC pops the saved position in some terminals rather than peeking at it.
+ // Spend the save without putting it back and the next restore sends the
+ // cursor to the top of the user's screen -- which is where the whole strip
+ // then redraws itself, over their shell. Every restore must re-arm.
+ const restores = [...out.matchAll(/\x1b8/g)].map((m) => m.index ?? 0);
+ assert.ok(restores.length > 4, `expected several restores, got ${restores.length}`);
+ for (const at of restores) {
+ // A pen reset may sit between the two; nothing that moves the cursor may.
+ const after = out.slice(at + 2, at + 12);
+ assert.ok(
+ after.startsWith("\x1b7") || after.startsWith("\x1b[0m\x1b7"),
+ `restore at ${at} left the save spent: ${JSON.stringify(out.slice(at, at + 14))}`,
+ );
+ }
+
+ // And the finished lines are all there, in order.
+ const order = ["ok 0", "ok 1", "ok 2", "ok 3"].map((line) => out.indexOf(line));
+ assert.ok(order.every((at) => at >= 0), `a line went missing: ${order}`);
+ assert.deepEqual(order, [...order].sort((a, b) => a - b), "lines arrived out of order");
+});
diff --git a/ports/bindings/src/bridge.cpp b/ports/bindings/src/bridge.cpp
index 798ddb7..dd605b8 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", "scrollbar"};
+ "commandpalette", "tooltip", "scrollbar", "chart"};
if (std::find(types.begin(), types.end(), type) == types.end())
throw std::runtime_error("unknown widget: " + type);
if (!n["children"].null() &&
@@ -271,6 +271,42 @@ 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 == "chart") {
+ Chart chart;
+ for (auto &sj : n["series"].array()) {
+ ChartSeries cs;
+ for (auto &pj : sj["points"].array())
+ cs.points.push_back({pj["x"].n(), pj["y"].n()});
+ if (cs.points.size() > 10000)
+ throw std::runtime_error("chart series too large");
+ cs.label = sj["label"].s("");
+ auto mark = sj["mark"].s("line");
+ cs.mark = mark == "scatter" ? HQ_MARK_SCATTER
+ : mark == "bar" ? HQ_MARK_BAR
+ : HQ_MARK_LINE;
+ cs.fill = sj["fill"].b(false);
+ chart.series.push_back(std::move(cs));
+ }
+ if (chart.series.size() > 64)
+ throw std::runtime_error("too many chart series");
+ chart.axis = n["axis"].b(false);
+ chart.legend = n["legend"].b(false);
+ if (!n["mode"].null())
+ chart.plot.mode = n["mode"].s("braille");
+ auto read_axis = [](const Json &j) -> std::optional {
+ if (j.null())
+ return std::nullopt;
+ Axis a;
+ if (!j["min"].null())
+ a.min = j["min"].n();
+ if (!j["max"].null())
+ a.max = j["max"].n();
+ a.ticks = integer(j["ticks"], 0, 0, 64);
+ return a;
+ };
+ chart.plot.x = read_axis(n["x"]);
+ chart.plot.y = read_axis(n["y"]);
+ ui.chart(chart, size(n));
} else if (type == "scrollbar") {
Scrollbar bar;
bar.total = integer(n["total"], 0, 0, 1000000);
diff --git a/ports/cobol/adapter/render.ts b/ports/cobol/adapter/render.ts
index 3442c90..5e6bed6 100644
--- a/ports/cobol/adapter/render.ts
+++ b/ports/cobol/adapter/render.ts
@@ -70,6 +70,9 @@ const ALIGN: Record = { LEFT: "left", CENTER: "center", RIGHT: "r
type Edge = "right" | "left" | "bottom" | "top";
const EDGE: Record = { RIGHT: "right", LEFT: "left", BOTTOM: "bottom", TOP: "top" };
+type Mark = "line" | "scatter" | "bar";
+const MARK: Record = { LINE: "line", SCATTER: "scatter", BAR: "bar" };
+
type ButtonVariant = "primary" | "success" | "warning" | "danger" | "ghost";
const VARIANT: Record = {
PRIMARY: "primary",
@@ -106,6 +109,7 @@ export function draw(scene: Scene, ui: Container, theme: Theme): void {
let modal: { title: string; width: number } | undefined;
let palette: { query: string; selected: number } | undefined;
let tooltip: { text: string; x: number; y: number } | undefined;
+ const chartSeries = new Map();
let selected = 0;
let activeTab = 0;
@@ -139,6 +143,29 @@ export function draw(scene: Scene, ui: Container, theme: Theme): void {
case "SELECT":
selected = Number(record.num) || 0;
break;
+ case "CHARTPT": {
+ // One point per record, like GRAPHPT. key names the series it joins, so
+ // a flat record stream can describe several of them.
+ const points = chartSeries.get(record.key) ?? [];
+ const [px = "", py = ""] = record.text.split("|");
+ points.push([Number(px) || 0, Number(py) || 0]);
+ chartSeries.set(record.key, points);
+ break;
+ }
+ case "CHART": {
+ // key is the mark every series takes; text is "xmin|xmax|ymin|ymax".
+ const [xmin = "", xmax = "", ymin = "", ymax = ""] = record.text.split("|");
+ const mark = MARK[record.key] ?? "line";
+ ui.chart({
+ series: [...chartSeries].map(([label, points]) => ({ label, points, type: mark })),
+ axis: true,
+ legend: chartSeries.size > 1,
+ x: { min: Number(xmin) || 0, max: Number(xmax) || 0 },
+ y: { min: Number(ymin) || 0, max: Number(ymax) || 0 },
+ });
+ chartSeries.clear();
+ break;
+ }
case "SCROLLBAR": {
// key is the edge, num the offset, text "total|viewport".
const [total = "", viewport = ""] = record.text.split("|");
diff --git a/ports/cobol/examples/widgets.cbl b/ports/cobol/examples/widgets.cbl
index cd484ea..603730f 100644
--- a/ports/cobol/examples/widgets.cbl
+++ b/ports/cobol/examples/widgets.cbl
@@ -69,6 +69,7 @@ MAIN-PARAGRAPH.
PERFORM DONUT-WIDGET
PERFORM LIST-WIDGET
PERFORM SCROLLBAR-WIDGET
+ PERFORM CHART-WIDGET
PERFORM TREE-WIDGET
PERFORM BUTTON-WIDGET
PERFORM CHECKBOX-WIDGET
@@ -403,6 +404,46 @@ SCROLLBAR-WIDGET.
PERFORM EMIT-RECORD.
*> @end
+*> @widget chart
+CHART-WIDGET.
+ MOVE "chart" TO SR-KEY
+ PERFORM START-WIDGET
+
+ *> CHARTPT carries one point; its key names the series it joins, so a
+ *> flat record stream can describe several. The text is "x|y".
+ MOVE "CHARTPT" TO SR-VERB
+ MOVE "load" TO SR-KEY
+ MOVE "0|1" TO SR-TEXT
+ PERFORM EMIT-RECORD
+
+ MOVE "CHARTPT" TO SR-VERB
+ MOVE "load" TO SR-KEY
+ MOVE "5|3" TO SR-TEXT
+ PERFORM EMIT-RECORD
+
+ MOVE "CHARTPT" TO SR-VERB
+ MOVE "load" TO SR-KEY
+ MOVE "10|4" TO SR-TEXT
+ PERFORM EMIT-RECORD
+
+ MOVE "CHARTPT" TO SR-VERB
+ MOVE "limit" TO SR-KEY
+ MOVE "0|8" TO SR-TEXT
+ PERFORM EMIT-RECORD
+
+ MOVE "CHARTPT" TO SR-VERB
+ MOVE "limit" TO SR-KEY
+ MOVE "10|2" TO SR-TEXT
+ PERFORM EMIT-RECORD
+
+ *> CHART draws what has accumulated. Its key is the mark, and the text
+ *> carries both domains as "xmin|xmax|ymin|ymax".
+ MOVE "CHART" TO SR-VERB
+ MOVE "LINE" TO SR-KEY
+ MOVE "0|10|0|10" 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 3376bad..832063f 100644
--- a/ports/conformance/fixtures/widgets.json
+++ b/ports/conformance/fixtures/widgets.json
@@ -7883,6 +7883,2705 @@
]
}
},
+ {
+ "name": "chart-line",
+ "width": 40,
+ "height": 8,
+ "result": {
+ "width": 40,
+ "height": 8,
+ "chars": [
+ [
+ 31,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 37,
+ 32
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 1,
+ 10257
+ ],
+ [
+ 1,
+ 10372
+ ],
+ [
+ 32,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 5,
+ 32
+ ],
+ [
+ 1,
+ 10257
+ ],
+ [
+ 1,
+ 10372
+ ],
+ [
+ 10,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10304
+ ],
+ [
+ 11,
+ 32
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 9,
+ 32
+ ],
+ [
+ 1,
+ 10257
+ ],
+ [
+ 1,
+ 10372
+ ],
+ [
+ 6,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 5,
+ 32
+ ],
+ [
+ 1,
+ 10248
+ ],
+ [
+ 1,
+ 10257
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 5,
+ 32
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 14,
+ 32
+ ],
+ [
+ 1,
+ 10257
+ ],
+ [
+ 1,
+ 10372
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 12,
+ 32
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 1,
+ 10257
+ ],
+ [
+ 1,
+ 10274
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 18,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 37,
+ 32
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 39,
+ 32
+ ]
+ ],
+ "fg": [
+ [
+ 31,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 37,
+ 29806811
+ ],
+ [
+ 5,
+ 22587135
+ ],
+ [
+ 32,
+ 29806811
+ ],
+ [
+ 3,
+ 22587135
+ ],
+ [
+ 5,
+ 29806811
+ ],
+ [
+ 2,
+ 22587135
+ ],
+ [
+ 10,
+ 29806811
+ ],
+ [
+ 7,
+ 22587135
+ ],
+ [
+ 11,
+ 29806811
+ ],
+ [
+ 3,
+ 22587135
+ ],
+ [
+ 9,
+ 29806811
+ ],
+ [
+ 2,
+ 22587135
+ ],
+ [
+ 6,
+ 29806811
+ ],
+ [
+ 3,
+ 22587135
+ ],
+ [
+ 5,
+ 29806811
+ ],
+ [
+ 5,
+ 22587135
+ ],
+ [
+ 5,
+ 29806811
+ ],
+ [
+ 2,
+ 22587135
+ ],
+ [
+ 14,
+ 29806811
+ ],
+ [
+ 2,
+ 22587135
+ ],
+ [
+ 2,
+ 29806811
+ ],
+ [
+ 3,
+ 22587135
+ ],
+ [
+ 12,
+ 29806811
+ ],
+ [
+ 5,
+ 22587135
+ ],
+ [
+ 18,
+ 29806811
+ ],
+ [
+ 3,
+ 22587135
+ ],
+ [
+ 37,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 39,
+ 29806811
+ ]
+ ],
+ "bg": [
+ [
+ 320,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 320,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " ⢀ ",
+ " ⡠⠒⠁⠑⢄ ",
+ " ⢀⠤⠊ ⠑⢄ ",
+ " ⢀⠔⠉⠒⠤⣀⡀ ⣀⠔⠁ ⠑⢄ ",
+ " ⢀⠔⠁ ⠈⠑⠒⠤⣀ ⡠⠊ ⠑⢄",
+ " ⢀⠔⠁ ⠉⠑⠢⠔⠉ ",
+ "⢀⠔⠁ ",
+ "⠁ "
+ ]
+ }
+ },
+ {
+ "name": "chart-scatter",
+ "width": 40,
+ "height": 8,
+ "result": {
+ "width": 40,
+ "height": 8,
+ "chars": [
+ [
+ 31,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 96,
+ 32
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 70,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 20,
+ 32
+ ],
+ [
+ 1,
+ 10244
+ ],
+ [
+ 59,
+ 32
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 39,
+ 32
+ ]
+ ],
+ "fg": [
+ [
+ 31,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 96,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 70,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 20,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 59,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 39,
+ 29806811
+ ]
+ ],
+ "bg": [
+ [
+ 320,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 320,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " ⢀ ",
+ " ",
+ " ",
+ " ⠁ ",
+ " ⢀",
+ " ⠄ ",
+ " ",
+ "⠁ "
+ ]
+ }
+ },
+ {
+ "name": "chart-bar",
+ "width": 40,
+ "height": 8,
+ "result": {
+ "width": 40,
+ "height": 8,
+ "chars": [
+ [
+ 31,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 39,
+ 32
+ ],
+ [
+ 1,
+ 10424
+ ],
+ [
+ 39,
+ 32
+ ],
+ [
+ 1,
+ 10424
+ ],
+ [
+ 16,
+ 32
+ ],
+ [
+ 1,
+ 10311
+ ],
+ [
+ 22,
+ 32
+ ],
+ [
+ 1,
+ 10424
+ ],
+ [
+ 16,
+ 32
+ ],
+ [
+ 1,
+ 10311
+ ],
+ [
+ 22,
+ 32
+ ],
+ [
+ 1,
+ 10424
+ ],
+ [
+ 7,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 8,
+ 32
+ ],
+ [
+ 1,
+ 10311
+ ],
+ [
+ 11,
+ 32
+ ],
+ [
+ 1,
+ 10308
+ ],
+ [
+ 10,
+ 32
+ ],
+ [
+ 1,
+ 10424
+ ],
+ [
+ 7,
+ 32
+ ],
+ [
+ 1,
+ 10424
+ ],
+ [
+ 8,
+ 32
+ ],
+ [
+ 1,
+ 10311
+ ],
+ [
+ 11,
+ 32
+ ],
+ [
+ 1,
+ 10311
+ ],
+ [
+ 10,
+ 32
+ ],
+ [
+ 1,
+ 10424
+ ],
+ [
+ 7,
+ 32
+ ],
+ [
+ 1,
+ 10424
+ ],
+ [
+ 1,
+ 10311
+ ],
+ [
+ 7,
+ 32
+ ],
+ [
+ 1,
+ 10311
+ ],
+ [
+ 11,
+ 32
+ ],
+ [
+ 1,
+ 10311
+ ],
+ [
+ 10,
+ 32
+ ],
+ [
+ 1,
+ 10424
+ ],
+ [
+ 7,
+ 32
+ ],
+ [
+ 1,
+ 10424
+ ]
+ ],
+ "fg": [
+ [
+ 31,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 39,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 39,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 16,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 22,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 16,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 22,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 7,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 8,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 11,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 10,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 7,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 8,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 11,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 10,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 7,
+ 29806811
+ ],
+ [
+ 2,
+ 22587135
+ ],
+ [
+ 7,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 11,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 10,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 7,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ]
+ ],
+ "bg": [
+ [
+ 320,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 320,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " ⢀ ",
+ " ⢸ ",
+ " ⢸ ",
+ " ⡇ ⢸ ",
+ " ⡇ ⢸ ⢀",
+ " ⡇ ⡄ ⢸ ⢸",
+ " ⡇ ⡇ ⢸ ⢸",
+ "⡇ ⡇ ⡇ ⢸ ⢸"
+ ]
+ }
+ },
+ {
+ "name": "chart-fill",
+ "width": 40,
+ "height": 8,
+ "result": {
+ "width": 40,
+ "height": 8,
+ "chars": [
+ [
+ 58,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 32,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 3,
+ 9608
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 24,
+ 32
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 11,
+ 9608
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 16,
+ 32
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 20,
+ 9608
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 8,
+ 32
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 28,
+ 9608
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 36,
+ 9608
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 40,
+ 9608
+ ]
+ ],
+ "fg": [
+ [
+ 58,
+ 29806811
+ ],
+ [
+ 4,
+ 22587135
+ ],
+ [
+ 32,
+ 29806811
+ ],
+ [
+ 5,
+ 22587135
+ ],
+ [
+ 3,
+ 19615866
+ ],
+ [
+ 4,
+ 22587135
+ ],
+ [
+ 24,
+ 29806811
+ ],
+ [
+ 5,
+ 22587135
+ ],
+ [
+ 11,
+ 19483765
+ ],
+ [
+ 4,
+ 22587135
+ ],
+ [
+ 16,
+ 29806811
+ ],
+ [
+ 4,
+ 22587135
+ ],
+ [
+ 20,
+ 19351920
+ ],
+ [
+ 4,
+ 22587135
+ ],
+ [
+ 8,
+ 29806811
+ ],
+ [
+ 4,
+ 22587135
+ ],
+ [
+ 28,
+ 19285354
+ ],
+ [
+ 4,
+ 22587135
+ ],
+ [
+ 2,
+ 29806811
+ ],
+ [
+ 2,
+ 22587135
+ ],
+ [
+ 36,
+ 19153509
+ ],
+ [
+ 2,
+ 22587135
+ ],
+ [
+ 40,
+ 19021664
+ ]
+ ],
+ "bg": [
+ [
+ 320,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 320,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " ",
+ " ⢀⡠⠤⣀ ",
+ " ⢀⡠⠔⠊⠁███⠉⠒⠤⣀ ",
+ " ⣀⡠⠔⠊⠁███████████⠉⠒⠤⣀ ",
+ " ⣀⠤⠒⠉████████████████████⠉⠒⠤⣀ ",
+ " ⣀⠤⠒⠉████████████████████████████⠉⠒⠤⣀ ",
+ "⠒⠉████████████████████████████████████⠉⠒",
+ "████████████████████████████████████████"
+ ]
+ }
+ },
+ {
+ "name": "chart-axes",
+ "width": 44,
+ "height": 9,
+ "result": {
+ "width": 44,
+ "height": 9,
+ "chars": [
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 2,
+ 48
+ ],
+ [
+ 35,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 34,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 33,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 34,
+ 32
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 33,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 33,
+ 32
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 33,
+ 32
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 33,
+ 32
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 39,
+ 32
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 19,
+ 32
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 17,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 48
+ ]
+ ],
+ "fg": [
+ [
+ 4,
+ 22702973
+ ],
+ [
+ 35,
+ 29806811
+ ],
+ [
+ 5,
+ 22587135
+ ],
+ [
+ 34,
+ 29806811
+ ],
+ [
+ 6,
+ 22587135
+ ],
+ [
+ 33,
+ 29806811
+ ],
+ [
+ 6,
+ 22587135
+ ],
+ [
+ 34,
+ 29806811
+ ],
+ [
+ 5,
+ 22587135
+ ],
+ [
+ 33,
+ 29806811
+ ],
+ [
+ 6,
+ 22587135
+ ],
+ [
+ 33,
+ 29806811
+ ],
+ [
+ 6,
+ 22587135
+ ],
+ [
+ 33,
+ 29806811
+ ],
+ [
+ 5,
+ 22587135
+ ],
+ [
+ 30,
+ 29806811
+ ],
+ [
+ 4,
+ 22702973
+ ],
+ [
+ 5,
+ 22587135
+ ],
+ [
+ 39,
+ 29806811
+ ],
+ [
+ 1,
+ 22702973
+ ],
+ [
+ 19,
+ 29806811
+ ],
+ [
+ 1,
+ 22702973
+ ],
+ [
+ 17,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ]
+ ],
+ "bg": [
+ [
+ 396,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 396,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " 100 ⢀⡠⠤⠒⠉",
+ " ⢀⣀⠤⠒⠊⠁ ",
+ " ⢀⣀⠤⠒⠊⠁ ",
+ " ⣀⠤⠔⠊⠁ ",
+ " ⢀⣀⠤⠒⠊⠉ ",
+ " ⣀⡠⠤⠒⠉⠁ ",
+ " ⣀⠤⠔⠒⠉ ",
+ " 0⣀⠤⠒⠊⠉ ",
+ " 0 5 10"
+ ]
+ }
+ },
+ {
+ "name": "chart-block",
+ "width": 40,
+ "height": 8,
+ "result": {
+ "width": 40,
+ "height": 8,
+ "chars": [
+ [
+ 31,
+ 32
+ ],
+ [
+ 1,
+ 9602
+ ],
+ [
+ 39,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 39,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 16,
+ 32
+ ],
+ [
+ 1,
+ 9606
+ ],
+ [
+ 22,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 16,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 22,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 7,
+ 32
+ ],
+ [
+ 1,
+ 9602
+ ],
+ [
+ 8,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 11,
+ 32
+ ],
+ [
+ 1,
+ 9603
+ ],
+ [
+ 10,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 7,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 8,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 11,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 10,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 7,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 1,
+ 9606
+ ],
+ [
+ 7,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 11,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 10,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ],
+ [
+ 7,
+ 32
+ ],
+ [
+ 1,
+ 9608
+ ]
+ ],
+ "fg": [
+ [
+ 31,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 39,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 39,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 16,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 22,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 16,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 22,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 7,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 8,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 11,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 10,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 7,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 8,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 11,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 10,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 7,
+ 29806811
+ ],
+ [
+ 2,
+ 22587135
+ ],
+ [
+ 7,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 11,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 10,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 7,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ]
+ ],
+ "bg": [
+ [
+ 320,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 320,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " ▂ ",
+ " █ ",
+ " █ ",
+ " ▆ █ ",
+ " █ █ ▂",
+ " █ ▃ █ █",
+ " █ █ █ █",
+ "▆ █ █ █ █"
+ ]
+ }
+ },
+ {
+ "name": "chart-multi",
+ "width": 44,
+ "height": 9,
+ "result": {
+ "width": 44,
+ "height": 9,
+ "chars": [
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 40,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10372
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10304
+ ],
+ [
+ 33,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 6,
+ 32
+ ],
+ [
+ 1,
+ 10248
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 1,
+ 10257
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10274
+ ],
+ [
+ 2,
+ 10276
+ ],
+ [
+ 2,
+ 10432
+ ],
+ [
+ 15,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 2,
+ 10276
+ ],
+ [
+ 2,
+ 10432
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 18,
+ 32
+ ],
+ [
+ 2,
+ 10249
+ ],
+ [
+ 2,
+ 10258
+ ],
+ [
+ 2,
+ 10276
+ ],
+ [
+ 1,
+ 10372
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10304
+ ],
+ [
+ 4,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 6,
+ 32
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 24,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 1,
+ 10257
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10248
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 1,
+ 10257
+ ],
+ [
+ 2,
+ 10258
+ ],
+ [
+ 2,
+ 10276
+ ],
+ [
+ 2,
+ 10432
+ ],
+ [
+ 19,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 2,
+ 10432
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 13,
+ 32
+ ],
+ [
+ 2,
+ 10249
+ ],
+ [
+ 2,
+ 10258
+ ],
+ [
+ 1,
+ 10274
+ ],
+ [
+ 1,
+ 10276
+ ],
+ [
+ 1,
+ 10372
+ ],
+ [
+ 1,
+ 10432
+ ],
+ [
+ 1,
+ 10304
+ ],
+ [
+ 6,
+ 32
+ ],
+ [
+ 1,
+ 10368
+ ],
+ [
+ 1,
+ 10336
+ ],
+ [
+ 1,
+ 10260
+ ],
+ [
+ 1,
+ 10250
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 4,
+ 32
+ ],
+ [
+ 2,
+ 10249
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 1,
+ 10241
+ ],
+ [
+ 24,
+ 32
+ ],
+ [
+ 1,
+ 10248
+ ],
+ [
+ 1,
+ 10249
+ ],
+ [
+ 1,
+ 10257
+ ],
+ [
+ 1,
+ 10258
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 9632
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 102
+ ],
+ [
+ 1,
+ 105
+ ],
+ [
+ 1,
+ 110
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 9632
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 99
+ ],
+ [
+ 1,
+ 111
+ ],
+ [
+ 1,
+ 97
+ ],
+ [
+ 1,
+ 114
+ ],
+ [
+ 1,
+ 115
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 29,
+ 32
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 39,
+ 32
+ ],
+ [
+ 1,
+ 55
+ ]
+ ],
+ "fg": [
+ [
+ 3,
+ 22702973
+ ],
+ [
+ 40,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 4,
+ 23068551
+ ],
+ [
+ 33,
+ 29806811
+ ],
+ [
+ 4,
+ 22587135
+ ],
+ [
+ 6,
+ 29806811
+ ],
+ [
+ 9,
+ 23068551
+ ],
+ [
+ 15,
+ 29806811
+ ],
+ [
+ 7,
+ 22587135
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 3,
+ 22587135
+ ],
+ [
+ 18,
+ 29806811
+ ],
+ [
+ 9,
+ 23068551
+ ],
+ [
+ 4,
+ 29806811
+ ],
+ [
+ 3,
+ 22587135
+ ],
+ [
+ 6,
+ 29806811
+ ],
+ [
+ 1,
+ 22587135
+ ],
+ [
+ 24,
+ 29806811
+ ],
+ [
+ 5,
+ 22587135
+ ],
+ [
+ 9,
+ 23068551
+ ],
+ [
+ 19,
+ 29806811
+ ],
+ [
+ 5,
+ 22587135
+ ],
+ [
+ 3,
+ 29806811
+ ],
+ [
+ 4,
+ 22587135
+ ],
+ [
+ 13,
+ 29806811
+ ],
+ [
+ 9,
+ 23068551
+ ],
+ [
+ 6,
+ 29806811
+ ],
+ [
+ 5,
+ 22587135
+ ],
+ [
+ 4,
+ 29806811
+ ],
+ [
+ 4,
+ 22587135
+ ],
+ [
+ 24,
+ 29806811
+ ],
+ [
+ 4,
+ 23068551
+ ],
+ [
+ 3,
+ 22702973
+ ],
+ [
+ 2,
+ 22587135
+ ],
+ [
+ 5,
+ 22702973
+ ],
+ [
+ 2,
+ 23068551
+ ],
+ [
+ 7,
+ 22702973
+ ],
+ [
+ 28,
+ 29806811
+ ],
+ [
+ 1,
+ 22702973
+ ],
+ [
+ 39,
+ 29806811
+ ],
+ [
+ 1,
+ 22702973
+ ]
+ ],
+ "bg": [
+ [
+ 396,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 396,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " 10 ⢀",
+ " ⠤⢄⣀⡀ ⢀⡠⠊⠁",
+ " ⠈⠉⠑⠒⠢⠤⠤⣀⣀ ⢀⡠⠒⠤⠤⣀⣀ ⡠⠔⠁ ",
+ " ⠉⠉⠒⠒⠤⠤⢄⣀⡀ ⢀⠔⠁ ⠉ ",
+ " ⢀⠔⠊⠑⠒⠈⠉⠑⠒⠒⠤⠤⣀⣀ ",
+ " ⢀⡠⠤⣀⣀ ⢀⡠⠊⠁ ⠉⠉⠒⠒⠢⠤⢄⣀⡀ ",
+ " ⢀⡠⠔⠊⠁ ⠉⠉⠒⠁ ⠈⠉⠑⠒",
+ " 0■ fine ■ coarse ",
+ " 0 7"
+ ]
+ }
+ },
+ {
+ "name": "chart-flat",
+ "width": 30,
+ "height": 5,
+ "result": {
+ "width": 30,
+ "height": 5,
+ "chars": [
+ [
+ 60,
+ 32
+ ],
+ [
+ 30,
+ 10276
+ ],
+ [
+ 60,
+ 32
+ ]
+ ],
+ "fg": [
+ [
+ 60,
+ 29806811
+ ],
+ [
+ 30,
+ 22587135
+ ],
+ [
+ 60,
+ 29806811
+ ]
+ ],
+ "bg": [
+ [
+ 150,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 150,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " ",
+ " ",
+ "⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤",
+ " ",
+ " "
+ ]
+ }
+ },
{
"name": "graph-axis",
"width": 30,
diff --git a/ports/conformance/generate.ts b/ports/conformance/generate.ts
index 3403f80..8bb1d6d 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 d812c58..908d7ac 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 src/scrollbar.cpp)
+add_library(hqtui_cpp_widgets src/widgets.cpp src/scrollbar.cpp src/chart.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 95a6352..ed2f9ff 100644
--- a/ports/cpp/examples/widgets.cpp
+++ b/ports/cpp/examples/widgets.cpp
@@ -99,6 +99,29 @@ void widget_scrollbar(Surface s) {
}
// @end
+// @widget chart
+void widget_chart(Surface s) {
+ // Points carry their own x, so a sparse series and a dense one line up.
+ Chart c;
+ c.series = {
+ {{{0, 1}, {2, 6}, {5, 3}, {8, 9}, {10, 4}}, 0, "load"},
+ {{{0, 8}, {10, 2}}, 0, "limit"},
+ };
+ c.axis = true;
+ c.legend = true;
+ Axis x;
+ x.min = 0;
+ x.max = 10;
+ x.ticks = 3;
+ Axis y;
+ y.min = 0;
+ y.max = 10;
+ c.plot.x = x;
+ c.plot.y = y;
+ draw_chart(s, c);
+}
+// @end
+
// @widget meter
void widget_meter(Surface s) {
const int width = s.rect().width;
@@ -508,6 +531,7 @@ int main() {
{"tree", widget_tree},
{"log", widget_log},
{"scrollbar", widget_scrollbar},
+ {"chart", widget_chart},
{"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 d9a25c7..7c4acf8 100644
--- a/ports/cpp/include/hqtui/widgets.hpp
+++ b/ports/cpp/include/hqtui/widgets.hpp
@@ -63,6 +63,8 @@ inline std::string fit(std::string_view s, int columns, int align = HQ_LEFT,
return std::string(left, ' ') + out + std::string(padding - left, ' ');
}
inline const hq_theme &theme(Surface s) { return *s.native().theme; }
+/// `4` rather than `4.0`, `4.2` rather than `4.20`.
+std::string number(double);
/// Returns the columns written, so a caller laying items out in a row can
/// advance by what was actually drawn rather than by what it hoped to draw.
inline std::size_t text(Surface s, int x, int y, std::string_view value, Color fg,
@@ -329,6 +331,58 @@ struct Progress {
};
void draw_progress(Surface, const Progress &);
void draw_graph(Surface, const Graph &);
+
+/// A point in a chart's own coordinates, not the grid's.
+struct ChartPoint {
+ double x = 0, y = 0;
+};
+/// How a series is marked: joined, dotted, or dropped to the baseline.
+enum MarkType { HQ_MARK_LINE, HQ_MARK_SCATTER, HQ_MARK_BAR };
+struct ChartSeries {
+ std::vector points;
+ Color color = 0;
+ std::string label;
+ MarkType mark = HQ_MARK_LINE;
+ /// Shade between the line and the baseline. Ignored for a scatter.
+ bool fill = false;
+};
+/// One axis: what it spans and how its numbers read.
+struct Axis {
+ std::optional min, max;
+ std::function format;
+ /// How many labels to place. Default 2 -- the ends.
+ int ticks = 0;
+};
+struct Domain {
+ double min = 0, max = 1;
+};
+struct ChartPlot {
+ std::string mode = "braille";
+ std::optional x, y;
+ std::optional background;
+ bool grid = false;
+ std::optional grid_color;
+ /// 0-1 opacity of the area fill against the background.
+ std::optional fill_alpha;
+ /// Where a bar or an area is measured from. Defaults to the y minimum.
+ std::optional baseline;
+};
+struct Chart {
+ std::vector series;
+ ChartPlot plot;
+ /// Numbers down the left edge.
+ bool axis = false;
+ Color axis_color = 0;
+ bool legend = false;
+ int legend_align = HQ_LEFT;
+};
+/// The span an axis covers, from the caller where they said and from the data
+/// where they did not.
+Domain domain_of(const std::vector &, const Axis *, int which);
+/// Draw point series across the whole surface.
+void plot_points(Surface, const std::vector &, const ChartPlot &);
+/// A chart of arbitrary (x, y) data, with a domain on both axes.
+void draw_chart(Surface, const Chart &);
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.
@@ -775,6 +829,14 @@ class UI {
void progress(Progress o) {
draw([=](Surface s) { draw_progress(s, o); }, cells(1));
}
+ /// A chart of arbitrary (x, y) data, with a domain on both axes.
+ ///
+ /// `graph` plots a history buffer, one sample per column. Use this when the
+ /// data has its own x values: two series of different lengths then line up,
+ /// and a point lands where its x says it does.
+ void chart(Chart o, Constraint size = fr()) {
+ draw([=](Surface s) { draw_chart(s, o); }, size);
+ }
void sparkline(Sparkline o) {
draw([=](Surface s) { draw_sparkline(s, o); }, cells(1));
}
diff --git a/ports/cpp/src/chart.cpp b/ports/cpp/src/chart.cpp
new file mode 100644
index 0000000..37e3e0d
--- /dev/null
+++ b/ports/cpp/src/chart.cpp
@@ -0,0 +1,364 @@
+/// Charts of arbitrary (x, y) data.
+///
+/// `draw_graph` takes a vector of doubles and puts one sample per column: the x
+/// axis is the vector index. That is the right model for a history buffer and
+/// the wrong one for everything else -- two series of different lengths
+/// silently render at different horizontal scales, a gap in the data is
+/// indistinguishable from a shorter series, and there is no way at all to say
+/// where on the x axis a point belongs.
+///
+/// This takes points and a domain for each axis, so a series is placed rather
+/// than appended. `draw_graph` is untouched and still means what it meant.
+#include
+
+namespace hqtui {
+namespace {
+
+/// A finite number, or nothing: a caller's bound is data, and data can be NaN.
+std::optional bound(std::optional value) {
+ if (value && std::isfinite(*value))
+ return value;
+ return std::nullopt;
+}
+
+double clamp01(double v) { return std::clamp(v, 0.0, 1.0); }
+
+/// Where a value sits in its domain, 0 at the minimum and 1 at the maximum.
+double ratio(double value, Domain d) { return (value - d.min) / (d.max - d.min); }
+
+/// The eighth-block for a partial cell, as the reference spells it.
+uint32_t partial(double r, const std::string &mode) {
+ int n = std::clamp(iround(clamp01(r) * 8), 0, 8);
+ if (mode == "ascii")
+ return r <= 0 ? ' ' : r < .4 ? '.' : r < .7 ? '=' : '#';
+ return n ? 0x2580 + n : ' ';
+}
+
+} // namespace
+
+Domain domain_of(const std::vector &series, const Axis *axis, int which) {
+ auto min = axis ? bound(axis->min) : std::nullopt;
+ auto max = axis ? bound(axis->max) : std::nullopt;
+ if (!min || !max) {
+ double lo = INFINITY, hi = -INFINITY;
+ for (auto &s : series)
+ for (auto &p : s.points) {
+ double v = which == 0 ? p.x : p.y;
+ if (!std::isfinite(v))
+ continue;
+ lo = std::min(lo, v);
+ hi = std::max(hi, v);
+ }
+ if (!std::isfinite(lo)) {
+ lo = 0;
+ hi = 1;
+ }
+ if (!min)
+ min = lo;
+ if (!max)
+ max = hi;
+ }
+ if (!(*max > *min)) {
+ // A flat series still has to be drawn somewhere sensible.
+ double pad = std::abs(*min) > 0 ? std::abs(*min) * .5 : .5;
+ return {*min - pad, *min + pad};
+ }
+ return {*min, *max};
+}
+
+/// The area between a series and its baseline, in block elements.
+///
+/// Braille would give eight scattered dots per cell, which reads as noise where
+/// an area should read as an area. The line itself stays Braille, so it keeps
+/// the sub-cell resolution.
+///
+/// The height of each column is interpolated along the line rather than sampled
+/// from the points that happen to land in it. Sampling leaves a gap wherever a
+/// column has no point of its own, which with arbitrary x values is most of
+/// them -- the area comes out striped instead of solid.
+static void fill_under(Surface s, const std::vector &points, Domain xd,
+ Domain yd, double baseline, Color color,
+ std::optional bg, double alpha) {
+ int w = s.rect().width, h = s.rect().height;
+ if (w <= 0 || h <= 0 || points.empty())
+ return;
+ auto &t = theme(s);
+ Color base = bg.value_or(t.background);
+ double floor_at = clamp01(ratio(baseline, yd));
+ auto column = [&](double x) { return ratio(x, xd) * (w - 1); };
+
+ std::vector tops(std::size_t(w), std::numeric_limits::quiet_NaN());
+ auto record = [&](int col, double value) {
+ if (col < 0 || col >= w)
+ return;
+ // A path that doubles back covers a column twice; the outer edge is the one
+ // that bounds the area.
+ double previous = tops[std::size_t(col)];
+ if (std::isnan(previous) ||
+ std::abs(value - floor_at) > std::abs(previous - floor_at))
+ tops[std::size_t(col)] = value;
+ };
+
+ if (points.size() == 1)
+ record(iround(column(points[0].x)), clamp01(ratio(points[0].y, yd)));
+ for (std::size_t i = 0; i + 1 < points.size(); i++) {
+ double x0 = points[i].x, y0 = points[i].y;
+ double x1 = points[i + 1].x, y1 = points[i + 1].y;
+ double c0 = column(x0), c1 = column(x1);
+ int from = int(std::max(0.0, std::floor(std::min(c0, c1))));
+ int to = int(std::min(double(w - 1), std::max(0.0, std::ceil(std::max(c0, c1)))));
+ for (int col = from; col <= to; col++) {
+ double u = c1 == c0 ? 0 : (col - c0) / (c1 - c0);
+ if (u < -.5 || u > 1.5)
+ continue;
+ record(col, clamp01(ratio(y0 + (y1 - y0) * clamp01(u), yd)));
+ }
+ }
+
+ for (int x = 0; x < w; x++) {
+ double top = tops[std::size_t(x)];
+ if (std::isnan(top))
+ continue;
+ double from01 = std::min(floor_at, top);
+ double filled = (std::max(floor_at, top) - from01) * h;
+ int bottom = int(std::floor(from01 * h));
+ int full = int(std::floor(filled));
+ for (int k = 0; k < full && k < h; k++) {
+ int row = h - 1 - bottom - k;
+ if (row < 0 || row >= h)
+ continue;
+ double depth = h <= 1 ? 0 : double(row) / (h - 1);
+ auto st = Style().foreground(hq_mix(base, color, alpha * (1 - depth * .3)));
+ if (bg)
+ st = st.background(*bg);
+ s.set(x, row, 0x2588, st);
+ }
+ if (full < h) {
+ uint32_t glyph = partial(filled - full, "block");
+ int row = h - 1 - bottom - full;
+ if (glyph != ' ' && row >= 0 && row < h) {
+ double depth = h <= 1 ? 0 : double(row) / (h - 1);
+ auto st =
+ Style().foreground(hq_mix(base, color, alpha * (1 - depth * .3) + .12));
+ if (bg)
+ st = st.background(*bg);
+ s.set(x, row, glyph, st);
+ }
+ }
+ }
+}
+
+/// The block and ascii degradations: one column per cell, tallest point wins.
+///
+/// A scatter keeps its dots rather than growing columns, because a scatter that
+/// fills to the baseline is a bar chart wearing the wrong name.
+static void plot_cells(Surface s, const std::vector &series,
+ const std::string &mode, Domain xd, Domain yd,
+ double baseline, std::optional bg) {
+ int w = s.rect().width, h = s.rect().height;
+ if (w <= 0 || h <= 0)
+ return;
+ auto &t = theme(s);
+ double floor_ratio = clamp01(ratio(baseline, yd));
+
+ for (std::size_t si = 0; si < series.size(); si++) {
+ auto &cs = series[si];
+ Color color = cs.color ? cs.color : hq_series(&t, si);
+ // Highest value per column, so a column shows the peak that fell in it
+ // rather than whichever point happened to be last.
+ std::vector tops(std::size_t(w), std::numeric_limits::quiet_NaN());
+ for (auto &p : cs.points) {
+ if (!std::isfinite(p.x) || !std::isfinite(p.y))
+ continue;
+ int col = std::clamp(iround(ratio(p.x, xd) * (w - 1)), 0, w - 1);
+ double value = clamp01(ratio(p.y, yd));
+ if (std::isnan(tops[std::size_t(col)]) || value > tops[std::size_t(col)])
+ tops[std::size_t(col)] = value;
+ }
+
+ for (int x = 0; x < w; x++) {
+ double top = tops[std::size_t(x)];
+ if (std::isnan(top))
+ continue;
+ auto st = Style().foreground(color);
+ if (bg)
+ st = st.background(*bg);
+ if (cs.mark == HQ_MARK_SCATTER) {
+ int row = h - 1 - std::min(int(std::floor(top * h)), h - 1);
+ s.set(x, row, mode == "ascii" ? '*' : 0x2022, st);
+ continue;
+ }
+ double from = std::min(floor_ratio, top) * h;
+ double filled = (std::max(floor_ratio, top) - std::min(floor_ratio, top)) * h;
+ int full = int(std::floor(filled));
+ for (int k = 0; k < full; k++) {
+ int row = h - 1 - int(std::floor(from)) - k;
+ if (row >= 0 && row < h)
+ s.set(x, row, 0x2588, st);
+ }
+ uint32_t glyph = partial(filled - full, mode);
+ int row = h - 1 - int(std::floor(from)) - full;
+ if (glyph != ' ' && row >= 0 && row < h)
+ s.set(x, row, glyph, st);
+ }
+ }
+}
+
+void plot_points(Surface s, const std::vector &series,
+ const ChartPlot &o) {
+ if (s.rect().width <= 0 || s.rect().height <= 0 || series.empty())
+ return;
+ auto &t = theme(s);
+ int w = s.rect().width, h = s.rect().height;
+
+ Domain xd = domain_of(series, o.x ? &*o.x : nullptr, 0);
+ Domain yd = domain_of(series, o.y ? &*o.y : nullptr, 1);
+ double baseline = bound(o.baseline).value_or(yd.min);
+
+ if (o.grid) {
+ Color color = o.grid_color.value_or(hq_mix(t.border, t.background, .4));
+ for (int y = 0; y < h; y += std::max(2, h / 4))
+ for (int x = 0; x < w; x += 2)
+ s.set(x, y, 0xb7, Style().foreground(color));
+ }
+
+ if (o.mode != "braille") {
+ plot_cells(s, series, o.mode, xd, yd, baseline, o.background);
+ return;
+ }
+
+ for (std::size_t si = 0; si < series.size(); si++) {
+ auto &cs = series[si];
+ Color color = cs.color ? cs.color : hq_series(&t, si);
+ std::vector finite;
+ for (auto &p : cs.points)
+ if (std::isfinite(p.x) && std::isfinite(p.y))
+ finite.push_back(p);
+ if (finite.empty())
+ continue;
+
+ Braille canvas(w, h);
+ double px = canvas.w, py = canvas.h;
+ std::vector pixels;
+ pixels.reserve(finite.size());
+ for (auto &p : finite)
+ pixels.push_back({double(iround(clamp01(ratio(p.x, xd)) * (px - 1))),
+ double(iround((1 - clamp01(ratio(p.y, yd))) * (py - 1)))});
+
+ if (cs.mark == HQ_MARK_SCATTER) {
+ for (auto &p : pixels)
+ canvas.pixel(p.x, p.y);
+ } else if (cs.mark == HQ_MARK_BAR) {
+ double floor_px = iround((1 - clamp01(ratio(baseline, yd))) * (py - 1));
+ for (auto &p : pixels)
+ canvas.line(p.x, std::min(p.y, floor_px), p.x, std::max(p.y, floor_px));
+ } else if (pixels.size() == 1) {
+ canvas.pixel(pixels[0].x, pixels[0].y);
+ } else {
+ for (std::size_t i = 0; i + 1 < pixels.size(); i++)
+ canvas.line(pixels[i].x, pixels[i].y, pixels[i + 1].x, pixels[i + 1].y);
+ }
+
+ if (cs.fill && cs.mark != HQ_MARK_SCATTER)
+ fill_under(s, finite, xd, yd, baseline, color, o.background,
+ o.fill_alpha.value_or(.5));
+ canvas.blit(s, color, o.background);
+ }
+}
+
+/// Evenly spaced values across a domain, ends included.
+///
+/// Two ticks means the ends and nothing else, which is what an axis wants when
+/// there is no room to say more.
+static std::vector ticks_for(double min, double max, int count) {
+ int n = std::max(2, count);
+ std::vector out;
+ out.reserve(std::size_t(n));
+ for (int i = 0; i < n; i++)
+ out.push_back(min + (max - min) * i / (n - 1));
+ return out;
+}
+
+void draw_chart(Surface surface, const Chart &o) {
+ if (surface.rect().width <= 0 || surface.rect().height <= 0)
+ return;
+ auto &t = theme(surface);
+ Color axis_color = o.axis_color ? o.axis_color : t.muted;
+
+ Domain xd = domain_of(o.series, o.plot.x ? &*o.plot.x : nullptr, 0);
+ Domain yd = domain_of(o.series, o.plot.y ? &*o.plot.y : nullptr, 1);
+
+ auto label_of = [](const std::optional &axis, double v) {
+ if (axis && axis->format)
+ return axis->format(v);
+ return std::abs(v) >= 1000 ? number(std::floor(v / 100 + .5) / 10) + "k"
+ : number(v);
+ };
+
+ // The x labels take a row, and they can only take one when there is a row to
+ // spare -- a two-row chart is all plot.
+ int x_ticks = o.axis ? 2 : 0;
+ if (o.plot.x && o.plot.x->ticks > 0)
+ x_ticks = o.plot.x->ticks;
+ const bool want_x_axis = o.axis && x_ticks >= 2 && surface.rect().height > 2;
+
+ Surface s = surface;
+ if (o.axis) {
+ std::string hi = label_of(o.plot.y, yd.max), lo = label_of(o.plot.y, yd.min);
+ int lw = std::max(int(width(hi)), int(width(lo))) + 1;
+ text(s, 0, 0, fit(hi, lw, HQ_RIGHT), axis_color);
+ if (s.rect().height > 1) {
+ // The minimum marks the bottom of the plot, which is a row higher when
+ // the x labels have taken the last one.
+ int bottom = want_x_axis ? s.rect().height - 2 : s.rect().height - 1;
+ text(s, 0, bottom, fit(lo, lw, HQ_RIGHT), axis_color);
+ }
+ s = s.sub({lw, 0, std::max(0, s.rect().width - lw), s.rect().height});
+ }
+
+ Surface area = s;
+ if (want_x_axis && s.rect().height > 1 && s.rect().width > 0) {
+ area = s.sub({0, 0, s.rect().width, s.rect().height - 1});
+ int row = s.rect().height - 1;
+ auto values = ticks_for(xd.min, xd.max, x_ticks);
+ double step = values.size() > 1 ? double(s.rect().width - 1) / (values.size() - 1) : 0;
+ for (std::size_t i = 0; i < values.size(); i++) {
+ std::string label = label_of(o.plot.x, values[i]);
+ // The last label is right-aligned to the edge, so it cannot run off it.
+ int x = std::min(s.rect().width - int(width(label)), iround(double(i) * step));
+ text(s, std::max(0, x), row, label, axis_color);
+ }
+ }
+
+ // The domain is resolved once and handed down, so the labels and the marks
+ // cannot disagree about what the axis spans.
+ ChartPlot plot = o.plot;
+ Axis x_axis = plot.x.value_or(Axis{});
+ x_axis.min = xd.min;
+ x_axis.max = xd.max;
+ Axis y_axis = plot.y.value_or(Axis{});
+ y_axis.min = yd.min;
+ y_axis.max = yd.max;
+ plot.x = x_axis;
+ plot.y = y_axis;
+ plot_points(area, o.series, plot);
+
+ if (o.legend) {
+ int total = 0;
+ for (auto &cs : o.series)
+ if (!cs.label.empty())
+ total += int(width(cs.label)) + 3;
+ int x = o.legend_align == HQ_RIGHT ? std::max(0, area.rect().width - total) : 0;
+ int y = area.rect().height > 3 ? area.rect().height - 1 : 0;
+ for (std::size_t i = 0; i < o.series.size(); i++) {
+ auto &cs = o.series[i];
+ if (cs.label.empty())
+ continue;
+ Color color = cs.color ? cs.color : hq_series(&t, i);
+ x += int(text(area, x, y, "■ ", color));
+ x += int(text(area, x, y, cs.label + " ", t.muted));
+ }
+ }
+}
+
+} // namespace hqtui
diff --git a/ports/cpp/src/widgets.cpp b/ports/cpp/src/widgets.cpp
index d429c72..e2a4854 100644
--- a/ports/cpp/src/widgets.cpp
+++ b/ports/cpp/src/widgets.cpp
@@ -2,7 +2,7 @@
#include
#include
namespace hqtui {
-static std::string number(double v) {
+std::string number(double v) {
char b[80];
std::snprintf(b, sizeof b, v == std::floor(v) ? "%.0f" : "%.1f", v);
return b;
diff --git a/ports/cpp/tests/conformance_widgets.cpp b/ports/cpp/tests/conformance_widgets.cpp
index a803c8d..f642177 100644
--- a/ports/cpp/tests/conformance_widgets.cpp
+++ b/ports/cpp/tests/conformance_widgets.cpp
@@ -32,6 +32,25 @@ const std::vector kSeries{3, 7, 2, 9, 4, 8, 6, 1, 5, 9,
3, 7, 8, 2, 6, 4, 9, 1, 5, 7};
/// Draws one named scene. Returns false when C++ has no implementation yet.
+
+/// The axis bounds every chart fixture pins, without the ceremony.
+static Axis axis_of(double min, double max, int ticks) {
+ Axis a;
+ a.min = min;
+ a.max = max;
+ a.ticks = ticks;
+ return a;
+}
+
+/// One series over the standard 0..10 domain.
+static Chart chart_fixture(MarkType mark) {
+ Chart c;
+ c.series = {{{{0, 1}, {2, 6}, {5, 3}, {8, 9}, {10, 4}}, 0, "", mark}};
+ c.plot.x = axis_of(0, 10, 0);
+ c.plot.y = axis_of(0, 10, 0);
+ return c;
+}
+
bool draw_scene(const std::string &name, Surface s) {
const auto &t = theme(s);
@@ -264,6 +283,64 @@ bool draw_scene(const std::string &name, Surface s) {
draw_table(s, table);
return true;
}
+ if (name == "chart-line") {
+ draw_chart(s, chart_fixture(HQ_MARK_LINE));
+ return true;
+ }
+ if (name == "chart-scatter") {
+ draw_chart(s, chart_fixture(HQ_MARK_SCATTER));
+ return true;
+ }
+ if (name == "chart-bar") {
+ draw_chart(s, chart_fixture(HQ_MARK_BAR));
+ return true;
+ }
+ if (name == "chart-fill") {
+ Chart c;
+ c.series = {{{{0, 2}, {5, 8}, {10, 2}}, 0, "", HQ_MARK_LINE, true}};
+ c.plot.x = axis_of(0, 10, 0);
+ c.plot.y = axis_of(0, 10, 0);
+ draw_chart(s, c);
+ return true;
+ }
+ if (name == "chart-axes") {
+ Chart c;
+ c.series = {{{{0, 0}, {5, 50}, {10, 100}}}};
+ c.axis = true;
+ c.plot.x = axis_of(0, 10, 3);
+ c.plot.y = axis_of(0, 100, 0);
+ draw_chart(s, c);
+ return true;
+ }
+ if (name == "chart-block") {
+ Chart c;
+ c.series = {{{{0, 1}, {2, 6}, {5, 3}, {8, 9}, {10, 4}}, 0, "", HQ_MARK_BAR}};
+ c.plot.mode = "block";
+ c.plot.x = axis_of(0, 10, 0);
+ c.plot.y = axis_of(0, 10, 0);
+ draw_chart(s, c);
+ return true;
+ }
+ if (name == "chart-multi") {
+ Chart c;
+ c.series = {
+ {{{0, 1}, {1, 3}, {2, 2}, {3, 5}, {4, 4}, {5, 7}, {6, 6}, {7, 9}}, 0, "fine"},
+ {{{0, 8}, {7, 2}}, 0, "coarse"},
+ };
+ c.axis = true;
+ c.legend = true;
+ c.plot.x = axis_of(0, 7, 0);
+ c.plot.y = axis_of(0, 10, 0);
+ draw_chart(s, c);
+ return true;
+ }
+ if (name == "chart-flat") {
+ Chart c;
+ c.series = {{{{0, 4}, {5, 4}, {10, 4}}}};
+ c.plot.x = axis_of(0, 10, 0);
+ draw_chart(s, c);
+ return true;
+ }
if (name == "graph-axis") {
Graph g;
g.series = {{kSeries, 0, "", false}};
diff --git a/ports/go/chart.go b/ports/go/chart.go
new file mode 100644
index 0000000..1685ae9
--- /dev/null
+++ b/ports/go/chart.go
@@ -0,0 +1,397 @@
+package hqtui
+
+import "math"
+
+// Charts of arbitrary (x, y) data.
+//
+// Plot takes []float64 and puts one sample per column: the x axis is the slice
+// index. That is the right model for a history buffer and the wrong one for
+// everything else — two series of different lengths silently render at
+// different horizontal scales, a gap in the data is indistinguishable from a
+// shorter series, and there is no way at all to say where on the x axis a point
+// belongs.
+//
+// This takes points and a domain for each axis, so a series is placed rather
+// than appended. Plot is untouched and still means what it meant.
+
+// MarkType is how a series is marked: joined, dotted, or dropped to the
+// baseline.
+type MarkType int
+
+const (
+ MarkLine MarkType = iota
+ MarkScatter
+ MarkBar
+)
+
+type ChartSeries struct {
+ Points []Point
+ Color *Color
+ Label string
+ Mark MarkType
+ // Fill shades between the line and the baseline. Ignored for a scatter.
+ Fill bool
+}
+
+// AxisOptions is one axis: what it spans and how its numbers read.
+type AxisOptions struct {
+ Min *float64
+ Max *float64
+ Format func(float64) string
+ // Ticks is how many labels to place. Default 2 — the ends.
+ Ticks int
+}
+
+type ChartPlotOptions struct {
+ // Mode: Braille is sharpest; block and ascii are graceful degradations.
+ Mode FillMode
+ X *AxisOptions
+ Y *AxisOptions
+ Background *Color
+ Grid bool
+ GridColor *Color
+ // FillAlpha is the 0-1 opacity of the area fill against the background.
+ FillAlpha *float64
+ // Baseline is where a bar or an area is measured from. Defaults to the y
+ // minimum.
+ Baseline *float64
+}
+
+type Domain struct{ Min, Max float64 }
+
+// DomainOf is the span an axis covers, from the caller where they said and from
+// the data where they did not.
+//
+// A domain of zero width cannot be mapped — every point would land in the same
+// place and a division would blow up — so a flat series is given room around
+// itself rather than being collapsed onto one line.
+func DomainOf(series []ChartSeries, axis *AxisOptions, which int) Domain {
+ var min, max *float64
+ if axis != nil {
+ min = bound(axis.Min)
+ max = bound(axis.Max)
+ }
+ if min == nil || max == nil {
+ lo, hi := math.Inf(1), math.Inf(-1)
+ for _, s := range series {
+ for _, p := range s.Points {
+ v := p.X
+ if which == 1 {
+ v = p.Y
+ }
+ if math.IsNaN(v) || math.IsInf(v, 0) {
+ continue
+ }
+ if v < lo {
+ lo = v
+ }
+ if v > hi {
+ hi = v
+ }
+ }
+ }
+ if math.IsInf(lo, 0) {
+ lo, hi = 0, 1
+ }
+ if min == nil {
+ min = &lo
+ }
+ if max == nil {
+ max = &hi
+ }
+ }
+ if !(*max > *min) {
+ // A flat series still has to be drawn somewhere sensible.
+ pad := 0.5
+ if math.Abs(*min) > 0 {
+ pad = math.Abs(*min) * 0.5
+ }
+ return Domain{Min: *min - pad, Max: *min + pad}
+ }
+ return Domain{Min: *min, Max: *max}
+}
+
+// chartRatio is where a value sits in its domain, 0 at the minimum and 1 at the
+// maximum.
+func chartRatio(value float64, d Domain) float64 {
+ return (value - d.Min) / (d.Max - d.Min)
+}
+
+func drawChartGrid(s Surface, color Color, bg *Color) {
+ w, h := s.Width(), s.Height()
+ step := h / 4
+ if step < 2 {
+ step = 2
+ }
+ for y := 0; y < h; y += step {
+ for x := 0; x < w; x += 2 {
+ s.Glyph(x, y, '·', Style{Fg: &color, Bg: bg})
+ }
+ }
+}
+
+// PlotPoints draws point series across the whole surface.
+//
+// Points are drawn in the order they are given: a line joins them as they come,
+// which is what lets a chart draw a loop or a path that doubles back. Sorting
+// them would quietly make that impossible.
+func PlotPoints(s Surface, series []ChartSeries, o ChartPlotOptions) {
+ if s.IsEmpty() || len(series) == 0 {
+ return
+ }
+ theme := s.Theme
+ mode := o.Mode
+ bg := o.Background
+ w, h := s.Width(), s.Height()
+
+ xd := DomainOf(series, o.X, 0)
+ yd := DomainOf(series, o.Y, 1)
+ baseline := yd.Min
+ if b := bound(o.Baseline); b != nil {
+ baseline = *b
+ }
+
+ if o.Grid {
+ color := theme.Border.Mix(theme.Background, 0.4)
+ if o.GridColor != nil {
+ color = *o.GridColor
+ }
+ drawChartGrid(s, color, bg)
+ }
+
+ if mode != FillBraille {
+ plotChartCells(s, series, mode, xd, yd, baseline, bg)
+ return
+ }
+
+ canvas := NewBrailleCanvas(w, h)
+ px := float64(canvas.Width)
+ py := float64(canvas.Height)
+
+ for si, cs := range series {
+ canvas.Clear()
+ color := SeriesColor(theme, si)
+ if cs.Color != nil {
+ color = *cs.Color
+ }
+ finite := make([]Point, 0, len(cs.Points))
+ for _, p := range cs.Points {
+ if math.IsNaN(p.X) || math.IsInf(p.X, 0) || math.IsNaN(p.Y) || math.IsInf(p.Y, 0) {
+ continue
+ }
+ finite = append(finite, p)
+ }
+ if len(finite) == 0 {
+ continue
+ }
+ pixels := make([]Point, len(finite))
+ for i, p := range finite {
+ pixels[i] = Point{
+ X: roundHalfUp(clamp01(chartRatio(p.X, xd)) * (px - 1)),
+ Y: roundHalfUp((1 - clamp01(chartRatio(p.Y, yd))) * (py - 1)),
+ }
+ }
+
+ switch cs.Mark {
+ case MarkScatter:
+ for _, p := range pixels {
+ canvas.Pixel(p.X, p.Y)
+ }
+ case MarkBar:
+ floor := roundHalfUp((1 - clamp01(chartRatio(baseline, yd))) * (py - 1))
+ for _, p := range pixels {
+ canvas.VLine(p.X, math.Min(p.Y, floor), math.Max(p.Y, floor))
+ }
+ default:
+ if len(pixels) == 1 {
+ canvas.Pixel(pixels[0].X, pixels[0].Y)
+ } else {
+ canvas.Polyline(pixels)
+ }
+ }
+
+ if cs.Fill && cs.Mark != MarkScatter {
+ alpha := 0.5
+ if o.FillAlpha != nil {
+ alpha = *o.FillAlpha
+ }
+ fillUnderPoints(s, finite, xd, yd, baseline, color, bg, alpha)
+ }
+ c := color
+ Blit(s, canvas, func(col, row int) Color { return c }, bg)
+ }
+}
+
+// fillUnderPoints shades the area between a series and its baseline, in block
+// elements.
+//
+// Braille would give eight scattered dots per cell, which reads as noise where
+// an area should read as an area. The line itself stays Braille, so it keeps
+// the sub-cell resolution.
+//
+// The height of each column is interpolated along the line rather than sampled
+// from the points that happen to land in it. Sampling leaves a gap wherever a
+// column has no point of its own, which with arbitrary x values is most of them
+// — the area comes out striped instead of solid.
+func fillUnderPoints(
+ s Surface, points []Point, xd, yd Domain, baseline float64, color Color, bg *Color, alpha float64,
+) {
+ w, h := s.Width(), s.Height()
+ if w == 0 || h == 0 || len(points) == 0 {
+ return
+ }
+ base := s.Theme.Background
+ if bg != nil {
+ base = *bg
+ }
+ floor := clamp01(chartRatio(baseline, yd))
+ column := func(x float64) float64 { return chartRatio(x, xd) * (float64(w) - 1) }
+
+ tops := make([]float64, w)
+ for i := range tops {
+ tops[i] = math.NaN()
+ }
+ record := func(col int, value float64) {
+ if col < 0 || col >= w {
+ return
+ }
+ // A path that doubles back covers a column twice; the outer edge is the
+ // one that bounds the area.
+ previous := tops[col]
+ if math.IsNaN(previous) || math.Abs(value-floor) > math.Abs(previous-floor) {
+ tops[col] = value
+ }
+ }
+
+ if len(points) == 1 {
+ record(int(roundHalfUp(column(points[0].X))), clamp01(chartRatio(points[0].Y, yd)))
+ }
+ for i := 0; i+1 < len(points); i++ {
+ x0, y0 := points[i].X, points[i].Y
+ x1, y1 := points[i+1].X, points[i+1].Y
+ c0, c1 := column(x0), column(x1)
+ from := int(math.Max(0, math.Floor(math.Min(c0, c1))))
+ to := int(math.Min(float64(w-1), math.Max(0, math.Ceil(math.Max(c0, c1)))))
+ for col := from; col <= to; col++ {
+ t := 0.0
+ if c1 != c0 {
+ t = (float64(col) - c0) / (c1 - c0)
+ }
+ if t < -0.5 || t > 1.5 {
+ continue
+ }
+ y := y0 + (y1-y0)*clamp01(t)
+ record(col, clamp01(chartRatio(y, yd)))
+ }
+ }
+
+ for x := 0; x < w; x++ {
+ top := tops[x]
+ if math.IsNaN(top) {
+ continue
+ }
+ from01 := math.Min(floor, top)
+ filled := (math.Max(floor, top) - from01) * float64(h)
+ bottom := int(math.Floor(from01 * float64(h)))
+ full := int(math.Floor(filled))
+ for k := 0; k < full && k < h; k++ {
+ row := h - 1 - bottom - k
+ if row < 0 || row >= h {
+ continue
+ }
+ depth := 0.0
+ if h > 1 {
+ depth = float64(row) / float64(h-1)
+ }
+ c := base.Mix(color, alpha*(1-depth*0.3))
+ s.Glyph(x, row, '█', Style{Fg: &c, Bg: bg})
+ }
+ if full < h {
+ glyph := VerticalGlyph(filled-float64(full), FillBlock)
+ row := h - 1 - bottom - full
+ if glyph != " " && row >= 0 && row < h {
+ depth := 0.0
+ if h > 1 {
+ depth = float64(row) / float64(h-1)
+ }
+ c := base.Mix(color, alpha*(1-depth*0.3)+0.12)
+ s.Glyph(x, row, []rune(glyph)[0], Style{Fg: &c, Bg: bg})
+ }
+ }
+ }
+}
+
+// plotChartCells is the block and ascii degradation: one column per cell,
+// tallest point wins.
+//
+// A scatter keeps its dots rather than growing columns, because a scatter that
+// fills to the baseline is a bar chart wearing the wrong name.
+func plotChartCells(
+ s Surface, series []ChartSeries, mode FillMode, xd, yd Domain, baseline float64, bg *Color,
+) {
+ w, h := s.Width(), s.Height()
+ theme := s.Theme
+ floorRatio := clamp01(chartRatio(baseline, yd))
+
+ for si, cs := range series {
+ color := SeriesColor(theme, si)
+ if cs.Color != nil {
+ color = *cs.Color
+ }
+ // Highest value per column, so a column shows the peak that fell in it
+ // rather than whichever point happened to be last.
+ tops := make([]float64, w)
+ for i := range tops {
+ tops[i] = math.NaN()
+ }
+ for _, p := range cs.Points {
+ if math.IsNaN(p.X) || math.IsInf(p.X, 0) || math.IsNaN(p.Y) || math.IsInf(p.Y, 0) {
+ continue
+ }
+ col := int(roundHalfUp(chartRatio(p.X, xd) * (float64(w) - 1)))
+ if col < 0 {
+ col = 0
+ }
+ if col > w-1 {
+ col = w - 1
+ }
+ value := clamp01(chartRatio(p.Y, yd))
+ if math.IsNaN(tops[col]) || value > tops[col] {
+ tops[col] = value
+ }
+ }
+
+ for x := 0; x < w; x++ {
+ top := tops[x]
+ if math.IsNaN(top) {
+ continue
+ }
+ if cs.Mark == MarkScatter {
+ k := int(math.Floor(top * float64(h)))
+ if k > h-1 {
+ k = h - 1
+ }
+ glyph := '•'
+ if mode == FillASCII {
+ glyph = '*'
+ }
+ s.Glyph(x, h-1-k, glyph, Style{Fg: &color, Bg: bg})
+ continue
+ }
+ from := math.Min(floorRatio, top) * float64(h)
+ filled := (math.Max(floorRatio, top) - math.Min(floorRatio, top)) * float64(h)
+ full := int(math.Floor(filled))
+ for k := 0; k < full; k++ {
+ row := h - 1 - int(math.Floor(from)) - k
+ if row >= 0 && row < h {
+ s.Glyph(x, row, '█', Style{Fg: &color, Bg: bg})
+ }
+ }
+ glyph := VerticalGlyph(filled-float64(full), mode)
+ row := h - 1 - int(math.Floor(from)) - full
+ if glyph != " " && row >= 0 && row < h {
+ s.Glyph(x, row, []rune(glyph)[0], Style{Fg: &color, Bg: bg})
+ }
+ }
+ }
+}
diff --git a/ports/go/conformance_widgets_test.go b/ports/go/conformance_widgets_test.go
index 4696475..9fc5a90 100644
--- a/ports/go/conformance_widgets_test.go
+++ b/ports/go/conformance_widgets_test.go
@@ -17,6 +17,28 @@ var widgetSeries = []float64{3, 7, 2, 9, 4, 8, 6, 1, 5, 9, 3, 7, 8, 2, 6, 4, 9,
func series() []float64 { return append([]float64(nil), widgetSeries...) }
+// pts builds a point list from flat x, y pairs, which is all a fixture needs.
+func pts(values ...float64) []Point {
+ out := make([]Point, 0, len(values)/2)
+ for i := 0; i+1 < len(values); i += 2 {
+ out = append(out, Point{X: values[i], Y: values[i+1]})
+ }
+ return out
+}
+
+// axisOf is the axis bounds every chart fixture pins, without the ceremony.
+func axisOf(min, max float64, ticks int) *AxisOptions {
+ return &AxisOptions{Min: &min, Max: &max, Ticks: ticks}
+}
+
+// chartFixture is one series over the standard 0..10 domain.
+func chartFixture(mark MarkType) ChartOptions {
+ return ChartOptions{
+ Series: []ChartSeries{{Points: pts(0, 1, 2, 6, 5, 3, 8, 9, 10, 4), Mark: mark}},
+ Plot: ChartPlotOptions{X: axisOf(0, 10, 0), Y: axisOf(0, 10, 0)},
+ }
+}
+
func drawWidgetScene(t *testing.T, name string, s Surface) {
switch name {
case "text-plain":
@@ -91,6 +113,47 @@ func drawWidgetScene(t *testing.T, name string, s Surface) {
Gauge(s, GaugeOptions{Value: 0.7, Label: "70%"})
case "donut":
Donut(s, DonutOptions{Segments: []DonutSegment{{Value: 3}, {Value: 5}, {Value: 2}}})
+ case "chart-line":
+ DrawChart(s, chartFixture(MarkLine))
+ case "chart-scatter":
+ DrawChart(s, chartFixture(MarkScatter))
+ case "chart-bar":
+ DrawChart(s, chartFixture(MarkBar))
+ case "chart-fill":
+ DrawChart(s, ChartOptions{
+ Series: []ChartSeries{{Points: pts(0, 2, 5, 8, 10, 2), Fill: true}},
+ Plot: ChartPlotOptions{X: axisOf(0, 10, 0), Y: axisOf(0, 10, 0)},
+ })
+ case "chart-axes":
+ DrawChart(s, ChartOptions{
+ Series: []ChartSeries{{Points: pts(0, 0, 5, 50, 10, 100)}},
+ Axis: true,
+ Plot: ChartPlotOptions{X: axisOf(0, 10, 3), Y: axisOf(0, 100, 0)},
+ })
+ case "chart-block":
+ DrawChart(s, ChartOptions{
+ Series: []ChartSeries{{Points: pts(0, 1, 2, 6, 5, 3, 8, 9, 10, 4), Mark: MarkBar}},
+ Plot: ChartPlotOptions{
+ Mode: FillBlock,
+ X: axisOf(0, 10, 0),
+ Y: axisOf(0, 10, 0),
+ },
+ })
+ case "chart-multi":
+ DrawChart(s, ChartOptions{
+ Series: []ChartSeries{
+ {Points: pts(0, 1, 1, 3, 2, 2, 3, 5, 4, 4, 5, 7, 6, 6, 7, 9), Label: "fine"},
+ {Points: pts(0, 8, 7, 2), Label: "coarse"},
+ },
+ Axis: true,
+ Legend: true,
+ Plot: ChartPlotOptions{X: axisOf(0, 7, 0), Y: axisOf(0, 10, 0)},
+ })
+ case "chart-flat":
+ DrawChart(s, ChartOptions{
+ Series: []ChartSeries{{Points: pts(0, 4, 5, 4, 10, 4)}},
+ Plot: ChartPlotOptions{X: axisOf(0, 10, 0)},
+ })
case "graph-axis":
DrawGraph(s, GraphOptions{Values: series(), Axis: true})
case "graph-legend":
diff --git a/ports/go/examples/widgets/main.go b/ports/go/examples/widgets/main.go
index 3a6c34f..9b5493c 100644
--- a/ports/go/examples/widgets/main.go
+++ b/ports/go/examples/widgets/main.go
@@ -204,6 +204,26 @@ func Scrollbar(ui *hqtui.Container) {
// @end
+// @widget chart
+func Chart(ui *hqtui.Container) {
+ // Points carry their own x, so a sparse series and a dense one line up.
+ zero, ten, seven := 0.0, 10.0, 3
+ ui.Chart(hqtui.ChartOptions{
+ Series: []hqtui.ChartSeries{
+ {Points: []hqtui.Point{{X: 0, Y: 1}, {X: 2, Y: 6}, {X: 5, Y: 3}, {X: 8, Y: 9}, {X: 10, Y: 4}}, Label: "load"},
+ {Points: []hqtui.Point{{X: 0, Y: 8}, {X: 10, Y: 2}}, Label: "limit"},
+ },
+ Axis: true,
+ Legend: true,
+ Plot: hqtui.ChartPlotOptions{
+ X: &hqtui.AxisOptions{Min: &zero, Max: &ten, Ticks: seven},
+ Y: &hqtui.AxisOptions{Min: &zero, Max: &ten},
+ },
+ })
+}
+
+// @end
+
// @widget meter
func Meter(ui *hqtui.Container) {
ui.Meter(hqtui.MeterOptions{Value: 0.62, Label: "CPU"})
@@ -406,7 +426,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},
+ {"scrollbar", Scrollbar}, {"chart", Chart},
{"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 2fe39c0..3ffaf6c 100644
--- a/ports/go/ui.go
+++ b/ports/go/ui.go
@@ -478,6 +478,17 @@ func (c *Container) Graph(o GraphOptions, layout ...Layout) *Container {
return c.add(c.filling(firstLayout(layout)), func(s Surface) { DrawGraph(s, o) })
}
+// Chart draws arbitrary (x, y) data, with a domain on both axes.
+//
+// Graph plots a history buffer, one sample per column. Use this when the data
+// has its own x values: two series of different lengths then line up, and a
+// point lands where its x says it does.
+func (c *Container) Chart(o ChartOptions, layout ...Layout) *Container {
+ return c.add(c.filling(firstLayout(layout)), func(s Surface) {
+ DrawChart(s, o)
+ })
+}
+
func (c *Container) Sparkline(o SparklineWidgetOptions, layout ...Layout) *Container {
return c.add(c.leaf(firstLayout(layout), 1), func(s Surface) { DrawSparkline(s, o) })
}
diff --git a/ports/go/widgets_chart.go b/ports/go/widgets_chart.go
new file mode 100644
index 0000000..2f95aba
--- /dev/null
+++ b/ports/go/widgets_chart.go
@@ -0,0 +1,176 @@
+package hqtui
+
+import "math"
+
+// A chart with two real axes.
+//
+// Graph plots a history buffer: one sample per column, x meaning "position in
+// the slice". This plots data that has its own x values, with a labelled domain
+// on both axes, so two series of different lengths line up and a point lands
+// where its x says it does.
+
+type ChartOptions struct {
+ Series []ChartSeries
+ Plot ChartPlotOptions
+ // Axis draws numbers down the left edge.
+ Axis bool
+ AxisColor *Color
+ Legend bool
+ LegendAlign Align
+}
+
+// ticksFor returns evenly spaced values across a domain, ends included.
+//
+// Two ticks means the ends and nothing else, which is what an axis wants when
+// there is no room to say more.
+func ticksFor(min, max float64, count int) []float64 {
+ n := count
+ if n < 2 {
+ n = 2
+ }
+ out := make([]float64, n)
+ for i := 0; i < n; i++ {
+ out[i] = min + (max-min)*float64(i)/float64(n-1)
+ }
+ return out
+}
+
+func formatWith(axis *AxisOptions, value float64) string {
+ if axis != nil && axis.Format != nil {
+ return axis.Format(value)
+ }
+ return NiceLabel(value)
+}
+
+func DrawChart(s Surface, o ChartOptions) {
+ if s.IsEmpty() {
+ return
+ }
+ theme := s.Theme
+ axisColor := theme.Muted
+ if o.AxisColor != nil {
+ axisColor = *o.AxisColor
+ }
+
+ xd := DomainOf(o.Series, o.Plot.X, 0)
+ yd := DomainOf(o.Series, o.Plot.Y, 1)
+
+ // The x labels take a row, and they can only take one when there is a row
+ // to spare — a two-row chart is all plot.
+ xTicks := 0
+ if o.Axis {
+ xTicks = 2
+ }
+ if o.Plot.X != nil && o.Plot.X.Ticks > 0 {
+ xTicks = o.Plot.X.Ticks
+ }
+ wantXAxis := o.Axis && xTicks >= 2 && s.Height() > 2
+
+ plotSurface := s
+ if o.Axis {
+ hi := formatWith(o.Plot.Y, yd.Max)
+ lo := formatWith(o.Plot.Y, yd.Min)
+ width := StringWidth(hi)
+ if w := StringWidth(lo); w > width {
+ width = w
+ }
+ width++
+ s.Text(0, 0, Fit(hi, width, AlignRight), TextOptions{Fg: &axisColor})
+ if s.Height() > 1 {
+ // The minimum marks the bottom of the plot, which is a row higher
+ // when the x labels have taken the last one.
+ bottom := s.Height() - 1
+ if wantXAxis {
+ bottom = s.Height() - 2
+ }
+ s.Text(0, bottom, Fit(lo, width, AlignRight), TextOptions{Fg: &axisColor})
+ }
+ plotSurface = s.Sub(width, 0, s.Width()-width, s.Height())
+ }
+
+ area := plotSurface
+ if wantXAxis && plotSurface.Height() > 1 && plotSurface.Width() > 0 {
+ area = plotSurface.Sub(0, 0, plotSurface.Width(), plotSurface.Height()-1)
+ row := plotSurface.Height() - 1
+ values := ticksFor(xd.Min, xd.Max, xTicks)
+ labels := make([]string, len(values))
+ for i, v := range values {
+ labels[i] = formatWith(o.Plot.X, v)
+ }
+ step := 0.0
+ if len(labels) > 1 {
+ step = (float64(plotSurface.Width()) - 1) / float64(len(labels)-1)
+ }
+ for i, label := range labels {
+ // The last label is right-aligned to the edge, so it cannot run off it.
+ x := int(roundHalfUp(float64(i) * step))
+ if limit := plotSurface.Width() - StringWidth(label); x > limit {
+ x = limit
+ }
+ if x < 0 {
+ x = 0
+ }
+ plotSurface.Text(x, row, label, TextOptions{Fg: &axisColor})
+ }
+ }
+
+ // The domain is resolved once and handed down, so the labels and the marks
+ // cannot disagree about what the axis spans.
+ plot := o.Plot
+ plot.X = withBounds(o.Plot.X, xd)
+ plot.Y = withBounds(o.Plot.Y, yd)
+ PlotPoints(area, o.Series, plot)
+
+ if o.Legend {
+ type part struct {
+ label string
+ color Color
+ }
+ parts := []part{}
+ for i, cs := range o.Series {
+ if cs.Label == "" {
+ continue
+ }
+ color := SeriesColor(theme, i)
+ if cs.Color != nil {
+ color = *cs.Color
+ }
+ parts = append(parts, part{cs.Label, color})
+ }
+ x := 0
+ if o.LegendAlign == AlignRight {
+ total := 0
+ for _, p := range parts {
+ total += StringWidth(p.label) + 3
+ }
+ x = area.Width() - total
+ if x < 0 {
+ x = 0
+ }
+ }
+ y := 0
+ if area.Height() > 3 {
+ y = area.Height() - 1
+ }
+ for _, p := range parts {
+ c := p.color
+ x += area.Text(x, y, "■ ", TextOptions{Fg: &c})
+ x += area.Text(x, y, p.label+" ", TextOptions{Fg: &theme.Muted})
+ }
+ }
+}
+
+// withBounds pins an axis to a resolved domain, keeping whatever else it said.
+func withBounds(axis *AxisOptions, d Domain) *AxisOptions {
+ out := AxisOptions{}
+ if axis != nil {
+ out = *axis
+ }
+ min, max := d.Min, d.Max
+ if math.IsNaN(min) || math.IsNaN(max) {
+ return axis
+ }
+ out.Min = &min
+ out.Max = &max
+ return &out
+}
diff --git a/ports/perl/examples/widgets.pl b/ports/perl/examples/widgets.pl
index 4baa181..c560789 100644
--- a/ports/perl/examples/widgets.pl
+++ b/ports/perl/examples/widgets.pl
@@ -94,6 +94,22 @@ sub widget_scrollbar {
}
# @end
+# @widget chart
+sub widget_chart {
+ my ($ui) = @_;
+ # Points carry their own x, so a sparse series and a dense one line up.
+ $ui->chart(
+ [
+ { points => [ { x => 0, y => 1 }, { x => 2, y => 6 }, { x => 5, y => 3 },
+ { x => 8, y => 9 }, { x => 10, y => 4 } ], label => 'load' },
+ { points => [ { x => 0, y => 8 }, { x => 10, y => 2 } ], label => 'limit' },
+ ],
+ axis => 1, legend => 1,
+ x => { min => 0, max => 10, ticks => 3 }, y => { min => 0, max => 10 },
+ );
+}
+# @end
+
# @widget meter
sub widget_meter {
my ($ui) = @_;
@@ -305,6 +321,7 @@ sub widget_tooltip {
['table', \&widget_table],
['log', \&widget_log],
['scrollbar', \&widget_scrollbar],
+ ['chart', \&widget_chart],
['meter', \&widget_meter],
['graph', \&widget_graph],
['gauge', \&widget_gauge],
diff --git a/ports/perl/lib/Hqtui.pm b/ports/perl/lib/Hqtui.pm
index 6703fff..f0876e4 100644
--- a/ports/perl/lib/Hqtui.pm
+++ b/ports/perl/lib/Hqtui.pm
@@ -64,6 +64,7 @@ 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 chart { my ($s,$series,%o)=@_; $s->add('chart',series=>$series,%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 349573d..524b4be 100644
--- a/ports/php/examples/widgets.php
+++ b/ports/php/examples/widgets.php
@@ -97,6 +97,22 @@ function widget_scrollbar(UI $ui): void
}
// @end
+// @widget chart
+function widget_chart(UI $ui): void
+{
+ // Points carry their own x, so a sparse series and a dense one line up.
+ $ui->chart(
+ [
+ ['points' => [['x' => 0, 'y' => 1], ['x' => 2, 'y' => 6], ['x' => 5, 'y' => 3],
+ ['x' => 8, 'y' => 9], ['x' => 10, 'y' => 4]], 'label' => 'load'],
+ ['points' => [['x' => 0, 'y' => 8], ['x' => 10, 'y' => 2]], 'label' => 'limit'],
+ ],
+ ['axis' => true, 'legend' => true,
+ 'x' => ['min' => 0, 'max' => 10, 'ticks' => 3], 'y' => ['min' => 0, 'max' => 10]]
+ );
+}
+// @end
+
// @widget meter
function widget_meter(UI $ui): void
{
@@ -309,6 +325,7 @@ function widget_tooltip(UI $ui): void
'table' => 'widget_table',
'log' => 'widget_log',
'scrollbar' => 'widget_scrollbar',
+ 'chart' => 'widget_chart',
'meter' => 'widget_meter',
'graph' => 'widget_graph',
'gauge' => 'widget_gauge',
diff --git a/ports/php/src/Hqtui.php b/ports/php/src/Hqtui.php
index 437843c..8389299 100644
--- a/ports/php/src/Hqtui.php
+++ b/ports/php/src/Hqtui.php
@@ -79,6 +79,7 @@ public function columns(array $values, array $o = []): self { return $this->add(
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 chart(array $series, array $o = []): self { return $this->add('chart', ['series'=>$series, ...$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 69eca2f..11b7fa5 100644
--- a/ports/python/examples/widgets.py
+++ b/ports/python/examples/widgets.py
@@ -13,6 +13,7 @@
import sys
+import hqtui.graphics.chart as g
import hqtui.widgets as w
from hqtui.graphics import BarStyle, DonutOptions, DonutSegment, GaugeOptions, PlotOptions, Series
from hqtui.testing import render_to_text
@@ -205,6 +206,24 @@ def row(r: Container) -> None:
# @end
+# @widget chart
+def chart(ui: Container) -> None:
+ # Points carry their own x, so a sparse series and a dense one line up.
+ ui.chart(w.ChartOptions(
+ series=[
+ g.ChartSeries(points=[(0, 1), (2, 6), (5, 3), (8, 9), (10, 4)], label="load"),
+ g.ChartSeries(points=[(0, 8), (10, 2)], label="limit"),
+ ],
+ axis=True,
+ legend=True,
+ plot=g.ChartPlotOptions(
+ x=g.AxisOptions(min=0, max=10, ticks=3),
+ y=g.AxisOptions(min=0, max=10),
+ ),
+ ))
+# @end
+
+
# @widget meter
def meter(ui: Container) -> None:
ui.meter(w.MeterOptions(value=0.62, label="CPU"))
@@ -391,7 +410,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),
+ ("scrollbar", scrollbar), ("chart", chart),
("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/graphics/__init__.py b/ports/python/hqtui/graphics/__init__.py
index ee64313..29757d6 100644
--- a/ports/python/hqtui/graphics/__init__.py
+++ b/ports/python/hqtui/graphics/__init__.py
@@ -15,6 +15,16 @@
vertical_glyph,
)
from .braille import BrailleCanvas
+from .chart import (
+ AxisOptions,
+ ChartPlotOptions,
+ ChartSeries,
+ Domain,
+ MarkType,
+ Point,
+ domain_of,
+ plot_points,
+)
from .plot import (
BarOptions,
BarStyle,
@@ -35,6 +45,14 @@
)
__all__ = [
+ "AxisOptions",
+ "ChartPlotOptions",
+ "ChartSeries",
+ "Domain",
+ "MarkType",
+ "Point",
+ "domain_of",
+ "plot_points",
"ASCII_RAMP", "BarOptions", "BarStyle", "BrailleCanvas", "DonutOptions",
"DonutSegment", "FillMode", "GaugeOptions", "HORIZONTAL_EIGHTHS",
"HistogramOptions", "PlotOptions", "QUADRANTS", "SHADES", "Series",
diff --git a/ports/python/hqtui/graphics/chart.py b/ports/python/hqtui/graphics/chart.py
new file mode 100644
index 0000000..f32eae1
--- /dev/null
+++ b/ports/python/hqtui/graphics/chart.py
@@ -0,0 +1,341 @@
+"""Charts of arbitrary (x, y) data.
+
+``plot`` takes a sequence of floats and puts one sample per column: the x axis
+is the list index. That is the right model for a history buffer and the wrong
+one for everything else — two series of different lengths silently render at
+different horizontal scales, a gap in the data is indistinguishable from a
+shorter series, and there is no way at all to say where on the x axis a point
+belongs.
+
+This takes points and a domain for each axis, so a series is placed rather than
+appended. ``plot`` is untouched and still means what it meant.
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass, field
+from typing import Callable, Literal, Sequence
+
+from ..buffer import Style
+from ..color import Color, round_half_up
+from ..surface import Surface
+from ..theme import series_color
+from .blocks import FillMode, clamp01, vertical_glyph
+from .braille import BrailleCanvas
+from .plot import blit
+
+__all__ = [
+ "AxisOptions",
+ "ChartPlotOptions",
+ "ChartSeries",
+ "Domain",
+ "MarkType",
+ "Point",
+ "domain_of",
+ "plot_points",
+]
+
+Point = tuple[float, float]
+
+#: How a series is marked: joined, dotted, or dropped to the baseline.
+MarkType = Literal["line", "scatter", "bar"]
+
+
+@dataclass(slots=True)
+class ChartSeries:
+ points: Sequence[Point] = ()
+ color: Color | None = None
+ label: str = ""
+ mark: MarkType = "line"
+ #: Shade between the line and the baseline. Ignored for a scatter.
+ fill: bool = False
+
+
+@dataclass(slots=True)
+class AxisOptions:
+ """One axis: what it spans and how its numbers read."""
+
+ min: float | None = None
+ max: float | None = None
+ format: Callable[[float], str] | None = None
+ #: How many labels to place. Default 2 — the ends.
+ ticks: int = 0
+
+
+@dataclass(slots=True)
+class ChartPlotOptions:
+ #: braille is sharpest; block and ascii are the graceful degradations.
+ mode: FillMode | str = FillMode.BRAILLE
+ x: AxisOptions | None = None
+ y: AxisOptions | None = None
+ background: Color | None = None
+ grid: bool = False
+ grid_color: Color | None = None
+ #: 0-1 opacity of the area fill against the background.
+ fill_alpha: float | None = None
+ #: Where a bar or an area is measured from. Defaults to the y minimum.
+ baseline: float | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class Domain:
+ min: float
+ max: float
+
+
+def _bound(value: float | None) -> float | None:
+ """A finite number, or None: a caller's bound is data, and data can be NaN."""
+ if value is None or not math.isfinite(value):
+ return None
+ return value
+
+
+def domain_of(
+ series: Sequence[ChartSeries], axis: AxisOptions | None, which: int
+) -> Domain:
+ """The span an axis covers, from the caller where they said and from the
+ data where they did not.
+
+ A domain of zero width cannot be mapped — every point would land in the same
+ place and a division would blow up — so a flat series is given room around
+ itself rather than being collapsed onto one line.
+ """
+ minimum = _bound(axis.min if axis else None)
+ maximum = _bound(axis.max if axis else None)
+ if minimum is None or maximum is None:
+ lo = math.inf
+ hi = -math.inf
+ for s in series:
+ for point in s.points:
+ v = point[which]
+ if not math.isfinite(v):
+ continue
+ lo = min(lo, v)
+ hi = max(hi, v)
+ if not math.isfinite(lo):
+ lo, hi = 0.0, 1.0
+ minimum = lo if minimum is None else minimum
+ maximum = hi if maximum is None else maximum
+ if not maximum > minimum:
+ # A flat series still has to be drawn somewhere sensible.
+ pad = abs(minimum) * 0.5 if abs(minimum) > 0 else 0.5
+ return Domain(minimum - pad, minimum + pad)
+ return Domain(minimum, maximum)
+
+
+def _ratio(value: float, domain: Domain) -> float:
+ """Where a value sits in its domain, 0 at the minimum and 1 at the maximum."""
+ return (value - domain.min) / (domain.max - domain.min)
+
+
+def _draw_grid(surface: Surface, color: Color, bg: Color | None) -> None:
+ w, h = surface.width, surface.height
+ step = max(2, h // 4)
+ for y in range(0, h, step):
+ for x in range(0, w, 2):
+ surface.char(x, y, "·", Style(fg=color, bg=bg))
+
+
+def plot_points(
+ surface: Surface, series: Sequence[ChartSeries], options: ChartPlotOptions | None = None
+) -> None:
+ """Draw point series across the whole surface.
+
+ Points are drawn in the order they are given: a line joins them as they
+ come, which is what lets a chart draw a loop or a path that doubles back.
+ Sorting them would quietly make that impossible.
+ """
+ options = options or ChartPlotOptions()
+ if surface.empty or not series:
+ return
+ theme = surface.theme
+ mode = options.mode or FillMode.BRAILLE
+ bg = options.background
+ w, h = surface.width, surface.height
+
+ xd = domain_of(series, options.x, 0)
+ yd = domain_of(series, options.y, 1)
+ baseline = _bound(options.baseline)
+ baseline = yd.min if baseline is None else baseline
+
+ if options.grid:
+ color = options.grid_color
+ if color is None:
+ color = theme.border.mix(theme.background, 0.4)
+ _draw_grid(surface, color, bg)
+
+ if mode != FillMode.BRAILLE:
+ _plot_cells(surface, series, mode, xd, yd, baseline, bg)
+ return
+
+ canvas = BrailleCanvas(w, h)
+ px = float(canvas.width)
+ py = float(canvas.height)
+
+ for si, s in enumerate(series):
+ canvas.clear()
+ color = s.color if s.color is not None else series_color(theme, si)
+ finite = [p for p in s.points if math.isfinite(p[0]) and math.isfinite(p[1])]
+ if not finite:
+ continue
+ pixels = [
+ (
+ round_half_up(clamp01(_ratio(p[0], xd)) * (px - 1)),
+ round_half_up((1 - clamp01(_ratio(p[1], yd))) * (py - 1)),
+ )
+ for p in finite
+ ]
+
+ if s.mark == "scatter":
+ for x, y in pixels:
+ canvas.pixel(x, y)
+ elif s.mark == "bar":
+ floor = round_half_up((1 - clamp01(_ratio(baseline, yd))) * (py - 1))
+ for x, y in pixels:
+ canvas.vline(x, min(y, floor), max(y, floor))
+ elif len(pixels) == 1:
+ canvas.pixel(pixels[0][0], pixels[0][1])
+ else:
+ canvas.polyline(pixels)
+
+ if s.fill and s.mark != "scatter":
+ alpha = 0.5 if options.fill_alpha is None else options.fill_alpha
+ _fill_under(surface, finite, xd, yd, baseline, color, bg, alpha)
+ blit(surface, canvas, lambda col, row, c=color: c, bg)
+
+
+def _fill_under(
+ surface: Surface,
+ points: Sequence[Point],
+ xd: Domain,
+ yd: Domain,
+ baseline: float,
+ color: Color,
+ bg: Color | None,
+ alpha: float,
+) -> None:
+ """The area between a series and its baseline, in block elements.
+
+ Braille would give eight scattered dots per cell, which reads as noise where
+ an area should read as an area. The line itself stays Braille, so it keeps
+ the sub-cell resolution.
+
+ The height of each column is interpolated along the line rather than sampled
+ from the points that happen to land in it. Sampling leaves a gap wherever a
+ column has no point of its own, which with arbitrary x values is most of
+ them — the area comes out striped instead of solid.
+ """
+ w, h = surface.width, surface.height
+ if w == 0 or h == 0 or not points:
+ return
+ base = bg if bg is not None else surface.theme.background
+ floor = clamp01(_ratio(baseline, yd))
+
+ def column(x: float) -> float:
+ return _ratio(x, xd) * (w - 1)
+
+ tops: list[float] = [math.nan] * w
+
+ def record(col: int, value: float) -> None:
+ if col < 0 or col >= w:
+ return
+ # A path that doubles back covers a column twice; the outer edge is the
+ # one that bounds the area.
+ previous = tops[col]
+ if math.isnan(previous) or abs(value - floor) > abs(previous - floor):
+ tops[col] = value
+
+ if len(points) == 1:
+ record(int(round_half_up(column(points[0][0]))), clamp01(_ratio(points[0][1], yd)))
+ for i in range(len(points) - 1):
+ x0, y0 = points[i]
+ x1, y1 = points[i + 1]
+ c0, c1 = column(x0), column(x1)
+ start = int(max(0.0, math.floor(min(c0, c1))))
+ end = int(min(float(w - 1), max(0.0, math.ceil(max(c0, c1)))))
+ for col in range(start, end + 1):
+ t = 0.0 if c1 == c0 else (col - c0) / (c1 - c0)
+ if t < -0.5 or t > 1.5:
+ continue
+ y = y0 + (y1 - y0) * clamp01(t)
+ record(col, clamp01(_ratio(y, yd)))
+
+ for x in range(w):
+ top = tops[x]
+ if math.isnan(top):
+ continue
+ from01 = min(floor, top)
+ filled = (max(floor, top) - from01) * h
+ bottom = int(math.floor(from01 * h))
+ full = int(math.floor(filled))
+ for k in range(min(full, h)):
+ row = h - 1 - bottom - k
+ if row < 0 or row >= h:
+ continue
+ depth = 0.0 if h <= 1 else row / (h - 1)
+ surface.char(x, row, "█", Style(fg=base.mix(color, alpha * (1 - depth * 0.3)), bg=bg))
+ if full < h:
+ glyph = vertical_glyph(filled - full, FillMode.BLOCK)
+ row = h - 1 - bottom - full
+ if glyph != " " and 0 <= row < h:
+ depth = 0.0 if h <= 1 else row / (h - 1)
+ surface.char(
+ x, row, glyph,
+ Style(fg=base.mix(color, alpha * (1 - depth * 0.3) + 0.12), bg=bg),
+ )
+
+
+def _plot_cells(
+ surface: Surface,
+ series: Sequence[ChartSeries],
+ mode: FillMode | str,
+ xd: Domain,
+ yd: Domain,
+ baseline: float,
+ bg: Color | None,
+) -> None:
+ """The block and ascii degradations: one column per cell, tallest point wins.
+
+ A scatter keeps its dots rather than growing columns, because a scatter that
+ fills to the baseline is a bar chart wearing the wrong name.
+ """
+ w, h = surface.width, surface.height
+ theme = surface.theme
+ floor_ratio = clamp01(_ratio(baseline, yd))
+
+ for si, s in enumerate(series):
+ color = s.color if s.color is not None else series_color(theme, si)
+ # Highest value per column, so a column shows the peak that fell in it
+ # rather than whichever point happened to be last.
+ tops: list[float] = [math.nan] * w
+ for point in s.points:
+ if not math.isfinite(point[0]) or not math.isfinite(point[1]):
+ continue
+ col = int(round_half_up(_ratio(point[0], xd) * (w - 1)))
+ col = max(0, min(col, w - 1))
+ value = clamp01(_ratio(point[1], yd))
+ if math.isnan(tops[col]) or value > tops[col]:
+ tops[col] = value
+
+ for x in range(w):
+ top = tops[x]
+ if math.isnan(top):
+ continue
+ if s.mark == "scatter":
+ row = h - 1 - min(int(math.floor(top * h)), h - 1)
+ surface.char(
+ x, row, "*" if mode == FillMode.ASCII else "•", Style(fg=color, bg=bg)
+ )
+ continue
+ start = min(floor_ratio, top) * h
+ filled = (max(floor_ratio, top) - min(floor_ratio, top)) * h
+ full = int(math.floor(filled))
+ for k in range(full):
+ row = h - 1 - int(math.floor(start)) - k
+ if 0 <= row < h:
+ surface.char(x, row, "█", Style(fg=color, bg=bg))
+ glyph = vertical_glyph(filled - full, mode)
+ row = h - 1 - int(math.floor(start)) - full
+ if glyph != " " and 0 <= row < h:
+ surface.char(x, row, glyph, Style(fg=color, bg=bg))
diff --git a/ports/python/hqtui/ui.py b/ports/python/hqtui/ui.py
index b503c03..46b276e 100644
--- a/ports/python/hqtui/ui.py
+++ b/ports/python/hqtui/ui.py
@@ -471,6 +471,18 @@ def graph(self, options: w.GraphOptions, layout: Layout | None = None):
"""Braille line/area graph. Fills the space it is given."""
return self._add(self._filling(layout or Layout()), lambda s: w.draw_graph(s, options))
+ def chart(self, options: w.ChartOptions, layout: Layout | None = None):
+ """A chart of arbitrary (x, y) data, with a domain on both axes.
+
+ ``graph`` plots a history buffer, one sample per column. Use this when
+ the data has its own x values: two series of different lengths then line
+ up, and a point lands where its x says it does.
+ """
+ return self._add(
+ self._constraint(layout or Layout(), "fill"),
+ lambda s: w.draw_chart(s, options),
+ )
+
def sparkline(self, options: w.SparklineWidgetOptions, layout: Layout | None = None):
return self._add(self._leaf(layout or Layout(), 1), lambda s: w.draw_sparkline(s, options))
diff --git a/ports/python/hqtui/widgets/__init__.py b/ports/python/hqtui/widgets/__init__.py
index 856ec5f..972480e 100644
--- a/ports/python/hqtui/widgets/__init__.py
+++ b/ports/python/hqtui/widgets/__init__.py
@@ -65,6 +65,7 @@
draw_tree,
resolve_offset,
)
+from .chart import ChartOptions, draw_chart
from .scrollbar import (
ScrollbarOptions,
ScrollbarOrientation,
@@ -105,6 +106,7 @@
"draw_columns", "draw_command_palette", "draw_divider", "draw_donut",
"draw_gauge", "draw_graph", "draw_heat_bar", "draw_key_values", "draw_list",
"draw_log", "draw_meter", "draw_meters", "draw_modal", "draw_progress",
+ "ChartOptions", "draw_chart",
"ScrollbarOptions", "ScrollbarOrientation", "is_vertical", "offset_for_position",
"thumb", "draw_scrollbar", "draw_scrollbar_widget", "draw_select", "draw_sparkline", "draw_status_bar",
"draw_table", "draw_tabs", "draw_text", "draw_text_input", "draw_tooltip",
diff --git a/ports/python/hqtui/widgets/chart.py b/ports/python/hqtui/widgets/chart.py
new file mode 100644
index 0000000..f374333
--- /dev/null
+++ b/ports/python/hqtui/widgets/chart.py
@@ -0,0 +1,123 @@
+"""A chart with two real axes.
+
+``graph`` plots a history buffer: one sample per column, x meaning "position in
+the list". This plots data that has its own x values, with a labelled domain on
+both axes, so two series of different lengths line up and a point lands where
+its x says it does.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field, replace
+from typing import Sequence
+
+from ..color import Color, round_half_up
+from ..graphics.chart import (
+ AxisOptions,
+ ChartPlotOptions,
+ ChartSeries,
+ domain_of,
+ plot_points,
+)
+from ..surface import Align, Surface, TextOptions
+from ..theme import series_color
+from ..unicode import fit, string_width
+from .meters import nice_label
+
+__all__ = ["ChartOptions", "draw_chart"]
+
+
+@dataclass(slots=True)
+class ChartOptions:
+ series: Sequence[ChartSeries] = ()
+ plot: ChartPlotOptions = field(default_factory=ChartPlotOptions)
+ #: Numbers down the left edge.
+ axis: bool = False
+ axis_color: Color | None = None
+ legend: bool = False
+ legend_align: Align = "left"
+
+
+def _ticks_for(minimum: float, maximum: float, count: int) -> list[float]:
+ """Evenly spaced values across a domain, ends included.
+
+ Two ticks means the ends and nothing else, which is what an axis wants when
+ there is no room to say more.
+ """
+ n = max(2, int(count))
+ return [minimum + (maximum - minimum) * i / (n - 1) for i in range(n)]
+
+
+def _format_with(axis: AxisOptions | None, value: float) -> str:
+ if axis is not None and axis.format is not None:
+ return axis.format(value)
+ return nice_label(value)
+
+
+def draw_chart(surface: Surface, options: ChartOptions) -> None:
+ if surface.empty:
+ return
+ theme = surface.theme
+ series = list(options.series)
+ axis_color = options.axis_color if options.axis_color is not None else theme.muted
+
+ xd = domain_of(series, options.plot.x, 0)
+ yd = domain_of(series, options.plot.y, 1)
+
+ # The x labels take a row, and they can only take one when there is a row to
+ # spare — a two-row chart is all plot.
+ x_ticks = 2 if options.axis else 0
+ if options.plot.x is not None and options.plot.x.ticks > 0:
+ x_ticks = options.plot.x.ticks
+ want_x_axis = options.axis and x_ticks >= 2 and surface.height > 2
+
+ plot_surface = surface
+ if options.axis:
+ hi = _format_with(options.plot.y, yd.max)
+ lo = _format_with(options.plot.y, yd.min)
+ width = max(string_width(hi), string_width(lo)) + 1
+ surface.text(0, 0, fit(hi, width, "right"), TextOptions(fg=axis_color))
+ if surface.height > 1:
+ # The minimum marks the bottom of the plot, which is a row higher
+ # when the x labels have taken the last one.
+ bottom = surface.height - 2 if want_x_axis else surface.height - 1
+ surface.text(0, bottom, fit(lo, width, "right"), TextOptions(fg=axis_color))
+ plot_surface = surface.sub(width, 0, surface.width - width, surface.height)
+
+ area = plot_surface
+ if want_x_axis and plot_surface.height > 1 and plot_surface.width > 0:
+ area = plot_surface.sub(0, 0, plot_surface.width, plot_surface.height - 1)
+ row = plot_surface.height - 1
+ labels = [
+ _format_with(options.plot.x, v) for v in _ticks_for(xd.min, xd.max, x_ticks)
+ ]
+ step = (plot_surface.width - 1) / (len(labels) - 1) if len(labels) > 1 else 0.0
+ for i, label in enumerate(labels):
+ # The last label is right-aligned to the edge, so it cannot run off it.
+ x = min(plot_surface.width - string_width(label), int(round_half_up(i * step)))
+ plot_surface.text(max(0, x), row, label, TextOptions(fg=axis_color))
+
+ # The domain is resolved once and handed down, so the labels and the marks
+ # cannot disagree about what the axis spans.
+ plot = replace(
+ options.plot,
+ x=replace(options.plot.x or AxisOptions(), min=xd.min, max=xd.max),
+ y=replace(options.plot.y or AxisOptions(), min=yd.min, max=yd.max),
+ )
+ plot_points(area, series, plot)
+
+ if options.legend:
+ parts = [
+ (s.label, s.color if s.color is not None else series_color(theme, i))
+ for i, s in enumerate(series)
+ if s.label
+ ]
+ if options.legend_align == "right":
+ total = sum(string_width(label) + 3 for label, _ in parts)
+ x = max(0, area.width - total)
+ else:
+ x = 0
+ y = area.height - 1 if area.height > 3 else 0
+ for label, color in parts:
+ x += area.text(x, y, "■ ", TextOptions(fg=color))
+ x += area.text(x, y, f"{label} ", TextOptions(fg=theme.muted))
diff --git a/ports/python/tests/test_conformance_widgets.py b/ports/python/tests/test_conformance_widgets.py
index bd23ce2..f887ed8 100644
--- a/ports/python/tests/test_conformance_widgets.py
+++ b/ports/python/tests/test_conformance_widgets.py
@@ -11,6 +11,7 @@
import math
import unittest
+import hqtui.graphics.chart as g
import hqtui.widgets as w
from hqtui.graphics import (
BarOptions,
@@ -32,6 +33,20 @@
from .support import assert_buffer, fixture, scene
+
+def _axis(minimum: float, maximum: float, ticks: int = 0) -> g.AxisOptions:
+ """The axis bounds every chart fixture pins, without the ceremony."""
+ return g.AxisOptions(min=minimum, max=maximum, ticks=ticks)
+
+
+def _chart(mark: str) -> w.ChartOptions:
+ """One series over the standard 0..10 domain."""
+ return w.ChartOptions(
+ series=[g.ChartSeries(points=[(0, 1), (2, 6), (5, 3), (8, 9), (10, 4)], mark=mark)],
+ plot=g.ChartPlotOptions(x=_axis(0, 10), y=_axis(0, 10)),
+ )
+
+
SERIES = [3, 7, 2, 9, 4, 8, 6, 1, 5, 9, 3, 7, 8, 2, 6, 4, 9, 1, 5, 7]
@@ -119,6 +134,46 @@ def draw_scene(case, name: str, s: Surface) -> None:
segments=[DonutSegment(value=3), DonutSegment(value=5), DonutSegment(value=2)]
),
)
+ elif name == "chart-line":
+ w.draw_chart(s, _chart("line"))
+ elif name == "chart-scatter":
+ w.draw_chart(s, _chart("scatter"))
+ elif name == "chart-bar":
+ w.draw_chart(s, _chart("bar"))
+ elif name == "chart-fill":
+ w.draw_chart(s, w.ChartOptions(
+ series=[g.ChartSeries(points=[(0, 2), (5, 8), (10, 2)], fill=True)],
+ plot=g.ChartPlotOptions(x=_axis(0, 10), y=_axis(0, 10)),
+ ))
+ elif name == "chart-axes":
+ w.draw_chart(s, w.ChartOptions(
+ series=[g.ChartSeries(points=[(0, 0), (5, 50), (10, 100)])],
+ axis=True,
+ plot=g.ChartPlotOptions(x=_axis(0, 10, 3), y=_axis(0, 100)),
+ ))
+ elif name == "chart-block":
+ w.draw_chart(s, w.ChartOptions(
+ series=[g.ChartSeries(points=[(0, 1), (2, 6), (5, 3), (8, 9), (10, 4)], mark="bar")],
+ plot=g.ChartPlotOptions(mode="block", x=_axis(0, 10), y=_axis(0, 10)),
+ ))
+ elif name == "chart-multi":
+ w.draw_chart(s, w.ChartOptions(
+ series=[
+ g.ChartSeries(
+ points=[(0, 1), (1, 3), (2, 2), (3, 5), (4, 4), (5, 7), (6, 6), (7, 9)],
+ label="fine",
+ ),
+ g.ChartSeries(points=[(0, 8), (7, 2)], label="coarse"),
+ ],
+ axis=True,
+ legend=True,
+ plot=g.ChartPlotOptions(x=_axis(0, 7), y=_axis(0, 10)),
+ ))
+ elif name == "chart-flat":
+ w.draw_chart(s, w.ChartOptions(
+ series=[g.ChartSeries(points=[(0, 4), (5, 4), (10, 4)])],
+ plot=g.ChartPlotOptions(x=_axis(0, 10)),
+ ))
elif name == "graph-axis":
w.draw_graph(s, w.GraphOptions(values=SERIES, axis=True))
elif name == "graph-legend":
diff --git a/ports/ruby/examples/widgets.rb b/ports/ruby/examples/widgets.rb
index 72e9037..7de1862 100644
--- a/ports/ruby/examples/widgets.rb
+++ b/ports/ruby/examples/widgets.rb
@@ -86,6 +86,21 @@ def scrollbar(ui)
end
# @end
+# @widget chart
+def chart(ui)
+ # Points carry their own x, so a sparse series and a dense one line up.
+ ui.chart(
+ [
+ { points: [{ x: 0, y: 1 }, { x: 2, y: 6 }, { x: 5, y: 3 }, { x: 8, y: 9 }, { x: 10, y: 4 }],
+ label: 'load' },
+ { points: [{ x: 0, y: 8 }, { x: 10, y: 2 }], label: 'limit' }
+ ],
+ axis: true, legend: true,
+ x: { min: 0, max: 10, ticks: 3 }, y: { min: 0, max: 10 }
+ )
+end
+# @end
+
# @widget meter
def meter(ui)
ui.meter(0.62, label: 'CPU')
@@ -274,6 +289,7 @@ def tooltip(ui)
'table' => method(:table),
'log' => method(:log),
'scrollbar' => method(:scrollbar),
+ 'chart' => method(:chart),
'meter' => method(:meter),
'graph' => method(:graph),
'gauge' => method(:gauge),
diff --git a/ports/ruby/lib/hqtui.rb b/ports/ruby/lib/hqtui.rb
index 33c72db..dc6d4d4 100644
--- a/ports/ruby/lib/hqtui.rb
+++ b/ports/ruby/lib/hqtui.rb
@@ -72,6 +72,7 @@ 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 chart(series, **options) = add('chart', series: series, **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 7e3e8e5..3e3db3d 100644
--- a/ports/rust/examples/cobol-bridge.rs
+++ b/ports/rust/examples/cobol-bridge.rs
@@ -15,6 +15,7 @@
use std::io::Read;
+use hqtui::graphics::chart::{AxisOptions, ChartPlotOptions, ChartSeries, MarkType};
use hqtui::graphics::plot::{DonutOptions, DonutSegment, GaugeOptions, PlotOptions, Series};
use hqtui::prelude::*;
use hqtui::testing::render_to_text;
@@ -86,6 +87,7 @@ fn draw(scene: &Scene, ui: &mut Container) {
let mut columns: Vec = Vec::new();
let mut rows: Vec = Vec::new();
let mut keys: Vec = Vec::new();
+ let mut chart_series: Vec<(String, Vec<(f64, f64)>)> = Vec::new();
let mut entries: Vec = Vec::new();
let mut points: Vec = Vec::new();
let mut bars: Vec = Vec::new();
@@ -148,6 +150,50 @@ fn draw(scene: &Scene, ui: &mut Container) {
"METER" => {
ui.meter(MeterOptions::new(record.num.parse().unwrap_or(0.0)).label(&record.key));
}
+ "CHARTPT" => {
+ // One point per record, like GRAPHPT. key names the series it
+ // joins, so a flat record stream can describe several.
+ let mut parts = record.text.split('|');
+ let x: f64 = parts.next().unwrap_or("").parse().unwrap_or(0.0);
+ let y: f64 = parts.next().unwrap_or("").parse().unwrap_or(0.0);
+ if let Some(points) = chart_series.iter_mut().find(|(k, _)| *k == record.key) {
+ points.1.push((x, y));
+ } else {
+ chart_series.push((record.key.clone(), vec![(x, y)]));
+ }
+ }
+ "CHART" => {
+ // key is the mark every series takes; text is the domain.
+ let mut parts = record.text.split('|');
+ let mut next = || -> f64 { parts.next().unwrap_or("").parse().unwrap_or(0.0) };
+ let (xmin, xmax, ymin, ymax) = (next(), next(), next(), next());
+ let mark = match record.key.as_str() {
+ "SCATTER" => MarkType::Scatter,
+ "BAR" => MarkType::Bar,
+ _ => MarkType::Line,
+ };
+ let legend = chart_series.len() > 1;
+ ui.chart(ChartOptions {
+ series: chart_series
+ .drain(..)
+ .map(|(label, points)| {
+ ChartSeries::new(points).mark(mark).label(label)
+ })
+ .collect(),
+ axis: true,
+ legend,
+ plot: ChartPlotOptions {
+ x: Some(AxisOptions {
+ min: Some(xmin), max: Some(xmax), ticks: None, format: None,
+ }),
+ y: Some(AxisOptions {
+ min: Some(ymin), max: Some(ymax), ticks: None, format: None,
+ }),
+ ..Default::default()
+ },
+ ..Default::default()
+ });
+ }
"SCROLLBAR" => {
// key is the edge, num the offset, text "total|viewport".
let mut parts = record.text.split('|');
diff --git a/ports/rust/examples/widgets.rs b/ports/rust/examples/widgets.rs
index d1efcce..7e2268d 100644
--- a/ports/rust/examples/widgets.rs
+++ b/ports/rust/examples/widgets.rs
@@ -8,6 +8,7 @@
//!
//! Keep each function self-contained: it takes a container and nothing else.
+use hqtui::graphics::chart::{AxisOptions, ChartPlotOptions, ChartSeries};
use hqtui::graphics::plot::{BarStyle, DonutOptions, DonutSegment, GaugeOptions, PlotOptions, Series};
use hqtui::prelude::*;
use hqtui::testing::render_to_text;
@@ -430,6 +431,27 @@ pub fn scrollbar(ui: &mut Container) {
}
// @end
+// @widget chart
+pub fn chart(ui: &mut Container) {
+ // Points carry their own x, so a sparse series and a dense one line up.
+ ui.chart(ChartOptions {
+ series: vec![
+ ChartSeries::new(vec![(0.0, 1.0), (2.0, 6.0), (5.0, 3.0), (8.0, 9.0), (10.0, 4.0)])
+ .label("load"),
+ ChartSeries::new(vec![(0.0, 8.0), (10.0, 2.0)]).label("limit"),
+ ],
+ axis: true,
+ legend: true,
+ plot: ChartPlotOptions {
+ x: Some(AxisOptions { min: Some(0.0), max: Some(10.0), ticks: Some(3), format: None }),
+ y: Some(AxisOptions { min: Some(0.0), max: Some(10.0), ticks: None, format: None }),
+ ..Default::default()
+ },
+ ..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![
@@ -445,6 +467,7 @@ fn main() {
("tree", tree),
("log", log),
("scrollbar", scrollbar),
+ ("chart", chart),
("meter", meter),
("meters", meters),
("progress", progress),
diff --git a/ports/rust/src/graphics/chart.rs b/ports/rust/src/graphics/chart.rs
new file mode 100644
index 0000000..f941a32
--- /dev/null
+++ b/ports/rust/src/graphics/chart.rs
@@ -0,0 +1,441 @@
+//! Charts of arbitrary (x, y) data.
+//!
+//! `plot` takes `&[f64]` and puts one sample per column: the x axis is the
+//! array index. That is the right model for a history buffer and the wrong one
+//! for everything else -- two series of different lengths silently render at
+//! different horizontal scales, a gap in the data is indistinguishable from a
+//! shorter series, and there is no way at all to say where on the x axis a
+//! point belongs.
+//!
+//! This takes points and a domain for each axis, so a series is placed rather
+//! than appended. `plot` is untouched and still means what it meant.
+
+use crate::buffer::Style;
+use crate::color::{round_half_up, Color};
+use crate::graphics::blocks::{vertical_glyph, FillMode};
+use crate::graphics::braille::BrailleCanvas;
+use crate::graphics::plot::{blit, first_char};
+use crate::surface::Surface;
+use crate::theme::series_color;
+
+pub type Point = (f64, f64);
+
+/// How a series is marked: joined, dotted, or dropped to the baseline.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum MarkType {
+ #[default]
+ Line,
+ Scatter,
+ Bar,
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct ChartSeries {
+ pub points: Vec,
+ pub color: Option,
+ pub label: Option,
+ pub mark: MarkType,
+ /// Shade between the line and the baseline. Ignored for a scatter.
+ pub fill: bool,
+}
+
+impl ChartSeries {
+ pub fn new(points: impl Into>) -> ChartSeries {
+ ChartSeries { points: points.into(), ..Default::default() }
+ }
+
+ pub fn mark(mut self, mark: MarkType) -> ChartSeries {
+ self.mark = mark;
+ self
+ }
+
+ pub fn label(mut self, label: impl Into) -> ChartSeries {
+ self.label = Some(label.into());
+ self
+ }
+
+ pub fn filled(mut self) -> ChartSeries {
+ self.fill = true;
+ self
+ }
+}
+
+/// One axis: what it spans and how its numbers read.
+#[derive(Clone, Default)]
+pub struct AxisOptions {
+ pub min: Option,
+ pub max: Option,
+ pub format: Option String + Send + Sync>>,
+ /// How many labels to place. Default 2 -- the ends.
+ pub ticks: Option,
+}
+
+impl std::fmt::Debug for AxisOptions {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("AxisOptions")
+ .field("min", &self.min)
+ .field("max", &self.max)
+ .field("ticks", &self.ticks)
+ .finish()
+ }
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct ChartPlotOptions {
+ /// Braille is sharpest; block and ascii are the graceful degradations.
+ pub mode: Option,
+ pub x: Option,
+ pub y: Option,
+ pub background: Option,
+ pub grid: bool,
+ pub grid_color: Option,
+ /// 0-1 opacity of the area fill against the background.
+ pub fill_alpha: Option,
+ /// Where a bar or an area is measured from. Defaults to the y minimum.
+ pub baseline: Option,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Domain {
+ pub min: f64,
+ pub max: f64,
+}
+
+/// A finite number, or none: a caller's bound is data, and data can be NaN.
+fn bound(value: Option) -> Option {
+ value.filter(|v| v.is_finite())
+}
+
+/// The span an axis covers, from the caller where they said and from the data
+/// where they did not.
+///
+/// A domain of zero width cannot be mapped -- every point would land in the
+/// same place and a division would blow up -- so a flat series is given room
+/// around itself rather than being collapsed onto one line.
+pub fn domain_of(series: &[ChartSeries], axis: Option<&AxisOptions>, which: usize) -> Domain {
+ let mut min = bound(axis.and_then(|a| a.min));
+ let mut max = bound(axis.and_then(|a| a.max));
+ if min.is_none() || max.is_none() {
+ let mut lo = f64::INFINITY;
+ let mut hi = f64::NEG_INFINITY;
+ for s in series {
+ for p in &s.points {
+ let v = if which == 0 { p.0 } else { p.1 };
+ if !v.is_finite() {
+ continue;
+ }
+ if v < lo {
+ lo = v;
+ }
+ if v > hi {
+ hi = v;
+ }
+ }
+ }
+ if !lo.is_finite() {
+ lo = 0.0;
+ hi = 1.0;
+ }
+ min = min.or(Some(lo));
+ max = max.or(Some(hi));
+ }
+ let min = min.unwrap_or(0.0);
+ let max = max.unwrap_or(1.0);
+ if !(max > min) {
+ // A flat series still has to be drawn somewhere sensible.
+ let pad = if min.abs() > 0.0 { min.abs() * 0.5 } else { 0.5 };
+ return Domain { min: min - pad, max: min + pad };
+ }
+ Domain { min, max }
+}
+
+/// Where a value sits in its domain, 0 at the minimum and 1 at the maximum.
+fn ratio(value: f64, domain: Domain) -> f64 {
+ (value - domain.min) / (domain.max - domain.min)
+}
+
+fn clamp01(v: f64) -> f64 {
+ v.clamp(0.0, 1.0)
+}
+
+fn draw_grid(surface: &Surface, color: Color, bg: Option) {
+ let w = surface.width();
+ let h = surface.height();
+ let step = std::cmp::max(2, h / 4);
+ let mut y = 0;
+ while y < h {
+ let mut x = 0;
+ while x < w {
+ surface.glyph(x as isize, y as isize, '·', &Style { fg: Some(color), bg, attrs: None });
+ x += 2;
+ }
+ y += step;
+ }
+}
+
+/// Draw point series across the whole surface.
+///
+/// Points are drawn in the order they are given: a line joins them as they
+/// come, which is what lets a chart draw a loop or a path that doubles back.
+/// Sorting them would quietly make that impossible.
+pub fn plot_points(surface: &Surface, series: &[ChartSeries], options: &ChartPlotOptions) {
+ if surface.is_empty() || series.is_empty() {
+ return;
+ }
+ let theme = surface.theme.clone();
+ let mode = options.mode.unwrap_or(FillMode::Braille);
+ let bg = options.background;
+ let w = surface.width();
+ let h = surface.height();
+
+ let xd = domain_of(series, options.x.as_ref(), 0);
+ let yd = domain_of(series, options.y.as_ref(), 1);
+ let baseline = bound(options.baseline).unwrap_or(yd.min);
+
+ if options.grid {
+ let color = options
+ .grid_color
+ .unwrap_or_else(|| theme.border.mix(theme.background, 0.4));
+ draw_grid(surface, color, bg);
+ }
+
+ if mode != FillMode::Braille {
+ plot_cells(surface, series, mode, xd, yd, baseline, bg);
+ return;
+ }
+
+ let mut canvas = BrailleCanvas::new(w, h);
+ let px = canvas.width as f64;
+ let py = canvas.height as f64;
+
+ for (si, s) in series.iter().enumerate() {
+ canvas.clear();
+ let color = s.color.unwrap_or_else(|| series_color(&theme, si as i64));
+ let finite: Vec = s
+ .points
+ .iter()
+ .copied()
+ .filter(|p| p.0.is_finite() && p.1.is_finite())
+ .collect();
+ if finite.is_empty() {
+ continue;
+ }
+ let pixels: Vec<(f64, f64)> = finite
+ .iter()
+ .map(|p| {
+ (
+ round_half_up(clamp01(ratio(p.0, xd)) * (px - 1.0)),
+ round_half_up((1.0 - clamp01(ratio(p.1, yd))) * (py - 1.0)),
+ )
+ })
+ .collect();
+
+ match s.mark {
+ MarkType::Scatter => {
+ for (x, y) in &pixels {
+ canvas.pixel(*x, *y);
+ }
+ }
+ MarkType::Bar => {
+ let floor = round_half_up((1.0 - clamp01(ratio(baseline, yd))) * (py - 1.0));
+ for (x, y) in &pixels {
+ canvas.vline(*x, y.min(floor), y.max(floor));
+ }
+ }
+ MarkType::Line => {
+ if pixels.len() == 1 {
+ canvas.pixel(pixels[0].0, pixels[0].1);
+ } else {
+ canvas.polyline(&pixels);
+ }
+ }
+ }
+
+ if s.fill && s.mark != MarkType::Scatter {
+ fill_under(
+ surface,
+ &finite,
+ xd,
+ yd,
+ baseline,
+ color,
+ bg,
+ options.fill_alpha.unwrap_or(0.5),
+ );
+ }
+ blit(surface, &canvas, |_, _| color, bg);
+ }
+}
+
+/// The area between a series and its baseline, in block elements.
+///
+/// Braille would give eight scattered dots per cell, which reads as noise where
+/// an area should read as an area. The line itself stays Braille, so it keeps
+/// the sub-cell resolution.
+///
+/// The height of each column is interpolated along the line rather than sampled
+/// from the points that happen to land in it. Sampling leaves a gap wherever a
+/// column has no point of its own, which with arbitrary x values is most of
+/// them -- the area comes out striped instead of solid.
+#[allow(clippy::too_many_arguments)]
+fn fill_under(
+ surface: &Surface,
+ points: &[Point],
+ xd: Domain,
+ yd: Domain,
+ baseline: f64,
+ color: Color,
+ bg: Option,
+ alpha: f64,
+) {
+ let w = surface.width();
+ let h = surface.height();
+ if w == 0 || h == 0 || points.is_empty() {
+ return;
+ }
+ let base = bg.unwrap_or(surface.theme.background);
+ let floor = clamp01(ratio(baseline, yd));
+ let column = |x: f64| ratio(x, xd) * (w as f64 - 1.0);
+
+ let mut tops = vec![f64::NAN; w];
+ let mut record = |col: isize, value: f64, tops: &mut Vec| {
+ if col < 0 || col as usize >= tops.len() {
+ return;
+ }
+ // A path that doubles back covers a column twice; the outer edge is the
+ // one that bounds the area.
+ let previous = tops[col as usize];
+ if previous.is_nan() || (value - floor).abs() > (previous - floor).abs() {
+ tops[col as usize] = value;
+ }
+ };
+
+ if points.len() == 1 {
+ record(round_half_up(column(points[0].0)) as isize, clamp01(ratio(points[0].1, yd)), &mut tops);
+ }
+ for pair in points.windows(2) {
+ let (x0, y0) = pair[0];
+ let (x1, y1) = pair[1];
+ let c0 = column(x0);
+ let c1 = column(x1);
+ let from = c0.min(c1).floor().max(0.0) as usize;
+ let to = std::cmp::min(w - 1, c0.max(c1).ceil().max(0.0) as usize);
+ for col in from..=to {
+ let t = if c1 == c0 { 0.0 } else { (col as f64 - c0) / (c1 - c0) };
+ if !(-0.5..=1.5).contains(&t) {
+ continue;
+ }
+ let y = y0 + (y1 - y0) * clamp01(t);
+ record(col as isize, clamp01(ratio(y, yd)), &mut tops);
+ }
+ }
+
+ for x in 0..w {
+ let top = tops[x];
+ if top.is_nan() {
+ continue;
+ }
+ let from01 = floor.min(top);
+ let filled = (floor.max(top) - from01) * h as f64;
+ let bottom = (from01 * h as f64).floor() as isize;
+ let full = filled.floor() as isize;
+ for k in 0..full.min(h as isize) {
+ let row = h as isize - 1 - bottom - k;
+ if row < 0 || row >= h as isize {
+ continue;
+ }
+ let depth = if h <= 1 { 0.0 } else { row as f64 / (h as f64 - 1.0) };
+ surface.glyph(
+ x as isize,
+ row,
+ '█',
+ &Style {
+ fg: Some(base.mix(color, alpha * (1.0 - depth * 0.3))),
+ bg,
+ attrs: None,
+ },
+ );
+ }
+ if full < h as isize {
+ let glyph = vertical_glyph(filled - full as f64, FillMode::Block);
+ let row = h as isize - 1 - bottom - full;
+ if glyph != " " && row >= 0 && row < h as isize {
+ let depth = if h <= 1 { 0.0 } else { row as f64 / (h as f64 - 1.0) };
+ surface.glyph(
+ x as isize,
+ row,
+ first_char(glyph),
+ &Style {
+ fg: Some(base.mix(color, alpha * (1.0 - depth * 0.3) + 0.12)),
+ bg,
+ attrs: None,
+ },
+ );
+ }
+ }
+ }
+}
+
+/// The block and ascii degradations: one column per cell, tallest point wins.
+///
+/// A scatter keeps its dots rather than growing columns, because a scatter that
+/// fills to the baseline is a bar chart wearing the wrong name.
+fn plot_cells(
+ surface: &Surface,
+ series: &[ChartSeries],
+ mode: FillMode,
+ xd: Domain,
+ yd: Domain,
+ baseline: f64,
+ bg: Option,
+) {
+ let w = surface.width();
+ let h = surface.height();
+ let theme = surface.theme.clone();
+ let floor_ratio = clamp01(ratio(baseline, yd));
+
+ for (si, s) in series.iter().enumerate() {
+ let color = s.color.unwrap_or_else(|| series_color(&theme, si as i64));
+ // Highest value per column, so a column shows the peak that fell in it
+ // rather than whichever point happened to be last.
+ let mut tops = vec![f64::NAN; w];
+ for p in &s.points {
+ if !p.0.is_finite() || !p.1.is_finite() {
+ continue;
+ }
+ let col = (round_half_up(ratio(p.0, xd) * (w as f64 - 1.0)) as isize)
+ .clamp(0, w as isize - 1) as usize;
+ let value = clamp01(ratio(p.1, yd));
+ if tops[col].is_nan() || value > tops[col] {
+ tops[col] = value;
+ }
+ }
+
+ for x in 0..w {
+ let top = tops[x];
+ if top.is_nan() {
+ continue;
+ }
+ if s.mark == MarkType::Scatter {
+ let row = h as isize - 1 - ((top * h as f64).floor() as isize).min(h as isize - 1);
+ let glyph = if mode == FillMode::Ascii { '*' } else { '•' };
+ surface.glyph(x as isize, row, glyph, &Style { fg: Some(color), bg, attrs: None });
+ continue;
+ }
+ let from = floor_ratio.min(top) * h as f64;
+ let filled = (floor_ratio.max(top) - floor_ratio.min(top)) * h as f64;
+ let full = filled.floor() as isize;
+ for k in 0..full {
+ let row = h as isize - 1 - from.floor() as isize - k;
+ if row >= 0 && row < h as isize {
+ surface.glyph(x as isize, row, '█', &Style { fg: Some(color), bg, attrs: None });
+ }
+ }
+ let glyph = vertical_glyph(filled - full as f64, mode);
+ let row = h as isize - 1 - from.floor() as isize - full;
+ if glyph != " " && row >= 0 && row < h as isize {
+ surface.glyph(x as isize, row, first_char(glyph), &Style { fg: Some(color), bg, attrs: None });
+ }
+ }
+ }
+}
diff --git a/ports/rust/src/graphics/mod.rs b/ports/rust/src/graphics/mod.rs
index f0bddc6..36be133 100644
--- a/ports/rust/src/graphics/mod.rs
+++ b/ports/rust/src/graphics/mod.rs
@@ -2,6 +2,7 @@
//! plotting primitives built on them.
pub mod blocks;
+pub mod chart;
pub mod braille;
pub mod plot;
@@ -10,6 +11,9 @@ pub use blocks::{
HORIZONTAL_EIGHTHS, QUADRANTS, SHADES, VERTICAL_EIGHTHS,
};
pub use braille::BrailleCanvas;
+pub use chart::{
+ domain_of, plot_points, AxisOptions, ChartPlotOptions, ChartSeries, Domain, MarkType, Point,
+};
pub use plot::{
bar, blit, donut, gauge, histogram, plot, sparkline, BarOptions, BarStyle, DonutOptions,
DonutSegment, GaugeOptions, HistogramOptions, PlotOptions, Series, SparklineOptions,
diff --git a/ports/rust/src/graphics/plot.rs b/ports/rust/src/graphics/plot.rs
index fb58728..985c5ca 100644
--- a/ports/rust/src/graphics/plot.rs
+++ b/ports/rust/src/graphics/plot.rs
@@ -62,7 +62,7 @@ pub struct PlotOptions {
/// Every glyph ramp entry is a single character; this is the reference
/// implementation's `codePointAt(0)` on a one-glyph string.
-fn first_char(s: &str) -> char {
+pub(crate) fn first_char(s: &str) -> char {
s.chars().next().unwrap_or(' ')
}
diff --git a/ports/rust/src/ui.rs b/ports/rust/src/ui.rs
index 0ea3ad4..33586be 100644
--- a/ports/rust/src/ui.rs
+++ b/ports/rust/src/ui.rs
@@ -749,6 +749,16 @@ impl<'a> Container<'a> {
self.add(constraint, move |s| w::draw_graph(&s, &options))
}
+ /// A chart of arbitrary (x, y) data, with a domain on both axes.
+ ///
+ /// `graph` plots a history buffer, one sample per column. Use this when the
+ /// data has its own x values: two series of different lengths then line up,
+ /// and a point lands where its x says it does.
+ pub fn chart(&mut self, options: w::ChartOptions) -> &mut Self {
+ let constraint = self.filling();
+ self.add(constraint, move |s| w::draw_chart(&s, &options))
+ }
+
pub fn sparkline(&mut self, options: w::SparklineWidgetOptions) -> &mut Self {
let constraint = self.leaf(1);
self.add(constraint, move |s| w::draw_sparkline(&s, &options))
diff --git a/ports/rust/src/widgets/chart.rs b/ports/rust/src/widgets/chart.rs
new file mode 100644
index 0000000..db57143
--- /dev/null
+++ b/ports/rust/src/widgets/chart.rs
@@ -0,0 +1,152 @@
+//! A chart with two real axes.
+//!
+//! `graph` plots a history buffer: one sample per column, x meaning "position
+//! in the array". This plots data that has its own x values, with a labelled
+//! domain on both axes, so two series of different lengths line up and a point
+//! lands where its x says it does.
+
+use crate::color::{round_half_up, Color};
+use crate::graphics::chart::{
+ domain_of, plot_points, AxisOptions, ChartPlotOptions, ChartSeries,
+};
+use crate::surface::{Surface, TextOptions};
+use crate::theme::series_color;
+use crate::unicode::{fit, string_width, Align};
+use crate::widgets::meters::nice_label;
+
+#[derive(Clone, Debug, Default)]
+pub struct ChartOptions {
+ pub series: Vec,
+ pub plot: ChartPlotOptions,
+ /// Numbers down the left edge.
+ pub axis: bool,
+ pub axis_color: Option,
+ pub legend: bool,
+ pub legend_align: Option,
+}
+
+/// Evenly spaced values across a domain, ends included.
+///
+/// Two ticks means the ends and nothing else, which is what an axis wants when
+/// there is no room to say more.
+fn ticks_for(min: f64, max: f64, count: usize) -> Vec {
+ let n = count.max(2);
+ (0..n).map(|i| min + (max - min) * i as f64 / (n - 1) as f64).collect()
+}
+
+fn format_with(axis: Option<&AxisOptions>, value: f64) -> String {
+ match axis.and_then(|a| a.format.as_ref()) {
+ Some(f) => f(value),
+ None => nice_label(value),
+ }
+}
+
+pub fn draw_chart(surface: &Surface, options: &ChartOptions) {
+ if surface.is_empty() {
+ return;
+ }
+ let theme = surface.theme.clone();
+ let series = &options.series;
+ let axis_color = options.axis_color.unwrap_or(theme.muted);
+
+ let xd = domain_of(series, options.plot.x.as_ref(), 0);
+ let yd = domain_of(series, options.plot.y.as_ref(), 1);
+
+ // The x labels take a row, and they can only take one when there is a row
+ // to spare -- a two-row chart is all plot.
+ let x_ticks = options
+ .plot
+ .x
+ .as_ref()
+ .and_then(|a| a.ticks)
+ .unwrap_or(if options.axis { 2 } else { 0 });
+ let want_x_axis = options.axis && x_ticks >= 2 && surface.height() > 2;
+
+ let mut plot_surface = surface.clone();
+ if options.axis {
+ let hi = format_with(options.plot.y.as_ref(), yd.max);
+ let lo = format_with(options.plot.y.as_ref(), yd.min);
+ let width = string_width(&hi).max(string_width(&lo)) + 1;
+ surface.text(0, 0, &fit(&hi, width, Align::Right), &TextOptions::new().fg(axis_color));
+ if surface.height() > 1 {
+ // The minimum marks the bottom of the plot, which is a row higher
+ // when the x labels have taken the last one.
+ let bottom = if want_x_axis {
+ surface.height() as isize - 2
+ } else {
+ surface.height() as isize - 1
+ };
+ surface.text(
+ 0,
+ bottom,
+ &fit(&lo, width, Align::Right),
+ &TextOptions::new().fg(axis_color),
+ );
+ }
+ plot_surface = surface.sub(
+ width as isize,
+ 0,
+ surface.width().saturating_sub(width),
+ surface.height(),
+ );
+ }
+
+ let mut area = plot_surface.clone();
+ if want_x_axis && plot_surface.height() > 1 && plot_surface.width() > 0 {
+ area = plot_surface.sub(0, 0, plot_surface.width(), plot_surface.height() - 1);
+ let row = plot_surface.height() as isize - 1;
+ let labels: Vec = ticks_for(xd.min, xd.max, x_ticks)
+ .into_iter()
+ .map(|v| format_with(options.plot.x.as_ref(), v))
+ .collect();
+ let step = if labels.len() > 1 {
+ (plot_surface.width() as f64 - 1.0) / (labels.len() - 1) as f64
+ } else {
+ 0.0
+ };
+ for (i, label) in labels.iter().enumerate() {
+ // The last label is right-aligned to the edge, so it cannot run off it.
+ let x = (plot_surface.width() as isize - string_width(label) as isize)
+ .min(round_half_up(i as f64 * step) as isize);
+ plot_surface.text(x.max(0), row, label, &TextOptions::new().fg(axis_color));
+ }
+ }
+
+ // The domain is resolved once and handed down, so the labels and the marks
+ // cannot disagree about what the axis spans.
+ let mut plot_options = options.plot.clone();
+ plot_options.x = Some(AxisOptions {
+ min: Some(xd.min),
+ max: Some(xd.max),
+ ..options.plot.x.clone().unwrap_or_default()
+ });
+ plot_options.y = Some(AxisOptions {
+ min: Some(yd.min),
+ max: Some(yd.max),
+ ..options.plot.y.clone().unwrap_or_default()
+ });
+ plot_points(&area, series, &plot_options);
+
+ if options.legend {
+ let parts: Vec<(String, Color)> = series
+ .iter()
+ .enumerate()
+ .filter_map(|(i, s)| {
+ s.label
+ .clone()
+ .map(|l| (l, s.color.unwrap_or_else(|| series_color(&theme, i as i64))))
+ })
+ .collect();
+ let mut x: isize = if options.legend_align == Some(Align::Right) {
+ let total: usize = parts.iter().map(|(l, _)| string_width(l) + 3).sum();
+ (area.width() as isize - total as isize).max(0)
+ } else {
+ 0
+ };
+ let y = if area.height() > 3 { area.height() as isize - 1 } else { 0 };
+ for (label, color) in &parts {
+ x += area.text(x, y, "■ ", &TextOptions::new().fg(*color)) as isize;
+ x += area.text(x, y, &format!("{label} "), &TextOptions::new().fg(theme.muted)) as isize;
+ }
+ }
+}
diff --git a/ports/rust/src/widgets/mod.rs b/ports/rust/src/widgets/mod.rs
index d8b9c34..1848617 100644
--- a/ports/rust/src/widgets/mod.rs
+++ b/ports/rust/src/widgets/mod.rs
@@ -2,6 +2,7 @@
//! builder in [`ui`](crate::ui) wraps every one of these with layout, so reach
//! for these directly only when you are drawing inside a `draw` escape hatch.
+pub mod chart;
pub mod controls;
pub mod meters;
pub mod scrollbar;
@@ -20,6 +21,7 @@ pub use meters::{
DonutSegment, GaugeOptions, GraphOptions, HeatBarOptions, MeterItem, MeterOptions,
MetersOptions, ProgressOptions, SparklineWidgetOptions,
};
+pub use chart::{draw_chart, ChartOptions};
pub use scrollbar::{
draw_scrollbar, draw_scrollbar_widget, offset_for_position, thumb, thumb_of, ScrollbarOptions,
ScrollbarOrientation,
diff --git a/ports/rust/tests/conformance_widgets.rs b/ports/rust/tests/conformance_widgets.rs
index 1a55d4f..5927047 100644
--- a/ports/rust/tests/conformance_widgets.rs
+++ b/ports/rust/tests/conformance_widgets.rs
@@ -13,6 +13,7 @@ use hqtui::graphics::plot::{
bar, donut, gauge, plot, sparkline, BarOptions, BarStyle, DonutOptions, DonutSegment,
GaugeOptions, PlotOptions, Series,
};
+use hqtui::graphics::chart::{AxisOptions, ChartPlotOptions, ChartSeries, MarkType};
use hqtui::graphics::FillMode;
use hqtui::surface::Surface;
use hqtui::unicode::Align;
@@ -25,6 +26,24 @@ fn series() -> Vec {
SERIES.to_vec()
}
+/// The axis bounds every chart fixture pins, without the ceremony.
+fn axis(min: f64, max: f64, ticks: Option) -> AxisOptions {
+ AxisOptions { min: Some(min), max: Some(max), ticks, format: None }
+}
+
+/// One series over the standard 0..10 domain.
+fn chart(points: &[(f64, f64)], mark: MarkType) -> ChartOptions {
+ ChartOptions {
+ series: vec![ChartSeries::new(points.to_vec()).mark(mark)],
+ plot: ChartPlotOptions {
+ x: Some(axis(0.0, 10.0, None)),
+ y: Some(axis(0.0, 10.0, None)),
+ ..Default::default()
+ },
+ ..Default::default()
+ }
+}
+
fn draw_scene(name: &str, s: &Surface) {
match name {
"text-plain" => draw_text(s, "hello terminal", &TextStyle::new()),
@@ -145,6 +164,79 @@ fn draw_scene(name: &str, s: &Surface) {
background: None,
},
),
+ "chart-line" => draw_chart(s, &chart(&[(0.0, 1.0), (2.0, 6.0), (5.0, 3.0), (8.0, 9.0), (10.0, 4.0)], MarkType::Line)),
+ "chart-scatter" => draw_chart(s, &chart(&[(0.0, 1.0), (2.0, 6.0), (5.0, 3.0), (8.0, 9.0), (10.0, 4.0)], MarkType::Scatter)),
+ "chart-bar" => draw_chart(s, &chart(&[(0.0, 1.0), (2.0, 6.0), (5.0, 3.0), (8.0, 9.0), (10.0, 4.0)], MarkType::Bar)),
+ "chart-fill" => draw_chart(
+ s,
+ &ChartOptions {
+ series: vec![ChartSeries::new(vec![(0.0, 2.0), (5.0, 8.0), (10.0, 2.0)]).filled()],
+ plot: ChartPlotOptions {
+ x: Some(axis(0.0, 10.0, None)),
+ y: Some(axis(0.0, 10.0, None)),
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ ),
+ "chart-axes" => draw_chart(
+ s,
+ &ChartOptions {
+ series: vec![ChartSeries::new(vec![(0.0, 0.0), (5.0, 50.0), (10.0, 100.0)])],
+ axis: true,
+ plot: ChartPlotOptions {
+ x: Some(axis(0.0, 10.0, Some(3))),
+ y: Some(axis(0.0, 100.0, None)),
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ ),
+ "chart-block" => draw_chart(
+ s,
+ &ChartOptions {
+ series: vec![ChartSeries::new(vec![
+ (0.0, 1.0), (2.0, 6.0), (5.0, 3.0), (8.0, 9.0), (10.0, 4.0),
+ ])
+ .mark(MarkType::Bar)],
+ plot: ChartPlotOptions {
+ mode: Some(FillMode::Block),
+ x: Some(axis(0.0, 10.0, None)),
+ y: Some(axis(0.0, 10.0, None)),
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ ),
+ "chart-multi" => draw_chart(
+ s,
+ &ChartOptions {
+ series: vec![
+ ChartSeries::new(vec![
+ (0.0, 1.0), (1.0, 3.0), (2.0, 2.0), (3.0, 5.0),
+ (4.0, 4.0), (5.0, 7.0), (6.0, 6.0), (7.0, 9.0),
+ ])
+ .label("fine"),
+ ChartSeries::new(vec![(0.0, 8.0), (7.0, 2.0)]).label("coarse"),
+ ],
+ axis: true,
+ legend: true,
+ plot: ChartPlotOptions {
+ x: Some(axis(0.0, 7.0, None)),
+ y: Some(axis(0.0, 10.0, None)),
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ ),
+ "chart-flat" => draw_chart(
+ s,
+ &ChartOptions {
+ series: vec![ChartSeries::new(vec![(0.0, 4.0), (5.0, 4.0), (10.0, 4.0)])],
+ plot: ChartPlotOptions { x: Some(axis(0.0, 10.0, None)), ..Default::default() },
+ ..Default::default()
+ },
+ ),
"graph-axis" => draw_graph(s, &GraphOptions::new(series()).with_axis()),
"graph-legend" => draw_graph(
s,
diff --git a/ports/zig/examples/widgets.zig b/ports/zig/examples/widgets.zig
index 2b8ddde..0ab3f67 100644
--- a/ports/zig/examples/widgets.zig
+++ b/ports/zig/examples/widgets.zig
@@ -187,6 +187,27 @@ fn scrollbarRow(r: *Container) anyerror!void {
}
// @end
+// @widget chart
+fn chart(ui: *Container) anyerror!void {
+ // Points carry their own x, so a sparse series and a dense one line up.
+ try ui.chart(.{
+ .series = &.{
+ .{ .points = &.{
+ .{ .x = 0, .y = 1 }, .{ .x = 2, .y = 6 }, .{ .x = 5, .y = 3 },
+ .{ .x = 8, .y = 9 }, .{ .x = 10, .y = 4 },
+ }, .label = "load" },
+ .{ .points = &.{ .{ .x = 0, .y = 8 }, .{ .x = 10, .y = 2 } }, .label = "limit" },
+ },
+ .axis = true,
+ .legend = true,
+ .plot = .{
+ .x = .{ .min = 0, .max = 10, .ticks = 3 },
+ .y = .{ .min = 0, .max = 10 },
+ },
+ });
+}
+// @end
+
// @widget meter
fn meter(ui: *Container) anyerror!void {
try ui.meter(.{ .value = 0.62, .label = "CPU" });
@@ -375,6 +396,7 @@ const examples = [_]Example{
.{ .name = "tree", .body = hqtui.Body.plain(tree) },
.{ .name = "log", .body = hqtui.Body.plain(log) },
.{ .name = "scrollbar", .body = hqtui.Body.plain(scrollbar) },
+ .{ .name = "chart", .body = hqtui.Body.plain(chart) },
.{ .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 394230b..156eecb 100644
--- a/ports/zig/src/conformance_widgets.zig
+++ b/ports/zig/src/conformance_widgets.zig
@@ -19,6 +19,26 @@ const Surface = surface_mod.Surface;
const series = [_]f64{ 3, 7, 2, 9, 4, 8, 6, 1, 5, 9, 3, 7, 8, 2, 6, 4, 9, 1, 5, 7 };
+
+/// The points every single-series chart fixture pins.
+const chart_points = [_]graphics.Point{
+ .{ .x = 0, .y = 1 }, .{ .x = 2, .y = 6 }, .{ .x = 5, .y = 3 },
+ .{ .x = 8, .y = 9 }, .{ .x = 10, .y = 4 },
+};
+
+/// The axis bounds every chart fixture pins, without the ceremony.
+fn axisOf(min: f64, max: f64, ticks: usize) graphics.AxisOptions {
+ return .{ .min = min, .max = max, .ticks = ticks };
+}
+
+/// One series over the standard 0..10 domain.
+fn chartScene(allocator: std.mem.Allocator, s: Surface, mark: graphics.MarkType) !void {
+ try w.drawChart(allocator, s, .{
+ .series = &.{.{ .points = &chart_points, .mark = mark }},
+ .plot = .{ .x = axisOf(0, 10, 0), .y = axisOf(0, 10, 0) },
+ });
+}
+
fn drawScene(allocator: std.mem.Allocator, name: []const u8, s: Surface) !void {
const eq = std.mem.eql;
@@ -98,6 +118,46 @@ fn drawScene(allocator: std.mem.Allocator, name: []const u8, s: Surface) !void {
try w.drawDonut(allocator, s, .{ .segments = &.{
.{ .value = 3 }, .{ .value = 5 }, .{ .value = 2 },
} });
+ } else if (eq(u8, name, "chart-line")) {
+ try chartScene(allocator, s, .line);
+ } else if (eq(u8, name, "chart-scatter")) {
+ try chartScene(allocator, s, .scatter);
+ } else if (eq(u8, name, "chart-bar")) {
+ try chartScene(allocator, s, .bar);
+ } else if (eq(u8, name, "chart-fill")) {
+ try w.drawChart(allocator, s, .{
+ .series = &.{.{ .points = &.{ .{ .x = 0, .y = 2 }, .{ .x = 5, .y = 8 }, .{ .x = 10, .y = 2 } }, .fill = true }},
+ .plot = .{ .x = axisOf(0, 10, 0), .y = axisOf(0, 10, 0) },
+ });
+ } else if (eq(u8, name, "chart-axes")) {
+ try w.drawChart(allocator, s, .{
+ .series = &.{.{ .points = &.{ .{ .x = 0, .y = 0 }, .{ .x = 5, .y = 50 }, .{ .x = 10, .y = 100 } } }},
+ .axis = true,
+ .plot = .{ .x = axisOf(0, 10, 3), .y = axisOf(0, 100, 0) },
+ });
+ } else if (eq(u8, name, "chart-block")) {
+ try w.drawChart(allocator, s, .{
+ .series = &.{.{ .points = &chart_points, .mark = .bar }},
+ .plot = .{ .mode = .block, .x = axisOf(0, 10, 0), .y = axisOf(0, 10, 0) },
+ });
+ } else if (eq(u8, name, "chart-multi")) {
+ try w.drawChart(allocator, s, .{
+ .series = &.{
+ .{ .points = &.{
+ .{ .x = 0, .y = 1 }, .{ .x = 1, .y = 3 }, .{ .x = 2, .y = 2 }, .{ .x = 3, .y = 5 },
+ .{ .x = 4, .y = 4 }, .{ .x = 5, .y = 7 }, .{ .x = 6, .y = 6 }, .{ .x = 7, .y = 9 },
+ }, .label = "fine" },
+ .{ .points = &.{ .{ .x = 0, .y = 8 }, .{ .x = 7, .y = 2 } }, .label = "coarse" },
+ },
+ .axis = true,
+ .legend = true,
+ .plot = .{ .x = axisOf(0, 7, 0), .y = axisOf(0, 10, 0) },
+ });
+ } else if (eq(u8, name, "chart-flat")) {
+ try w.drawChart(allocator, s, .{
+ .series = &.{.{ .points = &.{ .{ .x = 0, .y = 4 }, .{ .x = 5, .y = 4 }, .{ .x = 10, .y = 4 } } }},
+ .plot = .{ .x = axisOf(0, 10, 0) },
+ });
} else if (eq(u8, name, "graph-axis")) {
try w.drawGraph(allocator, s, .{ .values = &series, .axis = true });
} else if (eq(u8, name, "graph-legend")) {
diff --git a/ports/zig/src/graphics.zig b/ports/zig/src/graphics.zig
index 19f9f2d..f6b11f5 100644
--- a/ports/zig/src/graphics.zig
+++ b/ports/zig/src/graphics.zig
@@ -3,6 +3,7 @@
pub const blocks = @import("graphics/blocks.zig");
pub const braille = @import("graphics/braille.zig");
+pub const chart_mod = @import("graphics/chart.zig");
pub const plot_mod = @import("graphics/plot.zig");
pub const BrailleCanvas = braille.BrailleCanvas;
@@ -32,5 +33,12 @@ pub const donut = plot_mod.donut;
pub const gauge = plot_mod.gauge;
pub const histogram = plot_mod.histogram;
pub const plot = plot_mod.plot;
+pub const AxisOptions = chart_mod.AxisOptions;
+pub const ChartPlotOptions = chart_mod.ChartPlotOptions;
+pub const ChartSeries = chart_mod.ChartSeries;
+pub const Domain = chart_mod.Domain;
+pub const MarkType = chart_mod.MarkType;
+pub const domainOf = chart_mod.domainOf;
+pub const plotPoints = chart_mod.plotPoints;
pub const sparkline = plot_mod.sparkline;
pub const tail = plot_mod.tail;
diff --git a/ports/zig/src/graphics/chart.zig b/ports/zig/src/graphics/chart.zig
new file mode 100644
index 0000000..c0cebc8
--- /dev/null
+++ b/ports/zig/src/graphics/chart.zig
@@ -0,0 +1,389 @@
+//! Charts of arbitrary (x, y) data.
+//!
+//! `plot` takes `[]const f64` and puts one sample per column: the x axis is the
+//! slice index. That is the right model for a history buffer and the wrong one
+//! for everything else -- two series of different lengths silently render at
+//! different horizontal scales, a gap in the data is indistinguishable from a
+//! shorter series, and there is no way at all to say where on the x axis a
+//! point belongs.
+//!
+//! This takes points and a domain for each axis, so a series is placed rather
+//! than appended. `plot` is untouched and still means what it meant.
+
+const std = @import("std");
+
+const blocks = @import("blocks.zig");
+const braille_mod = @import("braille.zig");
+const buffer_mod = @import("../buffer.zig");
+const color_mod = @import("../color.zig");
+const plot_mod = @import("plot.zig");
+const surface_mod = @import("../surface.zig");
+const theme_mod = @import("../theme.zig");
+
+const BrailleCanvas = braille_mod.BrailleCanvas;
+const Color = color_mod.Color;
+const FillMode = blocks.FillMode;
+const Point = braille_mod.Point;
+const Style = buffer_mod.Style;
+const Surface = surface_mod.Surface;
+const clamp01 = blocks.clamp01;
+const firstCodepoint = blocks.firstCodepoint;
+const roundHalfUp = color_mod.roundHalfUp;
+const seriesColor = theme_mod.seriesColor;
+
+/// How a series is marked: joined, dotted, or dropped to the baseline.
+pub const MarkType = enum { line, scatter, bar };
+
+pub const ChartSeries = struct {
+ points: []const Point = &.{},
+ color: ?Color = null,
+ label: []const u8 = "",
+ mark: MarkType = .line,
+ /// Shade between the line and the baseline. Ignored for a scatter.
+ fill: bool = false,
+};
+
+/// One axis: what it spans and how its numbers read.
+pub const AxisOptions = struct {
+ min: ?f64 = null,
+ max: ?f64 = null,
+ format: ?*const fn (f64, []u8) []const u8 = null,
+ /// How many labels to place. Default 2 -- the ends.
+ ticks: usize = 0,
+};
+
+pub const ChartPlotOptions = struct {
+ /// Braille is sharpest; block and ascii are the graceful degradations.
+ mode: FillMode = .braille,
+ x: ?AxisOptions = null,
+ y: ?AxisOptions = null,
+ background: ?Color = null,
+ grid: bool = false,
+ grid_color: ?Color = null,
+ /// 0-1 opacity of the area fill against the background.
+ fill_alpha: ?f64 = null,
+ /// Where a bar or an area is measured from. Defaults to the y minimum.
+ baseline: ?f64 = null,
+};
+
+pub const Domain = struct { min: f64, max: f64 };
+
+/// A finite number, or null: a caller's bound is data, and data can be NaN.
+fn bound(value: ?f64) ?f64 {
+ const v = value orelse return null;
+ return if (std.math.isFinite(v)) v else null;
+}
+
+/// The span an axis covers, from the caller where they said and from the data
+/// where they did not.
+///
+/// A domain of zero width cannot be mapped -- every point would land in the same
+/// place and a division would blow up -- so a flat series is given room around
+/// itself rather than being collapsed onto one line.
+pub fn domainOf(series: []const ChartSeries, axis: ?AxisOptions, which: usize) Domain {
+ var min = if (axis) |a| bound(a.min) else null;
+ var max = if (axis) |a| bound(a.max) else null;
+ if (min == null or max == null) {
+ var lo: f64 = std.math.inf(f64);
+ var hi: f64 = -std.math.inf(f64);
+ for (series) |s| {
+ for (s.points) |p| {
+ const v = if (which == 0) p.x else p.y;
+ if (!std.math.isFinite(v)) continue;
+ if (v < lo) lo = v;
+ if (v > hi) hi = v;
+ }
+ }
+ if (!std.math.isFinite(lo)) {
+ lo = 0;
+ hi = 1;
+ }
+ if (min == null) min = lo;
+ if (max == null) max = hi;
+ }
+ const lo = min.?;
+ const hi = max.?;
+ if (!(hi > lo)) {
+ // A flat series still has to be drawn somewhere sensible.
+ const pad: f64 = if (@abs(lo) > 0) @abs(lo) * 0.5 else 0.5;
+ return .{ .min = lo - pad, .max = lo + pad };
+ }
+ return .{ .min = lo, .max = hi };
+}
+
+/// Where a value sits in its domain, 0 at the minimum and 1 at the maximum.
+fn ratio(value: f64, d: Domain) f64 {
+ return (value - d.min) / (d.max - d.min);
+}
+
+fn drawGrid(s: Surface, color: Color, bg: ?Color) void {
+ const w = s.width();
+ const h = s.height();
+ const step = @max(2, h / 4);
+ var y: usize = 0;
+ while (y < h) : (y += step) {
+ var x: usize = 0;
+ while (x < w) : (x += 2) {
+ s.glyph(@intCast(x), @intCast(y), '·', .{ .fg = color, .bg = bg });
+ }
+ }
+}
+
+/// Draw point series across the whole surface.
+///
+/// Points are drawn in the order they are given: a line joins them as they come,
+/// which is what lets a chart draw a loop or a path that doubles back. Sorting
+/// them would quietly make that impossible.
+pub fn plotPoints(
+ allocator: std.mem.Allocator,
+ s: Surface,
+ series: []const ChartSeries,
+ options: ChartPlotOptions,
+) !void {
+ if (s.isEmpty() or series.len == 0) return;
+ const theme = s.theme;
+ const bg = options.background;
+ const w = s.width();
+ const h = s.height();
+
+ const xd = domainOf(series, options.x, 0);
+ const yd = domainOf(series, options.y, 1);
+ const baseline = bound(options.baseline) orelse yd.min;
+
+ if (options.grid) {
+ const color = options.grid_color orelse theme.border.mix(theme.background, 0.4);
+ drawGrid(s, color, bg);
+ }
+
+ if (options.mode != .braille) {
+ plotCells(s, series, options.mode, xd, yd, baseline, bg);
+ return;
+ }
+
+ var canvas = try BrailleCanvas.init(allocator, w, h);
+ defer canvas.deinit();
+ const px: f64 = @floatFromInt(canvas.width);
+ const py: f64 = @floatFromInt(canvas.height);
+
+ for (series, 0..) |cs, si| {
+ canvas.clear();
+ const color = cs.color orelse seriesColor(theme.*, @intCast(si));
+ if (cs.points.len == 0) continue;
+
+ // Two buffers per series rather than one shared one: a series is drawn
+ // and blitted before the next is touched, so nothing outlives the loop.
+ const finite = try allocator.alloc(Point, cs.points.len);
+ defer allocator.free(finite);
+ var count: usize = 0;
+ for (cs.points) |p| {
+ if (!std.math.isFinite(p.x) or !std.math.isFinite(p.y)) continue;
+ finite[count] = p;
+ count += 1;
+ }
+ if (count == 0) continue;
+
+ const pixels = try allocator.alloc(Point, count);
+ defer allocator.free(pixels);
+ for (finite[0..count], 0..) |p, i| {
+ pixels[i] = .{
+ .x = roundHalfUp(clamp01(ratio(p.x, xd)) * (px - 1)),
+ .y = roundHalfUp((1 - clamp01(ratio(p.y, yd))) * (py - 1)),
+ };
+ }
+
+ switch (cs.mark) {
+ .scatter => for (pixels) |p| canvas.pixel(p.x, p.y),
+ .bar => {
+ const floor = roundHalfUp((1 - clamp01(ratio(baseline, yd))) * (py - 1));
+ for (pixels) |p| canvas.vline(p.x, @min(p.y, floor), @max(p.y, floor));
+ },
+ .line => {
+ if (pixels.len == 1) {
+ canvas.pixel(pixels[0].x, pixels[0].y);
+ } else {
+ canvas.polyline(pixels);
+ }
+ },
+ }
+
+ if (cs.fill and cs.mark != .scatter) {
+ try fillUnder(
+ allocator,
+ s,
+ finite[0..count],
+ xd,
+ yd,
+ baseline,
+ color,
+ bg,
+ options.fill_alpha orelse 0.5,
+ );
+ }
+ plot_mod.blitFlat(s, &canvas, color, bg);
+ }
+}
+
+/// The area between a series and its baseline, in block elements.
+///
+/// Braille would give eight scattered dots per cell, which reads as noise where
+/// an area should read as an area. The line itself stays Braille, so it keeps
+/// the sub-cell resolution.
+///
+/// The height of each column is interpolated along the line rather than sampled
+/// from the points that happen to land in it. Sampling leaves a gap wherever a
+/// column has no point of its own, which with arbitrary x values is most of
+/// them -- the area comes out striped instead of solid.
+fn fillUnder(
+ allocator: std.mem.Allocator,
+ s: Surface,
+ points: []const Point,
+ xd: Domain,
+ yd: Domain,
+ baseline: f64,
+ color: Color,
+ bg: ?Color,
+ alpha: f64,
+) !void {
+ const w = s.width();
+ const h = s.height();
+ if (w == 0 or h == 0 or points.len == 0) return;
+ const base = bg orelse s.theme.background;
+ const floor = clamp01(ratio(baseline, yd));
+
+ const tops = try allocator.alloc(f64, w);
+ defer allocator.free(tops);
+ @memset(tops, std.math.nan(f64));
+
+ const fw: f64 = @floatFromInt(w);
+ const fh: f64 = @floatFromInt(h);
+
+ for (0..points.len - 1) |i| {
+ const p0 = points[i];
+ const p1 = points[i + 1];
+ const c0 = ratio(p0.x, xd) * (fw - 1);
+ const c1 = ratio(p1.x, xd) * (fw - 1);
+ const lo = @max(0, @floor(@min(c0, c1)));
+ const hi = @min(fw - 1, @max(0, @ceil(@max(c0, c1))));
+ var col: usize = @intFromFloat(lo);
+ const last: usize = @intFromFloat(hi);
+ while (col <= last and col < w) : (col += 1) {
+ const fc: f64 = @floatFromInt(col);
+ const t = if (c1 == c0) 0 else (fc - c0) / (c1 - c0);
+ if (t < -0.5 or t > 1.5) continue;
+ const y = p0.y + (p1.y - p0.y) * clamp01(t);
+ const value = clamp01(ratio(y, yd));
+ // A path that doubles back covers a column twice; the outer edge is
+ // the one that bounds the area.
+ const previous = tops[col];
+ if (std.math.isNan(previous) or @abs(value - floor) > @abs(previous - floor)) {
+ tops[col] = value;
+ }
+ }
+ }
+ if (points.len == 1) {
+ const c: f64 = roundHalfUp(ratio(points[0].x, xd) * (fw - 1));
+ if (c >= 0 and c < fw) tops[@intFromFloat(c)] = clamp01(ratio(points[0].y, yd));
+ }
+
+ for (0..w) |x| {
+ const top = tops[x];
+ if (std.math.isNan(top)) continue;
+ const from01 = @min(floor, top);
+ const filled = (@max(floor, top) - from01) * fh;
+ const bottom: isize = @intFromFloat(@floor(from01 * fh));
+ const full: isize = @intFromFloat(@floor(filled));
+ var k: isize = 0;
+ while (k < full and k < @as(isize, @intCast(h))) : (k += 1) {
+ const row = @as(isize, @intCast(h)) - 1 - bottom - k;
+ if (row < 0 or row >= @as(isize, @intCast(h))) continue;
+ const depth: f64 = if (h <= 1) 0 else @as(f64, @floatFromInt(row)) / (fh - 1);
+ s.glyph(@intCast(x), row, '█', .{
+ .fg = base.mix(color, alpha * (1 - depth * 0.3)),
+ .bg = bg,
+ });
+ }
+ if (full < @as(isize, @intCast(h))) {
+ const glyph = blocks.verticalGlyph(filled - @as(f64, @floatFromInt(full)), .block);
+ const row = @as(isize, @intCast(h)) - 1 - bottom - full;
+ if (!std.mem.eql(u8, glyph, " ") and row >= 0 and row < @as(isize, @intCast(h))) {
+ const depth: f64 = if (h <= 1) 0 else @as(f64, @floatFromInt(row)) / (fh - 1);
+ s.glyph(@intCast(x), row, firstCodepoint(glyph), .{
+ .fg = base.mix(color, alpha * (1 - depth * 0.3) + 0.12),
+ .bg = bg,
+ });
+ }
+ }
+ }
+}
+
+/// The block and ascii degradations: one column per cell, tallest point wins.
+///
+/// A scatter keeps its dots rather than growing columns, because a scatter that
+/// fills to the baseline is a bar chart wearing the wrong name.
+fn plotCells(
+ s: Surface,
+ series: []const ChartSeries,
+ mode: FillMode,
+ xd: Domain,
+ yd: Domain,
+ baseline: f64,
+ bg: ?Color,
+) void {
+ const w = s.width();
+ const h = s.height();
+ if (w == 0 or h == 0) return;
+ const theme = s.theme;
+ const floor_ratio = clamp01(ratio(baseline, yd));
+ const fw: f64 = @floatFromInt(w);
+ const fh: f64 = @floatFromInt(h);
+
+ // Bounded by the surface width, which is a terminal's, so the stack is the
+ // right place for it and nothing here has to allocate.
+ var tops: [1024]f64 = undefined;
+ const columns = @min(w, tops.len);
+
+ for (series, 0..) |cs, si| {
+ const color = cs.color orelse seriesColor(theme.*, @intCast(si));
+ @memset(tops[0..columns], std.math.nan(f64));
+
+ // Highest value per column, so a column shows the peak that fell in it
+ // rather than whichever point happened to be last.
+ for (cs.points) |p| {
+ if (!std.math.isFinite(p.x) or !std.math.isFinite(p.y)) continue;
+ const raw = roundHalfUp(ratio(p.x, xd) * (fw - 1));
+ const clamped = @max(0, @min(raw, fw - 1));
+ const col: usize = @intFromFloat(clamped);
+ if (col >= columns) continue;
+ const value = clamp01(ratio(p.y, yd));
+ if (std.math.isNan(tops[col]) or value > tops[col]) tops[col] = value;
+ }
+
+ for (0..columns) |x| {
+ const top = tops[x];
+ if (std.math.isNan(top)) continue;
+ if (cs.mark == .scatter) {
+ const k = @min(@as(isize, @intFromFloat(@floor(top * fh))), @as(isize, @intCast(h)) - 1);
+ const row = @as(isize, @intCast(h)) - 1 - k;
+ const glyph: u21 = if (mode == .ascii) '*' else '•';
+ s.glyph(@intCast(x), row, glyph, .{ .fg = color, .bg = bg });
+ continue;
+ }
+ const from = @min(floor_ratio, top) * fh;
+ const filled = (@max(floor_ratio, top) - @min(floor_ratio, top)) * fh;
+ const full: isize = @intFromFloat(@floor(filled));
+ const bottom: isize = @intFromFloat(@floor(from));
+ var k: isize = 0;
+ while (k < full) : (k += 1) {
+ const row = @as(isize, @intCast(h)) - 1 - bottom - k;
+ if (row >= 0 and row < @as(isize, @intCast(h))) {
+ s.glyph(@intCast(x), row, '█', .{ .fg = color, .bg = bg });
+ }
+ }
+ const glyph = blocks.verticalGlyph(filled - @as(f64, @floatFromInt(full)), mode);
+ const row = @as(isize, @intCast(h)) - 1 - bottom - full;
+ if (!std.mem.eql(u8, glyph, " ") and row >= 0 and row < @as(isize, @intCast(h))) {
+ s.glyph(@intCast(x), row, firstCodepoint(glyph), .{ .fg = color, .bg = bg });
+ }
+ }
+ }
+}
diff --git a/ports/zig/src/ui.zig b/ports/zig/src/ui.zig
index ab2f68f..b45b11b 100644
--- a/ports/zig/src/ui.zig
+++ b/ports/zig/src/ui.zig
@@ -372,6 +372,7 @@ const Node = union(enum) {
meters: w.MetersOptions,
progress: w.ProgressOptions,
graph: w.GraphOptions,
+ chart: w.ChartOptions,
sparkline: w.SparklineWidgetOptions,
histogram: w.ColumnsOptions,
gauge: w.GaugeOptions,
@@ -459,6 +460,7 @@ fn drawNode(ctx: *Ctx, s: Surface, node: Node) anyerror!void {
.meters => |o| w.drawMeters(s, o),
.progress => |o| w.drawProgress(s, o),
.graph => |o| try w.drawGraph(allocator, s, o),
+ .chart => |o| try w.drawChart(allocator, s, o),
.sparkline => |o| w.drawSparkline(s, o),
.histogram => |o| w.drawColumns(s, o),
.gauge => |o| try w.drawGauge(allocator, s, o),
@@ -838,6 +840,15 @@ pub const Container = struct {
try self.add(self.filling(), .{ .graph = options });
}
+ /// A chart of arbitrary (x, y) data, with a domain on both axes.
+ ///
+ /// `graph` plots a history buffer, one sample per column. Use this when the
+ /// data has its own x values: two series of different lengths then line up,
+ /// and a point lands where its x says it does.
+ pub fn chart(self: *Container, options: w.ChartOptions) !void {
+ try self.add(self.filling(), .{ .chart = options });
+ }
+
pub fn sparkline(self: *Container, options: w.SparklineWidgetOptions) !void {
try self.add(self.leaf(1), .{ .sparkline = options });
}
diff --git a/ports/zig/src/widgets.zig b/ports/zig/src/widgets.zig
index a349c72..2b73e8d 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 chart = @import("widgets/chart.zig");
pub const scrollbar = @import("widgets/scrollbar.zig");
pub const table = @import("widgets/table.zig");
pub const text = @import("widgets/text.zig");
@@ -59,6 +60,8 @@ pub const TreeOptions = table.TreeOptions;
pub const TreeValue = table.TreeValue;
pub const drawList = table.drawList;
pub const drawLog = table.drawLog;
+pub const ChartOptions = chart.ChartOptions;
+pub const drawChart = chart.drawChart;
pub const ScrollbarOptions = scrollbar.ScrollbarOptions;
pub const ScrollbarOrientation = scrollbar.ScrollbarOrientation;
pub const drawScrollbar = scrollbar.drawScrollbar;
diff --git a/ports/zig/src/widgets/chart.zig b/ports/zig/src/widgets/chart.zig
new file mode 100644
index 0000000..1e3d8df
--- /dev/null
+++ b/ports/zig/src/widgets/chart.zig
@@ -0,0 +1,141 @@
+//! A chart with two real axes.
+//!
+//! `graph` plots a history buffer: one sample per column, x meaning "position
+//! in the slice". This plots data that has its own x values, with a labelled
+//! domain on both axes, so two series of different lengths line up and a point
+//! lands where its x says it does.
+
+const std = @import("std");
+
+const chart_mod = @import("../graphics/chart.zig");
+const color_mod = @import("../color.zig");
+const meters = @import("meters.zig");
+const surface_mod = @import("../surface.zig");
+const theme_mod = @import("../theme.zig");
+const unicode = @import("../unicode.zig");
+
+const AxisOptions = chart_mod.AxisOptions;
+const ChartPlotOptions = chart_mod.ChartPlotOptions;
+const ChartSeries = chart_mod.ChartSeries;
+const Color = color_mod.Color;
+const Surface = surface_mod.Surface;
+const roundHalfUp = color_mod.roundHalfUp;
+const seriesColor = theme_mod.seriesColor;
+
+pub const ChartOptions = struct {
+ series: []const ChartSeries = &.{},
+ plot: ChartPlotOptions = .{},
+ /// Numbers down the left edge.
+ axis: bool = false,
+ axis_color: ?Color = null,
+ legend: bool = false,
+ legend_align: unicode.Align = .left,
+};
+
+/// One evenly spaced tick value, ends included.
+///
+/// Two ticks means the ends and nothing else, which is what an axis wants when
+/// there is no room to say more.
+fn tickAt(min: f64, max: f64, i: usize, count: usize) f64 {
+ const n = @max(2, count);
+ return min + (max - min) * @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n - 1));
+}
+
+fn formatWith(axis: ?AxisOptions, buf: []u8, value: f64) []const u8 {
+ if (axis) |a| {
+ if (a.format) |format| return format(value, buf);
+ }
+ return meters.niceLabel(buf, value);
+}
+
+pub fn drawChart(allocator: std.mem.Allocator, s: Surface, options: ChartOptions) !void {
+ if (s.isEmpty()) return;
+ const theme = s.theme;
+ const axis_color = options.axis_color orelse theme.muted;
+
+ const xd = chart_mod.domainOf(options.series, options.plot.x, 0);
+ const yd = chart_mod.domainOf(options.series, options.plot.y, 1);
+
+ // The x labels take a row, and they can only take one when there is a row
+ // to spare -- a two-row chart is all plot.
+ var x_ticks: usize = if (options.axis) 2 else 0;
+ if (options.plot.x) |a| {
+ if (a.ticks > 0) x_ticks = a.ticks;
+ }
+ const want_x_axis = options.axis and x_ticks >= 2 and s.height() > 2;
+
+ var plot_surface = s;
+ if (options.axis) {
+ var hi_buf: [32]u8 = undefined;
+ var lo_buf: [32]u8 = undefined;
+ const hi = formatWith(options.plot.y, &hi_buf, yd.max);
+ const lo = formatWith(options.plot.y, &lo_buf, yd.min);
+ const width = @max(unicode.stringWidth(hi), unicode.stringWidth(lo)) + 1;
+
+ var padded: [64]u8 = undefined;
+ _ = s.text(0, 0, unicode.fit(&padded, hi, width, .right), .{ .fg = axis_color });
+ if (s.height() > 1) {
+ var padded_lo: [64]u8 = undefined;
+ // The minimum marks the bottom of the plot, which is a row higher
+ // when the x labels have taken the last one.
+ const bottom: isize = if (want_x_axis)
+ @intCast(s.height() - 2)
+ else
+ @intCast(s.height() - 1);
+ _ = s.text(0, bottom, unicode.fit(&padded_lo, lo, width, .right), .{ .fg = axis_color });
+ }
+ plot_surface = s.sub(@intCast(width), 0, s.width() -| width, s.height());
+ }
+
+ var area = plot_surface;
+ if (want_x_axis and plot_surface.height() > 1 and plot_surface.width() > 0) {
+ area = plot_surface.sub(0, 0, plot_surface.width(), plot_surface.height() - 1);
+ const row: isize = @intCast(plot_surface.height() - 1);
+ const step: f64 = if (x_ticks > 1)
+ @as(f64, @floatFromInt(plot_surface.width() - 1)) / @as(f64, @floatFromInt(x_ticks - 1))
+ else
+ 0;
+ for (0..x_ticks) |i| {
+ var buf: [32]u8 = undefined;
+ const label = formatWith(options.plot.x, &buf, tickAt(xd.min, xd.max, i, x_ticks));
+ // The last label is right-aligned to the edge, so it cannot run off it.
+ const at: isize = @intFromFloat(roundHalfUp(@as(f64, @floatFromInt(i)) * step));
+ const limit: isize = @as(isize, @intCast(plot_surface.width())) -
+ @as(isize, @intCast(unicode.stringWidth(label)));
+ _ = plot_surface.text(@max(0, @min(at, limit)), row, label, .{ .fg = axis_color });
+ }
+ }
+
+ // The domain is resolved once and handed down, so the labels and the marks
+ // cannot disagree about what the axis spans.
+ var plot = options.plot;
+ var x_axis = options.plot.x orelse AxisOptions{};
+ x_axis.min = xd.min;
+ x_axis.max = xd.max;
+ var y_axis = options.plot.y orelse AxisOptions{};
+ y_axis.min = yd.min;
+ y_axis.max = yd.max;
+ plot.x = x_axis;
+ plot.y = y_axis;
+ try chart_mod.plotPoints(allocator, area, options.series, plot);
+
+ if (options.legend) {
+ var total: usize = 0;
+ for (options.series) |cs| {
+ if (cs.label.len > 0) total += unicode.stringWidth(cs.label) + 3;
+ }
+ var x: isize = if (options.legend_align == .right)
+ @max(0, @as(isize, @intCast(area.width())) - @as(isize, @intCast(total)))
+ else
+ 0;
+ const y: isize = if (area.height() > 3) @intCast(area.height() - 1) else 0;
+ for (options.series, 0..) |cs, i| {
+ if (cs.label.len == 0) continue;
+ const color = cs.color orelse seriesColor(theme.*, @intCast(i));
+ x += @intCast(area.text(x, y, "■ ", .{ .fg = color }));
+ var buf: [128]u8 = undefined;
+ const label = std.fmt.bufPrint(&buf, "{s} ", .{cs.label}) catch cs.label;
+ x += @intCast(area.text(x, y, label, .{ .fg = theme.muted }));
+ }
+ }
+}