diff --git a/packages/hqtui/src/index.ts b/packages/hqtui/src/index.ts index 17cf2d3..e822de9 100644 --- a/packages/hqtui/src/index.ts +++ b/packages/hqtui/src/index.ts @@ -26,7 +26,10 @@ export { detectCapabilities, type Capabilities, type ColorDepth, type Capability // Rendering core export { FrameBuffer, Attr, type Style, type Attributes } from "./buffer.ts"; export { Encoder, encodeFull, type EncodeResult } from "./diff.ts"; -export { Surface, createSurface, BORDERS, type BorderStyle, type BoxOptions, type Align } from "./surface.ts"; +export { + Surface, createSurface, BORDERS, resolveSides, + type BorderStyle, type BoxOptions, type Align, type Side, type Sides, +} from "./surface.ts"; export { ansi, stripAnsi, moveTo, setTitle } from "./ansi.ts"; // Color diff --git a/packages/hqtui/src/surface.ts b/packages/hqtui/src/surface.ts index dea7392..bb2266d 100644 --- a/packages/hqtui/src/surface.ts +++ b/packages/hqtui/src/surface.ts @@ -77,6 +77,51 @@ export function borderBits(codepoint: number): number | null { return BITS_BY_CODEPOINT.get(codepoint) ?? null; } +export type Side = "top" | "right" | "bottom" | "left"; + +/** + * Which edges of a box to draw. "all" is every side, which is what a box was + * before this existed; "none" draws no rule but still insets nothing, the same + * as a border style of "none". + */ +export type Sides = "all" | "none" | readonly Side[]; + +/** The four sides as flags, in the order the drawing code wants them. */ +export interface DrawnSides { + top: boolean; + right: boolean; + bottom: boolean; + left: boolean; +} + +export function resolveSides(sides: Sides | undefined): DrawnSides { + if (sides === undefined || sides === "all") { + return { top: true, right: true, bottom: true, left: true }; + } + if (sides === "none") return { top: false, right: false, bottom: false, left: false }; + return { + top: sides.includes("top"), + right: sides.includes("right"), + bottom: sides.includes("bottom"), + left: sides.includes("left"), + }; +} + +/** + * The glyph for a cell where two edges meet, given which of them are drawn. + * + * A corner is only a corner when both its sides are there. A top-only rule + * runs straight through the cell a corner would occupy, which is why this + * falls back to the plain rule rather than leaving a gap. + */ +export function junction(style: Exclude, bits: number): string | null { + if (bits === 0) return null; + const glyph = borderGlyph(style, bits); + if (glyph !== null) return glyph; + const chars = BORDERS[style]; + return bits & (EDGE_LEFT | EDGE_RIGHT) ? chars.h : chars.v; +} + export type Align = "left" | "center" | "right"; export interface TextOptions extends Style { @@ -88,6 +133,11 @@ export interface TextOptions extends Style { export interface BoxOptions extends Style { border?: BorderStyle; + /** + * Which edges to draw. Defaults to all four. The interior follows the sides + * actually drawn, so a top-only box costs one row rather than two. + */ + sides?: Sides; borderColor?: Color; title?: string; titleAlign?: Align; @@ -312,8 +362,11 @@ export class Surface { this.fill({ bg }); } - if (style === "none" || this.width < 2 || this.height < 1) { - return this.inset(style === "none" ? 0 : 1); + const sides = resolveSides(options.sides); + const any = sides.top || sides.right || sides.bottom || sides.left; + + if (style === "none" || !any || this.width < 2 || this.height < 1) { + return this.inset(style === "none" || !any ? 0 : 1); } const b = BORDERS[style]; @@ -334,15 +387,25 @@ export class Surface { for (let i = 0; i < length; i++) put(x, y + i, ch); }; - put(0, 0, b.tl); - put(w - 1, 0, b.tr); - putH(1, 0, w - 2, b.h); + // A corner belongs to the two sides that meet there, so it exists only if + // both of them are drawn; where one is, the rule runs straight through. + const corner = (a: boolean, aBit: number, c: boolean, cBit: number): string | null => + junction(style, (a ? aBit : 0) | (c ? cBit : 0)); + + const tl = corner(sides.top, EDGE_RIGHT, sides.left, EDGE_DOWN); + const tr = corner(sides.top, EDGE_LEFT, sides.right, EDGE_DOWN); + if (sides.top) putH(1, 0, w - 2, b.h); + if (tl !== null) put(0, 0, tl); + if (tr !== null) put(w - 1, 0, tr); + if (h > 1) { - put(0, h - 1, b.bl); - put(w - 1, h - 1, b.br); - putH(1, h - 1, w - 2, b.h); - putV(0, 1, h - 2, b.v); - putV(w - 1, 1, h - 2, b.v); + const bl = corner(sides.bottom, EDGE_RIGHT, sides.left, EDGE_UP); + const br = corner(sides.bottom, EDGE_LEFT, sides.right, EDGE_UP); + if (sides.bottom) putH(1, h - 1, w - 2, b.h); + if (bl !== null) put(0, h - 1, bl); + if (br !== null) put(w - 1, h - 1, br); + if (sides.left) putV(0, 1, h - 2, b.v); + if (sides.right) putV(w - 1, 1, h - 2, b.v); } // Measured before the title is drawn: both share the top border row, and @@ -351,7 +414,7 @@ export class Surface { const subtitle = options.subtitle ? ` ${options.subtitle} ` : ""; const subtitleWidth = subtitle && stringWidth(subtitle) + 4 < w ? stringWidth(subtitle) : 0; - if (options.title) { + if (options.title && sides.top) { const titleColor = options.titleColor ?? this.theme.title; const label = ` ${options.title} `; // The title lives in [2, limit). Reserving the width is not enough on its @@ -373,14 +436,14 @@ export class Surface { this.text(tx, 0, shown, { fg: titleColor, bg, attrs: 1 /* bold */ }); } - if (subtitleWidth > 0) { + if (subtitleWidth > 0 && sides.top) { this.text(w - 2 - subtitleWidth, 0, subtitle, { fg: options.subtitleColor ?? this.theme.muted, bg, }); } - if (options.footer && h > 2) { + if (options.footer && sides.bottom && h > 2) { const foot = ` ${options.footer} `; const fw = stringWidth(foot); if (fw + 4 < w) { @@ -388,7 +451,13 @@ export class Surface { } } - return this.sub(1, 1, Math.max(0, w - 2), Math.max(0, h - 2)); + // The interior follows the sides actually drawn, so a top-only box costs + // one row rather than two. + const left = sides.left ? 1 : 0; + const top = sides.top ? 1 : 0; + const shrinkX = left + (sides.right ? 1 : 0); + const shrinkY = top + (sides.bottom ? 1 : 0); + return this.sub(left, top, Math.max(0, w - shrinkX), Math.max(0, h - shrinkY)); } /** Absolute rect of this surface, for hit-testing mouse events. */ diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts index b399607..7dfb462 100644 --- a/packages/hqtui/src/ui.ts +++ b/packages/hqtui/src/ui.ts @@ -1,4 +1,4 @@ -import type { Surface, BorderStyle, Align, BoxOptions } from "./surface.ts"; +import type { Surface, BorderStyle, Align, BoxOptions, Sides } from "./surface.ts"; import type { Style } from "./buffer.ts"; import type { Color } from "./color.ts"; import type { Theme } from "./theme.ts"; @@ -94,6 +94,12 @@ export interface PanelOptions extends ContainerOptions { footer?: string; border?: BorderStyle; borderColor?: Color; + /** + * Which edges of the panel to draw. Defaults to all four. Use it for chrome + * that is not a box: a header rule, a sidebar rail, a footer that should not + * look boxed in. + */ + sides?: Sides; /** Draws the focused border color and joins the Tab order. */ focusable?: boolean; focused?: boolean; @@ -271,6 +277,7 @@ export class Container { subtitleColor: options.subtitleColor, footer: options.footer, border: options.border ?? "rounded", + ...(options.sides === undefined ? {} : { sides: options.sides }), borderColor: options.borderColor ?? (focused ? this.theme.borderFocused : this.theme.border), bg: options.background, collapse: this.ctx.collapseBorders, diff --git a/packages/hqtui/test/justify.test.ts b/packages/hqtui/test/justify.test.ts index 84c95a0..6498968 100644 --- a/packages/hqtui/test/justify.test.ts +++ b/packages/hqtui/test/justify.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from "bun:test"; +import { test } from "node:test"; +import assert from "node:assert/strict"; import { distribute, stack, type Justify } from "../src/index.ts"; const rect = (width: number) => ({ x: 0, y: 0, width, height: 1 }); @@ -12,100 +13,95 @@ function placesExactly(slack: number, count: number, justify: Justify): boolean return placed >= 0 && placed <= slack && lead >= 0 && seams.every((s) => s >= 0); } -describe("justify", () => { - test("start is what every layout did before, so nothing moves", () => { - expect(xs([4, 4, 4], "start")).toEqual([0, 4, 8]); - expect(distribute(8, 3, "start")).toEqual({ lead: 0, seams: [0, 0] }); - }); - - test("end pushes everything against the far edge", () => { - // 20 wide, 12 used, 8 spare: the first child starts at 8. - expect(xs([4, 4, 4], "end")).toEqual([8, 12, 16]); - }); - - test("center splits the slack, and the odd cell falls after the content", () => { - expect(xs([4, 4, 4], "center")).toEqual([4, 8, 12]); - // 20 - 9 = 11 spare, so 5 before and 6 after: the odd cell falls after the - // content, which is what reads as centred. - expect(xs([3, 3, 3], "center")).toEqual([5, 8, 11]); - }); - - test("space-between puts it all between, never at the ends", () => { - const at = xs([4, 4, 4], "space-between"); - expect(at[0]).toBe(0); - // 8 spare across 2 seams. - expect(at).toEqual([0, 8, 16]); - // The last child ends exactly on the far edge. - expect((at[2] as number) + 4).toBe(20); - }); - - test("space-between with one child leaves it where it started", () => { - // There is nothing to sit between. - expect(xs([4], "space-between")).toEqual([0]); - expect(distribute(16, 1, "space-between")).toEqual({ lead: 0, seams: [] }); - }); - - test("space-evenly makes every gap the same, ends included", () => { - // 20 - 12 = 8 across 4 gaps: 2 each. - expect(xs([4, 4, 4], "space-evenly")).toEqual([2, 8, 14]); - }); - - test("space-around gives each child equal room, so the ends are half gaps", () => { - // 9 spare over 3 children is 3 each: 1.5 at the ends, 3 between. - const at = xs([4, 4, 3], "space-around", 20); - expect(at[0]).toBe(2); - expect((at[1] as number) - ((at[0] as number) + 4)).toBe(3); - }); - - test("an existing gap is kept and the slack is added to it", () => { - // 12 of content and 2 of gap leaves 6, so each seam becomes 1 + 3 and the - // last child still ends exactly on the far edge. - expect(xs([4, 4, 4], "space-between", 20, 1)).toEqual([0, 8, 16]); - expect(xs([4, 4, 4], "start", 20, 1)).toEqual([0, 5, 10]); - }); - - test("no slack means every mode agrees with start", () => { - for (const justify of ["end", "center", "space-between", "space-around", "space-evenly"] as const) { - // Exactly full: there is nothing to distribute. - expect(xs([5, 5, 5, 5], justify)).toEqual([0, 5, 10, 15]); - } - }); - - test("a flexible child leaves no slack, so justify is inert beside it", () => { - const at = stack(rect(20), [{ size: 4 }, { size: "fill" }], "row", 0, "center").map((r) => r.x); - // "fill" already absorbed everything; centring has nothing left to move. - expect(at).toEqual([0, 4]); - }); - - test("nothing is invented or lost, for any slack, count or mode", () => { - const modes: Justify[] = ["start", "end", "center", "space-between", "space-around", "space-evenly"]; - for (const justify of modes) { - for (let count = 1; count <= 6; count++) { - for (let slack = 0; slack <= 17; slack++) { - expect(placesExactly(slack, count, justify)).toBe(true); - } +const MODES: Justify[] = ["start", "end", "center", "space-between", "space-around", "space-evenly"]; + +test("justify: start is what every layout did before, so nothing moves", () => { + assert.deepEqual(xs([4, 4, 4], "start"), [0, 4, 8]); + assert.deepEqual(distribute(8, 3, "start"), { lead: 0, seams: [0, 0] }); +}); + +test("justify: end pushes everything against the far edge", () => { + // 20 wide, 12 used, 8 spare: the first child starts at 8. + assert.deepEqual(xs([4, 4, 4], "end"), [8, 12, 16]); +}); + +test("justify: center splits the slack, and the odd cell falls after the content", () => { + assert.deepEqual(xs([4, 4, 4], "center"), [4, 8, 12]); + // 20 - 9 = 11 spare, so 5 before and 6 after, which is what reads as centred. + assert.deepEqual(xs([3, 3, 3], "center"), [5, 8, 11]); +}); + +test("justify: space-between puts it all between, never at the ends", () => { + const at = xs([4, 4, 4], "space-between"); + assert.deepEqual(at, [0, 8, 16]); + // The last child ends exactly on the far edge. + assert.equal((at[2] as number) + 4, 20); +}); + +test("justify: space-between with one child leaves it where it started", () => { + // There is nothing to sit between. + assert.deepEqual(xs([4], "space-between"), [0]); + assert.deepEqual(distribute(16, 1, "space-between"), { lead: 0, seams: [] }); +}); + +test("justify: space-evenly makes every gap the same, ends included", () => { + // 20 - 12 = 8 across 4 gaps: 2 each. + assert.deepEqual(xs([4, 4, 4], "space-evenly"), [2, 8, 14]); +}); + +test("justify: space-around gives each child equal room, so the ends are half gaps", () => { + // 9 spare over 3 children is 3 each: 1.5 at the ends, 3 between. + const at = xs([4, 4, 3], "space-around", 20); + assert.equal(at[0], 2); + assert.equal((at[1] as number) - ((at[0] as number) + 4), 3); +}); + +test("justify: an existing gap is kept and the slack is added to it", () => { + // 12 of content and 2 of gap leaves 6, so each seam becomes 1 + 3 and the + // last child still ends exactly on the far edge. + assert.deepEqual(xs([4, 4, 4], "space-between", 20, 1), [0, 8, 16]); + assert.deepEqual(xs([4, 4, 4], "start", 20, 1), [0, 5, 10]); +}); + +test("justify: no slack means every mode agrees with start", () => { + for (const justify of MODES) { + // Exactly full: there is nothing to distribute. + assert.deepEqual(xs([5, 5, 5, 5], justify), [0, 5, 10, 15]); + } +}); + +test("justify: a flexible child leaves no slack, so it is inert beside one", () => { + const at = stack(rect(20), [{ size: 4 }, { size: "fill" }], "row", 0, "center").map((r) => r.x); + // "fill" already absorbed everything; centring has nothing left to move. + assert.deepEqual(at, [0, 4]); +}); + +test("justify: nothing is invented or lost, for any slack, count or mode", () => { + for (const justify of MODES) { + for (let count = 1; count <= 6; count++) { + for (let slack = 0; slack <= 17; slack++) { + assert.equal(placesExactly(slack, count, justify), true, `${justify} ${count} ${slack}`); } } - }); - - test("children never overlap and never leave the rect", () => { - const modes: Justify[] = ["start", "end", "center", "space-between", "space-around", "space-evenly"]; - for (const justify of modes) { - for (let total = 6; total <= 24; total++) { - const rects = stack(rect(total), [{ size: 3 }, { size: 4 }, { size: 2 }], "row", 1, justify); - let edge = 0; - for (const r of rects) { - expect(r.x).toBeGreaterThanOrEqual(edge); - edge = r.x + r.width; - } - expect(edge).toBeLessThanOrEqual(total); + } +}); + +test("justify: children never overlap and never leave the rect", () => { + for (const justify of MODES) { + for (let total = 6; total <= 24; total++) { + const rects = stack(rect(total), [{ size: 3 }, { size: 4 }, { size: 2 }], "row", 1, justify); + let edge = 0; + for (const r of rects) { + assert.ok(r.x >= edge, `${justify} ${total}: ${r.x} < ${edge}`); + edge = r.x + r.width; } + assert.ok(edge <= total, `${justify} ${total}: ${edge} > ${total}`); } - }); + } +}); - test("columns justify down the same way rows justify across", () => { - const ys = stack({ x: 0, y: 0, width: 10, height: 20 }, [{ size: 4 }, { size: 4 }], "column", 0, "end") - .map((r) => r.y); - expect(ys).toEqual([12, 16]); - }); +test("justify: columns justify down the same way rows justify across", () => { + const ys = stack({ x: 0, y: 0, width: 10, height: 20 }, [{ size: 4 }, { size: 4 }], "column", 0, "end") + .map((r) => r.y); + assert.deepEqual(ys, [12, 16]); }); diff --git a/packages/hqtui/test/sides.test.ts b/packages/hqtui/test/sides.test.ts new file mode 100644 index 0000000..d84170a --- /dev/null +++ b/packages/hqtui/test/sides.test.ts @@ -0,0 +1,114 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderToScreen, resolveSides, type Sides } from "../src/index.ts"; + +/** + * Draw a box with the given sides and read the frame back as lines. + * + * A panel pads its interior by one column, which is why the content reads as + * " ab" here and in every existing frame. + */ +const draw = (sides: Sides | undefined, width = 10, height = 4): string[] => { + const frame = renderToScreen( + ({ ui }) => { + ui.panel({ border: "single", ...(sides === undefined ? {} : { sides }) }, (p) => { + p.text("ab"); + }); + }, + { width, height }, + ); + return frame.text().split("\n").map((line) => line.replace(/\s+$/, "")); +}; + +test("sides: none given is all four, exactly as before", () => { + assert.deepEqual(resolveSides(undefined), { top: true, right: true, bottom: true, left: true }); + assert.deepEqual(resolveSides("all"), { top: true, right: true, bottom: true, left: true }); + + assert.deepEqual(draw(undefined), [ + "┌────────┐", + "│ ab │", + "│ │", + "└────────┘", + ]); +}); + +test("sides: a top rule is a rule, not a box missing three sides", () => { + // No corners: the rule runs straight through the cells they would occupy. + // And it costs one row, not two, so the content starts on row 1. + assert.deepEqual(draw(["top"]), ["──────────", " ab", "", ""]); +}); + +test("sides: a bottom rule leaves the top free for content", () => { + assert.deepEqual(draw(["bottom"]), [" ab", "", "", "──────────"]); +}); + +test("sides: left and right are rails with nothing joining them", () => { + // No top rule, so the content sits on row 0 between the two rails. + assert.deepEqual(draw(["left", "right"]), [ + "│ ab │", + "│ │", + "│ │", + "│ │", + ]); +}); + +test("sides: two that meet get their corner, and only that one", () => { + assert.deepEqual(draw(["top", "left"]), ["┌─────────", "│ ab", "│", "│"]); +}); + +test("sides: three leave the fourth open", () => { + assert.deepEqual(draw(["top", "left", "right"]), [ + "┌────────┐", + "│ ab │", + "│ │", + "│ │", + ]); +}); + +test("sides: none draws nothing and costs nothing", () => { + assert.deepEqual(resolveSides("none"), { top: false, right: false, bottom: false, left: false }); + // The whole area is the caller's, exactly as a border style of "none". + assert.deepEqual(draw("none"), [" ab", "", "", ""]); +}); + +test("sides: an empty list is the same as none", () => { + assert.deepEqual(draw([]), draw("none")); +}); + +test("sides: the interior costs one cell per rule, and only per rule", () => { + assert.equal(draw(["bottom"])[0], " ab"); + assert.equal(draw(["top"])[1], " ab"); + assert.equal(draw(["left"])[0], "│ ab"); + // Right-only still starts the content at column 0. + assert.equal(draw(["right"])[0], " ab │"); +}); + +test("sides: a title needs the rule it sits on", () => { + const withTop = renderToScreen( + ({ ui }) => ui.panel({ border: "single", title: "Head", sides: ["top"] }, (p) => p.text("x")), + { width: 14, height: 3 }, + ).text(); + assert.ok(withTop.includes("Head")); + + // Without a top rule there is nowhere for it to be, so it is not painted + // over the first row of content. + const withoutTop = renderToScreen( + ({ ui }) => ui.panel({ border: "single", title: "Head", sides: ["bottom"] }, (p) => p.text("x")), + { width: 14, height: 3 }, + ).text(); + assert.ok(!withoutTop.includes("Head")); +}); + +test("sides: every existing frame is untouched, whatever the border style", () => { + for (const border of ["rounded", "single", "double", "thick", "ascii"] as const) { + const before = renderToScreen( + ({ ui }) => ui.panel({ border, title: "T" }, (p) => p.text("x")), + { width: 12, height: 4 }, + ).text(); + const after = renderToScreen( + ({ ui }) => ui.panel({ border, title: "T", sides: "all" }, (p) => p.text("x")), + { width: 12, height: 4 }, + ).text(); + assert.equal(after, before, border); + } +}); diff --git a/ports/c/include/hqtui.h b/ports/c/include/hqtui.h index d565b17..fd087eb 100644 --- a/ports/c/include/hqtui.h +++ b/ports/c/include/hqtui.h @@ -144,8 +144,22 @@ enum { HQ_EDGE_UP = 1, HQ_EDGE_RIGHT = 2, HQ_EDGE_DOWN = 4, HQ_EDGE_LEFT = 8 }; int hq_border_bits(uint32_t cp); /* The glyph in `border` with exactly these edges, or 0 if there is none. */ uint32_t hq_border_glyph(int border,int bits); +/* Which edges of a box to draw, as a mask. Zero means all four, so a caller + * that has never heard of this gets the box it always got. HQ_SIDES_NONE draws + * no rule and insets nothing, the same as a border of HQ_NO_BORDER. */ +enum { + HQ_SIDE_TOP = 1, + HQ_SIDE_RIGHT = 2, + HQ_SIDE_BOTTOM = 4, + HQ_SIDE_LEFT = 8, + HQ_SIDES_ALL = 15, + HQ_SIDES_NONE = 16 +}; + typedef struct { int border, title_align, no_fill; + /* A mask of HQ_SIDE_*, or 0 for all four. */ + int sides; /* Merge this border with one already in the same cell rather than * overwriting it. Zero unless the caller asks for collapsed borders. */ int collapse; diff --git a/ports/c/src/surface.c b/ports/c/src/surface.c index 0ed9b93..b992fc4 100644 --- a/ports/c/src/surface.c +++ b/ports/c/src/surface.c @@ -149,13 +149,31 @@ static void hq_put_border(hq_surface s,int border,int collapse,int x,int y,uint3 hq_surface_set(s,x,y,cp,st); } +/* The glyph for a cell where two edges meet, given which of them are drawn. A + * single edge has no glyph of its own, so the plain rule stands in: that cell + * is part of a run, not a corner. */ +static uint32_t hq_side_glyph(int border,int bits) { + if(!bits) return 0; + uint32_t g=hq_border_glyph(border,bits); + if(g) return g; + return bits&(HQ_EDGE_LEFT|HQ_EDGE_RIGHT) ? hq_borders[border][4]:hq_borders[border][5]; +} + hq_surface hq_surface_box(hq_surface s,hq_box_options o) { const hq_theme *theme=s.theme ? s.theme:hq_theme_at(0); if(o.has_background && !o.no_fill) { hq_style bg={0,o.background,0,HQ_STYLE_BG}; hq_surface_fill(s,32,bg); } - if(o.border==HQ_NO_BORDER) return s; - hq_surface inner=hq_surface_region(s,hq_inset(s.rect,1,1,1,1)); + /* Zero means all four, so a caller that predates this gets what it always + * got. HQ_SIDES_NONE is an explicit "no rule at all". */ + int sides=o.sides==0 ? HQ_SIDES_ALL:(o.sides&HQ_SIDES_NONE ? 0:o.sides&HQ_SIDES_ALL); + int s_top=(sides&HQ_SIDE_TOP)!=0, s_right=(sides&HQ_SIDE_RIGHT)!=0; + int s_bottom=(sides&HQ_SIDE_BOTTOM)!=0, s_left=(sides&HQ_SIDE_LEFT)!=0; + + if(o.border==HQ_NO_BORDER || !sides) return s; + /* The interior follows the sides actually drawn, so a top-only box costs + * one row rather than two. */ + hq_surface inner=hq_surface_region(s,hq_inset(s.rect,s_top,s_right,s_bottom,s_left)); if(s.rect.width<2 || s.rect.height<1) return inner; int border=o.border>=0 && o.border<6 ? o.border:0; const uint32_t *c=hq_borders[border]; @@ -164,21 +182,31 @@ hq_surface hq_surface_box(hq_surface s,hq_box_options o) { /* With collapsing on a border glyph landing on another becomes the union * of the two; without it this is the plain write it always was, so a * screen that never asks for collapsing renders byte for byte as before. */ - hq_put_border(s,border,o.collapse,0,0,c[0],bs); - hq_put_border(s,border,o.collapse,w-1,0,c[1],bs); - for(int x=1;x1) { - hq_put_border(s,border,o.collapse,0,h-1,c[2],bs); - hq_put_border(s,border,o.collapse,w-1,h-1,c[3],bs); - for(int x=1;x=(size_t)w) sw=0; } - if(o.title) { + if(o.subtitle && s_top) { label(sub,o.subtitle); sw=hq_text_width(sub); if(sw+4>=(size_t)w) sw=0; } + if(o.title && s_top) { label(title,o.title); int limit=sw ? w-1-(int)sw:w-2,room=hq_max(0,limit-2); size_t tw=hq_text_width(title); @@ -200,7 +228,7 @@ hq_surface hq_surface_box(hq_surface s,hq_box_options o) { hq_text_options t={0}; t.style=default_style(o.subtitle_style,theme->muted,o); hq_surface_text(s,w-2-(int)sw,0,sub,t); } - if(o.footer && h>2) { + if(o.footer && s_bottom && h>2) { label(foot,o.footer); if(hq_text_width(foot)+4<(size_t)w) { hq_text_options t={0}; t.style=default_style(o.footer_style,theme->muted,o); diff --git a/ports/c/tests/conformance.py b/ports/c/tests/conformance.py index 6a86f01..b1f55ff 100644 --- a/ports/c/tests/conformance.py +++ b/ports/c/tests/conformance.py @@ -44,7 +44,10 @@ class Surface(C.Structure): _fields_ = [("buffer", P), ("theme", C.POINTER(Theme)), ("rect", Rect), ("clip", Rect)] class Box(C.Structure): - _fields_ = [("border", I), ("title_align", I), ("no_fill", I)] + [ + # Mirrors hq_box_options field for field. ctypes passes this by value, so a + # field missing here shifts every one after it and the C side reads whatever + # happens to be next on the stack. + _fields_ = [("border", I), ("title_align", I), ("no_fill", I), ("sides", I), ("collapse", I)] + [ (k, Style) for k in ("border_style", "title_style", "subtitle_style", "footer_style")] + [ ("background", U), ("has_background", I), ("title", S), ("subtitle", S), ("footer", S)] diff --git a/ports/cpp/include/hqtui/widgets.hpp b/ports/cpp/include/hqtui/widgets.hpp index fd38055..96eac02 100644 --- a/ports/cpp/include/hqtui/widgets.hpp +++ b/ports/cpp/include/hqtui/widgets.hpp @@ -636,16 +636,20 @@ class UI { void col(Constraint size, int gap, std::function body) { group(size, gap, false, std::move(body)); } + /// `sides` is a mask of HQ_SIDE_*, or 0 for all four. Use it for chrome that + /// is not a box: a header rule, a sidebar rail, a footer that should not look + /// boxed in. void panel(std::string title, std::function body, Constraint size = fr(), std::string subtitle = {}, Color border = 0, std::optional bg = {}, - Color subtitle_color = 0) { + Color subtitle_color = 0, int sides = 0) { auto regions_ = regions; auto collapse = collapse_; draw_bordered( [=](Surface s) { hq_box_options o{}; o.collapse = collapse ? 1 : 0; + o.sides = sides; o.title = title.empty() ? nullptr : title.c_str(); o.subtitle = subtitle.empty() ? nullptr : subtitle.c_str(); if (border) diff --git a/ports/go/surface.go b/ports/go/surface.go index e03f7a9..3e9c5d7 100644 --- a/ports/go/surface.go +++ b/ports/go/surface.go @@ -1,6 +1,9 @@ package hqtui -import "unicode/utf8" +import ( + "strings" + "unicode/utf8" +) // A clipped, translated view onto the framebuffer. Widgets only ever see a // Surface, so nothing can draw outside the rectangle it was given. @@ -175,6 +178,10 @@ type BoxOptions struct { // rather than overwriting it. Set for you by the container when the app // asks for collapsed borders; there is no reason to pass it by hand. Collapse bool + // Sides says which edges to draw. The zero value is all four, and the + // interior follows the sides actually drawn, so a top-only box costs one + // row rather than two. + Sides Sides // NoFill skips painting the interior with Bg before drawing. NoFill bool Footer string @@ -338,6 +345,79 @@ func (s Surface) mergeBorder(x, y int, ch rune, style BorderStyle, cellStyle Sty s.Glyph(x, y, ch, cellStyle) } +// Sides says which edges of a box to draw. The zero value is all four, so a +// caller that has never heard of this gets the box it always got. +type Sides struct { + Top, Right, Bottom, Left bool + // None draws no rule and insets nothing, the same as a border of BorderNone. + // A struct of four falses would otherwise be indistinguishable from the + // zero value, which has to mean "all". + None bool +} + +// AllSides is what a box was before partial borders existed. +func AllSides() Sides { return Sides{Top: true, Right: true, Bottom: true, Left: true} } + +// NoSides draws no rule at all. +func NoSides() Sides { return Sides{None: true} } + +// resolve turns the zero value into all four. +func (s Sides) resolve() Sides { + if s.None { + return Sides{} + } + if !s.Top && !s.Right && !s.Bottom && !s.Left { + return AllSides() + } + return s +} + +func (s Sides) any() bool { return s.Top || s.Right || s.Bottom || s.Left } + +// ParseSides reads the spelling the reference API uses: "all", "none", or a +// comma-separated list of sides. +func ParseSides(spec string) Sides { + switch strings.TrimSpace(spec) { + case "", "all": + return AllSides() + case "none": + return NoSides() + } + has := func(name string) bool { + for _, part := range strings.Split(spec, ",") { + if strings.TrimSpace(part) == name { + return true + } + } + return false + } + out := Sides{Top: has("top"), Right: has("right"), Bottom: has("bottom"), Left: has("left")} + if !out.any() { + return NoSides() + } + return out +} + +// sideGlyph is the glyph for a cell where two edges meet, given which of them +// are drawn. A single edge has no glyph of its own, so the plain rule stands +// in: that cell is part of a run, not a corner. +func sideGlyph(style BorderStyle, bits int) (rune, bool) { + if bits == 0 { + return 0, false + } + if glyph, ok := BorderGlyph(style, bits); ok { + return glyph, true + } + chars, ok := style.Chars() + if !ok { + return 0, false + } + if bits&(EdgeLeft|EdgeRight) != 0 { + return chars.H, true + } + return chars.V, true +} + func (s Surface) Box(o BoxOptions) Surface { fg := s.Theme.Border if o.BorderColor != nil { @@ -349,8 +429,9 @@ func (s Surface) Box(o BoxOptions) Surface { s.Fill(Style{Bg: bg}) } + sides := o.Sides.resolve() chars, hasBorder := o.Border.Chars() - if !hasBorder { + if !hasBorder || !sides.any() { return s.Inset(Padding{}) } if s.Width() < 2 || s.Height() < 1 { @@ -381,15 +462,45 @@ func (s Surface) Box(o BoxOptions) Surface { } } - put(0, 0, chars.TL) - put(w-1, 0, chars.TR) - putH(1, 0, w-2, chars.H) + // A corner belongs to the two sides that meet there, so it exists only when + // both are drawn; where one is, the rule runs straight through the cell the + // corner would have occupied. + corner := func(a bool, aBit int, b bool, bBit int) (rune, bool) { + bits := 0 + if a { + bits |= aBit + } + if b { + bits |= bBit + } + return sideGlyph(o.Border, bits) + } + + if sides.Top { + putH(1, 0, w-2, chars.H) + } + if ch, ok := corner(sides.Top, EdgeRight, sides.Left, EdgeDown); ok { + put(0, 0, ch) + } + if ch, ok := corner(sides.Top, EdgeLeft, sides.Right, EdgeDown); ok { + put(w-1, 0, ch) + } if h > 1 { - put(0, h-1, chars.BL) - put(w-1, h-1, chars.BR) - putH(1, h-1, w-2, chars.H) - putV(0, 1, h-2, chars.V) - putV(w-1, 1, h-2, chars.V) + if sides.Bottom { + putH(1, h-1, w-2, chars.H) + } + if ch, ok := corner(sides.Bottom, EdgeRight, sides.Left, EdgeUp); ok { + put(0, h-1, ch) + } + if ch, ok := corner(sides.Bottom, EdgeLeft, sides.Right, EdgeUp); ok { + put(w-1, h-1, ch) + } + if sides.Left { + putV(0, 1, h-2, chars.V) + } + if sides.Right { + putV(w-1, 1, h-2, chars.V) + } } // Measured before the title is drawn: both share the top border row, and @@ -454,7 +565,22 @@ func (s Surface) Box(o BoxOptions) Surface { } } - return s.Sub(1, 1, max(0, w-2), max(0, h-2)) + // The interior follows the sides actually drawn. + left, top := 0, 0 + if sides.Left { + left = 1 + } + if sides.Top { + top = 1 + } + shrinkX, shrinkY := left, top + if sides.Right { + shrinkX++ + } + if sides.Bottom { + shrinkY++ + } + return s.Sub(left, top, max(0, w-shrinkX), max(0, h-shrinkY)) } // firstRune is the reference's `codePointAt(0)` on a one-glyph string. diff --git a/ports/python/hqtui/surface.py b/ports/python/hqtui/surface.py index e2495ea..a06bad7 100644 --- a/ports/python/hqtui/surface.py +++ b/ports/python/hqtui/surface.py @@ -143,11 +143,46 @@ class BoxOptions: footer: str = "" footer_color: Color | None = None collapse: bool = False + #: Which edges to draw: None for all four, "none" for no rule, or a + #: sequence of "top"/"right"/"bottom"/"left". The interior follows the + #: sides actually drawn, so a top-only box costs one row rather than two. + sides: object = None """Merge this border with one already in the same cell rather than overwriting it. Set for you by the container when the app asks for collapsed borders; there is no reason to pass it by hand.""" +#: Which edges of a box to draw. ``None`` means all four, which is what a box +#: was before this existed; an empty tuple draws no rule and insets nothing, +#: the same as a border style of "none". +SIDES = ("top", "right", "bottom", "left") + + +def resolve_sides(sides) -> dict: + """Turn whatever the caller gave into four flags.""" + if sides is None or sides == "all": + return {name: True for name in SIDES} + if sides == "none": + return {name: False for name in SIDES} + chosen = set(sides) + return {name: name in chosen for name in SIDES} + + +def side_glyph(border: str, bits: int) -> "str | None": + """The glyph for a cell where two edges meet, given which are drawn. + + A single edge has no glyph of its own, so the plain rule stands in: that + cell is part of a run, not a corner. + """ + if not bits: + return None + glyph = border_glyph(border, bits) + if glyph is not None: + return glyph + chars = BORDERS[border] + return chars.h if bits & (EDGE_LEFT | EDGE_RIGHT) else chars.v + + class Surface: __slots__ = ("buffer", "rect", "clip", "theme") @@ -278,7 +313,7 @@ def _merge_border(self, x: int, y: int, ch: str, style: str, cell_style: Style) ch = border_glyph(style, before | after) or ch self.char(x, y, ch, cell_style) - def box(self, options: BoxOptions = BoxOptions()) -> "Surface": + def box(self, options: BoxOptions = BoxOptions()) -> "Surface": # noqa: C901 """Draw a bordered box with an optional title, and return the interior. Every panel in the library goes through here. @@ -290,7 +325,8 @@ def box(self, options: BoxOptions = BoxOptions()) -> "Surface": if options.fill and bg is not None: self.fill(Style(bg=bg)) - if border == "none": + sides = resolve_sides(options.sides) + if border == "none" or not any(sides.values()): return self.inset(0) if self.width < 2 or self.height < 1: return self.inset(1) @@ -316,15 +352,33 @@ def put_v(x: int, y: int, length: int, ch: str) -> None: for i in range(length): put(x, y + i, ch) - put(0, 0, b.tl) - put(w - 1, 0, b.tr) - put_h(1, 0, w - 2, b.h) + # A corner belongs to the two sides that meet there, so it exists only + # when both are drawn; where one is, the rule runs straight through the + # cell the corner would have occupied. + def corner(a: bool, a_bit: int, c: bool, c_bit: int) -> "str | None": + return side_glyph(border, (a_bit if a else 0) | (c_bit if c else 0)) + + if sides["top"]: + put_h(1, 0, w - 2, b.h) + tl = corner(sides["top"], EDGE_RIGHT, sides["left"], EDGE_DOWN) + tr = corner(sides["top"], EDGE_LEFT, sides["right"], EDGE_DOWN) + if tl is not None: + put(0, 0, tl) + if tr is not None: + put(w - 1, 0, tr) if h > 1: - put(0, h - 1, b.bl) - put(w - 1, h - 1, b.br) - put_h(1, h - 1, w - 2, b.h) - put_v(0, 1, h - 2, b.v) - put_v(w - 1, 1, h - 2, b.v) + bl = corner(sides["bottom"], EDGE_RIGHT, sides["left"], EDGE_UP) + br = corner(sides["bottom"], EDGE_LEFT, sides["right"], EDGE_UP) + if sides["bottom"]: + put_h(1, h - 1, w - 2, b.h) + if bl is not None: + put(0, h - 1, bl) + if br is not None: + put(w - 1, h - 1, br) + if sides["left"]: + put_v(0, 1, h - 2, b.v) + if sides["right"]: + put_v(w - 1, 1, h - 2, b.v) # Measured before the title is drawn: both share the top border row, and # the title used to be truncated against the full width and then painted @@ -376,4 +430,9 @@ def put_v(x: int, y: int, length: int, ch: str) -> None: ) self.text(2, h - 1, foot, TextOptions(fg=color, bg=bg)) - return self.sub(1, 1, max(0, w - 2), max(0, h - 2)) + # The interior follows the sides actually drawn. + left = 1 if sides["left"] else 0 + top = 1 if sides["top"] else 0 + shrink_x = left + (1 if sides["right"] else 0) + shrink_y = top + (1 if sides["bottom"] else 0) + return self.sub(left, top, max(0, w - shrink_x), max(0, h - shrink_y)) diff --git a/ports/rust/src/surface.rs b/ports/rust/src/surface.rs index 4ac06dc..c598b5e 100644 --- a/ports/rust/src/surface.rs +++ b/ports/rust/src/surface.rs @@ -96,6 +96,21 @@ pub fn border_bits(ch: char) -> Option { } /// The glyph in `style` with exactly these edges, or `None` if there is none. +/// The glyph for a cell where two edges meet, given which of them are drawn. +/// +/// A single edge has no glyph of its own, so the plain rule stands in: that +/// cell is part of a run, not a corner. +pub fn side_glyph(style: BorderStyle, bits: u8) -> Option { + if bits == 0 { + return None; + } + if let Some(glyph) = border_glyph(style, bits) { + return Some(glyph); + } + let chars = style.chars()?; + Some(if bits & (EDGE_LEFT | EDGE_RIGHT) != 0 { chars.h } else { chars.v }) +} + pub fn border_glyph(style: BorderStyle, bits: u8) -> Option { let chars = style.chars()?; let parts = chars.parts(); @@ -199,6 +214,55 @@ impl From