From 26cdafae711dd34fa674340badc45fb68dd512da Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 8 Sep 2026 21:33:04 +0000 Subject: [PATCH 1/3] Panels: draw only some border sides (#59) A border was all four sides or nothing. Anything else meant a divider plus manual padding, which gives you no corner joins and no way to say "just a top rule" -- the thing you reach for building a header strip, a sidebar rail, or a footer that should not look boxed in. ui.panel({ title: "Header", border: "single", sides: ["top"] }, ...) `sides` takes "all" (the default), "none", or a list. The interior follows the sides actually drawn, so a top-only panel costs one row rather than two. The bit vocabulary the collapse work left behind did most of it, as #59 guessed it would. 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. That is borderGlyph with the bits for the sides present, falling back to the plain rule when only one edge is set -- a single edge has no glyph of its own because that cell is part of a run, not a corner. A title and a subtitle live on the top rule and a footer on the bottom, so none of them are drawn when their rule is absent: painting a title over the first row of content is worse than leaving it out. All six ports. Defaulting to all four sides keeps every existing frame identical, which the suites confirm: TypeScript 249, Rust green, Go both packages, Python 36, Zig 17/17, C and C++ 9/9. Closes #59 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy --- packages/hqtui/src/index.ts | 5 +- packages/hqtui/src/surface.ts | 97 +++++++++++++++--- packages/hqtui/src/ui.ts | 9 +- packages/hqtui/test/sides.test.ts | 115 +++++++++++++++++++++ ports/c/include/hqtui.h | 14 +++ ports/c/src/surface.c | 54 +++++++--- ports/cpp/include/hqtui/widgets.hpp | 6 +- ports/go/surface.go | 148 +++++++++++++++++++++++++--- ports/python/hqtui/surface.py | 81 ++++++++++++--- ports/rust/src/surface.rs | 117 ++++++++++++++++++++-- ports/zig/src/surface.zig | 107 ++++++++++++++++++-- 11 files changed, 682 insertions(+), 71 deletions(-) create mode 100644 packages/hqtui/test/sides.test.ts 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/sides.test.ts b/packages/hqtui/test/sides.test.ts new file mode 100644 index 0000000..c75a3bb --- /dev/null +++ b/packages/hqtui/test/sides.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +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+$/, "")); +}; + +describe("partial borders", () => { + test("no sides given is all four, exactly as before", () => { + expect(resolveSides(undefined)).toEqual({ top: true, right: true, bottom: true, left: true }); + expect(resolveSides("all")).toEqual({ top: true, right: true, bottom: true, left: true }); + + expect(draw(undefined)).toEqual([ + "┌────────┐", + "│ ab │", + "│ │", + "└────────┘", + ]); + }); + + test("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. + expect(draw(["top"])).toEqual(["──────────", " ab", "", ""]); + }); + + test("a bottom rule leaves the top free for content", () => { + expect(draw(["bottom"])).toEqual([" ab", "", "", "──────────"]); + }); + + test("left and right are rails with nothing joining them", () => { + // No top rule, so the content sits on row 0 between the two rails. + expect(draw(["left", "right"])).toEqual([ + "│ ab │", + "│ │", + "│ │", + "│ │", + ]); + }); + + test("two sides that meet get their corner, and only that one", () => { + expect(draw(["top", "left"])).toEqual(["┌─────────", "│ ab", "│", "│"]); + }); + + test("three sides leave the fourth open", () => { + expect(draw(["top", "left", "right"])).toEqual([ + "┌────────┐", + "│ ab │", + "│ │", + "│ │", + ]); + }); + + test("none draws nothing and costs nothing", () => { + expect(resolveSides("none")).toEqual({ top: false, right: false, bottom: false, left: false }); + // The whole area is the caller's, exactly as a border style of "none". + expect(draw("none")).toEqual([" ab", "", "", ""]); + }); + + test("an empty list is the same as none", () => { + expect(draw([])).toEqual(draw("none")); + }); + + test("the interior costs one cell per rule, and only per rule", () => { + expect(draw(["bottom"])[0]).toBe(" ab"); + expect(draw(["top"])[1]).toBe(" ab"); + expect(draw(["left"])[0]).toBe("│ ab"); + // Right-only still starts the content at column 0. + expect(draw(["right"])[0]).toBe(" ab │"); + }); + + test("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(); + expect(withTop).toContain("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(); + expect(withoutTop).not.toContain("Head"); + }); + + test("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(); + expect(after).toBe(before); + } + }); +}); 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/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