From 0ae3f40e2fd48dd56591101ae2f7b3bfdd7a67ad Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 8 Sep 2026 23:40:14 +0000 Subject: [PATCH] A canvas you draw on in your own coordinates The third slice of #64, and the one the issue calls the most valuable. `BrailleCanvas` works in pixels. The primitives are good, but the caller does every unit conversion, which means a drawing written for one panel size is wrong in the next -- and there is no way to say "this line runs from (0, 0) to (10, 10) in my units" without doing the arithmetic by hand every frame. ui.shapes({ shapes: [ { type: "rect", x: 1, y: 1, width: 4, height: 4 }, { type: "circle", x: 7, y: 5, radius: 2 }, { type: "polyline", points: [[0, 8], [3, 9], [6, 7], [9, 9]] }, ], x: { min: 0, max: 10 }, y: { min: 0, max: 10 }, }); Bounds per axis, five shapes placed inside them, and a colour per shape. The existing `canvas` is untouched: it is still there for anyone who wants the pixel grid. Four decisions worth the words: Y goes up. A canvas is for drawing things that have their own geometry -- a plot, a map, a diagram -- and making the caller flip every y would be handing them back the conversion this exists to take away. There is a test asserting that y = max lands on the top row. A radius is scaled, not projected. It is a distance rather than a position, and the two axes rarely scale alike in a terminal cell, so a circle is measured against the x span. A rectangle is a corner and a size in the caller's own direction, so a positive height goes up, because their y does. Each shape is blitted before the next is drawn. A shared canvas would let the last colour win everywhere two shapes overlap; a test renders two lines in two colours and counts them. The C++ Braille class had only `pixel`, `line` and `blit`, so `rect`, `fill_rect`, `circle`, `hline`, `vline` and the span helper are new there, written against the reference rather than invented -- which is why all five canvas fixtures matched on the first run. Six ports, five fixtures, all additive. Not added to the widget gallery: the existing canvas is not in it either, because it is a graphics primitive rather than a widget, and `plot` is in the same position. Verified: 331 TS tests under bun and node; 86 widget scenes matching the reference in Rust, Go, Python, Zig and C++; 11/11 ctest; the galleries; the site builds. Part of #64 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy --- packages/hqtui/src/graphics/canvas.ts | 171 ++ packages/hqtui/src/graphics/index.ts | 1 + packages/hqtui/src/index.ts | 4 +- packages/hqtui/src/ui.ts | 12 + packages/hqtui/test/canvas.test.ts | 143 ++ ports/conformance/fixtures/widgets.json | 1556 +++++++++++++++++ ports/conformance/generate.ts | Bin 47711 -> 49059 bytes ports/cpp/CMakeLists.txt | 2 +- ports/cpp/include/hqtui/widgets.hpp | 109 ++ ports/cpp/src/canvas.cpp | 139 ++ ports/cpp/tests/conformance_widgets.cpp | 56 + ports/go/canvas.go | 217 +++ ports/go/conformance_widgets_test.go | 30 + ports/go/ui.go | 9 + ports/python/hqtui/graphics/__init__.py | 16 + ports/python/hqtui/graphics/canvas.py | 203 +++ ports/python/hqtui/ui.py | 13 + .../python/tests/test_conformance_widgets.py | 31 + ports/rust/src/graphics/canvas.rs | 222 +++ ports/rust/src/graphics/mod.rs | 4 + ports/rust/src/ui.rs | 10 + ports/rust/tests/conformance_widgets.rs | 59 + ports/zig/src/conformance_widgets.zig | 39 + ports/zig/src/graphics.zig | 7 + ports/zig/src/graphics/canvas.zig | 207 +++ ports/zig/src/ui.zig | 11 + 26 files changed, 3268 insertions(+), 3 deletions(-) create mode 100644 packages/hqtui/src/graphics/canvas.ts create mode 100644 packages/hqtui/test/canvas.test.ts create mode 100644 ports/cpp/src/canvas.cpp create mode 100644 ports/go/canvas.go create mode 100644 ports/python/hqtui/graphics/canvas.py create mode 100644 ports/rust/src/graphics/canvas.rs create mode 100644 ports/zig/src/graphics/canvas.zig diff --git a/packages/hqtui/src/graphics/canvas.ts b/packages/hqtui/src/graphics/canvas.ts new file mode 100644 index 0000000..691d0c8 --- /dev/null +++ b/packages/hqtui/src/graphics/canvas.ts @@ -0,0 +1,171 @@ +/** + * A canvas you can draw on in your own coordinates. + * + * `BrailleCanvas` works in pixels: good primitives, but the caller does every + * unit conversion, and a drawing written for one panel size is wrong in the + * next. This wraps it with a domain per axis and a list of shapes placed in + * that domain, so the same drawing fits whatever region it is given. + * + * Y increases upwards, as it does on paper and in every plot, rather than + * downwards as it does in a terminal. A canvas is for drawing things that have + * their own geometry; making the caller flip every y would be handing them back + * the conversion this exists to take away. + */ +import type { Surface } from "../surface.ts"; +import { type Color, mix } from "../color.ts"; +import { BrailleCanvas } from "./braille.ts"; +import { blit } from "./plot.ts"; + +export interface Bounds { + min: number; + max: number; +} + +export type Shape = + | { type: "line"; x1: number; y1: number; x2: number; y2: number; color?: Color } + | { type: "polyline"; points: [number, number][]; color?: Color } + | { type: "points"; points: [number, number][]; color?: Color } + | { type: "circle"; x: number; y: number; radius: number; color?: Color } + | { + type: "rect"; + x: number; + y: number; + width: number; + height: number; + color?: Color; + fill?: boolean; + }; + +export interface CanvasOptions { + shapes: Shape[]; + /** The span the drawing is in. Defaults to 0-1 on both axes. */ + x?: Bounds; + y?: Bounds; + /** Colour for shapes that do not name their own. */ + color?: Color; + background?: Color; + /** A faint dotted grid behind the shapes. */ + grid?: boolean; + gridColor?: Color; +} + +const UNIT: Bounds = { min: 0, max: 1 }; + +/** A usable span: a zero-width one cannot be mapped onto anything. */ +function span(bounds: Bounds | undefined): Bounds { + if (!bounds) return UNIT; + const { min, max } = bounds; + if (!Number.isFinite(min) || !Number.isFinite(max) || !(max > min)) return UNIT; + return { min, max }; +} + +/** + * A projection from the caller's coordinates onto the canvas's pixels. + * + * Handed out so a caller can place their own labels against the same drawing: + * a chart axis or a map legend has to agree with the shapes, and re-deriving + * the mapping by hand is exactly the arithmetic this is here to remove. + */ +export interface Projection { + x(value: number): number; + y(value: number): number; +} + +export function projection( + canvas: BrailleCanvas, + xBounds: Bounds, + yBounds: Bounds, +): Projection { + const width = Math.max(1, canvas.width - 1); + const height = Math.max(1, canvas.height - 1); + return { + x: (value) => ((value - xBounds.min) / (xBounds.max - xBounds.min)) * width, + // Flipped: the caller's y goes up, the canvas's goes down. + y: (value) => (1 - (value - yBounds.min) / (yBounds.max - yBounds.min)) * height, + }; +} + +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 }); + } +} + +/** Whether a shape has anything finite to draw. */ +function usable(shape: Shape): boolean { + const finite = (...values: number[]) => values.every((v) => Number.isFinite(v)); + switch (shape.type) { + case "line": + return finite(shape.x1, shape.y1, shape.x2, shape.y2); + case "circle": + return finite(shape.x, shape.y, shape.radius); + case "rect": + return finite(shape.x, shape.y, shape.width, shape.height); + default: + return shape.points.some((p) => finite(p[0], p[1])); + } +} + +export function drawCanvas(surface: Surface, options: CanvasOptions): void { + if (surface.empty || options.shapes.length === 0) return; + const theme = surface.theme; + const bg = options.background; + const xb = span(options.x); + const yb = span(options.y); + + if (options.grid) { + drawGrid(surface, options.gridColor ?? mix(theme.border, theme.background, 0.4), bg); + } + + const canvas = new BrailleCanvas(surface.width, surface.height); + const at = projection(canvas, xb, yb); + const base = options.color ?? theme.accent; + + // One shape at a time, blitted before the next is drawn, so each keeps its + // own colour. A shared canvas would make the last colour win everywhere the + // shapes overlap. + for (const shape of options.shapes) { + if (!usable(shape)) continue; + canvas.clear(); + switch (shape.type) { + case "line": + canvas.line(at.x(shape.x1), at.y(shape.y1), at.x(shape.x2), at.y(shape.y2)); + break; + case "polyline": { + const points = shape.points + .filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1])) + .map((p) => [at.x(p[0]), at.y(p[1])] as [number, number]); + if (points.length === 1) canvas.pixel(points[0][0], points[0][1]); + else canvas.polyline(points); + break; + } + case "points": + for (const [px, py] of shape.points) { + if (Number.isFinite(px) && Number.isFinite(py)) canvas.pixel(at.x(px), at.y(py)); + } + break; + case "circle": { + // A radius is a distance, not a position, so it is scaled by the span + // rather than projected. The two axes rarely scale alike in a terminal + // cell, and the x one is what a circle is measured against. + const scale = Math.max(1, canvas.width - 1) / (xb.max - xb.min); + canvas.circle(at.x(shape.x), at.y(shape.y), Math.abs(shape.radius) * scale); + break; + } + case "rect": { + // Given as a corner and a size, in the caller's own direction: a + // positive height goes up, because their y does. + const x0 = at.x(shape.x); + const x1 = at.x(shape.x + shape.width); + const y0 = at.y(shape.y); + const y1 = at.y(shape.y + shape.height); + if (shape.fill) canvas.fillRect(x0, Math.min(y0, y1), x1, Math.max(y0, y1)); + else canvas.rect(x0, Math.min(y0, y1), x1, Math.max(y0, y1)); + break; + } + } + const color = shape.color ?? base; + blit(surface, canvas, () => color, bg); + } +} diff --git a/packages/hqtui/src/graphics/index.ts b/packages/hqtui/src/graphics/index.ts index cc14656..3feeadb 100644 --- a/packages/hqtui/src/graphics/index.ts +++ b/packages/hqtui/src/graphics/index.ts @@ -1,4 +1,5 @@ export { BrailleCanvas } from "./braille.ts"; export * from "./blocks.ts"; export * from "./plot.ts"; +export * from "./canvas.ts"; export * from "./chart.ts"; diff --git a/packages/hqtui/src/index.ts b/packages/hqtui/src/index.ts index 37e8187..7de353c 100644 --- a/packages/hqtui/src/index.ts +++ b/packages/hqtui/src/index.ts @@ -70,10 +70,10 @@ export { BrailleCanvas } from "./graphics/braille.ts"; export { plot, blit, sparkline, bar, gauge, donut, histogram, verticalGlyph, horizontalGlyph, shadeGlyph, bestMode, - plotPoints, domainOf, + plotPoints, domainOf, drawCanvas, projection, type Series, type PlotOptions, type FillMode, type Point, type MarkType, type ChartSeries, type AxisOptions, type ChartPlotOptions, - type Domain, + type Domain, type Shape, type Bounds, type CanvasOptions, type Projection, } from "./graphics/index.ts"; // Widgets (for drawing straight onto a Surface) diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts index b35fbd0..f35d4eb 100644 --- a/packages/hqtui/src/ui.ts +++ b/packages/hqtui/src/ui.ts @@ -9,6 +9,7 @@ import { import { stringWidth, wrap } from "./unicode.ts"; import { isRich, toSpanLines, wrapRich, type RichText } from "./richtext.ts"; import { BrailleCanvas } from "./graphics/braille.ts"; +import { drawCanvas, type CanvasOptions } from "./graphics/canvas.ts"; import * as W from "./widgets/index.ts"; export interface HitRegion { @@ -619,6 +620,17 @@ export class Container { return this.add(fn, this.sizeOf(options, "fill")); } + /** + * A canvas drawn in your own coordinates rather than in pixels. + * + * `canvas` hands you the pixel grid and leaves the unit conversion to you, + * which means a drawing written for one panel size is wrong in the next. + * This takes bounds and shapes placed inside them, and y goes up. + */ + shapes(options: CanvasOptions & ContainerOptions): this { + return this.add((s) => drawCanvas(s, options), this.sizeOf(options, "fill")); + } + /** A Braille pixel canvas sized to the region, blitted when you are done. */ canvas( fn: (canvas: BrailleCanvas, surface: Surface) => void, diff --git a/packages/hqtui/test/canvas.test.ts b/packages/hqtui/test/canvas.test.ts new file mode 100644 index 0000000..5d5890e --- /dev/null +++ b/packages/hqtui/test/canvas.test.ts @@ -0,0 +1,143 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderToScreen } from "../src/index.ts"; +import { BrailleCanvas } from "../src/graphics/braille.ts"; +import { projection } from "../src/graphics/canvas.ts"; +import type { CanvasOptions } from "../src/graphics/canvas.ts"; + +const rows = (options: CanvasOptions, width = 20, height = 6): string[] => + renderToScreen(({ ui }) => ui.shapes(options), { width, height }) + .text().split("\n").map((line) => line.trimEnd()); + +/** Which rows carry ink, top first. */ +const inkedRows = (lines: string[]): number[] => + lines.map((line, i) => (line.trim().length > 0 ? i : -1)).filter((i) => i >= 0); + +const inkedColumns = (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); +}; + +test("canvas: y goes up, as it does on paper", () => { + // The whole reason this exists: a caller drawing a graph or a map should not + // have to flip every coordinate because a terminal counts rows downwards. + const low = rows({ shapes: [{ type: "points", points: [[0.5, 0]] }], x: { min: 0, max: 1 }, y: { min: 0, max: 1 } }); + const high = rows({ shapes: [{ type: "points", points: [[0.5, 1]] }], x: { min: 0, max: 1 }, y: { min: 0, max: 1 } }); + assert.deepEqual(inkedRows(high), [0], "y = max is the top row"); + assert.deepEqual(inkedRows(low), [5], "y = min is the bottom row"); +}); + +test("canvas: the same drawing fits whatever region it is given", () => { + // A drawing in its own coordinates is size-independent; that is the point. + const shapes: CanvasOptions = { + shapes: [{ type: "line", x1: 0, y1: 0, x2: 10, y2: 10 }], + x: { min: 0, max: 10 }, + y: { min: 0, max: 10 }, + }; + for (const [w, h] of [[20, 6], [40, 12], [11, 3]]) { + const lines = rows(shapes, w, h); + const columns = inkedColumns(lines); + assert.equal(columns[0], 0, `${w}x${h} did not start at the left edge`); + assert.equal(columns[columns.length - 1], w - 1, `${w}x${h} did not reach the right edge`); + // A diagonal: the top row has ink and so does the bottom. + assert.ok(lines[0].trim().length > 0, `${w}x${h} missed the top`); + assert.ok(lines[h - 1].trim().length > 0, `${w}x${h} missed the bottom`); + } +}); + +test("canvas: bounds place a shape, they do not merely scale it", () => { + // The same point under two different domains lands in two different places. + const shapes: CanvasOptions["shapes"] = [{ type: "points", points: [[1, 0.5]] }]; + const near = inkedColumns(rows({ shapes, x: { min: 0, max: 2 }, y: { min: 0, max: 1 } })); + const far = inkedColumns(rows({ shapes, x: { min: 0, max: 100 }, y: { min: 0, max: 1 } })); + assert.ok(near[0] > far[0], `expected the wider domain to push it left: ${near} vs ${far}`); + assert.equal(far[0], 0, "1 in a domain of 100 is at the left edge"); +}); + +test("canvas: a zero-width domain falls back rather than dividing by it", () => { + // Every point would map to the same place, and the division would blow up. + const lines = rows({ + shapes: [{ type: "points", points: [[0.5, 0.5]] }], + x: { min: 5, max: 5 }, + y: { min: 0, max: 1 }, + }); + assert.ok(lines.join("").trim().length >= 0, "did not throw"); + const nan = rows({ + shapes: [{ type: "points", points: [[0.5, 0.5]] }], + x: { min: Number.NaN, max: 1 }, + }); + assert.ok(nan.join("").trim().length > 0, "a broken bound left nothing drawn"); +}); + +test("canvas: a rectangle is a corner and a size, in the caller's direction", () => { + // A positive height goes up, because the caller's y does. + const outline = rows({ + shapes: [{ type: "rect", x: 0.2, y: 0.2, width: 0.6, height: 0.6 }], + x: { min: 0, max: 1 }, + y: { min: 0, max: 1 }, + }, 20, 6); + const filled = rows({ + shapes: [{ type: "rect", x: 0.2, y: 0.2, width: 0.6, height: 0.6, fill: true }], + x: { min: 0, max: 1 }, + y: { min: 0, max: 1 }, + }, 20, 6); + const ink = (lines: string[]) => lines.join("").replace(/\s/g, "").length; + assert.ok(ink(outline) > 0, "the outline drew nothing"); + assert.ok(ink(filled) > ink(outline), "the fill was no denser than the outline"); +}); + +test("canvas: shapes keep their own colours", () => { + const screen = renderToScreen( + ({ ui, theme }) => ui.shapes({ + shapes: [ + { type: "line", x1: 0, y1: 0, x2: 1, y2: 0, color: theme.danger }, + { type: "line", x1: 0, y1: 1, x2: 1, y2: 1, color: theme.success }, + ], + x: { min: 0, max: 1 }, + y: { min: 0, max: 1 }, + }), + { width: 20, height: 4 }, + ); + const colours = new Set( + [...screen.buffer.chars].map((c, i) => (c !== 0 && c !== 32 ? screen.buffer.fg[i] : -1)), + ); + colours.delete(-1); + // Two shapes, two colours: a shared canvas would let the last one win. + assert.equal(colours.size, 2, `expected two colours, got ${[...colours]}`); +}); + +test("canvas: a shape with nothing finite in it is skipped, not drawn at zero", () => { + const good = rows({ + shapes: [{ type: "line", x1: 0, y1: 0, x2: 1, y2: 1 }], + x: { min: 0, max: 1 }, + y: { min: 0, max: 1 }, + }); + const withJunk = rows({ + shapes: [ + { type: "line", x1: Number.NaN, y1: 0, x2: 1, y2: 1 }, + { type: "line", x1: 0, y1: 0, x2: 1, y2: 1 }, + ], + x: { min: 0, max: 1 }, + y: { min: 0, max: 1 }, + }); + assert.deepEqual(withJunk, good); +}); + +test("canvas: the projection is the one the shapes were drawn with", () => { + // Handed out so a caller can put their own labels against the same drawing. + const canvas = new BrailleCanvas(10, 4); + const at = projection(canvas, { min: 0, max: 10 }, { min: 0, max: 10 }); + assert.equal(at.x(0), 0); + assert.equal(at.x(10), canvas.width - 1); + // Flipped: the caller's maximum is the canvas's row zero. + assert.equal(at.y(10), 0); + assert.equal(at.y(0), canvas.height - 1); +}); + +test("canvas: an empty shape list draws nothing at all", () => { + const lines = rows({ shapes: [] }); + assert.deepEqual(lines, ["", "", "", "", "", ""]); +}); diff --git a/ports/conformance/fixtures/widgets.json b/ports/conformance/fixtures/widgets.json index 8138101..af28aec 100644 --- a/ports/conformance/fixtures/widgets.json +++ b/ports/conformance/fixtures/widgets.json @@ -3416,6 +3416,1562 @@ ] } }, + { + "name": "canvas-line", + "width": 24, + "height": 6, + "result": { + "width": 24, + "height": 6, + "chars": [ + [ + 20, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 16, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 16, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 16, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 16, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 16, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 20, + 32 + ] + ], + "fg": [ + [ + 20, + 29806811 + ], + [ + 4, + 22467805 + ], + [ + 16, + 29806811 + ], + [ + 4, + 22467805 + ], + [ + 16, + 29806811 + ], + [ + 4, + 22467805 + ], + [ + 16, + 29806811 + ], + [ + 4, + 22467805 + ], + [ + 16, + 29806811 + ], + [ + 4, + 22467805 + ], + [ + 16, + 29806811 + ], + [ + 4, + 22467805 + ], + [ + 20, + 29806811 + ] + ], + "bg": [ + [ + 144, + 17106698 + ] + ], + "attrs": [ + [ + 144, + 0 + ] + ], + "clusters": [], + "text": [ + " ⣀⠤⠒⠉", + " ⣀⠤⠒⠉ ", + " ⣀⠤⠒⠉ ", + " ⣀⠤⠒⠉ ", + " ⣀⠤⠒⠉ ", + "⣀⠤⠒⠉ " + ] + } + }, + { + "name": "canvas-shapes", + "width": 30, + "height": 8, + "result": { + "width": 30, + "height": 8, + "chars": [ + [ + 3, + 32 + ], + [ + 1, + 10304 + ], + [ + 3, + 32 + ], + [ + 1, + 10368 + ], + [ + 2, + 10432 + ], + [ + 16, + 32 + ], + [ + 1, + 10432 + ], + [ + 3, + 32 + ], + [ + 1, + 10276 + ], + [ + 1, + 10260 + ], + [ + 2, + 10258 + ], + [ + 1, + 10250 + ], + [ + 2, + 10249 + ], + [ + 1, + 10241 + ], + [ + 2, + 32 + ], + [ + 1, + 10249 + ], + [ + 1, + 10257 + ], + [ + 1, + 10258 + ], + [ + 1, + 10276 + ], + [ + 1, + 10432 + ], + [ + 1, + 10304 + ], + [ + 1, + 10368 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 1, + 10432 + ], + [ + 1, + 10336 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10250 + ], + [ + 1, + 10249 + ], + [ + 19, + 32 + ], + [ + 1, + 10248 + ], + [ + 1, + 10249 + ], + [ + 1, + 10258 + ], + [ + 1, + 10250 + ], + [ + 1, + 10249 + ], + [ + 5, + 32 + ], + [ + 1, + 10289 + ], + [ + 1, + 10304 + ], + [ + 17, + 32 + ], + [ + 1, + 10416 + ], + [ + 1, + 10241 + ], + [ + 10, + 32 + ], + [ + 1, + 10417 + ], + [ + 6, + 32 + ], + [ + 1, + 10319 + ], + [ + 10, + 10249 + ], + [ + 1, + 10424 + ], + [ + 1, + 10311 + ], + [ + 10, + 32 + ], + [ + 1, + 10424 + ], + [ + 6, + 32 + ], + [ + 1, + 10311 + ], + [ + 11, + 32 + ], + [ + 1, + 10403 + ], + [ + 9, + 32 + ], + [ + 1, + 10400 + ], + [ + 1, + 10243 + ], + [ + 6, + 32 + ], + [ + 1, + 10311 + ], + [ + 11, + 32 + ], + [ + 1, + 10311 + ], + [ + 1, + 10257 + ], + [ + 1, + 10276 + ], + [ + 1, + 10432 + ], + [ + 3, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10336 + ], + [ + 1, + 10260 + ], + [ + 1, + 10241 + ], + [ + 7, + 32 + ], + [ + 12, + 10249 + ], + [ + 4, + 32 + ], + [ + 3, + 10249 + ], + [ + 1, + 10241 + ], + [ + 3, + 32 + ], + [ + 1, + 10248 + ], + [ + 3, + 32 + ] + ], + "fg": [ + [ + 3, + 29806811 + ], + [ + 1, + 22467805 + ], + [ + 3, + 29806811 + ], + [ + 3, + 22467805 + ], + [ + 16, + 29806811 + ], + [ + 1, + 22467805 + ], + [ + 3, + 29806811 + ], + [ + 8, + 22467805 + ], + [ + 2, + 29806811 + ], + [ + 16, + 22467805 + ], + [ + 19, + 29806811 + ], + [ + 5, + 22467805 + ], + [ + 5, + 29806811 + ], + [ + 2, + 22467805 + ], + [ + 17, + 29806811 + ], + [ + 2, + 22467805 + ], + [ + 10, + 29806811 + ], + [ + 1, + 22467805 + ], + [ + 6, + 29806811 + ], + [ + 13, + 22467805 + ], + [ + 10, + 29806811 + ], + [ + 1, + 22467805 + ], + [ + 6, + 29806811 + ], + [ + 1, + 22467805 + ], + [ + 11, + 29806811 + ], + [ + 1, + 22467805 + ], + [ + 9, + 29806811 + ], + [ + 2, + 22467805 + ], + [ + 6, + 29806811 + ], + [ + 1, + 22467805 + ], + [ + 11, + 29806811 + ], + [ + 4, + 22467805 + ], + [ + 3, + 29806811 + ], + [ + 4, + 22467805 + ], + [ + 7, + 29806811 + ], + [ + 12, + 22467805 + ], + [ + 4, + 29806811 + ], + [ + 4, + 22467805 + ], + [ + 3, + 29806811 + ], + [ + 1, + 22467805 + ], + [ + 3, + 29806811 + ] + ], + "bg": [ + [ + 240, + 17106698 + ] + ], + "attrs": [ + [ + 240, + 0 + ] + ], + "clusters": [], + "text": [ + " ⡀ ⢀⣀⣀ ⣀ ", + "⠤⠔⠒⠒⠊⠉⠉⠁ ⠉⠑⠒⠤⣀⡀⢀⠤⠒⠉⣀⡠⠤⠒⠊⠉ ", + " ⠈⠉⠒⠊⠉ ⠱⡀ ", + " ⢰⠁ ⢱ ", + " ⡏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⢸⡇ ⢸ ", + " ⡇ ⢣ ⢠⠃ ", + " ⡇ ⡇⠑⠤⣀ ⢀⡠⠔⠁ ", + " ⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉ ⠉⠉⠉⠁ ⠈ " + ] + } + }, + { + "name": "canvas-filled", + "width": 24, + "height": 6, + "result": { + "width": 24, + "height": 6, + "chars": [ + [ + 29, + 32 + ], + [ + 14, + 10486 + ], + [ + 10, + 32 + ], + [ + 14, + 10495 + ], + [ + 10, + 32 + ], + [ + 14, + 10495 + ], + [ + 10, + 32 + ], + [ + 14, + 10303 + ], + [ + 29, + 32 + ] + ], + "fg": [ + [ + 29, + 29806811 + ], + [ + 14, + 22467805 + ], + [ + 10, + 29806811 + ], + [ + 14, + 22467805 + ], + [ + 10, + 29806811 + ], + [ + 14, + 22467805 + ], + [ + 10, + 29806811 + ], + [ + 14, + 22467805 + ], + [ + 29, + 29806811 + ] + ], + "bg": [ + [ + 144, + 17106698 + ] + ], + "attrs": [ + [ + 144, + 0 + ] + ], + "clusters": [], + "text": [ + " ", + " ⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶ ", + " ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ", + " ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ", + " ⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿ ", + " " + ] + } + }, + { + "name": "canvas-bounds", + "width": 24, + "height": 6, + "result": { + "width": 24, + "height": 6, + "chars": [ + [ + 99, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10336 + ], + [ + 1, + 10260 + ], + [ + 1, + 10242 + ], + [ + 17, + 32 + ], + [ + 1, + 10336 + ], + [ + 1, + 10260 + ], + [ + 1, + 10250 + ], + [ + 1, + 10241 + ], + [ + 20, + 32 + ] + ], + "fg": [ + [ + 99, + 29806811 + ], + [ + 4, + 22467805 + ], + [ + 17, + 29806811 + ], + [ + 4, + 22467805 + ], + [ + 20, + 29806811 + ] + ], + "bg": [ + [ + 144, + 17106698 + ] + ], + "attrs": [ + [ + 144, + 0 + ] + ], + "clusters": [], + "text": [ + " ", + " ", + " ", + " ", + " ⢀⡠⠔⠂ ", + "⡠⠔⠊⠁ " + ] + } + }, + { + "name": "canvas-grid", + "width": 24, + "height": 6, + "result": { + "width": 24, + "height": 6, + "chars": [ + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 25, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 13, + 32 + ], + [ + 1, + 10241 + ], + [ + 11, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 1, + 32 + ], + [ + 1, + 183 + ], + [ + 25, + 32 + ] + ], + "fg": [ + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 25, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 13, + 29806811 + ], + [ + 1, + 22467805 + ], + [ + 11, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 1, + 29806811 + ], + [ + 1, + 18358314 + ], + [ + 25, + 29806811 + ] + ], + "bg": [ + [ + 144, + 17106698 + ] + ], + "attrs": [ + [ + 144, + 0 + ] + ], + "clusters": [], + "text": [ + "· · · · · · · · · · · · ", + " ", + "· · · · · · · · · · · · ", + " ⠁ ", + "· · · · · · · · · · · · ", + " " + ] + } + }, { "name": "badge", "width": 20, diff --git a/ports/conformance/generate.ts b/ports/conformance/generate.ts index f5ee57c6ba96c0436329230ada2b2a80eaa27043..5a65c90d3ac688fb2b57aff11fb5c4d967c74ec4 100644 GIT binary patch delta 954 zcma)5%St0b6lKH*Gh1PVI0Kj77@>T0jE^)Zh<<_#ArqLgyCz-Oscxz(CLuV%r3>St zmM;AP!JRArWIkbLz`b*;x?>VYB)zDrTlBg2o^wzAdYZa@p1OOSo9B%rmI}HMNZLA} z;*6&H@2sP1GaqMt8{R+6(N1q|4fY|s04_j<45KJwaw&zlCBTnk8E`=rO9`~N3ZVn_ zn2S0zlhsdvU+Oc#icXeeCE|iP1WIKBTLg;fGVJb+^iaesT?<2CNRu*4J*aj;brR-* zlcU?N2kQhn+14(3;F{3xa9-1o{V%%EcYBt{ipj%LEV_X|?MzVA*36p#lqfGQnXfQQ z8>?Y4yS8|sLJyEk$ap3lMZHlE<PY{&p6;QEQrcj4?#3!;ENTEuLJ&777oIg*HaKX+XAHGM>q)$( z0v$yo_LjL$;-(0U0PnNdGq%k9HBMt span(double a, double b, int limit) const { + double lo = std::min(a, b), hi = std::max(a, b); + if (!(lo <= hi)) + return {0, -1}; + return {std::max(0, int(std::ceil(lo))), std::min(limit - 1, int(std::floor(hi)))}; + } + void vline(double x, double y0, double y1) { + auto [a, b] = span(y0, y1, h); + for (int y = a; y <= b; y++) + pixel(x, y); + } + void hline(double y, double x0, double x1) { + auto [a, b] = span(x0, x1, w); + for (int x = a; x <= b; x++) + pixel(x, y); + } + void rect(double x0, double y0, double x1, double y1) { + hline(y0, x0, x1); + hline(y1, x0, x1); + vline(x0, y0, y1); + vline(x1, y0, y1); + } + void fill_rect(double x0, double y0, double x1, double y1) { + auto [a, b] = span(y0, y1, h); + for (int y = a; y <= b; y++) + hline(y, x0, x1); + } + void circle(double cx, double cy, double radius) { + // A radius larger than the canvas draws the same arc as one exactly its + // size, and an unbounded one never finishes the `x >= y` walk. + if (std::isnan(radius)) + return; + int x = int(std::floor(std::min(std::abs(radius), double(w + h)) + .5)); + int y = 0, err = 1 - x; + while (x >= y) { + pixel(cx + x, cy + y); + pixel(cx + y, cy + x); + pixel(cx - y, cy + x); + pixel(cx - x, cy + y); + pixel(cx - x, cy - y); + pixel(cx - y, cy - x); + pixel(cx + y, cy - x); + pixel(cx + x, cy - y); + y++; + if (err < 0) { + err += 2 * y + 1; + } else { + x--; + err += 2 * (y - x) + 1; + } + } + } void blit(Surface s, Color color, std::optional bg = {}) const { blit(s, [color](int, int) { return color; }, bg); } @@ -392,6 +447,7 @@ struct Progress { void draw_progress(Surface, const Progress &); void draw_graph(Surface, const Graph &); + extern const char *const MONTH_NAMES[12]; /// Two letters each, so a week is exactly as wide as its days. extern const char *const WEEKDAY_NAMES[7]; @@ -486,6 +542,55 @@ Domain domain_of(const std::vector &, const Axis *, int which); 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 &); + +struct Bounds { + double min = 0, max = 1; +}; +/// Which of the shapes a Shape is. +enum ShapeKind { + HQ_SHAPE_LINE, + HQ_SHAPE_POLYLINE, + HQ_SHAPE_POINTS, + HQ_SHAPE_CIRCLE, + HQ_SHAPE_RECT, +}; +struct Shape { + ShapeKind kind = HQ_SHAPE_LINE; + /// Line: the two ends. Circle and rect: the centre or corner. + double x1 = 0, y1 = 0, x2 = 0, y2 = 0; + /// points and polyline. + std::vector points; + /// circle. + double radius = 0; + /// rect. + double width = 0, height = 0; + bool fill = false; + Color color = 0; +}; +struct Canvas { + std::vector shapes; + /// The span the drawing is in. Unset means 0-1. + std::optional x, y; + /// Colour for shapes that do not name their own. + Color color = 0; + std::optional background; + /// A faint dotted grid behind the shapes. + bool grid = false; + std::optional grid_color; +}; +/// A projection from the caller's coordinates onto the canvas's pixels. +/// +/// Handed out so a caller can place their own labels against the same drawing. +struct Projection { + double width = 0, height = 0; + Bounds x_bounds, y_bounds; + double x(double value) const; + /// Flipped: the caller's y goes up, the canvas's goes down. + double y(double value) const; +}; +Projection canvas_projection(const Braille &, Bounds x, Bounds y); +/// A canvas drawn in the caller's own coordinates rather than in pixels. +void draw_canvas(Surface, const Canvas &); 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. @@ -953,6 +1058,10 @@ class UI { int height = calendar_height(o); draw([=](Surface s) { draw_calendar(s, o); }, cells(height)); } + /// A canvas drawn in your own coordinates rather than in pixels. + void shapes(Canvas o, Constraint size = fr()) { + draw([=](Surface s) { draw_canvas(s, o); }, size); + } void sparkline(Sparkline o) { draw([=](Surface s) { draw_sparkline(s, o); }, cells(1)); } diff --git a/ports/cpp/src/canvas.cpp b/ports/cpp/src/canvas.cpp new file mode 100644 index 0000000..7217994 --- /dev/null +++ b/ports/cpp/src/canvas.cpp @@ -0,0 +1,139 @@ +/// A canvas you can draw on in your own coordinates. +/// +/// `Braille` works in pixels: good primitives, but the caller does every unit +/// conversion, and a drawing written for one panel size is wrong in the next. +/// This wraps it with a domain per axis and a list of shapes placed in that +/// domain, so the same drawing fits whatever region it is given. +/// +/// Y increases upwards, as it does on paper and in every plot, rather than +/// downwards as it does in a terminal. A canvas is for drawing things that have +/// their own geometry; making the caller flip every y would be handing them +/// back the conversion this exists to take away. +#include + +namespace hqtui { +namespace { + +/// A usable span: a zero-width one cannot be mapped onto anything. +Bounds span(const std::optional &bounds) { + if (!bounds || !std::isfinite(bounds->min) || !std::isfinite(bounds->max) || + !(bounds->max > bounds->min)) + return Bounds{0, 1}; + return *bounds; +} + +bool all_finite(std::initializer_list values) { + for (double v : values) + if (!std::isfinite(v)) + return false; + return true; +} + +/// Whether a shape has anything finite to draw. +bool usable(const Shape &s) { + switch (s.kind) { + case HQ_SHAPE_LINE: + return all_finite({s.x1, s.y1, s.x2, s.y2}); + case HQ_SHAPE_CIRCLE: + return all_finite({s.x1, s.y1, s.radius}); + case HQ_SHAPE_RECT: + return all_finite({s.x1, s.y1, s.width, s.height}); + default: + for (auto &p : s.points) + if (all_finite({p.x, p.y})) + return true; + return false; + } +} + +} // namespace + +Projection canvas_projection(const Braille &canvas, Bounds x, Bounds y) { + Projection p; + p.width = double(std::max(2, canvas.w) - 1); + p.height = double(std::max(2, canvas.h) - 1); + p.x_bounds = x; + p.y_bounds = y; + return p; +} + +double Projection::x(double value) const { + return (value - x_bounds.min) / (x_bounds.max - x_bounds.min) * width; +} + +double Projection::y(double value) const { + double at = (value - y_bounds.min) / (y_bounds.max - y_bounds.min); + return (1 - at) * height; +} + +void draw_canvas(Surface s, const Canvas &o) { + int sw = s.rect().width, sh = s.rect().height; + if (sw <= 0 || sh <= 0 || o.shapes.empty()) + return; + auto &t = theme(s); + Bounds xb = span(o.x), yb = span(o.y); + + if (o.grid) { + Color color = o.grid_color ? *o.grid_color : hq_mix(t.border, t.background, .4); + for (int y = 0; y < sh; y += std::max(2, sh / 4)) + for (int x = 0; x < sw; x += 2) + s.set(x, y, 0xb7, Style().foreground(color)); + } + + Braille canvas(sw, sh); + Projection at = canvas_projection(canvas, xb, yb); + Color base = o.color ? o.color : t.accent; + + // One shape at a time, blitted before the next is drawn, so each keeps its + // own colour. A shared canvas would make the last colour win everywhere the + // shapes overlap. + for (auto &shape : o.shapes) { + if (!usable(shape)) + continue; + std::fill(canvas.dots.begin(), canvas.dots.end(), 0); + switch (shape.kind) { + case HQ_SHAPE_LINE: + canvas.line(at.x(shape.x1), at.y(shape.y1), at.x(shape.x2), at.y(shape.y2)); + break; + case HQ_SHAPE_POLYLINE: { + std::vector pixels; + for (auto &p : shape.points) + if (all_finite({p.x, p.y})) + pixels.push_back({at.x(p.x), at.y(p.y)}); + 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); + break; + } + case HQ_SHAPE_POINTS: + for (auto &p : shape.points) + if (all_finite({p.x, p.y})) + canvas.pixel(at.x(p.x), at.y(p.y)); + break; + case HQ_SHAPE_CIRCLE: { + // A radius is a distance, not a position, so it is scaled by the span + // rather than projected. The two axes rarely scale alike in a terminal + // cell, and the x one is what a circle is measured against. + double scale = double(std::max(2, canvas.w) - 1) / (xb.max - xb.min); + canvas.circle(at.x(shape.x1), at.y(shape.y1), std::abs(shape.radius) * scale); + break; + } + case HQ_SHAPE_RECT: { + // Given as a corner and a size, in the caller's own direction: a positive + // height goes up, because their y does. + double x0 = at.x(shape.x1), x1 = at.x(shape.x1 + shape.width); + double y0 = at.y(shape.y1), y1 = at.y(shape.y1 + shape.height); + if (shape.fill) + canvas.fill_rect(x0, std::min(y0, y1), x1, std::max(y0, y1)); + else + canvas.rect(x0, std::min(y0, y1), x1, std::max(y0, y1)); + break; + } + } + canvas.blit(s, shape.color ? shape.color : base, o.background); + } +} + +} // namespace hqtui diff --git a/ports/cpp/tests/conformance_widgets.cpp b/ports/cpp/tests/conformance_widgets.cpp index 142c052..677ef7d 100644 --- a/ports/cpp/tests/conformance_widgets.cpp +++ b/ports/cpp/tests/conformance_widgets.cpp @@ -159,6 +159,62 @@ bool draw_scene(const std::string &name, Surface s) { draw_calendar(s, c); return true; } + if (name == "canvas-line") { + Canvas c; + c.shapes = {Shape{HQ_SHAPE_LINE, 0, 0, 10, 10}}; + c.x = Bounds{0, 10}; + c.y = Bounds{0, 10}; + draw_canvas(s, c); + return true; + } + if (name == "canvas-shapes") { + Canvas c; + Shape rect{HQ_SHAPE_RECT, 1, 1}; + rect.width = 4; + rect.height = 4; + Shape circle{HQ_SHAPE_CIRCLE, 7, 5}; + circle.radius = 2; + Shape poly{HQ_SHAPE_POLYLINE}; + poly.points = {{0, 8}, {3, 9}, {6, 7}, {9, 9}}; + Shape points{HQ_SHAPE_POINTS}; + points.points = {{1, 9}, {9, 1}}; + c.shapes = {rect, circle, poly, points}; + c.x = Bounds{0, 10}; + c.y = Bounds{0, 10}; + draw_canvas(s, c); + return true; + } + if (name == "canvas-filled") { + Canvas c; + Shape rect{HQ_SHAPE_RECT, 2, 2}; + rect.width = 6; + rect.height = 6; + rect.fill = true; + c.shapes = {rect}; + c.x = Bounds{0, 10}; + c.y = Bounds{0, 10}; + draw_canvas(s, c); + return true; + } + if (name == "canvas-bounds") { + Canvas c; + c.shapes = {Shape{HQ_SHAPE_LINE, 0, 0, 10, 10}}; + c.x = Bounds{0, 40}; + c.y = Bounds{0, 40}; + draw_canvas(s, c); + return true; + } + if (name == "canvas-grid") { + Canvas c; + Shape points{HQ_SHAPE_POINTS}; + points.points = {{5, 5}}; + c.shapes = {points}; + c.x = Bounds{0, 10}; + c.y = Bounds{0, 10}; + c.grid = true; + draw_canvas(s, c); + return true; + } if (name == "badge") { { Badge badge; diff --git a/ports/go/canvas.go b/ports/go/canvas.go new file mode 100644 index 0000000..b1ae1c5 --- /dev/null +++ b/ports/go/canvas.go @@ -0,0 +1,217 @@ +package hqtui + +import "math" + +// A canvas you can draw on in your own coordinates. +// +// BrailleCanvas works in pixels: good primitives, but the caller does every +// unit conversion, and a drawing written for one panel size is wrong in the +// next. This wraps it with a domain per axis and a list of shapes placed in +// that domain, so the same drawing fits whatever region it is given. +// +// Y increases upwards, as it does on paper and in every plot, rather than +// downwards as it does in a terminal. A canvas is for drawing things that have +// their own geometry; making the caller flip every y would be handing them back +// the conversion this exists to take away. + +type Bounds struct{ Min, Max float64 } + +// ShapeKind is which of the shapes a Shape is. +type ShapeKind int + +const ( + ShapeLine ShapeKind = iota + ShapePolyline + ShapePoints + ShapeCircle + ShapeRect +) + +type Shape struct { + Kind ShapeKind + // Line: the two ends. Circle and Rect: the centre or corner in X, Y. + X1, Y1, X2, Y2 float64 + // Points and Polyline. + Points []Point + // Circle. + Radius float64 + // Rect. + Width, Height float64 + Fill bool + Color *Color +} + +type CanvasOptions struct { + Shapes []Shape + // X and Y are the span the drawing is in. Nil means 0-1. + X *Bounds + Y *Bounds + // Color is for shapes that do not name their own. + Color *Color + Background *Color + // Grid draws a faint dotted grid behind the shapes. + Grid bool + GridColor *Color +} + +// canvasSpan is a usable span: a zero-width one cannot be mapped onto anything. +func canvasSpan(b *Bounds) Bounds { + if b == nil || math.IsNaN(b.Min) || math.IsNaN(b.Max) || math.IsInf(b.Min, 0) || + math.IsInf(b.Max, 0) || !(b.Max > b.Min) { + return Bounds{Min: 0, Max: 1} + } + return *b +} + +// Projection maps the caller's coordinates onto the canvas's pixels. +// +// Handed out so a caller can place their own labels against the same drawing: +// a chart axis or a map legend has to agree with the shapes, and re-deriving +// the mapping by hand is exactly the arithmetic this is here to remove. +type Projection struct { + width, height float64 + xBounds, yBounds Bounds +} + +func (p Projection) X(value float64) float64 { + return (value - p.xBounds.Min) / (p.xBounds.Max - p.xBounds.Min) * p.width +} + +// Y is flipped: the caller's y goes up, the canvas's goes down. +func (p Projection) Y(value float64) float64 { + return (1 - (value-p.yBounds.Min)/(p.yBounds.Max-p.yBounds.Min)) * p.height +} + +func NewProjection(canvas *BrailleCanvas, x, y Bounds) Projection { + return Projection{ + width: float64(max(2, canvas.Width) - 1), + height: float64(max(2, canvas.Height) - 1), + xBounds: x, + yBounds: y, + } +} + +// allFinite is spelled out rather than reusing braille.go's finite, which +// answers a different question: that one normalises a single coordinate, this +// one asks whether a whole shape is drawable. +func allFinite(values ...float64) bool { + for _, v := range values { + if math.IsNaN(v) || math.IsInf(v, 0) { + return false + } + } + return true +} + +// usable reports whether a shape has anything finite to draw. +func (s Shape) usable() bool { + switch s.Kind { + case ShapeLine: + return allFinite(s.X1, s.Y1, s.X2, s.Y2) + case ShapeCircle: + return allFinite(s.X1, s.Y1, s.Radius) + case ShapeRect: + return allFinite(s.X1, s.Y1, s.Width, s.Height) + default: + for _, p := range s.Points { + if allFinite(p.X, p.Y) { + return true + } + } + return false + } +} + +func drawCanvasGrid(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}) + } + } +} + +func DrawCanvas(s Surface, o CanvasOptions) { + if s.IsEmpty() || len(o.Shapes) == 0 { + return + } + theme := s.Theme + bg := o.Background + xb := canvasSpan(o.X) + yb := canvasSpan(o.Y) + + if o.Grid { + color := theme.Border.Mix(theme.Background, 0.4) + if o.GridColor != nil { + color = *o.GridColor + } + drawCanvasGrid(s, color, bg) + } + + canvas := NewBrailleCanvas(s.Width(), s.Height()) + at := NewProjection(canvas, xb, yb) + base := theme.Accent + if o.Color != nil { + base = *o.Color + } + + // One shape at a time, blitted before the next is drawn, so each keeps its + // own colour. A shared canvas would make the last colour win everywhere the + // shapes overlap. + for _, shape := range o.Shapes { + if !shape.usable() { + continue + } + canvas.Clear() + switch shape.Kind { + case ShapeLine: + canvas.Line(at.X(shape.X1), at.Y(shape.Y1), at.X(shape.X2), at.Y(shape.Y2)) + case ShapePolyline: + pixels := make([]Point, 0, len(shape.Points)) + for _, p := range shape.Points { + if allFinite(p.X, p.Y) { + pixels = append(pixels, Point{X: at.X(p.X), Y: at.Y(p.Y)}) + } + } + if len(pixels) == 1 { + canvas.Pixel(pixels[0].X, pixels[0].Y) + } else { + canvas.Polyline(pixels) + } + case ShapePoints: + for _, p := range shape.Points { + if allFinite(p.X, p.Y) { + canvas.Pixel(at.X(p.X), at.Y(p.Y)) + } + } + case ShapeCircle: + // A radius is a distance, not a position, so it is scaled by the + // span rather than projected. The two axes rarely scale alike in a + // terminal cell, and the x one is what a circle is measured against. + scale := float64(max(2, canvas.Width)-1) / (xb.Max - xb.Min) + canvas.Circle(at.X(shape.X1), at.Y(shape.Y1), math.Abs(shape.Radius)*scale) + case ShapeRect: + // Given as a corner and a size, in the caller's own direction: a + // positive height goes up, because their y does. + x0 := at.X(shape.X1) + x1 := at.X(shape.X1 + shape.Width) + y0 := at.Y(shape.Y1) + y1 := at.Y(shape.Y1 + shape.Height) + if shape.Fill { + canvas.FillRect(x0, math.Min(y0, y1), x1, math.Max(y0, y1)) + } else { + canvas.Rect(x0, math.Min(y0, y1), x1, math.Max(y0, y1)) + } + } + color := base + if shape.Color != nil { + color = *shape.Color + } + c := color + Blit(s, canvas, func(col, row int) Color { return c }, bg) + } +} diff --git a/ports/go/conformance_widgets_test.go b/ports/go/conformance_widgets_test.go index 0a02aa6..02d0d95 100644 --- a/ports/go/conformance_widgets_test.go +++ b/ports/go/conformance_widgets_test.go @@ -82,6 +82,36 @@ func drawWidgetScene(t *testing.T, name string, s Surface) { Year: 2026, Month: 9, Selected: &eighth, Marks: []CalendarMark{{Day: 15}, {Day: 22, Bold: true}}, }) + case "canvas-line": + DrawCanvas(s, CanvasOptions{ + Shapes: []Shape{{Kind: ShapeLine, X1: 0, Y1: 0, X2: 10, Y2: 10}}, + X: &Bounds{0, 10}, Y: &Bounds{0, 10}, + }) + case "canvas-shapes": + DrawCanvas(s, CanvasOptions{ + Shapes: []Shape{ + {Kind: ShapeRect, X1: 1, Y1: 1, Width: 4, Height: 4}, + {Kind: ShapeCircle, X1: 7, Y1: 5, Radius: 2}, + {Kind: ShapePolyline, Points: []Point{{0, 8}, {3, 9}, {6, 7}, {9, 9}}}, + {Kind: ShapePoints, Points: []Point{{1, 9}, {9, 1}}}, + }, + X: &Bounds{0, 10}, Y: &Bounds{0, 10}, + }) + case "canvas-filled": + DrawCanvas(s, CanvasOptions{ + Shapes: []Shape{{Kind: ShapeRect, X1: 2, Y1: 2, Width: 6, Height: 6, Fill: true}}, + X: &Bounds{0, 10}, Y: &Bounds{0, 10}, + }) + case "canvas-bounds": + DrawCanvas(s, CanvasOptions{ + Shapes: []Shape{{Kind: ShapeLine, X1: 0, Y1: 0, X2: 10, Y2: 10}}, + X: &Bounds{0, 40}, Y: &Bounds{0, 40}, + }) + case "canvas-grid": + DrawCanvas(s, CanvasOptions{ + Shapes: []Shape{{Kind: ShapePoints, Points: []Point{{5, 5}}}}, + X: &Bounds{0, 10}, Y: &Bounds{0, 10}, Grid: true, + }) case "badge": DrawBadge(s, BadgeOptions{Text: "LIVE"}) case "badge-outline": diff --git a/ports/go/ui.go b/ports/go/ui.go index 5583484..da9fc56 100644 --- a/ports/go/ui.go +++ b/ports/go/ui.go @@ -514,6 +514,15 @@ func (c *Container) Calendar(o CalendarOptions, layout ...Layout) *Container { }) } +// Shapes draws a canvas in your own coordinates rather than in pixels. +// +// Canvas hands you the pixel grid and leaves the unit conversion to you, which +// means a drawing written for one panel size is wrong in the next. This takes +// bounds and shapes placed inside them, and y goes up. +func (c *Container) Shapes(o CanvasOptions, layout ...Layout) *Container { + return c.add(c.filling(firstLayout(layout)), func(s Surface) { DrawCanvas(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/python/hqtui/graphics/__init__.py b/ports/python/hqtui/graphics/__init__.py index 29757d6..9bf5a80 100644 --- a/ports/python/hqtui/graphics/__init__.py +++ b/ports/python/hqtui/graphics/__init__.py @@ -15,6 +15,15 @@ vertical_glyph, ) from .braille import BrailleCanvas +from .canvas import ( + Bounds, + CanvasOptions, + Projection, + Shape, + ShapeKind, + draw_canvas, + projection, +) from .chart import ( AxisOptions, ChartPlotOptions, @@ -45,6 +54,13 @@ ) __all__ = [ + "Bounds", + "CanvasOptions", + "Projection", + "Shape", + "ShapeKind", + "draw_canvas", + "projection", "AxisOptions", "ChartPlotOptions", "ChartSeries", diff --git a/ports/python/hqtui/graphics/canvas.py b/ports/python/hqtui/graphics/canvas.py new file mode 100644 index 0000000..bde4936 --- /dev/null +++ b/ports/python/hqtui/graphics/canvas.py @@ -0,0 +1,203 @@ +"""A canvas you can draw on in your own coordinates. + +``BrailleCanvas`` works in pixels: good primitives, but the caller does every +unit conversion, and a drawing written for one panel size is wrong in the next. +This wraps it with a domain per axis and a list of shapes placed in that domain, +so the same drawing fits whatever region it is given. + +Y increases upwards, as it does on paper and in every plot, rather than +downwards as it does in a terminal. A canvas is for drawing things that have +their own geometry; making the caller flip every y would be handing them back +the conversion this exists to take away. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Literal, Sequence + +from ..buffer import Style +from ..color import Color +from ..surface import Surface +from .braille import BrailleCanvas +from .plot import blit + +__all__ = [ + "Bounds", + "CanvasOptions", + "Projection", + "Shape", + "ShapeKind", + "draw_canvas", + "projection", +] + +Point = tuple[float, float] + +#: Which of the shapes a Shape is. +ShapeKind = Literal["line", "polyline", "points", "circle", "rect"] + + +@dataclass(frozen=True, slots=True) +class Bounds: + min: float = 0.0 + max: float = 1.0 + + +@dataclass(frozen=True, slots=True) +class Shape: + kind: ShapeKind = "line" + #: Line: the two ends. Circle and rect: the centre or corner. + x1: float = 0.0 + y1: float = 0.0 + x2: float = 0.0 + y2: float = 0.0 + #: points and polyline. + points: Sequence[Point] = () + #: circle. + radius: float = 0.0 + #: rect. + width: float = 0.0 + height: float = 0.0 + fill: bool = False + color: Color | None = None + + @property + def usable(self) -> bool: + """Whether the shape has anything finite to draw.""" + if self.kind == "line": + return all(math.isfinite(v) for v in (self.x1, self.y1, self.x2, self.y2)) + if self.kind == "circle": + return all(math.isfinite(v) for v in (self.x1, self.y1, self.radius)) + if self.kind == "rect": + return all(math.isfinite(v) for v in (self.x1, self.y1, self.width, self.height)) + return any(math.isfinite(p[0]) and math.isfinite(p[1]) for p in self.points) + + +@dataclass(frozen=True, slots=True) +class CanvasOptions: + shapes: Sequence[Shape] = () + #: The span the drawing is in. None means 0-1. + x: Bounds | None = None + y: Bounds | None = None + #: Colour for shapes that do not name their own. + color: Color | None = None + background: Color | None = None + #: A faint dotted grid behind the shapes. + grid: bool = False + grid_color: Color | None = None + + +def _span(bounds: Bounds | None) -> Bounds: + """A usable span: a zero-width one cannot be mapped onto anything.""" + if bounds is None: + return Bounds() + if not math.isfinite(bounds.min) or not math.isfinite(bounds.max): + return Bounds() + if not bounds.max > bounds.min: + return Bounds() + return bounds + + +@dataclass(frozen=True, slots=True) +class Projection: + """A projection from the caller's coordinates onto the canvas's pixels. + + Handed out so a caller can place their own labels against the same drawing: + a chart axis or a map legend has to agree with the shapes, and re-deriving + the mapping by hand is exactly the arithmetic this is here to remove. + """ + + width: float + height: float + x_bounds: Bounds + y_bounds: Bounds + + def x(self, value: float) -> float: + return (value - self.x_bounds.min) / (self.x_bounds.max - self.x_bounds.min) * self.width + + def y(self, value: float) -> float: + """Flipped: the caller's y goes up, the canvas's goes down.""" + span = (value - self.y_bounds.min) / (self.y_bounds.max - self.y_bounds.min) + return (1 - span) * self.height + + +def projection(canvas: BrailleCanvas, x_bounds: Bounds, y_bounds: Bounds) -> Projection: + return Projection( + width=float(max(2, canvas.width) - 1), + height=float(max(2, canvas.height) - 1), + x_bounds=x_bounds, + y_bounds=y_bounds, + ) + + +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 draw_canvas(surface: Surface, options: CanvasOptions) -> None: + if surface.empty or not options.shapes: + return + theme = surface.theme + bg = options.background + xb = _span(options.x) + yb = _span(options.y) + + if options.grid: + color = options.grid_color + if color is None: + color = theme.border.mix(theme.background, 0.4) + _draw_grid(surface, color, bg) + + canvas = BrailleCanvas(surface.width, surface.height) + at = projection(canvas, xb, yb) + base = options.color if options.color is not None else theme.accent + + # One shape at a time, blitted before the next is drawn, so each keeps its + # own colour. A shared canvas would make the last colour win everywhere the + # shapes overlap. + for shape in options.shapes: + if not shape.usable: + continue + canvas.clear() + if shape.kind == "line": + canvas.line(at.x(shape.x1), at.y(shape.y1), at.x(shape.x2), at.y(shape.y2)) + elif shape.kind == "polyline": + pixels = [ + (at.x(p[0]), at.y(p[1])) + for p in shape.points + if math.isfinite(p[0]) and math.isfinite(p[1]) + ] + if len(pixels) == 1: + canvas.pixel(pixels[0][0], pixels[0][1]) + else: + canvas.polyline(pixels) + elif shape.kind == "points": + for px, py in shape.points: + if math.isfinite(px) and math.isfinite(py): + canvas.pixel(at.x(px), at.y(py)) + elif shape.kind == "circle": + # A radius is a distance, not a position, so it is scaled by the span + # rather than projected. The two axes rarely scale alike in a + # terminal cell, and the x one is what a circle is measured against. + scale = (max(2, canvas.width) - 1) / (xb.max - xb.min) + canvas.circle(at.x(shape.x1), at.y(shape.y1), abs(shape.radius) * scale) + elif shape.kind == "rect": + # Given as a corner and a size, in the caller's own direction: a + # positive height goes up, because their y does. + x0 = at.x(shape.x1) + x1 = at.x(shape.x1 + shape.width) + y0 = at.y(shape.y1) + y1 = at.y(shape.y1 + shape.height) + if shape.fill: + canvas.fill_rect(x0, min(y0, y1), x1, max(y0, y1)) + else: + canvas.rect(x0, min(y0, y1), x1, max(y0, y1)) + + color = shape.color if shape.color is not None else base + blit(surface, canvas, lambda col, row, c=color: c, bg) diff --git a/ports/python/hqtui/ui.py b/ports/python/hqtui/ui.py index 0dc5754..c4782f1 100644 --- a/ports/python/hqtui/ui.py +++ b/ports/python/hqtui/ui.py @@ -18,6 +18,7 @@ from .buffer import Style from .capabilities import Capabilities from .color import Color +from . import graphics as g from .graphics import BrailleCanvas from .layout import Constraint, Direction, Rect, solve, stack from .surface import BorderStyle, BoxOptions, Surface @@ -516,6 +517,18 @@ def calendar(self, options: w.CalendarOptions, layout: Layout | None = None): lambda s: w.draw_calendar(s, options), ) + def shapes(self, options: g.CanvasOptions, layout: Layout | None = None): + """A canvas drawn in your own coordinates rather than in pixels. + + ``canvas`` hands you the pixel grid and leaves the unit conversion to + you, which means a drawing written for one panel size is wrong in the + next. This takes bounds and shapes placed inside them, and y goes up. + """ + return self._add( + self._constraint(layout or Layout(), "fill"), + lambda s: g.draw_canvas(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/tests/test_conformance_widgets.py b/ports/python/tests/test_conformance_widgets.py index 438b6cc..f653251 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.canvas as gc import hqtui.graphics.chart as g import hqtui.widgets as w from hqtui.graphics import ( @@ -93,6 +94,36 @@ def draw_scene(case, name: str, s: Surface) -> None: year=2026, month=9, selected=8, marks=[w.CalendarMark(day=15), w.CalendarMark(day=22, bold=True)], )) + elif name == "canvas-line": + gc.draw_canvas(s, gc.CanvasOptions( + shapes=[gc.Shape(kind="line", x1=0, y1=0, x2=10, y2=10)], + x=gc.Bounds(0, 10), y=gc.Bounds(0, 10), + )) + elif name == "canvas-shapes": + gc.draw_canvas(s, gc.CanvasOptions( + shapes=[ + gc.Shape(kind="rect", x1=1, y1=1, width=4, height=4), + gc.Shape(kind="circle", x1=7, y1=5, radius=2), + gc.Shape(kind="polyline", points=[(0, 8), (3, 9), (6, 7), (9, 9)]), + gc.Shape(kind="points", points=[(1, 9), (9, 1)]), + ], + x=gc.Bounds(0, 10), y=gc.Bounds(0, 10), + )) + elif name == "canvas-filled": + gc.draw_canvas(s, gc.CanvasOptions( + shapes=[gc.Shape(kind="rect", x1=2, y1=2, width=6, height=6, fill=True)], + x=gc.Bounds(0, 10), y=gc.Bounds(0, 10), + )) + elif name == "canvas-bounds": + gc.draw_canvas(s, gc.CanvasOptions( + shapes=[gc.Shape(kind="line", x1=0, y1=0, x2=10, y2=10)], + x=gc.Bounds(0, 40), y=gc.Bounds(0, 40), + )) + elif name == "canvas-grid": + gc.draw_canvas(s, gc.CanvasOptions( + shapes=[gc.Shape(kind="points", points=[(5, 5)])], + x=gc.Bounds(0, 10), y=gc.Bounds(0, 10), grid=True, + )) elif name == "badge": w.draw_badge(s, w.BadgeOptions(text="LIVE")) elif name == "badge-outline": diff --git a/ports/rust/src/graphics/canvas.rs b/ports/rust/src/graphics/canvas.rs new file mode 100644 index 0000000..4b8c116 --- /dev/null +++ b/ports/rust/src/graphics/canvas.rs @@ -0,0 +1,222 @@ +//! A canvas you can draw on in your own coordinates. +//! +//! `BrailleCanvas` works in pixels: good primitives, but the caller does every +//! unit conversion, and a drawing written for one panel size is wrong in the +//! next. This wraps it with a domain per axis and a list of shapes placed in +//! that domain, so the same drawing fits whatever region it is given. +//! +//! Y increases upwards, as it does on paper and in every plot, rather than +//! downwards as it does in a terminal. A canvas is for drawing things that have +//! their own geometry; making the caller flip every y would be handing them +//! back the conversion this exists to take away. + +use crate::color::Color; +use crate::graphics::braille::BrailleCanvas; +use crate::graphics::plot::blit; +use crate::surface::Surface; +use crate::buffer::Style; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bounds { + pub min: f64, + pub max: f64, +} + +impl Bounds { + pub fn new(min: f64, max: f64) -> Bounds { + Bounds { min, max } + } +} + +impl Default for Bounds { + fn default() -> Bounds { + Bounds { min: 0.0, max: 1.0 } + } +} + +#[derive(Clone, Debug)] +pub enum Shape { + Line { x1: f64, y1: f64, x2: f64, y2: f64, color: Option }, + Polyline { points: Vec<(f64, f64)>, color: Option }, + Points { points: Vec<(f64, f64)>, color: Option }, + Circle { x: f64, y: f64, radius: f64, color: Option }, + Rect { x: f64, y: f64, width: f64, height: f64, fill: bool, color: Option }, +} + +impl Shape { + fn color(&self) -> Option { + match self { + Shape::Line { color, .. } + | Shape::Polyline { color, .. } + | Shape::Points { color, .. } + | Shape::Circle { color, .. } + | Shape::Rect { color, .. } => *color, + } + } + + /// Whether the shape has anything finite to draw. + fn usable(&self) -> bool { + match self { + Shape::Line { x1, y1, x2, y2, .. } => { + x1.is_finite() && y1.is_finite() && x2.is_finite() && y2.is_finite() + } + Shape::Circle { x, y, radius, .. } => { + x.is_finite() && y.is_finite() && radius.is_finite() + } + Shape::Rect { x, y, width, height, .. } => { + x.is_finite() && y.is_finite() && width.is_finite() && height.is_finite() + } + Shape::Polyline { points, .. } | Shape::Points { points, .. } => { + points.iter().any(|p| p.0.is_finite() && p.1.is_finite()) + } + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct CanvasOptions { + pub shapes: Vec, + /// The span the drawing is in. Defaults to 0-1 on both axes. + pub x: Option, + pub y: Option, + /// Colour for shapes that do not name their own. + pub color: Option, + pub background: Option, + /// A faint dotted grid behind the shapes. + pub grid: bool, + pub grid_color: Option, +} + +/// A usable span: a zero-width one cannot be mapped onto anything. +fn span(bounds: Option) -> Bounds { + match bounds { + Some(b) if b.min.is_finite() && b.max.is_finite() && b.max > b.min => b, + _ => Bounds::default(), + } +} + +/// A projection from the caller's coordinates onto the canvas's pixels. +/// +/// Handed out so a caller can place their own labels against the same drawing: +/// a chart axis or a map legend has to agree with the shapes, and re-deriving +/// the mapping by hand is exactly the arithmetic this is here to remove. +#[derive(Clone, Copy, Debug)] +pub struct Projection { + width: f64, + height: f64, + x_bounds: Bounds, + y_bounds: Bounds, +} + +impl Projection { + pub fn x(&self, value: f64) -> f64 { + (value - self.x_bounds.min) / (self.x_bounds.max - self.x_bounds.min) * self.width + } + + /// Flipped: the caller's y goes up, the canvas's goes down. + pub fn y(&self, value: f64) -> f64 { + (1.0 - (value - self.y_bounds.min) / (self.y_bounds.max - self.y_bounds.min)) * self.height + } +} + +pub fn projection(canvas: &BrailleCanvas, x_bounds: Bounds, y_bounds: Bounds) -> Projection { + Projection { + width: (canvas.width.max(2) - 1) as f64, + height: (canvas.height.max(2) - 1) as f64, + x_bounds, + y_bounds, + } +} + +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; + } +} + +pub fn draw_canvas(surface: &Surface, options: &CanvasOptions) { + if surface.is_empty() || options.shapes.is_empty() { + return; + } + let theme = surface.theme.clone(); + let bg = options.background; + let xb = span(options.x); + let yb = span(options.y); + + if options.grid { + let color = options + .grid_color + .unwrap_or_else(|| theme.border.mix(theme.background, 0.4)); + draw_grid(surface, color, bg); + } + + let mut canvas = BrailleCanvas::new(surface.width(), surface.height()); + let at = projection(&canvas, xb, yb); + let base = options.color.unwrap_or(theme.accent); + + // One shape at a time, blitted before the next is drawn, so each keeps its + // own colour. A shared canvas would make the last colour win everywhere the + // shapes overlap. + for shape in &options.shapes { + if !shape.usable() { + continue; + } + canvas.clear(); + match shape { + Shape::Line { x1, y1, x2, y2, .. } => { + canvas.line(at.x(*x1), at.y(*y1), at.x(*x2), at.y(*y2)); + } + Shape::Polyline { points, .. } => { + let pixels: Vec<(f64, f64)> = points + .iter() + .filter(|p| p.0.is_finite() && p.1.is_finite()) + .map(|p| (at.x(p.0), at.y(p.1))) + .collect(); + if pixels.len() == 1 { + canvas.pixel(pixels[0].0, pixels[0].1); + } else { + canvas.polyline(&pixels); + } + } + Shape::Points { points, .. } => { + for p in points { + if p.0.is_finite() && p.1.is_finite() { + canvas.pixel(at.x(p.0), at.y(p.1)); + } + } + } + Shape::Circle { x, y, radius, .. } => { + // A radius is a distance, not a position, so it is scaled by the + // span rather than projected. The two axes rarely scale alike in + // a terminal cell, and the x one is what a circle is measured + // against. + let scale = (canvas.width.max(2) - 1) as f64 / (xb.max - xb.min); + canvas.circle(at.x(*x), at.y(*y), radius.abs() * scale); + } + Shape::Rect { x, y, width, height, fill, .. } => { + // Given as a corner and a size, in the caller's own direction: a + // positive height goes up, because their y does. + let x0 = at.x(*x); + let x1 = at.x(*x + *width); + let y0 = at.y(*y); + let y1 = at.y(*y + *height); + if *fill { + canvas.fill_rect(x0, y0.min(y1), x1, y0.max(y1)); + } else { + canvas.rect(x0, y0.min(y1), x1, y0.max(y1)); + } + } + } + let color = shape.color().unwrap_or(base); + blit(surface, &canvas, |_, _| color, bg); + } +} diff --git a/ports/rust/src/graphics/mod.rs b/ports/rust/src/graphics/mod.rs index 36be133..cce0075 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 canvas; pub mod chart; pub mod braille; pub mod plot; @@ -11,6 +12,9 @@ pub use blocks::{ HORIZONTAL_EIGHTHS, QUADRANTS, SHADES, VERTICAL_EIGHTHS, }; pub use braille::BrailleCanvas; +pub use canvas::{ + draw_canvas, projection, Bounds, CanvasOptions, Projection, Shape, +}; pub use chart::{ domain_of, plot_points, AxisOptions, ChartPlotOptions, ChartSeries, Domain, MarkType, Point, }; diff --git a/ports/rust/src/ui.rs b/ports/rust/src/ui.rs index cda2879..2d834ef 100644 --- a/ports/rust/src/ui.rs +++ b/ports/rust/src/ui.rs @@ -785,6 +785,16 @@ impl<'a> Container<'a> { self.add(constraint, move |s| w::draw_calendar(&s, &options)) } + /// A canvas drawn in your own coordinates rather than in pixels. + /// + /// `canvas` hands you the pixel grid and leaves the unit conversion to you, + /// which means a drawing written for one panel size is wrong in the next. + /// This takes bounds and shapes placed inside them, and y goes up. + pub fn shapes(&mut self, options: crate::graphics::CanvasOptions) -> &mut Self { + let constraint = self.filling(); + self.add(constraint, move |s| crate::graphics::draw_canvas(&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/tests/conformance_widgets.rs b/ports/rust/tests/conformance_widgets.rs index c678022..4cc1aed 100644 --- a/ports/rust/tests/conformance_widgets.rs +++ b/ports/rust/tests/conformance_widgets.rs @@ -14,6 +14,7 @@ use hqtui::graphics::plot::{ GaugeOptions, PlotOptions, Series, }; use hqtui::graphics::chart::{AxisOptions, ChartPlotOptions, ChartSeries, MarkType}; +use hqtui::graphics::{draw_canvas, Bounds, CanvasOptions, Shape}; use hqtui::graphics::FillMode; use hqtui::surface::Surface; use hqtui::unicode::Align; @@ -87,6 +88,64 @@ fn draw_scene(name: &str, s: &Surface) { ..CalendarOptions::new(2026, 9) }, ), + "canvas-line" => draw_canvas( + s, + &CanvasOptions { + shapes: vec![Shape::Line { x1: 0.0, y1: 0.0, x2: 10.0, y2: 10.0, color: None }], + x: Some(Bounds::new(0.0, 10.0)), + y: Some(Bounds::new(0.0, 10.0)), + ..Default::default() + }, + ), + "canvas-shapes" => draw_canvas( + s, + &CanvasOptions { + shapes: vec![ + Shape::Rect { + x: 1.0, y: 1.0, width: 4.0, height: 4.0, fill: false, color: None, + }, + Shape::Circle { x: 7.0, y: 5.0, radius: 2.0, color: None }, + Shape::Polyline { + points: vec![(0.0, 8.0), (3.0, 9.0), (6.0, 7.0), (9.0, 9.0)], + color: None, + }, + Shape::Points { points: vec![(1.0, 9.0), (9.0, 1.0)], color: None }, + ], + x: Some(Bounds::new(0.0, 10.0)), + y: Some(Bounds::new(0.0, 10.0)), + ..Default::default() + }, + ), + "canvas-filled" => draw_canvas( + s, + &CanvasOptions { + shapes: vec![Shape::Rect { + x: 2.0, y: 2.0, width: 6.0, height: 6.0, fill: true, color: None, + }], + x: Some(Bounds::new(0.0, 10.0)), + y: Some(Bounds::new(0.0, 10.0)), + ..Default::default() + }, + ), + "canvas-bounds" => draw_canvas( + s, + &CanvasOptions { + shapes: vec![Shape::Line { x1: 0.0, y1: 0.0, x2: 10.0, y2: 10.0, color: None }], + x: Some(Bounds::new(0.0, 40.0)), + y: Some(Bounds::new(0.0, 40.0)), + ..Default::default() + }, + ), + "canvas-grid" => draw_canvas( + s, + &CanvasOptions { + shapes: vec![Shape::Points { points: vec![(5.0, 5.0)], color: None }], + x: Some(Bounds::new(0.0, 10.0)), + y: Some(Bounds::new(0.0, 10.0)), + grid: true, + ..Default::default() + }, + ), "badge" => { draw_badge(s, &BadgeOptions::new("LIVE")); } diff --git a/ports/zig/src/conformance_widgets.zig b/ports/zig/src/conformance_widgets.zig index 2a07b5d..00b1b6b 100644 --- a/ports/zig/src/conformance_widgets.zig +++ b/ports/zig/src/conformance_widgets.zig @@ -83,6 +83,45 @@ fn drawScene(allocator: std.mem.Allocator, name: []const u8, s: Surface) !void { .selected = 8, .marks = &.{ .{ .day = 15 }, .{ .day = 22, .bold = true } }, }); + } else if (eq(u8, name, "canvas-line")) { + try graphics.drawCanvas(allocator, s, .{ + .shapes = &.{.{ .kind = .line, .x1 = 0, .y1 = 0, .x2 = 10, .y2 = 10 }}, + .x = .{ .min = 0, .max = 10 }, + .y = .{ .min = 0, .max = 10 }, + }); + } else if (eq(u8, name, "canvas-shapes")) { + try graphics.drawCanvas(allocator, s, .{ + .shapes = &.{ + .{ .kind = .rect, .x1 = 1, .y1 = 1, .width = 4, .height = 4 }, + .{ .kind = .circle, .x1 = 7, .y1 = 5, .radius = 2 }, + .{ .kind = .polyline, .points = &.{ + .{ .x = 0, .y = 8 }, .{ .x = 3, .y = 9 }, + .{ .x = 6, .y = 7 }, .{ .x = 9, .y = 9 }, + } }, + .{ .kind = .points, .points = &.{ .{ .x = 1, .y = 9 }, .{ .x = 9, .y = 1 } } }, + }, + .x = .{ .min = 0, .max = 10 }, + .y = .{ .min = 0, .max = 10 }, + }); + } else if (eq(u8, name, "canvas-filled")) { + try graphics.drawCanvas(allocator, s, .{ + .shapes = &.{.{ .kind = .rect, .x1 = 2, .y1 = 2, .width = 6, .height = 6, .fill = true }}, + .x = .{ .min = 0, .max = 10 }, + .y = .{ .min = 0, .max = 10 }, + }); + } else if (eq(u8, name, "canvas-bounds")) { + try graphics.drawCanvas(allocator, s, .{ + .shapes = &.{.{ .kind = .line, .x1 = 0, .y1 = 0, .x2 = 10, .y2 = 10 }}, + .x = .{ .min = 0, .max = 40 }, + .y = .{ .min = 0, .max = 40 }, + }); + } else if (eq(u8, name, "canvas-grid")) { + try graphics.drawCanvas(allocator, s, .{ + .shapes = &.{.{ .kind = .points, .points = &.{.{ .x = 5, .y = 5 }} }}, + .x = .{ .min = 0, .max = 10 }, + .y = .{ .min = 0, .max = 10 }, + .grid = true, + }); } else if (eq(u8, name, "badge")) { _ = w.drawBadge(s, .{ .text = "LIVE" }); } else if (eq(u8, name, "badge-outline")) { diff --git a/ports/zig/src/graphics.zig b/ports/zig/src/graphics.zig index f6b11f5..42904c8 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 canvas_mod = @import("graphics/canvas.zig"); pub const chart_mod = @import("graphics/chart.zig"); pub const plot_mod = @import("graphics/plot.zig"); @@ -33,6 +34,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 Bounds = canvas_mod.Bounds; +pub const CanvasOptions = canvas_mod.CanvasOptions; +pub const Shape = canvas_mod.Shape; +pub const ShapeKind = canvas_mod.ShapeKind; +pub const drawCanvas = canvas_mod.drawCanvas; +pub const canvasProjection = canvas_mod.projection; pub const AxisOptions = chart_mod.AxisOptions; pub const ChartPlotOptions = chart_mod.ChartPlotOptions; pub const ChartSeries = chart_mod.ChartSeries; diff --git a/ports/zig/src/graphics/canvas.zig b/ports/zig/src/graphics/canvas.zig new file mode 100644 index 0000000..a2e71f9 --- /dev/null +++ b/ports/zig/src/graphics/canvas.zig @@ -0,0 +1,207 @@ +//! A canvas you can draw on in your own coordinates. +//! +//! `BrailleCanvas` works in pixels: good primitives, but the caller does every +//! unit conversion, and a drawing written for one panel size is wrong in the +//! next. This wraps it with a domain per axis and a list of shapes placed in +//! that domain, so the same drawing fits whatever region it is given. +//! +//! Y increases upwards, as it does on paper and in every plot, rather than +//! downwards as it does in a terminal. A canvas is for drawing things that have +//! their own geometry; making the caller flip every y would be handing them +//! back the conversion this exists to take away. + +const std = @import("std"); + +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 BrailleCanvas = braille_mod.BrailleCanvas; +const Color = color_mod.Color; +const Point = braille_mod.Point; +const Surface = surface_mod.Surface; + +pub const Bounds = struct { min: f64 = 0, max: f64 = 1 }; + +/// Which of the shapes a Shape is. +pub const ShapeKind = enum { line, polyline, points, circle, rect }; + +pub const Shape = struct { + kind: ShapeKind = .line, + /// Line: the two ends. Circle and rect: the centre or corner. + x1: f64 = 0, + y1: f64 = 0, + x2: f64 = 0, + y2: f64 = 0, + /// points and polyline. + points: []const Point = &.{}, + /// circle. + radius: f64 = 0, + /// rect. + width: f64 = 0, + height: f64 = 0, + fill: bool = false, + color: ?Color = null, + + /// Whether the shape has anything finite to draw. + fn usable(self: Shape) bool { + return switch (self.kind) { + .line => std.math.isFinite(self.x1) and std.math.isFinite(self.y1) and + std.math.isFinite(self.x2) and std.math.isFinite(self.y2), + .circle => std.math.isFinite(self.x1) and std.math.isFinite(self.y1) and + std.math.isFinite(self.radius), + .rect => std.math.isFinite(self.x1) and std.math.isFinite(self.y1) and + std.math.isFinite(self.width) and std.math.isFinite(self.height), + .polyline, .points => blk: { + for (self.points) |p| { + if (std.math.isFinite(p.x) and std.math.isFinite(p.y)) break :blk true; + } + break :blk false; + }, + }; + } +}; + +pub const CanvasOptions = struct { + shapes: []const Shape = &.{}, + /// The span the drawing is in. Null means 0-1. + x: ?Bounds = null, + y: ?Bounds = null, + /// Colour for shapes that do not name their own. + color: ?Color = null, + background: ?Color = null, + /// A faint dotted grid behind the shapes. + grid: bool = false, + grid_color: ?Color = null, +}; + +/// A usable span: a zero-width one cannot be mapped onto anything. +fn span(bounds: ?Bounds) Bounds { + const b = bounds orelse return .{}; + if (!std.math.isFinite(b.min) or !std.math.isFinite(b.max) or !(b.max > b.min)) return .{}; + return b; +} + +/// A projection from the caller's coordinates onto the canvas's pixels. +/// +/// Handed out so a caller can place their own labels against the same drawing: +/// a chart axis or a map legend has to agree with the shapes, and re-deriving +/// the mapping by hand is exactly the arithmetic this is here to remove. +pub const Projection = struct { + width: f64, + height: f64, + x_bounds: Bounds, + y_bounds: Bounds, + + pub fn x(self: Projection, value: f64) f64 { + return (value - self.x_bounds.min) / (self.x_bounds.max - self.x_bounds.min) * self.width; + } + + /// Flipped: the caller's y goes up, the canvas's goes down. + pub fn y(self: Projection, value: f64) f64 { + const at = (value - self.y_bounds.min) / (self.y_bounds.max - self.y_bounds.min); + return (1 - at) * self.height; + } +}; + +pub fn projection(canvas: *const BrailleCanvas, x_bounds: Bounds, y_bounds: Bounds) Projection { + return .{ + .width = @floatFromInt(@max(2, canvas.width) - 1), + .height = @floatFromInt(@max(2, canvas.height) - 1), + .x_bounds = x_bounds, + .y_bounds = y_bounds, + }; +} + +fn drawGrid(s: Surface, color: Color, bg: ?Color) void { + const w = s.width(); + const h = s.height(); + const step = @max(2, h / 4); + var yy: usize = 0; + while (yy < h) : (yy += step) { + var xx: usize = 0; + while (xx < w) : (xx += 2) { + s.glyph(@intCast(xx), @intCast(yy), '·', .{ .fg = color, .bg = bg }); + } + } +} + +pub fn drawCanvas( + allocator: std.mem.Allocator, + s: Surface, + options: CanvasOptions, +) !void { + if (s.isEmpty() or options.shapes.len == 0) return; + const theme = s.theme; + const bg = options.background; + const xb = span(options.x); + const yb = span(options.y); + + if (options.grid) { + const color = options.grid_color orelse theme.border.mix(theme.background, 0.4); + drawGrid(s, color, bg); + } + + var canvas = try BrailleCanvas.init(allocator, s.width(), s.height()); + defer canvas.deinit(); + const at = projection(&canvas, xb, yb); + const base = options.color orelse theme.accent; + + // One shape at a time, blitted before the next is drawn, so each keeps its + // own colour. A shared canvas would make the last colour win everywhere the + // shapes overlap. + for (options.shapes) |shape| { + if (!shape.usable()) continue; + canvas.clear(); + switch (shape.kind) { + .line => canvas.line(at.x(shape.x1), at.y(shape.y1), at.x(shape.x2), at.y(shape.y2)), + .polyline => { + const pixels = try allocator.alloc(Point, shape.points.len); + defer allocator.free(pixels); + var count: usize = 0; + for (shape.points) |p| { + if (!std.math.isFinite(p.x) or !std.math.isFinite(p.y)) continue; + pixels[count] = .{ .x = at.x(p.x), .y = at.y(p.y) }; + count += 1; + } + if (count == 1) { + canvas.pixel(pixels[0].x, pixels[0].y); + } else if (count > 1) { + canvas.polyline(pixels[0..count]); + } + }, + .points => { + for (shape.points) |p| { + if (std.math.isFinite(p.x) and std.math.isFinite(p.y)) { + canvas.pixel(at.x(p.x), at.y(p.y)); + } + } + }, + .circle => { + // A radius is a distance, not a position, so it is scaled by the + // span rather than projected. The two axes rarely scale alike in + // a terminal cell, and the x one is what a circle is measured + // against. + const scale = @as(f64, @floatFromInt(@max(2, canvas.width) - 1)) / + (xb.max - xb.min); + canvas.circle(at.x(shape.x1), at.y(shape.y1), @abs(shape.radius) * scale); + }, + .rect => { + // Given as a corner and a size, in the caller's own direction: a + // positive height goes up, because their y does. + const x0 = at.x(shape.x1); + const x1 = at.x(shape.x1 + shape.width); + const y0 = at.y(shape.y1); + const y1 = at.y(shape.y1 + shape.height); + if (shape.fill) { + canvas.fillRect(x0, @min(y0, y1), x1, @max(y0, y1)); + } else { + canvas.rect(x0, @min(y0, y1), x1, @max(y0, y1)); + } + }, + } + plot_mod.blitFlat(s, &canvas, shape.color orelse base, bg); + } +} diff --git a/ports/zig/src/ui.zig b/ports/zig/src/ui.zig index 02f9bb9..72f2837 100644 --- a/ports/zig/src/ui.zig +++ b/ports/zig/src/ui.zig @@ -374,6 +374,7 @@ const Node = union(enum) { graph: w.GraphOptions, calendar: w.CalendarOptions, chart: w.ChartOptions, + shapes: graphics.CanvasOptions, clear: w.ClearOptions, fill: w.FillOptions, sparkline: w.SparklineWidgetOptions, @@ -465,6 +466,7 @@ fn drawNode(ctx: *Ctx, s: Surface, node: Node) anyerror!void { .graph => |o| try w.drawGraph(allocator, s, o), .calendar => |o| w.drawCalendar(s, o), .chart => |o| try w.drawChart(allocator, s, o), + .shapes => |o| try graphics.drawCanvas(allocator, s, o), .clear => |o| w.drawClear(s, o), .fill => |o| w.drawFill(s, o), .sparkline => |o| w.drawSparkline(s, o), @@ -877,6 +879,15 @@ pub const Container = struct { try self.add(self.leaf(w.calendarHeight(options)), .{ .calendar = options }); } + /// A canvas drawn in your own coordinates rather than in pixels. + /// + /// `canvas` hands you the pixel grid and leaves the unit conversion to you, + /// which means a drawing written for one panel size is wrong in the next. + /// This takes bounds and shapes placed inside them, and y goes up. + pub fn shapes(self: *Container, options: graphics.CanvasOptions) !void { + try self.add(self.filling(), .{ .shapes = options }); + } + pub fn sparkline(self: *Container, options: w.SparklineWidgetOptions) !void { try self.add(self.leaf(1), .{ .sparkline = options }); }