Proc macros for components, views
This commit is contained in:
@@ -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"
|
||||
|
||||
95
lib/ruin_app/example/00_bootstrap_and_counter.rs
Normal file
95
lib/ruin_app/example/00_bootstrap_and_counter.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
12
lib/ruin_app_proc_macros/Cargo.toml
Normal file
12
lib/ruin_app_proc_macros/Cargo.toml
Normal 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"] }
|
||||
490
lib/ruin_app_proc_macros/src/lib.rs
Normal file
490
lib/ruin_app_proc_macros/src/lib.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user