//! A text input widget. use std::borrow::{Borrow, BorrowMut, Cow}; use std::cmp::Ordering; 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, NamedKey}; use kludgine::app::winit::window::{CursorIcon, 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::{MeasuredText, Text, TextOrigin}; use kludgine::{Color, DrawableExt}; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; use zeroize::Zeroizing; use crate::context::{EventContext, GraphicsContext, LayoutContext}; use crate::styles::components::{HighlightColor, IntrinsicPadding, OutlineColor, TextColor}; use crate::utils::ModifiersExt; use crate::value::{Dynamic, Generation, IntoDynamic, IntoValue, Value}; use crate::widget::{Callback, EventHandling, Widget, HANDLED, IGNORED}; use crate::{ConstraintLimit, Lazy}; const CURSOR_BLINK_DURATION: Duration = Duration::from_millis(500); /// A text input widget. #[must_use] pub struct Input { /// The value of this widget. pub value: Dynamic, /// The placeholder text to display when no value is present. pub placeholder: Value, mask_symbol: Value, mask: CowString, on_key: Option>, cache: Option, selection: SelectionState, blink_state: BlinkState, needs_to_select_all: bool, mouse_buttons_down: usize, line_navigation_x_target: Option, window_focused: bool, } struct CachedLayout { bytes: usize, color: Color, generation: Generation, mask_generation: Option, placeholder_generation: Option, mask_bytes: usize, width: Option, measured: MeasuredText, placeholder: MeasuredText, } impl CachedLayout { pub fn is_current( &self, generation: Generation, mask_generation: Option, placeholder_generation: Option, width: Option, color: Color, mask_bytes: usize, ) -> bool { self.generation == generation && self.mask_generation == mask_generation && self.placeholder_generation == placeholder_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_value: impl IntoDynamic) -> Self { Self { value: initial_value.into_dynamic(), mask: CowString::default(), mask_symbol: Storage::MASKED .then(|| CowString::from('\u{2022}')) .unwrap_or_default() .into_value(), placeholder: Value::default(), cache: None, blink_state: BlinkState::default(), selection: SelectionState::default(), on_key: None, mouse_buttons_down: 0, needs_to_select_all: true, line_navigation_x_target: None, window_focused: false, } } /// Sets the `placeholder` text, which is displayed when the field has an /// empty value. pub fn placeholder(mut self, placeholder: impl IntoValue) -> Self { self.placeholder = placeholder.into_value(); self } /// 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`] /// is returned, this widget will ignore the event. pub fn on_key(mut self, on_key: F) -> Self where F: FnMut(KeyEvent) -> EventHandling + Send + UnwindSafe + 'static, { self.on_key = Some(Callback::new(on_key)); self } 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, context: &mut EventContext<'_, '_>) { if !context.enabled() { return; } 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, ""); } } } 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, context: &mut EventContext<'_, '_>) { if !context.enabled() { return; } 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; } if let Ok(Some(offset)) = GraphemeCursor::new(cursor.offset, value.as_str().len(), true) .prev_boundary(value.as_str(), 0) { value .as_string_mut() .replace_range(offset..cursor.offset, ""); self.selection.cursor.offset -= cursor.offset - offset; } } } fn move_cursor( &mut self, direction: Affinity, mode: CursorNavigationMode, context: &mut EventContext<'_, '_>, ) { if !matches!(mode, CursorNavigationMode::Line) { self.line_navigation_x_target = None; } // @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 mode { CursorNavigationMode::Grapheme => self.move_cursor_by_grapheme(direction), CursorNavigationMode::Word => self.move_cursor_by_word(direction), CursorNavigationMode::Line => self.move_cursor_by_line(direction, context), CursorNavigationMode::LineExtent => self.move_cursor_by_line_extent(direction, context), } } fn move_cursor_by_grapheme(&mut self, affinity: Affinity) { let value = self.value.lock(); let length = value.as_str().len(); match affinity { Affinity::Before => { 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 => { 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; } } } } } fn move_cursor_by_word(&mut self, affinity: Affinity) { let value = self.value.lock(); let length = value.as_str().len(); match affinity { Affinity::Before => { 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 => { 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 move_cursor_by_line_extent( &mut self, affinity: Affinity, context: &mut EventContext<'_, '_>, ) { let Some(cache) = self.cache.as_ref() else { return; }; let (mut position, _) = Self::point_from_cursor(&cache.measured, self.selection.cursor, cache.bytes); position.y += context .get(&IntrinsicPadding) .into_px(context.kludgine.scale()); match affinity { Affinity::Before => position.x = Px::ZERO, Affinity::After => { position.x = context.last_layout().map_or(Px::MAX, |r| r.size.width); } }; self.selection.cursor = self.cursor_from_point(position, context); } fn move_cursor_by_line(&mut self, affinity: Affinity, context: &mut EventContext<'_, '_>) { let Some(cache) = self.cache.as_ref() else { return; }; let (mut position, _) = Self::point_from_cursor(&cache.measured, self.selection.cursor, cache.bytes); position += Point::squared( context .get(&IntrinsicPadding) .into_px(context.kludgine.scale()), ); if let Some(target_x) = self.line_navigation_x_target { position.x = target_x; } else { self.line_navigation_x_target = Some(position.x); } match affinity { Affinity::Before => position.y -= cache.measured.line_height, Affinity::After => { position.y += cache.measured.line_height; } }; self.selection.cursor = self.cursor_from_point(position, context); } fn selected_range(&mut self) -> (Cursor, Option) { match self.selection.start { Some(start) => match start.offset.cmp(&self.selection.cursor.offset) { 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, context: &mut EventContext<'_, '_>) { if !context.enabled() { return; } 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 { if !context.enabled() { return false; } match context .clipboard_guard() .map(|mut clipboard| clipboard.get_text()) { Some(Ok(text)) => { self.replace_selection(&text, context); 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::Named(key @ (NamedKey::Backspace| NamedKey::Delete)), _) => { match key { NamedKey::Backspace => self.delete(context), NamedKey::Delete => self.forward_delete(context), _ => unreachable!("previously matched"), } HANDLED } (ElementState::Pressed, Key::Named(key @ (NamedKey::ArrowLeft | NamedKey::ArrowDown | NamedKey::ArrowUp | NamedKey::ArrowRight | NamedKey::Home | NamedKey::End)), _) => { let modifiers = context.modifiers(); let affinity = if matches!(key, NamedKey::ArrowLeft | NamedKey::ArrowUp | NamedKey::Home) { Affinity::Before } else { Affinity::After }; match (self.selection.start, modifiers.state().shift_key()) { (None, true) => { self.selection.start = Some(self.selection.cursor); } (Some(start), false) => { self.selection.cursor = if affinity == Affinity::Before { start.min(self.selection.cursor) } else { start.max(self.selection.cursor) }; self.selection.start = None; } _ => {} }; match key { #[cfg(any(target_os = "ios", target_os = "macos"))] NamedKey::ArrowLeft | NamedKey::ArrowRight if modifiers.primary() => self.move_cursor(affinity, CursorNavigationMode::LineExtent, context), #[cfg(not(any(target_os = "ios", target_os = "macos")))] NamedKey::Home | NamedKey::End => self.move_cursor(affinity, CursorNavigationMode::LineExtent, context), NamedKey::ArrowLeft | NamedKey::ArrowRight if modifiers.word_select() => self.move_cursor(affinity, CursorNavigationMode::Word, context), NamedKey::ArrowLeft | NamedKey::ArrowRight => self.move_cursor(affinity, CursorNavigationMode::Grapheme, context), NamedKey::ArrowDown | NamedKey::ArrowUp => self.move_cursor(affinity, CursorNavigationMode::Line, context), _ => tracing::warn!("unhandled key: {key:?}"), } HANDLED } (state, _, Some("a")) if context.modifiers().primary() => { if state.is_pressed() { self.select_all(); } HANDLED } (state, _, Some("c")) if context.modifiers().primary() => { if state.is_pressed() { self.copy_selection_to_clipboard(context); } HANDLED } (state, _, Some("v")) if context.modifiers().primary() => { if state.is_pressed() { self.paste_from_clipboard(context); } HANDLED } (state, _, Some(text)) if !context.modifiers().primary() && text != "\t" // tab && text != "\r" // enter/return && text != "\u{1b}" // escape => { if state.is_pressed() { self.replace_selection(text, context); } 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 placeholder_generation = self.placeholder.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, placeholder_generation, width, color, mask_bytes, ) => {} _ => { let (bytes, measured, placeholder, ) = 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; } }); context.apply_current_font_settings(); let mut text = Text::new(text, color); if let Some(width) = width { text = text.wrap_at(width); } let placeholder_color = context.theme().surface.on_color_variant; let placeholder = self.placeholder.map(|placeholder| context.gfx.measure_text(Text::new(placeholder, placeholder_color))); (bytes, context.gfx.measure_text(text), placeholder) }); self.cache = Some(CachedLayout { bytes, color, generation, mask_generation, placeholder_generation, mask_bytes, width, measured, placeholder, }); } } // 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(); assert!(cursor.offset <= value.len()); cursor.offset = value[..cursor.offset].graphemes(true).count() * mask_bytes; if let Some(selection) = &mut selection { assert!(selection.offset <= value.len()); selection.offset = value[..selection.offset].graphemes(true).count() * mask_bytes; } }); } let cache = self.cache.as_ref().expect("always initialized"); CacheInfo { measured: &cache.measured, placeholder: &cache.placeholder, bytes: cache.bytes, masked: mask_bytes > 0, cursor, selection, } } #[allow(clippy::too_many_lines)] // it's text layout, c'mon fn point_from_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::ZERO); } // 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::ZERO; 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::new( 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::new( 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::ZERO) } 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::new(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::ZERO, before_y + measured.line_height), Px::ZERO, ), } } } } fn cursor_from_point( &mut self, location: Point, context: &mut EventContext<'_, '_>, ) -> Cursor { let mut cursor = self.cached_cursor_from_point(location, context); if let Some(symbol) = self.mask.graphemes(true).next() { let grapheme_offset = cursor.offset / symbol.len(); cursor.offset = self.value.map_ref(|value| { value .as_str() .graphemes(true) .take(grapheme_offset) .map(str::len) .sum::() }); } cursor } fn cached_cursor_from_point( &mut self, location: Point, context: &mut EventContext<'_, '_>, ) -> Cursor { let Some(cache) = &self.cache else { return Cursor::default(); }; let padding = context .get(&IntrinsicPadding) .into_px(context.kludgine.scale()); let mut location = location - padding; if location.y < 0 { location.y = Px::ZERO; } if location.x < 0 { location.x = Px::ZERO; } let mut closest: Option<(Cursor, i32, usize, Point)> = None; let mut current_line = usize::MAX; let mut current_line_y = Px::ZERO; for (index, glyph) in cache.measured.glyphs.iter().enumerate() { if current_line != glyph.info.line { current_line = glyph.info.line; current_line_y = cache .measured .line_height .saturating_mul(Px::new(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 < cache.bytes { 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 .get() .saturating_mul(current_line_y.get().saturating_pow(2)) .saturating_abs(); let cursor = Cursor { offset: if relative.x < 0 || relative.y < 0 { glyph.info.start } else { glyph.info.end }, affinity: Affinity::Before, }; match closest { Some((_, closest_xy, ..)) if xy < closest_xy => { closest = Some((cursor, xy, index, relative)); } None => closest = Some((cursor, xy, index, relative)), _ => {} } } if let Some((closest, _, index, relative)) = closest { // Having whitespace not in the measured text is really annoying. // This trick only works for the first line of text. Maybe we should // try and create a structure that organizes the glyphs into lines // so that it's easier to inspect and detect when there's // whitespace. For now, this is just a hack that helps get *some* // selection at the end of the input for trailing whitespace. if relative.x < 0 && index < cache.measured.glyphs.len() { return closest; } } Cursor { offset: cache.bytes, affinity: Affinity::After, } } } struct CacheInfo<'a> { measured: &'a MeasuredText, placeholder: &'a MeasuredText, bytes: usize, masked: bool, cursor: Cursor, selection: Option, } #[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.value) .finish_non_exhaustive() } } impl Widget for Input where Storage: InputStorage + Debug, { fn hit_test(&mut self, _location: Point, _context: &mut EventContext<'_, '_>) -> bool { true } fn accept_focus(&mut self, _context: &mut EventContext<'_, '_>) -> bool { true } fn mouse_down( &mut self, location: Point, _device_id: kludgine::app::winit::event::DeviceId, _button: kludgine::app::winit::event::MouseButton, context: &mut EventContext<'_, '_>, ) -> EventHandling { self.mouse_buttons_down += 1; context.focus(); self.needs_to_select_all = false; self.selection.cursor = self.cursor_from_point(location, context); self.selection.start = Some(self.selection.cursor); context.set_needs_redraw(); HANDLED } fn hover( &mut self, _location: Point, _context: &mut EventContext<'_, '_>, ) -> Option { Some(CursorIcon::Text) } fn mouse_drag( &mut self, location: Point, _device_id: kludgine::app::winit::event::DeviceId, _button: kludgine::app::winit::event::MouseButton, context: &mut EventContext<'_, '_>, ) { 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( &mut self, _location: Option>, _device_id: kludgine::app::winit::event::DeviceId, _button: kludgine::app::winit::event::MouseButton, _context: &mut EventContext<'_, '_>, ) { self.mouse_buttons_down -= 1; } #[allow(clippy::too_many_lines)] fn redraw(&mut self, context: &mut crate::context::GraphicsContext<'_, '_, '_, '_, '_>) { if self.needs_to_select_all { self.needs_to_select_all = false; self.select_all(); } self.blink_state.update(context.elapsed()); let window_focused = context.window().focused().get_tracking_refresh(context); if window_focused != self.window_focused { if window_focused { self.blink_state.force_on(); } self.window_focused = window_focused; } 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 cache = self.layout_text(Some(size.width.into_signed()), context); let highlight = if context.focused() && window_focused { context.draw_focus_ring(); context.get(&HighlightColor) } else { let outline_color = context.get(&OutlineColor); context.stroke_outline::(outline_color, StrokeOptions::default()); outline_color }; if context.focused() { context.set_ime_allowed(true); 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 { (cache.cursor, selection) }; let (start_position, _) = Self::point_from_cursor(cache.measured, start, cache.bytes); let (end_position, end_width) = Self::point_from_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, ) .translate_by(padding), ); } 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, ) .translate_by(padding), ); // 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::ZERO, bottom_of_first_line), Size::new(size.width.into_signed(), distance_between), ), highlight, ) .translate_by(padding), ); } // Draw from 0 to end + width context.gfx.draw_shape( Shape::filled_rect( Rect::new( Point::new(Px::ZERO, end_position.y), Size::new(end_position.x + end_width, cache.measured.line_height), ), highlight, ) .translate_by(padding), ); } } else if window_focused { let (location, _) = Self::point_from_cursor(cache.measured, cache.cursor, cache.bytes); if 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, ) .translate_by(padding), ); } context.redraw_in(cursor_state.remaining_until_blink); } else { context.redraw_when_changed(context.window().focused()); } } let text = if cache.bytes > 0 { cache.measured } else { cache.placeholder }; context .gfx .draw_measured_text(text.translate_by(padding), TextOrigin::TopLeft); } fn layout( &mut self, available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { let padding = context.get(&IntrinsicPadding).into_upx(context.gfx.scale()); 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 .max(cache.placeholder.size) .into_unsigned() + Size::squared(padding * 2) } fn keyboard_input( &mut self, _device_id: kludgine::app::winit::event::DeviceId, input: kludgine::app::winit::event::KeyEvent, _is_synthetic: bool, context: &mut EventContext<'_, '_>, ) -> EventHandling { if let Some(on_key) = &mut self.on_key { on_key.invoke(input.clone())?; } let handled = self.handle_key(input, context); if handled.is_break() { context.set_needs_redraw(); } self.blink_state.force_on(); handled } fn ime(&mut self, ime: Ime, context: &mut EventContext<'_, '_>) -> EventHandling { match ime { Ime::Enabled | Ime::Disabled => {} Ime::Preedit(text, cursor) => { tracing::warn!("TODO: preview IME input {text}, cursor: {cursor:?}"); } Ime::Commit(text) => { self.replace_selection(&text, context); context.set_needs_redraw(); } } HANDLED } fn focus(&mut self, context: &mut EventContext<'_, '_>) { if self.mouse_buttons_down == 0 { self.needs_to_select_all = true; } context.set_ime_allowed(true); context.set_ime_purpose(if self.is_masked() { ImePurpose::Password } else { ImePurpose::Normal }); context.set_needs_redraw(); } fn blur(&mut self, context: &mut EventContext<'_, '_>) { context.set_ime_allowed(false); context.set_needs_redraw(); } } #[derive(Debug, PartialEq, Eq)] struct NotVisible(Point, usize); #[derive(Clone, Copy)] struct BlinkState { visible: bool, remaining_until_blink: Duration, } impl Default for BlinkState { fn default() -> Self { Self { visible: true, remaining_until_blink: CURSOR_BLINK_DURATION, } } } 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( u64::try_from(elapsed.as_nanos() % CURSOR_BLINK_DURATION.as_nanos()) .expect("remainder fits in u64"), ); // If we have an odd number of totaal cycles, flip the visibility. if total_cycles & 1 == 1 { self.visible = !self.visible; } if let Some(remaining) = self.remaining_until_blink.checked_sub(remaining) { self.remaining_until_blink = remaining; } else { self.visible = !self.visible; self.remaining_until_blink = CURSOR_BLINK_DURATION - (remaining - self.remaining_until_blink); } } pub fn force_on(&mut self) { self.visible = true; 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 IntoValue<$type> for Dynamic { fn into_value(self) -> Value<$type> { Value::Dynamic(self.map_each_to()) } } impl IntoValue<$type> for Dynamic<&'static str> { fn into_value(self) -> Value<$type> { Value::Dynamic(self.map_each(|s| <$type>::from(*s))) } } 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);