diff --git a/LANGUAGE.md b/LANGUAGE.md
index 0dfebdbd..259442e0 100644
--- a/LANGUAGE.md
+++ b/LANGUAGE.md
@@ -532,6 +532,13 @@ applies to both:
- **The look is CSS.**
- Colours: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()` and the basic names,
usually through `var(--...)` from a theme.
+ - `background` (or `background-image`) may be `linear-gradient(to right, #000, rgba(0,0,0,0) 80%)`:
+ `to top|bottom|left|right` or an angle taken to the nearest side, and stops with or without a
+ place. It is drawn as whole-pixel bands with square corners.
+ - `aspect-ratio: 16 / 9` (or `1.5`) makes an element's height from its width (fixed, a share or
+ `fill`), or its width from a fixed height. `object-fit: fill|contain|cover|none|scale-down`
+ places a picture in its box by the picture's own size (a renderer's `image_w` / `image_h`;
+ `cover` is clipped to the box); a native places its own content with `ui_object_fit(n, w, h)`.
- Boxes: `border-radius`, `box-shadow` (sharp, offset), `background-image: url(...)`, and
`border-image: url(...) slice / width` as a nine-slice. With no background colour a nine-slice
is drawn as painted, as CSS draws one; with one it is tinted by it, so one rounded texture serves
diff --git a/changes/defaults-views-templates.md b/changes/defaults-views-templates.md
index ff116e89..65594a31 100644
--- a/changes/defaults-views-templates.md
+++ b/changes/defaults-views-templates.md
@@ -98,6 +98,8 @@ What a game's screens found missing, now in `ludic.ui`:
pixels (`ui_nine_cuts`), so a small key cap has no seam;
- `scroll-top="{px}"` holds a scroll box at an offset, with `on-scroll` when the player moves it;
`ui_scroll_set(id, px)` moves one once;
+- `linear-gradient(...)` backgrounds; `aspect-ratio`; `object-fit` for pictures (the renderer's
+ `image_w` / `image_h`) and `ui_object_fit` for natives;
- 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_fill.ludic b/examples/library/ui_fill.ludic
new file mode 100644
index 00000000..dff937b3
--- /dev/null
+++ b/examples/library/ui_fill.ludic
@@ -0,0 +1,39 @@
+# ui_fill.ludic — linear-gradient backgrounds (a direction or an angle, stops placed or spread) drawn
+# as whole-pixel bands; object-fit contain and cover for a picture of its own size; aspect-ratio
+# making a height from a width, and a native's width from its height.
+import "ludic.ui"
+program UiFill {
+ numbers float
+ var rects: []string = new []string
+ var pics: []string = new []string
+ var clips: int = 0
+ const STYLE: string = ""
+ const BODY: string = "

"
+ function rec_rect(x: float, y: float, w: float, h: float, c: int, a: float) -> void { push(rects, `{int(x)},{int(y)} {int(w)}x{int(h)} {ui_hex(c)}`) }
+ function rec_image(src: string, x: float, y: float, w: float, h: float, c: int, a: float) -> void { push(pics, `{int(x)},{int(y)} {int(w)}x{int(h)}`) }
+ function rec_clip(x: float, y: float, w: float, h: float) -> void { clips += 1 }
+ function no_clip() -> void { }
+ function no_text(x: float, y: float, s: string, size: float, c: int, a: float) -> void { }
+ function nat_w(src: string) -> float { return 200.0 }
+ function nat_h(src: string) -> float { return 100.0 }
+ function knob_measure(n: UiNode, avail: float) -> void { }
+ function knob_draw(n: UiNode) -> void { }
+ entry {
+ let b = new UiBackend
+ b.rect = fn rec_rect
+ b.text = fn no_text
+ b.image = fn rec_image
+ b.image_w = fn nat_w
+ b.image_h = fn nat_h
+ b.clip = fn rec_clip
+ b.unclip = fn no_clip
+ ui_backend(b)
+ ui_native("knob", fn knob_measure, fn knob_draw)
+ ui_load_text("" + STYLE + BODY + "", "f.xml")
+ ui_show("f", null, 0.0, 0.0, 400.0, 600.0)
+ let r: UiNode = ui_nodes("f", null)
+ ui_place(r, 0.0, 0.0, 400.0, 600.0)
+ let n = len(rects)
+ print(`{rects[0]} .. {rects[49]} | {rects[50]} .. {rects[59]} .. {rects[69]} of {n} | {pics[0]} | {pics[1]} clipped {clips} | ratio {int(r.children[4].ch)} knob {int(r.children[5].children[0].cw)}`)
+ }
+}
diff --git a/packages/ludic.ui/attr.ludic b/packages/ludic.ui/attr.ludic
index fe70bfac..62510a58 100644
--- a/packages/ludic.ui/attr.ludic
+++ b/packages/ludic.ui/attr.ludic
@@ -49,7 +49,7 @@ 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 tpl_words(s) == "transparent" {
+ if gr_is(tpl_words(s)) { nx(n).grad = gr_parse(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 {
diff --git a/packages/ludic.ui/attr_edge.ludic b/packages/ludic.ui/attr_edge.ludic
index bb93a985..254e27f5 100644
--- a/packages/ludic.ui/attr_edge.ludic
+++ b/packages/ludic.ui/attr_edge.ludic
@@ -8,7 +8,7 @@ function el_edge(n: UiNode, key: string, v: Val) -> bool {
let parts = lss_split(s, 32)
if len(parts) > 0 { n.bar_c = ui_color(parts[0]) }
if len(parts) > 1 { n.bar_track = ui_color(parts[1]) }
- } else if key == "background-image" { n.bg_img = el_url(s) } else if key == "border-image" { el_border_image(n, s) } else if key == "box-shadow" { el_shadow(n, s) } else if key == "animation" { el_animation(n, s) } else if key == "transition" { n.tr_dur = el_secs(s) } else { return false }
+ } else if key == "background-image" and gr_is(tpl_words(s)) { nx(n).grad = gr_parse(s) } else if key == "background-image" { n.bg_img = el_url(s) } else if key == "border-image" { el_border_image(n, s) } else if key == "box-shadow" { el_shadow(n, s) } else if key == "animation" { el_animation(n, s) } else if key == "transition" { n.tr_dur = el_secs(s) } else { return false }
return true
}
# url(path) or url("path") -> path
diff --git a/packages/ludic.ui/attr_fit.ludic b/packages/ludic.ui/attr_fit.ludic
new file mode 100644
index 00000000..ddb88bdd
--- /dev/null
+++ b/packages/ludic.ui/attr_fit.ludic
@@ -0,0 +1,33 @@
+# 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
+function el_fit_props(n: UiNode, key: string, s: string) -> bool {
+ if key == "aspect-ratio" {
+ let parts = lss_split(s, 47)
+ var r = el_num(tpl_words(parts[0]))
+ if len(parts) > 1 and el_num(tpl_words(parts[1])) > 0.0 { r = r / el_num(tpl_words(parts[1])) }
+ if tpl_words(s) == "auto" { r = 0.0 }
+ nx(n).ratio = Math.max(r, 0.0)
+ } else if key == "object-fit" {
+ let w = tpl_words(s)
+ 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 }
+ return true
+}
+# "a, rgba(1, 2, 3, 0.5) 40%, b" -> its three parts
+function el_commas(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] == 44 and depth == 0 {
+ push(out, s[at..i])
+ at = i + 1
+ }
+ }
+ push(out, s[at..len(s)])
+ return out
+}
diff --git a/packages/ludic.ui/attr_more.ludic b/packages/ludic.ui/attr_more.ludic
index 88cf95c5..dbd4d887 100644
--- a/packages/ludic.ui/attr_more.ludic
+++ b/packages/ludic.ui/attr_more.ludic
@@ -2,7 +2,7 @@
# shorthand, flex-shrink, order, text-shadow, pointer-events, text-fit and line-height (text_fit.ludic)
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 if key == "pointer-events" { nx(n).no_pointer = tpl_words(s) == "none" } else if key == "text-fit" { el_text_fit(n, s) } else if key == "line-height" { el_line_h(n, s) } else { return false }
+ 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 if key == "pointer-events" { nx(n).no_pointer = tpl_words(s) == "none" } else if key == "text-fit" { el_text_fit(n, s) } else if key == "line-height" { el_line_h(n, s) } else { return el_fit_props(n, key, s) }
return true
}
# width or height: a length (0 included), fill, a percentage, calc(), or auto/fit (none)
diff --git a/packages/ludic.ui/backend.ludic b/packages/ludic.ui/backend.ludic
index e617033a..9334f941 100644
--- a/packages/ludic.ui/backend.ludic
+++ b/packages/ludic.ui/backend.ludic
@@ -8,6 +8,8 @@ export property UiBackend {
text: fn(float, float, string, float, int, float) -> void = null # x y text size rgb alpha
measure: fn(string, float) -> float = null
image: fn(string, float, float, float, float, int, float) -> void = null # src x y w h tint alpha
+ image_w: fn(string) -> float = null # a picture's own size, for object-fit
+ image_h: fn(string) -> float = null
nine: fn(string, float, float, float, float, float, float, int, float) -> void = null # src slice x y w h width tint alpha (ui_nine_cuts)
clip: fn(float, float, float, float) -> void = null
unclip: fn() -> void = null
diff --git a/packages/ludic.ui/draw.ludic b/packages/ludic.ui/draw.ludic
index 09cdcb27..1972ed90 100644
--- a/packages/ludic.ui/draw.ludic
+++ b/packages/ludic.ui/draw.ludic
@@ -38,6 +38,7 @@ function fr_background(n: UiNode) -> void {
}
if a > 0.0 { fr_fill(n, n.x, n.y, c, a) }
}
+ if n.ex != null and n.ex.grad != null { gr_draw(n.ex.grad, n.x, n.y, n.cw, n.ch, fr_ga) }
if n.bg_img != "" and ui_be.image != null { ui_be.image(n.bg_img, n.x, n.y, n.cw, n.ch, 16777215, fr_ga) }
}
function fr_fill(n: UiNode, x: float, y: float, c: int, a: float) -> void {
diff --git a/packages/ludic.ui/draw_content.ludic b/packages/ludic.ui/draw_content.ludic
index e4a6a837..a0defe3e 100644
--- a/packages/ludic.ui/draw_content.ludic
+++ b/packages/ludic.ui/draw_content.ludic
@@ -50,7 +50,17 @@ function fr_image(n: UiNode) -> void {
c = n.rfg
a = fr_ga * n.rfg_a
}
- ui_be.image(n.src, n.x + n.pl + n.bl, n.y + n.pt + n.bt, n.cw - ly_hin(n), n.ch - ly_vin(n), c, a)
+ var nat_w = 0.0
+ var nat_h = 0.0
+ if ui_be.image_w != null and ui_be.image_h != null {
+ nat_w = ui_be.image_w(n.src)
+ nat_h = ui_be.image_h(n.src)
+ }
+ let r = ui_object_fit(n, nat_w, nat_h)
+ let clipped = ui_be.clip != null and (r[2] > n.cw - ly_hin(n) or r[3] > n.ch - ly_vin(n))
+ if clipped { fr_clip_push(n.x + n.pl + n.bl, n.y + n.pt + n.bt, n.cw - ly_hin(n), n.ch - ly_vin(n)) }
+ ui_be.image(n.src, r[0], r[1], r[2], r[3], c, a)
+ if clipped { fr_clip_pop() }
}
# prefix:name, and not a path (C:/... is a drive, not an atlas)
function fr_is_cell(src: string) -> bool {
diff --git a/packages/ludic.ui/draw_gradient.ludic b/packages/ludic.ui/draw_gradient.ludic
new file mode 100644
index 00000000..d98fde69
--- /dev/null
+++ b/packages/ludic.ui/draw_gradient.ludic
@@ -0,0 +1,86 @@
+# draw_gradient.ludic - background: linear-gradient(to bottom, #102030, rgba(0,0,0,0) 80%): colour
+# stops (a stop without a place is spread evenly), along `to top|bottom|left|right` or an angle
+# (0deg is up, 90deg right, taken to the nearest side); drawn as whole-pixel bands, square-cornered
+export property UiGradient {
+ dir: int = 0 # 0 down, 1 up, 2 right, 3 left
+ cols: []int = null
+ alphas: []float = null
+ at: []float = null # each stop's place, 0 to 1
+}
+function gr_is(s: string) -> bool { return len(s) > 16 and s[0..16] == "linear-gradient(" }
+function gr_parse(s0: string) -> UiGradient {
+ let s = tpl_words(s0)
+ let parts = el_commas(s[16..len(s) - 1])
+ let g = new UiGradient
+ g.cols = new []int
+ g.alphas = new []float
+ g.at = new []float
+ var from = 0
+ if len(parts) > 0 and gr_dir(g, tpl_words(parts[0])) { from = 1 }
+ for i in from .. len(parts) {
+ let words = el_words(tpl_words(parts[i]))
+ var col = tpl_words(parts[i])
+ var at = -1.0
+ let last = words[len(words) - 1]
+ if len(words) > 1 and last[len(last) - 1] == 37 {
+ at = el_num(last) / 100.0
+ col = tpl_words(col[0..len(col) - len(last)])
+ }
+ push(g.cols, ui_color(col))
+ push(g.alphas, cl_alpha)
+ push(g.at, at)
+ }
+ for i in 0 .. len(g.at) {
+ if g.at[i] < 0.0 { g.at[i] = float(i) / float(Math.max(len(g.at) - 1, 1)) }
+ }
+ return g
+}
+function gr_dir(g: UiGradient, w: string) -> bool {
+ if w == "to bottom" { g.dir = 0 } else if w == "to top" { g.dir = 1 } else if w == "to right" { g.dir = 2 } else if w == "to left" { g.dir = 3 } else if len(w) > 3 and w[len(w) - 3..len(w)] == "deg" {
+ let d = int(Math.round(el_num(w) / 90.0)) % 4
+ g.dir = 1
+ if d == 1 or d == -3 { g.dir = 2 }
+ if d == 2 or d == -2 { g.dir = 0 }
+ if d == 3 or d == -1 { g.dir = 3 }
+ } else { return false }
+ return true
+}
+# the gradient over the box, a band every two pixels at most
+function gr_draw(g: UiGradient, x: float, y: float, w: float, h: float, a: float) -> void {
+ if ui_be.rect == null or len(g.cols) == 0 { return }
+ let across = g.dir >= 2
+ var span = h
+ if across { span = w }
+ let bands = Math.clamp(int(span / 2.0), 1, 64)
+ for i in 0 .. bands {
+ let e0 = Math.floor(span * float(i) / float(bands) + 0.5)
+ let e1 = Math.floor(span * float(i + 1) / float(bands) + 0.5)
+ var t = (e0 + e1) / 2.0 / span
+ if g.dir == 1 or g.dir == 3 { t = 1.0 - t }
+ let c = gr_at(g, t)
+ if across { ui_be.rect(x + e0, y, e1 - e0, h, c, gr_a * a) } else { ui_be.rect(x, y + e0, w, e1 - e0, c, gr_a * a) }
+ }
+}
+var gr_a: float = 1.0
+# the colour at `t` between the stops around it, its alpha in gr_a
+function gr_at(g: UiGradient, t: float) -> int {
+ var k = 0
+ while k + 1 < len(g.at) and g.at[k + 1] < t { k += 1 }
+ let j = Math.min(k + 1, len(g.at) - 1)
+ var f = 0.0
+ if g.at[j] > g.at[k] { f = Math.clamp((t - g.at[k]) / (g.at[j] - g.at[k]), 0.0, 1.0) }
+ if t <= g.at[0] { f = 0.0 }
+ gr_a = g.alphas[k] + (g.alphas[j] - g.alphas[k]) * f
+ return cl_mix(g.cols[k], g.cols[j], f)
+}
+function cl_mix(a: int, b: int, f: float) -> int {
+ var out = 0
+ var shift = 65536
+ for i in 0 .. 3 {
+ let ca = a / shift % 256
+ let cb = b / shift % 256
+ out = out + int(Math.round(float(ca) + float(cb - ca) * f)) * shift
+ shift = shift / 256
+ }
+ return out
+}
diff --git a/packages/ludic.ui/index.ludic b/packages/ludic.ui/index.ludic
index 0bc7eadc..fa45806c 100644
--- a/packages/ludic.ui/index.ludic
+++ b/packages/ludic.ui/index.ludic
@@ -51,6 +51,7 @@ import "attr_box.ludic"
import "attr_border.ludic"
import "attr_place.ludic"
import "attr_inset.ludic"
+import "attr_fit.ludic"
import "attr_edge.ludic"
import "attr_more.ludic"
import "calc.ludic"
@@ -74,6 +75,7 @@ import "layout_box.ludic"
import "layout_shrink.ludic"
import "layout_text.ludic"
import "text_fit.ludic"
+import "layout_fit.ludic"
import "layout_run.ludic"
import "layout_wrap.ludic"
import "frame.ludic"
@@ -85,4 +87,5 @@ import "draw.ludic"
import "draw_content.ludic"
import "draw_order.ludic"
import "draw_nine.ludic"
+import "draw_gradient.ludic"
import "dump.ludic"
diff --git a/packages/ludic.ui/layout.ludic b/packages/ludic.ui/layout.ludic
index d65aad05..23745069 100644
--- a/packages/ludic.ui/layout.ludic
+++ b/packages/ludic.ui/layout.ludic
@@ -38,6 +38,7 @@ function ly_measure(n: UiNode, avail: float, size: float, fg: int) -> void {
n.content_h = n.mh
if fixed_w >= 0.0 { n.mw = fixed_w }
if n.h >= 0.0 { n.mh = n.h }
+ ly_ratio(n, fixed_w, avail)
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)
diff --git a/packages/ludic.ui/layout_fit.ludic b/packages/ludic.ui/layout_fit.ludic
new file mode 100644
index 00000000..e6391826
--- /dev/null
+++ b/packages/ludic.ui/layout_fit.ludic
@@ -0,0 +1,38 @@
+# layout_fit.ludic - aspect-ratio in layout: a width (fixed, a share, or fill) makes the height, a
+# height the width, and neither the height from the content's width; object-fit in drawing: where a
+# picture (or a native's content) of a natural size goes in the element's content box
+function ly_ratio(n: UiNode, fixed_w: float, avail: float) -> void {
+ if n.ex == null or n.ex.ratio <= 0.0 { return }
+ let r = n.ex.ratio
+ if n.h < 0.0 and n.h_pct <= 0.0 {
+ var w = n.mw
+ if fixed_w >= 0.0 { w = fixed_w } else if n.w == UI_FILL { w = avail - n.ml - n.mr }
+ n.mw = w
+ n.mh = w / r
+ } else if fixed_w < 0.0 and n.w != UI_FILL and n.h >= 0.0 { n.mw = n.h * r }
+}
+# x, y, w, h: a `nat_w` x `nat_h` picture in the node's content box by its object-fit
+export function ui_object_fit(n: UiNode, nat_w: float, nat_h: float) -> []float {
+ let bx = n.x + n.pl + n.bl
+ let by = n.y + n.pt + n.bt
+ let bw = n.cw - ly_hin(n)
+ let bh = n.ch - ly_vin(n)
+ var k = 0
+ if n.ex != null { k = n.ex.fit_obj }
+ var w = bw
+ var h = bh
+ if k != 0 and nat_w > 0.0 and nat_h > 0.0 {
+ var s = Math.min(bw / nat_w, bh / nat_h)
+ if k == 2 { s = Math.max(bw / nat_w, bh / nat_h) }
+ if k == 3 { s = 1.0 }
+ if k == 4 { s = Math.min(s, 1.0) }
+ w = nat_w * s
+ h = nat_h * s
+ }
+ let out = new []float
+ push(out, bx + (bw - w) / 2.0)
+ push(out, by + (bh - h) / 2.0)
+ push(out, w)
+ push(out, h)
+ return out
+}
diff --git a/packages/ludic.ui/node_more.ludic b/packages/ludic.ui/node_more.ludic
index bbdc3c29..fa664376 100644
--- a/packages/ludic.ui/node_more.ludic
+++ b/packages/ludic.ui/node_more.ludic
@@ -26,6 +26,9 @@ export property UiNodeX {
ip_r: float = 0.0
ip_b: float = 0.0
ip_l: float = 0.0
+ 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
}
function nx(n: UiNode) -> UiNodeX {
if n.ex == null { n.ex = new UiNodeX }
diff --git a/packages/ludic.ui/render3d.ludic b/packages/ludic.ui/render3d.ludic
index 37712d6d..b9118251 100644
--- a/packages/ludic.ui/render3d.ludic
+++ b/packages/ludic.ui/render3d.ludic
@@ -31,6 +31,8 @@ export function ui_render3d() -> void {
b.text = fn r3u_text
b.measure = fn r3u_measure
b.image = fn r3u_image
+ b.image_w = fn r3u_image_w
+ b.image_h = fn r3u_image_h
b.nine = fn r3u_nine
b.clip = fn r3u_clip
b.unclip = fn r3u_unclip
diff --git a/packages/ludic.ui/render3d_image.ludic b/packages/ludic.ui/render3d_image.ludic
index c34349f1..8e17c56d 100644
--- a/packages/ludic.ui/render3d_image.ludic
+++ b/packages/ludic.ui/render3d_image.ludic
@@ -61,6 +61,22 @@ function r3u_image(src: string, x: float, y: float, w: float, h: float, c: int,
let t = r3u_tex(src)
if t != 0 { ov_sub(t, int(x), int(y), int(w), int(h), 0.0, 0.0, 1.0, 1.0, r3u_r(c), r3u_g(c), r3u_b(c), a) }
}
+# a picture's own size, for object-fit; an atlas cell is drawn square
+function r3u_image_w(src: string) -> float {
+ if r3u_is_cell(src) { return 1.0 }
+ return float(tex_width(r3u_tex(src)))
+}
+function r3u_image_h(src: string) -> float {
+ if r3u_is_cell(src) { return 1.0 }
+ return float(tex_height(r3u_tex(src)))
+}
+function r3u_is_cell(src: string) -> bool {
+ for k in 0 .. len(r3u_atlas) {
+ let p = r3u_atlas[k]
+ if len(src) > len(p) and src[0..len(p)] == p and src[len(p)] == 58 { return true }
+ }
+ return false
+}
function r3u_cell(k: int, name: string, x: float, y: float, w: float, h: float, c: int, a: float) -> void {
let names = r3u_atlas_names[k]
var idx = -1
diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic
index 74a68beb..c70898c3 100644
--- a/tools/ludic-cli/test.ludic
+++ b/tools/ludic-cli/test.ludic
@@ -770,6 +770,7 @@ function cmd_dev_test() -> int {
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_nine", "", "cap 0 12 18 30/ 0 8 8 16 | wide 0 12 88 100/ 0 12 28 40 | odd 10 21 21 31/ 4 8 8 13", "ui_nine.ludic (L11: a nine-slice's corners clamped to half the box each way, on whole pixels)")
controller_case("library/ui_scroll_hold", "", "120 50 0 top 120 | 80 50 0 top 80 | 80 50 0 top 80 | 80 50 70 top 80 | 80 50 30 top 80", "ui_scroll_hold.ludic (L11: scroll-top holds a box at an offset, on-scroll follows the wheel, ui_scroll_set moves one once)")
+ controller_case("library/ui_fill", "", "0,0 2x20 #030303 .. 98,0 2x20 #fcfcfc | 0,20 50x2 #f2000d .. 0,38 50x2 #0d00f2 .. 0,58 50x2 #0000ff of 70 | 0,85 100x50 | -50,160 200x100 clipped 1 | ratio 90 knob 60", "ui_fill.ludic (L11: linear-gradient backgrounds, object-fit contain and cover, aspect-ratio for boxes and natives)")
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)")