feat(ui): text-fit, line-height, and ems against the font size an element ends with

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 21:51:41 +03:00
parent d163bad406
commit 355201f094
16 changed files with 149 additions and 21 deletions

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-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 "
const AT_UNDRAWN: string = " font-weight font-family font-style box-shadow cursor transition animation 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 }

View file

@ -1,8 +1,8 @@
# attr_more.ludic - width and height (where 0 is 0 and calc() may hold a percentage), the flex
# shorthand, flex-shrink, order, text-shadow and pointer-events
# 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 { 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 false }
return true
}
# width or height: a length (0 included), fill, a percentage, calc(), or auto/fit (none)

View file

@ -16,7 +16,16 @@ function cs_tree(n: UiNode) -> void {
n.children = shown
cs_order(n)
}
# twice: the font size first, from every rule, so an em anywhere reads the size the element ends with
function cs_node(n: UiNode) -> void {
cs_pass = 1
cs_styles(n)
cs_pass = 0
cs_styles(n)
an_apply(n)
tr_apply(n)
}
function cs_styles(n: UiNode) -> void {
cs_sheet(n, ua_sheet())
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) }
@ -25,10 +34,12 @@ function cs_node(n: UiNode) -> void {
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)
for i in 0 .. len(n.akeys) {
if cs_pass_ok(n.akeys[i]) { el_apply(n, n.akeys[i], n.vals[i]) }
}
}
var cs_pass: int = 0
function cs_pass_ok(key: string) -> bool { return cs_pass == 0 or at_alias(key) == "size" }
function cs_sheet(n: UiNode, sh: UiSheet) -> void {
for i in 0 .. len(sh.rules) {
let r = sh.rules[i]
@ -42,7 +53,7 @@ function cs_rule_in(n: UiNode, r: UiRule, e: UiEnv) -> void {
if len(r.keys[j]) > 2 and r.keys[j][0..2] == "--" { el_attr(n, r.keys[j], ui_eval(r.vals[j], e)) }
}
for j in 0 .. len(r.keys) {
if not (len(r.keys[j]) > 2 and r.keys[j][0..2] == "--") { el_attr(n, r.keys[j], ui_eval(r.vals[j], e)) }
if not (len(r.keys[j]) > 2 and r.keys[j][0..2] == "--") and cs_pass_ok(r.keys[j]) { el_attr(n, r.keys[j], ui_eval(r.vals[j], e)) }
}
}
var ua_sh: UiSheet = null

View file

@ -2,21 +2,22 @@
# text-shadow, and a picture
# a text's lines (one, or the wrapped ones), each placed by text-align, the last cut by an ellipsis
function fr_text(n: UiNode) -> void {
let y = n.y + n.pt + n.bt
let lh = ly_line_h(n)
let y = n.y + n.pt + n.bt + (lh - n.rsize * 1.25) / 2.0
let a = n.rfg_a * fr_ga
let sh = fr_text_shadow(n)
if n.lines == null {
var s = n.text
if n.ellipsis { s = ly_ellipsis(s, n.rsize, n.cw - ly_hin(n)) }
fr_line(n, sh, fr_line_x(n, s), y, s, a)
var s = tf_fit(n, n.text)
if n.ellipsis { s = ly_ellipsis(s, tf_size, n.cw - ly_hin(n)) }
fr_line(n, sh, fr_line_x(n, s, tf_size), y, s, a, tf_size)
return
}
for i in 0 .. len(n.lines) { fr_line(n, sh, fr_line_x(n, n.lines[i]), y + float(i) * n.rsize * 1.25, n.lines[i], a) }
for i in 0 .. len(n.lines) { fr_line(n, sh, fr_line_x(n, n.lines[i], n.rsize), y + float(i) * lh, n.lines[i], a, n.rsize) }
}
# one line, over its shadow when it has one
function fr_line(n: UiNode, sh: UiNodeX, x: float, y: float, s: string, a: float) -> void {
if sh != null { ui_be.text(x + sh.tsh_x, y + sh.tsh_y, s, n.rsize, sh.tsh_c, sh.tsh_a * fr_ga) }
ui_be.text(x, y, s, n.rsize, n.rfg, a)
function fr_line(n: UiNode, sh: UiNodeX, x: float, y: float, s: string, a: float, size: float) -> void {
if sh != null { ui_be.text(x + sh.tsh_x, y + sh.tsh_y, s, size, sh.tsh_c, sh.tsh_a * fr_ga) }
ui_be.text(x, y, s, size, n.rfg, a)
}
# text-shadow is inherited: the nearest element that says it, or null (none)
function fr_text_shadow(n: UiNode) -> UiNodeX {
@ -33,10 +34,10 @@ function fr_text_shadow(n: UiNode) -> UiNodeX {
# the opacity a native's draw is at: every group opacity around it, its own included
export function ui_opacity() -> float { return fr_ga }
# where a line starts: text-align within the room the box gives it
function fr_line_x(n: UiNode, s: string) -> float {
function fr_line_x(n: UiNode, s: string, size: float) -> float {
let x = n.x + n.pl + n.bl
if n.talign == UI_START { return x }
let spare = n.cw - ly_hin(n) - ly_text_w(s, n.rsize)
let spare = n.cw - ly_hin(n) - ly_text_w(s, size)
if n.talign == UI_CENTER { return x + spare / 2.0 }
return x + spare
}

View file

@ -70,6 +70,7 @@ import "layout.ludic"
import "layout_box.ludic"
import "layout_shrink.ludic"
import "layout_text.ludic"
import "text_fit.ludic"
import "layout_run.ludic"
import "layout_wrap.ludic"
import "frame.ludic"

View file

@ -2,11 +2,13 @@
# normal), breaking between words; nowrap keeps one line, and text-overflow: ellipsis cuts it
function ly_measure_text(n: UiNode, avail: float) -> void {
n.lines = null
let lh = n.rsize * 1.25
let one = ly_text_w(n.text, n.rsize)
let lh = ly_line_h(n)
var 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.nowrap or room <= 0.0 or one <= room {
let fits = n.ex != null and n.ex.fit_min > 0.0
if fits and room > 0.0 { one = Math.min(one, room) }
if n.nowrap or fits or room <= 0.0 or one <= room {
n.mw = n.mw + one
n.mh = n.mh + lh
return

View file

@ -19,6 +19,9 @@ export property UiNodeX {
tsh_c: int = -2
tsh_a: float = 1.0
no_pointer: bool = false # pointer-events: none - the pointer goes through it
fit_min: float = 0.0 # text-fit: shrink MIN - the smallest a line is shrunk to fit its box
lh_kind: int = 0 # line-height: 0 normal, 1 a multiple of the font size, 2 px
lh_v: float = 0.0
}
function nx(n: UiNode) -> UiNodeX {
if n.ex == null { n.ex = new UiNodeX }

View file

@ -0,0 +1,52 @@
# 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.
var tf_size: float = 0.0 # the size tf_fit drew at
function el_text_fit(n: UiNode, s: string) -> void {
let parts = lss_split(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(parts[1]), 1.0) }
}
function el_line_h(n: UiNode, s0: string) -> void {
let s = tpl_words(s0)
let x = nx(n)
x.lh_kind = 0
if s == "normal" or s == "" { return }
let c = s[len(s) - 1]
if c >= 48 and c <= 57 or c == 46 {
x.lh_kind = 1
x.lh_v = el_num(s)
return
}
x.lh_kind = 2
x.lh_v = el_unit(s)
}
# the nearest element, out from this one, that says `what` (0 text-fit, 1 line-height), or null
function tf_up(n: UiNode, what: int) -> UiNodeX {
var at = n
while at != null {
if at.ex != null and what == 0 and at.ex.fit_min > 0.0 { return at.ex }
if at.ex != null and what == 1 and at.ex.lh_kind > 0 { return at.ex }
at = at.parent
}
return null
}
function ly_line_h(n: UiNode) -> float {
let x = tf_up(n, 1)
if x == null { return n.rsize * 1.25 }
if x.lh_kind == 1 { return n.rsize * x.lh_v }
return x.lh_v
}
# one line to fit its box: at its own size if it does, else smaller down to text-fit's least, then
# cut; tf_size says the size it is drawn at
function tf_fit(n: UiNode, s: string) -> string {
tf_size = n.rsize
let x = tf_up(n, 0)
let room = n.cw - ly_hin(n)
if x == null or room <= 0.0 { return s }
let w = ly_text_w(s, tf_size)
if w <= room { return s }
tf_size = Math.max(x.fit_min, n.rsize * room / w)
while tf_size > x.fit_min and ly_text_w(s, tf_size) > room { tf_size = Math.max(x.fit_min, tf_size - 0.5) }
return ly_ellipsis(s, tf_size, room)
}