From e4297d0e095d9086fbeb0f5abee22ea74b1c1df9 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Mon, 27 Oct 2025 13:39:48 -0500 Subject: [PATCH] Readable and Writable helpers for String, HashMap and HashSet (#4834) * Add more read/write methods for string signals * add helpers for HashMap and HashSet as well * fix typo * fix conflicting string and str extensions * fix counters example --- examples/01-app-demos/calculator.rs | 8 +- examples/01-app-demos/counters.rs | 6 +- examples/01-app-demos/todomvc.rs | 12 +- examples/01-app-demos/todomvc_store.rs | 2 +- examples/01-app-demos/websocket_chat.rs | 2 +- examples/07-fullstack/server_sent_events.rs | 2 +- examples/07-fullstack/streaming.rs | 10 +- examples/08-apis/control_focus.rs | 2 +- examples/08-apis/file_upload.rs | 6 +- examples/08-apis/window_popup.rs | 2 +- packages/signals/src/lib.rs | 29 ++++ packages/signals/src/read.rs | 105 ++++++++++++- packages/signals/src/write.rs | 155 +++++++++++++++++++- 13 files changed, 308 insertions(+), 33 deletions(-) diff --git a/examples/01-app-demos/calculator.rs b/examples/01-app-demos/calculator.rs index 200c756e2..5e6df0900 100644 --- a/examples/01-app-demos/calculator.rs +++ b/examples/01-app-demos/calculator.rs @@ -34,15 +34,15 @@ fn app() -> Element { if val() == "0" { val.set(String::new()); } - val.write().push_str(num.as_str()); + val.push_str(num.as_str()); }; - let mut input_operator = move |key: &str| val.write().push_str(key); + let mut input_operator = move |key: &str| val.push_str(key); let handle_key_down_event = move |evt: KeyboardEvent| match evt.key() { Key::Backspace => { if !val().is_empty() { - val.write().pop(); + val.pop(); } } Key::Character(character) => match character.as_str() { @@ -104,7 +104,7 @@ fn app() -> Element { } button { class: "calculator-key key-dot", - onclick: move |_| val.write().push('.'), + onclick: move |_| val.push('.'), "●" } for k in 1..10 { diff --git a/examples/01-app-demos/counters.rs b/examples/01-app-demos/counters.rs index 8487a2b97..d42b9af21 100644 --- a/examples/01-app-demos/counters.rs +++ b/examples/01-app-demos/counters.rs @@ -19,8 +19,8 @@ fn app() -> Element { Stylesheet { href: STYLE } div { id: "controls", - button { onclick: move |_| counters.write().push(0), "Add counter" } - button { onclick: move |_| { counters.write().pop(); }, "Remove counter" } + button { onclick: move |_| counters.push(0), "Add counter" } + button { onclick: move |_| { counters.pop(); }, "Remove counter" } } h3 { "Total: {sum}" } @@ -47,7 +47,7 @@ fn app() -> Element { } } button { onclick: move |_| counters.write()[i] += 1, "+1" } - button { onclick: move |_| { counters.write().remove(i); }, "x" } + button { onclick: move |_| { counters.remove(i); }, "x" } } } } diff --git a/examples/01-app-demos/todomvc.rs b/examples/01-app-demos/todomvc.rs index 1c33b71c4..508b12531 100644 --- a/examples/01-app-demos/todomvc.rs +++ b/examples/01-app-demos/todomvc.rs @@ -115,13 +115,13 @@ fn TodoHeader(mut todos: WriteSignal>) -> Element { let mut todo_id = use_signal(|| 0); let onkeydown = move |evt: KeyboardEvent| { - if evt.key() == Key::Enter && !draft.read().is_empty() { + if evt.key() == Key::Enter && !draft.is_empty() { let id = todo_id(); let todo = TodoItem { checked: false, contents: draft.to_string(), }; - todos.write().insert(id, todo); + todos.insert(id, todo); todo_id += 1; draft.set("".to_string()); } @@ -169,7 +169,7 @@ fn TodoEntry(mut todos: WriteSignal>, id: u32) -> Element r#type: "checkbox", id: "cbg-{id}", checked: "{checked}", - oninput: move |evt| todos.write().get_mut(&id).unwrap().checked = evt.checked() + oninput: move |evt| todos.get_mut(&id).unwrap().checked = evt.checked() } label { r#for: "cbg-{id}", @@ -181,7 +181,7 @@ fn TodoEntry(mut todos: WriteSignal>, id: u32) -> Element class: "destroy", onclick: move |evt| { evt.prevent_default(); - todos.write().remove(&id); + todos.remove(&id); }, } } @@ -191,7 +191,7 @@ fn TodoEntry(mut todos: WriteSignal>, id: u32) -> Element input { class: "edit", value: "{contents}", - oninput: move |evt| todos.write().get_mut(&id).unwrap().contents = evt.value(), + oninput: move |evt| todos.get_mut(&id).unwrap().contents = evt.value(), autofocus: "true", onfocusout: move |_| is_editing.set(false), onkeydown: move |evt| { @@ -250,7 +250,7 @@ fn ListFooter( if show_clear_completed() { button { class: "clear-completed", - onclick: move |_| todos.write().retain(|_, todo| !todo.checked), + onclick: move |_| todos.retain(|_, todo| !todo.checked), "Clear completed" } } diff --git a/examples/01-app-demos/todomvc_store.rs b/examples/01-app-demos/todomvc_store.rs index 0fd02249b..35e432757 100644 --- a/examples/01-app-demos/todomvc_store.rs +++ b/examples/01-app-demos/todomvc_store.rs @@ -175,7 +175,7 @@ fn TodoHeader(mut todos: Store) -> Element { let mut todo_id = use_signal(|| 0); let onkeydown = move |evt: KeyboardEvent| { - if evt.key() == Key::Enter && !draft.read().is_empty() { + if evt.key() == Key::Enter && !draft.is_empty() { let id = todo_id(); let todo = TodoItem::new(draft.take()); todos.todos().insert(id, todo); diff --git a/examples/01-app-demos/websocket_chat.rs b/examples/01-app-demos/websocket_chat.rs index 28b770900..249dee035 100644 --- a/examples/01-app-demos/websocket_chat.rs +++ b/examples/01-app-demos/websocket_chat.rs @@ -46,7 +46,7 @@ fn app() -> Element { use_future(move || async move { while let Ok(msg) = socket.recv().await { match msg { - ServerEvent::ReceiveMessage(message) => message_list.write().push(message), + ServerEvent::ReceiveMessage(message) => message_list.push(message), ServerEvent::Connected { messages } => message_list.set(messages), } } diff --git a/examples/07-fullstack/server_sent_events.rs b/examples/07-fullstack/server_sent_events.rs index f08226254..103c6c37b 100644 --- a/examples/07-fullstack/server_sent_events.rs +++ b/examples/07-fullstack/server_sent_events.rs @@ -38,7 +38,7 @@ fn app() -> Element { // And then poll it for new events, adding them to our signal while let Some(Ok(event)) = stream.recv().await { - events.write().push(event); + events.push(event); } anyhow::Ok(()) diff --git a/examples/07-fullstack/streaming.rs b/examples/07-fullstack/streaming.rs index f8d4deaa9..e11d04f12 100644 --- a/examples/07-fullstack/streaming.rs +++ b/examples/07-fullstack/streaming.rs @@ -31,23 +31,23 @@ fn app() -> Element { let mut json_responses = use_signal(Vec::new); let mut start_text_stream = use_action(move || async move { - text_responses.write().clear(); + text_responses.clear(); let mut stream = text_stream(Some(100)).await?; while let Some(Ok(text)) = stream.next().await { - text_responses.write().push_str(&text); - text_responses.write().push('\n'); + text_responses.push_str(&text); + text_responses.push('\n'); } dioxus::Ok(()) }); let mut start_json_stream = use_action(move || async move { - json_responses.write().clear(); + json_responses.clear(); let mut stream = json_stream().await?; while let Some(Ok(dog)) = stream.next().await { - json_responses.write().push(dog); + json_responses.push(dog); } dioxus::Ok(()) diff --git a/examples/08-apis/control_focus.rs b/examples/08-apis/control_focus.rs index 156b10dd8..b488b6b74 100644 --- a/examples/08-apis/control_focus.rs +++ b/examples/08-apis/control_focus.rs @@ -56,7 +56,7 @@ fn app() -> Element { input { r#type: "number", value: "{i}", - onmounted: move |cx| elements.write().push(cx.data()), + onmounted: move |cx| elements.push(cx.data()), oninput: move |_| running.set(false), } } diff --git a/examples/08-apis/file_upload.rs b/examples/08-apis/file_upload.rs index 18b5d9e36..b05a7cf54 100644 --- a/examples/08-apis/file_upload.rs +++ b/examples/08-apis/file_upload.rs @@ -27,12 +27,12 @@ fn app() -> Element { for file in files { let filename = file.name(); if let Ok(contents) = file.read_string().await { - files_uploaded.write().push(UploadedFile { + files_uploaded.push(UploadedFile { name: filename, contents, }); } else { - files_uploaded.write().push(UploadedFile { + files_uploaded.push(UploadedFile { name: filename, contents: "Failed to read file".into(), }); @@ -45,7 +45,7 @@ fn app() -> Element { h1 { "File Upload Example" } p { "Drop a .txt, .rs, or .js file here to read it" } - button { onclick: move |_| files_uploaded.write().clear(), "Clear files" } + button { onclick: move |_| files_uploaded.clear(), "Clear files" } div { label { r#for: "directory-upload", "Enable directory upload" } diff --git a/examples/08-apis/window_popup.rs b/examples/08-apis/window_popup.rs index 675d99900..b2fcfcb6e 100644 --- a/examples/08-apis/window_popup.rs +++ b/examples/08-apis/window_popup.rs @@ -15,7 +15,7 @@ fn app() -> Element { let handle = use_coroutine(move |mut rx: UnboundedReceiver| async move { use futures_util::StreamExt; while let Some(message) = rx.next().await { - emails_sent.write().push(message); + emails_sent.push(message); } }); diff --git a/packages/signals/src/lib.rs b/packages/signals/src/lib.rs index 926cba75d..203b4fa6d 100644 --- a/packages/signals/src/lib.rs +++ b/packages/signals/src/lib.rs @@ -44,3 +44,32 @@ pub mod warnings; mod boxed; pub use boxed::*; + +/// A macro to define extension methods for signal types that call the method with either `with` or `with_mut` depending on the mutability of self. +macro_rules! ext_methods { + ( + $( + $(#[$meta:meta])* + fn $name:ident $(<$($gen:tt),*>)? (&$($self:ident)+ $(, $arg_name:ident: $arg_type:ty )* ) $(-> $ret:ty)? = $expr:expr; + )* + ) => { + $( + $(#[$meta])* + #[track_caller] + fn $name$(<$($gen),*>)? (& $($self)+ $(, $arg_name: $arg_type )* ) $(-> $ret)? + { + ext_methods!(@with $($self)+, $($arg_name),*; $expr) + } + )* + }; + + (@with mut $self:ident, $($arg_name:ident),*; $expr:expr) => { + $self.with_mut(|_self| ($expr)(_self, $($arg_name),*)) + }; + + (@with $self:ident, $($arg_name:ident),*; $expr:expr) => { + $self.with(|_self| ($expr)(_self, $($arg_name),*)) + }; +} + +pub(crate) use ext_methods; diff --git a/packages/signals/src/read.rs b/packages/signals/src/read.rs index 2604e2a88..42442f675 100644 --- a/packages/signals/src/read.rs +++ b/packages/signals/src/read.rs @@ -1,10 +1,13 @@ -use std::{mem::MaybeUninit, ops::Index}; +use std::collections::{HashMap, HashSet}; +use std::{ + mem::MaybeUninit, + ops::{Deref, Index}, +}; +use crate::{ext_methods, MappedSignal, ReadSignal}; use dioxus_core::Subscribers; use generational_box::{AnyStorage, UnsyncStorage}; -use crate::{MappedSignal, ReadSignal}; - /// A reference to a value that can be read from. #[allow(type_alias_bounds)] pub type ReadableRef<'a, T: Readable, O = ::Target> = @@ -423,3 +426,99 @@ pub trait ReadableResultExt: Readable> { } impl ReadableResultExt for R where R: Readable> {} + +/// An extension trait for [`Readable`] that provides some convenience methods. +pub trait ReadableStringExt: Readable { + ext_methods! { + /// Check the capacity of the string. + fn capacity(&self) -> usize = String::capacity; + } +} + +impl ReadableStringExt for W where W: Readable {} + +/// An extension trait for [`Readable`] and [`Readable`] that provides some convenience methods. +pub trait ReadableStrExt: Readable + 'static> { + ext_methods! { + /// Check if the string is empty. + fn is_empty(&self) -> bool = |s: &Self::Target| s.deref().is_empty(); + + /// Get the length of the string. + fn len(&self) -> usize = |s: &Self::Target| s.deref().len(); + + /// Check if the string contains the given pattern. + fn contains(&self, pat: &str) -> bool = |s: &Self::Target, pat| s.deref().contains(pat); + } +} + +impl ReadableStrExt for W where W: Readable + 'static> {} + +/// An extension trait for [`Readable>`] that provides some convenience methods. +pub trait ReadableHashMapExt: + Readable> +{ + ext_methods! { + /// Check if the hashmap is empty. + fn is_empty(&self) -> bool = HashMap::is_empty; + + /// Get the length of the hashmap. + fn len(&self) -> usize = HashMap::len; + + /// Get the capacity of the hashmap. + fn capacity(&self) -> usize = HashMap::capacity; + } + + /// Get the value for the given key. + #[track_caller] + fn get(&self, key: &K) -> Option> + where + K: std::hash::Hash + Eq, + H: std::hash::BuildHasher, + { + ::try_map(self.read(), |v| v.get(key)) + } + + /// Check if the hashmap contains the given key. + #[track_caller] + fn contains_key(&self, key: &K) -> bool + where + K: std::hash::Hash + Eq, + H: std::hash::BuildHasher, + { + self.with(|v| v.contains_key(key)) + } +} + +impl ReadableHashMapExt for R where + R: Readable> +{ +} + +/// An extension trait for [`Readable>`] that provides some convenience methods. +pub trait ReadableHashSetExt: Readable> { + ext_methods! { + /// Check if the hashset is empty. + fn is_empty(&self) -> bool = HashSet::is_empty; + + /// Get the length of the hashset. + fn len(&self) -> usize = HashSet::len; + + /// Get the capacity of the hashset. + fn capacity(&self) -> usize = HashSet::capacity; + } + + /// Check if the hashset contains the given value. + #[track_caller] + fn contains(&self, value: &V) -> bool + where + V: std::hash::Hash + Eq, + H: std::hash::BuildHasher, + { + self.with(|v| v.contains(value)) + } +} + +impl ReadableHashSetExt for R where + R: Readable> +{ +} diff --git a/packages/signals/src/write.rs b/packages/signals/src/write.rs index 461ca6ef9..13b4107fa 100644 --- a/packages/signals/src/write.rs +++ b/packages/signals/src/write.rs @@ -1,8 +1,11 @@ -use std::ops::{Deref, DerefMut, IndexMut}; +use std::{ + collections::{HashMap, HashSet}, + ops::{Deref, DerefMut, IndexMut}, +}; use generational_box::{AnyStorage, UnsyncStorage}; -use crate::{read::Readable, read::ReadableExt, MappedMutSignal, WriteSignal}; +use crate::{ext_methods, read::Readable, read::ReadableExt, MappedMutSignal, WriteSignal}; /// A reference to a value that can be written to. #[allow(type_alias_bounds)] @@ -59,7 +62,7 @@ pub trait Writable: Readable { /// # use dioxus::prelude::*; /// fn app() -> Element { /// let mut value = use_signal(|| String::from("hello")); -/// +/// /// rsx! { /// button { /// onclick: move |_| { @@ -578,4 +581,148 @@ impl<'a, T: 'static, R: Writable>> Iterator for WritableValueIte } } -impl WritableVecExt for W where W: Writable> {} +impl WritableVecExt for W where W: Writable> {} + +/// An extension trait for [`Writable`] that provides some convenience methods. +pub trait WritableStringExt: Writable { + ext_methods! { + /// Pushes a character to the end of the string. + fn push_str(&mut self, s: &str) = String::push_str; + + /// Pushes a character to the end of the string. + fn push(&mut self, c: char) = String::push; + + /// Pops a character from the end of the string. + fn pop(&mut self) -> Option = String::pop; + + /// Inserts a string at the given index. + fn insert_str(&mut self, idx: usize, s: &str) = String::insert_str; + + /// Inserts a character at the given index. + fn insert(&mut self, idx: usize, c: char) = String::insert; + + /// Remove a character at the given index + fn remove(&mut self, idx: usize) -> char = String::remove; + + /// Replace a range of the string with the given string. + fn replace_range(&mut self, range: impl std::ops::RangeBounds, replace_with: &str) = String::replace_range; + + /// Clears the string, removing all characters. + fn clear(&mut self) = String::clear; + + /// Extends the string with the given iterator of characters. + fn extend(&mut self, iter: impl IntoIterator) = String::extend; + + /// Truncates the string to the given length. + fn truncate(&mut self, len: usize) = String::truncate; + + /// Splits the string off at the given index, returning the tail as a new string. + fn split_off(&mut self, at: usize) -> String = String::split_off; + } +} + +impl WritableStringExt for W where W: Writable {} + +/// An extension trait for [`Writable>`] that provides some convenience methods. +pub trait WritableHashMapExt: + Writable> +{ + ext_methods! { + /// Clears the map, removing all key-value pairs. + fn clear(&mut self) = HashMap::clear; + + /// Retains only the key-value pairs that match the given predicate. + fn retain(&mut self, f: impl FnMut(&K, &mut V) -> bool) = HashMap::retain; + } + + /// Inserts a key-value pair into the map. If the key was already present, the old value is returned. + #[track_caller] + fn insert(&mut self, k: K, v: V) -> Option + where + K: std::cmp::Eq + std::hash::Hash, + H: std::hash::BuildHasher, + { + self.with_mut(|map: &mut HashMap| map.insert(k, v)) + } + + /// Extends the map with the key-value pairs from the given iterator. + #[track_caller] + fn extend(&mut self, iter: impl IntoIterator) + where + K: std::cmp::Eq + std::hash::Hash, + H: std::hash::BuildHasher, + { + self.with_mut(|map: &mut HashMap| map.extend(iter)) + } + + /// Removes a key from the map, returning the value at the key if the key was previously in the map. + #[track_caller] + fn remove(&mut self, k: &K) -> Option + where + K: std::cmp::Eq + std::hash::Hash, + H: std::hash::BuildHasher, + { + self.with_mut(|map: &mut HashMap| map.remove(k)) + } + + /// Get a mutable reference to the value at the given key. + #[track_caller] + fn get_mut(&mut self, k: &K) -> Option> + where + K: std::cmp::Eq + std::hash::Hash, + H: std::hash::BuildHasher, + { + WriteLock::filter_map(self.write(), |map: &mut HashMap| map.get_mut(k)) + } +} + +impl WritableHashMapExt for R where + R: Writable> +{ +} + +/// An extension trait for [`Writable>`] that provides some convenience methods. +pub trait WritableHashSetExt: Writable> { + ext_methods! { + /// Clear the hash set. + fn clear(&mut self) = HashSet::clear; + + /// Retain only the elements specified by the predicate. + fn retain(&mut self, f: impl FnMut(&V) -> bool) = HashSet::retain; + } + + /// Inserts a value into the set. Returns true if the value was not already present. + #[track_caller] + fn insert(&mut self, k: V) -> bool + where + V: std::cmp::Eq + std::hash::Hash, + H: std::hash::BuildHasher, + { + self.with_mut(|set| set.insert(k)) + } + + /// Extends the set with the values from the given iterator. + #[track_caller] + fn extend(&mut self, iter: impl IntoIterator) + where + V: std::cmp::Eq + std::hash::Hash, + H: std::hash::BuildHasher, + { + self.with_mut(|set| set.extend(iter)) + } + + /// Removes a value from the set. Returns true if the value was present. + #[track_caller] + fn remove(&mut self, k: &V) -> bool + where + V: std::cmp::Eq + std::hash::Hash, + H: std::hash::BuildHasher, + { + self.with_mut(|set| set.remove(k)) + } +} + +impl WritableHashSetExt for R where + R: Writable> +{ +}