feat(ui): linear-gradient backgrounds, object-fit and aspect-ratio

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 22:02:49 +03:00
parent abf45ef9db
commit 7605a0253c
18 changed files with 248 additions and 4 deletions

View file

@ -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

View file

@ -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).

View file

@ -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 = "<style>.g { width: 100px; height: 20px; background: linear-gradient(to right, #000000, #ffffff) }\n.h { width: 50px; height: 40px; background-image: linear-gradient(180deg, #ff0000 0%, #0000ff 50%, #0000ff) }\nimg { width: 100px; height: 100px }\n.contain { object-fit: contain }\n.cover { object-fit: cover }\n.r { width: 160px; aspect-ratio: 16 / 9 }\nknob { height: 30px; aspect-ratio: 2 }</style>"
const BODY: string = "<screen name='f'><div class='g'/><div class='h'/><img class='contain' src='wide.png'/><img class='cover' src='wide.png'/><div class='r'/><row><knob/></row></screen>"
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("<ui>" + STYLE + BODY + "</ui>", "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)}`)
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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
}

View file

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

View file

@ -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

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}

View file

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

View file

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

View file

@ -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
}

View file

@ -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 }

View file

@ -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

View file

@ -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

View file

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