Skip to content

Commit e030433

Browse files
committed
📝 add scroll specification and implementation
Introduces specs/scroll-spec.md (v0.2) with caller-owned scroll model. Clip API changes from { horizontal, vertical } to { x?, y? } with numeric offsets. Adds scrollDelta on ElementInfo for wheel delta reporting. Adds RenderOptions.event for input event integration. Includes scroll demo with ~10k lines of lorem markdown.
1 parent 9470772 commit e030433

16 files changed

Lines changed: 1271 additions & 26 deletions

AGENTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@
1414
- The workflow is: propose the spec change, wait for approval, then implement.
1515
Do not combine spec changes with implementation in a single step.
1616

17+
- During implementation, the spec is the sole authority. Do not add APIs,
18+
exports, parameters, or architectural elements that are not described in the
19+
spec — even if an implementation plan or subagent recommends them. If the spec
20+
is insufficient, update the spec first.
21+
22+
- If an implementation requires changing any API boundary — public TS exports,
23+
WASM exports, function signatures, the command protocol, or any interface that
24+
crosses a module boundary — stop and consult the user before proceeding. Do
25+
not rationalize changes as "internal." If it has a signature, it's an API.
26+
1727
- The renderer and input parser are specified separately (`renderer-spec.md` and
1828
`input-spec.md`). They are architecturally independent. Do not introduce
1929
dependencies between them.
@@ -26,6 +36,10 @@
2636
Do not include any agent marketing material (e.g. "Generated with...",
2737
"Co-Authored-By: \<agent>") in commits, pull requests, issues, or comments.
2838

39+
Before every commit, run `deno fmt` and `deno lint` and fix any issues. For C
40+
files, also run `clang-format -i src/*.c src/*.h`. Do not commit unformatted
41+
code.
42+
2943
## Rendering invariants
3044

3145
- The renderer MUST NOT perform IO. It produces bytes; the caller writes them.

demo/colors.ts

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import { each, ensure, main, until } from "effection";
2+
import {
3+
close,
4+
createTerm,
5+
fixed,
6+
grow,
7+
type InputEvent,
8+
type Op,
9+
open,
10+
rgba,
11+
text,
12+
} from "../mod.ts";
13+
import {
14+
alternateBuffer,
15+
cursor,
16+
mouseTracking,
17+
settings,
18+
} from "../settings.ts";
19+
import { useInput } from "./use-input.ts";
20+
import { useStdin } from "./use-stdin.ts";
21+
22+
let SWATCHES = [
23+
{ name: "Rose", r: 255, g: 0, b: 127 },
24+
{ name: "Crimson", r: 220, g: 20, b: 60 },
25+
{ name: "Tomato", r: 255, g: 99, b: 71 },
26+
{ name: "Coral", r: 255, g: 127, b: 80 },
27+
{ name: "Salmon", r: 250, g: 128, b: 114 },
28+
{ name: "Scarlet", r: 255, g: 36, b: 0 },
29+
{ name: "Vermillion", r: 227, g: 66, b: 52 },
30+
{ name: "Rust", r: 183, g: 65, b: 14 },
31+
{ name: "Terracotta", r: 204, g: 78, b: 92 },
32+
{ name: "Brick", r: 203, g: 65, b: 84 },
33+
{ name: "Tangerine", r: 255, g: 159, b: 0 },
34+
{ name: "Amber", r: 255, g: 191, b: 0 },
35+
{ name: "Marigold", r: 234, g: 162, b: 33 },
36+
{ name: "Gold", r: 255, g: 215, b: 0 },
37+
{ name: "Honey", r: 235, g: 177, b: 52 },
38+
{ name: "Saffron", r: 244, g: 196, b: 48 },
39+
{ name: "Canary", r: 255, g: 239, b: 0 },
40+
{ name: "Lemon", r: 255, g: 247, b: 0 },
41+
{ name: "Butter", r: 255, g: 225, b: 128 },
42+
{ name: "Cream", r: 255, g: 253, b: 208 },
43+
{ name: "Lime", r: 0, g: 255, b: 0 },
44+
{ name: "Chartreuse", r: 127, g: 255, b: 0 },
45+
{ name: "Emerald", r: 80, g: 200, b: 120 },
46+
{ name: "Jade", r: 0, g: 168, b: 107 },
47+
{ name: "Mint", r: 152, g: 255, b: 152 },
48+
{ name: "Sage", r: 188, g: 184, b: 138 },
49+
{ name: "Forest", r: 34, g: 139, b: 34 },
50+
{ name: "Pine", r: 1, g: 121, b: 111 },
51+
{ name: "Olive", r: 128, g: 128, b: 0 },
52+
{ name: "Fern", r: 79, g: 121, b: 66 },
53+
{ name: "Teal", r: 0, g: 128, b: 128 },
54+
{ name: "Cyan", r: 0, g: 255, b: 255 },
55+
{ name: "Aqua", r: 0, g: 255, b: 255 },
56+
{ name: "Turquoise", r: 64, g: 224, b: 208 },
57+
{ name: "Seafoam", r: 159, g: 226, b: 191 },
58+
{ name: "Cerulean", r: 0, g: 123, b: 167 },
59+
{ name: "Azure", r: 0, g: 127, b: 255 },
60+
{ name: "Sky", r: 135, g: 206, b: 235 },
61+
{ name: "Cornflower", r: 100, g: 149, b: 237 },
62+
{ name: "Periwinkle", r: 204, g: 204, b: 255 },
63+
{ name: "Cobalt", r: 0, g: 71, b: 171 },
64+
{ name: "Royal", r: 65, g: 105, b: 225 },
65+
{ name: "Navy", r: 0, g: 0, b: 128 },
66+
{ name: "Midnight", r: 25, g: 25, b: 112 },
67+
{ name: "Sapphire", r: 15, g: 82, b: 186 },
68+
{ name: "Indigo", r: 75, g: 0, b: 130 },
69+
{ name: "Violet", r: 127, g: 0, b: 255 },
70+
{ name: "Amethyst", r: 153, g: 102, b: 204 },
71+
{ name: "Lavender", r: 230, g: 230, b: 250 },
72+
{ name: "Lilac", r: 200, g: 162, b: 200 },
73+
{ name: "Plum", r: 142, g: 69, b: 133 },
74+
{ name: "Orchid", r: 218, g: 112, b: 214 },
75+
{ name: "Magenta", r: 255, g: 0, b: 255 },
76+
{ name: "Fuchsia", r: 255, g: 0, b: 128 },
77+
{ name: "Mauve", r: 224, g: 176, b: 255 },
78+
{ name: "Berry", r: 142, g: 0, b: 82 },
79+
{ name: "Wine", r: 114, g: 47, b: 55 },
80+
{ name: "Burgundy", r: 128, g: 0, b: 32 },
81+
{ name: "Maroon", r: 128, g: 0, b: 0 },
82+
{ name: "Mahogany", r: 192, g: 64, b: 0 },
83+
{ name: "Sienna", r: 160, g: 82, b: 45 },
84+
{ name: "Chocolate", r: 123, g: 63, b: 0 },
85+
{ name: "Cinnamon", r: 210, g: 105, b: 30 },
86+
{ name: "Caramel", r: 255, g: 213, b: 128 },
87+
{ name: "Peach", r: 255, g: 218, b: 185 },
88+
{ name: "Apricot", r: 251, g: 206, b: 177 },
89+
{ name: "Sand", r: 194, g: 178, b: 128 },
90+
{ name: "Tan", r: 210, g: 180, b: 140 },
91+
{ name: "Khaki", r: 195, g: 176, b: 145 },
92+
{ name: "Taupe", r: 72, g: 60, b: 50 },
93+
{ name: "Ivory", r: 255, g: 255, b: 240 },
94+
{ name: "Pearl", r: 234, g: 224, b: 200 },
95+
{ name: "Linen", r: 250, g: 240, b: 230 },
96+
{ name: "Bone", r: 227, g: 218, b: 201 },
97+
{ name: "Ash", r: 178, g: 190, b: 181 },
98+
{ name: "Silver", r: 192, g: 192, b: 192 },
99+
{ name: "Pewter", r: 150, g: 150, b: 150 },
100+
{ name: "Slate", r: 112, g: 128, b: 144 },
101+
{ name: "Charcoal", r: 54, g: 69, b: 79 },
102+
{ name: "Graphite", r: 56, g: 56, b: 56 },
103+
{ name: "Onyx", r: 53, g: 56, b: 57 },
104+
{ name: "Jet", r: 52, g: 52, b: 52 },
105+
{ name: "Obsidian", r: 28, g: 28, b: 28 },
106+
{ name: "Smoke", r: 115, g: 130, b: 118 },
107+
{ name: "Steel", r: 113, g: 121, b: 126 },
108+
{ name: "Iron", r: 82, g: 82, b: 82 },
109+
{ name: "Gunmetal", r: 42, g: 52, b: 57 },
110+
{ name: "Titanium", r: 135, g: 134, b: 129 },
111+
{ name: "Chrome", r: 219, g: 226, b: 233 },
112+
{ name: "Platinum", r: 229, g: 228, b: 226 },
113+
{ name: "Quartz", r: 217, g: 217, b: 217 },
114+
{ name: "Opal", r: 168, g: 195, b: 188 },
115+
{ name: "Topaz", r: 255, g: 200, b: 124 },
116+
{ name: "Citrine", r: 228, g: 208, b: 10 },
117+
{ name: "Jasper", r: 215, g: 59, b: 62 },
118+
{ name: "Garnet", r: 115, g: 54, b: 53 },
119+
{ name: "Ruby", r: 224, g: 17, b: 95 },
120+
{ name: "Carmine", r: 150, g: 0, b: 24 },
121+
{ name: "Copper", r: 184, g: 115, b: 51 },
122+
{ name: "Bronze", r: 205, g: 127, b: 50 },
123+
];
124+
125+
let DIM = rgba(80, 80, 90);
126+
let SELECT_BG = rgba(40, 80, 160);
127+
let FG = rgba(220, 220, 220);
128+
let STATUS_BG = rgba(30, 30, 40);
129+
let STATUS_FG = rgba(180, 180, 190);
130+
131+
function clamp(v: number, min: number, max: number): number {
132+
if (v < min) {
133+
return min;
134+
} else {
135+
return v > max ? max : v;
136+
}
137+
}
138+
139+
await main(function* () {
140+
let { columns, rows } = Deno.stdout.isTerminal()
141+
? Deno.consoleSize()
142+
: { columns: 80, rows: 24 };
143+
144+
Deno.stdin.setRaw(true);
145+
146+
let stdin = yield* useStdin();
147+
let input = useInput(stdin);
148+
149+
let term = yield* until(createTerm({ width: columns, height: rows }));
150+
151+
let tty = settings(alternateBuffer(), cursor(false), mouseTracking());
152+
Deno.stdout.writeSync(tty.apply);
153+
154+
yield* ensure(() => {
155+
Deno.stdout.writeSync(tty.revert);
156+
});
157+
158+
let selected = 0;
159+
let scrollY = 0;
160+
let viewHeight = rows - 1;
161+
let maxScroll = Math.max(SWATCHES.length - viewHeight, 0);
162+
163+
function ensureVisible() {
164+
if (selected < scrollY) {
165+
scrollY = selected;
166+
} else if (selected >= scrollY + viewHeight) {
167+
scrollY = selected - viewHeight + 1;
168+
}
169+
}
170+
171+
function render(event?: InputEvent) {
172+
let ops: Op[] = [
173+
open("root", {
174+
layout: { width: grow(), height: grow(), direction: "ttb" },
175+
}),
176+
open("list", {
177+
layout: { width: grow(), height: grow(), direction: "ttb" },
178+
clip: { y: -scrollY },
179+
}),
180+
];
181+
182+
for (let i = 0; i < SWATCHES.length; i++) {
183+
let s = SWATCHES[i];
184+
let isSelected = i === selected;
185+
let bg = isSelected ? SELECT_BG : undefined;
186+
let idx = String(i + 1).padStart(3, " ");
187+
188+
ops.push(
189+
open(`s${i}`, {
190+
layout: {
191+
direction: "ltr",
192+
height: fixed(1),
193+
width: grow(),
194+
padding: { left: 1 },
195+
},
196+
bg,
197+
}),
198+
open("", { layout: { width: fixed(4), height: fixed(1) } }),
199+
text(`${idx} `, { color: DIM }),
200+
close(),
201+
open("", {
202+
layout: { width: fixed(3), height: fixed(1) },
203+
bg: rgba(s.r, s.g, s.b),
204+
}),
205+
text(" "),
206+
close(),
207+
open("", {
208+
layout: { width: grow(), height: fixed(1), padding: { left: 1 } },
209+
}),
210+
text(s.name, { color: FG }),
211+
close(),
212+
open("", { layout: { width: fixed(14), height: fixed(1) } }),
213+
text(
214+
`rgb(${String(s.r).padStart(3)},${String(s.g).padStart(3)},${
215+
String(s.b).padStart(3)
216+
})`,
217+
{ color: DIM },
218+
),
219+
close(),
220+
close(),
221+
);
222+
}
223+
224+
ops.push(close()); // list
225+
226+
let s = SWATCHES[selected];
227+
let status = ` ${s.name} rgb(${s.r},${s.g},${s.b}) ${
228+
selected + 1
229+
}/${SWATCHES.length} j/k:\u2195 q:quit`;
230+
ops.push(
231+
open("status", {
232+
layout: {
233+
width: grow(),
234+
height: fixed(1),
235+
direction: "ltr",
236+
padding: { left: 1 },
237+
},
238+
bg: STATUS_BG,
239+
}),
240+
text(status, { color: STATUS_FG }),
241+
close(),
242+
);
243+
244+
ops.push(close()); // root
245+
246+
let result = term.render(ops, event ? { event } : undefined);
247+
let list = result.info.get("list");
248+
if (list && list.scrollDelta.y !== 0) {
249+
scrollY = clamp(scrollY - Math.round(list.scrollDelta.y), 0, maxScroll);
250+
render();
251+
return;
252+
}
253+
Deno.stdout.writeSync(result.output);
254+
}
255+
256+
render();
257+
258+
for (let event of yield* each(input)) {
259+
if (event.type === "keydown" && event.ctrl && event.key === "c") break;
260+
if (event.type === "keydown" && event.key === "q") break;
261+
262+
if (event.type === "keydown") {
263+
switch (event.code) {
264+
case "j":
265+
case "ArrowDown":
266+
selected = clamp(selected + 1, 0, SWATCHES.length - 1);
267+
ensureVisible();
268+
break;
269+
case "k":
270+
case "ArrowUp":
271+
selected = clamp(selected - 1, 0, SWATCHES.length - 1);
272+
ensureVisible();
273+
break;
274+
case "d":
275+
case "PageDown":
276+
selected = clamp(
277+
selected + Math.floor(viewHeight / 2),
278+
0,
279+
SWATCHES.length - 1,
280+
);
281+
ensureVisible();
282+
break;
283+
case "u":
284+
case "PageUp":
285+
selected = clamp(
286+
selected - Math.floor(viewHeight / 2),
287+
0,
288+
SWATCHES.length - 1,
289+
);
290+
ensureVisible();
291+
break;
292+
case "g":
293+
case "Home":
294+
selected = 0;
295+
ensureVisible();
296+
break;
297+
case "End":
298+
selected = SWATCHES.length - 1;
299+
ensureVisible();
300+
break;
301+
}
302+
if ((event as InputEvent & { key: string }).key === "G") {
303+
selected = SWATCHES.length - 1;
304+
ensureVisible();
305+
}
306+
}
307+
308+
if (event.type === "resize") {
309+
columns = event.width;
310+
rows = event.height;
311+
viewHeight = rows - 1;
312+
maxScroll = Math.max(SWATCHES.length - viewHeight, 0);
313+
scrollY = clamp(scrollY, 0, maxScroll);
314+
ensureVisible();
315+
term = yield* until(createTerm({ width: columns, height: rows }));
316+
}
317+
318+
render(event);
319+
320+
yield* each.next();
321+
}
322+
});

demo/lorem.ts

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)