From 3a0d34c0240f4421737a6a08761f99d6f8140d02 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Sat, 4 Mar 2023 05:37:11 +0100 Subject: Create `iced_widget` subcrate and re-organize the whole codebase --- widget/src/text_input.rs | 1221 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1221 insertions(+) create mode 100644 widget/src/text_input.rs (limited to 'widget/src/text_input.rs') diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs new file mode 100644 index 00000000..67d80e2b --- /dev/null +++ b/widget/src/text_input.rs @@ -0,0 +1,1221 @@ +//! Display fields that can be filled with text. +//! +//! A [`TextInput`] has some local [`State`]. +mod editor; +mod value; + +pub mod cursor; + +pub use cursor::Cursor; +pub use value::Value; + +use editor::Editor; + +use crate::core::alignment; +use crate::core::event::{self, Event}; +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::time::{Duration, Instant}; +use crate::core::touch; +use crate::core::widget; +use crate::core::widget::operation::{self, Operation}; +use crate::core::widget::tree::{self, Tree}; +use crate::core::window; +use crate::core::{ + Clipboard, Color, Element, Layout, Length, Padding, Pixels, Point, + Rectangle, Shell, Size, Vector, Widget, +}; +use crate::native::Command; + +pub use iced_style::text_input::{Appearance, StyleSheet}; + +/// A field that can be filled with text. +/// +/// # Example +/// ``` +/// # pub type TextInput<'a, Message> = +/// # iced_widget::TextInput<'a, Message, iced_widget::renderer::Renderer>; +/// # +/// #[derive(Debug, Clone)] +/// enum Message { +/// TextInputChanged(String), +/// } +/// +/// let value = "Some text"; +/// +/// let input = TextInput::new( +/// "This is the placeholder...", +/// value, +/// Message::TextInputChanged, +/// ) +/// .padding(10); +/// ``` +/// ![Text input drawn by `iced_wgpu`](https://github.com/iced-rs/iced/blob/7760618fb112074bc40b148944521f312152012a/docs/images/text_input.png?raw=true) +#[allow(missing_debug_implementations)] +pub struct TextInput<'a, Message, Renderer = crate::Renderer> +where + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + id: Option, + placeholder: String, + value: Value, + is_secure: bool, + font: Option, + width: Length, + padding: Padding, + size: Option, + on_change: Box Message + 'a>, + on_paste: Option Message + 'a>>, + on_submit: Option, + style: ::Style, +} + +impl<'a, Message, Renderer> TextInput<'a, Message, Renderer> +where + Message: Clone, + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + /// Creates a new [`TextInput`]. + /// + /// It expects: + /// - a placeholder, + /// - the current value, and + /// - a function that produces a message when the [`TextInput`] changes. + pub fn new(placeholder: &str, value: &str, on_change: F) -> Self + where + F: 'a + Fn(String) -> Message, + { + TextInput { + id: None, + placeholder: String::from(placeholder), + value: Value::new(value), + is_secure: false, + font: None, + width: Length::Fill, + padding: Padding::new(5.0), + size: None, + on_change: Box::new(on_change), + on_paste: None, + on_submit: None, + style: Default::default(), + } + } + + /// Sets the [`Id`] of the [`TextInput`]. + pub fn id(mut self, id: Id) -> Self { + self.id = Some(id); + self + } + + /// Converts the [`TextInput`] into a secure password input. + pub fn password(mut self) -> Self { + self.is_secure = true; + self + } + + /// Sets the message that should be produced when some text is pasted into + /// the [`TextInput`]. + pub fn on_paste( + mut self, + on_paste: impl Fn(String) -> Message + 'a, + ) -> Self { + self.on_paste = Some(Box::new(on_paste)); + self + } + + /// Sets the [`Font`] of the [`TextInput`]. + /// + /// [`Font`]: text::Renderer::Font + pub fn font(mut self, font: Renderer::Font) -> Self { + self.font = Some(font); + self + } + /// Sets the width of the [`TextInput`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the [`Padding`] of the [`TextInput`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the text size of the [`TextInput`]. + pub fn size(mut self, size: impl Into) -> Self { + self.size = Some(size.into().0); + self + } + + /// Sets the message that should be produced when the [`TextInput`] is + /// focused and the enter key is pressed. + pub fn on_submit(mut self, message: Message) -> Self { + self.on_submit = Some(message); + self + } + + /// Sets the style of the [`TextInput`]. + pub fn style( + mut self, + style: impl Into<::Style>, + ) -> Self { + self.style = style.into(); + self + } + + /// Draws the [`TextInput`] with the given [`Renderer`], overriding its + /// [`Value`] if provided. + /// + /// [`Renderer`]: text::Renderer + pub fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Renderer::Theme, + layout: Layout<'_>, + cursor_position: Point, + value: Option<&Value>, + ) { + draw( + renderer, + theme, + layout, + cursor_position, + tree.state.downcast_ref::(), + value.unwrap_or(&self.value), + &self.placeholder, + self.size, + self.font, + self.is_secure, + &self.style, + ) + } +} + +impl<'a, Message, Renderer> Widget + for TextInput<'a, Message, Renderer> +where + Message: Clone, + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::new()) + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + Length::Shrink + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout(renderer, limits, self.width, self.padding, self.size) + } + + fn operate( + &self, + tree: &mut Tree, + _layout: Layout<'_>, + _renderer: &Renderer, + operation: &mut dyn Operation, + ) { + let state = tree.state.downcast_mut::(); + + operation.focusable(state, self.id.as_ref().map(|id| &id.0)); + operation.text_input(state, self.id.as_ref().map(|id| &id.0)); + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + update( + event, + layout, + cursor_position, + renderer, + clipboard, + shell, + &mut self.value, + self.size, + self.font, + self.is_secure, + self.on_change.as_ref(), + self.on_paste.as_deref(), + &self.on_submit, + || tree.state.downcast_mut::(), + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Renderer::Theme, + _style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + ) { + draw( + renderer, + theme, + layout, + cursor_position, + tree.state.downcast_ref::(), + &self.value, + &self.placeholder, + self.size, + self.font, + self.is_secure, + &self.style, + ) + } + + fn mouse_interaction( + &self, + _state: &Tree, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + mouse_interaction(layout, cursor_position) + } +} + +impl<'a, Message, Renderer> From> + for Element<'a, Message, Renderer> +where + Message: 'a + Clone, + Renderer: 'a + text::Renderer, + Renderer::Theme: StyleSheet, +{ + fn from( + text_input: TextInput<'a, Message, Renderer>, + ) -> Element<'a, Message, Renderer> { + Element::new(text_input) + } +} + +/// The identifier of a [`TextInput`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Id(widget::Id); + +impl Id { + /// Creates a custom [`Id`]. + pub fn new(id: impl Into>) -> Self { + Self(widget::Id::new(id)) + } + + /// Creates a unique [`Id`]. + /// + /// This function produces a different [`Id`] every time it is called. + pub fn unique() -> Self { + Self(widget::Id::unique()) + } +} + +impl From for widget::Id { + fn from(id: Id) -> Self { + id.0 + } +} + +/// Produces a [`Command`] that focuses the [`TextInput`] with the given [`Id`]. +pub fn focus(id: Id) -> Command { + Command::widget(operation::focusable::focus(id.0)) +} + +/// Produces a [`Command`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the +/// end. +pub fn move_cursor_to_end(id: Id) -> Command { + Command::widget(operation::text_input::move_cursor_to_end(id.0)) +} + +/// Produces a [`Command`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the +/// front. +pub fn move_cursor_to_front(id: Id) -> Command { + Command::widget(operation::text_input::move_cursor_to_front(id.0)) +} + +/// Produces a [`Command`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the +/// provided position. +pub fn move_cursor_to( + id: Id, + position: usize, +) -> Command { + Command::widget(operation::text_input::move_cursor_to(id.0, position)) +} + +/// Produces a [`Command`] that selects all the content of the [`TextInput`] with the given [`Id`]. +pub fn select_all(id: Id) -> Command { + Command::widget(operation::text_input::select_all(id.0)) +} + +/// Computes the layout of a [`TextInput`]. +pub fn layout( + renderer: &Renderer, + limits: &layout::Limits, + width: Length, + padding: Padding, + size: Option, +) -> layout::Node +where + Renderer: text::Renderer, +{ + 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(text_size * 1.2); + + let mut text = layout::Node::new(limits.resolve(Size::ZERO)); + text.move_to(Point::new(padding.left, padding.top)); + + layout::Node::with_children(text.size().pad(padding), vec![text]) +} + +/// Processes an [`Event`] and updates the [`State`] of a [`TextInput`] +/// accordingly. +pub fn update<'a, Message, Renderer>( + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + value: &mut Value, + size: Option, + font: Option, + is_secure: bool, + on_change: &dyn Fn(String) -> Message, + on_paste: Option<&dyn Fn(String) -> Message>, + on_submit: &Option, + state: impl FnOnce() -> &'a mut State, +) -> event::Status +where + Message: Clone, + Renderer: text::Renderer, +{ + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + let state = state(); + let is_clicked = layout.bounds().contains(cursor_position); + + state.is_focused = if is_clicked { + state.is_focused.or_else(|| { + let now = Instant::now(); + + Some(Focus { + updated_at: now, + now, + }) + }) + } else { + None + }; + + if is_clicked { + let text_layout = layout.children().next().unwrap(); + let target = cursor_position.x - text_layout.bounds().x; + + let click = + mouse::Click::new(cursor_position, state.last_click); + + match click.kind() { + click::Kind::Single => { + let position = if target > 0.0 { + let value = if is_secure { + value.secure() + } else { + value.clone() + }; + + find_cursor_position( + renderer, + text_layout.bounds(), + font, + size, + &value, + state, + target, + ) + } else { + None + } + .unwrap_or(0); + + if state.keyboard_modifiers.shift() { + state.cursor.select_range( + state.cursor.start(value), + position, + ); + } else { + state.cursor.move_to(position); + } + state.is_dragging = true; + } + click::Kind::Double => { + if is_secure { + state.cursor.select_all(value); + } else { + let position = find_cursor_position( + renderer, + text_layout.bounds(), + font, + size, + value, + state, + target, + ) + .unwrap_or(0); + + state.cursor.select_range( + value.previous_start_of_word(position), + value.next_end_of_word(position), + ); + } + + state.is_dragging = false; + } + click::Kind::Triple => { + state.cursor.select_all(value); + state.is_dragging = false; + } + } + + state.last_click = Some(click); + + return event::Status::Captured; + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) + | Event::Touch(touch::Event::FingerLost { .. }) => { + state().is_dragging = false; + } + Event::Mouse(mouse::Event::CursorMoved { position }) + | Event::Touch(touch::Event::FingerMoved { position, .. }) => { + let state = state(); + + if state.is_dragging { + let text_layout = layout.children().next().unwrap(); + let target = position.x - text_layout.bounds().x; + + let value = if is_secure { + value.secure() + } else { + value.clone() + }; + + let position = find_cursor_position( + renderer, + text_layout.bounds(), + font, + size, + &value, + state, + target, + ) + .unwrap_or(0); + + state + .cursor + .select_range(state.cursor.start(&value), position); + + return event::Status::Captured; + } + } + Event::Keyboard(keyboard::Event::CharacterReceived(c)) => { + let state = state(); + + if let Some(focus) = &mut state.is_focused { + if state.is_pasting.is_none() + && !state.keyboard_modifiers.command() + && !c.is_control() + { + let mut editor = Editor::new(value, &mut state.cursor); + + editor.insert(c); + + let message = (on_change)(editor.contents()); + shell.publish(message); + + focus.updated_at = Instant::now(); + + return event::Status::Captured; + } + } + } + Event::Keyboard(keyboard::Event::KeyPressed { key_code, .. }) => { + let state = state(); + + if let Some(focus) = &mut state.is_focused { + let modifiers = state.keyboard_modifiers; + focus.updated_at = Instant::now(); + + match key_code { + keyboard::KeyCode::Enter + | keyboard::KeyCode::NumpadEnter => { + if let Some(on_submit) = on_submit.clone() { + shell.publish(on_submit); + } + } + keyboard::KeyCode::Backspace => { + if platform::is_jump_modifier_pressed(modifiers) + && state.cursor.selection(value).is_none() + { + if is_secure { + let cursor_pos = state.cursor.end(value); + state.cursor.select_range(0, cursor_pos); + } else { + state.cursor.select_left_by_words(value); + } + } + + let mut editor = Editor::new(value, &mut state.cursor); + editor.backspace(); + + let message = (on_change)(editor.contents()); + shell.publish(message); + } + keyboard::KeyCode::Delete => { + if platform::is_jump_modifier_pressed(modifiers) + && state.cursor.selection(value).is_none() + { + if is_secure { + let cursor_pos = state.cursor.end(value); + state + .cursor + .select_range(cursor_pos, value.len()); + } else { + state.cursor.select_right_by_words(value); + } + } + + let mut editor = Editor::new(value, &mut state.cursor); + editor.delete(); + + let message = (on_change)(editor.contents()); + shell.publish(message); + } + keyboard::KeyCode::Left => { + if platform::is_jump_modifier_pressed(modifiers) + && !is_secure + { + if modifiers.shift() { + state.cursor.select_left_by_words(value); + } else { + state.cursor.move_left_by_words(value); + } + } else if modifiers.shift() { + state.cursor.select_left(value) + } else { + state.cursor.move_left(value); + } + } + keyboard::KeyCode::Right => { + if platform::is_jump_modifier_pressed(modifiers) + && !is_secure + { + if modifiers.shift() { + state.cursor.select_right_by_words(value); + } else { + state.cursor.move_right_by_words(value); + } + } else if modifiers.shift() { + state.cursor.select_right(value) + } else { + state.cursor.move_right(value); + } + } + keyboard::KeyCode::Home => { + if modifiers.shift() { + state + .cursor + .select_range(state.cursor.start(value), 0); + } else { + state.cursor.move_to(0); + } + } + keyboard::KeyCode::End => { + if modifiers.shift() { + state.cursor.select_range( + state.cursor.start(value), + value.len(), + ); + } else { + state.cursor.move_to(value.len()); + } + } + keyboard::KeyCode::C + if state.keyboard_modifiers.command() => + { + if let Some((start, end)) = + state.cursor.selection(value) + { + clipboard + .write(value.select(start, end).to_string()); + } + } + keyboard::KeyCode::X + if state.keyboard_modifiers.command() => + { + if let Some((start, end)) = + state.cursor.selection(value) + { + clipboard + .write(value.select(start, end).to_string()); + } + + let mut editor = Editor::new(value, &mut state.cursor); + editor.delete(); + + let message = (on_change)(editor.contents()); + shell.publish(message); + } + keyboard::KeyCode::V => { + if state.keyboard_modifiers.command() { + let content = match state.is_pasting.take() { + Some(content) => content, + None => { + let content: String = clipboard + .read() + .unwrap_or_default() + .chars() + .filter(|c| !c.is_control()) + .collect(); + + Value::new(&content) + } + }; + + let mut editor = + Editor::new(value, &mut state.cursor); + + editor.paste(content.clone()); + + let message = if let Some(paste) = &on_paste { + (paste)(editor.contents()) + } else { + (on_change)(editor.contents()) + }; + shell.publish(message); + + state.is_pasting = Some(content); + } else { + state.is_pasting = None; + } + } + keyboard::KeyCode::A + if state.keyboard_modifiers.command() => + { + state.cursor.select_all(value); + } + keyboard::KeyCode::Escape => { + state.is_focused = None; + state.is_dragging = false; + state.is_pasting = None; + + state.keyboard_modifiers = + keyboard::Modifiers::default(); + } + keyboard::KeyCode::Tab + | keyboard::KeyCode::Up + | keyboard::KeyCode::Down => { + return event::Status::Ignored; + } + _ => {} + } + + return event::Status::Captured; + } + } + Event::Keyboard(keyboard::Event::KeyReleased { key_code, .. }) => { + let state = state(); + + if state.is_focused.is_some() { + match key_code { + keyboard::KeyCode::V => { + state.is_pasting = None; + } + keyboard::KeyCode::Tab + | keyboard::KeyCode::Up + | keyboard::KeyCode::Down => { + return event::Status::Ignored; + } + _ => {} + } + + return event::Status::Captured; + } else { + state.is_pasting = None; + } + } + Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { + let state = state(); + + state.keyboard_modifiers = modifiers; + } + Event::Window(window::Event::RedrawRequested(now)) => { + let state = state(); + + if let Some(focus) = &mut state.is_focused { + focus.now = now; + + 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), + )); + } + } + _ => {} + } + + event::Status::Ignored +} + +/// Draws the [`TextInput`] with the given [`Renderer`], overriding its +/// [`Value`] if provided. +/// +/// [`Renderer`]: text::Renderer +pub fn draw( + renderer: &mut Renderer, + theme: &Renderer::Theme, + layout: Layout<'_>, + cursor_position: Point, + state: &State, + value: &Value, + placeholder: &str, + size: Option, + font: Option, + is_secure: bool, + style: &::Style, +) where + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + let secure_value = is_secure.then(|| value.secure()); + let value = secure_value.as_ref().unwrap_or(value); + + let bounds = layout.bounds(); + let text_bounds = layout.children().next().unwrap().bounds(); + + let is_mouse_over = bounds.contains(cursor_position); + + let appearance = if state.is_focused() { + theme.focused(style) + } else if is_mouse_over { + theme.hovered(style) + } else { + theme.active(style) + }; + + renderer.fill_quad( + renderer::Quad { + bounds, + border_radius: appearance.border_radius.into(), + border_width: appearance.border_width, + border_color: appearance.border_color, + }, + appearance.background, + ); + + 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 { + match state.cursor.state(value) { + cursor::State::Index(position) => { + let (text_value_width, offset) = + measure_cursor_and_scroll_offset( + renderer, + text_bounds, + value, + size, + position, + font, + ); + + let is_cursor_visible = ((focus.now - focus.updated_at) + .as_millis() + / CURSOR_BLINK_INTERVAL_MILLIS) + % 2 + == 0; + + let cursor = if is_cursor_visible { + Some(( + renderer::Quad { + bounds: Rectangle { + x: text_bounds.x + text_value_width, + y: text_bounds.y, + width: 1.0, + height: text_bounds.height, + }, + border_radius: 0.0.into(), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + theme.value_color(style), + )) + } else { + None + }; + + (cursor, offset) + } + cursor::State::Selection { start, end } => { + let left = start.min(end); + let right = end.max(start); + + let (left_position, left_offset) = + measure_cursor_and_scroll_offset( + renderer, + text_bounds, + value, + size, + left, + font, + ); + + let (right_position, right_offset) = + measure_cursor_and_scroll_offset( + renderer, + text_bounds, + value, + size, + right, + font, + ); + + let width = right_position - left_position; + + ( + Some(( + renderer::Quad { + bounds: Rectangle { + x: text_bounds.x + left_position, + y: text_bounds.y, + width, + height: text_bounds.height, + }, + border_radius: 0.0.into(), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + theme.selection_color(style), + )), + if end == right { + right_offset + } else { + left_offset + }, + ) + } + } + } else { + (None, 0.0) + }; + + let text_width = renderer.measure_width( + if text.is_empty() { placeholder } else { &text }, + size, + font, + ); + + let render = |renderer: &mut Renderer| { + if let Some((cursor, color)) = cursor { + renderer.fill_quad(cursor, color); + } + + renderer.fill_text(Text { + content: if text.is_empty() { placeholder } else { &text }, + color: if text.is_empty() { + theme.placeholder_color(style) + } else { + theme.value_color(style) + }, + font, + bounds: Rectangle { + y: text_bounds.center_y(), + width: f32::INFINITY, + ..text_bounds + }, + size, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Center, + }); + }; + + if text_width > text_bounds.width { + renderer.with_layer(text_bounds, |renderer| { + renderer.with_translation(Vector::new(-offset, 0.0), render) + }); + } else { + render(renderer); + } +} + +/// Computes the current [`mouse::Interaction`] of the [`TextInput`]. +pub fn mouse_interaction( + layout: Layout<'_>, + cursor_position: Point, +) -> mouse::Interaction { + if layout.bounds().contains(cursor_position) { + mouse::Interaction::Text + } else { + mouse::Interaction::default() + } +} + +/// The state of a [`TextInput`]. +#[derive(Debug, Default, Clone)] +pub struct State { + is_focused: Option, + is_dragging: bool, + is_pasting: Option, + last_click: Option, + cursor: Cursor, + keyboard_modifiers: keyboard::Modifiers, + // TODO: Add stateful horizontal scrolling offset +} + +#[derive(Debug, Clone, Copy)] +struct Focus { + updated_at: Instant, + now: Instant, +} + +impl State { + /// Creates a new [`State`], representing an unfocused [`TextInput`]. + pub fn new() -> Self { + Self::default() + } + + /// Creates a new [`State`], representing a focused [`TextInput`]. + pub fn focused() -> Self { + Self { + is_focused: None, + is_dragging: false, + is_pasting: None, + last_click: None, + cursor: Cursor::default(), + keyboard_modifiers: keyboard::Modifiers::default(), + } + } + + /// Returns whether the [`TextInput`] is currently focused or not. + pub fn is_focused(&self) -> bool { + self.is_focused.is_some() + } + + /// Returns the [`Cursor`] of the [`TextInput`]. + pub fn cursor(&self) -> Cursor { + self.cursor + } + + /// Focuses the [`TextInput`]. + pub fn focus(&mut self) { + let now = Instant::now(); + + self.is_focused = Some(Focus { + updated_at: now, + now, + }); + + self.move_cursor_to_end(); + } + + /// Unfocuses the [`TextInput`]. + pub fn unfocus(&mut self) { + self.is_focused = None; + } + + /// Moves the [`Cursor`] of the [`TextInput`] to the front of the input text. + pub fn move_cursor_to_front(&mut self) { + self.cursor.move_to(0); + } + + /// Moves the [`Cursor`] of the [`TextInput`] to the end of the input text. + pub fn move_cursor_to_end(&mut self) { + self.cursor.move_to(usize::MAX); + } + + /// Moves the [`Cursor`] of the [`TextInput`] to an arbitrary location. + pub fn move_cursor_to(&mut self, position: usize) { + self.cursor.move_to(position); + } + + /// Selects all the content of the [`TextInput`]. + pub fn select_all(&mut self) { + self.cursor.select_range(0, usize::MAX); + } +} + +impl operation::Focusable for State { + fn is_focused(&self) -> bool { + State::is_focused(self) + } + + fn focus(&mut self) { + State::focus(self) + } + + fn unfocus(&mut self) { + State::unfocus(self) + } +} + +impl operation::TextInput for State { + fn move_cursor_to_front(&mut self) { + State::move_cursor_to_front(self) + } + + fn move_cursor_to_end(&mut self) { + State::move_cursor_to_end(self) + } + + fn move_cursor_to(&mut self, position: usize) { + State::move_cursor_to(self, position) + } + + fn select_all(&mut self) { + State::select_all(self) + } +} + +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() + } + } +} + +fn offset( + renderer: &Renderer, + text_bounds: Rectangle, + font: Renderer::Font, + size: f32, + value: &Value, + state: &State, +) -> f32 +where + Renderer: text::Renderer, +{ + if state.is_focused() { + let cursor = state.cursor(); + + let focus_position = match cursor.state(value) { + cursor::State::Index(i) => i, + cursor::State::Selection { end, .. } => end, + }; + + let (_, offset) = measure_cursor_and_scroll_offset( + renderer, + text_bounds, + value, + size, + focus_position, + font, + ); + + offset + } else { + 0.0 + } +} + +fn measure_cursor_and_scroll_offset( + renderer: &Renderer, + 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(); + + let text_value_width = + renderer.measure_width(&text_before_cursor, size, font); + + let offset = ((text_value_width + 5.0) - text_bounds.width).max(0.0); + + (text_value_width, offset) +} + +/// Computes the position of the text cursor at the given X coordinate of +/// a [`TextInput`]. +fn find_cursor_position( + renderer: &Renderer, + text_bounds: Rectangle, + font: Option, + size: Option, + value: &Value, + state: &State, + x: f32, +) -> Option +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); + let value = value.to_string(); + + let char_offset = renderer + .hit_test( + &value, + size, + font, + Size::INFINITY, + Point::new(x + offset, text_bounds.height / 2.0), + true, + ) + .map(text::Hit::cursor)?; + + Some( + unicode_segmentation::UnicodeSegmentation::graphemes( + &value[..char_offset], + true, + ) + .count(), + ) +} + +const CURSOR_BLINK_INTERVAL_MILLIS: u128 = 500; -- cgit From cfb8abb6f5806e08ccc3a80233e1fb1768adeaf7 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Sun, 5 Mar 2023 04:19:31 +0100 Subject: Use `no_run` for widget doc-tests --- widget/src/text_input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'widget/src/text_input.rs') diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index 67d80e2b..d1c48fbd 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -35,7 +35,7 @@ pub use iced_style::text_input::{Appearance, StyleSheet}; /// A field that can be filled with text. /// /// # Example -/// ``` +/// ```no_run /// # pub type TextInput<'a, Message> = /// # iced_widget::TextInput<'a, Message, iced_widget::renderer::Renderer>; /// # -- cgit From 99e0a71504456976ba88040f5d1d3bbc347694ea Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Sun, 5 Mar 2023 06:35:20 +0100 Subject: Rename `iced_native` to `iced_runtime` --- widget/src/text_input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'widget/src/text_input.rs') diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index d1c48fbd..d066109a 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -28,7 +28,7 @@ use crate::core::{ Clipboard, Color, Element, Layout, Length, Padding, Pixels, Point, Rectangle, Shell, Size, Vector, Widget, }; -use crate::native::Command; +use crate::runtime::Command; pub use iced_style::text_input::{Appearance, StyleSheet}; -- cgit From 0f7abffc0e94b4bb9f8117db633bfd07d900eb93 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Wed, 22 Mar 2023 00:36:57 +0100 Subject: Draft (very) basic incremental rendering for `iced_tiny_skia` --- widget/src/text_input.rs | 2 ++ 1 file changed, 2 insertions(+) (limited to 'widget/src/text_input.rs') diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index d066109a..4f018284 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -953,6 +953,8 @@ pub fn draw( let render = |renderer: &mut Renderer| { if let Some((cursor, color)) = cursor { renderer.fill_quad(cursor, color); + } else { + renderer.with_translation(Vector::ZERO, |_| {}); } renderer.fill_text(Text { -- cgit From 33b5a900197e2798a393d6d9a0834039666eddbb Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Wed, 19 Apr 2023 01:19:56 +0200 Subject: Make basic text shaping the default shaping strategy --- widget/src/text_input.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'widget/src/text_input.rs') diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index 9db382f7..abf858ca 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -463,6 +463,7 @@ where &icon.code_point.to_string(), icon.size.unwrap_or_else(|| renderer.default_size()), icon.font, + true, ); let mut text_node = layout::Node::new( @@ -975,6 +976,7 @@ pub fn draw( bounds: icon_layout.bounds(), horizontal_alignment: alignment::Horizontal::Left, vertical_alignment: alignment::Vertical::Top, + advanced_shape: true, }); } @@ -1079,6 +1081,7 @@ pub fn draw( if text.is_empty() { placeholder } else { &text }, size, font, + true, ); let render = |renderer: &mut Renderer| { @@ -1106,6 +1109,7 @@ pub fn draw( size, horizontal_alignment: alignment::Horizontal::Left, vertical_alignment: alignment::Vertical::Center, + advanced_shape: true, }); }; @@ -1311,7 +1315,7 @@ where let text_before_cursor = value.until(cursor_index).to_string(); let text_value_width = - renderer.measure_width(&text_before_cursor, size, font); + renderer.measure_width(&text_before_cursor, size, font, true); let offset = ((text_value_width + 5.0) - text_bounds.width).max(0.0); @@ -1346,6 +1350,7 @@ where Size::INFINITY, Point::new(x + offset, text_bounds.height / 2.0), true, + true, ) .map(text::Hit::cursor)?; -- cgit From 4bd290afe7d81d9aaf7467b3ce91491f6600261a Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Wed, 19 Apr 2023 02:00:45 +0200 Subject: Introduce `text::Shaping` enum and replace magic boolean --- widget/src/text_input.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) (limited to 'widget/src/text_input.rs') diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index abf858ca..32d0b1f8 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -463,7 +463,7 @@ where &icon.code_point.to_string(), icon.size.unwrap_or_else(|| renderer.default_size()), icon.font, - true, + text::Shaping::Advanced, ); let mut text_node = layout::Node::new( @@ -976,7 +976,7 @@ pub fn draw( bounds: icon_layout.bounds(), horizontal_alignment: alignment::Horizontal::Left, vertical_alignment: alignment::Vertical::Top, - advanced_shape: true, + shaping: text::Shaping::Advanced, }); } @@ -1081,7 +1081,7 @@ pub fn draw( if text.is_empty() { placeholder } else { &text }, size, font, - true, + text::Shaping::Advanced, ); let render = |renderer: &mut Renderer| { @@ -1109,7 +1109,7 @@ pub fn draw( size, horizontal_alignment: alignment::Horizontal::Left, vertical_alignment: alignment::Vertical::Center, - advanced_shape: true, + shaping: text::Shaping::Advanced, }); }; @@ -1314,8 +1314,12 @@ where { let text_before_cursor = value.until(cursor_index).to_string(); - let text_value_width = - renderer.measure_width(&text_before_cursor, size, font, true); + 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); @@ -1348,9 +1352,9 @@ where size, font, Size::INFINITY, + text::Shaping::Advanced, Point::new(x + offset, text_bounds.height / 2.0), true, - true, ) .map(text::Hit::cursor)?; -- cgit From 9499a8f9e6f9971dedfae563cb133232aa3cebc2 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Thu, 4 May 2023 13:00:16 +0200 Subject: Support configurable `LineHeight` in text widgets --- widget/src/text_input.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) (limited to 'widget/src/text_input.rs') diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index 364ec3cd..bbc07dac 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -68,6 +68,7 @@ where width: Length, padding: Padding, size: Option, + line_height: text::LineHeight, on_input: Option Message + 'a>>, on_paste: Option Message + 'a>>, on_submit: Option, @@ -96,6 +97,7 @@ where width: Length::Fill, padding: Padding::new(5.0), size: None, + line_height: text::LineHeight::default(), on_input: None, on_paste: None, on_submit: None, @@ -177,6 +179,15 @@ where self } + /// Sets the [`LineHeight`] of the [`TextInput`]. + pub fn line_height( + mut self, + line_height: impl Into, + ) -> Self { + self.line_height = line_height.into(); + self + } + /// Sets the style of the [`TextInput`]. pub fn style( mut self, @@ -208,6 +219,7 @@ where value.unwrap_or(&self.value), &self.placeholder, self.size, + self.line_height, self.font, self.on_input.is_none(), self.is_secure, @@ -263,6 +275,7 @@ where self.width, self.padding, self.size, + self.line_height, self.icon.as_ref(), ) } @@ -299,6 +312,7 @@ where shell, &mut self.value, self.size, + self.line_height, self.font, self.is_secure, self.on_input.as_deref(), @@ -327,6 +341,7 @@ where &self.value, &self.placeholder, self.size, + self.line_height, self.font, self.on_input.is_none(), self.is_secure, @@ -447,6 +462,7 @@ pub fn layout( width: Length, padding: Padding, size: Option, + line_height: text::LineHeight, icon: Option<&Icon>, ) -> layout::Node where @@ -454,7 +470,10 @@ where { 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(text_size * 1.2); + let limits = limits + .width(width) + .pad(padding) + .height(line_height.to_absolute(Pixels(text_size))); let text_bounds = limits.resolve(Size::ZERO); @@ -515,6 +534,7 @@ pub fn update<'a, Message, Renderer>( shell: &mut Shell<'_, Message>, value: &mut Value, size: Option, + line_height: text::LineHeight, font: Option, is_secure: bool, on_input: Option<&dyn Fn(String) -> Message>, @@ -567,6 +587,7 @@ where text_layout.bounds(), font, size, + line_height, &value, state, target, @@ -595,6 +616,7 @@ where text_layout.bounds(), font, size, + line_height, value, state, target, @@ -644,6 +666,7 @@ where text_layout.bounds(), font, size, + line_height, &value, state, target, @@ -926,6 +949,7 @@ pub fn draw( value: &Value, placeholder: &str, size: Option, + line_height: text::LineHeight, font: Option, is_disabled: bool, is_secure: bool, @@ -971,6 +995,7 @@ pub fn draw( 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 { @@ -1110,6 +1135,7 @@ pub fn draw( ..text_bounds }, size, + line_height, horizontal_alignment: alignment::Horizontal::Left, vertical_alignment: alignment::Vertical::Center, shaping: text::Shaping::Advanced, @@ -1336,6 +1362,7 @@ fn find_cursor_position( text_bounds: Rectangle, font: Option, size: Option, + line_height: text::LineHeight, value: &Value, state: &State, x: f32, @@ -1353,6 +1380,7 @@ where .hit_test( &value, size, + line_height, font, Size::INFINITY, text::Shaping::Advanced, -- cgit From 1234d528121265698f9f426ca89fc687dc95dc01 Mon Sep 17 00:00:00 2001 From: Casper Storm Date: Tue, 23 May 2023 15:28:45 +0200 Subject: clippy --- widget/src/text_input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'widget/src/text_input.rs') diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index bbc07dac..8f243c1a 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -982,7 +982,7 @@ pub fn draw( renderer.fill_quad( renderer::Quad { bounds, - border_radius: appearance.border_radius.into(), + border_radius: appearance.border_radius, border_width: appearance.border_width, border_color: appearance.border_color, }, -- cgit From 34451bff185d8875f55747ee97ed746828e30f40 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Thu, 8 Jun 2023 20:11:59 +0200 Subject: Implement basic cursor availability --- widget/src/text_input.rs | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) (limited to 'widget/src/text_input.rs') diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index 8f243c1a..5b3705d4 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -207,14 +207,14 @@ where renderer: &mut Renderer, theme: &Renderer::Theme, layout: Layout<'_>, - cursor_position: Point, + cursor: mouse::Cursor, value: Option<&Value>, ) { draw( renderer, theme, layout, - cursor_position, + cursor, tree.state.downcast_ref::(), value.unwrap_or(&self.value), &self.placeholder, @@ -298,7 +298,7 @@ where tree: &mut Tree, event: Event, layout: Layout<'_>, - cursor_position: Point, + cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, @@ -306,7 +306,7 @@ where update( event, layout, - cursor_position, + cursor, renderer, clipboard, shell, @@ -329,14 +329,14 @@ where theme: &Renderer::Theme, _style: &renderer::Style, layout: Layout<'_>, - cursor_position: Point, + cursor: mouse::Cursor, _viewport: &Rectangle, ) { draw( renderer, theme, layout, - cursor_position, + cursor, tree.state.downcast_ref::(), &self.value, &self.placeholder, @@ -354,11 +354,11 @@ where &self, _state: &Tree, layout: Layout<'_>, - cursor_position: Point, + cursor: mouse::Cursor, _viewport: &Rectangle, _renderer: &Renderer, ) -> mouse::Interaction { - mouse_interaction(layout, cursor_position, self.on_input.is_none()) + mouse_interaction(layout, cursor, self.on_input.is_none()) } } @@ -528,7 +528,7 @@ where pub fn update<'a, Message, Renderer>( event: Event, layout: Layout<'_>, - cursor_position: Point, + cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, @@ -550,10 +550,14 @@ where Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { let state = state(); - let is_clicked = - layout.bounds().contains(cursor_position) && on_input.is_some(); - state.is_focused = if is_clicked { + let click_position = if on_input.is_some() { + cursor.position_over(&layout.bounds()) + } else { + None + }; + + state.is_focused = if click_position.is_some() { state.is_focused.or_else(|| { let now = Instant::now(); @@ -566,7 +570,7 @@ where None }; - if is_clicked { + if let Some(cursor_position) = click_position { let text_layout = layout.children().next().unwrap(); let target = cursor_position.x - text_layout.bounds().x; @@ -944,7 +948,7 @@ pub fn draw( renderer: &mut Renderer, theme: &Renderer::Theme, layout: Layout<'_>, - cursor_position: Point, + cursor: mouse::Cursor, state: &State, value: &Value, placeholder: &str, @@ -967,7 +971,7 @@ pub fn draw( let mut children_layout = layout.children(); let text_bounds = children_layout.next().unwrap().bounds(); - let is_mouse_over = bounds.contains(cursor_position); + let is_mouse_over = cursor.is_over(&bounds); let appearance = if is_disabled { theme.disabled(style) @@ -1154,10 +1158,10 @@ pub fn draw( /// Computes the current [`mouse::Interaction`] of the [`TextInput`]. pub fn mouse_interaction( layout: Layout<'_>, - cursor_position: Point, + cursor: mouse::Cursor, is_disabled: bool, ) -> mouse::Interaction { - if layout.bounds().contains(cursor_position) { + if cursor.is_over(&layout.bounds()) { if is_disabled { mouse::Interaction::NotAllowed } else { -- cgit From 5c8cfb411ed0a9a6e55bd1193cd7e97252e63d28 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Thu, 8 Jun 2023 20:16:46 +0200 Subject: Take `Rectangle` by value in `Cursor` API --- widget/src/text_input.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'widget/src/text_input.rs') diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index 5b3705d4..272263f9 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -552,7 +552,7 @@ where let state = state(); let click_position = if on_input.is_some() { - cursor.position_over(&layout.bounds()) + cursor.position_over(layout.bounds()) } else { None }; @@ -971,7 +971,7 @@ pub fn draw( let mut children_layout = layout.children(); let text_bounds = children_layout.next().unwrap().bounds(); - let is_mouse_over = cursor.is_over(&bounds); + let is_mouse_over = cursor.is_over(bounds); let appearance = if is_disabled { theme.disabled(style) @@ -1161,7 +1161,7 @@ pub fn mouse_interaction( cursor: mouse::Cursor, is_disabled: bool, ) -> mouse::Interaction { - if cursor.is_over(&layout.bounds()) { + if cursor.is_over(layout.bounds()) { if is_disabled { mouse::Interaction::NotAllowed } else { -- cgit