Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/hqtui/src/widgets/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
101 changes: 101 additions & 0 deletions packages/hqtui/src/widgets/shadow.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
94 changes: 94 additions & 0 deletions packages/hqtui/test/shadow.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof drawShadow>[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));
});
Loading