From 8fb3943c0c165e473590462af30dd4c81ca1f0e0 Mon Sep 17 00:00:00 2001 From: Dongdong Zhou Date: Fri, 2 Jan 2026 21:27:00 +0000 Subject: [PATCH] Add Elm-style store for structured state management Introduces a new floem_store crate providing an alternative to signals for managing complex, structured state. Instead of nesting signals inside structs, state lives in a central Store and is accessed via Binding handles. --- Cargo.toml | 3 + examples/todo-store/Cargo.toml | 8 + examples/todo-store/src/main.rs | 349 ++++++ reactive/src/runtime.rs | 35 + store-derive/Cargo.toml | 14 + store-derive/src/lib.rs | 1458 +++++++++++++++++++++++ store/Cargo.toml | 13 + store/PLAN.md | 494 ++++++++ store/README.md | 532 +++++++++ store/src/binding.rs | 470 ++++++++ store/src/dyn_binding.rs | 333 ++++++ store/src/lens.rs | 269 +++++ store/src/lib.rs | 74 ++ store/src/local_state.rs | 351 ++++++ store/src/path.rs | 40 + store/src/store.rs | 195 +++ store/src/tests.rs | 1965 +++++++++++++++++++++++++++++++ store/src/traits.rs | 126 ++ 18 files changed, 6729 insertions(+) create mode 100644 examples/todo-store/Cargo.toml create mode 100644 examples/todo-store/src/main.rs create mode 100644 store-derive/Cargo.toml create mode 100644 store-derive/src/lib.rs create mode 100644 store/Cargo.toml create mode 100644 store/PLAN.md create mode 100644 store/README.md create mode 100644 store/src/binding.rs create mode 100644 store/src/dyn_binding.rs create mode 100644 store/src/lens.rs create mode 100644 store/src/lib.rs create mode 100644 store/src/local_state.rs create mode 100644 store/src/path.rs create mode 100644 store/src/store.rs create mode 100644 store/src/tests.rs create mode 100644 store/src/traits.rs diff --git a/Cargo.toml b/Cargo.toml index 0a2f05aa4..aa5e76748 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,8 @@ members = [ "vger", "tiny_skia", "reactive", + "store", + "store-derive", "editor-core", "examples/*", "ui-events-winit", @@ -18,6 +20,7 @@ default-members = [ "vger", "tiny_skia", "reactive", + "store", "editor-core", "ui-events-winit", "test", diff --git a/examples/todo-store/Cargo.toml b/examples/todo-store/Cargo.toml new file mode 100644 index 000000000..76fd609d6 --- /dev/null +++ b/examples/todo-store/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "todo-store" +version = "0.1.0" +edition = "2021" + +[dependencies] +floem = { path = "../.." } +floem_store = { path = "../../store" } diff --git a/examples/todo-store/src/main.rs b/examples/todo-store/src/main.rs new file mode 100644 index 000000000..150f89a7a --- /dev/null +++ b/examples/todo-store/src/main.rs @@ -0,0 +1,349 @@ +//! Todo app demonstrating the floem_store crate with IndexMap. +//! +//! This example shows how to use Store with IndexMap for O(1) key lookup. +//! The key benefits are: +//! - O(1) access by key (vs O(N) for Vec with by_id) +//! - State is centralized and not tied to any component scope +//! - Binding handles can be passed around freely without lifetime issues +//! - Updates automatically trigger reactive effects +//! - Insertion order is preserved for iteration +//! +//! Note: This example uses a hybrid approach where some views still use signals +//! (like text_input), demonstrating how Store can coexist with the signal system. +//! +//! This example demonstrates: +//! - `#[derive(Lenses)]` for zero-import wrapper types +//! - `IndexMap` for O(1) keyed access with preserved insertion order +//! - `dyn_view` for reactive text display +//! - `dyn_stack` for reactive list rendering +//! - `dyn_container` for view switching based on Store state +//! - `filtered_bindings()` for filtered iteration with bindings +//! - Proper integration of Store with Floem's reactive view system + +use std::sync::atomic::{AtomicU64, Ordering}; + +use floem::{prelude::*, unit::UnitExt, views::dyn_container}; +use floem_store::{IndexMap, Lenses}; + +/// Counter for generating unique todo IDs. +static NEXT_TODO_ID: AtomicU64 = AtomicU64::new(1); + +fn next_todo_id() -> u64 { + NEXT_TODO_ID.fetch_add(1, Ordering::Relaxed) +} + +/// A single todo item with a stable identity. +#[derive(Clone, Default, PartialEq, Lenses)] +struct Todo { + id: u64, + text: String, + done: bool, +} + +/// View mode for the todo list +#[derive(Clone, Copy, PartialEq, Default)] +enum ViewMode { + #[default] + All, + Active, + Completed, +} + +/// The application state. +/// +/// Using `IndexMap` instead of `Vec` gives us: +/// - O(1) lookup by key (vs O(N) for Vec with by_id) +/// - Preserved insertion order for iteration +/// - `#[nested(key = id)]` provides `push()` and `filtered_bindings()` convenience methods +#[derive(Clone, Default, PartialEq, Lenses)] +struct AppState { + #[nested(key = id)] + todos: IndexMap, + view_mode: ViewMode, +} + +fn app_view() -> impl IntoView { + // Create the store using the generated wrapper type - no imports needed! + let store = AppStateStore::new(AppState::default()); + + // Get binding handles using the generated wrapper methods + let todos = store.todos(); + let view_mode = store.view_mode(); + + // For text input, we still use a signal (demonstrating hybrid approach) + // In a future version, text_input could accept Binding directly + let new_todo_text = RwSignal::new(String::new()); + + // Header with title + let header = "Todo Store Example (IndexMap)" + .style(|s| s.font_size(24.0).margin_bottom(20.0)); + + // Input for new todos (using signal for text_input compatibility) + let input = { + let todos = todos.clone(); + + text_input(new_todo_text) + .placeholder("What needs to be done?") + .style(|s| { + s.width(300.0) + .padding(10.0) + .border(1.0) + .border_radius(5.0) + .border_color(palette::css::GRAY) + }) + .on_key_down( + Key::Named(NamedKey::Enter), + |m| m.is_empty(), + move |_| { + let text = new_todo_text.get(); + if !text.trim().is_empty() { + // Store update: push to the todos IndexMap + // `push()` extracts the id from the Todo automatically + todos.push(Todo { + id: next_todo_id(), + text: text.trim().to_string(), + done: false, + }); + new_todo_text.set(String::new()); + } + }, + ) + }; + + // Add button + let add_button = { + let todos = todos.clone(); + + "Add" + .style(|s| { + s.padding(10.0) + .margin_left(10.0) + .background(palette::css::LIGHT_BLUE) + .border_radius(5.0) + .hover(|s| s.background(palette::css::DEEP_SKY_BLUE)) + .active(|s| s.background(palette::css::DODGER_BLUE)) + }) + .on_click_stop(move |_| { + let text = new_todo_text.get(); + if !text.trim().is_empty() { + todos.push(Todo { + id: next_todo_id(), + text: text.trim().to_string(), + done: false, + }); + new_todo_text.set(String::new()); + } + }) + }; + + let input_row = (input, add_button).style(|s| s.flex_row().items_center().margin_bottom(20.0)); + + // Filter tabs - demonstrates dyn_container with Store + let filter_tabs = { + let view_mode = view_mode.clone(); + + let make_tab = |mode: ViewMode, label: &'static str| { + let view_mode = view_mode.clone(); + let view_mode_for_style = view_mode.clone(); + + label + .style(move |s| { + let is_active = view_mode_for_style.get() == mode; + s.padding(8.0) + .margin_right(5.0) + .border_radius(5.0) + .apply_if(is_active, |s| s.background(palette::css::LIGHT_BLUE)) + .apply_if(!is_active, |s| { + s.background(palette::css::LIGHT_GRAY) + .hover(|s| s.background(palette::css::SILVER)) + }) + }) + .on_click_stop(move |_| { + view_mode.set(mode); + }) + }; + + ( + make_tab(ViewMode::All, "All"), + make_tab(ViewMode::Active, "Active"), + make_tab(ViewMode::Completed, "Completed"), + ) + .style(|s| s.flex_row().margin_bottom(10.0)) + }; + + // Todo list using dyn_container - switches view based on filter mode + // This demonstrates Store integration with dyn_container for view switching + let todo_list = { + let todos = todos.clone(); + let view_mode = view_mode.clone(); + + dyn_container( + move || view_mode.get(), + move |mode| { + let todos = todos.clone(); + filtered_todo_list(todos, mode).into_any() + }, + ) + .style(|s| s.min_width(350.0)) + }; + + // Stats footer - reactive to todo changes + let stats = { + let todos = todos.clone(); + + dyn_view(move || { + let total = todos.len(); + let done = todos.with(|t| t.values().filter(|todo| todo.done).count()); + let active = total - done; + format!("{} items left, {} completed", active, done) + }) + .style(|s| s.margin_top(20.0).color(palette::css::GRAY)) + }; + + // Clear completed button + let clear_button = { + let todos = todos.clone(); + + "Clear Completed" + .style(|s| { + s.padding(8.0) + .margin_top(10.0) + .background(palette::css::LIGHT_CORAL) + .border_radius(5.0) + .hover(|s| s.background(palette::css::INDIAN_RED)) + .active(|s| s.background(palette::css::DARK_RED)) + }) + .on_click_stop(move |_| { + // Store update: filter out completed todos + todos.update(|t| t.retain(|_k, todo| !todo.done)); + }) + }; + + ( + header, + input_row, + filter_tabs, + todo_list, + stats, + clear_button, + ) + .style(|s| { + s.flex_col() + .items_center() + .padding(40.0) + .size(100.pct(), 100.pct()) + }) + .on_key_up( + Key::Named(NamedKey::F11), + |m| m.is_empty(), + move |_| floem::action::inspect(), + ) +} + +/// Render a filtered todo list based on the view mode. +/// +/// This demonstrates `filtered_bindings()` with IndexMap - O(1) access! +/// The each_fn returns an iterator of bindings, and +/// the view_fn receives `TodoBinding` so it can access fields directly. +/// +/// Using `filtered_bindings()` provides: +/// - Clean API: filter with plain `&Todo` reference, get bindings back +/// - O(1) access: each binding uses KeyLens for O(1) IndexMap lookup +/// - Full reactivity: bindings are connected to the store +fn filtered_todo_list>>( + todos: TodosIndexMapBinding, + mode: ViewMode, +) -> impl IntoView { + let todos_for_delete = todos.clone(); + + dyn_stack( + move || { + // filtered_bindings returns an iterator of TodoBinding + // We collect for dyn_stack (it needs to iterate multiple times for diffing) + todos + .filtered_bindings(|todo| match mode { + ViewMode::All => true, + ViewMode::Active => !todo.done, + ViewMode::Completed => todo.done, + }) + .collect::>() + }, + // Key function: extract id from the binding without subscribing + |binding| binding.id().get_untracked(), + // View function receives the binding directly! + move |binding| todo_item(todos_for_delete.clone(), binding), + ) + .style(|s| s.flex_col().gap(5.0)) +} + +/// Render a single todo item. +/// +/// This function receives a `TodoBinding` directly from `filtered_bindings()`. +/// No need to call `get()` - the binding is already connected to the right item! +/// TodoBinding has .done() and .text() methods - no manual bindings needed! +/// +/// We also receive the parent IndexMap binding for delete operations. +fn todo_item( + todos: TodosIndexMapBinding, + todo: TodoBinding, +) -> impl IntoView +where + L1: floem_store::Lens>, + L2: floem_store::Lens, +{ + // Access nested fields using wrapper methods - binding already points to our item! + let done = todo.done(); + let text = todo.text(); + let id = todo.id().get_untracked(); // Get id for removal + + // Checkbox that toggles the done state + let checkbox_view = { + let done_for_display = done.clone(); + let done_for_click = done.clone(); + checkbox(move || done_for_display.get()) + .on_click_stop(move |_| { + done_for_click.update(|d| *d = !*d); + }) + .style(|s| s.margin_right(10.0)) + }; + + // Label that shows the todo text with strikethrough if done + let label = { + let done = done.clone(); + dyn_view(move || text.get()).style(move |s| { + if done.get() { + s.color(palette::css::GRAY) + } else { + s + } + }) + }; + + // Delete button - uses remove_by_key on the parent binding (O(1)) + let delete_button = "X" + .style(|s| { + s.margin_left(10.0) + .padding_horiz(8.0) + .padding_vert(4.0) + .background(palette::css::LIGHT_GRAY) + .border_radius(3.0) + .hover(|s| s.background(palette::css::RED).color(palette::css::WHITE)) + }) + .on_click_stop(move |_| { + // Remove by id using the parent IndexMap binding (O(1)) + todos.remove_by_key(&id); + }); + + (checkbox_view, label, delete_button).style(|s| { + s.flex_row() + .items_center() + .padding(10.0) + .background(palette::css::WHITE_SMOKE) + .border_radius(5.0) + .width(350.0) + }) +} + +fn main() { + floem::launch(app_view); +} diff --git a/reactive/src/runtime.rs b/reactive/src/runtime.rs index 9315ff9f0..f264d6b0c 100644 --- a/reactive/src/runtime.rs +++ b/reactive/src/runtime.rs @@ -216,4 +216,39 @@ impl Runtime { pub fn set_sync_effect_waker(waker: impl Fn() + Send + Sync + 'static) { SYNC_RUNTIME.set_waker(waker); } + + /// Get the ID of the currently running effect, if any. + /// + /// This is useful for external reactive primitives that need to + /// integrate with the effect subscription system. + pub fn current_effect_id() -> Option { + RUNTIME.with(|runtime| { + runtime + .current_effect + .borrow() + .as_ref() + .map(|effect| effect.id()) + }) + } + + /// Schedule an effect to be re-run by its ID. + /// + /// This is useful for external reactive primitives that need to + /// trigger effect updates without going through the signal system. + pub fn update_from_id(effect_id: Id) { + RUNTIME.with(|runtime| { + runtime.add_pending_effect(effect_id); + if !runtime.batching.get() { + runtime.run_pending_effects(); + } + }); + } + + /// Check if an effect with the given ID still exists. + /// + /// This is useful for external reactive primitives to clean up + /// stale subscriber references when effects are disposed. + pub fn effect_exists(effect_id: Id) -> bool { + RUNTIME.with(|runtime| runtime.effects.borrow().contains_key(&effect_id)) + } } diff --git a/store-derive/Cargo.toml b/store-derive/Cargo.toml new file mode 100644 index 000000000..6adc7233b --- /dev/null +++ b/store-derive/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "floem_store_derive" +version = "0.2.0" +edition = "2021" +license = "MIT" +description = "Derive macros for floem_store" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full", "parsing"] } diff --git a/store-derive/src/lib.rs b/store-derive/src/lib.rs new file mode 100644 index 000000000..6b00fae62 --- /dev/null +++ b/store-derive/src/lib.rs @@ -0,0 +1,1458 @@ +//! Derive macros for floem_store. +//! +//! This crate provides the `#[derive(Lenses)]` macro that automatically +//! generates lens types, accessor methods, and wrapper types for struct fields. +//! +//! # Example +//! +//! ```rust,ignore +//! use floem_store::{Store, Lenses}; +//! use std::collections::HashMap; +//! +//! #[derive(Lenses, Default)] +//! struct State { +//! count: i32, +//! #[nested] // Mark fields that also have #[derive(Lenses)] +//! user: User, +//! #[nested] // Also works with Vec where T has #[derive(Lenses)] +//! items: Vec, +//! #[nested] // Also works with HashMap where V has #[derive(Lenses)] +//! users_by_id: HashMap, +//! } +//! +//! #[derive(Lenses, Default)] +//! struct User { +//! name: String, +//! age: i32, +//! } +//! +//! #[derive(Lenses, Default, Clone)] +//! struct Item { +//! text: String, +//! done: bool, +//! } +//! +//! // Use the generated wrapper type - NO IMPORTS NEEDED even for nested access! +//! let store = StateStore::new(State::default()); +//! let count = store.count(); +//! let name = store.user().name(); // Works without imports! +//! let first_item_text = store.items().index(0).text(); // Vec nested access! +//! let user_1_name = store.users_by_id().key(1).name(); // HashMap nested access! +//! +//! count.set(42); +//! name.set("Alice".into()); +//! ``` +//! +//! The `#[nested]` attribute tells the macro that a field's type also has +//! `#[derive(Lenses)]`, so it returns the wrapper type instead of raw `Binding`. +//! This works at multiple levels of nesting and with `Vec` and `HashMap` fields. + +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use syn::{parse_macro_input, Data, DeriveInput, Fields, GenericArgument, PathArguments, Type}; + +/// Information about a nested type +enum NestedKind { + /// Direct nested type (e.g., `user: User`) + Direct(syn::Ident), + /// Vec of nested type (e.g., `items: Vec`) + /// Contains the inner type ident, optional key field name, and optional key type for identity-based access + Vec { + inner_ident: syn::Ident, + key_field: Option, + key_type: Option, + }, + /// HashMap of nested type (e.g., `users: HashMap`) + /// Contains the value type's ident (the key type is handled separately) + HashMap(syn::Ident), + /// IndexMap of nested type (e.g., `todos: IndexMap`) + /// Contains the value type's ident and optional key field name for push() convenience + IndexMap { + val_ident: syn::Ident, + key_field: Option, + }, + /// Not nested + None, +} + +/// Parse #[nested] or #[nested(key = field_name)] or #[nested(key = field_name: KeyType)] attribute +fn parse_nested_attr(attr: &syn::Attribute) -> Option> { + if !attr.path().is_ident("nested") { + return None; + } + + // Try to parse as #[nested(key = field_name)] or #[nested(key = field_name: KeyType)] + match &attr.meta { + syn::Meta::Path(_) => { + // Just #[nested] without arguments + Some(None) + } + syn::Meta::List(list) => { + // #[nested(key = field_name)] or #[nested(key = field_name: KeyType)] + let tokens = list.tokens.clone(); + let parsed: Result = syn::parse2(tokens); + match parsed { + Ok(key_attr) => Some(Some(key_attr)), + Err(_) => Some(None), // Fallback to no key + } + } + _ => Some(None), + } +} + +/// Helper struct for parsing `key = field_name` or `key = field_name: KeyType` +struct KeyAttr { + field: syn::Ident, + key_type: Option, +} + +impl syn::parse::Parse for KeyAttr { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let key_ident: syn::Ident = input.parse()?; + if key_ident != "key" { + return Err(syn::Error::new(key_ident.span(), "expected `key`")); + } + let _eq: syn::Token![=] = input.parse()?; + let field: syn::Ident = input.parse()?; + + // Optionally parse `: KeyType` + let key_type = if input.peek(syn::Token![:]) { + let _colon: syn::Token![:] = input.parse()?; + Some(input.parse()?) + } else { + None + }; + + Ok(KeyAttr { field, key_type }) + } +} + +/// Extract the inner type from Vec if the type is a Vec +fn extract_vec_inner_type(ty: &Type) -> Option<&Type> { + if let Type::Path(type_path) = ty { + let segment = type_path.path.segments.last()?; + if segment.ident == "Vec" { + if let PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(GenericArgument::Type(inner_ty)) = args.args.first() { + return Some(inner_ty); + } + } + } + } + None +} + +/// Get the type name as an identifier (for simple types) +fn type_to_ident(ty: &Type) -> Option { + if let Type::Path(type_path) = ty { + let segment = type_path.path.segments.last()?; + Some(segment.ident.clone()) + } else { + None + } +} + +/// Extract the key and value types from HashMap if the type is a HashMap +fn extract_hashmap_types(ty: &Type) -> Option<(&Type, &Type)> { + if let Type::Path(type_path) = ty { + let segment = type_path.path.segments.last()?; + if segment.ident == "HashMap" { + if let PathArguments::AngleBracketed(args) = &segment.arguments { + let mut iter = args.args.iter(); + if let (Some(GenericArgument::Type(key_ty)), Some(GenericArgument::Type(val_ty))) = + (iter.next(), iter.next()) + { + return Some((key_ty, val_ty)); + } + } + } + } + None +} + +/// Extract the key and value types from IndexMap +fn extract_indexmap_types(ty: &Type) -> Option<(&Type, &Type)> { + if let Type::Path(type_path) = ty { + let segment = type_path.path.segments.last()?; + if segment.ident == "IndexMap" { + if let PathArguments::AngleBracketed(args) = &segment.arguments { + let mut iter = args.args.iter(); + if let (Some(GenericArgument::Type(key_ty)), Some(GenericArgument::Type(val_ty))) = + (iter.next(), iter.next()) + { + return Some((key_ty, val_ty)); + } + } + } + } + None +} + +/// Derive macro that generates lens types and wrapper types for struct fields. +/// +/// For a struct `State` with fields `count` and `user`, this generates: +/// - A module `state_lenses` containing lens types `CountLens` and `UserLens` +/// - A wrapper type `StateStore` with direct method access +/// - A wrapper type `StateBinding` for binding wrappers +/// +/// # Example +/// +/// ```rust,ignore +/// use floem_store::{Store, Lenses}; +/// +/// #[derive(Lenses, Default)] +/// struct AppState { +/// count: i32, +/// name: String, +/// } +/// +/// // Use the wrapper type - NO IMPORTS NEEDED! +/// let store = AppStateStore::new(AppState::default()); +/// let count = store.count(); +/// let name = store.name(); +/// +/// count.set(42); +/// name.set("Hello".into()); +/// ``` +#[proc_macro_derive(Lenses, attributes(nested))] +pub fn derive_lenses(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let struct_name = &input.ident; + + // Create module name: UserProfile -> user_profile_lenses + let module_name = format_ident!("{}_lenses", to_snake_case(&struct_name.to_string())); + + // Get the fields + let fields = match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) => &fields.named, + _ => { + return syn::Error::new_spanned( + &input, + "Lenses can only be derived for structs with named fields", + ) + .to_compile_error() + .into(); + } + }, + _ => { + return syn::Error::new_spanned(&input, "Lenses can only be derived for structs") + .to_compile_error() + .into(); + } + }; + + // Collect field info, including whether they have #[nested] attribute + let field_info: Vec<_> = fields + .iter() + .filter_map(|field| { + let field_name = field.ident.as_ref()?; + let field_type = &field.ty; + // Append "Lens" to avoid collision when field name matches type name + // e.g., field `user: User` creates lens `UserLens`, not `User` + let lens_struct_name = + format_ident!("{}Lens", to_pascal_case(&field_name.to_string())); + + // Parse #[nested] or #[nested(key = field_name)] attribute + let nested_attr = field + .attrs + .iter() + .find_map(parse_nested_attr); + + // Determine the nested kind + let nested_kind = if let Some(key_attr) = nested_attr { + // Check if it's Vec + if let Some(inner_ty) = extract_vec_inner_type(field_type) { + if let Some(inner_ident) = type_to_ident(inner_ty) { + NestedKind::Vec { + inner_ident, + key_field: key_attr.as_ref().map(|k| k.field.clone()), + key_type: key_attr.and_then(|k| k.key_type), + } + } else { + NestedKind::None + } + // Check if it's IndexMap + } else if let Some((_key_ty, val_ty)) = extract_indexmap_types(field_type) { + if let Some(val_ident) = type_to_ident(val_ty) { + NestedKind::IndexMap { + val_ident, + key_field: key_attr.map(|k| k.field), + } + } else { + NestedKind::None + } + // Check if it's HashMap + } else if let Some((_key_ty, val_ty)) = extract_hashmap_types(field_type) { + if let Some(val_ident) = type_to_ident(val_ty) { + NestedKind::HashMap(val_ident) + } else { + NestedKind::None + } + } else if let Some(ident) = type_to_ident(field_type) { + NestedKind::Direct(ident) + } else { + NestedKind::None + } + } else { + NestedKind::None + }; + + Some(( + field_name.clone(), + field_type.clone(), + lens_struct_name, + nested_kind, + )) + }) + .collect(); + + // Generate lens structs and field type aliases + let lens_impls: Vec<_> = field_info + .iter() + .map(|(field_name, field_type, lens_struct_name, _nested_kind)| { + let field_name_str = field_name.to_string(); + let lens_doc = format!("Lens for the `{}` field of [`{}`].", field_name, struct_name); + // Generate a type alias for this field's type, so it can be referenced + // by other structs (e.g., for by_key access without explicit type) + let field_type_alias = format_ident!( + "{}{}FieldType", + struct_name, + to_pascal_case(&field_name.to_string()) + ); + let type_alias_doc = format!( + "Type alias for the `{}` field of [`{}`].\n\n\ + This is used internally for identity-based Vec access.", + field_name, struct_name + ); + quote! { + #[doc = #type_alias_doc] + pub type #field_type_alias = #field_type; + + #[doc = #lens_doc] + #[derive(Copy, Clone, Debug, Default)] + pub struct #lens_struct_name; + + impl floem_store::Lens<#struct_name, #field_type> for #lens_struct_name { + const PATH_HASH: u64 = floem_store::lens::const_hash(#field_name_str); + + fn get<'a>(&self, source: &'a #struct_name) -> &'a #field_type { + &source.#field_name + } + + fn get_mut<'a>(&self, source: &'a mut #struct_name) -> &'a mut #field_type { + &mut source.#field_name + } + } + } + }) + .collect(); + + // Generate wrapper store struct name: State -> StateStore + let store_wrapper_name = format_ident!("{}Store", struct_name); + + // Generate wrapper binding struct name: State -> StateBinding + let binding_wrapper_name = format_ident!("{}Binding", struct_name); + + // Collect Vec wrapper types we need to generate + let vec_wrapper_types: Vec<_> = field_info + .iter() + .filter_map(|(field_name, field_type, _lens_struct_name, nested_kind)| { + if let NestedKind::Vec { inner_ident, key_field, key_type } = nested_kind { + let wrapper_name = + format_ident!("{}VecBinding", to_pascal_case(&field_name.to_string())); + let inner_binding_wrapper = format_ident!("{}Binding", inner_ident); + + // Extract the inner type from Vec + let inner_type = extract_vec_inner_type(field_type)?; + + Some((wrapper_name, inner_binding_wrapper, inner_type.clone(), key_field.clone(), key_type.clone())) + } else { + None + } + }) + .collect(); + + // Generate Vec wrapper structs + let vec_wrapper_impls: Vec<_> = vec_wrapper_types + .iter() + .map(|(wrapper_name, inner_binding_wrapper, inner_type, key_field, explicit_key_type)| { + // Generate by_key method and lens when key_field is provided. + // The key type is inferred from the inner type's field type alias if not explicitly provided. + // + // Usage: + // - `#[nested]` - no keyed reconciliation, no by_id method + // - `#[nested(key = id)]` - keyed reconciliation AND by_id method (type inferred from inner type) + // - `#[nested(key = id: u64)]` - keyed reconciliation AND by_id method (explicit type) + let by_key_impl = if let Some(key_field) = key_field { + // Determine the key type: use explicit type if provided, otherwise use the generated type alias + let key_type: syn::Type = if let Some(explicit) = explicit_key_type { + explicit.clone() + } else { + // Use the generated type alias: {inner_type_lenses}::{InnerType}{KeyFieldPascalCase}FieldType + let inner_type_ident = type_to_ident(inner_type); + if let Some(inner_ident) = inner_type_ident { + // The type alias is in the lens module: {inner_type_snake}_lenses::{InnerType}{Field}FieldType + let inner_module_name = format!("{}_lenses", to_snake_case(&inner_ident.to_string())); + let type_alias_name = format!( + "{}{}FieldType", + inner_ident, + to_pascal_case(&key_field.to_string()) + ); + let full_path = format!("{}::{}", inner_module_name, type_alias_name); + syn::parse_str(&full_path).unwrap_or_else(|_| { + // Fallback: if parsing fails, skip by_key generation + return syn::parse_str("()").unwrap(); + }) + } else { + // Can't determine inner type, skip by_key + return quote! {}; + } + }; + let by_key_method = format_ident!("by_{}", key_field); + let key_lens_name = format_ident!("{}By{}Lens", wrapper_name, to_pascal_case(&key_field.to_string())); + let key_field_str = key_field.to_string(); + + quote! { + /// Lens for accessing a Vec element by its key field (identity-based access). + /// + /// This lens finds the item with the matching key value, regardless of its position. + /// The PathId is based on the key value, not the position, enabling stable bindings + /// across reorders. + /// + /// Uses lazy caching: stores a position hint that's checked first (O(1)), + /// falling back to O(N) search only if the item has moved. + #[derive(Clone, Copy)] + pub struct #key_lens_name { + key: #key_type, + /// Cached position hint - checked first for O(1) access. + /// If the item at this position doesn't have the right key, we fall back to O(N). + cached_pos: usize, + } + + impl floem_store::Lens, #inner_type> for #key_lens_name { + const PATH_HASH: u64 = floem_store::lens::const_hash(concat!("[by_", #key_field_str, "]")); + + fn get<'a>(&self, source: &'a Vec<#inner_type>) -> &'a #inner_type { + // Try cached position first (O(1)) + if let Some(item) = source.get(self.cached_pos) { + if item.#key_field == self.key { + return item; + } + } + // Fall back to O(N) search if item moved + source.iter() + .find(|item| item.#key_field == self.key) + .expect(concat!("item with ", #key_field_str, " not found in Vec")) + } + + fn get_mut<'a>(&self, source: &'a mut Vec<#inner_type>) -> &'a mut #inner_type { + // Try cached position first (O(1)) + // Need to check without borrowing mutably first + let use_cached = source.get(self.cached_pos) + .map(|item| item.#key_field == self.key) + .unwrap_or(false); + + if use_cached { + return &mut source[self.cached_pos]; + } + + // Fall back to O(N) search if item moved + source.iter_mut() + .find(|item| item.#key_field == self.key) + .expect(concat!("item with ", #key_field_str, " not found in Vec")) + } + + /// Each key gets a unique path hash by mixing the key into the base hash. + /// This enables identity-based fine-grained reactivity. + fn path_hash(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.key.hash(&mut hasher); + let key_hash = hasher.finish(); + + let mut hash = floem_store::lens::const_hash(concat!("[by_", #key_field_str, "]")); + hash ^= key_hash; + hash = hash.wrapping_mul(0x100000001b3u64); // FNV_PRIME + hash + } + } + + impl<__Root: 'static, __L: floem_store::Lens<__Root, Vec<#inner_type>>> #wrapper_name<__Root, __L> { + /// Get a wrapped binding for the element with the given key (identity-based access). + /// + /// Unlike `index()` which accesses by position, this method accesses by the item's + /// identity (key field). The binding's PathId is based on the key value, so it + /// remains stable even if the item's position changes. + /// + /// Uses lazy caching for O(1) best-case access: the current position is cached + /// when the binding is created. If the item hasn't moved, access is O(1). + /// If the item has moved, it falls back to O(N) search. + /// + /// # Panics + /// + /// Panics if no item with the given key exists in the Vec. + /// + /// # Example + /// + /// ```rust,ignore + #[doc = concat!(" let item = vec_binding.", stringify!(#by_key_method), "(5);")] + /// // This binding stays on the item with key=5 even if the Vec is reordered + /// ``` + pub fn #by_key_method(&self, key: #key_type) -> #inner_binding_wrapper< + __Root, + floem_store::ComposedLens<__L, #key_lens_name, Vec<#inner_type>> + > + where + #key_type: std::hash::Hash + Eq + Copy + 'static, + { + // Find current position and cache it as a hint + let cached_pos = self.inner.with_untracked(|v| { + v.iter() + .position(|item| item.#key_field == key) + .expect(concat!("item with ", #key_field_str, " not found in Vec")) + }); + + #inner_binding_wrapper::from_binding( + self.inner.binding_with_lens(#key_lens_name { key, cached_pos }) + ) + } + + /// Check if an item with the given key exists in the Vec. + pub fn contains_key(&self, key: &#key_type) -> bool + where + #key_type: PartialEq, + { + self.inner.with(|v| v.iter().any(|item| &item.#key_field == key)) + } + + /// Remove an item by its key. Returns the removed item if found. + pub fn remove_by_key(&self, key: &#key_type) -> Option<#inner_type> + where + #key_type: PartialEq, + #inner_type: Clone, + { + self.inner.try_update(|v| { + if let Some(idx) = v.iter().position(|item| &item.#key_field == key) { + Some(v.remove(idx)) + } else { + None + } + }) + } + + /// Get bindings for all items that match a filter predicate. + /// + /// This is useful for `dyn_stack` where you want to return bindings directly: + /// + /// ```rust,ignore + /// dyn_stack( + /// move || todos.filtered_bindings(|todo| !todo.done), + /// |binding| binding.id().get_untracked(), + /// move |binding| todo_item_view(binding), + /// ) + /// ``` + /// + /// The filter closure receives `&T` (plain reference) so it doesn't create + /// reactive subscriptions. Each returned binding uses identity-based access + /// via `by_key`, so bindings remain stable across reorders. + /// + /// Uses cached positions for O(1) access on each binding. + pub fn filtered_bindings<__F>( + &self, + filter: __F, + ) -> impl Iterator> + >> + 'static + where + __F: Fn(&#inner_type) -> bool, + #key_type: std::hash::Hash + Eq + Copy + 'static, + { + // Collect (key, position) pairs during iteration - O(N) total + // Each binding gets its position cached for O(1) subsequent access + let inner = self.inner.clone(); + self.inner.with(|v| { + v.iter() + .enumerate() + .filter(|(_, item)| filter(item)) + .map(|(cached_pos, item)| { + let key = item.#key_field; + #inner_binding_wrapper::from_binding( + inner.binding_with_lens(#key_lens_name { key, cached_pos }) + ) + }) + .collect::>() + }).into_iter() + } + + /// Get bindings for all items in the Vec. + /// + /// Returns an iterator of bindings, one for each item, using identity-based access. + /// This is equivalent to `filtered_bindings(|_| true)`. + /// Uses cached positions for O(1) access on each binding. + pub fn all_bindings(&self) -> impl Iterator> + >> + 'static + where + #key_type: std::hash::Hash + Eq + Copy + 'static, + { + self.filtered_bindings(|_| true) + } + } + } + } else { + quote! {} + }; + + quote! { + /// Wrapper around `Binding, L>` that returns wrapped element types. + pub struct #wrapper_name<__Root: 'static, __L: floem_store::Lens<__Root, Vec<#inner_type>>> { + inner: floem_store::Binding<__Root, Vec<#inner_type>, __L>, + } + + impl<__Root: 'static, __L: floem_store::Lens<__Root, Vec<#inner_type>>> #wrapper_name<__Root, __L> { + /// Create a wrapper from a raw Binding. + pub fn from_binding(binding: floem_store::Binding<__Root, Vec<#inner_type>, __L>) -> Self { + Self { inner: binding } + } + + /// Get the underlying Binding. + pub fn inner(&self) -> &floem_store::Binding<__Root, Vec<#inner_type>, __L> { + &self.inner + } + + /// Consume wrapper and return the underlying Binding. + pub fn into_inner(self) -> floem_store::Binding<__Root, Vec<#inner_type>, __L> { + self.inner + } + + /// Get a wrapped binding for the element at the given index (position-based access). + pub fn index(&self, index: usize) -> #inner_binding_wrapper< + __Root, + floem_store::ComposedLens<__L, floem_store::lens::IndexLens, Vec<#inner_type>> + > { + #inner_binding_wrapper::from_binding(self.inner.index(index)) + } + + /// Get the length of the Vec. + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Check if the Vec is empty. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Push an element to the Vec. + pub fn push(&self, value: #inner_type) { + self.inner.push(value); + } + + /// Pop an element from the Vec. + pub fn pop(&self) -> Option<#inner_type> + where + #inner_type: Clone, + { + self.inner.pop() + } + + /// Clear the Vec. + pub fn clear(&self) { + self.inner.clear(); + } + + /// Update the Vec with a closure. + pub fn update(&self, f: impl FnOnce(&mut Vec<#inner_type>)) { + self.inner.update(f); + } + + /// Read the Vec by reference. + pub fn with(&self, f: impl FnOnce(&Vec<#inner_type>) -> R) -> R { + self.inner.with(f) + } + } + + impl<__Root: 'static, __L: floem_store::Lens<__Root, Vec<#inner_type>>> Clone for #wrapper_name<__Root, __L> { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } + } + + #by_key_impl + } + }) + .collect(); + + // Collect HashMap wrapper types we need to generate + let hashmap_wrapper_types: Vec<_> = field_info + .iter() + .filter_map(|(field_name, field_type, _lens_struct_name, nested_kind)| { + if let NestedKind::HashMap(val_ident) = nested_kind { + let wrapper_name = + format_ident!("{}HashMapBinding", to_pascal_case(&field_name.to_string())); + let val_binding_wrapper = format_ident!("{}Binding", val_ident); + + // Extract the key and value types from HashMap + let (key_type, val_type) = extract_hashmap_types(field_type)?; + + Some((wrapper_name, val_binding_wrapper, key_type.clone(), val_type.clone())) + } else { + None + } + }) + .collect(); + + // Generate HashMap wrapper structs + let hashmap_wrapper_impls: Vec<_> = hashmap_wrapper_types + .iter() + .map(|(wrapper_name, val_binding_wrapper, key_type, val_type)| { + quote! { + /// Wrapper around `Binding, L>` that returns wrapped value types. + pub struct #wrapper_name<__Root: 'static, __L: floem_store::Lens<__Root, std::collections::HashMap<#key_type, #val_type>>> { + inner: floem_store::Binding<__Root, std::collections::HashMap<#key_type, #val_type>, __L>, + } + + impl<__Root: 'static, __L: floem_store::Lens<__Root, std::collections::HashMap<#key_type, #val_type>>> #wrapper_name<__Root, __L> { + /// Create a wrapper from a raw Binding. + pub fn from_binding(binding: floem_store::Binding<__Root, std::collections::HashMap<#key_type, #val_type>, __L>) -> Self { + Self { inner: binding } + } + + /// Get the underlying Binding. + pub fn inner(&self) -> &floem_store::Binding<__Root, std::collections::HashMap<#key_type, #val_type>, __L> { + &self.inner + } + + /// Consume wrapper and return the underlying Binding. + pub fn into_inner(self) -> floem_store::Binding<__Root, std::collections::HashMap<#key_type, #val_type>, __L> { + self.inner + } + + /// Get a wrapped binding for the value at the given key. + pub fn key(&self, key: #key_type) -> #val_binding_wrapper< + __Root, + floem_store::ComposedLens<__L, floem_store::KeyLens<#key_type>, std::collections::HashMap<#key_type, #val_type>> + > + where + #key_type: Copy, + { + #val_binding_wrapper::from_binding(self.inner.key(key)) + } + + /// Get the number of entries in the HashMap. + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Check if the HashMap is empty. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Check if the HashMap contains the given key. + pub fn contains_key(&self, key: &#key_type) -> bool { + self.inner.contains_key(key) + } + + /// Insert a key-value pair into the HashMap. + pub fn insert(&self, key: #key_type, value: #val_type) -> Option<#val_type> { + self.inner.insert(key, value) + } + + /// Remove a key from the HashMap. + pub fn remove(&self, key: &#key_type) -> Option<#val_type> { + self.inner.remove(key) + } + + /// Clear the HashMap. + pub fn clear(&self) { + self.inner.clear(); + } + + /// Get a cloned value for the given key, if present. + pub fn get_value(&self, key: &#key_type) -> Option<#val_type> + where + #val_type: Clone, + { + self.inner.get_value(key) + } + + /// Update the HashMap with a closure. + pub fn update(&self, f: impl FnOnce(&mut std::collections::HashMap<#key_type, #val_type>)) { + self.inner.update(f); + } + + /// Read the HashMap by reference. + pub fn with(&self, f: impl FnOnce(&std::collections::HashMap<#key_type, #val_type>) -> R) -> R { + self.inner.with(f) + } + } + + impl<__Root: 'static, __L: floem_store::Lens<__Root, std::collections::HashMap<#key_type, #val_type>>> Clone for #wrapper_name<__Root, __L> { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } + } + } + }) + .collect(); + + // Collect IndexMap wrapper types we need to generate + let indexmap_wrapper_types: Vec<_> = field_info + .iter() + .filter_map(|(field_name, field_type, _lens_struct_name, nested_kind)| { + if let NestedKind::IndexMap { val_ident, key_field } = nested_kind { + let wrapper_name = + format_ident!("{}IndexMapBinding", to_pascal_case(&field_name.to_string())); + let val_binding_wrapper = format_ident!("{}Binding", val_ident); + + // Extract the key and value types from IndexMap + let (key_type, val_type) = extract_indexmap_types(field_type)?; + + Some((wrapper_name, val_binding_wrapper, key_type.clone(), val_type.clone(), key_field.clone())) + } else { + None + } + }) + .collect(); + + // Generate IndexMap wrapper structs + let indexmap_wrapper_impls: Vec<_> = indexmap_wrapper_types + .iter() + .map(|(wrapper_name, val_binding_wrapper, key_type, val_type, key_field)| { + // Generate push method if key_field is provided (extracts key from value) + let push_impl = if let Some(key_field) = key_field { + quote! { + /// Push an item to the IndexMap, extracting the key from the value. + /// + /// This is a convenience method that extracts the key from the value's + /// field and inserts it into the map. + pub fn push(&self, value: #val_type) + where + #key_type: Copy, + { + let key = value.#key_field; + self.inner.insert(key, value); + } + + /// Remove an item by its key field value. + pub fn remove_by_key(&self, key: &#key_type) -> Option<#val_type> + where + #key_type: std::hash::Hash + Eq, + { + self.inner.remove(key) + } + + /// Get bindings for all items that match a filter predicate. + /// + /// This is useful for `dyn_stack` where you want to return bindings directly. + /// The filter closure receives `&V` (plain reference) so it doesn't create + /// reactive subscriptions. + pub fn filtered_bindings<__F>( + &self, + filter: __F, + ) -> impl Iterator, floem_store::IndexMap<#key_type, #val_type>> + >> + 'static + where + __F: Fn(&#val_type) -> bool, + #key_type: std::hash::Hash + Eq + Copy + 'static, + { + let this = self.clone(); + self.inner.with(|m| { + m.iter() + .filter(|(_, v)| filter(v)) + .map(|(k, _)| this.get(*k)) + .collect::>() + }).into_iter() + } + + /// Get bindings for all items in the IndexMap (in insertion order). + /// + /// Returns an iterator of bindings, one for each value. + pub fn all_bindings(&self) -> impl Iterator, floem_store::IndexMap<#key_type, #val_type>> + >> + 'static + where + #key_type: std::hash::Hash + Eq + Copy + 'static, + { + self.filtered_bindings(|_| true) + } + } + } else { + quote! {} + }; + + quote! { + /// Wrapper around `Binding, L>` that returns wrapped value types. + /// + /// IndexMap provides O(1) key lookup while preserving insertion order. + pub struct #wrapper_name<__Root: 'static, __L: floem_store::Lens<__Root, floem_store::IndexMap<#key_type, #val_type>>> { + inner: floem_store::Binding<__Root, floem_store::IndexMap<#key_type, #val_type>, __L>, + } + + impl<__Root: 'static, __L: floem_store::Lens<__Root, floem_store::IndexMap<#key_type, #val_type>>> #wrapper_name<__Root, __L> { + /// Create a wrapper from a raw Binding. + pub fn from_binding(binding: floem_store::Binding<__Root, floem_store::IndexMap<#key_type, #val_type>, __L>) -> Self { + Self { inner: binding } + } + + /// Get the underlying Binding. + pub fn inner(&self) -> &floem_store::Binding<__Root, floem_store::IndexMap<#key_type, #val_type>, __L> { + &self.inner + } + + /// Consume wrapper and return the underlying Binding. + pub fn into_inner(self) -> floem_store::Binding<__Root, floem_store::IndexMap<#key_type, #val_type>, __L> { + self.inner + } + + /// Get a wrapped binding for the value at the given key (O(1) lookup). + pub fn get(&self, key: #key_type) -> #val_binding_wrapper< + __Root, + floem_store::ComposedLens<__L, floem_store::KeyLens<#key_type>, floem_store::IndexMap<#key_type, #val_type>> + > + where + #key_type: Copy, + { + #val_binding_wrapper::from_binding(self.inner.key(key)) + } + + /// Get the number of entries in the IndexMap. + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Check if the IndexMap is empty. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Check if the IndexMap contains the given key (O(1) lookup). + pub fn contains_key(&self, key: &#key_type) -> bool { + self.inner.contains_key(key) + } + + /// Insert a key-value pair into the IndexMap. + /// + /// If the key already exists, the value is updated but position is preserved. + pub fn insert(&self, key: #key_type, value: #val_type) -> Option<#val_type> { + self.inner.insert(key, value) + } + + /// Remove a key from the IndexMap (preserves order of remaining elements). + pub fn remove(&self, key: &#key_type) -> Option<#val_type> { + self.inner.remove(key) + } + + /// Clear the IndexMap. + pub fn clear(&self) { + self.inner.clear(); + } + + /// Get a cloned value for the given key, if present (O(1) lookup). + pub fn get_value(&self, key: &#key_type) -> Option<#val_type> + where + #val_type: Clone, + { + self.inner.get_value(key) + } + + /// Update the IndexMap with a closure. + pub fn update(&self, f: impl FnOnce(&mut floem_store::IndexMap<#key_type, #val_type>)) { + self.inner.update(f); + } + + /// Read the IndexMap by reference. + pub fn with(&self, f: impl FnOnce(&floem_store::IndexMap<#key_type, #val_type>) -> R) -> R { + self.inner.with(f) + } + + #push_impl + } + + impl<__Root: 'static, __L: floem_store::Lens<__Root, floem_store::IndexMap<#key_type, #val_type>>> Clone for #wrapper_name<__Root, __L> { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } + } + } + }) + .collect(); + + // Generate store wrapper methods + // For #[nested] fields, return the wrapper type; otherwise return raw Binding + let store_wrapper_methods: Vec<_> = field_info + .iter() + .map(|(field_name, field_type, lens_struct_name, nested_kind)| { + let field_doc = format!("Get a binding for the `{}` field.", field_name); + match nested_kind { + NestedKind::Direct(type_ident) => { + // For direct nested fields, return the wrapper type (e.g., UserBinding) + let nested_binding_wrapper = format_ident!("{}Binding", type_ident); + quote! { + #[doc = #field_doc] + pub fn #field_name(&self) -> #nested_binding_wrapper<#struct_name, #module_name::#lens_struct_name> { + #nested_binding_wrapper::from_binding(self.inner.binding_with_lens(#module_name::#lens_struct_name)) + } + } + } + NestedKind::Vec { .. } => { + // For Vec nested fields, return the Vec wrapper type + let vec_wrapper_name = + format_ident!("{}VecBinding", to_pascal_case(&field_name.to_string())); + quote! { + #[doc = #field_doc] + pub fn #field_name(&self) -> #vec_wrapper_name<#struct_name, #module_name::#lens_struct_name> { + #vec_wrapper_name::from_binding(self.inner.binding_with_lens(#module_name::#lens_struct_name)) + } + } + } + NestedKind::HashMap(_val_ident) => { + // For HashMap nested fields, return the HashMap wrapper type + let hashmap_wrapper_name = + format_ident!("{}HashMapBinding", to_pascal_case(&field_name.to_string())); + quote! { + #[doc = #field_doc] + pub fn #field_name(&self) -> #hashmap_wrapper_name<#struct_name, #module_name::#lens_struct_name> { + #hashmap_wrapper_name::from_binding(self.inner.binding_with_lens(#module_name::#lens_struct_name)) + } + } + } + NestedKind::IndexMap { .. } => { + // For IndexMap nested fields, return the IndexMap wrapper type + let indexmap_wrapper_name = + format_ident!("{}IndexMapBinding", to_pascal_case(&field_name.to_string())); + quote! { + #[doc = #field_doc] + pub fn #field_name(&self) -> #indexmap_wrapper_name<#struct_name, #module_name::#lens_struct_name> { + #indexmap_wrapper_name::from_binding(self.inner.binding_with_lens(#module_name::#lens_struct_name)) + } + } + } + NestedKind::None => { + // For non-nested fields, return raw Binding + quote! { + #[doc = #field_doc] + pub fn #field_name(&self) -> floem_store::Binding<#struct_name, #field_type, #module_name::#lens_struct_name> { + self.inner.binding_with_lens(#module_name::#lens_struct_name) + } + } + } + } + }) + .collect(); + + // Generate reconcile field statements for the binding wrapper + // This generates code that only updates fields that have changed + let reconcile_field_stmts: Vec<_> = field_info + .iter() + .map(|(field_name, _field_type, _lens_struct_name, nested_kind)| { + match nested_kind { + NestedKind::Direct(_type_ident) => { + // For nested fields, call reconcile recursively + quote! { + self.#field_name().reconcile(&new_value.#field_name); + } + } + NestedKind::Vec { key_field: Some(key_field), .. } => { + // Keyed Vec reconciliation: + // - If keys are in same order, reconcile each item individually + // - If structure changed (keys differ or reordered), replace the whole Vec + quote! { + { + let new_items = &new_value.#field_name; + + // Check if structure matches (same keys in same order) + let structure_matches = self.with_untracked(|v| { + if v.#field_name.len() != new_items.len() { + return false; + } + v.#field_name.iter().zip(new_items.iter()) + .all(|(old, new)| old.#key_field == new.#key_field) + }); + + if structure_matches { + // Same structure - reconcile each item individually + for (idx, new_item) in new_items.iter().enumerate() { + self.#field_name().index(idx).reconcile(new_item); + } + } else { + // Structure changed - replace the whole Vec + self.#field_name().inner().set(new_items.clone()); + } + } + } + } + NestedKind::Vec { key_field: None, .. } | NestedKind::HashMap(_) => { + // For non-keyed Vec and HashMap nested fields, compare whole collection and replace if different + // Do comparison inside closure to avoid lifetime issues + // Use the binding wrapper's method to ensure same lens path as user bindings + quote! { + { + let new_field = &new_value.#field_name; + let changed = self.with_untracked(|v| &v.#field_name != new_field); + if changed { + self.#field_name().inner().set(new_value.#field_name.clone()); + } + } + } + } + NestedKind::IndexMap { key_field: Some(_key_field), .. } => { + // IndexMap with key_field: reconcile items with matching keys + quote! { + { + let new_items = &new_value.#field_name; + + // Check if structure matches (same keys in same order) + let structure_matches = self.with_untracked(|v| { + if v.#field_name.len() != new_items.len() { + return false; + } + v.#field_name.keys().zip(new_items.keys()) + .all(|(old_k, new_k)| old_k == new_k) + }); + + if structure_matches { + // Same structure - reconcile each item individually by key + for (key, new_item) in new_items.iter() { + self.#field_name().get(*key).reconcile(new_item); + } + } else { + // Structure changed - replace the whole IndexMap + self.#field_name().inner().set(new_items.clone()); + } + } + } + } + NestedKind::IndexMap { key_field: None, .. } => { + // IndexMap without key_field: compare whole collection and replace if different + quote! { + { + let new_field = &new_value.#field_name; + let changed = self.with_untracked(|v| &v.#field_name != new_field); + if changed { + self.#field_name().inner().set(new_value.#field_name.clone()); + } + } + } + } + NestedKind::None => { + // For non-nested fields, compare and set if different + // Do comparison inside closure to avoid lifetime issues + quote! { + { + let new_field = &new_value.#field_name; + let changed = self.with_untracked(|v| &v.#field_name != new_field); + if changed { + self.#field_name().set(new_value.#field_name.clone()); + } + } + } + } + } + }) + .collect(); + + // Generate binding wrapper methods + // For #[nested] fields, return the wrapper type; otherwise return raw Binding + let binding_wrapper_methods: Vec<_> = field_info + .iter() + .map(|(field_name, field_type, lens_struct_name, nested_kind)| { + let field_doc = format!("Get a binding for the `{}` field.", field_name); + match nested_kind { + NestedKind::Direct(type_ident) => { + // For direct nested fields, return the wrapper type (e.g., UserBinding) + let nested_binding_wrapper = format_ident!("{}Binding", type_ident); + quote! { + #[doc = #field_doc] + pub fn #field_name(&self) -> #nested_binding_wrapper< + __Root, + floem_store::ComposedLens<__L, #module_name::#lens_struct_name, #struct_name> + > { + #nested_binding_wrapper::from_binding(self.inner.binding_with_lens(#module_name::#lens_struct_name)) + } + } + } + NestedKind::Vec { .. } => { + // For Vec nested fields, return the Vec wrapper type + let vec_wrapper_name = + format_ident!("{}VecBinding", to_pascal_case(&field_name.to_string())); + quote! { + #[doc = #field_doc] + pub fn #field_name(&self) -> #vec_wrapper_name< + __Root, + floem_store::ComposedLens<__L, #module_name::#lens_struct_name, #struct_name> + > { + #vec_wrapper_name::from_binding(self.inner.binding_with_lens(#module_name::#lens_struct_name)) + } + } + } + NestedKind::HashMap(_val_ident) => { + // For HashMap nested fields, return the HashMap wrapper type + let hashmap_wrapper_name = + format_ident!("{}HashMapBinding", to_pascal_case(&field_name.to_string())); + quote! { + #[doc = #field_doc] + pub fn #field_name(&self) -> #hashmap_wrapper_name< + __Root, + floem_store::ComposedLens<__L, #module_name::#lens_struct_name, #struct_name> + > { + #hashmap_wrapper_name::from_binding(self.inner.binding_with_lens(#module_name::#lens_struct_name)) + } + } + } + NestedKind::IndexMap { .. } => { + // For IndexMap nested fields, return the IndexMap wrapper type + let indexmap_wrapper_name = + format_ident!("{}IndexMapBinding", to_pascal_case(&field_name.to_string())); + quote! { + #[doc = #field_doc] + pub fn #field_name(&self) -> #indexmap_wrapper_name< + __Root, + floem_store::ComposedLens<__L, #module_name::#lens_struct_name, #struct_name> + > { + #indexmap_wrapper_name::from_binding(self.inner.binding_with_lens(#module_name::#lens_struct_name)) + } + } + } + NestedKind::None => { + // For non-nested fields, return raw Binding + quote! { + #[doc = #field_doc] + pub fn #field_name(&self) -> floem_store::Binding< + __Root, + #field_type, + floem_store::ComposedLens<__L, #module_name::#lens_struct_name, #struct_name> + > { + self.inner.binding_with_lens(#module_name::#lens_struct_name) + } + } + } + } + }) + .collect(); + + let module_doc = format!( + "Auto-generated lens types for [`{}`].\n\n\ + Contains lens structs for each field that can be used with `binding_with_lens()`.\n\ + For most use cases, prefer using the wrapper types ([`{}Store`], [`{}Binding`])\n\ + which provide direct method access without imports.", + struct_name, struct_name, struct_name + ); + + let store_wrapper_doc = format!( + "Wrapper around `Store<{}>` with direct field access methods.\n\n\ + This wrapper provides method-style access without requiring trait imports.\n\n\ + # Example\n\n\ + ```rust,ignore\n\ + let store = {}Store::new({}::default());\n\ + let field = store.field_name(); // No import needed!\n\ + ```", + struct_name, struct_name, struct_name + ); + + let binding_wrapper_doc = format!( + "Wrapper around `Binding` with direct field access methods.\n\n\ + This wrapper provides method-style access without requiring trait imports.", + struct_name + ); + + let expanded = quote! { + #[doc = #module_doc] + pub mod #module_name { + use super::*; + + #(#lens_impls)* + } + + #(#vec_wrapper_impls)* + + #(#hashmap_wrapper_impls)* + + #(#indexmap_wrapper_impls)* + + #[doc = #store_wrapper_doc] + pub struct #store_wrapper_name { + inner: floem_store::Store<#struct_name>, + } + + impl #store_wrapper_name { + /// Create a new store wrapper with the given initial value. + pub fn new(value: #struct_name) -> Self { + Self { + inner: floem_store::Store::new(value), + } + } + + /// Get the underlying Store. + pub fn inner(&self) -> &floem_store::Store<#struct_name> { + &self.inner + } + + /// Get a Binding for the root of the store. + pub fn root(&self) -> #binding_wrapper_name<#struct_name, floem_store::lens::IdentityLens<#struct_name>> { + #binding_wrapper_name { + inner: self.inner.root(), + } + } + + /// Read the entire state. + pub fn with(&self, f: impl FnOnce(&#struct_name) -> R) -> R { + self.inner.with(f) + } + + /// Update the entire state. + pub fn update(&self, f: impl FnOnce(&mut #struct_name)) { + self.inner.update(f); + } + + /// Reconcile the store state with new data, only updating changed fields. + /// + /// This is useful for syncing with server data without triggering + /// unnecessary updates for unchanged fields. + pub fn reconcile(&self, new_value: &#struct_name) { + self.root().reconcile(new_value); + } + + #(#store_wrapper_methods)* + } + + impl Default for #store_wrapper_name + where + #struct_name: Default, + { + fn default() -> Self { + Self::new(#struct_name::default()) + } + } + + impl Clone for #store_wrapper_name { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } + } + + #[doc = #binding_wrapper_doc] + pub struct #binding_wrapper_name<__Root: 'static, __L: floem_store::Lens<__Root, #struct_name>> { + inner: floem_store::Binding<__Root, #struct_name, __L>, + } + + impl<__Root: 'static, __L: floem_store::Lens<__Root, #struct_name>> #binding_wrapper_name<__Root, __L> { + /// Create a wrapper from a raw Binding. + pub fn from_binding(binding: floem_store::Binding<__Root, #struct_name, __L>) -> Self { + Self { inner: binding } + } + + /// Get the underlying Binding. + pub fn inner(&self) -> &floem_store::Binding<__Root, #struct_name, __L> { + &self.inner + } + + /// Consume wrapper and return the underlying Binding. + pub fn into_inner(self) -> floem_store::Binding<__Root, #struct_name, __L> { + self.inner + } + + /// Set the value. + pub fn set(&self, value: #struct_name) { + self.inner.set(value); + } + + /// Update the value with a closure. + pub fn update(&self, f: impl FnOnce(&mut #struct_name)) { + self.inner.update(f); + } + + /// Read the value by reference. + pub fn with(&self, f: impl FnOnce(&#struct_name) -> R) -> R { + self.inner.with(f) + } + + /// Read the value by reference without subscribing to changes. + pub fn with_untracked(&self, f: impl FnOnce(&#struct_name) -> R) -> R { + self.inner.with_untracked(f) + } + + /// Reconcile this binding with new data, only updating fields that changed. + /// + /// This is useful when receiving data from a server - instead of replacing + /// the entire value (which would notify all subscribers), this only updates + /// fields that are actually different. + /// + /// # Example + /// + /// ```rust,ignore + /// // Server returns new data + /// let server_data = fetch_from_server(); + /// + /// // Only changed fields are updated, minimizing re-renders + /// binding.reconcile(&server_data); + /// ``` + pub fn reconcile(&self, new_value: &#struct_name) + where + #struct_name: PartialEq + Clone, + { + #(#reconcile_field_stmts)* + } + + #(#binding_wrapper_methods)* + } + + impl<__Root: 'static, __L: floem_store::Lens<__Root, #struct_name>> Clone for #binding_wrapper_name<__Root, __L> { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } + } + }; + + TokenStream::from(expanded) +} + +/// Convert a string to snake_case. +fn to_snake_case(s: &str) -> String { + let mut result = String::new(); + for (i, c) in s.chars().enumerate() { + if c.is_uppercase() { + if i > 0 { + result.push('_'); + } + result.push(c.to_ascii_lowercase()); + } else { + result.push(c); + } + } + result +} + +/// Convert a string to PascalCase. +fn to_pascal_case(s: &str) -> String { + let mut result = String::new(); + let mut capitalize_next = true; + for c in s.chars() { + if c == '_' { + capitalize_next = true; + } else if capitalize_next { + result.push(c.to_ascii_uppercase()); + capitalize_next = false; + } else { + result.push(c); + } + } + result +} diff --git a/store/Cargo.toml b/store/Cargo.toml new file mode 100644 index 000000000..d088013df --- /dev/null +++ b/store/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "floem_store" +version.workspace = true +edition = "2021" +repository = "https://github.com/lapce/floem" +description = "Elm-style state management for Floem with structural field access" +license.workspace = true + +[dependencies] +floem_reactive = { path = "../reactive", version = "0.2.0" } +floem_store_derive = { path = "../store-derive", version = "0.2.0" } +indexmap = "2.7" +parking_lot = { workspace = true } diff --git a/store/PLAN.md b/store/PLAN.md new file mode 100644 index 000000000..e213651b1 --- /dev/null +++ b/store/PLAN.md @@ -0,0 +1,494 @@ +# Floem Store - Implementation Plan + +> **IMPORTANT**: This plan is a living document. Anyone implementing or modifying +> the store crate should update this file to reflect current progress, completed +> tasks, and any changes to the design. Keep it in sync with the actual implementation! + +## Motivation: Why Store? + +### The Problem with Current Signals + +Floem's current signal system (SolidJS-style) has a fundamental issue with **nested signals and scope lifetimes**: + +```rust +// Parent creates a struct containing signals +struct Parent { + items: RwSignal>, +} + +struct Item { + name: RwSignal, // Created in CHILD scope +} + +// Scenario: +// 1. Parent passes items to child component +// 2. Child creates Item with signals tied to child's scope +// 3. Child component is unmounted → child scope disposed +// 4. Parent still holds the Item struct +// 5. Parent tries to read item.name → RUNTIME PANIC (dangling signal) +``` + +The signal ID still exists in the struct, but the underlying reactive node was cleaned up when the child scope was disposed. + +### Why Signals Can't Reliably Track Usage + +The root cause is the combination of **Copy + arena allocation**: + +1. **Copy trait** - `RwSignal` is just an `Id` wrapper, so it's `Copy`. Copy types have no `Drop`, meaning Rust can't track when they go out of scope. + +2. **Arena allocation** - Signal data lives in `HashMap` in the Runtime, not owned by the handle. The `Id` can be freely copied everywhere. + +3. **Scope-based lifetime** - Data lifetime is tied to scope disposal, not to actual usage of the signal handles. + +```rust +let signal = create_rw_signal(42); // Copy type - just an Id + +// Can freely copy everywhere - Rust tracks nothing +let copy1 = signal; +let copy2 = signal; +move_to_closure(signal); +store_in_struct(signal); + +// When scope disposes, ALL these copies become dangling +// Rust has no way to know they exist or invalidate them +``` + +With `Rc`-based Store/Binding, reference counting naturally tracks lifetime - data lives as long as any reference exists. + +### The Reconciliation Problem + +Another pain point with fine-grained signals: **server data synchronization**. + +Consider a Todo app where each property needs fine-grained reactivity: + +```rust +// For fine-grained updates, each property is a signal +struct TodoItem { + text: RwSignal, + done: RwSignal, + priority: RwSignal, +} + +// But server returns plain data +struct ServerTodoItem { + text: String, + done: bool, + priority: i32, +} +``` + +When fetching from server, you need **manual diffing** to avoid unnecessary updates: + +```rust +fn update_from_server(local: &TodoItem, server: ServerTodoItem) { + // Must manually check each field - tedious and error-prone! + if local.text.get() != server.text { + local.text.set(server.text); + } + if local.done.get() != server.done { + local.done.set(server.done); + } + if local.priority.get() != server.priority { + local.priority.set(server.priority); + } + // Repeat for every field... +} +``` + +Problems: +- **Boilerplate** - Must write diff code for every field +- **Error-prone** - Easy to forget a field or make mistakes +- **No automatic reconciliation** - Unlike React's VDOM diffing + +Store has the same issue currently - `binding.set(new_value)` always notifies even if unchanged. Future improvements could include: +- `set_if_changed()` method that checks `PartialEq` +- `#[derive(Reconcile)]` macro for automatic field-by-field diffing +- Keyed list reconciliation for collections + +### The Solution: Elm-Style Store + +Instead of signals scattered across scopes, we use a **central Store** with **Binding handles**: + +1. **State lives in Store** - Not tied to any component scope +2. **Bindings are handles** - Clone-able references into the Store (like lenses with data) +3. **Updates are messages** - `binding.set(v)` internally queues an update +4. **No dangling references** - Data outlives components that use it + +```rust +// New approach +struct AppState { + items: Vec, // Plain data, no signals +} + +struct Item { + name: String, // Plain data +} + +// Store owns the data +let store = Store::new(AppState::default()); + +// Bindings are derived handles +let items = store.binding(|s| &s.items, |s| &mut s.items); +let first_name = items.index(0).binding(|i| &i.name, |i| &mut i.name); + +// Child component receives Binding, not the data +// When child unmounts, Binding is dropped but Store data remains +first_name.set("Alice".into()); // Works even after child scope cleanup +``` + +## Design Goals + +1. **Coexist with RwSignal** - Gradual migration, not a replacement +2. **Same traits** - `Binding` implements `SignalGet`, `SignalUpdate`, etc. +3. **Fine-grained reactivity** - Each Binding path has its own subscribers +4. **Implicit messages** - No user-defined message enums (unlike traditional Elm) +5. **Clone-friendly** - Bindings are cheap to clone (Rc + lens) + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Store │ +│ ┌─────────────────────────────────────────────────────┐│ +│ │ data: RefCell ││ +│ │ subscribers: HashMap> ││ +│ └─────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────┘ + │ + │ derives + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Binding │ +│ ┌─────────────────────────────────────────────────────┐│ +│ │ inner: Rc> (shared with Store) ││ +│ │ path_id: PathId (for subscriptions) ││ +│ │ lens: L (how to access T) ││ +│ └─────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────┘ + │ + │ implements + ▼ +┌─────────────────────────────────────────────────────────┐ +│ SignalGet, SignalWith, SignalUpdate, SignalTrack │ +│ (from floem_reactive - same as RwSignal) │ +└─────────────────────────────────────────────────────────┘ +``` + +## Implementation Plan + +### Phase 1: Core Types ✅ COMPLETE + +- [x] Create `store` subcrate in workspace +- [x] Add to workspace Cargo.toml +- [x] Add `Runtime::current_effect_id()` and `Runtime::update_from_id()` to reactive crate +- [x] **`path.rs`** - PathId for tracking binding locations +- [x] **`lens.rs`** - Lens trait and implementations + - [x] `Lens` trait (Copy + 'static) + - [x] `LensFn` - closure-based lens + - [x] `ComposedLens` - for nested bindings (includes middle type parameter) + - [x] `IndexLens` - for Vec access + - [x] `IdentityLens` - for root access +- [x] **`store.rs`** - Store type + - [x] `Store` struct with Rc + - [x] `StoreInner` with data + subscribers + - [x] `store.root()` → Binding + - [x] `store.binding(getter, getter_mut)` → Binding +- [x] **`binding.rs`** - Binding type + - [x] `Binding` struct + - [x] `binding.get()`, `binding.set()`, `binding.update()` + - [x] `binding.binding(getter, getter_mut)` for nesting + - [x] Effect subscription via `Runtime::current_effect_id()` + - [x] Effect notification via `Runtime::update_from_id()` +- [x] **`traits.rs`** - Implement reactive traits on Binding + - [x] `SignalGet` for Binding + - [x] `SignalWith` for Binding + - [x] `SignalUpdate` for Binding + - [x] `SignalTrack` for Binding +- [x] **`tests.rs`** - Unit tests + - [x] Basic get/set + - [x] Nested bindings + - [x] Vec operations + - [x] Effect subscription + - [x] Trait compatibility + +### Phase 2: Collection Support ✅ COMPLETE + +- [x] `Binding>` methods + - [x] `index(usize)` → Binding + - [x] `push(T)`, `pop()`, `clear()` + - [x] `iter_bindings()` → Iterator> + - [x] `len()`, `is_empty()` +- [x] `Binding>` methods (K must be Copy + Hash + Eq) + - [x] `key(k)` → Binding + - [x] `len()`, `is_empty()`, `contains_key(&k)` + - [x] `insert(k, v)`, `remove(&k)`, `clear()` + - [x] `get_value(&k)` → Option (for cloned access) + - [x] `iter_bindings()` → Iterator)> + +### Phase 3: Convenience Features ✅ COMPLETE + +- [x] `lens!` macro for ergonomic field access + ```rust + // Instead of: store.binding(|s| &s.user, |s| &mut s.user) + let user = store.binding_lens(lens!(user)); + + // Nested paths work too: + let name = store.binding_lens(lens!(user.name)); + ``` +- [x] `binding!` macro combining store.binding_lens() with lens!() + ```rust + let count = binding!(store, count); + let name = binding!(store, user.name); + ``` +- [x] `binding_lens()` method on Store and Binding to accept lens! tuple +- [x] `#[derive(Lenses)]` macro for automatic lens generation (in `store-derive` crate) + ```rust + #[derive(Lenses)] + struct State { + count: i32, // Generates state_lenses::CountLens + StateStore/StateBinding wrappers + #[nested] + user: User, // Returns UserBinding wrapper for nested access + } + + // Method-style access via wrapper types - NO IMPORTS NEEDED: + let store = StateStore::new(State::default()); + let count = store.count(); + let name = store.user().name(); // Chained access with #[nested]! + + // Or use with binding_with_lens (still works): + let count = store.inner().binding_with_lens(state_lenses::CountLens); + ``` + +### Phase 4: Testing & Examples ✅ COMPLETE + +- [x] Unit tests for Store, Binding, Lens (31 tests passing) +- [x] Integration test with Effects +- [x] Example: Todo app using Store (`examples/todo-store`) + - Demonstrates Store/Binding usage with Floem views + - Shows hybrid approach: Store for app state, signals for view-specific state + - Uses `binding!` macro, `Binding::index()`, nested binding access +- [x] Documentation with migration guide (`store/README.md`) + +### Phase 5: Integration with Floem Views ✅ PARTIAL + +- [x] Test with `dyn_container` - works for view switching based on Store state +- [x] Test with `dyn_view` - works for reactive text display +- [x] Test with `dyn_stack` - works for filtered list rendering with Binding +- [ ] Ensure proper effect cleanup when views unmount (inherits from scope disposal) +- [ ] Consider adding Store-aware view helpers + +## File Structure + +``` +store/ +├── Cargo.toml +├── PLAN.md (this file) +├── README.md (user documentation) +└── src/ + ├── lib.rs (public API re-exports) + ├── store.rs (Store type) + ├── binding.rs (Binding type) + ├── lens.rs (Lens trait and impls) + ├── path.rs (PathId for subscriptions) + ├── traits.rs (SignalGet etc. impls for Binding) + └── tests.rs (unit tests) + +store-derive/ +├── Cargo.toml +└── src/ + └── lib.rs (#[derive(Lenses)] proc macro) +``` + +## Usage Example (Target API) + +```rust +use floem_store::Lenses; +use floem_reactive::Effect; + +#[derive(Lenses, Default, Clone, PartialEq)] +struct AppState { + #[nested] + user: User, + #[nested(key = id)] // Use `id` field for reconciliation AND by_id() access (type inferred) + todos: Vec, +} + +#[derive(Lenses, Default, Clone, PartialEq)] +struct User { + name: String, + email: String, +} + +#[derive(Lenses, Default, Clone, PartialEq)] +struct Todo { + id: u64, + text: String, + done: bool, +} + +fn main() { + // Create typed store wrapper (generated by derive) + let store = AppStateStore::new(AppState::default()); + + // Access fields via generated methods + let name = store.user().name(); + let todos = store.todos(); + + // Set values + name.set("Alice".into()); + + // Create effect that tracks the binding + let name_clone = name.clone(); + Effect::new(move |_| { + println!("Name changed to: {}", name_clone.get()); + }); + + // This triggers the effect + name.set("Bob".into()); + + // Vec operations with typed wrappers + todos.push(Todo { id: 1, text: "Learn Floem".into(), done: false }); + todos.push(Todo { id: 2, text: "Build app".into(), done: false }); + + // Position-based access (may change after reorder) + let first_text = todos.index(0).text(); + println!("First todo: {}", first_text.get()); + + // Identity-based access (stable across reorders!) + let todo1 = todos.by_id(1); + let todo1_text = todo1.text(); + println!("Todo #1: {}", todo1_text.get()); // Always gets the todo with id=1 + + // Helper methods for identity-based operations + if todos.contains_key(&2) { + todos.remove_by_key(&2); // Remove by id, not position + } + + // Get all bindings for use with dyn_stack + let all_bindings = todos.all_bindings(); + for binding in all_bindings { + println!("Todo: {}", binding.text().get()); + } + + // Filtered bindings - perfect for dyn_stack views + // Returns impl Iterator, collect when needed + let active_bindings: Vec<_> = todos.filtered_bindings(|t| !t.done).collect(); + // Use in dyn_stack: each_fn returns Vec, view_fn receives binding directly + // dyn_stack( + // move || todos.filtered_bindings(|t| !t.done).collect::>(), + // |binding| binding.id().get_untracked(), // Key function + // move |binding| todo_item_view(binding), // View receives binding! + // ) + + // Reconcile with server data - only updates changed fields + // For todos, if ids match in same order, each item is reconciled individually + store.reconcile(&AppState { + user: User { name: "Charlie".into(), email: "charlie@example.com".into() }, + todos: vec![Todo { id: 1, text: "Updated".into(), done: true }], + }); +} +``` + +### Phase 6: LocalState ✅ COMPLETE + +- [x] `LocalState` - Simple atomic reactive value + - [x] `LocalState::new(value)` - Create new LocalState + - [x] `get()`, `get_untracked()` - Read value + - [x] `set(value)` - Set value + - [x] `update(f)`, `try_update(f)` - Update with closure + - [x] `with(f)`, `with_untracked(f)` - Read by reference + - [x] Implements SignalGet, SignalWith, SignalUpdate, SignalTrack + - [x] Clone (not Copy, Rc-based) + - [x] Default implementation + - [x] 7 unit tests passing + +## Current Progress + +**Phase 1-6 Substantially Complete!** + +- Created subcrate structure +- Added `Runtime::current_effect_id()` and `Runtime::update_from_id()` to reactive crate +- Implemented all core types: Store, Binding, Lens, PathId +- Implemented reactive traits on Binding +- Added Vec collection support with `index()`, `push()`, `pop()`, `clear()`, `iter_bindings()` +- Added HashMap support with `key()`, `get_value()`, `insert()`, `remove()`, `iter_bindings()` + - Note: `key()` and `iter_bindings()` require K: Copy due to Lens trait constraints + - Non-Copy keys work with `get_value()`, `insert()`, `remove()`, `update()` +- Created `store-derive` crate with `#[derive(Lenses)]` proc macro + - Generates lens types for each struct field (named `{FieldName}Lens` to avoid shadowing) + - Generates wrapper types (`StateStore`, `StateBinding`) with direct method access + - `#[nested]` attribute for fully import-free nested access (works at multiple levels) + - `#[nested]` also works on `Vec` fields where T has `#[derive(Lenses)]` + - `#[nested]` also works on `HashMap` fields where V has `#[derive(Lenses)]` + - No trait imports needed - just use wrapper types! +- Renamed `Field` to `Binding` for clearer semantics: + - Lens = stateless accessor recipe (how to navigate data) + - Binding = live handle with data reference + reactivity +- Added dead effect cleanup in `notify_subscribers()`: + - Added `Runtime::effect_exists(effect_id)` to reactive crate for checking if an effect is still alive + - Both `Binding` and `LocalState` now clean up dead effect IDs during notification + - This prevents minor memory leak where disposed effect IDs would accumulate in subscriber HashSets +- Added `reconcile()` method to binding wrappers generated by `#[derive(Lenses)]`: + - Automatically compares each field and only updates changed fields + - For `#[nested]` fields, calls `reconcile()` recursively + - For `Vec`/`HashMap` fields, compares and replaces the whole collection if different + - Requires `Clone + PartialEq` bounds on the struct + - Solves the "server data synchronization" pain point documented above +- Changed `PathId` from incrementing counter to hash-based: + - Bindings with the same normalized lens path share the same `PathId` + - Effects subscribed to one binding see updates from other bindings on the same path + - Required for reconcile to work correctly with existing effects +- Implemented lens path normalization: + - `store.count()` and `store.root().count()` now share the same PathId + - Works for arbitrarily deep nesting: `store.nested().value()` == `store.root().nested().value()` + - Uses hash-based path composition that strips identity lenses +- Added `store.reconcile()` shortcut (equivalent to `store.root().reconcile()`) +- **Simplified API by removing closure-based binding methods**: + - Removed `store.binding()`, `store.binding_lens()`, `binding.binding()`, `binding.binding_lens()` + - Removed `LensFn`, `lens!` macro, and `binding!` macro + - The derive-generated accessor methods are now the only way to create bindings + - This avoids the "closure type uniqueness" problem where identical closures would get different PathIds + - `binding_with_lens()` kept as `#[doc(hidden)]` for derive macro internal use +- Added keyed list reconciliation with `#[nested(key = field)]` attribute: + - When reconciling a Vec, if keys match in the same order, reconcile items individually + - If structure differs (keys added/removed/reordered), replace the entire Vec + - Example: `#[nested(key = id)] todos: Vec` uses `todo.id` as the key + - Solves the problem of losing fine-grained reactivity when reconciling lists +- Added identity-based Vec access with `#[nested(key = field)]` attribute: + - When a key field is specified, `by_{field}()`, `contains_key()`, and `remove_by_key()` methods are generated + - The key type is automatically inferred from the inner type's field type alias + - Example: `#[nested(key = id)] todos: Vec` generates `todos.by_id(id)` (type inferred from `Todo::id`) + - Explicit type can still be provided: `#[nested(key = id: u64)]` (useful for complex types) + - `by_id(5)` returns a binding to the item with `id == 5`, regardless of position + - Unlike `index(0)`, the binding stays attached to the same logical item after reorders + - PathId is based on key value, not position - enables per-item effect isolation +- Added per-index and per-key PathId isolation: + - `IndexLens` now includes the index in its path hash (`todos[0].text` ≠ `todos[1].text`) + - `KeyLens` now includes the key's hash in its path hash + - Effects subscribed to one index/key are NOT triggered by updates to other indices/keys + - Enables true per-item fine-grained reactivity in collections +- Added `filtered_bindings()` and `all_bindings()` helpers to Vec wrappers (for `#[nested(key = field)]`): + - `filtered_bindings(|item| predicate)` returns `impl Iterator` matching the filter + - `all_bindings()` returns `impl Iterator` for all items + - Returns iterator (keys collected internally, bindings created lazily) + - For `dyn_stack`, collect into Vec: `todos.filtered_bindings(...).collect::>()` + - Each binding is already connected to its item via identity-based access + - Example: `todos.filtered_bindings(|t| !t.done)` returns iterator of bindings to all active todos +- All 53 unit tests passing (including `test_filtered_bindings`) +- Workspace compiles successfully +- Created `todo-store` example demonstrating Store with Floem views: + - `dyn_container` for view switching (filter tabs) + - `dyn_view` for reactive text display + - `dyn_stack` with `filtered_bindings()` for filtered list rendering + - Identity-based access with `by_id()` for stable bindings across reorders + - `remove_by_key()` for identity-based deletion + - Demonstrates passing bindings directly to view functions (no id lookup needed) + +## Next Steps + +1. Consider view-aware helpers for Store +2. Add `set_if_changed()` method to Binding and LocalState (requires `T: PartialEq`) +3. ~~Consider `#[derive(Reconcile)]` macro for automatic field-by-field diffing~~ ✅ Implemented as `binding.reconcile(&new_value)` +4. ~~Consider keyed list reconciliation for Vec bindings (currently replaces entire Vec)~~ ✅ Implemented with `#[nested(key = field)]` +5. ~~Consider normalizing lens paths (e.g., strip IdentityLens) for more consistent PathId matching~~ ✅ Implemented +6. ~~Remove closure-based API to fix PathId inconsistency~~ ✅ Implemented diff --git a/store/README.md b/store/README.md new file mode 100644 index 000000000..8e0bbcec4 --- /dev/null +++ b/store/README.md @@ -0,0 +1,532 @@ +# Floem Store + +Elm-style state management for Floem with structural data access. + +## Overview + +`floem_store` provides an alternative to signals for managing complex, structured state. Instead of nesting signals inside structs (which can cause lifetime issues when child scopes are disposed), state lives in a central `Store` and is accessed via `Binding` handles. + +## Key Concepts + +- **Store**: Central state container for complex nested state +- **Binding**: Live handle pointing to a location in the Store (combines data reference + reactivity) +- **Lens**: Stateless accessor recipe for navigating data structures +- **LocalState**: Simple reactive value for single values (no nesting) + +## The Problem Store Solves + +With traditional signals, nested signal structs can cause runtime panics: + +```rust +// Problem: signals tied to child scope +struct Item { + name: RwSignal, // Created in child scope +} + +// When child component unmounts, the scope is disposed +// Parent still holds Item struct, but signal is gone +// Accessing item.name causes a runtime panic! +``` + +## The Solution + +Store keeps all state in one place, accessed via Binding handles: + +```rust +use floem_store::{Store, binding}; + +#[derive(Default)] +struct AppState { + items: Vec, +} + +#[derive(Clone, Default)] +struct Item { + name: String, // Plain data, no signals +} + +// Store owns the data +let store = Store::new(AppState::default()); + +// Bindings are handles that point into the Store +let items = binding!(store, items); +let first_name = items.index(0).binding(|i| &i.name, |i| &mut i.name); + +// Child components receive Binding handles +// When child unmounts, Binding is dropped but Store data remains safe +first_name.set("Alice".into()); // Always works! +``` + +## Quick Start + +Add to your `Cargo.toml`: + +```toml +[dependencies] +floem_store = { path = "path/to/store" } +``` + +### Basic Usage + +```rust +use floem_store::{Store, binding}; + +#[derive(Default)] +struct State { + count: i32, + name: String, +} + +// Create a store +let store = Store::new(State::default()); + +// Get binding handles with the binding! macro +let count = binding!(store, count); +let name = binding!(store, name); + +// Read and write +count.set(42); +assert_eq!(count.get(), 42); + +name.set("Hello".into()); +assert_eq!(name.get(), "Hello"); + +// Update with closure +count.update(|c| *c += 1); +``` + +### Nested Bindings + +```rust +#[derive(Default)] +struct State { + user: User, +} + +#[derive(Default)] +struct User { + name: String, + age: i32, +} + +let store = Store::new(State::default()); + +// Access nested fields with path syntax +let name = binding!(store, user.name); +let age = binding!(store, user.age); + +// Or chain binding access +let user = binding!(store, user); +let name = user.binding(|u| &u.name, |u| &mut u.name); +``` + +### Vec Operations + +```rust +#[derive(Default)] +struct State { + items: Vec, +} + +let store = Store::new(State::default()); +let items = binding!(store, items); + +// Vec methods +items.push(Item { text: "First".into() }); +items.push(Item { text: "Second".into() }); + +assert_eq!(items.len(), 2); + +// Access by index +let first = items.index(0); +let text = first.binding(|i| &i.text, |i| &mut i.text); +assert_eq!(text.get(), "First"); + +// Iterate over bindings +for item_binding in items.iter_bindings() { + let text = item_binding.binding(|i| &i.text, |i| &mut i.text); + println!("{}", text.get()); +} + +// Other Vec operations +items.pop(); +items.clear(); +``` + +### HashMap Operations + +```rust +use std::collections::HashMap; + +#[derive(Default)] +struct State { + users: HashMap, // Keys must be Copy + Hash + Eq +} + +let store = Store::new(State::default()); +let users = binding!(store, users); + +// Insert and remove +users.insert(1, "Alice".into()); +users.insert(2, "Bob".into()); +assert_eq!(users.len(), 2); + +// Access by key (key must be Copy) +let alice = users.key(1); +assert_eq!(alice.get(), "Alice"); +alice.set("Alice Smith".into()); + +// Check and get values +assert!(users.contains_key(&1)); +assert_eq!(users.get_value(&2), Some("Bob".to_string())); + +// Iterate over bindings +for (id, user_binding) in users.iter_bindings() { + println!("{}: {}", id, user_binding.get()); +} + +// Remove and clear +users.remove(&1); +users.clear(); +``` + +### With Floem Views + +Binding implements the same reactive traits as signals, so it works seamlessly with Floem views: + +```rust +use floem::prelude::*; +use floem_store::{Store, binding}; + +fn app_view() -> impl IntoView { + let store = Store::new(State::default()); + let count = binding!(store, count); + + // dyn_view reacts to Binding changes + let display = dyn_view(move || format!("Count: {}", count.get())); + + // Buttons update the Binding + let increment = "+" + .on_click_stop(move |_| count.update(|c| *c += 1)); + + (display, increment) +} +``` + +### With dyn_container + +```rust +use floem::views::dyn_container; + +#[derive(Clone, Copy, PartialEq)] +enum ViewMode { List, Grid } + +let view_mode = binding!(store, view_mode); + +dyn_container( + move || view_mode.get(), + move |mode| match mode { + ViewMode::List => list_view().into_any(), + ViewMode::Grid => grid_view().into_any(), + }, +) +``` + +## API Reference + +### Store + +- `Store::new(value)` - Create a new store +- `store.binding(getter, getter_mut)` - Get a binding handle +- `store.binding_lens(lens!(path))` - Get a binding using lens macro +- `store.binding_with_lens(lens)` - Get a binding using a derived lens type +- `store.root()` - Get a binding for the entire state +- `store.with(|state| ...)` - Read entire state +- `store.update(|state| ...)` - Update entire state + +### Binding + +- `binding.get()` - Get value (cloned), subscribes to changes +- `binding.set(value)` - Set value, notifies subscribers +- `binding.update(|v| ...)` - Update with closure +- `binding.with(|v| ...)` - Read by reference, subscribes +- `binding.with_untracked(|v| ...)` - Read without subscribing +- `binding.binding(getter, getter_mut)` - Derive nested binding +- `binding.binding_lens(lens!(path))` - Derive using lens macro +- `binding.binding_with_lens(lens)` - Derive using a derived lens type + +### Vec Bindings + +- `binding.index(i)` - Get binding for element at index +- `binding.len()` - Get length +- `binding.is_empty()` - Check if empty +- `binding.push(value)` - Push element +- `binding.pop()` - Pop element +- `binding.clear()` - Clear all elements +- `binding.iter_bindings()` - Iterate as bindings + +### HashMap Bindings + +For `HashMap` where K is Copy + Hash + Eq: + +- `binding.key(k)` - Get binding for value at key (requires K: Copy) +- `binding.len()` - Get number of entries +- `binding.is_empty()` - Check if empty +- `binding.contains_key(&k)` - Check if key exists +- `binding.get_value(&k)` - Get cloned value if present +- `binding.insert(k, v)` - Insert key-value pair +- `binding.remove(&k)` - Remove by key +- `binding.clear()` - Clear all entries +- `binding.iter_bindings()` - Iterate as (key, binding) pairs (requires K: Copy) + +### LocalState + +- `LocalState::new(value)` - Create new LocalState +- `local_state.get()` - Get value (cloned), subscribes to changes +- `local_state.get_untracked()` - Get without subscribing +- `local_state.set(value)` - Set value, notifies subscribers +- `local_state.update(|v| ...)` - Update with closure +- `local_state.with(|v| ...)` - Read by reference, subscribes +- `local_state.with_untracked(|v| ...)` - Read without subscribing + +### Macros + +- `lens!(field)` - Create getter tuple for a field +- `lens!(a.b.c)` - Create getter tuple for nested path +- `binding!(store, path)` - Shorthand for `store.binding_lens(lens!(path))` + +### Derive Macro + +Use `#[derive(Lenses)]` to automatically generate lens types and wrapper types: + +```rust +use floem_store::{Store, Lenses}; + +#[derive(Lenses, Default)] +struct State { + count: i32, + #[nested] // Mark fields that also have #[derive(Lenses)] + user: User, +} + +#[derive(Lenses, Default)] +struct User { + name: String, + age: i32, +} + +// Use the generated wrapper type - NO IMPORTS NEEDED! +let store = StateStore::new(State::default()); +let count = store.count(); // Direct method access +let name = store.user().name(); // Nested access also works! +count.set(42); +name.set("Alice".into()); +``` + +The derive macro generates: +- A module `_lenses` with lens types (e.g., `CountLens`, `UserLens`) for use with `binding_with_lens()` +- A wrapper type `Store` with direct method access (no imports needed!) +- A wrapper type `Binding` for binding wrappers + +The `#[nested]` attribute tells the macro that a field's type also has `#[derive(Lenses)]`, +so it returns the wrapper type instead of raw `Binding`, enabling fully import-free nested access. +This works at multiple levels of nesting (e.g., `store.level1().level2().level3().value()`). + +It also works with `Vec` fields where T has `#[derive(Lenses)]`: + +```rust +#[derive(Lenses, Default)] +struct AppState { + #[nested] + items: Vec, // Vec where T has #[derive(Lenses)] +} + +#[derive(Lenses, Default, Clone)] +struct Item { + text: String, + done: bool, +} + +let store = AppStateStore::new(AppState::default()); + +// items() returns a Vec wrapper with all Vec methods +let items = store.items(); +items.push(Item { text: "Task".into(), done: false }); + +// index() returns ItemBinding, not raw Binding! +let first = items.index(0); +first.text().set("Updated".into()); // Direct method access! +first.done().set(true); +``` + +It also works with `HashMap` fields where V has `#[derive(Lenses)]`: + +```rust +use std::collections::HashMap; + +#[derive(Lenses, Default)] +struct GameState { + #[nested] + players: HashMap, // HashMap where V has #[derive(Lenses)] +} + +#[derive(Lenses, Default, Clone)] +struct Player { + name: String, + score: i32, +} + +let store = GameStateStore::new(GameState::default()); + +// players() returns a HashMap wrapper with all HashMap methods +let players = store.players(); +players.insert(1, Player { name: "Alice".into(), score: 100 }); + +// key() returns PlayerBinding, not raw Binding! (requires K: Copy) +let player1 = players.key(1); +player1.name().set("Alice Smith".into()); // Direct method access! +player1.score().set(150); +``` + +## LocalState: Simple Reactive Values + +For simple values that don't need nested access, `LocalState` provides a simpler API: + +```rust +use floem_store::LocalState; + +// Create a simple reactive value +let count = LocalState::new(0); +let name = LocalState::new("Alice".to_string()); + +// Read and write +assert_eq!(count.get(), 0); +count.set(42); +assert_eq!(count.get(), 42); + +// Update with closure +count.update(|c| *c += 1); +assert_eq!(count.get(), 43); + +// Access by reference +let len = name.with(|s| s.len()); +``` + +### When to Use LocalState vs Store + +| Use Case | Recommendation | +|----------|----------------| +| Single value (counter, flag, text) | `LocalState` | +| Nested structs with field access | `Store` + `Binding` | +| Collections with item bindings | `Store` + `Binding` | +| Multiple related values | `Store` with struct | + +### LocalState with Floem Views + +```rust +use floem::prelude::*; +use floem_store::LocalState; + +fn counter_view() -> impl IntoView { + let count = LocalState::new(0); + + // dyn_view reacts to LocalState changes + let display = dyn_view(move || format!("Count: {}", count.get())); + + // Buttons update the LocalState + let increment = "+" + .on_click_stop(move |_| count.update(|c| *c += 1)); + + (display, increment) +} +``` + +## Migration from Signals + +### Before (Signals) + +```rust +struct State { + count: RwSignal, + items: RwSignal>, +} + +struct Item { + name: RwSignal, +} + +// Create with signals +let state = State { + count: RwSignal::new(0), + items: RwSignal::new(vec![]), +}; + +// Use signal methods +state.count.set(42); +let val = state.count.get(); +``` + +### After (Store) + +```rust +#[derive(Default)] +struct State { + count: i32, // Plain data + items: Vec, +} + +#[derive(Clone, Default)] +struct Item { + name: String, // Plain data +} + +// Create store +let store = Store::new(State::default()); + +// Get binding handles +let count = binding!(store, count); +let items = binding!(store, items); + +// Same API as signals! +count.set(42); +let val = count.get(); +``` + +### Hybrid Approach + +Store coexists with signals. Use Store for app state, signals for view-local state: + +```rust +fn my_view() -> impl IntoView { + // App state from Store + let items = binding!(store, items); + + // View-local state as signal (for text_input compatibility) + let input_text = RwSignal::new(String::new()); + + ( + text_input(input_text), + dyn_stack( + move || (0..items.len()).collect::>(), + |i| *i, + move |i| item_view(items.index(i)), + ), + ) +} +``` + +## Design Principles + +1. **Coexist with signals** - Gradual migration, not replacement +2. **Same traits** - Binding implements SignalGet, SignalUpdate, etc. +3. **Fine-grained reactivity** - Each binding path has its own subscribers +4. **Implicit messages** - No user-defined message enums (unlike traditional Elm) +5. **Clone-friendly** - Bindings are cheap to clone (Rc-based) + +## Example + +See `examples/todo-store` for a complete todo app demonstrating: +- Store and Binding usage +- dyn_container for view switching +- dyn_stack for filtered lists +- Hybrid approach with signals diff --git a/store/src/binding.rs b/store/src/binding.rs new file mode 100644 index 000000000..19030d9f4 --- /dev/null +++ b/store/src/binding.rs @@ -0,0 +1,470 @@ +//! Binding handles for accessing locations in a Store. + +use std::{collections::HashSet, marker::PhantomData, rc::Rc}; + +use floem_reactive::Runtime; + +use std::hash::Hash; + +use crate::{ + lens::{ComposedLens, IndexLens, KeyLens, Lens}, + path::PathId, + store::{StoreId, StoreInner}, +}; + +/// A handle pointing to a specific location in a Store. +/// +/// Bindings are created using `#[derive(Lenses)]` which generates accessor methods +/// on the store and binding wrappers. They implement the standard reactive traits +/// so they can be used interchangeably with signals in generic code. +/// +/// # Example +/// +/// ```rust,ignore +/// use floem_store::Lenses; +/// +/// #[derive(Lenses, Default)] +/// struct State { +/// count: i32, +/// name: String, +/// } +/// +/// let store = StateStore::new(State::default()); +/// +/// // Access fields via generated methods +/// let count = store.count(); +/// let name = store.name(); +/// +/// count.set(42); +/// name.set("Hello".into()); +/// ``` +pub struct Binding> { + pub(crate) store_id: StoreId, + pub(crate) inner: Rc>, + pub(crate) path_id: PathId, + pub(crate) lens: L, + pub(crate) _phantom: PhantomData T>, +} + +// Binding is Clone (not Copy because it contains Rc). +impl> Clone for Binding { + fn clone(&self) -> Self { + Self { + store_id: self.store_id, + inner: self.inner.clone(), + path_id: self.path_id, + lens: self.lens, + _phantom: PhantomData, + } + } +} + +// Note: Binding cannot be Copy because it contains Rc. +// This is a key design decision - we trade Copy for avoiding the scope lifetime issue. +// Users pass Binding by clone (cheap due to Rc) or by reference. + +impl> Binding { + /// Derive a child binding using a lens type. + /// + /// This is used internally by the `#[derive(Lenses)]` macro to compose lenses. + /// Users should prefer the generated accessor methods instead. + #[doc(hidden)] + pub fn binding_with_lens( + &self, + lens: L2, + ) -> Binding> + where + U: 'static, + L2: Lens, + { + let new_lens = ComposedLens::new(self.lens, lens); + Binding { + store_id: self.store_id, + inner: self.inner.clone(), + path_id: PathId::from_hash(new_lens.path_hash()), + lens: new_lens, + _phantom: PhantomData, + } + } + + /// Get the current value (cloned). + /// + /// This subscribes the current effect to changes on this field. + pub fn get(&self) -> T + where + T: Clone, + { + self.subscribe_current_effect(); + self.get_untracked() + } + + /// Get the current value without subscribing to changes. + pub fn get_untracked(&self) -> T + where + T: Clone, + { + self.lens.get(&self.inner.data.borrow()).clone() + } + + /// Access the value by reference. + /// + /// This subscribes the current effect to changes on this field. + pub fn with(&self, f: impl FnOnce(&T) -> R) -> R { + self.subscribe_current_effect(); + self.with_untracked(f) + } + + /// Access the value by reference without subscribing. + pub fn with_untracked(&self, f: impl FnOnce(&T) -> R) -> R { + f(self.lens.get(&self.inner.data.borrow())) + } + + /// Set a new value. + /// + /// This notifies all subscribers of this field. + pub fn set(&self, value: T) { + self.update(|v| *v = value); + } + + /// Update the value with a function. + /// + /// This notifies all subscribers of this field. + pub fn update(&self, f: impl FnOnce(&mut T)) { + { + let mut data = self.inner.data.borrow_mut(); + f(self.lens.get_mut(&mut *data)); + } + self.notify_subscribers(); + } + + /// Try to update the value, returning the result of the function. + pub fn try_update(&self, f: impl FnOnce(&mut T) -> R) -> R { + let result = { + let mut data = self.inner.data.borrow_mut(); + f(self.lens.get_mut(&mut *data)) + }; + self.notify_subscribers(); + result + } + + /// Subscribe the current running effect to this field's changes. + pub(crate) fn subscribe_current_effect(&self) { + if let Some(effect_id) = Runtime::current_effect_id() { + self.inner + .subscribers + .borrow_mut() + .entry(self.path_id) + .or_insert_with(HashSet::new) + .insert(effect_id); + } + } + + /// Notify all subscribers that this field has changed. + fn notify_subscribers(&self) { + // Collect effect IDs first, then drop the borrow before notifying. + // This avoids borrow conflicts when effects re-run and try to subscribe. + let effect_ids: Vec<_> = { + let subscribers = self.inner.subscribers.borrow(); + subscribers + .get(&self.path_id) + .map(|effects| effects.iter().copied().collect()) + .unwrap_or_default() + }; + + // Track dead effects for cleanup + let mut dead_effects = Vec::new(); + + for effect_id in effect_ids { + if Runtime::effect_exists(effect_id) { + Runtime::update_from_id(effect_id); + } else { + dead_effects.push(effect_id); + } + } + + // Clean up dead effect subscriptions + if !dead_effects.is_empty() { + let mut subscribers = self.inner.subscribers.borrow_mut(); + if let Some(effects) = subscribers.get_mut(&self.path_id) { + for dead_id in dead_effects { + effects.remove(&dead_id); + } + } + } + } + + /// Get the path ID for this field (useful for debugging). + pub fn path_id(&self) -> PathId { + self.path_id + } +} + +// Vec-specific methods +impl>> Binding, L> { + /// Get a binding for the element at the given index. + pub fn index(&self, index: usize) -> Binding>> { + let new_lens = ComposedLens::new(self.lens, IndexLens::new(index)); + Binding { + store_id: self.store_id, + inner: self.inner.clone(), + // Note: All IndexLens bindings share the same PathId because IndexLens has + // the same TypeId regardless of index value. This is a current limitation. + path_id: PathId::from_hash(new_lens.path_hash()), + lens: new_lens, + _phantom: PhantomData, + } + } + + /// Get the length of the Vec. + pub fn len(&self) -> usize { + self.with_untracked(|v| v.len()) + } + + /// Check if the Vec is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Push an element to the Vec. + pub fn push(&self, value: T) { + self.update(|v| v.push(value)); + } + + /// Pop an element from the Vec. + pub fn pop(&self) -> Option { + self.try_update(|v| v.pop()) + } + + /// Clear the Vec. + pub fn clear(&self) { + self.update(|v| v.clear()); + } + + /// Iterate over indices, returning Bindings for each element. + /// + /// Note: The returned iterator captures the current length. If the Vec + /// is modified during iteration, behavior may be unexpected. + pub fn iter_bindings( + &self, + ) -> impl Iterator>>> { + let len = self.len(); + let store_id = self.store_id; + let inner = self.inner.clone(); + let lens = self.lens; + + (0..len).map(move |i| { + let new_lens = ComposedLens::new(lens, IndexLens::new(i)); + Binding { + store_id, + inner: inner.clone(), + // Note: All IndexLens bindings share the same PathId + path_id: PathId::from_hash(new_lens.path_hash()), + lens: new_lens, + _phantom: PhantomData, + } + }) + } +} + +// HashMap-specific methods +impl Binding, L> +where + K: Hash + Eq + Clone + 'static, + V: 'static, + L: Lens>, +{ + /// Get a binding for the value at the given key. + /// + /// # Panics + /// + /// Panics if the key is not present when accessing the binding. + pub fn key(&self, key: K) -> Binding, std::collections::HashMap>> + where + K: Copy, + { + let new_lens = ComposedLens::new(self.lens, KeyLens::new(key)); + Binding { + store_id: self.store_id, + inner: self.inner.clone(), + // Note: All KeyLens bindings share the same PathId because KeyLens has + // the same TypeId regardless of key value. This is a current limitation. + path_id: PathId::from_hash(new_lens.path_hash()), + lens: new_lens, + _phantom: PhantomData, + } + } + + /// Get the number of entries in the HashMap. + pub fn len(&self) -> usize { + self.with_untracked(|m| m.len()) + } + + /// Check if the HashMap is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Check if the HashMap contains the given key. + pub fn contains_key(&self, key: &K) -> bool { + self.with_untracked(|m| m.contains_key(key)) + } + + /// Insert a key-value pair into the HashMap. + /// + /// Returns the old value if the key was already present. + pub fn insert(&self, key: K, value: V) -> Option { + self.try_update(|m| m.insert(key, value)) + } + + /// Remove a key from the HashMap. + /// + /// Returns the value if the key was present. + pub fn remove(&self, key: &K) -> Option { + self.try_update(|m| m.remove(key)) + } + + /// Clear the HashMap. + pub fn clear(&self) { + self.update(|m| m.clear()); + } + + /// Get a cloned value for the given key, if present. + pub fn get_value(&self, key: &K) -> Option + where + V: Clone, + { + self.with_untracked(|m| m.get(key).cloned()) + } + + /// Iterate over keys, returning Bindings for each value. + /// + /// Note: The returned iterator captures the current keys. If the HashMap + /// is modified during iteration, behavior may be unexpected. + pub fn iter_bindings( + &self, + ) -> impl Iterator, std::collections::HashMap>>)> + where + K: Copy, + { + let keys: Vec = self.with_untracked(|m| m.keys().copied().collect()); + let store_id = self.store_id; + let inner = self.inner.clone(); + let lens = self.lens; + + keys.into_iter().map(move |k| { + let new_lens = ComposedLens::new(lens, KeyLens::new(k)); + ( + k, + Binding { + store_id, + inner: inner.clone(), + // Note: All KeyLens bindings share the same PathId + path_id: PathId::from_hash(new_lens.path_hash()), + lens: new_lens, + _phantom: PhantomData, + }, + ) + }) + } +} + +// IndexMap-specific methods - O(1) key access with insertion order preservation +impl Binding, L> +where + K: Hash + Eq + Clone + 'static, + V: 'static, + L: Lens>, +{ + /// Get a binding for the value at the given key (O(1) lookup). + /// + /// # Panics + /// + /// Panics if the key is not present when accessing the binding. + pub fn key(&self, key: K) -> Binding, indexmap::IndexMap>> + where + K: Copy, + { + let new_lens = ComposedLens::new(self.lens, KeyLens::new(key)); + Binding { + store_id: self.store_id, + inner: self.inner.clone(), + path_id: PathId::from_hash(new_lens.path_hash()), + lens: new_lens, + _phantom: PhantomData, + } + } + + /// Get the number of entries in the IndexMap. + pub fn len(&self) -> usize { + self.with_untracked(|m| m.len()) + } + + /// Check if the IndexMap is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Check if the IndexMap contains the given key. + pub fn contains_key(&self, key: &K) -> bool { + self.with_untracked(|m| m.contains_key(key)) + } + + /// Insert a key-value pair into the IndexMap. + /// + /// If the key already exists, the value is updated but position is preserved. + /// Returns the old value if the key was already present. + pub fn insert(&self, key: K, value: V) -> Option { + self.try_update(|m| m.insert(key, value)) + } + + /// Remove a key from the IndexMap. + /// + /// Uses shift_remove to preserve insertion order of remaining elements. + /// Returns the value if the key was present. + pub fn remove(&self, key: &K) -> Option { + self.try_update(|m| m.shift_remove(key)) + } + + /// Clear the IndexMap. + pub fn clear(&self) { + self.update(|m| m.clear()); + } + + /// Get a cloned value for the given key, if present (O(1) lookup). + pub fn get_value(&self, key: &K) -> Option + where + V: Clone, + { + self.with_untracked(|m| m.get(key).cloned()) + } + + /// Iterate over keys in insertion order, returning Bindings for each value. + /// + /// Note: The returned iterator captures the current keys. If the IndexMap + /// is modified during iteration, behavior may be unexpected. + pub fn iter_bindings( + &self, + ) -> impl Iterator, indexmap::IndexMap>>)> + where + K: Copy, + { + let keys: Vec = self.with_untracked(|m| m.keys().copied().collect()); + let store_id = self.store_id; + let inner = self.inner.clone(); + let lens = self.lens; + + keys.into_iter().map(move |k| { + let new_lens = ComposedLens::new(lens, KeyLens::new(k)); + ( + k, + Binding { + store_id, + inner: inner.clone(), + path_id: PathId::from_hash(new_lens.path_hash()), + lens: new_lens, + _phantom: PhantomData, + }, + ) + }) + } +} diff --git a/store/src/dyn_binding.rs b/store/src/dyn_binding.rs new file mode 100644 index 000000000..e3c17ac6f --- /dev/null +++ b/store/src/dyn_binding.rs @@ -0,0 +1,333 @@ +//! Type-erased binding for simpler function signatures. +//! +//! `DynBinding` wraps a `Binding` and erases the `Root` and `L` +//! type parameters, making it easier to pass bindings as function parameters. +//! +//! # Example +//! +//! ```rust,ignore +//! use floem_store::{DynBinding, SignalGet, SignalUpdate}; +//! +//! // Instead of: fn foo>(binding: Binding) +//! // You can write: +//! fn foo(binding: DynBinding) { +//! let value = binding.get(); +//! binding.set(value + 1); +//! } +//! +//! // Convert a binding to DynBinding +//! let count = store.count(); +//! foo(count.into_dyn()); +//! ``` + +use std::cell::RefCell; +use std::rc::Rc; + +use floem_reactive::{ReactiveId, SignalGet, SignalTrack, SignalUpdate, SignalWith}; + +use crate::{binding::Binding, lens::Lens, path::PathId}; + +/// Trait for type-erased binding operations. +/// +/// Uses `&mut dyn FnMut` for update operations to avoid 'static requirements. +trait DynBindingOps { + fn get(&self) -> T + where + T: Clone; + fn get_untracked(&self) -> T + where + T: Clone; + fn set(&self, value: T); + fn update_with(&self, f: &mut dyn FnMut(&mut T)); + fn subscribe(&self); + fn path_id(&self) -> PathId; +} + +impl> DynBindingOps for Binding { + fn get(&self) -> T + where + T: Clone, + { + Binding::get(self) + } + + fn get_untracked(&self) -> T + where + T: Clone, + { + Binding::get_untracked(self) + } + + fn set(&self, value: T) { + Binding::set(self, value); + } + + fn update_with(&self, f: &mut dyn FnMut(&mut T)) { + Binding::update(self, |v| f(v)); + } + + fn subscribe(&self) { + Binding::subscribe_current_effect(self); + } + + fn path_id(&self) -> PathId { + Binding::path_id(self) + } +} + +/// A type-erased binding that hides the `Root` and `Lens` type parameters. +/// +/// This is useful when you want to pass bindings to functions without +/// exposing the full generic type. The trade-off is a small runtime cost +/// from dynamic dispatch. +/// +/// # Example +/// +/// ```rust,ignore +/// // A function that accepts any binding to an i32 +/// fn increment(counter: &DynBinding) { +/// counter.update(|c| *c += 1); +/// } +/// +/// let store = AppStateStore::new(AppState::default()); +/// increment(&store.count().into_dyn()); +/// ``` +pub struct DynBinding { + inner: Rc>>>, +} + +impl Clone for DynBinding { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl DynBinding { + /// Create a new DynBinding from any Binding. + pub fn new>(binding: Binding) -> Self { + Self { + inner: Rc::new(RefCell::new(Box::new(binding))), + } + } + + /// Get the current value (cloned). + /// + /// This subscribes the current effect to changes on this field. + pub fn get(&self) -> T + where + T: Clone, + { + let inner = self.inner.borrow(); + inner.subscribe(); + inner.get() + } + + /// Get the current value without subscribing to changes. + pub fn get_untracked(&self) -> T + where + T: Clone, + { + self.inner.borrow().get_untracked() + } + + /// Set a new value. + /// + /// This notifies all subscribers of this field. + pub fn set(&self, value: T) { + self.inner.borrow().set(value); + } + + /// Update the value with a function. + /// + /// This notifies all subscribers of this field. + pub fn update(&self, mut f: impl FnMut(&mut T)) { + self.inner.borrow().update_with(&mut f); + } + + /// Try to update the value, returning the result of the function. + pub fn try_update(&self, mut f: impl FnMut(&mut T) -> R) -> R { + let mut result = None; + self.inner.borrow().update_with(&mut |v| { + result = Some(f(v)); + }); + result.expect("update_with should have been called") + } + + /// Get the path ID for this field (useful for debugging). + pub fn path_id(&self) -> PathId { + self.inner.borrow().path_id() + } + + /// Subscribe the current running effect to this field's changes. + pub fn track(&self) { + self.inner.borrow().subscribe(); + } +} + +// Add into_dyn method to Binding +impl> Binding { + /// Convert this binding to a type-erased `DynBinding`. + /// + /// This erases the `Root` and `Lens` type parameters, making it easier + /// to pass bindings as function parameters. + /// + /// # Example + /// + /// ```rust,ignore + /// fn show_value(binding: DynBinding) { + /// println!("{}", binding.get()); + /// } + /// + /// let store = AppStateStore::new(AppState::default()); + /// show_value(store.name().into_dyn()); + /// ``` + pub fn into_dyn(self) -> DynBinding { + DynBinding::new(self) + } + + /// Convert a reference to this binding to a `DynBinding`. + /// + /// This clones the binding internally (cheap due to Rc). + pub fn to_dyn(&self) -> DynBinding { + DynBinding::new(self.clone()) + } +} + +// ============================================================================ +// Reactive trait implementations for DynBinding +// ============================================================================ + +fn dyn_binding_id_unsupported() -> ReactiveId { + panic!( + "DynBinding does not use ReactiveId. \ + Use DynBinding's native methods instead of id()-based operations." + ) +} + +impl SignalGet for DynBinding { + fn id(&self) -> ReactiveId { + dyn_binding_id_unsupported() + } + + fn get(&self) -> T + where + T: 'static, + { + DynBinding::get(self) + } + + fn get_untracked(&self) -> T + where + T: 'static, + { + DynBinding::get_untracked(self) + } + + fn try_get(&self) -> Option + where + T: 'static, + { + Some(DynBinding::get(self)) + } + + fn try_get_untracked(&self) -> Option + where + T: 'static, + { + Some(DynBinding::get_untracked(self)) + } +} + +impl SignalWith for DynBinding { + fn id(&self) -> ReactiveId { + dyn_binding_id_unsupported() + } + + fn with(&self, f: impl FnOnce(&T) -> O) -> O + where + T: 'static, + { + let inner = self.inner.borrow(); + inner.subscribe(); + f(&inner.get()) + } + + fn with_untracked(&self, f: impl FnOnce(&T) -> O) -> O + where + T: 'static, + { + f(&self.inner.borrow().get_untracked()) + } + + fn try_with(&self, f: impl FnOnce(Option<&T>) -> O) -> O + where + T: 'static, + { + let inner = self.inner.borrow(); + inner.subscribe(); + f(Some(&inner.get())) + } + + fn try_with_untracked(&self, f: impl FnOnce(Option<&T>) -> O) -> O + where + T: 'static, + { + f(Some(&self.inner.borrow().get_untracked())) + } +} + +impl SignalUpdate for DynBinding { + fn id(&self) -> ReactiveId { + dyn_binding_id_unsupported() + } + + fn set(&self, new_value: T) + where + T: 'static, + { + DynBinding::set(self, new_value); + } + + fn update(&self, f: impl FnOnce(&mut T)) + where + T: 'static, + { + // Wrap FnOnce in Option to call it as FnMut + let mut f_opt = Some(f); + self.inner.borrow().update_with(&mut |v| { + if let Some(func) = f_opt.take() { + func(v); + } + }); + } + + fn try_update(&self, f: impl FnOnce(&mut T) -> O) -> Option + where + T: 'static, + { + let mut f_opt = Some(f); + let mut result = None; + self.inner.borrow().update_with(&mut |v| { + if let Some(func) = f_opt.take() { + result = Some(func(v)); + } + }); + result + } +} + +impl SignalTrack for DynBinding { + fn id(&self) -> ReactiveId { + dyn_binding_id_unsupported() + } + + fn track(&self) { + self.inner.borrow().subscribe(); + } + + fn try_track(&self) { + self.inner.borrow().subscribe(); + } +} diff --git a/store/src/lens.rs b/store/src/lens.rs new file mode 100644 index 000000000..be3f2afda --- /dev/null +++ b/store/src/lens.rs @@ -0,0 +1,269 @@ +//! Lens trait for bidirectional data access. +//! +//! A lens provides both read and write access to a part of a larger structure. + +use std::marker::PhantomData; + +/// FNV-1a hash constants for 64-bit. +const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325; +const FNV_PRIME: u64 = 0x100000001b3; + +/// Compute a hash of a string at compile time using FNV-1a. +/// +/// This is used by the derive macro to generate deterministic path hashes +/// based on field names rather than TypeId. +pub const fn const_hash(s: &str) -> u64 { + let bytes = s.as_bytes(); + let mut hash = FNV_OFFSET_BASIS; + let mut i = 0; + while i < bytes.len() { + hash ^= bytes[i] as u64; + hash = hash.wrapping_mul(FNV_PRIME); + i += 1; + } + hash +} + +/// Special hash value for identity lens (empty path segment). +/// This is a const so it can be used in pattern matching. +pub const IDENTITY_PATH_HASH: u64 = const_hash(""); + +/// A lens that can read and write a field of type `T` from a source of type `S`. +/// +/// Lenses must be Copy and 'static so they can be stored in Bindings. +/// Use `#[derive(Lenses)]` to generate lens types for your structs. +pub trait Lens: Copy + 'static { + /// The path hash for this lens, computed from the field name. + /// + /// This is a const so it can be evaluated at compile time. + /// Override this in generated lens types using `const_hash("field_name")`. + const PATH_HASH: u64 = IDENTITY_PATH_HASH; + + /// Get a reference to the target field. + fn get<'a>(&self, source: &'a S) -> &'a T; + + /// Get a mutable reference to the target field. + fn get_mut<'a>(&self, source: &'a mut S) -> &'a mut T; + + /// Returns a hash used for path subscription normalization. + /// + /// This allows equivalent lens paths (e.g., `store.count()` vs `store.root().count()`) + /// to share the same PathId by stripping identity lenses from the path. + /// + /// Default implementation returns `Self::PATH_HASH`. + fn path_hash(&self) -> u64 { + Self::PATH_HASH + } +} + +/// Composed lens that combines two lenses: S -> M -> T +/// +/// The middle type M is part of the type signature to make the impl unambiguous. +pub struct ComposedLens { + first: L1, + second: L2, + _phantom: PhantomData M>, +} + +impl Clone for ComposedLens { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for ComposedLens {} + +impl ComposedLens { + pub fn new(first: L1, second: L2) -> Self { + Self { + first, + second, + _phantom: PhantomData, + } + } +} + +impl Lens for ComposedLens +where + L1: Lens, + L2: Lens, +{ + fn get<'a>(&self, source: &'a S) -> &'a T { + self.second.get(self.first.get(source)) + } + + fn get_mut<'a>(&self, source: &'a mut S) -> &'a mut T { + self.second.get_mut(self.first.get_mut(source)) + } + + /// If the first lens is an identity lens, strip it and return the second lens's path_hash. + /// Otherwise, combine the path hashes of both lenses. + /// This normalizes paths like `root().nested().value()` to match `nested().value()`. + fn path_hash(&self) -> u64 { + let first_hash = self.first.path_hash(); + + if first_hash == IDENTITY_PATH_HASH { + // First lens is identity, use the second lens's path + self.second.path_hash() + } else { + // Combine the path hashes using FNV-1a continuation + let second_hash = self.second.path_hash(); + // Use a simple combination that's order-dependent + let mut hash = first_hash; + hash ^= second_hash; + hash = hash.wrapping_mul(FNV_PRIME); + hash + } + } +} + +/// Index lens for accessing elements of a Vec. +#[derive(Copy, Clone)] +pub struct IndexLens { + index: usize, +} + +impl IndexLens { + pub fn new(index: usize) -> Self { + Self { index } + } +} + +impl Lens, T> for IndexLens { + // Base hash for index lens + const PATH_HASH: u64 = const_hash("[index]"); + + fn get<'a>(&self, source: &'a Vec) -> &'a T { + &source[self.index] + } + + fn get_mut<'a>(&self, source: &'a mut Vec) -> &'a mut T { + &mut source[self.index] + } + + /// Each index gets a unique path hash by mixing the index into the base hash. + /// This enables per-item fine-grained reactivity. + fn path_hash(&self) -> u64 { + let mut hash = const_hash("[index]"); + hash ^= self.index as u64; + hash = hash.wrapping_mul(FNV_PRIME); + hash + } +} + +/// Key lens for accessing elements of a HashMap by key. +/// +/// Note: The key must be Clone because we store it in the lens. +#[derive(Clone)] +pub struct KeyLens { + key: K, +} + +impl Copy for KeyLens {} + +impl KeyLens { + pub fn new(key: K) -> Self { + Self { key } + } +} + +impl Lens, V> for KeyLens +where + K: std::hash::Hash + Eq + Copy + 'static, + V: 'static, +{ + // Base hash for key lens + const PATH_HASH: u64 = const_hash("[key]"); + + fn get<'a>(&self, source: &'a std::collections::HashMap) -> &'a V { + source + .get(&self.key) + .expect("KeyLens: key not found in HashMap") + } + + fn get_mut<'a>(&self, source: &'a mut std::collections::HashMap) -> &'a mut V { + source + .get_mut(&self.key) + .expect("KeyLens: key not found in HashMap") + } + + /// Each key gets a unique path hash by mixing the key's hash into the base hash. + /// This enables per-key fine-grained reactivity. + fn path_hash(&self) -> u64 { + use std::hash::Hasher; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.key.hash(&mut hasher); + let key_hash = hasher.finish(); + + let mut hash = const_hash("[key]"); + hash ^= key_hash; + hash = hash.wrapping_mul(FNV_PRIME); + hash + } +} + +/// KeyLens implementation for IndexMap - O(1) access by key. +impl Lens, V> for KeyLens +where + K: std::hash::Hash + Eq + Copy + 'static, + V: 'static, +{ + // Base hash for key lens (same as HashMap) + const PATH_HASH: u64 = const_hash("[key]"); + + fn get<'a>(&self, source: &'a indexmap::IndexMap) -> &'a V { + source + .get(&self.key) + .expect("KeyLens: key not found in IndexMap") + } + + fn get_mut<'a>(&self, source: &'a mut indexmap::IndexMap) -> &'a mut V { + source + .get_mut(&self.key) + .expect("KeyLens: key not found in IndexMap") + } + + /// Each key gets a unique path hash by mixing the key's hash into the base hash. + /// This enables per-key fine-grained reactivity. + fn path_hash(&self) -> u64 { + use std::hash::Hasher; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.key.hash(&mut hasher); + let key_hash = hasher.finish(); + + let mut hash = const_hash("[key]"); + hash ^= key_hash; + hash = hash.wrapping_mul(FNV_PRIME); + hash + } +} + +/// Identity lens that returns the source as-is. +pub struct IdentityLens(PhantomData T>); + +impl Clone for IdentityLens { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for IdentityLens {} + +impl Default for IdentityLens { + fn default() -> Self { + Self(PhantomData) + } +} + +impl Lens for IdentityLens { + // Identity lens uses the empty string hash so it can be detected and stripped + const PATH_HASH: u64 = IDENTITY_PATH_HASH; + + fn get<'a>(&self, source: &'a T) -> &'a T { + source + } + + fn get_mut<'a>(&self, source: &'a mut T) -> &'a mut T { + source + } +} diff --git a/store/src/lib.rs b/store/src/lib.rs new file mode 100644 index 000000000..eab543fa0 --- /dev/null +++ b/store/src/lib.rs @@ -0,0 +1,74 @@ +//! # Floem Store +//! +//! Elm-style state management for Floem with structural field access. +//! +//! This crate provides an alternative to signals for managing complex, structured state. +//! Instead of nesting signals inside structs (which can cause lifetime issues when +//! child scopes are disposed), state lives in a central `Store` and is accessed via +//! `Binding` handles that can be derived for nested fields. +//! +//! ## Key Concepts +//! +//! - **Store**: Owns the state tree, not tied to any scope +//! - **Binding**: Handle pointing to a location in a Store (like a live lens with data) +//! - **Lens**: Stateless accessor recipe for navigating the data structure +//! - **Updates are messages**: `binding.set(v)` queues an update, doesn't mutate directly +//! +//! ## Example +//! +//! ```rust,ignore +//! use floem_store::Lenses; +//! +//! #[derive(Lenses, Default)] +//! struct AppState { +//! count: i32, +//! #[nested] +//! user: User, +//! } +//! +//! #[derive(Lenses, Default)] +//! struct User { +//! name: String, +//! age: i32, +//! } +//! +//! // Create a typed store wrapper (generated by derive) +//! let store = AppStateStore::new(AppState::default()); +//! +//! // Access fields via generated methods - these return Bindings +//! let count = store.count(); +//! let name = store.user().name(); +//! +//! // Read and write +//! name.set("Alice".into()); +//! assert_eq!(name.get(), "Alice"); +//! +//! // Update with closure +//! count.update(|c| *c += 1); +//! ``` + +mod binding; +mod dyn_binding; +pub mod lens; +mod local_state; +mod path; +mod store; +mod traits; + +#[cfg(test)] +mod tests; + +pub use binding::Binding; +pub use dyn_binding::DynBinding; +pub use lens::{ComposedLens, IdentityLens, IndexLens, KeyLens, Lens}; +pub use local_state::LocalState; +pub use store::Store; + +// Re-export derive macro +pub use floem_store_derive::Lenses; + +// Re-export IndexMap for keyed collections with O(1) access +pub use indexmap::IndexMap; + +// Re-export reactive traits for convenience +pub use floem_reactive::{SignalGet, SignalTrack, SignalUpdate, SignalWith}; diff --git a/store/src/local_state.rs b/store/src/local_state.rs new file mode 100644 index 000000000..30e1ed33a --- /dev/null +++ b/store/src/local_state.rs @@ -0,0 +1,351 @@ +//! LocalState - a simple reactive value. +//! +//! `LocalState` is a simpler alternative to `Store` + `Binding` when you just need +//! a single reactive value without complex nested state. It's Rc-based, so it: +//! - Automatically cleans up when all references are dropped +//! - Avoids the scope lifetime issues of arena-allocated signals +//! - Is Clone (not Copy, due to Rc) +//! +//! Use `LocalState` for simple values, use `Store` for complex nested state. + +use std::{ + cell::RefCell, + collections::HashSet, + rc::Rc, +}; + +use floem_reactive::{ReactiveId, Runtime}; + +/// Inner storage for LocalState. +struct LocalStateInner { + data: RefCell, + subscribers: RefCell>, +} + +/// A simple reactive value. +/// +/// `LocalState` wraps a single value with reactive tracking. When the value +/// changes, all subscribed effects are notified and re-run. +/// +/// # Example +/// +/// ```rust +/// use floem_store::LocalState; +/// +/// let count = LocalState::new(0); +/// +/// // Read the value +/// assert_eq!(count.get(), 0); +/// +/// // Update the value +/// count.set(42); +/// assert_eq!(count.get(), 42); +/// +/// // Update with a closure +/// count.update(|c| *c += 1); +/// assert_eq!(count.get(), 43); +/// ``` +/// +/// # Reactivity +/// +/// When read inside an effect, `LocalState` automatically subscribes to changes: +/// +/// ```rust,ignore +/// use floem_reactive::Effect; +/// use floem_store::LocalState; +/// +/// let name = LocalState::new("Alice".to_string()); +/// +/// // This effect re-runs whenever name changes +/// Effect::new(move |_| { +/// println!("Name is: {}", name.get()); +/// }); +/// +/// name.set("Bob".to_string()); // Triggers effect re-run +/// ``` +pub struct LocalState { + inner: Rc>, +} + +impl Clone for LocalState { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl LocalState { + /// Create a new LocalState with the given initial value. + pub fn new(value: T) -> Self { + Self { + inner: Rc::new(LocalStateInner { + data: RefCell::new(value), + subscribers: RefCell::new(HashSet::new()), + }), + } + } + + /// Get the current value (cloned). + /// + /// This subscribes the current effect to changes on this value. + pub fn get(&self) -> T + where + T: Clone, + { + self.subscribe_current_effect(); + self.get_untracked() + } + + /// Get the current value without subscribing to changes. + pub fn get_untracked(&self) -> T + where + T: Clone, + { + self.inner.data.borrow().clone() + } + + /// Access the value by reference. + /// + /// This subscribes the current effect to changes on this value. + pub fn with(&self, f: impl FnOnce(&T) -> R) -> R { + self.subscribe_current_effect(); + self.with_untracked(f) + } + + /// Access the value by reference without subscribing. + pub fn with_untracked(&self, f: impl FnOnce(&T) -> R) -> R { + f(&self.inner.data.borrow()) + } + + /// Set a new value. + /// + /// This notifies all subscribers. + pub fn set(&self, value: T) { + self.update(|v| *v = value); + } + + /// Update the value with a function. + /// + /// This notifies all subscribers. + pub fn update(&self, f: impl FnOnce(&mut T)) { + { + let mut data = self.inner.data.borrow_mut(); + f(&mut *data); + } + self.notify_subscribers(); + } + + /// Try to update the value, returning the result of the function. + pub fn try_update(&self, f: impl FnOnce(&mut T) -> R) -> R { + let result = { + let mut data = self.inner.data.borrow_mut(); + f(&mut *data) + }; + self.notify_subscribers(); + result + } + + /// Subscribe the current running effect to this value's changes. + fn subscribe_current_effect(&self) { + if let Some(effect_id) = Runtime::current_effect_id() { + self.inner.subscribers.borrow_mut().insert(effect_id); + } + } + + /// Notify all subscribers that the value has changed. + fn notify_subscribers(&self) { + // Collect effect IDs first, then drop the borrow before notifying. + // This avoids borrow conflicts when effects re-run and try to subscribe. + let effect_ids: Vec<_> = self.inner.subscribers.borrow().iter().copied().collect(); + + // Track dead effects for cleanup + let mut dead_effects = Vec::new(); + + for effect_id in effect_ids { + if Runtime::effect_exists(effect_id) { + Runtime::update_from_id(effect_id); + } else { + dead_effects.push(effect_id); + } + } + + // Clean up dead effect subscriptions + if !dead_effects.is_empty() { + let mut subscribers = self.inner.subscribers.borrow_mut(); + for dead_id in dead_effects { + subscribers.remove(&dead_id); + } + } + } +} + +impl Default for LocalState { + fn default() -> Self { + Self::new(T::default()) + } +} + +// Trait implementations for interoperability with floem_reactive + +impl floem_reactive::SignalGet for LocalState { + fn id(&self) -> ReactiveId { + panic!( + "LocalState does not use ReactiveId. \ + Use LocalState's native methods instead of id()-based operations." + ) + } + + fn get(&self) -> T { + LocalState::get(self) + } + + fn get_untracked(&self) -> T { + LocalState::get_untracked(self) + } + + fn try_get(&self) -> Option { + Some(LocalState::get(self)) + } + + fn try_get_untracked(&self) -> Option { + Some(LocalState::get_untracked(self)) + } +} + +impl floem_reactive::SignalWith for LocalState { + fn id(&self) -> ReactiveId { + panic!( + "LocalState does not use ReactiveId. \ + Use LocalState's native methods instead of id()-based operations." + ) + } + + fn with(&self, f: impl FnOnce(&T) -> O) -> O { + LocalState::with(self, f) + } + + fn with_untracked(&self, f: impl FnOnce(&T) -> O) -> O { + LocalState::with_untracked(self, f) + } + + fn try_with(&self, f: impl FnOnce(Option<&T>) -> O) -> O { + LocalState::with(self, |v| f(Some(v))) + } + + fn try_with_untracked(&self, f: impl FnOnce(Option<&T>) -> O) -> O { + LocalState::with_untracked(self, |v| f(Some(v))) + } +} + +impl floem_reactive::SignalUpdate for LocalState { + fn id(&self) -> ReactiveId { + panic!( + "LocalState does not use ReactiveId. \ + Use LocalState's native methods instead of id()-based operations." + ) + } + + fn set(&self, new_value: T) { + LocalState::set(self, new_value); + } + + fn update(&self, f: impl FnOnce(&mut T)) { + LocalState::update(self, f); + } + + fn try_update(&self, f: impl FnOnce(&mut T) -> O) -> Option { + Some(LocalState::try_update(self, f)) + } +} + +impl floem_reactive::SignalTrack for LocalState { + fn id(&self) -> ReactiveId { + panic!( + "LocalState does not use ReactiveId. \ + Use LocalState's native methods instead of id()-based operations." + ) + } + + fn track(&self) { + self.subscribe_current_effect(); + } + + fn try_track(&self) { + self.subscribe_current_effect(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_state_basic_get_set() { + let state = LocalState::new(10); + assert_eq!(state.get(), 10); + + state.set(20); + assert_eq!(state.get(), 20); + } + + #[test] + fn local_state_update() { + let state = LocalState::new(5); + state.update(|v| *v *= 2); + assert_eq!(state.get(), 10); + } + + #[test] + fn local_state_try_update() { + let state = LocalState::new(vec![1, 2, 3]); + let popped = state.try_update(|v| v.pop()); + assert_eq!(popped, Some(3)); + assert_eq!(state.get(), vec![1, 2]); + } + + #[test] + fn local_state_with() { + let state = LocalState::new("hello".to_string()); + let len = state.with(|s| s.len()); + assert_eq!(len, 5); + } + + #[test] + fn local_state_clone() { + let state1 = LocalState::new(42); + let state2 = state1.clone(); + + state1.set(100); + // Both point to the same inner state + assert_eq!(state2.get(), 100); + } + + #[test] + fn local_state_default() { + let state: LocalState = LocalState::default(); + assert_eq!(state.get(), 0); + + let state: LocalState = LocalState::default(); + assert_eq!(state.get(), ""); + } + + #[test] + fn local_state_with_complex_type() { + #[derive(Clone, Default, PartialEq, Debug)] + struct User { + name: String, + age: i32, + } + + let user = LocalState::new(User { + name: "Alice".into(), + age: 30, + }); + + assert_eq!(user.get().name, "Alice"); + + user.update(|u| u.age += 1); + assert_eq!(user.get().age, 31); + } +} diff --git a/store/src/path.rs b/store/src/path.rs new file mode 100644 index 000000000..d4641d872 --- /dev/null +++ b/store/src/path.rs @@ -0,0 +1,40 @@ +//! Path tracking for fine-grained reactivity. +//! +//! Each Binding has a PathId that identifies its location in the state tree. +//! When a field is updated, we notify subscribers of that specific path. +//! +//! PathIds are based on normalized lens path hashes, so equivalent paths +//! (e.g., `store.count()` vs `store.root().count()`) share the same PathId. + +use std::any::TypeId; +use std::hash::{Hash, Hasher}; + +/// Identifier for a path in the state tree. +/// +/// PathIds are determined by the normalized lens path hash, ensuring that bindings +/// to the same logical path share a PathId regardless of how they're created. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PathId(u64); + +impl PathId { + /// Create a PathId based on a lens type. + /// + /// Bindings with the same lens type get the same PathId. + pub fn for_lens() -> Self { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + TypeId::of::().hash(&mut hasher); + PathId(hasher.finish()) + } + + /// Create a PathId from a hash value directly. + /// + /// This is used with `Lens::path_hash()` for normalized paths. + pub fn from_hash(hash: u64) -> Self { + PathId(hash) + } + + /// The root path (uses the unit type as a sentinel). + pub fn root() -> Self { + PathId::for_lens::<()>() + } +} diff --git a/store/src/store.rs b/store/src/store.rs new file mode 100644 index 000000000..bac9f9035 --- /dev/null +++ b/store/src/store.rs @@ -0,0 +1,195 @@ +//! Central state container. + +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + marker::PhantomData, + rc::Rc, + sync::atomic::{AtomicU64, Ordering}, +}; + +use floem_reactive::{ReactiveId, Runtime}; + +use crate::{ + binding::Binding, + lens::{IdentityLens, Lens}, + path::PathId, +}; + +/// Unique identifier for a Store instance. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StoreId(u64); + +impl StoreId { + fn next() -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + StoreId(COUNTER.fetch_add(1, Ordering::Relaxed)) + } +} + +/// Internal state shared between Store and all its Fields. +pub(crate) struct StoreInner { + /// The actual data. + pub(crate) data: RefCell, + /// Subscribers for each path: path_id -> set of effect ids. + pub(crate) subscribers: RefCell>>, + /// Pending updates to apply (for batching). + pub(crate) pending_updates: RefCell>>, + /// Paths that have been modified and need notification. + pub(crate) dirty_paths: RefCell>, +} + +impl StoreInner { + fn new(data: T) -> Self { + Self { + data: RefCell::new(data), + subscribers: RefCell::new(HashMap::new()), + pending_updates: RefCell::new(Vec::new()), + dirty_paths: RefCell::new(HashSet::new()), + } + } +} + +/// A central store that owns state and provides Binding handles for access. +/// +/// Unlike signals, a Store is not tied to any scope. It lives as long as +/// there are references to it (via Rc). Bindings derived from the store +/// are Clone handles that point back to this store. +/// +/// Use `#[derive(Lenses)]` on your state type to generate a typed store wrapper +/// with accessor methods. For advanced use cases, you can use `Store` directly +/// with `root()` to get a binding to the entire state. +/// +/// # Example +/// +/// ```rust,ignore +/// use floem_store::Lenses; +/// +/// #[derive(Lenses, Default)] +/// struct State { +/// count: i32, +/// name: String, +/// } +/// +/// // Generated wrapper provides typed access +/// let store = StateStore::new(State::default()); +/// store.count().set(42); +/// store.name().set("Hello".into()); +/// ``` +pub struct Store { + pub(crate) id: StoreId, + pub(crate) inner: Rc>, +} + +impl Clone for Store { + fn clone(&self) -> Self { + Self { + id: self.id, + inner: self.inner.clone(), + } + } +} + +impl Store { + /// Create a new store with the given initial value. + pub fn new(value: T) -> Self { + Self { + id: StoreId::next(), + inner: Rc::new(StoreInner::new(value)), + } + } + + /// Get a Binding handle for the root of the store. + /// + /// This provides direct access to the entire state. For typed field access, + /// use `#[derive(Lenses)]` which generates accessor methods. + pub fn root(&self) -> Binding> { + Binding { + store_id: self.id, + inner: self.inner.clone(), + path_id: PathId::root(), + lens: IdentityLens::default(), + _phantom: PhantomData, + } + } + + /// Get a Binding handle using a lens type. + /// + /// This is used internally by the `#[derive(Lenses)]` macro. + /// Users should prefer the generated accessor methods instead. + #[doc(hidden)] + pub fn binding_with_lens(&self, lens: L) -> Binding + where + U: 'static, + L: Lens, + { + Binding { + store_id: self.id, + inner: self.inner.clone(), + path_id: PathId::from_hash(lens.path_hash()), + lens, + _phantom: PhantomData, + } + } + + /// Read the entire store value. + pub fn with(&self, f: impl FnOnce(&T) -> R) -> R { + f(&self.inner.data.borrow()) + } + + /// Update the entire store value. + pub fn update(&self, f: impl FnOnce(&mut T)) { + f(&mut self.inner.data.borrow_mut()); + self.notify_all(); + } + + /// Apply all pending updates and notify subscribers. + pub fn flush(&self) { + let updates: Vec<_> = self.inner.pending_updates.borrow_mut().drain(..).collect(); + if updates.is_empty() { + return; + } + + let mut data = self.inner.data.borrow_mut(); + for update in updates { + update(&mut *data); + } + drop(data); + + self.flush_notifications(); + } + + /// Notify all subscribers of the root path. + fn notify_all(&self) { + self.inner.dirty_paths.borrow_mut().insert(PathId::root()); + self.flush_notifications(); + } + + /// Flush pending notifications to subscribers. + pub(crate) fn flush_notifications(&self) { + let dirty: HashSet<_> = self.inner.dirty_paths.borrow_mut().drain().collect(); + let subscribers = self.inner.subscribers.borrow(); + + for path_id in dirty { + if let Some(effects) = subscribers.get(&path_id) { + for effect_id in effects { + // Trigger the effect to re-run by using floem_reactive's mechanism + // For now, we use direct notification + Self::notify_effect(*effect_id); + } + } + } + } + + fn notify_effect(effect_id: ReactiveId) { + // Access the reactive runtime to trigger the effect + // This integrates with floem_reactive's effect system + Runtime::update_from_id(effect_id); + } +} + +impl Default for Store { + fn default() -> Self { + Self::new(T::default()) + } +} diff --git a/store/src/tests.rs b/store/src/tests.rs new file mode 100644 index 000000000..5fd8576ac --- /dev/null +++ b/store/src/tests.rs @@ -0,0 +1,1965 @@ +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::collections::HashMap; + use std::rc::Rc; + + use floem_reactive::{Effect, Runtime}; + + // ===== Derive Macro Tests ===== + + // Alias crate as floem_store so the derive macro's generated code works + use crate as floem_store; + use crate::Lenses; + + #[derive(Lenses, Default, Clone, PartialEq)] + struct DeriveTestState { + count: i32, + name: String, + #[nested] // Mark as nested so wrapper returns DeriveTestNestedBinding + nested: DeriveTestNested, + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct DeriveTestNested { + value: f64, + flag: bool, + } + + #[test] + fn test_derive_lenses_basic() { + // No imports needed - use the generated wrapper type + let store = DeriveTestStateStore::new(DeriveTestState { + count: 42, + name: "Alice".into(), + ..Default::default() + }); + + // Method-style access via wrapper type + let count = store.count(); + let name = store.name(); + + assert_eq!(count.get(), 42); + assert_eq!(name.get(), "Alice"); + + count.set(100); + name.set("Bob".into()); + + assert_eq!(count.get(), 100); + assert_eq!(name.get(), "Bob"); + } + + #[test] + fn test_derive_lenses_nested() { + // No imports needed - use the generated wrapper type with #[nested] + let store = DeriveTestStateStore::new(DeriveTestState { + nested: DeriveTestNested { + value: 3.14, + flag: true, + }, + ..Default::default() + }); + + // Chain method calls: store.nested().value() + // Works without imports because nested field is marked with #[nested] + let value = store.nested().value(); + let flag = store.nested().flag(); + + assert!((value.get() - 3.14).abs() < 0.001); + assert!(flag.get()); + + value.set(2.71); + flag.set(false); + + assert!((value.get() - 2.71).abs() < 0.001); + assert!(!flag.get()); + } + + #[test] + fn test_derive_lenses_with_effects() { + // No imports needed - use wrapper type + let store = DeriveTestStateStore::new(DeriveTestState { + count: 0, + ..Default::default() + }); + + let count = store.count(); + let run_count = Rc::new(Cell::new(0)); + + { + let run_count = run_count.clone(); + let count = count.clone(); + Effect::new(move |_| { + let _ = count.get(); + run_count.set(run_count.get() + 1); + }); + } + + // Initial run + assert_eq!(run_count.get(), 1); + + // Change triggers effect + count.set(1); + Runtime::drain_pending_work(); + assert_eq!(run_count.get(), 2); + } + + #[test] + fn test_wrapper_store_basic() { + // Test the generated StateStore wrapper - no imports needed! + let store = DeriveTestStateStore::new(DeriveTestState { + count: 42, + name: "Alice".into(), + ..Default::default() + }); + + // Direct method access without any trait imports + let count = store.count(); + let name = store.name(); + + assert_eq!(count.get(), 42); + assert_eq!(name.get(), "Alice"); + + count.set(100); + name.set("Bob".into()); + + assert_eq!(count.get(), 100); + assert_eq!(name.get(), "Bob"); + } + + #[test] + fn test_wrapper_store_nested() { + // With #[nested] attribute, NO IMPORTS NEEDED for nested access! + let store = DeriveTestStateStore::new(DeriveTestState { + nested: DeriveTestNested { + value: 3.14, + flag: true, + }, + ..Default::default() + }); + + // First level: no import needed (using wrapper) + // With #[nested], store.nested() returns DeriveTestNestedBinding, not raw Binding + let nested_binding = store.nested(); + + // Second level: ALSO no import needed because nested_binding is a wrapper! + let value = nested_binding.value(); + let flag = nested_binding.flag(); + + assert!((value.get() - 3.14).abs() < 0.001); + assert!(flag.get()); + } + + #[test] + fn test_wrapper_binding() { + // Test the generated StateBinding wrapper + let store = DeriveTestStateStore::new(DeriveTestState { + count: 10, + ..Default::default() + }); + + // Get the root as a wrapper + let root = store.root(); + + // Access fields on the binding wrapper - no imports needed + let count = root.count(); + assert_eq!(count.get(), 10); + + count.set(20); + assert_eq!(count.get(), 20); + } + + #[test] + fn test_wrapper_store_default() { + // Test Default impl for wrapper + let store = DeriveTestStateStore::default(); + let count = store.count(); + assert_eq!(count.get(), 0); // Default for i32 + } + + #[test] + fn test_wrapper_store_clone() { + // Test Clone impl for wrapper + let store1 = DeriveTestStateStore::new(DeriveTestState { + count: 42, + ..Default::default() + }); + + let store2 = store1.clone(); + + // Both point to the same data + store1.count().set(100); + assert_eq!(store2.count().get(), 100); + } + + // ===== Multi-level Nested Tests ===== + + #[derive(Lenses, Default, Clone, PartialEq)] + struct Level1 { + #[nested] + level2: Level2, + name: String, + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct Level2 { + #[nested] + level3: Level3, + count: i32, + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct Level3 { + value: f64, + flag: bool, + } + + #[test] + fn test_multi_level_nested() { + // Test 3 levels of nesting: Level1 -> Level2 -> Level3 + let store = Level1Store::new(Level1 { + name: "Root".into(), + level2: Level2 { + count: 42, + level3: Level3 { + value: 3.14, + flag: true, + }, + }, + }); + + // Direct field access + assert_eq!(store.name().get(), "Root"); + + // 2 levels deep + assert_eq!(store.level2().count().get(), 42); + + // 3 levels deep - this is the key test! + assert!((store.level2().level3().value().get() - 3.14).abs() < 0.001); + assert!(store.level2().level3().flag().get()); + + // Modify at each level + store.name().set("Updated".into()); + store.level2().count().set(100); + store.level2().level3().value().set(2.71); + store.level2().level3().flag().set(false); + + // Verify changes + assert_eq!(store.name().get(), "Updated"); + assert_eq!(store.level2().count().get(), 100); + assert!((store.level2().level3().value().get() - 2.71).abs() < 0.001); + assert!(!store.level2().level3().flag().get()); + } + + // ===== Vec Nested Tests ===== + + #[derive(Lenses, Default, Clone, PartialEq)] + struct VecItem { + name: String, + value: i32, + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct VecContainer { + #[nested] + items: Vec, + } + + #[test] + fn test_vec_nested_basic() { + // Test that #[nested] on Vec returns a wrapper that gives wrapped elements + let store = VecContainerStore::new(VecContainer { + items: vec![ + VecItem { name: "First".into(), value: 1 }, + VecItem { name: "Second".into(), value: 2 }, + ], + }); + + // store.items() returns a Vec wrapper + let items = store.items(); + assert_eq!(items.len(), 2); + + // items.index(0) returns VecItemBinding, not raw Binding + // So we can use .name() and .value() methods + let first = items.index(0); + assert_eq!(first.name().get(), "First"); + assert_eq!(first.value().get(), 1); + + let second = items.index(1); + assert_eq!(second.name().get(), "Second"); + assert_eq!(second.value().get(), 2); + } + + #[test] + fn test_vec_nested_mutation() { + let store = VecContainerStore::new(VecContainer { + items: vec![ + VecItem { name: "Item".into(), value: 10 }, + ], + }); + + let items = store.items(); + let first = items.index(0); + + // Mutate through wrapper methods + first.name().set("Updated".into()); + first.value().set(42); + + assert_eq!(first.name().get(), "Updated"); + assert_eq!(first.value().get(), 42); + } + + #[test] + fn test_vec_nested_push_pop() { + let store = VecContainerStore::new(VecContainer::default()); + + let items = store.items(); + assert!(items.is_empty()); + + // Push items + items.push(VecItem { name: "A".into(), value: 1 }); + items.push(VecItem { name: "B".into(), value: 2 }); + assert_eq!(items.len(), 2); + + // Access pushed items via wrapper + assert_eq!(items.index(0).name().get(), "A"); + assert_eq!(items.index(1).name().get(), "B"); + + // Pop + let popped = items.pop(); + assert!(popped.is_some()); + assert_eq!(items.len(), 1); + + // Clear + items.clear(); + assert!(items.is_empty()); + } + + #[test] + fn test_vec_nested_with_update() { + let store = VecContainerStore::new(VecContainer { + items: vec![ + VecItem { name: "X".into(), value: 100 }, + ], + }); + + let items = store.items(); + + // Use with() to read + let name = items.with(|v| v[0].name.clone()); + assert_eq!(name, "X"); + + // Use update() to modify the whole vec + items.update(|v| { + v.push(VecItem { name: "Y".into(), value: 200 }); + }); + assert_eq!(items.len(), 2); + assert_eq!(items.index(1).name().get(), "Y"); + } + + // Test Vec where T has #[nested] fields (nested inside nested) + #[derive(Lenses, Default, Clone, PartialEq)] + struct PointItem { + #[nested] + coords: Coords, + label: String, + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct Coords { + x: f64, + y: f64, + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct PointsContainer { + #[nested] + points: Vec, + } + + #[test] + fn test_vec_nested_deep() { + // Test Vec where T itself has #[nested] fields + let store = PointsContainerStore::new(PointsContainer { + points: vec![ + PointItem { + coords: Coords { x: 1.0, y: 2.0 }, + label: "Point 1".into(), + }, + ], + }); + + let points = store.points(); + let first = points.index(0); + + // Access nested field inside Vec element + assert_eq!(first.label().get(), "Point 1"); + + // Access deeply nested field - coords() returns CoordsBinding wrapper! + assert!((first.coords().x().get() - 1.0).abs() < 0.001); + assert!((first.coords().y().get() - 2.0).abs() < 0.001); + + // Modify deeply nested + first.coords().x().set(10.0); + first.coords().y().set(20.0); + + assert!((first.coords().x().get() - 10.0).abs() < 0.001); + assert!((first.coords().y().get() - 20.0).abs() < 0.001); + } + + // ===== HashMap Nested Tests ===== + + // Note: HashMap is already imported at line 303 + + #[derive(Lenses, Default, Clone, PartialEq)] + struct MapEntry { + name: String, + score: i32, + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct MapContainer { + #[nested] + entries: HashMap, + } + + #[test] + fn test_hashmap_nested_basic() { + // Test that #[nested] on HashMap returns a wrapper that gives wrapped values + let mut initial_entries = HashMap::new(); + initial_entries.insert(1, MapEntry { name: "Alice".into(), score: 100 }); + initial_entries.insert(2, MapEntry { name: "Bob".into(), score: 85 }); + + let store = MapContainerStore::new(MapContainer { + entries: initial_entries, + }); + + // store.entries() returns a HashMap wrapper + let entries = store.entries(); + assert_eq!(entries.len(), 2); + + // entries.key(1) returns MapEntryBinding, not raw Binding + // So we can use .name() and .score() methods + let alice = entries.key(1); + assert_eq!(alice.name().get(), "Alice"); + assert_eq!(alice.score().get(), 100); + + let bob = entries.key(2); + assert_eq!(bob.name().get(), "Bob"); + assert_eq!(bob.score().get(), 85); + } + + #[test] + fn test_hashmap_nested_mutation() { + let mut initial_entries = HashMap::new(); + initial_entries.insert(1, MapEntry { name: "Test".into(), score: 50 }); + + let store = MapContainerStore::new(MapContainer { + entries: initial_entries, + }); + + let entries = store.entries(); + let entry = entries.key(1); + + // Mutate through wrapper methods + entry.name().set("Updated".into()); + entry.score().set(99); + + assert_eq!(entry.name().get(), "Updated"); + assert_eq!(entry.score().get(), 99); + } + + #[test] + fn test_hashmap_nested_insert_remove() { + let store = MapContainerStore::new(MapContainer::default()); + + let entries = store.entries(); + assert!(entries.is_empty()); + + // Insert entries + entries.insert(10, MapEntry { name: "Entry A".into(), score: 10 }); + entries.insert(20, MapEntry { name: "Entry B".into(), score: 20 }); + assert_eq!(entries.len(), 2); + assert!(entries.contains_key(&10)); + assert!(entries.contains_key(&20)); + + // Access inserted entries via wrapper + assert_eq!(entries.key(10).name().get(), "Entry A"); + assert_eq!(entries.key(20).name().get(), "Entry B"); + + // Remove + let removed = entries.remove(&10); + assert!(removed.is_some()); + assert_eq!(entries.len(), 1); + assert!(!entries.contains_key(&10)); + + // Clear + entries.clear(); + assert!(entries.is_empty()); + } + + #[test] + fn test_hashmap_nested_with_update() { + let mut initial_entries = HashMap::new(); + initial_entries.insert(1, MapEntry { name: "X".into(), score: 100 }); + + let store = MapContainerStore::new(MapContainer { + entries: initial_entries, + }); + + let entries = store.entries(); + + // Use with() to read + let name = entries.with(|m| m.get(&1).unwrap().name.clone()); + assert_eq!(name, "X"); + + // Use update() to modify the whole map + entries.update(|m| { + m.insert(2, MapEntry { name: "Y".into(), score: 200 }); + }); + assert_eq!(entries.len(), 2); + assert_eq!(entries.key(2).name().get(), "Y"); + } + + #[test] + fn test_hashmap_nested_get_value() { + let mut initial_entries = HashMap::new(); + initial_entries.insert(1, MapEntry { name: "Alice".into(), score: 100 }); + + let store = MapContainerStore::new(MapContainer { + entries: initial_entries, + }); + + let entries = store.entries(); + + // get_value returns cloned value + let entry = entries.get_value(&1); + assert!(entry.is_some()); + let entry = entry.unwrap(); + assert_eq!(entry.name, "Alice"); + assert_eq!(entry.score, 100); + + // Non-existent key + let missing = entries.get_value(&999); + assert!(missing.is_none()); + } + + // Test HashMap where V has #[nested] fields (nested inside nested) + #[derive(Lenses, Default, Clone, PartialEq)] + struct PlayerData { + #[nested] + stats: PlayerStats, + level: i32, + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct PlayerStats { + health: i32, + mana: i32, + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct PlayersContainer { + #[nested] + players: HashMap, + } + + #[test] + fn test_hashmap_nested_deep() { + // Test HashMap where V itself has #[nested] fields + let mut initial_players = HashMap::new(); + initial_players.insert(1, PlayerData { + stats: PlayerStats { health: 100, mana: 50 }, + level: 10, + }); + + let store = PlayersContainerStore::new(PlayersContainer { + players: initial_players, + }); + + let players = store.players(); + let player1 = players.key(1); + + // Access nested field inside HashMap value + assert_eq!(player1.level().get(), 10); + + // Access deeply nested field - stats() returns PlayerStatsBinding wrapper! + assert_eq!(player1.stats().health().get(), 100); + assert_eq!(player1.stats().mana().get(), 50); + + // Modify deeply nested + player1.stats().health().set(80); + player1.stats().mana().set(75); + + assert_eq!(player1.stats().health().get(), 80); + assert_eq!(player1.stats().mana().get(), 75); + } + + // ===== Reconcile Tests ===== + + // ===== Path Normalization Tests ===== + + #[test] + fn test_path_normalization_store_vs_root() { + // Test that store.count() and store.root().count() share the same PathId + // This is achieved by stripping IdentityLens from ComposedLens paths + let store = DeriveTestStateStore::new(DeriveTestState { + count: 0, + ..Default::default() + }); + + // Get bindings via different paths + let count_direct = store.count(); + let count_via_root = store.root().count(); + + // They should have the same PathId + assert_eq!(count_direct.path_id(), count_via_root.path_id()); + } + + #[test] + fn test_path_normalization_effect_subscription() { + // Test that an effect subscribed to store.count() sees updates from store.root().count().set() + let store = DeriveTestStateStore::new(DeriveTestState { + count: 0, + ..Default::default() + }); + + let run_count = Rc::new(Cell::new(0)); + + // Subscribe via direct path + { + let count = store.count(); + let run_count = run_count.clone(); + Effect::new(move |_| { + let _ = count.get(); + run_count.set(run_count.get() + 1); + }); + } + + // Initial run + assert_eq!(run_count.get(), 1); + + // Update via root path - should trigger the effect subscribed to direct path + store.root().count().set(42); + Runtime::drain_pending_work(); + + // Effect should have run again because paths are normalized + assert_eq!(run_count.get(), 2); + assert_eq!(store.count().get(), 42); + } + + #[test] + fn test_path_normalization_reverse_subscription() { + // Test the reverse: subscribe to store.root().count(), update via store.count() + let store = DeriveTestStateStore::new(DeriveTestState { + count: 0, + ..Default::default() + }); + + let run_count = Rc::new(Cell::new(0)); + + // Subscribe via root path + { + let count = store.root().count(); + let run_count = run_count.clone(); + Effect::new(move |_| { + let _ = count.get(); + run_count.set(run_count.get() + 1); + }); + } + + // Initial run + assert_eq!(run_count.get(), 1); + + // Update via direct path - should trigger the effect subscribed to root path + store.count().set(100); + Runtime::drain_pending_work(); + + // Effect should have run again + assert_eq!(run_count.get(), 2); + assert_eq!(store.root().count().get(), 100); + } + + #[test] + fn test_path_normalization_nested() { + // Test path normalization with nested bindings + let store = DeriveTestStateStore::new(DeriveTestState { + nested: DeriveTestNested { + value: 0.0, + ..Default::default() + }, + ..Default::default() + }); + + let run_count = Rc::new(Cell::new(0)); + + // Subscribe via direct nested path: store.nested().value() + { + let value = store.nested().value(); + let run_count = run_count.clone(); + Effect::new(move |_| { + let _ = value.get(); + run_count.set(run_count.get() + 1); + }); + } + + assert_eq!(run_count.get(), 1); + + // Update via root nested path: store.root().nested().value() + store.root().nested().value().set(3.14); + Runtime::drain_pending_work(); + + // Effect should have run + assert_eq!(run_count.get(), 2); + assert!((store.nested().value().get() - 3.14).abs() < 0.001); + } + + // ===== Reconcile Tests ===== + + #[derive(Lenses, Default, Clone, PartialEq)] + struct ReconcileTest { + count: i32, + name: String, + flag: bool, + } + + #[test] + fn test_reconcile_basic() { + let store = ReconcileTestStore::new(ReconcileTest { + count: 10, + name: "Original".into(), + flag: false, + }); + + // Track which fields are updated + let count_updates = Rc::new(Cell::new(0)); + let name_updates = Rc::new(Cell::new(0)); + let flag_updates = Rc::new(Cell::new(0)); + + // Create effects to track updates + // IMPORTANT: Use root().field_name() to get the same lens path that reconcile uses + { + let count = store.root().count(); + let count_updates = count_updates.clone(); + Effect::new(move |_| { + count.get(); + count_updates.set(count_updates.get() + 1); + }); + } + { + let name = store.root().name(); + let name_updates = name_updates.clone(); + Effect::new(move |_| { + name.get(); + name_updates.set(name_updates.get() + 1); + }); + } + { + let flag = store.root().flag(); + let flag_updates = flag_updates.clone(); + Effect::new(move |_| { + flag.get(); + flag_updates.set(flag_updates.get() + 1); + }); + } + + // Initial run counts + assert_eq!(count_updates.get(), 1); + assert_eq!(name_updates.get(), 1); + assert_eq!(flag_updates.get(), 1); + + // Reconcile with only count changed + store.root().reconcile(&ReconcileTest { + count: 20, // Changed + name: "Original".into(), // Same + flag: false, // Same + }); + Runtime::drain_pending_work(); + + // Only count effect should have re-run + assert_eq!(count_updates.get(), 2); + assert_eq!(name_updates.get(), 1); // No change + assert_eq!(flag_updates.get(), 1); // No change + + // Verify value + assert_eq!(store.root().count().get(), 20); + assert_eq!(store.root().name().get(), "Original"); + + // Reconcile with multiple changes + store.root().reconcile(&ReconcileTest { + count: 20, // Same + name: "Updated".into(), // Changed + flag: true, // Changed + }); + Runtime::drain_pending_work(); + + assert_eq!(count_updates.get(), 2); // No change + assert_eq!(name_updates.get(), 2); // Changed + assert_eq!(flag_updates.get(), 2); // Changed + + // Verify values + assert_eq!(store.root().name().get(), "Updated"); + assert!(store.root().flag().get()); + } + + #[test] + fn test_reconcile_no_change() { + let store = ReconcileTestStore::new(ReconcileTest { + count: 42, + name: "Test".into(), + flag: true, + }); + + let update_count = Rc::new(Cell::new(0)); + + { + let root = store.root(); + let update_count = update_count.clone(); + Effect::new(move |_| { + root.with(|_| {}); // Track root + update_count.set(update_count.get() + 1); + }); + } + + assert_eq!(update_count.get(), 1); + + // Reconcile with same values - no effects should trigger + store.root().reconcile(&ReconcileTest { + count: 42, + name: "Test".into(), + flag: true, + }); + Runtime::drain_pending_work(); + + // Note: root effect doesn't re-run because we only update individual fields + // But field effects would not re-run either (tested above) + assert_eq!(store.root().count().get(), 42); + assert_eq!(store.root().name().get(), "Test"); + assert!(store.root().flag().get()); + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct ReconcileOuter { + #[nested] + coords: ReconcileCoords, + value: i32, + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct ReconcileCoords { + x: f64, + y: f64, + } + + #[test] + fn test_reconcile_nested() { + let store = ReconcileOuterStore::new(ReconcileOuter { + coords: ReconcileCoords { x: 1.0, y: 2.0 }, + value: 100, + }); + + let x_updates = Rc::new(Cell::new(0)); + let y_updates = Rc::new(Cell::new(0)); + let value_updates = Rc::new(Cell::new(0)); + + // Use root().coords().x() to get same lens path that reconcile uses + { + let x = store.root().coords().x(); + let x_updates = x_updates.clone(); + Effect::new(move |_| { + x.get(); + x_updates.set(x_updates.get() + 1); + }); + } + { + let y = store.root().coords().y(); + let y_updates = y_updates.clone(); + Effect::new(move |_| { + y.get(); + y_updates.set(y_updates.get() + 1); + }); + } + { + let value = store.root().value(); + let value_updates = value_updates.clone(); + Effect::new(move |_| { + value.get(); + value_updates.set(value_updates.get() + 1); + }); + } + + assert_eq!(x_updates.get(), 1); + assert_eq!(y_updates.get(), 1); + assert_eq!(value_updates.get(), 1); + + // Reconcile with only coords.x changed + store.root().reconcile(&ReconcileOuter { + coords: ReconcileCoords { x: 10.0, y: 2.0 }, // Only x changed + value: 100, + }); + Runtime::drain_pending_work(); + + assert_eq!(x_updates.get(), 2); // Changed + assert_eq!(y_updates.get(), 1); // No change + assert_eq!(value_updates.get(), 1); // No change + + assert!((store.root().coords().x().get() - 10.0).abs() < 0.001); + assert!((store.root().coords().y().get() - 2.0).abs() < 0.001); + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct ReconcileWithVec { + #[nested] + entries: Vec, + count: i32, + } + + #[test] + fn test_reconcile_with_vec() { + let store = ReconcileWithVecStore::new(ReconcileWithVec { + entries: vec![ + VecItem { name: "A".into(), value: 1 }, + VecItem { name: "B".into(), value: 2 }, + ], + count: 10, + }); + + let entries_updates = Rc::new(Cell::new(0)); + let count_updates = Rc::new(Cell::new(0)); + + // Use root().field_name() to get same lens path that reconcile uses + { + let entries = store.root().entries(); + let entries_updates = entries_updates.clone(); + Effect::new(move |_| { + entries.with(|_| {}); + entries_updates.set(entries_updates.get() + 1); + }); + } + { + let count = store.root().count(); + let count_updates = count_updates.clone(); + Effect::new(move |_| { + count.get(); + count_updates.set(count_updates.get() + 1); + }); + } + + assert_eq!(entries_updates.get(), 1); + assert_eq!(count_updates.get(), 1); + + // Reconcile with same vec - no update + store.root().reconcile(&ReconcileWithVec { + entries: vec![ + VecItem { name: "A".into(), value: 1 }, + VecItem { name: "B".into(), value: 2 }, + ], + count: 10, + }); + Runtime::drain_pending_work(); + + assert_eq!(entries_updates.get(), 1); // No change + assert_eq!(count_updates.get(), 1); // No change + + // Reconcile with different vec + store.root().reconcile(&ReconcileWithVec { + entries: vec![ + VecItem { name: "A".into(), value: 1 }, + VecItem { name: "C".into(), value: 3 }, // Changed + ], + count: 10, + }); + Runtime::drain_pending_work(); + + assert_eq!(entries_updates.get(), 2); // Changed + assert_eq!(count_updates.get(), 1); // No change + + assert_eq!(store.root().entries().index(1).name().get(), "C"); + } + + #[test] + fn test_store_reconcile_shortcut() { + // Test that store.reconcile() works the same as store.root().reconcile() + let store = ReconcileTestStore::new(ReconcileTest { + count: 10, + name: "Original".into(), + flag: false, + }); + + let count_updates = Rc::new(Cell::new(0)); + + { + let count = store.count(); + let count_updates = count_updates.clone(); + Effect::new(move |_| { + count.get(); + count_updates.set(count_updates.get() + 1); + }); + } + + assert_eq!(count_updates.get(), 1); + + // Use store.reconcile() directly (shortcut for store.root().reconcile()) + store.reconcile(&ReconcileTest { + count: 42, // Changed + name: "Original".into(), + flag: false, + }); + Runtime::drain_pending_work(); + + // Effect should have run because count changed + assert_eq!(count_updates.get(), 2); + assert_eq!(store.count().get(), 42); + } + + // ===== DynBinding Tests ===== + + #[test] + fn test_dyn_binding_basic() { + use crate::DynBinding; + + let store = DeriveTestStateStore::new(DeriveTestState { + count: 10, + name: "Test".into(), + ..Default::default() + }); + + // Convert to DynBinding + let count_dyn: DynBinding = store.count().into_dyn(); + let name_dyn: DynBinding = store.name().into_dyn(); + + // Test get + assert_eq!(count_dyn.get(), 10); + assert_eq!(name_dyn.get(), "Test"); + + // Test set + count_dyn.set(20); + assert_eq!(count_dyn.get(), 20); + + // Test update + count_dyn.update(|c| *c += 5); + assert_eq!(count_dyn.get(), 25); + + // Test try_update + let old_value = count_dyn.try_update(|c| { + let old = *c; + *c = 100; + old + }); + assert_eq!(old_value, 25); + assert_eq!(count_dyn.get(), 100); + } + + #[test] + fn test_dyn_binding_clone() { + use crate::DynBinding; + + let store = DeriveTestStateStore::new(DeriveTestState::default()); + + let count_dyn: DynBinding = store.count().into_dyn(); + let count_dyn2 = count_dyn.clone(); + + count_dyn.set(42); + assert_eq!(count_dyn2.get(), 42); + } + + #[test] + fn test_dyn_binding_reactive_traits() { + use crate::DynBinding; + use floem_reactive::{SignalGet, SignalUpdate, SignalWith}; + + let store = DeriveTestStateStore::new(DeriveTestState { + count: 5, + ..Default::default() + }); + + let count_dyn: DynBinding = store.count().into_dyn(); + + // Test SignalGet + assert_eq!(SignalGet::get(&count_dyn), 5); + + // Test SignalUpdate + SignalUpdate::set(&count_dyn, 15); + assert_eq!(SignalGet::get(&count_dyn), 15); + + // Test SignalWith + let doubled = SignalWith::with(&count_dyn, |v| *v * 2); + assert_eq!(doubled, 30); + } + + #[test] + fn test_dyn_binding_as_function_param() { + use crate::DynBinding; + + // This is the main use case: passing bindings to functions without complex generics + fn increment_counter(counter: &DynBinding) { + counter.update(|c| *c += 1); + } + + fn get_doubled(counter: &DynBinding) -> i32 { + counter.get() * 2 + } + + let store = DeriveTestStateStore::new(DeriveTestState { + count: 10, + ..Default::default() + }); + + let count_dyn = store.count().into_dyn(); + + increment_counter(&count_dyn); + assert_eq!(count_dyn.get(), 11); + + assert_eq!(get_doubled(&count_dyn), 22); + } + + #[test] + fn test_dyn_binding_with_effect() { + use crate::DynBinding; + + let store = DeriveTestStateStore::new(DeriveTestState::default()); + let run_count = Rc::new(Cell::new(0)); + + let count_dyn: DynBinding = store.count().into_dyn(); + + { + let count_dyn = count_dyn.clone(); + let run_count = run_count.clone(); + Effect::new(move |_| { + count_dyn.get(); + run_count.set(run_count.get() + 1); + }); + } + + assert_eq!(run_count.get(), 1); + + // Update through DynBinding should trigger the effect + count_dyn.set(42); + Runtime::drain_pending_work(); + assert_eq!(run_count.get(), 2); + } + + // ===== Keyed Vec Reconciliation Tests ===== + + #[derive(Lenses, Default, Clone, PartialEq)] + struct KeyedItem { + id: u64, + text: String, + done: bool, + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct KeyedVecContainer { + #[nested(key = id)] // Type is inferred from KeyedItem::id + todos: Vec, + count: i32, + } + + #[test] + fn test_keyed_reconcile_same_structure_content_change() { + // Test that when structure (keys in same order) matches: + // - The Vec itself is NOT replaced (structure preserved) + // - Individual items are reconciled (only changed fields update) + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: false }, + ], + count: 0, + }); + + let todos_updates = Rc::new(Cell::new(0)); + + // Subscribe to the whole items array + { + let items = store.root().todos(); + let todos_updates = todos_updates.clone(); + Effect::new(move |_| { + items.with(|_| {}); + todos_updates.set(todos_updates.get() + 1); + }); + } + + // Initial effect run + assert_eq!(todos_updates.get(), 1); + + // Reconcile with same structure but changed content for item 1 only + store.root().reconcile(&KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, // Unchanged + KeyedItem { id: 2, text: "Modified".into(), done: false }, // Changed text + ], + count: 0, + }); + Runtime::drain_pending_work(); + + // Key guarantee: Vec itself should NOT be replaced (same structure) + // So the Vec-level effect should NOT run again + assert_eq!(todos_updates.get(), 1); + + // Verify the actual values are correct + assert_eq!(store.todos().index(0).text().get(), "First"); // Unchanged + assert_eq!(store.todos().index(1).text().get(), "Modified"); // Updated + } + + #[test] + fn test_keyed_reconcile_structural_change_reorder() { + // Test that reordering items (structure change) replaces the whole Vec + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: false }, + ], + count: 0, + }); + + let todos_updates = Rc::new(Cell::new(0)); + + { + let items = store.root().todos(); + let todos_updates = todos_updates.clone(); + Effect::new(move |_| { + items.with(|_| {}); + todos_updates.set(todos_updates.get() + 1); + }); + } + + assert_eq!(todos_updates.get(), 1); + + // Reconcile with reordered items (structure change) + store.root().reconcile(&KeyedVecContainer { + todos: vec![ + KeyedItem { id: 2, text: "Second".into(), done: false }, // Was at index 1 + KeyedItem { id: 1, text: "First".into(), done: false }, // Was at index 0 + ], + count: 0, + }); + Runtime::drain_pending_work(); + + // Items array should be updated (structural change) + assert_eq!(todos_updates.get(), 2); + + // Verify the new order + assert_eq!(store.todos().index(0).id().get(), 2); + assert_eq!(store.todos().index(1).id().get(), 1); + } + + #[test] + fn test_keyed_reconcile_structural_change_add_remove() { + // Test that adding/removing items (structure change) replaces the whole Vec + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: false }, + ], + count: 0, + }); + + let todos_updates = Rc::new(Cell::new(0)); + + { + let items = store.root().todos(); + let todos_updates = todos_updates.clone(); + Effect::new(move |_| { + items.with(|_| {}); + todos_updates.set(todos_updates.get() + 1); + }); + } + + assert_eq!(todos_updates.get(), 1); + + // Reconcile with an added item (structure change) + store.root().reconcile(&KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: false }, + KeyedItem { id: 3, text: "Third".into(), done: false }, // New item + ], + count: 0, + }); + Runtime::drain_pending_work(); + + // Items array should be updated (structural change) + assert_eq!(todos_updates.get(), 2); + assert_eq!(store.todos().len(), 3); + + // Reconcile with a removed item (structure change) + store.root().reconcile(&KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + ], + count: 0, + }); + Runtime::drain_pending_work(); + + // Items array should be updated again + assert_eq!(todos_updates.get(), 3); + assert_eq!(store.todos().len(), 1); + } + + #[test] + fn test_keyed_reconcile_no_change() { + // Test that no updates happen when nothing changed + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: true }, + ], + count: 5, + }); + + let todos_updates = Rc::new(Cell::new(0)); + let item0_text_updates = Rc::new(Cell::new(0)); + let count_updates = Rc::new(Cell::new(0)); + + { + let items = store.root().todos(); + let todos_updates = todos_updates.clone(); + Effect::new(move |_| { + items.with(|_| {}); + todos_updates.set(todos_updates.get() + 1); + }); + } + { + let text = store.root().todos().index(0).text(); + let item0_text_updates = item0_text_updates.clone(); + Effect::new(move |_| { + text.get(); + item0_text_updates.set(item0_text_updates.get() + 1); + }); + } + { + let count = store.root().count(); + let count_updates = count_updates.clone(); + Effect::new(move |_| { + count.get(); + count_updates.set(count_updates.get() + 1); + }); + } + + assert_eq!(todos_updates.get(), 1); + assert_eq!(item0_text_updates.get(), 1); + assert_eq!(count_updates.get(), 1); + + // Reconcile with identical data + store.root().reconcile(&KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: true }, + ], + count: 5, + }); + Runtime::drain_pending_work(); + + // Nothing should be updated + assert_eq!(todos_updates.get(), 1); + assert_eq!(item0_text_updates.get(), 1); + assert_eq!(count_updates.get(), 1); + } + + #[test] + fn test_per_index_effect_isolation() { + // Test that effects subscribed to different indices are isolated. + // Updating todos[1].text should NOT trigger effects on todos[0].text. + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: false }, + ], + count: 0, + }); + + let item0_text_updates = Rc::new(Cell::new(0)); + let item1_text_updates = Rc::new(Cell::new(0)); + + // Subscribe to item 0's text + { + let text = store.todos().index(0).text(); + let item0_text_updates = item0_text_updates.clone(); + Effect::new(move |_| { + text.get(); + item0_text_updates.set(item0_text_updates.get() + 1); + }); + } + + // Subscribe to item 1's text + { + let text = store.todos().index(1).text(); + let item1_text_updates = item1_text_updates.clone(); + Effect::new(move |_| { + text.get(); + item1_text_updates.set(item1_text_updates.get() + 1); + }); + } + + // Initial effect runs + assert_eq!(item0_text_updates.get(), 1); + assert_eq!(item1_text_updates.get(), 1); + + // Update only item 1's text + store.todos().index(1).text().set("Modified".into()); + Runtime::drain_pending_work(); + + // Only item 1's effect should run (per-index isolation!) + assert_eq!(item0_text_updates.get(), 1); // Unchanged + assert_eq!(item1_text_updates.get(), 2); // Updated + + // Update only item 0's text + store.todos().index(0).text().set("Also Modified".into()); + Runtime::drain_pending_work(); + + // Only item 0's effect should run + assert_eq!(item0_text_updates.get(), 2); // Updated + assert_eq!(item1_text_updates.get(), 2); // Unchanged + } + + #[test] + fn test_per_key_effect_isolation() { + // Test that effects subscribed to different HashMap keys are isolated. + // Uses the existing MapContainer and MapEntry types defined in this module. + let mut initial_entries = HashMap::new(); + initial_entries.insert(1, MapEntry { name: "One".into(), score: 10 }); + initial_entries.insert(2, MapEntry { name: "Two".into(), score: 20 }); + + let store = MapContainerStore::new(MapContainer { entries: initial_entries }); + + let key1_score_updates = Rc::new(Cell::new(0)); + let key2_score_updates = Rc::new(Cell::new(0)); + + // Subscribe to key 1's score + { + let score = store.entries().key(1).score(); + let key1_score_updates = key1_score_updates.clone(); + Effect::new(move |_| { + score.get(); + key1_score_updates.set(key1_score_updates.get() + 1); + }); + } + + // Subscribe to key 2's score + { + let score = store.entries().key(2).score(); + let key2_score_updates = key2_score_updates.clone(); + Effect::new(move |_| { + score.get(); + key2_score_updates.set(key2_score_updates.get() + 1); + }); + } + + // Initial effect runs + assert_eq!(key1_score_updates.get(), 1); + assert_eq!(key2_score_updates.get(), 1); + + // Update only key 2's score + store.entries().key(2).score().set(200); + Runtime::drain_pending_work(); + + // Only key 2's effect should run (per-key isolation!) + assert_eq!(key1_score_updates.get(), 1); // Unchanged + assert_eq!(key2_score_updates.get(), 2); // Updated + + // Update only key 1's score + store.entries().key(1).score().set(100); + Runtime::drain_pending_work(); + + // Only key 1's effect should run + assert_eq!(key1_score_updates.get(), 2); // Updated + assert_eq!(key2_score_updates.get(), 2); // Unchanged + } + + // ===== Identity-Based Vec Access Tests (by_key) ===== + + #[test] + fn test_by_id_basic_access() { + // Test that by_id can access items by their key + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: true }, + KeyedItem { id: 3, text: "Third".into(), done: false }, + ], + count: 0, + }); + + // Access by id + assert_eq!(store.todos().by_id(1).text().get(), "First"); + assert_eq!(store.todos().by_id(2).text().get(), "Second"); + assert_eq!(store.todos().by_id(3).text().get(), "Third"); + + // Check done status + assert!(!store.todos().by_id(1).done().get()); + assert!(store.todos().by_id(2).done().get()); + } + + #[test] + fn test_by_id_update() { + // Test that by_id can update items + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: false }, + ], + count: 0, + }); + + // Update via by_id + store.todos().by_id(2).text().set("Updated".into()); + store.todos().by_id(1).done().set(true); + + // Verify updates + assert_eq!(store.todos().by_id(2).text().get(), "Updated"); + assert!(store.todos().by_id(1).done().get()); + + // Position should still be the same + assert_eq!(store.todos().index(0).id().get(), 1); + assert_eq!(store.todos().index(1).id().get(), 2); + } + + #[test] + fn test_by_id_stable_after_reorder() { + // Test that by_id bindings stay on the same logical item after reorder + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: false }, + KeyedItem { id: 3, text: "Third".into(), done: false }, + ], + count: 0, + }); + + // Get a binding by id + let item2_text = store.todos().by_id(2).text(); + assert_eq!(item2_text.get(), "Second"); + + // Reorder the vec - move item 2 to the front + store.todos().update(|v| { + let item = v.remove(1); // Remove item with id=2 + v.insert(0, item); // Insert at front + }); + + // The by_id binding should still point to the same logical item + // (even though its position changed from index 1 to index 0) + assert_eq!(item2_text.get(), "Second"); + + // Verify the new order + assert_eq!(store.todos().index(0).id().get(), 2); // id=2 is now at index 0 + assert_eq!(store.todos().index(1).id().get(), 1); // id=1 is now at index 1 + assert_eq!(store.todos().index(2).id().get(), 3); // id=3 is still at index 2 + } + + #[test] + fn test_by_id_effect_isolation() { + // Test that effects on by_id bindings are isolated by key, not position + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: false }, + ], + count: 0, + }); + + let id1_text_updates = Rc::new(Cell::new(0)); + let id2_text_updates = Rc::new(Cell::new(0)); + + // Subscribe to item 1's text via by_id + { + let text = store.todos().by_id(1).text(); + let id1_text_updates = id1_text_updates.clone(); + Effect::new(move |_| { + text.get(); + id1_text_updates.set(id1_text_updates.get() + 1); + }); + } + + // Subscribe to item 2's text via by_id + { + let text = store.todos().by_id(2).text(); + let id2_text_updates = id2_text_updates.clone(); + Effect::new(move |_| { + text.get(); + id2_text_updates.set(id2_text_updates.get() + 1); + }); + } + + // Initial effect runs + assert_eq!(id1_text_updates.get(), 1); + assert_eq!(id2_text_updates.get(), 1); + + // Update only item 2's text via by_id + store.todos().by_id(2).text().set("Modified".into()); + Runtime::drain_pending_work(); + + // Only item 2's effect should run (identity-based isolation!) + assert_eq!(id1_text_updates.get(), 1); // Unchanged + assert_eq!(id2_text_updates.get(), 2); // Updated + + // Update only item 1's text via by_id + store.todos().by_id(1).text().set("Also Modified".into()); + Runtime::drain_pending_work(); + + // Only item 1's effect should run + assert_eq!(id1_text_updates.get(), 2); // Updated + assert_eq!(id2_text_updates.get(), 2); // Unchanged + } + + #[test] + fn test_by_id_helper_methods() { + // Test contains_key and remove_by_key helper methods + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: false }, + KeyedItem { id: 3, text: "Third".into(), done: false }, + ], + count: 0, + }); + + // Test contains_key + assert!(store.todos().contains_key(&1)); + assert!(store.todos().contains_key(&2)); + assert!(store.todos().contains_key(&3)); + assert!(!store.todos().contains_key(&99)); + + // Test remove_by_key + let removed = store.todos().remove_by_key(&2); + assert!(removed.is_some()); + assert_eq!(removed.unwrap().text, "Second"); + + // Verify removal + assert!(!store.todos().contains_key(&2)); + assert_eq!(store.todos().len(), 2); + + // Remaining items + assert!(store.todos().contains_key(&1)); + assert!(store.todos().contains_key(&3)); + } + + #[test] + fn test_filtered_bindings() { + // Test filtered_bindings helper method for dyn_stack integration + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: true }, + KeyedItem { id: 3, text: "Third".into(), done: false }, + ], + count: 0, + }); + + // Get all bindings (collect the iterator) + let all: Vec<_> = store.todos().all_bindings().collect(); + assert_eq!(all.len(), 3); + assert_eq!(all[0].id().get(), 1); + assert_eq!(all[1].id().get(), 2); + assert_eq!(all[2].id().get(), 3); + + // Filter for only not-done items + let active: Vec<_> = store.todos().filtered_bindings(|item| !item.done).collect(); + assert_eq!(active.len(), 2); + assert_eq!(active[0].id().get(), 1); + assert_eq!(active[1].id().get(), 3); + + // Filter for only done items + let completed: Vec<_> = store.todos().filtered_bindings(|item| item.done).collect(); + assert_eq!(completed.len(), 1); + assert_eq!(completed[0].id().get(), 2); + + // Bindings are reactive - update through them + active[0].text().set("Updated First".into()); + assert_eq!(store.todos().by_id(1).text().get(), "Updated First"); + + // Empty filter + let none: Vec<_> = store.todos().filtered_bindings(|item| item.id > 100).collect(); + assert_eq!(none.len(), 0); + } + + // ===== IndexMap Tests ===== + + #[derive(Lenses, Default, Clone, PartialEq)] + struct IndexMapTestItem { + id: u64, + name: String, + done: bool, + } + + #[derive(Lenses, Default, Clone, PartialEq)] + struct IndexMapTestState { + #[nested(key = id)] + items: crate::IndexMap, + } + + #[test] + fn test_indexmap_nested_basic() { + let mut items = crate::IndexMap::new(); + items.insert(1, IndexMapTestItem { id: 1, name: "First".into(), done: false }); + items.insert(2, IndexMapTestItem { id: 2, name: "Second".into(), done: true }); + + let store = IndexMapTestStateStore::new(IndexMapTestState { items }); + + // Check length + assert_eq!(store.items().len(), 2); + + // Get by key (O(1) access) + let item1 = store.items().get(1); + assert_eq!(item1.name().get(), "First"); + assert_eq!(item1.done().get(), false); + + let item2 = store.items().get(2); + assert_eq!(item2.name().get(), "Second"); + assert_eq!(item2.done().get(), true); + + // Update through binding + item1.done().set(true); + assert_eq!(store.items().get(1).done().get(), true); + } + + #[test] + fn test_indexmap_push() { + let store = IndexMapTestStateStore::new(IndexMapTestState::default()); + + // Push extracts key from value's id field + store.items().push(IndexMapTestItem { id: 10, name: "Item 10".into(), done: false }); + store.items().push(IndexMapTestItem { id: 20, name: "Item 20".into(), done: true }); + + assert_eq!(store.items().len(), 2); + assert_eq!(store.items().get(10).name().get(), "Item 10"); + assert_eq!(store.items().get(20).name().get(), "Item 20"); + + // Insertion order preserved + let all: Vec<_> = store.items().all_bindings().collect(); + assert_eq!(all[0].id().get(), 10); + assert_eq!(all[1].id().get(), 20); + } + + #[test] + fn test_indexmap_filtered_bindings() { + let mut items = crate::IndexMap::new(); + items.insert(1, IndexMapTestItem { id: 1, name: "First".into(), done: false }); + items.insert(2, IndexMapTestItem { id: 2, name: "Second".into(), done: true }); + items.insert(3, IndexMapTestItem { id: 3, name: "Third".into(), done: false }); + + let store = IndexMapTestStateStore::new(IndexMapTestState { items }); + + // Filter for not-done items + let active: Vec<_> = store.items().filtered_bindings(|item| !item.done).collect(); + assert_eq!(active.len(), 2); + assert_eq!(active[0].id().get(), 1); + assert_eq!(active[1].id().get(), 3); + + // Filter for done items + let completed: Vec<_> = store.items().filtered_bindings(|item| item.done).collect(); + assert_eq!(completed.len(), 1); + assert_eq!(completed[0].id().get(), 2); + } + + #[test] + fn test_indexmap_remove_by_key() { + let mut items = crate::IndexMap::new(); + items.insert(1, IndexMapTestItem { id: 1, name: "First".into(), done: false }); + items.insert(2, IndexMapTestItem { id: 2, name: "Second".into(), done: true }); + + let store = IndexMapTestStateStore::new(IndexMapTestState { items }); + + // Remove by key (O(1)) + let removed = store.items().remove_by_key(&2); + assert!(removed.is_some()); + assert_eq!(removed.unwrap().name, "Second"); + + assert_eq!(store.items().len(), 1); + assert!(!store.items().contains_key(&2)); + assert!(store.items().contains_key(&1)); + } + + #[test] + fn test_indexmap_o1_vs_vec() { + // This test demonstrates O(1) access with IndexMap vs Vec's by_id which is O(N) + // Both have the same API thanks to the derive macro, but IndexMap is faster + + // IndexMap version + let mut items = crate::IndexMap::new(); + for i in 1..=100 { + items.insert(i, IndexMapTestItem { id: i, name: format!("Item {}", i), done: false }); + } + let store = IndexMapTestStateStore::new(IndexMapTestState { items }); + + // Access last item - O(1) with IndexMap + let item = store.items().get(100); + assert_eq!(item.name().get(), "Item 100"); + + // Update it + item.done().set(true); + assert!(store.items().get(100).done().get()); + } + + // ===== Lazy Cache Tests ===== + + #[test] + fn test_lazy_cache_basic_access() { + // Test that lazy cache works for basic access - position is cached at creation time + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: false }, + KeyedItem { id: 3, text: "Third".into(), done: false }, + ], + count: 0, + }); + + // Get binding for item with id=2 (at position 1) + // The cached_pos should be 1 + let item2 = store.todos().by_id(2); + assert_eq!(item2.text().get(), "Second"); + + // Access should work via cached position (O(1)) + assert_eq!(item2.id().get(), 2); + assert!(!item2.done().get()); + + // Update should also work + item2.text().set("Modified".into()); + assert_eq!(item2.text().get(), "Modified"); + } + + #[test] + fn test_lazy_cache_fallback_after_reorder() { + // Test that lazy cache falls back to O(N) search when item moves + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: false }, + KeyedItem { id: 3, text: "Third".into(), done: false }, + ], + count: 0, + }); + + // Get binding for item with id=2 (at position 1) + // This caches position 1 + let item2 = store.todos().by_id(2); + assert_eq!(item2.text().get(), "Second"); + + // Now reorder the Vec - move item with id=3 to the front + store.todos().update(|v| { + let item = v.remove(2); // Remove id=3 from position 2 + v.insert(0, item); // Insert at position 0 + }); + + // New order: [id=3, id=1, id=2] + // item2's cached_pos (1) now points to id=1, not id=2 + // But the fallback should still find the correct item + + // Verify the new order + assert_eq!(store.todos().index(0).id().get(), 3); + assert_eq!(store.todos().index(1).id().get(), 1); + assert_eq!(store.todos().index(2).id().get(), 2); + + // item2 binding should STILL work - it falls back to O(N) search + assert_eq!(item2.text().get(), "Second"); + assert_eq!(item2.id().get(), 2); + + // Update through the binding should still work + item2.done().set(true); + assert!(store.todos().by_id(2).done().get()); + } + + #[test] + fn test_lazy_cache_filtered_bindings_positions() { + // Test that filtered_bindings captures correct positions for each item + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "First".into(), done: false }, + KeyedItem { id: 2, text: "Second".into(), done: true }, + KeyedItem { id: 3, text: "Third".into(), done: false }, + KeyedItem { id: 4, text: "Fourth".into(), done: true }, + ], + count: 0, + }); + + // Get bindings for done items + // These should have cached positions: id=2 at pos 1, id=4 at pos 3 + let done_items: Vec<_> = store.todos().filtered_bindings(|item| item.done).collect(); + assert_eq!(done_items.len(), 2); + + // Verify they point to correct items + assert_eq!(done_items[0].id().get(), 2); + assert_eq!(done_items[0].text().get(), "Second"); + assert_eq!(done_items[1].id().get(), 4); + assert_eq!(done_items[1].text().get(), "Fourth"); + + // Update through these bindings + done_items[0].text().set("Second Modified".into()); + done_items[1].text().set("Fourth Modified".into()); + + // Verify updates + assert_eq!(store.todos().by_id(2).text().get(), "Second Modified"); + assert_eq!(store.todos().by_id(4).text().get(), "Fourth Modified"); + } + + #[test] + fn test_lazy_cache_all_bindings() { + // Test that all_bindings captures positions for each item + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 10, text: "Item 10".into(), done: false }, + KeyedItem { id: 20, text: "Item 20".into(), done: false }, + KeyedItem { id: 30, text: "Item 30".into(), done: false }, + ], + count: 0, + }); + + let all: Vec<_> = store.todos().all_bindings().collect(); + assert_eq!(all.len(), 3); + + // Each binding should have correct cached position + assert_eq!(all[0].id().get(), 10); + assert_eq!(all[1].id().get(), 20); + assert_eq!(all[2].id().get(), 30); + + // Updates should work through each binding + for binding in &all { + binding.done().set(true); + } + + // Verify all items are now done + assert!(store.todos().by_id(10).done().get()); + assert!(store.todos().by_id(20).done().get()); + assert!(store.todos().by_id(30).done().get()); + } + + #[test] + fn test_lazy_cache_multiple_reorders() { + // Test that bindings work correctly through multiple reorders + let store = KeyedVecContainerStore::new(KeyedVecContainer { + todos: vec![ + KeyedItem { id: 1, text: "A".into(), done: false }, + KeyedItem { id: 2, text: "B".into(), done: false }, + KeyedItem { id: 3, text: "C".into(), done: false }, + ], + count: 0, + }); + + // Get binding for middle item + let item2 = store.todos().by_id(2); + assert_eq!(item2.text().get(), "B"); + + // First reorder: reverse + store.todos().update(|v| v.reverse()); + // New order: [3, 2, 1] + assert_eq!(item2.text().get(), "B"); // Still works + + // Second reorder: sort by id + store.todos().update(|v| v.sort_by_key(|item| item.id)); + // New order: [1, 2, 3] + assert_eq!(item2.text().get(), "B"); // Still works + + // Third reorder: move to end + store.todos().update(|v| { + let item = v.remove(1); // Remove id=2 + v.push(item); // Push to end + }); + // New order: [1, 3, 2] + assert_eq!(item2.text().get(), "B"); // Still works + + // Update still works + item2.text().set("B Modified".into()); + assert_eq!(store.todos().index(2).text().get(), "B Modified"); + } +} diff --git a/store/src/traits.rs b/store/src/traits.rs new file mode 100644 index 000000000..968eca1a2 --- /dev/null +++ b/store/src/traits.rs @@ -0,0 +1,126 @@ +//! Implementation of floem_reactive traits for Binding. +//! +//! This allows Bindings to be used interchangeably with Signals in generic code. + +use floem_reactive::{ReactiveId, SignalGet, SignalTrack, SignalUpdate, SignalWith}; + +use crate::{binding::Binding, lens::Lens}; + +// Binding doesn't use the reactive runtime's Id system for storage. +// The id() method on traits shouldn't be called for Binding in practice, +// but we need to provide an implementation. +fn binding_id_unsupported() -> ReactiveId { + panic!( + "Binding does not use ReactiveId. \ + Use Binding's native methods instead of id()-based operations." + ) +} + +impl> SignalGet for Binding { + fn id(&self) -> ReactiveId { + binding_id_unsupported() + } + + fn get(&self) -> T + where + T: 'static, + { + Binding::get(self) + } + + fn get_untracked(&self) -> T + where + T: 'static, + { + Binding::get_untracked(self) + } + + fn try_get(&self) -> Option + where + T: 'static, + { + Some(Binding::get(self)) + } + + fn try_get_untracked(&self) -> Option + where + T: 'static, + { + Some(Binding::get_untracked(self)) + } +} + +impl> SignalWith for Binding { + fn id(&self) -> ReactiveId { + binding_id_unsupported() + } + + fn with(&self, f: impl FnOnce(&T) -> O) -> O + where + T: 'static, + { + Binding::with(self, f) + } + + fn with_untracked(&self, f: impl FnOnce(&T) -> O) -> O + where + T: 'static, + { + Binding::with_untracked(self, f) + } + + fn try_with(&self, f: impl FnOnce(Option<&T>) -> O) -> O + where + T: 'static, + { + Binding::with(self, |v| f(Some(v))) + } + + fn try_with_untracked(&self, f: impl FnOnce(Option<&T>) -> O) -> O + where + T: 'static, + { + Binding::with_untracked(self, |v| f(Some(v))) + } +} + +impl> SignalUpdate for Binding { + fn id(&self) -> ReactiveId { + binding_id_unsupported() + } + + fn set(&self, new_value: T) + where + T: 'static, + { + Binding::set(self, new_value); + } + + fn update(&self, f: impl FnOnce(&mut T)) + where + T: 'static, + { + Binding::update(self, f); + } + + fn try_update(&self, f: impl FnOnce(&mut T) -> O) -> Option + where + T: 'static, + { + Some(Binding::try_update(self, f)) + } +} + +impl> SignalTrack for Binding { + fn id(&self) -> ReactiveId { + binding_id_unsupported() + } + + fn track(&self) { + self.subscribe_current_effect(); + } + + fn try_track(&self) { + self.subscribe_current_effect(); + } +}