From 418c2e63012a5b15d36e71bdd1f0160ac5b6fcac Mon Sep 17 00:00:00 2001 From: Samuel Guerra Date: Sat, 27 May 2023 22:58:59 -0300 Subject: [PATCH] Refactored `Txt` to represent text as an `Arc` too. Fixed `Txt` that contains a null character. --- examples/shortcuts.rs | 4 +- examples/text.rs | 2 +- zero-ui-core/src/event/command.rs | 2 +- zero-ui-core/src/image.rs | 2 +- zero-ui-core/src/inspector/prompt.rs | 4 +- zero-ui-core/src/l10n/service.rs | 6 +- zero-ui-core/src/text.rs | 282 +++++++++++++++--- zero-ui-core/src/var/tests.rs | 2 +- zero-ui/src/widgets/image/image_properties.rs | 2 +- zero-ui/src/widgets/markdown.rs | 2 +- zero-ui/src/widgets/window/commands.rs | 2 +- 11 files changed, 251 insertions(+), 59 deletions(-) diff --git a/examples/shortcuts.rs b/examples/shortcuts.rs index 5a59a53ee..7cc062034 100644 --- a/examples/shortcuts.rs +++ b/examples/shortcuts.rs @@ -17,8 +17,8 @@ fn main() { fn app_main() { App::default().run_window(async { - let shortcut_text = var(Txt::empty()); - let keypress_text = var(Txt::empty()); + let shortcut_text = var(Txt::from_str("")); + let keypress_text = var(Txt::from_str("")); let shortcut_error = var(false); // examples_util::trace_var!(ctx, ?shortcut_text); diff --git a/examples/text.rs b/examples/text.rs index 3e6697b91..faf428ccd 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -269,7 +269,7 @@ fn defaults() -> impl UiNode { ) .map(|f| match f.done() { Some(f) => f.best().family_name().to_text(), - None => Txt::empty(), + None => Txt::from_str(""), }); Stack! { diff --git a/zero-ui-core/src/event/command.rs b/zero-ui-core/src/event/command.rs index 9e26751c6..3d21d017f 100644 --- a/zero-ui-core/src/event/command.rs +++ b/zero-ui-core/src/event/command.rs @@ -959,7 +959,7 @@ pub trait CommandInfoExt { static COMMAND_INFO_ID: StaticCommandMetaVarId = StaticCommandMetaVarId::new_unique(); impl CommandInfoExt for Command { fn info(self) -> CommandMetaVar { - self.with_meta(|m| m.get_var_or_insert(&COMMAND_INFO_ID, Txt::empty)) + self.with_meta(|m| m.get_var_or_insert(&COMMAND_INFO_ID, Txt::default)) } fn init_info(self, info: impl Into) -> Self { diff --git a/zero-ui-core/src/image.rs b/zero-ui-core/src/image.rs index 89a4cc5de..597eea0c8 100644 --- a/zero-ui-core/src/image.rs +++ b/zero-ui-core/src/image.rs @@ -327,7 +327,7 @@ impl ImagesService { proxies: vec![], loading: vec![], decoding: vec![], - download_accept: Txt::empty(), + download_accept: Txt::from_static(""), cache: IdMap::new(), not_cached: vec![], render: render::ImagesRender::default(), diff --git a/zero-ui-core/src/inspector/prompt.rs b/zero-ui-core/src/inspector/prompt.rs index b2f968fe9..6bca684ff 100644 --- a/zero-ui-core/src/inspector/prompt.rs +++ b/zero-ui-core/src/inspector/prompt.rs @@ -299,7 +299,7 @@ mod print_fmt { Fmt { depth: 0, output, - property_group: Txt::empty(), + property_group: Txt::from_str(""), } } @@ -442,7 +442,7 @@ mod print_fmt { pub fn close_widget(&mut self, name: &str) { self.depth -= 1; - self.property_group = Txt::empty(); + self.property_group = Txt::from_str(""); self.write_tabs(); self.write("} ".bold()); self.write_comment_after(format_args!("{name}!")); diff --git a/zero-ui-core/src/l10n/service.rs b/zero-ui-core/src/l10n/service.rs index 5615b5669..b618987a1 100644 --- a/zero-ui-core/src/l10n/service.rs +++ b/zero-ui-core/src/l10n/service.rs @@ -71,7 +71,7 @@ impl L10nService { Ok(lang) => { // and it is named correctly. set.get_exact_or_insert(lang, Default::default) - .insert(Txt::empty(), dir.as_ref().unwrap().join(name_and_ext)); + .insert(Txt::from_str(""), dir.as_ref().unwrap().join(name_and_ext)); } Err(e) => { errors.push(Arc::new(e)); @@ -580,7 +580,7 @@ fn format_message(bundle: &ArcFluentBundle, id: &str, attribute: &str, args: &[( txt.to_text() } else { tracing::error!("found `{:?}/{id}`, but not value", &bundle.locales[0]); - Txt::empty() + Txt::from_str("") } } else { match msg.get_attribute(attribute) { @@ -597,7 +597,7 @@ fn format_message(bundle: &ArcFluentBundle, id: &str, attribute: &str, args: &[( } None => { tracing::error!("found `{:?}/{id}`, but not attribute `{attribute}`", &bundle.locales[0]); - Txt::empty() + Txt::from_str("") } } } diff --git a/zero-ui-core/src/text.rs b/zero-ui-core/src/text.rs index 26a1263de..e7640cda3 100644 --- a/zero-ui-core/src/text.rs +++ b/zero-ui-core/src/text.rs @@ -316,8 +316,8 @@ impl TextTransformFn { pub fn transform(&self, text: Txt) -> Txt { match self { TextTransformFn::None => text, - TextTransformFn::Uppercase => Txt::owned(text.to_uppercase()), - TextTransformFn::Lowercase => Txt::owned(text.to_lowercase()), + TextTransformFn::Uppercase => Txt::from_string(text.to_uppercase()), + TextTransformFn::Lowercase => Txt::from_string(text.to_lowercase()), TextTransformFn::Custom(fn_) => fn_(text), } } @@ -862,7 +862,8 @@ fn str_to_inline(s: &str) -> [u8; INLINE_MAX] { enum TxtData { Static(&'static str), Inline([u8; INLINE_MAX]), - Owned(String), + String(String), + Arc(Arc), } impl fmt::Debug for TxtData { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -870,7 +871,8 @@ impl fmt::Debug for TxtData { match self { Self::Static(s) => write!(f, "Static({s:?})"), Self::Inline(d) => write!(f, "Inline({:?})", inline_to_str(d)), - Self::Owned(s) => write!(f, "Owned({s:?})"), + Self::String(s) => write!(f, "String({s:?})"), + Self::Arc(s) => write!(f, "Arc({s:?})"), } } else { write!(f, "{:?}", self.deref()) @@ -900,30 +902,99 @@ impl Deref for TxtData { match self { TxtData::Static(s) => s, TxtData::Inline(d) => inline_to_str(d), - TxtData::Owned(s) => s, + TxtData::String(s) => s, + TxtData::Arc(s) => s, } } } -/// Text string type, can be owned, static or inlined. +/// Identifies how a [`Txt`] is currently storing the string data. /// -/// Note that this type dereferences to [`str`] so you can use all methods -/// of that type also. For mutation you can call [`to_mut`] -/// to access all mutating methods of [`String`]. The mutations that can be -/// implemented using only a borrowed `str` are provided as methods in this type. +/// Use [`Txt::repr`] to retrieve. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TxtRepr { + /// Text data is stored as a `&'static str`. + Static, + /// Text data is a small string stored as a null terminated `[u8; {size_of::() * 3}]`. + Inline, + /// Text data is stored as a `String`. + String, + /// Text data is stored as an `Arc`. + Arc, +} + +/// Text string type, can be one of multiple internal representations, mostly optimized for sharing and one for editing. /// -/// [`to_mut`]: Txt::to_mut -#[derive(Clone, dm::Display, PartialEq, Eq, Hash)] +/// This type dereferences to [`str`] so you can use all methods of that type. +/// +/// For editing some mutable methods are provided, you can also call [`Txt::to_mut`] +/// to access all mutating methods of [`String`]. After editing you can call [`Txt::end_mut`] to convert +/// back to an inner representation optimized for sharing. +/// +/// See [`Txt::repr`] for more details about the inner representations. +#[derive(dm::Display, PartialEq, Eq, Hash)] pub struct Txt(TxtData); +/// Clones the text. +/// +/// If the inner representation is [`TxtRepr::String`] the returned value is in a representation optimized +/// for sharing, either a static empty, an inlined short or an `Arc` long string. +impl Clone for Txt { + fn clone(&self) -> Self { + Self(match &self.0 { + TxtData::Static(s) => TxtData::Static(s), + TxtData::Inline(d) => TxtData::Inline(*d), + TxtData::String(s) => return Self::from_str(s), + TxtData::Arc(s) => TxtData::Arc(Arc::clone(s)), + }) + } +} impl Txt { /// New text that is a `&'static str`. pub const fn from_static(s: &'static str) -> Txt { Txt(TxtData::Static(s)) } - /// New text that is an owned [`String`]. - pub const fn owned(s: String) -> Txt { - Txt(TxtData::Owned(s)) + /// New text from a [`String`] optimized for editing. + /// + /// If you don't plan to edit the text after this call consider using [`from_str`] instead. + /// + /// [`from_string`]: Self::from_string + pub const fn from_string(s: String) -> Txt { + Txt(TxtData::String(s)) + } + + /// New cloned from `s`. + /// + /// The text will be internally optimized for sharing, if you plan to edit the text after this call + /// consider using [`from_string`] instead. + /// + /// [`from_string`]: Self::from_string + #[allow(clippy::should_implement_trait)] // have implemented trait, this one is infallible. + pub fn from_str(s: &str) -> Txt { + if s.is_empty() { + Self::from_static("") + } else if s.len() <= INLINE_MAX && !s.contains('\0') { + Self(TxtData::Inline(str_to_inline(s))) + } else { + Self(TxtData::Arc(Arc::from(s))) + } + } + + /// New from a shared arc str. + /// + /// Note that the text can outlive the `Arc`, by cloning the string data when modified or + /// to use a more optimal representation, you cannot use the reference count of `s` to track + /// the lifetime of the text. + /// + /// [`from_string`]: Self::from_string + pub fn from_arc(s: Arc) -> Txt { + if s.is_empty() { + Self::from_static("") + } else if s.len() <= INLINE_MAX && !s.contains('\0') { + Self(TxtData::Inline(str_to_inline(&s))) + } else { + Self(TxtData::Arc(s)) + } } /// New text that is an inlined `char`. @@ -934,43 +1005,75 @@ impl Txt { let mut buf = [0u8; 4]; let s = c.encode_utf8(&mut buf); + if s.contains('\0') { + return Txt(TxtData::Arc(Arc::from(&*s))); + } + Txt(TxtData::Inline(str_to_inline(s))) } - /// New empty text. - pub const fn empty() -> Txt { - Self::from_static("") + + /// New text from [`format_args!`], avoids allocation if the text is static (no args) or can fit the inlined representation. + pub fn from_fmt(args: std::fmt::Arguments) -> Txt { + if let Some(s) = args.as_str() { + Txt::from_static(s) + } else { + let mut r = Txt(TxtData::Inline([b'\0'; INLINE_MAX])); + std::fmt::write(&mut r, args).unwrap(); + r + } } - /// If the text is an owned [`String`]. - pub const fn is_owned(&self) -> bool { - matches!(&self.0, TxtData::Owned(_)) + /// Identifies how the text is currently stored. + pub const fn repr(&self) -> TxtRepr { + match &self.0 { + TxtData::Static(_) => TxtRepr::Static, + TxtData::Inline(_) => TxtRepr::Inline, + TxtData::String(_) => TxtRepr::String, + TxtData::Arc(_) => TxtRepr::Arc, + } } /// Acquires a mutable reference to a [`String`] buffer. /// - /// Turns the text to owned if it was borrowed. + /// Converts the text to an internal representation optimized for editing, you can call [`end_mut`] after + /// editing to re-optimize the text for sharing. + /// + /// [`end_mut`]: Self::end_mut pub fn to_mut(&mut self) -> &mut String { self.0 = match mem::replace(&mut self.0, TxtData::Static("")) { - TxtData::Owned(s) => TxtData::Owned(s), - TxtData::Static(s) => TxtData::Owned(s.to_owned()), - TxtData::Inline(d) => TxtData::Owned(inline_to_str(&d).to_owned()), + TxtData::String(s) => TxtData::String(s), + TxtData::Static(s) => TxtData::String(s.to_owned()), + TxtData::Inline(d) => TxtData::String(inline_to_str(&d).to_owned()), + TxtData::Arc(s) => TxtData::String((*s).to_owned()), }; - if let TxtData::Owned(s) = &mut self.0 { + if let TxtData::String(s) = &mut self.0 { s } else { unreachable!() } } + /// Convert the inner representation of the string to not be [`String`]. After + /// this call the text can be cheaply cloned. + pub fn end_mut(&mut self) { + match mem::replace(&mut self.0, TxtData::Static("")) { + TxtData::String(s) => { + *self = Self::from_str(&s); + } + already => self.0 = already, + } + } + /// Extracts the owned string. /// /// Turns the text to owned if it was borrowed. pub fn into_owned(self) -> String { match self.0 { - TxtData::Owned(s) => s, + TxtData::String(s) => s, TxtData::Static(s) => s.to_owned(), TxtData::Inline(d) => inline_to_str(&d).to_owned(), + TxtData::Arc(s) => (*s).to_owned(), } } @@ -978,7 +1081,7 @@ impl Txt { /// replaces `self` with an empty str (`""`). pub fn clear(&mut self) { match &mut self.0 { - TxtData::Owned(s) => s.clear(), + TxtData::String(s) => s.clear(), d => *d = TxtData::Static(""), } } @@ -987,11 +1090,11 @@ impl Txt { /// /// Returns None if this `Txt` is empty. /// - /// This method calls [`String::pop`] if the text is owned, otherwise - /// reborrows a slice of the `str` without the last character. + /// This method only converts to [`TxtRepr::String`] if the + /// internal representation is [`TxtRepr::Arc`], other representations are reborrowed. pub fn pop(&mut self) -> Option { match &mut self.0 { - TxtData::Owned(s) => s.pop(), + TxtData::String(s) => s.pop(), TxtData::Static(s) => { if let Some((i, c)) = s.char_indices().last() { *s = &s[..i]; @@ -1013,6 +1116,7 @@ impl Txt { None } } + TxtData::Arc(_) => self.to_mut().pop(), } } @@ -1021,11 +1125,11 @@ impl Txt { /// If `new_len` is greater than the text's current length, this has no /// effect. /// - /// This method calls [`String::truncate`] if the text is owned, otherwise - /// reborrows a slice of the text. + /// This method only converts to [`TxtRepr::String`] if the + /// internal representation is [`TxtRepr::Arc`], other representations are reborrowed. pub fn truncate(&mut self, new_len: usize) { match &mut self.0 { - TxtData::Owned(s) => s.truncate(new_len), + TxtData::String(s) => s.truncate(new_len), TxtData::Static(s) => { if new_len <= s.len() { assert!(s.is_char_boundary(new_len)); @@ -1043,6 +1147,7 @@ impl Txt { } } } + TxtData::Arc(_) => self.to_mut().truncate(new_len), } } @@ -1052,11 +1157,11 @@ impl Txt { /// the returned `Txt` contains bytes `[at, len)`. `at` must be on the /// boundary of a UTF-8 code point. /// - /// This method calls [`String::split_off`] if the text is owned, otherwise - /// reborrows slices of the text. + /// This method only converts to [`TxtRepr::String`] if the + /// internal representation is [`TxtRepr::Arc`], other representations are reborrowed. pub fn split_off(&mut self, at: usize) -> Txt { match &mut self.0 { - TxtData::Owned(s) => Txt::owned(s.split_off(at)), + TxtData::String(s) => Txt::from_string(s.split_off(at)), TxtData::Static(s) => { assert!(s.is_char_boundary(at)); let other = &s[at..]; @@ -1083,6 +1188,75 @@ impl Txt { r } + TxtData::Arc(_) => Txt::from_string(self.to_mut().split_off(at)), + } + } + + /// Push the character to the end of the text. + /// + /// This method avoids converting to [`TxtRepr::String`] when the current text + /// plus char can fit inlined. + pub fn push(&mut self, c: char) { + match &mut self.0 { + TxtData::String(s) => s.push(c), + TxtData::Inline(inlined) => { + if let Some(len) = inlined.iter().position(|&c| c != b'\0') { + if len + 4 <= INLINE_MAX && c != '\0' { + let mut buf = [0u8; 4]; + let s = c.encode_utf8(&mut buf); + inlined[len..len + 4].copy_from_slice(s.as_bytes()); + return; + } + } + self.to_mut().push(c) + } + _ => { + let len = self.len(); + if len + 4 <= INLINE_MAX && c != '\0' { + let mut inlined = str_to_inline(self.as_str()); + let mut buf = [0u8; 4]; + let s = c.encode_utf8(&mut buf); + inlined[len..len + 4].copy_from_slice(s.as_bytes()); + + self.0 = TxtData::Inline(inlined); + } else { + self.to_mut().push(c) + } + } + } + } + + /// Push the string to the end of the text. + /// + /// This method avoids converting to [`TxtRepr::String`] when the current text + /// plus char can fit inlined. + pub fn push_str(&mut self, s: &str) { + if s.is_empty() { + return; + } + + match &mut self.0 { + TxtData::String(str) => str.push_str(s), + TxtData::Inline(inlined) => { + if let Some(len) = inlined.iter().position(|&c| c != b'\0') { + if len + s.len() <= INLINE_MAX && !s.contains('\0') { + inlined[len..len + s.len()].copy_from_slice(s.as_bytes()); + return; + } + } + self.to_mut().push_str(s) + } + _ => { + let len = self.len(); + if len + s.len() <= INLINE_MAX && !s.contains('\0') { + let mut inlined = str_to_inline(self.as_str()); + inlined[len..len + s.len()].copy_from_slice(s.as_bytes()); + + self.0 = TxtData::Inline(inlined); + } else { + self.to_mut().push_str(s) + } + } } } @@ -1107,7 +1281,14 @@ impl fmt::Debug for Txt { impl Default for Txt { /// Empty. fn default() -> Self { - Self::empty() + Self::from_static("") + } +} +impl std::str::FromStr for Txt { + type Err = (); + + fn from_str(s: &str) -> Result { + Ok(Txt::from_str(s)) } } impl_from_and_into_var! { @@ -1115,12 +1296,12 @@ impl_from_and_into_var! { Txt(TxtData::Static(s)) } fn from(s: String) -> Txt { - Txt(TxtData::Owned(s)) + Txt(TxtData::String(s)) } fn from(s: Cow<'static, str>) -> Txt { match s { Cow::Borrowed(s) => Txt(TxtData::Static(s)), - Cow::Owned(s) => Txt(TxtData::Owned(s)) + Cow::Owned(s) => Txt(TxtData::String(s)) } } fn from(c: char) -> Txt { @@ -1132,8 +1313,9 @@ impl_from_and_into_var! { fn from(t: Txt) -> Cow<'static, str> { match t.0 { TxtData::Static(s) => Cow::Borrowed(s), - TxtData::Owned(s) => Cow::Owned(s), + TxtData::String(s) => Cow::Owned(s), TxtData::Inline(d) => Cow::Owned(inline_to_str(&d).to_owned()), + TxtData::Arc(s) => Cow::Owned((*s).to_owned()) } } fn from(t: Txt) -> std::path::PathBuf { @@ -1177,7 +1359,7 @@ impl<'a> std::ops::Add<&'a str> for Txt { } impl std::ops::AddAssign<&str> for Txt { fn add_assign(&mut self, rhs: &str) { - self.to_mut().push_str(rhs); + self.push_str(rhs); } } impl PartialEq<&str> for Txt { @@ -1231,6 +1413,12 @@ impl AsRef<[u8]> for Txt { self.as_str().as_ref() } } +impl std::fmt::Write for Txt { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.push_str(s); + Ok(()) + } +} /// A trait for converting a value to a [`Txt`]. /// @@ -1347,8 +1535,10 @@ pub enum UnderlinePosition { Descent, } -/// Creates a [`Txt`](crate::text::Txt) by calling the `format!` macro and -/// wrapping the result in a `Cow::Owned`. +/// Creates a [`Txt`] by formatting using the [`format_args!`] syntax. +/// +/// Note that this behaves like a [`format!`] for [`Txt`], but it can be more performant because the +/// text type can represent `&'static str` and can i /// /// # Examples /// @@ -1356,10 +1546,12 @@ pub enum UnderlinePosition { /// # use zero_ui_core::text::formatx; /// let text = formatx!("Hello {}", "World!"); /// ``` +/// +/// [`Txt`]: crate::text::Txt #[macro_export] macro_rules! formatx { ($($tt:tt)*) => { - $crate::text::Txt::owned(format!($($tt)*)) + $crate::text::Txt::from_fmt(format_args!($($tt)*)) }; } #[doc(inline)] diff --git a/zero-ui-core/src/var/tests.rs b/zero-ui-core/src/var/tests.rs index b437e2e3e..6444d438c 100644 --- a/zero-ui-core/src/var/tests.rs +++ b/zero-ui-core/src/var/tests.rs @@ -427,7 +427,7 @@ mod context { } app_local! { - static PROBE_ID: Txt = const { Txt::empty() }; + static PROBE_ID: Txt = const { Txt::from_static("") }; } #[property(CONTEXT, default(TEST_VAR))] diff --git a/zero-ui/src/widgets/image/image_properties.rs b/zero-ui/src/widgets/image/image_properties.rs index 25be10165..5d9824a5f 100644 --- a/zero-ui/src/widgets/image/image_properties.rs +++ b/zero-ui/src/widgets/image/image_properties.rs @@ -345,7 +345,7 @@ pub struct ImgErrorArgs { #[property(EVENT, widget_impl(Image))] pub fn on_error(child: impl UiNode, handler: impl WidgetHandler) -> impl UiNode { let mut handler = handler.cfg_boxed(); - let mut error = Txt::empty(); + let mut error = Txt::from_str(""); match_node(child, move |_, op| match op { UiNodeOp::Init => { diff --git a/zero-ui/src/widgets/markdown.rs b/zero-ui/src/widgets/markdown.rs index fe4456e1b..271042aac 100644 --- a/zero-ui/src/widgets/markdown.rs +++ b/zero-ui/src/widgets/markdown.rs @@ -265,7 +265,7 @@ fn markdown_view_fn(md: &str) -> impl UiNode { } blocks.push(code_block_view(CodeBlockFnArgs { lang: match kind { - CodeBlockKind::Indented => Txt::empty(), + CodeBlockKind::Indented => Txt::from_str(""), CodeBlockKind::Fenced(l) => l.to_text(), }, txt: txt.into(), diff --git a/zero-ui/src/widgets/window/commands.rs b/zero-ui/src/widgets/window/commands.rs index e84802d06..8c87d7971 100644 --- a/zero-ui/src/widgets/window/commands.rs +++ b/zero-ui/src/widgets/window/commands.rs @@ -28,7 +28,7 @@ pub(super) fn inspect_node( let mut inspector_state = WriteTreeState::new(); let inspector = WindowId::new_unique(); - let inspector_text = var(Txt::empty()); + let inspector_text = var(Txt::from_str("")); let can_inspect = can_inspect.into_var();