feat(lang): L11 views and templates - the UI is markup, not code

A `view Name { field = x; function q(..); on e(..) }` declaration is the one bridge between a
program and its UI: it writes view_<name>() -> UiView, whose model is a Value of every field and
whose call runs a query or an event by name.

ludic.ui is a template runtime:
- HTML-shaped XML screens and components, loaded at run time;
- {expression} bindings, if/else/each, props, slots, per-instance state;
- on-press / onclick actions (event, set, emit);
- component libraries (export="true", <import src as>).

Styling:
- stylesheets in <style> or importable .lss files (@import);
- CSS selectors (#id, .class, [attr=v], descendant and > combinators, :hover, :disabled,
  :first-child, :last-child, :nth-child, :not) weighed by specificity;
- the box model and flex under CSS's property names.

Also:
- default parameters, and positional-then-named calls;
- Value gains a float kind;
- a function shadowing a runtime one is refused;
- an index is evaluated before the slice is read;
- runtime errors name the right file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 15:58:47 +03:00
parent 334469ef61
commit 66a2bc2214
64 changed files with 74806 additions and 63975 deletions

View file

@ -0,0 +1,62 @@
# act.ludic - what a press does: `buy(i)` asks the view, `set pick = i` changes a state, and
# `emit picked` runs what the component's user said with on-picked. Several are `;` apart.
const A_CALL: int = 0
const A_SET: int = 1
const A_EMIT: int = 2
export property UiAct {
kinds: []int = null
names: []string = null
exprs: []UiExpr = null
}
function act_parse(src: string, err: []string) -> UiAct {
let a = new UiAct
a.kinds = new []int
a.names = new []string
a.exprs = new []UiExpr
let r = new UiRd
r.s = src
while r.err == "" {
rd_ws(r)
if r.i >= len(r.s) { break }
if rd_word(r, "set") {
push(a.kinds, A_SET)
push(a.names, rd_name(r))
if not rd_op(r, "=") { rd_fail(r, "'='") }
push(a.exprs, px_cond(r))
} else if rd_word(r, "emit") {
push(a.kinds, A_EMIT)
push(a.names, rd_name(r))
push(a.exprs, null)
} else {
push(a.kinds, A_CALL)
push(a.names, "")
push(a.exprs, px_cond(r))
}
if not rd_op(r, ";") { break }
}
rd_ws(r)
if r.err == "" and r.i < len(r.s) { rd_fail(r, "';' or the end") }
if r.err != "" { push(err, r.err) }
return a
}
function act_run(a: UiAct, e: UiEnv) -> void {
for i in 0 .. len(a.kinds) {
if a.kinds[i] == A_SET { env_set(e, a.names[i], ui_eval(a.exprs[i], e)) }
if a.kinds[i] == A_CALL { ui_eval(a.exprs[i], e) }
if a.kinds[i] == A_EMIT { act_emit(a.names[i], e) }
}
}
function act_emit(name: string, e: UiEnv) -> void {
var at = e
while at != null {
if at.on_names != null {
for i in 0 .. len(at.on_names) {
if at.on_names[i] == name {
act_run(at.on_acts[i], at.on_envs[i])
return
}
}
}
at = at.parent
}
}

View file

@ -0,0 +1,58 @@
# 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-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 = " border-radius font-weight font-family font-style box-shadow text-shadow cursor transition line-height letter-spacing text-decoration overflow z-index position "
# 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 }
if lk_in(AT_UNDRAWN, at_alias(key)) { ui_err(`{key}: this renderer does not draw it`) } else { ui_err(`{key}: there is no such property`) }
}
# an element's attribute: a property when it names one, otherwise only there for [selectors]
function el_apply(n: UiNode, key: string, v: Val) -> bool {
let k = at_alias(key)
return el_size(n, k, v) or el_box(n, k, v) or el_flex(n, k, v) or el_look(n, k, v)
}
function at_alias(key: string) -> string {
let pat = ` {key}=`
let n = len(pat)
for i in 0 .. len(AT_ALIAS) - n + 1 {
if AT_ALIAS[i..i + n] == pat {
var j = i + n
while AT_ALIAS[j] != 32 { j += 1 }
return AT_ALIAS[i + n..j]
}
}
return key
}
function el_size(n: UiNode, key: string, v: Val) -> bool {
if key == "w" {
n.w_pct = el_pct(v)
n.w = el_len(v)
} else if key == "h" {
n.h_pct = el_pct(v)
n.h = el_len(v)
} else if key == "min-w" { n.min_w = el_len(v) } else if key == "max-w" { n.max_w = el_len(v) } else if key == "min-h" { n.min_h = el_len(v) } else if key == "max-h" { n.max_h = el_len(v) } else if key == "gap" { n.gap = el_len(v) } else { return false }
return true
}
function el_flex(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" or key == "flex" { n.grow = el_num(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(n: UiNode, key: string, v: Val) -> bool {
let s = vl_text(v)
if key == "size" { n.size = el_len(v) } else if key == "color" { n.fg = ui_color(s) } else if key == "bg" { n.bg = ui_color(s) } else if key == "alpha" { n.alpha = el_num(s) } else if key == "enabled" { n.enabled = vl_truthy(v) and s != "false" } else if key == "border-color" { n.border_c = ui_color(s) } else if key == "border-width" { n.border = el_len(v) } else if key == "border" { el_border(n, s) } else { return false }
return true
}
# border: 1 #ffcc00, or 1px solid accent
function el_border(n: UiNode, s: string) -> void {
let parts = lss_split(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) { n.border = ui_px(el_num(p)) } else if p == "none" { n.border = 0.0 } else if len(p) > 0 and p != "solid" { n.border_c = ui_color(p) }
}
if n.border_c == UI_NONE { n.border_c = n.rfg }
}

View file

@ -0,0 +1,81 @@
# attr_box.ludic - padding and margin (one to four lengths, as CSS writes them, or one side by
# name), lengths with px or %, and the words that place things
function el_box(n: UiNode, key: string, v: Val) -> bool {
if key == "pad" {
el_sides(v)
n.pt = el_s0
n.pr = el_s1
n.pb = el_s2
n.pl = el_s3
} else if key == "margin" {
el_sides(v)
n.mt = el_s0
n.mr = el_s1
n.mb = el_s2
n.ml = el_s3
} else if key == "padding-top" { n.pt = el_len(v) } else if key == "padding-right" { n.pr = el_len(v) } else if key == "padding-bottom" { n.pb = el_len(v) } else if key == "padding-left" { n.pl = el_len(v) } else if key == "margin-top" { n.mt = el_len(v) } else if key == "margin-right" { n.mr = el_len(v) } else if key == "margin-bottom" { n.mb = el_len(v) } else if key == "margin-left" { n.ml = el_len(v) } else { return false }
return true
}
var el_s0: float = 0.0
var el_s1: float = 0.0
var el_s2: float = 0.0
var el_s3: float = 0.0
# top right bottom left out of 1 to 4 lengths
function el_sides(v: Val) -> void {
let parts = new []float
if vl_num(v) { push(parts, ui_px(Value.as_float(v))) } else {
let words = lss_split(vl_text(v), 32)
for i in 0 .. len(words) {
if len(words[i]) > 0 { push(parts, ui_px(el_num(words[i]))) }
}
}
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]) }
}
el_s0 = parts[0]
el_s1 = parts[1]
el_s2 = parts[2]
el_s3 = parts[3]
}
# a length: a number, 10px, fill, fit or auto (a percentage is el_pct's)
function el_len(v: Val) -> float {
if vl_num(v) { return ui_px(Value.as_float(v)) }
let s = vl_text(v)
if s == "fill" { return UI_FILL }
if s == "fit" or s == "auto" or (len(s) > 0 and s[len(s) - 1] == 37) { return 0.0 }
return ui_px(el_num(s))
}
function el_pct(v: Val) -> float {
let s = vl_text(v)
if len(s) > 1 and s[len(s) - 1] == 37 { return el_num(s) }
return 0.0
}
# the number at the front of `12.5px`
function el_num(s: string) -> float {
var f = 0.0
var unit = 0.0
var neg = false
for i in 0 .. len(s) {
let c = s[i]
if c == 45 and i == 0 { neg = true } else if c == 46 { unit = 0.1 } else if c >= 48 and c <= 57 {
if unit > 0.0 {
f = f + float(c - 48) * unit
unit = unit * 0.1
} else { f = f * 10.0 + float(c - 48) }
} else { break }
}
if neg { return 0.0 - f }
return f
}
# start, center, end, stretch; for justify also between, around, evenly - with CSS's spellings
function el_place(v: Val) -> int {
var s = vl_text(v)
if len(s) > 6 and s[0..6] == "space-" { s = s[6..len(s)] }
if len(s) > 5 and s[0..5] == "flex-" { s = s[5..len(s)] }
if s == "center" { return UI_CENTER }
if s == "end" or s == "right" { return UI_END }
if s == "stretch" or s == "between" { return 3 }
if s == "around" { return UI_AROUND }
if s == "evenly" { return UI_EVENLY }
return UI_START
}

View file

@ -0,0 +1,26 @@
# backend.ludic - the renderer seam
# the renderer. Only rect, text and measure are needed; the rest improve on a default:
# button draws, focuses and answers a press itself; color names a colour (else #rrggbb);
# image draws an <img src>; px scales a template's lengths; scroll_begin answers how far a region is scrolled; read
# reads a template file (else Fs.read_text)
export property UiBackend {
rect: fn(float, float, float, float, int, float) -> void = null
text: fn(float, float, string, float, int) -> void = null
measure: fn(string, float) -> float = null
pointer_x: fn() -> float = null
pointer_y: fn() -> float = null
pointer_down: fn() -> bool = null
button: fn(float, float, float, float, string, bool) -> bool = null
image: fn(string, float, float, float, float, int, float) -> void = null # src x y w h tint alpha
color: fn(string) -> int = null
px: fn(float) -> float = null
scroll_begin: fn(string, float, float, float, float, float) -> float = null
scroll_end: fn() -> void = null
read: fn(string) -> string = null
}
var ui_be: UiBackend = null
export function ui_backend(b: UiBackend) -> void { ui_be = b }
function ui_px(v: float) -> float {
if ui_be != null and ui_be.px != null { return ui_be.px(v) }
return v
}

View file

@ -0,0 +1,68 @@
# build.ludic - a template and the model, made into this frame's nodes. <if>/<else> and
# <each in= as= index=> are the template's own; a tag its file linked to a component is that one,
# given its attributes as props, its on-* as what `emit` runs, and its content as <slot/>.
function bd_kids(kids: []UiTpl, e: UiEnv, key: string, out: []UiNode) -> void {
var last_if = true
for i in 0 .. len(kids) {
let k = kids[i]
let ck = `{key}/{i}`
if k.tag == "state" { continue }
if k.tag == "if" {
last_if = bd_test(k, e)
if last_if { bd_kids(k.kids, e, ck, out) }
} else if k.tag == "else" {
if not last_if { bd_kids(k.kids, e, ck, out) }
last_if = true
} else if k.tag == "each" { bd_each(k, e, ck, out) } else if k.tag == "slot" { bd_slot(e, ck, out) } else if not bd_comp(k, e, ck, out) { push(out, bd_elem(k, e, ck)) }
}
}
function bd_test(k: UiTpl, e: UiEnv) -> bool {
let t = tpl_attr(k, "test")
if t == null {
ui_err("<if> needs a test=\"{...}\"")
return false
}
return ui_eval_bool(t, e)
}
function bd_name(k: UiTpl, attr: string, dflt: string) -> string {
let x = tpl_attr(k, attr)
if x == null or x.op != X_LIT { return dflt }
return Value.as_str(x.v)
}
function bd_each(k: UiTpl, e: UiEnv, key: string, out: []UiNode) -> void {
let src = tpl_attr(k, "in")
if src == null {
ui_err("<each> needs an in=\"{...}\"")
return
}
let list = ui_eval(src, e)
let as_name = bd_name(k, "as", "item")
let at_name = bd_name(k, "index", "index")
for j in 0 .. Value.count(list) {
let le = env_new(e)
env_bind(le, as_name, Value.at(list, j))
env_bind(le, at_name, Value.int(j))
bd_kids(k.kids, le, `{key}#{j}`, out)
}
}
function bd_slot(e: UiEnv, key: string, out: []UiNode) -> void {
var at = e
while at != null and at.slot == null { at = at.parent }
if at != null { bd_kids(at.slot.kids, at.slot_env, key, out) }
}
function bd_comp(k: UiTpl, e: UiEnv, key: string, out: []UiNode) -> bool {
if k.comp == null { return false }
let c = k.comp
let ce = env_new(null)
ce.model = e.model
for i in 0 .. len(k.keys) { env_bind(ce, k.keys[i], ui_eval(k.vals[i], e)) }
ce.on_names = k.on_keys
ce.on_acts = k.on_acts
ce.on_envs = new []UiEnv
for i in 0 .. len(k.on_keys) { push(ce.on_envs, e) }
ce.slot = k
ce.slot_env = e
ce.state = st_for(key, c, ce)
bd_kids(c.kids, ce, key, out)
return true
}

View file

@ -0,0 +1,44 @@
# cascade.ludic - once the frame's tree is built, every element is styled as a browser would:
# the default sheet, then the file's rules from least to most specific, then its style="...", then
# its own attributes. Then what is display: none leaves the tree.
function cs_tree(n: UiNode) -> void {
for i in 0 .. len(n.children) {
let c = n.children[i]
c.parent = n
c.index = i
}
if n.tag != "" { cs_node(n) }
for i in 0 .. len(n.children) { cs_tree(n.children[i]) }
var shown = new []UiNode
for i in 0 .. len(n.children) {
if not n.children[i].hidden { push(shown, n.children[i]) }
}
n.children = shown
}
function cs_node(n: UiNode) -> void {
cs_sheet(n, ua_sheet())
if n.sheet != null { cs_sheet(n, n.sheet) }
if n.style != null { cs_rule(n, n.style) }
for i in 0 .. len(n.akeys) { el_apply(n, n.akeys[i], n.vals[i]) }
}
function cs_sheet(n: UiNode, sh: UiSheet) -> void {
for i in 0 .. len(sh.rules) {
if sel_match(sh.rules[i].sel, n) { cs_rule(n, sh.rules[i]) }
}
}
function cs_rule(n: UiNode, r: UiRule) -> void {
for j in 0 .. len(r.keys) { el_attr(n, r.keys[j], ui_eval(r.vals[j], n.env)) }
}
# what every element looks like before anyone styles it, as a browser's own sheet does
const UA_LSS: string = "h1 { font-size: 32 } h2 { font-size: 26 } h3 { font-size: 22 } h4 { font-size: 19 } h5 { font-size: 16 } h6 { font-size: 14 } small { font-size: 13 } button { padding: 5 10 } hr { height: 1; width: fill; background: #808080; margin: 4 0 } scroll { width: fill; height: fill } img { width: 24; height: 24 }"
var ua_sh: UiSheet = null
function ua_sheet() -> UiSheet {
if ua_sh != null { return ua_sh }
let f = new UiFile
f.path = "the default sheet"
f.imp = new []UiFile
f.own = new []UiRule
lss_text(f, UA_LSS)
ua_sh = sheet_of(f)
return ua_sh
}

View file

@ -0,0 +1,66 @@
# draw.ludic - the laid-out nodes through the renderer: background, border, then content
function fr_draw(n: UiNode) -> void {
if n.kind == UI_BUTTON {
fr_button(n)
return
}
if n.bg >= 0 and ui_be.rect != null { ui_be.rect(n.x, n.y, n.cw, n.ch, n.bg, n.alpha) }
fr_border(n)
if n.kind == UI_TEXT { ui_be.text(fr_text_x(n), n.y + n.pt + n.border, n.text, n.rsize, n.rfg) }
if n.kind == UI_IMAGE and ui_be.image != null { ui_be.image(n.src, n.x + n.pl + n.border, n.y + n.pt + n.border, n.cw - ly_hin(n), n.ch - ly_vin(n), n.rfg, n.alpha) }
var off = 0.0
if n.kind == UI_SCROLL and ui_be.scroll_begin != null { off = ui_be.scroll_begin(n.key, n.x, n.y, n.cw, n.ch, n.mh) }
if off != 0.0 {
for i in 0 .. len(n.children) { fr_shift(n.children[i], off) }
}
if n.children != null {
for i in 0 .. len(n.children) { fr_draw(n.children[i]) }
}
if n.kind == UI_SCROLL and ui_be.scroll_end != null { ui_be.scroll_end() }
}
# where a text starts: text-align within the room the box gives it
function fr_text_x(n: UiNode) -> float {
let x = n.x + n.pl + n.border
if n.talign == UI_START { return x }
let spare = n.cw - ly_hin(n) - ly_text_w(n.text, n.rsize)
if n.talign == UI_CENTER { return x + spare / 2.0 }
return x + spare
}
function fr_border(n: UiNode) -> void {
if n.border <= 0.0 or ui_be.rect == null { return }
var c = n.border_c
if c < 0 { c = n.rfg }
let b = n.border
ui_be.rect(n.x, n.y, n.cw, b, c, n.alpha)
ui_be.rect(n.x, n.y + n.ch - b, n.cw, b, c, n.alpha)
ui_be.rect(n.x, n.y, b, n.ch, c, n.alpha)
ui_be.rect(n.x + n.cw - b, n.y, b, n.ch, c, n.alpha)
}
# the renderer's own button when it has one: it draws, focuses and answers the press
function fr_button(n: UiNode) -> void {
if ui_be.button != null {
if ui_be.button(n.x, n.y, n.cw, n.ch, n.text, n.enabled) and n.enabled { push(fr_pressed, n) }
return
}
var bg = n.bg
if bg < 0 { bg = 4210752 }
if ui_be.rect != null { ui_be.rect(n.x, n.y, n.cw, n.ch, bg, n.alpha) }
fr_border(n)
ui_be.text(fr_text_x(n), n.y + n.pt + n.border, n.text, n.rsize, n.rfg)
}
function fr_shift(n: UiNode, off: float) -> void {
n.y = n.y - off
if n.children != null {
for i in 0 .. len(n.children) { fr_shift(n.children[i], off) }
}
}
# every node and where it went, one line each - for a test, or a look at a layout
export function ui_dump(n: UiNode, depth: int) -> string {
var pad = ""
for d in 0 .. depth { pad = pad + " " }
var out = `{pad}{n.kind} {int(n.x)},{int(n.y)} {int(n.cw)}x{int(n.ch)} {n.text}\n`
if n.children != null {
for i in 0 .. len(n.children) { out = out + ui_dump(n.children[i], depth + 1) }
}
return out
}

View file

@ -0,0 +1,77 @@
# elem.ludic - an element made from its template: what kind of node its tag is, its id, classes
# and attributes (kept for selectors, applied after every rule), its text, and its children
const EL_BOXES: string = " col div section header footer nav main article aside ul ol li form box "
const EL_TEXTS: string = " text span p label h1 h2 h3 h4 h5 h6 strong em small b i a "
function bd_elem(k: UiTpl, e: UiEnv, key: string) -> UiNode {
let n = new UiNode
n.key = key
n.tag = k.tag
n.env = e
n.sheet = k.sheet
n.style = k.style
n.children = new []UiNode
n.classes = new []string
n.akeys = new []string
n.avals = new []string
n.vals = new []Val
el_kind(n, k.tag)
for i in 0 .. len(k.keys) { el_keep(n, k.keys[i], ui_eval(k.vals[i], e)) }
n.hovered = fr_was_hovered(key)
for i in 0 .. len(k.on_keys) {
if k.on_keys[i] == "press" {
n.act = k.on_acts[i]
n.env = e
} else { ui_err(`on-{k.on_keys[i]}: an element answers on-press (or onclick); a component's other events are its user's`) }
}
bd_kids(k.kids, e, key, n.children)
if k.text != null { el_text(n, ui_eval_text(k.text, e)) }
return n
}
function el_kind(n: UiNode, tag: string) -> void {
if lk_in(EL_TEXTS, tag) { n.kind = UI_TEXT } else if tag == "row" { n.dir = UI_ROW } else if tag == "button" { n.kind = UI_BUTTON } else if tag == "spacer" { n.kind = UI_SPACER } else if tag == "scroll" { n.kind = UI_SCROLL } else if tag == "img" { n.kind = UI_IMAGE } else if tag == "hr" { n.kind = UI_RULE } else if not lk_in(EL_BOXES, tag) { ui_err(`<{tag}>: there is no such element`) }
}
# a box with words in it holds them as a text of its own, as <div>words</div> does
function el_text(n: UiNode, s: string) -> void {
if n.kind != UI_BOX and n.kind != UI_SCROLL {
n.text = s
return
}
let t = new UiNode
t.kind = UI_TEXT
t.text = s
t.env = n.env
t.children = new []UiNode
push(n.children, t)
}
# id, class, hidden, disabled and src say what the element is; the rest are applied after the rules
function el_keep(n: UiNode, key: string, v: Val) -> void {
let s = vl_text(v)
if key == "id" { n.id = s } else if key == "class" {
let parts = lss_split(s, 32)
for i in 0 .. len(parts) {
if len(parts[i]) > 0 { push(n.classes, parts[i]) }
}
} else if key == "hidden" { n.hidden = vl_truthy(v) and s != "false" } else if key == "disabled" { n.enabled = not (vl_truthy(v) and s != "false") } else if key == "src" { n.src = s } else {
if key == "enabled" { n.enabled = vl_truthy(v) and s != "false" }
push(n.akeys, key)
push(n.avals, s)
push(n.vals, v)
}
}
# a colour by the renderer's name for it, else #rrggbb (or #rgb)
function ui_color(name: string) -> int {
if name == "none" or name == "" or name == "transparent" { return UI_NONE }
if name[0] != 35 {
if ui_be != null and ui_be.color != null { return ui_be.color(name) }
return 15658734
}
var c = 0
for i in 1 .. len(name) {
var d = name[i] - 48
if name[i] >= 97 { d = name[i] - 87 }
if name[i] >= 65 and name[i] < 97 { d = name[i] - 55 }
c = c * 16 + d
if len(name) == 4 { c = c * 16 + d }
}
return c
}

View file

@ -0,0 +1,65 @@
# env.ludic - what a name means where an expression is read: the loop's own names, then the
# component's props and state, then the view's model. A component sees its props, its state
# and the model - never the names of whoever used it.
export property UiEnv {
names: []string = null
vals: []Val = null
state: Val = null
parent: UiEnv = null
model: Val = null
slot: UiTpl = null
slot_env: UiEnv = null
on_names: []string = null # a component's on-* attributes, which its `emit` runs
on_acts: []UiAct = null
on_envs: []UiEnv = null
}
var ui_view: UiView = null
var ui_errs: []string = new []string
function env_new(parent: UiEnv) -> UiEnv {
let e = new UiEnv
e.names = new []string
e.vals = new []Val
e.parent = parent
if parent != null { e.model = parent.model }
return e
}
function env_bind(e: UiEnv, name: string, v: Val) -> void {
push(e.names, name)
push(e.vals, v)
}
function env_get(e: UiEnv, name: string) -> Val {
var at = e
while at != null {
var i = len(at.names) - 1
while i >= 0 {
if at.names[i] == name { return at.vals[i] }
i -= 1
}
if at.state != null and Value.has(at.state, name) != 0 { return Value.get(at.state, name) }
at = at.parent
}
if e.model != null and Value.has(e.model, name) != 0 { return Value.get(e.model, name) }
ui_err(`no {name} here: not a loop's, a prop, a state or in the view`)
return Value.null()
}
# `set name = value`: the nearest state that holds the name
function env_set(e: UiEnv, name: string, v: Val) -> void {
var at = e
while at != null {
if at.state != null and Value.has(at.state, name) != 0 {
Value.put(at.state, name, v)
return
}
at = at.parent
}
ui_err(`set {name}: no state here holds it - declare it with <state {name}="..."/>`)
}
# an error is said once, however many frames make it
function ui_err(msg: string) -> void {
for i in 0 .. len(ui_errs) {
if ui_errs[i] == msg { return }
}
push(ui_errs, msg)
print(`ui: {msg}`)
}

View file

@ -0,0 +1,52 @@
# eval.ludic - an expression's value, here
function ui_eval(x: UiExpr, e: UiEnv) -> Val {
if x.op == X_LIT { return x.v }
if x.op == X_NAME { return env_get(e, x.s) }
if x.op == X_MEMBER {
let o = ui_eval(x.a, e)
if Value.kind(o) == 6 and Value.has(o, x.s) != 0 { return Value.get(o, x.s) }
if Value.kind(o) == 5 and x.s == "length" { return Value.int(Value.count(o)) }
ui_err(`.{x.s}: there is no such field`)
return Value.null()
}
if x.op == X_INDEX { return Value.at(ui_eval(x.a, e), Value.as_int(ui_eval(x.b, e))) }
if x.op == X_CALL { return ev_call(x, e) }
if x.op == X_NOT { return vl_bool(not vl_truthy(ui_eval(x.a, e))) }
if x.op == X_NEG { return vl_arith("-", Value.int(0), ui_eval(x.a, e)) }
if x.op == X_AND {
if not vl_truthy(ui_eval(x.a, e)) { return vl_bool(false) }
return vl_bool(vl_truthy(ui_eval(x.b, e)))
}
if x.op == X_OR {
if vl_truthy(ui_eval(x.a, e)) { return vl_bool(true) }
return vl_bool(vl_truthy(ui_eval(x.b, e)))
}
if x.op == X_COND {
if vl_truthy(ui_eval(x.a, e)) { return ui_eval(x.b, e) }
return ui_eval(x.c, e)
}
if x.op == X_TEXT {
var out = ""
for i in 0 .. len(x.args) { out = out + vl_text(ui_eval(x.args[i], e)) }
return Value.str(out)
}
return vl_binary(x.s, ui_eval(x.a, e), ui_eval(x.b, e))
}
# a call: len() and range() are the template's own, anything else is the view's
function ev_call(x: UiExpr, e: UiEnv) -> Val {
let args = Value.list()
for i in 0 .. len(x.args) { Value.add(args, ui_eval(x.args[i], e)) }
if x.s == "len" and len(x.args) == 1 { return Value.int(Value.count(Value.at(args, 0))) }
if x.s == "range" and len(x.args) == 1 {
let out = Value.list()
for i in 0 .. Value.as_int(Value.at(args, 0)) { Value.add(out, Value.int(i)) }
return out
}
if ui_view == null or ui_view.call == null {
ui_err(`{x.s}(): no view is showing to ask`)
return Value.null()
}
return ui_view.call(x.s, args)
}
function ui_eval_text(x: UiExpr, e: UiEnv) -> string { return vl_text(ui_eval(x, e)) }
function ui_eval_bool(x: UiExpr, e: UiEnv) -> bool { return vl_truthy(ui_eval(x, e)) }

View file

@ -0,0 +1,82 @@
# expr.ludic - a template's expressions: `{pick == i}`, `{f.cost}`, `{placed(i) > 0 ? 'out' : ''}`.
# Read once when the template loads, evaluated against the view's model every frame.
const X_LIT: int = 0
const X_NAME: int = 1
const X_MEMBER: int = 2
const X_INDEX: int = 3
const X_CALL: int = 4
const X_NOT: int = 5
const X_NEG: int = 6
const X_BIN: int = 7
const X_AND: int = 8
const X_OR: int = 9
const X_COND: int = 10
const X_TEXT: int = 11
export property UiExpr {
op: int = 0
s: string = ""
v: Val = null
a: UiExpr = null
b: UiExpr = null
c: UiExpr = null
args: []UiExpr = null
}
# the text being read, and where the reading is
property UiRd {
s: string = ""
i: int = 0
err: string = ""
}
function ex_new(op: int) -> UiExpr {
let e = new UiExpr
e.op = op
e.args = new []UiExpr
return e
}
function ex_lit(v: Val) -> UiExpr {
let e = ex_new(X_LIT)
e.v = v
return e
}
function rd_ws(r: UiRd) -> void {
while r.i < len(r.s) and (r.s[r.i] == 32 or r.s[r.i] == 9 or r.s[r.i] == 10 or r.s[r.i] == 13) { r.i += 1 }
}
function rd_c(r: UiRd, k: int) -> int {
if r.i + k < len(r.s) { return r.s[r.i + k] }
return 0
}
# the operator `op` is next: take it
function rd_op(r: UiRd, op: string) -> bool {
rd_ws(r)
let n = len(op)
if r.i + n > len(r.s) { return false }
if r.s[r.i..r.i + n] != op { return false }
r.i += n
return true
}
function rd_is_word(c: int) -> bool { return (c >= 97 and c <= 122) or (c >= 65 and c <= 90) or c == 95 or (c >= 48 and c <= 57) }
# the word `w` is next, whole: take it
function rd_word(r: UiRd, w: string) -> bool {
rd_ws(r)
let at = r.i
if not rd_op(r, w) { return false }
if rd_is_word(rd_c(r, 0)) {
r.i = at
return false
}
return true
}
function rd_name(r: UiRd) -> string {
rd_ws(r)
let at = r.i
while r.i < len(r.s) and rd_is_word(r.s[r.i]) { r.i += 1 }
if r.i == at {
rd_fail(r, "a name")
return ""
}
return r.s[at..r.i]
}
function rd_fail(r: UiRd, want: string) -> void {
if r.err == "" { r.err = `expected {want} at {r.i + 1} in "{r.s}"` }
}

View file

@ -0,0 +1,96 @@
# expr_parse.ludic - a condition, or/and/not, a comparison, sums and products, then a name
# with its members, indexes and calls
function px_cond(r: UiRd) -> UiExpr {
let a = px_or(r)
if not rd_op(r, "?") { return a }
let e = ex_new(X_COND)
e.a = a
e.b = px_cond(r)
if not rd_op(r, ":") { rd_fail(r, "':'") }
e.c = px_cond(r)
return e
}
function px_or(r: UiRd) -> UiExpr {
var a = px_and(r)
while rd_word(r, "or") or rd_op(r, "||") {
let e = ex_new(X_OR)
e.a = a
e.b = px_and(r)
a = e
}
return a
}
function px_and(r: UiRd) -> UiExpr {
var a = px_not(r)
while rd_word(r, "and") or rd_op(r, "&&") {
let e = ex_new(X_AND)
e.a = a
e.b = px_not(r)
a = e
}
return a
}
function px_not(r: UiRd) -> UiExpr {
rd_ws(r)
if rd_word(r, "not") or (rd_c(r, 0) == 33 and rd_c(r, 1) != 61 and rd_op(r, "!")) {
let e = ex_new(X_NOT)
e.a = px_not(r)
return e
}
return px_cmp(r)
}
function px_bin(op: string, a: UiExpr, b: UiExpr) -> UiExpr {
let e = ex_new(X_BIN)
e.s = op
e.a = a
e.b = b
return e
}
function px_cmp(r: UiRd) -> UiExpr {
let a = px_sum(r)
let ops = ["==", "!=", "<=", ">=", "<", ">"]
for k in 0 .. len(ops) {
if rd_op(r, ops[k]) { return px_bin(ops[k], a, px_sum(r)) }
}
return a
}
function px_sum(r: UiRd) -> UiExpr {
var a = px_prod(r)
while true {
if rd_op(r, "+") { a = px_bin("+", a, px_prod(r)) } else if rd_op(r, "-") { a = px_bin("-", a, px_prod(r)) } else { return a }
}
return a
}
function px_prod(r: UiRd) -> UiExpr {
var a = px_unary(r)
while true {
if rd_op(r, "*") { a = px_bin("*", a, px_unary(r)) } else if rd_op(r, "/") { a = px_bin("/", a, px_unary(r)) } else if rd_op(r, "%") { a = px_bin("%", a, px_unary(r)) } else { return a }
}
return a
}
function px_unary(r: UiRd) -> UiExpr {
if rd_op(r, "-") {
let e = ex_new(X_NEG)
e.a = px_unary(r)
return e
}
return px_post(r)
}
function px_post(r: UiRd) -> UiExpr {
var a = px_primary(r)
while r.err == "" {
if rd_op(r, ".") {
let e = ex_new(X_MEMBER)
e.a = a
e.s = rd_name(r)
a = e
} else if rd_op(r, "[") {
let e = ex_new(X_INDEX)
e.a = a
e.b = px_cond(r)
if not rd_op(r, "]") { rd_fail(r, "']'") }
a = e
} else { return a }
}
return a
}

View file

@ -0,0 +1,88 @@
# expr_prim.ludic - a number, a quoted string, true/false/null, a name or a call, a bracket
function px_primary(r: UiRd) -> UiExpr {
rd_ws(r)
let c = rd_c(r, 0)
if c >= 48 and c <= 57 { return px_number(r) }
if c == 39 or c == 34 { return px_string(r, c) }
if rd_op(r, "(") {
let e = px_cond(r)
if not rd_op(r, ")") { rd_fail(r, "')'") }
return e
}
if rd_word(r, "true") { return ex_lit(Value.bool(1)) }
if rd_word(r, "false") { return ex_lit(Value.bool(0)) }
if rd_word(r, "null") { return ex_lit(Value.null()) }
let name = rd_name(r)
if not rd_op(r, "(") {
let e = ex_new(X_NAME)
e.s = name
return e
}
let e = ex_new(X_CALL)
e.s = name
if rd_op(r, ")") { return e }
while r.err == "" {
push(e.args, px_cond(r))
if rd_op(r, ")") { return e }
if not rd_op(r, ",") { rd_fail(r, "',' or ')'") }
}
return e
}
function px_number(r: UiRd) -> UiExpr {
let at = r.i
var whole = 0
while r.i < len(r.s) and r.s[r.i] >= 48 and r.s[r.i] <= 57 {
whole = whole * 10 + (r.s[r.i] - 48)
r.i += 1
}
if rd_c(r, 0) != 46 or rd_c(r, 1) < 48 or rd_c(r, 1) > 57 { return ex_lit(Value.int(whole)) }
r.i += 1
var f = float(whole)
var unit = 0.1
while r.i < len(r.s) and r.s[r.i] >= 48 and r.s[r.i] <= 57 {
f = f + float(r.s[r.i] - 48) * unit
unit = unit * 0.1
r.i += 1
}
return ex_lit(Value.float(f))
}
function px_string(r: UiRd, q: int) -> UiExpr {
r.i += 1
let at = r.i
while r.i < len(r.s) and r.s[r.i] != q { r.i += 1 }
let s: string = r.s[at..r.i]
if r.i >= len(r.s) { rd_fail(r, "the string's closing quote") } else { r.i += 1 }
return ex_lit(Value.str(s))
}
# one expression, the whole of `src`
function ex_parse(src: string, err: []string) -> UiExpr {
let r = new UiRd
r.s = src
let e = px_cond(r)
rd_ws(r)
if r.err == "" and r.i < len(r.s) { rd_fail(r, "the end") }
if r.err != "" { push(err, r.err) }
return e
}
# text with {holes}: a literal when it has none, the expression itself when it is one hole and
# nothing else (so it keeps its type), otherwise the pieces joined as text
function ex_text(src: string, err: []string) -> UiExpr {
let e = ex_new(X_TEXT)
var at = 0
var i = 0
while i < len(src) {
if src[i] == 123 {
var j = i + 1
while j < len(src) and src[j] != 125 { j += 1 }
if i > at { push(e.args, ex_lit(Value.str(src[at..i]))) }
push(e.args, ex_parse(src[i + 1..j], err))
at = j + 1
i = j
}
i += 1
}
if at < len(src) { push(e.args, ex_lit(Value.str(src[at..len(src)]))) }
if len(e.args) == 0 { return ex_lit(Value.str("")) }
if len(e.args) == 1 { return e.args[0] }
return e
}

View file

@ -0,0 +1,95 @@
# frame.ludic - a screen shown: built from the view's model, laid out, drawn, and its presses
# run once the frame is drawn, so nothing the view does can change a frame half way through it
var fr_was_down: bool = false
var fr_pressed: []UiNode = new []UiNode
export function ui_show(screen: string, view: UiView, x: float, y: float, w: float, h: float) -> void {
let root = ui_nodes(screen, view)
if root == null { return }
ui_place(root, x, y, w, h)
fr_pressed = new []UiNode
fr_pointer(root)
if ui_be != null and ui_be.text != null { fr_draw(root) }
let pressed = fr_pressed
for i in 0 .. len(pressed) { ui_press(pressed[i]) }
}
# the screen's nodes for this frame, not yet laid out - for a test, or a host that places them
export function ui_nodes(screen: string, view: UiView) -> UiNode {
let si = lib_find(lib_screen_names, screen)
if si < 0 {
ui_err(`there is no screen {screen} - ui_load the file that has it`)
return null
}
let t = lib_screens[si]
ui_view = view
let e = env_new(null)
if view != null and view.model != null { e.model = view.model() } else { e.model = Value.object() }
e.state = st_for(screen, t, e)
let root = new UiNode
root.key = screen
root.children = new []UiNode
root.env = e
root.sheet = t.sheet
root.classes = new []string
root.akeys = new []string
root.avals = new []string
root.vals = new []Val
for i in 0 .. len(t.keys) {
if t.keys[i] != "name" { el_keep(root, t.keys[i], ui_eval(t.vals[i], e)) }
}
for i in 0 .. len(root.akeys) { el_apply(root, root.akeys[i], root.vals[i]) }
bd_kids(t.kids, e, screen, root.children)
cs_tree(root)
return root
}
export function ui_place(root: UiNode, x: float, y: float, w: float, h: float) -> void {
root.w = UI_FILL
root.h = UI_FILL
var fg = 15658734
if ui_be != null and ui_be.color != null { fg = ui_be.color("text") }
ly_measure(root, w, ui_px(16.0), fg)
ly_place(root, x, y, w, h)
}
# a press, as if the pointer had made it
export function ui_press(n: UiNode) -> void {
if n.act != null and n.enabled { act_run(n.act, n.env) }
}
# the pointer: what it is over is :hover next frame (the element and all around it, as in CSS),
# and without a renderer's own button a press on the deepest pressable thing is a press
var fr_hover: []string = new []string
function fr_was_hovered(key: string) -> bool { return sel_has(fr_hover, key) }
function fr_pointer(root: UiNode) -> void {
if ui_be == null or ui_be.pointer_x == null {
fr_hover = new []string
return
}
let px = ui_be.pointer_x()
let py = ui_be.pointer_y()
let keys = new []string
fr_under(root, px, py, keys)
fr_hover = keys
if ui_be.button != null or ui_be.pointer_down == null { return }
let hit = fr_hit(root, px, py)
let down = ui_be.pointer_down()
if hit != null and down and not fr_was_down { push(fr_pressed, hit) }
fr_was_down = down
}
function fr_under(n: UiNode, px: float, py: float, keys: []string) -> void {
if px < n.x or py < n.y or px >= n.x + n.cw or py >= n.y + n.ch { return }
if n.key != "" { push(keys, n.key) }
for i in 0 .. len(n.children) { fr_under(n.children[i], px, py, keys) }
}
function fr_hit(n: UiNode, px: float, py: float) -> UiNode {
if px < n.x or py < n.y or px >= n.x + n.cw or py >= n.y + n.ch { return null }
if n.children != null {
var i = len(n.children) - 1
while i >= 0 {
let h = fr_hit(n.children[i], px, py)
if h != null { return h }
i -= 1
}
}
if n.act != null { return n }
return null
}
export function ui_hit(root: UiNode, px: float, py: float) -> UiNode { return fr_hit(root, px, py) }

View file

@ -0,0 +1,33 @@
# ludic.ui - templates: screens and components written as markup in files the program loads at
# run time, reading only what a `view` declaration lets them, and answering with its events
module ui
numbers float
import "node.ludic"
import "backend.ludic"
import "expr.ludic"
import "expr_parse.ludic"
import "expr_prim.ludic"
import "values.ludic"
import "env.ludic"
import "eval.ludic"
import "act.ludic"
import "tpl.ludic"
import "lib.ludic"
import "lib_util.ludic"
import "link.ludic"
import "state.ludic"
import "build.ludic"
import "elem.ludic"
import "attr.ludic"
import "attr_box.ludic"
import "style.ludic"
import "style_rule.ludic"
import "select.ludic"
import "select_parts.ludic"
import "select_match.ludic"
import "cascade.ludic"
import "layout.ludic"
import "layout_run.ludic"
import "layout_wrap.ludic"
import "frame.ludic"
import "draw.ludic"

View file

@ -0,0 +1,96 @@
# layout.ludic - flex layout on the CSS box model, top-down: a box is told how wide it may be,
# measures its content at that width (so a wrapping row knows how tall it is), then places each
# child. A size is the border box: padding and border inside it, margin outside it.
function ly_measure(n: UiNode, avail: float, size: float, fg: int) -> void {
n.rsize = size
if n.size > 0.0 { n.rsize = n.size }
n.rfg = fg
if n.fg != UI_INHERIT { n.rfg = n.fg }
n.mw = ly_hin(n)
n.mh = ly_vin(n)
if n.kind == UI_TEXT or n.kind == UI_BUTTON {
n.mw = n.mw + ly_text_w(n.text, n.rsize)
n.mh = n.mh + n.rsize * 1.25
}
if n.kind == UI_IMAGE {
n.mw = n.mw + n.rsize
n.mh = n.mh + n.rsize
}
var fixed_w = n.w
if n.w_pct > 0.0 { fixed_w = avail * n.w_pct / 100.0 }
if (n.kind == UI_BOX or n.kind == UI_SCROLL) and n.children != null {
var inner = avail - n.ml - n.mr
if fixed_w > 0.0 { inner = fixed_w }
inner = ly_clamp(inner, n.min_w, n.max_w) - ly_hin(n)
if n.dir == UI_ROW and n.wrap { ly_measure_wrap(n, inner) } else { ly_measure_box(n, inner) }
}
if fixed_w > 0.0 { n.mw = fixed_w }
if n.h > 0.0 { n.mh = n.h }
n.mw = ly_clamp(n.mw, n.min_w, n.max_w)
n.mh = ly_clamp(n.mh, n.min_h, n.max_h)
}
function ly_hin(n: UiNode) -> float { return n.pl + n.pr + n.border * 2.0 }
function ly_vin(n: UiNode) -> float { return n.pt + n.pb + n.border * 2.0 }
function ly_clamp(v: float, lo: float, hi: float) -> float {
var out = v
if hi > 0.0 and out > hi { out = hi }
if out < lo { out = lo }
return out
}
function ly_measure_box(n: UiNode, inner: float) -> void {
var main = 0.0
var cross = 0.0
for i in 0 .. len(n.children) {
let c = n.children[i]
ly_measure(c, inner, n.rsize, n.rfg)
main = main + ly_main(c, n.dir)
if i > 0 { main = main + n.gap }
cross = Math.max(cross, ly_cross(c, n.dir))
}
if n.dir == UI_ROW {
n.mw = n.mw + main
n.mh = n.mh + cross
} else {
n.mw = n.mw + cross
n.mh = n.mh + main
}
}
# a wrapping row: children in lines no wider than `inner`, the lines stacked `gap` apart
function ly_measure_wrap(n: UiNode, inner: float) -> void {
var line_w = 0.0
var line_h = 0.0
var widest = 0.0
var total = 0.0
var count = 0
for i in 0 .. len(n.children) {
let c = n.children[i]
ly_measure(c, inner, n.rsize, n.rfg)
let cw = ly_main(c, UI_ROW)
if count > 0 and line_w + n.gap + cw > inner {
widest = Math.max(widest, line_w)
total = total + line_h + n.gap
line_w = 0.0
line_h = 0.0
count = 0
}
if count > 0 { line_w = line_w + n.gap }
line_w = line_w + cw
line_h = Math.max(line_h, ly_cross(c, UI_ROW))
count += 1
}
n.mw = n.mw + Math.max(widest, line_w)
n.mh = n.mh + total + line_h
}
function ly_text_w(s: string, size: float) -> float {
if ui_be != null and ui_be.measure != null { return ui_be.measure(s, size) }
return float(len(s)) * size * 0.5
}
# a child's size along and across a direction, margins included
function ly_main(c: UiNode, dir: int) -> float {
if dir == UI_ROW { return c.mw + c.ml + c.mr }
return c.mh + c.mt + c.mb
}
function ly_cross(c: UiNode, dir: int) -> float {
if dir == UI_ROW { return c.mh + c.mt + c.mb }
return c.mw + c.ml + c.mr
}

View file

@ -0,0 +1,99 @@
# layout_run.ludic - a box's children given their places: along its direction by size, grow and
# justify, across it by align (or each child's own `self`), a wrapping row one line at a time
var ly_lead: float = 0.0
var ly_extra: float = 0.0
function ly_place(n: UiNode, x: float, y: float, w: float, h: float) -> void {
n.x = x
n.y = y
n.cw = w
n.ch = h
if (n.kind != UI_BOX and n.kind != UI_SCROLL) or n.children == null { return }
let inner_w = w - ly_hin(n)
var inner_h = h - ly_vin(n)
if n.kind == UI_SCROLL { inner_h = Math.max(n.mh, h) - ly_vin(n) }
let top = n.y + n.pt + n.border
if n.dir == UI_ROW and n.wrap {
ly_place_wrap(n, top, inner_w)
return
}
if n.dir == UI_ROW { ly_place_line(n, 0, len(n.children), top, inner_w, inner_h) } else { ly_place_line(n, 0, len(n.children), top, inner_h, inner_w) }
}
function ly_fills(c: UiNode, dir: int) -> bool {
if dir == UI_ROW { return c.w == UI_FILL }
return c.h == UI_FILL
}
function ly_grow(c: UiNode, dir: int) -> float {
if ly_fills(c, dir) { return Math.max(c.grow, 1.0) }
return c.grow
}
# where a child starts along the run: `fill` from nothing, a percentage from the room it is in
function ly_basis(c: UiNode, dir: int, room: float) -> float {
if ly_fills(c, dir) { return 0.0 }
if dir == UI_ROW and c.w_pct > 0.0 { return room * c.w_pct / 100.0 + c.ml + c.mr }
if dir == UI_COL and c.h_pct > 0.0 { return room * c.h_pct / 100.0 + c.mt + c.mb }
return ly_main(c, dir)
}
function ly_stretches(n: UiNode, c: UiNode) -> bool {
if ly_fills(c, 1 - n.dir) { return true }
var a = n.align
if c.self >= 0 { a = c.self }
return a == UI_STRETCH
}
# children from..to along one line starting at `top`: sizes, the spare room, then each place
function ly_place_line(n: UiNode, from: int, to: int, top: float, main_room: float, cross_room: float) -> void {
var used = 0.0
var grows = 0.0
for i in from .. to {
used = used + ly_basis(n.children[i], n.dir, main_room)
grows = grows + ly_grow(n.children[i], n.dir)
}
used = used + n.gap * float(Math.max(to - from - 1, 0))
let free = main_room - used
ly_justify(n, free, to - from, grows)
let extra = ly_extra
var at = ly_lead
for i in from .. to {
let c = n.children[i]
var m = ly_basis(c, n.dir, main_room)
if grows > 0.0 and free > 0.0 { m = m + free * ly_grow(c, n.dir) / grows }
ly_place_child(n, c, at, top, m, cross_room)
at = at + m + n.gap + extra
}
}
# one child: `m` along the run and its own size (or the whole room, stretched) across, less margins
function ly_place_child(n: UiNode, c: UiNode, at: float, top: float, m: float, cross_room: float) -> void {
var cr = ly_cross(c, n.dir)
if n.dir == UI_ROW and c.h_pct > 0.0 { cr = cross_room * c.h_pct / 100.0 + c.mt + c.mb }
if n.dir == UI_COL and c.w_pct > 0.0 { cr = cross_room * c.w_pct / 100.0 + c.ml + c.mr }
if ly_stretches(n, c) { cr = cross_room }
var a = n.align
if c.self >= 0 { a = c.self }
var off = 0.0
if a == UI_CENTER { off = (cross_room - cr) / 2.0 }
if a == UI_END { off = cross_room - cr }
let left = n.x + n.pl + n.border
if n.dir == UI_ROW {
let cw = ly_clamp(m - c.ml - c.mr, c.min_w, c.max_w)
ly_place(c, left + at + c.ml, top + off + c.mt, cw, ly_clamp(cr - c.mt - c.mb, c.min_h, c.max_h))
} else {
let chh = ly_clamp(m - c.mt - c.mb, c.min_h, c.max_h)
ly_place(c, left + off + c.ml, top + at + c.mt, ly_clamp(cr - c.ml - c.mr, c.min_w, c.max_w), chh)
}
}
# where the first child starts and what goes between, when nothing grows into the spare room
function ly_justify(n: UiNode, free: float, count: int, grows: float) -> void {
ly_lead = 0.0
ly_extra = 0.0
if grows > 0.0 or free <= 0.0 or count == 0 { return }
if n.justify == UI_CENTER { ly_lead = free / 2.0 }
if n.justify == UI_END { ly_lead = free }
if n.justify == UI_BETWEEN and count > 1 { ly_extra = free / float(count - 1) }
if n.justify == UI_AROUND {
ly_extra = free / float(count)
ly_lead = ly_extra / 2.0
}
if n.justify == UI_EVENLY {
ly_extra = free / float(count + 1)
ly_lead = ly_extra
}
}

View file

@ -0,0 +1,19 @@
# layout_wrap.ludic - a wrapping row, line by line: each line is as tall as its tallest child
function ly_place_wrap(n: UiNode, top0: float, inner_w: float) -> void {
var top = top0
var from = 0
while from < len(n.children) {
var to = from
var line_w = 0.0
var line_h = 0.0
while to < len(n.children) and (to == from or line_w + n.gap + ly_main(n.children[to], UI_ROW) <= inner_w) {
if to > from { line_w = line_w + n.gap }
line_w = line_w + ly_main(n.children[to], UI_ROW)
line_h = Math.max(line_h, ly_cross(n.children[to], UI_ROW))
to += 1
}
ly_place_line(n, from, to, top, inner_w, line_h)
top = top + line_h + n.gap
from = to
}
}

View file

@ -0,0 +1,78 @@
# lib.ludic - template files. A file's <component>s are its own unless marked export="true";
# <import src="kit.xml"/> brings a file's exported components in by their names, and
# <import src="kit.xml" as="kit"/> as <kit:Name>, so a component library is a file of them and two
# libraries never collide. A file's <style>s, and its imports' before them, style what it writes. A <screen> is the program's, by name, from whichever file has it.
export property UiFile {
path: string = ""
names: []string = null
comps: []UiTpl = null
exported: []bool = null
imp_as: []string = null
imp: []UiFile = null
own: []UiRule = null
sheet: UiSheet = null
}
var lib_files: []UiFile = new []UiFile
var lib_screen_names: []string = new []string
var lib_screens: []UiTpl = new []UiTpl
# the file a path names, read once
export function ui_load(path: string) -> bool { return lib_file(path) != null }
function lib_file(path: string) -> UiFile {
for i in 0 .. len(lib_files) {
if lib_files[i].path == path { return lib_files[i] }
}
let src = lib_text(path)
if len(src) == 0 {
ui_err(`{path}: cannot read it`)
return null
}
if lss_is(path) { return lss_file(src, path) }
return lib_read(src, path)
}
# a file's text, as if read from `path` (its imports are found beside it)
export function ui_load_text(src: string, path: string) -> void {
if lss_is(path) { lss_file(src, path) } else { lib_read(src, path) }
}
function lib_read(src: string, path: string) -> UiFile {
let f = lib_new(path)
let root = tpl_read(Xml.parse(src), path)
var tops = root.kids
if root.tag != "ui" { tops = [root] }
for i in 0 .. len(tops) { lib_top(f, tops[i]) }
f.sheet = sheet_of(f)
for i in 0 .. len(f.comps) { lib_link(f, f.comps[i]) }
for i in 0 .. len(tops) {
if tops[i].tag == "screen" { lib_link(f, tops[i]) }
}
return f
}
function lib_top(f: UiFile, t: UiTpl) -> void {
if t.tag == "style" {
sheet_read(f, t)
return
}
if t.tag == "import" {
let other = lib_file(lib_beside(f.path, bd_name(t, "src", "")))
if other != null {
push(f.imp, other)
push(f.imp_as, bd_name(t, "as", ""))
}
return
}
let name = bd_name(t, "name", "")
if name == "" or (t.tag != "component" and t.tag != "screen") {
ui_err(`{f.path}: <{t.tag}> - a file holds <import>s, <style>s, <component name=...>s and <screen name=...>s`)
return
}
if t.tag == "screen" {
let at = lib_find(lib_screen_names, name)
if at >= 0 { lib_screens[at] = t } else {
push(lib_screen_names, name)
push(lib_screens, t)
}
return
}
push(f.names, name)
push(f.comps, t)
push(f.exported, bd_name(t, "export", "") == "true")
}

View file

@ -0,0 +1,34 @@
# lib_util.ludic - a file made, found, read, and a path beside another
function lib_find(names: []string, name: string) -> int {
for i in 0 .. len(names) {
if names[i] == name { return i }
}
return -1
}
function lib_new(path: string) -> UiFile {
let f = new UiFile
f.path = path
f.names = new []string
f.comps = new []UiTpl
f.exported = new []bool
f.imp_as = new []string
f.imp = new []UiFile
f.own = new []UiRule
push(lib_files, f)
return f
}
function lib_text(path: string) -> string {
var src = ""
if ui_be != null and ui_be.read != null { src = ui_be.read(path) } else { src = Fs.read_text(path) }
if src == null { return "" }
return src
}
# `src` read from beside the file that imports it
function lib_beside(path: string, src: string) -> string {
var cut = -1
for i in 0 .. len(path) {
if path[i] == 47 { cut = i }
}
if cut < 0 or (len(src) > 0 and src[0] == 47) { return src }
return path[0..cut + 1] + src
}

View file

@ -0,0 +1,42 @@
# link.ludic - each tag in a file that is not an element names a component: the file's own, an
# unnamed import's exported one, or `as:Name` from the import called `as`
const LK_OWN: string = " row button spacer scroll img hr if else each slot state style component screen import "
function lib_link(f: UiFile, t: UiTpl) -> void {
t.sheet = f.sheet
if not lk_element(t.tag) {
t.comp = lk_find(f, t.tag, false)
if t.comp == null and lk_find(f, t.tag, true) != null { ui_err(`{f.path}: <{t.tag}>: that component is not exported - export="true" on it lets other files use it`) } else if t.comp == null { ui_err(`{f.path}: <{t.tag}>: there is no such element, and no component of that name here or exported by an import`) }
}
for i in 0 .. len(t.kids) { lib_link(f, t.kids[i]) }
}
function lk_element(tag: string) -> bool { return lk_in(LK_OWN, tag) or lk_in(EL_BOXES, tag) or lk_in(EL_TEXTS, tag) }
# a word in a list of words written " a b c "
function lk_in(words: string, w: string) -> bool {
let pat = ` {w} `
let n = len(pat)
for i in 0 .. len(words) - n + 1 {
if words[i..i + n] == pat { return true }
}
return false
}
# `any` finds an unexported one too, to say why it cannot be used
function lk_find(f: UiFile, tag: string, any: bool) -> UiTpl {
var colon = -1
for i in 0 .. len(tag) {
if tag[i] == 58 { colon = i }
}
if colon < 0 {
let own = lib_find(f.names, tag)
if own >= 0 { return f.comps[own] }
}
for i in 0 .. len(f.imp) {
var want = tag
if colon >= 0 {
if f.imp_as[i] != tag[0..colon] { continue }
want = tag[colon + 1..len(tag)]
} else if f.imp_as[i] != "" { continue }
let at = lib_find(f.imp[i].names, want)
if at >= 0 and (any or f.imp[i].exported[at]) { return f.imp[i].comps[at] }
}
return null
}

View file

@ -0,0 +1,89 @@
# node.ludic - what a template becomes each frame, the view it reads, and the renderer seam
export const UI_BOX: int = 0
export const UI_TEXT: int = 1
export const UI_SPACER: int = 2
export const UI_BUTTON: int = 3
export const UI_SCROLL: int = 4
export const UI_IMAGE: int = 5
export const UI_RULE: int = 6 # <hr>: a line across
export const UI_COL: int = 0
export const UI_ROW: int = 1
export const UI_FILL: float = -1.0
export const UI_START: int = 0
export const UI_CENTER: int = 1
export const UI_END: int = 2
export const UI_STRETCH: int = 3 # align: the whole cross axis
export const UI_BETWEEN: int = 3 # justify: the spare room between, around or evenly
export const UI_AROUND: int = 4
export const UI_EVENLY: int = 5
export const UI_INHERIT: int = -1
export const UI_NONE: int = -2
# what a program lets its templates see: `view Name { ... }` writes view_name() -> UiView
export property UiView {
name: string = ""
model: fn() -> Val = null
call: fn(string, Val) -> Val = null
}
export property UiNode {
kind: int = 0
dir: int = 0
text: string = ""
key: string = ""
tag: string = "" # what it is called in the template, and what a selector names
id: string = ""
classes: []string = null
akeys: []string = null # its attributes, as text, for [attr=value] selectors
avals: []string = null
vals: []Val = null # the same, as values, applied after every rule
style: UiRule = null # its style="..." declarations
sheet: UiSheet = null # the stylesheets of the file it was written in
parent: UiNode = null
index: int = 0 # its place among its parent's children, for :first-child and the rest
src: string = "" # an <img>'s picture
size: float = 0.0
w: float = 0.0
h: float = 0.0
w_pct: float = 0.0 # a width or height as a percentage of the parent's room
h_pct: float = 0.0
pt: float = 0.0 # padding, top right bottom left
pr: float = 0.0
pb: float = 0.0
pl: float = 0.0
mt: float = 0.0 # margin
mr: float = 0.0
mb: float = 0.0
ml: float = 0.0
border: float = 0.0
border_c: int = -2
gap: float = 0.0
align: int = 0
justify: int = 0
talign: int = 0 # text-align
self: int = -1 # align-self; -1 takes the parent's align
grow: float = 0.0 # a share of the spare room along the parent's direction
wrap: bool = false # a row breaks into lines where it runs out of room
min_w: float = 0.0
max_w: float = 0.0 # 0 is no limit
min_h: float = 0.0
max_h: float = 0.0
hidden: bool = false # display: none, or hidden
fg: int = -1
bg: int = -2
alpha: float = 1.0
enabled: bool = true
act: UiAct = null
env: UiEnv = null
children: []UiNode = null
mw: float = 0.0
mh: float = 0.0
x: float = 0.0
y: float = 0.0
cw: float = 0.0
ch: float = 0.0
rsize: float = 0.0
rfg: int = 0
hovered: bool = false
}

View file

@ -0,0 +1,5 @@
# ludic.ui — components: a UI declared as a tree of function calls, rebuilt from the program's
# state every frame, laid out, and drawn through whatever renderer the program registers.
package "ludic.ui"
version "0.1.0"
kind source

View file

@ -0,0 +1,17 @@
# screen.ludic - the 2D screen as a renderer, for a program that draws with Screen.*
function sc_rect(x: float, y: float, w: float, h: float, c: int, a: float) -> void { Screen.fill_rectangle(int(x), int(y), int(w), int(h), c) }
function sc_text(x: float, y: float, s: string, size: float, c: int) -> void { Screen.draw_text(int(x), int(y), s, c, Math.max(1, int(size / 8.0))) }
function sc_measure(s: string, size: float) -> float { return float(Screen.measure_text(s) * Math.max(1, int(size / 8.0))) }
function sc_px() -> float { return float(Input.mouse_x()) }
function sc_py() -> float { return float(Input.mouse_y()) }
function sc_down() -> bool { return Input.mouse_down(0) }
export function ui_screen_backend() -> UiBackend {
let b = new UiBackend
b.rect = fn sc_rect
b.text = fn sc_text
b.measure = fn sc_measure
b.pointer_x = fn sc_px
b.pointer_y = fn sc_py
b.pointer_down = fn sc_down
return b
}

View file

@ -0,0 +1,70 @@
# select.ludic - CSS selectors: a compound (`button.primary#buy[type=big]:hover:not(.off)`) joined
# to the one on its left by a space (anywhere inside it) or `>` (straight inside it)
export property UiSel {
tag: string = ""
id: string = ""
classes: []string = null
akeys: []string = null
avals: []string = null # "" with aops 0 is [attr]; aops 1 is [attr=value]
aops: []int = null
pseudos: []string = null
pargs: []string = null
nots: []UiSel = null
prev: UiSel = null
comb: int = 0 # 0: prev is an ancestor, 1: prev is the parent
}
function sel_new() -> UiSel {
let s = new UiSel
s.classes = new []string
s.akeys = new []string
s.avals = new []string
s.aops = new []int
s.pseudos = new []string
s.pargs = new []string
s.nots = new []UiSel
return s
}
function sel_parse(src: string, err: []string) -> UiSel {
let r = new UiRd
r.s = src
var cur: UiSel = null
var comb = 0
while r.err == "" {
rd_ws(r)
if r.i >= len(r.s) { break }
if rd_op(r, ">") {
comb = 1
continue
}
let c = sel_compound(r)
c.prev = cur
c.comb = comb
cur = c
comb = 0
}
if r.err != "" { push(err, r.err) }
if cur == null { push(err, `"{src}" selects nothing`) }
return cur
}
function sel_is_name(c: int) -> bool { return rd_is_word(c) or c == 45 }
function sel_name(r: UiRd) -> string {
let at = r.i
while r.i < len(r.s) and sel_is_name(r.s[r.i]) { r.i += 1 }
if r.i == at { rd_fail(r, "a name") }
return r.s[at..r.i]
}
function sel_compound(r: UiRd) -> UiSel {
let s = sel_new()
if rd_c(r, 0) == 42 { r.i += 1 } else if sel_is_name(rd_c(r, 0)) { s.tag = sel_name(r) }
while r.err == "" and r.i < len(r.s) {
let c = rd_c(r, 0)
if c == 35 {
r.i += 1
s.id = sel_name(r)
} else if c == 46 {
r.i += 1
push(s.classes, sel_name(r))
} else if c == 91 { sel_attr(r, s) } else if c == 58 { sel_pseudo(r, s) } else if c == 42 { r.i += 1 } else { return s }
}
return s
}

View file

@ -0,0 +1,74 @@
# select_match.ludic - whether a selector picks a node, and how specific it is (as CSS counts)
function sel_spec(s: UiSel) -> int {
var n = 0
var at = s
while at != null {
if at.id != "" { n = n + 100 }
n = n + (len(at.classes) + len(at.akeys) + len(at.pseudos)) * 10
if at.tag != "" { n = n + 1 }
for i in 0 .. len(at.nots) { n = n + sel_spec(at.nots[i]) }
at = at.prev
}
return n
}
function sel_match(s: UiSel, n: UiNode) -> bool {
if not sel_one(s, n) { return false }
if s.prev == null { return true }
if s.comb == 1 { return n.parent != null and sel_match(s.prev, n.parent) }
var a = n.parent
while a != null {
if sel_match(s.prev, a) { return true }
a = a.parent
}
return false
}
function sel_one(s: UiSel, n: UiNode) -> bool {
if s.tag != "" and s.tag != n.tag { return false }
if s.id != "" and s.id != n.id { return false }
for i in 0 .. len(s.classes) {
if not sel_has(n.classes, s.classes[i]) { return false }
}
for i in 0 .. len(s.akeys) {
let v = sel_attr_of(n, s.akeys[i])
if v == null or (s.aops[i] == 1 and v != s.avals[i]) { return false }
}
for i in 0 .. len(s.pseudos) {
if not sel_pseudo_ok(s.pseudos[i], s.pargs[i], n) { return false }
}
for i in 0 .. len(s.nots) {
if sel_one(s.nots[i], n) { return false }
}
return true
}
function sel_has(xs: []string, x: string) -> bool {
if xs == null { return false }
for i in 0 .. len(xs) {
if xs[i] == x { return true }
}
return false
}
function sel_attr_of(n: UiNode, key: string) -> string {
if n.akeys == null { return null }
for i in 0 .. len(n.akeys) {
if n.akeys[i] == key { return n.avals[i] }
}
return null
}
function sel_pseudo_ok(p: string, arg: string, n: UiNode) -> bool {
if p == "hover" { return n.hovered }
if p == "disabled" { return not n.enabled }
if p == "enabled" { return n.enabled }
if p == "first-child" { return n.index == 0 }
if p == "last-child" { return n.parent != null and n.index == len(n.parent.children) - 1 }
if p == "only-child" { return n.parent != null and len(n.parent.children) == 1 }
if p == "empty" { return len(n.children) == 0 and n.text == "" }
if p == "root" { return n.parent == null or n.parent.tag == "" }
if p == "nth-child" {
let k = n.index + 1
if arg == "odd" { return k % 2 == 1 }
if arg == "even" { return k % 2 == 0 }
return string(k) == arg
}
ui_err(`:{p}: there is no such pseudo-class here`)
return false
}

View file

@ -0,0 +1,35 @@
# select_parts.ludic - a compound's [attribute] and :pseudo-class parts
function sel_attr(r: UiRd, s: UiSel) -> void {
r.i += 1
push(s.akeys, sel_name(r))
if not rd_op(r, "=") {
push(s.aops, 0)
push(s.avals, "")
} else {
push(s.aops, 1)
rd_ws(r)
let q = rd_c(r, 0)
if q == 34 or q == 39 { push(s.avals, Value.as_str(px_string(r, q).v)) } else { push(s.avals, sel_name(r)) }
}
if not rd_op(r, "]") { rd_fail(r, "']'") }
}
function sel_pseudo(r: UiRd, s: UiSel) -> void {
r.i += 1
let name = sel_name(r)
var arg = ""
if rd_c(r, 0) == 40 {
r.i += 1
let at = r.i
while r.i < len(r.s) and r.s[r.i] != 41 { r.i += 1 }
arg = r.s[at..r.i]
r.i += 1
}
if name == "not" {
let inner = new UiRd
inner.s = tpl_words(arg)
push(s.nots, sel_compound(inner))
return
}
push(s.pseudos, name)
push(s.pargs, tpl_words(arg))
}

View file

@ -0,0 +1,34 @@
# state.ludic - a component's or a screen's own values, <state name="{first}"/>, kept from frame
# to frame by where the instance is in the tree, and set by `set name = value`
var st_keys: []string = new []string
var st_vals: []Val = new []Val
function st_for(key: string, t: UiTpl, e: UiEnv) -> Val {
var decl: UiTpl = null
for i in 0 .. len(t.kids) {
if t.kids[i].tag == "state" { decl = t.kids[i] }
}
if decl == null { return null }
for i in 0 .. len(st_keys) {
if st_keys[i] == key { return st_vals[i] }
}
let o = Value.object()
for i in 0 .. len(decl.keys) { Value.put(o, decl.keys[i], ui_eval(decl.vals[i], e)) }
push(st_keys, key)
push(st_vals, o)
return o
}
# a screen's state starts again the next time it opens
export function ui_forget(screen: string) -> void {
let keys = new []string
let vals = new []Val
for i in 0 .. len(st_keys) {
let k = st_keys[i]
let mine = len(k) >= len(screen) and k[0..len(screen)] == screen and (len(k) == len(screen) or k[len(screen)] == 47)
if not mine {
push(keys, k)
push(vals, st_vals[i])
}
}
st_keys = keys
st_vals = vals
}

View file

@ -0,0 +1,84 @@
# style.ludic - stylesheets: `.chip { bg: bg2; pad: 6 } button { h: 40 }` in a <style>, or an .lss
# file brought in by <import src="look.lss"/> (which may `@import "base.lss";` in turn). A selector is
# a tag, a .class, both, or *; a property is any attribute, {holes} and all. The more specific rule
# wins, then the later; an attribute beats them all.
export property UiRule {
sel: UiSel = null
spec: int = 0
keys: []string = null
vals: []UiExpr = null
}
export property UiSheet {
rules: []UiRule = null
}
function sheet_read(f: UiFile, t: UiTpl) -> void {
var src = t.raw
let from = bd_name(t, "src", "")
if from != "" { src = lib_text(lib_beside(f.path, from)) }
lss_text(f, src)
}
function lss_is(path: string) -> bool { return len(path) > 4 and path[len(path) - 4..len(path)] == ".lss" }
# a stylesheet file: a file with rules and imports and nothing else
function lss_file(src: string, path: string) -> UiFile {
let f = lib_new(path)
lss_text(f, src)
f.sheet = sheet_of(f)
return f
}
function lss_text(f: UiFile, src: string) -> void {
let err = new []string
let r = new UiRd
r.s = lss_strip(src)
while r.err == "" {
rd_ws(r)
if r.i >= len(r.s) { break }
if rd_op(r, "@import") {
lss_import(f, lss_upto(r, 59))
continue
}
let sels = lss_upto(r, 123)
let body = lss_upto(r, 125)
lss_rule(f, sels, body, err)
}
for i in 0 .. len(err) { ui_err(`{f.path}: <style> {err[i]}`) }
}
# @import "base.lss"; - its rules come before this file's own
function lss_import(f: UiFile, what: string) -> void {
var p = tpl_words(what)
if len(p) > 5 and p[0..4] == "url(" { p = p[4..len(p) - 1] }
if len(p) >= 2 and (p[0] == 34 or p[0] == 39) { p = p[1..len(p) - 1] }
let other = lib_file(lib_beside(f.path, p))
if other != null {
push(f.imp, other)
push(f.imp_as, "")
}
}
# the text up to the character `c`, which is taken too; a {hole} inside a body is skipped whole
function lss_upto(r: UiRd, c: int) -> string {
let at = r.i
var depth = 0
while r.i < len(r.s) and (r.s[r.i] != c or depth > 0) {
if c == 125 and r.s[r.i] == 123 { depth += 1 }
if c == 125 and r.s[r.i] == 125 { depth -= 1 }
r.i += 1
}
let out: string = r.s[at..r.i]
if r.i < len(r.s) { r.i += 1 } else { rd_fail(r, "a closing brace") }
return out
}
function lss_strip(src: string) -> string {
var out = ""
var at = 0
var i = 0
while i + 1 < len(src) {
if src[i] == 47 and src[i + 1] == 42 {
out = out + src[at..i]
var j = i + 2
while j + 1 < len(src) and not (src[j] == 42 and src[j + 1] == 47) { j += 1 }
i = j + 2
at = i
} else { i += 1 }
}
if at < len(src) { out = out + src[at..len(src)] }
return out
}

View file

@ -0,0 +1,71 @@
# style_rule.ludic - a rule read, the cascade a file sees, and a node's rules applied to it
function lss_rule(f: UiFile, sels: string, body: string, err: []string) -> void {
let decls = lss_decls(body, err)
let each = lss_split(sels, 44)
for i in 0 .. len(each) {
let sel = tpl_words(each[i])
if len(sel) == 0 { continue }
let r = new UiRule
r.keys = decls.keys
r.vals = decls.vals
r.sel = sel_parse(sel, err)
if r.sel != null {
r.spec = sel_spec(r.sel)
push(f.own, r)
}
}
}
# `key: value; key: value`, a rule's body or a style="..." attribute
function lss_decls(body: string, err: []string) -> UiRule {
let r = new UiRule
r.keys = new []string
r.vals = new []UiExpr
let decls = lss_split(body, 59)
for i in 0 .. len(decls) {
let d = decls[i]
var colon = -1
for k in 0 .. len(d) {
if colon < 0 and d[k] == 58 { colon = k }
}
if colon < 0 {
if len(tpl_words(d)) > 0 { push(err, `"{tpl_words(d)}" is not a property: value`) }
continue
}
push(r.keys, tpl_words(d[0..colon]))
push(r.vals, tpl_value(tpl_words(d[colon + 1..len(d)]), err))
}
return r
}
function lss_split(s: string, c: int) -> []string {
let out = new []string
var at = 0
for i in 0 .. len(s) {
if s[i] == c {
push(out, s[at..i])
at = i + 1
}
}
push(out, s[at..len(s)])
return out
}
# the imports' rules, then the file's own, ordered by specificity and otherwise as written
function sheet_of(f: UiFile) -> UiSheet {
let sh = new UiSheet
sh.rules = new []UiRule
for i in 0 .. len(f.imp) {
if f.imp[i].sheet != null {
for k in 0 .. len(f.imp[i].sheet.rules) { sheet_add(sh, f.imp[i].sheet.rules[k]) }
}
}
for k in 0 .. len(f.own) { sheet_add(sh, f.own[k]) }
return sh
}
function sheet_add(sh: UiSheet, r: UiRule) -> void {
push(sh.rules, r)
var i = len(sh.rules) - 1
while i > 0 and sh.rules[i - 1].spec > r.spec {
sh.rules[i] = sh.rules[i - 1]
i -= 1
}
sh.rules[i] = r
}

View file

@ -0,0 +1,84 @@
# tpl.ludic - a template file read into what the builder walks: every attribute and every
# element's text an expression, every on-* attribute an action. A file holds <component>s and
# <screen>s under one root; a component is used as a tag of its own name anywhere after.
export property UiTpl {
tag: string = ""
keys: []string = null
vals: []UiExpr = null
on_keys: []string = null
on_acts: []UiAct = null
text: UiExpr = null
kids: []UiTpl = null
comp: UiTpl = null # the component this tag names, found when its file loads
sheet: UiSheet = null # the styles of the file it was written in
style: UiRule = null # its style="..." attribute, read as declarations
raw: string = "" # a <style>'s text, which is not a template's
}
function tpl_read(x: Xml, file: string) -> UiTpl {
let t = new UiTpl
t.tag = Xml.tag(x)
t.keys = new []string
t.vals = new []UiExpr
t.on_keys = new []string
t.on_acts = new []UiAct
t.kids = new []UiTpl
let err = new []string
for i in 0 .. len(x.akeys) {
let k: string = x.akeys[i]
let v: string = x.avals[i]
if len(k) > 3 and k[0..3] == "on-" {
push(t.on_keys, tpl_event(k[3..len(k)]))
push(t.on_acts, act_parse(v, err))
} else if k == "onclick" {
push(t.on_keys, "press")
push(t.on_acts, act_parse(v, err))
} else if k == "style" { t.style = lss_decls(v, err) } else {
push(t.keys, k)
push(t.vals, tpl_value(v, err))
}
}
for i in 0 .. Xml.child_count(x) { push(t.kids, tpl_read(Xml.child(x, i), file)) }
if t.tag == "style" {
t.raw = Xml.text(x)
return t
}
let words = tpl_words(Xml.text(x))
if len(words) > 0 and len(t.kids) == 0 { t.text = ex_text(words, err) }
for i in 0 .. len(err) { ui_err(`{file}: <{t.tag}> {err[i]}`) }
return t
}
# on-click is on-press, as a browser would have it
function tpl_event(name: string) -> string {
if name == "click" { return "press" }
return name
}
# an attribute: a plain number is a number, anything else text with {holes}
function tpl_value(v: string, err: []string) -> UiExpr {
var num = len(v) > 0
for i in 0 .. len(v) {
if not ((v[i] >= 48 and v[i] <= 57) or v[i] == 46 or (i == 0 and v[i] == 45)) { num = false }
}
if num { return ex_parse(v, err) }
return ex_text(v, err)
}
# text as a reader sees it: the ends trimmed and every run of white space one space
function tpl_words(s: string) -> string {
var out = ""
var gap = false
for i in 0 .. len(s) {
let c = s[i]
if c == 32 or c == 9 or c == 10 or c == 13 { gap = len(out) > 0 } else {
if gap { out = out + " " }
gap = false
out = out + s[i..i + 1]
}
}
return out
}
function tpl_attr(t: UiTpl, key: string) -> UiExpr {
for i in 0 .. len(t.keys) {
if t.keys[i] == key { return t.vals[i] }
}
return null
}

View file

@ -0,0 +1,70 @@
# values.ludic - arithmetic, comparison, truth and text over a template's values. A sum of two
# whole numbers stays whole; anything with a fraction in it is a float; a string in a sum joins.
function vl_bool(b: bool) -> Val {
if b { return Value.bool(1) }
return Value.bool(0)
}
function vl_num(v: Val) -> bool { return Value.kind(v) == 1 or Value.kind(v) == 2 or Value.kind(v) == 7 }
function vl_truthy(v: Val) -> bool {
let k = Value.kind(v)
if k == 0 { return false }
if k == 1 or k == 3 { return Value.as_int(v) != 0 }
if k == 2 or k == 7 { return Value.as_float(v) != 0.0 }
if k == 4 { return len(Value.as_str(v)) > 0 }
return true
}
function vl_text(v: Val) -> string {
let k = Value.kind(v)
if k == 0 { return "" }
if k == 1 { return string(Value.as_int(v)) }
if k == 3 {
if Value.as_int(v) != 0 { return "true" }
return "false"
}
if k == 4 { return Value.as_str(v) }
if k == 2 or k == 7 {
let f = Value.as_float(v)
if f == float(int(f)) { return string(int(f)) }
return string(f)
}
return Json.encode(v)
}
function vl_arith(op: string, a: Val, b: Val) -> Val {
if Value.kind(a) == 1 and Value.kind(b) == 1 {
let x = Value.as_int(a)
let y = Value.as_int(b)
if op == "+" { return Value.int(x + y) }
if op == "-" { return Value.int(x - y) }
if op == "*" { return Value.int(x * y) }
if y == 0 { return Value.int(0) }
if op == "/" { return Value.int(x / y) }
return Value.int(x % y)
}
let x = Value.as_float(a)
let y = Value.as_float(b)
if op == "+" { return Value.float(x + y) }
if op == "-" { return Value.float(x - y) }
if op == "*" { return Value.float(x * y) }
if y == 0.0 { return Value.float(0.0) }
if op == "/" { return Value.float(x / y) }
return Value.float(x - y * float(int(x / y)))
}
function vl_equal(a: Val, b: Val) -> bool {
if vl_num(a) and vl_num(b) { return Value.as_float(a) == Value.as_float(b) }
if Value.kind(a) != Value.kind(b) { return false }
if Value.kind(a) == 4 { return Value.as_str(a) == Value.as_str(b) }
if Value.kind(a) == 3 { return Value.as_int(a) == Value.as_int(b) }
return Value.kind(a) == 0
}
function vl_binary(op: string, a: Val, b: Val) -> Val {
if op == "==" { return vl_bool(vl_equal(a, b)) }
if op == "!=" { return vl_bool(not vl_equal(a, b)) }
if op == "+" and (Value.kind(a) == 4 or Value.kind(b) == 4) { return Value.str(vl_text(a) + vl_text(b)) }
if op == "+" or op == "-" or op == "*" or op == "/" or op == "%" { return vl_arith(op, a, b) }
let x = Value.as_float(a)
let y = Value.as_float(b)
if op == "<" { return vl_bool(x < y) }
if op == "<=" { return vl_bool(x <= y) }
if op == ">" { return vl_bool(x > y) }
return vl_bool(x >= y)
}