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

@ -71,6 +71,10 @@ function bd_slot(k: UiTpl, e: UiEnv, key: string, out: []UiNode) -> void {
}
}
function bd_comp(k: UiTpl, e: UiEnv, key: string, out: []UiNode) -> bool {
if k.cls != null {
bd_class(k, e, key, out)
return true
}
if k.comp == null { return false }
let c = k.comp
let ce = env_new(null)

View file

@ -0,0 +1,45 @@
# build_class.ludic - a Ludic component's instance built: the parent's attributes are its props (id,
# class and style go on its root element instead, styled by the parent's sheet as well as its own),
# its model is its props, state and fields, and its template reads nothing else
function bd_class(k: UiTpl, e: UiEnv, key: string, out: []UiNode) -> void {
let cls = k.cls
uc_load(cls)
let it = in_get(key, cls)
let props = Value.object()
for i in 0 .. len(k.keys) {
let a = k.keys[i]
if a != "class" and a != "style" and a != "id" { Value.put(props, a, ui_eval(k.vals[i], e)) }
}
cls.props(it.ptr, props)
let ce = env_new(null)
ce.model = cls.model(it.ptr)
ce.inst = it
ce.ctx = e.ctx
ce.slot = k
ce.slot_env = 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) }
lc_note(key, k.on_keys, k.on_acts, e)
let at = len(out)
let root = new []UiTpl
push(root, cls.root)
bd_kids(root, ce, key, out)
if len(out) > at { bd_pass_down(out[at], k, e) }
}
# <Card class="wide" style="margin: 4px" id="c"> puts those on the card's own root element
function bd_pass_down(n: UiNode, k: UiTpl, e: UiEnv) -> void {
let c = tpl_attr(k, "class")
if c != null {
let parts = lss_split(ui_eval_text(c, e), 32)
for i in 0 .. len(parts) {
if len(parts[i]) > 0 { push(n.classes, parts[i]) }
}
}
let id = tpl_attr(k, "id")
if id != null { n.id = ui_eval_text(id, e) }
if k.style != null { n.style2 = k.style }
n.sheet2 = k.sheet
n.env2 = e
}

View file

@ -18,7 +18,9 @@ function cs_tree(n: UiNode) -> void {
function cs_node(n: UiNode) -> void {
cs_sheet(n, ua_sheet())
if n.sheet != null { cs_sheet(n, n.sheet) }
if n.sheet2 != null and n.sheet2 != n.sheet { cs_sheet(n, n.sheet2) }
if n.style != null { cs_rule(n, n.style) }
if n.style2 != null { cs_rule_in(n, n.style2, n.env2) }
for i in 0 .. len(n.akeys) { el_apply(n, n.akeys[i], n.vals[i]) }
}
function cs_sheet(n: UiNode, sh: UiSheet) -> void {
@ -27,13 +29,14 @@ function cs_sheet(n: UiNode, sh: UiSheet) -> void {
if md_ok(r) and sel_match(r.sel, n) { cs_rule(n, r) }
}
}
# a rule's custom properties first, so its other declarations can read them
function cs_rule(n: UiNode, r: UiRule) -> void {
function cs_rule(n: UiNode, r: UiRule) -> void { cs_rule_in(n, r, n.env) }
# a rule's custom properties first, so its other declarations can read them; values read in `e`
function cs_rule_in(n: UiNode, r: UiRule, e: UiEnv) -> void {
for j in 0 .. len(r.keys) {
if len(r.keys[j]) > 2 and r.keys[j][0..2] == "--" { el_attr(n, r.keys[j], ui_eval(r.vals[j], n.env)) }
if len(r.keys[j]) > 2 and r.keys[j][0..2] == "--" { el_attr(n, r.keys[j], ui_eval(r.vals[j], e)) }
}
for j in 0 .. len(r.keys) {
if not (len(r.keys[j]) > 2 and r.keys[j][0..2] == "--") { el_attr(n, r.keys[j], ui_eval(r.vals[j], n.env)) }
if not (len(r.keys[j]) > 2 and r.keys[j][0..2] == "--") { el_attr(n, r.keys[j], ui_eval(r.vals[j], e)) }
}
}
# what every element looks like before anyone styles it, as a browser's own sheet does

View file

@ -0,0 +1,72 @@
# class.ludic - components declared in Ludic (`component Name { ... }` beside Name.xml and
# Name.lss): the compiler registers each at start with its template, its styles and its functions,
# and a tag of its name anywhere is an instance of it
export property UiClass {
name: string = ""
xml_path: string = "" # where the template is, for a developer's build to read again
xml: string = ""
lss_path: string = ""
lss: string = ""
make: fn() -> pointer = null
props: fn(pointer, Val) -> void = null
model: fn(pointer) -> Val = null
call: fn(pointer, string, Val) -> Val = null
root: UiTpl = null # the template, once read
sheet: UiSheet = null
seen_xml: string = "" # the files as a developer's build last found them on disk
seen_lss: string = ""
}
var uc_list: []UiClass = null # filled by the compiler's registrations, before the program's globals
export function ui_register(k: UiClass) -> int {
if uc_list == null { uc_list = new []UiClass }
push(uc_list, k)
return len(uc_list)
}
function uc_find(name: string) -> UiClass {
if uc_list == null { return null }
for i in 0 .. len(uc_list) {
if uc_list[i].name == name { return uc_list[i] }
}
return null
}
# the class's template and styles, read the first time it is shown
function uc_load(k: UiClass) -> void {
if k.root != null { return }
let f = lib_blank(k.xml_path)
f.src = k.xml
if k.lss != "" { lss_text(f, k.lss) }
f.sheet = sheet_of(f)
tp_lines(k.xml)
let root = tpl_read(Xml.parse(k.xml), k.xml_path)
k.sheet = f.sheet
lib_link(f, root)
k.root = root
}
# a developer's build reads a class's files again when they change on disk: the first look is what
# they are, and a later one that differs is taken (an @import in the styles is read from disk too)
function uc_reload() -> bool {
if uc_list == null { return false }
var changed = false
for i in 0 .. len(uc_list) {
let k = uc_list[i]
if k.xml_path != "" and Fs.exists(k.xml_path) {
let x = lib_text(k.xml_path)
var l = ""
if k.lss_path != "" and Fs.exists(k.lss_path) { l = lib_text(k.lss_path) }
if k.seen_xml != "" and (x != k.seen_xml or l != k.seen_lss) {
k.xml = x
k.lss = l
k.root = null
changed = true
}
k.seen_xml = x
k.seen_lss = l
}
}
return changed
}
# every class reads its template and styles again, as when a stylesheet they import has changed
function uc_invalidate() -> void {
if uc_list == null { return }
for i in 0 .. len(uc_list) { uc_list[i].root = null }
}

View file

@ -0,0 +1,23 @@
# consts.ludic - the kinds of node, directions and the words that place things
export const UI_BOX: int = 0
export const UI_TEXT: int = 1
export const UI_SPACER: int = 2
export const UI_BUTTON: int = 3
export const UI_SCROLL: int = 4
export const UI_IMAGE: int = 5
export const UI_RULE: int = 6 # <hr>: a line across
export const UI_NATIVE: int = 7 # an element the host draws itself (ui_native)
export const UI_AUTO: float = -1000000.0 # an inset that is not set
export const UI_COL: int = 0
export const UI_ROW: int = 1
export const UI_FILL: float = -1.0
export const UI_START: int = 0
export const UI_CENTER: int = 1
export const UI_END: int = 2
export const UI_STRETCH: int = 3 # align: the whole cross axis
export const UI_BETWEEN: int = 3 # justify: the spare room between, around or evenly
export const UI_AROUND: int = 4
export const UI_EVENLY: int = 5
export const UI_INHERIT: int = -1
export const UI_NONE: int = -2

View file

@ -13,6 +13,7 @@ export property UiEnv {
on_acts: []UiAct = null
on_envs: []UiEnv = null
ctx: UiEnv = null # what a <provide> above gave, seen through components too
inst: UiInst = null # the Ludic component whose template this is
}
var ui_view: UiView = null
var ui_errs: []string = new []string
@ -54,10 +55,15 @@ function env_get(e: UiEnv, name: string) -> Val {
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
# `set name = value`: the nearest component's state of that name, or <state> that holds it
function env_set(e: UiEnv, name: string, v: Val) -> void {
var at = e
while at != null {
if at.inst != null {
let args = Value.list()
Value.add(args, v)
if Value.kind(at.inst.cls.call(at.inst.ptr, `set:{name}`, args)) != 99 { return }
}
if at.state != null and Value.has(at.state, name) != 0 {
Value.put(at.state, name, v)
return

View file

@ -42,8 +42,16 @@ function ev_call(x: UiExpr, e: UiEnv) -> Val {
for i in 0 .. Value.as_int(Value.at(args, 0)) { Value.add(out, Value.int(i)) }
return out
}
var at = e
while at != null {
if at.inst != null {
let r = at.inst.cls.call(at.inst.ptr, x.s, args)
if Value.kind(r) != 99 { return r }
}
at = at.parent
}
if ui_view == null or ui_view.call == null {
ui_err(`{x.s}(): no view is showing to ask`)
ui_err(`{x.s}(): there is no function or event of that name here`)
return Value.null()
}
return ui_view.call(x.s, args)

View file

@ -17,6 +17,8 @@ export function ui_show(screen: string, view: UiView, x: float, y: float, w: flo
# 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)
let cls = uc_find(screen)
if si < 0 and cls != null { return fr_class_nodes(cls) }
if si < 0 {
ui_err(`there is no screen {screen} - ui_load the file that has it`)
return null
@ -65,46 +67,3 @@ export function ui_press(n: UiNode) -> void {
}
# any event, as if the element had fired it - for a test
export function ui_event(n: UiNode, name: string, value: Val) -> void { nt_run(n, name, value) }
# 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.pointer_down == null { return }
let down = ui_be.pointer_down()
fr_down_now = down
if ui_be.button != null { return }
let hit = fr_hit(root, px, py)
if hit != null and down and not fr_was_down and hit.enabled {
if hit.kind == UI_NATIVE { fm_press(hit) } else { ui_fire(hit, "press", Value.null()) }
}
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 or (n.kind == UI_NATIVE and n.on_names != null and len(n.on_names) > 0) { return n }
return null
}
export function ui_hit(root: UiNode, px: float, py: float) -> UiNode { return fr_hit(root, px, py) }

View file

@ -1,8 +1,10 @@
# 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
module ludic_ui
numbers float
import "consts.ludic"
import "node.ludic"
import "view.ludic"
import "backend.ludic"
import "expr.ludic"
import "expr_parse.ludic"
@ -15,13 +17,17 @@ import "native.ludic"
import "forms.ludic"
import "life.ludic"
import "tpl.ludic"
import "tpl_lines.ludic"
import "lib.ludic"
import "lib_util.ludic"
import "class.ludic"
import "instance.ludic"
import "reload.ludic"
import "link.ludic"
import "state.ludic"
import "build.ludic"
import "build_more.ludic"
import "build_class.ludic"
import "elem.ludic"
import "attr.ludic"
import "attr_box.ludic"
@ -39,5 +45,7 @@ import "layout_text.ludic"
import "layout_run.ludic"
import "layout_wrap.ludic"
import "frame.ludic"
import "pointer.ludic"
import "screen_class.ludic"
import "draw.ludic"
import "dump.ludic"

View file

@ -0,0 +1,35 @@
# instance.ludic - a mounted component's record (its props and state), found by where it sits in the
# tree; one that a build of its screen did not reach is gone, and its state with it
property UiInst {
key: string = ""
cls: UiClass = null
ptr: pointer = null
seen: int = 0
}
var in_list: []UiInst = new []UiInst
var in_build: int = 0
function in_get(key: string, k: UiClass) -> UiInst {
for i in 0 .. len(in_list) {
let it = in_list[i]
if it.key == key and it.cls.name == k.name {
it.seen = in_build
return it
}
}
let it = new UiInst
it.key = key
it.cls = k
it.ptr = k.make()
it.seen = in_build
push(in_list, it)
return it
}
# after a build of `screen`: its instances the build did not reach are unmounted
function in_sweep(screen: string) -> void {
let keep = new []UiInst
for i in 0 .. len(in_list) {
let it = in_list[i]
if it.seen == in_build or not lc_mine(it.key, screen) { push(keep, it) }
}
in_list = keep
}

View file

@ -1,4 +1,5 @@
# lib_util.ludic - a file made, found, read, and a path beside another
# lib_util.ludic - a file made, found, read, and a path beside another (or from the project's
# root, when it starts with /)
function lib_find(names: []string, name: string) -> int {
for i in 0 .. len(names) {
if names[i] == name { return i }
@ -6,6 +7,12 @@ function lib_find(names: []string, name: string) -> int {
return -1
}
function lib_new(path: string) -> UiFile {
let f = lib_blank(path)
push(lib_files, f)
return f
}
# a file that is not one of the loaded ones (a component's own)
function lib_blank(path: string) -> UiFile {
let f = new UiFile
f.path = path
f.names = new []string
@ -14,7 +21,6 @@ function lib_new(path: string) -> UiFile {
f.imp_as = new []string
f.imp = new []UiFile
f.own = new []UiRule
push(lib_files, f)
return f
}
function lib_text(path: string) -> string {
@ -29,6 +35,7 @@ function lib_beside(path: string, src: string) -> string {
for i in 0 .. len(path) {
if path[i] == 47 { cut = i }
}
if cut < 0 or (len(src) > 0 and src[0] == 47) { return src }
if len(src) > 0 and src[0] == 47 { return src[1..len(src)] }
if cut < 0 { return src }
return path[0..cut + 1] + src
}

View file

@ -5,8 +5,16 @@ function lib_link(f: UiFile, t: UiTpl) -> void {
t.sheet = f.sheet
if not lk_element(t.tag) {
t.comp = lk_find(f, t.tag, false)
if t.comp == null { t.cls = uc_find(t.tag) }
if t.cls != null {
lk_kids(f, t)
return
}
if t.comp == null and lk_find(f, t.tag, true) != null { ui_err(`{t.where}: <{t.tag}>: that component is not exported - export="true" on it lets other files use it`) } else if t.comp == null { ui_err(`{t.where}: <{t.tag}>: there is no such element, and no component of that name here or exported by an import`) }
}
lk_kids(f, t)
}
function lk_kids(f: UiFile, t: UiTpl) -> void {
for i in 0 .. len(t.kids) { lib_link(f, t.kids[i]) }
}
function lk_element(tag: string) -> bool { return lk_in(LK_OWN, tag) or lk_in(EL_BOXES, tag) or lk_in(EL_TEXTS, tag) or nt_find(tag) != null }

View file

@ -1,33 +1,4 @@
# node.ludic - what a template becomes each frame, the view it reads, and the renderer seam
export const UI_BOX: int = 0
export const UI_TEXT: int = 1
export const UI_SPACER: int = 2
export const UI_BUTTON: int = 3
export const UI_SCROLL: int = 4
export const UI_IMAGE: int = 5
export const UI_RULE: int = 6 # <hr>: a line across
export const UI_NATIVE: int = 7 # an element the host draws itself (ui_native)
export const UI_AUTO: float = -1000000.0 # an inset that is not set
export const UI_COL: int = 0
export const UI_ROW: int = 1
export const UI_FILL: float = -1.0
export const UI_START: int = 0
export const UI_CENTER: int = 1
export const UI_END: int = 2
export const UI_STRETCH: int = 3 # align: the whole cross axis
export const UI_BETWEEN: int = 3 # justify: the spare room between, around or evenly
export const UI_AROUND: int = 4
export const UI_EVENLY: int = 5
export const UI_INHERIT: int = -1
export const UI_NONE: int = -2
# what a program lets its templates see: `view Name { ... }` writes view_name() -> UiView
export property UiView {
name: string = ""
model: fn() -> Val = null
call: fn(string, Val) -> Val = null
}
export property UiNode {
kind: int = 0
dir: int = 0
@ -41,6 +12,9 @@ export property UiNode {
vals: []Val = null # the same, as values, applied after every rule
style: UiRule = null # its style="..." declarations
sheet: UiSheet = null # the stylesheets of the file it was written in
sheet2: UiSheet = null # a component's root: its user's sheet too, and their style="..."
style2: UiRule = null
env2: UiEnv = null
parent: UiNode = null
index: int = 0 # its place among its parent's children, for :first-child and the rest
src: string = "" # an <img>'s picture

View file

@ -0,0 +1,44 @@
# pointer.ludic - where the pointer is, what it presses
# 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.pointer_down == null { return }
let down = ui_be.pointer_down()
fr_down_now = down
if ui_be.button != null { return }
let hit = fr_hit(root, px, py)
if hit != null and down and not fr_was_down and hit.enabled {
if hit.kind == UI_NATIVE { fm_press(hit) } else { ui_fire(hit, "press", Value.null()) }
}
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 or (n.kind == UI_NATIVE and n.on_names != null and len(n.on_names) > 0) { return n }
return null
}
export function ui_hit(root: UiNode, px: float, py: float) -> UiNode { return fr_hit(root, px, py) }

View file

@ -2,12 +2,17 @@
# file is re-read in the order it was first loaded (so an import is there before its importer), and
# the screens' state is kept, as React's fast refresh keeps it. True when anything changed.
export function ui_reload() -> bool {
var changed = false
var changed = uc_reload()
for i in 0 .. len(lib_files) {
let f = lib_files[i]
if f.disk and lib_text(f.path) != f.src { changed = true }
}
if not changed { return false }
ui_errs = new []string
if len(lib_files) == 0 {
print(`ui: templates reloaded`)
return true
}
let old = lib_files
lib_files = new []UiFile
lib_screen_names = new []string
@ -22,6 +27,7 @@ export function ui_reload() -> bool {
if have { continue }
if f.disk { lib_file(f.path) } else { ui_load_text(f.src, f.path) }
}
uc_invalidate()
print(`ui: templates reloaded`)
return true
}

View file

@ -0,0 +1,36 @@
# screen_class.ludic - a Ludic component shown as a whole screen
# a Ludic component shown as a screen: an instance at the top, keyed by its name
function fr_class_nodes(cls: UiClass) -> UiNode {
lc_begin()
in_build += 1
ui_view = null
uc_load(cls)
let root = fr_root(cls.name, cls.sheet)
let use = new UiTpl
use.tag = cls.name
use.cls = cls
use.keys = new []string
use.vals = new []UiExpr
use.on_keys = new []string
use.on_acts = new []UiAct
use.kids = new []UiTpl
let e = env_new(null)
e.model = Value.object()
root.env = e
bd_class(use, e, cls.name, root.children)
in_sweep(cls.name)
cs_tree(root)
return root
}
function fr_root(key: string, sheet: UiSheet) -> UiNode {
let root = new UiNode
root.key = key
root.tag = "screen"
root.sheet = sheet
root.children = new []UiNode
root.classes = new []string
root.akeys = new []string
root.avals = new []string
root.vals = new []Val
return root
}

View file

@ -10,29 +10,12 @@ export property UiTpl {
text: UiExpr = null
kids: []UiTpl = null
comp: UiTpl = null # the component this tag names, found when its file loads
cls: UiClass = null # or the Ludic-declared component it names
sheet: UiSheet = null # the styles of the file it was written in
style: UiRule = null # its style="..." attribute, read as declarations
raw: string = "" # a <style>'s text, which is not a template's
where: string = "" # file:line, for an error
}
# the text being read, and where its lines start, so an element can say which line it is on
var tp_nl: []int = new []int
function tp_lines(src: string) -> void {
tp_nl = new []int
for i in 0 .. len(src) {
if src[i] == 10 { push(tp_nl, i) }
}
}
function tp_line(at: int) -> int {
var lo = 0
var hi = len(tp_nl)
while lo < hi {
let mid = (lo + hi) / 2
if tp_nl[mid] < at { lo = mid + 1 } else { hi = mid }
}
return lo + 1
}
function tpl_read(x: Xml, file: string) -> UiTpl {
let t = new UiTpl
t.tag = Xml.tag(x)

View file

@ -0,0 +1,19 @@
# tpl_lines.ludic - which line of its file an element is on
# the text being read, and where its lines start, so an element can say which line it is on
var tp_nl: []int = new []int
function tp_lines(src: string) -> void {
tp_nl = new []int
for i in 0 .. len(src) {
if src[i] == 10 { push(tp_nl, i) }
}
}
function tp_line(at: int) -> int {
var lo = 0
var hi = len(tp_nl)
while lo < hi {
let mid = (lo + hi) / 2
if tp_nl[mid] < at { lo = mid + 1 } else { hi = mid }
}
return lo + 1
}

View file

@ -0,0 +1,8 @@
# view.ludic - a `view` declaration's bridge, for a screen written as a template file of its own
# what a program lets its templates see: `view Name { ... }` writes view_name() -> UiView
export property UiView {
name: string = ""
model: fn() -> Val = null
call: fn(string, Val) -> Val = null
}