diff --git a/liana-ui/src/theme/overlay.rs b/liana-ui/src/theme/overlay.rs index 40f226496..40882e666 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/menu.rs b/liana-ui/src/widget/menu.rs new file mode 100644 index 000000000..d78e0a0df --- /dev/null +++ b/liana-ui/src/widget/menu.rs @@ -0,0 +1,553 @@ +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) { + 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 { .. }) => { + 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) { + 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 index e8f25ec23..9bfe4bcd5 100644 --- a/liana-ui/src/widget/mod.rs +++ b/liana-ui/src/widget/mod.rs @@ -1,5 +1,6 @@ mod cursor; mod editor; +mod menu; pub mod text_input; use crate::theme::Theme; diff --git a/liana-ui/src/widget/text_input.rs b/liana-ui/src/widget/text_input.rs index efdfc8905..b5e3c2b10 100644 --- a/liana-ui/src/widget/text_input.rs +++ b/liana-ui/src/widget/text_input.rs @@ -3,6 +3,7 @@ use super::{ 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}; @@ -27,9 +28,9 @@ use iced_runtime::task::{self, Task}; use iced_runtime::Action; #[allow(missing_debug_implementations)] -pub struct TextInput<'a, Message, Theme = iced::Theme, Renderer = iced::widget::Renderer> +pub struct TextInput<'a, Message, Theme = crate::theme::Theme, Renderer = iced::widget::Renderer> where - Theme: Catalog, + Theme: Catalog + menu::Catalog, Renderer: text::Renderer, { id: Option, @@ -41,12 +42,14 @@ where 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: Theme::Class<'a>, + class: ::Class<'a>, + menu_class: ::Class<'a>, } /// The default [`Padding`] of a [`TextInput`]. @@ -55,7 +58,7 @@ pub const DEFAULT_PADDING: Padding = Padding::new(5.0); impl<'a, Message, Theme, Renderer> TextInput<'a, Message, Theme, Renderer> where Message: Clone, - Theme: Catalog, + Theme: Catalog + iced::widget::overlay::menu::Catalog, Renderer: text::Renderer, { /// Creates a new [`TextInput`] with the given placeholder and @@ -71,12 +74,14 @@ where 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: Theme::default(), + class: ::default(), + menu_class: ::default(), } } @@ -186,7 +191,7 @@ where #[must_use] pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self where - Theme::Class<'a>: From>, + ::Class<'a>: From>, { self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); self @@ -194,7 +199,7 @@ where /// Sets the style class of the [`TextInput`]. #[must_use] - pub fn class(mut self, class: impl Into>) -> Self { + pub fn class(mut self, class: impl Into<::Class<'a>>) -> Self { self.class = class.into(); self } @@ -326,7 +331,7 @@ where Status::Active }; - let style = theme.style(&self.class, status); + let style = Catalog::style(theme, &self.class, status); renderer.fill_quad( renderer::Quad { @@ -469,7 +474,7 @@ impl<'a, Message, Theme, Renderer> Widget for TextInput<'a, Message, Theme, Renderer> where Message: Clone, - Theme: Catalog, + Theme: Catalog + menu::Catalog, Renderer: text::Renderer, { fn tag(&self) -> tree::Tag { @@ -542,9 +547,30 @@ where }; 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()); @@ -1022,13 +1048,95 @@ where 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 value_width = Vector { + x: state.value.min_width(), + 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 + value_width, bounds.height)) + } else { + None + } + } } impl<'a, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> where Message: Clone + 'a, - Theme: Catalog + 'a, + Theme: Catalog + iced::overlay::menu::Catalog + 'a, Renderer: text::Renderer + 'a, { fn from( @@ -1136,7 +1244,7 @@ pub fn select_all(id: impl Into) -> Task { } /// The state of a [`TextInput`]. -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default)] pub struct State { value: paragraph::Plain

, placeholder: paragraph::Plain

, @@ -1148,6 +1256,14 @@ pub struct State { 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 {