feat(lang): UI components as files - component Name { ... } beside Name.xml and Name.lss

The compiler turns a component declaration into:
- a record of its props and state;
- a constructor, a props setter, a model and a call;
- a class, registered with ludic.ui at start.

Its template and styles are read from beside it and compiled in, with @import inlined, and a
missing template fails the build.

In the runtime:
- each mounted instance keeps its own props and state, and `set` in a template writes the state;
- styles are scoped to the component;
- class, style and id on a component's tag land on its root, styled by the parent's sheet too;
- ui_reload re-reads a component's files from disk and keeps instances.

The package's own module is now ludic_ui, so a program may call a directory of its own ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 16:50:12 +03:00
parent 637d07400e
commit 20d011f8e9
40 changed files with 62224 additions and 49996 deletions

View file

@ -0,0 +1,116 @@
# component.ludic — L11: a UI component is three files side by side: NAME.ludic declares it, NAME.xml
# is its template and NAME.lss its styles (optional). The declaration says what it takes, keeps and
# does, in Ludic:
#
# component ShopRow {
# prop item: Val # given by the parent's template, as an attribute
# prop picked: bool = false
# state taps: int = 0 # the instance's own, kept while it is mounted
# price: int = cost_of(item) # worked out each frame
# function label() -> string { return `{taps}` }
# on buy() { taps += 1 } # props and state are plain names here
# }
#
# Each mounted instance is a record of its props and state. The compiler reads the template and the
# styles into the program, so a missing template fails the build and nothing more has to ship, and
# registers the component with ludic.ui when the program starts.
var g_cm_name: []pointer = new []pointer
var g_cm_file: []pointer = new []pointer
var g_cm_line: []int = new []int
var g_cm_vis: []int = new []int
var g_cm_keep: [][]pointer = new [][]pointer # props and state, which are fields of the record
var g_cm_keep_ty: [][]pointer = new [][]pointer
var g_cm_prop: [][]bool = new [][]bool
var g_cm_defs: [][]Node = new [][]Node # a getter per default, or null
var g_cm_fields: [][]Node = new [][]Node
var g_cm_fnames: [][]pointer = new [][]pointer
var g_cm_calls: [][]Node = new [][]Node
var g_cm_cnames: [][]pointer = new [][]pointer
var g_cm_head: []Node = new []Node
function cm_prefix(name: pointer) -> pointer { return `cmp_{reg_lower(name)}_` }
# component NAME { members }
function parse_ui_component() -> void {
pi += 1
let name = eat_id()
let px = cm_prefix(name)
g_uses_value = true
let c = len(g_cm_name)
push(g_cm_name, name)
push(g_cm_file, g_parse_file)
push(g_cm_line, toks[pi].line)
push(g_cm_keep, new []pointer)
push(g_cm_keep_ty, new []pointer)
push(g_cm_prop, new []bool)
push(g_cm_defs, new []Node)
push(g_cm_fields, new []Node)
push(g_cm_fnames, new []pointer)
push(g_cm_calls, new []Node)
push(g_cm_cnames, new []pointer)
skipnl()
eat_op("{")
while true {
skipnl()
if is_op("}") { break }
cm_member(c, px)
}
eat_op("}")
let head = node(N_VAR)
head.s = px + "reg"
head.ty = "int"
push(g_cm_head, head)
push(prog, head)
}
function cm_member(c: int, px: pointer) -> void {
if is_id("prop") or is_id("state") {
let is_prop = is_id("prop")
pi += 1
let fname = eat_id()
eat_op(":")
let ty = ptype()
push(g_cm_keep[c], fname)
push(g_cm_keep_ty[c], ty)
push(g_cm_prop[c], is_prop)
var def: Node = null
if is_op("=") {
pi += 1
def = cm_getter(px + "def_" + fname, ty, expr())
}
push(g_cm_defs[c], def)
return
}
if is_id("function") or is_id("on") {
let ev = is_id("on")
if ev { toks[pi].text = "function" }
let f = parse_fn()
push(g_cm_cnames[c], f.s)
if ev { f.s = px + "on_" + f.s } else { f.s = px + f.s }
push(g_cm_calls[c], f)
push(prog, f)
return
}
let fname = eat_id()
var ty: pointer = null
if is_op(":") {
pi += 1
ty = ptype()
}
if not is_op("=") { perr(`component field {fname}: say what it is - {fname} = some_global`) }
pi += 1
push(g_cm_fnames[c], fname)
push(g_cm_fields[c], cm_getter(px + "get_" + fname, ty, expr()))
}
# a function of the instance that answers one expression (a field, or a default)
function cm_getter(name: pointer, ty: pointer, e: Node) -> Node {
let g = node(N_FN)
g.s = name
g.ty = ty
let r = node(S_RETURN)
r.a = e
let body = node(N_BLOCK)
push(body.kids, r)
g.a = body
push(prog, g)
return g
}

View file

@ -0,0 +1,78 @@
# component_class.ludic — L11: a component's class: its name, its template and styles as read from
# beside it (and where they are, for a developer's build to read again), and its functions; the
# registration is a global, so every component is known before the program's first frame
function cm_class(c: int, px: pointer, head: Node) -> void {
let name = g_cm_name[c]
let dir = dir_of(g_cm_file[c])
let xml_path = join_path(dir, name + ".xml")
let lss_path = join_path(dir, name + ".lss")
let xml = read_file(xml_path)
if xml == null { cm_err(c, `there is no {name}.xml beside it - a component's template is NAME.xml in its directory`) }
var lss = read_file(lss_path)
var lss_at = lss_path
if lss == null {
lss = ""
lss_at = ""
} else { lss = cm_lss_inline(c, lss, dir, 0) }
var src = `function {px}class() -> UiClass {{\n let k = new UiClass\n k.name = "{name}"\n`
src = src + ` k.xml_path = "{xml_path}"\n k.xml = {px}xml_text()\n k.lss_path = "{lss_at}"\n k.lss = {px}lss_text()\n`
src = src + ` k.make = fn {px}new\n k.props = fn {px}props\n k.model = fn {px}model\n k.call = fn {px}call\n return k\n}}\n`
vw_parse(src, g_cm_file[c], g_cm_line[c], head.vis)
cm_text_fn(c, px + "xml_text", xml, head)
cm_text_fn(c, px + "lss_text", lss, head)
let reg = node(E_CALL)
let callee = node(E_ID)
callee.s = "ui_register"
reg.a = callee
let arg = node(E_CALL)
let make = node(E_ID)
make.s = px + "class"
arg.a = make
arg.kids = new []Node
reg.kids = new []Node
push(reg.kids, arg)
head.a = reg
}
# a function answering a file's text, carried in the program as it was read
function cm_text_fn(c: int, name: pointer, text: pointer, head: Node) -> void {
let at = len(prog)
vw_parse(`function {name}() -> string {{\n return ""\n}}\n`, g_cm_file[c], g_cm_line[c], head.vis)
prog[at].a.kids[0].a.s = text
}
# a stylesheet with each `@import "x.lss";` replaced by that file (beside it, or from the project's
# root when it starts with /), so the program carries its whole look
function cm_lss_inline(c: int, text: pointer, dir: pointer, depth: int) -> pointer {
if depth > 8 { cm_err(c, "its stylesheets @import each other in a circle") }
var out = ""
var i = 0
let n = len(text)
while i < n {
var j = i
while j < n and text[j] != '\n' { j += 1 }
let line: pointer = text[i..j]
let path = cm_import_of(line)
if path == null { out = out + line + "\n" } else {
var at = join_path(dir, path)
if path[0] == '/' { at = path[1..len(path)] }
let inner = read_file(at)
if inner == null { cm_err(c, `its stylesheet imports {path}, which is not there`) }
out = out + cm_lss_inline(c, inner, dir_of(at), depth + 1) + "\n"
}
i = j + 1
}
return out
}
# the path in `@import "x";` (or with single quotes), or null for any other line
function cm_import_of(line: pointer) -> pointer {
var i = 0
while i < len(line) and (line[i] == ' ' or line[i] == '\t') { i += 1 }
if i + 7 > len(line) { return null }
if not (line[i..i + 7] == "@import") { return null }
var a = i + 7
while a < len(line) and line[a] != '"' and line[a] != '\'' { a += 1 }
if a >= len(line) { return null }
let q = line[a]
var b = a + 1
while b < len(line) and line[b] != q { b += 1 }
return line[a + 1..b]
}

View file

@ -0,0 +1,91 @@
# component_gen.ludic — L11: when the program is read, each component gets its record, its
# constructor, a props setter, a model, a call and its class, registered with ludic.ui at start
function components_finish() -> void {
var c = 0
while c < len(g_cm_name) {
cm_finish(c)
c += 1
}
}
function cm_err(c: int, msg: pointer) -> void {
g_err_file = g_cm_file[c]
g_err_line = g_cm_line[c]
g_parsing = false
perr(`component {g_cm_name[c]}: {msg}`)
}
function cm_finish(c: int) -> void {
if find_comp("UiClass") == null { cm_err(c, `a component is shown by ludic.ui - import "ludic.ui"`) }
let name = g_cm_name[c]
let px = cm_prefix(name)
let rec = "Cmp" + name
let head = g_cm_head[c]
let file = g_cm_file[c]
let line = g_cm_line[c]
var src = `property {rec} {{\n`
var i = 0
while i < len(g_cm_keep[c]) {
src = src + ` {g_cm_keep[c][i]}: {g_cm_keep_ty[c][i]}\n`
i += 1
}
vw_parse(src + "}\n", file, line, head.vis)
cm_bodies(c, rec)
vw_parse(cm_new_src(c, px, rec), file, line, head.vis)
vw_parse(cm_props_src(c, px, rec), file, line, head.vis)
vw_parse(cm_model_src(c, px, rec, head), file, line, head.vis)
vw_parse(cm_call_src(c, px, rec, head), file, line, head.vis)
cm_class(c, px, head)
}
# every function of the component (getters, defaults, functions, events) takes the instance
function cm_bodies(c: int, rec: pointer) -> void {
var i = 0
while i < len(g_cm_defs[c]) {
if g_cm_defs[c][i] != null { cm_rewrite(c, g_cm_defs[c][i], rec) }
i += 1
}
i = 0
while i < len(g_cm_fields[c]) {
let g = g_cm_fields[c][i]
if g.ty == null { g.ty = cm_infer(c, g, g_cm_fnames[c][i]) }
cm_rewrite(c, g, rec)
i += 1
}
i = 0
while i < len(g_cm_calls[c]) {
cm_rewrite(c, g_cm_calls[c][i], rec)
i += 1
}
}
function cm_infer(c: int, g: Node, fname: pointer) -> pointer {
let e = g.a.kids[0].a
if e.kind == E_ID and cm_is_keep(c, e.s) {
var i = 0
while i < len(g_cm_keep[c]) {
if (g_cm_keep[c][i] == e.s) { return g_cm_keep_ty[c][i] }
i += 1
}
}
if e.kind == E_ID {
let d = find_global(e.s)
if d != null and d.ty != null { return d.ty }
}
if e.kind == E_INT { return "int" }
if e.kind == E_FLOAT { return "float" }
if e.kind == E_BOOL { return "bool" }
if e.kind == E_STR { return "string" }
if e.kind == E_CALL and e.a != null and e.a.kind == E_ID {
let f = find_fn(e.a.s)
if f != null and f.ty != null and not (f.ty == "void") { return f.ty }
}
g_cm_line[c] = g.line
cm_err(c, `say what type {fname} is - {fname}: int = ...`)
return "int"
}
function cm_new_src(c: int, px: pointer, rec: pointer) -> pointer {
var src = `function {px}new() -> pointer {{\n let s = new {rec}\n`
var i = 0
while i < len(g_cm_keep[c]) {
if g_cm_defs[c][i] != null { src = src + ` s.{g_cm_keep[c][i]} = {px}def_{g_cm_keep[c][i]}(s)\n` }
i += 1
}
return src + " return s\n}\n"
}

View file

@ -0,0 +1,90 @@
# component_gen2.ludic — L11: a component's props from the parent's attributes, its model for the
# template (props, state and fields), and its call (functions, events, and `set` of a state)
function cm_props_src(c: int, px: pointer, rec: pointer) -> pointer {
var src = `function {px}props(p: pointer, v: Val) -> void {{\n let s: {rec} = p\n`
var i = 0
while i < len(g_cm_keep[c]) {
if g_cm_prop[c][i] {
let k = g_cm_keep[c][i]
let got = cm_from(c, g_cm_keep_ty[c][i], "value_get(v, \"" + k + "\")")
src = src + ` if value_has(v, "{k}") != 0 {{ s.{k} = {got} }}\n`
}
i += 1
}
return src + "}\n"
}
# a template's value as a prop or state of the component's own type
function cm_from(c: int, ty: pointer, x: pointer) -> pointer {
if (ty == "Val") { return x }
if (ty == "int") { return `value_as_int({x})` }
if (ty == "float") { return `value_as_float({x})` }
if (ty == "bool") { return `value_as_int({x}) != 0` }
if (ty == "string") { return `value_as_str({x})` }
cm_err(c, `a prop or state is an int, a float, a bool, a string or a Val, not a {ty}`)
return x
}
function cm_model_src(c: int, px: pointer, rec: pointer, head: Node) -> pointer {
var src = `function {px}model(p: pointer) -> Val {{\n let s: {rec} = p\n let o = value_object()\n`
var i = 0
while i < len(g_cm_keep[c]) {
let k = g_cm_keep[c][i]
let put = cm_to(c, g_cm_keep_ty[c][i], "s." + k, head)
src = src + ` value_put(o, "{k}", {put})\n`
i += 1
}
i = 0
while i < len(g_cm_fields[c]) {
let g = g_cm_fields[c][i]
let put = cm_to(c, g.ty, g.s + "(s)", head)
src = src + ` value_put(o, "{g_cm_fnames[c][i]}", {put})\n`
i += 1
}
return src + " return o\n}\n"
}
function cm_to(c: int, ty: pointer, x: pointer, head: Node) -> pointer {
if (ty == "Val") { return x }
let conv = vw_conv(0, ty, x, head)
if conv == null { cm_err(c, `a {ty} is something a template cannot read`) }
return conv
}
# call(name, args): a function or an event by name, or "set:NAME" for a state the template sets.
# A name it does not know answers a Value of kind 99, which the runtime reports.
function cm_call_src(c: int, px: pointer, rec: pointer, head: Node) -> pointer {
var src = `function {px}call(p: pointer, name: string, args: Val) -> Val {{\n let s: {rec} = p\n`
let calls = g_cm_calls[c]
var i = 0
while i < len(calls) {
let f = calls[i]
var args = "s"
var k = 1
while k < len(f.kids) {
args = args + ", " + cm_arg(c, f.kids[k], k - 1)
k += 1
}
src = src + ` if name == "{g_cm_cnames[c][i]}" {{\n`
let call = f.s + "(" + args + ")"
if f.ty == "void" { src = src + ` {call}\n return value_null()\n` } else {
let back = cm_to(c, f.ty, call, head)
src = src + ` return {back}\n`
}
src = src + " }\n"
i += 1
}
i = 0
while i < len(g_cm_keep[c]) {
if not g_cm_prop[c][i] {
let k = g_cm_keep[c][i]
let got = cm_from(c, g_cm_keep_ty[c][i], "value_at(args, 0)")
src = src + ` if name == "set:{k}" {{\n s.{k} = {got}\n return value_null()\n }}\n`
}
i += 1
}
return src + " return value_new(99)\n}\n"
}
# a parameter out of the template's argument list
function cm_arg(c: int, p: Node, i: int) -> pointer {
let at = `value_at(args, {i})`
if (p.ty == "int") or (p.ty == "float") or (p.ty == "bool") or (p.ty == "string") or (p.ty == "Val") { return cm_from(c, p.ty, at) }
cm_err(c, `{p.s} is a {p.ty}; a template can pass an int, a float, a bool, a string or a Val`)
return ""
}

View file

@ -0,0 +1,99 @@
# component_rw.ludic — L11: a component's code names its props, state, fields and functions plainly;
# each becomes the instance's: `taps += 1` is `self.taps += 1`, `label()` is cmp_x_label(self), a
# field is its getter called on self. Every function of the component takes `self` first.
function cm_rewrite(c: int, f: Node, self_ty: pointer) -> void {
var i = 0
while i < len(f.kids) {
let p = f.kids[i]
if cm_is_keep(c, p.s) or cm_is_field(c, p.s) {
g_err_file = f.file
g_err_line = f.line
g_parsing = false
perr(`component {g_cm_name[c]}: the parameter {p.s} has the name of its prop, state or field; call it something else`)
}
i += 1
}
let me = node(N_PARAM)
me.s = "self"
me.ty = self_ty
let kids = new []Node
push(kids, me)
i = 0
while i < len(f.kids) {
push(kids, f.kids[i])
i += 1
}
f.kids = kids
cm_rw(c, f.a)
}
function cm_is_keep(c: int, s: pointer) -> bool {
var i = 0
while i < len(g_cm_keep[c]) {
if (g_cm_keep[c][i] == s) { return true }
i += 1
}
return false
}
function cm_is_field(c: int, s: pointer) -> bool {
var i = 0
while i < len(g_cm_fnames[c]) {
if (g_cm_fnames[c][i] == s) { return true }
i += 1
}
return false
}
function cm_is_call(c: int, s: pointer) -> bool {
var i = 0
while i < len(g_cm_cnames[c]) {
if (g_cm_cnames[c][i] == s) { return true }
i += 1
}
return false
}
function cm_self() -> Node {
let e = node(E_ID)
e.s = "self"
return e
}
function cm_rw(c: int, n: Node) -> void {
if n == null { return }
if (n.kind == S_LET or n.kind == S_FOR) and n.s != null and (cm_is_keep(c, n.s) or cm_is_field(c, n.s)) {
g_err_file = n.file
g_err_line = n.line
g_parsing = false
perr(`component {g_cm_name[c]}: {n.s} is its own prop, state or field; name the local something else`)
}
if n.kind == E_ID and cm_is_keep(c, n.s) {
n.kind = E_MEMBER
n.a = cm_self()
return
}
if n.kind == E_ID and cm_is_field(c, n.s) {
n.kind = E_CALL
let callee = node(E_ID)
callee.s = cm_prefix(g_cm_name[c]) + "get_" + n.s
n.a = callee
n.kids = new []Node
push(n.kids, cm_self())
return
}
if n.kind == E_CALL and n.a != null and n.a.kind == E_ID and cm_is_call(c, n.a.s) and not cm_is_keep(c, n.a.s) {
n.a.s = cm_prefix(g_cm_name[c]) + n.a.s
let args = new []Node
push(args, cm_self())
var i = 0
while i < len(n.kids) {
push(args, n.kids[i])
i += 1
}
n.kids = args
}
cm_rw(c, n.a)
cm_rw(c, n.b)
cm_rw(c, n.c)
var i = 0
while i < len(n.kids) {
cm_rw(c, n.kids[i])
i += 1
}
}

View file

@ -1017,6 +1017,7 @@ function parse_one_decl() -> void {
if is_id("registry") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "of") { parse_registry(); return } # L8
if is_id("def") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].kind == TK_ID) { parse_def(); return } # L8
if is_id("view") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_view(); return } # L11
if is_id("component") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_ui_component(); return } # L11
if is_id("var") { push(prog, parse_var()); return }
if is_id("const") { push(prog, parse_const()); return }
var is_unsafe = false
@ -1514,4 +1515,5 @@ function parse_program() -> void {
}
registries_finish() # L8: each registry gets its defs
views_finish() # L11: each view gets its model and call
components_finish() # L11: each component gets its class
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff