From f86b91a07238f6163ca867b305921a9a0654cd43 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 8 Sep 2026 23:49:44 +0000 Subject: [PATCH] A drop shadow that dims what it covers The last of #64. A shadow cast by a region onto whatever is behind it, and the point of it is that it dims rather than paints: a shadow that filled its band with a flat colour would erase the dashboard underneath, which is the opposite of what a shadow is for. Every covered cell keeps its character and its hue and only loses some of its light. app.render(({ ui, theme }) => { ui.ctx.overlay(root => { drawShadow(root, dialogRect); // ... then draw the dialog into the same rect }); }); `dimRect` is the effect on its own, for anyone who wants it without the geometry. A `color` option paints instead, for a shadow falling on empty background where there is nothing to dim. Two bugs the ports found, both worth writing down. The colour encoding has a sentinel. Zero is not black -- it means "the terminal's own colour" -- so the first version mixed towards it and *drained* every cell it touched instead of darkening it. Rust disagreeing with the fixture is what surfaced it; the TypeScript looked plausible on its own. It is `rgb(0, 0, 0)` now, in every port, and the fixture that captured the wrong behaviour was regenerated. The corner was dimmed twice. Splitting the shadow into "the band down the side" and "the band along the bottom" overlaps where they meet, and a cell dimmed twice is visibly darker than the rest -- it reads as a smudge rather than an edge. The shadow is now the moved region minus the original, cut into two rectangles that do not touch. A test counts the shades. Dimming goes towards black rather than towards the theme's background: on a light theme the background *is* the light, so dimming towards it would make the shadow brighter than the page it falls on. Six ports, four fixtures, all additive. Verified: 339 TS tests under bun and node; 90 widget scenes matching the reference in Rust, Go, Python, Zig and C++; 11/11 ctest; the galleries; the site builds. Closes #64 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy --- packages/hqtui/src/widgets/index.ts | 1 + packages/hqtui/src/widgets/shadow.ts | 101 ++++++ packages/hqtui/test/shadow.test.ts | 94 ++++++ ports/conformance/fixtures/widgets.json | 292 ++++++++++++++++++ ports/conformance/generate.ts | Bin 49059 -> 49961 bytes ports/cpp/CMakeLists.txt | 2 +- ports/cpp/include/hqtui/widgets.hpp | 13 + ports/cpp/src/shadow.cpp | 74 +++++ ports/cpp/tests/conformance_widgets.cpp | 36 +++ ports/go/conformance_widgets_test.go | 17 + ports/go/widgets_shadow.go | 101 ++++++ ports/python/hqtui/widgets/__init__.py | 2 + ports/python/hqtui/widgets/shadow.py | 99 ++++++ .../python/tests/test_conformance_widgets.py | 14 + ports/rust/src/surface.rs | 9 + ports/rust/src/widgets/mod.rs | 2 + ports/rust/src/widgets/shadow.rs | 115 +++++++ ports/rust/tests/conformance_widgets.rs | 30 ++ ports/zig/src/conformance_widgets.zig | 15 + ports/zig/src/widgets.zig | 4 + ports/zig/src/widgets/shadow.zig | 101 ++++++ 21 files changed, 1121 insertions(+), 1 deletion(-) create mode 100644 packages/hqtui/src/widgets/shadow.ts create mode 100644 packages/hqtui/test/shadow.test.ts create mode 100644 ports/cpp/src/shadow.cpp create mode 100644 ports/go/widgets_shadow.go create mode 100644 ports/python/hqtui/widgets/shadow.py create mode 100644 ports/rust/src/widgets/shadow.rs create mode 100644 ports/zig/src/widgets/shadow.zig diff --git a/packages/hqtui/src/widgets/index.ts b/packages/hqtui/src/widgets/index.ts index 25f68b9..b0f075f 100644 --- a/packages/hqtui/src/widgets/index.ts +++ b/packages/hqtui/src/widgets/index.ts @@ -1,6 +1,7 @@ export * from "./text.ts"; export * from "./calendar.ts"; export * from "./chart.ts"; +export * from "./shadow.ts"; export * from "./surface.ts"; export * from "./scrollbar.ts"; export * from "./table.ts"; diff --git a/packages/hqtui/src/widgets/shadow.ts b/packages/hqtui/src/widgets/shadow.ts new file mode 100644 index 0000000..062da01 --- /dev/null +++ b/packages/hqtui/src/widgets/shadow.ts @@ -0,0 +1,101 @@ +/** + * A drop shadow cast by a region onto whatever is behind it. + * + * The point is that it dims what it covers rather than painting over it: a + * shadow that filled its band with a flat colour would erase the dashboard + * underneath, which is the opposite of what a shadow is for. Every covered cell + * keeps its own character and its own hue, and only loses some of its light. + */ +import type { Surface } from "../surface.ts"; +import type { Rect } from "../layout.ts"; +import { type Color, mix, rgb } from "../color.ts"; + +export interface ShadowOptions { + /** How far the shadow falls. Default one cell right and one down. */ + offsetX?: number; + offsetY?: number; + /** 0-1: how much light the covered cells lose. Default 0.55. */ + amount?: number; + /** + * Paint this colour instead of dimming what is underneath. + * + * For a shadow falling on empty background, where there is nothing to dim + * and a flat colour is cheaper and reads the same. + */ + color?: Color; +} + +/** + * Darken every cell in a region, keeping its character and its hue. + * + * Towards black rather than towards the theme's background: on a light theme + * the background *is* the light, so dimming towards it would make the shadow + * brighter than the page it falls on. + */ +export function dimRect( + surface: Surface, + x: number, + y: number, + width: number, + height: number, + amount: number, +): void { + const buffer = surface.buffer; + const t = amount < 0 ? 0 : amount > 1 ? 1 : amount; + // `rgb(0, 0, 0)`, not the literal 0: zero is the sentinel for "the terminal's + // own colour", so mixing towards it drains a cell's colour rather than + // darkening it. + const black = rgb(0, 0, 0); + for (let row = 0; row < height; row++) { + for (let col = 0; col < width; col++) { + const ax = surface.rect.x + x + col; + const ay = surface.rect.y + y + row; + if ( + ax < surface.clip.x || ay < surface.clip.y || + ax >= surface.clip.x + surface.clip.width || + ay >= surface.clip.y + surface.clip.height + ) continue; + const i = buffer.index(ax, ay); + buffer.fg[i] = mix(buffer.fg[i], black, t); + buffer.bg[i] = mix(buffer.bg[i], black, t); + } + } +} + +/** + * Cast a shadow from `rect` onto `surface`. + * + * The shadow is the band the region would cover if it were moved by the offset, + * minus the region itself -- an L along the two trailing edges. Drawn before the + * region is, so it never falls on top of it. + */ +export function drawShadow(surface: Surface, rect: Rect, options: ShadowOptions = {}): void { + if (surface.empty) return; + const dx = Math.trunc(options.offsetX ?? 1); + const dy = Math.trunc(options.offsetY ?? 1); + if (dx === 0 && dy === 0) return; + const amount = options.amount ?? 0.55; + + const paint = (x: number, y: number, w: number, h: number): void => { + if (w <= 0 || h <= 0) return; + if (options.color !== undefined) surface.fillRect(x, y, w, h, { bg: options.color }, 32); + else dimRect(surface, x, y, w, h, amount); + }; + + // The shadow is the moved region minus the original, which splits into two + // rectangles that do not touch: the rows the move added, at the moved + // region's full width, and then the columns it added over the rows the two + // still share. Cutting it any other way overlaps at the corner, and a corner + // dimmed twice reads as a smudge rather than an edge. + const tx = rect.x + dx; + const ty = rect.y + dy; + if (dy > 0) paint(tx, rect.y + rect.height, rect.width, dy); + else if (dy < 0) paint(tx, ty, rect.width, -dy); + + const y0 = Math.max(rect.y, ty); + const shared = Math.min(rect.y + rect.height, ty + rect.height) - y0; + if (shared > 0) { + if (dx > 0) paint(rect.x + rect.width, y0, dx, shared); + else if (dx < 0) paint(tx, y0, -dx, shared); + } +} diff --git a/packages/hqtui/test/shadow.test.ts b/packages/hqtui/test/shadow.test.ts new file mode 100644 index 0000000..9b7fd70 --- /dev/null +++ b/packages/hqtui/test/shadow.test.ts @@ -0,0 +1,94 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderToScreen } from "../src/index.ts"; +import { dimRect, drawShadow } from "../src/widgets/shadow.ts"; +import { rgb } from "../src/color.ts"; + +/** A screen filled with text, then a shadow cast over part of it. */ +const shaded = ( + rect: { x: number; y: number; width: number; height: number }, + options: Parameters[2] = {}, + width = 12, + height = 6, +) => + renderToScreen(({ ui }) => { + ui.fill({ symbol: "x" }); + ui.ctx.overlay((root) => drawShadow(root, rect, options)); + }, { width, height }); + +test("shadow: it dims what it covers rather than painting over it", () => { + const screen = shaded({ x: 1, y: 1, width: 4, height: 2 }); + // Every character survives; a shadow that blanked its band would erase the + // dashboard underneath, which is the opposite of what a shadow is for. + assert.equal(screen.text().replace(/\n/g, ""), "x".repeat(12 * 6)); + // And some cells are darker than the rest. + const colours = new Set(screen.buffer.fg); + assert.equal(colours.size, 2, `expected two shades, got ${colours.size}`); +}); + +test("shadow: it falls outside the region, never on it", () => { + const plain = renderToScreen(({ ui }) => ui.fill({ symbol: "x" }), { width: 12, height: 6 }); + const screen = shaded({ x: 1, y: 1, width: 4, height: 2 }); + const changed: [number, number][] = []; + for (let y = 0; y < 6; y++) { + for (let x = 0; x < 12; x++) { + const i = y * 12 + x; + if (screen.buffer.fg[i] !== plain.buffer.fg[i]) changed.push([x, y]); + } + } + assert.ok(changed.length > 0, "nothing was shaded"); + for (const [x, y] of changed) { + const inside = x >= 1 && x < 5 && y >= 1 && y < 3; + assert.ok(!inside, `shaded (${x}, ${y}), which is inside the region`); + } +}); + +test("shadow: the corner is dimmed once, not twice", () => { + // The two bands meet there. Dimming it twice makes it visibly darker than the + // rest of the shadow, which reads as a smudge rather than an edge. + const screen = shaded({ x: 1, y: 1, width: 4, height: 2 }, { offsetX: 2, offsetY: 2 }); + const shades = new Set(screen.buffer.fg); + assert.equal(shades.size, 2, `expected one shade of shadow, got ${shades.size - 1}`); +}); + +test("shadow: a zero offset casts nothing", () => { + const plain = renderToScreen(({ ui }) => ui.fill({ symbol: "x" }), { width: 12, height: 6 }); + const none = shaded({ x: 1, y: 1, width: 4, height: 2 }, { offsetX: 0, offsetY: 0 }); + assert.deepEqual([...none.buffer.fg], [...plain.buffer.fg]); +}); + +test("shadow: a colour paints instead of dimming", () => { + // For a shadow falling on empty background, where there is nothing to dim. + const screen = shaded({ x: 1, y: 1, width: 4, height: 2 }, { color: rgb(0x12, 0x34, 0x56) }); + assert.ok([...screen.buffer.bg].includes(rgb(0x12, 0x34, 0x56)), "the colour was never painted"); +}); + +test("shadow: it can fall up and to the left", () => { + const down = shaded({ x: 4, y: 2, width: 4, height: 2 }, { offsetX: 1, offsetY: 1 }); + const up = shaded({ x: 4, y: 2, width: 4, height: 2 }, { offsetX: -1, offsetY: -1 }); + assert.notDeepEqual([...down.buffer.fg], [...up.buffer.fg]); + // Up-left shades the row above the region; down-right does not. + const dimmed = (s: typeof down, x: number, y: number) => + s.buffer.fg[y * 12 + x] !== down.buffer.fg[0 * 12 + 0]; + assert.ok(dimmed(up, 3, 1), "the up-left shadow missed the corner above"); +}); + +test("shadow: dimming darkens rather than washing out", () => { + // Towards black, not towards the theme background: on a light theme the + // background is the light, and dimming towards it would make the shadow + // brighter than the page it falls on. + const screen = renderToScreen(({ ui }) => { + ui.fill({ symbol: "x", fg: rgb(255, 255, 255), bg: rgb(255, 255, 255) }); + ui.ctx.overlay((root) => dimRect(root, 0, 0, 4, 1, 0.5)); + }, { width: 8, height: 1 }); + const dimmedCell = screen.buffer.fg[0]; + const plainCell = screen.buffer.fg[5]; + assert.equal(plainCell, rgb(255, 255, 255)); + assert.ok(dimmedCell < plainCell, `expected darker, got ${dimmedCell.toString(16)}`); +}); + +test("shadow: it stops at the edge of the surface", () => { + // A region flush against the bottom-right corner has nowhere to cast. + const screen = shaded({ x: 8, y: 4, width: 4, height: 2 }); + assert.equal(screen.text().replace(/\n/g, ""), "x".repeat(12 * 6)); +}); diff --git a/ports/conformance/fixtures/widgets.json b/ports/conformance/fixtures/widgets.json index af28aec..e0b7877 100644 --- a/ports/conformance/fixtures/widgets.json +++ b/ports/conformance/fixtures/widgets.json @@ -4972,6 +4972,298 @@ ] } }, + { + "name": "shadow", + "width": 16, + "height": 5, + "result": { + "width": 16, + "height": 5, + "chars": [ + [ + 80, + 120 + ] + ], + "fg": [ + [ + 40, + 29806811 + ], + [ + 1, + 22634083 + ], + [ + 10, + 29806811 + ], + [ + 6, + 22634083 + ], + [ + 23, + 29806811 + ] + ], + "bg": [ + [ + 40, + 17106698 + ], + [ + 1, + 16909061 + ], + [ + 10, + 17106698 + ], + [ + 6, + 16909061 + ], + [ + 23, + 17106698 + ] + ], + "attrs": [ + [ + 80, + 0 + ] + ], + "clusters": [], + "text": [ + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx" + ] + } + }, + { + "name": "shadow-offset", + "width": 16, + "height": 5, + "result": { + "width": 16, + "height": 5, + "chars": [ + [ + 80, + 120 + ] + ], + "fg": [ + [ + 40, + 29806811 + ], + [ + 2, + 22634083 + ], + [ + 10, + 29806811 + ], + [ + 6, + 22634083 + ], + [ + 22, + 29806811 + ] + ], + "bg": [ + [ + 40, + 17106698 + ], + [ + 2, + 16909061 + ], + [ + 10, + 17106698 + ], + [ + 6, + 16909061 + ], + [ + 22, + 17106698 + ] + ], + "attrs": [ + [ + 80, + 0 + ] + ], + "clusters": [], + "text": [ + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx" + ] + } + }, + { + "name": "shadow-back", + "width": 16, + "height": 5, + "result": { + "width": 16, + "height": 5, + "chars": [ + [ + 80, + 120 + ] + ], + "fg": [ + [ + 20, + 29806811 + ], + [ + 6, + 22634083 + ], + [ + 10, + 29806811 + ], + [ + 1, + 22634083 + ], + [ + 43, + 29806811 + ] + ], + "bg": [ + [ + 20, + 17106698 + ], + [ + 6, + 16909061 + ], + [ + 10, + 17106698 + ], + [ + 1, + 16909061 + ], + [ + 43, + 17106698 + ] + ], + "attrs": [ + [ + 80, + 0 + ] + ], + "clusters": [], + "text": [ + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx" + ] + } + }, + { + "name": "shadow-solid", + "width": 16, + "height": 5, + "result": { + "width": 16, + "height": 5, + "chars": [ + [ + 40, + 120 + ], + [ + 1, + 32 + ], + [ + 10, + 120 + ], + [ + 6, + 32 + ], + [ + 23, + 120 + ] + ], + "fg": [ + [ + 80, + 29806811 + ] + ], + "bg": [ + [ + 40, + 17106698 + ], + [ + 1, + 17830936 + ], + [ + 10, + 17106698 + ], + [ + 6, + 17830936 + ], + [ + 23, + 17106698 + ] + ], + "attrs": [ + [ + 80, + 0 + ] + ], + "clusters": [], + "text": [ + "xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxx", + "xxxxxxxx xxxxxxx", + "xxx xxxxxxx", + "xxxxxxxxxxxxxxxx" + ] + } + }, { "name": "badge", "width": 20, diff --git a/ports/conformance/generate.ts b/ports/conformance/generate.ts index 5a65c90d3ac688fb2b57aff11fb5c4d967c74ec4..34222fb663c98139ac16e102c0fba6aacd3dbb7b 100644 GIT binary patch delta 722 zcmbu6%}N6?6oo-?o+Nnx$*B6*45Etr7 z!HsXHcQW9j6kV7_2;6(lJ@?Dg*WBx5{{3?Du)dB1ILTs@Ayz$yOfnDU!Oc)u2g|$^ z7G5%qK2zE&-9=HNo(RrRMJ$328F9%h9mDAK7O8Q9lsy5H ztQC4?k*cor&0yrX(4tl?GgTwBb|P%&htJFT=eV*zs$yK<3yr`@Gqx-{3h8Su!aYVE zY(}{4pcNrgv6mf?oJNp6mGY~X)uqthFiGNspY}ihpFhS$h|>BrL@D2A6exeZTS|`m zfrAg+kcxyLW>Rqp color; +}; +/// Darken every cell in a region, keeping its character and its hue. +void dim_rect(Surface, int x, int y, int width, int height, double amount); +/// Cast a shadow from `rect` onto the surface, behind whatever sits there. +void draw_shadow(Surface, Rect rect, const Shadow & = {}); 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. diff --git a/ports/cpp/src/shadow.cpp b/ports/cpp/src/shadow.cpp new file mode 100644 index 0000000..33cec9f --- /dev/null +++ b/ports/cpp/src/shadow.cpp @@ -0,0 +1,74 @@ +/// A drop shadow cast by a region onto whatever is behind it. +/// +/// The point is that it dims what it covers rather than painting over it: a +/// shadow that filled its band with a flat colour would erase the dashboard +/// underneath, which is the opposite of what a shadow is for. Every covered cell +/// keeps its own character and its own hue, and only loses some of its light. +#include + +namespace hqtui { + +void dim_rect(Surface s, int x, int y, int width, int height, double amount) { + double t = std::clamp(amount, 0.0, 1.0); + // `hq_rgb(0, 0, 0)`, not a bare 0: zero is the sentinel for "the terminal's + // own colour", so mixing towards it drains a cell's colour rather than + // darkening it. + Color black = hq_rgb(0, 0, 0); + auto native = s.native(); + for (int row = 0; row < height; row++) { + for (int col = 0; col < width; col++) { + int ax = native.rect.x + x + col; + int ay = native.rect.y + y + row; + if (ax < native.clip.x || ay < native.clip.y || + ax >= native.clip.x + native.clip.width || ay >= native.clip.y + native.clip.height) + continue; + hq_cell cell{}; + if (!hq_buffer_cell(native.buffer, ax, ay, &cell)) + continue; + // Written through the absolute-coordinate surface so the cell keeps its + // own character: only the colours change. + Surface whole(hq_surface{native.buffer, native.theme, native.clip, native.clip}); + whole.set(ax - native.clip.x, ay - native.clip.y, cell.value, + Style(hq_mix(cell.fg, black, t), hq_mix(cell.bg, black, t), cell.attrs)); + } + } +} + +void draw_shadow(Surface s, Rect rect, const Shadow &o) { + if (s.rect().width <= 0 || s.rect().height <= 0) + return; + int dx = o.offset_x, dy = o.offset_y; + if (dx == 0 && dy == 0) + return; + + auto paint = [&](int x, int y, int w, int h) { + if (w <= 0 || h <= 0) + return; + if (o.color) + s.sub({x, y, w, h}).fill(' ', Style().background(*o.color)); + else + dim_rect(s, x, y, w, h, o.amount); + }; + + // The shadow is the moved region minus the original, which splits into two + // rectangles that do not touch: the rows the move added, at the moved + // region's full width, and then the columns it added over the rows the two + // still share. Cutting it any other way overlaps at the corner, and a corner + // dimmed twice reads as a smudge rather than an edge. + int tx = rect.x + dx, ty = rect.y + dy; + if (dy > 0) + paint(tx, rect.y + rect.height, rect.width, dy); + else if (dy < 0) + paint(tx, ty, rect.width, -dy); + + int y0 = std::max(rect.y, ty); + int shared = std::min(rect.y + rect.height, ty + rect.height) - y0; + if (shared > 0) { + if (dx > 0) + paint(rect.x + rect.width, y0, dx, shared); + else if (dx < 0) + paint(tx, y0, -dx, shared); + } +} + +} // namespace hqtui diff --git a/ports/cpp/tests/conformance_widgets.cpp b/ports/cpp/tests/conformance_widgets.cpp index 677ef7d..d76bbc9 100644 --- a/ports/cpp/tests/conformance_widgets.cpp +++ b/ports/cpp/tests/conformance_widgets.cpp @@ -215,6 +215,42 @@ bool draw_scene(const std::string &name, Surface s) { draw_canvas(s, c); return true; } + if (name == "shadow") { + Fill f; + f.symbol = "x"; + draw_fill(s, f); + draw_shadow(s, Rect{2, 1, 6, 2}); + return true; + } + if (name == "shadow-offset") { + Fill f; + f.symbol = "x"; + draw_fill(s, f); + Shadow sh; + sh.offset_x = 2; + sh.offset_y = 1; + draw_shadow(s, Rect{2, 1, 6, 2}, sh); + return true; + } + if (name == "shadow-back") { + Fill f; + f.symbol = "x"; + draw_fill(s, f); + Shadow sh; + sh.offset_x = -1; + sh.offset_y = -1; + draw_shadow(s, Rect{5, 2, 6, 2}, sh); + return true; + } + if (name == "shadow-solid") { + Fill f; + f.symbol = "x"; + draw_fill(s, f); + Shadow sh; + sh.color = hq_rgb(0x10, 0x14, 0x18); + draw_shadow(s, Rect{2, 1, 6, 2}, sh); + return true; + } if (name == "badge") { { Badge badge; diff --git a/ports/go/conformance_widgets_test.go b/ports/go/conformance_widgets_test.go index 02d0d95..ff59e13 100644 --- a/ports/go/conformance_widgets_test.go +++ b/ports/go/conformance_widgets_test.go @@ -112,6 +112,23 @@ func drawWidgetScene(t *testing.T, name string, s Surface) { Shapes: []Shape{{Kind: ShapePoints, Points: []Point{{5, 5}}}}, X: &Bounds{0, 10}, Y: &Bounds{0, 10}, Grid: true, }) + case "shadow": + DrawFill(s, FillOptions{Symbol: "x"}) + DrawShadow(s, Rect{X: 2, Y: 1, Width: 6, Height: 2}, + ShadowOptions{OffsetX: 1, OffsetY: 1, Amount: 0.55}) + case "shadow-offset": + DrawFill(s, FillOptions{Symbol: "x"}) + DrawShadow(s, Rect{X: 2, Y: 1, Width: 6, Height: 2}, + ShadowOptions{OffsetX: 2, OffsetY: 1, Amount: 0.55}) + case "shadow-back": + DrawFill(s, FillOptions{Symbol: "x"}) + DrawShadow(s, Rect{X: 5, Y: 2, Width: 6, Height: 2}, + ShadowOptions{OffsetX: -1, OffsetY: -1, Amount: 0.55}) + case "shadow-solid": + DrawFill(s, FillOptions{Symbol: "x"}) + solid := RGB(0x10, 0x14, 0x18) + DrawShadow(s, Rect{X: 2, Y: 1, Width: 6, Height: 2}, + ShadowOptions{OffsetX: 1, OffsetY: 1, Color: &solid}) case "badge": DrawBadge(s, BadgeOptions{Text: "LIVE"}) case "badge-outline": diff --git a/ports/go/widgets_shadow.go b/ports/go/widgets_shadow.go new file mode 100644 index 0000000..ac6537f --- /dev/null +++ b/ports/go/widgets_shadow.go @@ -0,0 +1,101 @@ +package hqtui + +// A drop shadow cast by a region onto whatever is behind it. +// +// The point is that it dims what it covers rather than painting over it: a +// shadow that filled its band with a flat colour would erase the dashboard +// underneath, which is the opposite of what a shadow is for. Every covered cell +// keeps its own character and its own hue, and only loses some of its light. + +type ShadowOptions struct { + // OffsetX and OffsetY are how far the shadow falls. The zero value casts + // nothing, so a caller who wants the usual one-cell shadow says so. + OffsetX int + OffsetY int + // Amount is 0-1: how much light the covered cells lose. + Amount float64 + // Color paints instead of dimming what is underneath. + // + // For a shadow falling on empty background, where there is nothing to dim + // and a flat colour is cheaper and reads the same. + Color *Color +} + +// DimRect darkens every cell in a region, keeping its character and its hue. +// +// Towards black rather than towards the theme's background: on a light theme +// the background *is* the light, so dimming towards it would make the shadow +// brighter than the page it falls on. +func DimRect(s Surface, x, y, width, height int, amount float64) { + t := amount + if t < 0 { + t = 0 + } + if t > 1 { + t = 1 + } + black := RGB(0, 0, 0) + b := s.Buffer() + for row := 0; row < height; row++ { + for col := 0; col < width; col++ { + ax := s.Rect.X + x + col + ay := s.Rect.Y + y + row + if ax < s.Clip.X || ay < s.Clip.Y || + ax >= s.Clip.X+s.Clip.Width || ay >= s.Clip.Y+s.Clip.Height { + continue + } + i := b.Index(ax, ay) + b.Fg[i] = b.Fg[i].Mix(black, t) + b.Bg[i] = b.Bg[i].Mix(black, t) + } + } +} + +// DrawShadow casts a shadow from rect onto s. +// +// The shadow is the band the region would cover if it were moved by the offset, +// minus the region itself. Drawn before the region is, so it never falls on top +// of it. +func DrawShadow(s Surface, rect Rect, o ShadowOptions) { + if s.IsEmpty() { + return + } + dx, dy := o.OffsetX, o.OffsetY + if dx == 0 && dy == 0 { + return + } + amount := o.Amount + + paint := func(x, y, w, h int) { + if w <= 0 || h <= 0 { + return + } + if o.Color != nil { + s.FillRect(x, y, w, h, Style{Bg: o.Color}, 32) + } else { + DimRect(s, x, y, w, h, amount) + } + } + + // The shadow is the moved region minus the original, which splits into two + // rectangles that do not touch: the rows the move added, at the moved + // region's full width, and then the columns it added over the rows the two + // still share. Cutting it any other way overlaps at the corner, and a corner + // dimmed twice reads as a smudge rather than an edge. + tx, ty := rect.X+dx, rect.Y+dy + if dy > 0 { + paint(tx, rect.Y+rect.Height, rect.Width, dy) + } else if dy < 0 { + paint(tx, ty, rect.Width, -dy) + } + + y0 := max(rect.Y, ty) + shared := min(rect.Y+rect.Height, ty+rect.Height) - y0 + if shared > 0 { + if dx > 0 { + paint(rect.X+rect.Width, y0, dx, shared) + } else if dx < 0 { + paint(tx, y0, -dx, shared) + } + } +} diff --git a/ports/python/hqtui/widgets/__init__.py b/ports/python/hqtui/widgets/__init__.py index de15799..1ea8e1f 100644 --- a/ports/python/hqtui/widgets/__init__.py +++ b/ports/python/hqtui/widgets/__init__.py @@ -76,6 +76,7 @@ is_leap_year, ) from .chart import ChartOptions, draw_chart +from .shadow import ShadowOptions, dim_rect, draw_shadow from .surface import ClearOptions, FillOptions, draw_clear, draw_fill from .scrollbar import ( ScrollbarOptions, @@ -120,6 +121,7 @@ "CalendarMark", "CalendarOptions", "calendar_height", "day_of_week", "days_in_month", "draw_calendar", "is_leap_year", "CALENDAR_WIDTH", "ChartOptions", "draw_chart", "ClearOptions", "FillOptions", "draw_clear", "draw_fill", + "ShadowOptions", "dim_rect", "draw_shadow", "ScrollbarOptions", "ScrollbarOrientation", "is_vertical", "offset_for_position", "thumb", "draw_scrollbar", "draw_scrollbar_widget", "draw_select", "draw_sparkline", "draw_status_bar", "draw_table", "draw_tabs", "draw_text", "draw_text_input", "draw_tooltip", diff --git a/ports/python/hqtui/widgets/shadow.py b/ports/python/hqtui/widgets/shadow.py new file mode 100644 index 0000000..4bc1e9d --- /dev/null +++ b/ports/python/hqtui/widgets/shadow.py @@ -0,0 +1,99 @@ +"""A drop shadow cast by a region onto whatever is behind it. + +The point is that it dims what it covers rather than painting over it: a shadow +that filled its band with a flat colour would erase the dashboard underneath, +which is the opposite of what a shadow is for. Every covered cell keeps its own +character and its own hue, and only loses some of its light. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..buffer import Style +from ..color import Color, rgb +from ..layout import Rect +from ..surface import Surface + +__all__ = ["ShadowOptions", "dim_rect", "draw_shadow"] + + +@dataclass(frozen=True, slots=True) +class ShadowOptions: + #: How far the shadow falls. + offset_x: int = 1 + offset_y: int = 1 + #: 0-1: how much light the covered cells lose. + amount: float = 0.55 + #: Paint this colour instead of dimming what is underneath. + #: + #: For a shadow falling on empty background, where there is nothing to dim + #: and a flat colour is cheaper and reads the same. + color: Color | None = None + + +def dim_rect( + surface: Surface, x: int, y: int, width: int, height: int, amount: float +) -> None: + """Darken every cell in a region, keeping its character and its hue. + + Towards black rather than towards the theme's background: on a light theme + the background *is* the light, so dimming towards it would make the shadow + brighter than the page it falls on. + """ + t = min(1.0, max(0.0, amount)) + black = rgb(0, 0, 0) + buffer = surface.buffer + clip = surface.clip + for row in range(height): + for col in range(width): + ax = surface.rect.x + x + col + ay = surface.rect.y + y + row + if ax < clip.x or ay < clip.y or ax >= clip.x + clip.width or ay >= clip.y + clip.height: + continue + i = buffer.index(ax, ay) + # The buffer stores raw ints, so each value is wrapped before it can + # be mixed and unwrapped on the way back in. + buffer.fg[i] = int(Color(buffer.fg[i]).mix(black, t)) + buffer.bg[i] = int(Color(buffer.bg[i]).mix(black, t)) + + +def draw_shadow(surface: Surface, rect: Rect, options: ShadowOptions = ShadowOptions()) -> None: + """Cast a shadow from ``rect`` onto ``surface``. + + The shadow is the band the region would cover if it were moved by the + offset, minus the region itself. Drawn before the region is, so it never + falls on top of it. + """ + if surface.empty: + return + dx, dy = int(options.offset_x), int(options.offset_y) + if dx == 0 and dy == 0: + return + + def paint(x: int, y: int, w: int, h: int) -> None: + if w <= 0 or h <= 0: + return + if options.color is not None: + surface.fill_rect(x, y, w, h, Style(bg=options.color), 32) + else: + dim_rect(surface, x, y, w, h, options.amount) + + # The shadow is the moved region minus the original, which splits into two + # rectangles that do not touch: the rows the move added, at the moved + # region's full width, and then the columns it added over the rows the two + # still share. Cutting it any other way overlaps at the corner, and a corner + # dimmed twice reads as a smudge rather than an edge. + tx, ty = rect.x + dx, rect.y + dy + if dy > 0: + paint(tx, rect.y + rect.height, rect.width, dy) + elif dy < 0: + paint(tx, ty, rect.width, -dy) + + y0 = max(rect.y, ty) + shared = min(rect.y + rect.height, ty + rect.height) - y0 + if shared > 0: + if dx > 0: + paint(rect.x + rect.width, y0, dx, shared) + elif dx < 0: + paint(tx, y0, -dx, shared) diff --git a/ports/python/tests/test_conformance_widgets.py b/ports/python/tests/test_conformance_widgets.py index f653251..acebbe5 100644 --- a/ports/python/tests/test_conformance_widgets.py +++ b/ports/python/tests/test_conformance_widgets.py @@ -30,6 +30,8 @@ plot, sparkline, ) +from hqtui.color import rgb +from hqtui.layout import Rect from hqtui.surface import Surface from .support import assert_buffer, fixture, scene @@ -124,6 +126,18 @@ def draw_scene(case, name: str, s: Surface) -> None: shapes=[gc.Shape(kind="points", points=[(5, 5)])], x=gc.Bounds(0, 10), y=gc.Bounds(0, 10), grid=True, )) + elif name == "shadow": + w.draw_fill(s, w.FillOptions(symbol="x")) + w.draw_shadow(s, Rect(2, 1, 6, 2)) + elif name == "shadow-offset": + w.draw_fill(s, w.FillOptions(symbol="x")) + w.draw_shadow(s, Rect(2, 1, 6, 2), w.ShadowOptions(offset_x=2, offset_y=1)) + elif name == "shadow-back": + w.draw_fill(s, w.FillOptions(symbol="x")) + w.draw_shadow(s, Rect(5, 2, 6, 2), w.ShadowOptions(offset_x=-1, offset_y=-1)) + elif name == "shadow-solid": + w.draw_fill(s, w.FillOptions(symbol="x")) + w.draw_shadow(s, Rect(2, 1, 6, 2), w.ShadowOptions(color=rgb(0x10, 0x14, 0x18))) elif name == "badge": w.draw_badge(s, w.BadgeOptions(text="LIVE")) elif name == "badge-outline": diff --git a/ports/rust/src/surface.rs b/ports/rust/src/surface.rs index c598b5e..c7f1600 100644 --- a/ports/rust/src/surface.rs +++ b/ports/rust/src/surface.rs @@ -348,6 +348,15 @@ pub struct Surface { } impl Surface { + /// The frame buffer this surface draws into. + /// + /// For effects that read a cell before writing it -- a shadow dims what is + /// already there rather than painting over it, so it cannot go through the + /// write-only drawing methods. + pub fn buffer_mut(&self) -> std::cell::RefMut<'_, FrameBuffer> { + self.buffer.borrow_mut() + } + pub fn new(buffer: SharedBuffer, rect: Rect, theme: Rc, clip: Option) -> Surface { let clip = match clip { Some(c) => rect.intersect(c), diff --git a/ports/rust/src/widgets/mod.rs b/ports/rust/src/widgets/mod.rs index 8efd479..0ae7f78 100644 --- a/ports/rust/src/widgets/mod.rs +++ b/ports/rust/src/widgets/mod.rs @@ -7,6 +7,7 @@ pub mod chart; pub mod controls; pub mod meters; pub mod scrollbar; +pub mod shadow; pub mod surface; pub mod table; pub mod text; @@ -32,6 +33,7 @@ pub use scrollbar::{ draw_scrollbar, draw_scrollbar_widget, offset_for_position, thumb, thumb_of, ScrollbarOptions, ScrollbarOrientation, }; +pub use shadow::{dim_rect, draw_shadow, ShadowOptions}; pub use surface::{draw_clear, draw_fill, ClearOptions, FillOptions}; pub use table::{ draw_list, draw_log, draw_table, draw_tree, resolve_offset, TableColumn, diff --git a/ports/rust/src/widgets/shadow.rs b/ports/rust/src/widgets/shadow.rs new file mode 100644 index 0000000..94efc3b --- /dev/null +++ b/ports/rust/src/widgets/shadow.rs @@ -0,0 +1,115 @@ +//! A drop shadow cast by a region onto whatever is behind it. +//! +//! The point is that it dims what it covers rather than painting over it: a +//! shadow that filled its band with a flat colour would erase the dashboard +//! underneath, which is the opposite of what a shadow is for. Every covered cell +//! keeps its own character and its own hue, and only loses some of its light. + +use crate::buffer::Style; +use crate::color::Color; +use crate::layout::Rect; +use crate::surface::Surface; + +#[derive(Clone, Copy, Debug)] +pub struct ShadowOptions { + /// How far the shadow falls. Default one cell right and one down. + pub offset_x: isize, + pub offset_y: isize, + /// 0-1: how much light the covered cells lose. + pub amount: f64, + /// Paint this colour instead of dimming what is underneath. + /// + /// For a shadow falling on empty background, where there is nothing to dim + /// and a flat colour is cheaper and reads the same. + pub color: Option, +} + +impl Default for ShadowOptions { + fn default() -> ShadowOptions { + ShadowOptions { offset_x: 1, offset_y: 1, amount: 0.55, color: None } + } +} + +/// Darken every cell in a region, keeping its character and its hue. +/// +/// Towards black rather than towards the theme's background: on a light theme +/// the background *is* the light, so dimming towards it would make the shadow +/// brighter than the page it falls on. +pub fn dim_rect(surface: &Surface, x: isize, y: isize, width: usize, height: usize, amount: f64) { + let t = amount.clamp(0.0, 1.0); + let black = Color::rgb(0, 0, 0); + for row in 0..height as isize { + for col in 0..width as isize { + let ax = surface.rect.x + x + col; + let ay = surface.rect.y + y + row; + if ax < surface.clip.x + || ay < surface.clip.y + || ax >= surface.clip.x + surface.clip.width as isize + || ay >= surface.clip.y + surface.clip.height as isize + { + continue; + } + let mut buffer = surface.buffer_mut(); + let i = buffer.index(ax as usize, ay as usize); + buffer.fg[i] = buffer.fg[i].mix(black, t); + buffer.bg[i] = buffer.bg[i].mix(black, t); + } + } +} + +/// Cast a shadow from `rect` onto `surface`. +/// +/// The shadow is the band the region would cover if it were moved by the offset, +/// minus the region itself -- an L along the two trailing edges. Drawn before +/// the region is, so it never falls on top of it. +pub fn draw_shadow(surface: &Surface, rect: Rect, options: &ShadowOptions) { + if surface.is_empty() { + return; + } + let (dx, dy) = (options.offset_x, options.offset_y); + if dx == 0 && dy == 0 { + return; + } + + let paint = |x: isize, y: isize, w: isize, h: isize| { + if w <= 0 || h <= 0 { + return; + } + match options.color { + Some(color) => surface.fill_rect( + x, + y, + w as usize, + h as usize, + &Style { fg: None, bg: Some(color), attrs: None }, + 32, + ), + None => dim_rect(surface, x, y, w as usize, h as usize, options.amount), + } + }; + + // The shadow is the moved region minus the original, which splits into two + // rectangles that do not touch: the rows the move added, at the moved + // region's full width, and then the columns it added over the rows the two + // still share. Cutting it any other way overlaps at the corner, and a + // corner dimmed twice reads as a smudge rather than an edge. + let rw = rect.width as isize; + let rh = rect.height as isize; + let tx = rect.x + dx; + let ty = rect.y + dy; + if dy > 0 { + paint(tx, rect.y + rh, rw, dy); + } else if dy < 0 { + paint(tx, ty, rw, -dy); + } + + let y0 = rect.y.max(ty); + let shared = (rect.y + rh).min(ty + rh) - y0; + if shared > 0 { + if dx > 0 { + paint(rect.x + rw, y0, dx, shared); + } else if dx < 0 { + paint(tx, y0, -dx, shared); + } + } +} diff --git a/ports/rust/tests/conformance_widgets.rs b/ports/rust/tests/conformance_widgets.rs index 4cc1aed..6a28c92 100644 --- a/ports/rust/tests/conformance_widgets.rs +++ b/ports/rust/tests/conformance_widgets.rs @@ -16,6 +16,8 @@ use hqtui::graphics::plot::{ use hqtui::graphics::chart::{AxisOptions, ChartPlotOptions, ChartSeries, MarkType}; use hqtui::graphics::{draw_canvas, Bounds, CanvasOptions, Shape}; use hqtui::graphics::FillMode; +use hqtui::color::Color; +use hqtui::layout::Rect; use hqtui::surface::Surface; use hqtui::unicode::Align; use hqtui::widgets::*; @@ -146,6 +148,34 @@ fn draw_scene(name: &str, s: &Surface) { ..Default::default() }, ), + "shadow" => { + draw_fill(s, &FillOptions { symbol: "x".into(), ..Default::default() }); + draw_shadow(s, Rect { x: 2, y: 1, width: 6, height: 2 }, &ShadowOptions::default()); + } + "shadow-offset" => { + draw_fill(s, &FillOptions { symbol: "x".into(), ..Default::default() }); + draw_shadow( + s, + Rect { x: 2, y: 1, width: 6, height: 2 }, + &ShadowOptions { offset_x: 2, offset_y: 1, ..Default::default() }, + ); + } + "shadow-back" => { + draw_fill(s, &FillOptions { symbol: "x".into(), ..Default::default() }); + draw_shadow( + s, + Rect { x: 5, y: 2, width: 6, height: 2 }, + &ShadowOptions { offset_x: -1, offset_y: -1, ..Default::default() }, + ); + } + "shadow-solid" => { + draw_fill(s, &FillOptions { symbol: "x".into(), ..Default::default() }); + draw_shadow( + s, + Rect { x: 2, y: 1, width: 6, height: 2 }, + &ShadowOptions { color: Some(Color::rgb(0x10, 0x14, 0x18)), ..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 00b1b6b..35017c8 100644 --- a/ports/zig/src/conformance_widgets.zig +++ b/ports/zig/src/conformance_widgets.zig @@ -11,6 +11,7 @@ const buffer_mod = @import("buffer.zig"); const conformance = @import("conformance.zig"); const graphics = @import("graphics.zig"); const surface_mod = @import("surface.zig"); +const hqtui_color = @import("color.zig"); const theme_mod = @import("theme.zig"); const w = @import("widgets.zig"); @@ -122,6 +123,20 @@ fn drawScene(allocator: std.mem.Allocator, name: []const u8, s: Surface) !void { .y = .{ .min = 0, .max = 10 }, .grid = true, }); + } else if (eq(u8, name, "shadow")) { + w.drawFill(s, .{ .symbol = "x" }); + w.drawShadow(s, .{ .x = 2, .y = 1, .width = 6, .height = 2 }, .{}); + } else if (eq(u8, name, "shadow-offset")) { + w.drawFill(s, .{ .symbol = "x" }); + w.drawShadow(s, .{ .x = 2, .y = 1, .width = 6, .height = 2 }, .{ .offset_x = 2, .offset_y = 1 }); + } else if (eq(u8, name, "shadow-back")) { + w.drawFill(s, .{ .symbol = "x" }); + w.drawShadow(s, .{ .x = 5, .y = 2, .width = 6, .height = 2 }, .{ .offset_x = -1, .offset_y = -1 }); + } else if (eq(u8, name, "shadow-solid")) { + w.drawFill(s, .{ .symbol = "x" }); + w.drawShadow(s, .{ .x = 2, .y = 1, .width = 6, .height = 2 }, .{ + .color = hqtui_color.Color.rgb(0x10, 0x14, 0x18), + }); } else if (eq(u8, name, "badge")) { _ = w.drawBadge(s, .{ .text = "LIVE" }); } else if (eq(u8, name, "badge-outline")) { diff --git a/ports/zig/src/widgets.zig b/ports/zig/src/widgets.zig index f7c13da..ad4a2e4 100644 --- a/ports/zig/src/widgets.zig +++ b/ports/zig/src/widgets.zig @@ -6,6 +6,7 @@ pub const controls = @import("widgets/controls.zig"); pub const meters = @import("widgets/meters.zig"); pub const calendar = @import("widgets/calendar.zig"); pub const chart = @import("widgets/chart.zig"); +pub const shadow = @import("widgets/shadow.zig"); pub const surface_widgets = @import("widgets/surface.zig"); pub const scrollbar = @import("widgets/scrollbar.zig"); pub const table = @import("widgets/table.zig"); @@ -67,6 +68,9 @@ pub const CalendarOptions = calendar.CalendarOptions; pub const calendarHeight = calendar.calendarHeight; pub const drawCalendar = calendar.drawCalendar; pub const ChartOptions = chart.ChartOptions; +pub const ShadowOptions = shadow.ShadowOptions; +pub const dimRect = shadow.dimRect; +pub const drawShadow = shadow.drawShadow; pub const ClearOptions = surface_widgets.ClearOptions; pub const FillOptions = surface_widgets.FillOptions; pub const drawClear = surface_widgets.drawClear; diff --git a/ports/zig/src/widgets/shadow.zig b/ports/zig/src/widgets/shadow.zig new file mode 100644 index 0000000..6975616 --- /dev/null +++ b/ports/zig/src/widgets/shadow.zig @@ -0,0 +1,101 @@ +//! A drop shadow cast by a region onto whatever is behind it. +//! +//! The point is that it dims what it covers rather than painting over it: a +//! shadow that filled its band with a flat colour would erase the dashboard +//! underneath, which is the opposite of what a shadow is for. Every covered cell +//! keeps its own character and its own hue, and only loses some of its light. + +const std = @import("std"); + +const buffer_mod = @import("../buffer.zig"); +const color_mod = @import("../color.zig"); +const layout = @import("../layout.zig"); +const surface_mod = @import("../surface.zig"); + +const Color = color_mod.Color; +const Rect = layout.Rect; +const Style = buffer_mod.Style; +const Surface = surface_mod.Surface; + +pub const ShadowOptions = struct { + /// How far the shadow falls. + offset_x: isize = 1, + offset_y: isize = 1, + /// 0-1: how much light the covered cells lose. + amount: f64 = 0.55, + /// Paint this colour instead of dimming what is underneath. + /// + /// For a shadow falling on empty background, where there is nothing to dim + /// and a flat colour is cheaper and reads the same. + color: ?Color = null, +}; + +/// Darken every cell in a region, keeping its character and its hue. +/// +/// Towards black rather than towards the theme's background: on a light theme +/// the background *is* the light, so dimming towards it would make the shadow +/// brighter than the page it falls on. +pub fn dimRect(s: Surface, x: isize, y: isize, width: usize, height: usize, amount: f64) void { + const t = std.math.clamp(amount, 0, 1); + const black = Color.rgb(0, 0, 0); + for (0..height) |row| { + for (0..width) |col| { + const ax = s.rect.x + x + @as(isize, @intCast(col)); + const ay = s.rect.y + y + @as(isize, @intCast(row)); + if (ax < s.clip.x or ay < s.clip.y or + ax >= s.clip.x + @as(isize, @intCast(s.clip.width)) or + ay >= s.clip.y + @as(isize, @intCast(s.clip.height))) continue; + const i = s.buffer.index(@intCast(ax), @intCast(ay)); + s.buffer.fg[i] = s.buffer.fg[i].mix(black, t); + s.buffer.bg[i] = s.buffer.bg[i].mix(black, t); + } + } +} + +/// Cast a shadow from `rect` onto `s`. +/// +/// The shadow is the band the region would cover if it were moved by the offset, +/// minus the region itself. Drawn before the region is, so it never falls on top +/// of it. +pub fn drawShadow(s: Surface, rect: Rect, options: ShadowOptions) void { + if (s.isEmpty()) return; + const dx = options.offset_x; + const dy = options.offset_y; + if (dx == 0 and dy == 0) return; + + const paint = struct { + fn go(sf: Surface, o: ShadowOptions, x: isize, y: isize, w: isize, h: isize) void { + if (w <= 0 or h <= 0) return; + if (o.color) |c| { + sf.fillRect(x, y, @intCast(w), @intCast(h), .{ .bg = c }, 32); + } else { + dimRect(sf, x, y, @intCast(w), @intCast(h), o.amount); + } + } + }.go; + + // The shadow is the moved region minus the original, which splits into two + // rectangles that do not touch: the rows the move added, at the moved + // region's full width, and then the columns it added over the rows the two + // still share. Cutting it any other way overlaps at the corner, and a corner + // dimmed twice reads as a smudge rather than an edge. + const rw: isize = @intCast(rect.width); + const rh: isize = @intCast(rect.height); + const tx = rect.x + dx; + const ty = rect.y + dy; + if (dy > 0) { + paint(s, options, tx, rect.y + rh, rw, dy); + } else if (dy < 0) { + paint(s, options, tx, ty, rw, -dy); + } + + const y0 = @max(rect.y, ty); + const shared = @min(rect.y + rh, ty + rh) - y0; + if (shared > 0) { + if (dx > 0) { + paint(s, options, rect.x + rw, y0, dx, shared); + } else if (dx < 0) { + paint(s, options, tx, y0, -dx, shared); + } + } +}