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

@ -530,7 +530,13 @@ applies to both:
is drawn as painted, as CSS draws one; with one it is tinted by it, so one rounded texture serves
every colour, and `transparent` (or any colour with no alpha) leaves nothing to see.
- Text: `text-shadow: x y blur colour` draws the text again under itself, offset (the blur is
drawn sharp), and is inherited.
drawn sharp), and is inherited. `text-fit: shrink 12px` draws a line too long for its box
smaller, down to that size, then cuts what still does not fit with an ellipsis (for a chip
whose words may be long in another language). `line-height` is a multiple of the font size
(`1.5`), px or em. Both are inherited.
- `em` is the font size the element ends with, wherever its `font-size` is set among the rules
that style it; in `font-size` itself it is the parent's. A sheet's own rule wins a tie with a
rule it `@import`s.
- Pictures: an `<img>` of a file is drawn as it is; a cell of an atlas is a glyph and takes the
`color` around it, as text does. An `<img>` with a `color` of its own is tinted by it.
- Motion: `opacity` fades the element and everything inside it. `@keyframes` with `animation:

View file

@ -89,6 +89,9 @@ What a game's screens found missing, now in `ludic.ui`:
there rather than whenever the pointer is down over it;
- an anchored popover's `align="start|center|end"`, and `within="id"` (by default the nearest
scroll box around it) for the bounds it is flipped against and kept inside;
- `text-fit: shrink MIN` shrinks a line to its box, then cuts it with an ellipsis; `line-height`;
an `em` reads the font size the element ends with (a `font-size` later in the rule, or in a later
rule), not the one it had so far;
- 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,23 @@
# ui_text.ludic — text: text-fit shrinks a line to its chip down to a least size and then cuts it with
# an ellipsis; line-height as a multiple and in px; an em reads the font size the element ends with
# (set later in the rule, or by a later rule); a sheet's own rule wins a tie with one it imports.
import "ludic.ui"
import "ui_text_parts/Fit.ludic"
program UiText {
numbers float
var said: []string = new []string
function rec_text(x: float, y: float, s: string, size: float, c: int, a: float) -> void { push(said, `{s}@{int(size * 10.0)} y{int(y)}`) }
function w(s: string, size: float) -> float { return float(len(s)) * size * 0.5 }
entry {
let b = new UiBackend
b.text = fn rec_text
b.measure = fn w
ui_backend(b)
ui_show("Fit", null, 0.0, 0.0, 400.0, 600.0)
let root: UiNode = ui_nodes("Fit", null)
ui_place(root, 0.0, 0.0, 400.0, 600.0)
let page = root.children[0]
let lines = page.children[3]
print(`{said[0]} | {said[1]} | {said[2]} | lines {len(lines.lines)} {int(lines.mh)} {said[3]} {said[4]} | tall {int(page.children[4].mh)} | em {int(page.children[5].pt)} {int(page.children[6].pt)} | tie {ui_hex(page.children[7].fg)}`)
}
}

View file

@ -0,0 +1,10 @@
@import "base.lss";
.page { width: 400px }
.chip { width: 100px; font-size: 16px; text-fit: shrink 10px }
.mid { width: 80px }
.lines { width: 60px; font-size: 16px; line-height: 2 }
.tall { line-height: 30px }
.em { padding: 1em; font-size: 20px }
.em2 { padding: 1em }
.page .em2 { font-size: 24px }
.tie { color: #00ff00 }

View file

@ -0,0 +1,4 @@
# Fit.ludic - chips too narrow for their words, lines spaced by line-height, ems read against the
# font size an element ends with, and a sheet's own rule over an imported one of the same weight
component Fit {
}

View file

@ -0,0 +1,10 @@
<div class="page">
<p class="chip long">Photograph the Bells from the signs</p>
<p class="chip">Short</p>
<p class="chip mid">Medium words</p>
<p class="lines">one two three four five six</p>
<p class="tall">a</p>
<div class="em">x</div>
<div class="em2">y</div>
<p class="tie">t</p>
</div>

View file

@ -0,0 +1 @@
.tie { color: #0000ff }

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

View file

@ -766,6 +766,7 @@ function cmd_dev_test() -> int {
controller_case("library/ui_gamepad", "", "last 6 vol 0 q 0 open false | last 0 vol 0 q 0 open false | last 0 vol 2 q 0 open false | last 0 vol 2 q 2 open false | last 0 vol 2 q 2 open true | last 0 vol 2 q 2 open false", "ui_gamepad.ludic (L11: the pad and held keys - a held direction repeats on the clock at any rate, A presses, left and right step, B closes a popover)")
controller_case("library/ui_pointer", "", "pointerdown 100,80 0 0; pointerup 300,300 0 0; wheel 50,50 0 1; dragged 200 | zoomed 1 pad 7 10 1 2 hold true 0 0 active 16711680 | zoomed 1 pad 7 10 1 2 hold false 1 0 | zoomed 1 pad 7 10 1 2 hold true 1 0 | zoomed 1 pad 7 10 1 2 hold false 2 0 3817286", "ui_pointer.ludic (L11: pointer events for natives and markup, capture until release, a button over a native keeps its press; on-down, on-up and :active)")
controller_case("library/ui_popover_align", "", "centred, flipped in the panel 90,40 | end below 110,70 | above b in the list 0 inside 1", "ui_popover_align.ludic (L11: anchored popovers aligned along their side, flipped and kept within a named container or their scroll box)")
controller_case("library/ui_text", "", "Photograph the Be...@100 y0 | Short@160 y20 | Medium words@133 y40 | lines 5 160 one two@160 y66 three@160 y98 | tall 30 | em 20 24 | tie #00ff00", "ui_text.ludic (L11: text-fit shrinks then cuts, line-height, em against the final font size, a sheet over its imports on a tie)")
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)")