diff --git a/LANGUAGE.md b/LANGUAGE.md
index 23244163..e5852cd4 100644
--- a/LANGUAGE.md
+++ b/LANGUAGE.md
@@ -275,6 +275,142 @@ The entries are compiled in: nothing is parsed at start-up, and a build that suc
every resource it uses. The path is the project's (where the build runs), else beside the file that
declares the registry.
+### Default parameters, and calls that name what they change
+
+A parameter can have a default, and a call leaves out what it does not change - the last ones when
+it passes arguments by position, or any it does not name. A call can pass its first arguments by
+position and the rest by name:
+
+```ludic
+program Boxes {
+ numbers float
+ function box(label: string, w: float = 300.0, pad: float = 8.0, bold: bool = false) -> string {
+ return `{label} {w} {pad} {bold}`
+ }
+ entry {
+ print(box("a")) # every default
+ print(box("b", 120.0)) # the first two by position
+ print(box("c", bold: true)) # the subject by position, a prop by name
+ }
+}
+```
+
+A default is an expression written with the function and evaluated for each call that leaves it
+out. A call that leaves out a parameter with no default, names one the function does not have, or
+puts a positional argument after a named one is refused with that said.
+
+### Views and templates (`ludic.ui`)
+
+A screen is not code. It is a template - markup in a file the program loads at run time - and it
+sees only what the program's `view` lets it: its fields to read, its functions to ask, and its
+`on` events to send. Nothing else crosses in either direction.
+
+```ludic
+# doc-check: skip — a fragment of a program that imports ludic.ui
+view Shop {
+ purse = purse # a global's own type
+ stock = stock # a list of records is a list of objects
+ owned: int = len(bought) # any expression, with its type said
+ function afford(i: int) -> bool { return purse >= stock[i].cost }
+ on buy(i: int) { purse -= stock[i].cost }
+}
+```
+
+`view Shop` writes `view_shop() -> UiView`. Its model is a `Value` object of every field, with a
+record as an object of the fields a template can read and a list or registry as a list. Its `call`
+runs a function or an event by name with arguments from the template: an int, a float, a bool or a
+string.
+
+```xml
+
+
+
+
+ The purse: ${purse}
+
+
+
+
+
+ Nothing bought yet
+ {owned} bought
+
+
+```
+
+- **Elements.** A template is HTML-shaped:
+ - `div`, `section`, `header`, `footer`, `nav`, `main`, `article`, `aside`, `ul`, `ol`, `li` and
+ `form` are boxes laid out in a column; `row` is one laid out in a row.
+ - `p`, `span`, `label`, `h1`-`h6`, `strong`, `em`, `small`, `b`, `i` and `a` are text; words
+ inside a box become a text of their own.
+ - `button`, `img src`, `hr` and `spacer`; `scroll` is a column that scrolls.
+
+ A default stylesheet, like a browser's, sizes the headings and pads the buttons.
+- **Attributes.** `id`, `class` (which may be bound: `class="{picked ? 'on' : ''} row"`),
+ `style="padding: 4px; color: gold"`, `hidden`, `disabled`, `onclick` or `on-click` (also
+ `on-press`), and any attribute a property is named after (`width="300"`). Any other attribute is
+ kept for `[attr=value]` selectors, as HTML's are.
+- **The box model and flex.** Sizes are border boxes.
+ - `padding` and `margin` take one to four lengths, or one side by name (`padding-left`).
+ - `border` is `2px solid #ffcc00`, or `border-width` and `border-color`.
+ - A length is `12`, `12px`, `50%`, `fit`/`auto` or `fill`.
+ - `flex-grow` (or `flex`) shares out the spare room along `flex-direction`, and `fill` is a share
+ of 1. `justify-content` takes `flex-start`/`start`, `center`, `end`, `space-between`,
+ `space-around` or `space-evenly`.
+ - `align-items` and `align-self` take `start`, `center`, `end` or `stretch`. `flex-wrap: wrap`
+ breaks a row into lines, and `min-`/`max-width`/`-height` bound it.
+ - `gap`, `text-align`, `display: none`, `background(-color)`, `color`, `opacity` and `font-size`
+ are CSS's.
+
+ Every property also has a short name (`w`, `h`, `pad`, `bg`, `size`, `grow`, `align`,
+ `justify`, `self`, `dir`, `wrap`, `alpha`). Rounded corners, font weight and a few more are
+ things the renderer does not draw, and the runtime says so rather than ignoring them. A colour
+ is the renderer's name for one, `#rrggbb` or `#rgb`.
+- **Stylesheets.** Rules go in a `\n\n \n ab\n \n lmr\n \n t{i}\n \n
x\n\n\n"
+ entry {
+ ui_load_text(BASE, "ui/base.lss")
+ ui_load_text(THEME, "ui/theme.lss")
+ ui_load_text(PAGE, "ui/flex.xml")
+ let root: UiNode = ui_nodes("flex", view_tags())
+ ui_place(root, 0.0, 0.0, 400.0, 300.0)
+ let bar = root.children[0]
+ let wrap = root.children[2]
+ let tall = root.children[3]
+ print(`grow {int(bar.children[0].cw)}:{int(bar.children[1].cw)}; between at {int(root.children[1].children[1].x)}; wrap lines at {int(wrap.children[0].y)}, {int(wrap.children[3].y)}; red {wrap.children[1].fg}; stretched {int(tall.children[0].ch)}; own size {int(tall.children[1].rsize)}`)
+ }
+}
diff --git a/examples/library/ui_html.ludic b/examples/library/ui_html.ludic
new file mode 100644
index 00000000..b4ceafcf
--- /dev/null
+++ b/examples/library/ui_html.ludic
@@ -0,0 +1,28 @@
+# ui_html.ludic — ludic.ui read as HTML and CSS: HTML's elements with a browser's defaults, ids,
+# classes, style="...", hidden and disabled, onclick; selectors with ids, descendants, children,
+# attributes and pseudo-classes, weighed by specificity; CSS's own property names and the box model.
+import "ludic.ui"
+program UiHtml {
+ numbers float
+ var clicks: int = 0
+ view Page {
+ clicks = clicks
+ on clicked() { clicks += 1 }
+ }
+ const LOOK: string = "/* look.lss */\n.card { padding: 4 8 12 16; margin: 3; border: 2px solid #0000ff; width: 50% }\n#title { font-size: 40 }\nh1.big { font-size: 20 }\n.card p { color: #00ff00 }\nul > li { height: 30 }\nli:first-child { color: #ff0000 }\nli:nth-child(even) { color: #0000ff }\nli:last-child:not(.keep) { display: none }\n[kind=warn] { background-color: #ffff00 }\nbutton:disabled { opacity: 0.5 }\n"
+ const PAGE: string = "\n\n\n
Title
\n
inside
careful
\n
outside
\n
one
two
three
gone
\n \n
mid
\n \n
never
\n\n\n"
+ entry {
+ ui_load_text(LOOK, "ui/look.lss")
+ ui_load_text(PAGE, "ui/page.ludic.xml")
+ var root: UiNode = ui_nodes("page", view_page())
+ ui_place(root, 0.0, 0.0, 400.0, 600.0)
+ let card = root.children[1]
+ let items = root.children[3].children
+ print(`id beats class: {int(root.children[0].rsize)}; card {int(card.cw)} wide at {int(card.x)},{int(card.y)} pad {int(card.pt)} {int(card.pr)} {int(card.pb)} {int(card.pl)} border {int(card.border)}`)
+ print(`inside {card.children[0].fg}, outside {root.children[2].fg}; warn bg {card.children[1].bg}; items {len(items)} of 30: {int(items[0].ch)}; colours {items[0].fg} {items[1].fg} {items[2].fg}; hr {int(root.children[4].ch)}; children {len(root.children)}`)
+ ui_press(root.children[6])
+ root = ui_nodes("page", view_page())
+ ui_place(root, 0.0, 0.0, 400.0, 600.0)
+ print(`clicks {clicks}; the button now disabled {not root.children[6].enabled} at opacity {root.children[6].alpha}; text-align {root.children[5].talign}`)
+ }
+}
diff --git a/examples/library/ui_templates.ludic b/examples/library/ui_templates.ludic
new file mode 100644
index 00000000..1a380ad6
--- /dev/null
+++ b/examples/library/ui_templates.ludic
@@ -0,0 +1,48 @@
+# ui_templates.ludic — ludic.ui without a renderer: a shop written as a template, reading a view
+# of the program's state and answering with its events. A press is made as a pointer would make
+# it; it runs the view's event once the frame is built, and a component's own state keeps. The
+# row comes from a component library, whose unexported Price no other file can use.
+import "ludic.ui"
+program UiTemplates {
+ numbers float
+ property Item {
+ name: string = ""
+ cost: int = 0
+ }
+ var stock: []Item = new []Item
+ var purse: int = 100
+ var bought: []int = new []int
+ function add(name: string, cost: int) -> void {
+ let it = new Item
+ it.name = name
+ it.cost = cost
+ push(stock, it)
+ }
+ view Shop {
+ purse = purse
+ stock = stock
+ owned: int = len(bought)
+ function afford(i: int) -> bool { return purse >= stock[i].cost }
+ on buy(i: int) {
+ purse -= stock[i].cost
+ push(bought, i)
+ }
+ }
+ const KIT: string = "\n${cost}\n\n \n \n {item.name} -\n \n \n {picks}\n \n \n\n\n"
+ const PAGE: string = "\n\n\n \n The purse: ${purse}\n \n \n \n Nothing bought yet\n {owned} bought, the last {pick + 1}\n\n\n"
+ const PRIVATE: string = ""
+ entry {
+ add("Rope", 30)
+ add("Lantern", 80)
+ ui_load_text(KIT, "ui/kit.xml")
+ ui_load_text(PAGE, "ui/shop.xml")
+ ui_load_text(PRIVATE, "ui/peek.xml")
+ var root: UiNode = ui_nodes("shop", view_shop())
+ ui_place(root, 0.0, 0.0, 600.0, 400.0)
+ let press = root.children[1].children[4]
+ ui_press(ui_hit(root, press.x + 1.0, press.y + 1.0))
+ root = ui_nodes("shop", view_shop())
+ ui_place(root, 0.0, 0.0, 600.0, 400.0)
+ print(`purse {purse}, bought {len(bought)}; the lantern is enabled: {root.children[2].children[4].enabled}`)
+ }
+}
diff --git a/examples/rejected/default_missing.ludic b/examples/rejected/default_missing.ludic
new file mode 100644
index 00000000..f1800855
--- /dev/null
+++ b/examples/rejected/default_missing.ludic
@@ -0,0 +1,5 @@
+# L11: a call leaves out only what has a default
+program DefaultMissing {
+ function f(a: int, b: int = 2) -> int { return a + b }
+ entry { print(f(b: 3)) }
+}
diff --git a/examples/rejected/default_unknown.ludic b/examples/rejected/default_unknown.ludic
new file mode 100644
index 00000000..9cdc9d19
--- /dev/null
+++ b/examples/rejected/default_unknown.ludic
@@ -0,0 +1,5 @@
+# L11: a named argument names a parameter the function has
+program DefaultUnknown {
+ function f(a: int, b: int = 2) -> int { return a + b }
+ entry { print(f(1, c: 3)) }
+}
diff --git a/examples/rejected/positional_after_named.ludic b/examples/rejected/positional_after_named.ludic
new file mode 100644
index 00000000..6ff94078
--- /dev/null
+++ b/examples/rejected/positional_after_named.ludic
@@ -0,0 +1,5 @@
+# L11: positional arguments come first, then named ones
+program PositionalAfterNamed {
+ function f(a: int, b: int = 2) -> int { return a + b }
+ entry { print(f(a: 1, 2)) }
+}
diff --git a/examples/rejected/view_untyped.ludic b/examples/rejected/view_untyped.ludic
new file mode 100644
index 00000000..de2f3b7e
--- /dev/null
+++ b/examples/rejected/view_untyped.ludic
@@ -0,0 +1,9 @@
+# a view field that is not a bare global has to say its type
+import "ludic.ui"
+program ViewUntyped {
+ var a: int = 1
+ view Sums {
+ total = a + 2
+ }
+ entry { print(view_sums().name) }
+}
diff --git a/examples/rejected/view_without_ui.ludic b/examples/rejected/view_without_ui.ludic
new file mode 100644
index 00000000..5470cda8
--- /dev/null
+++ b/examples/rejected/view_without_ui.ludic
@@ -0,0 +1,8 @@
+# a view is for templates, which ludic.ui shows
+program ViewWithoutUi {
+ var a: int = 1
+ view Sums {
+ a = a
+ }
+ entry { print(a) }
+}
diff --git a/packages/ludic.ui/act.ludic b/packages/ludic.ui/act.ludic
new file mode 100644
index 00000000..c9ccb2f3
--- /dev/null
+++ b/packages/ludic.ui/act.ludic
@@ -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
+ }
+}
diff --git a/packages/ludic.ui/attr.ludic b/packages/ludic.ui/attr.ludic
new file mode 100644
index 00000000..3f9c8158
--- /dev/null
+++ b/packages/ludic.ui/attr.ludic
@@ -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 }
+}
diff --git a/packages/ludic.ui/attr_box.ludic b/packages/ludic.ui/attr_box.ludic
new file mode 100644
index 00000000..4d7377d0
--- /dev/null
+++ b/packages/ludic.ui/attr_box.ludic
@@ -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
+}
diff --git a/packages/ludic.ui/backend.ludic b/packages/ludic.ui/backend.ludic
new file mode 100644
index 00000000..f24fc9dc
--- /dev/null
+++ b/packages/ludic.ui/backend.ludic
@@ -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 ; 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
+}
diff --git a/packages/ludic.ui/build.ludic b/packages/ludic.ui/build.ludic
new file mode 100644
index 00000000..048134ae
--- /dev/null
+++ b/packages/ludic.ui/build.ludic
@@ -0,0 +1,68 @@
+# build.ludic - a template and the model, made into this frame's nodes. / and
+# 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 .
+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(" 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(" 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
+}
diff --git a/packages/ludic.ui/cascade.ludic b/packages/ludic.ui/cascade.ludic
new file mode 100644
index 00000000..0631cd7f
--- /dev/null
+++ b/packages/ludic.ui/cascade.ludic
@@ -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
+}
diff --git a/packages/ludic.ui/draw.ludic b/packages/ludic.ui/draw.ludic
new file mode 100644
index 00000000..e1f67bb3
--- /dev/null
+++ b/packages/ludic.ui/draw.ludic
@@ -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
+}
diff --git a/packages/ludic.ui/elem.ludic b/packages/ludic.ui/elem.ludic
new file mode 100644
index 00000000..695d9ddc
--- /dev/null
+++ b/packages/ludic.ui/elem.ludic
@@ -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
words
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
+}
diff --git a/packages/ludic.ui/env.ludic b/packages/ludic.ui/env.ludic
new file mode 100644
index 00000000..5807675b
--- /dev/null
+++ b/packages/ludic.ui/env.ludic
@@ -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 `)
+}
+# 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}`)
+}
diff --git a/packages/ludic.ui/eval.ludic b/packages/ludic.ui/eval.ludic
new file mode 100644
index 00000000..612378ff
--- /dev/null
+++ b/packages/ludic.ui/eval.ludic
@@ -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)) }
diff --git a/packages/ludic.ui/expr.ludic b/packages/ludic.ui/expr.ludic
new file mode 100644
index 00000000..42da76f9
--- /dev/null
+++ b/packages/ludic.ui/expr.ludic
@@ -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}"` }
+}
diff --git a/packages/ludic.ui/expr_parse.ludic b/packages/ludic.ui/expr_parse.ludic
new file mode 100644
index 00000000..f14f6f12
--- /dev/null
+++ b/packages/ludic.ui/expr_parse.ludic
@@ -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
+}
diff --git a/packages/ludic.ui/expr_prim.ludic b/packages/ludic.ui/expr_prim.ludic
new file mode 100644
index 00000000..38350312
--- /dev/null
+++ b/packages/ludic.ui/expr_prim.ludic
@@ -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
+}
diff --git a/packages/ludic.ui/frame.ludic b/packages/ludic.ui/frame.ludic
new file mode 100644
index 00000000..61a25518
--- /dev/null
+++ b/packages/ludic.ui/frame.ludic
@@ -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) }
diff --git a/packages/ludic.ui/index.ludic b/packages/ludic.ui/index.ludic
new file mode 100644
index 00000000..14183d85
--- /dev/null
+++ b/packages/ludic.ui/index.ludic
@@ -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"
diff --git a/packages/ludic.ui/layout.ludic b/packages/ludic.ui/layout.ludic
new file mode 100644
index 00000000..31c27890
--- /dev/null
+++ b/packages/ludic.ui/layout.ludic
@@ -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
+}
diff --git a/packages/ludic.ui/layout_run.ludic b/packages/ludic.ui/layout_run.ludic
new file mode 100644
index 00000000..c0a69a6c
--- /dev/null
+++ b/packages/ludic.ui/layout_run.ludic
@@ -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
+ }
+}
diff --git a/packages/ludic.ui/layout_wrap.ludic b/packages/ludic.ui/layout_wrap.ludic
new file mode 100644
index 00000000..a3e81169
--- /dev/null
+++ b/packages/ludic.ui/layout_wrap.ludic
@@ -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
+ }
+}
diff --git a/packages/ludic.ui/lib.ludic b/packages/ludic.ui/lib.ludic
new file mode 100644
index 00000000..1744785e
--- /dev/null
+++ b/packages/ludic.ui/lib.ludic
@@ -0,0 +1,78 @@
+# lib.ludic - template files. A file's s are its own unless marked export="true";
+# brings a file's exported components in by their names, and
+# as , so a component library is a file of them and two
+# libraries never collide. A file's