diff --git a/Cargo.lock b/Cargo.lock index 2f2be353..812901c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3118,6 +3118,9 @@ dependencies = [ "bitcoin", "chrono", "iced", + "iced_core", + "iced_runtime", + "unicode-segmentation", ] [[package]] diff --git a/liana-ui/Cargo.toml b/liana-ui/Cargo.toml index c13b5701..2afc4565 100644 --- a/liana-ui/Cargo.toml +++ b/liana-ui/Cargo.toml @@ -7,5 +7,8 @@ edition = "2021" [dependencies] iced = { version = "0.13.1", default-features = false, features = ["svg", "image", "lazy", "qr_code", "advanced"] } +iced_core = { version = "0.13.2" } +iced_runtime = { version = "0.13.2" } +unicode-segmentation = "1.0" bitcoin = "0.32" chrono = "0.4" diff --git a/liana-ui/src/component/form.rs b/liana-ui/src/component/form.rs index d4ecce95..5b618982 100644 --- a/liana-ui/src/component/form.rs +++ b/liana-ui/src/component/form.rs @@ -1,5 +1,6 @@ +use crate::widget::text_input; use bitcoin::Denomination; -use iced::{widget::text_input, Length}; +use iced::Length; use crate::{color, component::text, theme, widget::*}; diff --git a/liana-ui/src/lib.rs b/liana-ui/src/lib.rs index b6727801..3b09c8f2 100644 --- a/liana-ui/src/lib.rs +++ b/liana-ui/src/lib.rs @@ -4,24 +4,4 @@ pub mod font; pub mod icon; pub mod image; pub mod theme; - -pub mod widget { - #![allow(dead_code)] - use crate::theme::Theme; - - pub type Renderer = iced::Renderer; - pub type Element<'a, Message> = iced::Element<'a, Message, Theme, Renderer>; - pub type Container<'a, Message> = iced::widget::Container<'a, Message, Theme, Renderer>; - pub type Column<'a, Message> = iced::widget::Column<'a, Message, Theme, Renderer>; - pub type Row<'a, Message> = iced::widget::Row<'a, Message, Theme, Renderer>; - pub type Button<'a, Message> = iced::widget::Button<'a, Message, Theme, Renderer>; - pub type CheckBox<'a, Message> = iced::widget::Checkbox<'a, Message, Theme, Renderer>; - pub type Text<'a> = iced::widget::Text<'a, Theme, Renderer>; - pub type TextInput<'a, Message> = iced::widget::TextInput<'a, Message, Theme, Renderer>; - pub type Tooltip<'a> = iced::widget::Tooltip<'a, Theme, Renderer>; - pub type ProgressBar<'a> = iced::widget::ProgressBar<'a, Theme>; - pub type PickList<'a, T, L, V, Message> = - iced::widget::PickList<'a, T, L, V, Message, Theme, Renderer>; - pub type Scrollable<'a, Message> = iced::widget::Scrollable<'a, Message, Theme, Renderer>; - pub type Svg<'a> = iced::widget::Svg<'a, Theme>; -} +pub mod widget; diff --git a/liana-ui/src/theme/overlay.rs b/liana-ui/src/theme/overlay.rs index 40f22649..40882e66 100644 --- a/liana-ui/src/theme/overlay.rs +++ b/liana-ui/src/theme/overlay.rs @@ -1,5 +1,6 @@ +pub use iced::widget::overlay::menu::Catalog; use iced::{ - widget::overlay::menu::{Catalog, Style, StyleFn}, + widget::overlay::menu::{Style, StyleFn}, Border, }; diff --git a/liana-ui/src/widget/cursor.rs b/liana-ui/src/widget/cursor.rs new file mode 100644 index 00000000..77071079 --- /dev/null +++ b/liana-ui/src/widget/cursor.rs @@ -0,0 +1,183 @@ +//! Track the cursor of a text input. +use iced::widget::text_input::Value; + +/// The cursor of a text input. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct Cursor { + state: State, +} + +/// The state of a [`Cursor`]. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +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`]. + 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`. + 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/liana-ui/src/widget/editor.rs b/liana-ui/src/widget/editor.rs new file mode 100644 index 00000000..194f71b8 --- /dev/null +++ b/liana-ui/src/widget/editor.rs @@ -0,0 +1,71 @@ +use super::cursor::Cursor; +use iced::widget::text_input::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 } + } + + 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/liana-ui/src/widget/menu.rs b/liana-ui/src/widget/menu.rs new file mode 100644 index 00000000..c8aad99d --- /dev/null +++ b/liana-ui/src/widget/menu.rs @@ -0,0 +1,556 @@ +use std::fmt::Display; + +use iced::widget::overlay::menu::Catalog; +use iced::widget::scrollable::{self, Scrollable}; +use iced_core::alignment; +use iced_core::border::{self}; +use iced_core::clipboard::{self, Clipboard}; +use iced_core::event::{self, Event}; +use iced_core::layout::{self, Layout}; +use iced_core::mouse; +use iced_core::overlay; +use iced_core::renderer; +use iced_core::text::{self, Text}; +use iced_core::touch; +use iced_core::widget::Tree; +use iced_core::{Element, Shell, Widget}; +use iced_core::{Length, Padding, Pixels, Point, Rectangle, Size, Vector}; + +#[derive(Clone)] +pub enum Command { + Copy, + Paste, +} + +impl Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Command::Copy => write!(f, "Copy"), + Command::Paste => write!(f, "Paste"), + } + } +} + +pub const ALL_OPTIONS: [Command; 2] = [Command::Copy, Command::Paste]; + +/// A list of selectable options. +#[allow(missing_debug_implementations)] +pub struct Menu<'a, 'b, Message, Theme = crate::theme::Theme, Renderer = iced::Renderer> +where + Theme: Catalog, + Renderer: text::Renderer, + 'b: 'a, +{ + value: String, + state: &'a mut State, + options: &'a [Command], + hovered_option: &'a mut Option, + on_select: Box) -> Option + 'a>, + width: f32, + padding: Padding, + text_size: Option, + text_line_height: text::LineHeight, + text_shaping: text::Shaping, + font: Option, + class: &'a ::Class<'b>, +} + +impl<'a, 'b, Message, Theme, Renderer> Menu<'a, 'b, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + 'a, + Renderer: text::Renderer + 'a, + 'b: 'a, +{ + /// Creates a new [`Menu`] with the given [`State`], a list of options, + /// the message to produced when an option is selected, and its [`Style`]. + pub fn new( + value: String, + state: &'a mut State, + hovered_option: &'a mut Option, + on_select: impl FnMut(Option) -> Option + 'a, + class: &'a ::Class<'b>, + ) -> Self { + Menu { + state, + options: if value.is_empty() { + &[super::menu::Command::Paste] + } else { + &super::menu::ALL_OPTIONS + }, + value, + hovered_option, + on_select: Box::new(on_select), + width: 0.0, + padding: Padding::ZERO, + text_size: None, + text_line_height: text::LineHeight::default(), + text_shaping: text::Shaping::Basic, + font: None, + class, + } + } + + /// Sets the width of the [`Menu`]. + pub fn width(mut self, width: f32) -> Self { + self.width = width; + self + } + + /// Sets the [`Padding`] of the [`Menu`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the [`text::Shaping`] strategy of the [`Menu`]. + pub fn text_shaping(mut self, shaping: text::Shaping) -> Self { + self.text_shaping = shaping; + self + } + + /// Sets the font of the [`Menu`]. + pub fn font(mut self, font: impl Into) -> Self { + self.font = Some(font.into()); + self + } + + /// Turns the [`Menu`] into an overlay [`Element`] at the given target + /// position. + /// + /// The `target_height` will be used to display the menu either on top + /// of the target or under it, depending on the screen position and the + /// dimensions of the [`Menu`]. + pub fn overlay( + self, + position: Point, + target_height: f32, + ) -> overlay::Element<'a, Message, Theme, Renderer> { + overlay::Element::new(Box::new(Overlay::new(position, self, target_height))) + } +} + +/// The local state of a [`Menu`]. +#[derive(Debug)] +pub struct State { + tree: Tree, +} + +impl State { + /// Creates a new [`State`] for a [`Menu`]. + pub fn new() -> Self { + Self { + tree: Tree::empty(), + } + } +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +struct Overlay<'a, 'b, Message, Theme, Renderer> +where + Theme: Catalog, + Renderer: iced_core::Renderer, +{ + position: Point, + state: &'a mut Tree, + list: Scrollable<'a, Message, Theme, Renderer>, + width: f32, + target_height: f32, + class: &'a ::Class<'b>, +} + +impl<'a, 'b, Message, Theme, Renderer> Overlay<'a, 'b, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + scrollable::Catalog + 'a, + Renderer: text::Renderer + 'a, + 'b: 'a, +{ + pub fn new( + position: Point, + menu: Menu<'a, 'b, Message, Theme, Renderer>, + target_height: f32, + ) -> Self { + let Menu { + value, + state, + options, + hovered_option, + on_select, + width, + padding, + font, + text_size, + text_line_height, + text_shaping, + class, + } = menu; + + let list = Scrollable::new(List { + value, + options, + hovered_option, + on_select, + font, + text_size, + text_line_height, + text_shaping, + padding, + class, + }); + + state.tree.diff(&list as &dyn Widget<_, _, _>); + + Self { + position, + state: &mut state.tree, + list, + width, + target_height, + class, + } + } +} + +impl<'a, 'b, Message, Theme, Renderer> iced_core::Overlay + for Overlay<'a, 'b, Message, Theme, Renderer> +where + Theme: Catalog, + Renderer: text::Renderer, +{ + fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { + let space_below = bounds.height - (self.position.y + self.target_height); + let space_above = self.position.y; + + let limits = layout::Limits::new( + Size::ZERO, + Size::new( + bounds.width - self.position.x, + if space_below > space_above { + space_below + } else { + space_above + }, + ), + ) + .width(self.width); + + let node = self.list.layout(self.state, renderer, &limits); + let size = node.size(); + + node.move_to(if space_below > space_above { + self.position + Vector::new(0.0, self.target_height) + } else { + self.position - Vector::new(0.0, size.height) + }) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + let bounds = layout.bounds(); + + self.list.on_event( + self.state, event, layout, cursor, renderer, clipboard, shell, &bounds, + ) + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.list + .mouse_interaction(self.state, layout, cursor, viewport, renderer) + } + + fn draw( + &self, + renderer: &mut Renderer, + theme: &Theme, + defaults: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + let bounds = layout.bounds(); + + let style = Catalog::style(theme, self.class); + + renderer.fill_quad( + renderer::Quad { + bounds, + border: style.border, + ..renderer::Quad::default() + }, + style.background, + ); + + self.list.draw( + self.state, renderer, theme, defaults, layout, cursor, &bounds, + ); + } +} + +struct List<'a, 'b, Message, Theme, Renderer> +where + Theme: Catalog, + Renderer: text::Renderer, +{ + value: String, + options: &'a [Command], + hovered_option: &'a mut Option, + on_select: Box) -> Option + 'a>, + padding: Padding, + text_size: Option, + text_line_height: text::LineHeight, + text_shaping: text::Shaping, + font: Option, + class: &'a ::Class<'b>, +} + +impl<'a, 'b, Message, Theme, Renderer> Widget + for List<'a, 'b, Message, Theme, Renderer> +where + Theme: Catalog, + Renderer: text::Renderer, +{ + fn size(&self) -> Size { + Size { + width: Length::Fill, + height: Length::Shrink, + } + } + + fn layout( + &self, + _tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + use std::f32; + + let text_size = self.text_size.unwrap_or_else(|| renderer.default_size()); + + let text_line_height = self.text_line_height.to_absolute(text_size); + + let size = { + let intrinsic = Size::new( + 0.0, + (f32::from(text_line_height) + self.padding.vertical()) * self.options.len() as f32, + ); + + limits.resolve(Length::Fill, Length::Shrink, intrinsic) + }; + + layout::Node::new(size) + } + + fn on_event( + &mut self, + _state: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) -> event::Status { + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if cursor.is_over(layout.bounds()) { + if let Some(index) = *self.hovered_option { + if let Some(option) = self.options.get(index) { + *self.hovered_option = None; + let content = match option { + Command::Copy => { + clipboard.write(clipboard::Kind::Standard, self.value.clone()); + None + } + Command::Paste => { + let content: String = clipboard + .read(clipboard::Kind::Standard) + .unwrap_or_default() + .chars() + .filter(|c| !c.is_control()) + .collect(); + Some(content) + } + }; + if let Some(msg) = (self.on_select)(content) { + shell.publish(msg); + } + return event::Status::Captured; + } + } + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + *self.hovered_option = None; + if let Some(cursor_position) = cursor.position_in(layout.bounds()) { + let text_size = self.text_size.unwrap_or_else(|| renderer.default_size()); + + let option_height = f32::from(self.text_line_height.to_absolute(text_size)) + + self.padding.vertical(); + + let new_hovered_option = (cursor_position.y / option_height) as usize; + + *self.hovered_option = Some(new_hovered_option); + } + } + Event::Touch(touch::Event::FingerPressed { .. }) => { + if let Some(cursor_position) = cursor.position_in(layout.bounds()) { + let text_size = self.text_size.unwrap_or_else(|| renderer.default_size()); + + let option_height = f32::from(self.text_line_height.to_absolute(text_size)) + + self.padding.vertical(); + + *self.hovered_option = Some((cursor_position.y / option_height) as usize); + + if let Some(index) = *self.hovered_option { + if let Some(option) = self.options.get(index) { + *self.hovered_option = None; + let content = match option { + Command::Copy => { + clipboard.write(clipboard::Kind::Standard, self.value.clone()); + None + } + Command::Paste => { + let content: String = clipboard + .read(clipboard::Kind::Standard) + .unwrap_or_default() + .chars() + .filter(|c| !c.is_control()) + .collect(); + Some(content) + } + }; + if let Some(msg) = (self.on_select)(content) { + shell.publish(msg); + } + return event::Status::Captured; + } + } + } + } + _ => {} + } + + event::Status::Ignored + } + + fn mouse_interaction( + &self, + _state: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let is_mouse_over = cursor.is_over(layout.bounds()); + + if is_mouse_over { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } + + fn draw( + &self, + _state: &Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let style = Catalog::style(theme, self.class); + let bounds = layout.bounds(); + + let text_size = self.text_size.unwrap_or_else(|| renderer.default_size()); + let option_height = + f32::from(self.text_line_height.to_absolute(text_size)) + self.padding.vertical(); + + let offset = viewport.y - bounds.y; + let start = (offset / option_height) as usize; + let end = ((offset + viewport.height) / option_height).ceil() as usize; + + let visible_options = &self.options[start..end.min(self.options.len())]; + + for (i, option) in visible_options.iter().enumerate() { + let i = start + i; + let is_selected = *self.hovered_option == Some(i); + + let bounds = Rectangle { + x: bounds.x, + y: bounds.y + (option_height * i as f32), + width: bounds.width, + height: option_height, + }; + + if is_selected { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x + style.border.width, + width: bounds.width - style.border.width * 2.0, + ..bounds + }, + border: border::rounded(style.border.radius), + ..renderer::Quad::default() + }, + style.selected_background, + ); + } + + renderer.fill_text( + Text { + content: option.to_string(), + bounds: Size::new(f32::INFINITY, bounds.height), + size: text_size, + line_height: self.text_line_height, + font: self.font.unwrap_or_else(|| renderer.default_font()), + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Center, + shaping: self.text_shaping, + wrapping: text::Wrapping::default(), + }, + Point::new(bounds.x + self.padding.left, bounds.center_y()), + if is_selected { + style.selected_text_color + } else { + style.text_color + }, + *viewport, + ); + } + } +} + +impl<'a, 'b, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a + Catalog, + Renderer: 'a + text::Renderer, + 'b: 'a, +{ + fn from(list: List<'a, 'b, Message, Theme, Renderer>) -> Self { + Element::new(list) + } +} diff --git a/liana-ui/src/widget/mod.rs b/liana-ui/src/widget/mod.rs new file mode 100644 index 00000000..9bfe4bcd --- /dev/null +++ b/liana-ui/src/widget/mod.rs @@ -0,0 +1,22 @@ +mod cursor; +mod editor; +mod menu; +pub mod text_input; + +use crate::theme::Theme; + +pub type Renderer = iced::Renderer; +pub type Element<'a, Message> = iced::Element<'a, Message, Theme, Renderer>; +pub type Container<'a, Message> = iced::widget::Container<'a, Message, Theme, Renderer>; +pub type Column<'a, Message> = iced::widget::Column<'a, Message, Theme, Renderer>; +pub type Row<'a, Message> = iced::widget::Row<'a, Message, Theme, Renderer>; +pub type Button<'a, Message> = iced::widget::Button<'a, Message, Theme, Renderer>; +pub type CheckBox<'a, Message> = iced::widget::Checkbox<'a, Message, Theme, Renderer>; +pub type Text<'a> = iced::widget::Text<'a, Theme, Renderer>; +pub type TextInput<'a, Message> = text_input::TextInput<'a, Message, Theme, Renderer>; +pub type Tooltip<'a> = iced::widget::Tooltip<'a, Theme, Renderer>; +pub type ProgressBar<'a> = iced::widget::ProgressBar<'a, Theme>; +pub type PickList<'a, T, L, V, Message> = + iced::widget::PickList<'a, T, L, V, Message, Theme, Renderer>; +pub type Scrollable<'a, Message> = iced::widget::Scrollable<'a, Message, Theme, Renderer>; +pub type Svg<'a> = iced::widget::Svg<'a, Theme>; diff --git a/liana-ui/src/widget/text_input.rs b/liana-ui/src/widget/text_input.rs new file mode 100644 index 00000000..f4f69033 --- /dev/null +++ b/liana-ui/src/widget/text_input.rs @@ -0,0 +1,1492 @@ +use super::{ + cursor::{self, Cursor}, + editor::Editor, +}; +use iced::alignment; +use iced::widget::overlay::menu; +use iced::widget::text_input::{Catalog, Status, Style, StyleFn, Value}; + +use iced_core::clipboard::{self, Clipboard}; +use iced_core::event::{self, Event}; +use iced_core::keyboard; +use iced_core::keyboard::key; +use iced_core::layout; +use iced_core::mouse::{self, click}; +use iced_core::renderer; +use iced_core::text::paragraph::{self, Paragraph as _}; +use iced_core::text::{self, Text}; +use iced_core::time::{Duration, Instant}; +use iced_core::touch; +use iced_core::widget; +use iced_core::widget::operation::{self, Operation}; +use iced_core::widget::tree::{self, Tree}; +use iced_core::window; +use iced_core::{ + Element, Layout, Length, Padding, Pixels, Point, Rectangle, Shell, Size, Vector, Widget, +}; +use iced_runtime::task::{self, Task}; +use iced_runtime::Action; + +#[allow(missing_debug_implementations)] +pub struct TextInput<'a, Message, Theme = crate::theme::Theme, Renderer = iced::widget::Renderer> +where + Theme: Catalog + menu::Catalog, + Renderer: text::Renderer, +{ + id: Option, + placeholder: String, + value: Value, + is_secure: bool, + font: Option, + width: Length, + padding: Padding, + size: Option, + line_height: text::LineHeight, + text_shaping: text::Shaping, + alignment: alignment::Horizontal, + on_input: Option Message + 'a>>, + on_paste: Option Message + 'a>>, + on_submit: Option, + icon: Option>, + class: ::Class<'a>, + menu_class: ::Class<'a>, +} + +/// The default [`Padding`] of a [`TextInput`]. +pub const DEFAULT_PADDING: Padding = Padding::new(5.0); + +impl<'a, Message, Theme, Renderer> TextInput<'a, Message, Theme, Renderer> +where + Message: Clone, + Theme: Catalog + iced::widget::overlay::menu::Catalog, + Renderer: text::Renderer, +{ + /// Creates a new [`TextInput`] with the given placeholder and + /// its 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: DEFAULT_PADDING, + size: None, + line_height: text::LineHeight::default(), + text_shaping: text::Shaping::default(), + alignment: alignment::Horizontal::Left, + on_input: None, + on_paste: None, + on_submit: None, + icon: None, + class: ::default(), + menu_class: ::default(), + } + } + + /// Sets the [`Id`] of the [`TextInput`]. + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Converts the [`TextInput`] into a secure password input. + pub fn secure(mut self, is_secure: bool) -> Self { + self.is_secure = is_secure; + 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, on_input: impl Fn(String) -> Message + 'a) -> Self { + self.on_input = Some(Box::new(on_input)); + self + } + + /// Sets the message that should be produced when some text is typed into + /// the [`TextInput`], if `Some`. + /// + /// If `None`, the [`TextInput`] will be disabled. + pub fn on_input_maybe(mut self, on_input: Option Message + 'a>) -> Self { + self.on_input = on_input.map(|f| Box::new(f) as _); + 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 the [`TextInput`] is + /// focused and the enter key is pressed, if `Some`. + pub fn on_submit_maybe(mut self, on_submit: Option) -> Self { + self.on_submit = on_submit; + 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 message that should be produced when some text is pasted into + /// the [`TextInput`], if `Some`. + pub fn on_paste_maybe(mut self, on_paste: Option Message + 'a>) -> Self { + self.on_paste = on_paste.map(|f| Box::new(f) as _); + self + } + + /// Sets the [`Font`] of the [`TextInput`]. + /// + /// [`Font`]: text::Renderer::Font + pub fn font(mut self, font: Renderer::Font) -> Self { + self.font = Some(font); + self + } + + /// Sets the [`Icon`] of the [`TextInput`]. + pub fn icon(mut self, icon: Icon) -> 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()); + self + } + + /// Sets the [`text::LineHeight`] of the [`TextInput`]. + pub fn line_height(mut self, line_height: impl Into) -> Self { + self.line_height = line_height.into(); + self + } + + /// Sets the horizontal alignment of the [`TextInput`]. + pub fn align_x(mut self, alignment: impl Into) -> Self { + self.alignment = alignment.into(); + self + } + + /// Sets the style of the [`TextInput`]. + #[must_use] + pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self + where + ::Class<'a>: From>, + { + self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); + self + } + + /// Sets the style class of the [`TextInput`]. + #[must_use] + pub fn class(mut self, class: impl Into<::Class<'a>>) -> Self { + self.class = class.into(); + self + } + + /// Lays out the [`TextInput`], overriding its [`Value`] if provided. + /// + /// [`Renderer`]: text::Renderer + pub fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + value: Option<&Value>, + ) -> layout::Node { + let state = tree.state.downcast_mut::>(); + let value = value.unwrap_or(&self.value); + + let font = self.font.unwrap_or_else(|| renderer.default_font()); + let text_size = self.size.unwrap_or_else(|| renderer.default_size()); + let padding = self.padding.fit(Size::ZERO, limits.max()); + let height = self.line_height.to_absolute(text_size); + + let limits = limits.width(self.width).shrink(padding); + let text_bounds = limits.resolve(self.width, height, Size::ZERO); + + let placeholder_text = Text { + font, + line_height: self.line_height, + content: self.placeholder.as_str(), + bounds: Size::new(f32::INFINITY, text_bounds.height), + size: text_size, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Center, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::default(), + }; + + state.placeholder.update(placeholder_text); + + let secure_value = self.is_secure.then(|| value.secure()); + let value = secure_value.as_ref().unwrap_or(value); + + state.value.update(Text { + content: &value.to_string(), + ..placeholder_text + }); + + if let Some(icon) = &self.icon { + let mut content = [0; 4]; + + let icon_text = Text { + line_height: self.line_height, + content: icon.code_point.encode_utf8(&mut content) as &_, + font: icon.font, + size: icon.size.unwrap_or_else(|| renderer.default_size()), + bounds: Size::new(f32::INFINITY, text_bounds.height), + horizontal_alignment: alignment::Horizontal::Center, + vertical_alignment: alignment::Vertical::Center, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::default(), + }; + + state.icon.update(icon_text); + + let icon_width = state.icon.min_width(); + + let (text_position, icon_position) = match icon.side { + Side::Left => ( + Point::new(padding.left + icon_width + icon.spacing, padding.top), + Point::new(padding.left, padding.top), + ), + Side::Right => ( + Point::new(padding.left, padding.top), + Point::new(padding.left + text_bounds.width - icon_width, padding.top), + ), + }; + + let text_node = + layout::Node::new(text_bounds - Size::new(icon_width + icon.spacing, 0.0)) + .move_to(text_position); + + let icon_node = + layout::Node::new(Size::new(icon_width, text_bounds.height)).move_to(icon_position); + + layout::Node::with_children(text_bounds.expand(padding), vec![text_node, icon_node]) + } else { + let text = + layout::Node::new(text_bounds).move_to(Point::new(padding.left, padding.top)); + + layout::Node::with_children(text_bounds.expand(padding), vec![text]) + } + } + + /// Draws the [`TextInput`] with the given [`Renderer`], overriding its + /// [`Value`] if provided. + /// + /// [`Renderer`]: text::Renderer + #[allow(clippy::too_many_arguments)] + pub fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + layout: Layout<'_>, + cursor: mouse::Cursor, + value: Option<&Value>, + viewport: &Rectangle, + ) { + let state = tree.state.downcast_ref::>(); + let value = value.unwrap_or(&self.value); + let is_disabled = self.on_input.is_none(); + + let secure_value = self.is_secure.then(|| value.secure()); + let value = secure_value.as_ref().unwrap_or(value); + + let bounds = layout.bounds(); + + let mut children_layout = layout.children(); + let text_bounds = children_layout.next().unwrap().bounds(); + + let is_mouse_over = cursor.is_over(bounds); + + let status = if is_disabled { + Status::Disabled + } else if state.is_focused() { + Status::Focused + } else if is_mouse_over { + Status::Hovered + } else { + Status::Active + }; + + let style = Catalog::style(theme, &self.class, status); + + renderer.fill_quad( + renderer::Quad { + bounds, + border: style.border, + ..renderer::Quad::default() + }, + style.background, + ); + + if self.icon.is_some() { + let icon_layout = children_layout.next().unwrap(); + + renderer.fill_paragraph( + state.icon.raw(), + icon_layout.bounds().center(), + style.icon, + *viewport, + ); + } + + let text = value.to_string(); + + let (cursor, offset, is_selecting) = if let Some(focus) = state + .is_focused + .as_ref() + .filter(|focus| focus.is_window_focused) + { + match state.cursor.state(value) { + cursor::State::Index(position) => { + let (text_value_width, offset) = + measure_cursor_and_scroll_offset(state.value.raw(), text_bounds, position); + + let is_cursor_visible = !is_disabled + && ((focus.now - focus.updated_at).as_millis() + / CURSOR_BLINK_INTERVAL_MILLIS) + % 2 + == 0; + + let cursor = if is_cursor_visible { + Some(( + renderer::Quad { + bounds: Rectangle { + x: (text_bounds.x + text_value_width).floor(), + y: text_bounds.y, + width: 1.0, + height: text_bounds.height, + }, + ..renderer::Quad::default() + }, + style.value, + )) + } else { + None + }; + + (cursor, offset, false) + } + 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(state.value.raw(), text_bounds, left); + + let (right_position, right_offset) = + measure_cursor_and_scroll_offset(state.value.raw(), text_bounds, right); + + let width = right_position - left_position; + + ( + Some(( + renderer::Quad { + bounds: Rectangle { + x: text_bounds.x + left_position, + y: text_bounds.y, + width, + height: text_bounds.height, + }, + ..renderer::Quad::default() + }, + style.selection, + )), + if end == right { + right_offset + } else { + left_offset + }, + true, + ) + } + } + } else { + (None, 0.0, false) + }; + + let draw = |renderer: &mut Renderer, viewport| { + let paragraph = if text.is_empty() { + state.placeholder.raw() + } else { + state.value.raw() + }; + + let alignment_offset = + alignment_offset(text_bounds.width, paragraph.min_width(), self.alignment); + + if let Some((cursor, color)) = cursor { + renderer.with_translation( + Vector::new(alignment_offset - offset, 0.0), + |renderer| { + renderer.fill_quad(cursor, color); + }, + ); + } else { + renderer.with_translation(Vector::ZERO, |_| {}); + } + + renderer.fill_paragraph( + paragraph, + Point::new(text_bounds.x, text_bounds.center_y()) + + Vector::new(alignment_offset - offset, 0.0), + if text.is_empty() { + style.placeholder + } else { + style.value + }, + viewport, + ); + }; + + if is_selecting { + renderer.with_layer(text_bounds, |renderer| draw(renderer, *viewport)); + } else { + draw(renderer, text_bounds); + } + } +} + +impl<'a, Message, Theme, Renderer> Widget + for TextInput<'a, Message, Theme, Renderer> +where + Message: Clone, + Theme: Catalog + menu::Catalog, + Renderer: text::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::>() + } + + fn state(&self) -> tree::State { + tree::State::new(State::::new()) + } + + fn diff(&self, tree: &mut Tree) { + let state = tree.state.downcast_mut::>(); + + // Stop pasting if input becomes disabled + if self.on_input.is_none() { + state.is_pasting = None; + } + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: Length::Shrink, + } + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.layout(tree, renderer, limits, None) + } + + fn operate( + &self, + tree: &mut Tree, + _layout: Layout<'_>, + _renderer: &Renderer, + operation: &mut dyn Operation, + ) { + let state = tree.state.downcast_mut::>(); + + operation.focusable(state, self.id.as_ref().map(|id| &id.0)); + operation.text_input(state, self.id.as_ref().map(|id| &id.0)); + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) -> event::Status { + let update_cache = |state, value| { + replace_paragraph( + renderer, + state, + layout, + value, + self.font, + self.size, + self.line_height, + ); + }; + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { + let state = tree.state.downcast_mut::>(); + + if state.menu.is_open { + // Event wasn't processed by overlay, so cursor was clicked either outside its + // bounds or on the drop-down, either way we close the overlay. + state.menu.is_open = false; + + return event::Status::Captured; + } else if cursor.is_over(layout.bounds()) { + state.menu.is_open = true; + return event::Status::Captured; + } else { + return event::Status::Ignored; + } + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + let state = state::(tree); + // Event wasn't processed by overlay, so cursor was clicked either outside its + // bounds or on the drop-down, either way we close the overlay. + if state.menu.is_open { + state.menu.is_open = false; + } + + let click_position = cursor.position_over(layout.bounds()); + + state.is_focused = if click_position.is_some() { + state.is_focused.or_else(|| { + let now = Instant::now(); + + Some(Focus { + updated_at: now, + now, + is_window_focused: true, + }) + }) + } else { + None + }; + + if let Some(cursor_position) = click_position { + let text_layout = layout.children().next().unwrap(); + + let target = { + let text_bounds = text_layout.bounds(); + + let alignment_offset = alignment_offset( + text_bounds.width, + state.value.raw().min_width(), + self.alignment, + ); + + cursor_position.x - text_bounds.x - alignment_offset + }; + + let click = + mouse::Click::new(cursor_position, mouse::Button::Left, state.last_click); + + match click.kind() { + click::Kind::Single => { + let position = if target > 0.0 { + let value = if self.is_secure { + self.value.secure() + } else { + self.value.clone() + }; + + find_cursor_position(text_layout.bounds(), &value, state, target) + } else { + None + } + .unwrap_or(0); + + if state.keyboard_modifiers.shift() { + state + .cursor + .select_range(state.cursor.start(&self.value), position); + } else { + state.cursor.move_to(position); + } + state.is_dragging = true; + } + click::Kind::Double => { + if self.is_secure { + state.cursor.select_all(&self.value); + } else { + let position = find_cursor_position( + text_layout.bounds(), + &self.value, + state, + target, + ) + .unwrap_or(0); + + state.cursor.select_range( + self.value.previous_start_of_word(position), + self.value.next_end_of_word(position), + ); + } + + state.is_dragging = false; + } + click::Kind::Triple => { + state.cursor.select_all(&self.value); + state.is_dragging = false; + } + } + + state.last_click = Some(click); + + return event::Status::Captured; + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) + | Event::Touch(touch::Event::FingerLost { .. }) => { + state::(tree).is_dragging = false; + } + Event::Mouse(mouse::Event::CursorMoved { position }) + | Event::Touch(touch::Event::FingerMoved { position, .. }) => { + let state = state::(tree); + + if state.is_dragging { + let text_layout = layout.children().next().unwrap(); + + let target = { + let text_bounds = text_layout.bounds(); + + let alignment_offset = alignment_offset( + text_bounds.width, + state.value.raw().min_width(), + self.alignment, + ); + + position.x - text_bounds.x - alignment_offset + }; + + let value = if self.is_secure { + self.value.secure() + } else { + self.value.clone() + }; + + let position = + find_cursor_position(text_layout.bounds(), &value, state, target) + .unwrap_or(0); + + state + .cursor + .select_range(state.cursor.start(&value), position); + + return event::Status::Captured; + } + } + Event::Keyboard(keyboard::Event::KeyPressed { key, text, .. }) => { + let state = state::(tree); + + if let Some(focus) = &mut state.is_focused { + let modifiers = state.keyboard_modifiers; + focus.updated_at = Instant::now(); + + match key.as_ref() { + keyboard::Key::Character("c") + if state.keyboard_modifiers.command() && !self.is_secure => + { + if let Some((start, end)) = state.cursor.selection(&self.value) { + clipboard.write( + clipboard::Kind::Standard, + self.value.select(start, end).to_string(), + ); + } + + return event::Status::Captured; + } + keyboard::Key::Character("x") + if state.keyboard_modifiers.command() && !self.is_secure => + { + let Some(on_input) = &self.on_input else { + return event::Status::Ignored; + }; + + if let Some((start, end)) = state.cursor.selection(&self.value) { + clipboard.write( + clipboard::Kind::Standard, + self.value.select(start, end).to_string(), + ); + } + + let mut editor = Editor::new(&mut self.value, &mut state.cursor); + editor.delete(); + + let message = (on_input)(editor.contents()); + shell.publish(message); + + update_cache(state, &self.value); + + return event::Status::Captured; + } + keyboard::Key::Character("v") + if state.keyboard_modifiers.command() + && !state.keyboard_modifiers.alt() => + { + let Some(on_input) = &self.on_input else { + return event::Status::Ignored; + }; + + let content = match state.is_pasting.take() { + Some(content) => content, + None => { + let content: String = clipboard + .read(clipboard::Kind::Standard) + .unwrap_or_default() + .chars() + .filter(|c| !c.is_control()) + .collect(); + + Value::new(&content) + } + }; + + let mut editor = Editor::new(&mut self.value, &mut state.cursor); + + editor.paste(content.clone()); + + let message = if let Some(paste) = &self.on_paste { + (paste)(editor.contents()) + } else { + (on_input)(editor.contents()) + }; + shell.publish(message); + + state.is_pasting = Some(content); + + update_cache(state, &self.value); + + return event::Status::Captured; + } + keyboard::Key::Character("a") if state.keyboard_modifiers.command() => { + state.cursor.select_all(&self.value); + + return event::Status::Captured; + } + _ => {} + } + + if let Some(text) = text { + let Some(on_input) = &self.on_input else { + return event::Status::Ignored; + }; + + state.is_pasting = None; + + if let Some(c) = text.chars().next().filter(|c| !c.is_control()) { + let mut editor = Editor::new(&mut self.value, &mut state.cursor); + + editor.insert(c); + + let message = (on_input)(editor.contents()); + shell.publish(message); + + focus.updated_at = Instant::now(); + + update_cache(state, &self.value); + + return event::Status::Captured; + } + } + + match key.as_ref() { + keyboard::Key::Named(key::Named::Enter) => { + if let Some(on_submit) = self.on_submit.clone() { + shell.publish(on_submit); + } + } + keyboard::Key::Named(key::Named::Backspace) => { + let Some(on_input) = &self.on_input else { + return event::Status::Ignored; + }; + + if modifiers.jump() && state.cursor.selection(&self.value).is_none() { + if self.is_secure { + let cursor_pos = state.cursor.end(&self.value); + state.cursor.select_range(0, cursor_pos); + } else { + state.cursor.select_left_by_words(&self.value); + } + } + + let mut editor = Editor::new(&mut self.value, &mut state.cursor); + editor.backspace(); + + let message = (on_input)(editor.contents()); + shell.publish(message); + + update_cache(state, &self.value); + } + keyboard::Key::Named(key::Named::Delete) => { + let Some(on_input) = &self.on_input else { + return event::Status::Ignored; + }; + + if modifiers.jump() && state.cursor.selection(&self.value).is_none() { + if self.is_secure { + let cursor_pos = state.cursor.end(&self.value); + state.cursor.select_range(cursor_pos, self.value.len()); + } else { + state.cursor.select_right_by_words(&self.value); + } + } + + let mut editor = Editor::new(&mut self.value, &mut state.cursor); + editor.delete(); + + let message = (on_input)(editor.contents()); + shell.publish(message); + + update_cache(state, &self.value); + } + keyboard::Key::Named(key::Named::Home) => { + if modifiers.shift() { + state + .cursor + .select_range(state.cursor.start(&self.value), 0); + } else { + state.cursor.move_to(0); + } + } + keyboard::Key::Named(key::Named::End) => { + if modifiers.shift() { + state.cursor.select_range( + state.cursor.start(&self.value), + self.value.len(), + ); + } else { + state.cursor.move_to(self.value.len()); + } + } + keyboard::Key::Named(key::Named::ArrowLeft) + if modifiers.macos_command() => + { + if modifiers.shift() { + state + .cursor + .select_range(state.cursor.start(&self.value), 0); + } else { + state.cursor.move_to(0); + } + } + keyboard::Key::Named(key::Named::ArrowRight) + if modifiers.macos_command() => + { + if modifiers.shift() { + state.cursor.select_range( + state.cursor.start(&self.value), + self.value.len(), + ); + } else { + state.cursor.move_to(self.value.len()); + } + } + keyboard::Key::Named(key::Named::ArrowLeft) => { + if modifiers.jump() && !self.is_secure { + if modifiers.shift() { + state.cursor.select_left_by_words(&self.value); + } else { + state.cursor.move_left_by_words(&self.value); + } + } else if modifiers.shift() { + state.cursor.select_left(&self.value); + } else { + state.cursor.move_left(&self.value); + } + } + keyboard::Key::Named(key::Named::ArrowRight) => { + if modifiers.jump() && !self.is_secure { + if modifiers.shift() { + state.cursor.select_right_by_words(&self.value); + } else { + state.cursor.move_right_by_words(&self.value); + } + } else if modifiers.shift() { + state.cursor.select_right(&self.value); + } else { + state.cursor.move_right(&self.value); + } + } + keyboard::Key::Named(key::Named::Escape) => { + state.is_focused = None; + state.is_dragging = false; + state.is_pasting = None; + + state.keyboard_modifiers = keyboard::Modifiers::default(); + } + keyboard::Key::Named( + key::Named::Tab | key::Named::ArrowUp | key::Named::ArrowDown, + ) => { + return event::Status::Ignored; + } + _ => {} + } + + return event::Status::Captured; + } + } + Event::Keyboard(keyboard::Event::KeyReleased { key, .. }) => { + let state = state::(tree); + + if state.is_focused.is_some() { + match key.as_ref() { + keyboard::Key::Character("v") => { + state.is_pasting = None; + } + keyboard::Key::Named( + key::Named::Tab | key::Named::ArrowUp | key::Named::ArrowDown, + ) => { + return event::Status::Ignored; + } + _ => {} + } + + return event::Status::Captured; + } + + state.is_pasting = None; + } + Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { + let state = state::(tree); + + state.keyboard_modifiers = modifiers; + } + Event::Window(window::Event::Unfocused) => { + let state = state::(tree); + + if let Some(focus) = &mut state.is_focused { + focus.is_window_focused = false; + } + } + Event::Window(window::Event::Focused) => { + let state = state::(tree); + + if let Some(focus) = &mut state.is_focused { + focus.is_window_focused = true; + focus.updated_at = Instant::now(); + + shell.request_redraw(window::RedrawRequest::NextFrame); + } + } + Event::Window(window::Event::RedrawRequested(now)) => { + let state = state::(tree); + + if let Some(focus) = &mut state.is_focused { + if focus.is_window_focused { + focus.now = now; + + let millis_until_redraw = CURSOR_BLINK_INTERVAL_MILLIS + - (now - focus.updated_at).as_millis() % CURSOR_BLINK_INTERVAL_MILLIS; + + shell.request_redraw(window::RedrawRequest::At( + now + Duration::from_millis(millis_until_redraw as u64), + )); + } + } + } + _ => {} + } + + event::Status::Ignored + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.draw(tree, renderer, theme, layout, cursor, None, viewport); + } + + fn mouse_interaction( + &self, + _state: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + if cursor.is_over(layout.bounds()) { + if self.on_input.is_none() { + mouse::Interaction::Idle + } else { + mouse::Interaction::Text + } + } else { + mouse::Interaction::default() + } + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + translation: Vector, + ) -> Option> { + let state = tree.state.downcast_mut::>(); + let font = self.font.unwrap_or_else(|| renderer.default_font()); + let text_size = self.size.unwrap_or_else(|| renderer.default_size()); + + if state.menu.is_open { + let bounds = layout.bounds(); + let cursor_state = state.cursor.state(&self.value); + let grapheme_position = state + .value + .raw() + .grapheme_position( + 0, + match cursor_state { + cursor::State::Index(i) => i, + cursor::State::Selection { end, .. } => end, + }, + ) + .unwrap_or(Point::ORIGIN); + + let overlay_translate = Vector { + x: if grapheme_position.x > bounds.width { + bounds.width + } else { + grapheme_position.x + }, + y: 0.0, + }; + + let mut children_layout = layout.children(); + let text_bounds = children_layout.next().unwrap().bounds(); + let selected_text = if let Some((start, end)) = state.cursor.selection(&self.value) { + self.value.select(start, end).to_string() + } else { + String::new() + }; + + let value = &mut self.value; + let line_height = iced_core::text::LineHeight::Absolute(1.0.into()); + let is_open = &mut state.menu.is_open; + let cursor = &mut state.cursor; + let state_value = &mut state.value; + let on_input = &mut self.on_input; + let on_paste = &mut self.on_paste; + let menu = super::menu::Menu::new( + selected_text, + &mut state.menu.menu, + &mut state.menu.hovered_option, + move |content| { + *is_open = false; + if let Some(content) = content { + let content = Value::new(&content); + let mut editor = Editor::new(value, cursor); + + editor.paste(content.clone()); + let message = if let Some(on_paste) = on_paste { + Some((on_paste)(editor.contents())) + } else { + on_input + .as_ref() + .map(|on_input| (on_input)(editor.contents())) + }; + + *state_value = paragraph::Plain::new(Text { + font, + line_height, + content: &value.to_string(), + bounds: Size::new(f32::INFINITY, text_bounds.height), + size: text_size, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Top, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::default(), + }); + + message + } else { + None + } + }, + &self.menu_class, + ) + .width(100.0) + .padding(self.padding) + .font(font) + .text_shaping(self.text_shaping); + + Some(menu.overlay( + layout.position() + translation + overlay_translate, + bounds.height, + )) + } else { + None + } + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: Clone + 'a, + Theme: Catalog + iced::overlay::menu::Catalog + 'a, + Renderer: text::Renderer + 'a, +{ + fn from( + text_input: TextInput<'a, Message, Theme, Renderer>, + ) -> Element<'a, Message, Theme, 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, +} + +/// The identifier of a [`TextInput`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Id(widget::Id); + +impl Id { + /// Creates a custom [`Id`]. + pub fn new(id: impl Into>) -> Self { + Self(widget::Id::new(id)) + } + + /// Creates a unique [`Id`]. + /// + /// This function produces a different [`Id`] every time it is called. + pub fn unique() -> Self { + Self(widget::Id::unique()) + } +} + +impl From for widget::Id { + fn from(id: Id) -> Self { + id.0 + } +} + +impl From<&'static str> for Id { + fn from(id: &'static str) -> Self { + Self::new(id) + } +} + +impl From for Id { + fn from(id: String) -> Self { + Self::new(id) + } +} + +/// Produces a [`Task`] that focuses the [`TextInput`] with the given [`Id`]. +pub fn focus(id: impl Into) -> Task { + task::effect(Action::widget(operation::focusable::focus(id.into().0))) +} + +/// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the +/// end. +pub fn move_cursor_to_end(id: impl Into) -> Task { + task::effect(Action::widget(operation::text_input::move_cursor_to_end( + id.into().0, + ))) +} + +/// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the +/// front. +pub fn move_cursor_to_front(id: impl Into) -> Task { + task::effect(Action::widget(operation::text_input::move_cursor_to_front( + id.into().0, + ))) +} + +/// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the +/// provided position. +pub fn move_cursor_to(id: impl Into, position: usize) -> Task { + task::effect(Action::widget(operation::text_input::move_cursor_to( + id.into().0, + position, + ))) +} + +/// Produces a [`Task`] that selects all the content of the [`TextInput`] with the given [`Id`]. +pub fn select_all(id: impl Into) -> Task { + task::effect(Action::widget(operation::text_input::select_all( + id.into().0, + ))) +} + +/// The state of a [`TextInput`]. +#[derive(Debug, Default)] +pub struct State { + value: paragraph::Plain

, + placeholder: paragraph::Plain

, + icon: paragraph::Plain

, + is_focused: Option, + is_dragging: bool, + is_pasting: Option, + last_click: Option, + cursor: Cursor, + keyboard_modifiers: keyboard::Modifiers, + // TODO: Add stateful horizontal scrolling offset + menu: MenuState, +} + +#[derive(Debug, Default)] +pub struct MenuState { + menu: super::menu::State, + is_open: bool, + hovered_option: Option, +} + +fn state(tree: &mut Tree) -> &mut State { + tree.state.downcast_mut::>() +} + +#[derive(Debug, Clone, Copy)] +struct Focus { + updated_at: Instant, + now: Instant, + is_window_focused: bool, +} + +impl State

{ + /// Creates a new [`State`], representing an unfocused [`TextInput`]. + pub fn new() -> Self { + Self::default() + } + + /// Returns whether the [`TextInput`] is currently focused or not. + pub fn is_focused(&self) -> bool { + self.is_focused.is_some() + } + + /// Returns the [`Cursor`] of the [`TextInput`]. + pub fn cursor(&self) -> Cursor { + self.cursor + } + + /// Focuses the [`TextInput`]. + pub fn focus(&mut self) { + let now = Instant::now(); + + self.is_focused = Some(Focus { + updated_at: now, + now, + is_window_focused: true, + }); + + 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); + } +} + +fn offset(text_bounds: Rectangle, value: &Value, state: &State

) -> f32 { + 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(state.value.raw(), text_bounds, focus_position); + + offset + } else { + 0.0 + } +} + +fn measure_cursor_and_scroll_offset( + paragraph: &impl text::Paragraph, + text_bounds: Rectangle, + cursor_index: usize, +) -> (f32, f32) { + let grapheme_position = paragraph + .grapheme_position(0, cursor_index) + .unwrap_or(Point::ORIGIN); + + let offset = ((grapheme_position.x + 5.0) - text_bounds.width).max(0.0); + + (grapheme_position.x, offset) +} + +/// Computes the position of the text cursor at the given X coordinate of +/// a [`TextInput`]. +fn find_cursor_position( + text_bounds: Rectangle, + value: &Value, + state: &State

, + x: f32, +) -> Option { + let offset = offset(text_bounds, value, state); + let value = value.to_string(); + + let char_offset = state + .value + .raw() + .hit_test(Point::new(x + offset, text_bounds.height / 2.0)) + .map(text::Hit::cursor)?; + + Some( + unicode_segmentation::UnicodeSegmentation::graphemes( + &value[..char_offset.min(value.len())], + true, + ) + .count(), + ) +} + +fn replace_paragraph( + renderer: &Renderer, + state: &mut State, + layout: Layout<'_>, + value: &Value, + font: Option, + text_size: Option, + line_height: text::LineHeight, +) where + Renderer: text::Renderer, +{ + let font = font.unwrap_or_else(|| renderer.default_font()); + let text_size = text_size.unwrap_or_else(|| renderer.default_size()); + + let mut children_layout = layout.children(); + let text_bounds = children_layout.next().unwrap().bounds(); + + state.value = paragraph::Plain::new(Text { + font, + line_height, + content: &value.to_string(), + bounds: Size::new(f32::INFINITY, text_bounds.height), + size: text_size, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Top, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::default(), + }); +} + +const CURSOR_BLINK_INTERVAL_MILLIS: u128 = 500; + +fn alignment_offset( + text_bounds_width: f32, + text_min_width: f32, + alignment: alignment::Horizontal, +) -> f32 { + if text_min_width > text_bounds_width { + 0.0 + } else { + match alignment { + alignment::Horizontal::Left => 0.0, + alignment::Horizontal::Center => (text_bounds_width - text_min_width) / 2.0, + alignment::Horizontal::Right => text_bounds_width - text_min_width, + } + } +}