feat(ui): ui_scale and ui_box, on-submit, zoom (and a length over a length in calc), on-hold

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 03:31:38 +03:00
parent 3d2a103dfb
commit ec9a650e65
24 changed files with 323 additions and 23 deletions

View file

@ -455,7 +455,9 @@ applies to both:
go to that element until it is let go, wherever the pointer is. A scroll box leaves the wheel to
an element inside it that takes `on-wheel`.
- `on-down` and `on-up` say when a button goes down and comes up again, by the pointer (released
anywhere) or by Enter or the pad's A held on it; it is `:active` in between.
anywhere) or by Enter or the pad's A held on it; it is `:active` in between. `on-hold` runs
every frame it is held, with `event.dt` (seconds since the last frame) and `event.t` (how long
it has been held), on the ui clock.
- 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
@ -482,7 +484,8 @@ applies to both:
`unit=" s"` after it;
- `<select label value>` with `<option value>`s;
- `<input type="text" label value maxlength>`, which takes typing, and Backspace takes a letter
back;
back. Enter (or the pad's A) on it runs `on-submit` with `event.value`; the focus stays, and so
does the text unless it says `clear-on-submit`;
- `<input type="number" label min max step value>`: typed digits (a minus when min allows one, a
point when step has a fraction) replace the value, Enter or leaving the field commits them held
between min and max, and the left and right arrows step it (`decimals`, `unit` as a range's);
@ -533,6 +536,10 @@ applies to both:
- nine-slices for `border-image`, sliced by the texture's own width and height;
- a scale from the screen's height (`ui_render3d_scale` for the player's interface size).
`ui_scale()` is the scale the interface is drawn at (screen pixels per design pixel), and
`ui_box("id")` where an element (by id or key) was laid out when its screen was last shown, as a
`UiBox` of `x`, `y`, `w` and `h` in screen pixels, or null when there is none.
`ui_translator(fn)`, `ui_sounds(fn)` and `ui_clock(fn)` hand the runtime the program's
language, sounds and time. `import "ludic.ui/screen.ludic"` is the 2D screen's renderer.
Every text a template shows is translated, except inside `translate="no"` (and `translate="yes"`
@ -568,6 +575,10 @@ applies to both:
name 0.25s [infinite] [alternate]` animate numeric properties from when the element first
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.
- `zoom: 0.8` (or `80%`, or `calc()` / `min()`) draws an element and everything inside it that
much larger or smaller - its lengths, its text and its layout; zooms multiply. In `calc()` a
length over a length is a plain number, so `zoom: min(1, calc(100vw / 1800px))` shrinks a HUD
for a narrow window.
- **Mixed content.** Text beside elements keeps its place: `<button><img src="icon:arrow"/>Resume
</button>`, `<p>Hi <b>there</b></p>`. A boolean attribute present with no value is true, as in
HTML (`<button autofocus>`).

View file

@ -108,6 +108,10 @@ What a game's screens found missing, now in `ludic.ui`:
through a renderer with no `rect` no longer crashes;
- a component with no stylesheet of its own reads a theme's `:root` variables from around it (it
did; now a test says so);
- `ui_scale()` and `ui_box("id")`, the scale and an element's laid-out box, for a host;
- `on-submit` on a text field (Enter or A; the focus and text stay unless `clear-on-submit`);
- `zoom` on any element, and a length over a length in `calc()` is a plain number;
- `on-hold` every frame a button is held, with `event.dt` and `event.t`;
- 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,19 @@
# ui_box.ludic — what a host reads from outside the interface: ui_scale(), the scale it is drawn at
# (the renderer's), and ui_box("id"), where an element was laid out when its screen was last shown,
# in screen pixels (null for one that is not there) - to frame a 3D view beside a panel.
import "ludic.ui"
program UiBox {
numbers float
const PAGE: string = "<ui><style>.side { width: 100px; height: 60px; margin: 10px }</style><screen name='b'><p>title</p><div id='panel' class='side'/></screen></ui>"
function scale() -> float { return 1.5 }
entry {
let b = new UiBackend
b.scale = fn scale
ui_backend(b)
ui_load_text(PAGE, "b.xml")
ui_show("b", null, 20.0, 30.0, 800.0, 600.0)
let p = ui_box("panel")
let none = ui_box("nothing")
print(`scale {ui_scale()} | panel {int(p.x)},{int(p.y)} {int(p.w)}x{int(p.h)} | missing {none == null}`)
}
}

View file

@ -0,0 +1,59 @@
# ui_hold.ludic — on-hold fires every frame a button is held, by the pointer (still when it is dragged
# off, until let go) or by Enter or the pad's A on the focus, with event.dt and event.t on the ui clock,
# the same at 60 and 240 Hz; on-down and on-up still mark the ends.
import "ludic.ui"
program UiHold {
numbers float
var now: float = 0.0
var held: float = 0.0
var frames: int = 0
var last_t: float = 0.0
var ends: string = ""
view Craft {
on tick(dt: float, t: float) {
held = held + dt
frames = frames + 1
last_t = t
}
on down() { ends = ends + "d" }
on up() { ends = ends + "u" }
}
const PAGE: string = "<ui><screen name='h'><button on-hold='tick(event.dt, event.t)' on-down='down()' on-up='up()'>Make</button></screen></ui>"
function clock() -> float { return now }
function frame(i: UiInput) -> UiNode {
ui_input(i)
ui_show("h", view_craft(), 0.0, 0.0, 400.0, 400.0)
let r: UiNode = ui_nodes("h", view_craft())
ui_place(r, 0.0, 0.0, 400.0, 400.0)
return r.children[0]
}
# held for `secs` at `hz`, by the pointer (off the button after the first frame) or by A
function hold(b: UiNode, secs: float, hz: int, pad: bool) -> string {
held = 0.0
frames = 0
let n = int(secs * float(hz) + 0.5)
for k in 0 .. n + 1 {
let i = new UiInput
i.pad_a = pad
i.down = not pad
i.x = b.x + 5.0
i.y = b.y + 5.0
if k > 0 and not pad { i.x = 390.0 }
frame(i)
now = now + 1.0 / float(hz)
}
frame(new UiInput)
now = now + 1.0 / float(hz)
return `{frames} frames {int(Math.round(held * 1000.0))} ms t {int(Math.round(last_t * 1000.0))}`
}
entry {
ui_backend(new UiBackend)
ui_clock(fn clock)
ui_load_text(PAGE, "h.xml")
let b = frame(new UiInput)
let slow = hold(b, 0.5, 60, false)
let fast = hold(b, 0.5, 240, false)
let pad = hold(b, 0.25, 60, true)
print(`{slow} | {fast} | pad {pad} | {ends}`)
}
}

View file

@ -0,0 +1,45 @@
# ui_submit.ludic — on-submit on a text field: Enter (or the pad's A) while it has the focus sends what
# it holds with event.value; the focus stays, and so does the text, unless clear-on-submit empties it.
import "ludic.ui"
program UiSubmit {
numbers float
var said: string = ""
var chat: string = ""
var name: string = ""
view Chat {
chat = chat
name = name
on set_chat(v: string) { chat = v }
on set_name(v: string) { name = v }
on send(v: string) { said = said + "[" + v + "]" }
}
const PAGE: string = "<ui><screen name='c'><input type='text' label='Say' value='{chat}' clear-on-submit on-change='set_chat(event.value)' on-submit='send(event.value)'/><input type='text' label='Name' value='{name}' on-change='set_name(event.value)' on-submit='send(event.value)'/></screen></ui>"
function frame(i: UiInput) -> void {
ui_input(i)
ui_show("c", view_chat(), 0.0, 0.0, 400.0, 400.0)
}
function press(tab: bool, enter: bool, pad: bool, typed: string) -> void {
let i = new UiInput
i.tab = tab
i.enter = enter
i.pad_a = pad
i.typed = typed
frame(i)
frame(new UiInput)
}
entry {
ui_backend(new UiBackend)
ui_load_text(PAGE, "c.xml")
frame(new UiInput)
press(true, false, false, "")
press(false, false, false, "hi")
press(false, true, false, "")
let after = `{said} chat '{chat}'`
press(false, false, false, "yo")
press(false, false, true, "")
press(true, false, false, "")
press(false, false, false, "Ada")
press(false, true, false, "")
print(`{after} | {said} chat '{chat}' name '{name}'`)
}
}

View file

@ -0,0 +1,21 @@
# ui_zoom.ludic — zoom scales an element and everything inside it: its lengths, its text and its
# layout, and zooms multiply; zoom takes calc() and min(), with a length over a length a plain number,
# so a HUD can shrink to a narrow window (zoom: min(1, calc(100vw / 800px))).
import "ludic.ui"
program UiZoom {
numbers float
const STYLE: string = "<style>.box { width: 100px; padding: 10px }\n.half { zoom: 0.5 }\n.twice { zoom: 200% }\n.fit { zoom: min(1, calc(100vw / 800px)) }\np { height: 20px }\n.em { width: 2em }</style>"
const BODY: string = "<screen name='z'><div class='box'><p>a</p></div><div class='box half'><p>b</p><div class='twice'><div class='box'/></div></div><div class='fit'><div class='box'/><div class='em'/></div></screen>"
entry {
ui_backend(new UiBackend)
ui_load_text("<ui>" + STYLE + BODY + "</ui>", "z.xml")
ui_show("z", null, 0.0, 0.0, 400.0, 600.0)
let r: UiNode = ui_nodes("z", null)
ui_place(r, 0.0, 0.0, 400.0, 600.0)
let plain = r.children[0]
let half = r.children[1]
let inner = half.children[1].children[0]
let fit = r.children[2]
print(`plain {int(plain.cw)} pad {int(plain.pt)} text {int(plain.children[0].rsize)} h {int(plain.children[0].ch)} | half {int(half.cw)} pad {int(half.pt)} text {int(half.children[0].rsize)} h {int(half.children[0].ch)} | half of twice {int(inner.cw)} | fit {int(fit.children[0].cw)} em {int(fit.children[1].cw)}`)
}
}

View file

@ -23,7 +23,7 @@ var el_s3: float = 0.0
# top right bottom left out of 1 to 4 lengths
function el_sides(v: Val) -> void {
let parts = new []float
if vl_num(v) { push(parts, ui_px(Value.as_float(v))) } else {
if vl_num(v) { push(parts, el_px(Value.as_float(v))) } else {
let words = el_words(vl_text(v))
for i in 0 .. len(words) {
if len(words[i]) > 0 { push(parts, el_word_len(words[i])) }
@ -39,7 +39,7 @@ function el_sides(v: Val) -> void {
}
# a length: a number, 10px, 1.5em, 2rem, 50vw, calc(...), fill, fit or auto (a percentage is el_pct's)
function el_len(v: Val) -> float {
if vl_num(v) { return ui_px(Value.as_float(v)) }
if vl_num(v) { return el_px(Value.as_float(v)) }
let s = vl_text(v)
if s == "fill" { return UI_FILL }
if ca_is(s) { return el_calc_len(s) }

View file

@ -26,7 +26,7 @@ function el_border_image(n: UiNode, s: string) -> void {
n.bi_src = el_url(s[0..Math.min(close + 1, len(s))])
let rest = lss_split(s[Math.min(close + 1, len(s))..len(s)], 47)
n.bi_slice = el_num(tpl_words(rest[0]))
n.bi_width = ui_px(n.bi_slice)
n.bi_width = el_px(n.bi_slice)
if len(rest) > 1 { n.bi_width = el_unit(tpl_words(rest[1])) }
}
# box-shadow: 6px 10px 0 rgba(0, 0, 0, 0.45) - x, y, a blur this renderer draws sharp, the colour

View file

@ -1,5 +1,5 @@
# attr_fit.ludic - aspect-ratio (16 / 9, or 1.5) and object-fit (fill, contain, cover, none,
# scale-down) for pictures and natives, and a list split at its top-level commas
# attr_fit.ludic - aspect-ratio (16 / 9, or 1.5), object-fit (fill, contain, cover, none, scale-down)
# for pictures and natives, zoom (zoom.ludic), and a list split at its top-level commas
function el_fit_props(n: UiNode, key: string, s: string) -> bool {
if key == "aspect-ratio" {
let parts = lss_split(s, 47)
@ -12,7 +12,7 @@ function el_fit_props(n: UiNode, key: string, s: string) -> bool {
var k = 0
if w == "contain" { k = 1 } else if w == "cover" { k = 2 } else if w == "none" { k = 3 } else if w == "scale-down" { k = 4 }
nx(n).fit_obj = k
} else { return false }
} else if key == "zoom" { el_zoom(n, s) } else { return false }
return true
}
# "a, rgba(1, 2, 3, 0.5) 40%, b" -> its three parts

View file

@ -6,6 +6,8 @@ var ca_i: int = 0
var ca_bad: string = ""
var ca_px: float = 0.0 # the result: its lengths, its percentage, and whether it had units
var ca_pct: float = 0.0
var ca_num: float = 0.0 # a plain number's own value, and whether it was one
var ca_bare: bool = false
function ca_is(s: string) -> bool { return ca_fn_at(s, 0) != "" }
# calc(...) worked out into ca_px and ca_pct; false (with the reason said) when it cannot be
function ca_eval(s: string) -> bool {
@ -21,7 +23,9 @@ function ca_eval(s: string) -> bool {
}
ca_px = v.px
ca_pct = v.pct
if v.bare { ca_px = ui_px(v.n) }
ca_num = v.n
ca_bare = v.bare
if v.bare { ca_px = el_px(v.n) }
return true
}
property CaVal {
@ -63,6 +67,6 @@ function ca_len(a: CaVal) -> CaVal {
if not a.bare { return a }
let r = new CaVal
r.bare = false
r.px = ui_px(a.n)
r.px = el_px(a.n)
return r
}

View file

@ -1,6 +1,6 @@
# calc_terms.ludic - calc()'s products and its terms: a number with its unit, a bracket, a nested
# calc(), a minus in front; a product or a quotient needs a plain number on one side (on the right
# of a /), as CSS's does
# calc(), a minus in front; a product needs a plain number on one side, and a quotient a plain number
# or a length on its right (a length over a length is a plain number, as CSS's typed division makes)
function ca_product() -> CaVal {
var a = ca_factor()
while ca_bad == "" {
@ -9,8 +9,12 @@ function ca_product() -> CaVal {
let div = ca_s[ca_i] == 47
ca_i += 1
let b = ca_factor()
if div and not b.bare and not a.bare and a.pct == 0.0 and b.pct == 0.0 and b.px != 0.0 {
a = ca_ratio(a.px / b.px)
continue
}
if div and not b.bare {
ca_bad = "a plain number after /"
ca_bad = "a plain number, or a length under a length, after /"
return a
}
if div and b.n == 0.0 {
@ -24,6 +28,12 @@ function ca_product() -> CaVal {
}
return a
}
# a length over a length is a plain number (100vw / 1800px)
function ca_ratio(k: float) -> CaVal {
let r = new CaVal
r.n = k
return r
}
function ca_scale(a: CaVal, k: float) -> CaVal {
let r = new CaVal
r.bare = a.bare

View file

@ -16,10 +16,12 @@ function cs_tree(n: UiNode) -> void {
n.children = shown
cs_order(n)
}
# twice: the font size first, from every rule, so an em anywhere reads the size the element ends with
# twice: the font size (and zoom) first, from every rule, so an em anywhere reads the size the
# element ends with (again when it zooms, so its own lengths are zoomed)
function cs_node(n: UiNode) -> void {
cs_pass = 1
cs_styles(n)
if zm_own(n) != 1.0 { cs_styles(n) }
cs_pass = 0
cs_styles(n)
an_apply(n)
@ -39,7 +41,7 @@ function cs_styles(n: UiNode) -> void {
}
}
var cs_pass: int = 0
function cs_pass_ok(key: string) -> bool { return cs_pass == 0 or at_alias(key) == "size" }
function cs_pass_ok(key: string) -> bool { return cs_pass == 0 or at_alias(key) == "size" or key == "zoom" }
function cs_sheet(n: UiNode, sh: UiSheet) -> void {
for i in 0 .. len(sh.rules) {
let r = sh.rules[i]

View file

@ -23,7 +23,7 @@ function ct_sound(n: UiNode) -> string {
function ct_activate(n: UiNode) -> void {
if not n.enabled { return }
let t = ct_type(n)
if t == "checkbox" { ct_change(n, vl_bool(not ui_attr_on(n, "checked"))) } else if t == "radio" { ct_change(n, ui_attr(n, "value")) } else if t == "select" { ct_step(n, 1) } else if t == "key" { ct_key_start(n) } else if t == "number" { ct_num_commit(n) } else if t == "" {
if t == "checkbox" { ct_change(n, vl_bool(not ui_attr_on(n, "checked"))) } else if t == "radio" { ct_change(n, ui_attr(n, "value")) } else if t == "select" { ct_step(n, 1) } else if t == "key" { ct_key_start(n) } else if t == "number" { ct_num_commit(n) } else if t == "text" { ct_submit(n) } else if t == "" {
ui_sound(ct_sound(n))
ui_fire(n, "press", Value.null())
}

View file

@ -44,3 +44,9 @@ function ct_fixed(f: float, places: int) -> string {
if neg and whole != 0 { s = "-" + s }
return s
}
# Enter (or A) on a text field: on-submit with what it holds; the focus stays, and the text too
# unless it says clear-on-submit
function ct_submit(n: UiNode) -> void {
ui_fire(n, "submit", ui_attr(n, "value"))
if ui_attr_on(n, "clear-on-submit") { ui_fire(n, "change", Value.str("")) }
}

View file

@ -14,7 +14,9 @@ export function ui_show(screen: string, view: UiView, x: float, y: float, w: flo
sc_update(root)
fr_pointer(root)
fc_keys(root)
hd_tick(root)
tt_track(root, in_now.x, in_now.y)
fb_keep(screen, root)
if ui_be != null and ui_be.text != null {
fr_draw(root)
pp_draw_top()

View file

@ -0,0 +1,44 @@
# frame_box.ludic - what a host asks of the interface from outside it: the scale it is drawn at, and
# where an element was laid out the last time its screen was shown (a 3D view framed beside a panel)
export property UiBox {
x: float = 0.0
y: float = 0.0
w: float = 0.0
h: float = 0.0
}
var fb_roots: []UiNode = new []UiNode
# the screen pixels per design pixel: the renderer's scale (render3d's includes ui_render3d_scale)
export function ui_scale() -> float { return ui_px(1.0) }
function fb_keep(screen: string, root: UiNode) -> void {
for i in 0 .. len(fb_roots) {
if fb_roots[i].key == screen {
fb_roots[i] = root
return
}
}
push(fb_roots, root)
}
# the box of the element with that id (or key) on any screen shown, in screen pixels; null if none
export function ui_box(id: string) -> UiBox {
for i in 0 .. len(fb_roots) {
let n = fb_find(fb_roots[i], id)
if n != null {
let b = new UiBox
b.x = n.x
b.y = n.y
b.w = n.cw
b.h = n.ch
return b
}
}
return null
}
function fb_find(n: UiNode, id: string) -> UiNode {
if n.id == id or n.key == id { return n }
if n.children == null { return null }
for i in 0 .. len(n.children) {
let f = fb_find(n.children[i], id)
if f != null { return f }
}
return null
}

View file

@ -0,0 +1,28 @@
# hold.ludic - on-hold: every frame a control is held down - by the pointer (wherever it goes until it
# is let go), or by Enter or the pad's A on the focus - with event.dt, the seconds since the last
# frame, and event.t, how long it has been held, on the ui clock
var hd_key: string = ""
var hd_t0: float = 0.0
var hd_last: float = 0.0
function hd_tick(root: UiNode) -> void {
var key = fr_key_press
if fr_press_key != "" and in_now.down { key = fr_press_key }
if key == "" {
hd_key = ""
return
}
let now = an_now()
if key != hd_key {
hd_key = key
hd_t0 = now
hd_last = now
}
let n = fr_node(root, key)
if n != null {
let v = Value.object()
Value.put(v, "dt", Value.float(now - hd_last))
Value.put(v, "t", Value.float(now - hd_t0))
ui_fire(n, "hold", v)
}
hd_last = now
}

View file

@ -63,6 +63,7 @@ import "ua.ludic"
import "anim.ludic"
import "transition.ludic"
import "units.ludic"
import "zoom.ludic"
import "style.ludic"
import "style_rule.ludic"
import "media.ludic"
@ -81,6 +82,8 @@ import "layout_fit.ludic"
import "layout_run.ludic"
import "layout_wrap.ludic"
import "frame.ludic"
import "frame_box.ludic"
import "hold.ludic"
import "pointer.ludic"
import "pointer_pick.ludic"
import "pointer_events.ludic"

View file

@ -2,7 +2,7 @@
# measures its content at that width (so a wrapping row knows how tall it is), then places each
# child. A size is the border box: padding and border inside it, margin outside it.
function ly_measure(n: UiNode, avail: float, size: float, fg: int) -> void {
n.rsize = size
n.rsize = size * zm_own(n)
if n.size > 0.0 { n.rsize = n.size }
n.rfg = fg
n.rfg_a = ly_fg_a

View file

@ -65,7 +65,7 @@ function nt_run(n: UiNode, name: string, value: Val) -> void {
for i in 0 .. len(n.on_names) {
if n.on_names[i] == name {
var ev = Value.object()
if lk_in(PE_NAMES, name) and Value.kind(value) == 6 { ev = value }
if (lk_in(PE_NAMES, name) or name == "hold") and Value.kind(value) == 6 { ev = value }
Value.put(ev, "name", Value.str(name))
Value.put(ev, "value", value)
let e = env_new(n.env)

View file

@ -29,6 +29,7 @@ export property UiNodeX {
grad: UiGradient = null # background: linear-gradient(...)
ratio: float = 0.0 # aspect-ratio, width over height; 0 none
fit_obj: int = 0 # object-fit: 0 fill, 1 contain, 2 cover, 3 none, 4 scale-down
zoom: float = 1.0 # zoom: this element and everything inside it, larger or smaller
}
function nx(n: UiNode) -> UiNodeX {
if n.ex == null { n.ex = new UiNodeX }

View file

@ -10,21 +10,21 @@ var ly_vp_h: float = 0.0
function el_unit(s: string) -> float {
let f = el_num(s)
let n = len(s)
if n > 3 and s[n - 3..n] == "rem" { return f * ui_px(16.0) }
if n > 3 and s[n - 3..n] == "rem" { return f * el_px(16.0) }
if n > 2 and s[n - 2..n] == "em" {
if el_cur == null { return f * ui_px(16.0) }
if el_cur == null { return f * el_px(16.0) }
if el_key == "size" and el_cur.parent != null { return f * cs_fs(el_cur.parent) }
return f * cs_fs(el_cur)
}
if n > 2 and s[n - 2..n] == "vw" { return f * ly_vp_w / 100.0 }
if n > 2 and s[n - 2..n] == "vh" { return f * ly_vp_h / 100.0 }
return ui_px(f)
return el_px(f)
}
# the font size an element has so far in its cascade (its own, else its parent's)
function cs_fs(n: UiNode) -> float {
if n.size > 0.0 { return n.size }
if n.parent != null { return cs_fs(n.parent) }
return ui_px(16.0)
if n.parent != null { return cs_fs(n.parent) * zm_own(n) }
return ui_px(16.0) * zm_own(n)
}
function cs_var_set(n: UiNode, name: string, value: string) -> void {
if n.var_names == null {

View file

@ -0,0 +1,37 @@
# zoom.ludic - zoom: 0.8 (or 80%, or calc(), min() and the rest: min(1, calc(100vw / 1800px))) makes
# an element and everything inside it that much larger or smaller, its lengths, its text and its
# layout; zooms multiply down the tree. A length in px is zoomed as it is read (el_px).
function el_zoom(n: UiNode, s0: string) -> void {
let s = tpl_words(s0)
var z = 1.0
if ca_is(s) {
if ca_eval(s) {
z = ca_num
if not ca_bare { ui_err(`zoom: {s} - a zoom is a plain number (divide a length by a length)`) }
}
} else if len(s) > 1 and s[len(s) - 1] == 37 { z = el_num(s) / 100.0 } else if s != "normal" { z = el_num(s) }
if z <= 0.0 { z = 1.0 }
nx(n).zoom = z
}
function zm_own(n: UiNode) -> float {
if n.ex == null { return 1.0 }
return n.ex.zoom
}
# every zoom from this element out
function zm_all(n: UiNode) -> float {
var z = 1.0
var at = n
while at != null {
z = z * zm_own(at)
at = at.parent
}
return z
}
# a length of `v` design pixels on the element being styled: the renderer's scale and every zoom
# around it (its own too, except while its zoom itself is read)
function el_px(v: float) -> float {
if el_cur == null { return ui_px(v) }
if el_key == "zoom" and el_cur.parent != null { return ui_px(v) * zm_all(el_cur.parent) }
if el_key == "zoom" { return ui_px(v) }
return ui_px(v) * zm_all(el_cur)
}

View file

@ -776,6 +776,10 @@ function cmd_dev_test() -> int {
controller_case("library/ui_keycap", "", "listening 16711680 | 257 Right click -2 | 258 Middle click -2 | 256 Left click -2 | pressed 0", "ui_keycap.ludic (L11: a key field takes the left, right and middle mouse buttons, and is :capturing while it listens)")
controller_case("library/ui_focus_tip", "", "b 14,38 | Drop it 27,45 | Give it away 27,77 | b 14,38", "ui_focus_tip.ludic (L11: a title shows for the keyboard's focus after half a second, and goes back to the pointer when it moves)")
controller_case("library/ui_theme", "", "accent #ff0000 gap 7", "ui_theme.ludic (L11: a component with no stylesheet reads the theme's :root variables from the screen around it)")
controller_case("library/ui_box", "", "scale 1.5 | panel 35,75 150x90 | missing 1", "ui_box.ludic (L11: ui_scale and ui_box, the interface's scale and an element's laid-out box, read from outside)")
controller_case("library/ui_submit", "", "[hi] chat '' | [hi][yo][Ada] chat '' name 'Ada'", "ui_submit.ludic (L11: on-submit on a text field by Enter or A, clear-on-submit, the focus kept)")
controller_case("library/ui_zoom", "", "plain 100 pad 10 text 16 h 20 | half 50 pad 5 text 8 h 10 | half of twice 100 | fit 50 em 16", "ui_zoom.ludic (L11: zoom scales a subtree's lengths, text and layout, multiplies, and takes min() and a length over a length)")
controller_case("library/ui_hold", "", "31 frames 500 ms t 500 | 121 frames 500 ms t 500 | pad 16 frames 250 ms t 250 | dududu", "ui_hold.ludic (L11: on-hold every frame a button is held, by the pointer or A, with dt and t on the clock at any rate)")
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)")