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