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}