diff --git a/examples/cosmic-sctk/src/window.rs b/examples/cosmic-sctk/src/window.rs index 76a857d0..54d27757 100644 --- a/examples/cosmic-sctk/src/window.rs +++ b/examples/cosmic-sctk/src/window.rs @@ -13,9 +13,10 @@ use cosmic::{ iced_widget::text, theme::{self, Theme}, widget::{ - button, cosmic_container, header_bar, nav_bar, nav_bar_toggle, + button, cosmic_container, header_bar, icon, inline_input, nav_bar, nav_bar_toggle, rectangle_tracker::{rectangle_tracker_subscription, RectangleTracker, RectangleUpdate}, - scrollable, segmented_button, segmented_selection, settings, IconSource, + scrollable, search_input, secure_input, segmented_button, segmented_selection, settings, + text_input, IconSource, }, Element, ElementExt, }; @@ -127,6 +128,8 @@ pub struct Window { rectangle_tracker: Option>, pub selection: segmented_button::SingleSelectModel, timeline: Timeline, + input_value: String, + secure_input_visible: bool, } impl Window { @@ -183,12 +186,13 @@ pub enum Message { Drag, Minimize, Maximize, - InputChanged, Rectangle(RectangleUpdate), NavBar(segmented_button::Entity), Ignore, Selection(segmented_button::Entity), Tick(Instant), + InputChanged(String), + ToggleVisible, } impl Window { @@ -305,7 +309,6 @@ impl Application for Window { Message::Minimize => return set_mode_window(window::Id(0), window::Mode::Hidden), Message::Maximize => return toggle_maximize(window::Id(0)), Message::RowSelected(row) => println!("Selected row {row}"), - Message::InputChanged => {} Message::Rectangle(r) => match r { RectangleUpdate::Rectangle(_) => {} RectangleUpdate::Init(t) => { @@ -315,6 +318,12 @@ impl Application for Window { Message::Ignore => {} Message::Selection(key) => self.selection.activate(key), Message::Tick(now) => self.timeline.now(now), + Message::InputChanged(v) => { + self.input_value = v; + } + Message::ToggleVisible => { + self.secure_input_visible = !self.secure_input_visible; + } } Command::none() @@ -476,6 +485,41 @@ impl Application for Window { .padding(16) .style(cosmic::theme::Container::Secondary), )) + .add(settings::item( + "Text Input", + text_input("test", &self.input_value) + .width(Length::Fill) + .on_input(Message::InputChanged), + )) + .add(settings::item( + "Text Input", + secure_input( + "test", + &self.input_value, + Some(Message::ToggleVisible), + !self.secure_input_visible, + ) + .label("Test Secure Input Label") + .helper_text("password") + .width(Length::Fill) + .on_input(Message::InputChanged), + )) + .add(settings::item( + "Text Input", + search_input( + "search for stuff", + &self.input_value, + Some(Message::InputChanged("".to_string())), + ) + .width(Length::Fill) + .on_input(Message::InputChanged), + )) + .add(settings::item( + "Text Input", + inline_input(&self.input_value) + .width(Length::Fill) + .on_input(Message::InputChanged), + )) .into(), ]) .into(); diff --git a/examples/cosmic/src/window/demo.rs b/examples/cosmic/src/window/demo.rs index c1856eb8..375b7044 100644 --- a/examples/cosmic/src/window/demo.rs +++ b/examples/cosmic/src/window/demo.rs @@ -504,6 +504,10 @@ impl State { .size(20) .id(INPUT_ID.clone()) .into(), + cosmic::widget::text_input("test", &self.entry_value) + .width(Length::Fill) + .on_input(Message::InputChanged) + .into(), ]) .into() } diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 5e96cdf9..272824b8 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -70,6 +70,10 @@ pub use warning::*; pub mod cosmic_container; pub use cosmic_container::*; +// #[cfg(feature = "wayland")] +pub mod text_input; +// #[cfg(feature = "wayland")] +pub use text_input::*; /// An element to distinguish a boundary between two elements. pub mod divider { diff --git a/src/widget/text_input/cursor.rs b/src/widget/text_input/cursor.rs new file mode 100644 index 00000000..ba59ca04 --- /dev/null +++ b/src/widget/text_input/cursor.rs @@ -0,0 +1,173 @@ +//! Track the cursor of a text input. +use super::value::Value; + +/// The cursor of a text input. +#[derive(Debug, Copy, Clone)] +pub struct Cursor { + state: State, +} + +/// The state of a [`Cursor`]. +#[derive(Debug, Copy, Clone)] +pub enum State { + /// Cursor without a selection + Index(usize), + + /// Cursor selecting a range of text + Selection { + /// The start of the selection + start: usize, + /// The end of the selection + end: usize, + }, +} + +impl Default for Cursor { + fn default() -> Self { + Cursor { + state: State::Index(0), + } + } +} + +impl Cursor { + /// Returns the [`State`] of the [`Cursor`]. + #[must_use] + pub fn state(&self, value: &Value) -> State { + match self.state { + State::Index(index) => State::Index(index.min(value.len())), + State::Selection { start, end } => { + let start = start.min(value.len()); + let end = end.min(value.len()); + + if start == end { + State::Index(start) + } else { + State::Selection { start, end } + } + } + } + } + + /// Returns the current selection of the [`Cursor`] for the given [`Value`]. + /// + /// `start` is guaranteed to be <= than `end`. + #[must_use] + pub fn selection(&self, value: &Value) -> Option<(usize, usize)> { + match self.state(value) { + State::Selection { start, end } => Some((start.min(end), start.max(end))), + State::Index(_) => None, + } + } + + pub(crate) fn move_to(&mut self, position: usize) { + self.state = State::Index(position); + } + + pub(crate) fn move_right(&mut self, value: &Value) { + 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))); + } + + pub(crate) fn move_right_by_amount(&mut self, value: &Value, amount: usize) { + match self.state(value) { + State::Index(index) => self.move_to(index.saturating_add(amount).min(value.len())), + State::Selection { start, end } => self.move_to(end.max(start)), + } + } + + pub(crate) fn move_left(&mut self, value: &Value) { + 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)), + State::Index(_) => self.move_to(0), + } + } + + pub(crate) fn move_left_by_words(&mut self, value: &Value) { + self.move_to(value.previous_start_of_word(self.left(value))); + } + + pub(crate) fn select_range(&mut self, start: usize, end: usize) { + if start == end { + self.state = State::Index(start); + } else { + self.state = State::Selection { start, end }; + } + } + + 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), + State::Selection { start, end } if end > 0 => self.select_range(start, end - 1), + _ => {} + } + } + + 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), + State::Selection { start, end } if end < value.len() => { + self.select_range(start, end + 1); + } + _ => {} + } + } + + 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)), + State::Selection { start, end } => { + self.select_range(start, value.previous_start_of_word(end)); + } + } + } + + 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)), + State::Selection { start, end } => { + self.select_range(start, value.next_end_of_word(end)); + } + } + } + + pub(crate) fn select_all(&mut self, value: &Value) { + self.select_range(0, value.len()); + } + + pub(crate) fn start(&self, value: &Value) -> usize { + let start = match self.state { + State::Index(index) => index, + State::Selection { start, .. } => start, + }; + + start.min(value.len()) + } + + pub(crate) fn end(&self, value: &Value) -> usize { + let end = match self.state { + State::Index(index) => index, + State::Selection { end, .. } => end, + }; + + end.min(value.len()) + } + + fn left(&self, value: &Value) -> usize { + match self.state(value) { + State::Index(index) => index, + State::Selection { start, end } => start.min(end), + } + } + + fn right(&self, value: &Value) -> usize { + match self.state(value) { + State::Index(index) => index, + State::Selection { start, end } => start.max(end), + } + } +} diff --git a/src/widget/text_input/editor.rs b/src/widget/text_input/editor.rs new file mode 100644 index 00000000..07648b71 --- /dev/null +++ b/src/widget/text_input/editor.rs @@ -0,0 +1,65 @@ +use super::{cursor::Cursor, value::Value}; + +pub struct Editor<'a> { + value: &'a mut Value, + cursor: &'a mut Cursor, +} + +impl<'a> Editor<'a> { + pub fn new(value: &'a mut Value, cursor: &'a mut Cursor) -> Editor<'a> { + Editor { value, cursor } + } + + #[must_use] + pub fn contents(&self) -> String { + self.value.to_string() + } + + pub fn insert(&mut self, character: char) { + if let Some((left, right)) = self.cursor.selection(self.value) { + self.cursor.move_left(self.value); + self.value.remove_many(left, right); + } + + self.value.insert(self.cursor.end(self.value), character); + self.cursor.move_right(self.value); + } + + pub fn paste(&mut self, content: Value) { + let length = content.len(); + if let Some((left, right)) = self.cursor.selection(self.value) { + self.cursor.move_left(self.value); + self.value.remove_many(left, right); + } + + self.value.insert_many(self.cursor.end(self.value), content); + + self.cursor.move_right_by_amount(self.value, length); + } + + pub fn backspace(&mut self) { + if let Some((start, end)) = self.cursor.selection(self.value) { + self.cursor.move_left(self.value); + self.value.remove_many(start, end); + } else { + let start = self.cursor.start(self.value); + + if start > 0 { + self.cursor.move_left(self.value); + self.value.remove(start - 1); + } + } + } + + pub fn delete(&mut self) { + if self.cursor.selection(self.value).is_some() { + self.backspace(); + } else { + let end = self.cursor.end(self.value); + + if end < self.value.len() { + self.value.remove(end); + } + } + } +} diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs new file mode 100644 index 00000000..abb4290d --- /dev/null +++ b/src/widget/text_input/input.rs @@ -0,0 +1,2319 @@ +//! Display fields that can be filled with text. +//! +//! A [`TextInput`] has some local [`State`]. +use crate::theme::THEME; + +use super::cursor; +pub use super::cursor::Cursor; +use super::editor::Editor; +use super::style::StyleSheet; +pub use super::value::Value; + +use iced::Limits; +use iced_core::event::{self, Event}; +use iced_core::keyboard; +use iced_core::layout; +use iced_core::mouse::{self, click}; +use iced_core::renderer::{self, Renderer as CoreRenderer}; +use iced_core::text::{self, Renderer, Text}; +use iced_core::time::{Duration, Instant}; +use iced_core::touch; +use iced_core::widget::operation::{self, Operation}; +use iced_core::widget::tree::{self, Tree}; +use iced_core::widget::Id; +use iced_core::window; +use iced_core::{alignment, Background}; +use iced_core::{ + Clipboard, Color, Element, Layout, Length, Padding, Pixels, Point, Rectangle, Shell, Size, + Vector, Widget, +}; +#[cfg(feature = "wayland")] +use iced_renderer::core::event::{wayland, PlatformSpecific}; +use iced_renderer::core::widget::OperationOutputWrapper; +#[cfg(feature = "wayland")] +use iced_runtime::command::platform_specific; +use iced_runtime::Command; + +#[cfg(feature = "wayland")] +use iced_runtime::command::platform_specific::wayland::data_device::{DataFromMimeType, DndIcon}; +#[cfg(feature = "wayland")] +use sctk::reexports::client::protocol::wl_data_device_manager::DndAction; + +/// Creates a new [`TextInput`]. +/// +/// [`TextInput`]: widget::TextInput +pub fn text_input<'a, Message>(placeholder: &str, value: &str) -> TextInput<'a, Message> +where + Message: Clone, +{ + TextInput::new(placeholder, value) +} + +/// Creates a new search [`TextInput`]. +/// +/// [`TextInput`]: widget::TextInput +pub fn search_input<'a, Message>( + placeholder: &str, + value: &str, + on_clear: Option, +) -> TextInput<'a, Message> +where + Message: Clone + 'static, +{ + let spacing = THEME.with(|t| t.borrow().cosmic().space_xxs()); + let input = TextInput::new(placeholder, value) + .padding([0, spacing, 0, spacing]) + .style(super::style::TextInput::Search) + .start_icon( + iced_widget::container( + crate::widget::icon("system-search-symbolic", 16) + .style(crate::theme::Svg::Symbolic), + ) + .padding([spacing, spacing, spacing, spacing]) + .into(), + ); + + if let Some(msg) = on_clear { + input.end_icon( + crate::widget::button::button(crate::theme::Button::Text) + .icon(crate::theme::Svg::Symbolic, "edit-clear-symbolic", 16) + .on_press(msg) + .padding([spacing, spacing, spacing, spacing]) + .into(), + ) + } else { + input + } +} +/// Creates a new search [`TextInput`]. +/// +/// [`TextInput`]: widget::TextInput +pub fn secure_input<'a, Message>( + placeholder: &str, + value: &str, + on_visible_toggle: Option, + hidden: bool, +) -> TextInput<'a, Message> +where + Message: Clone + 'static, +{ + let spacing = THEME.with(|t| t.borrow().cosmic().space_xxs()); + let mut input = TextInput::new(placeholder, value) + .padding([0, spacing, 0, spacing]) + .style(super::style::TextInput::Default) + .start_icon( + iced_widget::container( + crate::widget::icon("system-lock-screen-symbolic", 16) + .style(crate::theme::Svg::Symbolic), + ) + .padding([spacing, spacing, spacing, spacing]) + .into(), + ); + if hidden { + input = input.password(); + } + if let Some(msg) = on_visible_toggle { + input.end_icon( + crate::widget::button::button(crate::theme::Button::Text) + .icon( + crate::theme::Svg::Symbolic, + "document-properties-symbolic", + 16, + ) + .on_press(msg) + .padding([spacing, spacing, spacing, spacing]) + .into(), + ) + } else { + input + } +} + +/// Creates a new inline [`TextInput`]. +/// +/// [`TextInput`]: widget::TextInput +pub fn inline_input<'a, Message>(value: &str) -> TextInput<'a, Message> +where + Message: Clone, +{ + let spacing = THEME.with(|t| t.borrow().cosmic().space_xxs()); + + TextInput::new("", value) + .style(super::style::TextInput::Inline) + .padding([spacing, spacing, spacing, spacing]) +} + +#[cfg(feature = "wayland")] +const SUPPORTED_MIME_TYPES: &[&str; 6] = &[ + "text/plain;charset=utf-8", + "text/plain;charset=UTF-8", + "UTF8_STRING", + "STRING", + "text/plain", + "TEXT", +]; +#[cfg(feature = "wayland")] +pub type DnDCommand = + Box platform_specific::wayland::data_device::ActionInner>; +#[cfg(not(feature = "wayland"))] +pub type DnDCommand = (); + +/// 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>; +/// # +/// #[derive(Debug, Clone)] +/// enum Message { +/// TextInputChanged(String), +/// } +/// +/// let value = "Some text"; +/// +/// let input = TextInput::new( +/// "This is the placeholder...", +/// value, +/// ) +/// .on_input(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)] +#[must_use] +pub struct TextInput<'a, Message> { + id: Option, + placeholder: String, + value: Value, + is_secure: bool, + font: Option<::Font>, + width: Length, + padding: Padding, + size: Option, + helper_size: f32, + label: Option<&'a str>, + helper_text: Option<&'a str>, + error: Option<&'a str>, + on_input: Option Message + 'a>>, + on_paste: Option Message + 'a>>, + on_submit: Option, + start_icon: Option>, + end_element: Option>, + style: <::Theme as StyleSheet>::Style, + // (text_input::State, mime_type, dnd_action) -> Message + on_create_dnd_source: Option Message + 'a>>, + on_dnd_command_produced: Option Message + 'a>>, + surface_ids: Option<(window::Id, window::Id)>, + dnd_icon: bool, + line_height: text::LineHeight, + helper_line_height: text::LineHeight, +} + +impl<'a, Message> TextInput<'a, Message> +where + Message: Clone, +{ + /// Creates a new [`TextInput`]. + /// + /// It expects: + /// - a placeholder, + /// - the current value + pub fn new(placeholder: &str, value: &str) -> Self { + let spacing = THEME.with(|t| t.borrow().cosmic().space_xxs()); + + TextInput { + id: None, + placeholder: String::from(placeholder), + value: Value::new(value), + is_secure: false, + font: None, + width: Length::Fill, + padding: [spacing, spacing, spacing, spacing].into(), + size: None, + helper_size: 10.0, + helper_line_height: text::LineHeight::from(14.0), + on_input: None, + on_paste: None, + on_submit: None, + start_icon: None, + end_element: None, + error: None, + style: super::style::TextInput::default(), + on_dnd_command_produced: None, + on_create_dnd_source: None, + surface_ids: None, + dnd_icon: false, + line_height: text::LineHeight::default(), + label: None, + helper_text: None, + } + } + + /// Sets the text of the [`TextInput`]. + pub fn label(mut self, label: &'a str) -> Self { + self.label = Some(label); + self + } + + /// Sets the helper text of the [`TextInput`]. + pub fn helper_text(mut self, helper_text: &'a str) -> Self { + self.helper_text = Some(helper_text); + self + } + + /// Sets the [`Id`] of the [`TextInput`]. + pub fn id(mut self, id: Id) -> Self { + self.id = Some(id); + self + } + + /// Sets the error message of the [`TextInput`]. + pub fn error(mut self, error: &'a str) -> Self { + self.error = Some(error); + 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 + } + + /// 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 typed into + /// the [`TextInput`]. + /// + /// If this method is not called, the [`TextInput`] will be disabled. + pub fn on_input(mut self, callback: F) -> Self + where + F: 'a + Fn(String) -> Message, + { + self.on_input = Some(Box::new(callback)); + 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 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: ::Font) -> Self { + self.font = Some(font); + self + } + + /// Sets the start [`Icon`] of the [`TextInput`]. + pub fn start_icon(mut self, icon: Element<'a, Message, crate::Renderer>) -> Self { + self.start_icon = Some(icon); + self + } + + /// Sets the end [`Icon`] of the [`TextInput`]. + pub fn end_icon(mut self, icon: Element<'a, Message, crate::Renderer>) -> Self { + self.end_element = Some(icon); + 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 style of the [`TextInput`]. + pub fn style( + mut self, + style: impl Into<<::Theme as StyleSheet>::Style>, + ) -> Self { + self.style = style.into(); + self + } + + /// Draws the [`TextInput`] with the given [`Renderer`], overriding its + /// [`Value`] if provided. + /// + /// [`Renderer`]: text::Renderer + #[allow(clippy::too_many_arguments)] + pub fn draw( + &self, + tree: &Tree, + renderer: &mut crate::Renderer, + theme: &::Theme, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + value: Option<&Value>, + style: &renderer::Style, + ) { + draw( + renderer, + theme, + layout, + cursor_position, + tree, + value.unwrap_or(&self.value), + &self.placeholder, + self.size, + self.font, + self.on_input.is_none(), + self.is_secure, + self.start_icon.as_ref(), + self.end_element.as_ref(), + &self.style, + self.dnd_icon, + self.line_height, + self.error, + self.label, + self.helper_text, + self.helper_size, + self.helper_line_height, + &layout.bounds(), + style, + ); + } + + /// Sets the start dnd handler of the [`TextInput`]. + #[cfg(feature = "wayland")] + pub fn on_start_dnd(mut self, on_start_dnd: impl Fn(State) -> Message + 'a) -> Self { + self.on_create_dnd_source = Some(Box::new(on_start_dnd)); + self + } + + /// Sets the dnd command produced handler of the [`TextInput`]. + /// Commands should be returned in the update function of the application. + #[cfg(feature = "wayland")] + pub fn on_dnd_command_produced( + mut self, + on_dnd_command_produced: impl Fn( + Box platform_specific::wayland::data_device::ActionInner>, + ) -> Message + + 'a, + ) -> Self { + self.on_dnd_command_produced = Some(Box::new(on_dnd_command_produced)); + self + } + + /// Sets the window id of the [`TextInput`] and the window id of the drag icon. + /// Both ids are required to be unique. + /// This is required for the dnd to work. + pub fn surface_ids(mut self, window_id: (window::Id, window::Id)) -> Self { + self.surface_ids = Some(window_id); + self + } + + /// Sets the mode of this [`TextInput`] to be a drag and drop icon. + pub fn dnd_icon(mut self, dnd_icon: bool) -> Self { + self.dnd_icon = dnd_icon; + self + } + + /// Get the layout node of the actual text input + fn text_layout<'b>(&'a self, layout: Layout<'b>) -> Layout<'b> { + if self.dnd_icon { + layout + } else if self.label.is_some() { + let mut nodes = layout.children(); + nodes.next(); + nodes.next().unwrap() + } else { + layout.children().next().unwrap() + } + } +} + +impl<'a, Message> Widget for TextInput<'a, Message> +where + Message: Clone, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::new()) + } + + fn diff(&mut self, tree: &mut Tree) { + let state = tree.state.downcast_mut::(); + + // Unfocus text input if it becomes disabled + if self.on_input.is_none() { + state.last_click = None; + state.is_focused = None; + state.is_pasting = None; + state.dragging_state = None; + } + let mut children: Vec<_> = self + .start_icon + .iter_mut() + .chain(self.end_element.iter_mut()) + .map(iced_core::Element::as_widget_mut) + .collect(); + tree.diff_children(children.as_mut_slice()); + } + + fn children(&self) -> Vec { + self.start_icon + .iter() + .chain(self.end_element.iter()) + .map(|icon| Tree::new(icon)) + .collect() + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + Length::Shrink + } + + fn layout(&self, renderer: &crate::Renderer, limits: &layout::Limits) -> layout::Node { + if self.dnd_icon { + let limits = limits.width(Length::Shrink).height(Length::Shrink); + + let size = self.size.unwrap_or_else(|| renderer.default_size()); + + let bounds = limits.max(); + let font = self.font.unwrap_or_else(|| renderer.default_font()); + + let Size { width, height } = renderer.measure( + &self.value.to_string(), + size, + self.line_height, + font, + bounds, + text::Shaping::Advanced, + ); + + let size = limits.resolve(Size::new(width, height)); + layout::Node::with_children(size, vec![layout::Node::new(size)]) + } else { + layout( + renderer, + limits, + self.width, + self.padding, + self.size, + self.start_icon.as_ref(), + self.end_element.as_ref(), + self.line_height, + self.label, + self.helper_text, + self.helper_size, + self.helper_line_height, + ) + } + } + + fn operate( + &self, + tree: &mut Tree, + _layout: Layout<'_>, + _renderer: &crate::Renderer, + operation: &mut dyn Operation>, + ) { + let state = tree.state.downcast_mut::(); + + operation.focusable(state, self.id.as_ref()); + operation.text_input(state, self.id.as_ref()); + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + renderer: &crate::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + let text_layout = self.text_layout(layout); + let mut child_state = tree.children.iter_mut(); + if let (Some(start_icon), Some(tree)) = (self.start_icon.as_mut(), child_state.next()) { + let mut children = text_layout.children(); + children.next(); + let start_icon_layout = children.next().unwrap(); + + if cursor_position.is_over(start_icon_layout.bounds()) { + return start_icon.as_widget_mut().on_event( + tree, + event.clone(), + layout, + cursor_position, + renderer, + clipboard, + shell, + viewport, + ); + } + } + if let (Some(end_icon), Some(tree)) = (self.end_element.as_mut(), child_state.next()) { + let mut children = text_layout.children(); + children.next(); + children.next(); + let end_icon_layout = children.next().unwrap(); + + if cursor_position.is_over(end_icon_layout.bounds()) { + return end_icon.as_widget_mut().on_event( + tree, + event.clone(), + layout, + cursor_position, + renderer, + clipboard, + shell, + viewport, + ); + } + } + + update( + event, + text_layout.children().next().unwrap(), + cursor_position, + renderer, + clipboard, + shell, + &mut self.value, + self.size, + self.font, + self.is_secure, + self.on_input.as_deref(), + self.on_paste.as_deref(), + &self.on_submit, + || tree.state.downcast_mut::(), + self.on_create_dnd_source.as_deref(), + self.dnd_icon, + self.on_dnd_command_produced.as_deref(), + self.surface_ids, + self.line_height, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut crate::Renderer, + theme: &::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + viewport: &Rectangle, + ) { + draw( + renderer, + theme, + layout, + cursor_position, + tree, + &self.value, + &self.placeholder, + self.size, + self.font, + self.on_input.is_none(), + self.is_secure, + self.start_icon.as_ref(), + self.end_element.as_ref(), + &self.style, + self.dnd_icon, + self.line_height, + self.error, + self.label, + self.helper_text, + self.helper_size, + self.helper_line_height, + viewport, + style, + ); + } + + fn mouse_interaction( + &self, + state: &Tree, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + viewport: &Rectangle, + renderer: &crate::Renderer, + ) -> mouse::Interaction { + let layout = self.text_layout(layout); + let mut index = 0; + if let (Some(start_icon), Some(tree)) = + (self.start_icon.as_ref(), state.children.get(index)) + { + let mut children = layout.children(); + children.next(); + let start_icon_layout = children.next().unwrap(); + + if cursor_position.is_over(start_icon_layout.bounds()) { + return start_icon.mouse_interaction( + tree, + layout, + cursor_position, + viewport, + renderer, + ); + } + index += 1; + } + + if let (Some(end_icon), Some(tree)) = (self.end_element.as_ref(), state.children.get(index)) + { + let mut children = layout.children(); + children.next(); + children.next(); + let end_icon_layout = children.next().unwrap(); + + if cursor_position.is_over(end_icon_layout.bounds()) { + return end_icon.mouse_interaction( + tree, + layout, + cursor_position, + viewport, + renderer, + ); + } + } + + mouse_interaction(layout, cursor_position, self.on_input.is_none()) + } +} + +impl<'a, Message> From> for Element<'a, Message, crate::Renderer> +where + Message: 'a + Clone, +{ + fn from(text_input: TextInput<'a, Message>) -> Element<'a, Message, crate::Renderer> { + Element::new(text_input) + } +} + +/// Produces a [`Command`] that focuses the [`TextInput`] with the given [`Id`]. +pub fn focus(id: Id) -> Command { + Command::widget(operation::focusable::focus(id)) +} + +/// 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)) +} + +/// 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)) +} + +/// 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, 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)) +} + +/// Computes the layout of a [`TextInput`]. +#[allow(clippy::cast_precision_loss)] +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_lines)] +pub fn layout( + renderer: &crate::Renderer, + limits: &layout::Limits, + width: Length, + padding: Padding, + size: Option, + start_icon: Option<&Element<'_, Message, crate::Renderer>>, + end_icon: Option<&Element<'_, Message, crate::Renderer>>, + line_height: text::LineHeight, + label: Option<&str>, + helper_text: Option<&str>, + helper_text_size: f32, + helper_text_line_height: text::LineHeight, +) -> layout::Node { + let spacing = THEME.with(|t| t.borrow().cosmic().space_xxs()); + let mut nodes = Vec::with_capacity(3); + + let text_pos = if let Some(label) = label { + let limits = limits.width(width); + let text_bounds = limits.resolve(Size::ZERO); + + let label_size = renderer.measure( + label, + size.unwrap_or_else(|| renderer.default_size()), + line_height, + renderer.default_font(), + text_bounds, + text::Shaping::Advanced, + ); + + nodes.push(layout::Node::new(label_size)); + Vector::new(0.0, label_size.height + f32::from(spacing)) + } else { + Vector::ZERO + }; + + let text_size = size.unwrap_or_else(|| renderer.default_size()); + let padding = padding.fit(Size::ZERO, limits.max()); + + let helper_pos = if start_icon.is_some() || end_icon.is_some() { + // TODO configurable icon spacing, maybe via appearance + let mut height = text_size * 1.2; + let icon_spacing = 8.0; + let (start_icon_width, mut start_icon) = if let Some(icon) = start_icon.as_ref() { + let icon_node = icon.layout( + renderer, + &Limits::NONE + .width(icon.as_widget().width()) + .height(icon.as_widget().height()), + ); + height = height.max(icon_node.bounds().height); + (icon_node.bounds().width + icon_spacing, Some(icon_node)) + } else { + (0.0, None) + }; + + let (end_icon_width, mut end_icon) = if let Some(icon) = end_icon.as_ref() { + let icon_node = icon.layout( + renderer, + &Limits::NONE + .width(icon.as_widget().width()) + .height(icon.as_widget().height()), + ); + height = height.max(icon_node.bounds().height); + (icon_node.bounds().width + icon_spacing, Some(icon_node)) + } else { + (0.0, None) + }; + let text_limits = limits.width(width).pad(padding).height(text_size * 1.2); + + let text_bounds = text_limits.resolve(Size::ZERO); + + let mut text_node = + layout::Node::new(text_bounds - Size::new(start_icon_width + end_icon_width, 0.0)); + + text_node.move_to(Point::new( + padding.left + start_icon_width, + padding.top + ((height - text_size * 1.2) / 2.0).max(0.0), + )); + let mut node_list: Vec<_> = Vec::with_capacity(3); + + let text_node_bounds = text_node.bounds(); + node_list.push(text_node); + + if let Some(mut start_icon) = start_icon.take() { + start_icon.move_to(Point::new( + padding.left, + padding.top + ((text_size * 1.2 - start_icon.bounds().height) / 2.0).max(0.0), + )); + node_list.push(start_icon); + } + if let Some(mut end_icon) = end_icon.take() { + end_icon.move_to(Point::new( + text_node_bounds.x + text_node_bounds.width, + padding.top + ((text_size * 1.2 - end_icon.bounds().height) / 2.0).max(0.0), + )); + node_list.push(end_icon); + } + + let input_limits = limits.width(width).pad(padding).height(height); + let input_bounds = input_limits.resolve(Size::ZERO); + let input_node = layout::Node::with_children(input_bounds, node_list).translate(text_pos); + let y_pos = input_node.bounds().y + input_node.bounds().height + f32::from(spacing); + nodes.push(input_node); + + Vector::new(0.0, y_pos) + } else { + let limits = limits.width(width).pad(padding).height(text_size * 1.2); + let text_bounds = limits.resolve(Size::ZERO); + + let mut text = layout::Node::new(text_bounds); + text.move_to(Point::new(padding.left, padding.top)); + + let node = + layout::Node::with_children(text_bounds.pad(padding), vec![text]).translate(text_pos); + let y_pos = node.bounds().y + node.bounds().height + f32::from(spacing); + nodes.push(node); + + Vector::new(0.0, y_pos) + }; + + if let Some(helper_text) = helper_text { + let limits = limits + .width(width) + .pad(padding) + .height(helper_text_size * 1.2); + let text_bounds = limits.resolve(Size::ZERO); + + let helper_text_size = renderer.measure( + helper_text, + helper_text_size, + helper_text_line_height, + renderer.default_font(), + text_bounds, + text::Shaping::Advanced, + ); + + nodes.push(layout::Node::new(helper_text_size).translate(helper_pos)); + }; + + let mut size = nodes.iter().fold(Size::ZERO, |size, node| { + Size::new( + size.width.max(node.bounds().width), + size.height + node.bounds().height, + ) + }); + size.height += (nodes.len() - 1) as f32 * f32::from(spacing); + let limits = limits.width(width).pad(padding).height(size.height); + + layout::Node::with_children(limits.resolve(size), nodes) +} + +/// Processes an [`Event`] and updates the [`State`] of a [`TextInput`] +/// accordingly. +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_lines)] +#[allow(clippy::missing_panics_doc)] +#[allow(clippy::cast_lossless)] +#[allow(clippy::cast_possible_truncation)] +pub fn update<'a, Message, Renderer>( + event: Event, + text_layout: Layout<'_>, + cursor_position: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + value: &mut Value, + size: Option, + font: Option, + is_secure: bool, + on_input: Option<&dyn Fn(String) -> Message>, + on_paste: Option<&dyn Fn(String) -> Message>, + on_submit: &Option, + state: impl FnOnce() -> &'a mut State, + on_start_dnd_source: Option<&dyn Fn(State) -> Message>, + _dnd_icon: bool, + on_dnd_command_produced: Option<&dyn Fn(DnDCommand) -> Message>, + surface_ids: Option<(window::Id, window::Id)>, + line_height: text::LineHeight, +) -> 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 = cursor_position.is_over(text_layout.bounds()) && on_input.is_some(); + + state.is_focused = if is_clicked { + state.is_focused.or_else(|| { + let now = Instant::now(); + Some(Focus { + updated_at: now, + now, + }) + }) + } else { + None + }; + + let font: ::Font = + font.unwrap_or_else(|| renderer.default_font()); + if is_clicked { + let Some(pos) = cursor_position.position() else { + return event::Status::Ignored; + }; + let target = pos.x - text_layout.bounds().x; + + let click = mouse::Click::new(pos, state.last_click); + + match ( + &state.dragging_state, + click.kind(), + state.cursor().state(value), + ) { + #[cfg(feature = "wayland")] + (None, click::Kind::Single, cursor::State::Selection { start, end }) => { + // if something is already selected, we can start a drag and drop for a + // single click that is on top of the selected text + // is the click on selected text? + if is_secure { + return event::Status::Ignored; + } + + if let ( + Some(on_start_dnd), + Some(on_dnd_command_produced), + Some((window_id, icon_id)), + Some(on_input), + ) = ( + on_start_dnd_source, + on_dnd_command_produced, + surface_ids, + on_input, + ) { + let actual_size = size.unwrap_or_else(|| renderer.default_size()); + + let left = start.min(end); + let right = end.max(start); + + let (left_position, _left_offset) = measure_cursor_and_scroll_offset( + renderer, + text_layout.bounds(), + value, + actual_size, + left, + font, + ); + + let (right_position, _right_offset) = measure_cursor_and_scroll_offset( + renderer, + text_layout.bounds(), + value, + actual_size, + right, + font, + ); + + let width = right_position - left_position; + let selection_bounds = Rectangle { + x: text_layout.bounds().x + left_position, + y: text_layout.bounds().y, + width, + height: text_layout.bounds().height, + }; + + if cursor_position.is_over(selection_bounds) { + let text = + state.selected_text(&value.to_string()).unwrap_or_default(); + state.dragging_state = + Some(DraggingState::Dnd(DndAction::empty(), text.clone())); + let mut editor = Editor::new(value, &mut state.cursor); + editor.delete(); + + let message = (on_input)(editor.contents()); + shell.publish(message); + shell.publish(on_start_dnd(state.clone())); + let state = state.clone(); + shell.publish(on_dnd_command_produced(Box::new(move || { + platform_specific::wayland::data_device::ActionInner::StartDnd { + mime_types: SUPPORTED_MIME_TYPES + .iter() + .map(std::string::ToString::to_string) + .collect(), + actions: DndAction::Move, + origin_id: window_id, + icon_id: Some(DndIcon::Widget( + icon_id, + Box::new(state.clone()), + )), + data: Box::new(TextInputString(text.clone())), + } + }))); + } else { + // existing logic for setting the selection + 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, + line_height, + ) + } else { + None + }; + + state.cursor.move_to(position.unwrap_or(0)); + state.dragging_state = Some(DraggingState::Selection); + } + } else { + state.dragging_state = None; + } + } + (None, click::Kind::Single, _) => { + // existing logic for setting the selection + 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, + line_height, + ) + } else { + None + }; + + state.cursor.move_to(position.unwrap_or(0)); + state.dragging_state = Some(DraggingState::Selection); + } + (None | Some(DraggingState::Selection), 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, + line_height, + ) + .unwrap_or(0); + + state.cursor.select_range( + value.previous_start_of_word(position), + value.next_end_of_word(position), + ); + } + state.dragging_state = Some(DraggingState::Selection); + } + (None | Some(DraggingState::Selection), click::Kind::Triple, _) => { + state.cursor.select_all(value); + state.dragging_state = Some(DraggingState::Selection); + } + _ => { + state.dragging_state = None; + } + } + + state.last_click = Some(click); + + return event::Status::Captured; + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) => { + let state = state(); + state.dragging_state = None; + } + Event::Mouse(mouse::Event::CursorMoved { position }) + | Event::Touch(touch::Event::FingerMoved { position, .. }) => { + let state = state(); + + if matches!(state.dragging_state, Some(DraggingState::Selection)) { + let target = position.x - text_layout.bounds().x; + + let value: Value = if is_secure { + value.secure() + } else { + value.clone() + }; + let font: ::Font = + font.unwrap_or_else(|| renderer.default_font()); + + let position = find_cursor_position( + renderer, + text_layout.bounds(), + font, + size, + &value, + state, + target, + line_height, + ) + .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 { + let Some(on_input) = on_input else { return event::Status::Ignored }; + + 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_input)(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 Some(on_input) = on_input else { return event::Status::Ignored }; + + 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_input)(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_input)(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_input)(editor.contents()); + shell.publish(message); + } + keyboard::KeyCode::V => { + if state.keyboard_modifiers.command() { + let content = if let Some(content) = state.is_pasting.take() { + content + } else { + 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_input)(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.dragging_state = None; + 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; + } + } + 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(u64::try_from(millis_until_redraw).unwrap()), + )); + } + } + #[cfg(feature = "wayland")] + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DataSource( + wayland::DataSourceEvent::DndFinished | wayland::DataSourceEvent::Cancelled, + ))) => { + let state = state(); + if matches!(state.dragging_state, Some(DraggingState::Dnd(..))) { + state.dragging_state = None; + return event::Status::Captured; + } + } + #[cfg(feature = "wayland")] + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DataSource( + wayland::DataSourceEvent::Cancelled + | wayland::DataSourceEvent::DndFinished + | wayland::DataSourceEvent::Cancelled, + ))) => { + let state = state(); + if matches!(state.dragging_state, Some(DraggingState::Dnd(..))) { + state.dragging_state = None; + return event::Status::Captured; + } + } + #[cfg(feature = "wayland")] + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DataSource( + wayland::DataSourceEvent::DndActionAccepted(action), + ))) => { + let state = state(); + if let Some(DraggingState::Dnd(_, text)) = state.dragging_state.as_ref() { + state.dragging_state = Some(DraggingState::Dnd(action, text.clone())); + return event::Status::Captured; + } + } + #[cfg(feature = "wayland")] + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( + wayland::DndOfferEvent::Enter { x, y, mime_types }, + ))) => { + let Some(on_dnd_command_produced) = on_dnd_command_produced else { + return event::Status::Ignored + }; + + let state = state(); + let is_clicked = text_layout.bounds().contains(Point { + x: x as f32, + y: y as f32, + }); + + if !is_clicked { + state.dnd_offer = DndOfferState::OutsideWidget(mime_types, DndAction::None); + return event::Status::Captured; + } + let mut accepted = false; + for m in &mime_types { + if SUPPORTED_MIME_TYPES.contains(&m.as_str()) { + let clone = m.clone(); + accepted = true; + shell.publish(on_dnd_command_produced(Box::new(move || { + platform_specific::wayland::data_device::ActionInner::Accept(Some( + clone.clone(), + )) + }))); + } + } + if accepted { + shell.publish(on_dnd_command_produced(Box::new(move || { + platform_specific::wayland::data_device::ActionInner::SetActions { + preferred: DndAction::Move, + accepted: DndAction::Move.union(DndAction::Copy), + } + }))); + let target = x as f32 - text_layout.bounds().x; + state.dnd_offer = DndOfferState::HandlingOffer(mime_types.clone(), DndAction::None); + // existing logic for setting the selection + let position = if target > 0.0 { + let value = if is_secure { + value.secure() + } else { + value.clone() + }; + + let font = font.unwrap_or_else(|| renderer.default_font()); + + find_cursor_position( + renderer, + text_layout.bounds(), + font, + size, + &value, + state, + target, + line_height, + ) + } else { + None + }; + + state.cursor.move_to(position.unwrap_or(0)); + return event::Status::Captured; + } + } + #[cfg(feature = "wayland")] + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( + wayland::DndOfferEvent::Motion { x, y }, + ))) => { + let Some(on_dnd_command_produced) = on_dnd_command_produced else { + return event::Status::Ignored; + }; + + let state = state(); + let is_clicked = text_layout.bounds().contains(Point { + x: x as f32, + y: y as f32, + }); + + if !is_clicked { + if let DndOfferState::HandlingOffer(mime_types, action) = state.dnd_offer.clone() { + state.dnd_offer = DndOfferState::OutsideWidget(mime_types, action); + shell.publish(on_dnd_command_produced(Box::new(move || { + platform_specific::wayland::data_device::ActionInner::SetActions { + preferred: DndAction::None, + accepted: DndAction::None, + } + }))); + shell.publish(on_dnd_command_produced(Box::new(move || { + platform_specific::wayland::data_device::ActionInner::Accept(None) + }))); + } + return event::Status::Captured; + } else if let DndOfferState::OutsideWidget(mime_types, action) = state.dnd_offer.clone() + { + let mut accepted = false; + for m in &mime_types { + if SUPPORTED_MIME_TYPES.contains(&m.as_str()) { + accepted = true; + let clone = m.clone(); + shell.publish(on_dnd_command_produced(Box::new(move || { + platform_specific::wayland::data_device::ActionInner::Accept(Some( + clone.clone(), + )) + }))); + } + } + if accepted { + shell.publish(on_dnd_command_produced(Box::new(move || { + platform_specific::wayland::data_device::ActionInner::SetActions { + preferred: DndAction::Move, + accepted: DndAction::Move.union(DndAction::Copy), + } + }))); + state.dnd_offer = DndOfferState::HandlingOffer(mime_types.clone(), action); + } + }; + let target = x as f32 - text_layout.bounds().x; + // existing logic for setting the selection + let position = if target > 0.0 { + let value = if is_secure { + value.secure() + } else { + value.clone() + }; + let font = font.unwrap_or_else(|| renderer.default_font()); + + find_cursor_position( + renderer, + text_layout.bounds(), + font, + size, + &value, + state, + target, + line_height, + ) + } else { + None + }; + + state.cursor.move_to(position.unwrap_or(0)); + return event::Status::Captured; + } + #[cfg(feature = "wayland")] + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( + wayland::DndOfferEvent::DropPerformed, + ))) => { + let Some(on_dnd_command_produced) = on_dnd_command_produced else { + return event::Status::Ignored; + }; + + let state = state(); + if let DndOfferState::HandlingOffer(mime_types, _action) = state.dnd_offer.clone() { + let Some(mime_type) = SUPPORTED_MIME_TYPES + .iter() + .find(|m| mime_types.contains(&(**m).to_string())) + else { + state.dnd_offer = DndOfferState::None; + return event::Status::Captured; + }; + state.dnd_offer = DndOfferState::Dropped; + shell.publish(on_dnd_command_produced(Box::new(move || { + platform_specific::wayland::data_device::ActionInner::RequestDndData( + (*mime_type).to_string(), + ) + }))); + } else if let DndOfferState::OutsideWidget(..) = &state.dnd_offer { + state.dnd_offer = DndOfferState::None; + return event::Status::Captured; + } + return event::Status::Ignored; + } + #[cfg(feature = "wayland")] + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( + wayland::DndOfferEvent::Leave, + ))) => { + let state = state(); + // ASHLEY TODO we should be able to reset but for now we don't if we are handling a + // drop + match state.dnd_offer { + DndOfferState::Dropped => {} + _ => { + state.dnd_offer = DndOfferState::None; + } + }; + return event::Status::Captured; + } + #[cfg(feature = "wayland")] + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( + wayland::DndOfferEvent::DndData { mime_type, data }, + ))) => { + let Some(on_dnd_command_produced) = on_dnd_command_produced else { + return event::Status::Ignored; + }; + + let state = state(); + if let DndOfferState::Dropped = state.dnd_offer.clone() { + state.dnd_offer = DndOfferState::None; + if !SUPPORTED_MIME_TYPES.contains(&mime_type.as_str()) || data.is_empty() { + return event::Status::Captured; + } + let Ok(content) = String::from_utf8(data) else { + return event::Status::Captured; + }; + + let mut editor = Editor::new(value, &mut state.cursor); + + editor.paste(Value::new(content.as_str())); + if let Some(on_paste) = on_paste.as_ref() { + let message = (on_paste)(editor.contents()); + shell.publish(message); + } + if let Some(on_paste) = on_paste { + let message = (on_paste)(editor.contents()); + shell.publish(message); + } + + shell.publish(on_dnd_command_produced(Box::new(move || { + platform_specific::wayland::data_device::ActionInner::DndFinished + }))); + return event::Status::Captured; + } + return event::Status::Ignored; + } + #[cfg(feature = "wayland")] + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( + wayland::DndOfferEvent::SourceActions(actions), + ))) => { + let Some(on_dnd_command_produced) = on_dnd_command_produced else { + return event::Status::Ignored; + }; + + let state = state(); + if let DndOfferState::HandlingOffer(..) = state.dnd_offer.clone() { + shell.publish(on_dnd_command_produced(Box::new(move || { + platform_specific::wayland::data_device::ActionInner::SetActions { + preferred: actions.intersection(DndAction::Move), + accepted: actions, + } + }))); + return event::Status::Captured; + } + return event::Status::Ignored; + } + _ => {} + } + + event::Status::Ignored +} + +/// Draws the [`TextInput`] with the given [`Renderer`], overriding its +/// [`Value`] if provided. +/// +/// [`Renderer`]: text::Renderer +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_lines)] +#[allow(clippy::missing_panics_doc)] +pub fn draw<'a, Message>( + renderer: &mut crate::Renderer, + theme: &crate::Theme, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + tree: &Tree, + value: &Value, + placeholder: &str, + size: Option, + font: Option<::Font>, + is_disabled: bool, + is_secure: bool, + icon: Option<&Element<'a, Message, crate::Renderer>>, + end_element: Option<&Element<'a, Message, crate::Renderer>>, + style: &::Style, + dnd_icon: bool, + line_height: text::LineHeight, + error: Option<&str>, + label: Option<&str>, + helper_text: Option<&str>, + helper_text_size: f32, + helper_line_height: text::LineHeight, + viewport: &Rectangle, + renderer_style: &renderer::Style, +) { + // all children should be icon images + let children = &tree.children; + + let state = tree.state.downcast_ref::(); + let secure_value = is_secure.then(|| value.secure()); + let value = secure_value.as_ref().unwrap_or(value); + + let mut children_layout = layout.children(); + + let (label_layout, layout, helper_text_layout) = if label.is_some() && helper_text.is_some() { + let label_layout = children_layout.next(); + let layout = children_layout.next().unwrap(); + let helper_text_layout = children_layout.next(); + (label_layout, layout, helper_text_layout) + } else if label.is_some() { + let label_layout = children_layout.next(); + let layout = children_layout.next().unwrap(); + (label_layout, layout, None) + } else if helper_text.is_some() { + let layout = children_layout.next().unwrap(); + let helper_text_layout = children_layout.next(); + (None, layout, helper_text_layout) + } else { + let layout = children_layout.next().unwrap(); + + (None, layout, None) + }; + + let mut children_layout = layout.children(); + let bounds = layout.bounds(); + let text_bounds = children_layout.next().unwrap().bounds(); + + let is_mouse_over = cursor_position.is_over(bounds); + + let appearance = if is_disabled { + theme.disabled(style) + } else if error.is_some() { + theme.error(style) + } else if state.is_focused() { + theme.focused(style) + } else if is_mouse_over { + theme.hovered(style) + } else { + theme.active(style) + }; + + // draw background and its border + if let Some(border_offset) = appearance.border_offset { + let offset_bounds = Rectangle { + x: bounds.x - border_offset, + y: bounds.y - border_offset, + width: bounds.width + border_offset * 2.0, + height: bounds.height + border_offset * 2.0, + }; + renderer.fill_quad( + renderer::Quad { + bounds, + border_radius: appearance.border_radius, + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + appearance.background, + ); + renderer.fill_quad( + renderer::Quad { + bounds: offset_bounds, + border_radius: appearance.border_radius, + border_width: appearance.border_width, + border_color: appearance.border_color, + }, + Background::Color(Color::TRANSPARENT), + ); + } else { + renderer.fill_quad( + renderer::Quad { + bounds, + border_radius: appearance.border_radius, + border_width: appearance.border_width, + border_color: appearance.border_color, + }, + appearance.background, + ); + } + + // draw the label if it exists + if let (Some(label_layout), Some(label)) = (label_layout, label) { + renderer.fill_text(Text { + content: label, + size: size.unwrap_or_else(|| renderer.default_size()), + font: font.unwrap_or_else(|| renderer.default_font()), + color: appearance.label_color, + bounds: label_layout.bounds(), + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Top, + line_height, + shaping: text::Shaping::Advanced, + }); + } + let mut child_index = 0; + let start_icon_tree = children.get(child_index); + // draw the start icon in the text input + if let (Some(icon), Some(tree)) = (icon, start_icon_tree) { + let icon_layout = children_layout.next().unwrap(); + + icon.as_widget().draw( + tree, + renderer, + theme, + &renderer::Style { + text_color: appearance.text_color, + scale_factor: renderer_style.scale_factor, + }, + icon_layout, + cursor_position, + viewport, + ); + child_index += 1; + } + + 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; + + if is_cursor_visible { + if dnd_icon { + (None, 0.0) + } else { + ( + 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, + }, + appearance.text_color, + )), + offset, + ) + } + } else { + (None, 0.0) + } + } + 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; + + if dnd_icon { + (None, 0.0) + } else { + ( + 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, + }, + appearance.selected_fill, + )), + 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, + text::Shaping::Advanced, + ); + + let color = if text.is_empty() { + theme.placeholder_color(style) + } else { + appearance.text_color + }; + + let render = |renderer: &mut crate::Renderer| { + if let Some((cursor, color)) = cursor { + renderer.fill_quad(cursor, color); + } else { + renderer.with_translation(Vector::ZERO, |_| {}); + } + + renderer.fill_text(Text { + content: if text.is_empty() { placeholder } else { &text }, + color, + font, + bounds: Rectangle { + y: text_bounds.center_y(), + width: f32::INFINITY, + ..text_bounds + }, + size, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Center, + line_height: text::LineHeight::default(), + 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); + }); + } else { + render(renderer); + } + + let end_icon_tree = children.get(child_index); + + // draw the end icon in the text input + if let (Some(icon), Some(tree)) = (end_element, end_icon_tree) { + let icon_layout = children_layout.next().unwrap(); + + icon.as_widget().draw( + tree, + renderer, + theme, + &renderer::Style { + text_color: appearance.text_color, + scale_factor: renderer_style.scale_factor, + }, + icon_layout, + cursor_position, + viewport, + ); + } + + // draw the helper text if it exists + if let (Some(helper_text_layout), Some(helper_text)) = (helper_text_layout, helper_text) { + renderer.fill_text(Text { + content: helper_text, + size: helper_text_size, + font, + color, + bounds: helper_text_layout.bounds(), + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Top, + line_height: helper_line_height, + shaping: text::Shaping::Advanced, + }); + } +} + +/// Computes the current [`mouse::Interaction`] of the [`TextInput`]. +#[must_use] +pub fn mouse_interaction( + layout: Layout<'_>, + cursor_position: mouse::Cursor, + is_disabled: bool, +) -> mouse::Interaction { + if cursor_position.is_over(layout.bounds()) { + if is_disabled { + mouse::Interaction::NotAllowed + } else { + mouse::Interaction::Text + } + } else { + mouse::Interaction::default() + } +} + +/// A string which can be sent to the clipboard or drag-and-dropped. +#[derive(Debug, Clone)] +pub struct TextInputString(String); + +#[cfg(feature = "wayland")] +impl DataFromMimeType for TextInputString { + fn from_mime_type(&self, mime_type: &str) -> Option> { + if SUPPORTED_MIME_TYPES.contains(&mime_type) { + Some(self.0.as_bytes().to_vec()) + } else { + None + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DraggingState { + Selection, + #[cfg(feature = "wayland")] + Dnd(DndAction, String), +} + +#[cfg(feature = "wayland")] +#[derive(Debug, Default, Clone)] +pub(crate) enum DndOfferState { + #[default] + None, + OutsideWidget(Vec, DndAction), + HandlingOffer(Vec, DndAction), + Dropped, +} +#[derive(Debug, Default, Clone)] +#[cfg(not(feature = "wayland"))] +pub(crate) struct DndOfferState; + +/// The state of a [`TextInput`]. +#[derive(Debug, Default, Clone)] +#[must_use] +pub struct State { + is_focused: Option, + dragging_state: Option, + dnd_offer: DndOfferState, + 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() + } + + /// Returns the current value of the selected text in the [`TextInput`]. + #[must_use] + pub fn selected_text(&self, text: &str) -> Option { + let value = Value::new(text); + match self.cursor.state(&value) { + cursor::State::Index(_) => None, + cursor::State::Selection { start, end } => { + let left = start.min(end); + let right = end.max(start); + Some(text[left..right].to_string()) + } + } + } + + #[cfg(feature = "wayland")] + /// Returns the current value of the dragged text in the [`TextInput`]. + #[must_use] + pub fn dragged_text(&self) -> Option { + match self.dragging_state.as_ref() { + Some(DraggingState::Dnd(_, text)) => Some(text.clone()), + _ => None, + } + } + + /// Creates a new [`State`], representing a focused [`TextInput`]. + pub fn focused() -> Self { + Self { + is_focused: None, + dragging_state: None, + dnd_offer: DndOfferState::default(), + is_pasting: None, + last_click: None, + cursor: Cursor::default(), + keyboard_modifiers: keyboard::Modifiers::default(), + } + } + + /// Returns whether the [`TextInput`] is currently focused or not. + #[must_use] + pub fn is_focused(&self) -> bool { + self.is_focused.is_some() + } + + /// Returns the [`Cursor`] of the [`TextInput`]. + #[must_use] + 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 iced_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, text::Shaping::Advanced); + + 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`]. +#[allow(clippy::too_many_arguments)] +fn find_cursor_position( + renderer: &Renderer, + text_bounds: Rectangle, + font: Renderer::Font, + size: Option, + value: &Value, + state: &State, + x: f32, + line_height: text::LineHeight, +) -> Option +where + Renderer: text::Renderer, +{ + 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, + line_height, + font, + Size::INFINITY, + text::Shaping::Advanced, + 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; diff --git a/src/widget/text_input/mod.rs b/src/widget/text_input/mod.rs new file mode 100644 index 00000000..dac71503 --- /dev/null +++ b/src/widget/text_input/mod.rs @@ -0,0 +1,10 @@ +//! A text input widget from iced widgets plus some added details. + +pub mod cursor; +pub mod editor; +mod input; +mod style; +pub mod value; + +pub use input::*; +pub use style::{Appearance as TextInputAppearance, StyleSheet as TextInputStyleSheet}; diff --git a/src/widget/text_input/style.rs b/src/widget/text_input/style.rs new file mode 100644 index 00000000..fe383cd7 --- /dev/null +++ b/src/widget/text_input/style.rs @@ -0,0 +1,263 @@ +//! Change the appearance of a text input. +use iced_core::{Background, BorderRadius, Color}; + +/// The appearance of a text input. +#[derive(Debug, Clone, Copy)] +pub struct Appearance { + /// The [`Background`] of the text input. + pub background: Background, + /// The border radius of the text input. + pub border_radius: BorderRadius, + /// The border offset + pub border_offset: Option, + /// The border width of the text input. + pub border_width: f32, + /// The border [`Color`] of the text input. + pub border_color: Color, + /// The label [`Color`] of the text input. + pub label_color: Color, + /// The text [`Color`] of the text input. + pub selected_text_color: Color, + /// The text [`Color`] of the text input. + pub text_color: Color, + /// The selected fill [`Color`] of the text input. + pub selected_fill: Color, +} + +/// A set of rules that dictate the style of a text input. +pub trait StyleSheet { + /// The supported style of the [`StyleSheet`]. + type Style: Default; + + /// Produces the style of an active text input. + fn active(&self, style: &Self::Style) -> Appearance; + + /// Produces the style of an errored text input. + fn error(&self, style: &Self::Style) -> Appearance; + + /// Produces the style of a focused text input. + fn focused(&self, style: &Self::Style) -> Appearance; + + /// Produces the [`Color`] of the placeholder of a text input. + fn placeholder_color(&self, style: &Self::Style) -> Color; + + /// Produces the style of an hovered text input. + fn hovered(&self, style: &Self::Style) -> Appearance { + self.focused(style) + } + + /// Produces the style of a disabled text input. + fn disabled(&self, style: &Self::Style) -> Appearance; +} + +#[derive(Copy, Clone, Default)] +pub enum TextInput { + #[default] + Default, + ExpandableSearch, + Search, + Inline, +} + +impl StyleSheet for crate::Theme { + type Style = TextInput; + + fn active(&self, style: &Self::Style) -> Appearance { + let palette = self.cosmic(); + let mut bg = palette.palette.neutral_7; + bg.alpha = 0.25; + let corner = palette.corner_radii; + let label_color = palette.palette.neutral_9; + match style { + TextInput::Default => Appearance { + background: Color::from(bg).into(), + border_radius: corner.radius_s.into(), + border_width: 1.0, + border_offset: None, + border_color: self.current_container().component.divider.into(), + text_color: self.current_container().on.into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + TextInput::ExpandableSearch => Appearance { + background: Color::TRANSPARENT.into(), + border_radius: corner.radius_xl.into(), + border_width: 0.0, + border_offset: None, + border_color: Color::TRANSPARENT, + text_color: self.current_container().on.into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + TextInput::Search => Appearance { + background: Color::from(bg).into(), + border_radius: corner.radius_xl.into(), + border_width: 0.0, + border_offset: None, + border_color: Color::TRANSPARENT, + text_color: self.current_container().on.into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + TextInput::Inline => Appearance { + background: Color::TRANSPARENT.into(), + border_radius: corner.radius_0.into(), + border_width: 0.0, + border_offset: None, + border_color: Color::TRANSPARENT, + text_color: self.current_container().on.into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + } + } + + fn error(&self, style: &Self::Style) -> Appearance { + let palette = self.cosmic(); + let mut bg = palette.palette.neutral_7; + bg.alpha = 0.25; + let corner = palette.corner_radii; + let label_color = palette.palette.neutral_9; + + match style { + TextInput::Default => Appearance { + background: Color::from(bg).into(), + border_radius: corner.radius_s.into(), + border_width: 1.0, + border_offset: Some(2.0), + border_color: Color::from(palette.destructive_color()), + text_color: self.current_container().on.into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + TextInput::Search | TextInput::ExpandableSearch => Appearance { + background: Color::from(bg).into(), + border_radius: corner.radius_xl.into(), + border_width: 0.0, + border_offset: None, + border_color: Color::TRANSPARENT, + text_color: self.current_container().on.into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + TextInput::Inline => Appearance { + background: Color::TRANSPARENT.into(), + border_radius: corner.radius_0.into(), + border_width: 0.0, + border_offset: None, + border_color: Color::TRANSPARENT, + text_color: self.current_container().on.into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + } + } + + fn hovered(&self, style: &Self::Style) -> Appearance { + let palette = self.cosmic(); + let mut bg = palette.palette.neutral_7; + bg.alpha = 0.25; + let corner = palette.corner_radii; + let label_color = palette.palette.neutral_9; + + match style { + TextInput::Default => Appearance { + background: Color::from(bg).into(), + border_radius: corner.radius_s.into(), + border_width: 1.0, + border_offset: None, + border_color: palette.accent.base.into(), + text_color: self.current_container().on.into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + TextInput::Search | TextInput::ExpandableSearch => Appearance { + background: Color::from(bg).into(), + border_radius: corner.radius_xl.into(), + border_offset: None, + border_width: 0.0, + border_color: Color::TRANSPARENT, + text_color: self.current_container().on.into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + TextInput::Inline => Appearance { + background: Color::from(self.current_container().component.hover).into(), + border_radius: corner.radius_0.into(), + border_width: 0.0, + border_offset: None, + border_color: Color::TRANSPARENT, + text_color: self.current_container().on.into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + } + } + + fn focused(&self, style: &Self::Style) -> Appearance { + let palette = self.cosmic(); + let mut bg = palette.palette.neutral_7; + bg.alpha = 0.25; + let corner = palette.corner_radii; + let label_color = palette.palette.neutral_9; + + match style { + TextInput::Default => Appearance { + background: Color::from(bg).into(), + border_radius: corner.radius_s.into(), + border_width: 1.0, + border_offset: Some(2.0), + border_color: palette.accent.base.into(), + text_color: self.current_container().on.into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + TextInput::Search | TextInput::ExpandableSearch => Appearance { + background: Color::from(bg).into(), + border_radius: corner.radius_xl.into(), + border_width: 0.0, + border_offset: Some(2.0), + border_color: Color::TRANSPARENT, + text_color: self.current_container().on.into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + TextInput::Inline => Appearance { + background: Color::from(palette.accent.base).into(), + border_radius: corner.radius_0.into(), + border_width: 0.0, + border_offset: None, + border_color: Color::TRANSPARENT, + // TODO use regular text color here after text rendering handles multiple colors + // in this case, for selected and unselected text + text_color: palette.on_accent_color().into(), + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + } + } + + fn placeholder_color(&self, _style: &Self::Style) -> Color { + let palette = self.cosmic(); + let mut neutral_9 = palette.palette.neutral_9; + neutral_9.alpha = 0.7; + neutral_9.into() + } + + fn disabled(&self, style: &Self::Style) -> Appearance { + self.active(style) + } +} diff --git a/src/widget/text_input/value.rs b/src/widget/text_input/value.rs new file mode 100644 index 00000000..3b7f8198 --- /dev/null +++ b/src/widget/text_input/value.rs @@ -0,0 +1,131 @@ +use unicode_segmentation::UnicodeSegmentation; + +/// The value of a [`TextInput`]. +/// +/// [`TextInput`]: crate::widget::TextInput +// TODO: Reduce allocations, cache results (?) +#[derive(Debug, Clone)] +pub struct Value { + graphemes: Vec, +} + +impl Value { + /// Creates a new [`Value`] from a string slice. + pub fn new(string: &str) -> Self { + let graphemes = UnicodeSegmentation::graphemes(string, true) + .map(String::from) + .collect(); + + Self { graphemes } + } + + /// Returns whether the [`Value`] is empty or not. + /// + /// A [`Value`] is empty when it contains no graphemes. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Returns the total amount of graphemes in the [`Value`]. + #[must_use] + pub fn len(&self) -> usize { + self.graphemes.len() + } + + /// Returns the position of the previous start of a word from the given + /// grapheme `index`. + #[must_use] + pub fn previous_start_of_word(&self, index: usize) -> usize { + let previous_string = &self.graphemes[..index.min(self.graphemes.len())].concat(); + + UnicodeSegmentation::split_word_bound_indices(previous_string as &str) + .filter(|(_, word)| !word.trim_start().is_empty()) + .next_back() + .map_or(0, |(i, previous_word)| { + index + - UnicodeSegmentation::graphemes(previous_word, true).count() + - UnicodeSegmentation::graphemes( + &previous_string[i + previous_word.len()..] as &str, + true, + ) + .count() + }) + } + + /// Returns the position of the next end of a word from the given grapheme + /// `index`. + #[must_use] + pub fn next_end_of_word(&self, index: usize) -> usize { + let next_string = &self.graphemes[index..].concat(); + + UnicodeSegmentation::split_word_bound_indices(next_string as &str) + .find(|(_, word)| !word.trim_start().is_empty()) + .map_or(self.len(), |(i, next_word)| { + index + + UnicodeSegmentation::graphemes(next_word, true).count() + + UnicodeSegmentation::graphemes(&next_string[..i] as &str, true).count() + }) + } + + /// Returns a new [`Value`] containing the graphemes from `start` until the + /// given `end`. + #[must_use] + pub fn select(&self, start: usize, end: usize) -> Self { + let graphemes = self.graphemes[start.min(self.len())..end.min(self.len())].to_vec(); + + Self { graphemes } + } + + /// Returns a new [`Value`] containing the graphemes until the given + /// `index`. + #[must_use] + pub fn until(&self, index: usize) -> Self { + let graphemes = self.graphemes[..index.min(self.len())].to_vec(); + + Self { graphemes } + } + + /// 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()); + + self.graphemes = UnicodeSegmentation::graphemes(&self.to_string() as &str, true) + .map(String::from) + .collect(); + } + + /// Inserts a bunch of graphemes at the given grapheme `index`. + pub fn insert_many(&mut self, index: usize, mut value: Value) { + let _ = self + .graphemes + .splice(index..index, value.graphemes.drain(..)); + } + + /// Removes the grapheme at the given `index`. + pub fn remove(&mut self, index: usize) { + let _ = self.graphemes.remove(index); + } + + /// Removes the graphemes from `start` to `end`. + pub fn remove_many(&mut self, start: usize, end: usize) { + let _ = self.graphemes.splice(start..end, std::iter::empty()); + } + + /// Returns a new [`Value`] with all its graphemes replaced with the + /// dot ('•') character. + #[must_use] + pub fn secure(&self) -> Self { + Self { + graphemes: std::iter::repeat(String::from("•")) + .take(self.graphemes.len()) + .collect(), + } + } +} + +impl ToString for Value { + fn to_string(&self) -> String { + self.graphemes.concat() + } +}