diff options
Diffstat (limited to '')
| -rw-r--r-- | widget/src/text_editor.rs | 708 | ||||
| -rw-r--r-- | widget/src/text_input.rs | 432 | ||||
| -rw-r--r-- | widget/src/text_input/cursor.rs | 26 | ||||
| -rw-r--r-- | widget/src/text_input/value.rs | 13 |
4 files changed, 978 insertions, 201 deletions
diff --git a/widget/src/text_editor.rs b/widget/src/text_editor.rs new file mode 100644 index 00000000..1708a2e5 --- /dev/null +++ b/widget/src/text_editor.rs @@ -0,0 +1,708 @@ +//! Display a multi-line text input for text editing. +use crate::core::event::{self, Event}; +use crate::core::keyboard; +use crate::core::layout::{self, Layout}; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::text::editor::{Cursor, Editor as _}; +use crate::core::text::highlighter::{self, Highlighter}; +use crate::core::text::{self, LineHeight}; +use crate::core::widget::{self, Widget}; +use crate::core::{ + Clipboard, Color, Element, Length, Padding, Pixels, Rectangle, Shell, + Vector, +}; + +use std::cell::RefCell; +use std::fmt; +use std::ops::DerefMut; +use std::sync::Arc; + +pub use crate::style::text_editor::{Appearance, StyleSheet}; +pub use text::editor::{Action, Edit, Motion}; + +/// A multi-line text input. +#[allow(missing_debug_implementations)] +pub struct TextEditor<'a, Highlighter, Message, Renderer = crate::Renderer> +where + Highlighter: text::Highlighter, + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + content: &'a Content<Renderer>, + font: Option<Renderer::Font>, + text_size: Option<Pixels>, + line_height: LineHeight, + width: Length, + height: Length, + padding: Padding, + style: <Renderer::Theme as StyleSheet>::Style, + on_edit: Option<Box<dyn Fn(Action) -> Message + 'a>>, + highlighter_settings: Highlighter::Settings, + highlighter_format: fn( + &Highlighter::Highlight, + &Renderer::Theme, + ) -> highlighter::Format<Renderer::Font>, +} + +impl<'a, Message, Renderer> + TextEditor<'a, highlighter::PlainText, Message, Renderer> +where + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + /// Creates new [`TextEditor`] with the given [`Content`]. + pub fn new(content: &'a Content<Renderer>) -> Self { + Self { + content, + font: None, + text_size: None, + line_height: LineHeight::default(), + width: Length::Fill, + height: Length::Fill, + padding: Padding::new(5.0), + style: Default::default(), + on_edit: None, + highlighter_settings: (), + highlighter_format: |_highlight, _theme| { + highlighter::Format::default() + }, + } + } +} + +impl<'a, Highlighter, Message, Renderer> + TextEditor<'a, Highlighter, Message, Renderer> +where + Highlighter: text::Highlighter, + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + /// Sets the message that should be produced when some action is performed in + /// the [`TextEditor`]. + /// + /// If this method is not called, the [`TextEditor`] will be disabled. + pub fn on_action( + mut self, + on_edit: impl Fn(Action) -> Message + 'a, + ) -> Self { + self.on_edit = Some(Box::new(on_edit)); + self + } + + /// Sets the [`Font`] of the [`TextEditor`]. + /// + /// [`Font`]: text::Renderer::Font + pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self { + self.font = Some(font.into()); + self + } + + /// Sets the [`Padding`] of the [`TextEditor`]. + pub fn padding(mut self, padding: impl Into<Padding>) -> Self { + self.padding = padding.into(); + self + } + + /// Highlights the [`TextEditor`] with the given [`Highlighter`] and + /// a strategy to turn its highlights into some text format. + pub fn highlight<H: text::Highlighter>( + self, + settings: H::Settings, + to_format: fn( + &H::Highlight, + &Renderer::Theme, + ) -> highlighter::Format<Renderer::Font>, + ) -> TextEditor<'a, H, Message, Renderer> { + TextEditor { + content: self.content, + font: self.font, + text_size: self.text_size, + line_height: self.line_height, + width: self.width, + height: self.height, + padding: self.padding, + style: self.style, + on_edit: self.on_edit, + highlighter_settings: settings, + highlighter_format: to_format, + } + } +} + +/// The content of a [`TextEditor`]. +pub struct Content<R = crate::Renderer>(RefCell<Internal<R>>) +where + R: text::Renderer; + +struct Internal<R> +where + R: text::Renderer, +{ + editor: R::Editor, + is_dirty: bool, +} + +impl<R> Content<R> +where + R: text::Renderer, +{ + /// Creates an empty [`Content`]. + pub fn new() -> Self { + Self::with_text("") + } + + /// Creates a [`Content`] with the given text. + pub fn with_text(text: &str) -> Self { + Self(RefCell::new(Internal { + editor: R::Editor::with_text(text), + is_dirty: true, + })) + } + + /// Performs an [`Action`] on the [`Content`]. + pub fn perform(&mut self, action: Action) { + let internal = self.0.get_mut(); + + internal.editor.perform(action); + internal.is_dirty = true; + } + + /// Returns the amount of lines of the [`Content`]. + pub fn line_count(&self) -> usize { + self.0.borrow().editor.line_count() + } + + /// Returns the text of the line at the given index, if it exists. + pub fn line( + &self, + index: usize, + ) -> Option<impl std::ops::Deref<Target = str> + '_> { + std::cell::Ref::filter_map(self.0.borrow(), |internal| { + internal.editor.line(index) + }) + .ok() + } + + /// Returns an iterator of the text of the lines in the [`Content`]. + pub fn lines( + &self, + ) -> impl Iterator<Item = impl std::ops::Deref<Target = str> + '_> { + struct Lines<'a, Renderer: text::Renderer> { + internal: std::cell::Ref<'a, Internal<Renderer>>, + current: usize, + } + + impl<'a, Renderer: text::Renderer> Iterator for Lines<'a, Renderer> { + type Item = std::cell::Ref<'a, str>; + + fn next(&mut self) -> Option<Self::Item> { + let line = std::cell::Ref::filter_map( + std::cell::Ref::clone(&self.internal), + |internal| internal.editor.line(self.current), + ) + .ok()?; + + self.current += 1; + + Some(line) + } + } + + Lines { + internal: self.0.borrow(), + current: 0, + } + } + + /// Returns the text of the [`Content`]. + /// + /// Lines are joined with `'\n'`. + pub fn text(&self) -> String { + let mut text = self.lines().enumerate().fold( + String::new(), + |mut contents, (i, line)| { + if i > 0 { + contents.push('\n'); + } + + contents.push_str(&line); + + contents + }, + ); + + if !text.ends_with('\n') { + text.push('\n'); + } + + text + } + + /// Returns the selected text of the [`Content`]. + pub fn selection(&self) -> Option<String> { + self.0.borrow().editor.selection() + } + + /// Returns the current cursor position of the [`Content`]. + pub fn cursor_position(&self) -> (usize, usize) { + self.0.borrow().editor.cursor_position() + } +} + +impl<Renderer> Default for Content<Renderer> +where + Renderer: text::Renderer, +{ + fn default() -> Self { + Self::new() + } +} + +impl<Renderer> fmt::Debug for Content<Renderer> +where + Renderer: text::Renderer, + Renderer::Editor: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let internal = self.0.borrow(); + + f.debug_struct("Content") + .field("editor", &internal.editor) + .field("is_dirty", &internal.is_dirty) + .finish() + } +} + +struct State<Highlighter: text::Highlighter> { + is_focused: bool, + last_click: Option<mouse::Click>, + drag_click: Option<mouse::click::Kind>, + highlighter: RefCell<Highlighter>, + highlighter_settings: Highlighter::Settings, + highlighter_format_address: usize, +} + +impl<'a, Highlighter, Message, Renderer> Widget<Message, Renderer> + for TextEditor<'a, Highlighter, Message, Renderer> +where + Highlighter: text::Highlighter, + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::<State<Highlighter>>() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State { + is_focused: false, + last_click: None, + drag_click: None, + highlighter: RefCell::new(Highlighter::new( + &self.highlighter_settings, + )), + highlighter_settings: self.highlighter_settings.clone(), + highlighter_format_address: self.highlighter_format as usize, + }) + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + self.height + } + + fn layout( + &self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> iced_renderer::core::layout::Node { + let mut internal = self.content.0.borrow_mut(); + let state = tree.state.downcast_mut::<State<Highlighter>>(); + + if state.highlighter_format_address != self.highlighter_format as usize + { + state.highlighter.borrow_mut().change_line(0); + + state.highlighter_format_address = self.highlighter_format as usize; + } + + if state.highlighter_settings != self.highlighter_settings { + state + .highlighter + .borrow_mut() + .update(&self.highlighter_settings); + + state.highlighter_settings = self.highlighter_settings.clone(); + } + + internal.editor.update( + limits.pad(self.padding).max(), + self.font.unwrap_or_else(|| renderer.default_font()), + self.text_size.unwrap_or_else(|| renderer.default_size()), + self.line_height, + state.highlighter.borrow_mut().deref_mut(), + ); + + layout::Node::new(limits.max()) + } + + fn on_event( + &mut self, + tree: &mut widget::Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) -> event::Status { + let Some(on_edit) = self.on_edit.as_ref() else { + return event::Status::Ignored; + }; + + let state = tree.state.downcast_mut::<State<Highlighter>>(); + + let Some(update) = Update::from_event( + event, + state, + layout.bounds(), + self.padding, + cursor, + ) else { + return event::Status::Ignored; + }; + + match update { + Update::Click(click) => { + let action = match click.kind() { + mouse::click::Kind::Single => { + Action::Click(click.position()) + } + mouse::click::Kind::Double => Action::SelectWord, + mouse::click::Kind::Triple => Action::SelectLine, + }; + + state.is_focused = true; + state.last_click = Some(click); + state.drag_click = Some(click.kind()); + + shell.publish(on_edit(action)); + } + Update::Unfocus => { + state.is_focused = false; + state.drag_click = None; + } + Update::Release => { + state.drag_click = None; + } + Update::Action(action) => { + shell.publish(on_edit(action)); + } + Update::Copy => { + if let Some(selection) = self.content.selection() { + clipboard.write(selection); + } + } + Update::Paste => { + if let Some(contents) = clipboard.read() { + shell.publish(on_edit(Action::Edit(Edit::Paste( + Arc::new(contents), + )))); + } + } + } + + event::Status::Captured + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &<Renderer as renderer::Renderer>::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + + let mut internal = self.content.0.borrow_mut(); + let state = tree.state.downcast_ref::<State<Highlighter>>(); + + internal.editor.highlight( + self.font.unwrap_or_else(|| renderer.default_font()), + state.highlighter.borrow_mut().deref_mut(), + |highlight| (self.highlighter_format)(highlight, theme), + ); + + let is_disabled = self.on_edit.is_none(); + let is_mouse_over = cursor.is_over(bounds); + + let appearance = if is_disabled { + theme.disabled(&self.style) + } else if state.is_focused { + theme.focused(&self.style) + } else if is_mouse_over { + theme.hovered(&self.style) + } else { + theme.active(&self.style) + }; + + renderer.fill_quad( + renderer::Quad { + bounds, + border_radius: appearance.border_radius, + border_width: appearance.border_width, + border_color: appearance.border_color, + }, + appearance.background, + ); + + renderer.fill_editor( + &internal.editor, + bounds.position() + + Vector::new(self.padding.left, self.padding.top), + style.text_color, + ); + + let translation = Vector::new( + bounds.x + self.padding.left, + bounds.y + self.padding.top, + ); + + if state.is_focused { + match internal.editor.cursor() { + Cursor::Caret(position) => { + let position = position + translation; + + if bounds.contains(position) { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: position.x, + y: position.y, + width: 1.0, + height: self + .line_height + .to_absolute( + self.text_size.unwrap_or_else( + || renderer.default_size(), + ), + ) + .into(), + }, + border_radius: 0.0.into(), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + theme.value_color(&self.style), + ); + } + } + Cursor::Selection(ranges) => { + for range in ranges.into_iter().filter_map(|range| { + bounds.intersection(&(range + translation)) + }) { + renderer.fill_quad( + renderer::Quad { + bounds: range, + border_radius: 0.0.into(), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + theme.selection_color(&self.style), + ); + } + } + } + } + } + + fn mouse_interaction( + &self, + _state: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let is_disabled = self.on_edit.is_none(); + + if cursor.is_over(layout.bounds()) { + if is_disabled { + mouse::Interaction::NotAllowed + } else { + mouse::Interaction::Text + } + } else { + mouse::Interaction::default() + } + } +} + +impl<'a, Highlighter, Message, Renderer> + From<TextEditor<'a, Highlighter, Message, Renderer>> + for Element<'a, Message, Renderer> +where + Highlighter: text::Highlighter, + Message: 'a, + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + fn from( + text_editor: TextEditor<'a, Highlighter, Message, Renderer>, + ) -> Self { + Self::new(text_editor) + } +} + +enum Update { + Click(mouse::Click), + Unfocus, + Release, + Action(Action), + Copy, + Paste, +} + +impl Update { + fn from_event<H: Highlighter>( + event: Event, + state: &State<H>, + bounds: Rectangle, + padding: Padding, + cursor: mouse::Cursor, + ) -> Option<Self> { + let action = |action| Some(Update::Action(action)); + let edit = |edit| action(Action::Edit(edit)); + + match event { + Event::Mouse(event) => match event { + mouse::Event::ButtonPressed(mouse::Button::Left) => { + if let Some(cursor_position) = cursor.position_in(bounds) { + let cursor_position = cursor_position + - Vector::new(padding.top, padding.left); + + let click = mouse::Click::new( + cursor_position, + state.last_click, + ); + + Some(Update::Click(click)) + } else if state.is_focused { + Some(Update::Unfocus) + } else { + None + } + } + mouse::Event::ButtonReleased(mouse::Button::Left) => { + Some(Update::Release) + } + mouse::Event::CursorMoved { .. } => match state.drag_click { + Some(mouse::click::Kind::Single) => { + let cursor_position = cursor.position_in(bounds)? + - Vector::new(padding.top, padding.left); + + action(Action::Drag(cursor_position)) + } + _ => None, + }, + mouse::Event::WheelScrolled { delta } + if cursor.is_over(bounds) => + { + action(Action::Scroll { + lines: match delta { + mouse::ScrollDelta::Lines { y, .. } => { + if y.abs() > 0.0 { + (y.signum() * -(y.abs() * 4.0).max(1.0)) + as i32 + } else { + 0 + } + } + mouse::ScrollDelta::Pixels { y, .. } => { + (-y / 4.0) as i32 + } + }, + }) + } + _ => None, + }, + Event::Keyboard(event) => match event { + keyboard::Event::KeyPressed { + key_code, + modifiers, + } if state.is_focused => { + if let Some(motion) = motion(key_code) { + let motion = + if platform::is_jump_modifier_pressed(modifiers) { + motion.widen() + } else { + motion + }; + + return action(if modifiers.shift() { + Action::Select(motion) + } else { + Action::Move(motion) + }); + } + + match key_code { + keyboard::KeyCode::Enter => edit(Edit::Enter), + keyboard::KeyCode::Backspace => edit(Edit::Backspace), + keyboard::KeyCode::Delete => edit(Edit::Delete), + keyboard::KeyCode::Escape => Some(Self::Unfocus), + keyboard::KeyCode::C if modifiers.command() => { + Some(Self::Copy) + } + keyboard::KeyCode::V + if modifiers.command() && !modifiers.alt() => + { + Some(Self::Paste) + } + _ => None, + } + } + keyboard::Event::CharacterReceived(c) if state.is_focused => { + edit(Edit::Insert(c)) + } + _ => None, + }, + _ => None, + } + } +} + +fn motion(key_code: keyboard::KeyCode) -> Option<Motion> { + match key_code { + keyboard::KeyCode::Left => Some(Motion::Left), + keyboard::KeyCode::Right => Some(Motion::Right), + keyboard::KeyCode::Up => Some(Motion::Up), + keyboard::KeyCode::Down => Some(Motion::Down), + keyboard::KeyCode::Home => Some(Motion::Home), + keyboard::KeyCode::End => Some(Motion::End), + keyboard::KeyCode::PageUp => Some(Motion::PageUp), + keyboard::KeyCode::PageDown => Some(Motion::PageDown), + _ => None, + } +} + +mod platform { + use crate::core::keyboard; + + pub fn is_jump_modifier_pressed(modifiers: keyboard::Modifiers) -> bool { + if cfg!(target_os = "macos") { + modifiers.alt() + } else { + modifiers.control() + } + } +} diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index 03bcb86a..f1688746 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -17,7 +17,7 @@ use crate::core::keyboard; use crate::core::layout; use crate::core::mouse::{self, click}; use crate::core::renderer; -use crate::core::text::{self, Text}; +use crate::core::text::{self, Paragraph as _, Text}; use crate::core::time::{Duration, Instant}; use crate::core::touch; use crate::core::widget; @@ -67,7 +67,7 @@ where font: Option<Renderer::Font>, width: Length, padding: Padding, - size: Option<f32>, + size: Option<Pixels>, line_height: text::LineHeight, on_input: Option<Box<dyn Fn(String) -> Message + 'a>>, on_paste: Option<Box<dyn Fn(String) -> Message + 'a>>, @@ -76,6 +76,9 @@ where style: <Renderer::Theme as StyleSheet>::Style, } +/// The default [`Padding`] of a [`TextInput`]. +pub const DEFAULT_PADDING: Padding = Padding::new(5.0); + impl<'a, Message, Renderer> TextInput<'a, Message, Renderer> where Message: Clone, @@ -95,7 +98,7 @@ where is_secure: false, font: None, width: Length::Fill, - padding: Padding::new(5.0), + padding: DEFAULT_PADDING, size: None, line_height: text::LineHeight::default(), on_input: None, @@ -175,11 +178,11 @@ where /// Sets the text size of the [`TextInput`]. pub fn size(mut self, size: impl Into<Pixels>) -> Self { - self.size = Some(size.into().0); + self.size = Some(size.into()); self } - /// Sets the [`LineHeight`] of the [`TextInput`]. + /// Sets the [`text::LineHeight`] of the [`TextInput`]. pub fn line_height( mut self, line_height: impl Into<text::LineHeight>, @@ -197,6 +200,32 @@ where self } + /// Lays out the [`TextInput`], overriding its [`Value`] if provided. + /// + /// [`Renderer`]: text::Renderer + pub fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + value: Option<&Value>, + ) -> layout::Node { + layout( + renderer, + limits, + self.width, + self.padding, + self.size, + self.font, + self.line_height, + self.icon.as_ref(), + tree.state.downcast_mut::<State<Renderer::Paragraph>>(), + value.unwrap_or(&self.value), + &self.placeholder, + self.is_secure, + ) + } + /// Draws the [`TextInput`] with the given [`Renderer`], overriding its /// [`Value`] if provided. /// @@ -215,17 +244,13 @@ where theme, layout, cursor, - tree.state.downcast_ref::<State>(), + tree.state.downcast_ref::<State<Renderer::Paragraph>>(), value.unwrap_or(&self.value), - &self.placeholder, - self.size, - self.line_height, - self.font, self.on_input.is_none(), self.is_secure, self.icon.as_ref(), &self.style, - ) + ); } } @@ -237,15 +262,15 @@ where Renderer::Theme: StyleSheet, { fn tag(&self) -> tree::Tag { - tree::Tag::of::<State>() + tree::Tag::of::<State<Renderer::Paragraph>>() } fn state(&self) -> tree::State { - tree::State::new(State::new()) + tree::State::new(State::<Renderer::Paragraph>::new()) } fn diff(&self, tree: &mut Tree) { - let state = tree.state.downcast_mut::<State>(); + let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>(); // Unfocus text input if it becomes disabled if self.on_input.is_none() { @@ -266,6 +291,7 @@ where fn layout( &self, + tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { @@ -275,8 +301,13 @@ where self.width, self.padding, self.size, + self.font, self.line_height, self.icon.as_ref(), + tree.state.downcast_mut::<State<Renderer::Paragraph>>(), + &self.value, + &self.placeholder, + self.is_secure, ) } @@ -287,7 +318,7 @@ where _renderer: &Renderer, operation: &mut dyn Operation<Message>, ) { - let state = tree.state.downcast_mut::<State>(); + let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>(); operation.focusable(state, self.id.as_ref().map(|id| &id.0)); operation.text_input(state, self.id.as_ref().map(|id| &id.0)); @@ -302,6 +333,7 @@ where renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, ) -> event::Status { update( event, @@ -318,7 +350,7 @@ where self.on_input.as_deref(), self.on_paste.as_deref(), &self.on_submit, - || tree.state.downcast_mut::<State>(), + || tree.state.downcast_mut::<State<Renderer::Paragraph>>(), ) } @@ -337,17 +369,13 @@ where theme, layout, cursor, - tree.state.downcast_ref::<State>(), + tree.state.downcast_ref::<State<Renderer::Paragraph>>(), &self.value, - &self.placeholder, - self.size, - self.line_height, - self.font, self.on_input.is_none(), self.is_secure, self.icon.as_ref(), &self.style, - ) + ); } fn mouse_interaction( @@ -384,7 +412,7 @@ pub struct Icon<Font> { /// The unicode code point that will be used as the icon. pub code_point: char, /// The font size of the content. - pub size: Option<f32>, + pub size: Option<Pixels>, /// The spacing between the [`Icon`] and the text in a [`TextInput`]. pub spacing: f32, /// The side of a [`TextInput`] where to display the [`Icon`]. @@ -461,29 +489,65 @@ pub fn layout<Renderer>( limits: &layout::Limits, width: Length, padding: Padding, - size: Option<f32>, + size: Option<Pixels>, + font: Option<Renderer::Font>, line_height: text::LineHeight, icon: Option<&Icon<Renderer::Font>>, + state: &mut State<Renderer::Paragraph>, + value: &Value, + placeholder: &str, + is_secure: bool, ) -> layout::Node where Renderer: text::Renderer, { + let font = font.unwrap_or_else(|| renderer.default_font()); let text_size = size.unwrap_or_else(|| renderer.default_size()); + let padding = padding.fit(Size::ZERO, limits.max()); let limits = limits .width(width) .pad(padding) - .height(line_height.to_absolute(Pixels(text_size))); + .height(line_height.to_absolute(text_size)); let text_bounds = limits.resolve(Size::ZERO); + let placeholder_text = Text { + font, + line_height, + content: placeholder, + bounds: Size::new(f32::INFINITY, text_bounds.height), + size: text_size, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Center, + shaping: text::Shaping::Advanced, + }; + + state.placeholder.update(placeholder_text); + + let secure_value = is_secure.then(|| value.secure()); + let value = secure_value.as_ref().unwrap_or(value); + + state.value.update(Text { + content: &value.to_string(), + ..placeholder_text + }); + if let Some(icon) = icon { - let icon_width = renderer.measure_width( - &icon.code_point.to_string(), - icon.size.unwrap_or_else(|| renderer.default_size()), - icon.font, - text::Shaping::Advanced, - ); + let icon_text = Text { + line_height, + content: &icon.code_point.to_string(), + font: icon.font, + size: icon.size.unwrap_or_else(|| renderer.default_size()), + bounds: Size::new(f32::INFINITY, text_bounds.height), + horizontal_alignment: alignment::Horizontal::Center, + vertical_alignment: alignment::Vertical::Center, + shaping: text::Shaping::Advanced, + }; + + state.icon.update(icon_text); + + let icon_width = state.icon.min_width(); let mut text_node = layout::Node::new( text_bounds - Size::new(icon_width + icon.spacing, 0.0), @@ -533,19 +597,31 @@ pub fn update<'a, Message, Renderer>( clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, value: &mut Value, - size: Option<f32>, + size: Option<Pixels>, line_height: text::LineHeight, font: Option<Renderer::Font>, is_secure: bool, on_input: Option<&dyn Fn(String) -> Message>, on_paste: Option<&dyn Fn(String) -> Message>, on_submit: &Option<Message>, - state: impl FnOnce() -> &'a mut State, + state: impl FnOnce() -> &'a mut State<Renderer::Paragraph>, ) -> event::Status where Message: Clone, Renderer: text::Renderer, { + let update_cache = |state, value| { + replace_paragraph( + renderer, + state, + layout, + value, + font, + size, + line_height, + ); + }; + match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { @@ -564,6 +640,7 @@ where Some(Focus { updated_at: now, now, + is_window_focused: true, }) }) } else { @@ -587,11 +664,7 @@ where }; find_cursor_position( - renderer, text_layout.bounds(), - font, - size, - line_height, &value, state, target, @@ -616,11 +689,7 @@ where state.cursor.select_all(value); } else { let position = find_cursor_position( - renderer, text_layout.bounds(), - font, - size, - line_height, value, state, target, @@ -666,11 +735,7 @@ where }; let position = find_cursor_position( - renderer, text_layout.bounds(), - font, - size, - line_height, &value, state, target, @@ -688,7 +753,9 @@ where let state = state(); if let Some(focus) = &mut state.is_focused { - let Some(on_input) = on_input else { return event::Status::Ignored }; + let Some(on_input) = on_input else { + return event::Status::Ignored; + }; if state.is_pasting.is_none() && !state.keyboard_modifiers.command() @@ -703,6 +770,8 @@ where focus.updated_at = Instant::now(); + update_cache(state, value); + return event::Status::Captured; } } @@ -711,7 +780,9 @@ where let state = state(); if let Some(focus) = &mut state.is_focused { - let Some(on_input) = on_input else { return event::Status::Ignored }; + let Some(on_input) = on_input else { + return event::Status::Ignored; + }; let modifiers = state.keyboard_modifiers; focus.updated_at = Instant::now(); @@ -740,6 +811,8 @@ where let message = (on_input)(editor.contents()); shell.publish(message); + + update_cache(state, value); } keyboard::KeyCode::Delete => { if platform::is_jump_modifier_pressed(modifiers) @@ -760,6 +833,8 @@ where let message = (on_input)(editor.contents()); shell.publish(message); + + update_cache(state, value); } keyboard::KeyCode::Left => { if platform::is_jump_modifier_pressed(modifiers) @@ -771,7 +846,7 @@ where state.cursor.move_left_by_words(value); } } else if modifiers.shift() { - state.cursor.select_left(value) + state.cursor.select_left(value); } else { state.cursor.move_left(value); } @@ -786,7 +861,7 @@ where state.cursor.move_right_by_words(value); } } else if modifiers.shift() { - state.cursor.select_right(value) + state.cursor.select_right(value); } else { state.cursor.move_right(value); } @@ -835,9 +910,13 @@ where let message = (on_input)(editor.contents()); shell.publish(message); + + update_cache(state, value); } keyboard::KeyCode::V => { - if state.keyboard_modifiers.command() { + if state.keyboard_modifiers.command() + && !state.keyboard_modifiers.alt() + { let content = match state.is_pasting.take() { Some(content) => content, None => { @@ -865,6 +944,8 @@ where shell.publish(message); state.is_pasting = Some(content); + + update_cache(state, value); } else { state.is_pasting = None; } @@ -919,19 +1000,38 @@ where state.keyboard_modifiers = modifiers; } + Event::Window(_, window::Event::Unfocused) => { + let state = state(); + + if let Some(focus) = &mut state.is_focused { + focus.is_window_focused = false; + } + } + Event::Window(_, window::Event::Focused) => { + let state = state(); + + if let Some(focus) = &mut state.is_focused { + focus.is_window_focused = true; + focus.updated_at = Instant::now(); + + shell.request_redraw(window::RedrawRequest::NextFrame); + } + } Event::Window(_, window::Event::RedrawRequested(now)) => { let state = state(); if let Some(focus) = &mut state.is_focused { - focus.now = now; + if focus.is_window_focused { + focus.now = now; - let millis_until_redraw = CURSOR_BLINK_INTERVAL_MILLIS - - (now - focus.updated_at).as_millis() - % CURSOR_BLINK_INTERVAL_MILLIS; + let millis_until_redraw = CURSOR_BLINK_INTERVAL_MILLIS + - (now - focus.updated_at).as_millis() + % CURSOR_BLINK_INTERVAL_MILLIS; - shell.request_redraw(window::RedrawRequest::At( - now + Duration::from_millis(millis_until_redraw as u64), - )); + shell.request_redraw(window::RedrawRequest::At( + now + Duration::from_millis(millis_until_redraw as u64), + )); + } } } _ => {} @@ -949,12 +1049,8 @@ pub fn draw<Renderer>( theme: &Renderer::Theme, layout: Layout<'_>, cursor: mouse::Cursor, - state: &State, + state: &State<Renderer::Paragraph>, value: &Value, - placeholder: &str, - size: Option<f32>, - line_height: text::LineHeight, - font: Option<Renderer::Font>, is_disabled: bool, is_secure: bool, icon: Option<&Icon<Renderer::Font>>, @@ -993,40 +1089,30 @@ pub fn draw<Renderer>( appearance.background, ); - if let Some(icon) = icon { + if icon.is_some() { let icon_layout = children_layout.next().unwrap(); - renderer.fill_text(Text { - content: &icon.code_point.to_string(), - size: icon.size.unwrap_or_else(|| renderer.default_size()), - line_height: text::LineHeight::default(), - font: icon.font, - color: appearance.icon_color, - bounds: Rectangle { - y: text_bounds.center_y(), - ..icon_layout.bounds() - }, - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, - shaping: text::Shaping::Advanced, - }); + renderer.fill_paragraph( + &state.icon, + icon_layout.bounds().center(), + appearance.icon_color, + ); } let text = value.to_string(); - let font = font.unwrap_or_else(|| renderer.default_font()); - let size = size.unwrap_or_else(|| renderer.default_size()); - let (cursor, offset) = if let Some(focus) = &state.is_focused { + let (cursor, offset) = if let Some(focus) = state + .is_focused + .as_ref() + .filter(|focus| focus.is_window_focused) + { match state.cursor.state(value) { cursor::State::Index(position) => { let (text_value_width, offset) = measure_cursor_and_scroll_offset( - renderer, + &state.value, text_bounds, - value, - size, position, - font, ); let is_cursor_visible = ((focus.now - focus.updated_at) @@ -1062,22 +1148,16 @@ pub fn draw<Renderer>( let (left_position, left_offset) = measure_cursor_and_scroll_offset( - renderer, + &state.value, text_bounds, - value, - size, left, - font, ); let (right_position, right_offset) = measure_cursor_and_scroll_offset( - renderer, + &state.value, text_bounds, - value, - size, right, - font, ); let width = right_position - left_position; @@ -1109,12 +1189,7 @@ pub fn draw<Renderer>( (None, 0.0) }; - let text_width = renderer.measure_width( - if text.is_empty() { placeholder } else { &text }, - size, - font, - text::Shaping::Advanced, - ); + let text_width = state.value.min_width(); let render = |renderer: &mut Renderer| { if let Some((cursor, color)) = cursor { @@ -1123,32 +1198,26 @@ pub fn draw<Renderer>( renderer.with_translation(Vector::ZERO, |_| {}); } - renderer.fill_text(Text { - content: if text.is_empty() { placeholder } else { &text }, - color: if text.is_empty() { + renderer.fill_paragraph( + if text.is_empty() { + &state.placeholder + } else { + &state.value + }, + Point::new(text_bounds.x, text_bounds.center_y()), + if text.is_empty() { theme.placeholder_color(style) } else if is_disabled { theme.disabled_color(style) } else { theme.value_color(style) }, - font, - bounds: Rectangle { - y: text_bounds.center_y(), - width: f32::INFINITY, - ..text_bounds - }, - size, - line_height, - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, - shaping: text::Shaping::Advanced, - }); + ); }; if text_width > text_bounds.width { renderer.with_layer(text_bounds, |renderer| { - renderer.with_translation(Vector::new(-offset, 0.0), render) + renderer.with_translation(Vector::new(-offset, 0.0), render); }); } else { render(renderer); @@ -1174,7 +1243,10 @@ pub fn mouse_interaction( /// The state of a [`TextInput`]. #[derive(Debug, Default, Clone)] -pub struct State { +pub struct State<P: text::Paragraph> { + value: P, + placeholder: P, + icon: P, is_focused: Option<Focus>, is_dragging: bool, is_pasting: Option<Value>, @@ -1188,9 +1260,10 @@ pub struct State { struct Focus { updated_at: Instant, now: Instant, + is_window_focused: bool, } -impl State { +impl<P: text::Paragraph> State<P> { /// Creates a new [`State`], representing an unfocused [`TextInput`]. pub fn new() -> Self { Self::default() @@ -1199,6 +1272,9 @@ impl State { /// Creates a new [`State`], representing a focused [`TextInput`]. pub fn focused() -> Self { Self { + value: P::default(), + placeholder: P::default(), + icon: P::default(), is_focused: None, is_dragging: false, is_pasting: None, @@ -1225,6 +1301,7 @@ impl State { self.is_focused = Some(Focus { updated_at: now, now, + is_window_focused: true, }); self.move_cursor_to_end(); @@ -1256,35 +1333,35 @@ impl State { } } -impl operation::Focusable for State { +impl<P: text::Paragraph> operation::Focusable for State<P> { fn is_focused(&self) -> bool { State::is_focused(self) } fn focus(&mut self) { - State::focus(self) + State::focus(self); } fn unfocus(&mut self) { - State::unfocus(self) + State::unfocus(self); } } -impl operation::TextInput for State { +impl<P: text::Paragraph> operation::TextInput for State<P> { fn move_cursor_to_front(&mut self) { - State::move_cursor_to_front(self) + State::move_cursor_to_front(self); } fn move_cursor_to_end(&mut self) { - State::move_cursor_to_end(self) + State::move_cursor_to_end(self); } fn move_cursor_to(&mut self, position: usize) { - State::move_cursor_to(self, position) + State::move_cursor_to(self, position); } fn select_all(&mut self) { - State::select_all(self) + State::select_all(self); } } @@ -1300,17 +1377,11 @@ mod platform { } } -fn offset<Renderer>( - renderer: &Renderer, +fn offset<P: text::Paragraph>( text_bounds: Rectangle, - font: Renderer::Font, - size: f32, value: &Value, - state: &State, -) -> f32 -where - Renderer: text::Renderer, -{ + state: &State<P>, +) -> f32 { if state.is_focused() { let cursor = state.cursor(); @@ -1320,12 +1391,9 @@ where }; let (_, offset) = measure_cursor_and_scroll_offset( - renderer, + &state.value, text_bounds, - value, - size, focus_position, - font, ); offset @@ -1334,72 +1402,72 @@ where } } -fn measure_cursor_and_scroll_offset<Renderer>( - renderer: &Renderer, +fn measure_cursor_and_scroll_offset( + paragraph: &impl text::Paragraph, text_bounds: Rectangle, - value: &Value, - size: f32, cursor_index: usize, - font: Renderer::Font, -) -> (f32, f32) -where - Renderer: text::Renderer, -{ - let text_before_cursor = value.until(cursor_index).to_string(); +) -> (f32, f32) { + let grapheme_position = paragraph + .grapheme_position(0, cursor_index) + .unwrap_or(Point::ORIGIN); - let text_value_width = renderer.measure_width( - &text_before_cursor, - size, - font, - text::Shaping::Advanced, - ); - - let offset = ((text_value_width + 5.0) - text_bounds.width).max(0.0); + let offset = ((grapheme_position.x + 5.0) - text_bounds.width).max(0.0); - (text_value_width, offset) + (grapheme_position.x, offset) } /// Computes the position of the text cursor at the given X coordinate of /// a [`TextInput`]. -fn find_cursor_position<Renderer>( - renderer: &Renderer, +fn find_cursor_position<P: text::Paragraph>( text_bounds: Rectangle, - font: Option<Renderer::Font>, - size: Option<f32>, - line_height: text::LineHeight, value: &Value, - state: &State, + state: &State<P>, x: f32, -) -> Option<usize> -where - Renderer: text::Renderer, -{ - let font = font.unwrap_or_else(|| renderer.default_font()); - let size = size.unwrap_or_else(|| renderer.default_size()); - - let offset = offset(renderer, text_bounds, font, size, value, state); +) -> Option<usize> { + let offset = offset(text_bounds, value, state); let value = value.to_string(); - let char_offset = renderer - .hit_test( - &value, - size, - line_height, - font, - Size::INFINITY, - text::Shaping::Advanced, - Point::new(x + offset, text_bounds.height / 2.0), - true, - ) + let char_offset = state + .value + .hit_test(Point::new(x + offset, text_bounds.height / 2.0)) .map(text::Hit::cursor)?; Some( unicode_segmentation::UnicodeSegmentation::graphemes( - &value[..char_offset], + &value[..char_offset.min(value.len())], true, ) .count(), ) } +fn replace_paragraph<Renderer>( + renderer: &Renderer, + state: &mut State<Renderer::Paragraph>, + layout: Layout<'_>, + value: &Value, + font: Option<Renderer::Font>, + text_size: Option<Pixels>, + line_height: text::LineHeight, +) where + Renderer: text::Renderer, +{ + let font = font.unwrap_or_else(|| renderer.default_font()); + let text_size = text_size.unwrap_or_else(|| renderer.default_size()); + + let mut children_layout = layout.children(); + let text_bounds = children_layout.next().unwrap().bounds(); + + state.value = Renderer::Paragraph::with_text(Text { + font, + line_height, + content: &value.to_string(), + bounds: Size::new(f32::INFINITY, text_bounds.height), + size: text_size, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Top, + shaping: text::Shaping::Advanced, + }); +} + const CURSOR_BLINK_INTERVAL_MILLIS: u128 = 500; diff --git a/widget/src/text_input/cursor.rs b/widget/src/text_input/cursor.rs index 9680dfd7..f682b17d 100644 --- a/widget/src/text_input/cursor.rs +++ b/widget/src/text_input/cursor.rs @@ -56,7 +56,7 @@ impl Cursor { State::Selection { start, end } => { Some((start.min(end), start.max(end))) } - _ => None, + State::Index(_) => None, } } @@ -65,11 +65,11 @@ impl Cursor { } pub(crate) fn move_right(&mut self, value: &Value) { - self.move_right_by_amount(value, 1) + self.move_right_by_amount(value, 1); } pub(crate) fn move_right_by_words(&mut self, value: &Value) { - self.move_to(value.next_end_of_word(self.right(value))) + self.move_to(value.next_end_of_word(self.right(value))); } pub(crate) fn move_right_by_amount( @@ -79,7 +79,7 @@ impl Cursor { ) { match self.state(value) { State::Index(index) => { - self.move_to(index.saturating_add(amount).min(value.len())) + self.move_to(index.saturating_add(amount).min(value.len())); } State::Selection { start, end } => self.move_to(end.max(start)), } @@ -89,7 +89,7 @@ impl Cursor { match self.state(value) { State::Index(index) if index > 0 => self.move_to(index - 1), State::Selection { start, end } => self.move_to(start.min(end)), - _ => self.move_to(0), + State::Index(_) => self.move_to(0), } } @@ -108,10 +108,10 @@ impl Cursor { pub(crate) fn select_left(&mut self, value: &Value) { match self.state(value) { State::Index(index) if index > 0 => { - self.select_range(index, index - 1) + self.select_range(index, index - 1); } State::Selection { start, end } if end > 0 => { - self.select_range(start, end - 1) + self.select_range(start, end - 1); } _ => {} } @@ -120,10 +120,10 @@ impl Cursor { pub(crate) fn select_right(&mut self, value: &Value) { match self.state(value) { State::Index(index) if index < value.len() => { - self.select_range(index, index + 1) + self.select_range(index, index + 1); } State::Selection { start, end } if end < value.len() => { - self.select_range(start, end + 1) + self.select_range(start, end + 1); } _ => {} } @@ -132,10 +132,10 @@ impl Cursor { pub(crate) fn select_left_by_words(&mut self, value: &Value) { match self.state(value) { State::Index(index) => { - self.select_range(index, value.previous_start_of_word(index)) + self.select_range(index, value.previous_start_of_word(index)); } State::Selection { start, end } => { - self.select_range(start, value.previous_start_of_word(end)) + self.select_range(start, value.previous_start_of_word(end)); } } } @@ -143,10 +143,10 @@ impl Cursor { pub(crate) fn select_right_by_words(&mut self, value: &Value) { match self.state(value) { State::Index(index) => { - self.select_range(index, value.next_end_of_word(index)) + self.select_range(index, value.next_end_of_word(index)); } State::Selection { start, end } => { - self.select_range(start, value.next_end_of_word(end)) + self.select_range(start, value.next_end_of_word(end)); } } } diff --git a/widget/src/text_input/value.rs b/widget/src/text_input/value.rs index cf4da562..46a1f754 100644 --- a/widget/src/text_input/value.rs +++ b/widget/src/text_input/value.rs @@ -2,7 +2,7 @@ use unicode_segmentation::UnicodeSegmentation; /// The value of a [`TextInput`]. /// -/// [`TextInput`]: crate::widget::TextInput +/// [`TextInput`]: super::TextInput // TODO: Reduce allocations, cache results (?) #[derive(Debug, Clone)] pub struct Value { @@ -89,11 +89,6 @@ impl Value { Self { graphemes } } - /// Converts the [`Value`] into a `String`. - pub fn to_string(&self) -> String { - self.graphemes.concat() - } - /// Inserts a new `char` at the given grapheme `index`. pub fn insert(&mut self, index: usize, c: char) { self.graphemes.insert(index, c.to_string()); @@ -131,3 +126,9 @@ impl Value { } } } + +impl std::fmt::Display for Value { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.graphemes.concat()) + } +} |
