diff --git a/Cargo.lock b/Cargo.lock index d3cc1e2..f00f13e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,6 +1606,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "ruin-app-proc-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ruin-runtime" version = "0.1.0" @@ -1631,6 +1640,7 @@ dependencies = [ name = "ruin_app" version = "0.1.0" dependencies = [ + "ruin-app-proc-macros", "ruin-runtime", "ruin_reactivity", "ruin_ui", diff --git a/lib/ruin_app/Cargo.toml b/lib/ruin_app/Cargo.toml index 69f3a27..c133dfd 100644 --- a/lib/ruin_app/Cargo.toml +++ b/lib/ruin_app/Cargo.toml @@ -6,9 +6,14 @@ edition = "2024" [dependencies] ruin_reactivity = { path = "../reactivity" } ruin_runtime = { package = "ruin-runtime", path = "../runtime" } +ruin_app_proc_macros = { package = "ruin-app-proc-macros", path = "../ruin_app_proc_macros" } 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" + +[[example]] +name = "00_bootstrap_and_counter" +path = "example/00_bootstrap_and_counter.rs" diff --git a/lib/ruin_app/example/00_bootstrap_and_counter.rs b/lib/ruin_app/example/00_bootstrap_and_counter.rs new file mode 100644 index 0000000..4ff00dd --- /dev/null +++ b/lib/ruin_app/example/00_bootstrap_and_counter.rs @@ -0,0 +1,95 @@ +use ruin_app::prelude::*; +use ruin_ui::Border; + +#[ruin_runtime::async_main] +async fn main() -> ruin_app::Result<()> { + let title = "RUIN Counter"; + + App::new() + .window( + Window::new() + .title(title) + .app_id("dev.ruin.counter") + .size(960.0, 640.0), + ) + .mount(view! { + CounterApp(title = title) {} + }) + .run() + .await +} + +#[component] +fn CounterApp(title: &'static str) -> impl IntoView { + let count = use_signal(|| 0_i32); + let doubled = use_memo({ + let count = count.clone(); + move || count.get() * 2 + }); + + use_window_title({ + let count = count.clone(); + move || format!("{title} ({})", count.get()) + }); + + view! { + column(gap = 16.0, padding = 24.0) { + text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold) { + title + } + + CounterActions(count = count.clone()) {} + + block( + padding = 16.0, + gap = 8.0, + background = surfaces::raised(), + border_radius = 12.0, + border = Border::new(2.0, Color::rgb(0, 0, 0)) + ) { + text(size = 18.0) { "count = "; count.clone() } + text(color = colors::muted()) { "double = "; doubled.clone() } + } + } + } +} + +#[component] +fn CounterActions(count: Signal) -> impl IntoView { + view! { + row(gap = 8.0) { + button( + on_press = { + let count = count.clone(); + move |_| { + count.update(|value| *value -= 1); + } + }, + ) { + "-1" + } + + button( + on_press = { + let count = count.clone(); + move |_| { + let _ = count.set(0); + } + }, + ) { + "Reset" + } + + button( + on_press = { + let count = count.clone(); + move |_| { + count.update(|value| *value += 1); + } + }, + ) { + "+1" + } + } + } +} diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index 51ed1be..bee95fa 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -12,13 +12,15 @@ use std::rc::Rc; use ruin_reactivity::effect; use ruin_ui::{ - Color, CursorIcon, Edges, Element, ElementId, InteractionTree, LayoutSnapshot, PlatformEvent, - PointerButton, PointerEvent, PointerRouter, RoutedPointerEvent, RoutedPointerEventKind, - TextFontFamily, TextSpan, TextSpanWeight, TextStyle, TextSystem, UiSize, WindowController, - WindowSpec, WindowUpdate, layout_snapshot_with_text_system, + Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, LayoutSnapshot, + PlatformEvent, PointerButton, PointerEvent, PointerRouter, RoutedPointerEvent, + RoutedPointerEventKind, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, TextSystem, + UiSize, WindowController, WindowSpec, WindowUpdate, layout_snapshot_with_text_system, }; use ruin_ui_platform_wayland::start_wayland_ui; +pub use ruin_app_proc_macros::{component, view}; + pub type Result = std::result::Result>; #[derive(Clone, Debug)] @@ -564,6 +566,11 @@ impl ContainerBuilder { self } + pub fn border(mut self, border: Border) -> Self { + self.element = self.element.border(border.width, border.color); + self + } + pub fn width(mut self, width: f32) -> Self { self.element = self.element.width(width); self @@ -949,7 +956,8 @@ pub mod prelude { pub use crate::{ App, ButtonBuilder, Children, Component, ContainerBuilder, FontWeight, IntoEdges, IntoView, Memo, Mountable, Result, Signal, TextBuilder, TextChildren, TextRole, View, Window, block, - button, colors, column, row, surfaces, text, use_memo, use_signal, use_window_title, + button, colors, column, component, row, surfaces, text, use_memo, use_signal, + use_window_title, view, }; pub use ruin_ui::{ Color, CursorIcon, Edges, Element, ElementId, PointerButton, PointerEventKind, diff --git a/lib/ruin_app_proc_macros/Cargo.toml b/lib/ruin_app_proc_macros/Cargo.toml new file mode 100644 index 0000000..aa91925 --- /dev/null +++ b/lib/ruin_app_proc_macros/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ruin-app-proc-macros" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } diff --git a/lib/ruin_app_proc_macros/src/lib.rs b/lib/ruin_app_proc_macros/src/lib.rs new file mode 100644 index 0000000..3a2bc8c --- /dev/null +++ b/lib/ruin_app_proc_macros/src/lib.rs @@ -0,0 +1,490 @@ +use proc_macro::TokenStream; +use proc_macro2::Span; +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, +}; + +#[proc_macro_attribute] +pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream { + if !proc_macro2::TokenStream::from(attr).is_empty() { + return Error::new(Span::call_site(), "#[component] takes no arguments") + .to_compile_error() + .into(); + } + + let function = parse_macro_input!(item as ItemFn); + match expand_component(function) { + Ok(tokens) => tokens.into(), + Err(error) => error.to_compile_error().into(), + } +} + +#[proc_macro] +pub fn view(input: TokenStream) -> TokenStream { + let root = parse_macro_input!(input as ViewRoot); + expand_node(&root.root).into() +} + +fn expand_component(mut function: ItemFn) -> Result { + validate_component_function(&function)?; + + let vis = function.vis.clone(); + let component_name = function.sig.ident.clone(); + let render_name = format_ident!("__ruin_app_render_{}", component_name); + function.sig.ident = render_name.clone(); + function + .attrs + .push(syn::parse_quote!(#[allow(non_snake_case)])); + + let props = function + .sig + .inputs + .iter() + .map(parse_prop) + .collect::>>()?; + + let builder_name = format_ident!("__{}Builder", component_name); + + let builder_tokens = if props.is_empty() { + quote! { + #vis struct #builder_name; + + impl #builder_name { + #vis fn children(self, _children: ()) -> #component_name { + #component_name + } + } + } + } else { + let missing_names = props + .iter() + .map(|prop| format_ident!("__{}__{}_Missing", component_name, prop.ident)) + .collect::>(); + let present_names = props + .iter() + .map(|prop| format_ident!("__{}__{}_Present", component_name, prop.ident)) + .collect::>(); + let state_idents = (0..props.len()) + .map(|index| format_ident!("P{index}")) + .collect::>(); + let field_idents = props.iter().map(|prop| &prop.ident).collect::>(); + let marker_structs = props + .iter() + .zip(missing_names.iter()) + .zip(present_names.iter()) + .map(|((prop, missing), present)| { + let ty = &prop.ty; + quote! { + #[allow(non_camel_case_types)] + #vis struct #missing; + #[allow(non_camel_case_types)] + #vis struct #present(#ty); + } + }); + + 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 impl_generics = state_idents + .iter() + .enumerate() + .filter(|(current, _)| *current != index) + .map(|(_, ident)| ident); + let impl_generics = impl_generics.collect::>(); + + let self_builder_args = state_idents + .iter() + .enumerate() + .map(|(current, ident)| { + if current == index { + quote! { #missing_at_index } + } else { + quote! { #ident } + } + }) + .collect::>(); + + let next_builder_args = state_idents + .iter() + .enumerate() + .map(|(current, ident)| { + if current == index { + quote! { #present_at_index } + } else { + quote! { #ident } + } + }) + .collect::>(); + + let field_initializers = field_idents + .iter() + .enumerate() + .map(|(current, field_ident)| { + if current == index { + quote! { #field_ident: #present_at_index(#value_ident) } + } else { + quote! { #field_ident: self.#field_ident } + } + }) + .collect::>(); + + 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),*> { + #builder_name { + #(#field_initializers),* + } + } + } + } + } else { + quote! { + impl<#(#impl_generics),*> #builder_name<#(#self_builder_args),*> { + #vis fn #value_ident(self, #value_ident: #value_ty) -> #builder_name<#(#next_builder_args),*> { + #builder_name { + #(#field_initializers),* + } + } + } + } + } + }); + + let ready_builder_args = present_names + .iter() + .map(|present| quote! { #present }) + .collect::>(); + let missing_builder_args = missing_names + .iter() + .map(|missing| quote! { #missing }) + .collect::>(); + let field_extractors = field_idents + .iter() + .map(|field_ident| quote! { #field_ident: builder.#field_ident.0 }); + + quote! { + #(#marker_structs)* + + #vis struct #builder_name<#(#state_idents),*> { + #(#field_idents: #state_idents),* + } + + #(#builder_methods)* + + impl #builder_name<#(#ready_builder_args),*> { + #vis fn children(self, _children: ()) -> #component_name { + #component_name::from_builder(self) + } + } + + impl #component_name { + fn from_builder(builder: #builder_name<#(#ready_builder_args),*>) -> Self { + Self { + #(#field_extractors),* + } + } + } + + impl ::ruin_app::Component for #component_name { + type Builder = #builder_name<#(#missing_builder_args),*>; + + fn builder() -> Self::Builder { + #builder_name { + #(#field_idents: #missing_names),* + } + } + + fn render(&self) -> ::ruin_app::View { + ::ruin_app::IntoView::into_view(#render_name( + #(::core::clone::Clone::clone(&self.#field_idents)),* + )) + } + } + } + }; + + 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 component_tokens = if props.is_empty() { + quote! { + #vis struct #component_name; + + #builder_tokens + + impl ::ruin_app::Component for #component_name { + type Builder = #builder_name; + + fn builder() -> Self::Builder { + #builder_name + } + + 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 ),* + } + + #builder_tokens + } + }; + + Ok(quote! { + #function + + #component_tokens + }) +} + +fn validate_component_function(function: &ItemFn) -> Result<()> { + let signature = &function.sig; + + if signature.asyncness.is_some() { + return Err(Error::new_spanned( + signature.asyncness, + "components must be synchronous functions", + )); + } + if !signature.generics.params.is_empty() || signature.generics.where_clause.is_some() { + return Err(Error::new_spanned( + &signature.generics, + "generic components are not supported yet", + )); + } + if signature.constness.is_some() { + return Err(Error::new_spanned( + signature.fn_token, + "components cannot be const", + )); + } + if signature.unsafety.is_some() { + return Err(Error::new_spanned( + signature.fn_token, + "components cannot be unsafe", + )); + } + if signature.abi.is_some() { + return Err(Error::new_spanned( + &signature.abi, + "components cannot declare an ABI", + )); + } + if signature.variadic.is_some() { + return Err(Error::new_spanned( + &signature.variadic, + "components cannot be variadic", + )); + } + for input in &signature.inputs { + let FnArg::Typed(typed) = input else { + return Err(Error::new_spanned( + input, + "methods with `self` are not supported by #[component]", + )); + }; + let Pat::Ident(PatIdent { ident, .. }) = typed.pat.as_ref() else { + return Err(Error::new_spanned( + &typed.pat, + "component props must be simple identifier bindings", + )); + }; + if ident == "children" { + return Err(Error::new_spanned( + ident, + "`children` is reserved for the generated builder finalizer", + )); + } + } + + match &signature.output { + ReturnType::Default => Err(Error::new_spanned( + &signature.ident, + "components must return a view-like value", + )), + ReturnType::Type(_, _) => Ok(()), + } +} + +struct Prop { + ident: Ident, + ty: Type, +} + +fn parse_prop(input: &FnArg) -> Result { + let FnArg::Typed(typed) = input else { + return Err(Error::new_spanned( + input, + "methods with `self` are not supported by #[component]", + )); + }; + let Pat::Ident(PatIdent { ident, .. }) = typed.pat.as_ref() else { + return Err(Error::new_spanned( + &typed.pat, + "component props must be simple identifier bindings", + )); + }; + + Ok(Prop { + ident: ident.clone(), + ty: (*typed.ty).clone(), + }) +} + +struct ViewRoot { + root: Node, +} + +impl Parse for ViewRoot { + fn parse(input: ParseStream<'_>) -> Result { + let root = input.parse::()?; + if !input.is_empty() { + return Err(input.error("view! expects a single root node")); + } + Ok(Self { root }) + } +} + +struct Node { + path: Path, + props: Vec, + children: Vec, +} + +struct Property { + name: Ident, + value: Expr, +} + +enum Child { + Node(Node), + Expr(Expr), +} + +impl Parse for Node { + fn parse(input: ParseStream<'_>) -> Result { + let path = input.parse::()?; + + let props_content; + parenthesized!(props_content in input); + let props = Punctuated::::parse_terminated(&props_content)? + .into_iter() + .collect::>(); + + let children_content; + braced!(children_content in input); + let mut children = Vec::new(); + while !children_content.is_empty() { + if looks_like_node(&children_content)? { + children.push(Child::Node(children_content.parse()?)); + } else { + let expr = children_content.parse::()?; + if children_content.peek(Token![;]) { + let _ = children_content.parse::()?; + } + children.push(Child::Expr(expr)); + } + } + + Ok(Self { + path, + props, + children, + }) + } +} + +impl Parse for Property { + fn parse(input: ParseStream<'_>) -> Result { + Ok(Self { + name: input.parse()?, + value: { + let _ = input.parse::()?; + input.parse()? + }, + }) + } +} + +fn looks_like_node(input: ParseStream<'_>) -> Result { + let fork = input.fork(); + if fork.parse::().is_err() { + return Ok(false); + } + if !fork.peek(syn::token::Paren) { + return Ok(false); + } + let props_content; + parenthesized!(props_content in fork); + let _ = Punctuated::::parse_terminated(&props_content)?; + Ok(fork.peek(syn::token::Brace)) +} + +fn expand_node(node: &Node) -> proc_macro2::TokenStream { + let path = &node.path; + let prop_calls = node.props.iter().map(|property| { + let name = &property.name; + let value = &property.value; + quote! { .#name(#value) } + }); + let children = expand_children(&node.children); + + if is_component_path(path) { + quote! { + #path::builder() + #(#prop_calls)* + .children(#children) + } + } else { + quote! { + ::ruin_app::#path() + #(#prop_calls)* + .children(#children) + } + } +} + +fn expand_children(children: &[Child]) -> proc_macro2::TokenStream { + match children { + [] => quote! { () }, + [child] => expand_child(child), + many => { + let items = many.iter().map(expand_child); + quote! { ( #(#items),* ) } + } + } +} + +fn expand_child(child: &Child) -> proc_macro2::TokenStream { + match child { + Child::Node(node) => expand_node(node), + Child::Expr(expr) => quote! { #expr }, + } +} + +fn is_component_path(path: &Path) -> bool { + path.segments + .last() + .map(|segment| segment.ident.to_string()) + .and_then(|name| name.chars().next()) + .map(|ch| ch.is_ascii_uppercase()) + .unwrap_or(false) +}