From 66a2bc2214e5153c17bd6b7d496af47057a6402b Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Thu, 24 Sep 2026 15:58:47 +0300 Subject: [PATCH] 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_() -> 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", ). Styling: - stylesheets in \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 \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