feat(ui): flex-shrink, text that wraps beside its siblings, calc(), order, and 0 as a size

- flex-shrink: a line whose children want more room than it has takes it back from those that
  shrink, in proportion to shrink times size and never below a min size, a fixed size or the
  content. A scroll box shrinks (and scrolls), and a box in a column shrinks as far as the scroll
  boxes inside it let it, so a list in a column takes the room its siblings leave with no height.
  `flex: grow shrink`, and `flex-shrink` by name.
- A row wider than its room measures its texts (and boxes without a width) again in the room the
  rest leave them, so a line wraps beside an icon.
- calc() with + - * / and brackets over px, %, em, rem, vw, vh, plain numbers and var(); a width
  or height keeps its percentage, taken of the room it is given. Lengths lists (padding, margin)
  keep a calc()'s spaces.
- `order` places a box's children without touching the tree.
- width: 0 and height: 0 are 0: an unset size is UI_AUTO now, not 0.
- A component root that is itself a component takes each user's class, style and id in turn (the
  outermost's id wins), and the rules of all their sheets are weighed by specificity together; a
  user's rule wins a tie. The parent's sheet used to override the child's whatever its specificity,
  and a middle component's sheet was lost.
- The default sheet lays .ui-track out as a row, so a range's fill is as tall as its track and a
  checked box's knob goes right; a range's thumb is centred on the fill's end.
- ui_boxes.ludic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 19:54:14 +03:00
parent 9ad6347044
commit 7d8f34de17
23 changed files with 609 additions and 94 deletions

View file

@ -0,0 +1,25 @@
# ui_boxes.ludic — ludic.ui's flex and sizes: a scroll box in a column shrinks to the room its
# siblings leave (flex-shrink) and scrolls what it cannot show; text in a row wraps in the room left
# after the icon beside it; width and height 0 are 0; calc() mixes a percentage with lengths; order
# rearranges a row; and a range's track is a row, its fill as tall as the track.
import "ludic.ui"
import "ui_boxes_parts/Boxes.ludic"
program UiBoxes {
numbers float
entry {
ui_backend(new UiBackend)
ui_show("Boxes", null, 0.0, 0.0, 400.0, 600.0)
let root: UiNode = ui_nodes("Boxes", null)
ui_place(root, 0.0, 0.0, 400.0, 600.0)
let page = root.children[0]
let list = page.children[0].children[1]
let line = page.children[1]
let text = line.children[1]
let zero = page.children[2]
let half = page.children[3]
let ord = page.children[4]
let track = page.children[5].children[0]
let fill = track.children[0]
print(`list {int(list.ch)} of {int(list.content_h)} | text at {int(text.x)} {int(text.cw)} wide, {len(text.lines)} lines, row {int(line.ch)} | zero {int(zero.cw)}x{int(zero.ch)} | half {int(half.cw)}x{int(half.ch)} pad {int(half.pt)} {int(half.pr)} | order {ord.children[0].text}{ord.children[1].text}{ord.children[2].text} at {int(ord.children[2].x)} | fill {int(fill.cw)}x{int(fill.ch)} of {int(track.cw)}x{int(track.ch)}`)
}
}

View file

@ -0,0 +1,14 @@
.page { width: 300px; gap: 4px }
.panel { height: 200px; gap: 10px }
.bar { height: 30px }
.list { overflow: auto }
.item { height: 20px }
.line { width: 200px; gap: 8px }
.line img { width: 24px; height: 24px }
.zero { width: 0; height: 0 }
.half { --gap: 10px; width: calc(50% - var(--gap)); height: calc(2 * var(--gap) + 1em); padding: calc(1em / 4) 2px }
.ordered { gap: 0 }
.a { order: 1 }
.b { order: 2 }
.c { order: 3 }
.ui-track { height: 12px; padding: 2px }

View file

@ -0,0 +1,5 @@
# Boxes.ludic - a panel with a list that takes the room its header and footer leave, a line of text
# beside an icon, zero sizes, calc(), order, and a range's track
component Boxes {
state rows: int = 10
}

View file

@ -0,0 +1,12 @@
<div class="page">
<div class="panel">
<p class="bar">Head</p>
<div class="list"><each in="{range(rows)}" as="r"><p class="item">{r}</p></each></div>
<p class="bar">Foot</p>
</div>
<row class="line"><img src="icon:tent"/><p>A line long enough that it has to wrap beside the icon</p></row>
<div class="zero"><p>hidden</p></div>
<div class="half"></div>
<row class="ordered"><span class="c">c</span><span class="a">a</span><span class="b">b</span></row>
<input type="range" min="{0}" max="{10}" value="{5}"/>
</div>

View file

@ -1,7 +1,7 @@
# attr.ludic - what a property sets, by its CSS name or the short one (width or w, background or
# bg, font-size or size, ...). An attribute on an element is the same property, applied last.
const AT_ALIAS: string = " width=w height=h min-width=min-w max-width=max-w min-height=min-h max-height=max-h padding=pad background=bg background-color=bg font-size=size opacity=alpha flex-grow=grow flex-direction=dir flex-wrap=wrap justify-content=justify align-items=align align-self=self row-gap=gap column-gap=gap text-align=talign "
const AT_UNDRAWN: string = " font-weight font-family font-style box-shadow text-shadow cursor transition animation line-height letter-spacing text-decoration "
const AT_ALIAS: string = " width=w height=h min-width=min-w max-width=max-w min-height=min-h max-height=max-h padding=pad background=bg background-color=bg font-size=size opacity=alpha flex-grow=grow flex-shrink=shrink flex-direction=dir flex-wrap=wrap justify-content=justify align-items=align align-self=self row-gap=gap column-gap=gap text-align=talign "
const AT_UNDRAWN: string = " font-weight font-family font-style box-shadow cursor transition animation line-height letter-spacing text-decoration "
# a stylesheet's property: one that means nothing is said
function el_attr(n: UiNode, key: string, v: Val) -> void {
if el_apply(n, key, v) { return }
@ -17,7 +17,7 @@ function el_apply(n: UiNode, key: string, v0: Val) -> bool {
return true
}
let v = cs_vars_in(n, v0)
return el_size(n, k, v) or el_box(n, k, v) or el_flex(n, k, v) or el_look(n, k, v) or el_place_prop(n, k, v) or el_edge(n, k, v)
return el_size(n, k, v) or el_box(n, k, v) or el_flex(n, k, v) or el_look(n, k, v) or el_place_prop(n, k, v) or el_edge(n, k, v) or el_more(n, k, v)
}
function at_alias(key: string) -> string {
let pat = ` {key}=`
@ -32,13 +32,7 @@ function at_alias(key: string) -> string {
return key
}
function el_size(n: UiNode, key: string, v: Val) -> bool {
if key == "w" {
n.w_pct = el_pct(v)
n.w = el_len(v)
} else if key == "h" {
n.h_pct = el_pct(v)
n.h = el_len(v)
} else if key == "min-w" { n.min_w = el_len(v) } else if key == "max-w" { n.max_w = el_len(v) } else if key == "min-h" { n.min_h = el_len(v) } else if key == "max-h" { n.max_h = el_len(v) } else if key == "gap" { n.gap = el_len(v) } else { return false }
if key == "w" or key == "h" { el_dim(n, key == "w", v) } else if key == "min-w" { n.min_w = el_len(v) } else if key == "max-w" { n.max_w = el_len(v) } else if key == "min-h" { n.min_h = el_len(v) } else if key == "max-h" { n.max_h = el_len(v) } else if key == "gap" { n.gap = el_len(v) } else { return false }
return true
}
function el_flex(n: UiNode, key: string, v: Val) -> bool {
@ -46,7 +40,7 @@ function el_flex(n: UiNode, key: string, v: Val) -> bool {
if key == "dir" {
n.dir = UI_COL
if s == "row" { n.dir = UI_ROW }
} else if key == "grow" or key == "flex" { n.grow = el_num(s) } else if key == "wrap" { n.wrap = s == "wrap" or (vl_truthy(v) and s != "false" and s != "nowrap") } else if key == "align" { n.align = el_place(v) } else if key == "self" { n.self = el_place(v) } else if key == "justify" { n.justify = el_place(v) } else if key == "talign" { n.talign = el_place(v) } else if key == "display" { n.hidden = s == "none" } else { return false }
} else if key == "grow" { n.grow = el_num(s) } else if key == "flex" { el_flex_short(n, s) } else if key == "wrap" { n.wrap = s == "wrap" or (vl_truthy(v) and s != "false" and s != "nowrap") } else if key == "align" { n.align = el_place(v) } else if key == "self" { n.self = el_place(v) } else if key == "justify" { n.justify = el_place(v) } else if key == "talign" { n.talign = el_place(v) } else if key == "display" { n.hidden = s == "none" } else { return false }
return true
}
function el_look(n: UiNode, key: string, v: Val) -> bool {
@ -55,7 +49,10 @@ function el_look(n: UiNode, key: string, v: Val) -> bool {
n.fg = ui_color(s)
n.fg_a = cl_alpha
} else if key == "bg" {
if len(s) > 4 and s[0..4] == "url(" { n.bg_img = el_url(s) } else {
if len(s) > 4 and s[0..4] == "url(" { n.bg_img = el_url(s) } else if tpl_words(s) == "transparent" {
n.bg = 0
n.bg_a = 0.0
} else {
n.bg = ui_color(s)
n.bg_a = cl_alpha
}

View file

@ -24,9 +24,9 @@ var el_s3: float = 0.0
function el_sides(v: Val) -> void {
let parts = new []float
if vl_num(v) { push(parts, ui_px(Value.as_float(v))) } else {
let words = lss_split(vl_text(v), 32)
let words = el_words(vl_text(v))
for i in 0 .. len(words) {
if len(words[i]) > 0 { push(parts, el_unit(words[i])) }
if len(words[i]) > 0 { push(parts, el_word_len(words[i])) }
}
}
while len(parts) < 4 {
@ -37,11 +37,12 @@ function el_sides(v: Val) -> void {
el_s2 = parts[2]
el_s3 = parts[3]
}
# a length: a number, 10px, 1.5em, 2rem, 50vw, 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 {
if vl_num(v) { return ui_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) }
if s == "fit" or s == "auto" or (len(s) > 0 and s[len(s) - 1] == 37) { return 0.0 }
return el_unit(s)
}

View file

@ -0,0 +1,94 @@
# attr_more.ludic - width and height (where 0 is 0 and calc() may hold a percentage), the flex
# shorthand, flex-shrink, order and text-shadow
function el_more(n: UiNode, key: string, v: Val) -> bool {
let s = vl_text(v)
if key == "shrink" { nx(n).shrink = Math.max(el_num(s), 0.0) } else if key == "order" { nx(n).order = int(el_num(s)) } else if key == "text-shadow" { el_text_shadow(n, s) } else { return false }
return true
}
# width or height: a length (0 included), fill, a percentage, calc(), or auto/fit (none)
function el_dim(n: UiNode, wide: bool, v: Val) -> void {
let s = vl_text(v)
var fixed = UI_AUTO
var pct = 0.0
var off = 0.0
if ca_is(s) {
if ca_eval(s) {
pct = ca_pct
off = ca_px
if pct <= 0.0 { fixed = Math.max(ca_px, 0.0) }
}
} else if s == "fill" { fixed = UI_FILL } else if len(s) > 1 and s[len(s) - 1] == 37 {
pct = el_num(s)
if pct <= 0.0 { fixed = 0.0 }
} else if s != "auto" and s != "fit" and s != "" { fixed = el_len(v) }
if pct <= 0.0 { off = 0.0 }
if n.ex != null or off != 0.0 {
if wide { nx(n).w_off = off } else { nx(n).h_off = off }
}
if wide {
n.w = fixed
n.w_pct = Math.max(pct, 0.0)
} else {
n.h = fixed
n.h_pct = Math.max(pct, 0.0)
}
}
# a length out of calc(); a percentage has nothing to be a share of here
function el_calc_len(s: string) -> float {
if not ca_eval(s) { return 0.0 }
if ca_pct != 0.0 { ui_err(`{el_key}: {s} - a percentage in calc() is for a width or a height`) }
return ca_px
}
# flex: 1 | flex: 1 0 | flex: 2 1 auto | flex: none - the grow, then the shrink
function el_flex_short(n: UiNode, s: string) -> void {
let parts = el_words(s)
if s == "none" {
n.grow = 0.0
nx(n).shrink = 0.0
return
}
if len(parts) > 0 { n.grow = el_num(parts[0]) }
if len(parts) > 1 and len(parts[1]) > 0 and parts[1][0] >= 48 and parts[1][0] <= 57 { nx(n).shrink = el_num(parts[1]) }
}
# words apart by spaces, a bracket's own spaces kept: "calc(1em + 2px) 4px" is two
function el_words(s: string) -> []string {
let out = new []string
var at = 0
var depth = 0
for i in 0 .. len(s) {
if s[i] == 40 { depth += 1 }
if s[i] == 41 { depth -= 1 }
if s[i] == 32 and depth == 0 {
if i > at { push(out, s[at..i]) }
at = i + 1
}
}
if len(s) > at { push(out, s[at..len(s)]) }
return out
}
# text-shadow: 1px 2px 3px rgba(0, 0, 0, 0.6) - x, y, a blur drawn sharp, the colour; or none
function el_text_shadow(n: UiNode, s: string) -> void {
let x = nx(n)
x.tsh_set = true
x.tsh_c = -2
if tpl_words(s) == "none" { return }
let parts = el_words(s)
var nums = 0
var col = ""
for i in 0 .. len(parts) {
let p = parts[i]
if col == "" and ((p[0] >= 48 and p[0] <= 57) or p[0] == 45 or p[0] == 46) {
if nums == 0 { x.tsh_x = el_unit(p) }
if nums == 1 { x.tsh_y = el_unit(p) }
nums += 1
} else if col == "" { col = p } else { col = col + " " + p }
}
if col == "" { col = "rgba(0,0,0,0.6)" }
x.tsh_c = ui_color(col)
x.tsh_a = cl_alpha
}
# one word of a list of lengths (padding: calc(1em / 4) 2px)
function el_word_len(w: string) -> float {
if ca_is(w) { return el_calc_len(w) }
return el_unit(w)
}

View file

@ -28,7 +28,8 @@ function bd_class(k: UiTpl, e: UiEnv, key: string, out: []UiNode) -> void {
bd_kids(root, ce, key, out)
if len(out) > at { bd_pass_down(out[at], k, e) }
}
# <Card class="wide" style="margin: 4px" id="c"> puts those on the card's own root element
# <Card class="wide" style="margin: 4px" id="c"> puts those on the card's own root element; when that
# root is itself a component, each user in turn adds theirs (the outermost's id wins)
function bd_pass_down(n: UiNode, k: UiTpl, e: UiEnv) -> void {
let c = tpl_attr(k, "class")
if c != null {
@ -39,7 +40,10 @@ function bd_pass_down(n: UiNode, k: UiTpl, e: UiEnv) -> void {
}
let id = tpl_attr(k, "id")
if id != null { n.id = ui_eval_text(id, e) }
if k.style != null { n.style2 = k.style }
n.sheet2 = k.sheet
n.env2 = e
let up = new UiPass
up.sheet = k.sheet
up.style = k.style
up.env = e
if n.ups == null { n.ups = new []UiPass }
push(n.ups, up)
}

View file

@ -0,0 +1,69 @@
# 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.
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(" }
# 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_i = 0
ca_bad = ""
let v = ca_sum()
ca_ws()
if ca_bad == "" and (ca_i >= len(ca_s) or ca_s[ca_i] != 41) { ca_bad = "a closing )" }
if ca_bad != "" {
ui_err(`{s}: calc() expected {ca_bad}`)
return false
}
ca_px = v.px
ca_pct = v.pct
if v.bare { ca_px = ui_px(v.n) }
return true
}
property CaVal {
px: float = 0.0
pct: float = 0.0
n: float = 0.0
bare: bool = true # a plain number, which may scale a length
}
function ca_ws() -> void {
while ca_i < len(ca_s) and (ca_s[ca_i] == 32 or ca_s[ca_i] == 9 or ca_s[ca_i] == 10) { ca_i += 1 }
}
function ca_sum() -> CaVal {
var a = ca_product()
while ca_bad == "" {
ca_ws()
if ca_i >= len(ca_s) or (ca_s[ca_i] != 43 and ca_s[ca_i] != 45) { return a }
let minus = ca_s[ca_i] == 45
ca_i += 1
let b = ca_product()
a = ca_add(a, b, minus)
}
return a
}
function ca_add(a: CaVal, b: CaVal, minus: bool) -> CaVal {
var sign = 1.0
if minus { sign = -1.0 }
let r = new CaVal
if a.bare and b.bare {
r.n = a.n + sign * b.n
return r
}
r.bare = false
r.px = ca_len(a).px + sign * ca_len(b).px
r.pct = a.pct + sign * b.pct
return r
}
# a bare number beside a length is a length in px
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)
return r
}

View file

@ -0,0 +1,76 @@
# 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
function ca_product() -> CaVal {
var a = ca_factor()
while ca_bad == "" {
ca_ws()
if ca_i >= len(ca_s) or (ca_s[ca_i] != 42 and ca_s[ca_i] != 47) { return a }
let div = ca_s[ca_i] == 47
ca_i += 1
let b = ca_factor()
if div and not b.bare {
ca_bad = "a plain number after /"
return a
}
if div and b.n == 0.0 {
ca_bad = "a number that is not 0 after /"
return a
}
if div { a = ca_scale(a, 1.0 / b.n) } else if a.bare { a = ca_scale(b, a.n) } else if b.bare { a = ca_scale(a, b.n) } else {
ca_bad = "a plain number on one side of *"
return a
}
}
return a
}
function ca_scale(a: CaVal, k: float) -> CaVal {
let r = new CaVal
r.bare = a.bare
r.n = a.n * k
r.px = a.px * k
r.pct = a.pct * k
return r
}
function ca_factor() -> CaVal {
ca_ws()
if ca_i >= len(ca_s) {
ca_bad = "a value"
return new CaVal
}
let c = ca_s[ca_i]
if c == 45 {
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(") {
while ca_s[ca_i] != 40 { ca_i += 1 }
ca_i += 1
let v = ca_sum()
ca_ws()
if ca_i < len(ca_s) and ca_s[ca_i] == 41 { ca_i += 1 } else if ca_bad == "" { ca_bad = "a closing )" }
return v
}
return ca_number()
}
# 12, 1.5em, 50%, 20vw
function ca_number() -> CaVal {
let at = ca_i
while ca_i < len(ca_s) and ((ca_s[ca_i] >= 48 and ca_s[ca_i] <= 57) or ca_s[ca_i] == 46) { ca_i += 1 }
let num_end = ca_i
while ca_i < len(ca_s) and ((ca_s[ca_i] >= 97 and ca_s[ca_i] <= 122) or ca_s[ca_i] == 37) { ca_i += 1 }
let r = new CaVal
if num_end == at {
ca_bad = "a number"
return r
}
let word: string = ca_s[at..ca_i]
let unit: string = ca_s[num_end..ca_i]
if unit == "" {
r.n = el_num(word)
return r
}
r.bare = false
if unit == "%" { r.pct = el_num(word) } else if unit == "px" or unit == "em" or unit == "rem" or unit == "vw" or unit == "vh" { r.px = el_unit(word) } else { ca_bad = `a unit calc() knows, not {unit}` }
return r
}

View file

@ -14,13 +14,17 @@ function cs_tree(n: UiNode) -> void {
if not n.children[i].hidden { push(shown, n.children[i]) }
}
n.children = shown
cs_order(n)
}
function cs_node(n: UiNode) -> void {
cs_sheet(n, ua_sheet())
if n.sheet != null { cs_sheet(n, n.sheet) }
if n.sheet2 != null and n.sheet2 != n.sheet { cs_sheet(n, n.sheet2) }
if n.ups != null { cs_merged(n) } else if n.sheet != null { cs_sheet(n, n.sheet) }
if n.style != null { cs_rule(n, n.style) }
if n.style2 != null { cs_rule_in(n, n.style2, n.env2) }
if n.ups != null {
for i in 0 .. len(n.ups) {
if n.ups[i].style != null { cs_rule_in(n, n.ups[i].style, n.ups[i].env) }
}
}
for i in 0 .. len(n.akeys) { el_apply(n, n.akeys[i], n.vals[i]) }
an_apply(n)
tr_apply(n)

View file

@ -0,0 +1,53 @@
# cascade_merge.ludic - a component's root is styled by its own sheet and by the sheet of every user
# that passed a class down to it, all at once: the rules that match, from all of them, ordered by
# specificity as one sheet's are. On a tie the user's rule wins (the outermost user's last), as a
# later rule does. Then `order`, which rearranges a flex box's children without touching the tree.
function cs_merged(n: UiNode) -> void {
let rules = new []UiRule
let envs = new []UiEnv
let seen = new []UiSheet
if n.sheet != null { cs_gather(n, n.sheet, n.env, rules, envs, seen) }
for i in 0 .. len(n.ups) {
if n.ups[i].sheet != null { cs_gather(n, n.ups[i].sheet, n.ups[i].env, rules, envs, seen) }
}
for i in 0 .. len(rules) { cs_rule_in(n, rules[i], envs[i]) }
}
# a sheet's matching rules into the list, each after every rule at most as specific (stable)
function cs_gather(n: UiNode, sh: UiSheet, e: UiEnv, rules: []UiRule, envs: []UiEnv, seen: []UiSheet) -> void {
for i in 0 .. len(seen) {
if seen[i] == sh { return }
}
push(seen, sh)
for i in 0 .. len(sh.rules) {
let r = sh.rules[i]
if md_ok(r) and sel_match(r.sel, n) {
push(rules, r)
push(envs, e)
var j = len(rules) - 1
while j > 0 and rules[j - 1].spec > r.spec {
rules[j] = rules[j - 1]
envs[j] = envs[j - 1]
j -= 1
}
rules[j] = r
envs[j] = e
}
}
}
# CSS's order: the children placed by it, lowest first, the tree's order kept between equals
function cs_order(n: UiNode) -> void {
var any = false
for i in 0 .. len(n.children) {
if ly_order(n.children[i]) != 0 { any = true }
}
if not any { return }
for i in 1 .. len(n.children) {
let c = n.children[i]
var j = i
while j > 0 and ly_order(n.children[j - 1]) > ly_order(c) {
n.children[j] = n.children[j - 1]
j -= 1
}
n.children[j] = c
}
}

View file

@ -4,6 +4,7 @@ module ludic_ui
numbers float
import "consts.ludic"
import "node.ludic"
import "node_more.ludic"
import "view.ludic"
import "backend.ludic"
import "expr.ludic"
@ -42,6 +43,9 @@ import "attr_box.ludic"
import "attr_border.ludic"
import "attr_place.ludic"
import "attr_edge.ludic"
import "attr_more.ludic"
import "calc.ludic"
import "calc_terms.ludic"
import "ua.ludic"
import "anim.ludic"
import "transition.ludic"
@ -53,7 +57,10 @@ import "select.ludic"
import "select_parts.ludic"
import "select_match.ludic"
import "cascade.ludic"
import "cascade_merge.ludic"
import "layout.ludic"
import "layout_box.ludic"
import "layout_shrink.ludic"
import "layout_text.ludic"
import "layout_run.ludic"
import "layout_wrap.ludic"

View file

@ -28,18 +28,19 @@ function ly_measure(n: UiNode, avail: float, size: float, fg: int) -> void {
n.mh = n.mh + n.rsize
}
var fixed_w = n.w
if n.w_pct > 0.0 { fixed_w = avail * n.w_pct / 100.0 }
if n.w_pct > 0.0 { fixed_w = ly_pw(n, avail) }
if (n.kind == UI_BOX or n.kind == UI_SCROLL) and n.children != null {
var inner = avail - n.ml - n.mr
if fixed_w > 0.0 { inner = fixed_w }
if fixed_w >= 0.0 { inner = fixed_w }
inner = ly_clamp(inner, n.min_w, n.max_w) - ly_hin(n)
if n.dir == UI_ROW and n.wrap { ly_measure_wrap(n, inner) } else { ly_measure_box(n, inner) }
}
n.content_h = n.mh
if fixed_w > 0.0 { n.mw = fixed_w }
if n.h > 0.0 { n.mh = n.h }
if fixed_w >= 0.0 { n.mw = fixed_w }
if n.h >= 0.0 { n.mh = n.h }
n.mw = ly_clamp(n.mw, n.min_w, n.max_w)
n.mh = ly_clamp(n.mh, n.min_h, n.max_h)
ly_min_note(n)
}
var ly_fg_a: float = 1.0
function ly_hin(n: UiNode) -> float { return n.pl + n.pr + n.bl + n.br }
@ -50,54 +51,3 @@ function ly_clamp(v: float, lo: float, hi: float) -> float {
if out < lo { out = lo }
return out
}
function ly_measure_box(n: UiNode, inner: float) -> void {
var main = 0.0
var cross = 0.0
var count = 0
for i in 0 .. len(n.children) {
let c = n.children[i]
ly_fg_a = n.rfg_a
ly_measure(c, inner, n.rsize, n.rfg)
if c.pos < 2 {
main = main + ly_main(c, n.dir)
if count > 0 { main = main + n.gap }
cross = Math.max(cross, ly_cross(c, n.dir))
count += 1
}
}
if n.dir == UI_ROW {
n.mw = n.mw + main
n.mh = n.mh + cross
} else {
n.mw = n.mw + cross
n.mh = n.mh + main
}
}
# a wrapping row: children in lines no wider than `inner`, the lines stacked `gap` apart
function ly_measure_wrap(n: UiNode, inner: float) -> void {
var line_w = 0.0
var line_h = 0.0
var widest = 0.0
var total = 0.0
var count = 0
for i in 0 .. len(n.children) {
let c = n.children[i]
ly_fg_a = n.rfg_a
ly_measure(c, inner, n.rsize, n.rfg)
if c.pos >= 2 { continue }
let cw = ly_main(c, UI_ROW)
if count > 0 and line_w + n.gap + cw > inner {
widest = Math.max(widest, line_w)
total = total + line_h + n.gap
line_w = 0.0
line_h = 0.0
count = 0
}
if count > 0 { line_w = line_w + n.gap }
line_w = line_w + cw
line_h = Math.max(line_h, ly_cross(c, UI_ROW))
count += 1
}
n.mw = n.mw + Math.max(widest, line_w)
n.mh = n.mh + total + line_h
}

View file

@ -0,0 +1,86 @@
# layout_box.ludic - a box's content measured: its children in a line along its direction (a
# row's text wrapped into the room its siblings leave), or a wrapping row's lines
function ly_measure_box(n: UiNode, inner: float) -> void {
for i in 0 .. len(n.children) {
ly_fg_a = n.rfg_a
ly_measure(n.children[i], inner, n.rsize, n.rfg)
}
if n.dir == UI_ROW and inner > 0.0 { ly_row_fit(n, inner) }
var main = 0.0
var cross = 0.0
var count = 0
for i in 0 .. len(n.children) {
let c = n.children[i]
if c.pos < 2 {
main = main + ly_main(c, n.dir)
if count > 0 { main = main + n.gap }
cross = Math.max(cross, ly_cross(c, n.dir))
count += 1
}
}
if n.dir == UI_ROW {
n.mw = n.mw + main
n.mh = n.mh + cross
} else {
n.mw = n.mw + cross
n.mh = n.mh + main
}
}
# a row wider than its room: its texts (and boxes of their own width) measured again in the room the
# rest leave them, each giving up a share of the excess as wide as it is, so a line wraps beside an icon
function ly_row_fit(n: UiNode, inner: float) -> void {
var used = 0.0
var flex = 0.0
var count = 0
for i in 0 .. len(n.children) {
let c = n.children[i]
if c.pos >= 2 { continue }
if count > 0 { used = used + n.gap }
used = used + ly_main(c, UI_ROW)
if ly_flexes(c) { flex = flex + ly_main(c, UI_ROW) }
count += 1
}
let over = used - inner
if over <= 0.0 or flex <= 0.0 { return }
for i in 0 .. len(n.children) {
let c = n.children[i]
let share = ly_main(c, UI_ROW) - over * ly_main(c, UI_ROW) / flex
if ly_flexes(c) and share > 0.0 {
ly_fg_a = n.rfg_a
ly_measure(c, share, n.rsize, n.rfg)
}
}
}
function ly_flexes(c: UiNode) -> bool {
if c.pos >= 2 or c.w >= 0.0 or c.w_pct > 0.0 { return false }
if c.kind == UI_TEXT { return not c.nowrap }
return c.kind == UI_BOX and c.children != null and len(c.children) > 0
}
# a wrapping row: children in lines no wider than `inner`, the lines stacked `gap` apart
function ly_measure_wrap(n: UiNode, inner: float) -> void {
var line_w = 0.0
var line_h = 0.0
var widest = 0.0
var total = 0.0
var count = 0
for i in 0 .. len(n.children) {
let c = n.children[i]
ly_fg_a = n.rfg_a
ly_measure(c, inner, n.rsize, n.rfg)
if c.pos >= 2 { continue }
let cw = ly_main(c, UI_ROW)
if count > 0 and line_w + n.gap + cw > inner {
widest = Math.max(widest, line_w)
total = total + line_h + n.gap
line_w = 0.0
line_h = 0.0
count = 0
}
if count > 0 { line_w = line_w + n.gap }
line_w = line_w + cw
line_h = Math.max(line_h, ly_cross(c, UI_ROW))
count += 1
}
n.mw = n.mw + Math.max(widest, line_w)
n.mh = n.mh + total + line_h
}

View file

@ -28,8 +28,8 @@ function ly_grow(c: UiNode, dir: int) -> float {
# where a child starts along the run: `fill` from nothing, a percentage from the room it is in
function ly_basis(c: UiNode, dir: int, room: float) -> float {
if ly_fills(c, dir) { return 0.0 }
if dir == UI_ROW and c.w_pct > 0.0 { return room * c.w_pct / 100.0 + c.ml + c.mr }
if dir == UI_COL and c.h_pct > 0.0 { return room * c.h_pct / 100.0 + c.mt + c.mb }
if dir == UI_ROW and c.w_pct > 0.0 { return ly_pw(c, room) + c.ml + c.mr }
if dir == UI_COL and c.h_pct > 0.0 { return ly_ph(c, room) + c.mt + c.mb }
return ly_main(c, dir)
}
function ly_stretches(n: UiNode, c: UiNode) -> bool {
@ -43,22 +43,25 @@ function ly_place_line(n: UiNode, from: int, to: int, top: float, main_room: flo
var used = 0.0
var grows = 0.0
var count = 0
let sizes = new []float
for i in from .. to {
push(sizes, ly_basis(n.children[i], n.dir, main_room))
if n.children[i].pos < 2 {
used = used + ly_basis(n.children[i], n.dir, main_room)
used = used + sizes[i - from]
grows = grows + ly_grow(n.children[i], n.dir)
count += 1
}
}
used = used + n.gap * float(Math.max(count - 1, 0))
let free = main_room - used
if free < 0.0 { ly_shrink_line(n, from, sizes, 0.0 - free) }
ly_justify(n, free, count, grows)
let extra = ly_extra
var at = ly_lead
for i in from .. to {
let c = n.children[i]
if c.pos >= 2 { continue }
var m = ly_basis(c, n.dir, main_room)
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)) }
@ -68,8 +71,8 @@ function ly_place_line(n: UiNode, from: int, to: int, top: float, main_room: flo
# one child: `m` along the run and its own size (or the whole room, stretched) across, less margins
function ly_place_child(n: UiNode, c: UiNode, at: float, top: float, m: float, cross_room: float) -> void {
var cr = ly_cross(c, n.dir)
if n.dir == UI_ROW and c.h_pct > 0.0 { cr = cross_room * c.h_pct / 100.0 + c.mt + c.mb }
if n.dir == UI_COL and c.w_pct > 0.0 { cr = cross_room * c.w_pct / 100.0 + c.ml + c.mr }
if n.dir == UI_ROW and c.h_pct > 0.0 { cr = ly_ph(c, cross_room) + c.mt + c.mb }
if n.dir == UI_COL and c.w_pct > 0.0 { cr = ly_pw(c, cross_room) + c.ml + c.mr }
if ly_stretches(n, c) { cr = cross_room }
var a = n.align
if c.self >= 0 { a = c.self }

View file

@ -0,0 +1,75 @@
# layout_shrink.ludic - flex-shrink: when a line's children want more room than it has, those that
# shrink give it up, each in proportion to its shrink times its size, and none below what it must
# keep (its min size, a fixed size, its content's). A scroll box keeps nothing - it scrolls - so a
# list in a column takes the room its siblings leave; a box in a column shrinks as far as the scroll
# boxes inside it let it, and anything else shrinks only when flex-shrink says so.
function ly_shrink(dir: int, c: UiNode) -> float {
if c.ex != null and c.ex.shrink >= 0.0 { return c.ex.shrink }
if c.kind == UI_SCROLL { return 1.0 }
if dir == UI_COL and c.kind == UI_BOX { return 1.0 }
return 0.0
}
# how small a child may be pressed along the run, margins included
function ly_floor(dir: int, c: UiNode) -> float {
if dir == UI_ROW {
var w = c.min_w
if c.w >= 0.0 { w = Math.max(w, c.w) }
return w + c.ml + c.mr
}
return Math.max(ly_min_h(c), c.min_h) + c.mt + c.mb
}
function ly_min_h(c: UiNode) -> float {
if c.ex != null and c.ex.has_min { return c.ex.mh_min }
return c.mh
}
# `sizes` (the children from..to) shrunk by `over` in all
function ly_shrink_line(n: UiNode, from: int, sizes: []float, over0: float) -> void {
var over = over0
let frozen = new []bool
for k in 0 .. len(sizes) { push(frozen, n.children[from + k].pos >= 2) }
for pass in 0 .. 8 {
var wsum = 0.0
for k in 0 .. len(sizes) {
if not frozen[k] { wsum = wsum + ly_shrink(n.dir, n.children[from + k]) * sizes[k] }
}
if wsum <= 0.0 or over <= 0.01 { return }
var taken = 0.0
for k in 0 .. len(sizes) {
let c = n.children[from + k]
let wk = ly_shrink(n.dir, c) * sizes[k]
if frozen[k] or wk <= 0.0 { continue }
let floor = Math.min(ly_floor(n.dir, c), sizes[k])
var want = sizes[k] - over * wk / wsum
if want <= floor {
want = floor
frozen[k] = true
}
taken = taken + sizes[k] - want
sizes[k] = want
}
over = over - taken
}
}
# how short the box can be pressed: a scroll box to its padding, a column to what its children can
# be, a row to its tallest child's least; kept only when it is less than the box's own height
function ly_min_note(n: UiNode) -> void {
var m = n.mh
if n.h < 0.0 and n.kind == UI_SCROLL { m = ly_vin(n) } else if n.h < 0.0 and n.kind == UI_BOX and n.children != null and not n.wrap {
var sum = 0.0
var count = 0
for i in 0 .. len(n.children) {
let c = n.children[i]
if c.pos >= 2 { continue }
let cm = ly_min_h(c) + c.mt + c.mb
if n.dir == UI_ROW { sum = Math.max(sum, cm) } else { sum = sum + cm }
if n.dir == UI_COL and count > 0 { sum = sum + n.gap }
count += 1
}
m = ly_vin(n) + sum
}
m = ly_clamp(m, n.min_h, n.max_h)
if m < n.mh - 0.01 {
nx(n).mh_min = m
n.ex.has_min = true
} else if n.ex != null { n.ex.has_min = false }
}

View file

@ -5,7 +5,7 @@ function ly_measure_text(n: UiNode, avail: float) -> void {
let lh = n.rsize * 1.25
let one = ly_text_w(n.text, n.rsize)
var room = avail - n.ml - n.mr - ly_hin(n)
if n.w > 0.0 { room = n.w - ly_hin(n) }
if n.w >= 0.0 { room = n.w - ly_hin(n) }
if n.nowrap or room <= 0.0 or one <= room {
n.mw = n.mw + one
n.mh = n.mh + lh

View file

@ -36,10 +36,10 @@ function ly_place_abs(n: UiNode, c: UiNode) -> void {
}
var w = c.mw
var h = c.mh
if c.w_pct > 0.0 { w = bw * c.w_pct / 100.0 }
if c.h_pct > 0.0 { h = bh * c.h_pct / 100.0 }
if c.w == UI_FILL or (c.left != UI_AUTO and c.right != UI_AUTO and c.w <= 0.0) { 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 <= 0.0) { h = bh - ly_or0(c.top) - ly_or0(c.bottom) - c.mt - c.mb }
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 }
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 }
var y = by + n.pt + c.mt

View file

@ -12,15 +12,13 @@ export property UiNode {
vals: []Val = null # the same, as values, applied after every rule
style: UiRule = null # its style="..." declarations
sheet: UiSheet = null # the stylesheets of the file it was written in
sheet2: UiSheet = null # a component's root: its user's sheet too, and their style="..."
style2: UiRule = null
env2: UiEnv = null
ups: []UiPass = null # a component's root: each user's sheet and style="...", inner first
parent: UiNode = null
index: int = 0 # its place among its parent's children, for :first-child and the rest
src: string = "" # an <img>'s picture
size: float = 0.0
w: float = 0.0
h: float = 0.0
w: float = -1000000.0 # a fixed width or height (0 is 0); UI_AUTO: none, UI_FILL: fill
h: float = -1000000.0
w_pct: float = 0.0 # a width or height as a percentage of the parent's room
h_pct: float = 0.0
pt: float = 0.0 # padding, top right bottom left
@ -108,5 +106,6 @@ export property UiNode {
anim_alt: bool = false
tr_dur: float = 0.0 # transition: how long a change of opacity takes
popover: bool = false # drawn on the top layer; a press outside it or Esc closes it
ex: UiNodeX = null # what fewer elements say (node_more.ludic), made when one does
}

View file

@ -0,0 +1,40 @@
# node_more.ludic - what only some elements say, kept apart from UiNode so the common node stays
# small: made the first time a property needs it (nx), and null everywhere else
# a component user's styles for the root it passed class, style and id down to
export property UiPass {
sheet: UiSheet = null # the sheet of the file the component's tag was written in
style: UiRule = null # the tag's style="..."
env: UiEnv = null # where the tag's values are read
}
export property UiNodeX {
shrink: float = -1.0 # flex-shrink; -1: a scroll box's 1, anything else's 0 (or 1 in a column)
order: int = 0 # flex's order
w_off: float = 0.0 # calc(): the lengths added to a percentage width or height
h_off: float = 0.0
mh_min: float = 0.0 # how short a column can be pressed: its scroll boxes give way
has_min: bool = false
tsh_set: bool = false # text-shadow, inherited as CSS's is; tsh_c -2 is none
tsh_x: float = 0.0
tsh_y: float = 0.0
tsh_c: int = -2
tsh_a: float = 1.0
}
function nx(n: UiNode) -> UiNodeX {
if n.ex == null { n.ex = new UiNodeX }
return n.ex
}
function ly_woff(n: UiNode) -> float {
if n.ex == null { return 0.0 }
return n.ex.w_off
}
function ly_hoff(n: UiNode) -> float {
if n.ex == null { return 0.0 }
return n.ex.h_off
}
# a percentage width or height in `room`, with what calc() added to it
function ly_pw(n: UiNode, room: float) -> float { return room * n.w_pct / 100.0 + ly_woff(n) }
function ly_ph(n: UiNode, room: float) -> float { return room * n.h_pct / 100.0 + ly_hoff(n) }
function ly_order(n: UiNode) -> int {
if n.ex == null { return 0 }
return n.ex.order
}

View file

@ -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-track { 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-right: -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 } [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 }"

View file

@ -760,6 +760,7 @@ 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_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)")
reject_case("rejected/view_untyped", "view Sums: say what type total is", "a view field that is not a bare global says its type")
reject_case("rejected/view_without_ui", "a view is shown by ludic.ui", "a view needs ludic.ui to show it")