From 18b9be0baa2a833ab4142e4bb16e30dda762f0f0 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Tue, 15 Aug 2023 17:16:49 -0400 Subject: [PATCH 1/7] update: iced 0.10.0 --- Cargo.toml | 2 ++ examples/cosmic-sctk/src/window.rs | 37 +++++++++++++++++++++++++++--- src/widget/mod.rs | 4 ++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 28c771d67d8..403702223d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,3 +124,5 @@ exclude = [ [patch."https://github.com/pop-os/libcosmic"] libcosmic = { path = "./", features = ["wayland", "tokio", "a11y"]} +[patch."https://github.com/pop-os/cosmic-time"] +cosmic-time = { path = "../cosmic-time"} diff --git a/examples/cosmic-sctk/src/window.rs b/examples/cosmic-sctk/src/window.rs index 76a857d0801..66315f1b228 100644 --- a/examples/cosmic-sctk/src/window.rs +++ b/examples/cosmic-sctk/src/window.rs @@ -15,7 +15,7 @@ use cosmic::{ widget::{ button, cosmic_container, header_bar, nav_bar, nav_bar_toggle, rectangle_tracker::{rectangle_tracker_subscription, RectangleTracker, RectangleUpdate}, - scrollable, segmented_button, segmented_selection, settings, IconSource, + scrollable, segmented_button, segmented_selection, settings, text_input, IconSource, }, Element, ElementExt, }; @@ -127,6 +127,7 @@ pub struct Window { rectangle_tracker: Option>, pub selection: segmented_button::SingleSelectModel, timeline: Timeline, + input_value: String, } impl Window { @@ -183,12 +184,12 @@ pub enum Message { Drag, Minimize, Maximize, - InputChanged, Rectangle(RectangleUpdate), NavBar(segmented_button::Entity), Ignore, Selection(segmented_button::Entity), Tick(Instant), + InputChanged(String), } impl Window { @@ -305,7 +306,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 +315,9 @@ 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; + } } Command::none() @@ -476,6 +479,34 @@ 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", + text_input("test", &self.input_value) + .label("Test Label") + .helper_text("helper_text") + .width(Length::Fill) + .on_input(Message::InputChanged), + )) + // .add(settings::item( + // "Text Input", + // text_input("test", &self.input_value) + // .helper_text("helper_text") + // .width(Length::Fill) + // .on_input(Message::InputChanged), + // )) + // .add(settings::item( + // "Text Input", + // text_input("test", &self.input_value) + // .helper_text("helper_text") + // .width(Length::Fill) + // .on_input(Message::InputChanged), + // )) .into(), ]) .into(); diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 5e96cdf967c..708808d4287 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 { From d15999e5db1f53ecb7f536dff46318cc66bdee4d Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Tue, 15 Aug 2023 17:17:16 -0400 Subject: [PATCH 2/7] wip: text input --- src/widget/text_input/cursor.rs | 173 +++ src/widget/text_input/editor.rs | 71 + src/widget/text_input/mod.rs | 9 + src/widget/text_input/style.rs | 259 ++++ src/widget/text_input/text_input.rs | 2107 +++++++++++++++++++++++++++ src/widget/text_input/value.rs | 131 ++ 6 files changed, 2750 insertions(+) create mode 100644 src/widget/text_input/cursor.rs create mode 100644 src/widget/text_input/editor.rs create mode 100644 src/widget/text_input/mod.rs create mode 100644 src/widget/text_input/style.rs create mode 100644 src/widget/text_input/text_input.rs create mode 100644 src/widget/text_input/value.rs diff --git a/src/widget/text_input/cursor.rs b/src/widget/text_input/cursor.rs new file mode 100644 index 00000000000..ba59ca04ec2 --- /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 00000000000..3eec052a32b --- /dev/null +++ b/src/widget/text_input/editor.rs @@ -0,0 +1,71 @@ +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) { + match self.cursor.selection(self.value) { + Some((start, end)) => { + self.cursor.move_left(self.value); + self.value.remove_many(start, end); + } + None => { + 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) { + match self.cursor.selection(self.value) { + Some(_) => { + self.backspace(); + } + None => { + let end = self.cursor.end(self.value); + + if end < self.value.len() { + self.value.remove(end); + } + } + } + } +} diff --git a/src/widget/text_input/mod.rs b/src/widget/text_input/mod.rs new file mode 100644 index 00000000000..053560acf66 --- /dev/null +++ b/src/widget/text_input/mod.rs @@ -0,0 +1,9 @@ +//! A text input widget from iced_widgets plus some added details. + +pub mod cursor; +pub mod editor; +pub mod style; +mod text_input; +pub mod value; + +pub use text_input::*; diff --git a/src/widget/text_input/style.rs b/src/widget/text_input/style.rs new file mode 100644 index 00000000000..e7422c82711 --- /dev/null +++ b/src/widget/text_input/style.rs @@ -0,0 +1,259 @@ +//! 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 icon [`Color`] of the text input. + pub icon_color: Color, + /// The label [`Color`] of the text input. + pub label_color: 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 [`Color`] of the value of a text input. + fn value_color(&self, style: &Self::Style) -> Color; + + /// Produces the [`Color`] of the value of a disabled text input. + fn disabled_color(&self, style: &Self::Style) -> Color; + + /// Produces the [`Color`] of the selection of a text input. + fn selection_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(), + icon_color: self.current_container().on.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, + icon_color: self.current_container().on.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, + icon_color: self.current_container().on.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, + icon_color: self.current_container().on.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()), + icon_color: self.current_container().on.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, + icon_color: self.current_container().on.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, + icon_color: self.current_container().on.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(), + icon_color: self.current_container().on.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, + icon_color: self.current_container().on.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, + icon_color: self.current_container().on.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(), + icon_color: self.current_container().on.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, + icon_color: self.current_container().on.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, + icon_color: palette.accent.on.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 value_color(&self, _style: &Self::Style) -> Color { + let palette = self.cosmic(); + + palette.palette.neutral_9.into() + } + + fn selection_color(&self, _style: &Self::Style) -> Color { + let palette = self.cosmic(); + + palette.accent.base.into() + } + + fn disabled_color(&self, _style: &Self::Style) -> Color { + let palette = self.cosmic(); + let mut neutral_9 = palette.palette.neutral_9; + neutral_9.alpha = 0.5; + neutral_9.into() + } + + fn disabled(&self, style: &Self::Style) -> Appearance { + self.active(style) + } +} diff --git a/src/widget/text_input/text_input.rs b/src/widget/text_input/text_input.rs new file mode 100644 index 00000000000..86a061218d2 --- /dev/null +++ b/src/widget/text_input/text_input.rs @@ -0,0 +1,2107 @@ +//! 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_core::{alignment, Background}; +use iced_core::event::{self, Event}; +use iced_core::keyboard; +use iced_core::layout; +use iced_core::mouse::{self, click}; +use iced_core::renderer; +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::{ + Clipboard, Color, Element, Layout, Length, Padding, Pixels, Point, Rectangle, Shell, Size, + Vector, Widget, +}; +use iced_renderer::core::event::{wayland, PlatformSpecific}; +use iced_renderer::core::widget::OperationOutputWrapper; +use iced_runtime::Command; + +use iced_runtime::command::platform_specific; +use iced_runtime::command::platform_specific::wayland::data_device::{DataFromMimeType, DndIcon}; +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 expandable search [`TextInput`]. +/// +/// [`TextInput`]: widget::TextInput +pub fn expandable_search_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, +) -> TextInput<'a, Message> +where + Message: Clone +{ + TextInput::new(placeholder, value) +} + +/// Creates a new inline [`TextInput`]. +/// +/// [`TextInput`]: widget::TextInput +pub fn inline_input<'a, Message>( + placeholder: &str, + value: &str, +) -> TextInput<'a, Message> +where + Message: Clone +{ + TextInput::new(placeholder, value) +} + +const SUPPORTED_MIME_TYPES: &[&str; 6] = &[ + "text/plain;charset=utf-8", + "text/plain;charset=UTF-8", + "UTF8_STRING", + "STRING", + "text/plain", + "TEXT", +]; +pub type DnDCommand = + Box platform_specific::wayland::data_device::ActionInner>; + +/// 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, + icon: Option::Font>>, + 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 { + 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, + helper_size: 10.0, + helper_line_height: text::LineHeight::from(14.0), + on_input: None, + on_paste: None, + on_submit: None, + icon: 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 [`Icon`] of the [`TextInput`]. + pub fn icon( + mut self, + icon: Icon<::Font>, + ) -> Self { + self.icon = 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 + pub fn draw( + &self, + tree: &Tree, + renderer: &mut crate::Renderer, + theme: &::Theme, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + 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.on_input.is_none(), + self.is_secure, + self.icon.as_ref(), + &self.style, + self.dnd_icon, + self.line_height, + self.error, + self.label, + self.helper_text, + self.helper_size, + self.helper_line_height + ); + } + + /// Sets the start dnd handler of the [`TextInput`]. + 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. + 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; + } + } + + 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.icon.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 { + update( + event, + layout, + 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.state.downcast_ref::(), + &self.value, + &self.placeholder, + self.size, + self.font, + self.on_input.is_none(), + self.is_secure, + self.icon.as_ref(), + &self.style, + self.dnd_icon, + self.line_height, + self.error, + self.label, + self.helper_text, + self.helper_size, + self.helper_line_height, + ); + } + + 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); + 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) + } +} + +/// The content of the [`Icon`]. +#[derive(Debug, Clone)] +pub struct Icon { + /// The font that will be used to display the `code_point`. + pub font: Font, + /// The unicode code point that will be used as the icon. + pub code_point: char, + /// The font size of the content. + pub size: Option, + /// The spacing between the [`Icon`] and the text in a [`TextInput`]. + pub spacing: f32, + /// The side of a [`TextInput`] where to display the [`Icon`]. + pub side: Side, +} + +/// The side of a [`TextInput`]. +#[derive(Debug, Clone)] +pub enum Side { + /// The left side of a [`TextInput`]. + Left, + /// The right side of a [`TextInput`]. + Right, +} + +/// 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)] +pub fn layout( + renderer: &Renderer, + limits: &layout::Limits, + width: Length, + padding: Padding, + size: Option, + icon: Option<&Icon>, + line_height: text::LineHeight, + label: Option<&str>, + helper_text: Option<&str>, + helper_text_size: f32, + helper_text_line_height: text::LineHeight, +) -> layout::Node +where + Renderer: text::Renderer, +{ + 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 let Some(icon) = icon { + let limits = limits.width(width).pad(padding).height(text_size * 1.2); + let text_bounds = limits.resolve(Size::ZERO); + + let icon_width = renderer.measure_width( + &icon.code_point.to_string(), + icon.size.unwrap_or_else(|| renderer.default_size()), + icon.font, + text::Shaping::Advanced, + ); + + let mut text_node = + layout::Node::new(text_bounds - Size::new(icon_width + icon.spacing, 0.0)); + + let mut icon_node = layout::Node::new(Size::new(icon_width, text_bounds.height)); + + match icon.side { + Side::Left => { + text_node.move_to(Point::new( + padding.left + icon_width + icon.spacing, + padding.top, + )); + + icon_node.move_to(Point::new(padding.left, padding.top)); + } + Side::Right => { + text_node.move_to(Point::new(padding.left, padding.top)); + + icon_node.move_to(Point::new( + padding.left + text_bounds.width - icon_width, + padding.top, + )); + } + }; + + let node = layout::Node::with_children(text_bounds.pad(padding), vec![text_node, icon_node]).translate(text_pos); + let y_pos = node.bounds().y + node.bounds().height + f32::from(spacing); + nodes.push(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, + 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(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 text_layout = layout.children().next().unwrap(); + 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), + ) { + (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 text_bounds = layout.children().next().unwrap().bounds(); + 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_bounds, + value, + actual_size, + left, + font, + ); + + let (right_position, _right_offset) = measure_cursor_and_scroll_offset( + renderer, + text_bounds, + value, + actual_size, + right, + font, + ); + + let width = right_position - left_position; + let selection_bounds = Rectangle { + x: text_bounds.x + left_position, + y: text_bounds.y, + width, + height: text_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 text_layout = layout.children().next().unwrap(); + 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()), + )); + } + } + 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; + } + } + 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; + } + } + 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; + } + } + // TODO: handle dnd offer events + 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 bounds = layout.bounds(); + let is_clicked = 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 text_layout = layout.children().next().unwrap(); + 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; + } + } + 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 bounds = layout.bounds(); + let is_clicked = 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 text_layout = layout.children().next().unwrap(); + 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; + } + 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; + } + 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; + } + 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; + } + 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( + renderer: &mut Renderer, + theme: &Renderer::Theme, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + state: &State, + value: &Value, + placeholder: &str, + size: Option, + font: Option, + is_disabled: bool, + is_secure: bool, + icon: Option<&Icon>, + 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, +) 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 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, + }); + } + + // draw the start icon in the text input + if let Some(icon) = icon { + let icon_layout = children_layout.next().unwrap(); + + renderer.fill_text(Text { + content: &icon.code_point.to_string(), + size: icon.size.unwrap_or_else(|| renderer.default_size()), + font: icon.font, + color: appearance.icon_color, + bounds: icon_layout.bounds(), + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Top, + line_height: text::LineHeight::default(), + shaping: text::Shaping::Advanced, + }); + } + + 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, + }, + theme.value_color(style), + )), + 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, + }, + 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, + text::Shaping::Advanced, + ); + + let color = if text.is_empty() { + theme.placeholder_color(style) + } else if is_disabled { + theme.disabled_color(style) + } else { + theme.value_color(style) + }; + + 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 { + 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); + } + + // 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); + +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, + Dnd(DndAction, String), +} + +#[derive(Debug, Default, Clone)] +pub(crate) enum DndOfferState { + #[default] + None, + OutsideWidget(Vec, DndAction), + HandlingOffer(Vec, DndAction), + Dropped, +} + +/// 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()) + } + } + } + + /// 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::None, + 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/value.rs b/src/widget/text_input/value.rs new file mode 100644 index 00000000000..3b7f8198efa --- /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() + } +} From 80fe195a2ace0140fe5a2b11233fb5a78046e8c4 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Thu, 17 Aug 2023 01:40:52 -0400 Subject: [PATCH 3/7] wip: text inputs with icons and buttons --- examples/cosmic-sctk/src/window.rs | 37 +-- src/widget/text_input/text_input.rs | 373 +++++++++++++++++----------- 2 files changed, 250 insertions(+), 160 deletions(-) diff --git a/examples/cosmic-sctk/src/window.rs b/examples/cosmic-sctk/src/window.rs index 66315f1b228..2b33d2a8e6f 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, text_input, IconSource, + scrollable, search_input, segmented_button, segmented_selection, settings, text_input, + IconSource, }, Element, ElementExt, }; @@ -488,25 +489,29 @@ impl Application for Window { .add(settings::item( "Text Input", text_input("test", &self.input_value) + .start_icon(icon("document-properties-symbolic", 16).into()) + .end_icon(icon("document-properties-symbolic", 16).into()) .label("Test Label") .helper_text("helper_text") .width(Length::Fill) .on_input(Message::InputChanged), )) - // .add(settings::item( - // "Text Input", - // text_input("test", &self.input_value) - // .helper_text("helper_text") - // .width(Length::Fill) - // .on_input(Message::InputChanged), - // )) - // .add(settings::item( - // "Text Input", - // text_input("test", &self.input_value) - // .helper_text("helper_text") - // .width(Length::Fill) - // .on_input(Message::InputChanged), - // )) + .add(settings::item( + "Text Input", + search_input( + "search for stuff", + &self.input_value, + 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/src/widget/text_input/text_input.rs b/src/widget/text_input/text_input.rs index 86a061218d2..5bda6237767 100644 --- a/src/widget/text_input/text_input.rs +++ b/src/widget/text_input/text_input.rs @@ -9,12 +9,13 @@ use super::editor::Editor; use super::style::StyleSheet; pub use super::value::Value; -use iced_core::{alignment, Background}; +use apply::Also; +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; +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; @@ -22,6 +23,7 @@ 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, @@ -37,12 +39,9 @@ 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> +pub fn text_input<'a, Message>(placeholder: &str, value: &str) -> TextInput<'a, Message> where - Message: Clone + Message: Clone, { TextInput::new(placeholder, value) } @@ -55,36 +54,47 @@ pub fn expandable_search_input<'a, Message>( value: &str, ) -> TextInput<'a, Message> where - Message: Clone + Message: Clone, { - TextInput::new(placeholder, value) + TextInput::new(placeholder, value).style(super::style::TextInput::Default) } - /// Creates a new search [`TextInput`]. /// /// [`TextInput`]: widget::TextInput pub fn search_input<'a, Message>( placeholder: &str, value: &str, + on_clear: Message, ) -> TextInput<'a, Message> where - Message: Clone + Message: Clone + 'static, { + let spacing = THEME.with(|t| t.borrow().cosmic().space_xxs()); TextInput::new(placeholder, value) + .style(super::style::TextInput::Search) + .start_icon( + iced_widget::container(crate::widget::icon("system-search-symbolic", 16)) + .padding([spacing, spacing, spacing, 2 * spacing]) + .into(), + ) + .end_icon( + crate::widget::button::button(crate::theme::Button::Text) + .icon(crate::theme::Svg::Symbolic, "edit-clear-symbolic", 16) + .on_press(on_clear) + .padding([spacing, 2 * spacing, spacing, spacing]) + .into(), + ) } /// Creates a new inline [`TextInput`]. /// /// [`TextInput`]: widget::TextInput -pub fn inline_input<'a, Message>( - placeholder: &str, - value: &str, -) -> TextInput<'a, Message> +pub fn inline_input<'a, Message>(value: &str) -> TextInput<'a, Message> where - Message: Clone + Message: Clone, { - TextInput::new(placeholder, value) + TextInput::new("", value).style(super::style::TextInput::Inline) } const SUPPORTED_MIME_TYPES: &[&str; 6] = &[ @@ -138,7 +148,8 @@ pub struct TextInput<'a, Message> { on_input: Option Message + 'a>>, on_paste: Option Message + 'a>>, on_submit: Option, - icon: Option::Font>>, + 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>>, @@ -173,7 +184,8 @@ where on_input: None, on_paste: None, on_submit: None, - icon: None, + start_icon: None, + end_element: None, error: None, style: super::style::TextInput::default(), on_dnd_command_produced: None, @@ -256,12 +268,15 @@ where self } - /// Sets the [`Icon`] of the [`TextInput`]. - pub fn icon( - mut self, - icon: Icon<::Font>, - ) -> Self { - self.icon = Some(icon); + /// 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 } @@ -310,14 +325,15 @@ where theme, layout, cursor_position, - tree.state.downcast_ref::(), + tree, value.unwrap_or(&self.value), &self.placeholder, self.size, self.font, self.on_input.is_none(), self.is_secure, - self.icon.as_ref(), + self.start_icon.as_ref(), + self.end_element.as_ref(), &self.style, self.dnd_icon, self.line_height, @@ -325,7 +341,8 @@ where self.label, self.helper_text, self.helper_size, - self.helper_line_height + self.helper_line_height, + &layout.bounds(), ); } @@ -398,6 +415,21 @@ where 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 { @@ -435,12 +467,13 @@ where self.width, self.padding, self.size, - self.icon.as_ref(), + 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 + self.helper_line_height, ) } } @@ -467,11 +500,48 @@ where renderer: &crate::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, - _viewport: &Rectangle, + viewport: &Rectangle, ) -> event::Status { + if let (Some(start_icon), Some(tree)) = (self.start_icon.as_mut(), tree.children.get_mut(0)) + { + if matches!( + start_icon.as_widget_mut().on_event( + tree, + event.clone(), + layout, + cursor_position, + renderer, + clipboard, + shell, + viewport + ), + event::Status::Captured + ) { + return event::Status::Captured; + } + } + if let (Some(end_icon), Some(tree)) = (self.end_element.as_mut(), tree.children.get_mut(2)) + { + if matches!( + end_icon.as_widget_mut().on_event( + tree, + event.clone(), + layout, + cursor_position, + renderer, + clipboard, + shell, + viewport + ), + event::Status::Captured + ) { + return event::Status::Captured; + } + } + update( event, - layout, + self.text_layout(layout), cursor_position, renderer, clipboard, @@ -500,21 +570,22 @@ where _style: &renderer::Style, layout: Layout<'_>, cursor_position: mouse::Cursor, - _viewport: &Rectangle, + viewport: &Rectangle, ) { draw( renderer, theme, layout, cursor_position, - tree.state.downcast_ref::(), + tree, &self.value, &self.placeholder, self.size, self.font, self.on_input.is_none(), self.is_secure, - self.icon.as_ref(), + self.start_icon.as_ref(), + self.end_element.as_ref(), &self.style, self.dnd_icon, self.line_height, @@ -523,6 +594,7 @@ where self.helper_text, self.helper_size, self.helper_line_height, + viewport, ); } @@ -548,30 +620,6 @@ where } } -/// The content of the [`Icon`]. -#[derive(Debug, Clone)] -pub struct Icon { - /// The font that will be used to display the `code_point`. - pub font: Font, - /// The unicode code point that will be used as the icon. - pub code_point: char, - /// The font size of the content. - pub size: Option, - /// The spacing between the [`Icon`] and the text in a [`TextInput`]. - pub spacing: f32, - /// The side of a [`TextInput`] where to display the [`Icon`]. - pub side: Side, -} - -/// The side of a [`TextInput`]. -#[derive(Debug, Clone)] -pub enum Side { - /// The left side of a [`TextInput`]. - Left, - /// The right side of a [`TextInput`]. - Right, -} - /// Produces a [`Command`] that focuses the [`TextInput`] with the given [`Id`]. pub fn focus(id: Id) -> Command { Command::widget(operation::focusable::focus(id)) @@ -603,22 +651,21 @@ pub fn select_all(id: Id) -> Command { /// Computes the layout of a [`TextInput`]. #[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_arguments)] -pub fn layout( - renderer: &Renderer, +#[allow(clippy::too_many_lines)] +pub fn layout( + renderer: &crate::Renderer, limits: &layout::Limits, width: Length, padding: Padding, size: Option, - icon: Option<&Icon>, + 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 -where - Renderer: text::Renderer, -{ +) -> layout::Node { let spacing = THEME.with(|t| t.borrow().cosmic().space_xxs()); let mut nodes = Vec::with_capacity(3); @@ -644,42 +691,62 @@ where let text_size = size.unwrap_or_else(|| renderer.default_size()); let padding = padding.fit(Size::ZERO, limits.max()); - let helper_pos = if let Some(icon) = icon { - let limits = limits.width(width).pad(padding).height(text_size * 1.2); + 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 limits = limits.width(width).pad(padding).height(height); + let text_bounds = limits.resolve(Size::ZERO); - - let icon_width = renderer.measure_width( - &icon.code_point.to_string(), - icon.size.unwrap_or_else(|| renderer.default_size()), - icon.font, - text::Shaping::Advanced, - ); let mut text_node = - layout::Node::new(text_bounds - Size::new(icon_width + icon.spacing, 0.0)); + layout::Node::new(text_bounds - Size::new(start_icon_width + end_icon_width, 0.0)); - let mut icon_node = layout::Node::new(Size::new(icon_width, text_bounds.height)); + text_node.move_to(Point::new(padding.left + start_icon_width, padding.top)); + let mut node_list: Vec<_> = Vec::with_capacity(3); - match icon.side { - Side::Left => { - text_node.move_to(Point::new( - padding.left + icon_width + icon.spacing, - padding.top, - )); - - icon_node.move_to(Point::new(padding.left, padding.top)); - } - Side::Right => { - text_node.move_to(Point::new(padding.left, padding.top)); + let text_node_bounds = text_node.bounds(); + node_list.push(text_node); - icon_node.move_to(Point::new( - padding.left + text_bounds.width - icon_width, - padding.top, - )); - } - }; + if let Some(mut start_icon) = start_icon.take() { + start_icon.move_to(Point::new(padding.left, padding.top)); + 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 + icon_spacing, + padding.top, + )); + node_list.push(end_icon); + } - let node = layout::Node::with_children(text_bounds.pad(padding), vec![text_node, icon_node]).translate(text_pos); + let node = + layout::Node::with_children(text_bounds.pad(padding), node_list).translate(text_pos); let y_pos = node.bounds().y + node.bounds().height + f32::from(spacing); nodes.push(node); @@ -687,11 +754,12 @@ where } 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 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); @@ -699,7 +767,10 @@ where }; if let Some(helper_text) = helper_text { - let limits = limits.width(width).pad(padding).height(helper_text_size * 1.2); + 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( @@ -715,7 +786,10 @@ where }; let mut size = nodes.iter().fold(Size::ZERO, |size, node| { - Size::new(size.width.max(node.bounds().width), size.height + node.bounds().height) + 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); @@ -732,7 +806,7 @@ where #[allow(clippy::cast_possible_truncation)] pub fn update<'a, Message, Renderer>( event: Event, - layout: Layout<'_>, + text_layout: Layout<'_>, cursor_position: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, @@ -759,7 +833,7 @@ where Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { let state = state(); - let is_clicked = cursor_position.is_over(layout.bounds()) && on_input.is_some(); + 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(|| { @@ -779,7 +853,6 @@ where let Some(pos) = cursor_position.position() else { return event::Status::Ignored; }; - let text_layout = layout.children().next().unwrap(); let target = pos.x - text_layout.bounds().x; let click = mouse::Click::new(pos, state.last_click); @@ -807,7 +880,6 @@ where surface_ids, on_input, ) { - let text_bounds = layout.children().next().unwrap().bounds(); let actual_size = size.unwrap_or_else(|| renderer.default_size()); let left = start.min(end); @@ -815,7 +887,7 @@ where let (left_position, _left_offset) = measure_cursor_and_scroll_offset( renderer, - text_bounds, + text_layout.bounds(), value, actual_size, left, @@ -824,7 +896,7 @@ where let (right_position, _right_offset) = measure_cursor_and_scroll_offset( renderer, - text_bounds, + text_layout.bounds(), value, actual_size, right, @@ -833,10 +905,10 @@ where let width = right_position - left_position; let selection_bounds = Rectangle { - x: text_bounds.x + left_position, - y: text_bounds.y, + x: text_layout.bounds().x + left_position, + y: text_layout.bounds().y, width, - height: text_bounds.height, + height: text_layout.bounds().height, }; if cursor_position.is_over(selection_bounds) { @@ -969,7 +1041,6 @@ where let state = state(); if matches!(state.dragging_state, Some(DraggingState::Selection)) { - let text_layout = layout.children().next().unwrap(); let target = position.x - text_layout.bounds().x; let value: Value = if is_secure { @@ -1255,8 +1326,7 @@ where }; let state = state(); - let bounds = layout.bounds(); - let is_clicked = bounds.contains(Point { + let is_clicked = text_layout.bounds().contains(Point { x: x as f32, y: y as f32, }); @@ -1284,7 +1354,6 @@ where accepted: DndAction::Move.union(DndAction::Copy), } }))); - let text_layout = layout.children().next().unwrap(); 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 @@ -1323,8 +1392,7 @@ where }; let state = state(); - let bounds = layout.bounds(); - let is_clicked = bounds.contains(Point { + let is_clicked = text_layout.bounds().contains(Point { x: x as f32, y: y as f32, }); @@ -1367,7 +1435,6 @@ where state.dnd_offer = DndOfferState::HandlingOffer(mime_types.clone(), action); } }; - let text_layout = layout.children().next().unwrap(); let target = x as f32 - text_layout.bounds().x; // existing logic for setting the selection let position = if target > 0.0 { @@ -1408,10 +1475,8 @@ where .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 || { @@ -1507,20 +1572,21 @@ where #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_lines)] #[allow(clippy::missing_panics_doc)] -pub fn draw( - renderer: &mut Renderer, - theme: &Renderer::Theme, +pub fn draw<'a, Message>( + renderer: &mut crate::Renderer, + theme: &crate::Theme, layout: Layout<'_>, cursor_position: mouse::Cursor, - state: &State, + tree: &Tree, value: &Value, placeholder: &str, size: Option, - font: Option, + font: Option<::Font>, is_disabled: bool, is_secure: bool, - icon: Option<&Icon>, - style: &::Style, + 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>, @@ -1528,10 +1594,13 @@ pub fn draw( helper_text: Option<&str>, helper_text_size: f32, helper_line_height: text::LineHeight, -) where - Renderer: text::Renderer, - Renderer::Theme: StyleSheet, -{ + viewport: &Rectangle, +) { + // all children should be icon images + let children = &tree.children; + let start_icon_tree = children.get(0); + let end_icon_tree = children.get(1); + let state = tree.state.downcast_ref::(); let secure_value = is_secure.then(|| value.secure()); let value = secure_value.as_ref().unwrap_or(value); @@ -1556,7 +1625,6 @@ pub fn draw( (None, layout, None) }; - let mut children_layout = layout.children(); let bounds = layout.bounds(); let text_bounds = children_layout.next().unwrap().bounds(); @@ -1576,7 +1644,7 @@ pub fn draw( }; // draw background and its border - if let Some(border_offset) = appearance.border_offset { + if let Some(border_offset) = appearance.border_offset { let offset_bounds = Rectangle { x: bounds.x - border_offset, y: bounds.y - border_offset, @@ -1627,22 +1695,22 @@ pub fn draw( shaping: text::Shaping::Advanced, }); } - + // draw the start icon in the text input - if let Some(icon) = icon { + if let (Some(icon), Some(tree)) = (icon, start_icon_tree) { let icon_layout = children_layout.next().unwrap(); - renderer.fill_text(Text { - content: &icon.code_point.to_string(), - size: icon.size.unwrap_or_else(|| renderer.default_size()), - font: icon.font, - color: appearance.icon_color, - bounds: icon_layout.bounds(), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Top, - line_height: text::LineHeight::default(), - shaping: text::Shaping::Advanced, - }); + icon.as_widget().draw( + tree, + renderer, + theme, + &renderer::Style { + text_color: appearance.icon_color, + }, + icon_layout, + cursor_position, + viewport, + ); } let text = value.to_string(); @@ -1761,7 +1829,7 @@ pub fn draw( theme.value_color(style) }; - let render = |renderer: &mut Renderer| { + let render = |renderer: &mut crate::Renderer| { if let Some((cursor, color)) = cursor { renderer.fill_quad(cursor, color); } else { @@ -1793,6 +1861,23 @@ pub fn draw( render(renderer); } + // 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.icon_color, + }, + 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 { From 4a554e9ec544405a46af0ce271cc055cf1162bbc Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Thu, 17 Aug 2023 12:46:23 -0400 Subject: [PATCH 4/7] wip: improve text input --- examples/cosmic-sctk/src/window.rs | 28 ++-- src/widget/text_input/text_input.rs | 204 ++++++++++++++++++++-------- 2 files changed, 168 insertions(+), 64 deletions(-) diff --git a/examples/cosmic-sctk/src/window.rs b/examples/cosmic-sctk/src/window.rs index 2b33d2a8e6f..54d27757d04 100644 --- a/examples/cosmic-sctk/src/window.rs +++ b/examples/cosmic-sctk/src/window.rs @@ -15,8 +15,8 @@ use cosmic::{ widget::{ button, cosmic_container, header_bar, icon, inline_input, nav_bar, nav_bar_toggle, rectangle_tracker::{rectangle_tracker_subscription, RectangleTracker, RectangleUpdate}, - scrollable, search_input, segmented_button, segmented_selection, settings, text_input, - IconSource, + scrollable, search_input, secure_input, segmented_button, segmented_selection, settings, + text_input, IconSource, }, Element, ElementExt, }; @@ -129,6 +129,7 @@ pub struct Window { pub selection: segmented_button::SingleSelectModel, timeline: Timeline, input_value: String, + secure_input_visible: bool, } impl Window { @@ -191,6 +192,7 @@ pub enum Message { Selection(segmented_button::Entity), Tick(Instant), InputChanged(String), + ToggleVisible, } impl Window { @@ -319,6 +321,9 @@ impl Application for Window { Message::InputChanged(v) => { self.input_value = v; } + Message::ToggleVisible => { + self.secure_input_visible = !self.secure_input_visible; + } } Command::none() @@ -488,20 +493,23 @@ impl Application for Window { )) .add(settings::item( "Text Input", - text_input("test", &self.input_value) - .start_icon(icon("document-properties-symbolic", 16).into()) - .end_icon(icon("document-properties-symbolic", 16).into()) - .label("Test Label") - .helper_text("helper_text") - .width(Length::Fill) - .on_input(Message::InputChanged), + 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, - Message::InputChanged("".to_string()), + Some(Message::InputChanged("".to_string())), ) .width(Length::Fill) .on_input(Message::InputChanged), diff --git a/src/widget/text_input/text_input.rs b/src/widget/text_input/text_input.rs index 5bda6237767..c33c1a2f3a8 100644 --- a/src/widget/text_input/text_input.rs +++ b/src/widget/text_input/text_input.rs @@ -46,45 +46,84 @@ where TextInput::new(placeholder, value) } -/// Creates a new expandable search [`TextInput`]. +/// Creates a new search [`TextInput`]. /// /// [`TextInput`]: widget::TextInput -pub fn expandable_search_input<'a, Message>( +pub fn search_input<'a, Message>( placeholder: &str, value: &str, + on_clear: Option, ) -> TextInput<'a, Message> where - Message: Clone, + Message: Clone + 'static, { - TextInput::new(placeholder, value).style(super::style::TextInput::Default) -} + 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 search_input<'a, Message>( +pub fn secure_input<'a, Message>( placeholder: &str, value: &str, - on_clear: Message, + on_visible_toggle: Option, + hidden: bool, ) -> TextInput<'a, Message> where Message: Clone + 'static, { let spacing = THEME.with(|t| t.borrow().cosmic().space_xxs()); - TextInput::new(placeholder, value) - .style(super::style::TextInput::Search) + 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-search-symbolic", 16)) - .padding([spacing, spacing, spacing, 2 * spacing]) - .into(), - ) - .end_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, "edit-clear-symbolic", 16) - .on_press(on_clear) - .padding([spacing, 2 * spacing, spacing, spacing]) + .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`]. @@ -94,7 +133,11 @@ pub fn inline_input<'a, Message>(value: &str) -> TextInput<'a, Message> where Message: Clone, { - TextInput::new("", value).style(super::style::TextInput::Inline) + let spacing = THEME.with(|t| t.borrow().cosmic().space_xxs()); + + TextInput::new("", value) + .style(super::style::TextInput::Inline) + .padding([spacing, spacing, spacing, spacing]) } const SUPPORTED_MIME_TYPES: &[&str; 6] = &[ @@ -170,6 +213,8 @@ where /// - 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), @@ -177,7 +222,7 @@ where is_secure: false, font: None, width: Length::Fill, - padding: Padding::new(5.0), + padding: [spacing, spacing, spacing, spacing].into(), size: None, helper_size: 10.0, helper_line_height: text::LineHeight::from(14.0), @@ -502,10 +547,15 @@ where shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) -> event::Status { - if let (Some(start_icon), Some(tree)) = (self.start_icon.as_mut(), tree.children.get_mut(0)) - { - if matches!( - start_icon.as_widget_mut().on_event( + 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, @@ -513,17 +563,18 @@ where renderer, clipboard, shell, - viewport - ), - event::Status::Captured - ) { - return event::Status::Captured; + viewport, + ); } } - if let (Some(end_icon), Some(tree)) = (self.end_element.as_mut(), tree.children.get_mut(2)) - { - if matches!( - end_icon.as_widget_mut().on_event( + 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, @@ -531,17 +582,14 @@ where renderer, clipboard, shell, - viewport - ), - event::Status::Captured - ) { - return event::Status::Captured; + viewport, + ); } } update( event, - self.text_layout(layout), + text_layout.children().next().unwrap(), cursor_position, renderer, clipboard, @@ -600,13 +648,51 @@ where fn mouse_interaction( &self, - _state: &Tree, + state: &Tree, layout: Layout<'_>, cursor_position: mouse::Cursor, - _viewport: &Rectangle, - _renderer: &crate::Renderer, + 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()) } } @@ -720,35 +806,42 @@ pub fn layout( } else { (0.0, None) }; - let limits = limits.width(width).pad(padding).height(height); + let text_limits = limits.width(width).pad(padding).height(text_size * 1.2); - let text_bounds = limits.resolve(Size::ZERO); + 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)); + 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)); + 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 + icon_spacing, - padding.top, + 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 node = - layout::Node::with_children(text_bounds.pad(padding), node_list).translate(text_pos); - let y_pos = node.bounds().y + node.bounds().height + f32::from(spacing); - nodes.push(node); + 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 { @@ -1598,8 +1691,7 @@ pub fn draw<'a, Message>( ) { // all children should be icon images let children = &tree.children; - let start_icon_tree = children.get(0); - let end_icon_tree = children.get(1); + let state = tree.state.downcast_ref::(); let secure_value = is_secure.then(|| value.secure()); let value = secure_value.as_ref().unwrap_or(value); @@ -1695,7 +1787,8 @@ pub fn draw<'a, Message>( 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(); @@ -1711,6 +1804,7 @@ pub fn draw<'a, Message>( cursor_position, viewport, ); + child_index += 1; } let text = value.to_string(); @@ -1861,6 +1955,8 @@ pub fn draw<'a, Message>( 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(); From 9a9f2efecd35123ec8d5f6507b6a8c663552a2ef Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Thu, 17 Aug 2023 13:56:16 -0400 Subject: [PATCH 5/7] refactor: text input styling --- src/widget/text_input/style.rs | 90 +++++++++++++++-------------- src/widget/text_input/text_input.rs | 14 ++--- 2 files changed, 53 insertions(+), 51 deletions(-) diff --git a/src/widget/text_input/style.rs b/src/widget/text_input/style.rs index e7422c82711..fe383cd7f20 100644 --- a/src/widget/text_input/style.rs +++ b/src/widget/text_input/style.rs @@ -14,10 +14,14 @@ pub struct Appearance { pub border_width: f32, /// The border [`Color`] of the text input. pub border_color: Color, - /// The icon [`Color`] of the text input. - pub icon_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. @@ -37,15 +41,6 @@ pub trait StyleSheet { /// Produces the [`Color`] of the placeholder of a text input. fn placeholder_color(&self, style: &Self::Style) -> Color; - /// Produces the [`Color`] of the value of a text input. - fn value_color(&self, style: &Self::Style) -> Color; - - /// Produces the [`Color`] of the value of a disabled text input. - fn disabled_color(&self, style: &Self::Style) -> Color; - - /// Produces the [`Color`] of the selection of a text input. - fn selection_color(&self, style: &Self::Style) -> Color; - /// Produces the style of an hovered text input. fn hovered(&self, style: &Self::Style) -> Appearance { self.focused(style) @@ -80,7 +75,9 @@ impl StyleSheet for crate::Theme { border_width: 1.0, border_offset: None, border_color: self.current_container().component.divider.into(), - icon_color: self.current_container().on.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 { @@ -89,7 +86,9 @@ impl StyleSheet for crate::Theme { border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: self.current_container().on.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 => Appearance { @@ -98,7 +97,9 @@ impl StyleSheet for crate::Theme { border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: self.current_container().on.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::Inline => Appearance { @@ -107,7 +108,9 @@ impl StyleSheet for crate::Theme { border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: self.current_container().on.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(), }, } @@ -127,7 +130,9 @@ impl StyleSheet for crate::Theme { border_width: 1.0, border_offset: Some(2.0), border_color: Color::from(palette.destructive_color()), - icon_color: self.current_container().on.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 { @@ -136,7 +141,9 @@ impl StyleSheet for crate::Theme { border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: self.current_container().on.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::Inline => Appearance { @@ -145,7 +152,9 @@ impl StyleSheet for crate::Theme { border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: self.current_container().on.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(), }, } @@ -165,7 +174,9 @@ impl StyleSheet for crate::Theme { border_width: 1.0, border_offset: None, border_color: palette.accent.base.into(), - icon_color: self.current_container().on.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 { @@ -174,7 +185,9 @@ impl StyleSheet for crate::Theme { border_offset: None, border_width: 0.0, border_color: Color::TRANSPARENT, - icon_color: self.current_container().on.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::Inline => Appearance { @@ -183,7 +196,9 @@ impl StyleSheet for crate::Theme { border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: self.current_container().on.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(), }, } @@ -203,7 +218,9 @@ impl StyleSheet for crate::Theme { border_width: 1.0, border_offset: Some(2.0), border_color: palette.accent.base.into(), - icon_color: self.current_container().on.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 { @@ -212,7 +229,9 @@ impl StyleSheet for crate::Theme { border_width: 0.0, border_offset: Some(2.0), border_color: Color::TRANSPARENT, - icon_color: self.current_container().on.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::Inline => Appearance { @@ -221,7 +240,11 @@ impl StyleSheet for crate::Theme { border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: palette.accent.on.into(), + // 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(), }, } @@ -234,25 +257,6 @@ impl StyleSheet for crate::Theme { neutral_9.into() } - fn value_color(&self, _style: &Self::Style) -> Color { - let palette = self.cosmic(); - - palette.palette.neutral_9.into() - } - - fn selection_color(&self, _style: &Self::Style) -> Color { - let palette = self.cosmic(); - - palette.accent.base.into() - } - - fn disabled_color(&self, _style: &Self::Style) -> Color { - let palette = self.cosmic(); - let mut neutral_9 = palette.palette.neutral_9; - neutral_9.alpha = 0.5; - neutral_9.into() - } - fn disabled(&self, style: &Self::Style) -> Appearance { self.active(style) } diff --git a/src/widget/text_input/text_input.rs b/src/widget/text_input/text_input.rs index c33c1a2f3a8..6b6f50b06ff 100644 --- a/src/widget/text_input/text_input.rs +++ b/src/widget/text_input/text_input.rs @@ -1,6 +1,7 @@ //! Display fields that can be filled with text. //! //! A [`TextInput`] has some local [`State`]. +use crate::app; use crate::theme::THEME; use super::cursor; @@ -9,7 +10,6 @@ use super::editor::Editor; use super::style::StyleSheet; pub use super::value::Value; -use apply::Also; use iced::Limits; use iced_core::event::{self, Event}; use iced_core::keyboard; @@ -1798,7 +1798,7 @@ pub fn draw<'a, Message>( renderer, theme, &renderer::Style { - text_color: appearance.icon_color, + text_color: appearance.text_color, }, icon_layout, cursor_position, @@ -1844,7 +1844,7 @@ pub fn draw<'a, Message>( border_width: 0.0, border_color: Color::TRANSPARENT, }, - theme.value_color(style), + appearance.text_color, )), offset, ) @@ -1893,7 +1893,7 @@ pub fn draw<'a, Message>( border_width: 0.0, border_color: Color::TRANSPARENT, }, - theme.selection_color(style), + appearance.selected_fill, )), if end == right { right_offset @@ -1917,10 +1917,8 @@ pub fn draw<'a, Message>( let color = if text.is_empty() { theme.placeholder_color(style) - } else if is_disabled { - theme.disabled_color(style) } else { - theme.value_color(style) + appearance.text_color }; let render = |renderer: &mut crate::Renderer| { @@ -1966,7 +1964,7 @@ pub fn draw<'a, Message>( renderer, theme, &renderer::Style { - text_color: appearance.icon_color, + text_color: appearance.text_color, }, icon_layout, cursor_position, From 878c2dcd5da62bff3d760268491435186218778c Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Mon, 21 Aug 2023 11:36:05 -0400 Subject: [PATCH 6/7] chore: add scale factor --- src/widget/text_input/text_input.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/widget/text_input/text_input.rs b/src/widget/text_input/text_input.rs index 6b6f50b06ff..79e5fb3a3c9 100644 --- a/src/widget/text_input/text_input.rs +++ b/src/widget/text_input/text_input.rs @@ -364,6 +364,7 @@ where layout: Layout<'_>, cursor_position: mouse::Cursor, value: Option<&Value>, + style: &renderer::Style, ) { draw( renderer, @@ -388,6 +389,7 @@ where self.helper_size, self.helper_line_height, &layout.bounds(), + style, ); } @@ -615,7 +617,7 @@ where tree: &Tree, renderer: &mut crate::Renderer, theme: &::Theme, - _style: &renderer::Style, + style: &renderer::Style, layout: Layout<'_>, cursor_position: mouse::Cursor, viewport: &Rectangle, @@ -643,6 +645,7 @@ where self.helper_size, self.helper_line_height, viewport, + style, ); } @@ -1688,6 +1691,7 @@ pub fn draw<'a, Message>( 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; @@ -1799,6 +1803,7 @@ pub fn draw<'a, Message>( theme, &renderer::Style { text_color: appearance.text_color, + scale_factor: renderer_style.scale_factor, }, icon_layout, cursor_position, @@ -1965,6 +1970,7 @@ pub fn draw<'a, Message>( theme, &renderer::Style { text_color: appearance.text_color, + scale_factor: renderer_style.scale_factor, }, icon_layout, cursor_position, From cc74c63d9b34d7974db396939baa9dc4d45bba30 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Mon, 21 Aug 2023 18:58:36 -0400 Subject: [PATCH 7/7] chore(text_input): add winit example and do some cleanup --- Cargo.toml | 2 -- examples/cosmic/src/window/demo.rs | 4 +++ src/widget/mod.rs | 4 +-- src/widget/text_input/editor.rs | 34 ++++++++---------- .../text_input/{text_input.rs => input.rs} | 35 ++++++++++++++++--- src/widget/text_input/mod.rs | 9 ++--- 6 files changed, 56 insertions(+), 32 deletions(-) rename src/widget/text_input/{text_input.rs => input.rs} (98%) diff --git a/Cargo.toml b/Cargo.toml index 403702223d3..28c771d67d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,5 +124,3 @@ exclude = [ [patch."https://github.com/pop-os/libcosmic"] libcosmic = { path = "./", features = ["wayland", "tokio", "a11y"]} -[patch."https://github.com/pop-os/cosmic-time"] -cosmic-time = { path = "../cosmic-time"} diff --git a/examples/cosmic/src/window/demo.rs b/examples/cosmic/src/window/demo.rs index c1856eb89d3..375b7044931 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 708808d4287..272824b8c61 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -70,9 +70,9 @@ pub use warning::*; pub mod cosmic_container; pub use cosmic_container::*; -#[cfg(feature = "wayland")] +// #[cfg(feature = "wayland")] pub mod text_input; -#[cfg(feature = "wayland")] +// #[cfg(feature = "wayland")] pub use text_input::*; /// An element to distinguish a boundary between two elements. diff --git a/src/widget/text_input/editor.rs b/src/widget/text_input/editor.rs index 3eec052a32b..07648b71c6b 100644 --- a/src/widget/text_input/editor.rs +++ b/src/widget/text_input/editor.rs @@ -38,33 +38,27 @@ impl<'a> Editor<'a> { } pub fn backspace(&mut self) { - match self.cursor.selection(self.value) { - Some((start, end)) => { - self.cursor.move_left(self.value); - self.value.remove_many(start, end); - } - None => { - let start = self.cursor.start(self.value); + 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); - } + if start > 0 { + self.cursor.move_left(self.value); + self.value.remove(start - 1); } } } pub fn delete(&mut self) { - match self.cursor.selection(self.value) { - Some(_) => { - self.backspace(); - } - None => { - let end = self.cursor.end(self.value); + 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); - } + if end < self.value.len() { + self.value.remove(end); } } } diff --git a/src/widget/text_input/text_input.rs b/src/widget/text_input/input.rs similarity index 98% rename from src/widget/text_input/text_input.rs rename to src/widget/text_input/input.rs index 79e5fb3a3c9..abb4290d412 100644 --- a/src/widget/text_input/text_input.rs +++ b/src/widget/text_input/input.rs @@ -1,7 +1,6 @@ //! Display fields that can be filled with text. //! //! A [`TextInput`] has some local [`State`]. -use crate::app; use crate::theme::THEME; use super::cursor; @@ -28,12 +27,16 @@ 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; -use iced_runtime::command::platform_specific; +#[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`]. @@ -140,6 +143,7 @@ where .padding([spacing, spacing, spacing, spacing]) } +#[cfg(feature = "wayland")] const SUPPORTED_MIME_TYPES: &[&str; 6] = &[ "text/plain;charset=utf-8", "text/plain;charset=UTF-8", @@ -148,8 +152,11 @@ const SUPPORTED_MIME_TYPES: &[&str; 6] = &[ "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. /// @@ -356,6 +363,7 @@ where /// [`Value`] if provided. /// /// [`Renderer`]: text::Renderer + #[allow(clippy::too_many_arguments)] pub fn draw( &self, tree: &Tree, @@ -394,6 +402,7 @@ where } /// 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 @@ -401,6 +410,7 @@ where /// 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( @@ -958,6 +968,7 @@ where 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 @@ -965,6 +976,7 @@ where if is_secure { return event::Status::Ignored; } + if let ( Some(on_start_dnd), Some(on_dnd_command_produced), @@ -1384,6 +1396,7 @@ where )); } } + #[cfg(feature = "wayland")] Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DataSource( wayland::DataSourceEvent::DndFinished | wayland::DataSourceEvent::Cancelled, ))) => { @@ -1393,6 +1406,7 @@ where return event::Status::Captured; } } + #[cfg(feature = "wayland")] Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DataSource( wayland::DataSourceEvent::Cancelled | wayland::DataSourceEvent::DndFinished @@ -1404,6 +1418,7 @@ where return event::Status::Captured; } } + #[cfg(feature = "wayland")] Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DataSource( wayland::DataSourceEvent::DndActionAccepted(action), ))) => { @@ -1413,7 +1428,7 @@ where return event::Status::Captured; } } - // TODO: handle dnd offer events + #[cfg(feature = "wayland")] Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( wayland::DndOfferEvent::Enter { x, y, mime_types }, ))) => { @@ -1480,6 +1495,7 @@ where return event::Status::Captured; } } + #[cfg(feature = "wayland")] Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( wayland::DndOfferEvent::Motion { x, y }, ))) => { @@ -1558,6 +1574,7 @@ where 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, ))) => { @@ -1586,6 +1603,7 @@ where } return event::Status::Ignored; } + #[cfg(feature = "wayland")] Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( wayland::DndOfferEvent::Leave, ))) => { @@ -1600,6 +1618,7 @@ where }; return event::Status::Captured; } + #[cfg(feature = "wayland")] Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( wayland::DndOfferEvent::DndData { mime_type, data }, ))) => { @@ -1636,6 +1655,7 @@ where } return event::Status::Ignored; } + #[cfg(feature = "wayland")] Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( wayland::DndOfferEvent::SourceActions(actions), ))) => { @@ -2016,6 +2036,7 @@ pub fn mouse_interaction( #[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) { @@ -2029,9 +2050,11 @@ impl DataFromMimeType for TextInputString { #[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] @@ -2040,6 +2063,9 @@ pub(crate) enum DndOfferState { 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)] @@ -2081,6 +2107,7 @@ impl State { } } + #[cfg(feature = "wayland")] /// Returns the current value of the dragged text in the [`TextInput`]. #[must_use] pub fn dragged_text(&self) -> Option { @@ -2095,7 +2122,7 @@ impl State { Self { is_focused: None, dragging_state: None, - dnd_offer: DndOfferState::None, + dnd_offer: DndOfferState::default(), is_pasting: None, last_click: None, cursor: Cursor::default(), diff --git a/src/widget/text_input/mod.rs b/src/widget/text_input/mod.rs index 053560acf66..dac71503afd 100644 --- a/src/widget/text_input/mod.rs +++ b/src/widget/text_input/mod.rs @@ -1,9 +1,10 @@ -//! A text input widget from iced_widgets plus some added details. +//! A text input widget from iced widgets plus some added details. pub mod cursor; pub mod editor; -pub mod style; -mod text_input; +mod input; +mod style; pub mod value; -pub use text_input::*; +pub use input::*; +pub use style::{Appearance as TextInputAppearance, StyleSheet as TextInputStyleSheet};