From 12f4ba8431be51d1d30d485a7b45ca9c0e68f24a Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Thu, 24 Sep 2026 21:53:44 +0300 Subject: [PATCH] feat(ui): min(), max() and clamp(); insets as percentages of the containing block Co-Authored-By: Claude Opus 5.5 --- LANGUAGE.md | 8 +++-- changes/defaults-views-templates.md | 2 ++ examples/library/ui_calc.ludic | 20 ++++++++++++ packages/ludic.ui/attr_inset.ludic | 47 ++++++++++++++++++++++++++++ packages/ludic.ui/attr_place.ludic | 6 +--- packages/ludic.ui/calc.ludic | 15 +++++---- packages/ludic.ui/calc_fns.ludic | 48 +++++++++++++++++++++++++++++ packages/ludic.ui/calc_terms.ludic | 4 ++- packages/ludic.ui/index.ludic | 2 ++ packages/ludic.ui/layout_run.ludic | 2 +- packages/ludic.ui/layout_wrap.ludic | 12 +++++--- packages/ludic.ui/node_more.ludic | 4 +++ tools/ludic-cli/test.ludic | 1 + 13 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 examples/library/ui_calc.ludic create mode 100644 packages/ludic.ui/attr_inset.ludic create mode 100644 packages/ludic.ui/calc_fns.ludic diff --git a/LANGUAGE.md b/LANGUAGE.md index 2f0da60c..14a29a2c 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -376,8 +376,12 @@ applies to both: - A length is `12`, `12px`, `50%`, `fit`/`auto`, `fill` or `calc()`. `width: 0` and `height: 0` are 0, not unset. - `calc()` takes `+`, `-`, `*` and `/` and brackets over `px`, `em`, `rem`, `vw`, `vh`, plain - numbers and `var()`; in a `width` or `height` it may also hold a percentage of the room - (`calc(50% - 10px)`). + numbers and `var()`; in a `width`, `height`, `top`, `right`, `bottom` or `left` it may also hold + a percentage of the room or of the containing block (`calc(50% - 10px)`). `min()`, `max()` and + `clamp(least, want, most)` pick among lengths, inside `calc()` or on their own + (`width: min(300px, 50vw)`); a percentage cannot be compared there. + - `top`, `right`, `bottom` and `left` take a percentage of the containing block: its width for + left and right, its height for top and bottom. - `flex-grow` (or `flex`) shares out the spare room along `flex-direction`, and `fill` is a share of 1. `justify-content` takes `flex-start`/`start`, `center`, `end`, `space-between`, `space-around` or `space-evenly`. diff --git a/changes/defaults-views-templates.md b/changes/defaults-views-templates.md index 7b99a6aa..9aa26562 100644 --- a/changes/defaults-views-templates.md +++ b/changes/defaults-views-templates.md @@ -92,6 +92,8 @@ What a game's screens found missing, now in `ludic.ui`: - `text-fit: shrink MIN` shrinks a line to its box, then cuts it with an ellipsis; `line-height`; an `em` reads the font size the element ends with (a `font-size` later in the rule, or in a later rule), not the one it had so far; +- `min()`, `max()` and `clamp()`, in `calc()` or on their own; `top` / `right` / `bottom` / `left` + as a percentage or a `calc()` of one, of the containing block; - a transition lands exactly on its end value (it had stopped a rounding error short of it, at every frame rate). diff --git a/examples/library/ui_calc.ludic b/examples/library/ui_calc.ludic new file mode 100644 index 00000000..545ca437 --- /dev/null +++ b/examples/library/ui_calc.ludic @@ -0,0 +1,20 @@ +# ui_calc.ludic — min(), max() and clamp() (on their own and inside calc()), and top, right, bottom +# and left as percentages and calc() of the containing block, for absolute and relative elements. +import "ludic.ui" +program UiCalc { + numbers float + const STYLE: string = "" + const BODY: string = "
" + entry { + ui_backend(new UiBackend) + ui_load_text("" + STYLE + BODY + "", "c.xml") + ui_show("c", null, 0.0, 0.0, 400.0, 600.0) + let r: UiNode = ui_nodes("c", null) + ui_place(r, 0.0, 0.0, 400.0, 600.0) + let box = r.children[4] + let p = box.children[0] + let q = box.children[1] + let rel = box.children[2] + print(`min {int(r.children[0].cw)} max {int(r.children[1].cw)} clamp {int(r.children[2].cw)} {int(r.children[3].ch)} | p {int(p.x - box.x)},{int(p.y - box.y)} q {int(q.x - box.x)},{int(q.y - box.y)} r {int(rel.x - box.x)}`) + } +} diff --git a/packages/ludic.ui/attr_inset.ludic b/packages/ludic.ui/attr_inset.ludic new file mode 100644 index 00000000..7ae1430b --- /dev/null +++ b/packages/ludic.ui/attr_inset.ludic @@ -0,0 +1,47 @@ +# attr_inset.ludic - top, right, bottom and left as lengths, percentages or calc() of both: the +# percentage is kept apart and taken of the containing block's width (left, right) or height (top, +# bottom) when the element is placed +function el_inset(n: UiNode, side: int, v: Val) -> float { + let s = vl_text(v) + var pct = 0.0 + var px = UI_AUTO + if s != "auto" { + if ca_is(s) { + px = 0.0 + if ca_eval(s) { + pct = ca_pct + px = ca_px + } + } else if len(s) > 1 and s[len(s) - 1] == 37 { + pct = el_num(s) + px = 0.0 + } else { px = el_len(v) } + } + if pct != 0.0 or n.ex != null { + let x = nx(n) + if side == 0 { x.ip_t = pct } + if side == 1 { x.ip_r = pct } + if side == 2 { x.ip_b = pct } + if side == 3 { x.ip_l = pct } + } + return px +} +# an inset in px against a containing block `base` wide (or high), or UI_AUTO +function ly_ins(c: UiNode, side: int, base: float) -> float { + var v = c.top + if side == 1 { v = c.right } + if side == 2 { v = c.bottom } + if side == 3 { v = c.left } + if v == UI_AUTO or c.ex == null { return v } + var p = c.ex.ip_t + if side == 1 { p = c.ex.ip_r } + if side == 2 { p = c.ex.ip_b } + if side == 3 { p = c.ex.ip_l } + return v + base * p / 100.0 +} +# position: relative moves an element by its insets, a percentage taken of its parent's content box +function ly_nudge(n: UiNode, c: UiNode) -> void { + let bw = n.cw - ly_hin(n) + let bh = n.ch - ly_vin(n) + ly_offset(c, ly_or0(ly_ins(c, 3, bw)) - ly_or0(ly_ins(c, 1, bw)), ly_or0(ly_ins(c, 0, bh)) - ly_or0(ly_ins(c, 2, bh))) +} diff --git a/packages/ludic.ui/attr_place.ludic b/packages/ludic.ui/attr_place.ludic index 763fe865..16ee6d40 100644 --- a/packages/ludic.ui/attr_place.ludic +++ b/packages/ludic.ui/attr_place.ludic @@ -6,7 +6,7 @@ function el_place_prop(n: UiNode, key: string, v: Val) -> bool { if s == "relative" { n.pos = 1 } if s == "absolute" { n.pos = 2 } if s == "fixed" { n.pos = 3 } - } else if key == "top" { n.top = el_inset(v) } else if key == "right" { n.right = el_inset(v) } else if key == "bottom" { n.bottom = el_inset(v) } else if key == "left" { n.left = el_inset(v) } else if key == "inset" { + } else if key == "top" { n.top = el_inset(n, 0, v) } else if key == "right" { n.right = el_inset(n, 1, v) } else if key == "bottom" { n.bottom = el_inset(n, 2, v) } else if key == "left" { n.left = el_inset(n, 3, v) } else if key == "inset" { el_sides(v) n.top = el_s0 n.right = el_s1 @@ -17,7 +17,3 @@ function el_place_prop(n: UiNode, key: string, v: Val) -> bool { } else if key == "white-space" { n.nowrap = s == "nowrap" or s == "pre" } else if key == "text-overflow" { n.ellipsis = s == "ellipsis" } else { return false } return true } -function el_inset(v: Val) -> float { - if vl_text(v) == "auto" { return UI_AUTO } - return el_len(v) -} diff --git a/packages/ludic.ui/calc.ludic b/packages/ludic.ui/calc.ludic index f3f1a1c2..99fa4041 100644 --- a/packages/ludic.ui/calc.ludic +++ b/packages/ludic.ui/calc.ludic @@ -1,21 +1,20 @@ -# calc.ludic - CSS's calc(): + - * / and brackets over lengths (px, em, rem, vw, vh, or a bare -# number, which is px as everywhere here), percentages and plain numbers; var() is already read by -# then. A width or height keeps its percentage apart (w_pct plus an offset) to be taken of the room -# the element is given; anywhere else a percentage in calc() has nothing to be a share of. +# calc.ludic - CSS's calc(), min(), max() and clamp(): + - * / and brackets over lengths (px, em, rem, +# vw, vh, a bare number is px), percentages and plain numbers. A width, height or inset keeps its +# percentage apart, to be taken of the room it is given; elsewhere a percentage has nothing to share. var ca_s: string = "" 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 -function ca_is(s: string) -> bool { return len(s) > 5 and s[0..5] == "calc(" } +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 { - ca_s = s[5..len(s)] + ca_s = s ca_i = 0 ca_bad = "" - let v = ca_sum() + let v = ca_factor() ca_ws() - if ca_bad == "" and (ca_i >= len(ca_s) or ca_s[ca_i] != 41) { ca_bad = "a closing )" } + if ca_bad == "" and ca_i < len(ca_s) { ca_bad = "nothing after its closing )" } if ca_bad != "" { ui_err(`{s}: calc() expected {ca_bad}`) return false diff --git a/packages/ludic.ui/calc_fns.ludic b/packages/ludic.ui/calc_fns.ludic new file mode 100644 index 00000000..b9eb7b4a --- /dev/null +++ b/packages/ludic.ui/calc_fns.ludic @@ -0,0 +1,48 @@ +# calc_fns.ludic - min(a, b, ...), max(a, b, ...) and clamp(least, want, most) over lengths or plain +# numbers, inside calc() or on their own. Comparing needs a length: a percentage there has no room yet. +# the function a value starts with at `at` (calc, min, max or clamp), or "" +function ca_fn_at(s: string, at: int) -> string { + if ca_word(s, at, "calc(") { return "calc" } + if ca_word(s, at, "min(") { return "min" } + if ca_word(s, at, "max(") { return "max" } + if ca_word(s, at, "clamp(") { return "clamp" } + return "" +} +function ca_word(s: string, at: int, w: string) -> bool { return at + len(w) <= len(s) and s[at..at + len(w)] == w } +function ca_fn(f: string) -> CaVal { + ca_i = ca_i + len(f) + 1 + let args = new []CaVal + while ca_bad == "" { + push(args, ca_sum()) + ca_ws() + if ca_i < len(ca_s) and ca_s[ca_i] == 44 { ca_i += 1 } else { break } + } + if ca_bad == "" and (ca_i >= len(ca_s) or ca_s[ca_i] != 41) { ca_bad = "a closing )" } + ca_i += 1 + if ca_bad != "" { return new CaVal } + if f == "clamp" and len(args) != 3 { + ca_bad = "clamp(least, want, most)" + return new CaVal + } + for i in 0 .. len(args) { + if args[i].pct != 0.0 { + ca_bad = `lengths in {f}(), not a percentage` + return new CaVal + } + } + if f == "clamp" { return ca_pick(ca_pick(args[1], args[0], false), args[2], true) } + var r = args[0] + for i in 1 .. len(args) { r = ca_pick(r, args[i], f == "min") } + return r +} +# the smaller (or the larger) of two, a bare number beside a length being px +function ca_pick(a: CaVal, b: CaVal, smaller: bool) -> CaVal { + if a.bare and b.bare { + if (b.n < a.n) == smaller and b.n != a.n { return b } + return a + } + let la = ca_len(a) + let lb = ca_len(b) + if (lb.px < la.px) == smaller and lb.px != la.px { return lb } + return la +} diff --git a/packages/ludic.ui/calc_terms.ludic b/packages/ludic.ui/calc_terms.ludic index bae716c7..833cf460 100644 --- a/packages/ludic.ui/calc_terms.ludic +++ b/packages/ludic.ui/calc_terms.ludic @@ -43,7 +43,9 @@ function ca_factor() -> CaVal { ca_i += 1 return ca_scale(ca_factor(), -1.0) } - if c == 40 or (ca_i + 5 <= len(ca_s) and ca_s[ca_i..ca_i + 5] == "calc(") { + let f = ca_fn_at(ca_s, ca_i) + if f == "min" or f == "max" or f == "clamp" { return ca_fn(f) } + if c == 40 or f == "calc" { while ca_s[ca_i] != 40 { ca_i += 1 } ca_i += 1 let v = ca_sum() diff --git a/packages/ludic.ui/index.ludic b/packages/ludic.ui/index.ludic index 059c6c0e..5adf31c1 100644 --- a/packages/ludic.ui/index.ludic +++ b/packages/ludic.ui/index.ludic @@ -49,10 +49,12 @@ import "attr.ludic" import "attr_box.ludic" import "attr_border.ludic" import "attr_place.ludic" +import "attr_inset.ludic" import "attr_edge.ludic" import "attr_more.ludic" import "calc.ludic" import "calc_terms.ludic" +import "calc_fns.ludic" import "ua.ludic" import "anim.ludic" import "transition.ludic" diff --git a/packages/ludic.ui/layout_run.ludic b/packages/ludic.ui/layout_run.ludic index e972be48..ff12d114 100644 --- a/packages/ludic.ui/layout_run.ludic +++ b/packages/ludic.ui/layout_run.ludic @@ -64,7 +64,7 @@ function ly_place_line(n: UiNode, from: int, to: int, top: float, main_room: flo var m = sizes[i - from] if grows > 0.0 and free > 0.0 { m = m + free * ly_grow(c, n.dir) / grows } ly_place_child(n, c, at, top, m, cross_room) - if c.pos == 1 { ly_offset(c, ly_or0(c.left) - ly_or0(c.right), ly_or0(c.top) - ly_or0(c.bottom)) } + if c.pos == 1 { ly_nudge(n, c) } at = at + m + n.gap + extra } } diff --git a/packages/ludic.ui/layout_wrap.ludic b/packages/ludic.ui/layout_wrap.ludic index 317d2fe8..39430496 100644 --- a/packages/ludic.ui/layout_wrap.ludic +++ b/packages/ludic.ui/layout_wrap.ludic @@ -38,12 +38,16 @@ function ly_place_abs(n: UiNode, c: UiNode) -> void { var h = c.mh if c.w_pct > 0.0 { w = ly_pw(c, bw) } if c.h_pct > 0.0 { h = ly_ph(c, bh) } - if c.w == UI_FILL or (c.left != UI_AUTO and c.right != UI_AUTO and c.w == UI_AUTO) { w = bw - ly_or0(c.left) - ly_or0(c.right) - c.ml - c.mr } - if c.h == UI_FILL or (c.top != UI_AUTO and c.bottom != UI_AUTO and c.h == UI_AUTO) { h = bh - ly_or0(c.top) - ly_or0(c.bottom) - c.mt - c.mb } + let l = ly_ins(c, 3, bw) + let r = ly_ins(c, 1, bw) + let t = ly_ins(c, 0, bh) + let b = ly_ins(c, 2, bh) + if c.w == UI_FILL or (l != UI_AUTO and r != UI_AUTO and c.w == UI_AUTO) { w = bw - ly_or0(l) - ly_or0(r) - c.ml - c.mr } + if c.h == UI_FILL or (t != UI_AUTO and b != UI_AUTO and c.h == UI_AUTO) { h = bh - ly_or0(t) - ly_or0(b) - c.mt - c.mb } var x = bx + n.pl + c.ml - if c.left != UI_AUTO { x = bx + c.left + c.ml } else if c.right != UI_AUTO { x = bx + bw - c.right - c.mr - w } + if l != UI_AUTO { x = bx + l + c.ml } else if r != UI_AUTO { x = bx + bw - r - c.mr - w } var y = by + n.pt + c.mt - if c.top != UI_AUTO { y = by + c.top + c.mt } else if c.bottom != UI_AUTO { y = by + bh - c.bottom - c.mb - h } + if t != UI_AUTO { y = by + t + c.mt } else if b != UI_AUTO { y = by + bh - b - c.mb - h } ly_place(c, x, y, w, h) } function ly_or0(v: float) -> float { diff --git a/packages/ludic.ui/node_more.ludic b/packages/ludic.ui/node_more.ludic index dd06e6e1..bbdc3c29 100644 --- a/packages/ludic.ui/node_more.ludic +++ b/packages/ludic.ui/node_more.ludic @@ -22,6 +22,10 @@ export property UiNodeX { fit_min: float = 0.0 # text-fit: shrink MIN - the smallest a line is shrunk to fit its box lh_kind: int = 0 # line-height: 0 normal, 1 a multiple of the font size, 2 px lh_v: float = 0.0 + ip_t: float = 0.0 # an inset's percentage of its containing block (top right bottom left) + ip_r: float = 0.0 + ip_b: float = 0.0 + ip_l: float = 0.0 } function nx(n: UiNode) -> UiNodeX { if n.ex == null { n.ex = new UiNodeX } diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index 877b30e8..441cdc7e 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -767,6 +767,7 @@ function cmd_dev_test() -> int { controller_case("library/ui_pointer", "", "pointerdown 100,80 0 0; pointerup 300,300 0 0; wheel 50,50 0 1; dragged 200 | zoomed 1 pad 7 10 1 2 hold true 0 0 active 16711680 | zoomed 1 pad 7 10 1 2 hold false 1 0 | zoomed 1 pad 7 10 1 2 hold true 1 0 | zoomed 1 pad 7 10 1 2 hold false 2 0 3817286", "ui_pointer.ludic (L11: pointer events for natives and markup, capture until release, a button over a native keeps its press; on-down, on-up and :active)") controller_case("library/ui_popover_align", "", "centred, flipped in the panel 90,40 | end below 110,70 | above b in the list 0 inside 1", "ui_popover_align.ludic (L11: anchored popovers aligned along their side, flipped and kept within a named container or their scroll box)") controller_case("library/ui_text", "", "Photograph the Be...@100 y0 | Short@160 y20 | Medium words@133 y40 | lines 5 160 one two@160 y66 three@160 y98 | tall 30 | em 20 24 | tie #00ff00", "ui_text.ludic (L11: text-fit shrinks then cuts, line-height, em against the final font size, a sheet over its imports on a tie)") + controller_case("library/ui_calc", "", "min 200 max 82 clamp 50 21 | p 150,60 q 255,80 r 30", "ui_calc.ludic (L11: min(), max() and clamp(), and insets as percentages and calc() of the containing block)") 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)")