From ed4c216f96288086230b71aa49f34a2f010fbbe8 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Sun, 22 Mar 2026 00:33:43 -0400 Subject: [PATCH] Port example 04 --- lib/ruin_app/Cargo.toml | 4 + .../example/04_composition_and_context.rs | 340 ++++++++++++++++++ lib/ruin_app/src/lib.rs | 166 ++++++++- lib/ruin_app_proc_macros/src/lib.rs | 82 ++++- lib/ui/src/layout.rs | 40 +++ 5 files changed, 615 insertions(+), 17 deletions(-) create mode 100644 lib/ruin_app/example/04_composition_and_context.rs diff --git a/lib/ruin_app/Cargo.toml b/lib/ruin_app/Cargo.toml index f6e72b7..6126e17 100644 --- a/lib/ruin_app/Cargo.toml +++ b/lib/ruin_app/Cargo.toml @@ -29,3 +29,7 @@ path = "example/02_widget_refs_and_commands.rs" [[example]] name = "03_fine_grained_list" path = "example/03_fine_grained_list.rs" + +[[example]] +name = "04_composition_and_context" +path = "example/04_composition_and_context.rs" diff --git a/lib/ruin_app/example/04_composition_and_context.rs b/lib/ruin_app/example/04_composition_and_context.rs new file mode 100644 index 0000000..ded4d7b --- /dev/null +++ b/lib/ruin_app/example/04_composition_and_context.rs @@ -0,0 +1,340 @@ +use ruin_app::prelude::*; + +#[ruin_runtime::async_main] +async fn main() -> ruin_app::Result<()> { + App::new() + .window( + Window::new() + .title("RUIN Workspace") + .app_id("dev.ruin.composition-context") + .size(1280.0, 840.0), + ) + .mount(view! { + WorkspaceRoot() {} + }) + .run() + .await +} + +#[component] +fn WorkspaceRoot() -> impl IntoView { + let notifications = Notifications::new(use_signal(|| { + "No workspace actions yet. Click a navigation button to emit a context-driven update." + .to_string() + })); + let session = SessionInfo::guest(); + + view! { + provide::(value = notifications.clone()) { + provide::(value = session.clone()) { + provide::(value = ThemePalette::workspace()) { + WorkspaceShell() {} + } + } + } + } +} + +#[component] +fn WorkspaceShell() -> impl IntoView { + let session = use_context::(); + let notifications = use_context::(); + let theme = use_context::(); + + use_window_title({ + let session = session.clone(); + move || format!("RUIN Workspace - {}", session.workspace) + }); + + view! { + column(gap = 16.0, padding = 20.0, background = theme.canvas) { + block( + padding = 14.0, + gap = 8.0, + background = theme.panel, + border_radius = 12.0, + border = (1.0, theme.accent), + ) { + text(role = TextRole::Heading(1), size = 30.0, weight = FontWeight::Semibold) { + "Composition and typed context" + } + text(color = theme.muted, wrap = TextWrap::Word) { + "This example implements real marker-keyed context providers. `AppThemeContext` \ + and `SidebarThemeContext` both carry the same `ThemePalette` value type, but \ + descendants resolve them independently by marker type. Nested providers of the \ + same marker also shadow outer values as you would expect." + } + text(color = theme.muted) { + "Last notification: "; + notifications.last_message.clone() + } + } + + row(gap = 16.0) { + provide::(value = ThemePalette::sidebar()) { + Sidebar() {} + } + + Dashboard() {} + } + } + } +} + +#[component] +fn Sidebar() -> impl IntoView { + let theme = use_context::(); + let session = use_context::(); + let notifications = use_context::(); + + view! { + block( + width = 300.0, + padding = 16.0, + gap = 12.0, + background = theme.panel, + border_radius = 14.0, + border = (1.0, theme.accent), + ) { + text(size = 24.0, weight = FontWeight::Semibold, color = theme.text) { + "Workspace nav" + } + + text(color = theme.muted, wrap = TextWrap::Word) { + "Signed in as "; + session.user.clone(); + " in "; + session.workspace.clone(); + } + + button(on_press = { + let notifications = notifications.clone(); + move |_| notifications.info("Opened the Home workspace panel") + }) { "Home" } + + button(on_press = { + let notifications = notifications.clone(); + move |_| notifications.info("Opened the Projects workspace panel") + }) { "Projects" } + + button(on_press = { + let notifications = notifications.clone(); + move |_| notifications.info("Opened the Settings workspace panel") + }) { "Settings" } + + block( + padding = 12.0, + gap = 8.0, + background = theme.canvas, + border_radius = 10.0, + ) { + text(weight = FontWeight::Semibold, color = theme.accent) { "Sidebar theme" } + text(color = theme.muted, wrap = TextWrap::Word) { + "This panel resolves `SidebarThemeContext`, even though the rest of the \ + workspace uses `AppThemeContext` with the same `ThemePalette` value type." + } + } + } + } +} + +#[component] +fn Dashboard() -> impl IntoView { + let theme = use_context::(); + let session = use_context::(); + + view! { + column(flex = 1.0, gap = 16.0) { + block( + padding = 16.0, + gap = 10.0, + background = theme.panel, + border_radius = 14.0, + border = (1.0, theme.accent), + ) { + text(size = 24.0, weight = FontWeight::Semibold, color = theme.text) { + "Workspace dashboard" + } + text(color = theme.muted, wrap = TextWrap::Word) { + "The main content resolves `AppThemeContext` and `SessionContext` from the \ + outer providers. These values are regular cloned Rust values; the context \ + system is responsible only for scoping and lookup by marker type." + } + text(color = theme.muted) { + "Active workspace: "; + session.workspace.clone() + } + } + + column(gap = 16.0) { + ThemeCard( + title = "App theme".to_string(), + subtitle = "Resolved from the outer `AppThemeContext` provider.".to_string(), + theme = theme.clone(), + ) {} + + provide::(value = ThemePalette::inspector()) { + InspectorPanel() {} + } + } + } + } +} + +#[component] +fn ThemeCard(title: String, subtitle: String, theme: ThemePalette) -> impl IntoView { + view! { + block( + padding = 16.0, + gap = 10.0, + background = theme.panel, + border_radius = 14.0, + border = (1.0, theme.accent), + ) { + text(size = 20.0, weight = FontWeight::Semibold, color = theme.text) { title } + text(color = theme.muted, wrap = TextWrap::Word) { subtitle } + block( + height = 56.0, + background = theme.canvas, + border_radius = 10.0, + border = (2.0, theme.accent), + ) {} + } + } +} + +#[component] +fn InspectorPanel() -> impl IntoView { + let theme = use_context::(); + let notifications = use_context::(); + + view! { + block( + padding = 16.0, + gap = 12.0, + background = theme.canvas, + border_radius = 14.0, + border = (1.0, theme.accent), + ) { + text(size = 18.0, weight = FontWeight::Semibold, color = theme.accent) { + "Inspector panel" + } + + ThemeCard( + title = "Nested override".to_string(), + subtitle = "This panel shadows the outer `AppThemeContext` with a nearer provider of the same marker type.".to_string(), + theme = theme.clone(), + ) {} + + button(on_press = { + let notifications = notifications.clone(); + move |_| notifications.info("Inspector theme override is active") + }) { "Emit inspector notification" } + } + } +} + +#[context_provider(ThemePalette)] +struct AppThemeContext; + +#[context_provider(ThemePalette)] +struct SidebarThemeContext; + +#[context_provider(SessionInfo)] +struct SessionContext; + +#[context_provider(Notifications)] +struct NotificationsContext; + +#[derive(Clone)] +struct ThemePalette { + canvas: Color, + panel: Color, + accent: Color, + text: Color, + muted: Color, +} + +impl ThemePalette { + fn workspace() -> Self { + Self { + canvas: Color::rgb(0x0E, 0x14, 0x20), + panel: Color::rgb(0x16, 0x1F, 0x30), + accent: Color::rgb(0x71, 0xA7, 0xF7), + text: Color::rgb(0xF5, 0xF7, 0xFB), + muted: Color::rgb(0x9A, 0xA9, 0xC2), + } + } + + fn sidebar() -> Self { + Self { + canvas: Color::rgb(0x0F, 0x17, 0x24), + panel: Color::rgb(0x1A, 0x24, 0x36), + accent: Color::rgb(0x8B, 0xC3, 0x7E), + text: Color::rgb(0xF3, 0xF7, 0xEF), + muted: Color::rgb(0xA7, 0xB5, 0xA4), + } + } + + fn inspector() -> Self { + Self { + canvas: Color::rgb(0x22, 0x17, 0x0F), + panel: Color::rgb(0x35, 0x24, 0x16), + accent: Color::rgb(0xF4, 0xB7, 0x56), + text: Color::rgb(0xFC, 0xF6, 0xEB), + muted: Color::rgb(0xCF, 0xB8, 0x98), + } + } +} + +#[derive(Clone)] +struct SessionInfo { + user: String, + workspace: String, +} + +impl SessionInfo { + fn guest() -> Self { + Self { + user: "guest".to_string(), + workspace: "ruin".to_string(), + } + } +} + +#[derive(Clone)] +struct Notifications { + last_message: Signal, +} + +impl Notifications { + fn new(last_message: Signal) -> Self { + Self { last_message } + } + + fn info(&self, message: impl Into) { + self.last_message.replace(message.into()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inspector_panel_text_reaches_the_scene() { + let root = WorkspaceRoot::builder().children(()); + let view = ruin_app::__render_mountable_for_test(&root); + let snapshot = ruin_ui::layout_snapshot(1, UiSize::new(1280.0, 840.0), view.element()); + + assert!( + snapshot.scene.items.iter().any(|item| matches!( + item, + ruin_ui::DisplayItem::Text(text) + if text.text.contains("Nested override") + || text.text.contains("Inspector panel") + )), + "expected InspectorPanel text to appear in the rendered scene", + ); + } +} diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index 6f31aa3..99178da 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -3,7 +3,7 @@ //! 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::any::Any; +use std::any::{Any, TypeId, type_name}; use std::cell::{Cell as StdCell, RefCell}; use std::collections::HashMap; use std::error::Error; @@ -25,7 +25,7 @@ use ruin_ui::{ use ruin_ui_platform_wayland::start_wayland_ui; pub use ResourceState::{Pending, Ready}; -pub use ruin_app_proc_macros::{component, view}; +pub use ruin_app_proc_macros::{component, context_provider, view}; pub type Result = std::result::Result>; @@ -94,6 +94,11 @@ impl Default for App { } } +#[doc(hidden)] +pub fn __render_mountable_for_test(mountable: &M) -> View { + render_with_context(Rc::new(RenderState::default()), || mountable.render()).view +} + pub trait Mountable: 'static { fn render(&self) -> View; } @@ -105,6 +110,10 @@ pub trait Component: 'static { fn render(&self) -> View; } +pub trait ContextKey: 'static { + type Value: Clone + 'static; +} + impl Mountable for T { fn render(&self) -> View { Component::render(self) @@ -1147,10 +1156,57 @@ impl Memo { } } +#[derive(Clone)] +struct ContextEntry { + key: TypeId, + value: Rc, +} + +impl ContextEntry { + fn new(value: C::Value) -> Self { + Self { + key: TypeId::of::(), + value: Rc::new(value), + } + } +} + pub fn use_signal(initial: impl FnOnce() -> T) -> Signal { with_hook_slot(|| Signal::new(initial()), |signal| signal.clone()) } +pub fn use_context() -> C::Value { + with_render_context_state(|context| { + context + .context_entries + .iter() + .rev() + .find_map(|entry| { + (entry.key == TypeId::of::()) + .then(|| entry.value.downcast_ref::()) + .flatten() + .cloned() + }) + .unwrap_or_else(|| { + panic!( + "missing context provider for {} while rendering", + type_name::() + ) + }) + }) +} + +pub fn provide(value: C::Value, render: impl FnOnce() -> View) -> View { + with_render_context_state(|context| { + let mut context_entries = (*context.context_entries).clone(); + context_entries.push(ContextEntry::new::(value)); + with_render_context( + context.with_context_entries(Rc::new(context_entries)), + render, + ) + }) +} + pub fn use_memo(compute: impl Fn() -> T + 'static) -> Memo { let compute: Rc T>>> = Rc::new(RefCell::new(Box::new(compute) as Box T>)); @@ -1434,6 +1490,19 @@ struct RenderContext { hook_index: Rc>, element_index: Rc>, side_effects: Rc>, + context_entries: Rc>, +} + +impl RenderContext { + fn with_context_entries(&self, context_entries: Rc>) -> Self { + Self { + state: Rc::clone(&self.state), + hook_index: Rc::clone(&self.hook_index), + element_index: Rc::clone(&self.element_index), + side_effects: Rc::clone(&self.side_effects), + context_entries, + } + } } struct RenderOutput { @@ -1451,8 +1520,15 @@ fn render_with_context(state: Rc, render: impl FnOnce() -> View) -> hook_index: Rc::new(StdCell::new(0)), element_index: Rc::new(StdCell::new(0)), side_effects: Rc::new(RefCell::new(RenderSideEffects::default())), + context_entries: Rc::new(Vec::new()), }; + let view = with_render_context(context.clone(), render); + let side_effects = context.side_effects.borrow().clone(); + RenderOutput { view, side_effects } +} + +fn with_render_context(context: RenderContext, render: impl FnOnce() -> View) -> View { CURRENT_RENDER_CONTEXT.with(|slot| { let previous = slot.replace(Some(context.clone())); @@ -1468,19 +1544,17 @@ fn render_with_context(state: Rc, render: impl FnOnce() -> View) -> } let _guard = Guard { slot, previous }; - let view = render(); - let side_effects = context.side_effects.borrow().clone(); - RenderOutput { view, side_effects } + render() }) } fn with_render_context_state(f: impl FnOnce(&RenderContext) -> R) -> R { CURRENT_RENDER_CONTEXT.with(|slot| { - let context = slot.borrow(); - let context = context - .as_ref() + let context = slot + .borrow() + .clone() .expect("ruin_app hooks can only run while rendering a mounted component"); - f(context) + f(&context) }) } @@ -1837,13 +1911,13 @@ fn build_focused_ancestor_chain( pub mod prelude { pub use crate::{ - App, BlockWidget, ButtonBuilder, Children, Component, ContainerBuilder, FocusScope, - FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable, Pending, Ready, - Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Shortcut, + App, BlockWidget, ButtonBuilder, Children, Component, ContainerBuilder, ContextKey, + FocusScope, FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable, Pending, + Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, View, WidgetRef, Window, block, - button, colors, column, component, row, scroll_box, surfaces, text, use_effect, use_memo, - use_resource, use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, - use_window_title, view, + button, colors, column, component, context_provider, provide, row, scroll_box, surfaces, + text, use_context, use_effect, use_memo, use_resource, use_shortcut, + use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, view, }; pub use ruin_ui::{ Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton, @@ -1851,6 +1925,68 @@ pub mod prelude { TextFontFamily, TextStyle, TextWrap, UiSize, }; } + struct SignalInner { cell: ruin_reactivity::Cell, } + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, Debug, PartialEq, Eq)] + struct NamedValue(&'static str); + + struct OuterContext; + struct InnerContext; + + impl ContextKey for OuterContext { + type Value = NamedValue; + } + + impl ContextKey for InnerContext { + type Value = NamedValue; + } + + #[test] + fn use_context_distinguishes_marker_types_for_same_value_type() { + let seen_outer = Rc::new(RefCell::new(None::)); + let seen_inner = Rc::new(RefCell::new(None::)); + + let _ = render_with_context(Rc::new(RenderState::default()), { + let seen_outer = Rc::clone(&seen_outer); + let seen_inner = Rc::clone(&seen_inner); + move || { + provide::(NamedValue("outer"), || { + provide::(NamedValue("inner"), || { + *seen_outer.borrow_mut() = Some(use_context::()); + *seen_inner.borrow_mut() = Some(use_context::()); + View::from_element(Element::column()) + }) + }) + } + }); + + assert_eq!(*seen_outer.borrow(), Some(NamedValue("outer"))); + assert_eq!(*seen_inner.borrow(), Some(NamedValue("inner"))); + } + + #[test] + fn nearer_provider_shadows_outer_provider_of_same_marker() { + let seen_value = Rc::new(RefCell::new(None::)); + + let _ = render_with_context(Rc::new(RenderState::default()), { + let seen_value = Rc::clone(&seen_value); + move || { + provide::(NamedValue("outer"), || { + provide::(NamedValue("inner"), || { + *seen_value.borrow_mut() = Some(use_context::()); + View::from_element(Element::column()) + }) + }) + } + }); + + assert_eq!(*seen_value.borrow(), Some(NamedValue("inner"))); + } +} diff --git a/lib/ruin_app_proc_macros/src/lib.rs b/lib/ruin_app_proc_macros/src/lib.rs index d9c8b89..e72bd38 100644 --- a/lib/ruin_app_proc_macros/src/lib.rs +++ b/lib/ruin_app_proc_macros/src/lib.rs @@ -4,8 +4,8 @@ use quote::{format_ident, quote}; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::{ - Error, Expr, FnArg, Ident, ItemFn, Pat, PatIdent, Path, Result, ReturnType, Token, Type, - braced, parenthesized, parse_macro_input, + Error, Expr, FnArg, Ident, ItemFn, ItemStruct, Pat, PatIdent, Path, Result, ReturnType, Token, + Type, braced, parenthesized, parse_macro_input, }; #[proc_macro_attribute] @@ -29,6 +29,16 @@ pub fn view(input: TokenStream) -> TokenStream { expand_node(&root.root).into() } +#[proc_macro_attribute] +pub fn context_provider(attr: TokenStream, item: TokenStream) -> TokenStream { + let value_ty = parse_macro_input!(attr as Type); + let item_struct = parse_macro_input!(item as ItemStruct); + match expand_context_provider(item_struct, value_ty) { + Ok(tokens) => tokens.into(), + Err(error) => error.to_compile_error().into(), + } +} + fn expand_component(mut function: ItemFn) -> Result { validate_component_function(&function)?; @@ -256,6 +266,34 @@ fn expand_component(mut function: ItemFn) -> Result { }) } +fn expand_context_provider( + item_struct: ItemStruct, + value_ty: Type, +) -> Result { + if !item_struct.generics.params.is_empty() || item_struct.generics.where_clause.is_some() { + return Err(Error::new_spanned( + &item_struct.generics, + "context providers cannot be generic", + )); + } + if !matches!(item_struct.fields, syn::Fields::Unit) { + return Err(Error::new_spanned( + &item_struct.fields, + "context providers must be unit structs", + )); + } + + let ident = &item_struct.ident; + + Ok(quote! { + #item_struct + + impl ::ruin_app::ContextKey for #ident { + type Value = #value_ty; + } + }) +} + fn validate_component_function(function: &ItemFn) -> Result<()> { let signature = &function.sig; @@ -443,6 +481,9 @@ fn looks_like_node(input: ParseStream<'_>) -> Result { fn expand_node(node: &Node) -> proc_macro2::TokenStream { let path = &node.path; + if is_provider_path(path) { + return expand_provider_node(node); + } let prop_calls = node.props.iter().map(|property| { let name = &property.name; let value = &property.value; @@ -465,6 +506,34 @@ fn expand_node(node: &Node) -> proc_macro2::TokenStream { } } +fn expand_provider_node(node: &Node) -> proc_macro2::TokenStream { + let path = &node.path; + let value = match node.props.as_slice() { + [Property { name, value }] if name == "value" => value, + _ => { + return Error::new_spanned( + path, + "provide::(...) expects exactly one `value = ...` property", + ) + .to_compile_error(); + } + }; + let child = match node.children.as_slice() { + [child] => expand_child(child), + _ => { + return Error::new_spanned(path, "provide::(...) requires exactly one child") + .to_compile_error(); + } + }; + + quote! { + ::ruin_app::#path( + #value, + || ::ruin_app::IntoView::into_view(#child), + ) + } +} + fn expand_children(children: &[Child]) -> proc_macro2::TokenStream { match children { [] => quote! { () }, @@ -491,3 +560,12 @@ fn is_component_path(path: &Path) -> bool { .map(|ch| ch.is_ascii_uppercase()) .unwrap_or(false) } + +fn is_provider_path(path: &Path) -> bool { + !is_component_path(path) + && path + .segments + .last() + .map(|segment| segment.ident == "provide") + .unwrap_or(false) +} diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index b142186..3616867 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -383,11 +383,17 @@ fn layout_element( perf_stats.container_nodes += 1; if element.children.is_empty() { + if pushed_clip { + scene.pop_clip(); + } return interaction; } let content = inset_rect(rect, content_insets(&element.style)); if content.size.width <= 0.0 || content.size.height <= 0.0 { + if pushed_clip { + scene.pop_clip(); + } return interaction; } interaction.children = layout_container_children( @@ -1543,6 +1549,40 @@ mod tests { ); } + #[test] + fn empty_rounded_container_pops_clip_before_following_sibling() { + let root = Element::column().children([ + Element::column() + .height(56.0) + .corner_radius(12.0) + .background(Color::rgb(0x22, 0x33, 0x44)), + Element::paragraph( + "Sibling text should not inherit a stale clip from an empty rounded container.", + TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0), + ), + ]); + + let scene = layout_scene(1, UiSize::new(480.0, 240.0), &root); + let rounded_push = scene + .items + .iter() + .position(|item| matches!(item, DisplayItem::PushClip(_))) + .expect("rounded empty container should push a clip"); + let rounded_pop = scene + .items + .iter() + .position(|item| matches!(item, DisplayItem::PopClip)) + .expect("rounded empty container should pop its clip"); + let sibling_text = scene + .items + .iter() + .position(|item| matches!(item, DisplayItem::Text(text) if text.text.contains("Sibling text"))) + .expect("following sibling text should be emitted"); + + assert!(rounded_push < rounded_pop); + assert!(rounded_pop < sibling_text); + } + #[test] fn shadowed_container_emits_shadow_rect_items() { let root = Element::column().child(