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 new file mode 100644 index 0000000..a30751f --- /dev/null +++ b/examples/transitions/subcell.ts @@ -0,0 +1,315 @@ +/** + * Sub-cell rect smoothing demo. + * + * 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: + * + * 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. + * 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; +} + +const COLS = 3; + +function shuffleRows(arr: T[]): T[] { + let out = arr.slice(); + 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; +} + +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("v", "vertical"), + ...hint("d", "diagonal"), + ...hint("w", "wide/narrow"), + ...hint("q", "quit"), + close(), + ); + + ops.push( + open("grid", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 2, right: 2, top: 1, bottom: 1 }, + gap: 1, + }, + }), + ); + + for (let r = 0; r < 2; r++) { + ops.push( + open(`row:${r}`, { + layout: { + width: grow(), + height: grow(), + direction: "ltr", + gap: 2, + alignX: "center", + alignY: "center", + }, + }), + ); + 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()); + 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: 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 }; + } + } + } + + draw(); + yield* each.next(); + } +}); 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; + } + } diff --git a/src/clayterm.c b/src/clayterm.c index 7e4e62e..afe4e57 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -369,12 +369,119 @@ 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; + /* 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; + 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: + * 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; + 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; 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); + 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 +1045,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..1bfff4f 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -6,9 +6,16 @@ import { grow, type Op, open, + percent, 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 +87,99 @@ 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); + }); + + 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"); + }); + + 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", () => { it("runs color transitions in line mode", async () => { let term = await createTerm({ width: 20, height: 5 });