Skip to content

Commit 70445d3

Browse files
committed
📝 add scroll specification and update renderer/input specs
Introduces specs/scroll-spec.md defining per-axis clip modes (manual numeric offset vs pointer-driven wheel scrolling), input event integration via RenderOptions.event, and deprecation of the manual pointer option.
1 parent 9470772 commit 70445d3

3 files changed

Lines changed: 350 additions & 8 deletions

File tree

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.

specs/renderer-spec.md

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,26 @@ Line mode is intended for inline region rendering where the caller manages
353353
cursor positioning externally and the output must work in pipes or non-alternate
354354
screen contexts.
355355

356+
#### 8.2.3 Input event
357+
358+
The optional `event` field on `RenderOptions` accepts a single `InputEvent` from
359+
the input parser. The renderer extracts relevant state from the event:
360+
361+
- **Mouse events** (`mousedown`, `mouseup`, `mousemove`) — update pointer
362+
position and button state for hit testing and pointer event generation.
363+
- **Wheel events** (`wheel`) — apply scroll deltas to pointer-driven scroll
364+
containers. See the [Scroll Specification](scroll-spec.md).
365+
- **Other event types** — ignored by the renderer.
366+
367+
A render transaction accepts at most one input event. When multiple events are
368+
available from a single `input.scan()` call, the caller SHOULD render once per
369+
event. The diff engine ensures frames with no visual change emit zero bytes,
370+
making per-event rendering efficient.
371+
372+
This field deprecates the `pointer` option on `RenderOptions`, which required
373+
the caller to manually decompose mouse events into `{ x, y, down }` state. See
374+
Section 12.4 for deprecation details.
375+
356376
### 8.3 Directive constructors
357377

358378
Directives are created using constructor functions that return plain objects.
@@ -698,15 +718,24 @@ Future versions may restructure the return type.
698718
### 12.4 Pointer event model
699719

700720
Clayterm currently supports pointer hit-testing via the underlying layout
701-
engine's element-identification mechanism. The caller passes pointer state
702-
(`{ x, y, down }`) as part of render options, and the renderer returns pointer
703-
events as part of the render result:
721+
engine's element-identification mechanism. The renderer returns pointer events
722+
as part of the render result:
704723

705724
- `pointerenter` — the pointer has entered an element's bounding box
706725
- `pointerleave` — the pointer has left an element's bounding box
707726
- `pointerclick` — a pointer-up occurred over an element that was also under the
708727
pointer at pointer-down
709728

729+
**Input event integration.** The preferred way to drive pointer state is via
730+
`RenderOptions.event`, which accepts a single `InputEvent` from the input
731+
parser. The renderer extracts pointer position and button state from mouse
732+
events internally. See the [Scroll Specification](scroll-spec.md), Section 5.
733+
734+
**Deprecated: `pointer` option.** The `RenderOptions.pointer` field
735+
(`{ x, y, down }`) is deprecated. It required the caller to manually track
736+
pointer state from input events. Callers SHOULD migrate to
737+
`RenderOptions.event`. The `pointer` option will be removed in a future version.
738+
710739
This surface is functional but should not be treated as stable contract. The
711740
calling convention was discovered through iteration, the event model has
712741
acknowledged gaps, and the approach may evolve.
@@ -764,9 +793,8 @@ junction glyphs in a post-render pass.
764793
_This section is non-normative. These topics are explicitly excluded from this
765794
specification. Their omission is intentional, not an oversight._
766795

767-
**Scroll container API.** The underlying layout engine supports scroll
768-
containers. No TypeScript-side API exists for providing scroll state to the
769-
renderer.
796+
**Scroll container API.** Now specified in the
797+
[Scroll Specification](scroll-spec.md).
770798

771799
**CSI helper for terminal setup.** A helper for generating paired apply/rollback
772800
byte arrays for terminal mode configuration was discussed but not implemented.

specs/scroll-spec.md

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
# Clayterm Scroll Specification
2+
3+
**Version:** 0.1 (draft) **Status:** Proposed.
4+
5+
---
6+
7+
## 1. Purpose
8+
9+
This specification defines how clipped elements scroll their children. It covers
10+
the clip configuration API, the two scroll modes (manual and pointer-driven),
11+
and the interaction between them.
12+
13+
Scrolling builds on the existing clip infrastructure described in the renderer
14+
spec (Section 12.2). This specification promotes clip/scroll from an elastic
15+
surface to a specified contract.
16+
17+
---
18+
19+
## 2. Scope
20+
21+
### In scope
22+
23+
- The `clip` property on `open()` element configuration
24+
- Manual scroll offsets (numeric per-axis values)
25+
- Pointer-driven scrolling via wheel events
26+
- Per-axis mode selection (manual, pointer, or off)
27+
- Input event integration via `RenderOptions.event`
28+
29+
### Out of scope
30+
31+
- Drag scrolling and momentum (future opt-in, not precluded by this design)
32+
- Programmatic momentum (caller-implemented spring/ease animations)
33+
- Snap points or pagination
34+
- Scroll event callbacks
35+
- Focus-based scroll management (higher-level framework concern)
36+
37+
---
38+
39+
## 3. Clip Configuration
40+
41+
The `clip` property on `open()` configures per-axis clipping and scrolling:
42+
43+
```ts
44+
clip?: {
45+
x?: number | "pointer";
46+
y?: number | "pointer";
47+
}
48+
```
49+
50+
Each axis is independently configured:
51+
52+
- **`undefined`** (or omitted) — No clipping or scrolling on this axis. Children
53+
may overflow visually.
54+
- **`number`** — Clipping is enabled. Children are offset by the given value (in
55+
layout-engine units). The caller controls the offset each frame. Typically
56+
negative to scroll content upward/leftward.
57+
- **`"pointer"`** — Clipping is enabled. The offset is managed by the underlying
58+
layout engine's scroll system, driven by wheel events passed via
59+
`RenderOptions.event`.
60+
61+
A bare `clip` value is always an object with optional `x` and `y` fields. There
62+
is no shorthand form.
63+
64+
### 3.1 Examples
65+
66+
Vertical pointer scrolling (typical scroll container):
67+
68+
```ts
69+
open("list", {
70+
layout: { width: grow(), height: fixed(20) },
71+
clip: { y: "pointer" },
72+
});
73+
```
74+
75+
Manual horizontal offset (e.g., programmatic scroll-to):
76+
77+
```ts
78+
open("viewport", {
79+
layout: { width: fixed(40), height: fixed(10) },
80+
clip: { x: -scrollX },
81+
});
82+
```
83+
84+
Mixed — horizontal manual, vertical pointer:
85+
86+
```ts
87+
open("editor", {
88+
layout: { width: grow(), height: grow() },
89+
clip: { x: -columnOffset, y: "pointer" },
90+
});
91+
```
92+
93+
---
94+
95+
## 4. Pointer-Driven Scrolling
96+
97+
When an axis is set to `"pointer"`, the underlying layout engine manages scroll
98+
state for that axis across frames.
99+
100+
### 4.1 Wheel scrolling
101+
102+
When a `WheelEvent` is passed via `RenderOptions.event` and the wheel event's
103+
coordinates fall within a clip element that has a `"pointer"` axis, the layout
104+
engine applies the scroll delta to that axis. The innermost scroll container at
105+
the event coordinates takes priority.
106+
107+
### 4.2 Clamping
108+
109+
Scroll position is clamped to content bounds. The maximum scroll offset is
110+
`-(contentSize - containerSize)` and the minimum is `0`. Content that fits
111+
within the container on a given axis cannot be scrolled on that axis.
112+
113+
### 4.3 Future: drag scrolling and momentum
114+
115+
The clip configuration and rendering pipeline are designed to support
116+
pointer-driven drag scrolling with momentum in the future. The underlying layout
117+
engine already supports this via `Clay_UpdateScrollContainers`. When added, it
118+
would be an opt-in behavior — the current wheel-only scrolling would remain the
119+
default.
120+
121+
---
122+
123+
## 5. Input Event Integration
124+
125+
Wheel events reach the scroll system via `RenderOptions.event`, which accepts a
126+
single `InputEvent` per render transaction. When the event is a `WheelEvent`
127+
whose coordinates fall within a pointer-driven scroll container, the layout
128+
engine applies the scroll delta.
129+
130+
The `event` field, its semantics, and the one-event-per-render contract are
131+
defined in the [Renderer Specification](renderer-spec.md), Section 8.2.3.
132+
133+
---
134+
135+
## 6. Transfer Encoding
136+
137+
The clip property mask bit (`0x10`) remains unchanged. The encoded payload
138+
changes to support per-axis modes.
139+
140+
For the clip configuration, the transfer encoding includes:
141+
142+
- A packed word with axis modes: low byte = x mode, next byte = y mode. Mode
143+
values: `0` = off, `1` = manual, `2` = pointer.
144+
- For each axis in mode `1` (manual): a float32 offset value.
145+
146+
When mode is `2`, no offset value is encoded. The C side reads the scroll
147+
position from the layout engine's internal state via `Clay_GetScrollOffset()`
148+
while the element is open.
149+
150+
---
151+
152+
## 7. C-Side Decoding
153+
154+
When decoding a clip configuration, the C side:
155+
156+
1. Reads axis modes from the packed word.
157+
2. Sets `decl.clip.horizontal` / `decl.clip.vertical` to true for any non-zero
158+
mode.
159+
3. For manual axes, reads the offset from the buffer and writes it into Clay's
160+
internal scroll state via `Clay_GetScrollContainerData`. This keeps Clay's
161+
scroll position in sync so that switching to pointer mode on a subsequent
162+
frame continues from the manual position.
163+
4. For pointer axes, reads the offset from `Clay_GetScrollOffset()`.
164+
5. Combines both into `decl.clip.childOffset`.
165+
166+
This happens while the element is open (between `Clay__OpenElementWithId` and
167+
`Clay__ConfigureOpenElement`), so `Clay_GetScrollOffset()` resolves to the
168+
correct element.
169+
170+
### 7.1 Mode switching
171+
172+
Because manual mode writes to Clay's scroll state every frame, and pointer mode
173+
reads from it, the two modes are always in sync. The caller can freely alternate
174+
between numeric and `"pointer"` values on any axis between frames without
175+
jumps or discontinuities. No explicit mode-switching logic is needed.
176+
177+
---
178+
179+
## 8. Invariants
180+
181+
**SCROLL-1.** A clip axis set to `undefined` MUST NOT clip children on that axis
182+
and MUST NOT participate in scroll input handling.
183+
184+
**SCROLL-2.** A numeric clip axis MUST clip children and offset them by exactly
185+
the provided value each frame. The layout engine's internal scroll state for
186+
that axis MUST NOT affect the rendered offset.
187+
188+
**SCROLL-3.** A `"pointer"` clip axis MUST clip children and derive the offset
189+
from the layout engine's scroll state, which is updated by wheel events.
190+
191+
**SCROLL-4.** Manual and pointer modes MAY be mixed on different axes of the
192+
same element.
193+
194+
**SCROLL-5.** Scroll position on pointer axes MUST be clamped to content bounds.
195+
A container whose content fits within it on a given axis MUST NOT scroll on that
196+
axis.
197+
198+
**SCROLL-6.** When `RenderOptions.event` carries a `WheelEvent`, the scroll
199+
delta MUST be applied to the innermost pointer-driven scroll container whose
200+
bounding box contains the event's coordinates.
201+
202+
---
203+
204+
## 9. Test Plan
205+
206+
### Manual offset
207+
208+
1. `clip: { x: -10 }` offsets children horizontally, clips overflow.
209+
2. `clip: { y: -5 }` offsets children vertically, clips overflow.
210+
3. `clip: { x: 0, y: 0 }` clips but does not offset.
211+
4. Omitted axis does not clip — children overflow visually.
212+
5. Offset updates between frames produce correct output.
213+
214+
### Pointer-driven (wheel)
215+
216+
6. `clip: { y: "pointer" }` with a wheel-down event scrolls content.
217+
7. Multiple frames of wheel events accumulate scroll position.
218+
8. Scroll is clamped — cannot scroll past content bounds.
219+
9. Content that fits within the container does not scroll.
220+
10. Wheel targets innermost scroll container at event coordinates.
221+
222+
### Mixed mode
223+
224+
11. `clip: { x: -20, y: "pointer" }` — manual x, pointer y, both work
225+
independently.
226+
12. Manual axis is unaffected by wheel events.
227+
13. Pointer axis ignores manual values (uses engine state).
228+
229+
### Mode switching
230+
231+
14. Switch from manual to pointer — pointer continues from manual position.
232+
15. Switch from pointer to manual — manual overrides pointer position.
233+
16. Rapid alternation between modes — no jumps or discontinuities.
234+
235+
### Edge cases
236+
237+
17. Clip with no children — no crash, empty output.
238+
18. Nested scroll containers — inner container gets priority for wheel.
239+
19. Scroll container removed between frames — no stale state.
240+
20. Wheel event with no scroll containers under pointer — no effect.
241+
21. Wheel event with only manual-mode containers under pointer — no effect.
242+
243+
---
244+
245+
## 10. Open Questions
246+
247+
### 10.1 Manual offset sign convention
248+
249+
Should numeric clip values use positive-scroll or negative-offset semantics?
250+
251+
**Option A: Positive (scroll-position semantics).** The caller provides the
252+
logical scroll position as a positive number. Clayterm negates it internally
253+
before passing to the layout engine.
254+
255+
```ts
256+
clip: {
257+
y: 20;
258+
} // "show content starting at position 20"
259+
```
260+
261+
Pros:
262+
263+
- Matches `scrollTop` / `scrollLeft` conventions from the browser.
264+
- More intuitive — "scroll to 20" rather than "offset by -20".
265+
- Callers never deal with negative values for a conceptually positive quantity.
266+
267+
Cons:
268+
269+
- Introduces a sign inversion between the API and the underlying layout engine,
270+
which could confuse contributors reading the C code.
271+
- Inconsistent with Clay's `childOffset` model, which uses negative values
272+
natively.
273+
- Clipping without scrolling (`clip: { y: 0 }`) works identically either way,
274+
but the mental model differs — 0 means "scroll position zero" vs "zero
275+
offset."
276+
277+
**Option B: Negative (raw offset semantics).** The caller provides the raw pixel
278+
offset, typically negative. Clayterm passes it through to the layout engine
279+
unchanged.
280+
281+
```ts
282+
clip: {
283+
y: -20;
284+
} // "offset children by -20 pixels"
285+
```
286+
287+
Pros:
288+
289+
- Direct mapping to Clay's `childOffset` — no hidden transformation.
290+
- Transparent to anyone reading the implementation.
291+
- Allows positive offsets if the caller genuinely wants to shift content
292+
downward/rightward (unusual but not impossible).
293+
294+
Cons:
295+
296+
- Unnatural for the common case — scrolling down requires negative numbers.
297+
- Easy to get the sign wrong, especially for callers unfamiliar with the layout
298+
engine internals.
299+
- Every scroll-position calculation needs manual negation.

0 commit comments

Comments
 (0)