use iced::advanced::clipboard::{self, Clipboard}; use iced::advanced::layout; use iced::advanced::mouse; use iced::advanced::overlay; use iced::advanced::renderer; use iced::advanced::text; use iced::advanced::widget::tree::{self, Tree}; use iced::advanced::widget::{self, Widget}; use iced::advanced::{Layout, Shell}; use iced::widget::text_input; use iced::{ alignment, Background, Border, Color, Element, Event, Length, Padding, Pixels, Point, Rectangle, Shadow, Size, Vector, }; use std::rc::Rc; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Edit { pub value: String, pub cursor: usize, } pub fn copy_selection(value: &str, start: usize, end: usize) -> Option { let value = text_input::Value::new(value); let (start, end) = normalized_range(&value, start, end); (start != end).then(|| value.select(start, end).to_string()) } pub fn cut_selection( value: &str, start: usize, end: usize, ) -> (Edit, Option) { let mut value = text_input::Value::new(value); let (start, end) = normalized_range(&value, start, end); if start == end { return ( Edit { value: value.to_string(), cursor: start, }, None, ); } let selected = value.select(start, end).to_string(); value.remove_many(start, end); ( Edit { value: value.to_string(), cursor: start, }, Some(selected), ) } pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit { let mut value = text_input::Value::new(value); let (start, end) = normalized_range(&value, start, end); let clip = text_input::Value::new(clip); let cursor = start + clip.len(); if start != end { value.remove_many(start, end); } value.insert_many(start, clip); Edit { value: value.to_string(), cursor, } } pub fn select_all_range(value: &str) -> (usize, usize) { let value = text_input::Value::new(value); (0, value.len()) } fn normalized_range( value: &text_input::Value, start: usize, end: usize, ) -> (usize, usize) { let len = value.len(); (start.min(end).min(len), start.max(end).min(len)) } type InputStyleFn<'a, Theme> = Rc text_input::Style + 'a>; pub fn context_input<'a, Message, Theme, Renderer>( placeholder: &str, value: &str, ) -> ContextInput<'a, Message, Theme, Renderer> where Message: Clone + 'a, Theme: text_input::Catalog + 'a, Renderer: text::Renderer, { ContextInput::new(placeholder, value) } pub fn locked_value<'a, Message, Theme, Renderer>( value: &str, noop: Message, ) -> ContextInput<'a, Message, Theme, Renderer> where Message: Clone + 'a, Theme: text_input::Catalog + 'a, Renderer: text::Renderer, { ContextInput::new("", value) .on_input(move |_| noop.clone()) .locked(true) } pub struct ContextInput< 'a, Message, Theme = iced::Theme, Renderer = iced::Renderer, > where Theme: text_input::Catalog, Renderer: text::Renderer, { input: text_input::TextInput<'a, Message, Theme, Renderer>, value: String, is_secure: bool, locked: bool, on_input: Option Message + 'a>>, on_paste: Option Message + 'a>>, style: Option>, } impl<'a, Message, Theme, Renderer> ContextInput<'a, Message, Theme, Renderer> where Message: Clone + 'a, Theme: text_input::Catalog + 'a, Renderer: text::Renderer, { pub fn new(placeholder: &str, value: &str) -> Self { Self { input: text_input::TextInput::new(placeholder, value), value: value.to_owned(), is_secure: false, locked: false, on_input: None, on_paste: None, style: None, } } pub fn id(mut self, id: impl Into) -> Self { self.input = self.input.id(id); self } pub fn secure(mut self, is_secure: bool) -> Self { self.is_secure = is_secure; self.input = self.input.secure(is_secure); self } pub fn locked(mut self, yes: bool) -> Self { self.locked = yes; self } pub fn on_input( mut self, on_input: impl Fn(String) -> Message + 'a, ) -> Self { let on_input: Rc Message + 'a> = Rc::new(on_input); let input_callback = Rc::clone(&on_input); self.input = self.input.on_input(move |value| input_callback.as_ref()(value)); self.on_input = Some(on_input); self } pub fn on_submit(mut self, message: Message) -> Self { self.input = self.input.on_submit(message); self } pub fn on_submit_maybe(mut self, message: Option) -> Self { self.input = self.input.on_submit_maybe(message); self } pub fn on_paste( mut self, on_paste: impl Fn(String) -> Message + 'a, ) -> Self { let on_paste: Rc Message + 'a> = Rc::new(on_paste); let paste_callback = Rc::clone(&on_paste); self.input = self.input.on_paste(move |value| paste_callback.as_ref()(value)); self.on_paste = Some(on_paste); self } pub fn font(mut self, font: Renderer::Font) -> Self { self.input = self.input.font(font); self } pub fn icon(mut self, icon: text_input::Icon) -> Self { self.input = self.input.icon(icon); self } pub fn width(mut self, width: impl Into) -> Self { self.input = self.input.width(width); self } pub fn padding>(mut self, padding: P) -> Self { self.input = self.input.padding(padding); self } pub fn size(mut self, size: impl Into) -> Self { self.input = self.input.size(size); self } pub fn line_height( mut self, line_height: impl Into, ) -> Self { self.input = self.input.line_height(line_height); self } pub fn align_x( mut self, alignment: impl Into, ) -> Self { self.input = self.input.align_x(alignment); self } pub fn style( mut self, style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a, ) -> Self where Theme::Class<'a>: From>, { let style: InputStyleFn<'a, Theme> = Rc::new(style); let input_style = Rc::clone(&style); self.input = self .input .style(move |theme, status| input_style.as_ref()(theme, status)); self.style = Some(style); self } pub fn class(mut self, class: impl Into>) -> Self { self.input = self.input.class(class); self.style = None; self } } #[derive(Default)] struct ContextInputState { menu: Option, } #[derive(Debug, Clone, Copy)] struct MenuState { anchor: Point, selection: (usize, usize), } impl Widget for ContextInput<'_, Message, Theme, Renderer> where Message: Clone, Theme: text_input::Catalog, Renderer: text::Renderer, { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { tree::State::new(ContextInputState::default()) } fn children(&self) -> Vec { vec![Tree::new(&self.input as &dyn Widget<_, _, _>)] } fn diff(&self, tree: &mut Tree) { if tree.children.is_empty() { tree.children .push(Tree::new(&self.input as &dyn Widget<_, _, _>)); } else { tree.children[0].diff(&self.input as &dyn Widget<_, _, _>); tree.children.truncate(1); } } fn size(&self) -> Size { Widget::size(&self.input) } fn size_hint(&self) -> Size { Widget::size_hint(&self.input) } fn layout( &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { Widget::layout(&mut self.input, &mut tree.children[0], renderer, limits) } fn operate( &mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn widget::Operation, ) { Widget::operate( &mut self.input, &mut tree.children[0], layout, renderer, operation, ); } fn update( &mut self, tree: &mut Tree, event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) { let right_click_on_input = matches!( event, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) ) && cursor.is_over(layout.bounds()); if right_click_on_input { let value = text_input::Value::new(&self.value); let input_state = tree.children[0] .state .downcast_ref::>(); let selection = match input_state.cursor().state(&value) { text_input::cursor::State::Index(index) => { let index = index.min(value.len()); (index, index) } text_input::cursor::State::Selection { start, end } => { normalized_range(&value, start, end) } }; tree.state.downcast_mut::().menu = cursor.position().map(|anchor| MenuState { anchor, selection, }); shell.capture_event(); shell.request_redraw(); return; } Widget::update( &mut self.input, &mut tree.children[0], event, layout, cursor, renderer, clipboard, shell, viewport, ); } fn draw( &self, tree: &Tree, renderer: &mut Renderer, theme: &Theme, style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, ) { Widget::draw( &self.input, &tree.children[0], renderer, theme, style, layout, cursor, viewport, ); } fn mouse_interaction( &self, tree: &Tree, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, renderer: &Renderer, ) -> mouse::Interaction { Widget::mouse_interaction( &self.input, &tree.children[0], layout, cursor, viewport, renderer, ) } fn overlay<'a>( &'a mut self, tree: &'a mut Tree, _layout: Layout<'a>, _renderer: &Renderer, _viewport: &Rectangle, _translation: Vector, ) -> Option> { let Tree { state, children, .. } = tree; let menu = &mut state.downcast_mut::().menu; if menu.is_none() { return None; } let input_state = children[0] .state .downcast_mut::>(); Some(overlay::Element::new(Box::new(ContextMenuOverlay { menu, input_state, value: &self.value, is_secure: self.is_secure, locked: self.locked, on_input: self.on_input.clone(), on_paste: self.on_paste.clone(), style: self.style.clone(), }))) } } impl<'a, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> where Message: Clone + 'a, Theme: text_input::Catalog + 'a, Renderer: text::Renderer + 'a, { fn from( input: ContextInput<'a, Message, Theme, Renderer>, ) -> Element<'a, Message, Theme, Renderer> { Element::new(input) } } struct ContextMenuOverlay<'a, Message, Theme, Renderer> where Theme: text_input::Catalog, Renderer: text::Renderer, { menu: &'a mut Option, input_state: &'a mut text_input::State, value: &'a str, is_secure: bool, locked: bool, on_input: Option Message + 'a>>, on_paste: Option Message + 'a>>, style: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MenuAction { Cut, Copy, Paste, SelectAll, } impl MenuAction { const ALL: [Self; 4] = [ Self::Cut, Self::Copy, Self::Paste, Self::SelectAll, ]; fn label(self) -> &'static str { match self { Self::Cut => "Cut", Self::Copy => "Copy", Self::Paste => "Paste", Self::SelectAll => "Select All", } } } const MENU_WIDTH: f32 = 136.0; const ITEM_HEIGHT: f32 = 28.0; const TEXT_SIZE: f32 = 13.0; const MENU_PADDING_X: f32 = 10.0; impl overlay::Overlay for ContextMenuOverlay<'_, Message, Theme, Renderer> where Theme: text_input::Catalog, Renderer: text::Renderer, { fn layout(&mut self, _renderer: &Renderer, bounds: Size) -> layout::Node { let size = Size::new(MENU_WIDTH, ITEM_HEIGHT * MenuAction::ALL.len() as f32); let Some(menu) = self.menu.as_ref() else { return layout::Node::new(Size::ZERO); }; let x = menu.anchor.x.min((bounds.width - size.width).max(0.0)); let y = menu.anchor.y.min((bounds.height - size.height).max(0.0)); layout::Node::new(size).move_to(Point::new(x.max(0.0), y.max(0.0))) } fn draw( &self, renderer: &mut Renderer, theme: &Theme, _style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, ) { let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active); let hovered_style = input_style(theme, self.style.as_ref(), text_input::Status::Hovered); let bounds = layout.bounds(); let viewport = Rectangle::INFINITE; renderer.fill_quad( renderer::Quad { bounds, border: Border { radius: 5.0.into(), width: 1.0, color: active_style.border.color, }, shadow: Shadow { color: Color::from_rgba(0.0, 0.0, 0.0, 0.22), offset: Vector::new(0.0, 4.0), blur_radius: 10.0, }, ..renderer::Quad::default() }, active_style.background, ); for (index, action) in MenuAction::ALL.iter().copied().enumerate() { let item_bounds = item_bounds(bounds, index); let enabled = self.enabled(action); let hovered = enabled && cursor.is_over(item_bounds); if hovered { renderer.fill_quad( renderer::Quad { bounds: item_bounds, border: Border { radius: 3.0.into(), ..Border::default() }, ..renderer::Quad::default() }, Background::Color(hovered_style.selection), ); } renderer.fill_text( text::Text { content: action.label().to_owned(), bounds: Size::new(item_bounds.width - MENU_PADDING_X * 2.0, item_bounds.height), size: Pixels(TEXT_SIZE), line_height: text::LineHeight::default(), font: renderer.default_font(), align_x: text::Alignment::Default, align_y: alignment::Vertical::Center, shaping: text::Shaping::Advanced, wrapping: text::Wrapping::default(), }, Point::new(item_bounds.x + MENU_PADDING_X, item_bounds.center_y()), if enabled { active_style.value } else { disabled_color(active_style.value) }, viewport, ); } } fn update( &mut self, event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, _renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, ) { match event { Event::Keyboard(iced::keyboard::Event::KeyPressed { key: iced::keyboard::Key::Named( iced::keyboard::key::Named::Escape, ), .. }) => { self.close(shell); } Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { let Some(position) = cursor.position() else { self.close(shell); return; }; let bounds = layout.bounds(); if !bounds.contains(position) { self.close(shell); return; } if let Some(action) = self.hit_action(bounds, position) { if self.enabled(action) { self.perform(action, clipboard, shell); } self.close(shell); } } Event::Mouse(mouse::Event::ButtonPressed(_)) => { let should_close = cursor .position() .is_none_or(|position| !layout.bounds().contains(position)); if should_close { self.close(shell); } } _ => {} } } fn mouse_interaction( &self, layout: Layout<'_>, cursor: mouse::Cursor, _renderer: &Renderer, ) -> mouse::Interaction { let Some(position) = cursor.position() else { return mouse::Interaction::default(); }; if self.hit_action(layout.bounds(), position).is_some() { mouse::Interaction::Pointer } else { mouse::Interaction::default() } } } impl ContextMenuOverlay<'_, Message, Theme, Renderer> where Theme: text_input::Catalog, Renderer: text::Renderer, { fn enabled(&self, action: MenuAction) -> bool { let Some(menu) = self.menu.as_ref() else { return false; }; let has_selection = menu.selection.0 != menu.selection.1; let has_value = !text_input::Value::new(self.value).is_empty(); menu_action_enabled( action, has_selection, has_value, self.is_secure, self.locked, ) } fn hit_action( &self, bounds: Rectangle, position: Point, ) -> Option { if !bounds.contains(position) { return None; } let index = ((position.y - bounds.y) / ITEM_HEIGHT).floor() as usize; MenuAction::ALL.get(index).copied() } fn perform( &mut self, action: MenuAction, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, ) { let Some(menu) = self.menu.as_ref().copied() else { return; }; let (start, end) = menu.selection; match action { MenuAction::Cut => { let (edit, selected) = cut_selection(self.value, start, end); if let Some(selected) = selected { clipboard.write(clipboard::Kind::Standard, selected); self.publish_edit(edit, shell); } } MenuAction::Copy => { if let Some(selected) = copy_selection(self.value, start, end) { clipboard.write(clipboard::Kind::Standard, selected); } } MenuAction::Paste => { let clip = clipboard .read(clipboard::Kind::Standard) .unwrap_or_default() .chars() .filter(|c| !c.is_control()) .collect::(); let edit = paste(self.value, start, end, &clip); self.publish_paste(edit, shell); } MenuAction::SelectAll => { let (start, end) = select_all_range(self.value); self.input_state.select_range(start, end); shell.request_redraw(); } } } fn publish_edit(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) { if let Some(on_input) = &self.on_input { self.input_state.move_cursor_to(edit.cursor); shell.publish(on_input.as_ref()(edit.value)); shell.request_redraw(); } } fn publish_paste(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) { self.input_state.move_cursor_to(edit.cursor); if let Some(on_paste) = &self.on_paste { shell.publish(on_paste.as_ref()(edit.value)); } else if let Some(on_input) = &self.on_input { shell.publish(on_input.as_ref()(edit.value)); } shell.request_redraw(); } fn close(&mut self, shell: &mut Shell<'_, Message>) { *self.menu = None; shell.capture_event(); shell.request_redraw(); } } fn item_bounds(menu_bounds: Rectangle, index: usize) -> Rectangle { Rectangle { x: menu_bounds.x + 3.0, y: menu_bounds.y + 3.0 + ITEM_HEIGHT * index as f32, width: menu_bounds.width - 6.0, height: ITEM_HEIGHT, } } fn input_style( theme: &Theme, style: Option<&InputStyleFn<'_, Theme>>, status: text_input::Status, ) -> text_input::Style { if let Some(style) = style { style.as_ref()(theme, status) } else { let class = ::default(); theme.style(&class, status) } } fn disabled_color(color: Color) -> Color { Color { a: color.a * 0.45, ..color } } fn menu_action_enabled( action: MenuAction, has_selection: bool, has_value: bool, is_secure: bool, locked: bool, ) -> bool { match action { MenuAction::Cut => has_selection && !is_secure && !locked, MenuAction::Copy => has_selection && !is_secure, MenuAction::Paste => !locked, MenuAction::SelectAll => has_value, } } #[cfg(test)] mod tests { use super::*; #[test] fn copy_selection_returns_middle_substring_and_empty_none() { assert_eq!(copy_selection("abcdef", 2, 5), Some("cde".to_owned())); assert_eq!(copy_selection("abcdef", 3, 3), None); } #[test] fn cut_selection_removes_range_and_copies_selection() { let (edit, clip) = cut_selection("abcdef", 2, 5); assert_eq!( edit, Edit { value: "abf".to_owned(), cursor: 2, } ); assert_eq!(clip, Some("cde".to_owned())); let (edit, clip) = cut_selection("abcdef", 3, 3); assert_eq!( edit, Edit { value: "abcdef".to_owned(), cursor: 3, } ); assert_eq!(clip, None); } #[test] fn paste_replaces_selection_or_inserts_at_cursor() { assert_eq!( paste("abcdef", 2, 5, "XY"), Edit { value: "abXYf".to_owned(), cursor: 4, } ); assert_eq!( paste("abcdef", 3, 3, "XY"), Edit { value: "abcXYdef".to_owned(), cursor: 5, } ); } #[test] fn select_all_range_uses_grapheme_length() { assert_eq!(select_all_range(""), (0, 0)); assert_eq!(select_all_range("abé🦀"), (0, 4)); } #[test] fn unicode_selection_boundaries_are_grapheme_correct() { assert_eq!(copy_selection("aé🦀z", 1, 3), Some("é🦀".to_owned())); let (edit, clip) = cut_selection("aé🦀z", 2, 3); assert_eq!(clip, Some("🦀".to_owned())); assert_eq!( edit, Edit { value: "aéz".to_owned(), cursor: 2, } ); assert_eq!( paste("aéz", 2, 2, "🦀"), Edit { value: "aé🦀z".to_owned(), cursor: 3, } ); } #[test] fn locked_menu_allows_copy_and_select_all_only() { assert!(!menu_action_enabled(MenuAction::Cut, true, true, false, true)); assert!(menu_action_enabled(MenuAction::Copy, true, true, false, true)); assert!(!menu_action_enabled(MenuAction::Paste, true, true, false, true)); assert!(menu_action_enabled(MenuAction::SelectAll, true, true, false, true)); assert!(!menu_action_enabled(MenuAction::Copy, false, true, false, true)); assert!(!menu_action_enabled(MenuAction::SelectAll, false, false, false, true)); } }