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 5a65c90..34222fb 100644 Binary files a/ports/conformance/generate.ts and b/ports/conformance/generate.ts differ diff --git a/ports/cpp/CMakeLists.txt b/ports/cpp/CMakeLists.txt index 8022c29..bf46e4e 100644 --- a/ports/cpp/CMakeLists.txt +++ b/ports/cpp/CMakeLists.txt @@ -14,7 +14,7 @@ if(HQTUI_LTO) set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON) endif() add_library(hqtui_cpp INTERFACE) -add_library(hqtui_cpp_widgets src/widgets.cpp src/scrollbar.cpp src/chart.cpp src/calendar.cpp src/canvas.cpp) +add_library(hqtui_cpp_widgets src/widgets.cpp src/scrollbar.cpp src/chart.cpp src/calendar.cpp src/canvas.cpp src/shadow.cpp) set_target_properties(hqtui_cpp_widgets PROPERTIES POSITION_INDEPENDENT_CODE ON) target_compile_features(hqtui_cpp_widgets PUBLIC cxx_std_17) if(MSVC) diff --git a/ports/cpp/include/hqtui/widgets.hpp b/ports/cpp/include/hqtui/widgets.hpp index c249542..a5edc9c 100644 --- a/ports/cpp/include/hqtui/widgets.hpp +++ b/ports/cpp/include/hqtui/widgets.hpp @@ -591,6 +591,19 @@ struct Projection { Projection canvas_projection(const Braille &, Bounds x, Bounds y); /// A canvas drawn in the caller's own coordinates rather than in pixels. void draw_canvas(Surface, const Canvas &); + +struct Shadow { + /// How far the shadow falls. + int offset_x = 1, offset_y = 1; + /// 0-1: how much light the covered cells lose. + double amount = .55; + /// Paint this colour instead of dimming what is underneath. + std::optional 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); + } + } +}