Skip to main content

dynamic_ui/
dynamic_ui.rs

1//! A SwiftUI screen whose *types* are chosen at run time.
2//!
3//! This is the claim the crate exists to make. A SwiftUI view is a type:
4//! `VStack { Text("hi"); Divider() }` is
5//! `VStack<TupleView<(Text, Divider)>>`, and Swift decides that when it
6//! compiles the file. There is no Swift compiler in this process and no Swift
7//! source anywhere in it — so the type is decided by whatever text arrives,
8//! and instantiated through the Swift runtime's own metadata accessors.
9//!
10//! The difference is easy to state and hard to fake: **this binary renders
11//! screens that did not exist when it was built.**
12//!
13//! ```sh
14//! # The built-in specs, side by side as PNGs
15//! cargo run -p swiftui-native --example dynamic_ui -- --render
16//!
17//! # Any file, in a window
18//! cargo run -p swiftui-native --example dynamic_ui -- path/to/screen.ui
19//!
20//! # Type a screen and watch it appear
21//! cargo run -p swiftui-native --example dynamic_ui -- --repl
22//!
23//! # What the format accepts, and the Swift type a spec produces
24//! cargo run -p swiftui-native --example dynamic_ui -- --names
25//! cargo run -p swiftui-native --example dynamic_ui -- --type demo.ui
26//! ```
27
28// The iOS `main` hands the whole screen to `app::run`, so the demo table and
29// the argument parsing the macOS one uses have no caller there. Keeping them
30// in one file is what shows the *interpreter* is platform-independent and only
31// the entry point differs.
32#![allow(dead_code)]
33
34use std::sync::{Mutex, OnceLock};
35use swiftui_native::prelude::*;
36
37#[path = "common/spec.rs"]
38mod spec;
39
40#[cfg(target_os = "macos")]
41#[path = "common/simulator.rs"]
42mod simulator;
43
44/// The spec the body renders, replaced from outside while the app runs.
45///
46/// A `static` rather than a captured variable because `state::root_view` takes
47/// a plain `fn` — SwiftUI calls the body, so there is nowhere for a closure's
48/// environment to live. That constraint is also what makes reloading work: the
49/// body reads the current text every time it is evaluated.
50fn current() -> &'static Mutex<String> {
51    static CURRENT: OnceLock<Mutex<String>> = OnceLock::new();
52    CURRENT.get_or_init(|| Mutex::new(DEMOS[0].1.to_string()))
53}
54
55pub fn set_spec(text: &str) {
56    *current().lock().expect("spec") = text.to_string();
57}
58
59/// Build the view the spec describes, or a legible error screen.
60///
61/// A spec comes from outside, so a mistake in it is not a bug in this program.
62/// Rendering the message is more useful than a panic: the window stays up and
63/// the next edit can fix it, which is the whole point of reloading.
64pub fn body() -> View {
65    let text_src = current().lock().expect("spec").clone();
66    match spec::parse(&text_src).and_then(|nodes| spec::build(&nodes)) {
67        Ok(v) => v,
68        Err(e) => vstack_aligned_spacing(
69            &[
70                text("Could not build that screen")
71                    .text_style(TextStyle::Headline)
72                    .fg_system(SystemColor::Red),
73                text(&e).font_size(12.0).fg_system(SystemColor::Secondary),
74            ],
75            HorizontalAlignment::Leading,
76            Some(8.0),
77        )
78        .padding(20.0),
79    }
80}
81
82fn content_view() -> swiftui_native::ViewHandle {
83    swiftui_native::state::root_view(|_| body().into_handle())
84}
85
86/// Screens written here as *data*, not as Rust.
87///
88/// Each is a string this file could have read from disk instead; they are
89/// inline so the example runs with no arguments and so the renders are
90/// reproducible. Between them they reach stacks, shapes, controls, text
91/// styling and nesting — the same span the hand-written examples cover, with
92/// nothing about them in the compiled code.
93const DEMOS: &[(&str, &str)] = &[
94    (
95        "readout",
96        r#"
97// A status readout. Nothing here is a Rust type.
98vstack spacing=14 pad=20 align=leading {
99  text "Deployment" style=large_title bold
100  text "3 of 4 regions healthy" color=secondary size=13
101
102  divider
103
104  hstack spacing=12 {
105    circle color=green frame=14x14
106    text "us-east" size=14
107    spacer
108    text "ok" color=green size=13
109  }
110  hstack spacing=12 {
111    circle color=green frame=14x14
112    text "us-west" size=14
113    spacer
114    text "ok" color=green size=13
115  }
116  hstack spacing=12 {
117    circle color=orange frame=14x14
118    text "eu-central" size=14
119    spacer
120    text "degraded" color=orange size=13
121  }
122  hstack spacing=12 {
123    circle color=gray frame=14x14
124    text "ap-south" size=14
125    spacer
126    text "draining" color=gray size=13
127  }
128
129  divider
130  text "Rollout" size=12 color=secondary
131  progress 0.62
132  spacer
133  button "Promote"
134}
135"#,
136    ),
137    (
138        "card",
139        r#"
140// A card, to show layering and a hex colour.
141zstack align=center frame=320x200 {
142  color #1d2b53 frame=300x180 corner=16
143  vstack spacing=10 pad=24 align=leading {
144    text "Boarding pass" style=title2 bold color=white
145    text "LHR to SFO" color=white size=14 opacity=0.8
146    spacer
147    hstack spacing=16 {
148      vstack spacing=2 align=leading {
149        text "GATE" size=10 color=white opacity=0.6
150        text "B14" style=title3 bold color=white
151      }
152      vstack spacing=2 align=leading {
153        text "SEAT" size=10 color=white opacity=0.6
154        text "22A" style=title3 bold color=white
155      }
156      spacer
157      image "airplane"
158    }
159  }
160}
161"#,
162    ),
163    (
164        "gauges",
165        r#"
166// Three gauges and a grouped block, from data alone.
167vstack spacing=16 pad=20 align=leading {
168  text "Capacity" style=title bold
169  gauge 0.34 label="CPU"
170  gauge 0.71 label="Memory"
171  gauge 0.12 label="Disk"
172  divider
173  group_box {
174    vstack spacing=6 align=leading {
175      text "Notes" style=headline
176      text "Every view on this screen was named by a string." size=12 color=secondary
177      text "The binary has no VStack<...> type baked in for it." size=12 color=secondary
178    }
179  }
180  spacer
181}
182"#,
183    ),
184];
185
186fn demo(name: &str) -> Option<&'static str> {
187    DEMOS.iter().find(|(n, _)| *n == name).map(|(_, s)| *s)
188}
189
190/// Read a spec from a path, a demo name, or stdin for `-`.
191fn load(arg: &str) -> Result<String, String> {
192    if arg == "-" {
193        use std::io::Read;
194        let mut s = String::new();
195        std::io::stdin().read_to_string(&mut s).map_err(|e| e.to_string())?;
196        return Ok(s);
197    }
198    if let Some(d) = demo(arg) {
199        return Ok(d.to_string());
200    }
201    std::fs::read_to_string(arg).map_err(|e| format!("{arg}: {e}"))
202}
203
204/// Read specs from stdin and render each as it arrives.
205///
206/// A blank line submits. This is the shortest demonstration there is: the same
207/// process, still running, drawing a screen typed a moment ago.
208fn repl() {
209    use std::io::{BufRead, Write};
210    eprintln!("Type a screen. Blank line renders it; Ctrl-D quits.");
211    eprintln!("Names: {}", spec::NAMES.join(" "));
212    let stdin = std::io::stdin();
213    let mut buf = String::new();
214    let mut n = 0usize;
215    loop {
216        eprint!("> ");
217        let _ = std::io::stderr().flush();
218        let mut line = String::new();
219        match stdin.lock().read_line(&mut line) {
220            Ok(0) => break,
221            Ok(_) => {}
222            Err(e) => {
223                eprintln!("{e}");
224                break;
225            }
226        }
227        if line.trim().is_empty() {
228            if buf.trim().is_empty() {
229                continue;
230            }
231            match spec::parse(&buf).and_then(|nodes| spec::build(&nodes)) {
232                Ok(v) => {
233                    n += 1;
234                    let out = format!(
235                        "{}/../../target/dynamic_ui_repl_{n}.png",
236                        env!("CARGO_MANIFEST_DIR")
237                    );
238                    let handle = v.into_handle();
239                    eprintln!("  {}", swiftui_native::diag::type_name(
240                        swiftui_native::views::meta_of(&handle)
241                    ));
242                    if swiftui_native::render::render(&handle, 420, 420).write_png(&out) {
243                        eprintln!("  wrote {out}");
244                    }
245                }
246                Err(e) => eprintln!("  {e}"),
247            }
248            buf.clear();
249        } else {
250            buf.push_str(&line);
251        }
252    }
253}
254
255/// Print the Swift type a spec builds.
256///
257/// The strongest short proof that this is real SwiftUI and not a lookalike: the
258/// name comes from the Swift runtime's own metadata, and it is a different name
259/// for every different spec.
260fn print_type(arg: &str) -> i32 {
261    match load(arg).and_then(|s| {
262        spec::parse(&s).and_then(|nodes| spec::build(&nodes)).map(|v| v.into_handle())
263    }) {
264        Ok(h) => {
265            println!(
266                "{}",
267                swiftui_native::diag::type_name(swiftui_native::views::meta_of(&h))
268            );
269            0
270        }
271        Err(e) => {
272            eprintln!("{e}");
273            1
274        }
275    }
276}
277
278#[cfg(target_os = "macos")]
279fn main() {
280    let args: Vec<String> = std::env::args().skip(1).collect();
281    let flag = |f: &str| args.iter().any(|a| a == f);
282
283    if flag("--names") {
284        println!("{}", spec::NAMES.join("\n"));
285        return;
286    }
287    if flag("--repl") {
288        return repl();
289    }
290    if let Some(i) = args.iter().position(|a| a == "--type") {
291        let Some(arg) = args.get(i + 1) else {
292            eprintln!("--type wants a spec: a path, `-`, or a demo name");
293            std::process::exit(2);
294        };
295        std::process::exit(print_type(arg));
296    }
297    if flag("--render") {
298        // One PNG per built-in spec. Different text, different Swift type,
299        // different picture — from one binary, in one process.
300        for (name, src) in DEMOS {
301            set_spec(src);
302            // The spec's own view, for the type name: `content_view` wraps it
303            // in the runtime-synthesised `RustView` that carries `@State`, and
304            // that wrapper is the same whatever the spec says. What differs —
305            // and what makes the point — is the type underneath it.
306            let built = body().into_handle();
307            let out = format!("{}/../../target/dynamic_ui_{name}.png", env!("CARGO_MANIFEST_DIR"));
308            let ok = swiftui_native::render::render(&content_view(), 380, 420).write_png(&out);
309            println!(
310                "{name:10} {}\n           {}",
311                swiftui_native::diag::type_name(swiftui_native::views::meta_of(&built)),
312                if ok { out } else { "render failed".to_string() }
313            );
314        }
315        return;
316    }
317    if flag("--sim") {
318        return simulator::main(&simulator::Bundle {
319            example: "dynamic_ui",
320            bundle_id: "dev.rswift.DynamicUI",
321            executable: "DynamicUI",
322            display_name: "Dynamic UI",
323            launch_args: &[],
324        });
325    }
326
327    // A path or a demo name, else the first demo.
328    if let Some(arg) = args.iter().find(|a| !a.starts_with("--")) {
329        match load(arg) {
330            Ok(s) => set_spec(&s),
331            Err(e) => {
332                eprintln!("{e}");
333                std::process::exit(1);
334            }
335        }
336    }
337    show_window(&content_view(), "Dynamic UI", 380.0, 460.0);
338}
339
340#[cfg(not(target_os = "macos"))]
341fn main() {
342    swiftui_native::app::run(content_view)
343}