Skip to content

Commit 2d44ee2

Browse files
committed
📝 add scroll specification and implementation
Introduces specs/scroll-spec.md (v0.2) defining clip configuration with per-axis numeric offsets and wheel delta reporting via RenderInfo. Clip API changes from { horizontal, vertical } to { x?, y? } with numeric offsets. Adds RenderOptions.event for input event integration and deprecates the manual pointer option. Includes scroll demo with ~10k lines of lorem markdown.
1 parent 9470772 commit 2d44ee2

14 files changed

Lines changed: 887 additions & 25 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/lorem.ts

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

demo/scroll.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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+
import content from "./lorem.ts";
22+
23+
let FG = rgba(204, 204, 204);
24+
let DIM = rgba(100, 100, 110);
25+
let GUTTER_FG = rgba(100, 120, 160);
26+
let STATUS_BG = rgba(30, 30, 40);
27+
let STATUS_FG = rgba(180, 180, 190);
28+
let THUMB = rgba(120, 120, 140);
29+
let TRACK = rgba(40, 40, 50);
30+
31+
let lines = content.split("\n");
32+
33+
function gutter(lineNum: number, width: number): string {
34+
let num = String(lineNum);
35+
let pad = width - 2 - num.length;
36+
return " ".repeat(Math.max(0, pad)) + num + " \u2502";
37+
}
38+
39+
function gutterWidth(): number {
40+
let digits = Math.max(1, Math.floor(Math.log10(lines.length)) + 1);
41+
return Math.max(4, digits + 2);
42+
}
43+
44+
function thumbGeometry(
45+
scrollY: number,
46+
totalLines: number,
47+
viewportHeight: number,
48+
): { pos: number; size: number } {
49+
let size = totalLines > viewportHeight
50+
? Math.max(1, Math.round(viewportHeight * viewportHeight / totalLines))
51+
: viewportHeight;
52+
let maxScroll = Math.max(totalLines - viewportHeight, 1);
53+
let pos = Math.round((scrollY / maxScroll) * (viewportHeight - size));
54+
return { pos, size };
55+
}
56+
57+
function build(
58+
scrollY: number,
59+
rows: number,
60+
): Op[] {
61+
let ops: Op[] = [];
62+
let gw = gutterWidth();
63+
let viewHeight = rows - 1;
64+
let { pos, size } = thumbGeometry(scrollY, lines.length, viewHeight);
65+
66+
ops.push(open("root", {
67+
layout: { width: grow(), height: grow(), direction: "ttb" },
68+
}));
69+
70+
for (let row = 0; row < viewHeight; row++) {
71+
let lineIdx = scrollY + row;
72+
let hasLine = lineIdx < lines.length;
73+
let lineNum = hasLine ? lineIdx + 1 : 0;
74+
let lineText = hasLine ? lines[lineIdx] : "";
75+
76+
let inThumb = row >= pos && row < pos + size;
77+
let scrollChar = inThumb ? "\u2588" : "\u2502";
78+
let scrollColor = inThumb ? THUMB : TRACK;
79+
80+
ops.push(
81+
open(`r${row}`, {
82+
layout: { direction: "ltr", height: fixed(1), width: grow() },
83+
}),
84+
open("", { layout: { width: fixed(gw), height: fixed(1) } }),
85+
text(hasLine ? gutter(lineNum, gw) : " ".repeat(gw - 1) + "\u2502", {
86+
color: hasLine ? GUTTER_FG : DIM,
87+
}),
88+
close(),
89+
open("", { layout: { width: grow(), height: fixed(1) } }),
90+
text(lineText || " ", { color: hasLine ? FG : DIM }),
91+
close(),
92+
open("", { layout: { width: fixed(1), height: fixed(1) } }),
93+
text(scrollChar, { color: scrollColor }),
94+
close(),
95+
close(),
96+
);
97+
}
98+
99+
let status = ` ${
100+
scrollY + 1
101+
}/${lines.length} n/p:line j/k:scroll g/G:top/bottom q:quit`;
102+
ops.push(
103+
open("status", {
104+
layout: {
105+
width: grow(),
106+
height: fixed(1),
107+
direction: "ltr",
108+
padding: { left: 1 },
109+
},
110+
bg: STATUS_BG,
111+
}),
112+
text(status, { color: STATUS_FG }),
113+
close(),
114+
);
115+
116+
ops.push(close());
117+
return ops;
118+
}
119+
120+
function clamp(v: number, min: number, max: number): number {
121+
if (v < min) {
122+
return min;
123+
} else {
124+
return v > max ? max : v;
125+
}
126+
}
127+
128+
await main(function* () {
129+
let { columns, rows } = Deno.stdout.isTerminal()
130+
? Deno.consoleSize()
131+
: { columns: 80, rows: 24 };
132+
133+
Deno.stdin.setRaw(true);
134+
135+
let stdin = yield* useStdin();
136+
let input = useInput(stdin);
137+
138+
let term = yield* until(createTerm({ width: columns, height: rows }));
139+
140+
let tty = settings(alternateBuffer(), cursor(false), mouseTracking());
141+
Deno.stdout.writeSync(tty.apply);
142+
143+
yield* ensure(() => {
144+
Deno.stdout.writeSync(tty.revert);
145+
});
146+
147+
let scrollY = 0;
148+
let viewHeight = rows - 1;
149+
let maxScroll = Math.max(lines.length - viewHeight, 0);
150+
let drag = { active: false, offset: 0 };
151+
152+
Deno.stdout.writeSync(term.render(build(scrollY, rows)).output);
153+
154+
for (let event of yield* each(input)) {
155+
if (event.type === "keydown" && event.ctrl && event.key === "c") break;
156+
if (event.type === "keydown" && event.key === "q") break;
157+
158+
if (event.type === "keydown") {
159+
switch (event.code) {
160+
case "n":
161+
case "j":
162+
case "ArrowDown":
163+
scrollY = clamp(scrollY + 1, 0, maxScroll);
164+
break;
165+
case "p":
166+
case "k":
167+
case "ArrowUp":
168+
scrollY = clamp(scrollY - 1, 0, maxScroll);
169+
break;
170+
case "d":
171+
case "PageDown":
172+
scrollY = clamp(scrollY + Math.floor(viewHeight / 2), 0, maxScroll);
173+
break;
174+
case "u":
175+
case "PageUp":
176+
scrollY = clamp(scrollY - Math.floor(viewHeight / 2), 0, maxScroll);
177+
break;
178+
case "g":
179+
case "Home":
180+
scrollY = 0;
181+
break;
182+
case "End":
183+
scrollY = maxScroll;
184+
break;
185+
}
186+
if ((event as InputEvent & { key: string }).key === "G") {
187+
scrollY = maxScroll;
188+
}
189+
}
190+
191+
if (event.type === "wheel") {
192+
let delta = event.direction === "down" ? 3 : -3;
193+
scrollY = clamp(scrollY + delta, 0, maxScroll);
194+
}
195+
196+
if (
197+
event.type === "mousedown" &&
198+
event.button === "left" &&
199+
event.x === columns - 1 &&
200+
event.y < viewHeight
201+
) {
202+
let { pos, size } = thumbGeometry(scrollY, lines.length, viewHeight);
203+
if (event.y >= pos && event.y < pos + size) {
204+
drag.active = true;
205+
drag.offset = event.y - pos;
206+
} else {
207+
let fraction = event.y / Math.max(viewHeight - 1, 1);
208+
scrollY = clamp(Math.round(fraction * maxScroll), 0, maxScroll);
209+
}
210+
}
211+
212+
if (event.type === "mousemove" && drag.active) {
213+
let { size } = thumbGeometry(scrollY, lines.length, viewHeight);
214+
let fraction = (event.y - drag.offset) /
215+
Math.max(viewHeight - size, 1);
216+
scrollY = clamp(Math.round(fraction * maxScroll), 0, maxScroll);
217+
}
218+
219+
if (event.type === "mouseup") {
220+
drag.active = false;
221+
}
222+
223+
if (event.type === "resize") {
224+
columns = event.width;
225+
rows = event.height;
226+
viewHeight = rows - 1;
227+
maxScroll = Math.max(lines.length - viewHeight, 0);
228+
scrollY = clamp(scrollY, 0, maxScroll);
229+
term = yield* until(createTerm({ width: columns, height: rows }));
230+
}
231+
232+
Deno.stdout.writeSync(
233+
term.render(build(scrollY, rows)).output,
234+
);
235+
236+
yield* each.next();
237+
}
238+
});

deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"fmt:check": "deno fmt --check && clang-format --dry-run --Werror src/*.c src/*.h",
88
"build:npm": "deno run -A tasks/build-npm.ts",
99
"build:jsr": "deno run -A tasks/build-jsr.ts",
10-
"demo": "deno run demo/keyboard.ts"
10+
"demo": "deno run demo/keyboard.ts",
11+
"demo:scroll": "deno run demo/scroll.ts"
1112
},
1213
"imports": {
1314
"@std/testing": "jsr:@std/testing@1",

ops.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,18 @@ export function pack(
149149
}
150150

151151
if (op.clip) {
152-
view.setUint32(
153-
o,
154-
(op.clip.horizontal ? 1 : 0) | ((op.clip.vertical ? 1 : 0) << 8),
155-
true,
156-
);
152+
let xm = op.clip.x !== undefined ? 1 : 0;
153+
let ym = op.clip.y !== undefined ? 1 : 0;
154+
view.setUint32(o, xm | (ym << 8), true);
157155
o += 4;
156+
if (xm) {
157+
view.setFloat32(o, op.clip.x!, true);
158+
o += 4;
159+
}
160+
if (ym) {
161+
view.setFloat32(o, op.clip.y!, true);
162+
o += 4;
163+
}
158164
}
159165

160166
if (op.floating) {
@@ -260,7 +266,7 @@ export interface OpenElement {
260266
top?: number;
261267
bottom?: number;
262268
};
263-
clip?: { horizontal?: boolean; vertical?: boolean };
269+
clip?: { x?: number; y?: number };
264270
floating?: {
265271
x?: number;
266272
y?: number;

specs/input-spec.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,22 @@ has already been extended with fields that are not yet mapped to the TS types).
135135

136136
---
137137

138-
## 6. Deferred / Future Areas
138+
## 6. Integration with Rendering
139+
140+
Input events produced by the parser can be passed directly to the renderer via
141+
`RenderOptions.event`. The renderer extracts pointer state from mouse events and
142+
scroll deltas from wheel events, removing the need for the caller to manually
143+
decompose input events into renderer-specific structures.
144+
145+
See the [Scroll Specification](scroll-spec.md), Section 5 for details.
146+
147+
This integration does not create an architectural dependency between the input
148+
parser and the renderer. The `InputEvent` type is the shared contract; neither
149+
module imports the other.
150+
151+
---
152+
153+
## 7. Deferred / Future Areas
139154

140155
_These topics are explicitly excluded from this specification. Their omission is
141156
intentional, not an oversight._
@@ -153,7 +168,7 @@ decision is open.
153168

154169
---
155170

156-
## Open Decisions
171+
## 8. Open Decisions
157172

158173
1. **What are the normative Kitty progressive enhancement event types?** The
159174
C-side struct has been extended. The TypeScript types have not been updated.

0 commit comments

Comments
 (0)