feat(ui): a scrollbar held where it was taken, a popover that keeps the mouse, easing that lands

- the scrollbar is .ui-scrollbar with a .ui-thumb, styled by the default sheet or a theme; a press
  on the thumb holds it at the point it was taken and the pointer moves it in proportion, a press on
  the track jumps the thumb's middle there and holds it, and neither presses what is under the bar
  (it used to centre the thumb on the pointer and press the row beneath as well);
- while a popover is up only scroll boxes inside it take the pointer, and a press outside it that
  closes it forgets any press in progress;
- a transition ends exactly on its value (it stopped a rounding error short, at every frame rate).

Tests: ui_popover_mouse, ui_scrollbar, ui_ease (60/120/180/240 Hz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 21:39:28 +03:00
parent 9f59000f5b
commit a4c7a756f0
19 changed files with 299 additions and 29 deletions

View file

@ -1,9 +1,10 @@
# scroll.ludic - a box with overflow (or <scroll>) scrolls itself: the wheel over it, a scrollbar
# thumb dragged, and the focused control pulled into view. Its content is shifted by how far it is
# scroll.ludic - a box with overflow (or <scroll>) scrolls itself: the wheel over it, its scrollbar
# held and dragged, and the focused control pulled into view. Its content is shifted by how far it is
# scrolled and clipped to it, and the offset is kept by its key from frame to frame.
var sc_keys: []string = new []string
var sc_offs: []float = new []float
var sc_drag: string = ""
var sc_scope: UiNode = null # the popover that has the pointer this frame, if one is up
function sc_get(key: string) -> float {
for i in 0 .. len(sc_keys) {
if sc_keys[i] == key { return sc_offs[i] }
@ -21,7 +22,11 @@ function sc_put(key: string, v: float) -> void {
push(sc_offs, v)
}
# every scroll box, outermost first: its offset (from input, when `live`), then its content moved
function sc_update(n: UiNode) -> void { sc_walk(n, true) }
function sc_update(n: UiNode) -> void {
sc_scope = pp_top(n)
if not in_now.down { sc_drag = "" }
sc_walk(n, true)
}
function sc_apply(n: UiNode) -> void { sc_walk(n, false) }
function sc_walk(n: UiNode, live: bool) -> void {
if n.kind == UI_SCROLL {
@ -38,17 +43,25 @@ function sc_walk(n: UiNode, live: bool) -> void {
}
for k in 0 .. len(n.children) { sc_walk(n.children[k], live) }
}
# the wheel over it, its thumb dragged, the keyboard's focus pulled into view
# the wheel over it, its bar held and dragged (scroll_bar.ludic), the keyboard's focus pulled into
# view; while a popover is up, only a box inside it answers the pointer
function sc_input(n: UiNode, off0: float, room: float, most: float) -> float {
var off = off0
if sc_scope != null and not sc_within(n, sc_scope) { return sc_into_view(n, off, room) }
let i = in_now
let over = i.x >= n.x and i.y >= n.y and i.x < n.x + n.cw and i.y < n.y + n.ch
if over and i.wheel != 0.0 { off = off - i.wheel * ui_px(40.0) }
if i.down and not in_was_down and over and i.x >= n.x + n.cw - ui_px(14.0) and most > 0.0 { sc_drag = n.key }
if not i.down { sc_drag = "" }
if sc_drag == n.key and room > 0.0 { off = (i.y - n.y) / room * (most + room) - room / 2.0 }
off = sb_input(n, off, room, most)
return sc_into_view(n, off, room)
}
function sc_within(n: UiNode, top: UiNode) -> bool {
var at = n
while at != null {
if at == top { return true }
at = at.parent
}
return false
}
# the focused control, moved by the keyboard, is brought into the box's view
function sc_into_view(n: UiNode, off: float, room: float) -> float {
if not fc_visible or fc_key == "" or not lc_mine(fc_key, n.key) { return off }
@ -59,22 +72,6 @@ function sc_into_view(n: UiNode, off: float, room: float) -> float {
if top + f.ch > off + room { return top + f.ch - room + ui_px(8.0) }
return off
}
# the scrollbar: a thumb on a track down the box's right edge, when there is more than it shows
function sc_bar(n: UiNode) -> void {
let room = n.ch - ly_vin(n)
let total = n.content_h - ly_vin(n)
if total <= room or ui_be.rect == null { return }
let w = ui_px(6.0)
let x = n.x + n.cw - w - ui_px(2.0)
var track = n.bar_track
if track == -2 { track = 3355443 }
var thumb = n.bar_c
if thumb < 0 { thumb = 10066329 }
if track >= 0 { ui_be.rect(x, n.y, w, n.ch, track, 0.4 * fr_ga) }
let th = Math.max(n.ch * room / total, ui_px(24.0))
let ty = n.y + (n.ch - th) * n.scroll_y / (total - room)
ui_be.rect(x, ty, w, th, thumb, 0.85 * fr_ga)
}
# ui_place moved every box by its stored offset; the frame takes that back before moving it again
function sc_undo(n: UiNode) -> void {
if n.kind == UI_SCROLL and n.scroll_y != 0.0 {