From 28e140b30208077e825b394054cc00f261a18b4e Mon Sep 17 00:00:00 2001 From: Will Temple Date: Sun, 22 Mar 2026 13:12:00 -0400 Subject: [PATCH] Port example 07 --- lib/ruin_app/Cargo.toml | 4 + lib/ruin_app/example/07_children_and_slots.rs | 282 +++++++++++++++++ lib/ruin_app/src/lib.rs | 84 ++++- lib/ruin_app_proc_macros/src/lib.rs | 295 +++++++++++++++--- 4 files changed, 610 insertions(+), 55 deletions(-) create mode 100644 lib/ruin_app/example/07_children_and_slots.rs diff --git a/lib/ruin_app/Cargo.toml b/lib/ruin_app/Cargo.toml index 7ec29af..d8408b8 100644 --- a/lib/ruin_app/Cargo.toml +++ b/lib/ruin_app/Cargo.toml @@ -37,3 +37,7 @@ path = "example/04_composition_and_context.rs" [[example]] name = "05_async_runtime_io" path = "example/05_async_runtime_io.rs" + +[[example]] +name = "07_children_and_slots" +path = "example/07_children_and_slots.rs" diff --git a/lib/ruin_app/example/07_children_and_slots.rs b/lib/ruin_app/example/07_children_and_slots.rs new file mode 100644 index 0000000..54eaf8c --- /dev/null +++ b/lib/ruin_app/example/07_children_and_slots.rs @@ -0,0 +1,282 @@ +use ruin_app::prelude::*; + +#[derive(Clone)] +struct Project { + id: u64, + name: &'static str, + status: &'static str, + summary: &'static str, +} + +#[ruin_runtime::async_main] +async fn main() -> ruin_app::Result<()> { + App::new() + .window( + Window::new() + .title("RUIN Children and Slots") + .app_id("dev.ruin.children-and-slots") + .size(1180.0, 820.0), + ) + .mount(view! { + ChildrenAndSlotsExample() {} + }) + .run() + .await +} + +#[component] +fn ChildrenAndSlotsExample() -> impl IntoView { + let active_project = use_signal(|| 1_u64); + let dialog_open = use_signal(|| false); + let projects = [ + Project { + id: 1, + name: "Renderer clip recovery", + status: "Ready for validation", + summary: "Nested clip propagation now preserves empty intersections all the way down the tree.", + }, + Project { + id: 2, + name: "Runtime I/O dashboard", + status: "Shipped", + summary: "Filesystem snapshots and local TCP-backed endpoint previews now update immediately after async work completes.", + }, + Project { + id: 3, + name: "Children contract support", + status: "In progress", + summary: "Custom components can now accept unnamed child content and typed child-like slot props without faking overlay APIs.", + }, + ]; + let active = projects + .iter() + .find(|project| project.id == active_project.get()) + .cloned() + .unwrap_or_else(|| projects[0].clone()); + + view! { + column(gap = 18.0, padding = 22.0, background = surfaces::canvas()) { + text(role = TextRole::Heading(1), size = 30.0, weight = FontWeight::Semibold) { + "Children and slots" + } + + text(color = colors::muted(), wrap = TextWrap::Word) { + "This honest slice implements unnamed child contracts plus typed child-like slot props. It does not pretend modal overlays or keyed list primitives exist yet; the dialog below is rendered inline on purpose." + } + + SplitLayout( + sidebar = vec![ + view! { + text(role = TextRole::Heading(2), size = 22.0, weight = FontWeight::Semibold) { + "Views" + } + }, + IntoView::into_view(FilterChip::builder().selected(active.id == 1).children("Rendering")), + IntoView::into_view(FilterChip::builder().selected(active.id == 2).children("Runtime I/O")), + IntoView::into_view(FilterChip::builder().selected(active.id == 3).children("Framework")), + view! { + text(color = colors::muted(), wrap = TextWrap::Word) { + "The sidebar itself is passed as a typed child-view slot, while the chips use a text-only child contract." + } + }, + ] + ) { + column(gap = 16.0) { + CardFrame( + title = view! { + text(role = TextRole::Heading(2), size = 24.0, weight = FontWeight::Semibold) { + "Workspace focus" + } + }, + toolbar = vec![ + view! { + button(on_press = { + let dialog_open = dialog_open.clone(); + move |_| { + let _ = dialog_open.set(true); + } + }) { "Open review dialog" } + }, + view! { + button(on_press = { + let active_project = active_project.clone(); + move |_| { + let next = if active_project.get() == 3 { 1 } else { active_project.get() + 1 }; + let _ = active_project.set(next); + } + }) { "Cycle project" } + }, + ] + ) { + text(color = colors::muted()) { + ("Active project: ", active.name) + } + + text(color = colors::muted(), wrap = TextWrap::Word) { + active.summary + } + + column(gap = 10.0) { + button(on_press = { + let active_project = active_project.clone(); + move |_| { + let _ = active_project.set(1); + } + }) { "Show rendering work" } + + button(on_press = { + let active_project = active_project.clone(); + move |_| { + let _ = active_project.set(2); + } + }) { "Show runtime I/O work" } + + button(on_press = { + let active_project = active_project.clone(); + move |_| { + let _ = active_project.set(3); + } + }) { "Show framework work" } + } + } + + InlineDialog( + open = dialog_open.get(), + title = view! { + text(role = TextRole::Heading(2), size = 22.0, weight = FontWeight::Semibold) { + "Inline review dialog" + } + }, + actions = vec![ + view! { + button(on_press = { + let dialog_open = dialog_open.clone(); + move |_| { + let _ = dialog_open.set(false); + } + }) { "Dismiss" } + }, + view! { + button(on_press = { + let dialog_open = dialog_open.clone(); + move |_| { + eprintln!("example07: accepted review for {}", active.name); + let _ = dialog_open.set(false); + } + }) { "Accept" } + }, + ] + ) { + text(color = colors::muted(), wrap = TextWrap::Word) { + "This body arrives through the unnamed child contract. The title and action row are separate typed slot props." + } + + text(color = colors::muted()) { + ("Selected item status: ", active.status) + } + } + } + } + } + } +} + +#[component] +fn SplitLayout(sidebar: ChildViews, children: ChildViews) -> impl IntoView { + view! { + row(gap = 18.0) { + block( + width = 280.0, + gap = 12.0, + padding = 16.0, + background = surfaces::raised(), + border_radius = 16.0, + ) { + sidebar + } + + block( + flex = 1.0, + gap = 16.0, + padding = 16.0, + background = surfaces::raised(), + border_radius = 16.0, + ) { + children + } + } + } +} + +#[component] +fn FilterChip(selected: bool, children: TextValue) -> impl IntoView { + let background = if selected { + surfaces::interactive() + } else { + surfaces::interactive_muted() + }; + let border = if selected { + colors::text() + } else { + colors::muted() + }; + + view! { + block( + padding = Edges::symmetric(14.0, 10.0), + background = background, + border = (1.0, border), + border_radius = 999.0, + ) { + text(weight = FontWeight::Medium) { + children + } + } + } +} + +#[component] +fn CardFrame(title: View, toolbar: ChildViews, children: ChildViews) -> impl IntoView { + let header = view! { + row(gap = 12.0) { + block(flex = 1.0) { + title + } + + row(gap = 10.0) { + toolbar + } + } + }; + let mut body = vec![header]; + body.extend(children.into_vec()); + block() + .gap(14.0) + .padding(18.0) + .background(surfaces::canvas()) + .border_radius(16.0) + .children(body) +} + +#[component] +fn InlineDialog(open: bool, title: View, actions: ChildViews, children: ChildViews) -> impl IntoView { + if open { + let actions_row = view! { + row(gap = 10.0) { + actions + } + }; + let mut body = vec![title]; + body.extend(children.into_vec()); + body.push(actions_row); + block() + .gap(14.0) + .padding(18.0) + .background(surfaces::raised()) + .border((1.0, colors::text())) + .border_radius(16.0) + .children(body) + } else { + column().children(()) + } +} diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index e15593c..1470663 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -3,6 +3,8 @@ //! 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. +extern crate self as ruin_app; + use std::any::{Any, TypeId, type_name}; use std::cell::{Cell as StdCell, RefCell}; use std::collections::HashMap; @@ -496,6 +498,19 @@ pub trait Children { fn into_views(self) -> Vec; } +#[derive(Clone, Default)] +pub struct ChildViews(Vec); + +impl ChildViews { + pub fn from_children(children: impl Children) -> Self { + Self(children.into_views()) + } + + pub fn into_vec(self) -> Vec { + self.0 + } +} + impl Children for () { fn into_views(self) -> Vec { Vec::new() @@ -514,6 +529,12 @@ impl Children for Vec { } } +impl Children for ChildViews { + fn into_views(self) -> Vec { + self.0 + } +} + macro_rules! impl_children_tuple { ($($name:ident),+ $(,)?) => { #[allow(non_camel_case_types)] @@ -538,6 +559,25 @@ pub trait TextChildren { fn into_text(self) -> String; } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TextValue(String); + +impl TextValue { + pub fn from_text(children: impl TextChildren) -> Self { + Self(children.into_text()) + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl TextChildren for TextValue { + fn into_text(self) -> String { + self.0 + } +} + impl TextChildren for &'static str { fn into_text(self) -> String { self.to_string() @@ -1911,12 +1951,12 @@ fn build_focused_ancestor_chain( pub mod prelude { pub use crate::{ - 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, context_provider, provide, row, scroll_box, surfaces, - text, use_context, use_effect, use_memo, use_resource, use_shortcut, + App, BlockWidget, ButtonBuilder, ChildViews, 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, TextValue, View, + WidgetRef, Window, block, 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::{ @@ -1949,6 +1989,15 @@ mod tests { type Value = NamedValue; } + #[component] + fn ContractProbe(label: TextValue, actions: ChildViews, children: ChildViews) -> impl IntoView { + column().children(( + text().children(label), + row().children(actions), + block().children(children), + )) + } + #[test] fn use_context_distinguishes_marker_types_for_same_value_type() { let seen_outer = Rc::new(RefCell::new(None::)); @@ -1991,6 +2040,29 @@ mod tests { assert_eq!(*seen_value.borrow(), Some(NamedValue("inner"))); } + #[test] + fn components_accept_child_contracts_and_child_like_slot_props() { + let render = render_with_context(Rc::new(RenderState::default()), || { + IntoView::into_view(view! { + ContractProbe( + label = "slot label", + actions = ( + text().children("action a"), + text().children("action b"), + ) + ) { + text() { "body child" } + } + }) + }); + let debug = format!("{:?}", render.view.element()); + + assert!(debug.contains("slot label"), "{debug}"); + assert!(debug.contains("action a"), "{debug}"); + assert!(debug.contains("action b"), "{debug}"); + assert!(debug.contains("body child"), "{debug}"); + } + #[test] fn key_dispatch_prefers_the_nearest_focused_ancestor_handler() { let outer_id = ElementId::new(41); diff --git a/lib/ruin_app_proc_macros/src/lib.rs b/lib/ruin_app_proc_macros/src/lib.rs index e72bd38..e007c1b 100644 --- a/lib/ruin_app_proc_macros/src/lib.rs +++ b/lib/ruin_app_proc_macros/src/lib.rs @@ -50,22 +50,57 @@ fn expand_component(mut function: ItemFn) -> Result { .attrs .push(syn::parse_quote!(#[allow(non_snake_case)])); - let props = function + let mut inputs = function .sig .inputs .iter() .map(parse_prop) .collect::>>()?; + let child_contract = match inputs.last() { + Some(prop) if prop.ident == "children" => { + let prop = inputs.pop().expect("last input should exist"); + let kind = parse_children_contract_kind(&prop.ty)?; + Some(ChildContract { + ty: prop.ty, + kind, + }) + } + _ => None, + }; + let props = inputs; let builder_name = format_ident!("__{}Builder", component_name); + let child_field_ident = child_contract.as_ref().map(|_| format_ident!("children")); let builder_tokens = if props.is_empty() { - quote! { - #vis struct #builder_name; + match child_contract.as_ref() { + None => { + quote! { + #vis struct #builder_name; - impl #builder_name { - #vis fn children(self, _children: ()) -> #component_name { - #component_name + impl #builder_name { + #vis fn children(self, _children: ()) -> #component_name { + #component_name + } + } + } + } + Some(contract) => { + let child_arg_ty = child_builder_arg_type_tokens(contract.kind); + let child_value = wrap_special_value_tokens(contract.kind, &format_ident!("children")); + let child_field_ident = child_field_ident + .as_ref() + .expect("child field ident should exist"); + quote! { + #vis struct #builder_name; + + impl #builder_name { + #vis fn children(self, children: #child_arg_ty) -> #component_name { + #component_name { + #child_field_ident: #child_value, + } + } + } } } } @@ -98,9 +133,10 @@ fn expand_component(mut function: ItemFn) -> Result { let builder_methods = props.iter().enumerate().map(|(index, prop)| { let value_ident = &prop.ident; - let value_ty = &prop.ty; let missing_at_index = &missing_names[index]; let present_at_index = &present_names[index]; + let setter_arg_ty = prop_builder_arg_type_tokens(prop); + let stored_value = wrap_special_value_tokens(prop.kind, value_ident); let impl_generics = state_idents .iter() @@ -138,7 +174,7 @@ fn expand_component(mut function: ItemFn) -> Result { .enumerate() .map(|(current, field_ident)| { if current == index { - quote! { #field_ident: #present_at_index(#value_ident) } + quote! { #field_ident: #present_at_index(#stored_value) } } else { quote! { #field_ident: self.#field_ident } } @@ -148,7 +184,7 @@ fn expand_component(mut function: ItemFn) -> Result { if impl_generics.is_empty() { quote! { impl #builder_name<#(#self_builder_args),*> { - #vis fn #value_ident(self, #value_ident: #value_ty) -> #builder_name<#(#next_builder_args),*> { + #vis fn #value_ident(self, #value_ident: #setter_arg_ty) -> #builder_name<#(#next_builder_args),*> { #builder_name { #(#field_initializers),* } @@ -158,7 +194,7 @@ fn expand_component(mut function: ItemFn) -> Result { } else { quote! { impl<#(#impl_generics),*> #builder_name<#(#self_builder_args),*> { - #vis fn #value_ident(self, #value_ident: #value_ty) -> #builder_name<#(#next_builder_args),*> { + #vis fn #value_ident(self, #value_ident: #setter_arg_ty) -> #builder_name<#(#next_builder_args),*> { #builder_name { #(#field_initializers),* } @@ -179,6 +215,59 @@ fn expand_component(mut function: ItemFn) -> Result { let field_extractors = field_idents .iter() .map(|field_ident| quote! { #field_ident: builder.#field_ident.0 }); + let component_render_args = props + .iter() + .map(|prop| { + let ident = &prop.ident; + quote! { ::core::clone::Clone::clone(&self.#ident) } + }) + .chain(child_field_ident.iter().map(|ident| { + quote! { ::core::clone::Clone::clone(&self.#ident) } + })) + .collect::>(); + let children_method = match child_contract.as_ref() { + None => quote! { + #vis fn children(self, _children: ()) -> #component_name { + #component_name::from_builder(self) + } + }, + Some(contract) => { + let child_arg_ty = child_builder_arg_type_tokens(contract.kind); + let child_value = + wrap_special_value_tokens(contract.kind, &format_ident!("children")); + quote! { + #vis fn children(self, children: #child_arg_ty) -> #component_name { + #component_name::from_builder(self, #child_value) + } + } + } + }; + let from_builder = match child_contract.as_ref() { + None => quote! { + fn from_builder(builder: #builder_name<#(#ready_builder_args),*>) -> Self { + Self { + #(#field_extractors),* + } + } + }, + Some(contract) => { + let child_ty = &contract.ty; + let child_field_ident = child_field_ident + .as_ref() + .expect("child field ident should exist"); + quote! { + fn from_builder( + builder: #builder_name<#(#ready_builder_args),*>, + #child_field_ident: #child_ty, + ) -> Self { + Self { + #(#field_extractors),*, + #child_field_ident, + } + } + } + } + }; quote! { #(#marker_structs)* @@ -190,17 +279,11 @@ fn expand_component(mut function: ItemFn) -> Result { #(#builder_methods)* impl #builder_name<#(#ready_builder_args),*> { - #vis fn children(self, _children: ()) -> #component_name { - #component_name::from_builder(self) - } + #children_method } impl #component_name { - fn from_builder(builder: #builder_name<#(#ready_builder_args),*>) -> Self { - Self { - #(#field_extractors),* - } - } + #from_builder } impl ::ruin_app::Component for #component_name { @@ -214,47 +297,82 @@ fn expand_component(mut function: ItemFn) -> Result { fn render(&self) -> ::ruin_app::View { ::ruin_app::IntoView::into_view(#render_name( - #(::core::clone::Clone::clone(&self.#field_idents)),* + #(#component_render_args),* )) } } } }; - let render_call = if props.is_empty() { - quote! { #render_name() } - } else { - let field_idents = props.iter().map(|prop| &prop.ident); - quote! { #render_name(#(::core::clone::Clone::clone(&self.#field_idents)),*) } - }; + let render_args = props + .iter() + .map(|prop| { + let ident = &prop.ident; + quote! { ::core::clone::Clone::clone(&self.#ident) } + }) + .chain(child_field_ident.iter().map(|ident| { + quote! { ::core::clone::Clone::clone(&self.#ident) } + })) + .collect::>(); + let render_call = quote! { #render_name(#(#render_args),*) }; - let component_tokens = if props.is_empty() { - quote! { - #vis struct #component_name; + let component_tokens = match (props.is_empty(), child_contract.as_ref()) { + (true, None) => { + quote! { + #vis struct #component_name; - #builder_tokens + #builder_tokens - impl ::ruin_app::Component for #component_name { - type Builder = #builder_name; + impl ::ruin_app::Component for #component_name { + type Builder = #builder_name; - fn builder() -> Self::Builder { - #builder_name - } + fn builder() -> Self::Builder { + #builder_name + } - fn render(&self) -> ::ruin_app::View { - ::ruin_app::IntoView::into_view(#render_call) + fn render(&self) -> ::ruin_app::View { + ::ruin_app::IntoView::into_view(#render_call) + } } } } - } else { - let field_idents = props.iter().map(|prop| &prop.ident); - let field_types = props.iter().map(|prop| &prop.ty); - quote! { - #vis struct #component_name { - #( #field_idents: #field_types ),* - } + _ => { + let field_idents = props + .iter() + .map(|prop| prop.ident.clone()) + .chain(child_field_ident.iter().cloned()) + .collect::>(); + let field_types = props + .iter() + .map(|prop| prop.ty.clone()) + .chain(child_contract.iter().map(|contract| contract.ty.clone())) + .collect::>(); + let component_impl = if props.is_empty() { + quote! { + impl ::ruin_app::Component for #component_name { + type Builder = #builder_name; - #builder_tokens + fn builder() -> Self::Builder { + #builder_name + } + + fn render(&self) -> ::ruin_app::View { + ::ruin_app::IntoView::into_view(#render_call) + } + } + } + } else { + quote! {} + }; + quote! { + #vis struct #component_name { + #( #field_idents: #field_types ),* + } + + #builder_tokens + + #component_impl + } } }; @@ -333,7 +451,8 @@ fn validate_component_function(function: &ItemFn) -> Result<()> { "components cannot be variadic", )); } - for input in &signature.inputs { + let input_count = signature.inputs.len(); + for (index, input) in signature.inputs.iter().enumerate() { let FnArg::Typed(typed) = input else { return Err(Error::new_spanned( input, @@ -347,10 +466,13 @@ fn validate_component_function(function: &ItemFn) -> Result<()> { )); }; if ident == "children" { - return Err(Error::new_spanned( - ident, - "`children` is reserved for the generated builder finalizer", - )); + if index + 1 != input_count { + return Err(Error::new_spanned( + ident, + "component `children` contracts must be the final parameter", + )); + } + parse_children_contract_kind(typed.ty.as_ref())?; } } @@ -366,6 +488,7 @@ fn validate_component_function(function: &ItemFn) -> Result<()> { struct Prop { ident: Ident, ty: Type, + kind: SpecialValueKind, } fn parse_prop(input: &FnArg) -> Result { @@ -385,9 +508,83 @@ fn parse_prop(input: &FnArg) -> Result { Ok(Prop { ident: ident.clone(), ty: (*typed.ty).clone(), + kind: parse_special_value_kind(typed.ty.as_ref()), }) } +#[derive(Clone)] +struct ChildContract { + ty: Type, + kind: SpecialValueKind, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum SpecialValueKind { + Plain, + ChildViews, + TextValue, +} + +fn parse_special_value_kind(ty: &Type) -> SpecialValueKind { + match terminal_type_ident(ty).as_deref() { + Some("ChildViews") => SpecialValueKind::ChildViews, + Some("TextValue") => SpecialValueKind::TextValue, + _ => SpecialValueKind::Plain, + } +} + +fn parse_children_contract_kind(ty: &Type) -> Result { + match parse_special_value_kind(ty) { + SpecialValueKind::ChildViews => Ok(SpecialValueKind::ChildViews), + SpecialValueKind::TextValue => Ok(SpecialValueKind::TextValue), + SpecialValueKind::Plain => Err(Error::new_spanned( + ty, + "component `children` contracts must use `ruin_app::ChildViews` or `ruin_app::TextValue`", + )), + } +} + +fn terminal_type_ident(ty: &Type) -> Option { + let Type::Path(type_path) = ty else { + return None; + }; + type_path + .path + .segments + .last() + .map(|segment| segment.ident.to_string()) +} + +fn prop_builder_arg_type_tokens(prop: &Prop) -> proc_macro2::TokenStream { + match prop.kind { + SpecialValueKind::Plain => { + let ty = &prop.ty; + quote! { #ty } + } + SpecialValueKind::ChildViews => quote! { impl ::ruin_app::Children }, + SpecialValueKind::TextValue => quote! { impl ::ruin_app::TextChildren }, + } +} + +fn child_builder_arg_type_tokens(kind: SpecialValueKind) -> proc_macro2::TokenStream { + match kind { + SpecialValueKind::Plain => quote! { () }, + SpecialValueKind::ChildViews => quote! { impl ::ruin_app::Children }, + SpecialValueKind::TextValue => quote! { impl ::ruin_app::TextChildren }, + } +} + +fn wrap_special_value_tokens( + kind: SpecialValueKind, + ident: &Ident, +) -> proc_macro2::TokenStream { + match kind { + SpecialValueKind::Plain => quote! { #ident }, + SpecialValueKind::ChildViews => quote! { ::ruin_app::ChildViews::from_children(#ident) }, + SpecialValueKind::TextValue => quote! { ::ruin_app::TextValue::from_text(#ident) }, + } +} + struct ViewRoot { root: Node, }