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

@ -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)
}