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,37 @@
# ui_component.ludic — components as files: Counter.ludic declares props, state, a field, a function
# and an event; Counter.xml is its template; Counter.lss its scoped styles (which @import a theme).
# Two counters keep their own state, a parent's class reaches the child's root, and the child's
# `p { color }` does not leak into the parent.
import "ludic.ui"
import "ui_component_parts/Counter.ludic"
import "ui_component_parts/App.ludic"
program UiComponent {
numbers float
function texts(n: UiNode, out: []string) -> void {
if n.text != "" { push(out, n.text) }
for i in 0 .. len(n.children) { texts(n.children[i], out) }
}
function frame() -> UiNode {
let root: UiNode = ui_nodes("App", null)
ui_place(root, 0.0, 0.0, 400.0, 400.0)
return root
}
entry {
var root = frame()
let b = root.children[0].children[2]
ui_press(b.children[1])
ui_press(b.children[1])
ui_press(root.children[0].children[1].children[1])
root = frame()
let out = new []string
texts(root, out)
var joined = ""
for i in 0 .. len(out) { joined = joined + out[i] + "|" }
print(joined)
let b2 = root.children[0].children[2]
print(`b margin {int(b2.mt)}; b text {b2.children[0].fg}; outside {root.children[0].children[3].fg}; button {int(b2.children[1].ch)}`)
ui_press(b2.children[2])
root = frame()
print(root.children[0].children[2].children[0].text)
}
}

View file

@ -0,0 +1 @@
.wide { margin: 9px }

View file

@ -0,0 +1,4 @@
# App.ludic — a screen is a component too: this one has no props, only what it shows
component App {
title: string = "counters"
}

View file

@ -0,0 +1,6 @@
<div>
<h1>{title}</h1>
<Counter label="a"/>
<Counter label="b" step="{5}" class="wide"/>
<p>outside</p>
</div>

View file

@ -0,0 +1,3 @@
@import "theme.lss";
.counter { padding: 4px }
p { color: #ff0000 }

View file

@ -0,0 +1,9 @@
# Counter.ludic — a component: what it takes (props), keeps (state), works out and does
component Counter {
prop label: string
prop step: int = 1
state count: int = 0
doubled: int = count * 2
function big() -> bool { return count > 3 }
on bump() { count += step }
}

View file

@ -0,0 +1,5 @@
<div class="counter">
<p>{label}: {count} ({doubled}){big() ? ' big' : ''}</p>
<button on-click="bump()">+</button>
<button on-click="set count = 0">reset</button>
</div>

View file

@ -0,0 +1,2 @@
/* theme.lss - shared by whoever imports it */
button { height: 30px }

View file

@ -0,0 +1,8 @@
# a component's template is NAME.xml beside it; this one has none
import "ludic.ui"
program ComponentNoTemplate {
component Lonely {
prop name: string
}
entry { print("never") }
}