Skip to content

Commit 6faca4b

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 6faca4b

3 files changed

Lines changed: 328 additions & 7 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
141+
via `RenderOptions.event`. The renderer extracts pointer state from mouse
142+
events and scroll deltas from wheel events, removing the need for the caller
143+
to manually 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
148+
input parser and the renderer. The `InputEvent` type is the shared contract;
149+
neither 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 & 5 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`
359+
from 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
368+
are available from a single `input.scan()` call, the caller SHOULD render
369+
once per event. The diff engine ensures frames with no visual change emit
370+
zero bytes, making per-event rendering efficient.
371+
372+
This field deprecates the `pointer` option on `RenderOptions`, which
373+
required the caller to manually decompose mouse events into `{ x, y, down }`
374+
state. See 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,25 @@ 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
721+
engine's element-identification mechanism. The renderer returns pointer
703722
events 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
738+
version.
739+
710740
This surface is functional but should not be treated as stable contract. The
711741
calling convention was discovered through iteration, the event model has
712742
acknowledged gaps, and the approach may evolve.
@@ -764,9 +794,8 @@ junction glyphs in a post-render pass.
764794
_This section is non-normative. These topics are explicitly excluded from this
765795
specification. Their omission is intentional, not an oversight._
766796

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.
797+
**Scroll container API.** Now specified in the
798+
[Scroll Specification](scroll-spec.md).
770799

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

specs/scroll-spec.md

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
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
10+
covers the clip configuration API, the two scroll modes (manual and
11+
pointer-driven), and the interaction between them.
12+
13+
Scrolling builds on the existing clip infrastructure described in the
14+
renderer spec (Section 12.2). This specification promotes clip/scroll from
15+
an elastic 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.
53+
Children may overflow visually.
54+
- **`number`** — Clipping is enabled. Children are offset by the given
55+
value (in layout-engine units). The caller controls the offset each frame.
56+
Typically negative to scroll content upward/leftward.
57+
- **`"pointer"`** — Clipping is enabled. The offset is managed by the
58+
underlying layout engine's scroll system, driven by wheel events passed
59+
via `RenderOptions.event`.
60+
61+
A bare `clip` value is always an object with optional `x` and `y` fields.
62+
There 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
98+
scroll 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
103+
event's coordinates fall within a clip element that has a `"pointer"` axis,
104+
the layout engine applies the scroll delta to that axis. The innermost
105+
scroll container at 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
117+
layout engine already supports this via `Clay_UpdateScrollContainers`. When
118+
added, it would be an opt-in behavior — the current wheel-only scrolling
119+
would remain the default.
120+
121+
---
122+
123+
## 5. Input Event Integration
124+
125+
Wheel events reach the scroll system via `RenderOptions.event`, which
126+
accepts a single `InputEvent` per render transaction. When the event is a
127+
`WheelEvent` whose coordinates fall within a pointer-driven scroll
128+
container, the layout 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.
143+
Mode 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
158+
non-zero mode.
159+
3. For manual axes, reads the offset from the buffer.
160+
4. For pointer axes, reads the offset from `Clay_GetScrollOffset()`.
161+
5. Combines both into `decl.clip.childOffset`.
162+
163+
This happens while the element is open (between `Clay__OpenElementWithId`
164+
and `Clay__ConfigureOpenElement`), so `Clay_GetScrollOffset()` resolves to
165+
the correct element.
166+
167+
---
168+
169+
## 8. Invariants
170+
171+
**SCROLL-1.** A clip axis set to `undefined` MUST NOT clip children on that
172+
axis and MUST NOT participate in scroll input handling.
173+
174+
**SCROLL-2.** A numeric clip axis MUST clip children and offset them by
175+
exactly the provided value each frame. The layout engine's internal scroll
176+
state for that axis MUST NOT affect the rendered offset.
177+
178+
**SCROLL-3.** A `"pointer"` clip axis MUST clip children and derive the
179+
offset from the layout engine's scroll state, which is updated by wheel
180+
events.
181+
182+
**SCROLL-4.** Manual and pointer modes MAY be mixed on different axes of
183+
the same element.
184+
185+
**SCROLL-5.** Scroll position on pointer axes MUST be clamped to content
186+
bounds. A container whose content fits within it on a given axis MUST NOT
187+
scroll on that axis.
188+
189+
**SCROLL-6.** When `RenderOptions.event` carries a `WheelEvent`, the scroll
190+
delta MUST be applied to the innermost pointer-driven scroll container whose
191+
bounding box contains the event's coordinates.
192+
193+
---
194+
195+
## 9. Test Plan
196+
197+
### Manual offset
198+
199+
1. `clip: { x: -10 }` offsets children horizontally, clips overflow.
200+
2. `clip: { y: -5 }` offsets children vertically, clips overflow.
201+
3. `clip: { x: 0, y: 0 }` clips but does not offset.
202+
4. Omitted axis does not clip — children overflow visually.
203+
5. Offset updates between frames produce correct output.
204+
205+
### Pointer-driven (wheel)
206+
207+
6. `clip: { y: "pointer" }` with a wheel-down event scrolls content.
208+
7. Multiple frames of wheel events accumulate scroll position.
209+
8. Scroll is clamped — cannot scroll past content bounds.
210+
9. Content that fits within the container does not scroll.
211+
10. Wheel targets innermost scroll container at event coordinates.
212+
213+
### Mixed mode
214+
215+
11. `clip: { x: -20, y: "pointer" }` — manual x, pointer y, both work
216+
independently.
217+
12. Manual axis is unaffected by wheel events.
218+
13. Pointer axis ignores manual values (uses engine state).
219+
220+
### Edge cases
221+
222+
14. Clip with no children — no crash, empty output.
223+
15. Nested scroll containers — inner container gets priority for wheel.
224+
16. Scroll container removed between frames — no stale state.
225+
17. Wheel event with no scroll containers under pointer — no effect.
226+
18. Wheel event with only manual-mode containers under pointer — no effect.
227+
228+
---
229+
230+
## 10. Open Questions
231+
232+
### 10.1 Manual offset sign convention
233+
234+
Should numeric clip values use positive-scroll or negative-offset semantics?
235+
236+
**Option A: Positive (scroll-position semantics).** The caller provides the
237+
logical scroll position as a positive number. Clayterm negates it internally
238+
before passing to the layout engine.
239+
240+
```ts
241+
clip: { y: 20 } // "show content starting at position 20"
242+
```
243+
244+
Pros:
245+
- Matches `scrollTop` / `scrollLeft` conventions from the browser.
246+
- More intuitive — "scroll to 20" rather than "offset by -20".
247+
- Callers never deal with negative values for a conceptually positive
248+
quantity.
249+
250+
Cons:
251+
- Introduces a sign inversion between the API and the underlying layout
252+
engine, which could confuse contributors reading the C code.
253+
- Inconsistent with Clay's `childOffset` model, which uses negative values
254+
natively.
255+
- Clipping without scrolling (`clip: { y: 0 }`) works identically either
256+
way, but the mental model differs — 0 means "scroll position zero" vs
257+
"zero offset."
258+
259+
**Option B: Negative (raw offset semantics).** The caller provides the raw
260+
pixel offset, typically negative. Clayterm passes it through to the layout
261+
engine unchanged.
262+
263+
```ts
264+
clip: { y: -20 } // "offset children by -20 pixels"
265+
```
266+
267+
Pros:
268+
- Direct mapping to Clay's `childOffset` — no hidden transformation.
269+
- Transparent to anyone reading the implementation.
270+
- Allows positive offsets if the caller genuinely wants to shift content
271+
downward/rightward (unusual but not impossible).
272+
273+
Cons:
274+
- Unnatural for the common case — scrolling down requires negative numbers.
275+
- Easy to get the sign wrong, especially for callers unfamiliar with the
276+
layout engine internals.
277+
- Every scroll-position calculation needs manual negation.

0 commit comments

Comments
 (0)