From a4c7a756f0ec18fb4625ee78df7e9e7727aade50 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Thu, 24 Sep 2026 21:39:28 +0300 Subject: [PATCH] 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 --- LANGUAGE.md | 8 ++- changes/defaults-views-templates.md | 8 ++- examples/library/ui_ease.ludic | 55 ++++++++++++++++ examples/library/ui_popover_mouse.ludic | 52 +++++++++++++++ .../library/ui_popover_mouse_parts/Grid.lss | 3 + .../library/ui_popover_mouse_parts/Grid.ludic | 8 +++ .../library/ui_popover_mouse_parts/Grid.xml | 9 +++ examples/library/ui_scrollbar.ludic | 52 +++++++++++++++ examples/library/ui_scrollbar_parts/Long.lss | 3 + .../library/ui_scrollbar_parts/Long.ludic | 5 ++ examples/library/ui_scrollbar_parts/Long.xml | 4 ++ packages/ludic.ui/index.ludic | 1 + packages/ludic.ui/pointer.ludic | 7 ++- packages/ludic.ui/popover.ludic | 1 + packages/ludic.ui/scroll.ludic | 43 ++++++------- packages/ludic.ui/scroll_bar.ludic | 63 +++++++++++++++++++ packages/ludic.ui/transition.ludic | 1 + packages/ludic.ui/ua.ludic | 2 +- tools/ludic-cli/test.ludic | 3 + 19 files changed, 299 insertions(+), 29 deletions(-) create mode 100644 examples/library/ui_ease.ludic create mode 100644 examples/library/ui_popover_mouse.ludic create mode 100644 examples/library/ui_popover_mouse_parts/Grid.lss create mode 100644 examples/library/ui_popover_mouse_parts/Grid.ludic create mode 100644 examples/library/ui_popover_mouse_parts/Grid.xml create mode 100644 examples/library/ui_scrollbar.ludic create mode 100644 examples/library/ui_scrollbar_parts/Long.lss create mode 100644 examples/library/ui_scrollbar_parts/Long.ludic create mode 100644 examples/library/ui_scrollbar_parts/Long.xml create mode 100644 packages/ludic.ui/scroll_bar.ludic diff --git a/LANGUAGE.md b/LANGUAGE.md index 8da9eae1..5b9cec36 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -444,6 +444,9 @@ applies to both: it is let go. - A scroll box (`overflow: auto` or ``) takes the wheel, has a scrollbar that can be dragged (`scrollbar-color`), clips what it holds and pulls the focused control into view. + - 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. - `:focus`, `:focus-visible` and `:focus-within` style the focus, and `outline` (which follows `border-radius`) draws the ring. - A test drives all of it with `ui_input(i)`. @@ -474,6 +477,8 @@ applies to both: - **Popovers, tooltips and bars.** - `
` sits absolutely in its parent and draws on the top layer. While it is up, the pointer and Tab stay inside it, and a press outside it or Esc closes it. + A press on it goes to its own controls, never to what lies under it at the same pixels, and a + press outside it only closes it. - `anchor="cell"` puts a popover beside the element with that id (the nearest, looking out from the popover), and a bare `anchor` beside the element before it. `placement` is `right` (the default), `left`, `bottom` or `top`, its margin on that side is the gap, and where it would @@ -511,7 +516,8 @@ applies to both: `color` around it, as text does. An `` with a `color` of its own is tinted by it. - Motion: `opacity` fades the element and everything inside it. `@keyframes` with `animation: name 0.25s [infinite] [alternate]` animate numeric properties from when the element first - appeared, and `transition: opacity 0.2s` eases a changed opacity. + appeared, and `transition: opacity 0.2s` eases a changed opacity. Both run on the clock, in + float, and land exactly on their end values whatever the frame rate. - **Mixed content.** Text beside elements keeps its place: ``, `

Hi there

`. A boolean attribute present with no value is true, as in HTML (` +
+ + +

opened {opened} dropped {dropped} closed {closed} {open}

+ diff --git a/examples/library/ui_scrollbar.ludic b/examples/library/ui_scrollbar.ludic new file mode 100644 index 00000000..b411078e --- /dev/null +++ b/examples/library/ui_scrollbar.ludic @@ -0,0 +1,52 @@ +# ui_scrollbar.ludic — a scroll box's bar held and dragged: a press on the thumb holds it where it +# was taken, the pointer held moves it in proportion, letting go leaves it; a press on the track +# jumps the thumb's middle there; the wheel still scrolls; and the rows under the bar are never pressed. +import "ludic.ui" +import "ui_scrollbar_parts/Long.ludic" +program UiScrollbar { + numbers float + var bx: float = 0.0 + var by: float = 0.0 + function frame(i: UiInput) -> UiNode { + ui_input(i) + ui_show("Long", null, 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes("Long", null) + ui_place(root, 0.0, 0.0, 400.0, 400.0) + return root.children[0] + } + # the pointer at (bx + x, by + y), held or not; how far the box is scrolled then + function at(x: float, y: float, down: bool) -> int { + let i = new UiInput + i.x = bx + x + i.y = by + y + i.down = down + return int(frame(i).children[0].scroll_y) + } + function wheel(x: float, y: float) -> int { + let i = new UiInput + i.x = bx + x + i.y = by + y + i.wheel = 1.0 + frame(i) + return int(frame(new UiInput).children[0].scroll_y) + } + entry { + ui_backend(new UiBackend) + let list = frame(new UiInput).children[0] + bx = list.x + by = list.y + let grab = at(195.0, 10.0, true) + let moved = at(195.0, 40.0, true) + let far = at(195.0, 300.0, true) + let back = at(195.0, 25.0, true) + let kept = at(195.0, 25.0, false) + at(0.0 - bx - 1.0, 0.0 - by - 1.0, false) + let jump = at(195.0, 80.0, true) + at(195.0, 80.0, false) + let wheeled = wheel(50.0, 50.0) + at(50.0, 20.0, true) + at(50.0, 20.0, false) + let page = frame(new UiInput) + print(`grab {grab} moved {moved} far {far} back {back} kept {kept} | jump {jump} | wheel {wheeled} | {page.children[1].text}`) + } +} diff --git a/examples/library/ui_scrollbar_parts/Long.lss b/examples/library/ui_scrollbar_parts/Long.lss new file mode 100644 index 00000000..5cee44f2 --- /dev/null +++ b/examples/library/ui_scrollbar_parts/Long.lss @@ -0,0 +1,3 @@ +.page { width: 300px; height: 300px } +.list { overflow: auto; width: 200px; height: 100px } +.row { width: fill; height: 40px; padding: 0; border-radius: 0 } diff --git a/examples/library/ui_scrollbar_parts/Long.ludic b/examples/library/ui_scrollbar_parts/Long.ludic new file mode 100644 index 00000000..bbdf2132 --- /dev/null +++ b/examples/library/ui_scrollbar_parts/Long.ludic @@ -0,0 +1,5 @@ +# Long.ludic - a box of ten rows, four times taller than it shows; each row is a button as wide as +# the box, so the scrollbar sits over them +component Long { + state pressed: int = 0 +} diff --git a/examples/library/ui_scrollbar_parts/Long.xml b/examples/library/ui_scrollbar_parts/Long.xml new file mode 100644 index 00000000..08daf0ea --- /dev/null +++ b/examples/library/ui_scrollbar_parts/Long.xml @@ -0,0 +1,4 @@ +
+
+

pressed {pressed}

+
diff --git a/packages/ludic.ui/index.ludic b/packages/ludic.ui/index.ludic index 67088e9c..38ff3596 100644 --- a/packages/ludic.ui/index.ludic +++ b/packages/ludic.ui/index.ludic @@ -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" diff --git a/packages/ludic.ui/pointer.ludic b/packages/ludic.ui/pointer.ludic index 18530630..509ba011 100644 --- a/packages/ludic.ui/pointer.ludic +++ b/packages/ludic.ui/pointer.ludic @@ -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) diff --git a/packages/ludic.ui/popover.ludic b/packages/ludic.ui/popover.ludic index 0ba0a3f4..b605a76d 100644 --- a/packages/ludic.ui/popover.ludic +++ b/packages/ludic.ui/popover.ludic @@ -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 } diff --git a/packages/ludic.ui/scroll.ludic b/packages/ludic.ui/scroll.ludic index acc60d4e..22829934 100644 --- a/packages/ludic.ui/scroll.ludic +++ b/packages/ludic.ui/scroll.ludic @@ -1,9 +1,10 @@ -# scroll.ludic - a box with overflow (or ) 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 ) 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 { diff --git a/packages/ludic.ui/scroll_bar.ludic b/packages/ludic.ui/scroll_bar.ludic new file mode 100644 index 00000000..31199322 --- /dev/null +++ b/packages/ludic.ui/scroll_bar.ludic @@ -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) } +} diff --git a/packages/ludic.ui/transition.ludic b/packages/ludic.ui/transition.ludic index 7a034092..83861716 100644 --- a/packages/ludic.ui/transition.ludic +++ b/packages/ludic.ui/transition.ludic @@ -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 } diff --git a/packages/ludic.ui/ua.ludic b/packages/ludic.ui/ua.ludic index 5b0d0611..3798637b 100644 --- a/packages/ludic.ui/ua.ludic +++ b/packages/ludic.ui/ua.ludic @@ -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 }" diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index d634f0fc..bcbf9a49 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -760,6 +760,9 @@ function cmd_dev_test() -> int { controller_case("library/ui_controls", "", "1 false 7 2 Adalove 70 1 false 8 2 Adalove 70; scrolled 40", "ui_controls.ludic (L11: focus and keys, the built-in controls, a range dragged, a box scrolled)") controller_case("library/ui_anim", "", "0 100 100 | 49 50 100 | 100 100 61", "ui_anim.ludic (L11: @keyframes and animation, looping and alternating, and a transition)") controller_case("library/ui_popover", "", "drop false | drop true | drop false | drop false | fill 60 of 200", "ui_popover.ludic (L11: a popover keeps the keys and closes on a press outside or Esc; progress fills)") + controller_case("library/ui_popover_mouse", "", "over the cell 1 | opened 1 dropped 0 closed 0 true | opened 1 dropped 1 closed 0 false | opened 2 dropped 1 closed 1 false | opened 3 dropped 1 closed 2 false", "ui_popover_mouse.ludic (L11: a popover takes the mouse - its own button wins over the cell under it, and a press outside closes it and presses nothing)") + controller_case("library/ui_scrollbar", "", "grab 0 moved 120 far 300 back 60 kept 60 | jump 270 | wheel 230 | pressed 1", "ui_scrollbar.ludic (L11: a scrollbar's thumb held where it was taken and dragged, a press on the track jumps there, the wheel, nothing under the bar pressed)") + controller_case("library/ui_ease", "", "60: 1480 680 slid 1 landed 1 left 0; 120: 1480 680 slid 1 landed 1 left 0; 180: 1480 680 slid 1 landed 1 left 0; 240: 1480 680 slid 1 landed 1 left 0;", "ui_ease.ludic (L11: a transition and an animation at 60, 120, 180 and 240 Hz are the same half way and land exactly on their targets)") 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)")