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 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`. 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 - `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 - 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. 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 - `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; `unit=" s"` after it;
- `<select label value>` with `<option value>`s; - `<select label value>` with `<option value>`s;
- `<input type="text" label value maxlength>`, which takes typing, and Backspace takes a letter - `<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 - `<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 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); 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; - 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). - 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 `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. 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"` 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 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 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. 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 - **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 </button>`, `<p>Hi <b>there</b></p>`. A boolean attribute present with no value is true, as in
HTML (`<button autofocus>`). 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; 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 - a component with no stylesheet of its own reads a theme's `:root` variables from around it (it
did; now a test says so); 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 - a transition lands exactly on its end value (it had stopped a rounding error short of it, at every
frame rate). 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 # top right bottom left out of 1 to 4 lengths
function el_sides(v: Val) -> void { function el_sides(v: Val) -> void {
let parts = new []float 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)) let words = el_words(vl_text(v))
for i in 0 .. len(words) { for i in 0 .. len(words) {
if len(words[i]) > 0 { push(parts, el_word_len(words[i])) } 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) # 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 { 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) let s = vl_text(v)
if s == "fill" { return UI_FILL } if s == "fill" { return UI_FILL }
if ca_is(s) { return el_calc_len(s) } 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))]) 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) 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_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])) } 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 # 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, # attr_fit.ludic - aspect-ratio (16 / 9, or 1.5), object-fit (fill, contain, cover, none, scale-down)
# scale-down) for pictures and natives, and a list split at its top-level commas # 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 { function el_fit_props(n: UiNode, key: string, s: string) -> bool {
if key == "aspect-ratio" { if key == "aspect-ratio" {
let parts = lss_split(s, 47) let parts = lss_split(s, 47)
@ -12,7 +12,7 @@ function el_fit_props(n: UiNode, key: string, s: string) -> bool {
var k = 0 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 } 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 nx(n).fit_obj = k
} else { return false } } else if key == "zoom" { el_zoom(n, s) } else { return false }
return true return true
} }
# "a, rgba(1, 2, 3, 0.5) 40%, b" -> its three parts # "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_bad: string = ""
var ca_px: float = 0.0 # the result: its lengths, its percentage, and whether it had units 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_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) != "" } 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 # calc(...) worked out into ca_px and ca_pct; false (with the reason said) when it cannot be
function ca_eval(s: string) -> bool { function ca_eval(s: string) -> bool {
@ -21,7 +23,9 @@ function ca_eval(s: string) -> bool {
} }
ca_px = v.px ca_px = v.px
ca_pct = v.pct 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 return true
} }
property CaVal { property CaVal {
@ -63,6 +67,6 @@ function ca_len(a: CaVal) -> CaVal {
if not a.bare { return a } if not a.bare { return a }
let r = new CaVal let r = new CaVal
r.bare = false r.bare = false
r.px = ui_px(a.n) r.px = el_px(a.n)
return r 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_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 # calc(), a minus in front; a product needs a plain number on one side, and a quotient a plain number
# of a /), as CSS's does # 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 { function ca_product() -> CaVal {
var a = ca_factor() var a = ca_factor()
while ca_bad == "" { while ca_bad == "" {
@ -9,8 +9,12 @@ function ca_product() -> CaVal {
let div = ca_s[ca_i] == 47 let div = ca_s[ca_i] == 47
ca_i += 1 ca_i += 1
let b = ca_factor() 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 { 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 return a
} }
if div and b.n == 0.0 { if div and b.n == 0.0 {
@ -24,6 +28,12 @@ function ca_product() -> CaVal {
} }
return a 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 { function ca_scale(a: CaVal, k: float) -> CaVal {
let r = new CaVal let r = new CaVal
r.bare = a.bare r.bare = a.bare

View file

@ -16,10 +16,12 @@ function cs_tree(n: UiNode) -> void {
n.children = shown n.children = shown
cs_order(n) 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 { function cs_node(n: UiNode) -> void {
cs_pass = 1 cs_pass = 1
cs_styles(n) cs_styles(n)
if zm_own(n) != 1.0 { cs_styles(n) }
cs_pass = 0 cs_pass = 0
cs_styles(n) cs_styles(n)
an_apply(n) an_apply(n)
@ -39,7 +41,7 @@ function cs_styles(n: UiNode) -> void {
} }
} }
var cs_pass: int = 0 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 { function cs_sheet(n: UiNode, sh: UiSheet) -> void {
for i in 0 .. len(sh.rules) { for i in 0 .. len(sh.rules) {
let r = sh.rules[i] let r = sh.rules[i]

View file

@ -23,7 +23,7 @@ function ct_sound(n: UiNode) -> string {
function ct_activate(n: UiNode) -> void { function ct_activate(n: UiNode) -> void {
if not n.enabled { return } if not n.enabled { return }
let t = ct_type(n) 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_sound(ct_sound(n))
ui_fire(n, "press", Value.null()) 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 } if neg and whole != 0 { s = "-" + s }
return 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) sc_update(root)
fr_pointer(root) fr_pointer(root)
fc_keys(root) fc_keys(root)
hd_tick(root)
tt_track(root, in_now.x, in_now.y) tt_track(root, in_now.x, in_now.y)
fb_keep(screen, root)
if ui_be != null and ui_be.text != null { if ui_be != null and ui_be.text != null {
fr_draw(root) fr_draw(root)
pp_draw_top() 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 "anim.ludic"
import "transition.ludic" import "transition.ludic"
import "units.ludic" import "units.ludic"
import "zoom.ludic"
import "style.ludic" import "style.ludic"
import "style_rule.ludic" import "style_rule.ludic"
import "media.ludic" import "media.ludic"
@ -81,6 +82,8 @@ import "layout_fit.ludic"
import "layout_run.ludic" import "layout_run.ludic"
import "layout_wrap.ludic" import "layout_wrap.ludic"
import "frame.ludic" import "frame.ludic"
import "frame_box.ludic"
import "hold.ludic"
import "pointer.ludic" import "pointer.ludic"
import "pointer_pick.ludic" import "pointer_pick.ludic"
import "pointer_events.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 # 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. # 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 { 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 } if n.size > 0.0 { n.rsize = n.size }
n.rfg = fg n.rfg = fg
n.rfg_a = ly_fg_a 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) { for i in 0 .. len(n.on_names) {
if n.on_names[i] == name { if n.on_names[i] == name {
var ev = Value.object() 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, "name", Value.str(name))
Value.put(ev, "value", value) Value.put(ev, "value", value)
let e = env_new(n.env) let e = env_new(n.env)

View file

@ -29,6 +29,7 @@ export property UiNodeX {
grad: UiGradient = null # background: linear-gradient(...) grad: UiGradient = null # background: linear-gradient(...)
ratio: float = 0.0 # aspect-ratio, width over height; 0 none 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 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 { function nx(n: UiNode) -> UiNodeX {
if n.ex == null { n.ex = new 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 { function el_unit(s: string) -> float {
let f = el_num(s) let f = el_num(s)
let n = len(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 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) } if el_key == "size" and el_cur.parent != null { return f * cs_fs(el_cur.parent) }
return f * cs_fs(el_cur) 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] == "vw" { return f * ly_vp_w / 100.0 }
if n > 2 and s[n - 2..n] == "vh" { return f * ly_vp_h / 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) # the font size an element has so far in its cascade (its own, else its parent's)
function cs_fs(n: UiNode) -> float { function cs_fs(n: UiNode) -> float {
if n.size > 0.0 { return n.size } if n.size > 0.0 { return n.size }
if n.parent != null { return cs_fs(n.parent) } if n.parent != null { return cs_fs(n.parent) * zm_own(n) }
return ui_px(16.0) return ui_px(16.0) * zm_own(n)
} }
function cs_var_set(n: UiNode, name: string, value: string) -> void { function cs_var_set(n: UiNode, name: string, value: string) -> void {
if n.var_names == null { 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_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_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_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_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_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)") 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)")