diff --git a/packages/iocraft-macros/Cargo.toml b/packages/iocraft-macros/Cargo.toml index 1ddc043..4a37ff6 100644 --- a/packages/iocraft-macros/Cargo.toml +++ b/packages/iocraft-macros/Cargo.toml @@ -11,6 +11,7 @@ proc-macro = true proc-macro2 = "1.0.86" quote = "1.0.37" syn = { version = "2.0.77", features = ["full"] } +uuid = { version = "1.10.0", features = ["v4"] } [dev-dependencies] iocraft = { path = "../iocraft" } diff --git a/packages/iocraft-macros/src/lib.rs b/packages/iocraft-macros/src/lib.rs index 256e3d1..c5e44f6 100644 --- a/packages/iocraft-macros/src/lib.rs +++ b/packages/iocraft-macros/src/lib.rs @@ -13,8 +13,9 @@ use syn::{ spanned::Spanned, token::{Brace, Comma, Paren}, DeriveInput, Error, Expr, FieldValue, FnArg, GenericParam, Ident, ItemFn, ItemStruct, Lifetime, - Lit, Pat, Result, Token, Type, TypePath, + Lit, Member, Pat, Result, Token, Type, TypePath, }; +use uuid::Uuid; enum ParsedElementChild { Element(ParsedElement), @@ -72,22 +73,36 @@ impl ToTokens for ParsedElement { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { let ty = &self.ty; + let decl_key = Uuid::new_v4().as_u128(); + + let key = self + .props + .iter() + .find_map(|FieldValue { member, expr, .. }| match member { + Member::Named(ident) if ident == "key" => Some(quote!((#decl_key, #expr))), + _ => None, + }) + .unwrap_or_else(|| quote!(#decl_key)); + let props = self .props .iter() - .map(|FieldValue { member, expr, .. }| match expr { - Expr::Lit(lit) => match &lit.lit { - Lit::Int(lit) if lit.suffix() == "pct" => { - let value = lit.base10_parse::().unwrap(); - quote!(#member: ::iocraft::Percent(#value).into()) - } - Lit::Float(lit) if lit.suffix() == "pct" => { - let value = lit.base10_parse::().unwrap(); - quote!(#member: ::iocraft::Percent(#value).into()) - } + .filter_map(|FieldValue { member, expr, .. }| match member { + Member::Named(ident) if ident == "key" => None, + _ => Some(match expr { + Expr::Lit(lit) => match &lit.lit { + Lit::Int(lit) if lit.suffix() == "pct" => { + let value = lit.base10_parse::().unwrap(); + quote!(#member: ::iocraft::Percent(#value).into()) + } + Lit::Float(lit) if lit.suffix() == "pct" => { + let value = lit.base10_parse::().unwrap(); + quote!(#member: ::iocraft::Percent(#value).into()) + } + _ => quote!(#member: (#expr).into()), + }, _ => quote!(#member: (#expr).into()), - }, - _ => quote!(#member: (#expr).into()), + }), }) .collect::>(); @@ -107,7 +122,7 @@ impl ToTokens for ParsedElement { { type Props<'a> = <#ty as ::iocraft::ElementType>::Props<'a>; let mut _iocraft_element = ::iocraft::Element::<#ty>{ - key: core::default::Default::default(), + key: ::iocraft::ElementKey::new(#key), props: Props{ #(#props,)* ..core::default::Default::default() diff --git a/packages/iocraft-macros/tests/element.rs b/packages/iocraft-macros/tests/element.rs index 67f7daa..36b3c4a 100644 --- a/packages/iocraft-macros/tests/element.rs +++ b/packages/iocraft-macros/tests/element.rs @@ -135,3 +135,13 @@ fn comment() { }; assert_eq!(e.props.children.len(), 1); } + +#[test] +fn key() { + let e = element! { + MyContainer(key: "foo") { + MyContainer + } + }; + assert_eq!(e.props.children.len(), 1); +} diff --git a/packages/iocraft/Cargo.toml b/packages/iocraft/Cargo.toml index 4b09878..131a888 100644 --- a/packages/iocraft/Cargo.toml +++ b/packages/iocraft/Cargo.toml @@ -9,12 +9,12 @@ crossterm = { version = "0.28.1", features = ["event-stream"] } futures = "0.3.30" taffy = { version = "0.5.2", default-features = false, features = ["flexbox", "taffy_tree"] } iocraft-macros = { path = "../iocraft-macros" } -uuid = { version = "1.10.0", features = ["v4"] } -derive_more = { version = "1.0.0", features = ["debug", "display"] } bitflags = "2.6.0" unicode-width = "0.1.13" textwrap = "0.16.1" generational-box = "0.5.6" +any_key = "0.1.1" +uuid = { version = "1.10.0", features = ["v4"] } [dev-dependencies] indoc = "2" diff --git a/packages/iocraft/src/element.rs b/packages/iocraft/src/element.rs index 45caae8..ff3ffd7 100644 --- a/packages/iocraft/src/element.rs +++ b/packages/iocraft/src/element.rs @@ -3,11 +3,15 @@ use crate::{ props::AnyProps, render, terminal_render_loop, Canvas, Terminal, }; +use any_key::AnyHash; use crossterm::{terminal, tty::IsTty}; use std::{ + fmt::Debug, future::Future, + hash::Hash, io::{self, stderr, stdout, Write}, os::fd::AsRawFd, + rc::Rc, }; /// Used by the `element!` macro to extend a collection with elements. @@ -54,19 +58,13 @@ where /// Used to identify an element within the scope of its parent. This is used to minimize the number /// of times components are destroyed and recreated from render-to-render. -#[derive(Clone, Hash, PartialEq, Eq, Debug, derive_more::Display)] -pub struct ElementKey(uuid::Uuid); - -impl Default for ElementKey { - fn default() -> Self { - Self::new() - } -} +#[derive(Clone, Hash, PartialEq, Eq, Debug)] +pub struct ElementKey(Rc>); impl ElementKey { - /// Constructs a new, random element key. - pub fn new() -> Self { - Self(uuid::Uuid::new_v4()) + /// Constructs a new key. + pub fn new(key: K) -> Self { + Self(Rc::new(Box::new(key))) } } diff --git a/packages/iocraft/src/hooks/mod.rs b/packages/iocraft/src/hooks/mod.rs index 52b65d9..65410cb 100644 --- a/packages/iocraft/src/hooks/mod.rs +++ b/packages/iocraft/src/hooks/mod.rs @@ -27,6 +27,13 @@ //! } //! } //! ``` +//! +//! # Rules of Hooks +//! +//! Usage of hooks is subject to the same sorts of rules as [React hooks](https://react.dev/reference/rules/rules-of-hooks). +//! +//! They must be called in the same order every time, so calling them in any sort of conditional or +//! loop is not allowed. If you break the rules of hooks, you can expect a panic. mod use_context; pub use use_context::*; diff --git a/packages/iocraft/src/lib.rs b/packages/iocraft/src/lib.rs index 7de080a..89dc4b5 100644 --- a/packages/iocraft/src/lib.rs +++ b/packages/iocraft/src/lib.rs @@ -133,6 +133,28 @@ mod flattened_exports { /// } /// } /// # } + /// ``` + /// + /// If you're rendering a dynamic UI, you will want to ensure that when adding multiple + /// elements via an iterator a unique key is specified for each one. Otherwise, the elements + /// may not correctly maintain their state across renders. This is done using the special `key` + /// property, which can be given to any element: + /// + /// ``` + /// # use iocraft::prelude::*; + /// # struct User { id: i32, name: String } + /// # fn my_element(users: Vec) -> Element<'static, Box> { + /// element! { + /// Box { + /// #(users.iter().map(|user| element! { + /// Box(key: user.id, flex_direction: FlexDirection::Column) { + /// Text(content: format!("Hello, {}!", user.name)) + /// } + /// })) + /// } + /// } + /// # } + /// ``` pub use iocraft_macros::element; pub use iocraft_macros::*; diff --git a/packages/iocraft/src/render.rs b/packages/iocraft/src/render.rs index cb43c4a..7bbe364 100644 --- a/packages/iocraft/src/render.rs +++ b/packages/iocraft/src/render.rs @@ -2,7 +2,7 @@ use crate::{ canvas::{Canvas, CanvasSubviewMut}, component::{ComponentHelperExt, Components, InstantiatedComponent}, context::{Context, ContextStack, SystemContext}, - element::ElementExt, + element::{ElementExt, ElementKey}, props::AnyProps, terminal::{Terminal, TerminalEvents}, }; @@ -12,10 +12,10 @@ use std::{ any::Any, cell::{Ref, RefMut}, collections::HashMap, - io::{self, Write}, - mem, + io, mem, }; use taffy::{AvailableSpace, Layout, NodeId, Point, Size, Style, TaffyTree}; +use uuid::Uuid; pub(crate) struct UpdateContext<'a> { terminal: Option<&'a mut Terminal>, @@ -120,6 +120,8 @@ impl<'a, 'b, 'c> ComponentUpdater<'a, 'b, 'c> { .with_context(context, |component_context_stack| { let mut used_components = HashMap::with_capacity(self.children.components.len()); + let mut child_node_ids = Vec::new(); + for mut child in children { let mut component: InstantiatedComponent = match self.children.components.remove(child.key()) { @@ -127,6 +129,7 @@ impl<'a, 'b, 'c> ComponentUpdater<'a, 'b, 'c> { if component.component().type_id() == child.helper().component_type_id() => { + child_node_ids.push(component.node_id()); component } _ => { @@ -138,23 +141,25 @@ impl<'a, 'b, 'c> ComponentUpdater<'a, 'b, 'c> { LayoutEngineNodeContext::default(), ) .expect("we should be able to add the node"); - self.context - .layout_engine - .add_child(self.node_id, new_node_id) - .expect("we should be able to add the child"); + child_node_ids.push(new_node_id); let h = child.helper(); InstantiatedComponent::new(new_node_id, child.props_mut(), h) } }; component.update(self.context, component_context_stack, child.props_mut()); - if used_components - .insert(child.key().clone(), component) - .is_some() - { - panic!("duplicate key for sibling components: {}", child.key()); + + let mut child_key = child.key().clone(); + while used_components.contains_key(&child_key) { + child_key = ElementKey::new(Uuid::new_v4().as_u128()); } + used_components.insert(child_key, component); } + self.context + .layout_engine + .set_children(self.node_id, &child_node_ids) + .expect("we should be able to set the children"); + for (_, component) in self.children.components.drain() { self.context .layout_engine @@ -375,7 +380,6 @@ impl<'a> Tree<'a> { break; } } - write!(term, "\r\n")?; Ok(()) } } @@ -411,21 +415,45 @@ mod tests { use macro_rules_attribute::apply; use smol_macros::test; + #[derive(Default, Props)] + struct MyInnerComponentProps { + label: String, + } + + #[component] + fn MyInnerComponent( + mut hooks: Hooks, + props: &MyInnerComponentProps, + ) -> impl Into> { + let mut counter = hooks.use_state(|| 0); + counter += 1; + element! { + Text(content: format!("render count ({}): {}", props.label, counter)) + } + } + #[component] fn MyComponent(mut hooks: Hooks) -> impl Into> { let mut system = hooks.use_context_mut::(); - let mut counter = hooks.use_state(|| 0); + let mut tick = hooks.use_state(|| 0); hooks.use_future(async move { - counter += 1; + tick += 1; }); - if counter == 1 { + if tick == 1 { system.exit(); } element! { - Text(content: format!("count: {}", counter)) + Box(flex_direction: FlexDirection::Column) { + Text(content: format!("tick: {}", tick)) + MyInnerComponent(label: "a") + // without a key, these next elements may not be re-used across renders + #((0..2).map(|i| element! { MyInnerComponent(label: format!("b{}", i)) })) + // with a key, these next elements will definitely be re-used across renders + #((0..2).map(|i| element! { MyInnerComponent(key: i, label: format!("c{}", i)) })) + } } } @@ -435,7 +463,10 @@ mod tests { .await .unwrap(); let actual = canvases.iter().map(|c| c.to_string()).collect::>(); - let expected = vec!["count: 0\n", "count: 1\n"]; + let expected = vec![ + "tick: 0\nrender count (a): 1\nrender count (b0): 1\nrender count (b1): 1\nrender count (c0): 1\nrender count (c1): 1\n", + "tick: 1\nrender count (a): 2\nrender count (b0): 2\nrender count (b1): 1\nrender count (c0): 2\nrender count (c1): 2\n", + ]; assert_eq!(actual, expected); } }