feat(ui): scroll-top holds a scroll box at an offset, on-scroll, and ui_scroll_set

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 22:00:21 +03:00
parent cf8b9e425e
commit abf45ef9db
10 changed files with 85 additions and 1 deletions

View file

@ -457,6 +457,9 @@ applies to both:
anywhere) or by Enter or the pad's A held on it; it is `:active` in between.
- A scroll box (`overflow: auto` or `<scroll>`) takes the wheel, has a scrollbar that can be
dragged (`scrollbar-color`), clips what it holds and pulls the focused control into view.
- `scroll-top="{px}"` holds a scroll box at that offset every frame; the wheel, the bar and the
focus still move it for the frame and say so with `on-scroll` (`event.value`), for the program
to keep. `ui_scroll_set("id", px)` (an id or a key) moves a box once.
- The scrollbar is `.ui-scrollbar` with a `.ui-thumb`, styled like a control's parts. A press on
the thumb holds it where it was taken and the pointer moves it in proportion; a press on the
track jumps the thumb's middle there and holds it. A press on the bar presses nothing under it.

View file

@ -96,6 +96,8 @@ What a game's screens found missing, now in `ludic.ui`:
as a percentage or a `calc()` of one, of the containing block;
- a nine-slice's corners are clamped to half the box in each direction on its own and cut on whole
pixels (`ui_nine_cuts`), so a small key cap has no seam;
- `scroll-top="{px}"` holds a scroll box at an offset, with `on-scroll` when the player moves it;
`ui_scroll_set(id, px)` moves one once;
- a transition lands exactly on its end value (it had stopped a rounding error short of it, at every
frame rate).

View file

@ -0,0 +1,32 @@
# ui_scroll_hold.ludic — scroll-top holds a box at an offset: one follows its state, which on-scroll
# keeps in step with the wheel; one stays where it is held; and ui_scroll_set moves another once.
import "ludic.ui"
import "ui_scroll_hold_parts/Held.ludic"
program UiScrollHold {
numbers float
function frame(x: float, y: float, wheel: float) -> UiNode {
let i = new UiInput
i.x = x
i.y = y
i.wheel = wheel
ui_input(i)
ui_show("Held", null, 0.0, 0.0, 400.0, 600.0)
let root: UiNode = ui_nodes("Held", null)
ui_place(root, 0.0, 0.0, 400.0, 600.0)
return root.children[0]
}
function at(p: UiNode) -> string { return `{int(p.children[0].scroll_y)} {int(p.children[1].scroll_y)} {int(p.children[2].scroll_y)} {p.children[3].text}` }
entry {
ui_backend(new UiBackend)
let p = frame(-1.0, -1.0, 0.0)
let first = at(frame(-1.0, -1.0, 0.0))
frame(p.children[0].x + 5.0, p.children[0].y + 5.0, 1.0)
let followed = at(frame(-1.0, -1.0, 0.0))
frame(p.children[1].x + 5.0, p.children[1].y + 5.0, -1.0)
let fixed = at(frame(-1.0, -1.0, 0.0))
ui_scroll_set("free", 70.0)
let set = at(frame(-1.0, -1.0, 0.0))
frame(p.children[2].x + 5.0, p.children[2].y + 5.0, 1.0)
print(`{first} | {followed} | {fixed} | {set} | {at(frame(-1.0, -1.0, 0.0))}`)
}
}

View file

@ -0,0 +1,3 @@
.page { width: 400px; gap: 4px }
.list { overflow: auto; width: 200px; height: 100px }
p { height: 40px }

View file

@ -0,0 +1,5 @@
# Held.ludic - three scroll boxes: one held at its state's offset and told when it moves, one held
# at a fixed offset, and one the program moves once
component Held {
state top: float = 120.0
}

View file

@ -0,0 +1,6 @@
<div class="page">
<div class="list" id="log" scroll-top="{top}" on-scroll="set top = event.value"><each in="{range(10)}" as="r"><p>{r}</p></each></div>
<div class="list" id="fixed" scroll-top="50"><each in="{range(10)}" as="r"><p>{r}</p></each></div>
<div class="list" id="free"><each in="{range(10)}" as="r"><p>{r}</p></each></div>
<p>top {top}</p>
</div>

View file

@ -25,6 +25,7 @@ import "input_pad.ludic"
import "focus.ludic"
import "scroll.ludic"
import "scroll_bar.ludic"
import "scroll_held.ludic"
import "popover.ludic"
import "popover_anchor.ludic"
import "popover_within.ludic"

View file

@ -32,9 +32,11 @@ function sc_walk(n: UiNode, live: bool) -> void {
if n.kind == UI_SCROLL {
let room = n.ch - ly_vin(n)
let most = Math.max(n.content_h - ly_vin(n) - room, 0.0)
var off = sc_get(n.key)
let held = sc_held(n, sc_get(n.key), live)
var off = held
if live { off = sc_input(n, off, room, most) }
off = Math.clamp(off, 0.0, most)
if live and off != Math.clamp(held, 0.0, most) { ui_fire(n, "scroll", Value.float(off / ui_px(1.0))) }
sc_put(n.key, off)
n.scroll_y = off
if off != 0.0 {

View file

@ -0,0 +1,29 @@
# scroll_held.ludic - a scroll box held at an offset: scroll-top="{px}" puts it there every frame
# (the wheel, the bar and the focus still move it, and say so with on-scroll and event.value, for
# the program to keep or not), and ui_scroll_set(id or key, px) moves it once
var sc_want_keys: []string = new []string
var sc_want_px: []float = new []float
export function ui_scroll_set(key: string, px: float) -> void {
push(sc_want_keys, key)
push(sc_want_px, px)
}
# how far a box is scrolled before this frame's input: what it was, what ui_scroll_set asked for,
# or what its scroll-top says
function sc_held(n: UiNode, off: float, live: bool) -> float {
var at = off
if live {
let keys = new []string
let pxs = new []float
for i in 0 .. len(sc_want_keys) {
if sc_want_keys[i] == n.key or (n.id != "" and sc_want_keys[i] == n.id) { at = ui_px(sc_want_px[i]) } else {
push(keys, sc_want_keys[i])
push(pxs, sc_want_px[i])
}
}
sc_want_keys = keys
sc_want_px = pxs
}
let v = ui_attr(n, "scroll-top")
if Value.kind(v) != 0 { at = ui_px(el_num(vl_text(v))) }
return at
}

View file

@ -769,6 +769,7 @@ function cmd_dev_test() -> int {
controller_case("library/ui_text", "", "Photograph the Be...@100 y0 | Short@160 y20 | Medium words@133 y40 | lines 5 160 one two@160 y66 three@160 y98 | tall 30 | em 20 24 | tie #00ff00", "ui_text.ludic (L11: text-fit shrinks then cuts, line-height, em against the final font size, a sheet over its imports on a tie)")
controller_case("library/ui_calc", "", "min 200 max 82 clamp 50 21 | p 150,60 q 255,80 r 30", "ui_calc.ludic (L11: min(), max() and clamp(), and insets as percentages and calc() of the containing block)")
controller_case("library/ui_nine", "", "cap 0 12 18 30/ 0 8 8 16 | wide 0 12 88 100/ 0 12 28 40 | odd 10 21 21 31/ 4 8 8 13", "ui_nine.ludic (L11: a nine-slice's corners clamped to half the box each way, on whole pixels)")
controller_case("library/ui_scroll_hold", "", "120 50 0 top 120 | 80 50 0 top 80 | 80 50 0 top 80 | 80 50 70 top 80 | 80 50 30 top 80", "ui_scroll_hold.ludic (L11: scroll-top holds a box at an offset, on-scroll follows the wheel, ui_scroll_set moves one once)")
controller_case("library/ui_inputs", "", "count 12 12 32 0.5 0 | 20 32 0.5 0 | 19 32 0.5 0 | How many to carry | Space bar listening 1 19 9 0.5 0 | 19 9 0.5 0 | 19 0 0.5 0 | 60% 0.50 s | 19 0 0.6 9 | 7!", "ui_inputs.ludic (L11: number fields, key capture with Esc and Backspace, notes, range formats, an event named set, a string prop given a number)")
controller_case("library/ui_boxes", "", "list 120 of 200 | text at 32 152 wide, 3 lines, row 60 | zero 0x0 | half 140x36 pad 4 2 | order abc at 16 | fill 98x8 of 200x12", "ui_boxes.ludic (L11: flex-shrink to the leftover room, text wrapping beside its siblings, zero sizes, calc(), order, the range track as a row)")
controller_case("library/ui_look", "", "root div.in.i.m pad 5 margin 9 color #ff0000 text Hi 2,53 #102030 | text Hi 0,50 #eeeeee | opacity 0.25 | nine 40 #ffffff nine 40 #ff0000 of 2 image photo.png #ffffff | image icon:tent #00ff00 | text first line 29,205 #eeeeee | text second line 29,223 #eeeeee by id at 84,50 | flipped at 246,268 100x32", "ui_look.ludic (L11: nested components styled by specificity, text-shadow, ui_opacity, border-image tints, pictures, tooltip lines, anchored popovers)")