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:
parent
28f7b4666b
commit
49b5e80af5
38 changed files with 1986 additions and 16 deletions
15
changes/ui-preview.md
Normal file
15
changes/ui-preview.md
Normal 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`.
|
||||||
|
|
@ -226,6 +226,7 @@ install_from_source() {
|
||||||
_stage="${TMP}/stage"
|
_stage="${TMP}/stage"
|
||||||
mkdir -p "${_stage}/bin"
|
mkdir -p "${_stage}/bin"
|
||||||
cp "${_src}"/bin/ludic "${_src}"/bin/ludicc "${_src}"/bin/ludic-fmt "${_src}"/bin/ludic-lsp "${_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}/runtime" "${_stage}/runtime"
|
||||||
cp -R "${_src}/packages" "${_stage}/packages"
|
cp -R "${_src}/packages" "${_stage}/packages"
|
||||||
cp "${_src}/VERSION" "${_src}/LICENSE" "${_src}/README.md" "${_stage}/"
|
cp "${_src}/VERSION" "${_src}/LICENSE" "${_src}/README.md" "${_stage}/"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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
|
var at = e
|
||||||
while at != null {
|
while at != null {
|
||||||
if at.on_names != null {
|
if at.on_names != null {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ export property UiBackend {
|
||||||
read: fn(string) -> string = null # a template file's text (else Fs.read_text)
|
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)
|
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)
|
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
|
# a renderer a program installs by importing it (render3d.ludic): the first one is the backend
|
||||||
property UiRenderer {
|
property UiRenderer {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export property UiClass {
|
||||||
lss_path: string = ""
|
lss_path: string = ""
|
||||||
lss: string = ""
|
lss: string = ""
|
||||||
make: fn() -> pointer = null
|
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
|
renew: fn(pointer) -> void = null # a record `make` made, put back as new: an instance mounted again
|
||||||
props: fn(pointer, Val) -> void = null
|
props: fn(pointer, Val) -> void = null
|
||||||
model: fn(pointer, Val) -> Val = null # fills the object it is given (the instance keeps one)
|
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)
|
push(ui_st.uc_list, k)
|
||||||
return len(ui_st.uc_list)
|
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 {
|
function uc_find(ui_st: UiState, name: string) -> UiClass {
|
||||||
if ui_st.uc_list == null { return null }
|
if ui_st.uc_list == null { return null }
|
||||||
for i in 0 .. len(ui_st.uc_list) {
|
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 }
|
if k.root != null { return }
|
||||||
let f = lib_blank(k.xml_path)
|
let f = lib_blank(k.xml_path)
|
||||||
f.src = k.xml
|
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)
|
f.sheet = sheet_of(f)
|
||||||
tp_lines(ui_st, k.xml)
|
tp_lines(ui_st, k.xml)
|
||||||
let doc = Xml.parse(k.xml)
|
let doc = Xml.parse(k.xml)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
# x0 x1 x2 x3 then y0 y1 y2 y3: the columns' and the rows' edges
|
# 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) }
|
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
|
# 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 dx = Math.max(Math.min(dst, w / 2.0), 0.0)
|
||||||
let dy = Math.max(Math.min(dst, h / 2.0), 0.0)
|
let dy = Math.max(Math.min(dst, h / 2.0), 0.0)
|
||||||
List.clear(out)
|
List.clear(out)
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ export function ui_err(ui_st: mut UiState, msg0: string) -> void {
|
||||||
if ui_st.ui_errs[i] == msg { return }
|
if ui_st.ui_errs[i] == msg { return }
|
||||||
}
|
}
|
||||||
push(ui_st.ui_errs, msg)
|
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 {
|
function ui_has(s: string, part: string) -> bool {
|
||||||
let n = len(part)
|
let n = len(part)
|
||||||
|
|
|
||||||
|
|
@ -108,3 +108,4 @@ import "draw_order.ludic"
|
||||||
import "draw_nine.ludic"
|
import "draw_nine.ludic"
|
||||||
import "draw_gradient.ludic"
|
import "draw_gradient.ludic"
|
||||||
import "dump.ludic"
|
import "dump.ludic"
|
||||||
|
import "inspect.ludic"
|
||||||
|
|
|
||||||
71
packages/ludic.ui/inspect.ludic
Normal file
71
packages/ludic.ui/inspect.ludic
Normal 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) }
|
||||||
|
|
@ -41,7 +41,7 @@ function in_reuse(ui_st: mut UiState, k: UiClass) -> UiInst {
|
||||||
}
|
}
|
||||||
let it = new UiInst
|
let it = new UiInst
|
||||||
it.cls = k
|
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
|
it.fresh = true
|
||||||
return it
|
return it
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ export property UiRule {
|
||||||
md: UiMedia = null # the @media it sits in, if any
|
md: UiMedia = null # the @media it sits in, if any
|
||||||
keys: []string = null
|
keys: []string = null
|
||||||
vals: []UiExpr = null
|
vals: []UiExpr = null
|
||||||
|
text: string = "" # its selector as written, and where: "file.lss:12" (for an inspector)
|
||||||
|
at: string = ""
|
||||||
}
|
}
|
||||||
export property UiSheet {
|
export property UiSheet {
|
||||||
rules: []UiRule = null
|
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 {
|
function lss_text(ui_st: mut UiState, f: UiFile, src: string) -> void {
|
||||||
let err = new []string
|
let err = new []string
|
||||||
let body = lss_strip(src) # a copy of its own, unless src had no comment (then src itself)
|
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
|
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
|
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]}`) }
|
for i in 0 .. len(err) { ui_err(ui_st, `{f.path}: <style> {err[i]}`) }
|
||||||
unsafe { free(err) }
|
unsafe { free(err) }
|
||||||
}
|
}
|
||||||
# rules, @imports and @media blocks; the rules in a @media block carry its condition
|
# rules, @imports and @media blocks; the rules in a @media block carry its condition. `line` is the
|
||||||
function lss_block(ui_st: mut UiState, f: UiFile, src: string, md: UiMedia, err: []string) -> void {
|
# 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
|
let r = new UiRd
|
||||||
r.s = src
|
r.s = src
|
||||||
|
var line = line0
|
||||||
|
var seen = 0
|
||||||
while r.err == "" {
|
while r.err == "" {
|
||||||
rd_ws(r)
|
rd_ws(r)
|
||||||
if r.i >= len(r.s) { break }
|
if r.i >= len(r.s) { break }
|
||||||
|
line = line + lss_lines(src, seen, r.i)
|
||||||
|
seen = r.i
|
||||||
if rd_op(r, "@import") {
|
if rd_op(r, "@import") {
|
||||||
lss_import(ui_st, f, lss_upto(r, 59))
|
lss_import(ui_st, f, lss_upto(r, 59))
|
||||||
continue
|
continue
|
||||||
|
|
@ -59,15 +66,16 @@ function lss_block(ui_st: mut UiState, f: UiFile, src: string, md: UiMedia, err:
|
||||||
}
|
}
|
||||||
if rd_op(r, "@media") {
|
if rd_op(r, "@media") {
|
||||||
let cond = md_parse(lss_upto(r, 123))
|
let cond = md_parse(lss_upto(r, 123))
|
||||||
|
let mline = line + lss_lines(src, seen, r.i)
|
||||||
let mb = lss_upto(r, 125)
|
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) }
|
unsafe { free(mb) }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let sels = lss_upto(r, 123)
|
let sels = lss_upto(r, 123)
|
||||||
let body = lss_upto(r, 125)
|
let body = lss_upto(r, 125)
|
||||||
let at = len(f.own)
|
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 }
|
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
|
unsafe { # a rule keeps what it parsed, never the text it was sliced from
|
||||||
free(sels)
|
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) }
|
if r.err != "" { push(err, r.err) }
|
||||||
unsafe { free(r) } # the reader only: its text is the caller's
|
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
|
# @import "base.lss"; - its rules come before this file's own
|
||||||
function lss_import(ui_st: mut UiState, f: UiFile, what: string) -> void {
|
function lss_import(ui_st: mut UiState, f: UiFile, what: string) -> void {
|
||||||
var p = tpl_words(what)
|
var p = tpl_words(what)
|
||||||
|
|
@ -112,7 +136,13 @@ function lss_strip(src: string) -> string {
|
||||||
while i < len(src) {
|
while i < len(src) {
|
||||||
if i + 1 < len(src) and src[i] == 47 and src[i + 1] == 42 {
|
if i + 1 < len(src) and src[i] == 47 and src[i + 1] == 42 {
|
||||||
var j = i + 2
|
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
|
i = j + 2
|
||||||
} else {
|
} else {
|
||||||
unsafe { p[n] = src[i] }
|
unsafe { p[n] = src[i] }
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# style_rule.ludic - a rule read, the cascade a file sees, and a node's rules applied to it
|
# 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 decls = lss_decls(body, err)
|
||||||
let each = lss_split(sels, 44)
|
let each = lss_split(sels, 44)
|
||||||
for i in 0 .. len(each) {
|
for i in 0 .. len(each) {
|
||||||
let sel = tpl_words(each[i])
|
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
|
unsafe { # a selector keeps its parsed parts, never its text
|
||||||
if not tpl_clean(each[i]) { free(sel) }
|
if not tpl_clean(each[i]) { free(sel) }
|
||||||
free(each[i])
|
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
|
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
|
let r = new UiRule
|
||||||
r.keys = decls.keys
|
r.keys = decls.keys
|
||||||
r.vals = decls.vals
|
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)
|
r.sel = sel_parse(sel, err)
|
||||||
if r.sel != null {
|
if r.sel != null {
|
||||||
r.spec = sel_spec(r.sel)
|
r.spec = sel_spec(r.sel)
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ program LudicDev {
|
||||||
print("which ships to users and carries none of the below.")
|
print("which ships to users and carries none of the below.")
|
||||||
print("")
|
print("")
|
||||||
print("build:")
|
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(" build-cli build just bin/ludicc from the IR seed")
|
||||||
print(" tools [--install] [--test] build the editor toolchain (ludic-fmt, ludic-lsp)")
|
print(" tools [--install] [--test] build the editor toolchain (ludic-fmt, ludic-lsp)")
|
||||||
print(" clean remove build/")
|
print(" clean remove build/")
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ program Ludic {
|
||||||
import "scripts.ludic"
|
import "scripts.ludic"
|
||||||
import "deps.ludic"
|
import "deps.ludic"
|
||||||
import "schema.ludic"
|
import "schema.ludic"
|
||||||
|
import "ui_preview.ludic"
|
||||||
import "testpar.ludic"
|
import "testpar.ludic"
|
||||||
import "migrate.ludic"
|
import "migrate.ludic"
|
||||||
|
|
||||||
|
|
@ -78,6 +79,7 @@ program Ludic {
|
||||||
print(" doctor check that the install is complete and usable")
|
print(" doctor check that the install is complete and usable")
|
||||||
print(" fmt [--check] [paths...] format Ludic source in place (--check: report, write nothing)")
|
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(" 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(" help this message")
|
||||||
print("")
|
print("")
|
||||||
print("package scripts (package.ludic):")
|
print("package scripts (package.ludic):")
|
||||||
|
|
@ -110,6 +112,7 @@ program Ludic {
|
||||||
if (cmd == "clean") { return cmd_clean() }
|
if (cmd == "clean") { return cmd_clean() }
|
||||||
if (cmd == "fmt") { return cmd_fmt() }
|
if (cmd == "fmt") { return cmd_fmt() }
|
||||||
if (cmd == "lsp") { return cmd_lsp() }
|
if (cmd == "lsp") { return cmd_lsp() }
|
||||||
|
if (cmd == "ui-preview") { return cmd_ui_preview() }
|
||||||
if (cmd == "doctor") { return cmd_doctor() }
|
if (cmd == "doctor") { return cmd_doctor() }
|
||||||
if (cmd == "upgrade") { return cmd_upgrade() }
|
if (cmd == "upgrade") { return cmd_upgrade() }
|
||||||
if (cmd == "script") { return cmd_script() }
|
if (cmd == "script") { return cmd_script() }
|
||||||
|
|
|
||||||
|
|
@ -324,7 +324,7 @@ function build_artifacts(ver: pointer) -> bool {
|
||||||
let name = `ludic-{ver}-{plat}-{arch}`
|
let name = `ludic-{ver}-{plat}-{arch}`
|
||||||
let stage = `{tmp_dir()}/{name}`
|
let stage = `{tmp_dir()}/{name}`
|
||||||
shell(`rm -rf {stage} && mkdir -p {stage}/bin`)
|
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")
|
err("release: the toolchain is not built (run: ludic-dev build)\n")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,9 @@ function cmd_dev_build() -> int {
|
||||||
if not build_tool("ludic-fmt", "tools/ludic-tools/fmt.ludic") { return 1 }
|
if not build_tool("ludic-fmt", "tools/ludic-tools/fmt.ludic") { return 1 }
|
||||||
print("ludicc: tools/ludic-tools/lsp.ludic -> bin/ludic-lsp")
|
print("ludicc: tools/ludic-tools/lsp.ludic -> bin/ludic-lsp")
|
||||||
if not build_tool("ludic-lsp", "tools/ludic-tools/lsp.ludic") { return 1 }
|
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()
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
tools/ludic-cli/ui_preview.ludic
Normal file
19
tools/ludic-cli/ui_preview.ludic
Normal 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
|
||||||
|
}
|
||||||
59
tools/ui-preview/README.md
Normal file
59
tools/ui-preview/README.md
Normal 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.
|
||||||
66
tools/ui-preview/calls.ludic
Normal file
66
tools/ui-preview/calls.ludic
Normal 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
|
||||||
|
}
|
||||||
83
tools/ui-preview/cmd_load.ludic
Normal file
83
tools/ui-preview/cmd_load.ludic
Normal 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)
|
||||||
|
}
|
||||||
97
tools/ui-preview/cmd_world.ludic
Normal file
97
tools/ui-preview/cmd_world.ludic
Normal 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 ""
|
||||||
|
}
|
||||||
72
tools/ui-preview/cmds.ludic
Normal file
72
tools/ui-preview/cmds.ludic
Normal 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 ""
|
||||||
|
}
|
||||||
78
tools/ui-preview/computed.ludic
Normal file
78
tools/ui-preview/computed.ludic
Normal 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"
|
||||||
|
}
|
||||||
79
tools/ui-preview/draw.ludic
Normal file
79
tools/ui-preview/draw.ludic
Normal 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) }
|
||||||
74
tools/ui-preview/events.ludic
Normal file
74
tools/ui-preview/events.ludic
Normal 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)
|
||||||
|
}
|
||||||
80
tools/ui-preview/font.ludic
Normal file
80
tools/ui-preview/font.ludic
Normal 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))
|
||||||
|
}
|
||||||
81
tools/ui-preview/images.ludic
Normal file
81
tools/ui-preview/images.ludic
Normal 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)
|
||||||
|
}
|
||||||
78
tools/ui-preview/input.ludic
Normal file
78
tools/ui-preview/input.ludic
Normal 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
|
||||||
|
}
|
||||||
28
tools/ui-preview/input_keys.ludic
Normal file
28
tools/ui-preview/input_keys.ludic
Normal 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
|
||||||
|
}
|
||||||
72
tools/ui-preview/inspect.ludic
Normal file
72
tools/ui-preview/inspect.ludic
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
tools/ui-preview/json.ludic
Normal file
67
tools/ui-preview/json.ludic
Normal 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()
|
||||||
|
}
|
||||||
92
tools/ui-preview/line.ludic
Normal file
92
tools/ui-preview/line.ludic
Normal 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
|
||||||
|
}
|
||||||
88
tools/ui-preview/main.ludic
Normal file
88
tools/ui-preview/main.ludic
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
tools/ui-preview/mock.ludic
Normal file
89
tools/ui-preview/mock.ludic
Normal 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 }
|
||||||
59
tools/ui-preview/num.ludic
Normal file
59
tools/ui-preview/num.ludic
Normal 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)
|
||||||
|
}
|
||||||
78
tools/ui-preview/out.ludic
Normal file
78
tools/ui-preview/out.ludic
Normal 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
|
||||||
|
}
|
||||||
244
tools/ui-preview/protocol-v1.md
Normal file
244
tools/ui-preview/protocol-v1.md
Normal 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
152
tools/ui-preview/smoke.txt
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue