From 6dae2aabbcdf469d04c08bffb884d0867e831d67 Mon Sep 17 00:00:00 2001 From: Edward Tan Date: Sat, 26 Sep 2026 14:00:33 -0700 Subject: [PATCH 1/4] Route L0 Card controls to a host-owned event channel --- crates/octoscript-makepad/src/lib.rs | 67 ++++++++++++--------- crates/octoscript-makepad/tests/l0_packs.rs | 13 ++++ 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/crates/octoscript-makepad/src/lib.rs b/crates/octoscript-makepad/src/lib.rs index d15f634..5d779d0 100644 --- a/crates/octoscript-makepad/src/lib.rs +++ b/crates/octoscript-makepad/src/lib.rs @@ -61,7 +61,7 @@ pub fn to_makepad_ui(root: &UiNode) -> String { let mut root = root.clone(); resolve_ink_planes(&mut root, None, None); let mut out = String::new(); - emit(&root, &mut out, 0, false); + emit(&root, &mut out, 0, false, None); out } @@ -69,13 +69,30 @@ pub fn to_makepad_ui(root: &UiNode) -> String { /// Material's semantic Card/Chip lowering must not overwrite these dimensions, /// colors or child labels, and L0 sizes are already Makepad points. pub fn to_makepad_l0_ui(root: &UiNode) -> String { + to_makepad_l0_ui_inner(root, None) +} + +/// Route checked L0 event targets to a host-owned channel. The host must +/// verify the currently mounted Card identity before dispatching the target. +pub fn to_makepad_l0_ui_with_events(root: &UiNode, event_channel: &str) -> String { + to_makepad_l0_ui_inner(root, Some(event_channel)) +} + +fn to_makepad_l0_ui_inner(root: &UiNode, event_channel: Option<&str>) -> String { let mut root = root.clone(); resolve_ink_planes(&mut root, None, None); let mut out = String::new(); - emit(&root, &mut out, 0, true); + emit(&root, &mut out, 0, true, event_channel); out } +fn click_call(target: &str, event_channel: Option<&str>) -> String { + match event_channel.filter(|_| target.starts_with("l0:")) { + Some(channel) => format!("agent.notify({channel:?}, {{target: {target:?}}})"), + None => format!("NAV(t: {target:?})"), + } +} + pub mod design; /// Relative luminance of an 0xAARRGGBB colour, sRGB, alpha ignored. @@ -229,7 +246,7 @@ fn needs_click_overlay(node: &UiNode) -> bool { node.attrs.tapto.is_some() && is_container(node.kind) } -fn emit(node: &UiNode, out: &mut String, depth: usize, resolved: bool) { +fn emit(node: &UiNode, out: &mut String, depth: usize, resolved: bool, event_channel: Option<&str>) { let mut primitive; let node = if resolved && matches!(node.kind, NodeKind::Card | NodeKind::Chip) { primitive = node.clone(); @@ -243,18 +260,18 @@ fn emit(node: &UiNode, out: &mut String, depth: usize, resolved: bool) { // and left every lowered component inert. This terminates because a lowered // node is primitive, and a resolved text role clears the `variant`. if let Some(lowered) = (!resolved).then(|| material::lower(node, &theme())).flatten() { - emit(&lowered, out, depth, resolved); + emit(&lowered, out, depth, resolved, event_channel); return; } if !resolved && needs_vertical_pad_wrapper(node) { - emit_vertical_pad(node, out, depth, resolved); + emit_vertical_pad(node, out, depth, resolved, event_channel); return; } if needs_click_overlay(node) { - emit_click_overlay(node, out, depth, resolved); + emit_click_overlay(node, out, depth, resolved, event_channel); return; } - emit_widget(node, out, depth, resolved); + emit_widget(node, out, depth, resolved, event_channel); } /// Vertical-only padding (`pady` with no `padx`) on a node that has no fixed @@ -295,7 +312,7 @@ fn needs_vertical_pad_wrapper(node: &UiNode) -> bool { || text_default } -fn emit_vertical_pad(node: &UiNode, out: &mut String, depth: usize, resolved: bool) { +fn emit_vertical_pad(node: &UiNode, out: &mut String, depth: usize, resolved: bool, event_channel: Option<&str>) { let ind = " ".repeat(depth); let inner_ind = " ".repeat(depth + 1); // The widget already carries makepad's own `theme.mspace_1` inset (~4dp), so @@ -349,19 +366,19 @@ fn emit_vertical_pad(node: &UiNode, out: &mut String, depth: usize, resolved: bo bare.attrs.pad = None; bare.attrs.marginy = Some(0.0); bare.attrs.margin = None; - emit(&bare, out, depth + 1, resolved); + emit(&bare, out, depth + 1, resolved, event_channel); let _ = writeln!(out, "{inner_ind}View {{ height: {py} }}"); let _ = writeln!(out, "{ind}}}"); } /// `Overlay{ , Button{…on_click} }` — see [`needs_click_overlay`]. /// -/// The handler calls `NAV`, a global the host registers, rather than reaching -/// through `ui.nav_signal`. `ui` is injected by `Splash::eval_body`, so a body +/// The handler calls `NAV` for ordinary routes. L0 Card event targets can use +/// a host-owned notification channel instead. `ui` is injected by `Splash::eval_body`, so a body /// mounted anywhere else — notably on the app's main VM, which is what gets this /// crate's fonts and a widget kit's theming into reach — had every tap silently /// do nothing. A global works on either VM. -fn emit_click_overlay(node: &UiNode, out: &mut String, depth: usize, resolved: bool) { +fn emit_click_overlay(node: &UiNode, out: &mut String, depth: usize, resolved: bool, event_channel: Option<&str>) { let ind = " ".repeat(depth); let inner_ind = " ".repeat(depth + 1); let a = &node.attrs; @@ -426,7 +443,7 @@ fn emit_click_overlay(node: &UiNode, out: &mut String, depth: usize, resolved: b // The content, with `tapto` stripped so it does not re-enter this path. let mut content = node.clone(); content.attrs.tapto = None; - emit_widget(&content, out, depth + 1, resolved); + emit_widget(&content, out, depth + 1, resolved, event_channel); // The hit target: an empty ButtonFlatter filling the wrapper. // @@ -445,15 +462,13 @@ fn emit_click_overlay(node: &UiNode, out: &mut String, depth: usize, resolved: b let _ = writeln!(out, "{inner_ind}OctoscriptTap {{"); let _ = writeln!(out, "{inner_ind} width: Fill"); let _ = writeln!(out, "{inner_ind} height: Fill"); - let _ = writeln!( - out, - "{inner_ind} on_click: || {{ NAV(t: {target:?}) }}" - ); + let call = click_call(target, event_channel); + let _ = writeln!(out, "{inner_ind} on_click: || {{ {call} }}"); let _ = writeln!(out, "{inner_ind}}}"); let _ = writeln!(out, "{ind}}}"); } -fn emit_widget(node: &UiNode, out: &mut String, depth: usize, resolved: bool) { +fn emit_widget(node: &UiNode, out: &mut String, depth: usize, resolved: bool, event_channel: Option<&str>) { let ind = " ".repeat(depth); let name = widget_for(node); // An `id` makes the widget addressable in the mounted tree: `name := Widget{…}`. @@ -465,14 +480,14 @@ fn emit_widget(node: &UiNode, out: &mut String, depth: usize, resolved: bool) { let _ = writeln!(out, "{ind}{name} {{"); } } - emit_attrs(node, out, depth + 1, resolved); + emit_attrs(node, out, depth + 1, resolved, event_channel); // Only containers carry children — decided by the node's *kind*, and now // actually so. This read `widget_name(node.kind) == "View"`, which is a // question about the mapped name wearing the comment of a question about the // kind. See `is_container`. if is_container(node.kind) { for c in &node.children { - emit(c, out, depth + 1, resolved); + emit(c, out, depth + 1, resolved, event_channel); } } let _ = writeln!(out, "{ind}}}"); @@ -586,7 +601,7 @@ fn control_roles(kind: NodeKind, a: &Attrs) -> Vec { /// makepad evidently resolves those on a different rounding path. const DP_SCALE: f32 = 157.7 / 157.0; -fn emit_attrs(node: &UiNode, out: &mut String, depth: usize, resolved: bool) { +fn emit_attrs(node: &UiNode, out: &mut String, depth: usize, resolved: bool, event_channel: Option<&str>) { let ind = " ".repeat(depth); let a = &node.attrs; if resolved && node.kind == NodeKind::Column { @@ -778,13 +793,11 @@ fn emit_attrs(node: &UiNode, out: &mut String, depth: usize, resolved: bool) { if let Some(ph) = a.placeholder.as_ref() { let _ = writeln!(out, "{ind}empty_text: {ph:?}"); } - // `tapto` wires an on_click that writes the route into the `nav_signal` - // widget; the host app reads that text and re-mounts the target screen. + // `tapto` uses the registered navigation callback or the Card host's + // event channel; only the latter handles L0 event targets. if let Some(target) = a.tapto.as_ref().filter(|t| !t.is_empty()) { - let _ = writeln!( - out, - "{ind}on_click: || {{ NAV(t: {target:?}) }}" - ); + let call = click_call(target, event_channel); + let _ = writeln!(out, "{ind}on_click: || {{ {call} }}"); } if let Some(s) = a.size { // The DSL states type sizes in sp, as Material does; makepad's font_size diff --git a/crates/octoscript-makepad/tests/l0_packs.rs b/crates/octoscript-makepad/tests/l0_packs.rs index 76d555c..9f03da8 100644 --- a/crates/octoscript-makepad/tests/l0_packs.rs +++ b/crates/octoscript-makepad/tests/l0_packs.rs @@ -63,3 +63,16 @@ fn theme_modes_answer_the_same_registered_component_names() { assert!(dark["compounds"].as_object().unwrap().len() > 50); } } + +#[test] +fn l0_event_target_routes_to_a_host_owned_channel() { + let source = "state selected { shape: enum[off, on], initial: .off }\n\ + event flip { selected: cycle(.off, .on) }\n\ + view root Row(on_tap: flip) { TextBody(text: selected) }"; + let prepared = octoscript_makepad::l0::prepare(source, &serde_json::json!({}), &directory()).unwrap(); + let ui = octoscript_makepad::to_makepad_l0_ui_with_events(&prepared.tree, "card-runtime-42"); + assert!(ui.contains("OctoscriptTap {")); + assert!(ui.contains("agent.notify(\"card-runtime-42\"")); + assert!(ui.contains("l0:{")); + assert!(!ui.contains("NAV(")); +} From 4de95e1d25b556b8b2a123ec32e3a1bbad7edbd2 Mon Sep 17 00:00:00 2001 From: Edward Tan Date: Sat, 26 Sep 2026 14:02:30 -0700 Subject: [PATCH 2/4] Rebuild L0 Cards from updated instance state --- crates/octoscript-makepad/src/l0.rs | 8 +++++++- crates/octoscript-makepad/tests/l0_packs.rs | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/octoscript-makepad/src/l0.rs b/crates/octoscript-makepad/src/l0.rs index 43e6032..2e9725e 100644 --- a/crates/octoscript-makepad/src/l0.rs +++ b/crates/octoscript-makepad/src/l0.rs @@ -40,7 +40,13 @@ pub fn inspectable(tree: &mut octoscript_render::UiNode) -> Vec Result { - let report = octoscript_ui_l0::realize(card, data, Default::default()); + prepare_with_state(card, data, dir, &octoscript_ui_l0::InstanceStore::default()) +} + +/// Rebuild the same native Card after a declared event changed instance state. +pub fn prepare_with_state(card: &str, data: &serde_json::Value, dir: &Path, + state: &octoscript_ui_l0::InstanceStore) -> Result { + let report = octoscript_ui_l0::realize_with_state(card, data, state, Default::default()); let root = report.complete_root()?; let mood = octoscript_ui_l0::card_theme(card).unwrap_or_else(|| "dark".into()); if octoscript_ui_l0::kit_pack::contains(root) { diff --git a/crates/octoscript-makepad/tests/l0_packs.rs b/crates/octoscript-makepad/tests/l0_packs.rs index 9f03da8..d6433cb 100644 --- a/crates/octoscript-makepad/tests/l0_packs.rs +++ b/crates/octoscript-makepad/tests/l0_packs.rs @@ -75,4 +75,11 @@ fn l0_event_target_routes_to_a_host_owned_channel() { assert!(ui.contains("agent.notify(\"card-runtime-42\"")); assert!(ui.contains("l0:{")); assert!(!ui.contains("NAV(")); + let mut state = octoscript_ui_l0::InstanceStore::default(); + assert!(octoscript_ui_l0::dispatch_with_data(source, &mut state, "root", "flip", None, &serde_json::json!({}))); + let changed = octoscript_makepad::l0::prepare_with_state(source, &serde_json::json!({}), &directory(), &state).unwrap(); + fn has_text(node: &octoscript_render::UiNode, text: &str) -> bool { + node.attrs.text.as_deref() == Some(text) || node.children.iter().any(|child| has_text(child, text)) + } + assert!(has_text(&changed.tree, "on")); } From 4fe5cc50b828a6dba10cc003b7e3b055a23b83b1 Mon Sep 17 00:00:00 2001 From: Edward Tan Date: Sat, 26 Sep 2026 14:35:14 -0700 Subject: [PATCH 3/4] chore(runtime): pin refreshed L0 state snapshots --- Cargo.toml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 85eccab..b7ae2b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ members = [ makepad-widgets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "cd812acde910e9c4523f83509356450eb8edf908" } makepad-script = { git = "https://github.com/OctoSense-org/makepad.git", rev = "cd812acde910e9c4523f83509356450eb8edf908" } # The VM-independent Octoscript node model, from the Octoscript repo itself. -octoscript-ui-l0 = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "68f6a9df55692b5d8ef8873a12721e279a3f40d6" } +octoscript-ui-l0 = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "3c1c6bae8c037627f8610aafa2a3a4f1c56ac567" } [profile.release] opt-level = 3 @@ -33,8 +33,5 @@ codegen-units = 1 makepad-widgets = { path = "../makepad/widgets" } makepad-script = { path = "../makepad/platform/script" } -[patch."https://github.com/OctoSense-org/Octoscript.git"] -octoscript-ui-l0 = { path = "../octoscript/crates/octoscript-ui-l0" } - [patch."https://github.com/OctoSense-org/Octoscript-Makepad.git"] octoscript-node = { path = "crates/octoscript-node" } From c2c6699b7dcccf976025e8671def56215013a71e Mon Sep 17 00:00:00 2001 From: Edward Tan Date: Tue, 29 Sep 2026 18:13:02 -0700 Subject: [PATCH 4/4] Resolve octoscript-ui-l0 from the prepared sibling again runtime.json locks Octoscript 76032ed4; tools/runtime.py prepare puts ../octoscript at that revision, as on main. --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 6940ac6..f7c5e69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,5 +33,8 @@ codegen-units = 1 makepad-widgets = { path = "../makepad/widgets" } makepad-script = { path = "../makepad/platform/script" } +[patch."https://github.com/OctoSense-org/Octoscript.git"] +octoscript-ui-l0 = { path = "../octoscript/crates/octoscript-ui-l0" } + [patch."https://github.com/OctoSense-org/Octoscript-Makepad.git"] octoscript-node = { path = "crates/octoscript-node" }