diff --git a/src/clayterm.c b/src/clayterm.c index e48091e..66b3b27 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -291,12 +291,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); + } } static void render_text(struct Clayterm *ct, int x0, int y0, @@ -726,7 +824,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 });