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

@ -23,6 +23,7 @@ import "controls_number.ludic"
import "input.ludic"
import "focus.ludic"
import "scroll.ludic"
import "scroll_bar.ludic"
import "popover.ludic"
import "popover_anchor.ludic"
import "tooltip.ludic"

View file

@ -1,6 +1,6 @@
# pointer.ludic - the pointer: what it is over is :hover (the element and all around it, as in CSS),
# a press on a control focuses it and makes it :active, and letting go over it is a click. The
# control being dragged (a range's thumb, a scrollbar) keeps the pointer until it is let go.
# pointer.ludic - the pointer: what it is over is :hover (and all around it, as in CSS), a press on a
# control focuses it and makes it :active, letting go over it is a click. A dragged thumb or scrollbar
# keeps the pointer until let go; a press that takes a scrollbar or closes a popover presses nothing else.
var fr_hover: []string = new []string
var fr_press_key: string = ""
var fr_down_now: bool = false
@ -16,6 +16,7 @@ function fr_pointer(root: UiNode) -> void {
fr_hover = keys
tt_track(scope, i.x, i.y)
if top != null and pp_pointer(top) { return }
if sc_drag != "" { return }
let hit = fr_hit(scope, i.x, i.y)
if i.down and not in_was_down {
ct_key_pressed_away(hit)

View file

@ -22,6 +22,7 @@ function pp_pointer(top: UiNode) -> bool {
let i = in_now
if not (i.down and not in_was_down) { return false }
if i.x >= top.x and i.y >= top.y and i.x < top.x + top.cw and i.y < top.y + top.ch { return false }
fr_press_key = ""
ui_fire(top, "close", Value.null())
return true
}

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 {

View file

@ -0,0 +1,63 @@
# scroll_bar.ludic - a scroll box's bar: .ui-scrollbar, the track down its right edge, and its
# .ui-thumb, styled by the default sheet or a theme as the box's own parts. A press on the thumb holds
# it where it was taken; a press on the track jumps the thumb's middle to the pointer and holds it there.
var sb_x: float = 0.0
var sb_w: float = 0.0
var sb_ty: float = 0.0
var sb_th: float = 0.0
var sb_bar: UiNode = null
var sc_grab: float = 0.0 # how far below the thumb's top the pointer holds it
# the bar's parts, styled as descendants of the box, and where they are at this offset
function sb_geo(n: UiNode, off: float, room: float, most: float) -> void {
let bar = ct_part(n, "ui-scrollbar", "")
bar.tag = "div"
bar.parent = n
push(bar.children, ct_part(n, "ui-thumb", ""))
cs_tree(bar)
sb_bar = bar
sb_w = ui_px(6.0)
if bar.w >= 0.0 { sb_w = bar.w }
sb_x = n.x + n.cw - sb_w - bar.mr
var least = ui_px(24.0)
if len(bar.children) > 0 and bar.children[0].min_h > 0.0 { least = bar.children[0].min_h }
sb_th = Math.min(Math.max(n.ch * room / (room + most), least), n.ch)
sb_ty = n.y
if most > 0.0 { sb_ty = n.y + (n.ch - sb_th) * Math.clamp(off, 0.0, most) / most }
}
# the pointer on the bar: taken on a press over it, moved while held, let go on release
function sb_input(n: UiNode, off: float, room: float, most: float) -> float {
let i = in_now
if most <= 0.0 or room <= 0.0 { return off }
if i.down and not in_was_down and sc_drag == "" {
sb_geo(n, off, room, most)
let reach = Math.min(sb_x, n.x + n.cw - ui_px(14.0))
let over = i.x >= reach and i.y >= n.y and i.x < n.x + n.cw and i.y < n.y + n.ch
if over and not sb_bar.hidden {
sc_drag = n.key
sc_grab = sb_th / 2.0
if i.y >= sb_ty and i.y < sb_ty + sb_th { sc_grab = i.y - sb_ty }
}
}
if sc_drag != n.key { return off }
sb_geo(n, off, room, most)
let span = n.ch - sb_th
if span <= 0.0 { return off }
return (i.y - sc_grab - n.y) / span * most
}
# the track, then the thumb over it; scrollbar-color on the box wins over the parts' backgrounds
function sc_bar(n: UiNode) -> void {
let room = n.ch - ly_vin(n)
let most = n.content_h - ly_vin(n) - room
if most <= 0.0 or ui_be.rect == null { return }
sb_geo(n, n.scroll_y, room, most)
let bar = sb_bar
if bar.hidden { return }
var track = bar.bg
if n.bar_track != -2 { track = n.bar_track }
if track >= 0 { fr_box(sb_x, n.y, sb_w, n.ch, bar.radius, track, bar.bg_a * fr_ga) }
if len(bar.children) == 0 { return }
let t = bar.children[0]
var thumb = t.bg
if n.bar_c >= 0 { thumb = n.bar_c }
if thumb >= 0 { fr_box(sb_x, sb_ty, sb_w, sb_th, t.radius, thumb, t.bg_a * fr_ga) }
}

View file

@ -27,5 +27,6 @@ function tr_apply(n: UiNode) -> void {
}
function tr_value(i: int, now: float, dur: float) -> float {
let t = Math.clamp((now - tr_at[i]) / dur, 0.0, 1.0)
if t >= 1.0 { return tr_to[i] }
return tr_from[i] + (tr_to[i] - tr_from[i]) * t
}

View file

@ -1,4 +1,4 @@
# ua.ludic - what every element looks like before a program styles it, as a browser's own sheet
# does: headings sized, buttons and controls usable and plain, a focus ring. A theme overrides any
# of it, with the same selectors or with the variables it reads.
const UA_LSS: string = "h1 { font-size: 32 } h2 { font-size: 26 } h3 { font-size: 22 } h4 { font-size: 19 } h5 { font-size: 16 } h6 { font-size: 14 } small { font-size: 13 } hr { height: 1; width: fill; background: #808080; margin: 4 0 } scroll { width: fill; height: fill } img { width: 24; height: 24 } button { align-items: center; justify-content: center; gap: 8; padding: 6 14; background: #3a3f46; border-radius: 4 } button:hover { background: #4a5058 } button:disabled { opacity: 0.4 } :focus-visible { outline: 2px solid #ffcc55; outline-offset: 2 } input, select { align-items: center; gap: 10; padding: 6 10 } .ui-label { flex-grow: 1 } .ui-labels { flex-grow: 1; gap: 2 } .ui-labels .ui-label { flex-grow: 0 } .ui-note { font-size: 13; color: #9aa0a6 } .ui-track { flex-direction: row; align-items: center; width: 44; height: 22; padding: 3; background: #555a60; border-radius: 11 } .ui-knob { width: 16; height: 16; background: #eeeeee; border-radius: 8 } input:checked .ui-track { justify-content: end; background: #d9a54a } input[type=range] .ui-track { width: 200; height: 8; padding: 0; border-radius: 4 } .ui-fill { height: fill; background: #d9a54a; justify-content: end; align-items: center; border-radius: 4 } .ui-thumb { width: 14; height: 14; background: #eeeeee; border-radius: 7; margin: 0 -7 } .ui-value { min-width: 40 } .ui-field { width: 200; padding: 4 8; background: #202428; border: 1px solid #555a60 } .ui-caret { width: 2; height: 1.2em; background: #eeeeee } .ui-prev, .ui-next { color: #aaaaaa } progress, meter { width: 160; height: 10; background: #202428; border-radius: 5 } progress .ui-fill, meter .ui-fill { justify-content: start; border-radius: 5 } [popover] { position: absolute; z-index: 900 } .ui-tooltip { position: fixed; padding: 6 10; background: #202428; border: 1px solid #555a60; font-size: 15; z-index: 1000 }"
const UA_LSS: string = "h1 { font-size: 32 } h2 { font-size: 26 } h3 { font-size: 22 } h4 { font-size: 19 } h5 { font-size: 16 } h6 { font-size: 14 } small { font-size: 13 } hr { height: 1; width: fill; background: #808080; margin: 4 0 } scroll { width: fill; height: fill } img { width: 24; height: 24 } button { align-items: center; justify-content: center; gap: 8; padding: 6 14; background: #3a3f46; border-radius: 4 } button:hover { background: #4a5058 } button:disabled { opacity: 0.4 } :focus-visible { outline: 2px solid #ffcc55; outline-offset: 2 } input, select { align-items: center; gap: 10; padding: 6 10 } .ui-label { flex-grow: 1 } .ui-labels { flex-grow: 1; gap: 2 } .ui-labels .ui-label { flex-grow: 0 } .ui-note { font-size: 13; color: #9aa0a6 } .ui-track { flex-direction: row; align-items: center; width: 44; height: 22; padding: 3; background: #555a60; border-radius: 11 } .ui-knob { width: 16; height: 16; background: #eeeeee; border-radius: 8 } input:checked .ui-track { justify-content: end; background: #d9a54a } input[type=range] .ui-track { width: 200; height: 8; padding: 0; border-radius: 4 } .ui-fill { height: fill; background: #d9a54a; justify-content: end; align-items: center; border-radius: 4 } .ui-thumb { width: 14; height: 14; background: #eeeeee; border-radius: 7; margin: 0 -7 } .ui-value { min-width: 40 } .ui-field { width: 200; padding: 4 8; background: #202428; border: 1px solid #555a60 } .ui-caret { width: 2; height: 1.2em; background: #eeeeee } .ui-prev, .ui-next { color: #aaaaaa } progress, meter { width: 160; height: 10; background: #202428; border-radius: 5 } progress .ui-fill, meter .ui-fill { justify-content: start; border-radius: 5 } .ui-scrollbar { width: 6; margin: 0 2 0 0; background: rgba(51, 51, 51, 0.4); border-radius: 3 } .ui-scrollbar .ui-thumb { min-height: 24; margin: 0; background: rgba(153, 153, 153, 0.85); border-radius: 3 } [popover] { position: absolute; z-index: 900 } .ui-tooltip { position: fixed; padding: 6 10; background: #202428; border: 1px solid #555a60; font-size: 15; z-index: 1000 }"