swiftui-native 0.0.1

SwiftUI from pure Rust — no Swift bridge, no shim, only swift-runtime calls
Documentation
//! A todo list — the interactive shape the other examples leave out.
//!
//! `settings.rs`, `inbox.rs` and `dashboard.rs` are all screens you look at.
//! This one you *use*: typing into a field and submitting, toggling rows,
//! deleting them, and an empty state that appears and leaves with a transition.
//!
//! ```sh
//! cargo run -p swiftui-native --example todo
//! cargo run -p swiftui-native --example todo -- --render
//! ```

use std::sync::{Mutex, OnceLock};
use swiftui_native::prelude::*;
use swiftui_native::{state, views};

#[derive(Clone)]
struct Item {
    title: String,
    done: bool,
}

/// The list itself, on the Rust side. SwiftUI never owns it — it reads it when
/// it evaluates the body, which is the whole arrangement.
fn items() -> &'static Mutex<Vec<Item>> {
    static ITEMS: OnceLock<Mutex<Vec<Item>>> = OnceLock::new();
    ITEMS.get_or_init(|| {
        Mutex::new(vec![
            Item { title: "Read the field offsets from the metadata".into(), done: true },
            Item { title: "Draw the ring as an arc".into(), done: false },
        ])
    })
}

/// What the user is typing, which the field writes into directly.
fn draft() -> &'static views::BoundState<String> {
    static DRAFT: OnceLock<views::BoundState<String>> = OnceLock::new();
    DRAFT.get_or_init(|| views::BoundState::new(String::new()))
}

fn add() {
    let text = draft().get();
    if text.trim().is_empty() {
        return;
    }
    items().lock().unwrap().push(Item { title: text, done: false });
    draft().set(String::new());
    state::refresh_animated(state::ease_in_out(0.25));
}

fn toggle(i: usize) {
    let mut list = items().lock().unwrap();
    if let Some(it) = list.get_mut(i) {
        it.done = !it.done;
    }
    drop(list);
    state::refresh();
}

fn row(i: usize, it: &Item) -> View {
    hstack_spacing(
        &[
            // The checkmark is the button, so the whole symbol is the target.
            button_labelled(
                system_image(if it.done { "checkmark.circle.fill" } else { "circle" })
                    .symbol_rendering_mode(Some(views::SymbolRenderingMode::Hierarchical))
                    .view()
                    .fg_system(if it.done { SystemColor::Green } else { SystemColor::Gray }),
                move || toggle(i),
            )
            .button_style(views::ButtonStyle::Bordered),
            text(&it.title)
                .strikethrough_styled(it.done, LinePattern::Solid, None)
                .fg_system(if it.done { SystemColor::Gray } else { SystemColor::Primary }),
            spacer(),
        ],
        10.0,
    )
    .padding_edges(EdgeSet::Vertical, Some(6.0))
    .a11y_label(&it.title)
    .a11y_value(if it.done { "Done" } else { "Not done" })
}

fn body() -> View {
    let todos = items().lock().unwrap().clone();
    let remaining = todos.iter().filter(|i| !i.done).count();

    vstack_aligned_spacing(
        &[
            hstack![
                text("Todo").text_style(TextStyle::LargeTitle).bold(),
                spacer(),
                text!("{remaining} left").fg_system(SystemColor::Gray),
            ],
            hstack_spacing(
                &[
                    textfield_bound("What needs doing?", draft())
                        .text_field_style(views::TextFieldStyle::RoundedBorderTextFieldStyle)
                        .on_submit(SubmitTrigger::Text, add)
                        .submit_label(SubmitLabel::Done),
                    button("Add", add).button_style(views::ButtonStyle::BorderedProminent),
                ],
                8.0,
            ),
            // The empty state is a branch, so it arrives and leaves rather than
            // appearing from nothing.
            view_if!(
                todos.is_empty(),
                vstack_spacing(
                    &[
                        system_image("checkmark.seal").view().font_size(40.0)
                            .fg_system(SystemColor::Green),
                        text("Nothing left to do").fg_system(SystemColor::Gray),
                    ],
                    8.0,
                )
                .frame_optional(None, Some(160.0), Alignment::Center)
                .transition(Transition::ScaleWithOpacity { scale: 0.8, anchor: (0.5, 0.5) }),
                list(for_each_items(
                    &todos.iter().cloned().enumerate().collect::<Vec<_>>(),
                    |(i, it)| row(*i, it),
                ))
                .list_style(views::ListStyle::PlainListStyle)
            ),
        ],
        HorizontalAlignment::Leading,
        Some(14.0),
    )
    .padding(20.0)
}

fn content_view() -> swiftui_native::ViewHandle {
    state::root_view(|_| body().into_handle())
}

#[cfg(target_os = "macos")]
#[path = "common/simulator.rs"]
mod simulator;

#[cfg(target_os = "macos")]
fn main() {
    if std::env::args().any(|a| a == "--sim" || a == "device") {
        return simulator::main(&simulator::Bundle {
            example: "todo",
            bundle_id: "dev.rswift.Todo",
            executable: "Todo",
            display_name: "Todo",
            launch_args: &[],
        });
    }
    if std::env::args().any(|a| a == "--render") {
        let out = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/todo.png");
        swiftui_native::render::render(&content_view(), 420, 460).write_png(out);
        eprintln!("wrote {out}");
        return;
    }
    show_window(&content_view(), "Todo", 420.0, 460.0);
}

#[cfg(not(target_os = "macos"))]
fn main() {
    swiftui_native::app::run(content_view)
}