Add overlay with copy paste to text_input

This commit is contained in:
edouardparis 2025-02-10 15:15:47 +01:00
parent 64dc91b578
commit 74f10a564f
4 changed files with 706 additions and 12 deletions

View File

@ -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,
};

556
liana-ui/src/widget/menu.rs Normal file
View File

@ -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<usize>,
on_select: Box<dyn FnMut(Option<String>) -> Option<Message> + 'a>,
width: f32,
padding: Padding,
text_size: Option<Pixels>,
text_line_height: text::LineHeight,
text_shaping: text::Shaping,
font: Option<Renderer::Font>,
class: &'a <Theme as Catalog>::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<usize>,
on_select: impl FnMut(Option<String>) -> Option<Message> + 'a,
class: &'a <Theme as Catalog>::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<P: Into<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<Renderer::Font>) -> 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 <Theme as Catalog>::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<Message, Theme, Renderer>
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<usize>,
on_select: Box<dyn FnMut(Option<String>) -> Option<Message> + 'a>,
padding: Padding,
text_size: Option<Pixels>,
text_line_height: text::LineHeight,
text_shaping: text::Shaping,
font: Option<Renderer::Font>,
class: &'a <Theme as Catalog>::Class<'b>,
}
impl<'a, 'b, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for List<'a, 'b, Message, Theme, Renderer>
where
Theme: Catalog,
Renderer: text::Renderer,
{
fn size(&self) -> Size<Length> {
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<List<'a, 'b, Message, Theme, Renderer>>
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)
}
}

View File

@ -1,5 +1,6 @@
mod cursor;
mod editor;
mod menu;
pub mod text_input;
use crate::theme::Theme;

View File

@ -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<Id>,
@ -41,12 +42,14 @@ where
padding: Padding,
size: Option<Pixels>,
line_height: text::LineHeight,
text_shaping: text::Shaping,
alignment: alignment::Horizontal,
on_input: Option<Box<dyn Fn(String) -> Message + 'a>>,
on_paste: Option<Box<dyn Fn(String) -> Message + 'a>>,
on_submit: Option<Message>,
icon: Option<Icon<Renderer::Font>>,
class: Theme::Class<'a>,
class: <Theme as Catalog>::Class<'a>,
menu_class: <Theme as menu::Catalog>::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: <Theme as Catalog>::default(),
menu_class: <Theme as menu::Catalog>::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<StyleFn<'a, Theme>>,
<Theme as Catalog>::Class<'a>: From<StyleFn<'a, Theme>>,
{
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<Theme::Class<'a>>) -> Self {
pub fn class(mut self, class: impl Into<<Theme as Catalog>::Class<'a>>) -> Self {
self.class = class.into();
self
}
@ -327,7 +332,7 @@ where
Status::Active
};
let style = theme.style(&self.class, status);
let style = Catalog::style(theme, &self.class, status);
renderer.fill_quad(
renderer::Quad {
@ -470,7 +475,7 @@ impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for TextInput<'a, Message, Theme, Renderer>
where
Message: Clone,
Theme: Catalog,
Theme: Catalog + menu::Catalog,
Renderer: text::Renderer,
{
fn tag(&self) -> tree::Tag {
@ -543,9 +548,30 @@ where
};
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
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::<Renderer>(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());
@ -1023,13 +1049,115 @@ where
mouse::Interaction::default()
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
translation: Vector,
) -> Option<iced_core::overlay::Element<'b, Message, Theme, Renderer>> {
let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
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<TextInput<'a, Message, Theme, Renderer>>
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(
@ -1137,7 +1265,7 @@ pub fn select_all<T>(id: impl Into<Id>) -> Task<T> {
}
/// The state of a [`TextInput`].
#[derive(Debug, Default, Clone)]
#[derive(Debug, Default)]
pub struct State<P: text::Paragraph> {
value: paragraph::Plain<P>,
placeholder: paragraph::Plain<P>,
@ -1149,6 +1277,14 @@ pub struct State<P: text::Paragraph> {
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<usize>,
}
fn state<Renderer: text::Renderer>(tree: &mut Tree) -> &mut State<Renderer::Paragraph> {