ludic.ui: splits, words, gradients and keys are made once per text (a bounded memo), sides without a list - benchmark 110 -> 63 KB a frame

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-27 23:38:57 +03:00
parent 978f128755
commit 1d40f6d95a
18 changed files with 172 additions and 41 deletions

View file

@ -55,7 +55,7 @@ function an_apply(ui_st: mut UiState, n: UiNode) -> void {
ui_err(ui_st, `animation {n.anim}: there is no @keyframes of that name`)
return
}
var t = (an_now(ui_st) - an_began(ui_st, `{n.key}@{n.anim}`)) / n.anim_dur
var t = (an_now(ui_st) - an_began(ui_st, mm_join(ui_st, n.key, "@", n.anim))) / n.anim_dur
if n.anim_loop {
let lap = int(t)
t = t - float(lap)

View file

@ -16,7 +16,7 @@ function el_apply(ui_st: mut UiState, n: UiNode, key: string, v0: Val) -> bool {
return true
}
let v = cs_vars_in(ui_st, n, v0)
return el_size(ui_st, n, k, v) or el_box(ui_st, n, k, v) or el_flex(n, k, v) or el_look(ui_st, n, k, v) or el_place_prop(ui_st, n, k, v) or el_edge(ui_st, n, k, v) or el_more(ui_st, n, k, v)
return el_size(ui_st, n, k, v) or el_box(ui_st, n, k, v) or el_flex(ui_st, n, k, v) or el_look(ui_st, n, k, v) or el_place_prop(ui_st, n, k, v) or el_edge(ui_st, n, k, v) or el_more(ui_st, n, k, v)
}
function at_alias(key: string) -> string {
# the CSS name to the short one; literals, so asking allocates nothing (asked per rule per element)
@ -47,12 +47,12 @@ function el_size(ui_st: mut UiState, n: UiNode, key: string, v: Val) -> bool {
if key == "w" or key == "h" { el_dim(ui_st, n, key == "w", v) } else if key == "min-w" { n.min_w = el_len(ui_st, v) } else if key == "max-w" { n.max_w = el_len(ui_st, v) } else if key == "min-h" { n.min_h = el_len(ui_st, v) } else if key == "max-h" { n.max_h = el_len(ui_st, v) } else if key == "gap" { n.gap = el_len(ui_st, v) } else { return false }
return true
}
function el_flex(n: UiNode, key: string, v: Val) -> bool {
function el_flex(ui_st: mut UiState, n: UiNode, key: string, v: Val) -> bool {
let s = vl_text(v)
if key == "dir" {
n.dir = UI_COL
if s == "row" { n.dir = UI_ROW }
} 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 }
} else if key == "grow" { n.grow = el_num(s) } else if key == "flex" { el_flex_short(ui_st, 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(ui_st: mut UiState, n: UiNode, key: string, v: Val) -> bool {
@ -76,7 +76,7 @@ function el_look(ui_st: mut UiState, n: UiNode, key: string, v: Val) -> bool {
}
# border: 1 #ffcc00, or 1px solid accent
function el_border(ui_st: mut UiState, n: UiNode, s: string) -> void {
let parts = lss_split(s, 32)
let parts = mm_split(ui_st, s, 32)
for i in 0 .. len(parts) {
let p = parts[i]
if len(p) > 0 and ((p[0] >= 48 and p[0] <= 57) or p[0] == 46) { el_sides_border(n, el_unit(ui_st, p)) } else if p == "none" { el_sides_border(n, 0.0) } else if len(p) > 0 and p != "solid" {

View file

@ -18,20 +18,27 @@ function el_box(ui_st: mut UiState, n: UiNode, key: string, v: Val) -> bool {
}
# top right bottom left out of 1 to 4 lengths
function el_sides(ui_st: mut UiState, v: Val) -> void {
let parts = new []float
if vl_num(v) { push(parts, el_px(ui_st, Value.as_float(v))) } else {
let words = el_words(vl_text(v))
var n = 0
if vl_num(v) {
ui_st.el_s0 = el_px(ui_st, Value.as_float(v))
n = 1
} else {
let words = mm_words(ui_st, vl_text(v))
for i in 0 .. len(words) {
if len(words[i]) > 0 { push(parts, el_word_len(ui_st, words[i])) }
if len(words[i]) > 0 and n < 4 {
el_side_set(ui_st, n, el_word_len(ui_st, words[i]))
n += 1
}
}
}
while len(parts) < 4 {
if len(parts) == 0 { push(parts, 0.0) } else if len(parts) == 1 { push(parts, parts[0]) } else if len(parts) == 2 { push(parts, parts[0]) } else { push(parts, parts[1]) }
}
ui_st.el_s0 = parts[0]
ui_st.el_s1 = parts[1]
ui_st.el_s2 = parts[2]
ui_st.el_s3 = parts[3]
if n == 0 { ui_st.el_s0 = 0.0 }
if n < 2 { ui_st.el_s1 = ui_st.el_s0 }
if n < 3 { ui_st.el_s2 = ui_st.el_s0 }
if n < 4 { ui_st.el_s3 = ui_st.el_s1 }
}
# CSS's one to four sides, top right bottom left, without a list made for them
function el_side_set(ui_st: mut UiState, i: int, v: float) -> void {
if i == 0 { ui_st.el_s0 = v } else if i == 1 { ui_st.el_s1 = v } else if i == 2 { ui_st.el_s2 = v } else { ui_st.el_s3 = v }
}
# a length: a number, 10px, 1.5em, 2rem, 50vw, calc(...), fill, fit or auto (a percentage is el_pct's)
function el_len(ui_st: mut UiState, v: Val) -> float {

View file

@ -5,10 +5,10 @@ function el_edge(ui_st: mut UiState, n: UiNode, key: string, v: Val) -> bool {
n.outline_c = ui_color(ui_st, s)
n.outline_a = ui_st.cl_alpha
} else if key == "scrollbar-color" {
let parts = lss_split(s, 32)
let parts = mm_split(ui_st, s, 32)
if len(parts) > 0 { n.bar_c = ui_color(ui_st, parts[0]) }
if len(parts) > 1 { n.bar_track = ui_color(ui_st, parts[1]) }
} else if key == "background-image" and gr_is(tpl_words(s)) { nx(n).grad = gr_parse(ui_st, s) } else if key == "background-image" { n.bg_img = el_url(s) } else if key == "border-image" { el_border_image(ui_st, n, s) } else if key == "box-shadow" { el_shadow(ui_st, 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(ui_st, s) } else if key == "background-image" { n.bg_img = el_url(s) } else if key == "border-image" { el_border_image(ui_st, n, s) } else if key == "box-shadow" { el_shadow(ui_st, n, s) } else if key == "animation" { el_animation(ui_st, n, s) } else if key == "transition" { n.tr_dur = el_secs(s) } else { return false }
return true
}
# url(path) or url("path") -> path
@ -20,11 +20,11 @@ function el_url(s: string) -> string {
return t
}
# border-image: url(chip.png) 14 / 12px - the source's corner size, then how wide they are drawn
function el_border_image(ui_st: UiState, n: UiNode, s: string) -> void {
function el_border_image(ui_st: mut UiState, n: UiNode, s: string) -> void {
var close = 0
while close < len(s) and s[close] != 41 { close += 1 }
n.bi_src = el_url(s[0..Math.min(close + 1, len(s))])
let rest = lss_split(s[Math.min(close + 1, len(s))..len(s)], 47)
let rest = mm_split(ui_st, s[Math.min(close + 1, len(s))..len(s)], 47)
n.bi_slice = el_num(tpl_words(rest[0]))
n.bi_width = el_px(ui_st, n.bi_slice)
if len(rest) > 1 { n.bi_width = el_unit(ui_st, tpl_words(rest[1])) }
@ -35,7 +35,7 @@ function el_shadow(ui_st: mut UiState, n: UiNode, s: string) -> void {
n.sh_c = -2
return
}
let parts = lss_split(s, 32)
let parts = mm_split(ui_st, s, 32)
var nums = 0
var col = ""
for i in 0 .. len(parts) {
@ -53,7 +53,7 @@ function el_shadow(ui_st: mut UiState, n: UiNode, s: string) -> void {
}
# outline: 2px solid #ffcc00, or none
function el_outline(ui_st: mut UiState, n: UiNode, s: string) -> void {
let parts = lss_split(s, 32)
let parts = mm_split(ui_st, s, 32)
n.outline_w = 0.0
for i in 0 .. len(parts) {
let p = parts[i]
@ -64,8 +64,8 @@ function el_outline(ui_st: mut UiState, n: UiNode, s: string) -> void {
}
}
# animation: fade 0.25s infinite alternate
function el_animation(n: UiNode, s: string) -> void {
let parts = lss_split(s, 32)
function el_animation(ui_st: mut UiState, n: UiNode, s: string) -> void {
let parts = mm_split(ui_st, s, 32)
n.anim = ""
for i in 0 .. len(parts) {
let p = parts[i]

View file

@ -2,7 +2,7 @@
# for pictures and natives, zoom (zoom.ludic), and a list split at its top-level commas
function el_fit_props(ui_st: mut UiState, n: UiNode, key: string, s: string) -> bool {
if key == "aspect-ratio" {
let parts = lss_split(s, 47)
let parts = mm_split(ui_st, 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 }

View file

@ -40,8 +40,8 @@ function el_calc_len(ui_st: mut UiState, s: string) -> float {
return ui_st.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)
function el_flex_short(ui_st: mut UiState, n: UiNode, s: string) -> void {
let parts = mm_words(ui_st, s)
if s == "none" {
n.grow = 0.0
nx(n).shrink = 0.0
@ -72,7 +72,7 @@ function el_text_shadow(ui_st: mut UiState, n: UiNode, s: string) -> void {
x.tsh_set = true
x.tsh_c = -2
if tpl_words(s) == "none" { return }
let parts = el_words(s)
let parts = mm_words(ui_st, s)
var nums = 0
var col = ""
for i in 0 .. len(parts) {

View file

@ -34,6 +34,8 @@ function ui_default_backend() -> UiBackend {
export state UiState {
pl_pools: []UiPool = new []UiPool # pool.ludic: each screen's nodes and envs, used again
pl_cur: UiPool = null
mm: UiMemo = null # memo.ludic: splits, gradients and keys by their text
mm_ints: []string = null
an_frames: []UiKeyframes = new []UiKeyframes
an_keys: []string = new []string
an_start: []float = new []float

View file

@ -8,7 +8,7 @@ function bd_kids(ui_st: mut UiState, kids: []UiTpl, e0: UiEnv, key: string, out:
for i in 0 .. len(kids) {
let k = kids[i]
ui_st.ui_at = k.where
let ck = `{key}/{i}`
let ck = mm_join(ui_st, key, "/", mm_int(ui_st, i))
if k.tag == "state" or k.tag == "style" { continue }
if k.tag == "let" {
e = bd_let(ui_st, k, e)
@ -50,9 +50,9 @@ function bd_each(ui_st: mut UiState, k: UiTpl, e: UiEnv, key: string, out: []UiN
let le = env_new(ui_st, e)
env_bind(le, as_name, Value.at(list, j))
env_bind(le, at_name, Value.int(j))
var id = string(j)
var id = mm_int(ui_st, j)
if by != null { id = ui_eval_text(ui_st, by, le) }
bd_kids(ui_st, k.kids, le, `{key}#{id}`, out)
bd_kids(ui_st, k.kids, le, mm_join(ui_st, key, "#", id), out)
}
}
# <slot/> is what the component's user put inside it; <slot name="x"/> their <template slot="x">

View file

@ -32,7 +32,7 @@ function bd_class(ui_st: mut UiState, k: UiTpl, e: UiEnv, key: string, out: []Ui
function bd_pass_down(ui_st: mut UiState, n: UiNode, k: UiTpl, e: UiEnv) -> void {
let c = tpl_attr(k, "class")
if c != null {
let parts = lss_split(ui_eval_text(ui_st, c, e), 32)
let parts = mm_split(ui_st, ui_eval_text(ui_st, c, e), 32)
for i in 0 .. len(parts) {
if len(parts[i]) > 0 { push(n.classes, parts[i]) }
}

View file

@ -36,7 +36,7 @@ function cl_hex_at(ui_st: mut UiState, s: string, at: int, n: int) -> int {
function cl_rgb(ui_st: mut UiState, s: string) -> int {
var open = 0
while open < len(s) and s[open] != 40 { open += 1 }
let parts = lss_split(s[open + 1..len(s) - 1], 44)
let parts = mm_split(ui_st, s[open + 1..len(s) - 1], 44)
var c = 0
for i in 0 .. Math.min(3, len(parts)) { c = c * 256 + Math.clamp(int(el_num(tpl_words(parts[i]))), 0, 255) }
if len(parts) > 3 { ui_st.cl_alpha = el_num(tpl_words(parts[3])) }

View file

@ -6,7 +6,7 @@
# with a note="...", the label is .ui-labels > .ui-label + .ui-note
function ct_part(ui_st: mut UiState, n: UiNode, cls: string, text: string) -> UiNode {
let p = nd_take(ui_st)
p.key = `{n.key}/{cls}`
p.key = mm_join(ui_st, n.key, "/", cls)
p.tag = "span"
p.env = n.env
p.sheet = n.sheet

View file

@ -8,7 +8,17 @@ export property UiGradient {
at: []float = null # each stop's place, 0 to 1
}
function gr_is(s: string) -> bool { return len(s) > 16 and sat(s, 0, "linear-gradient(") }
# a gradient is parsed once per text and shared by every element that wears it - never changed after
function gr_parse(ui_st: mut UiState, s0: string) -> UiGradient {
let m = mm_of(ui_st)
let i = mm_find(m, MM_GRAD, s0)
if i >= 0 { return m.grads[i] }
let g = gr_parse_new(ui_st, s0)
let j = mm_add(m, MM_GRAD, s0)
if j >= 0 { m.grads[j] = g }
return g
}
function gr_parse_new(ui_st: mut UiState, s0: string) -> UiGradient {
let s = tpl_words(s0)
let parts = el_commas(s[16..len(s) - 1])
let g = new UiGradient
@ -18,7 +28,7 @@ function gr_parse(ui_st: mut UiState, s0: string) -> UiGradient {
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]))
let words = mm_words(ui_st, tpl_words(parts[i]))
var col = tpl_words(parts[i])
var at = -1.0
let last = words[len(words) - 1]

View file

@ -10,6 +10,8 @@ import "backend.ludic"
import "pool.ludic"
import "pool_reset.ludic"
import "pool_reset_more.ludic"
import "memo.ludic"
import "memo_join.ludic"
import "expr.ludic"
import "expr_parse.ludic"
import "expr_prim.ludic"

View file

@ -1,6 +1,6 @@
# layout_text.ludic - a text's size: one line, or as many as it takes to fit its room (white-space
# normal), breaking between words; nowrap keeps one line, and text-overflow: ellipsis cuts it
function ly_measure_text(ui_st: UiState, n: UiNode, avail: float) -> void {
function ly_measure_text(ui_st: mut UiState, n: UiNode, avail: float) -> void {
n.lines = null
let lh = ly_line_h(n)
var one = ly_text_w(ui_st, n.text, n.rsize)
@ -19,8 +19,8 @@ function ly_measure_text(ui_st: UiState, n: UiNode, avail: float) -> void {
n.mw = n.mw + widest
n.mh = n.mh + lh * float(len(n.lines))
}
function ly_wrap(ui_st: UiState, s: string, size: float, room: float) -> []string {
let words = lss_split(s, 32)
function ly_wrap(ui_st: mut UiState, s: string, size: float, room: float) -> []string {
let words = mm_split(ui_st, s, 32)
let out = new []string
var line = ""
for i in 0 .. len(words) {

View file

@ -0,0 +1,68 @@
# memo.ludic - what a frame would work out again from the same text is kept by that text: a value
# split into its words, a gradient, a key joined from its parent's. Ludic frees nothing, so parsing
# "2px 6px" per element per frame was a leak; the table is bounded, and past it a result is made
# (and lost) as before rather than the table growing without end.
const MM_CAP: int = 8192 # slots; at most three quarters are used
export property UiMemo {
slots: words = null # index + 1 into keys, 0 empty
keys: []string = null # the text a result was made from (or the joined text itself)
tags: []int = null # what kind of result: a separator, or a kind below
lists: [][]string = null
grads: []UiGradient = null
}
const MM_WORDS: int = -1 # el_words: spaces, a bracket's own kept
const MM_GRAD: int = -2
const MM_JOIN: int = -3 # keys[i] is the joined text itself
function mm_new() -> UiMemo {
let m = new UiMemo
m.slots = words(MM_CAP)
m.keys = new []string
m.tags = new []int
m.lists = new [][]string
m.grads = new []UiGradient
return m
}
function mm_of(ui_st: mut UiState) -> UiMemo {
if ui_st.mm == null { ui_st.mm = mm_new() }
return ui_st.mm
}
# FNV-1a over the text, the tag mixed in first
function mm_hash(tag: int, s: string) -> int {
var h = 216613626 ^ (tag & 255)
for i in 0 .. len(s) { h = ((h ^ s[i]) * 16777619) & 2147483647 }
return h
}
# the entry for (tag, s), or -1
function mm_find(m: UiMemo, tag: int, s: string) -> int {
var at = mm_hash(tag, s) & (MM_CAP - 1)
while m.slots[at] != 0 {
let i = m.slots[at] - 1
if m.tags[i] == tag and m.keys[i] == s { return i }
at = (at + 1) & (MM_CAP - 1)
}
return -1
}
# a new entry for (tag, s) with its results empty, or -1 when the table is full
function mm_add(m: UiMemo, tag: int, s: string) -> int {
if len(m.keys) * 4 >= MM_CAP * 3 { return -1 }
var at = mm_hash(tag, s) & (MM_CAP - 1)
while m.slots[at] != 0 { at = (at + 1) & (MM_CAP - 1) }
push(m.keys, s)
push(m.tags, tag)
push(m.lists, null)
push(m.grads, null)
m.slots[at] = len(m.keys)
return len(m.keys) - 1
}
# lss_split's parts of `s` at `c`, made once per text; the list is shared - read it, never change it
function mm_split(ui_st: mut UiState, s: string, c: int) -> []string {
let m = mm_of(ui_st)
let i = mm_find(m, c, s)
if i >= 0 { return m.lists[i] }
var parts: []string = null
if c == MM_WORDS { parts = el_words(s) } else { parts = lss_split(s, c) }
let j = mm_add(m, c, s)
if j >= 0 { m.lists[j] = parts }
return parts
}
function mm_words(ui_st: mut UiState, s: string) -> []string { return mm_split(ui_st, s, MM_WORDS) }

View file

@ -0,0 +1,42 @@
# memo_join.ludic - a key joined from its parent's key, a separator and a part ("App/3",
# "App/3#7", "App/3/ui-thumb"), found by comparing in place, so an element's key is made once
# the element first appears and never again
function mm_hash_join(a: string, sep: string, b: string) -> int {
var h = 216613626 ^ (MM_JOIN & 255)
for i in 0 .. len(a) { h = ((h ^ a[i]) * 16777619) & 2147483647 }
h = ((h ^ sep[0]) * 16777619) & 2147483647
for i in 0 .. len(b) { h = ((h ^ b[i]) * 16777619) & 2147483647 }
return h
}
function mm_is_join(k: string, a: string, sep: string, b: string) -> bool {
let na = len(a)
if len(k) != na + 1 + len(b) or k[na] != sep[0] { return false }
return sat(k, 0, a) and sat(k, na + 1, b)
}
# a + sep + b, the same string every frame
function mm_join(ui_st: mut UiState, a: string, sep: string, b: string) -> string {
let m = mm_of(ui_st)
var at = mm_hash_join(a, sep, b) & (MM_CAP - 1)
while m.slots[at] != 0 {
let i = m.slots[at] - 1
if m.tags[i] == MM_JOIN and mm_is_join(m.keys[i], a, sep, b) { return m.keys[i] }
at = (at + 1) & (MM_CAP - 1)
}
let k = a + sep + b
if len(m.keys) * 4 >= MM_CAP * 3 { return k }
push(m.keys, k)
push(m.tags, MM_JOIN)
push(m.lists, null)
push(m.grads, null)
m.slots[at] = len(m.keys)
return k
}
# a small whole number's text, made once (a key's index, a loop's count)
function mm_int(ui_st: mut UiState, i: int) -> string {
if i < 0 or i >= 1024 { return string(i) }
if ui_st.mm_ints == null {
ui_st.mm_ints = new []string
for k in 0 .. 1024 { push(ui_st.mm_ints, string(k)) }
}
return ui_st.mm_ints[i]
}

View file

@ -1,8 +1,8 @@
# text_fit.ludic - text-fit: shrink 12px draws a line that does not fit its box smaller, down to that
# size, and cuts what still does not fit with an ellipsis; line-height (a multiple of the font size,
# px or em) spaces a text's lines. Both are inherited, as font properties are.
function el_text_fit(ui_st: UiState, n: UiNode, s: string) -> void {
let parts = lss_split(tpl_words(s), 32)
function el_text_fit(ui_st: mut UiState, n: UiNode, s: string) -> void {
let parts = mm_split(ui_st, tpl_words(s), 32)
nx(n).fit_min = 0.0
if len(parts) >= 2 and parts[0] == "shrink" { nx(n).fit_min = Math.max(el_unit(ui_st, parts[1]), 1.0) }
}

View file

@ -73,7 +73,7 @@ function cs_vars_in(ui_st: mut UiState, n: UiNode, v: Val) -> Val {
return Value.str(tpl_words(out + s[at..len(s)]))
}
function cs_var_ref(ui_st: mut UiState, n: UiNode, inner: string) -> string {
let parts = lss_split(inner, 44)
let parts = mm_split(ui_st, inner, 44)
let v = cs_var(n, tpl_words(parts[0]))
if v != null { return v }
if len(parts) > 1 { return tpl_words(inner[len(parts[0]) + 1..len(inner)]) }