diff --git a/changes/ui-override.md b/changes/ui-override.md
new file mode 100644
index 00000000..481a9af1
--- /dev/null
+++ b/changes/ui-override.md
@@ -0,0 +1,8 @@
+bump: minor
+type: feature
+**`ui_override(path, text)`: a template or stylesheet given from outside.** From then on a read of `path` answers
+`text`; a component whose template or stylesheet it is swaps its own and is shown again with its state kept,
+and an importer reads the new text. `ui_override_clear(path)` (or `""` for every one) brings the file back,
+and `ui_overridden(path)` says whether one stands. A developer build's disk polling leaves an overridden
+component alone. For a studio editing a running game's interface (devlink's `ui_override`); a game that
+overrides nothing pays a length check per file read.
diff --git a/examples/library/ui_override.ludic b/examples/library/ui_override.ludic
new file mode 100644
index 00000000..867341e2
--- /dev/null
+++ b/examples/library/ui_override.ludic
@@ -0,0 +1,36 @@
+# ui_override.ludic - a template's text given from outside in place of its file (a studio editing a
+# running game's interface): the component is shown again with the new template and its state kept,
+# and letting go brings the file back, the state still kept. Prints three lines: the counters as the
+# files have them, as the override has them, and as the files have them again.
+import "ludic.ui"
+import "ui_component_parts/Counter.ludic"
+import "ui_component_parts/App.ludic"
+program UiOverride {
+ numbers float
+ function texts(n: UiNode, out: []string) -> void {
+ if n.text != "" { push(out, n.text) }
+ for i in 0 .. len(n.children) { texts(n.children[i], out) }
+ }
+ function frame(ui_st: mut UiState) -> UiNode {
+ let root: UiNode = ui_nodes(ui_st, "App", null)
+ ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0)
+ return root
+ }
+ function said(ui_st: mut UiState) -> string {
+ let out = new []string
+ texts(frame(ui_st), out)
+ var joined = ""
+ for i in 0 .. len(out) { joined = joined + out[i] + "|" }
+ return joined
+ }
+ entry (ui_st: mut UiState) {
+ let root = frame(ui_st)
+ ui_press(ui_st, root.children[0].children[1].children[1]) # counter a: + once
+ print(said(ui_st))
+ let xml = "examples/library/ui_component_parts/Counter.xml"
+ ui_override(ui_st, xml, "
")
+ print(`{said(ui_st)} {ui_overridden(ui_st, xml)}`)
+ ui_override_clear(ui_st, "")
+ print(`{said(ui_st)} {ui_overridden(ui_st, xml)}`)
+ }
+}
diff --git a/packages/ludic.ui/backend.ludic b/packages/ludic.ui/backend.ludic
index a822b597..393ecfff 100644
--- a/packages/ludic.ui/backend.ludic
+++ b/packages/ludic.ui/backend.ludic
@@ -34,6 +34,8 @@ function ui_default_backend() -> UiBackend {
return UiRenderers[0].make()
}
export state UiState {
+ @max(64) ov_path: []string = new []string # override.ludic: paths a studio's text stands in for ...
+ @max(64) ov_text: []string = new []string # ... and the texts
@max(256) ph_keys: []string = new []string # held.ludic: elements a preview holds a pseudo-class on for ...
@max(256) ph_bits: []int = new []int # ... and which (hover 1, active 2, focus 4, focus-visible 8, checked 16, disabled 32)
@max(512) pl_pools: []UiPool = new []UiPool # pool.ludic: each screen's nodes and envs, used again
diff --git a/packages/ludic.ui/class.ludic b/packages/ludic.ui/class.ludic
index dd792e1e..75ef501b 100644
--- a/packages/ludic.ui/class.ludic
+++ b/packages/ludic.ui/class.ludic
@@ -18,6 +18,8 @@ export property UiClass {
seen_xml: string = "" # the files as a developer's build last found them on disk
seen_lss: string = ""
use: UiTpl = null # screen_class.ludic: the element that shows it as a screen, made once
+ ov_xml: string = null # override.ludic: its own template and styles, kept while a studio's text stands in
+ ov_lss: string = null
roots: []UiTpl = null # build_class.ludic: [root], for bd_kids
none: Val = null # and the empty model it is shown with
}
@@ -62,6 +64,7 @@ function uc_reload(ui_st: mut UiState) -> bool {
var changed = false
for i in 0 .. len(ui_st.uc_list) {
let k = ui_st.uc_list[i]
+ if ov_find(ui_st, k.xml_path) >= 0 or ov_find(ui_st, k.lss_path) >= 0 { continue } # a studio's text stands
if k.xml_path != "" and Fs.exists(k.xml_path) {
let x = lib_text(ui_st, k.xml_path)
var l = ""
diff --git a/packages/ludic.ui/index.ludic b/packages/ludic.ui/index.ludic
index 0612a511..022c474d 100644
--- a/packages/ludic.ui/index.ludic
+++ b/packages/ludic.ui/index.ludic
@@ -110,3 +110,4 @@ import "draw_gradient.ludic"
import "dump.ludic"
import "inspect.ludic"
import "held.ludic"
+import "override.ludic"
diff --git a/packages/ludic.ui/lib_util.ludic b/packages/ludic.ui/lib_util.ludic
index c769f4df..98298593 100644
--- a/packages/ludic.ui/lib_util.ludic
+++ b/packages/ludic.ui/lib_util.ludic
@@ -24,6 +24,8 @@ function lib_blank(path: string) -> UiFile {
return f
}
function lib_text(ui_st: UiState, path: string) -> string {
+ let o = ov_find(ui_st, path)
+ if o >= 0 { return `{ui_st.ov_text[o]}` } # a copy: a caller may give back what it read (rl_let_go)
var src = ""
if ui_st.ui_be != null and ui_st.ui_be.read != null { src = ui_st.ui_be.read(path) } else { src = Fs.read_text(path) }
if src == null { return "" }
diff --git a/packages/ludic.ui/override.ludic b/packages/ludic.ui/override.ludic
new file mode 100644
index 00000000..0f13da0f
--- /dev/null
+++ b/packages/ludic.ui/override.ludic
@@ -0,0 +1,91 @@
+# override.ludic - a template's or a stylesheet's text given from outside, in place of its file: a
+# studio editing a running game's interface (devlink's ui_override). Every read of that path answers
+# the given text, a component whose file it is swaps its own and is shown again with its state kept,
+# and letting go puts the file back. A game overrides nothing; a read asks an empty list first.
+
+# from now on `path` reads as `text`; its component (or every importer) is reloaded, state kept
+@alloc_ok("a studio's edit: the text kept until it is let go, never a frame")
+export function ui_override(ui_st: mut UiState, path: string, text: string) -> void {
+ let t = keep(text)
+ var found = false
+ for i in 0 .. len(ui_st.ov_path) {
+ if ui_st.ov_path[i] == path {
+ ui_st.ov_text[i] = t
+ found = true
+ }
+ }
+ if not found {
+ push(ui_st.ov_path, intern(path))
+ push(ui_st.ov_text, t)
+ }
+ ov_classes(ui_st, path, t)
+ ui_file_forget(ui_st, path) # a file an import read is read again; every class re-shown
+}
+# let go of `path`'s override ("" every override): the file reads from disk again, a component's
+# own text comes back
+@alloc_ok("a studio's edit, never a frame")
+export function ui_override_clear(ui_st: mut UiState, path: string) -> void {
+ var keep_p = new []string
+ var keep_t = new []string
+ for i in 0 .. len(ui_st.ov_path) {
+ let p = ui_st.ov_path[i]
+ if path == "" or p == path {
+ ov_restore(ui_st, p)
+ ui_file_forget(ui_st, p)
+ } else {
+ push(keep_p, p)
+ push(keep_t, ui_st.ov_text[i])
+ }
+ }
+ List.clear(ui_st.ov_path)
+ List.clear(ui_st.ov_text)
+ for i in 0 .. len(keep_p) {
+ push(ui_st.ov_path, keep_p[i])
+ push(ui_st.ov_text, keep_t[i])
+ }
+}
+# is `path` overridden, and its text (null when it is not)
+export function ui_overridden(ui_st: UiState, path: string) -> bool { return ov_find(ui_st, path) >= 0 }
+function ov_find(ui_st: UiState, path: string) -> int {
+ for i in 0 .. len(ui_st.ov_path) {
+ if ui_st.ov_path[i] == path { return i }
+ }
+ return -1
+}
+# a component whose template or stylesheet is `path` takes `text`, its own kept to come back to
+function ov_classes(ui_st: mut UiState, path: string, text: string) -> void {
+ if ui_st.uc_list == null { return }
+ for i in 0 .. len(ui_st.uc_list) {
+ let k = ui_st.uc_list[i]
+ if k.xml_path == path or k.lss_path == path {
+ if k.ov_xml == null {
+ k.ov_xml = k.xml
+ k.ov_lss = k.lss
+ }
+ if k.xml_path == path { k.xml = text } else { k.lss = text }
+ k.root = null
+ }
+ }
+}
+function ov_restore(ui_st: mut UiState, path: string) -> void {
+ if ui_st.uc_list == null { return }
+ for i in 0 .. len(ui_st.uc_list) {
+ let k = ui_st.uc_list[i]
+ if k.ov_xml != null and (k.xml_path == path or k.lss_path == path) {
+ if k.xml_path == path { k.xml = k.ov_xml } else { k.lss = k.ov_lss }
+ if not ov_class_held(ui_st, k, path) {
+ k.ov_xml = null
+ k.ov_lss = null
+ }
+ k.root = null
+ }
+ }
+}
+# does the class's other file still have an override (so its own texts are still needed later)
+function ov_class_held(ui_st: UiState, k: UiClass, going: string) -> bool {
+ for i in 0 .. len(ui_st.ov_path) {
+ let p = ui_st.ov_path[i]
+ if p != going and (p == k.xml_path or p == k.lss_path) { return true }
+ }
+ return false
+}
diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic
index 5e9af9d7..7699683d 100644
--- a/tools/ludic-cli/test.ludic
+++ b/tools/ludic-cli/test.ludic
@@ -1295,6 +1295,7 @@ function cmd_dev_test() -> int {
controller_case("library/ui_css", "", "badge at 255,175 bg 16746496; nudged 17,13; big 32; corner 0,0 40x32 media keeps narrow; li gaps 6 0; li colours 255 -1 255; after 16746496; fallback 65280 checked opacity 0.25; list scrolls 1 50 of 80; para lines 5", "ui_css.ludic (L11: var(), positioning, em/rem/vw, @media, wrapping, sibling combinators, :nth-child formulas, :checked, overflow)")
controller_case("library/ui_reload", "", "ui: templates reloaded note 1 | reloaded 0 then 1 | note again 1", "ui_reload.ludic (L11: a template changed on disk is read again, and its state kept)")
controller_case("library/ui_component", "", "counters|a: 1 (2)|+|reset|b: 10 (20) big|+|reset|outside| b margin 9; b text 16711680; outside -1; button 30 b: 0 (0)", "ui_component.ludic (L11: components as NAME.ludic + .xml + .lss - props, state, fields, events, scoped styles, @import, class passed down)")
+ controller_case("library/ui_override", "", "counters|a: 1 (2)|+|reset|b: 0 (0)|+|reset|outside| counters|[a=1]|+|[b=0]|+|outside| 1 counters|a: 1 (2)|+|reset|b: 0 (0)|+|reset|outside| 0", "ui_override.ludic (a template given from outside: shown with the state kept, and the file back when let go)")
reject_case("rejected/component_no_template", "there is no Lonely.xml beside it", "a component needs its template beside it")
controller_case("library/ui_remount", "", "c: 2 (4) | c: 0 (0) | heap 0", "ui_remount.ludic (a component unmounted and mounted again starts as new, and two thousand comings and goings hold the heap flat)")
controller_case("library/ui_controls", "", "1 false 7 2 Adalove 70 1 false 8 2 Adalove 70; scrolled 40", "ui_controls.ludic (L11: focus and keys, the built-in controls, a range dragged, a box scrolled)")