From 2afbfe6b6f27a8fd3f691c5a5c969d4697474a49 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Sat, 21 Mar 2026 18:24:59 -0400 Subject: [PATCH] First pass on ruin_app, raw expanded example --- Cargo.lock | 10 + lib/ruin_app/Cargo.toml | 14 + .../example/00_bootstrap_and_counter_raw.rs | 192 ++++++++++++++ lib/ruin_app/src/lib.rs | 239 ++++++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 lib/ruin_app/Cargo.toml create mode 100644 lib/ruin_app/example/00_bootstrap_and_counter_raw.rs create mode 100644 lib/ruin_app/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index bdde0a0..d3cc1e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1627,6 +1627,16 @@ dependencies = [ "syn", ] +[[package]] +name = "ruin_app" +version = "0.1.0" +dependencies = [ + "ruin-runtime", + "ruin_reactivity", + "ruin_ui", + "ruin_ui_platform_wayland", +] + [[package]] name = "ruin_reactivity" version = "0.1.0" diff --git a/lib/ruin_app/Cargo.toml b/lib/ruin_app/Cargo.toml new file mode 100644 index 0000000..69f3a27 --- /dev/null +++ b/lib/ruin_app/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ruin_app" +version = "0.1.0" +edition = "2024" + +[dependencies] +ruin_reactivity = { path = "../reactivity" } +ruin_runtime = { package = "ruin-runtime", path = "../runtime" } +ruin_ui = { path = "../ui" } +ruin_ui_platform_wayland = { path = "../ui_platform_wayland" } + +[[example]] +name = "00_bootstrap_and_counter_raw" +path = "example/00_bootstrap_and_counter_raw.rs" diff --git a/lib/ruin_app/example/00_bootstrap_and_counter_raw.rs b/lib/ruin_app/example/00_bootstrap_and_counter_raw.rs new file mode 100644 index 0000000..19212be --- /dev/null +++ b/lib/ruin_app/example/00_bootstrap_and_counter_raw.rs @@ -0,0 +1,192 @@ +use ruin_app::{Component, prelude::*}; + +// This is the aspirational author-facing source we eventually want to support: +// +// ```ignore +// #[ruin_runtime::async_main] +// async fn main() -> ruin::Result<()> { +// App::new() +// .window(Window::new().title("RUIN Counter").size(960.0, 640.0)) +// .app_id("dev.ruin.counter") +// .mount(view! { CounterApp {} }) +// #[component] +// fn CounterApp() -> impl View { +// let count = use_signal(|| 0); +// let doubled = use_memo(move || count.get() * 2); +// +// use_window_title(move || format!("RUIN Counter ({})", count.get())); +// +// view! { +// column(gap = 16, padding = 24) { +// text(role = TextRole::Heading(1), size = 32, weight = FontWeight::Semibold) { +// "RUIN counter" +// } +// +// row(gap = 8) { +// button(on_press = move |_| count.update(|value| *value -= 1)) { "-1" } +// button(on_press = move |_| count.set(0)) { "Reset" } +// button(on_press = move |_| count.update(|value| *value += 1)) { "+1" } +// } +// +// block(padding = 16, gap = 8, background = surfaces::raised()) { +// text(size = 18) { "count = "; count } +// text(color = colors::muted()) { "double = "; doubled } +// } +// } +// } +// } +// ``` +// +// The hand-written code below is the kind of lower-level shape the proc macro should expand to. + +#[ruin_runtime::async_main] +async fn main() -> ruin_app::Result<()> { + App::new() + .window( + Window::new() + .title("RUIN Counter") + .app_id("dev.ruin.counter") + .size(960.0, 640.0), + ) + .mount(CounterApp::builder().children(())) + .run() + .await +} + +struct CounterApp { + count: Cell, + doubled: Memo, +} + +const DECREMENT_BUTTON_ID: ElementId = ElementId::new(1); +const RESET_BUTTON_ID: ElementId = ElementId::new(2); +const INCREMENT_BUTTON_ID: ElementId = ElementId::new(3); + +struct __CounterAppBuilder; + +impl __CounterAppBuilder { + fn children(self, _children: ()) -> CounterApp { + CounterApp::new() + } +} + +impl Component for CounterApp { + type Builder = __CounterAppBuilder; + + fn builder() -> Self::Builder { + __CounterAppBuilder + } +} + +impl CounterApp { + fn new() -> Self { + let count = cell(0_i32); + let doubled = memo({ + let count = count.clone(); + move || count.get() * 2 + }); + Self { count, doubled } + } + + fn button(&self, label: &str, id: ElementId, fill: Color) -> Element { + Element::column() + .padding(Edges::symmetric(14.0, 10.0)) + .background(fill) + .corner_radius(10.0) + .cursor(CursorIcon::Pointer) + .focusable(true) + .id(id) + .child( + Element::text(label, body_text()) + .pointer_events(false) + .focusable(false), + ) + } +} + +impl RootComponent for CounterApp { + fn build(&self, cx: &BuildCx) -> View { + let count = self.count.get(); + let doubled = self.doubled.get(); + + Element::column() + .width(cx.viewport.width) + .height(cx.viewport.height) + .padding(Edges::all(24.0)) + .gap(16.0) + .background(Color::rgb(0x0F, 0x16, 0x25)) + .child( + Element::text("RUIN counter", title_text()) + .pointer_events(false) + .focusable(false), + ) + .child( + Element::paragraph( + "This is the raw, hand-written shape a future proc macro should lower the \ + ergonomic counter component into.", + body_text().with_wrap(TextWrap::Word), + ) + .pointer_events(false) + .focusable(false), + ) + .child( + Element::row() + .gap(8.0) + .child(self.button("-1", DECREMENT_BUTTON_ID, Color::rgb(0x2B, 0x3A, 0x67))) + .child(self.button("Reset", RESET_BUTTON_ID, Color::rgb(0x3D, 0x4B, 0x72))) + .child(self.button("+1", INCREMENT_BUTTON_ID, Color::rgb(0x2B, 0x3A, 0x67))), + ) + .child( + Element::column() + .padding(Edges::all(16.0)) + .gap(8.0) + .background(Color::rgb(0x1B, 0x26, 0x3D)) + .corner_radius(12.0) + .child( + Element::text(format!("count = {count}"), body_text()) + .pointer_events(false) + .focusable(false), + ) + .child( + Element::text( + format!("double = {doubled}"), + body_text().with_font_family(TextFontFamily::Monospace), + ) + .pointer_events(false) + .focusable(false), + ), + ) + } + + fn handle_pointer(&self, event: &ruin_ui::RoutedPointerEvent) { + if !matches!( + event.kind, + ruin_ui::RoutedPointerEventKind::Up { + button: PointerButton::Primary + } + ) { + return; + } + + match event.target.element_id { + Some(DECREMENT_BUTTON_ID) => { + self.count.update(|value| *value -= 1); + } + Some(RESET_BUTTON_ID) => { + let _ = self.count.set(0); + } + Some(INCREMENT_BUTTON_ID) => { + self.count.update(|value| *value += 1); + } + _ => {} + } + } +} + +fn title_text() -> TextStyle { + TextStyle::new(32.0, Color::rgb(0xFF, 0xFF, 0xFF)) +} + +fn body_text() -> TextStyle { + TextStyle::new(18.0, Color::rgb(0xDF, 0xE8, 0xF6)).with_line_height(24.0) +} diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs new file mode 100644 index 0000000..8ad783a --- /dev/null +++ b/lib/ruin_app/src/lib.rs @@ -0,0 +1,239 @@ +//! Minimal app/runtime glue for RUIN application experiments. +//! +//! This crate is intentionally low-level. It is the substrate that a future proc-macro-driven +//! component system can expand to, not the final ergonomic authoring API. + +use std::cell::{Cell as StdCell, RefCell}; +use std::error::Error; +use std::iter; +use std::rc::Rc; + +use ruin_reactivity::effect; +use ruin_ui::{ + CursorIcon, Element, InteractionTree, LayoutSnapshot, PlatformEvent, PointerEvent, + PointerRouter, RoutedPointerEvent, TextSystem, UiSize, WindowController, WindowSpec, + WindowUpdate, layout_snapshot_with_text_system, +}; +use ruin_ui_platform_wayland::start_wayland_ui; + +pub use ruin_reactivity::{Cell, Memo, cell, memo}; +pub use ruin_ui::{ + Color, Edges, ElementId, Point, PointerButton, PointerEventKind, Rect, RoutedPointerEventKind, + TextAlign, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, TextWrap, +}; + +pub type View = Element; +pub type Result = std::result::Result>; + +#[derive(Clone, Copy, Debug)] +pub struct BuildCx { + pub viewport: UiSize, +} + +pub trait RootComponent: 'static { + fn build(&self, cx: &BuildCx) -> View; + + fn handle_pointer(&self, _event: &RoutedPointerEvent) {} +} + +#[derive(Clone, Debug)] +pub struct Window { + spec: WindowSpec, +} + +impl Window { + pub fn new() -> Self { + Self { + spec: WindowSpec::new("RUIN App"), + } + } + + pub fn title(mut self, title: impl Into) -> Self { + self.spec.title = title.into(); + self + } + + pub fn app_id(mut self, app_id: impl Into) -> Self { + self.spec = self.spec.app_id(app_id); + self + } + + pub fn size(mut self, width: f32, height: f32) -> Self { + self.spec = self.spec.requested_inner_size(UiSize::new(width, height)); + self + } +} + +impl Default for Window { + fn default() -> Self { + Self::new() + } +} + +pub struct App { + window: Window, +} + +impl App { + pub fn new() -> Self { + Self { + window: Window::new(), + } + } + + pub fn window(mut self, window: Window) -> Self { + self.window = window; + self + } + + pub fn mount(self, root: R) -> MountedApp { + MountedApp { + window: self.window, + root, + } + } +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} + +pub struct MountedApp { + window: Window, + root: R, +} + +impl MountedApp { + pub async fn run(self) -> Result<()> { + let MountedApp { + window: app_window, + root, + } = self; + let mut ui = start_wayland_ui(); + let window = ui.create_window(app_window.spec.clone())?; + let initial_viewport = app_window + .spec + .requested_inner_size + .unwrap_or(UiSize::new(960.0, 640.0)); + + let viewport = ruin_reactivity::cell(initial_viewport); + let scene_version = StdCell::new(0_u64); + let text_system = Rc::new(RefCell::new(TextSystem::new())); + let interaction_tree = Rc::new(RefCell::new(None::)); + let root = Rc::new(root); + let mut pointer_router = PointerRouter::new(); + let mut current_cursor = CursorIcon::Default; + + let _scene_effect = effect({ + let window = window.clone(); + let viewport = viewport.clone(); + let text_system = Rc::clone(&text_system); + let interaction_tree = Rc::clone(&interaction_tree); + let root = Rc::clone(&root); + move || { + let viewport = viewport.get(); + let version = scene_version.get().wrapping_add(1); + scene_version.set(version); + + let tree = root.build(&BuildCx { viewport }); + let LayoutSnapshot { + scene, + interaction_tree: next_interaction_tree, + } = layout_snapshot_with_text_system( + version, + viewport, + &tree, + &mut text_system.borrow_mut(), + ); + *interaction_tree.borrow_mut() = Some(next_interaction_tree); + window + .replace_scene(scene) + .expect("window should remain alive while the app is running"); + } + }); + + loop { + let Some(event) = ui.next_event().await else { + break; + }; + + for event in iter::once(event).chain(ui.take_pending_events()) { + match event { + PlatformEvent::Configured { + window_id, + configuration, + } if window_id == window.id() => { + let _ = viewport.set(configuration.actual_inner_size); + } + PlatformEvent::Pointer { window_id, event } if window_id == window.id() => { + Self::handle_pointer_event( + &root, + &window, + &interaction_tree, + &mut pointer_router, + &mut current_cursor, + event, + )?; + } + PlatformEvent::CloseRequested { window_id } if window_id == window.id() => { + let _ = window.update(WindowUpdate::new().open(false)); + } + PlatformEvent::Closed { window_id } if window_id == window.id() => { + ui.shutdown()?; + return Ok(()); + } + _ => {} + } + } + } + + ui.shutdown()?; + Ok(()) + } + + fn handle_pointer_event( + root: &Rc, + window: &WindowController, + interaction_tree: &RefCell>, + pointer_router: &mut PointerRouter, + current_cursor: &mut CursorIcon, + event: PointerEvent, + ) -> Result<()> { + let routed = { + let interaction_tree = interaction_tree.borrow(); + let Some(interaction_tree) = interaction_tree.as_ref() else { + return Ok(()); + }; + pointer_router.route(interaction_tree, event) + }; + + for routed_event in &routed { + root.handle_pointer(routed_event); + } + + let next_cursor = pointer_router + .hovered_targets() + .last() + .map(|target| target.cursor) + .unwrap_or(CursorIcon::Default); + if next_cursor != *current_cursor { + *current_cursor = next_cursor; + window.set_cursor_icon(next_cursor)?; + } + + Ok(()) + } +} + +pub mod prelude { + pub use crate::{ + App, BuildCx, Cell, Color, Edges, ElementId, Memo, Point, Result, RootComponent, View, + Window, cell, memo, + }; + pub use ruin_ui::{ + CursorIcon, Element, PointerButton, PointerEventKind, RoutedPointerEvent, + RoutedPointerEventKind, TextFontFamily, TextStyle, TextWrap, UiSize, + }; +}