From 49b5e80af5e225e09108f18f1743dede21e2dcc1 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 19:53:26 +0300 Subject: [PATCH] ludic ui-preview: ludic.ui components previewed for a studio over stdio (R5) A prebuilt tool, bin/ludic-ui-preview (built by ludic-dev build, shipped beside ludic-lsp, run as `ludic ui-preview [--font DIR]`): ludic.ui with a backend that records every draw call as a line, and mock component classes the studio describes (load / model / calls). frame t runs ui_show at interface time t; text is measured on the CPU with the game's font.json metrics; locale goes through ludic.i18n; tree / box / rules inspect the frame. No game code, no GPU, no network. The wire protocol is frozen as tools/ui-preview/protocol-v1.md; smoke.txt is a transcript to run. ludic.ui gains, all additive: UiClass.make_of, UiBackend.said / emitted, UiRule.text / at (with comment line breaks kept so rule lines count true, and a class's styles read under its .lss path), and ui_root, ui_find, ui_rules_of, ui_rule_value, ui_building, ui_class, ui_class_load, ui_file_forget, ui_errors_clear; ui_nine_cuts_into exported. Co-Authored-By: Claude Opus 5.5 --- changes/ui-preview.md | 15 ++ install.sh | 1 + packages/ludic.ui/act.ludic | 1 + packages/ludic.ui/backend.ludic | 2 + packages/ludic.ui/class.ludic | 9 +- packages/ludic.ui/draw_nine.ludic | 2 +- packages/ludic.ui/env.ludic | 2 +- packages/ludic.ui/index.ludic | 1 + packages/ludic.ui/inspect.ludic | 71 +++++++++ packages/ludic.ui/instance.ludic | 2 +- packages/ludic.ui/style.ludic | 42 ++++- packages/ludic.ui/style_rule.ludic | 8 +- tools/ludic-cli/dev.ludic | 2 +- tools/ludic-cli/main.ludic | 3 + tools/ludic-cli/release.ludic | 2 +- tools/ludic-cli/toolchain.ludic | 4 +- tools/ludic-cli/ui_preview.ludic | 19 +++ tools/ui-preview/README.md | 59 +++++++ tools/ui-preview/calls.ludic | 66 ++++++++ tools/ui-preview/cmd_load.ludic | 83 ++++++++++ tools/ui-preview/cmd_world.ludic | 97 ++++++++++++ tools/ui-preview/cmds.ludic | 72 +++++++++ tools/ui-preview/computed.ludic | 78 +++++++++ tools/ui-preview/draw.ludic | 79 ++++++++++ tools/ui-preview/events.ludic | 74 +++++++++ tools/ui-preview/font.ludic | 80 ++++++++++ tools/ui-preview/images.ludic | 81 ++++++++++ tools/ui-preview/input.ludic | 78 +++++++++ tools/ui-preview/input_keys.ludic | 28 ++++ tools/ui-preview/inspect.ludic | 72 +++++++++ tools/ui-preview/json.ludic | 67 ++++++++ tools/ui-preview/line.ludic | 92 +++++++++++ tools/ui-preview/main.ludic | 88 +++++++++++ tools/ui-preview/mock.ludic | 89 +++++++++++ tools/ui-preview/num.ludic | 59 +++++++ tools/ui-preview/out.ludic | 78 +++++++++ tools/ui-preview/protocol-v1.md | 244 +++++++++++++++++++++++++++++ tools/ui-preview/smoke.txt | 152 ++++++++++++++++++ 38 files changed, 1986 insertions(+), 16 deletions(-) create mode 100644 changes/ui-preview.md create mode 100644 packages/ludic.ui/inspect.ludic create mode 100644 tools/ludic-cli/ui_preview.ludic create mode 100644 tools/ui-preview/README.md create mode 100644 tools/ui-preview/calls.ludic create mode 100644 tools/ui-preview/cmd_load.ludic create mode 100644 tools/ui-preview/cmd_world.ludic create mode 100644 tools/ui-preview/cmds.ludic create mode 100644 tools/ui-preview/computed.ludic create mode 100644 tools/ui-preview/draw.ludic create mode 100644 tools/ui-preview/events.ludic create mode 100644 tools/ui-preview/font.ludic create mode 100644 tools/ui-preview/images.ludic create mode 100644 tools/ui-preview/input.ludic create mode 100644 tools/ui-preview/input_keys.ludic create mode 100644 tools/ui-preview/inspect.ludic create mode 100644 tools/ui-preview/json.ludic create mode 100644 tools/ui-preview/line.ludic create mode 100644 tools/ui-preview/main.ludic create mode 100644 tools/ui-preview/mock.ludic create mode 100644 tools/ui-preview/num.ludic create mode 100644 tools/ui-preview/out.ludic create mode 100644 tools/ui-preview/protocol-v1.md create mode 100644 tools/ui-preview/smoke.txt diff --git a/changes/ui-preview.md b/changes/ui-preview.md new file mode 100644 index 00000000..74e75c80 --- /dev/null +++ b/changes/ui-preview.md @@ -0,0 +1,15 @@ +bump: minor +type: feature +**`ludic ui-preview`: a game's ludic.ui components previewed for a studio, over stdio.** A new +prebuilt tool (`bin/ludic-ui-preview`, built by `ludic-dev build` and shipped beside `ludic-lsp`) that +is ludic.ui with a backend that records every draw call as a line of text, and component classes the +studio describes: their template and styles (`load`, unsaved text welcome), their model (`model`), +their functions' answers (`calls`). `frame t` runs ludic.ui's own frame - build, cascade, layout, +input, draw - at interface time `t` and writes the draw list, then what happened (template errors +with file and line, calls, sets, emits, sounds); text is measured with the game's `font.json` +metrics on the CPU, translated through ludic.i18n with `locale`, and `tree` / `box` / `rules` +inspect the frame, rules with the file and line they were written on. No game code, no GPU, no +network. The wire protocol is frozen as `tools/ui-preview/protocol-v1.md`. ludic.ui gains what it +needs, all additive: `UiClass.make_of`, `UiBackend.said` / `emitted`, `UiRule.text` / `at`, and +`ui_root`, `ui_find`, `ui_rules_of`, `ui_class`, `ui_class_load`, `ui_file_forget`, +`ui_errors_clear`, `ui_building`. diff --git a/install.sh b/install.sh index a098427c..a5f4a2a3 100755 --- a/install.sh +++ b/install.sh @@ -226,6 +226,7 @@ install_from_source() { _stage="${TMP}/stage" mkdir -p "${_stage}/bin" cp "${_src}"/bin/ludic "${_src}"/bin/ludicc "${_src}"/bin/ludic-fmt "${_src}"/bin/ludic-lsp "${_stage}/bin/" + if [ -x "${_src}/bin/ludic-ui-preview" ]; then cp "${_src}/bin/ludic-ui-preview" "${_stage}/bin/"; fi cp -R "${_src}/runtime" "${_stage}/runtime" cp -R "${_src}/packages" "${_stage}/packages" cp "${_src}/VERSION" "${_src}/LICENSE" "${_src}/README.md" "${_stage}/" diff --git a/packages/ludic.ui/act.ludic b/packages/ludic.ui/act.ludic index 62daa47f..1346326c 100644 --- a/packages/ludic.ui/act.ludic +++ b/packages/ludic.ui/act.ludic @@ -60,6 +60,7 @@ function act_run(ui_st: mut UiState, a: UiAct, e: UiEnv) -> void { } } function act_emit(ui_st: mut UiState, name: string, e: UiEnv) -> void { + if ui_st.ui_be != null and ui_st.ui_be.emitted != null { ui_st.ui_be.emitted(name) } var at = e while at != null { if at.on_names != null { diff --git a/packages/ludic.ui/backend.ludic b/packages/ludic.ui/backend.ludic index 11f0fe53..c21ead72 100644 --- a/packages/ludic.ui/backend.ludic +++ b/packages/ludic.ui/backend.ludic @@ -20,6 +20,8 @@ export property UiBackend { read: fn(string) -> string = null # a template file's text (else Fs.read_text) now: fn() -> float = null # seconds, for animation (else frames at 60 a second) input: bool = true # false: take no input from Input.* (a test drives it) + said: fn(string) -> void = null # a template error, for a host that reports it itself (else printed) + emitted: fn(string) -> void = null # `emit name` ran, whether or not a handler took it (a host's log) } # a renderer a program installs by importing it (render3d.ludic): the first one is the backend property UiRenderer { diff --git a/packages/ludic.ui/class.ludic b/packages/ludic.ui/class.ludic index 2294a31f..dd792e1e 100644 --- a/packages/ludic.ui/class.ludic +++ b/packages/ludic.ui/class.ludic @@ -8,6 +8,7 @@ export property UiClass { lss_path: string = "" lss: string = "" make: fn() -> pointer = null + make_of: fn(UiClass) -> pointer = null # instead of make: one constructor a host's classes share, handed the class renew: fn(pointer) -> void = null # a record `make` made, put back as new: an instance mounted again props: fn(pointer, Val) -> void = null model: fn(pointer, Val) -> Val = null # fills the object it is given (the instance keeps one) @@ -24,6 +25,8 @@ export function ui_register(ui_st: mut UiState, k: UiClass) -> int { push(ui_st.uc_list, k) return len(ui_st.uc_list) } +# the class of that name, for a host that changes a class it registered (null: none) +export function ui_class(ui_st: UiState, name: string) -> UiClass { return uc_find(ui_st, name) } function uc_find(ui_st: UiState, name: string) -> UiClass { if ui_st.uc_list == null { return null } for i in 0 .. len(ui_st.uc_list) { @@ -37,7 +40,11 @@ function uc_load(ui_st: mut UiState, k: UiClass) -> void { if k.root != null { return } let f = lib_blank(k.xml_path) f.src = k.xml - if k.lss != "" { lss_text(ui_st, f, k.lss) } + if k.lss != "" { + if k.lss_path != "" { f.path = k.lss_path } # its rules and errors say the stylesheet they are in + lss_text(ui_st, f, k.lss) + f.path = k.xml_path + } f.sheet = sheet_of(f) tp_lines(ui_st, k.xml) let doc = Xml.parse(k.xml) diff --git a/packages/ludic.ui/draw_nine.ludic b/packages/ludic.ui/draw_nine.ludic index 213702f6..7c2bac41 100644 --- a/packages/ludic.ui/draw_nine.ludic +++ b/packages/ludic.ui/draw_nine.ludic @@ -4,7 +4,7 @@ # x0 x1 x2 x3 then y0 y1 y2 y3: the columns' and the rows' edges export function ui_nine_cuts(x: float, y: float, w: float, h: float, dst: float) -> []float { return ui_nine_cuts_into(new []float, x, y, w, h, dst) } # the same into a list the caller keeps, emptied first -function ui_nine_cuts_into(out: []float, x: float, y: float, w: float, h: float, dst: float) -> []float { +export function ui_nine_cuts_into(out: []float, x: float, y: float, w: float, h: float, dst: float) -> []float { let dx = Math.max(Math.min(dst, w / 2.0), 0.0) let dy = Math.max(Math.min(dst, h / 2.0), 0.0) List.clear(out) diff --git a/packages/ludic.ui/env.ludic b/packages/ludic.ui/env.ludic index bf432b35..cd525f7f 100644 --- a/packages/ludic.ui/env.ludic +++ b/packages/ludic.ui/env.ludic @@ -81,7 +81,7 @@ export function ui_err(ui_st: mut UiState, msg0: string) -> void { if ui_st.ui_errs[i] == msg { return } } push(ui_st.ui_errs, msg) - print(`ui: {msg}`) + if ui_st.ui_be != null and ui_st.ui_be.said != null { ui_st.ui_be.said(msg) } else { print(`ui: {msg}`) } } function ui_has(s: string, part: string) -> bool { let n = len(part) diff --git a/packages/ludic.ui/index.ludic b/packages/ludic.ui/index.ludic index d10bc1d9..c3e4530f 100644 --- a/packages/ludic.ui/index.ludic +++ b/packages/ludic.ui/index.ludic @@ -108,3 +108,4 @@ import "draw_order.ludic" import "draw_nine.ludic" import "draw_gradient.ludic" import "dump.ludic" +import "inspect.ludic" diff --git a/packages/ludic.ui/inspect.ludic b/packages/ludic.ui/inspect.ludic new file mode 100644 index 00000000..f4e65d5d --- /dev/null +++ b/packages/ludic.ui/inspect.ludic @@ -0,0 +1,71 @@ +# inspect.ludic - what an inspector asks of the last frame shown (ludic ui-preview, a developer's +# tools): a screen's root, an element by id, the rules that style it, and whether a build is running +# the root of the screen `screen` as it was last shown, or null +export function ui_root(ui_st: UiState, screen: string) -> UiNode { + for i in 0 .. len(ui_st.fb_roots) { + if ui_st.fb_roots[i].key == screen { return ui_st.fb_roots[i] } + } + return null +} +# the element with that id (or key) on any screen shown, or null +export function ui_find(ui_st: UiState, id: string) -> UiNode { + for i in 0 .. len(ui_st.fb_roots) { + let n = fb_find(ui_st.fb_roots[i], id) + if n != null { return n } + } + return null +} +# true while a frame's tree is being built (a model read), false while an action runs (a press) +export function ui_building(ui_st: UiState) -> bool { return ui_st.uv_on } +# the rules that match the element now, in the order the cascade applies them: the default sheet's, +# then its file's (merged with each user's sheet for a component's root); its style="..." is not one +@alloc_ok("an inspector's question, never a frame") +export function ui_rules_of(ui_st: mut UiState, n: UiNode, out: []UiRule) -> void { + List.clear(out) + ir_sheet(ui_st, n, ua_sheet(ui_st), out) + if n.ups == null { + if n.sheet != null { ir_sheet(ui_st, n, n.sheet, out) } + return + } + let at = len(out) + if n.sheet != null { ir_sheet(ui_st, n, n.sheet, out) } + for i in 0 .. len(n.ups) { + if n.ups[i].sheet != null and n.ups[i].sheet != n.sheet { ir_sheet(ui_st, n, n.ups[i].sheet, out) } + } + for i in at + 1 .. len(out) { # one order by specificity, stable, as cs_merged applies them + let r = out[i] + var j = i + while j > at and out[j - 1].spec > r.spec { + out[j] = out[j - 1] + j -= 1 + } + out[j] = r + } +} +@alloc_ok("an inspector's question, never a frame") +function ir_sheet(ui_st: mut UiState, n: UiNode, sh: UiSheet, out: []UiRule) -> void { + for i in 0 .. len(sh.rules) { + let r = sh.rules[i] + if md_ok(ui_st, r) and sel_match(ui_st, r.sel, n) { push(out, r) } + } +} +# a declaration's value as written, when it is plain text (else "" - an expression, read per element) +export function ui_rule_value(r: UiRule, k: int) -> string { + let x = r.vals[k] + if x.op == X_LIT { return vl_text(x.v) } + return "" +} +# a class's template and styles read now (else at its first show), so a host hears their errors at once +export function ui_class_load(ui_st: mut UiState, k: UiClass) -> void { uc_load(ui_st, k) } +# a file read again the next time anything imports it (a host changed its text): every class too +@alloc_ok("an inspector's edit, never a frame") +export function ui_file_forget(ui_st: mut UiState, path: string) -> void { + let keep = new []UiFile + for i in 0 .. len(ui_st.lib_files) { + if ui_st.lib_files[i].path != path { push(keep, ui_st.lib_files[i]) } + } + ui_st.lib_files = keep + uc_invalidate(ui_st) +} +# every error forgotten, so each is said again the next time it is made (a host that reloads a class) +export function ui_errors_clear(ui_st: mut UiState) -> void { List.clear(ui_st.ui_errs) } diff --git a/packages/ludic.ui/instance.ludic b/packages/ludic.ui/instance.ludic index 4f99a1c5..5e46b3a1 100644 --- a/packages/ludic.ui/instance.ludic +++ b/packages/ludic.ui/instance.ludic @@ -41,7 +41,7 @@ function in_reuse(ui_st: mut UiState, k: UiClass) -> UiInst { } let it = new UiInst it.cls = k - it.ptr = k.make() + if k.make_of != null { it.ptr = k.make_of(k) } else { it.ptr = k.make() } it.fresh = true return it } diff --git a/packages/ludic.ui/style.ludic b/packages/ludic.ui/style.ludic index 00dcbd58..365c9736 100644 --- a/packages/ludic.ui/style.ludic +++ b/packages/ludic.ui/style.ludic @@ -8,6 +8,8 @@ export property UiRule { md: UiMedia = null # the @media it sits in, if any keys: []string = null vals: []UiExpr = null + text: string = "" # its selector as written, and where: "file.lss:12" (for an inspector) + at: string = "" } export property UiSheet { rules: []UiRule = null @@ -31,7 +33,7 @@ function lss_file(ui_st: mut UiState, src: string, path: string) -> UiFile { function lss_text(ui_st: mut UiState, f: UiFile, src: string) -> void { let err = new []string let body = lss_strip(src) # a copy of its own, unless src had no comment (then src itself) - lss_block(ui_st, f, body, null, err) + lss_block(ui_st, f, body, null, err, 1) if lss_has_comment(src) { # else lss_strip handed src itself back, the caller's to keep unsafe { free(body) } # every rule, selector and value read from it is a copy } @@ -39,13 +41,18 @@ function lss_text(ui_st: mut UiState, f: UiFile, src: string) -> void { for i in 0 .. len(err) { ui_err(ui_st, `{f.path}: