a2ui-slint 0.2.0

Slint native-GUI backend for A2UI (Agent to UI)
Documentation
// With `backend` off the helpers below are unused; `cargo publish` verifies
// with default features (backend off), so silence that rather than cfg-gating
// every item.
#![allow(unused)]

//! Compile the A2UI Slint UI into a Rust module.
//!
//! Slint cannot express recursion (neither recursive structs nor self-
//! referencing components — see slint-ui/slint#4218). A2UI component trees are
//! arbitrarily nested, so we work around this by **unrolling a bounded number
//! of nesting levels**: a chain of components `Node0` (leaf) → `Node1` → … →
//! `Node{MAX_DEPTH}`, where each `NodeK` renders a node and, for its children,
//! instantiates `Node{K-1}`. Because each level references a *different,
//! already-declared* sibling (never itself), Slint resolves it fine. A2UI trees
//! are shallow, so `MAX_DEPTH = 7` covers realistic UIs; deeper subtrees
//! truncate to a `…` marker.
//!
//! To keep the per-kind rendering defined once (not duplicated across levels),
//! `build.rs` generates the whole `.slint` from a single template. Adding a new
//! component kind means editing [`node_body`] below, once.
//!
//! Only runs under the `backend` feature (slint-build is an optional
//! build-dependency).

use std::path::Path;

/// Maximum renderable nesting depth. `Node0` is the leaf; `Node{MAX_DEPTH}` is
/// what the root `Surface` instantiates. Trees deeper than this truncate.
const MAX_DEPTH: usize = 7;

#[cfg(feature = "backend")]
fn main() {
    println!("cargo:rerun-if-changed=build.rs");

    let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
    let dst = Path::new(&out_dir).join("a2ui_generated.slint");

    let generated = generate();
    std::fs::write(&dst, generated).expect("write generated .slint");

    slint_build::compile(dst.to_str().expect("utf8 path"))
        .expect("Slint UI compilation failed");
}

// Without the `backend` feature, `slint-build` is not a build-dependency, so
// the build script is a no-op. `cargo publish` verifies with default features
// (backend off), so this must compile standalone.
#[cfg(not(feature = "backend"))]
fn main() {}

/// Produce the full `.slint` source: the `LiveNode` struct, the `Events`
/// global (routes UI interactions back to Rust), the `Node0..Node{MAX_DEPTH}`
/// chain, and the root `Surface` window.
fn generate() -> String {
    let mut out = String::new();
    out.push_str("// AUTO-GENERATED by crates/slint/build.rs — do not edit.\n");
    out.push_str("// Bounded-depth unrolling of the A2UI component tree (Slint cannot recurse).\n\n");

    // Struct: children are indices into the flat node array (not self-referential).
    out.push_str(
        "struct LiveNode {\n\
         \x20   id: string,\n\
         \x20   kind: string,\n\
         \x20   text: string,\n\
         \x20   label: string,\n\
         \x20   variant: string,\n\
         \x20   checked: bool,\n\
         \x20   number: float,\n\
         \x20   extra: string,\n\
         \x20   focused: bool,\n\
         \x20   children: [int],\n\
         }\n\n",
    );

    // One entry in the gallery's left-hand sample browser. The model position
    // is the sample index used for selection.
    out.push_str(
        "struct SampleEntry {\n\
         \x20   name: string,\n\
         }\n\n",
    );

    // Event bridge global. Rust connects to `activate` to route a node's action.
    // `export`-ed so slint generates a public `Events` type reachable from Rust
    // via `surface.global::<Events>()`.
    out.push_str(
        "global Events {\n\
         \x20   // Fired when an interactive node is activated (button press, etc.).\n\
         \x20   // Carries the node's A2UI id so Rust can dispatch its handle_event.\n\
         \x20   callback activate(string);\n\
         }\n\n\
         export { Events }\n\n",
    );

    // Node levels: leaf first, then each higher level references the previous.
    for k in 0..=MAX_DEPTH {
        let child = if k == 0 { None } else { Some(format!("Node{}", k - 1)) };
        out.push_str(&node_body(k, child.as_deref()));
        out.push('\n');
    }

    // Root surface: a left-hand sample browser + the rendered A2UI surface.
    //   samples        — the sample list (left sidebar)
    //   selected-sample — currently highlighted/loaded sample index
    //   select-sample  — fired when a sidebar row is clicked
    //   nodes          — the flat LiveNode array for the loaded sample (right pane)
    out.push_str(&format!(
        "export component Surface inherits Window {{\n\
         \x20   title: \"A2UI Slint Gallery\";\n\
         \x20   preferred-width: 1000px;\n\
         \x20   preferred-height: 700px;\n\
         \x20   in property <[LiveNode]> nodes;\n\
         \x20   in property <[SampleEntry]> samples;\n\
         \x20   in property <int> selected-sample;\n\
         \x20   callback select-sample(int);\n\
         \n\
         \x20   HorizontalLayout {{\n\
         \x20       // Left: sample browser.\n\
         \x20       Rectangle {{\n\
         \x20           width: 220px;\n\
         \x20           background: #f5f5f5;\n\
         \x20           VerticalLayout {{\n\
         \x20               padding: 8px;\n\
         \x20               spacing: 6px;\n\
         \x20               Text {{ text: \"Samples\"; font-weight: 800; }}\n\
         \x20               Flickable {{\n\
         \x20               VerticalLayout {{\n\
         \x20                   for s[i] in root.samples : Rectangle {{\n\
         \x20                       height: 28px;\n\
         \x20                       background: i == root.selected-sample ? #2563eb : transparent;\n\
         \x20                       HorizontalLayout {{\n\
         \x20                           padding-left: 8px;\n\
         \x20                           Text {{\n\
         \x20                               text: s.name;\n\
         \x20                               color: i == root.selected-sample ? #ffffff : #222222;\n\
         \x20                               vertical-alignment: center;\n\
         \x20                           }}\n\
         \x20                       }}\n\
         \x20                       TouchArea {{ clicked => {{ root.select-sample(i); }} }}\n\
         \x20                   }}\n\
         \x20               }}\n\
         \x20               }}\n\
         \x20           }}\n\
         \x20       }}\n\
         \x20       // Divider.\n\
         \x20       Rectangle {{ width: 1px; background: #ddd; }}\n\
         \x20       // Right: the rendered A2UI surface.\n\
         \x20       Node{MAX_DEPTH} {{ all: nodes; idx: 0; }}\n\
         \x20   }}\n\
         }}\n",
    ));

    out
}

/// The body of `Node{k}`. `child_comp` is `Some("Node{k-1}")` for k>0, or `None`
/// for the leaf (k=0), where container children are truncated to a marker.
fn node_body(k: usize, child_comp: Option<&str>) -> String {
    let name = format!("Node{k}");

    // The Slint snippet that renders a container's children. For the leaf level,
    // there is no deeper component to instantiate → emit a truncation marker.
    let render_children = match child_comp {
        Some(c) => format!("for c in me.children : {c} {{ all: all; idx: c; }}"),
        None => "Text { text: \"\"; color: #888; }".to_string(),
    };

    format!(
        "component {name} inherits Rectangle {{\n\
         \x20   in property <[LiveNode]> all;\n\
         \x20   in property <int> idx;\n\
         \x20   property <LiveNode> me: all[idx];\n\
         \n\
         \x20   // Column — vertical stack.\n\
         \x20   if me.kind == \"Column\" : VerticalLayout {{\n\
         \x20       spacing: 4px;\n\
         \x20       {render_children}\n\
         \x20   }}\n\
         \n\
         \x20   // Row — horizontal stack.\n\
         \x20   if me.kind == \"Row\" : HorizontalLayout {{\n\
         \x20       spacing: 4px;\n\
         \x20       {render_children}\n\
         \x20   }}\n\
         \n\
         \x20   // Card — bordered panel wrapping one child.\n\
         \x20   if me.kind == \"Card\" : Rectangle {{\n\
         \x20       background: #fff;\n\
         \x20       border-radius: 8px;\n\
         \x20       border-width: 1px;\n\
         \x20       border-color: #d0d0d0;\n\
         \x20       VerticalLayout {{ padding: 10px; {render_children} }}\n\
         \x20   }}\n\
         \n\
         \x20   // Text — styled paragraph; variant selects heading/caption styles.\n\
         \x20   if me.kind == \"Text\" : Text {{\n\
         \x20       text: me.text;\n\
         \x20       font-weight: me.variant == \"h1\" || me.variant == \"h2\" || me.variant == \"h3\" ? 800 : 400;\n\
         \x20       color: me.focused ? #1d4ed8 : #111;\n\
         \x20   }}\n\
         \n\
         \x20   // Button — labeled press target; child node is its label.\n\
         \x20   if me.kind == \"Button\" : Rectangle {{\n\
         \x20       background: me.variant == \"primary\" ? #2563eb : #f3f4f6;\n\
         \x20       border-radius: 4px;\n\
         \x20       border-width: me.focused ? 2px : 0px;\n\
         \x20       border-color: #1d4ed8;\n\
         \x20       HorizontalLayout {{ padding: 6px; {render_children} }}\n\
         \x20       TouchArea {{ clicked => {{ Events.activate(me.id); }} }}\n\
         \x20   }}\n\
         \n\
         \x20   // TextField — labeled input showing its bound value; focus ring when focused.\n\
         \x20   if me.kind == \"TextField\" : Rectangle {{\n\
         \x20       border-width: 1px;\n\
         \x20       border-color: me.focused ? #eab308 : #ccc;\n\
         \x20       border-radius: 4px;\n\
         \x20       VerticalLayout {{\n\
         \x20           padding: 4px;\n\
         \x20           Text {{ text: me.label; color: #666; font-size: 11px; }}\n\
         \x20           Text {{ text: me.text; }}\n\
         \x20       }}\n\
         \x20   }}\n\
         \n\
         \x20   // Divider — thin full-width horizontal rule.\n\
         \x20   if me.kind == \"Divider\" : Rectangle {{\n\
         \x20       height: 1px;\n\
         \x20       background: #d0d0d0;\n\
         \x20   }}\n\
         \n\
         \x20   // List — vertical stack of children (same layout as Column).\n\
         \x20   if me.kind == \"List\" : VerticalLayout {{\n\
         \x20       spacing: 2px;\n\
         \x20       {render_children}\n\
         \x20   }}\n\
         \n\
         \x20   // CheckBox — indicator + label; Enter toggles via core dispatch.\n\
         \x20   if me.kind == \"CheckBox\" : HorizontalLayout {{\n\
         \x20       spacing: 6px;\n\
         \x20       Text {{ text: me.checked ? \"\u{2611}\" : \"\u{2610}\"; }}\n\
         \x20       Text {{ text: me.text; }}\n\
         \x20       TouchArea {{ clicked => {{ Events.activate(me.id); }} }}\n\
         \x20   }}\n\
         \n\
         \x20   // Slider — display-only track showing the current value.\n\
         \x20   if me.kind == \"Slider\" : HorizontalLayout {{\n\
         \x20       Text {{ text: \"\u{25ae}\u{2500}\u{2500} (slider) \" + me.number; }}\n\
         \x20   }}\n\
         \n\
         \x20   // Icon — labeled box (no icon font available).\n\
         \x20   if me.kind == \"Icon\" : Rectangle {{\n\
         \x20       background: #f3f4f6;\n\
         \x20       border-width: 1px; border-color: #ddd;\n\
         \x20       HorizontalLayout {{ padding: 4px; Text {{ text: \"[icon: \" + me.extra + \"]\"; color: #666; }} }}\n\
         \x20   }}\n\
         \n\
         \x20   // Tabs — active index label + active child panel.\n\
         \x20   if me.kind == \"Tabs\" : VerticalLayout {{\n\
         \x20       Text {{ text: \"Tabs (active: \" + me.number + \")\"; color: #666; font-size: 11px; }}\n\
         \x20       {render_children}\n\
         \x20   }}\n\
         \n\
         \x20   // Modal — elevated panel; renders nothing when closed.\n\
         \x20   if me.kind == \"Modal\" && me.checked : Rectangle {{\n\
         \x20       background: #fff;\n\
         \x20       border-radius: 8px;\n\
         \x20       border-width: 2px; border-color: #bbb;\n\
         \x20       drop-shadow-blur: 6px; drop-shadow-color: #0003;\n\
         \x20       VerticalLayout {{ padding: 12px; {render_children} }}\n\
         \x20   }}\n\
         \n\
         \x20   // ChoicePicker — labeled box (display-only).\n\
         \x20   if me.kind == \"ChoicePicker\" : Rectangle {{\n\
         \x20       background: #fafafa;\n\
         \x20       border-width: 1px; border-color: #ddd;\n\
         \x20       HorizontalLayout {{ padding: 6px; Text {{ text: me.label; }} }}\n\
         \x20   }}\n\
         \n\
         \x20   // DateTimeInput — bordered field showing label + datetime value.\n\
         \x20   if me.kind == \"DateTimeInput\" : Rectangle {{\n\
         \x20       border-width: 1px; border-color: me.focused ? #eab308 : #ccc;\n\
         \x20       border-radius: 4px;\n\
         \x20       VerticalLayout {{\n\
         \x20           padding: 4px;\n\
         \x20           Text {{ text: me.label; color: #666; font-size: 11px; }}\n\
         \x20           Text {{ text: me.extra; }}\n\
         \x20       }}\n\
         \x20   }}\n\
         \n\
         \x20   // Image / Video / AudioPlayer — labeled placeholders (bytes not carried).\n\
         \x20   if me.kind == \"Image\" : Rectangle {{\n\
         \x20       background: #f3f4f6; border-width: 1px; border-color: #ddd;\n\
         \x20       HorizontalLayout {{ padding: 6px; Text {{ text: \"[Image: \" + me.extra + \"]\"; color: #666; }} }}\n\
         \x20   }}\n\
         \x20   if me.kind == \"Video\" : Rectangle {{\n\
         \x20       background: #f3f4f6; border-width: 1px; border-color: #ddd;\n\
         \x20       HorizontalLayout {{ padding: 6px; Text {{ text: \"[Video: \" + me.extra + \"]\"; color: #666; }} }}\n\
         \x20   }}\n\
         \x20   if me.kind == \"AudioPlayer\" : Rectangle {{\n\
         \x20       background: #f3f4f6; border-width: 1px; border-color: #ddd;\n\
         \x20       HorizontalLayout {{ padding: 6px; Text {{ text: \"\u{25b6} [Audio: \" + me.extra + \"]\"; color: #666; }} }}\n\
         \x20   }}\n\
         \n\
         \x20   // Unknown / not-yet-implemented kind — show the kind name so the tree is visible.\n\
         \x20   if me.kind != \"Column\" && me.kind != \"Row\" && me.kind != \"Card\" &&\n\
         \x20      me.kind != \"Text\" && me.kind != \"Button\" && me.kind != \"TextField\" &&\n\
         \x20      me.kind != \"Divider\" && me.kind != \"List\" && me.kind != \"CheckBox\" &&\n\
         \x20      me.kind != \"Slider\" && me.kind != \"Icon\" && me.kind != \"Tabs\" &&\n\
         \x20      me.kind != \"Modal\" && me.kind != \"ChoicePicker\" && me.kind != \"DateTimeInput\" &&\n\
         \x20      me.kind != \"Image\" && me.kind != \"Video\" && me.kind != \"AudioPlayer\" :\n\
         \x20       Rectangle {{\n\
         \x20           background: #fafafa;\n\
         \x20           border-width: 1px; border-color: #ddd;\n\
         \x20           VerticalLayout {{ padding: 6px; Text {{ text: \"[\" + me.kind + \"]\"; color: #888; {render_children} }} }}\n\
         \x20       }}\n\
         }}\n",
    )
}