Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/subcell-edge-smoothing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changeset needs revision.

Changeset should start with an approved active present-tense verb. Changeset package scope does not match the affected packages confidently. Changeset is not sufficiently consumer-focused.

View the proposed replacement
---
'@bomb.sh/tty': minor
---

Adds sub-cell rect smoothing and fixes cross-container position transitions.

Animated rectangle edges now render as eighth-block glyphs (▏▎▍▌▋▊▉ horizontally, ▁▂▃▄▅▆▇ vertically) instead of snapping to cell boundaries, so fills slide smoothly through the shallow ends of an ease curve. Edge glyphs composite over whatever they slide across, and stale combining marks are cleared from any cell they repaint. Corner cells where a horizontal and vertical edge meet mid-cell are left blank — a single block glyph cannot express an L-corner.

Also fixes `position` transitions teleporting instead of 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).

#### Migration

This release changes how `render_rect` handles fractional bounding boxes. The C backend now calls `render_rect_cov` and passes the raw `Clay_BoundingBox` float coordinates; any fork that wraps or replaces the render backend must update its call site:

```diff
-render_rect(ct, x0, y0, x1, y1, &cmd->renderData.rectangle);
+render_rect_cov(ct, box, color(cmd->renderData.rectangle.backgroundColor));
```

"@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.
315 changes: 315 additions & 0 deletions examples/transitions/subcell.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<T>(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<T>(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<void, void> {
return resource(function* (provide) {
let ch = createChannel<void, void>();
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, B, TClose>(
a: Stream<A, TClose>,
b: Stream<B, TClose>,
): Stream<A | B, TClose> {
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<T, TClose>(stream: Stream<T, TClose>) {
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();
}
});
19 changes: 19 additions & 0 deletions patches/clay-reparent-position-transition.patch
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading