From f3923919cd7db1f37baea9784fa893d10d5e322c Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 24 Jun 2026 23:41:42 -0400 Subject: [PATCH 1/5] feat(transitions): add subcell handling for backgrounds --- src/clayterm.c | 113 ++++++++++++++++++++++++++++++++++++--- test/transitions.test.ts | 43 +++++++++++++++ 2 files changed, 149 insertions(+), 7 deletions(-) diff --git a/src/clayterm.c b/src/clayterm.c index 7e4e62e..8b035da 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -369,12 +369,110 @@ static uint32_t color(Clay_Color c) { /* ── Clay render backend ──────────────────────────────────────────── */ -static void render_rect(struct Clayterm *ct, int x0, int y0, int x1, int y1, - Clay_RectangleRenderData *r) { - uint32_t bg = color(r->backgroundColor); - for (int y = y0; y < y1; y++) - for (int x = x0; x < x1; x++) - setcell(ct, x, y, ' ', ATTR_DEFAULT, bg); +/* Eighth-block ramps. Only LEFT (2588..258F) and LOWER (2581..2588) families + * exist; RIGHT/UPPER coverage is the same glyph with fg/bg swapped. */ +static uint32_t block_left(int n) { + return 0x2590u - (uint32_t)n; +} /* n=1..7 -> ▏..▉ */ +static uint32_t block_lower(int n) { + return 0x2580u + (uint32_t)n; +} /* n=1..7 -> ▁..▇ */ + +typedef enum { EDGE_LEFT, EDGE_RIGHT, EDGE_TOP, EDGE_BOTTOM } EdgeSide; + +/* cov in (0,1): fraction of THIS cell the fill covers, measured inward from the + * box edge. Reads the cell's current bg as paper so the edge composites over + * what it slides across. Mirrors setcell's bounds+clip guard because it writes + * cell_at directly. */ +static void edge_cell(struct Clayterm *ct, int x, int y, EdgeSide side, + float cov, uint32_t fill) { + if (cov <= 0.0f || (fill & ATTR_DEFAULT)) + return; /* transparent rect: no edge */ + if (x < 0 || x >= ct->w || y < 0 || y >= ct->h) + return; + if (ct->clipping) { + if (x < ct->clipx || x >= ct->clipx + ct->clipw) + return; + if (y < ct->clipy || y >= ct->clipy + ct->cliph) + return; + } + Cell *c = cell_at(ct, ct->back, x, y); + uint32_t paper = c->bg & COLOR_MASK; + fill &= COLOR_MASK; + + int n = (int)(cov * 8.0f + 0.5f); /* 8 hard sub-positions per axis */ + if (n <= 0) + return; + if (n >= 8) { + c->ch = 0x2588; + c->fg = fill; + c->bg = paper; + return; + } + + switch (side) { + case EDGE_RIGHT: + c->ch = block_left(n); + c->fg = fill; + c->bg = paper; + break; + case EDGE_LEFT: + c->ch = block_left(8 - n); + c->fg = paper; + c->bg = fill; + break; /* swap */ + case EDGE_TOP: + c->ch = block_lower(n); + c->fg = fill; + c->bg = paper; + break; + case EDGE_BOTTOM: + c->ch = block_lower(8 - n); + c->fg = paper; + c->bg = fill; + break; /* swap */ + } +} + +/* Solid interior + four coverage boundary lines. Corners are left empty in v1: + * both fractions meet in one cell and a single eighth-block can't express an + * L-corner; the octant path is deferred. */ +static void render_rect_cov(struct Clayterm *ct, Clay_BoundingBox b, + uint32_t fill) { + float l = b.x, r = b.x + b.width; + float t = b.y, btm = b.y + b.height; + + /* floor==trunc and ceil==trunc+frac for the non-negative coords Clay emits, + * so we avoid libm. xi0/yi0 = ceil (first fully-covered), xi1/yi1 = floor. */ + int xi0 = (int)l + (l > (float)(int)l ? 1 : 0); + int yi0 = (int)t + (t > (float)(int)t ? 1 : 0); + int xi1 = (int)r; + int yi1 = (int)btm; + + for (int y = yi0; y < yi1; y++) /* interior: unchanged fast path */ + for (int x = xi0; x < xi1; x++) + setcell(ct, x, y, ' ', ATTR_DEFAULT, fill); + + float lf = (float)xi0 - l; /* edge slivers */ + float rf = r - (float)xi1; + float tf = (float)yi0 - t; + float bf = btm - (float)yi1; + + /* edges span the interior extent only; the 4 corner cells are deferred to the + * octant path. A missing corner sliver reads as a faint notch, which is far + * less distracting than the over-ink you get by letting one axis claim it. */ + for (int x = xi0; x < xi1; x++) { + if (tf > 0.0f) + edge_cell(ct, x, yi0 - 1, EDGE_TOP, tf, fill); + if (bf > 0.0f) + edge_cell(ct, x, yi1, EDGE_BOTTOM, bf, fill); + } + for (int y = yi0; y < yi1; y++) { + if (lf > 0.0f) + edge_cell(ct, xi0 - 1, y, EDGE_LEFT, lf, fill); + if (rf > 0.0f) + edge_cell(ct, xi1, y, EDGE_RIGHT, rf, fill); + } } /* Return the byte length of the first `cps` code points of `start`, @@ -938,7 +1036,8 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, switch (cmd->commandType) { case CLAY_RENDER_COMMAND_TYPE_RECTANGLE: - render_rect(ct, x0, y0, x1, y1, &cmd->renderData.rectangle); + render_rect_cov(ct, box, + color(cmd->renderData.rectangle.backgroundColor)); break; case CLAY_RENDER_COMMAND_TYPE_TEXT: render_text(ct, x0, y0, cmd); diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 3a99c33..6e1f1e6 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -9,6 +9,12 @@ import { rgba, text, } from "../mod.ts"; +import { print } from "./print.ts"; + +const decode = (b: Uint8Array) => new TextDecoder().decode(b); + +/* eighth-block ramps used for sub-cell rect edges: ▏▎▍▌▋▊▉ and ▁▂▃▄▅▆▇ */ +const EIGHTH_BLOCKS = /[▁-▇▉-▏]/; describe("transitions", () => { describe("deltaTime", () => { @@ -80,6 +86,43 @@ describe("transitions", () => { }); }); + describe("sub-cell edges", () => { + it("renders a partial-block edge for a fractional rect width", async () => { + let W = 16, H = 4; + let term = await createTerm({ width: W, height: H }); + let frame = (w: number): Op[] => [ + open("root", { layout: { width: grow(), height: grow() } }), + open("box", { + layout: { width: fixed(w), height: fixed(2) }, + bg: rgba(255, 0, 0), + transition: { + duration: 0.3, + easing: "easeInOut", + properties: ["width"], + }, + }), + close(), + close(), + ]; + + // establish start width, then retarget so the box is mid-transition + term.render(frame(4), { deltaTime: 0 }); + term.render(frame(12), { deltaTime: 0 }); + + // step through the curve; the leading edge lands on fractional columns, + // which must render as eighth-block glyphs rather than snapping. + let sawPartial = false; + for (let dt of [0.05, 0.08, 0.1, 0.12, 0.15]) { + let r = term.render(frame(12), { deltaTime: dt }); + if (EIGHTH_BLOCKS.test(print(decode(r.output), W, H))) { + sawPartial = true; + break; + } + } + expect(sawPartial).toBe(true); + }); + }); + describe("line mode", () => { it("runs color transitions in line mode", async () => { let term = await createTerm({ width: 20, height: 5 }); From 31f804878ef162e49cfb8eb4ca91e663648231e4 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 24 Jun 2026 23:41:54 -0400 Subject: [PATCH 2/5] feat(transitions): add subcell demo --- examples/transitions/subcell.ts | 261 ++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 examples/transitions/subcell.ts diff --git a/examples/transitions/subcell.ts b/examples/transitions/subcell.ts new file mode 100644 index 0000000..9029809 --- /dev/null +++ b/examples/transitions/subcell.ts @@ -0,0 +1,261 @@ +/** + * Sub-cell rect smoothing demo. + * + * A row of filled boxes (no borders) that animate their position. Because the + * boxes are rectangle fills, their leading/trailing edges land on fractional + * cell columns mid-transition and render as eighth-block glyphs (▏▎▍▌▋▊▉) + * instead of snapping column to column. + * + * Press 's' to shuffle (animates position). + * Press 'w' to toggle wide/narrow (animates width). + * Press 'q' or Ctrl+C to quit. + * + * Filled, border-free boxes are deliberate: a border would emit a separate + * (still integer-snapped) command that overdraws the fill's fractional edge. + * easeInOut's shallow ends are where the snapping was worst, so it's also + * where the sub-cell ramp is easiest to see. + */ + +import { + createChannel, + each, + ensure, + main, + race, + resource, + sleep, + spawn, + type Stream, + until, +} from "effection"; +import { + close, + createTerm, + fixed, + grow, + type InputEvent, + type Op, + open, + rgba, + text, +} from "../../mod.ts"; +import { + alternateBuffer, + cursor, + mouseTracking, + settings, +} from "../../settings.ts"; +import { useInput } from "../use-input.ts"; +import { useStdin } from "../use-stdin.ts"; + +const PALETTE = [ + rgba(225, 138, 50), + rgba(111, 173, 162), + rgba(184, 87, 134), + rgba(87, 134, 184), + rgba(134, 184, 87), + rgba(184, 134, 87), +]; + +const ROOT_BG = rgba(18, 18, 22); +const HINT_BG = rgba(40, 40, 55); +const KEY_COLOR = rgba(255, 220, 120); +const LABEL_COLOR = rgba(200, 200, 220); + +interface Box { + id: number; + color: number; +} + +interface State { + boxes: Box[]; + wide: boolean; +} + +function fisherYates(arr: T[]): T[] { + let out = arr.slice(); + for (let i = out.length - 1; i > 0; i--) { + let j = Math.floor(Math.random() * (i + 1)); + let tmp = out[i]; + out[i] = out[j]; + out[j] = tmp; + } + return out; +} + +function hint(key: string, label: string): Op[] { + return [ + text(key, { color: KEY_COLOR }), + text(` ${label} `, { color: LABEL_COLOR }), + ]; +} + +function view(state: State): Op[] { + let ops: Op[] = []; + + ops.push( + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + bg: ROOT_BG, + }), + ); + + ops.push( + open("hintbar", { + layout: { + width: grow(), + height: fixed(1), + direction: "ltr", + padding: { left: 2, right: 2, top: 0, bottom: 0 }, + alignY: "center", + }, + bg: HINT_BG, + }), + ...hint("s", "shuffle"), + ...hint("w", "wide/narrow"), + ...hint("q", "quit"), + close(), + ); + + ops.push( + open("row", { + layout: { + width: grow(), + height: grow(), + direction: "ltr", + padding: { left: 2, right: 2, top: 1, bottom: 1 }, + gap: 2, + alignY: "center", + }, + }), + ); + + for (let b of state.boxes) { + ops.push( + open(`box:${b.id}`, { + layout: { + width: state.wide ? grow() : fixed(7), + height: fixed(5), + }, + bg: b.color, + transition: { + duration: 0.5, + easing: "easeInOut", + properties: ["width", "position"], + }, + }), + close(), + ); + } + + ops.push(close()); + ops.push(close()); + + return ops; +} + +function ticker(flag: { animating: boolean }): Stream { + return resource(function* (provide) { + let ch = createChannel(); + yield* spawn(function* () { + while (true) { + if (flag.animating) { + yield* sleep(2); + yield* ch.send(); + } else { + yield* sleep(50); + } + } + }); + let sub = yield* ch; + yield* race([provide(sub), drain(ch)]); + }); +} + +function merge( + a: Stream, + b: Stream, +): Stream { + return resource(function* (provide) { + let sub = { + a: yield* a, + b: yield* b, + }; + return yield* provide({ + *next() { + return yield* race([sub.a.next(), sub.b.next()]); + }, + }); + }); +} + +function* drain(stream: Stream) { + for (let _ of yield* each(stream)) { + yield* each.next(); + } +} + +await main(function* () { + let { columns, rows } = Deno.stdout.isTerminal() + ? Deno.consoleSize() + : { columns: 80, rows: 24 }; + + Deno.stdin.setRaw(true); + yield* ensure(() => Deno.stdin.setRaw(false)); + + let stdin = yield* useStdin(); + let input = useInput(stdin); + + let term = yield* until(createTerm({ width: columns, height: rows })); + + let tty = settings(alternateBuffer(), cursor(false), mouseTracking()); + Deno.stdout.writeSync(tty.apply); + yield* ensure(() => { + Deno.stdout.writeSync(tty.revert); + }); + + let state: State = { + boxes: Array.from({ length: PALETTE.length }, (_, i) => ({ + id: i, + color: PALETTE[i], + })), + wide: false, + }; + + let flag = { animating: false }; + + function draw(): void { + let { output, animating } = term.render(view(state)); + flag.animating = animating; + Deno.stdout.writeSync(output); + } + + draw(); + + let ticks = ticker(flag); + let events = merge(input, ticks); + + for (let ev of yield* each(events)) { + if (ev !== undefined && typeof ev === "object" && "type" in ev) { + let e = ev as InputEvent; + + if (e.type === "keydown") { + if (e.ctrl && e.key === "c") { + break; + } + if (e.key === "q") { + break; + } + if (e.key === "s") { + state = { ...state, boxes: fisherYates(state.boxes) }; + } + if (e.key === "w") { + state = { ...state, wide: !state.wide }; + } + } + } + + draw(); + yield* each.next(); + } +}); From b6f44d94a94f2c43f4a9a4e30e6ad64b8dd55337 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 10:48:22 -0500 Subject: [PATCH 3/5] fix(term): clear combining marks in edge_cell edge_cell writes cells directly, bypassing setcell's combining-mark clearing added in #114. A fractional edge repainting a cell that a text element marked earlier in the same frame reattached the stale mark to the block glyph. Regression test paints a floating overlay whose left edge lands mid-cell over a marked cell. --- src/clayterm.c | 5 +++++ test/transitions.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/clayterm.c b/src/clayterm.c index 8b035da..11e9502 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -403,6 +403,11 @@ static void edge_cell(struct Clayterm *ct, int x, int y, EdgeSide side, int n = (int)(cov * 8.0f + 0.5f); /* 8 hard sub-positions per axis */ if (n <= 0) return; + /* Overwriting the glyph invalidates any combining marks attached to the + * previous content. setcell clears them on every write; this direct-write + * path must do the same or stale marks reattach to the block glyph. */ + for (int i = 0; i < CELL_MAX_COMBINING; i++) + c->combining[i] = 0; if (n >= 8) { c->ch = 0x2588; c->fg = fill; diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 6e1f1e6..92901dd 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -6,6 +6,7 @@ import { grow, type Op, open, + percent, rgba, text, } from "../mod.ts"; @@ -121,6 +122,35 @@ describe("transitions", () => { } expect(sawPartial).toBe(true); }); + + it("clears combining marks when an edge repaints a marked cell", async () => { + // The overlay floats at x=12.5, so its left edge lands mid-cell over + // cell 12. Floating elements paint after earlier siblings, so in this + // single frame the text first writes "e" + U+0301 into cell 12 and the + // overlay's edge glyph then overwrites it — the edge path must clear + // the mark the way setcell does. + let W = 21, H = 3; + let term = await createTerm({ width: W, height: H }); + let frame: Op[] = [ + open("root", { layout: { width: grow(), height: grow() } }), + open("box", { layout: { width: percent(1), height: fixed(1) } }), + text("abcdefghijkl" + "e\u0301"), // U+0301 lands in cell 12 + close(), + open("overlay", { + layout: { width: fixed(4), height: fixed(1) }, + bg: rgba(0, 0, 255), + floating: { x: 12.5, attachTo: "parent" }, + }), + close(), + close(), + ]; + + let out = decode(term.render(frame, { deltaTime: 0 }).output); + // The overlay's edge glyph must be painted over the marked cell… + expect(EIGHTH_BLOCKS.test(print(out, W, H))).toBe(true); + // …and must not inherit the stale combining mark. + expect(out).not.toContain("\u0301"); + }); }); describe("line mode", () => { From c89ca69465b44d35d0ab0d1cdb80b2bacb96f311 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 11 Oct 2026 11:12:29 -0500 Subject: [PATCH 4/5] fix(clay): animate position transitions when reparenting unchanged Clay suppresses a position transition when the element's position relative to its parent is unchanged, to keep children from animating when their parent moves. But the comparison crosses reparenting: an element moving to a sibling container at the same relative slot (a box swapped between two rows of a grid) teleports instead of animating. Skip the relative-position check for reparented elements; the absolute position comparison already made the call. Ships as a clay.patch until upstream. --- .../clay-reparent-position-transition.patch | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 patches/clay-reparent-position-transition.patch diff --git a/patches/clay-reparent-position-transition.patch b/patches/clay-reparent-position-transition.patch new file mode 100644 index 0000000..4d07699 --- /dev/null +++ b/patches/clay-reparent-position-transition.patch @@ -0,0 +1,19 @@ +diff --git a/clay.h b/clay.h +index 6452cd6..6b15cb4 100644 +--- a/clay.h ++++ b/clay.h +@@ -4589,12 +4589,12 @@ Clay_RenderCommandArray Clay_EndLayout(float deltaTime) { + Clay_TransitionProperty properties = currentElement->config.transition.properties; + int32_t activeProperties = CLAY_TRANSITION_PROPERTY_NONE; + if (properties & CLAY_TRANSITION_PROPERTY_X) { +- if (!Clay__FloatEqual(oldTargetState.boundingBox.x, targetState.boundingBox.x) && !(Clay__FloatEqual(oldRelativePosition.x, newRelativePosition.x)) && !context->rootResizedLastFrame) { ++ if (!Clay__FloatEqual(oldTargetState.boundingBox.x, targetState.boundingBox.x) && (transitionData->reparented || !Clay__FloatEqual(oldRelativePosition.x, newRelativePosition.x)) && !context->rootResizedLastFrame) { + activeProperties |= CLAY_TRANSITION_PROPERTY_X; + } + } + if (properties & CLAY_TRANSITION_PROPERTY_Y) { +- if (!Clay__FloatEqual(oldTargetState.boundingBox.y, targetState.boundingBox.y) && !(Clay__FloatEqual(oldRelativePosition.y, newRelativePosition.y)) && !context->rootResizedLastFrame) { ++ if (!Clay__FloatEqual(oldTargetState.boundingBox.y, targetState.boundingBox.y) && (transitionData->reparented || !Clay__FloatEqual(oldRelativePosition.y, newRelativePosition.y)) && !context->rootResizedLastFrame) { + activeProperties |= CLAY_TRANSITION_PROPERTY_Y; + } + } From a9f7c2a9f4be2a3ba5b8a240cd50d40a83e6ea42 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 11 Oct 2026 11:12:31 -0500 Subject: [PATCH 5/5] feat(transitions): vertical and diagonal motion in subcell demo The demo becomes a 2x3 grid with one key per movement class: s shuffles within rows (horizontal ramps), v swaps each column (vertical, lower-block ramps), d slides every box to the other row and one column over (both ramps at once), w stays wide/narrow. Adds a transitions test for the lower-block vertical ramps; corners stay blank by design, with the reasoning recorded in clayterm.c. Also adds the branch's changeset covering sub-cell edge rendering. --- .changeset/subcell-edge-smoothing.md | 7 ++ examples/transitions/subcell.ts | 112 ++++++++++++++++++++------- src/clayterm.c | 16 ++-- test/transitions.test.ts | 27 +++++++ 4 files changed, 127 insertions(+), 35 deletions(-) create mode 100644 .changeset/subcell-edge-smoothing.md diff --git a/.changeset/subcell-edge-smoothing.md b/.changeset/subcell-edge-smoothing.md new file mode 100644 index 0000000..dfcc698 --- /dev/null +++ b/.changeset/subcell-edge-smoothing.md @@ -0,0 +1,7 @@ +--- +"@bomb.sh/tty": minor +--- + +Renders fractional rect edges as eighth-block glyphs (▏▎▍▌▋▊▉ horizontally, ▁▂▃▄▅▆▇ vertically) instead of snapping to cell boundaries, so animated rectangles slide smoothly through the shallow ends of an ease curve. Edge glyphs composite over whatever they slide across and clear stale combining marks from the cells they repaint. Corner cells stay blank where a horizontal and vertical edge meet mid-cell; a single block glyph can't express an L-corner and finer mosaic glyphs aren't reliable across terminal fonts. + +Also fixes position transitions not animating when an element moves between layout containers while keeping the same position relative to its new parent (e.g. a box swapped between two rows of a grid): such moves now slide instead of teleporting. diff --git a/examples/transitions/subcell.ts b/examples/transitions/subcell.ts index 9029809..a30751f 100644 --- a/examples/transitions/subcell.ts +++ b/examples/transitions/subcell.ts @@ -1,14 +1,17 @@ /** * Sub-cell rect smoothing demo. * - * A row of filled boxes (no borders) that animate their position. Because the - * boxes are rectangle fills, their leading/trailing edges land on fractional - * cell columns mid-transition and render as eighth-block glyphs (▏▎▍▌▋▊▉) - * instead of snapping column to column. + * A 2x3 grid of filled boxes (no borders) that animate between slots. Because + * the boxes are rectangle fills, their leading/trailing edges land on + * fractional cells mid-transition and render as block glyphs instead of + * snapping cell to cell: * - * Press 's' to shuffle (animates position). - * Press 'w' to toggle wide/narrow (animates width). - * Press 'q' or Ctrl+C to quit. + * s — shuffle within rows horizontal: eighth-block columns (▏▎▍▌▋▊▉) + * v — swap each column vertical: eighth-block rows (▁▂▃▄▅▆▇) + * d — slide diagonally diagonal: both ramps at once (corner cells + * stay blank — see clayterm.c) + * w — toggle wide/narrow (animates width) + * q or Ctrl+C — quit * * Filled, border-free boxes are deliberate: a border would emit a separate * (still integer-snapped) command that overdraws the fill's fractional edge. @@ -72,13 +75,41 @@ interface State { wide: boolean; } -function fisherYates(arr: T[]): T[] { +const COLS = 3; + +function shuffleRows(arr: T[]): T[] { let out = arr.slice(); - for (let i = out.length - 1; i > 0; i--) { - let j = Math.floor(Math.random() * (i + 1)); - let tmp = out[i]; - out[i] = out[j]; - out[j] = tmp; + for (let lo = 0; lo < out.length; lo += COLS) { + let hi = Math.min(lo + COLS, out.length); + for (let i = hi - 1; i > lo; i--) { + let j = lo + Math.floor(Math.random() * (i - lo + 1)); + let tmp = out[i]; + out[i] = out[j]; + out[j] = tmp; + } + } + return out; +} + +/* Swap the two boxes in every column: vertical movement only. */ +function swapColumns(arr: T[]): T[] { + let out = arr.slice(); + for (let c = 0; c < COLS; c++) { + let tmp = out[c]; + out[c] = out[c + COLS]; + out[c + COLS] = tmp; + } + return out; +} + +/* Flip every box to the other row and shift it one column: row and column + * both change, so x and y lerp together and the corners composite. */ +function slideDiagonal(arr: T[]): T[] { + let out: T[] = new Array(arr.length); + for (let i = 0; i < arr.length; i++) { + let r = Math.floor(i / COLS); + let c = i % COLS; + out[(1 - r) * COLS + ((c + 1) % COLS)] = arr[i]; } return out; } @@ -112,40 +143,57 @@ function view(state: State): Op[] { bg: HINT_BG, }), ...hint("s", "shuffle"), + ...hint("v", "vertical"), + ...hint("d", "diagonal"), ...hint("w", "wide/narrow"), ...hint("q", "quit"), close(), ); ops.push( - open("row", { + open("grid", { layout: { width: grow(), height: grow(), - direction: "ltr", + direction: "ttb", padding: { left: 2, right: 2, top: 1, bottom: 1 }, - gap: 2, - alignY: "center", + gap: 1, }, }), ); - for (let b of state.boxes) { + for (let r = 0; r < 2; r++) { ops.push( - open(`box:${b.id}`, { + open(`row:${r}`, { layout: { - width: state.wide ? grow() : fixed(7), - height: fixed(5), - }, - bg: b.color, - transition: { - duration: 0.5, - easing: "easeInOut", - properties: ["width", "position"], + width: grow(), + height: grow(), + direction: "ltr", + gap: 2, + alignX: "center", + alignY: "center", }, }), - close(), ); + for (let c = 0; c < COLS; c++) { + let b = state.boxes[r * COLS + c]; + ops.push( + open(`box:${b.id}`, { + layout: { + width: state.wide ? grow() : fixed(7), + height: fixed(5), + }, + bg: b.color, + transition: { + duration: 0.5, + easing: "easeInOut", + properties: ["width", "position"], + }, + }), + close(), + ); + } + ops.push(close()); } ops.push(close()); @@ -247,7 +295,13 @@ await main(function* () { break; } if (e.key === "s") { - state = { ...state, boxes: fisherYates(state.boxes) }; + state = { ...state, boxes: shuffleRows(state.boxes) }; + } + if (e.key === "v") { + state = { ...state, boxes: swapColumns(state.boxes) }; + } + if (e.key === "d") { + state = { ...state, boxes: slideDiagonal(state.boxes) }; } if (e.key === "w") { state = { ...state, wide: !state.wide }; diff --git a/src/clayterm.c b/src/clayterm.c index 11e9502..afe4e57 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -439,9 +439,14 @@ static void edge_cell(struct Clayterm *ct, int x, int y, EdgeSide side, } } -/* Solid interior + four coverage boundary lines. Corners are left empty in v1: - * both fractions meet in one cell and a single eighth-block can't express an - * L-corner; the octant path is deferred. */ +/* Solid interior + four coverage boundary lines. Corners are left empty: + * both fractions meet in one cell and a single eighth-block can't express + * an L-corner, and the quadrant/octant glyphs that could are either too + * coarse (quadrant = hard half-by-half paint regardless of sliver size) or + * too poorly supported in terminal fonts (Unicode 16 octants) to be safe + * mid-animation. A missing corner sliver reads as a faint notch, which is + * far less distracting than the over-ink one axis claiming the cell would + * produce. */ static void render_rect_cov(struct Clayterm *ct, Clay_BoundingBox b, uint32_t fill) { float l = b.x, r = b.x + b.width; @@ -463,9 +468,8 @@ static void render_rect_cov(struct Clayterm *ct, Clay_BoundingBox b, float tf = (float)yi0 - t; float bf = btm - (float)yi1; - /* edges span the interior extent only; the 4 corner cells are deferred to the - * octant path. A missing corner sliver reads as a faint notch, which is far - * less distracting than the over-ink you get by letting one axis claim it. */ + /* edges span the interior extent only; corner cells stay blank (see the + * render_rect_cov comment) */ for (int x = xi0; x < xi1; x++) { if (tf > 0.0f) edge_cell(ct, x, yi0 - 1, EDGE_TOP, tf, fill); diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 92901dd..1bfff4f 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -151,6 +151,33 @@ describe("transitions", () => { // …and must not inherit the stale combining mark. expect(out).not.toContain("\u0301"); }); + + it("renders lower-block edges for a fractional rect height", async () => { + let W = 14, H = 6; + let term = await createTerm({ width: W, height: H }); + let frame: Op[] = [ + open("root", { layout: { width: grow(), height: grow() } }), + open("box", { + layout: { width: fixed(6), height: fixed(3) }, + bg: rgba(255, 0, 0), + floating: { x: 2, y: 1.5, attachTo: "parent" }, + }), + close(), + close(), + ]; + + let grid = print( + decode(term.render(frame, { deltaTime: 0 }).output), + W, + H, + ) + .split("\n"); + // top edge covers the bottom half of row 1, bottom edge the top half of + // row 4; x is integer-aligned so there are no side ramps or corners + expect(grid[1].slice(2, 8)).toBe("▄▄▄▄▄▄"); + expect(grid[4].slice(2, 8)).toBe("▄▄▄▄▄▄"); + expect(grid[2].slice(1, 9)).toBe(" "); + }); }); describe("line mode", () => {