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)")