From c39f8f33ad7a9d97b66be9647045c6320853e94d Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Thu, 16 Nov 2023 15:34:26 -0800 Subject: [PATCH] Rewrote text input Also implemnted secure/masked input Closes #58 --- Cargo.lock | 11 +- Cargo.toml | 2 + examples/focus-order.rs | 6 +- examples/gameui.rs | 3 +- examples/input.rs | 18 +- examples/login.rs | 5 +- examples/slider.rs | 3 +- examples/theme.rs | 3 +- src/value.rs | 24 +- src/widgets.rs | 2 +- src/widgets/input.rs | 1352 ++++++++++++++++++++++++++++----------- 11 files changed, 1036 insertions(+), 393 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index efea6db..fc44016 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -831,6 +831,8 @@ dependencies = [ "pollster", "tracing", "tracing-subscriber", + "unicode-segmentation", + "zeroize", ] [[package]] @@ -1087,7 +1089,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kludgine" version = "0.1.0" -source = "git+https://github.com/khonsulabs/kludgine#c0135da62309896fab58a2f5b517c32cde151fb3" +source = "git+https://github.com/khonsulabs/kludgine#220df4ed07fcf86459a34591a7923d7ddb25e67e" dependencies = [ "ahash", "alot", @@ -1101,6 +1103,7 @@ dependencies = [ "lyon_tessellation", "pollster", "smallvec", + "unicode-bidi", "wgpu", ] @@ -3152,3 +3155,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" diff --git a/Cargo.toml b/Cargo.toml index 3b3f92a..ebea333 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ ahash = "0.8.6" gooey-macros = { version = "0.1.0", path = "gooey-macros" } derive_more = { version = "1.0.0-beta.6", features = ["from"] } arboard = "3.2.1" +zeroize = "1.6.1" +unicode-segmentation = "1.10.1" # [patch."https://github.com/khonsulabs/kludgine"] diff --git a/examples/focus-order.rs b/examples/focus-order.rs index 24f573f..ec9362b 100644 --- a/examples/focus-order.rs +++ b/examples/focus-order.rs @@ -1,7 +1,8 @@ use std::process::exit; -use gooey::value::{Dynamic, MapEach, StringValue}; +use gooey::value::{Dynamic, MapEach}; use gooey::widget::{MakeWidget, MakeWidgetWithId, WidgetTag}; +use gooey::widgets::input::{InputValue, MaskedString}; use gooey::widgets::Expand; use gooey::Run; use kludgine::figures::units::Lp; @@ -33,7 +34,6 @@ fn main() -> gooey::Result { let password_row = "Password" .and( - // TODO secure input password .clone() .into_input() @@ -79,6 +79,6 @@ fn main() -> gooey::Result { .run() } -fn validate(username: &String, password: &String) -> bool { +fn validate(username: &String, password: &MaskedString) -> bool { !username.is_empty() && !password.is_empty() } diff --git a/examples/gameui.rs b/examples/gameui.rs index f3e172d..045f976 100644 --- a/examples/gameui.rs +++ b/examples/gameui.rs @@ -1,5 +1,6 @@ -use gooey::value::{Dynamic, StringValue}; +use gooey::value::Dynamic; use gooey::widget::{MakeWidget, HANDLED, IGNORED}; +use gooey::widgets::input::InputValue; use gooey::widgets::Space; use gooey::Run; use kludgine::app::winit::event::ElementState; diff --git a/examples/input.rs b/examples/input.rs index 65e3982..9887d64 100644 --- a/examples/input.rs +++ b/examples/input.rs @@ -1,7 +1,21 @@ -use gooey::value::StringValue; +use gooey::value::Dynamic; use gooey::widget::MakeWidget; +use gooey::widgets::input::{InputValue, MaskedString}; use gooey::Run; +use kludgine::figures::units::Px; fn main() -> gooey::Result { - "Hello".into_input().expand().run() + let contents = Dynamic::from("Hello World"); + let password = Dynamic::new(MaskedString::default()); + + "Text Input Field:" + .and(contents.into_input()) + .and("Masked Input Field:") + .and(password.into_input()) + .into_rows() + .width(Px(100)..Px(800)) + .scroll() + .centered() + .expand() + .run() } diff --git a/examples/login.rs b/examples/login.rs index 22a762e..3a61f04 100644 --- a/examples/login.rs +++ b/examples/login.rs @@ -1,7 +1,8 @@ use std::process::exit; -use gooey::value::{Dynamic, MapEach, StringValue}; +use gooey::value::{Dynamic, MapEach}; use gooey::widget::MakeWidget; +use gooey::widgets::input::{InputValue, MaskedString}; use gooey::widgets::Expand; use gooey::Run; use kludgine::figures::units::Lp; @@ -58,6 +59,6 @@ fn main() -> gooey::Result { .run() } -fn validate(username: &String, password: &String) -> bool { +fn validate(username: &String, password: &MaskedString) -> bool { !username.is_empty() && !password.is_empty() } diff --git a/examples/slider.rs b/examples/slider.rs index 2c6d832..08ba3b8 100644 --- a/examples/slider.rs +++ b/examples/slider.rs @@ -1,6 +1,7 @@ use gooey::animation::{LinearInterpolate, PercentBetween}; -use gooey::value::{Dynamic, StringValue}; +use gooey::value::Dynamic; use gooey::widget::MakeWidget; +use gooey::widgets::input::InputValue; use gooey::widgets::slider::Slidable; use gooey::Run; use kludgine::figures::units::Lp; diff --git a/examples/theme.rs b/examples/theme.rs index 948c53b..3dbd5ac 100644 --- a/examples/theme.rs +++ b/examples/theme.rs @@ -3,8 +3,9 @@ use gooey::styles::components::{TextColor, WidgetBackground}; use gooey::styles::{ ColorScheme, ColorSource, ColorTheme, FixedTheme, SurfaceTheme, Theme, ThemePair, }; -use gooey::value::{Dynamic, MapEach, StringValue}; +use gooey::value::{Dynamic, MapEach}; use gooey::widget::MakeWidget; +use gooey::widgets::input::InputValue; use gooey::widgets::slider::Slidable; use gooey::widgets::{Slider, Stack}; use gooey::window::ThemeMode; diff --git a/src/value.rs b/src/value.rs index c378755..8dae32a 100644 --- a/src/value.rs +++ b/src/value.rs @@ -17,7 +17,7 @@ use crate::animation::{DynamicTransition, LinearInterpolate}; use crate::context::{WidgetContext, WindowHandle}; use crate::utils::{IgnorePoison, WithClone}; use crate::widget::{WidgetId, WidgetInstance}; -use crate::widgets::{Input, Switcher}; +use crate::widgets::Switcher; /// An instance of a value that provides APIs to observe and react to its /// contents. @@ -504,6 +504,18 @@ impl From> for DynamicReader { } } +impl From<&str> for Dynamic { + fn from(value: &str) -> Self { + Dynamic::from(value.to_string()) + } +} + +impl From for Dynamic { + fn from(value: String) -> Self { + Dynamic::new(value) + } +} + #[derive(Debug)] struct DynamicMutexGuard<'a, T> { dynamic: &'a DynamicData, @@ -1384,13 +1396,3 @@ macro_rules! impl_tuple_map_each { } impl_all_tuples!(impl_tuple_map_each); - -/// A type that can be converted into a [`Value`]. -pub trait StringValue: IntoValue + Sized { - /// Returns this string as a text input widget. - fn into_input(self) -> Input { - Input::new(self.into_value()) - } -} - -impl StringValue for T where T: IntoValue {} diff --git a/src/widgets.rs b/src/widgets.rs index 0c0061e..2bd7c4d 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -6,7 +6,7 @@ mod canvas; pub mod checkbox; pub mod container; mod expand; -mod input; +pub mod input; pub mod label; mod mode_switch; mod resize; diff --git a/src/widgets/input.rs b/src/widgets/input.rs index da35c71..c9c291d 100644 --- a/src/widgets/input.rs +++ b/src/widgets/input.rs @@ -1,62 +1,144 @@ +//! A text input widget. + +use std::borrow::{Borrow, BorrowMut, Cow}; use std::cmp::Ordering; -use std::fmt::Debug; +use std::fmt::{self, Debug, Display, Formatter, Write}; +use std::hash::Hash; +use std::ops::{Deref, DerefMut}; use std::panic::UnwindSafe; +use std::sync::{Arc, OnceLock}; use std::time::Duration; +use intentional::Cast; use kludgine::app::winit::event::{ElementState, Ime, KeyEvent}; use kludgine::app::winit::keyboard::Key; -use kludgine::cosmic_text::{ - Action, Affinity, Attrs, Buffer, Cursor, Edit, Editor, Metrics, Shaping, -}; +use kludgine::app::winit::window::ImePurpose; use kludgine::figures::units::{Lp, Px, UPx}; use kludgine::figures::{ FloatConversion, IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size, }; use kludgine::shapes::{Shape, StrokeOptions}; -use kludgine::text::TextOrigin; -use kludgine::{Color, Kludgine}; +use kludgine::text::{MeasuredText, Text, TextOrigin}; +use kludgine::Color; +use unicode_segmentation::UnicodeSegmentation; +use zeroize::Zeroizing; -use crate::context::{EventContext, LayoutContext, WidgetContext}; -use crate::styles::components::{ - HighlightColor, IntrinsicPadding, LineHeight, OutlineColor, TextColor, TextSize, -}; +use crate::context::{EventContext, GraphicsContext, LayoutContext}; +use crate::styles::components::{HighlightColor, IntrinsicPadding, OutlineColor, TextColor}; use crate::utils::ModifiersExt; -use crate::value::{Generation, IntoValue, Value}; +use crate::value::{Dynamic, Generation, IntoDynamic, IntoValue, Value}; use crate::widget::{Callback, EventHandling, Widget, HANDLED, IGNORED}; -use crate::ConstraintLimit; +use crate::{ConstraintLimit, Lazy}; const CURSOR_BLINK_DURATION: Duration = Duration::from_millis(500); /// A text input widget. #[must_use] -pub struct Input { +pub struct Input { /// The value of this widget. - pub text: Value, + pub value: Dynamic, + mask_symbol: Value, + mask: CowString, on_key: Option>, - editor: Option, - cursor_state: CursorState, + cache: Option, + selection: SelectionState, + blink_state: BlinkState, needs_to_select_all: bool, mouse_buttons_down: usize, } -impl Input { - /// Returns an empty widget. - pub fn empty() -> Self { - Self::new(String::new()) - } +struct CachedLayout { + bytes: usize, + color: Color, + generation: Generation, + mask_generation: Option, + mask_bytes: usize, + width: Option, + measured: MeasuredText, +} +impl CachedLayout { + pub fn is_current( + &self, + generation: Generation, + mask_generation: Option, + width: Option, + color: Color, + mask_bytes: usize, + ) -> bool { + self.generation == generation + && self.mask_generation == mask_generation + && self.width == width + && self.color == color + && self.mask_bytes == mask_bytes + } +} + +/// The current selection of an [`Input`]. +#[derive(Clone, Copy, Eq, PartialEq, Debug, Default)] +pub struct SelectionState { + /// The cursor location, which is what is moved when the user types or uses + /// the arrow keys. + pub cursor: Cursor, + /// The start of the selection, which is the original cursor location when + /// the current series of selection actions began. + pub start: Option, +} + +/// A location within an [`Input`] widget. +#[derive(Clone, Copy, Eq, PartialEq, Debug, Ord, PartialOrd, Default)] +pub struct Cursor { + /// A byte offset within the value of the [`Input`] widget. + pub offset: usize, + /// The direction the cursor should be placed relative to the line end. + pub affinity: Affinity, +} + +/// An affinity towards a direction. +#[derive(Clone, Copy, Eq, PartialEq, Debug, Ord, PartialOrd, Default)] +pub enum Affinity { + /// The affinity is before the item in question. + #[default] + Before, + /// The affinity is after the item in question. + After, +} + +impl Input +where + Storage: InputStorage, +{ /// Returns a new widget containing `initial_text`. - pub fn new(initial_text: impl IntoValue) -> Self { + pub fn new(initial_value: impl IntoDynamic) -> Self { Self { - text: initial_text.into_value(), - editor: None, - cursor_state: CursorState::default(), + value: initial_value.into_dynamic(), + mask: CowString::default(), + mask_symbol: Storage::MASKED + .then(|| CowString::from('\u{2022}')) + .unwrap_or_default() + .into_value(), + cache: None, + blink_state: BlinkState::default(), + selection: SelectionState::default(), on_key: None, mouse_buttons_down: 0, needs_to_select_all: true, } } + /// Sets the symbol to use for masking sensitive content to `symbol`. + /// + /// Only the first unicode grapheme will be used for the symbol. A warning + /// will be printed if a multi-grapheme string is provided. + /// + /// When using a [`InputStorage`] that is masked by default, the unicode + /// bullet character (`\u{2022}`) is used as the default. + pub fn mask_symbol(mut self, symbol: impl IntoValue) -> Self { + self.mask_symbol = symbol.into_value(); + + self + } + /// Sets the `on_key` callback. /// /// This function is called for every keyboard input event. If [`HANDLED`] @@ -69,134 +151,265 @@ impl Input { self } - fn editor_mut( - &mut self, - kludgine: &mut Kludgine, - context: &WidgetContext<'_, '_>, - ) -> &mut Editor { - match (&self.editor, self.text.generation()) { - (Some(editor), generation) if editor.generation == generation => {} - (_, generation) => { - let scale = kludgine.scale(); - let mut buffer = Buffer::new( - kludgine.font_system(), - Metrics::new( - context.get(&TextSize).into_px(scale).into_float(), - context.get(&LineHeight).into_px(scale).into_float(), - ), - ); - self.text.map_tracking_invalidate(context, |text| { - buffer.set_text( - kludgine.font_system(), - text, - Attrs::new(), - Shaping::Advanced, - ); - }); - self.editor = Some(LiveEditor { - editor: Editor::new(buffer), - generation, - }); + fn select_all(&mut self) { + self.value.map_ref(|value| { + let text = value.as_str(); + + self.selection.start = Some(Cursor::default()); + self.selection.cursor.offset = text.len(); + self.selection.cursor.affinity = Affinity::After; + }); + } + + fn forward_delete(&mut self) { + let (cursor, selection) = self.selected_range(); + if let Some(selection) = selection { + self.replace_range(cursor, selection, ""); + } else { + let mut value = self.value.lock(); + if let Some(length) = value.as_str()[cursor.offset..] + .graphemes(true) + .next() + .map(str::len) + { + value + .as_string_mut() + .replace_range(cursor.offset..cursor.offset + length, ""); } } - - &mut self.editor.as_mut().expect("just initialized").editor } - fn select_all(&mut self) { - let Some(editor) = self.editor.as_mut().map(|editor| &mut editor.editor) else { - return; - }; - if !editor.buffer().lines.is_empty() { - let line = editor.buffer().lines.len() - 1; - let end = Cursor::new_with_affinity( - line, - editor.buffer().lines[line].text().len(), - Affinity::After, - ); - editor.set_cursor(end); - editor.set_select_opt(Some(Cursor::new_with_affinity(0, 0, Affinity::Before))); + fn replace_range(&mut self, start: Cursor, end: Cursor, new_text: &str) { + self.value.map_mut(|value| { + let value = value.as_string_mut(); + let start = start.offset.min(value.len().saturating_sub(1)); + let end = end.offset.min(value.len()); + value.replace_range(start..end, new_text); + + self.selection.cursor.offset = start + new_text.len(); + self.selection.start = None; + }); + } + + fn delete(&mut self) { + let (cursor, selection) = self.selected_range(); + if let Some(selection) = selection { + self.replace_range(cursor, selection, ""); + } else if cursor.offset > 0 { + let mut value = self.value.lock(); + let length = value.as_str().len(); + if length == 0 || cursor.offset == 0 || cursor.offset > length { + return; + } + + // TODO remove a full grapheme + let removed = value.as_string_mut().remove(cursor.offset - 1); + self.selection.cursor.offset -= removed.len_utf8(); } } - fn handle_key( - &mut self, - input: KeyEvent, - context: &mut EventContext<'_, '_>, - ) -> (bool, EventHandling) { - let editor = self.editor_mut(context.kludgine, &context.widget); + fn move_cursor(&mut self, direction: Affinity, mode: CursorNavigationMode) { + let value = self.value.lock(); + let length = value.as_str().len(); + // @ecton: After a lot of thought, it seems like the only way for + // affinity to be switched to After is via dragging the mouse. + self.selection.cursor.affinity = Affinity::Before; + match (direction, mode) { + (Affinity::Before, CursorNavigationMode::Grapheme) => { + if let Some((_, grapheme)) = + value + .as_str() + .grapheme_indices(true) + .find(|(index, grapheme)| { + index + grapheme.len() == self.selection.cursor.offset + }) + { + self.selection.cursor.offset -= grapheme.len(); + } else { + self.selection.cursor.offset = 0; + } + } + (Affinity::After, CursorNavigationMode::Grapheme) => { + if self.selection.cursor.offset < length { + if let Some(grapheme) = value.as_str()[self.selection.cursor.offset..] + .graphemes(true) + .next() + { + self.selection.cursor.offset += grapheme.len(); + } else { + self.selection.cursor.offset = length; + } + } + } + (Affinity::Before, CursorNavigationMode::Word) => { + let mut words = value.as_str().unicode_word_indices().peekable(); + while let Some((index, _)) = words.next() { + let next_starts_after_selection = words + .peek() + .map_or(true, |(index, _)| *index >= self.selection.cursor.offset); + if next_starts_after_selection { + self.selection.cursor.offset = index; + return; + } + } + + self.selection.cursor.offset = 0; + } + (Affinity::After, CursorNavigationMode::Word) => { + if self.selection.cursor.offset < length { + if let Some((index, word)) = value.as_str()[self.selection.cursor.offset..] + .unicode_word_indices() + .next() + { + self.selection.cursor.offset += index + word.len(); + } else { + self.selection.cursor.offset = length; + } + } + } + } + } + + fn selected_range(&mut self) -> (Cursor, Option) { + match self.selection.start { + Some(start) => match start.cmp(&self.selection.cursor) { + Ordering::Less => (start, Some(self.selection.cursor)), + Ordering::Equal => { + if self.mouse_buttons_down == 0 { + self.selection.start = None; + } + (self.selection.cursor, None) + } + Ordering::Greater => (self.selection.cursor, Some(start)), + }, + None => (self.selection.cursor, None), + } + } + + fn map_selected_text(&mut self, map: impl FnOnce(&str) -> R) -> Option { + let (cursor, Some(end)) = self.selected_range() else { + return None; + }; + + Some( + self.value + .map_ref(|value| map(&value.as_str()[cursor.offset..end.offset])), + ) + } + + fn is_masked(&self) -> bool { + self.mask_symbol.map(|mask| !mask.is_empty()) + } + + fn copy_selection_to_clipboard(&mut self, context: &mut EventContext<'_, '_>) { + if self.is_masked() { + return; + } + + self.map_selected_text(|text| { + if let Some(mut clipboard) = context.clipboard_guard() { + match clipboard.set_text(text) { + Ok(()) => {} + Err(err) => tracing::error!("error copying to clipboard: {err}"), + } + } + }); + } + + fn replace_selection(&mut self, new_text: &str) { + let selected_range = self.selected_range(); + match selected_range { + (start, Some(end)) => { + self.replace_range(start, end, new_text); + } + (cursor, None) => { + let mut value = self.value.lock(); + if cursor.offset < value.as_str().len() { + value.as_string_mut().insert_str(cursor.offset, new_text); + self.selection.cursor.offset += new_text.len(); + } else { + value.as_string_mut().push_str(new_text); + self.selection.cursor.offset += new_text.len(); + } + } + }; + } + + fn paste_from_clipboard(&mut self, context: &mut EventContext<'_, '_>) -> bool { + match context + .clipboard_guard() + .map(|mut clipboard| clipboard.get_text()) + { + Some(Ok(text)) => { + self.replace_selection(&text); + true + } + None | Some(Err(arboard::Error::ConversionFailure)) => false, + Some(Err(err)) => { + tracing::error!("error retrieving clipboard contents: {err}"); + false + } + } + } + + fn handle_key(&mut self, input: KeyEvent, context: &mut EventContext<'_, '_>) -> EventHandling { match (input.state, input.logical_key, input.text.as_deref()) { (ElementState::Pressed, key @ (Key::Backspace | Key::Delete), _) => { - editor.action( - context.kludgine.font_system(), - match key { - Key::Backspace => Action::Backspace, - Key::Delete => Action::Delete, - _ => unreachable!("previously matched"), - }, - ); - (true, HANDLED) + match key { + Key::Backspace => self.delete(), + Key::Delete => self.forward_delete(), + _ => unreachable!("previously matched"), + } + + HANDLED } (ElementState::Pressed, key @ (Key::ArrowLeft | Key::ArrowDown | Key::ArrowUp | Key::ArrowRight), _) => { let modifiers = context.modifiers(); - match (editor.select_opt(), modifiers.state().shift_key()) { + let affinity = if matches!(key, Key::ArrowLeft | Key::ArrowUp) { + Affinity::Before + } else { + Affinity::After + }; + match (self.selection.start, modifiers.state().shift_key()) { (None, true) => { - editor.set_select_opt(Some(editor.cursor())); + self.selection.start = Some(self.selection.cursor); } (Some(_), false) => { - editor.set_select_opt(None); + self.selection.start = None; } _ => {} }; - editor.action( - context.kludgine.font_system(), - match key { - Key::ArrowLeft if modifiers.word_select() => Action::PreviousWord, - Key::ArrowLeft => Action::Left, - Key::ArrowDown => Action::Down, - Key::ArrowUp => Action::Up, - Key::ArrowRight if modifiers.word_select() => Action::NextWord, - Key::ArrowRight => Action::Right, - _ => unreachable!("previously matched"), - }, - ); - (false, HANDLED) + match key { + // Key::ArrowLeft | Key::ArrowRight if modifiers.primary() => self.move_cursor(affinity, CursorNavigationMode::LineExtent), + Key::ArrowLeft | Key::ArrowRight if modifiers.word_select() => self.move_cursor(affinity, CursorNavigationMode::Word), + Key::ArrowLeft | Key::ArrowRight => self.move_cursor(affinity, CursorNavigationMode::Grapheme), + // Key::ArrowDown | Key::ArrowUp => self.move_cursor(affinity, CursorNavigationMode::Line), + _ => tracing::warn!("unhandled key: {key:?}"), + } + + HANDLED } (state, _, Some("a")) if context.modifiers().primary() => { if state.is_pressed() { self.select_all(); } - (false, HANDLED) + HANDLED } (state, _, Some("c")) if context.modifiers().primary() => { if state.is_pressed() { - if let Some(mut clipboard) = context.clipboard_guard() { - if let Some(selection) = editor.copy_selection() { - match clipboard.set_text(selection) { - Ok(()) => {}, - Err(err) => tracing::error!("error copying to clipboard: {err}"), - } - } - } + self.copy_selection_to_clipboard(context); } - (false, HANDLED) + HANDLED } (state, _, Some("v")) if context.modifiers().primary() => { - let pasted = state.is_pressed() && - match context.clipboard_guard().map(|mut clipboard| clipboard.get_text()) { - Some(Ok(text)) => { - editor.insert_string(&text, None); - true - }, - None | Some(Err(arboard::Error::ConversionFailure)) => false, - Some(Err(err)) => {tracing::error!("error retrieving clipboard contents: {err}"); false}, - } + if state.is_pressed() { + self.paste_from_clipboard(context); + } - ; - (pasted, HANDLED) + HANDLED } (state, _, Some(text)) if !context.modifiers().primary() @@ -206,30 +419,353 @@ impl Input { => { if state.is_pressed() { - editor.insert_string(text, None); + self.replace_selection(text); } - (state.is_pressed(), HANDLED) + HANDLED + } + (_, _, _) => IGNORED, + } + } + + fn layout_text( + &mut self, + width: Option, + context: &mut GraphicsContext<'_, '_, '_, '_, '_>, + ) -> CacheInfo<'_> { + let (mut cursor, mut selection) = self.selected_range(); + let generation = self.value.generation(); + let mask_generation = self.mask_symbol.generation(); + let mut mask_bytes = self + .mask_symbol + .map(|sym| sym.graphemes(true).next().map_or(0, str::len)); + let color = context.get(&TextColor); + context.invalidate_when_changed(&self.value); + match &mut self.cache { + Some(cache) + if cache.is_current(generation, mask_generation, width, color, mask_bytes) => {} + _ => { + let (bytes, measured) = self.value.map_ref(|storage| { + let mut text = storage.as_str(); + let mut bytes = text.len(); + + self.mask_symbol.map(|mask_symbol| { + // Another thread could have updated the mask symbol + // since we checked above. + if let Some(first_grapheme) = mask_symbol.graphemes(true).next() { + if mask_symbol != first_grapheme { + static WARNING: OnceLock<()> = OnceLock::new(); + WARNING.get_or_init(|| tracing::warn!("Mask symbol {mask_symbol} as more than one grapheme. Only the first grapheme will be used.")); + } + // Technically something more optimal than asking the + // layout system to lay out a repeated string should be + // doable, but it seems like a lot of code. + mask_bytes = first_grapheme.len(); + let char_count = text.graphemes(true).count(); + bytes = mask_bytes * char_count; + self.mask.truncate(bytes); + + while self.mask.len() < bytes { + self.mask.push_str(first_grapheme); + } + text = &self.mask; + } else { + mask_bytes = 0; + } + }); + + let mut text = Text::new(text, color); + if let Some(width) = width { + text = text.wrap_at(width); + } + (bytes, context.gfx.measure_text(text)) + }); + self.cache = Some(CachedLayout { + bytes, + color, + generation, + mask_generation, + mask_bytes, + width, + measured, + }); + } + } + + // Adjust the selection cursors to accommodate the difference in unicode + // widths of characters in the source string and the mask_char. + if mask_bytes > 0 { + self.value.map_ref(|value| { + let value = value.as_str(); + cursor.offset = value[..cursor.offset].graphemes(true).count() * mask_bytes; + if let Some(selection) = &mut selection { + selection.offset = + value[..selection.offset].graphemes(true).count() * mask_bytes; + } + }); + } + + let cache = self.cache.as_ref().expect("always initialized"); + CacheInfo { + measured: &cache.measured, + bytes: cache.bytes, + masked: mask_bytes > 0, + cursor, + selection, + } + } + + #[allow(clippy::too_many_lines)] // it's text layout, c'mon + fn locate_cursor( + measured: &MeasuredText, + cursor: Cursor, + total_bytes: usize, + ) -> (Point, Px) { + if measured.glyphs.is_empty() || (cursor.offset == 0 && cursor.affinity == Affinity::Before) + { + return (Point::default(), Px(0)); + } + + // Space between glyphs isn't represented in the glyphs. If the cursor rests + // within characters that have no glyphs (whitespace), we need to + // approximate the position based on the location of the nearest glyphs. + let mut closest_before_index = 0; + let mut closest_after_index = usize::MAX; + let mut bottom_right_index = 0; + let mut bottom_right_line = 0; + let mut bottom_right_rect = Rect::default(); + let mut unrendered_offset = 0; + for (index, glyph) in measured.glyphs.iter().enumerate() { + unrendered_offset = unrendered_offset.max(glyph.info.end); + let rect = glyph.rect(); + if bottom_right_rect.size.width == 0 + || glyph.info.line > bottom_right_line + || (glyph.info.line == bottom_right_line + && rect.origin.x > bottom_right_rect.origin.x) + { + bottom_right_line = glyph.info.line; + bottom_right_index = index; + bottom_right_rect = rect; + } + + match ( + glyph.info.start.cmp(&cursor.offset), + cursor.offset.cmp(&glyph.info.end), + ) { + (Ordering::Less | Ordering::Equal, Ordering::Less) => { + // cosmic text may have grouped multiple graphemes into a single glyph. + let mut grapheme_offset = Px(0); + if glyph.info.start < cursor.offset { + let clustered_bytes = glyph.info.end - glyph.info.start; + if clustered_bytes > 1 { + let cursor_offset = cursor.offset - glyph.info.start; + + grapheme_offset = rect.size.width * cursor_offset.cast::() + / clustered_bytes.cast::(); + } + } + + return ( + Point::new( + rect.origin.x + grapheme_offset, + measured.line_height.saturating_mul(Px( + i32::try_from(glyph.info.line).unwrap_or(i32::MAX) + )), + ), + rect.size.width, + ); + } + (Ordering::Less, _) => { + closest_before_index = closest_before_index.max(index); + } + (_, Ordering::Less) => { + closest_after_index = closest_after_index.min(index); + } + _ => {} + } + } + + if closest_after_index == usize::MAX { + let bottom_right = &measured.glyphs[bottom_right_index]; + let bottom_y = measured + .line_height + .saturating_mul(Px(i32::try_from(bottom_right.info.line).unwrap_or(i32::MAX))); + // No glyph could be found that started/contained the cursors offset. + let mut bottom_right_cursor = Point::new( + bottom_right_rect.origin.x + bottom_right_rect.size.width, + bottom_y, + ); + let bytes_after_glyph = total_bytes - unrendered_offset; + if !(bottom_right.info.end == cursor.offset || bytes_after_glyph == 0) { + // We're rendering past the end of the text. We shuld probably try to + // estimate the amount of whitespace should be visible based on the + // number of whitespace characters at the end of the text. + let space_past_glyph = bottom_right.info.line_width - bottom_right_cursor.x; + let space_per_byte = + space_past_glyph.into_float() / bytes_after_glyph.cast::(); + let cursor_position = space_per_byte + * (cursor.offset.saturating_sub(unrendered_offset)).cast::(); + + bottom_right_cursor.x += Px::from(cursor_position); + } + + // The cursor should be placed after the bottom_right glyph + (bottom_right_cursor, Px(0)) + } else { + let before = &measured.glyphs[closest_before_index]; + let after = &measured.glyphs[closest_after_index]; + let before_rect = before.rect(); + let after_rect = after.rect(); + let before_y = measured + .line_height + .saturating_mul(Px(i32::try_from(before.info.line).unwrap_or(i32::MAX))); + + if before.info.line == after.info.line { + let before_right = before_rect.origin.x + before_rect.size.width; + let space_between = after_rect.origin.x - before_right; + let bytes_between = after.info.start - before.info.end; + let space_per_byte = space_between.into_float() / bytes_between.cast::(); + let cursor_position = + space_per_byte * (cursor.offset - before.info.end).cast::(); + + ( + Point::new(before_right + Px::from(cursor_position), before_y), + Px::from(space_per_byte), + ) + } else { + match cursor.affinity { + Affinity::Before => { + // TODO We need to look out for whitespace at the end of the line. + let mut origin = before_rect.origin; + origin.x += before_rect.size.width; + (origin, before_y) + } + Affinity::After => (Point::new(Px(0), before_y + measured.line_height), Px(0)), + } + } + } + } + + fn cursor_from_point( + &mut self, + location: Point, + context: &mut EventContext<'_, '_>, + ) -> Cursor { + let Some(cache) = &self.cache else { + return Cursor::default(); + }; + + let text_length = self.value.map_ref(|value| value.as_str().len()); + let padding = context + .get(&IntrinsicPadding) + .into_px(context.kludgine.scale()); + let location = location - padding; + if location.y < 0 { + return Cursor::default(); + } + + let mut closest: Option<(Cursor, i32)> = None; + let mut current_line = usize::MAX; + let mut current_line_y = Px(0); + for glyph in &cache.measured.glyphs { + if current_line != glyph.info.line { + current_line = glyph.info.line; + + current_line_y = cache + .measured + .line_height + .saturating_mul(Px(i32::try_from(current_line).unwrap_or(i32::MAX))); + } + let rect = glyph.rect(); + let relative = location - Point::new(rect.origin.x, current_line_y); + if relative.x >= 0 + && relative.y >= 0 + && relative.x < rect.size.width + && relative.y < cache.measured.line_height + { + return if relative.x > rect.size.width / 2 { + if glyph.info.start + 1 < text_length { + Cursor { + offset: glyph.info.start + 1, + affinity: Affinity::Before, + } + } else { + Cursor { + offset: glyph.info.start, + affinity: Affinity::After, + } + } + } else { + Cursor { + offset: glyph.info.start, + affinity: Affinity::Before, + } + }; + } + + // Make relative be relative to the center of the glyph for a nearest search. + let relative = relative + rect.size / 2; + let xy = (relative.x.0.saturating_mul(relative.y.0)).saturating_abs(); + match closest { + Some((_, closest_xy)) if xy < closest_xy => { + closest = Some(( + Cursor { + offset: glyph.info.start, + affinity: if relative.x < 0 || relative.y < 0 { + Affinity::Before + } else { + Affinity::After + }, + }, + xy, + )); + } + _ => {} + } + } + + if let Some((closest, _)) = closest { + closest + } else { + Cursor { + offset: text_length, + affinity: Affinity::After, } - (_, _, _) => (false, IGNORED), } } } -impl Default for Input { - fn default() -> Self { - Self::new(String::new()) - } +struct CacheInfo<'a> { + measured: &'a MeasuredText, + bytes: usize, + masked: bool, + cursor: Cursor, + selection: Option, } -impl Debug for Input { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +#[derive(Debug, Clone, Copy)] +enum CursorNavigationMode { + Grapheme, + Word, + // LineExtent, + // Line, + // Document, +} + +impl Debug for Input +where + Storage: Debug, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { f.debug_struct("Input") - .field("text", &self.text) + .field("text", &self.value) .finish_non_exhaustive() } } -impl Widget for Input { +impl Widget for Input +where + Storage: InputStorage + Debug, +{ fn hit_test(&mut self, _location: Point, _context: &mut EventContext<'_, '_>) -> bool { true } @@ -248,13 +784,8 @@ impl Widget for Input { self.mouse_buttons_down += 1; context.focus(); self.needs_to_select_all = false; - self.editor_mut(context.kludgine, &context.widget).action( - context.kludgine.font_system(), - Action::Click { - x: location.x.0, - y: location.y.0, - }, - ); + self.selection.cursor = self.cursor_from_point(location, context); + self.selection.start = Some(self.selection.cursor); context.set_needs_redraw(); HANDLED } @@ -266,15 +797,12 @@ impl Widget for Input { _button: kludgine::app::winit::event::MouseButton, context: &mut EventContext<'_, '_>, ) { - self.editor_mut(context.kludgine, &context.widget).action( - context.kludgine.font_system(), - Action::Drag { - x: location.x.0, - y: location.y.0, - }, - ); - self.cursor_state.force_on(); - context.set_needs_redraw(); + let cursor_location = self.cursor_from_point(location, context); + if self.selection.cursor != cursor_location { + self.selection.cursor = cursor_location; + context.set_needs_redraw(); + } + self.blink_state.force_on(); } fn mouse_up( @@ -289,113 +817,67 @@ impl Widget for Input { #[allow(clippy::too_many_lines)] fn redraw(&mut self, context: &mut crate::context::GraphicsContext<'_, '_, '_, '_, '_>) { - self.cursor_state.update(context.elapsed()); - let cursor_state = self.cursor_state; + self.blink_state.update(context.elapsed()); + let cursor_state = self.blink_state; let size = context.gfx.size(); let padding = context.get(&IntrinsicPadding).into_px(context.gfx.scale()); let padding = Point::::new(padding, padding); let highlight = context.get(&HighlightColor); - let editor = self.editor_mut(&mut context.gfx, &context.widget); - let cursor = editor.cursor(); - let selection = editor.select_opt(); - let buffer = editor.buffer_mut(); - buffer.set_size( - context.gfx.font_system(), - size.width.into_float(), - size.height.into_float(), - ); - buffer.shape_until_scroll(context.gfx.font_system()); + + let cache = self.layout_text(Some(size.width.into_signed()), context); if context.focused() { context.draw_focus_ring(); context.set_ime_allowed(true); - let line_height = Px::from_float(buffer.metrics().line_height); - if let Some(selection) = selection { - let (start, end) = if selection < cursor { - (selection, cursor) + context.set_ime_purpose(if cache.masked { + ImePurpose::Password + } else { + ImePurpose::Normal + }); + if let Some(selection) = cache.selection { + let (start, end) = if selection < cache.cursor { + (selection, cache.cursor) } else { - (cursor, selection) + (cache.cursor, selection) }; - match (cursor_glyph(buffer, &start), cursor_glyph(buffer, &end)) { - (Ok((start_position, _)), Ok((end_position, end_width))) => { - if start_position.y == end_position.y { - // Single line selection - let width = end_position.x - start_position.x + end_width; - context.gfx.draw_shape( - &Shape::filled_rect( - Rect::new(start_position, Size::new(width, line_height)), - highlight, - ), - padding, - None, - None, - ); - } else { - // Draw from start to end of line, - let width = size.width.into_signed() - start_position.x; - context.gfx.draw_shape( - &Shape::filled_rect( - Rect::new(start_position, Size::new(width, line_height)), - highlight, - ), - padding, - None, - None, - ); - // Fill region between - let bottom_of_first_line = start_position.y + line_height; - let distance_between = end_position.y - bottom_of_first_line; - if distance_between > 0 { - context.gfx.draw_shape( - &Shape::filled_rect( - Rect::new( - Point::new(Px(0), bottom_of_first_line), - Size::new(size.width.into_signed(), distance_between), - ), - highlight, - ), - padding, - None, - None, - ); - } - // Draw from 0 to end + width - context.gfx.draw_shape( - &Shape::filled_rect( - Rect::new( - Point::new(Px(0), end_position.y), - Size::new(end_position.x + end_width, line_height), - ), - highlight, - ), - padding, - None, - None, - ); - } - } - (Ok((start_position, _)), Err(_)) => { - let width = size.width.into_signed() - start_position.x; - context.gfx.draw_shape( - &Shape::filled_rect( - Rect::new(start_position, Size::new(width, line_height)), - highlight, - ), - padding, - None, - None, - ); - } - (Err(_), Ok((end_position, end_width))) => { - if end_position.y > 0 { - todo!("fill above start"); - } + let (start_position, _) = Self::locate_cursor(cache.measured, start, cache.bytes); + let (end_position, end_width) = + Self::locate_cursor(cache.measured, end, cache.bytes); + + if start_position.y == end_position.y { + // Single line selection + let width = end_position.x - start_position.x; + context.gfx.draw_shape( + &Shape::filled_rect( + Rect::new(start_position, Size::new(width, cache.measured.line_height)), + highlight, + ), + padding, + None, + None, + ); + } else { + // Draw from start to end of line, + let width = size.width.into_signed() - start_position.x; + context.gfx.draw_shape( + &Shape::filled_rect( + Rect::new(start_position, Size::new(width, cache.measured.line_height)), + highlight, + ), + padding, + None, + None, + ); + // Fill region between + let bottom_of_first_line = start_position.y + cache.measured.line_height; + let distance_between = end_position.y - bottom_of_first_line; + if distance_between > 0 { context.gfx.draw_shape( &Shape::filled_rect( Rect::new( - Point::new(Px(0), end_position.y), - Size::new(end_position.x + end_width, line_height), + Point::new(Px(0), bottom_of_first_line), + Size::new(size.width.into_signed(), distance_between), ), highlight, ), @@ -404,19 +886,31 @@ impl Widget for Input { None, ); } - (Err(start_not_visible), Err(end_not_visible)) - if start_not_visible != end_not_visible => - { - todo!("render full selection") - } - (Err(_), Err(_)) => {} - } - } else if let Ok((location, _)) = cursor_glyph(buffer, &cursor) { - let window_focused = context.window().focused().get(); - if window_focused && cursor_state.visible { + // Draw from 0 to end + width context.gfx.draw_shape( &Shape::filled_rect( - Rect::new(location, Size::new(Px(1), line_height)), + Rect::new( + Point::new(Px(0), end_position.y), + Size::new(end_position.x + end_width, cache.measured.line_height), + ), + highlight, + ), + padding, + None, + None, + ); + } + } else { + let (location, _) = Self::locate_cursor(cache.measured, cache.cursor, cache.bytes); + let window_focused = context.window().focused().get(); + if window_focused && cursor_state.visible { + let cursor_width = Lp::points(2).into_px(context.gfx.scale()); + context.gfx.draw_shape( + &Shape::filled_rect( + Rect::new( + Point::new(location.x - cursor_width / 2, location.y), + Size::new(cursor_width, cache.measured.line_height), + ), highlight, // TODO cursor should be a bold color, highlight probably not. This should have its own color. ), padding, @@ -435,10 +929,9 @@ impl Widget for Input { context.stroke_outline::(outline_color, StrokeOptions::default()); } - let text_color = context.get(&TextColor); context .gfx - .draw_text_buffer(buffer, text_color, TextOrigin::TopLeft, padding, None, None); + .draw_measured_text(cache.measured, TextOrigin::TopLeft, padding, None, None); } fn layout( @@ -451,26 +944,12 @@ impl Widget for Input { self.needs_to_select_all = false; self.select_all(); } - let editor = self.editor_mut(&mut context.graphics.gfx, &context.graphics.widget); - let buffer = editor.buffer_mut(); - let width = available_space - .width - .max() - .saturating_sub(padding * 2) - .into_float(); - let height = available_space - .height - .max() - .saturating_sub(padding * 2) - .into_float(); - buffer.set_size(context.gfx.font_system(), width, height); - context - .gfx - .measure_text_buffer::(buffer, Color::WHITE) - .size - .into_unsigned() - + Size::new(padding * 2, padding * 2) + let width = available_space.width.max().saturating_sub(padding * 2); + + let cache = self.layout_text(Some(width.into_signed()), &mut context.graphics); + + cache.measured.size.into_unsigned() + Size::new(padding * 2, padding * 2) } fn keyboard_input( @@ -484,29 +963,14 @@ impl Widget for Input { on_key.invoke(input.clone())?; } - // println!( - // "Keyboard input: {:?}. {:?}, {:?}", - // input.logical_key, input.text, input.physical_key - // ); - let (text_changed, handled) = self.handle_key(input, context); + let handled = self.handle_key(input, context); if handled.is_break() { context.set_needs_redraw(); - self.cursor_state.force_on(); - if text_changed { - if let Value::Dynamic(value) = &self.text { - let state = self.editor.as_mut().expect("just_used"); - value.map_mut(|text| { - text.clear(); - for line in &state.editor.buffer().lines { - text.push_str(line.text()); - } - }); - state.generation = Some(value.generation()); - } - } } + self.blink_state.force_on(); + handled } @@ -517,8 +981,7 @@ impl Widget for Input { tracing::warn!("TODO: preview IME input {text}, cursor: {cursor:?}"); } Ime::Commit(text) => { - self.editor_mut(context.kludgine, &context.widget) - .insert_string(&text, None); + self.replace_selection(&text); context.set_needs_redraw(); } } @@ -532,6 +995,11 @@ impl Widget for Input { } context.set_ime_allowed(true); + context.set_ime_purpose(if self.is_masked() { + ImePurpose::Password + } else { + ImePurpose::Normal + }); context.set_needs_redraw(); } @@ -541,89 +1009,16 @@ impl Widget for Input { } } -struct LiveEditor { - editor: Editor, - generation: Option, -} - -fn cursor_glyph(buffer: &Buffer, cursor: &Cursor) -> Result<(Point, Px), NotVisible> { - // let cursor = buffer.layout_cursor(cursor); - - let mut layout_cursor = buffer.layout_cursor(cursor); - // TODO this is because of a TODO inside of layout_cursor. It currently - // falls back to 0,0 on the current line, rather than picking the last one. - if layout_cursor.glyph == 0 && layout_cursor.layout == 0 && cursor.index > 0 { - layout_cursor.glyph = usize::MAX; - } - let mut return_after_character = false; - let searching_for = match buffer - .lines - .get(layout_cursor.line) - .and_then(|line| { - line.layout_opt() - .as_ref() - .expect("line layout missing") - .get(layout_cursor.layout) - }) - .and_then(|layout| layout.glyphs.get(layout_cursor.glyph)) - // TODO these should progressively fail rather than a single or_else. - .or_else(|| { - return_after_character = true; - buffer - .lines - .last() - .and_then(|line| { - line.layout_opt() - .as_ref() - .expect("line layout missing") - .last() - }) - .and_then(|layout| layout.glyphs.last()) - }) { - Some(glyph) => glyph, - None => return Err(NotVisible::Before), - } - .start; - - for (index, run) in buffer.layout_runs().enumerate() { - match run.line_i.cmp(&cursor.line) { - Ordering::Less => continue, - Ordering::Equal => {} - Ordering::Greater => { - if index > 0 { - return Err(NotVisible::After); - } - return Err(NotVisible::Before); - } - } - if let Some(glyph) = run.glyphs.iter().find(|g| g.start == searching_for) { - let physical = glyph.physical((0., run.line_y), 1.); - let position = Point::new(Px(physical.x), Px::from_float(run.line_top)); - let width = Px::from_float(glyph.w); - return Ok(if return_after_character { - (Point::new(position.x + width, position.y), Px(0)) - } else { - (position, width) - }); - } - } - - Err(NotVisible::After) -} - -#[derive(Debug, Eq, PartialEq)] -enum NotVisible { - Before, - After, -} +#[derive(Debug, PartialEq, Eq)] +struct NotVisible(Point, usize); #[derive(Clone, Copy)] -struct CursorState { +struct BlinkState { visible: bool, remaining_until_blink: Duration, } -impl Default for CursorState { +impl Default for BlinkState { fn default() -> Self { Self { visible: true, @@ -632,7 +1027,7 @@ impl Default for CursorState { } } -impl CursorState { +impl BlinkState { pub fn update(&mut self, elapsed: Duration) { let total_cycles = elapsed.as_nanos() / CURSOR_BLINK_DURATION.as_nanos(); let remaining = Duration::from_nanos( @@ -658,3 +1053,220 @@ impl CursorState { self.remaining_until_blink = CURSOR_BLINK_DURATION; } } + +/// A type that can be used as the storage of an [`Input`]'s string value. +/// +/// This crate implements this trait for these types: +/// +/// - [`String`] +/// - `Cow<'static, str>` +/// - [`CowString`] +/// - [`MaskedString`] +pub trait InputStorage: UnwindSafe + Send + 'static { + /// If true, the input field should display a mask instead of the actual + /// string by default. + const MASKED: bool; + + /// Returns a reference to the contents as a `str`. + fn as_str(&self) -> &str; + /// Returns an exclusive reference to the contents as a `String`. + fn as_string_mut(&mut self) -> &mut String; +} + +impl InputStorage for String { + const MASKED: bool = false; + + fn as_str(&self) -> &str { + self.borrow() + } + + fn as_string_mut(&mut self) -> &mut String { + self.borrow_mut() + } +} + +impl InputStorage for Cow<'static, str> { + const MASKED: bool = false; + + fn as_str(&self) -> &str { + self.borrow() + } + + fn as_string_mut(&mut self) -> &mut String { + self.to_mut() + } +} + +/// A type that can be converted into a [`Dynamic`] containing `Storage`. +pub trait InputValue: IntoDynamic + Sized +where + Storage: InputStorage, +{ + /// Returns this string as a text input widget. + fn into_input(self) -> Input { + Input::new(self.into_dynamic()) + } +} + +impl InputValue for T where T: IntoDynamic {} +impl InputValue> for T where T: IntoDynamic> {} + +/// A cheap-to-clone, copy-on-write [`String`] type that implements +/// [`InputStorage`]. +#[derive(Eq, Clone, Hash, Ord)] +pub struct CowString(Arc); + +impl CowString { + /// Returns a new copy-on-write string with `str` as its contents. + pub fn new(str: impl Into) -> Self { + Self(Arc::new(str.into())) + } +} + +impl Debug for CowString { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Debug::fmt(self.as_str(), f) + } +} + +impl Display for CowString { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(self.as_str(), f) + } +} + +impl PartialOrd for CowString +where + T: PartialOrd + ?Sized, +{ + fn partial_cmp(&self, other: &T) -> Option { + other.partial_cmp(self.as_str()).map(Ordering::reverse) + } +} + +/// A cheap-to-clone, copy-on-write [`String`] type that masks its contents in +/// [`Debug`] and [`InputStorage`] implementations. +/// +/// This type is designed to be used with an [`Input`] widget to create a +/// password/secure text entry field. +/// +/// Internally, [`zeroize::Zeroizing`] is used to clear any contents of all +/// instances of [`MaskedString`] upon drop. +#[derive(Eq, Clone)] +pub struct MaskedString(Arc>); + +impl MaskedString { + /// Returns a new copy-on-write string with `str` as its contents. + /// + /// When used in an [`Input`] widget, the input will be masked by default. + pub fn new(str: impl Into) -> Self { + Self(Arc::new(Zeroizing::new(str.into()))) + } +} + +impl Debug for MaskedString { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + if f.alternate() { + f.write_str("MaskedString(")?; + for _ in 0..self.as_str().len() { + f.write_char('*')?; + } + f.write_char(')') + } else { + f.debug_struct("MaskedString").finish_non_exhaustive() + } + } +} + +macro_rules! impl_cow_string { + ($type:ident, $masked:literal) => { + impl Deref for $type { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl DerefMut for $type { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut *Arc::make_mut(&mut self.0) + } + } + + impl Default for $type { + fn default() -> Self { + static EMPTY: Lazy<$type> = Lazy::new(|| $type(Arc::default())); + EMPTY.clone() + } + } + + impl From for $type { + fn from(s: char) -> Self { + Self::new(s) + } + } + + impl IntoValue<$type> for char { + fn into_value(self) -> Value<$type> { + Value::Constant(<$type>::from(self)) + } + } + + impl From for $type { + fn from(s: String) -> Self { + Self::new(s) + } + } + + impl IntoValue<$type> for String { + fn into_value(self) -> Value<$type> { + Value::Constant(<$type>::from(self)) + } + } + + impl<'a> From<&'a str> for $type { + fn from(s: &'a str) -> Self { + Self::new(s) + } + } + + impl IntoValue<$type> for &str { + fn into_value(self) -> Value<$type> { + Value::Constant(<$type>::from(self)) + } + } + + impl<'a> From<&'a String> for $type { + fn from(s: &'a String) -> Self { + Self::new(s.as_str()) + } + } + + impl PartialEq for $type + where + T: PartialEq + ?Sized, + { + fn eq(&self, other: &T) -> bool { + other == self.as_str() + } + } + + impl InputStorage for $type { + const MASKED: bool = $masked; + + fn as_str(&self) -> &str { + &**self + } + + fn as_string_mut(&mut self) -> &mut String { + &mut *Arc::make_mut(&mut self.0) + } + } + + impl InputValue<$type> for T where T: IntoDynamic<$type> {} + }; +} + +impl_cow_string!(CowString, false); +impl_cow_string!(MaskedString, true);