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 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 19:53:26 +03:00
parent 28f7b4666b
commit 49b5e80af5
38 changed files with 1986 additions and 16 deletions

15
changes/ui-preview.md Normal file
View file

@ -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`.

View file

@ -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}/"

View file

@ -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 {

View file

@ -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 {

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -108,3 +108,4 @@ import "draw_order.ludic"
import "draw_nine.ludic"
import "draw_gradient.ludic"
import "dump.ludic"
import "inspect.ludic"

View file

@ -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) }

View file

@ -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
}

View file

@ -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}: <style> {err[i]}`) }
unsafe { free(err) }
}
# rules, @imports and @media blocks; the rules in a @media block carry its condition
function lss_block(ui_st: mut UiState, f: UiFile, src: string, md: UiMedia, err: []string) -> void {
# rules, @imports and @media blocks; the rules in a @media block carry its condition. `line` is the
# line src starts on, so each rule knows where it was written (a host's inspector asks)
function lss_block(ui_st: mut UiState, f: UiFile, src: string, md: UiMedia, err: []string, line0: int) -> void {
let r = new UiRd
r.s = src
var line = line0
var seen = 0
while r.err == "" {
rd_ws(r)
if r.i >= len(r.s) { break }
line = line + lss_lines(src, seen, r.i)
seen = r.i
if rd_op(r, "@import") {
lss_import(ui_st, f, lss_upto(r, 59))
continue
@ -59,15 +66,16 @@ function lss_block(ui_st: mut UiState, f: UiFile, src: string, md: UiMedia, err:
}
if rd_op(r, "@media") {
let cond = md_parse(lss_upto(r, 123))
let mline = line + lss_lines(src, seen, r.i)
let mb = lss_upto(r, 125)
lss_block(ui_st, f, mb, cond, err)
lss_block(ui_st, f, mb, cond, err, mline)
unsafe { free(mb) }
continue
}
let sels = lss_upto(r, 123)
let body = lss_upto(r, 125)
let at = len(f.own)
lss_rule(f, sels, body, err)
lss_rule(f, sels, body, err, line + lss_lead(sels))
for i in at .. len(f.own) { f.own[i].md = md }
unsafe { # a rule keeps what it parsed, never the text it was sliced from
free(sels)
@ -77,6 +85,22 @@ function lss_block(ui_st: mut UiState, f: UiFile, src: string, md: UiMedia, err:
if r.err != "" { push(err, r.err) }
unsafe { free(r) } # the reader only: its text is the caller's
}
# the line breaks in src between two offsets
function lss_lines(src: string, from: int, to: int) -> int {
var n = 0
for i in from .. to {
if src[i] == 10 { n += 1 }
}
return n
}
# the line breaks before a selector's first word (rd_ws stops at the first non-space)
function lss_lead(s: string) -> int {
var n = 0
for i in 0 .. len(s) {
if s[i] == 10 { n += 1 } else if s[i] != 32 and s[i] != 9 and s[i] != 13 { return n }
}
return n
}
# @import "base.lss"; - its rules come before this file's own
function lss_import(ui_st: mut UiState, f: UiFile, what: string) -> void {
var p = tpl_words(what)
@ -112,7 +136,13 @@ function lss_strip(src: string) -> string {
while i < len(src) {
if i + 1 < len(src) and src[i] == 47 and src[i + 1] == 42 {
var j = i + 2
while j + 1 < len(src) and not (src[j] == 42 and src[j + 1] == 47) { j += 1 }
while j + 1 < len(src) and not (src[j] == 42 and src[j + 1] == 47) {
if src[j] == 10 { # a comment's line breaks stay, so a rule's line is its own
unsafe { p[n] = 10 }
n += 1
}
j += 1
}
i = j + 2
} else {
unsafe { p[n] = src[i] }

View file

@ -1,10 +1,10 @@
# style_rule.ludic - a rule read, the cascade a file sees, and a node's rules applied to it
function lss_rule(f: UiFile, sels: string, body: string, err: []string) -> void {
function lss_rule(f: UiFile, sels: string, body: string, err: []string, line: int) -> void {
let decls = lss_decls(body, err)
let each = lss_split(sels, 44)
for i in 0 .. len(each) {
let sel = tpl_words(each[i])
if len(sel) > 0 { lss_rule_add(f, decls, sel, err) }
if len(sel) > 0 { lss_rule_add(f, decls, sel, err, line) }
unsafe { # a selector keeps its parsed parts, never its text
if not tpl_clean(each[i]) { free(sel) }
free(each[i])
@ -15,10 +15,12 @@ function lss_rule(f: UiFile, sels: string, body: string, err: []string) -> void
free(decls) # the holder only: each rule keeps its keys and values lists
}
}
function lss_rule_add(f: UiFile, decls: UiRule, sel: string, err: []string) -> void {
function lss_rule_add(f: UiFile, decls: UiRule, sel: string, err: []string, line: int) -> void {
let r = new UiRule
r.keys = decls.keys
r.vals = decls.vals
r.text = `{sel}` # a copy: the selector's text is given back below
r.at = `{f.path}:{line}`
r.sel = sel_parse(sel, err)
if r.sel != null {
r.spec = sel_spec(r.sel)

View file

@ -48,7 +48,7 @@ program LudicDev {
print("which ships to users and carries none of the below.")
print("")
print("build:")
print(" build build the whole toolchain into bin/ (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp)")
print(" build build the whole toolchain into bin/ (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp, ludic-ui-preview)")
print(" build-cli build just bin/ludicc from the IR seed")
print(" tools [--install] [--test] build the editor toolchain (ludic-fmt, ludic-lsp)")
print(" clean remove build/")

View file

@ -32,6 +32,7 @@ program Ludic {
import "scripts.ludic"
import "deps.ludic"
import "schema.ludic"
import "ui_preview.ludic"
import "testpar.ludic"
import "migrate.ludic"
@ -78,6 +79,7 @@ program Ludic {
print(" doctor check that the install is complete and usable")
print(" fmt [--check] [paths...] format Ludic source in place (--check: report, write nothing)")
print(" lsp run the language server on stdio (what editors spawn)")
print(" ui-preview [--font DIR] preview a game's ludic.ui components for a studio, on stdio")
print(" help this message")
print("")
print("package scripts (package.ludic):")
@ -110,6 +112,7 @@ program Ludic {
if (cmd == "clean") { return cmd_clean() }
if (cmd == "fmt") { return cmd_fmt() }
if (cmd == "lsp") { return cmd_lsp() }
if (cmd == "ui-preview") { return cmd_ui_preview() }
if (cmd == "doctor") { return cmd_doctor() }
if (cmd == "upgrade") { return cmd_upgrade() }
if (cmd == "script") { return cmd_script() }

View file

@ -324,7 +324,7 @@ function build_artifacts(ver: pointer) -> bool {
let name = `ludic-{ver}-{plat}-{arch}`
let stage = `{tmp_dir()}/{name}`
shell(`rm -rf {stage} && mkdir -p {stage}/bin`)
if not shq(`cp bin/ludic bin/ludicc bin/ludic-fmt bin/ludic-lsp {stage}/bin/`) {
if not shq(`cp bin/ludic bin/ludicc bin/ludic-fmt bin/ludic-lsp bin/ludic-ui-preview {stage}/bin/`) {
err("release: the toolchain is not built (run: ludic-dev build)\n")
return false
}

View file

@ -44,7 +44,9 @@ function cmd_dev_build() -> int {
if not build_tool("ludic-fmt", "tools/ludic-tools/fmt.ludic") { return 1 }
print("ludicc: tools/ludic-tools/lsp.ludic -> bin/ludic-lsp")
if not build_tool("ludic-lsp", "tools/ludic-tools/lsp.ludic") { return 1 }
print("ludicc: tools/ui-preview/main.ludic -> bin/ludic-ui-preview")
if not build_tool("ludic-ui-preview", "tools/ui-preview/main.ludic") { return 1 }
sync_vscode_grammar()
print("done. toolchain in bin/ (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp)")
print("done. toolchain in bin/ (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp, ludic-ui-preview)")
return 0
}

View file

@ -0,0 +1,19 @@
# ---- ludic ui-preview ---------------------------------------------------------
# A game's ludic.ui components previewed for a studio over stdio: ludic.ui with a backend that
# records its draw calls, and component classes the studio describes. No game code, no GPU, no
# network. The studio spawns it; tools/ui-preview/protocol-v1.md is the contract.
#
# ludic ui-preview [--font DIR] the host, on stdin and stdout
function cmd_ui_preview() -> int {
var args = ""
var ai = 2
while ai < arg_count() {
args = `{args} {sh_single(arg(ai))}`
ai += 1
}
let bin = `{ludic_home()}bin/{exe_name("ludic-ui-preview")}`
if is_exec(bin) { return sh(`exec {sh_single(bin)}{args}`) }
if capture_line(`command -v {exe_name("ludic-ui-preview")} 2>/dev/null`) != "" { return sh(`exec {exe_name("ludic-ui-preview")}{args}`) }
err("ludic ui-preview: no bin/ludic-ui-preview in this install (a checkout builds it with: ludic-dev build)\n")
return 1
}

View file

@ -0,0 +1,59 @@
# ludic ui-preview
A game's `ludic.ui` components, previewed for a studio (a UI editor) without the game: `ludic.ui`
itself, with a backend that records its draw calls instead of drawing them, and component classes
the studio describes over stdin. No game code is built, nothing touches a GPU, nothing opens a
network connection. It is the toolchain's own program, shipped prebuilt as `bin/ludic-ui-preview`.
```bash
ludic ui-preview [--font DIR] # the host on stdin/stdout; DIR holds the game's font.json
```
**The wire protocol is [protocol-v1.md](protocol-v1.md)**, frozen: every request, every reply and
every line format. `smoke.txt` is a scripted session with what each reply must contain.
## What it does
- **Components are mocks.** `load` registers a `UiClass` named as the component, with the template
and styles the studio sends (unsaved text is fine), or changes that class in place. Its model is
the story's (`model`), with the props its parent's tag gives it and whatever its template `set`s
laid over it; its functions answer from the story's table (`calls`). A component tag the studio
has not sent yet is an empty class until it is.
- **The frame is ludic.ui's own.** `frame t` sets the interface clock to `t`, hands over the input
the studio sent (`input ...`), and calls `ui_show`: build, cascade, layout, pointer and focus,
draw, then the actions a press ran. The draw list is every `UiBackend` call in painting order.
- **Text is measured as the game measures it.** `font DIR` (or `--font DIR`) reads the same
`font.json` ludic.render3d's overlay reads, and the width of a run is the overlay's sum of glyph
advances at the font's whole-pixel size; each `text` line carries it, so the studio fits its own
rendering to the game's line breaks.
- **Languages** are the game's: `locale <file.po>` translates every drawn text with ludic.i18n.
- **Natives** (a radar, a dial) are game code, so a `native` line is a box for the studio to fill.
- **Inspection**: `tree`, `box <id>` (box and computed style) and `rules <id>` (the matching rules,
each with the file and line its selector was written on).
## ludic.ui additions it relies on
All additive, and none changes what a game draws:
- `UiClass.make_of` - a constructor handed the class, so one host function makes every mock.
- `UiBackend.said` - template errors to the host instead of `print`; `UiBackend.emitted` - every
`emit`, for the host's log.
- `UiRule.text` and `UiRule.at` - a rule's selector as written and `file:line`. A stylesheet's
comments keep their line breaks when stripped, so lines count true; a component's own styles are
read under its `.lss` path.
- `inspect.ludic`: `ui_root`, `ui_find`, `ui_rules_of`, `ui_rule_value`, `ui_building`,
`ui_class_load`, `ui_file_forget`, `ui_errors_clear`; `class.ludic`: `ui_class`;
`ui_nine_cuts_into` exported.
## Limits in v1
- An error's column is not known (`col` is 0): ludic.ui records a file and line per element and per
rule, not a column. A malformed XML file is read leniently by the runtime's parser and does not
report a syntax error.
- In a GAME build a component's styles have their `@import`s inlined by the compiler, so a rule's
line there counts in the inlined text; in the host the studio sends the file as written, and
lines are the file's.
- A declaration's value is given as written only when it is plain text; one with `{...}` holes is
`""` in `rules` (its computed result is in `box`).
- Images are referenced, not decoded: the studio draws them from its own copies of the game's files
and atlases, and says a picture's natural size with `image` when `object-fit` needs it.

View file

@ -0,0 +1,66 @@
# calls.ludic - a component's functions, answered from the story: `calls <Name> {"pct": [80, 20],
# "label": {"0": "Food", "*": "?"}, "ready": true}`. A list is indexed by the first argument, an
# object keyed by the arguments' text ("," between several; "*" when none matches), anything else is
# the answer whatever the arguments. A call made by an action (a press, not a build) is an event, and
# so is `set`; a name the story does not answer is null, and said once as a miss.
function pv_call(pv: mut PvState, ui_st: UiState, p: pointer, name: string, args: Val, into: Val) -> Val {
let m: PvInst = p
if len(name) > 4 and pv_sat(name, 0, "set:") { return pv_set(pv, m, name, args, into) }
let building = ui_building(ui_st)
if not building { pv_call_event(pv, m, name, args) }
let s = pv_story(pv, m.cls)
if s != null and s.calls != null and Value.kind(s.calls) == 6 and Value.has(s.calls, name) != 0 { return pv_answer(Value.get(s.calls, name), args, into) }
if building { pv_miss(pv, m.cls, name) }
return value_into_null(into)
}
@alloc_ok("an action's call: a press, never a build")
function pv_call_event(pv: mut PvState, m: PvInst, name: string, args: Val) -> void {
pv_s(pv.ev, "call")
pv_q(pv.ev, m.cls)
pv_q(pv.ev, name)
pv_json(pv.ev, args)
pv_nl(pv.ev)
}
# `set name = value` in the template: kept on the instance (a copy: the value is ludic.ui's to reuse)
@alloc_ok("an action's set: a press, never a build")
function pv_set(pv: mut PvState, m: PvInst, name: string, args: Val, into: Val) -> Val {
let key = name[4..len(name)]
let v = pv_copy(Value.at(args, 0))
Value.put(m.sets, key, v)
pv_s(pv.ev, "set")
pv_q(pv.ev, m.cls)
pv_q(pv.ev, key)
pv_json(pv.ev, v)
pv_nl(pv.ev)
return value_into_null(into)
}
@alloc_ok("a miss: said once per class and name")
function pv_miss(pv: mut PvState, cls: string, name: string) -> void {
for i in 0 .. len(pv.miss_cls) {
if pv.miss_cls[i] == cls and pv.miss_fn[i] == name { return }
}
push(pv.miss_cls, cls)
push(pv.miss_fn, name)
pv_s(pv.ev, "miss")
pv_q(pv.ev, cls)
pv_q(pv.ev, name)
pv_nl(pv.ev)
}
# the story's answer to one call; a scalar is written into `into`, as a component's call writes it
@alloc_ok("a story's answer keyed by several arguments: a tool's frame, never the game's")
function pv_answer(spec: Val, args: Val, into: Val) -> Val {
var v = spec
if Value.kind(spec) == 5 { v = Value.at(spec, Value.as_int(Value.at(args, 0))) } else if Value.kind(spec) == 6 {
var key = ""
if Value.count(args) > 0 { key = ui_val_text(Value.at(args, 0)) }
for i in 1 .. Value.count(args) { key = key + "," + ui_val_text(Value.at(args, i)) }
if Value.has(spec, key) != 0 { v = Value.get(spec, key) } else { v = Value.get(spec, "*") }
}
let k = Value.kind(v)
if k == 1 { return value_into_int(into, Value.as_int(v)) }
if k == 7 or k == 2 { return value_into_float(into, Value.as_float(v)) }
if k == 3 { return value_into_bool(into, Value.as_int(v) != 0) }
if k == 4 { return value_into_str(into, Value.as_str(v)) }
if k == 0 { return value_into_null(into) }
return v
}

View file

@ -0,0 +1,83 @@
# cmd_load.ludic - the requests that describe components: load, model, calls, show, native, file
# `load <Name> <xml> <lss> [<xml path> [<lss path>]]`: a class registered, or changed in place
@alloc_ok("a request: a class's text and names, once per load")
function pv_load(pv: mut PvState, ui_st: mut UiState, w: []string) -> string {
if len(w) < 4 { return "load: <Name> <xml> <lss> [<xml path> [<lss path>]]" }
let name = pv_text(w[1])
let xml = pv_text(w[2])
let lss = pv_text(w[3])
var xp = name + ".xml"
var lp = name + ".lss"
if len(w) >= 5 { xp = pv_text(w[4]) }
if len(w) >= 6 { lp = pv_text(w[5]) }
ui_errors_clear(ui_st)
pv_placeholders(ui_st, xml, name)
var k = ui_class(ui_st, name)
if k == null {
k = pv_class_new(name)
ui_register(ui_st, k)
}
k.xml = xml
k.lss = lss
k.xml_path = xp
k.lss_path = lp
k.root = null
k.sheet = null
ui_class_load(ui_st, k)
return ""
}
# `model <Name> <json object>` and `calls <Name> <json object>`: the story's side of a class
@alloc_ok("a request: the story's data, kept until replaced")
function pv_story_cmd(pv: mut PvState, w: []string, calls: bool) -> string {
if len(w) < 3 { return `{w[0]}: <Name> <json object>` }
let v = pv_val(w[2])
if Value.kind(v) != 6 { return `{w[0]}: the story's value must be a JSON object` }
let s = pv_story_get(pv, pv_text(w[1]))
if calls { s.calls = v } else { s.model = v }
return ""
}
# `show <Name>`: the class a frame shows, as a screen of its own
@alloc_ok("a request")
function pv_show(pv: mut PvState, ui_st: UiState, w: []string) -> string {
if len(w) < 2 { return "show: <Name>" }
let name = pv_text(w[1])
if ui_class(ui_st, name) == null { return `show {name}: load it first` }
pv.root = name
return ""
}
# `native <tag> [<w> <h>]`: a tag the game draws itself, a placeholder box of that content size
@alloc_ok("a request: a native tag, once")
function pv_native(pv: mut PvState, ui_st: mut UiState, w: []string) -> string {
if len(w) < 2 { return "native: <tag> [<w> <h>]" }
let tag = pv_text(w[1])
var at = -1
for i in 0 .. len(pv.nt_tag) {
if pv.nt_tag[i] == tag { at = i }
}
if at < 0 {
push(pv.nt_tag, tag)
push(pv.nt_w, 0.0)
push(pv.nt_h, 0.0)
at = len(pv.nt_tag) - 1
ui_native(ui_st, tag, fn pv_nat_measure, fn pv_nat_draw)
}
if len(w) >= 4 {
pv.nt_w[at] = pv_float(w[2])
pv.nt_h[at] = pv_float(w[3])
}
return ""
}
function pv_nat_measure(pv: PvState, n: UiNode, avail: float) -> void {
for i in 0 .. len(pv.nt_tag) {
if pv.nt_tag[i] == n.tag {
n.mw = pv.nt_w[i] * pv_scale(pv)
n.mh = pv.nt_h[i] * pv_scale(pv)
}
}
}
function pv_nat_draw(pv: mut PvState, n: UiNode) -> void {
pv_s(pv.out, "native")
pv_q(pv.out, n.tag)
pv_box(pv.out, n.x, n.y, n.cw, n.ch)
pv_nl(pv.out)
}

View file

@ -0,0 +1,97 @@
# cmd_world.ludic - the requests that set what the preview draws against: the viewport, the font,
# the language, atlases, pictures' sizes and files the studio holds unsaved
function pv_viewport(pv: mut PvState, w: []string) -> string {
if len(w) < 3 { return "viewport: <w> <h> [<scale>]" }
let vw = pv_float(w[1])
let vh = pv_float(w[2])
if vw <= 0.0 or vh <= 0.0 { return "viewport: a width and a height above 0" }
pv.vw = vw
pv.vh = vh
pv.vs = 0.0
if len(w) >= 4 { pv.vs = pv_float(w[3]) }
return ""
}
function pv_font_cmd(pv: mut PvState, w: []string) -> string {
if len(w) < 2 { return "font: <dir>" }
let dir = pv_text(w[1])
if not pv_font_load(pv, dir) { return `font {dir}: no font.json with "adv" there` }
return ""
}
# `locale <po path>`: the .po every drawn text is looked up in; "" or "en" is English again
@alloc_ok("a request: a language read")
function pv_locale(pv: mut PvState, i18n_st: mut I18nState, w: []string) -> string {
if len(w) < 2 { return "locale: <po path>" }
let path = pv_text(w[1])
if path == "" or path == "en" {
i18n_off(i18n_st)
pv.locale = ""
return ""
}
let text = pv_read(pv, path)
if text == null { return `locale {path}: cannot read it` }
i18n_load_text(i18n_st, text)
pv.locale = path
return ""
}
# `atlas <prefix> <name> <cols> <rows> [<names>]`: <img src="prefix:cell"> is a cell of atlas `name`
@alloc_ok("a request: an atlas named, once")
function pv_atlas(pv: mut PvState, w: []string) -> string {
if len(w) < 5 { return "atlas: <prefix> <name> <cols> <rows> [<names as a JSON list>]" }
let names = new []string
if len(w) >= 6 {
let l = pv_val(w[5])
for i in 0 .. Value.count(l) { push(names, ui_val_text(Value.at(l, i))) }
}
let prefix = pv_text(w[1])
var at = -1
for i in 0 .. len(pv.at_prefix) {
if pv.at_prefix[i] == prefix { at = i }
}
if at < 0 {
push(pv.at_prefix, prefix)
push(pv.at_name, "")
push(pv.at_cols, 1)
push(pv.at_rows, 1)
push(pv.at_names, names)
at = len(pv.at_prefix) - 1
}
pv.at_name[at] = pv_text(w[2])
pv.at_cols[at] = Math.max(pv_int_of(w[3]), 1)
pv.at_rows[at] = Math.max(pv_int_of(w[4]), 1)
pv.at_names[at] = names
return ""
}
# `image <path> <w> <h>`: a picture's natural size, for object-fit
@alloc_ok("a request: a picture's size, once")
function pv_image_cmd(pv: mut PvState, w: []string) -> string {
if len(w) < 4 { return "image: <path> <w> <h>" }
let path = pv_text(w[1])
for i in 0 .. len(pv.im_path) {
if pv.im_path[i] == path {
pv.im_w[i] = pv_float(w[2])
pv.im_h[i] = pv_float(w[3])
return ""
}
}
push(pv.im_path, path)
push(pv.im_w, pv_float(w[2]))
push(pv.im_h, pv_float(w[3]))
return ""
}
# `file <path> <text>`: what reading that path gives (an @import, a .po), over the disk's
@alloc_ok("a request: a file's text, kept until replaced")
function pv_file(pv: mut PvState, ui_st: mut UiState, w: []string) -> string {
if len(w) < 3 { return "file: <path> <text>" }
let path = pv_text(w[1])
let text = pv_text(w[2])
ui_file_forget(ui_st, path)
for i in 0 .. len(pv.fl_path) {
if pv.fl_path[i] == path {
pv.fl_text[i] = text
return ""
}
}
push(pv.fl_path, path)
push(pv.fl_text, text)
return ""
}

View file

@ -0,0 +1,72 @@
# cmds.ludic - one request run: its words to the command they name, then the reply's end - the draw
# list or the answer, then the events, then `ok` or `fail "<why>"` (protocol-v1.md, "Replies")
const PV_PROTOCOL: int = 1
@alloc_ok("a request's reply")
function pv_run(pv: mut PvState, ui_st: mut UiState, i18n_st: mut I18nState, w: []string) -> bool {
if len(w) == 0 { # a blank line is answered too: one reply a line, always
pv_end(pv, "")
return true
}
let c = w[0]
var why = ""
if c == "hello" { pv.ok_more = pv_hello(pv) } else if c == "quit" {
pv_end(pv, "")
return false
} else if c == "frame" { why = pv_frame(pv, ui_st, w) } else if c == "load" { why = pv_load(pv, ui_st, w) } else if c == "model" { why = pv_story_cmd(pv, w, false) } else if c == "calls" { why = pv_story_cmd(pv, w, true) } else if c == "show" { why = pv_show(pv, ui_st, w) } else if c == "native" { why = pv_native(pv, ui_st, w) } else if c == "input" { why = pv_input(pv, w) } else if c == "viewport" { why = pv_viewport(pv, w) } else if c == "font" { why = pv_font_cmd(pv, w) } else if c == "locale" { why = pv_locale(pv, i18n_st, w) } else if c == "atlas" { why = pv_atlas(pv, w) } else if c == "image" { why = pv_image_cmd(pv, w) } else if c == "file" { why = pv_file(pv, ui_st, w) } else if c == "tree" { why = pv_tree(pv, ui_st) } else if c == "box" { why = pv_id_cmd(pv, ui_st, w, false) } else if c == "rules" { why = pv_id_cmd(pv, ui_st, w, true) } else if c == "reset" { why = pv_reset(pv, ui_st) } else { why = `unknown request {c}` }
pv_end(pv, why)
return true
}
function pv_id_cmd(pv: mut PvState, ui_st: mut UiState, w: []string, rules: bool) -> string {
if len(w) < 2 { return `{w[0]}: <id or key>` }
if rules { return pv_rules_cmd(pv, ui_st, pv_text(w[1])) }
return pv_box_cmd(pv, ui_st, pv_text(w[1]))
}
# the reply's end: what was drawn or answered, what happened, and the verdict
function pv_end(pv: mut PvState, why: string) -> void {
pv_flush(pv.out)
pv_flush(pv.ev)
if why == "" {
pv_s(pv.out, "ok")
if pv.ok_more != "" { pv_s(pv.out, pv.ok_more) }
} else {
pv_s(pv.out, "fail")
pv_q(pv.out, why)
}
pv_nl(pv.out)
pv_flush(pv.out)
pv.ok_more = ""
}
@alloc_ok("a request's reply")
function pv_hello(pv: mut PvState) -> string {
let o = pv.ev # borrowed to quote the font's path; empty again below
pv_s(o, " ui-preview")
pv_int(o, PV_PROTOCOL)
pv_q(o, pv.font_dir)
pv_q(o, pv.locale)
var s: pointer = null
unsafe {
s = bytes(o.n + 1)
for i in 0 .. o.n { s[i] = o.p[i] }
s[o.n] = 0
}
o.n = 0
return s
}
# `frame <t>`: the clock at t seconds, this frame's input, the root laid out and drawn
function pv_frame(pv: mut PvState, ui_st: mut UiState, w: []string) -> string {
if pv.root == "" { return "frame: show a class first" }
if len(w) >= 2 { pv.t = pv_float(w[1]) }
pv_frame_input(pv, ui_st)
pv_s(pv.out, "frame")
pv_num(pv.out, pv.t)
pv_nl(pv.out)
ui_show(ui_st, pv.root, null, 0.0, 0.0, pv.vw, pv.vh)
pv_s(pv.out, "end")
pv_nl(pv.out)
return ""
}
# `reset`: the root's template state and instances forgotten, as a screen shown for the first time
function pv_reset(pv: mut PvState, ui_st: mut UiState) -> string {
if pv.root != "" { ui_forget(ui_st, pv.root) }
return ""
}

View file

@ -0,0 +1,78 @@
# computed.ludic - an element's computed style after the cascade and layout, one `style` line each
# (protocol-v1.md, "box")
function pv_style(o: PvOut, n: UiNode) -> void {
pv_st(o, "font-size")
pv_num(o, n.rsize)
pv_nl(o)
pv_st(o, "color")
pv_rgba(o, n.rfg, n.rfg_a)
pv_nl(o)
pv_st(o, "background")
if n.bg < 0 { pv_w(o, "none") } else { pv_rgba(o, n.bg, n.bg_a) }
pv_nl(o)
pv_st(o, "opacity")
pv_num(o, n.alpha)
pv_nl(o)
pv_four(o, "padding", n.pt, n.pr, n.pb, n.pl)
pv_four(o, "margin", n.mt, n.mr, n.mb, n.ml)
pv_four(o, "border-width", n.bt, n.br, n.bb, n.bl)
pv_st(o, "border-color")
if n.border_c < 0 { pv_w(o, "none") } else { pv_rgba(o, n.border_c, n.border_a) }
pv_nl(o)
pv_one(o, "border-radius", n.radius)
pv_one(o, "gap", n.gap)
pv_one(o, "flex-grow", n.grow)
pv_st(o, "flex-direction")
if n.dir == UI_ROW { pv_w(o, "row") } else { pv_w(o, "column") }
pv_nl(o)
pv_st(o, "align-items")
pv_w(o, pv_place_word(n.align, false))
pv_nl(o)
pv_st(o, "justify-content")
pv_w(o, pv_place_word(n.justify, true))
pv_nl(o)
pv_st(o, "position")
pv_w(o, pv_pos_word(n.pos))
pv_int(o, n.z)
pv_nl(o)
pv_st(o, "content")
pv_num(o, n.mw)
pv_num(o, n.mh)
pv_nl(o)
pv_st(o, "enabled")
if n.enabled { pv_w(o, "true") } else { pv_w(o, "false") }
pv_nl(o)
}
function pv_st(o: PvOut, name: string) -> void {
pv_s(o, "style ")
pv_s(o, name)
}
function pv_one(o: PvOut, name: string, v: float) -> void {
pv_st(o, name)
pv_num(o, v)
pv_nl(o)
}
function pv_four(o: PvOut, name: string, t: float, r: float, b: float, l: float) -> void {
pv_st(o, name)
pv_num(o, t)
pv_num(o, r)
pv_num(o, b)
pv_num(o, l)
pv_nl(o)
}
function pv_place_word(v: int, justify: bool) -> string {
if v == UI_START { return "start" }
if v == UI_CENTER { return "center" }
if v == UI_END { return "end" }
if justify and v == UI_BETWEEN { return "space-between" }
if justify and v == UI_AROUND { return "space-around" }
if justify and v == UI_EVENLY { return "space-evenly" }
if v == UI_STRETCH { return "stretch" }
return "start"
}
function pv_pos_word(p: int) -> string {
if p == 1 { return "relative" }
if p == 2 { return "absolute" }
if p == 3 { return "fixed" }
return "static"
}

View file

@ -0,0 +1,79 @@
# draw.ludic - the recording backend: every UiBackend primitive a line of the frame's draw list
# (protocol-v1.md, "The draw list"), in the order ludic.ui draws them
function pv_backend() -> UiBackend {
let b = new UiBackend
b.rect = fn pv_rect
b.round = fn pv_round
b.ring = fn pv_ring
b.text = fn pv_text_draw
b.measure = fn pv_measure_be
b.image = fn pv_image
b.image_w = fn pv_image_w
b.image_h = fn pv_image_h
b.nine = fn pv_nine
b.clip = fn pv_clip
b.unclip = fn pv_unclip
b.scale = fn pv_scale
b.now = fn pv_now
b.translate = fn pv_translate
b.sound = fn pv_sound
b.read = fn pv_read
b.said = fn pv_said
b.emitted = fn pv_emitted
b.input = false
return b
}
function pv_box(o: PvOut, x: float, y: float, w: float, h: float) -> void {
pv_num(o, x)
pv_num(o, y)
pv_num(o, w)
pv_num(o, h)
}
function pv_rect(pv: mut PvState, x: float, y: float, w: float, h: float, c: int, a: float) -> void {
pv_s(pv.out, "rect")
pv_box(pv.out, x, y, w, h)
pv_rgba(pv.out, c, a)
pv_nl(pv.out)
}
function pv_round(pv: mut PvState, x: float, y: float, w: float, h: float, r: float, c: int, a: float) -> void {
pv_s(pv.out, "round")
pv_box(pv.out, x, y, w, h)
pv_num(pv.out, r)
pv_rgba(pv.out, c, a)
pv_nl(pv.out)
}
function pv_ring(pv: mut PvState, x: float, y: float, w: float, h: float, r: float, t: float, c: int, a: float) -> void {
pv_s(pv.out, "ring")
pv_box(pv.out, x, y, w, h)
pv_num(pv.out, r)
pv_num(pv.out, t)
pv_rgba(pv.out, c, a)
pv_nl(pv.out)
}
function pv_text_draw(pv: mut PvState, x: float, y: float, s: string, size: float, c: int, a: float) -> void {
pv_s(pv.out, "text")
pv_num(pv.out, x)
pv_num(pv.out, y)
pv_num(pv.out, size)
pv_num(pv.out, pv_measure(pv, s, size))
pv_rgba(pv.out, c, a)
pv_q(pv.out, s)
pv_nl(pv.out)
}
function pv_measure_be(pv: PvState, s: string, size: float) -> float { return pv_measure(pv, s, size) }
function pv_clip(pv: mut PvState, x: float, y: float, w: float, h: float) -> void {
pv_s(pv.out, "clip")
pv_box(pv.out, x, y, w, h)
pv_nl(pv.out)
}
function pv_unclip(pv: mut PvState) -> void {
pv_s(pv.out, "unclip")
pv_nl(pv.out)
}
# screen pixels a design pixel: the viewport's own, else render3d's (the height over 1080)
function pv_scale(pv: PvState) -> float {
if pv.vs > 0.0 { return pv.vs }
return pv.vh / 1080.0
}
function pv_now(pv: PvState) -> float { return pv.t }
function pv_translate(i18n_st: mut I18nState, s: string) -> string { return L(i18n_st, s) }

View file

@ -0,0 +1,74 @@
# events.ludic - what happened while a request ran, kept apart from the draw list and written after
# it (protocol-v1.md, "Events"): template errors, calls, emits, sets, sounds and misses
# a template error: ludic.ui's text, its file and line split off the front when it has them
@alloc_ok("a template error: said once (ludic.ui says each once)")
function pv_err(pv: mut PvState, msg: string) -> void {
let o = pv.ev
var cut = -1
for i in 0 .. len(msg) - 1 {
if cut < 0 and msg[i] == 58 and msg[i + 1] == 32 { cut = i }
}
var file = ""
var line = 0
var rest = msg
if cut > 0 {
let head = msg[0..cut]
let colon = pv_last_colon(head)
if colon > 0 and pv_digits_only(head, colon + 1) {
file = head[0..colon]
line = pv_int_of(head[colon + 1..len(head)])
rest = msg[cut + 2..len(msg)]
} else if pv_pathish(head) {
file = head
rest = msg[cut + 2..len(msg)]
}
}
pv_s(o, "err")
pv_q(o, file)
pv_int(o, line)
pv_int(o, 0)
pv_q(o, rest)
pv_nl(o)
}
function pv_last_colon(s: string) -> int {
var at = -1
for i in 0 .. len(s) {
if s[i] == 58 { at = i }
}
return at
}
function pv_digits_only(s: string, from: int) -> bool {
if from >= len(s) { return false }
for i in from .. len(s) {
if s[i] < 48 or s[i] > 57 { return false }
}
return true
}
# a name with a dot or a slash in it, and no space: a file
function pv_pathish(s: string) -> bool {
var dot = false
for i in 0 .. len(s) {
if s[i] == 32 { return false }
if s[i] == 46 or s[i] == 47 { dot = true }
}
return dot
}
function pv_said(pv: mut PvState, msg: string) -> void { pv_err(pv, msg) }
# `emit name` ran in a template (a click's is "press", as ludic.ui keeps it)
function pv_emitted(pv: mut PvState, name: string) -> void {
pv_s(pv.ev, "emit")
pv_q(pv.ev, name)
pv_nl(pv.ev)
}
function pv_sound(pv: mut PvState, name: string) -> void {
pv_s(pv.ev, "sound")
pv_q(pv.ev, name)
pv_nl(pv.ev)
}
# a template file's text: one the studio sent (`file`), else the disk's
function pv_read(pv: PvState, path: string) -> string {
for i in 0 .. len(pv.fl_path) {
if pv.fl_path[i] == path { return pv.fl_text[i] }
}
return Fs.read_text(path)
}

View file

@ -0,0 +1,80 @@
# font.ludic - text measured as ludic.render3d's overlay measures it (overlay_font, ov_text_w), from
# the same font.json and with no GPU: a glyph's advance times the size in whole pixels, summed, and
# the sum cut to a whole pixel. With no font, a character is half the size wide.
const PV_MAP_N: int = 8192
@alloc_ok("a font read: once per font line")
function pv_font_load(pv: mut PvState, dir: string) -> bool {
let meta = Fs.read_text(dir + "/font.json")
if meta == null { return false }
let j = Json.parse(meta)
if Value.kind(j) != 6 or Value.has(j, "adv") == 0 { return false }
let adv = Value.get(j, "adv")
let n = Value.count(adv)
var first = 32
if Value.has(j, "first") != 0 { first = Value.as_int(Value.get(j, "first")) }
let codes = Value.get(j, "codes")
pv.fn_adv = new []float
pv.fn_map = new []int
pv.fn_hi_cp = new []int
pv.fn_hi_g = new []int
for i in 0 .. n { push(pv.fn_adv, Value.as_float(Value.at(adv, i))) }
for c in 0 .. PV_MAP_N { push(pv.fn_map, 0) }
for i in 0 .. n {
var cp = first + i
if Value.kind(codes) == 5 and i < Value.count(codes) { cp = Value.as_int(Value.at(codes, i)) }
if cp >= 0 and cp < PV_MAP_N { pv.fn_map[cp] = i + 1 } else if cp >= PV_MAP_N {
push(pv.fn_hi_cp, cp)
push(pv.fn_hi_g, i)
}
}
pv.fn_space = 0
if pv.fn_map[32] > 0 { pv.fn_space = pv.fn_map[32] - 1 }
pv.fn_qmark = pv.fn_space
if pv.fn_map[63] > 0 { pv.fn_qmark = pv.fn_map[63] - 1 }
pv.font_dir = dir
pv.fn_on = true
return true
}
# the glyph for a code point, as ov_glyph: a control character is a space, a missing one is '?'
function pv_glyph(pv: PvState, cp: int) -> int {
if cp < 32 { return pv.fn_space }
if cp < PV_MAP_N {
let g = pv.fn_map[cp]
if g > 0 { return g - 1 }
return pv.fn_qmark
}
for i in 0 .. len(pv.fn_hi_cp) {
if pv.fn_hi_cp[i] == cp { return pv.fn_hi_g[i] }
}
return pv.fn_qmark
}
# the bytes the UTF-8 sequence at s[i] takes, as ov_u8 reads it (a broken one is one byte)
function pv_u8_len(s: string, i: int) -> int {
let n = len(s)
let b0 = s[i] & 255
if b0 >= 240 and b0 < 248 and i + 3 < n { return 4 }
if b0 >= 224 and b0 < 240 and i + 2 < n { return 3 }
if b0 >= 192 and b0 < 224 and i + 1 < n { return 2 }
return 1
}
function pv_u8_cp(s: string, i: int, k: int) -> int {
let b0 = s[i] & 255
if k == 4 { return ((b0 & 7) << 18) | ((s[i + 1] & 63) << 12) | ((s[i + 2] & 63) << 6) | (s[i + 3] & 63) }
if k == 3 { return ((b0 & 15) << 12) | ((s[i + 1] & 63) << 6) | (s[i + 2] & 63) }
if k == 2 { return ((b0 & 31) << 6) | (s[i + 1] & 63) }
if b0 >= 128 { return 63 }
return b0
}
# the width of s at `size` pixels a em, in whole pixels
function pv_measure(pv: PvState, s: string, size: float) -> float {
let px = float(int(size))
var w = 0.0
var i = 0
let n = len(s)
while i < n {
let k = pv_u8_len(s, i)
if pv.fn_on { w = w + pv.fn_adv[pv_glyph(pv, pv_u8_cp(s, i, k))] * px } else { w = w + 0.5 * px }
i += k
}
return float(int(w))
}

View file

@ -0,0 +1,81 @@
# images.ludic - pictures in the draw list: a cell of an atlas the studio named (`atlas`), placed as
# ludic.ui's render3d backend places one (square, centred, the cell's share of the sheet), else a file
# by its path; a nine-slice by its path with the box's cuts. Natural sizes come from `image` lines.
function pv_atlas_of(pv: PvState, src: string) -> int {
for k in 0 .. len(pv.at_prefix) {
let p = pv.at_prefix[k]
if len(src) > len(p) and src[len(p)] == 58 and pv_sat(src, 0, p) { return k }
}
return -1
}
# the cell `src` names after its prefix: by name, else by number (-1: none)
function pv_cell(pv: PvState, k: int, src: string) -> int {
let at = len(pv.at_prefix[k]) + 1
let n = len(src) - at
let names = pv.at_names[k]
var idx = -1
for i in 0 .. len(names) {
if len(names[i]) == n and pv_sat(src, at, names[i]) { idx = i }
}
if idx >= 0 or n == 0 or src[at] < 48 or src[at] > 57 { return idx }
idx = 0
for i in at .. len(src) {
if src[i] >= 48 and src[i] <= 57 { idx = idx * 10 + src[i] - 48 }
}
return idx
}
function pv_image(pv: mut PvState, src: string, x: float, y: float, w: float, h: float, c: int, a: float) -> void {
let o = pv.out
let k = pv_atlas_of(pv, src)
if k < 0 {
pv_s(o, "image path")
pv_q(o, src)
pv_box(o, x, y, w, h)
pv_box(o, 0.0, 0.0, 1.0, 1.0)
} else {
let idx = pv_cell(pv, k, src)
if idx < 0 { # as render3d's backend: nothing drawn, and the template is wrong
pv_err(pv, `{src}: the atlas has no such picture`)
return
}
let cols = pv.at_cols[k]
let rows = pv.at_rows[k]
let s = Math.min(w, h)
pv_s(o, "image atlas")
pv_q(o, pv.at_name[k])
pv_int(o, idx)
pv_box(o, float(int(x + (w - s) / 2.0)), float(int(y + (h - s) / 2.0)), float(int(s)), float(int(s)))
let u0 = float(idx % cols) / float(cols)
let v0 = float(idx / cols) / float(rows)
pv_box(o, u0, v0, u0 + 1.0 / float(cols), v0 + 1.0 / float(rows))
}
pv_rgba(o, c, a)
pv_nl(o)
}
# a picture's own size: an atlas cell is 1 by 1 (drawn square), a file what an `image` line said
function pv_image_w(pv: PvState, src: string) -> float {
if pv_atlas_of(pv, src) >= 0 { return 1.0 }
for i in 0 .. len(pv.im_path) {
if pv.im_path[i] == src { return pv.im_w[i] }
}
return 0.0
}
function pv_image_h(pv: PvState, src: string) -> float {
if pv_atlas_of(pv, src) >= 0 { return 1.0 }
for i in 0 .. len(pv.im_path) {
if pv.im_path[i] == src { return pv.im_h[i] }
}
return 0.0
}
function pv_nine(pv: mut PvState, src: string, slice: float, x: float, y: float, w: float, h: float, dst: float, c: int, a: float) -> void {
let o = pv.out
let cut = ui_nine_cuts_into(pv.cuts, x, y, w, h, dst)
pv_s(o, "nine")
pv_q(o, src)
pv_num(o, slice)
pv_box(o, x, y, w, h)
pv_num(o, dst)
for i in 0 .. 8 { pv_num(o, cut[i]) }
pv_rgba(o, c, a)
pv_nl(o)
}

View file

@ -0,0 +1,78 @@
# input.ludic - `input ...`: what the player does, held (the pointer, the pad, shift) until changed,
# or pressed once (a key, the wheel, typed text) and handed to the next frame only
property PvIn {
x: float = -1.0
y: float = -1.0
buttons: int = 0
shift: bool = false
pad_a: bool = false
pad_b: bool = false
hold_up: bool = false
hold_down: bool = false
hold_left: bool = false
hold_right: bool = false
hold_enter: bool = false
once: UiInput = new UiInput # this frame's presses, cleared once a frame takes them
}
# the request's words after `input`; "" when they were understood, else what was wrong
function pv_input(pv: mut PvState, w: []string) -> string {
let i = pv.inp
if len(w) < 2 { return "input: pointer, wheel, key, text, pad or shift" }
let what = w[1]
if what == "pointer" and len(w) >= 5 {
i.x = pv_float(w[2])
i.y = pv_float(w[3])
i.buttons = pv_int_of(w[4])
return ""
}
if what == "wheel" and len(w) >= 3 {
i.once.wheel = i.once.wheel + pv_float(w[2])
return ""
}
if what == "text" and len(w) >= 3 {
i.once.typed = i.once.typed + pv_text(w[2])
return ""
}
if what == "shift" and len(w) >= 3 {
i.shift = pv_int_of(w[2]) != 0
return ""
}
if what == "key" and len(w) >= 3 { return pv_key(i, pv_text(w[2])) }
if what == "pad" and len(w) >= 4 { return pv_pad(i, pv_text(w[2]), pv_int_of(w[3]) != 0) }
return `input {what}: not understood`
}
# the frame's input: the held state and the presses, into the record ludic.ui is not holding
function pv_frame_input(pv: mut PvState, ui_st: mut UiState) -> void {
let i = pv.inp
var r = pv.in_a
if pv.in_flip { r = pv.in_b }
pv.in_flip = not pv.in_flip
let o = i.once
r.x = i.x
r.y = i.y
r.down = (i.buttons & 1) != 0
r.down_right = (i.buttons & 2) != 0
r.down_middle = (i.buttons & 4) != 0
r.wheel = o.wheel
r.tab = o.tab
r.shift = i.shift or o.shift
r.up = o.up
r.down_key = o.down_key
r.left = o.left
r.right = o.right
r.enter = o.enter
r.space = o.space
r.escape = o.escape
r.backspace = o.backspace
r.typed = o.typed
r.key = o.key
r.held_up = i.hold_up
r.held_down = i.hold_down
r.held_left = i.hold_left
r.held_right = i.hold_right
r.pad_a = i.pad_a
r.pad_b = i.pad_b
r.held_enter = i.hold_enter
ui_input(ui_st, r)
pv_in_blank(o) # the presses taken: the next frame starts with none
}

View file

@ -0,0 +1,28 @@
# input_keys.ludic - the names `input key` and `input pad` take, and a frame's presses cleared
function pv_key(i: PvIn, k: string) -> string {
let o = i.once
if k == "tab" { o.tab = true } else if k == "backtab" {
o.tab = true
o.shift = true
} else if k == "up" { o.up = true } else if k == "down" { o.down_key = true } else if k == "left" { o.left = true } else if k == "right" { o.right = true } else if k == "enter" { o.enter = true } else if k == "space" { o.space = true } else if k == "escape" { o.escape = true } else if k == "backspace" { o.backspace = true } else if len(k) > 0 and k[0] >= 48 and k[0] <= 57 { o.key = pv_int_of(k) } else { return `input key {k}: not a key` }
return ""
}
function pv_pad(i: PvIn, b: string, on: bool) -> string {
if b == "a" { i.pad_a = on } else if b == "b" { i.pad_b = on } else if b == "up" { i.hold_up = on } else if b == "down" { i.hold_down = on } else if b == "left" { i.hold_left = on } else if b == "right" { i.hold_right = on } else if b == "enter" { i.hold_enter = on } else { return `input pad {b}: not a button` }
return ""
}
function pv_in_blank(o: UiInput) -> void {
o.wheel = 0.0
o.tab = false
o.shift = false
o.up = false
o.down_key = false
o.left = false
o.right = false
o.enter = false
o.space = false
o.escape = false
o.backspace = false
o.typed = ""
o.key = 0
}

View file

@ -0,0 +1,72 @@
# inspect.ludic - `tree`, `box <id>` and `rules <id>`: the last frame's elements, one's box and
# computed style, and the rules that style it with where each was written
function pv_tree(pv: mut PvState, ui_st: UiState) -> string {
let root = ui_root(ui_st, pv.root)
if root == null { return "tree: no frame shown yet" }
pv_node(pv.out, root, 0)
return ""
}
function pv_node(o: PvOut, n: UiNode, depth: int) -> void {
pv_s(o, "node")
pv_int(o, depth)
pv_int(o, n.kind)
pv_q(o, n.key)
pv_q(o, n.tag)
pv_q(o, n.id)
pv_byte(o, 32)
pv_byte(o, 34)
if n.classes != null {
for i in 0 .. len(n.classes) {
if i > 0 { pv_byte(o, 32) }
pv_s(o, n.classes[i])
}
}
pv_byte(o, 34)
pv_box(o, n.x, n.y, n.cw, n.ch)
pv_q(o, n.text)
pv_nl(o)
if n.children == null { return }
for i in 0 .. len(n.children) { pv_node(o, n.children[i], depth + 1) }
}
# the element whose id (or key) that is, on the root's last frame
function pv_find(pv: PvState, ui_st: UiState, id: string) -> UiNode { return ui_find(ui_st, id) }
function pv_box_cmd(pv: mut PvState, ui_st: UiState, id: string) -> string {
let n = pv_find(pv, ui_st, id)
if n == null { return `box {id}: no element has that id or key` }
let o = pv.out
pv_s(o, "box")
pv_q(o, n.key)
pv_box(o, n.x, n.y, n.cw, n.ch)
pv_nl(o)
pv_style(o, n)
return ""
}
function pv_rules_cmd(pv: mut PvState, ui_st: mut UiState, id: string) -> string {
let n = ui_find(ui_st, id)
if n == null { return `rules {id}: no element has that id or key` }
ui_rules_of(ui_st, n, pv.rules)
for i in 0 .. len(pv.rules) { pv_rule(pv.out, pv.rules[i], "rule") }
if n.style != null { pv_rule(pv.out, n.style, "inline") }
return ""
}
@alloc_ok("an inspector's question: a rule's file cut from its place")
function pv_rule(o: PvOut, r: UiRule, word: string) -> void {
pv_s(o, word)
let colon = pv_last_colon(r.at)
if colon > 0 and pv_digits_only(r.at, colon + 1) {
pv_q(o, r.at[0..colon])
pv_int(o, pv_int_of(r.at[colon + 1..len(r.at)]))
} else {
pv_q(o, r.at)
pv_int(o, 0)
}
pv_int(o, r.spec)
pv_q(o, r.text)
pv_nl(o)
for k in 0 .. len(r.keys) {
pv_s(o, "decl")
pv_q(o, r.keys[k])
pv_q(o, ui_rule_value(r, k))
pv_nl(o)
}
}

View file

@ -0,0 +1,67 @@
# json.ludic - a Value written into the reply as one line of JSON (strings quoted as pv_q quotes every
# string, numbers as pv_num writes them), and a Value copied deep
function pv_json(o: PvOut, v: Val) -> void {
pv_byte(o, 32)
pv_json_in(o, v)
}
function pv_json_in(o: PvOut, v: Val) -> void {
let k = Value.kind(v)
if k == 1 {
pv_digits(o, Value.as_int(v))
} else if k == 2 or k == 7 {
let at = o.n
pv_num(o, Value.as_float(v))
pv_unspace(o, at)
} else if k == 3 {
if Value.as_int(v) != 0 { pv_s(o, "true") } else { pv_s(o, "false") }
} else if k == 4 {
let at = o.n
pv_q(o, Value.as_str(v))
pv_unspace(o, at)
} else if k == 5 {
pv_byte(o, 91)
for i in 0 .. Value.count(v) {
if i > 0 { pv_byte(o, 44) }
pv_json_in(o, Value.at(v, i))
}
pv_byte(o, 93)
} else if k == 6 {
pv_byte(o, 123)
for i in 0 .. Value.count(v) {
if i > 0 { pv_byte(o, 44) }
let at = o.n
pv_q(o, Value.key_at(v, i))
pv_unspace(o, at)
pv_byte(o, 58)
pv_json_in(o, Value.at(v, i))
}
pv_byte(o, 125)
} else { pv_s(o, "null") }
}
# the space pv_num and pv_q put before what they write (at `at`), taken back inside a JSON value
function pv_unspace(o: PvOut, at: int) -> void {
unsafe {
for i in at .. o.n - 1 { o.p[i] = o.p[i + 1] }
}
o.n -= 1
}
# a Value and everything under it made again, so nothing is shared with the original
@alloc_ok("an action's set: a press, never a build")
function pv_copy(v: Val) -> Val {
let k = Value.kind(v)
if k == 1 { return Value.int(Value.as_int(v)) }
if k == 2 or k == 7 { return Value.float(Value.as_float(v)) }
if k == 3 { return Value.bool(Value.as_int(v)) }
if k == 4 { return Value.str(Value.as_str(v)) }
if k == 5 {
let l = Value.list()
for i in 0 .. Value.count(v) { Value.add(l, pv_copy(Value.at(v, i))) }
return l
}
if k == 6 {
let o = Value.object()
for i in 0 .. Value.count(v) { Value.put(o, Value.key_at(v, i), pv_copy(Value.at(v, i))) }
return o
}
return Value.null()
}

View file

@ -0,0 +1,92 @@
# line.ludic - a request: one line of stdin, cut into words. A word is a JSON value (a string, an
# object or a list, read whole) or a run of anything but spaces (a name, a number)
# the next line without its end (a \r before it is dropped too), or null at the end of the input
@alloc_ok("a request read: the studio's input, never a frame")
function pv_read_line(b: PvOut) -> string {
b.n = 0
var c = read_char()
if c < 0 { return null }
while c >= 0 and c != 10 {
if c != 13 { pv_byte(b, c) }
c = read_char()
}
var s: pointer = null
unsafe {
s = bytes(b.n + 1)
for i in 0 .. b.n { s[i] = b.p[i] }
s[b.n] = 0
}
return s
}
@alloc_ok("a request's words: one list kept, each word a slice of the line")
function pv_words(s: string, out: []string) -> void {
List.clear(out)
let n = len(s)
var i = 0
while i < n {
while i < n and (s[i] == 32 or s[i] == 9) { i += 1 }
if i >= n { break }
var j = i
let c = s[i]
if c == 34 or c == 123 or c == 91 { j = pv_json_end(s, i) } else {
while j < n and s[j] != 32 and s[j] != 9 { j += 1 }
}
push(out, s[i..j])
i = j
}
}
# where the JSON value starting at i ends: its closing quote or bracket, strings and escapes skipped
function pv_json_end(s: string, i: int) -> int {
let n = len(s)
var depth = 0
var ins = false
var j = i
while j < n {
let c = s[j]
if ins {
if c == 92 { j += 1 } else if c == 34 {
ins = false
if depth == 0 { return j + 1 }
}
} else if c == 34 { ins = true } else if c == 123 or c == 91 { depth += 1 } else if c == 125 or c == 93 {
depth -= 1
if depth == 0 { return j + 1 }
}
j += 1
}
return n
}
# a word as text: a JSON string unquoted, anything else as it stands
@alloc_ok("a request's word read")
function pv_text(w: string) -> string {
if len(w) > 0 and w[0] == 34 { return Value.as_str(Json.parse(w)) }
return w
}
# a word as a number (0 when it is not one)
@alloc_ok("a request's word read")
function pv_float(w: string) -> float {
if len(w) == 0 { return 0.0 }
let c = w[0]
if not ((c >= 48 and c <= 57) or c == 45 or c == 46) { return 0.0 }
return Value.as_float(Json.parse(w))
}
function pv_int_of(w: string) -> int { return int(pv_float(w)) }
# a word as a JSON value (a bare word is a string)
@alloc_ok("a request's word read")
function pv_val(w: string) -> Val {
if len(w) == 0 { return Value.null() }
let c = w[0]
if c == 34 or c == 123 or c == 91 or (c >= 48 and c <= 57) or c == 45 { return Json.parse(w) }
if w == "true" { return Value.bool(1) }
if w == "false" { return Value.bool(0) }
if w == "null" { return Value.null() }
return Value.str(w)
}
# whether s holds p at `at`, compared where it stands
function pv_sat(s: string, at: int, p: string) -> bool {
if at + len(p) > len(s) { return false }
for i in 0 .. len(p) {
if s[at + i] != p[i] { return false }
}
return true
}

View file

@ -0,0 +1,88 @@
# main.ludic - ludic ui-preview: a game's ludic.ui components previewed for a studio, over stdio. It is
# ludic.ui itself with a backend that records instead of drawing, and component classes the studio
# describes (their templates, styles, models and function answers): no game code, no GPU, no
# network. One request a line in, a reply a request out; protocol-v1.md is the whole contract.
# ui-preview [--font DIR]
import "ludic.ui"
import "ludic.i18n"
program UiPreview {
numbers float
import "out.ludic"
import "num.ludic"
import "line.ludic"
import "font.ludic"
import "draw.ludic"
import "images.ludic"
import "events.ludic"
import "json.ludic"
import "mock.ludic"
import "calls.ludic"
import "input.ludic"
import "input_keys.ludic"
import "inspect.ludic"
import "computed.ludic"
import "cmd_load.ludic"
import "cmd_world.ludic"
import "cmds.ludic"
state PvState {
out: PvOut = new PvOut # the reply: the draw list or an answer
ev: PvOut = new PvOut # what happened, written after it
inb: PvOut = new PvOut # the request being read
words: []string = new []string
ok_more: string = ""
font_dir: string = ""
locale: string = ""
fn_on: bool = false
fn_adv: []float = new []float
fn_map: []int = new []int
fn_hi_cp: []int = new []int
fn_hi_g: []int = new []int
fn_space: int = 0
fn_qmark: int = 0
vw: float = 1920.0
vh: float = 1080.0
vs: float = 0.0 # 0: the height over 1080, as render3d's backend
t: float = 0.0
root: string = ""
stories: []PvStory = new []PvStory
miss_cls: []string = new []string
miss_fn: []string = new []string
nt_tag: []string = new []string
nt_w: []float = new []float
nt_h: []float = new []float
at_prefix: []string = new []string
at_name: []string = new []string
at_cols: []int = new []int
at_rows: []int = new []int
at_names: [][]string = new [][]string
im_path: []string = new []string
im_w: []float = new []float
im_h: []float = new []float
fl_path: []string = new []string
fl_text: []string = new []string
inp: PvIn = new PvIn
in_a: UiInput = new UiInput
in_b: UiInput = new UiInput
in_flip: bool = false
cuts: []float = new []float
rules: []UiRule = new []UiRule
}
entry (pv: mut PvState, ui_st: mut UiState, i18n_st: mut I18nState) {
ui_backend(ui_st, pv_backend())
var ai = 1
while ai < Os.arg_count() {
if Os.arg(ai) == "--font" and ai + 1 < Os.arg_count() {
ai += 1
if not pv_font_load(pv, Os.arg(ai)) { Os.stderr_write(`ui-preview: no font.json with "adv" in {Os.arg(ai)}\n`) }
}
ai += 1
}
var going = true
while going {
let line = pv_read_line(pv.inb)
if line == null { break }
pv_words(line, pv.words)
going = pv_run(pv, ui_st, i18n_st, pv.words)
}
}
}

View file

@ -0,0 +1,89 @@
# mock.ludic - a component class the studio describes: its template and styles as sent, its model the
# story's (`model`), its props what the parent's tag gives it, its state what the template has set,
# and its functions answered from the story's table (`calls`, calls.ludic)
property PvInst {
cls: string = ""
props: Val = null
sets: Val = null # what `set name = value` wrote, over the story's model
}
# what the story says of a class: its model and its function table
property PvStory {
name: string = ""
model: Val = null
calls: Val = null
}
function pv_story(pv: PvState, name: string) -> PvStory {
for i in 0 .. len(pv.stories) {
if pv.stories[i].name == name { return pv.stories[i] }
}
return null
}
@alloc_ok("a story line: once per request")
function pv_story_get(pv: mut PvState, name: string) -> PvStory {
let s = pv_story(pv, name)
if s != null { return s }
let t = new PvStory
t.name = name
push(pv.stories, t)
return t
}
@alloc_ok("a component instance: made once per instance, as a component's record is")
function pv_make(k: UiClass) -> pointer {
let m = new PvInst
m.cls = k.name
m.sets = Value.object()
return m
}
function pv_renew(p: pointer) -> void {
let m: PvInst = p
Value.clear(m.sets)
}
function pv_props(p: pointer, v: Val) -> void {
let m: PvInst = p
m.props = v
}
# the story's model, then the props, then what was set: the later wins
function pv_model(pv: PvState, p: pointer, o: Val) -> Val {
let m: PvInst = p
Value.clear(o)
let s = pv_story(pv, m.cls)
if s != null and s.model != null and Value.kind(s.model) == 6 { pv_merge(o, s.model) }
if m.props != null { pv_merge(o, m.props) }
pv_merge(o, m.sets)
return o
}
function pv_merge(o: Val, src: Val) -> void {
for i in 0 .. Value.count(src) { Value.put(o, Value.key_at(src, i), Value.at(src, i)) }
}
@alloc_ok("once per class the studio names")
function pv_class_new(name: string) -> UiClass {
let k = new UiClass
k.name = name
k.make_of = fn pv_make
k.renew = fn pv_renew
k.props = fn pv_props
k.model = fn pv_model
k.call = fn pv_call
return k
}
# a class for every component tag in a template that the studio has not sent yet: an empty one, so
# the template links, and the studio's `load` of it later fills it in place
@alloc_ok("a template's tags read: once per load")
function pv_placeholders(ui_st: mut UiState, xml: string, own: string) -> void {
let n = len(xml)
for i in 0 .. n - 1 {
if xml[i] == 60 and xml[i + 1] >= 65 and xml[i + 1] <= 90 {
var j = i + 1
while j < n and pv_name_ch(xml[j]) { j += 1 }
if j < n and xml[j] == 58 { continue } # kit:Name, a template import's own
let tag = xml[i + 1..j]
if tag != own and ui_class(ui_st, tag) == null {
let k = pv_class_new(tag)
k.xml = "<row/>"
k.xml_path = tag + ".xml"
ui_register(ui_st, k)
}
}
}
}
function pv_name_ch(c: int) -> bool { return (c >= 48 and c <= 57) or (c >= 65 and c <= 90) or (c >= 97 and c <= 122) or c == 95 }

View file

@ -0,0 +1,59 @@
# num.ludic - numbers and colours written into the reply: a space, then the number
# a whole number, in decimal
function pv_int(o: PvOut, n: int) -> void {
pv_byte(o, 32)
pv_digits(o, n)
}
function pv_digits(o: PvOut, n0: int) -> void {
var n = n0
if n < 0 {
pv_byte(o, 45)
n = 0 - n
}
if n >= 10 { pv_digits(o, n / 10) }
pv_byte(o, 48 + n % 10)
}
# a real number rounded to thousandths, with no trailing zeros: 12, 12.5, -0.125
function pv_num(o: PvOut, x: float) -> void {
pv_byte(o, 32)
var a = x
var neg = false
if a < 0.0 {
a = 0.0 - a
neg = true
}
if a > 2000000.0 { # past what thousandths hold in an int: whole units
if neg { pv_byte(o, 45) }
pv_digits(o, int(a))
return
}
let c = int(a * 1000.0 + 0.5)
if neg and c != 0 { pv_byte(o, 45) }
pv_digits(o, c / 1000)
var f = c % 1000
if f == 0 { return }
pv_byte(o, 46)
var d = 100
while f > 0 {
pv_byte(o, 48 + f / d)
f = f % d
d = d / 10
}
}
# a colour, 0xRRGGBB, as #rrggbb (lower case); a negative (none, inherit) is #000000
function pv_hex(o: PvOut, c0: int) -> void {
pv_byte(o, 32)
pv_byte(o, 35)
var c = c0
if c < 0 { c = 0 }
var shift = 1048576
for i in 0 .. 6 {
pv_byte(o, pv_hexch((c / shift) % 16))
shift = shift / 16
}
}
# a colour and its alpha, as every primitive and style line carries them
function pv_rgba(o: PvOut, c: int, a: float) -> void {
pv_hex(o, c)
pv_num(o, a)
}

View file

@ -0,0 +1,78 @@
# out.ludic - the reply stream: lines written into a buffer made once and grown when a frame needs
# more, then written and flushed whole, so a draw list makes no string per primitive
property PvOut {
p: pointer = null
n: int = 0
cap: int = 0
}
extern function fflush(f: pointer) -> int = "fflush"
function pv_room(o: PvOut, more: int) -> void {
if o.n + more + 1 <= o.cap { return }
var cap = o.cap * 2
if cap < 65536 { cap = 65536 }
while cap < o.n + more + 1 { cap = cap * 2 }
var q: pointer = null
unsafe {
q = bytes(cap)
for i in 0 .. o.n { q[i] = o.p[i] }
if o.p != null { free(o.p) }
}
o.p = q
o.cap = cap
}
function pv_byte(o: PvOut, c: int) -> void {
pv_room(o, 1)
unsafe { o.p[o.n] = c }
o.n += 1
}
# text as it is (a word of the protocol)
function pv_s(o: PvOut, s: string) -> void {
let k = len(s)
pv_room(o, k)
unsafe {
for i in 0 .. k { o.p[o.n + i] = s[i] }
}
o.n += k
}
# a space, then a word
function pv_w(o: PvOut, s: string) -> void {
pv_byte(o, 32)
pv_s(o, s)
}
# a space, then text as a JSON string: " and \ escaped, control bytes as \n \r \t or \u00XX, UTF-8 as is
function pv_q(o: PvOut, s: string) -> void {
pv_byte(o, 32)
pv_byte(o, 34)
for i in 0 .. len(s) {
let c = s[i] & 255
if c == 34 or c == 92 {
pv_byte(o, 92)
pv_byte(o, c)
} else if c == 10 {
pv_s(o, "\\n")
} else if c == 13 {
pv_s(o, "\\r")
} else if c == 9 {
pv_s(o, "\\t")
} else if c < 32 {
pv_s(o, "\\u00")
pv_byte(o, pv_hexch(c / 16))
pv_byte(o, pv_hexch(c % 16))
} else { pv_byte(o, c) }
}
pv_byte(o, 34)
}
function pv_hexch(d: int) -> int {
if d < 10 { return 48 + d }
return 87 + d
}
# the line so far ends
function pv_nl(o: PvOut) -> void { pv_byte(o, 10) }
# everything buffered goes to stdout now, and the buffer is empty again
function pv_flush(o: PvOut) -> void {
if o.n == 0 { return }
let f = file_stdout()
unsafe { file_write(f, o.p, o.n) }
fflush(f)
o.n = 0
}

View file

@ -0,0 +1,244 @@
# ui-preview wire protocol, version 1 (frozen)
This is the whole contract between `ludic ui-preview` (the host) and a studio that spawns it. The
studio builds its process management and its canvas replay against this document and nothing else.
Version 1 is frozen: a change to any line format below is version 2, announced by `hello`. A later
v1 host may add a request or an event kind; a studio ignores reply lines it does not know.
## 1. Transport
- The studio starts `ludic ui-preview [--font DIR]` (or `bin/ludic-ui-preview` directly) and talks
to it over stdin (requests) and stdout (replies). Nothing else is read or written except the files
a request names. stderr carries only a start-up complaint about `--font`; ignore it.
- Everything is UTF-8. A line ends with LF (`\n`); a CR before the LF of a request is dropped.
- One request is one line. The host answers every request, in order, with zero or more reply lines
followed by exactly one **terminal line** (§3). It reads the next request only after writing and
flushing that terminal line, so a studio may pipeline requests.
- End of stdin, or `quit`, ends the process with exit status 0.
## 2. Words
A line is a sequence of words separated by one or more spaces or tabs. A word is either:
- a **JSON value** - a string (`"..."`), an object (`{...}`) or a list (`[...]`) - read whole,
whatever spaces, brackets or escaped quotes it holds; or
- a **bare word**: a run of characters other than space and tab.
In the request tables below, `<text>` is a JSON string or a bare word (a bare word is its own text);
`<num>` is a JSON number written bare (`12`, `-3.5`); `<json>` is a JSON value.
Template and stylesheet text contains line breaks, so it is always sent as a JSON string.
### 2.1 Quoting in replies
Every string the host writes - in a draw line, an event, an answer - is a JSON string quoted the same
way: `"` and `\` are escaped as `\"` and `\\`; LF, CR and TAB as `\n`, `\r`, `\t`; any other byte
below 0x20 as `\u00XX` (lower-case hex); every other byte, UTF-8 included, is written as it is. A
reply line never contains a raw LF.
### 2.2 Numbers in replies
- An integer (`<int>`) is written in decimal: `-12`, `0`, `640`.
- A real number (`<real>`) is rounded to the nearest thousandth, written with no exponent and with no
trailing zeros or trailing point: `12`, `12.5`, `-0.125`, `0.333`. `-0` is written `0`. A value
beyond 2,000,000 in magnitude is written as a whole number, truncated.
- A colour (`<rgb>`) is `#rrggbb`, six lower-case hex digits, 8 bits a channel, sRGB as the game
draws it (no premultiplication). An alpha (`<a>`) is a `<real>` from 0 (clear) to 1 (opaque).
Every primitive and style line that has a colour writes it as the pair `<rgb> <a>`.
## 3. Terminal lines
| Line | Meaning |
| --- | --- |
| `ok` | the request was done |
| `ok ui-preview 1 "<font dir>" "<locale>"` | the answer to `hello` only (§4) |
| `fail "<why>"` | the request was not understood or could not be done; nothing changed |
A template error is NOT a `fail`: the request succeeded and the error is reported as an `err` event
(§6) before the `ok`.
## 4. Requests
| Request | Reply before the terminal line |
| --- | --- |
| `hello` | none; the terminal line is `ok ui-preview <protocol> "<font dir>" "<locale>"`, protocol `1`, `""` for no font or English |
| `font <text dir>` | none. Reads `<dir>/font.json` (§5.3); `fail` if it has no `adv` list |
| `viewport <num w> <num h> [<num scale>]` | none. The frame's size in screen pixels, and screen pixels a design pixel; with no scale, `h / 1080` (the game's own rule). Default 1920 1080 |
| `locale <text po path>` | none. Every text a template shows is looked up in that gettext file as the game looks it up (ludic.i18n: exact, then patterns with holes, then sentence by sentence). `""` or `en` is English. The path is read through `file` overrides first |
| `load <text Name> <text xml> <text lss> [<text xml path> [<text lss path>]]` | events only (`err`). Registers component class `Name`, or replaces its template and styles in place if it exists. Paths default to `Name.xml` and `Name.lss`; they name the files in errors and rules, and an `@import` in the styles is read from beside the xml path. Every capitalised tag in the template naming a class not yet loaded gets an empty class (`<row/>`) of that name, filled in place when the studio loads it; the studio sends every class a story names before it `show`s the root |
| `model <text Name> <json object>` | none. The class's model: every key is a name its template reads. Props from the parent's tag and values the template `set`s are laid over it |
| `calls <text Name> <json object>` | none. How the class's functions answer (§7) |
| `show <text Name>` | none. The class a `frame` shows, as a whole screen; `fail` if it is not loaded |
| `native <text tag> [<num w> <num h>]` | none. `<tag>` becomes an element the game draws itself: laid out with that content size in design pixels (default 0 0), drawn as a `native` line. Declare natives before the templates that use them are loaded |
| `atlas <text prefix> <text name> <int cols> <int rows> [<json list of names>]` | none. `<img src="prefix:cell">` is a cell of atlas `name`: a `cols` x `rows` grid, cells numbered row by row from 0, named by the list (a cell is also reachable by its number) |
| `image <text path> <num w> <num h>` | none. A picture's natural size in pixels, for `object-fit`; an undeclared picture has none (it fills its box) |
| `file <text path> <text contents>` | none. Reading `path` gives these contents (an imported stylesheet, a `.po`) instead of the disk's, until replaced; every class re-reads its styles at its next show |
| `input pointer <num x> <num y> <int buttons>` | none. Held until changed. Screen pixels; buttons is a mask: 1 left, 2 right, 4 middle |
| `input wheel <num d>` | none. Added to the next frame's wheel only |
| `input key <text name>` | none. Pressed in the next frame only. Names: `tab` `backtab` (shift-tab) `up` `down` `left` `right` `enter` `space` `escape` `backspace`, or a runtime key code as digits (a key-capture field) |
| `input text <text utf8>` | none. Typed in the next frame only (appended if sent twice) |
| `input shift <int 0/1>` | none. Held until changed |
| `input pad <text button> <int 0/1>` | none. Held until changed. Buttons: `a` (Enter), `b` (Escape), `up` `down` `left` `right` (a held direction repeats as the game's does), `enter` (Enter held down) |
| `frame [<num t>]` | the frame (§5), then events. `t` is the interface clock in seconds (animations, transitions, key repeat); it is the studio's to drive and may go backwards. Without `t` the clock stays where it was |
| `tree` | one `node` line an element of the last frame (§8) |
| `box <text id>` | a `box` line and its `style` lines (§8); `id` is an element's id or its key |
| `rules <text id>` | `rule` / `inline` lines, each followed by its `decl` lines (§8) |
| `reset` | none. The root's template state and component instances are forgotten, as a screen shown for the first time |
| `quit` | none; the process ends after `ok` |
Input takes effect in the next `frame`; a press's events are reported by that frame.
## 5. The frame
```
frame <real t>
<draw line>
...
end
<event line>
...
ok
```
The draw lines are the UiBackend's calls, in the order ludic.ui made them, which is painting order:
draw each over the ones before. Coordinates are screen pixels from the viewport's top left, x right
and y down. Every draw line starts with its kind:
| Line | Draw |
| --- | --- |
| `rect <x> <y> <w> <h> <rgb> <a>` | a filled rectangle |
| `round <x> <y> <w> <h> <radius> <rgb> <a>` | a filled rectangle with every corner rounded by `radius` (the game clamps it to half the shorter side) |
| `ring <x> <y> <w> <h> <radius> <width> <rgb> <a>` | a rounded frame `width` thick, drawn inside that rectangle (a focus ring, an outline) |
| `text <x> <y> <size> <width> <rgb> <a> "<utf8>"` | one line of text; see §5.3 |
| `image path "<path>" <x> <y> <w> <h> <u0> <v0> <u1> <v1> <rgb> <a>` | a picture file stretched over the rectangle; the source rectangle is always `0 0 1 1` (the whole picture) in v1 |
| `image atlas "<atlas name>" <int cell> <x> <y> <w> <h> <u0> <v0> <u1> <v1> <rgb> <a>` | a cell of an atlas (`atlas` request); the rectangle is already the square the game draws it in (centred, the shorter side, whole pixels); the source rectangle is the cell's share of the sheet, in 0-1 of its width and height |
| `nine "<path>" <slice> <x> <y> <w> <h> <dst> <x0> <x1> <x2> <x3> <y0> <y1> <y2> <y3> <rgb> <a>` | `border-image`: the picture cut `slice` source pixels in from each of its four edges, into nine pieces; the corners are drawn `dst` wide into the columns `x0..x1`, `x1..x2`, `x2..x3` and rows `y0..y1`, `y1..y2`, `y2..y3` (already on whole pixels, and never more than half the box); the edges and the centre stretch |
| `clip <x> <y> <w> <h>` | from here on, draw only inside this rectangle |
| `unclip` | from here on, draw everywhere |
| `native "<tag>" <x> <y> <w> <h>` | a native element's box: the studio draws its placeholder (or a story's still) there |
**Colours.** `<rgb> <a>` is the colour to draw with and its opacity, every opacity around the element
already multiplied in. For `image` and `nine` it is a TINT multiplied into the picture's own colours
(`#ffffff 1` draws the picture as it is).
**Clips do not nest in the list.** ludic.ui intersects nested clips itself: every `clip` line is the
whole clip from then on, replacing the one before, and `unclip` removes it. A frame starts
unclipped, and the next frame starts unclipped again.
### 5.3 Text
- `x`, `y` is the TOP LEFT of the line box, not the baseline. The game's overlay puts the baseline at
`y + 0.8 * floor(size)`, and draws each glyph from font.json's cell with `pad_x` / `base_y`.
- `size` is the font size in screen pixels as ludic.ui asked for it; the game draws and measures at
`floor(size)` pixels an em.
- `width` is the run's MEASURED width in screen pixels, from the host's own copy of the game's
metrics: for each code point, its glyph's `adv` (from font.json; a control character is the space,
a code point the atlas lacks is `?`) times `floor(size)`, summed, the sum truncated to a whole
pixel. Line breaking, `text-fit: shrink` and ellipses were decided with the same numbers, so the
studio's own rendering of the run should be fitted to `width`.
- With no font loaded (`hello` says `""`), every code point measures half of `floor(size)`.
- The string is the text as drawn: already translated (`locale`), already wrapped (one line a `text`
line) and already cut with its ellipsis. A text-shadow is its own `text` line, drawn before.
## 6. Events
Events are what happened while a request ran. They are written after the request's own reply lines
(after `end` for a frame) and before the terminal line, in the order they happened. Each is one line:
| Line | Meaning |
| --- | --- |
| `err "<file>" <int line> <int col> "<message>"` | a template or stylesheet error. `file` and `line` are where the element or stylesheet was written when ludic.ui knows it (`""` and `0` when not); `col` is always `0` in v1. The message is ludic.ui's, with the `file:line: ` it began with taken off. Each distinct error is said once; a `load` forgets them, so the next frame says again whatever is still wrong |
| `call "<Class>" "<name>" <json list of arguments>` | an action (a press, a key, a fired event) called a function of that class. Calls made while a frame's tree is built are not events: they are the story answering |
| `set "<Class>" "<name>" <json value>` | an action set the class's state `name` (`set name = value`); the value is kept and laid over the model from then on |
| `emit "<name>"` | an action ran `emit name` (a click is `press`), whether or not a parent handled it |
| `sound "<name>"` | a control asked for a sound (`click`, or its `sound="..."`) |
| `miss "<Class>" "<name>"` | a frame's build called a function `calls` does not answer; it answered `null`. Said once a class and name |
JSON values in events are written on one line with the quoting of §2.1 and the numbers of §2.2.
## 7. Function answers (`calls`)
`calls <Name> {"<function>": <answer>, ...}`. A template's call `f(args...)` on an instance of the
class is answered from `<answer>`:
- a JSON list: its item at the first argument (as an int); `null` past its end;
- a JSON object: its value under the arguments' texts joined by `,` (`"0"`, `"food"`, `"2,3"`), else
under `"*"`, else `null`;
- anything else: that value, whatever the arguments.
A constant object is written as `{"*": {...}}`. A name the table does not have answers `null` (and a
`miss` event during a build). `set name = value` never reaches the table.
## 8. Inspection replies
`tree` - one line an element of the last frame shown, depth first:
```
node <int depth> <int kind> "<key>" "<tag>" "<id>" "<classes>" <x> <y> <w> <h> "<text>"
```
`kind` is ludic.ui's: 0 box, 1 text, 2 spacer, 3 button, 4 scroll, 5 image, 6 rule, 7 native.
`key` is the element's key (unique in the frame, usable in `box` / `rules`); `tag` is `""` for a
node the template did not name (a text run); `classes` is space-separated.
`box <id>`:
```
box "<key>" <x> <y> <w> <h>
style font-size <real>
style color <rgb> <a>
style background <rgb> <a> (or: style background none)
style opacity <real>
style padding <top> <right> <bottom> <left>
style margin <top> <right> <bottom> <left>
style border-width <top> <right> <bottom> <left>
style border-color <rgb> <a> (or: style border-color none)
style border-radius <real>
style gap <real>
style flex-grow <real>
style flex-direction row|column
style align-items start|center|end|stretch
style justify-content start|center|end|space-between|space-around|space-evenly
style position static|relative|absolute|fixed <int z-index>
style content <w> <h>
style enabled true|false
```
Lengths are screen pixels, after the cascade and layout; `content` is the measured content size.
A later v1 host may add `style` lines; a studio ignores names it does not know.
`rules <id>` - the stylesheet rules that match the element now (its `:hover` and the viewport's
`@media` included), in the order the cascade applies them (the later wins), then its `style="..."`:
```
rule "<file>" <int line> <int specificity> "<selector>"
decl "<property>" "<value>"
...
inline "" 0 0 ""
decl "<property>" "<value>"
```
`file` and `line` are where the selector was written (the default sheet is `"the default sheet"`);
`value` is the declaration as written when it is plain text, and `""` when it is an expression
(`{...}`), which is evaluated per element.
## 9. Example
```
> hello
< ok ui-preview 1 "" ""
> load "Pill" "<row class=\"pill\"><p>{label}</p></row>" ".pill { background: #203040; }"
< ok
> model "Pill" {"label": "Food"}
< ok
> show Pill
< ok
> frame 0
< frame 0
< rect 0 0 1920 1080 ...
< text 0 0 16 32 #eeeeee 1 "Food"
< end
< ok
```
(The draw lines of a real frame depend on the templates and the default sheet; `smoke.txt` has a
transcript with what each line must contain.)

152
tools/ui-preview/smoke.txt Normal file
View file

@ -0,0 +1,152 @@
# smoke.txt - a scripted session for ludic ui-preview (protocol-v1.md), to be run by hand or by a
# small driver: `bin/ludic-ui-preview < requests` where the requests are the `> ` lines below, in order.
#
# > LINE a request, sent as it stands (without the "> ")
# < TEXT some reply line of that request contains TEXT (in order within the reply)
# <= LINE the request's terminal line is exactly LINE
# <! TEXT no reply line of that request contains TEXT
#
# No font is loaded, so a character measures half the font size, and the viewport is 1920 x 1080, so
# a design pixel is a screen pixel and the default text is 16 px (a 4-letter word is 32 wide).
> hello
<= ok ui-preview 1 "" ""
> viewport 1920 1080
<= ok
> native "dial" 40 40
<= ok
> load "Pill" "<row id=\"root\" class=\"pill\">\n <p id=\"label\">{label}</p>\n <p id=\"pct\">{pct(0)}</p>\n <button id=\"go\" on-press=\"pick(3)\">Go</button>\n <button id=\"set\" on-press=\"set n = 2\">Set</button>\n <button id=\"close\" on-press=\"emit close\">Shut</button>\n <dial id=\"d\"/>\n <Badge id=\"badge\"/>\n</row>" ".pill {\n background: #203040;\n padding: 8;\n gap: 4;\n}\np { color: #ff0000; }"
<! err
<= ok
> model "Pill" {"label": "Food", "n": 0}
<= ok
> show Pill
<= ok
> frame 0
< frame 0
< rect
< #203040 1
< text
< 16 32 #ff0000 1 "Food"
< round
< #3a3f46 1
< "Go"
< native "dial"
< end
< miss "Pill" "pct"
<= ok
> calls "Pill" {"pct": [80, 20], "pick": true}
<= ok
> frame 0.5
< frame 0.5
< "80"
< end
<! miss
<= ok
> input key tab
<= ok
> frame 1
< end
<= ok
> input key enter
<= ok
> frame 1.1
< end
< call "Pill" "pick" [3]
<= ok
> input key tab
<= ok
> frame 1.2
<= ok
> input key enter
<= ok
> frame 1.3
< set "Pill" "n" 2
<= ok
> input key tab
<= ok
> frame 1.4
<= ok
> input key enter
<= ok
> frame 1.5
< emit "close"
<= ok
> load "Badge" "<span class=\"b\">new</span>" ".b { color: #00ff00; }"
<= ok
> frame 2
< #00ff00 1 "new"
<= ok
> tree
< node 0
< "root"
< "go"
< "Food"
<= ok
> box label
< box "
< style font-size 16
< style color #ff0000 1
<= ok
> rules label
< rule "Pill.lss" 6
< "p"
< decl "color" "#ff0000"
<= ok
> box nosuch
<= fail "box nosuch: no element has that id or key"
> load "Bad" "<row>\n <nosuch/>\n</row>" ""
< err "Bad.xml" 2 0 "<nosuch>: there is no such element
<= ok
> atlas "icon" "icons" 4 2 ["tent", "fish"]
<= ok
> load "Pic" "<row><img src=\"icon:fish\"/><img src=\"photo.png\"/></row>" ""
<= ok
> show Pic
<= ok
> frame 3
< image atlas "icons" 1
< 0.25 0 0.5 0.5
< image path "photo.png"
< 0 0 1 1 #ffffff 1
<= ok
> frobnicate
<= fail "unknown request frobnicate"
>
<= ok
> quit
<= ok