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