Proc macros for components, views

This commit is contained in:
2026-03-21 20:50:13 -04:00
parent 497dff987e
commit 437b318ad9
6 changed files with 625 additions and 5 deletions

View File

@@ -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"

View File

@@ -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<i32>) -> 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"
}
}
}
}

View File

@@ -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<T> = std::result::Result<T, Box<dyn Error>>;
#[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,

View File

@@ -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"] }

View File

@@ -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<proc_macro2::TokenStream> {
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::<Result<Vec<_>>>()?;
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::<Vec<_>>();
let present_names = props
.iter()
.map(|prop| format_ident!("__{}__{}_Present", component_name, prop.ident))
.collect::<Vec<_>>();
let state_idents = (0..props.len())
.map(|index| format_ident!("P{index}"))
.collect::<Vec<_>>();
let field_idents = props.iter().map(|prop| &prop.ident).collect::<Vec<_>>();
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::<Vec<_>>();
let self_builder_args = state_idents
.iter()
.enumerate()
.map(|(current, ident)| {
if current == index {
quote! { #missing_at_index }
} else {
quote! { #ident }
}
})
.collect::<Vec<_>>();
let next_builder_args = state_idents
.iter()
.enumerate()
.map(|(current, ident)| {
if current == index {
quote! { #present_at_index }
} else {
quote! { #ident }
}
})
.collect::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
let missing_builder_args = missing_names
.iter()
.map(|missing| quote! { #missing })
.collect::<Vec<_>>();
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<Prop> {
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<Self> {
let root = input.parse::<Node>()?;
if !input.is_empty() {
return Err(input.error("view! expects a single root node"));
}
Ok(Self { root })
}
}
struct Node {
path: Path,
props: Vec<Property>,
children: Vec<Child>,
}
struct Property {
name: Ident,
value: Expr,
}
enum Child {
Node(Node),
Expr(Expr),
}
impl Parse for Node {
fn parse(input: ParseStream<'_>) -> Result<Self> {
let path = input.parse::<Path>()?;
let props_content;
parenthesized!(props_content in input);
let props = Punctuated::<Property, Token![,]>::parse_terminated(&props_content)?
.into_iter()
.collect::<Vec<_>>();
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::<Expr>()?;
if children_content.peek(Token![;]) {
let _ = children_content.parse::<Token![;]>()?;
}
children.push(Child::Expr(expr));
}
}
Ok(Self {
path,
props,
children,
})
}
}
impl Parse for Property {
fn parse(input: ParseStream<'_>) -> Result<Self> {
Ok(Self {
name: input.parse()?,
value: {
let _ = input.parse::<Token![=]>()?;
input.parse()?
},
})
}
}
fn looks_like_node(input: ParseStream<'_>) -> Result<bool> {
let fork = input.fork();
if fork.parse::<Path>().is_err() {
return Ok(false);
}
if !fork.peek(syn::token::Paren) {
return Ok(false);
}
let props_content;
parenthesized!(props_content in fork);
let _ = Punctuated::<Property, Token![,]>::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)
}