Skip to content

Commit

Permalink
key prop, docs, and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
ccbrown committed Sep 23, 2024
1 parent ff1c9ec commit ffc7298
Show file tree
Hide file tree
Showing 8 changed files with 129 additions and 45 deletions.
1 change: 1 addition & 0 deletions packages/iocraft-macros/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
43 changes: 29 additions & 14 deletions packages/iocraft-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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::<f32>().unwrap();
quote!(#member: ::iocraft::Percent(#value).into())
}
Lit::Float(lit) if lit.suffix() == "pct" => {
let value = lit.base10_parse::<f32>().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::<f32>().unwrap();
quote!(#member: ::iocraft::Percent(#value).into())
}
Lit::Float(lit) if lit.suffix() == "pct" => {
let value = lit.base10_parse::<f32>().unwrap();
quote!(#member: ::iocraft::Percent(#value).into())
}
_ => quote!(#member: (#expr).into()),
},
_ => quote!(#member: (#expr).into()),
},
_ => quote!(#member: (#expr).into()),
}),
})
.collect::<Vec<_>>();

Expand All @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions packages/iocraft-macros/tests/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
4 changes: 2 additions & 2 deletions packages/iocraft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 9 additions & 11 deletions packages/iocraft/src/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Box<dyn AnyHash>>);

impl ElementKey {
/// Constructs a new, random element key.
pub fn new() -> Self {
Self(uuid::Uuid::new_v4())
/// Constructs a new key.
pub fn new<K: Debug + Hash + Eq + 'static>(key: K) -> Self {
Self(Rc::new(Box::new(key)))
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/iocraft/src/hooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
22 changes: 22 additions & 0 deletions packages/iocraft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>) -> 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::*;
Expand Down
67 changes: 49 additions & 18 deletions packages/iocraft/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand All @@ -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>,
Expand Down Expand Up @@ -120,13 +120,16 @@ 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()) {
Some(component)
if component.component().type_id()
== child.helper().component_type_id() =>
{
child_node_ids.push(component.node_id());
component
}
_ => {
Expand All @@ -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
Expand Down Expand Up @@ -375,7 +380,6 @@ impl<'a> Tree<'a> {
break;
}
}
write!(term, "\r\n")?;
Ok(())
}
}
Expand Down Expand Up @@ -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<AnyElement<'static>> {
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<AnyElement<'static>> {
let mut system = hooks.use_context_mut::<SystemContext>();
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)) }))
}
}
}

Expand All @@ -435,7 +463,10 @@ mod tests {
.await
.unwrap();
let actual = canvases.iter().map(|c| c.to_string()).collect::<Vec<_>>();
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);
}
}

0 comments on commit ffc7298

Please sign in to comment.