Skip to main content

scenes/
scenes.rs

1//! A whole macOS app — several scenes and a menu bar — from pure Rust.
2//!
3//! `examples/app.rs` is one `WindowGroup`. This is what an app actually is on
4//! macOS: a main window, a preferences pane behind ⌘,, a second window that
5//! `openWindow` can reach, an item in the system menu bar, and a menu bar with
6//! a menu of its own plus items grafted into File and Help.
7//!
8//! Every one of those is SwiftUI's own scene type, built by calling its own
9//! initialiser. `_TupleScene` and `TupleCommandContent` — what `SceneBuilder`
10//! and `CommandsBuilder` produce, and what writing several scenes in a row
11//! reduces to — are built the same way; both builders are
12//! `@_alwaysEmitIntoClient` and export no symbol at all, so the binding calls
13//! what their bodies call.
14//!
15//! ```bash
16//! cargo run -p swiftui-native --example scenes
17//!
18//! # print what was built and exit, without entering the event loop
19//! cargo run -p swiftui-native --example scenes -- --describe
20//! ```
21//!
22//! Things to try once it is up: ⌘, opens Settings; the star in the menu bar has
23//! a menu; the menu bar has a "Counter" menu of its own; File's New item has
24//! been replaced; Help has an extra entry.
25
26// This example builds for both platforms and each has a half the other does not:
27// `Settings`, `Window`, `MenuBarExtra` and every command are macOS-only, so the
28// views that feed them have no caller on iOS. Keeping them is what makes it
29// visible *which* of the five features survive the crossing — see `ios_body`.
30#![allow(dead_code)]
31
32use swiftui_native::prelude::*;
33use swiftui_native::scene::{self, SceneHandle};
34// Only the macOS body uses the macro — the iOS one has no scene to declare.
35#[cfg(target_os = "macos")]
36use swiftui_native::scenes;
37use swiftui_native::views::{BoundState, ViewHandle};
38use swiftui_native::views;
39use swiftui_native::app;
40
41/// Shared between the windows and the menus, so the menu-bar item and the main
42/// window show the same number and the menu's buttons move it.
43fn counter() -> &'static BoundState<i64> {
44    static C: std::sync::OnceLock<&'static BoundState<i64>> = std::sync::OnceLock::new();
45    C.get_or_init(|| Box::leak(Box::new(BoundState::new(0))))
46}
47
48fn main_window() -> ViewHandle {
49    vstack(&[
50        text("A SwiftUI app, no Swift source")
51            .font_size(20.0)
52            .padding(8.0),
53        divider(),
54        text(&format!("count: {}", counter().get()))
55            .font_size(34.0)
56            .padding(12.0),
57        hstack(&[
58            button("−", || counter().set(counter().get() - 1)),
59            button("+", || counter().set(counter().get() + 1)),
60        ]),
61        spacer(),
62        text("⌘, for Settings · the ★ in the menu bar has a menu")
63            .font_size(11.0)
64            .opacity(0.6),
65    ])
66    .padding(24.0)
67    .frame(420.0, 300.0)
68    .focused_scene_value(counter_focus_key(), counter().get())
69    .into_handle()
70}
71
72/// The focused-value key the window publishes its count under.
73///
74/// `.focusedSceneValue(\.key, value)` is how a window tells the commands above
75/// it what is selected. Both halves are synthesised at run time: the
76/// `FocusedValueKey` conformance *and* the `WritableKeyPath<FocusedValues, Int?>`
77/// — `FocusedValues` exports no properties, so unlike `EnvironmentValues` there
78/// is no SwiftUI modifier holding a key path to borrow.
79///
80/// The menu cannot *read* it yet: that needs `@FocusedValue`, a `DynamicProperty`
81/// SwiftUI instantiates itself, which is a different mechanism from anything
82/// here. What this demonstrates is the publishing half, in a real app.
83fn counter_focus_key() -> &'static views::FocusKey {
84    static K: std::sync::OnceLock<views::FocusKey> = std::sync::OnceLock::new();
85    K.get_or_init(|| views::focus_key("sceneCounter"))
86}
87
88fn settings_pane() -> ViewHandle {
89    vstack(&[
90        text("Preferences").font_size(18.0),
91        divider(),
92        text("This pane is a `Settings` scene — the one ⌘, opens."),
93        text(&format!("It sees the same state: {}", counter().get()))
94            .opacity(0.7),
95    ])
96    .padding(24.0)
97    .frame(360.0, 200.0)
98    .into_handle()
99}
100
101fn utility_window() -> ViewHandle {
102    vstack(&[
103        text("Utility").font_size(16.0),
104        text("A `Window`, not a `WindowGroup`: one of it, addressed by id.")
105            .opacity(0.7),
106    ])
107    .padding(20.0)
108    .frame(320.0, 140.0)
109    .into_handle()
110}
111
112fn menu_bar_item() -> ViewHandle {
113    vstack(&[
114        text(&format!("count: {}", counter().get())),
115        button("Reset", || counter().set(0)),
116    ])
117    .padding(10.0)
118    .into_handle()
119}
120
121/// The whole app, as one scene.
122///
123/// Built afresh every time SwiftUI reads `App.body`, which is what makes the
124/// numbers in it live rather than a snapshot taken at launch.
125///
126/// macOS only, and not by choice: `Settings`, `Window`, `MenuBarExtra` and the
127/// whole of `Commands` are `@available(iOS, unavailable)`. iOS gets
128/// [`ios_body`], which is the part of this that exists there.
129#[cfg(target_os = "macos")]
130fn body() -> SceneHandle {
131    scenes! {
132        window_group { main_window() }
133        settings { settings_pane() }
134        window("Utility", "utility") { utility_window() }
135        menu_bar_extra("Count", "star.fill") { menu_bar_item() }
136        commands {
137            // A menu of its own, beside File and Edit.
138            menu("Counter") {
139                vstack(&[
140                    button("Increment", || counter().set(counter().get() + 1)),
141                    button("Decrement", || counter().set(counter().get() - 1)),
142                    divider(),
143                    button("Reset", || counter().set(0)),
144                ])
145            }
146            // In place of the standard File ▸ New.
147            replacing(NewItem) { button("New Count", || counter().set(0)) }
148            // Added after the standard Help entry.
149            after(Help) {
150                button("About this Rust app", || {
151                    eprintln!("built with no Swift compiler in sight");
152                })
153            }
154        }
155    }
156}
157
158/// What iOS has of the app above: one `WindowGroup`.
159///
160/// Kept beside the macOS body rather than left out, so the same example builds
161/// for both and it is visible *which* of the five features survive the crossing.
162#[cfg(not(target_os = "macos"))]
163fn body() -> SceneHandle {
164    scene::window_group(main_window)
165}
166
167// ── Verification, without a screen ──
168//
169// "It launched and did not crash" is weak evidence that four scenes and three
170// menus were built. This asks AppKit instead: a little after the run loop
171// starts, walk `NSApp.windows` and `NSApp.mainMenu` and print what SwiftUI
172// actually made of the Rust-built scenes, then quit. No display needed, which
173// is what makes it usable from a terminal or CI.
174//
175// The inspection has to run on the main thread — AppKit is not thread-safe, and
176// a background thread reading a menu that is being built is a race — so it goes
177// on the main queue through `dispatch_after_f` rather than on a `std::thread`.
178#[cfg(target_os = "macos")]
179mod probe {
180    use core::ffi::c_void;
181    use swiftui_native::objc;
182
183    unsafe extern "C" {
184        fn dispatch_time(base: u64, delta: i64) -> u64;
185        fn dispatch_after_f(
186            when: u64,
187            queue: *const c_void,
188            ctx: *mut c_void,
189            work: unsafe extern "C" fn(*mut c_void),
190        );
191        /// `dispatch_get_main_queue()` is this symbol's address.
192        static _dispatch_main_q: u8;
193    }
194
195    /// Queue the inspection for `seconds` from now.
196    pub fn schedule(seconds: f64) {
197        unsafe {
198            let when = dispatch_time(0, (seconds * 1e9) as i64);
199            dispatch_after_f(
200                when,
201                &_dispatch_main_q as *const u8 as *const c_void,
202                core::ptr::null_mut(),
203                inspect,
204            );
205        }
206    }
207
208    /// An `NSString`'s bytes, as Rust sees them.
209    unsafe fn text(ns: *mut c_void) -> String {
210        if ns.is_null() {
211            return String::new();
212        }
213        let p = unsafe { objc::send_ret_ptr(ns, objc::sel(c"UTF8String")) };
214        if p.is_null() {
215            return String::new();
216        }
217        unsafe { core::ffi::CStr::from_ptr(p as *const core::ffi::c_char) }
218            .to_string_lossy()
219            .into_owned()
220    }
221
222    /// `array[i]`, for an `NSArray`.
223    unsafe fn at(array: *mut c_void, i: u64) -> *mut c_void {
224        unsafe { objc::send_u64(array, objc::sel(c"objectAtIndex:"), i) }
225    }
226
227    unsafe fn count(array: *mut c_void) -> u64 {
228        if array.is_null() {
229            0
230        } else {
231            unsafe { objc::send_ret_u64(array, objc::sel(c"count")) }
232        }
233    }
234
235    unsafe extern "C" fn inspect(_: *mut c_void) {
236        unsafe {
237            let app = objc::shared_app();
238
239            let windows = objc::send(app, objc::sel(c"windows"));
240            let n = count(windows);
241            println!("windows: {n}");
242            for i in 0..n {
243                let w = at(windows, i);
244                let title = text(objc::send(w, objc::sel(c"title")));
245                let visible = objc::send_ret_bool(w, objc::sel(c"isVisible"));
246                let class = objc::send(w, objc::sel(c"class"));
247                let kind = text(objc::send(class, objc::sel(c"description")));
248                println!("  {kind:<24} visible={visible:<5} {title:?}");
249            }
250
251            let menu = objc::send(app, objc::sel(c"mainMenu"));
252            let items = objc::send(menu, objc::sel(c"itemArray"));
253            let n = count(items);
254            println!("menu bar: {n} menus");
255            for i in 0..n {
256                let item = at(items, i);
257                let title = text(objc::send(item, objc::sel(c"title")));
258                let sub = objc::send(item, objc::sel(c"submenu"));
259                let kids = objc::send(sub, objc::sel(c"itemArray"));
260                let entries: Vec<String> = (0..count(kids))
261                    .map(|j| text(objc::send(at(kids, j), objc::sel(c"title"))))
262                    .filter(|t| !t.is_empty())
263                    .collect();
264                println!("  {title:?}: {}", entries.join(" · "));
265            }
266
267            // The menu-bar extra is an `NSStatusItem`, not a window or a menu,
268            // so it is counted separately or it would look like nothing was
269            // built for it.
270            let bar = objc::class(c"OBJC_CLASS_$_NSStatusBar");
271            let system = objc::send(bar, objc::sel(c"systemStatusBar"));
272            println!(
273                "status bar thickness: {}",
274                objc::send_ret_f64(system, objc::sel(c"thickness")),
275            );
276
277            std::process::exit(0);
278        }
279    }
280}
281
282fn main() {
283    // `--describe` asks the same question the event loop would and prints the
284    // answer: what Swift types came out of this. Useful because the types are
285    // the claim — `_TupleScene<(WindowGroup<AnyView>, Settings<AnyView>, …)>` is
286    // what Swift builds from four scenes in a row, and nothing about a window
287    // appearing would say whether that is what got built.
288    #[cfg(target_os = "macos")]
289    if std::env::args().any(|a| a == "--describe") {
290        let whole = body();
291        println!("App.Body\n  {}", whole.type_name());
292        for (name, s) in [
293            ("window_group", scene::window_group(main_window)),
294            ("settings", scene::settings(settings_pane)),
295            ("window", scene::window("Utility", "utility", utility_window)),
296            ("menu_bar_extra", scene::menu_bar_extra("Count", "star.fill", menu_bar_item)),
297        ] {
298            println!("{name}\n  {}", s.type_name());
299        }
300        // Built through the macro, so this also checks that what `scenes!`
301        // expands to is the same thing the free functions build.
302        let one = scenes! { window_group { text("x") } commands { menu("M") { text("x") } } };
303        println!("scenes! with one menu\n  {}", one.type_name());
304        return;
305    }
306
307    // `--probe` launches for real and reports what AppKit built, then quits.
308    #[cfg(target_os = "macos")]
309    if std::env::args().any(|a| a == "--probe") {
310        probe::schedule(2.5);
311    }
312
313    app::run_scene(body)
314}