From d751855c74e81b84646b2dedf0bb09393c71b7b2 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 19 May 2026 19:36:50 -0600 Subject: [PATCH] feat: add Wayland popup context menu to TextInput --- src/widget/text_context_menu.rs | 10 - src/widget/text_input/input.rs | 380 +++++++++++++++++++++------ src/widget/wayland/tooltip/widget.rs | 2 - 3 files changed, 301 insertions(+), 91 deletions(-) diff --git a/src/widget/text_context_menu.rs b/src/widget/text_context_menu.rs index 6bf530c9..c8505c1a 100644 --- a/src/widget/text_context_menu.rs +++ b/src/widget/text_context_menu.rs @@ -109,16 +109,6 @@ fn send_popup_direct(settings: iced_runtime::platform_specific::wayland::popup:: ); } -/// Sends a destroy popup action directly to the SCTK event loop. -#[cfg(feature = "wayland")] -fn send_destroy_popup_direct(id: iced_core::window::Id) { - iced_winit::send_wayland_action_direct( - iced_runtime::platform_specific::wayland::Action::Popup( - iced_runtime::platform_specific::wayland::popup::Action::Destroy { id }, - ), - ); -} - /// Creates a context menu overlay for any widget implementing /// [`HasSelectableText`]. /// diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index d382a5a8..c46f895c 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -43,6 +43,12 @@ thread_local! { static LAST_FOCUS_UPDATE: LazyCell> = LazyCell::new(|| Cell::new(Instant::now())); } +/// Notify all text inputs that a different widget has taken focus. +/// This causes any focused text input to unfocus on its next layout. +pub fn notify_focus_change() { + LAST_FOCUS_UPDATE.with(|x| x.set(Instant::now())); +} + /// Creates a new [`TextInput`]. /// /// [`TextInput`]: widget::TextInput @@ -577,6 +583,17 @@ where self.drag_threshold = drag_threshold; self } + + fn uses_popup_context_menu(&self) -> bool { + #[cfg(feature = "wayland")] + if matches!( + crate::app::cosmic::WINDOWING_SYSTEM.get(), + Some(crate::app::cosmic::WindowingSystem::Wayland) + ) { + return true; + } + false + } } impl Widget for TextInput<'_, Message> @@ -843,6 +860,24 @@ where viewport: &Rectangle, translation: Vector, ) -> Option> { + if !self.uses_popup_context_menu() { + let has_context_menu = tree + .state + .downcast_ref::() + .context_menu_position + .is_some(); + if has_context_menu { + let menu_bar_state = tree.state.downcast_ref::().menu_bar_state.clone(); + return crate::widget::text_context_menu::context_menu_overlay( + self, + tree, + self.on_input.as_deref(), + translation, + menu_bar_state, + ); + } + } + let mut layout_ = Vec::with_capacity(2); if self.leading_icon.is_some() { let mut children = self.text_layout(layout).children(); @@ -857,7 +892,7 @@ where } layout_.push(children.next().unwrap()); }; - let children = self + let children: Vec> = self .leading_icon .iter_mut() .chain(self.trailing_icon.iter_mut()) @@ -868,7 +903,7 @@ where .as_widget_mut() .overlay(state, layout, renderer, viewport, translation) }) - .collect::>(); + .collect(); (!children.is_empty()).then(|| Group::with_children(children).overlay()) } @@ -936,6 +971,23 @@ where } } + // Unfocus on any click outside widget bounds. + if matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(_)) + | Event::Touch(touch::Event::FingerPressed { .. }) + ) && cursor_position.position_over(layout.bounds()).is_none() + { + let state = tree.state.downcast_mut::(); + state.is_focused = None; + state.context_menu_position = None; + state.dragging_state = None; + if let Some(on_unfocus) = self.on_unfocus.as_ref() { + shell.publish(on_unfocus.clone()); + } + return; + } + let state = tree.state.downcast_mut::(); if let Some(on_unfocus) = self.on_unfocus.as_ref() { @@ -977,6 +1029,77 @@ where self.always_active, ); + // On Wayland: if right-click just set context_menu_position, create a popup instead. + #[cfg(feature = "wayland")] + if matches!( + crate::app::cosmic::WINDOWING_SYSTEM.get(), + Some(crate::app::cosmic::WindowingSystem::Wayland) + ) { + let state = tree.state.downcast_ref::(); + if state.context_menu_position.is_some() { + let selected_text = state + .cursor() + .selection(&state.tracked_value) + .map(|(start, end)| state.tracked_value.select(start, end).to_string()); + let has_selection = selected_text.is_some(); + let click_position = state.context_menu_position.unwrap(); + let menu_bar_state = state.menu_bar_state.clone(); + let pending_action = state.pending_action.clone(); + + crate::widget::text_context_menu::create_text_context_popup( + click_position, + selected_text, + true, + has_selection, + &menu_bar_state, + &pending_action, + renderer, + viewport, + cursor_position, + ); + + let state = tree.state.downcast_mut::(); + state.context_menu_position = None; + } + + // Process deferred actions from the popup. + let state = tree.state.downcast_ref::(); + let pending_action = state.pending_action.clone(); + if let Some(action) = + crate::widget::text_context_menu::take_pending_action(&pending_action) + { + let state = tree.state.downcast_mut::(); + match action { + crate::widget::text_context_menu::TextCtxAction::Copy => {} + crate::widget::text_context_menu::TextCtxAction::Cut => { + let contents = state.delete_selection(); + if let Some(on_input) = self.on_input.as_deref() { + shell.publish((on_input)(contents)); + } + } + crate::widget::text_context_menu::TextCtxAction::Paste => { + let content: String = clipboard + .read(iced_core::clipboard::Kind::Standard) + .unwrap_or_default(); + let filtered: String = + content.chars().filter(|c| !c.is_control()).collect(); + let contents = state.paste_text(&filtered); + if let Some(on_input) = self.on_input.as_deref() { + shell.publish((on_input)(contents)); + } + } + crate::widget::text_context_menu::TextCtxAction::SelectAll => { + state.select_all(); + } + } + } + + // Cleanup popup if menu closed. + let state = tree.state.downcast_ref::(); + let menu_bar_state = state.menu_bar_state.clone(); + crate::widget::text_context_menu::cleanup_text_popup(&menu_bar_state); + } + let state = tree.state.downcast_mut::(); let value = if self.is_secure { self.value.secure() @@ -1461,11 +1584,44 @@ pub fn update<'a, Message: Clone + 'static>( #[cold] fn cold() {} + let state = state(); + + // Any click outside clears focus and selection. + if matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(_)) + | Event::Touch(touch::Event::FingerPressed { .. }) + ) && cursor.position_over(layout.bounds()).is_none() + { + state.is_focused = None; + state.context_menu_position = None; + state.dragging_state = None; + if let Some(on_unfocus) = on_unfocus { + shell.publish(on_unfocus.clone()); + } + return; + } + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { + if let Some(pos) = cursor.position_over(layout.bounds()) { + if !state.is_focused() { + state.focus(); + } + state.context_menu_position = Some(pos); + shell.capture_event(); + return; + } + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { cold(); - let state = state(); + + if state.context_menu_position.take().is_some() { + shell.capture_event(); + return; + } let click_position = if on_input.is_some() || manage_value { cursor.position_over(layout.bounds()) @@ -1694,7 +1850,6 @@ pub fn update<'a, Message: Clone + 'static>( Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) => { cold(); - let state = state(); #[cfg(wayland_platform)] if matches!(state.dragging_state, Some(DraggingState::PrepareDnd(_))) { // clear selection and place cursor at click position @@ -1722,8 +1877,6 @@ pub fn update<'a, Message: Clone + 'static>( } Event::Mouse(mouse::Event::CursorMoved { position }) | Event::Touch(touch::Event::FingerMoved { position, .. }) => { - let state = state(); - if matches!(state.dragging_state, Some(DraggingState::Selection)) { let target = { let text_bounds = text_layout.bounds(); @@ -1812,7 +1965,6 @@ pub fn update<'a, Message: Clone + 'static>( modifiers, .. }) => { - let state = state(); state.keyboard_modifiers = *modifiers; if let Some(focus) = state.is_focused.as_mut().filter(|f| f.focused) { @@ -2107,8 +2259,6 @@ pub fn update<'a, Message: Clone + 'static>( } } Event::Keyboard(keyboard::Event::KeyReleased { key, .. }) => { - let state = state(); - if state.is_focused() { match key { keyboard::Key::Character(c) if "v" == c => { @@ -2130,73 +2280,65 @@ pub fn update<'a, Message: Clone + 'static>( } } Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { - let state = state(); - state.keyboard_modifiers = *modifiers; } - Event::InputMethod(event) => { - let state = state(); - - match event { - input_method::Event::Opened | input_method::Event::Closed => { - state.preedit = matches!(event, input_method::Event::Opened) - .then(input_method::Preedit::new); - shell.capture_event(); - return; - } - input_method::Event::Preedit(content, selection) => { - if state.is_focused() { - state.preedit = Some(input_method::Preedit { - content: content.to_owned(), - selection: selection.clone(), - text_size: Some(size.into()), - }); - shell.capture_event(); - return; - } - } - input_method::Event::Commit(text) => { - let Some(focus) = state.is_focused.as_mut().filter(|f| f.focused) else { - return; - }; - let Some(on_input) = on_input else { - return; - }; - if state.is_read_only { - return; - } - - focus.updated_at = Instant::now(); - LAST_FOCUS_UPDATE.with(|x| x.set(focus.updated_at)); - - let mut editor = Editor::new(unsecured_value, &mut state.cursor); - editor.paste(Value::new(&text)); - - let contents = editor.contents(); - let unsecured_value = Value::new(&contents); - let message = if let Some(paste) = &on_paste { - (paste)(contents) - } else { - (on_input)(contents) - }; - shell.publish(message); - - state.is_pasting = None; - let value = if is_secure { - unsecured_value.secure() - } else { - unsecured_value - }; - - update_cache(state, &value); + Event::InputMethod(event) => match event { + input_method::Event::Opened | input_method::Event::Closed => { + state.preedit = + matches!(event, input_method::Event::Opened).then(input_method::Preedit::new); + shell.capture_event(); + return; + } + input_method::Event::Preedit(content, selection) => { + if state.is_focused() { + state.preedit = Some(input_method::Preedit { + content: content.to_owned(), + selection: selection.clone(), + text_size: Some(size.into()), + }); shell.capture_event(); return; } } - } - Event::Window(window::Event::RedrawRequested(now)) => { - let state = state(); + input_method::Event::Commit(text) => { + let Some(focus) = state.is_focused.as_mut().filter(|f| f.focused) else { + return; + }; + let Some(on_input) = on_input else { + return; + }; + if state.is_read_only { + return; + } + focus.updated_at = Instant::now(); + LAST_FOCUS_UPDATE.with(|x| x.set(focus.updated_at)); + + let mut editor = Editor::new(unsecured_value, &mut state.cursor); + editor.paste(Value::new(&text)); + + let contents = editor.contents(); + let unsecured_value = Value::new(&contents); + let message = if let Some(paste) = &on_paste { + (paste)(contents) + } else { + (on_input)(contents) + }; + shell.publish(message); + + state.is_pasting = None; + let value = if is_secure { + unsecured_value.secure() + } else { + unsecured_value + }; + + update_cache(state, &value); + shell.capture_event(); + return; + } + }, + Event::Window(window::Event::RedrawRequested(now)) => { if let Some(focus) = state.is_focused.as_mut().filter(|f| f.focused) { focus.now = *now; @@ -2215,7 +2357,6 @@ pub fn update<'a, Message: Clone + 'static>( #[cfg(wayland_platform)] Event::Dnd(DndEvent::Source(SourceEvent::Finished | SourceEvent::Cancelled)) => { cold(); - let state = state(); if matches!(state.dragging_state, Some(DraggingState::Dnd(..))) { // TODO: restore value in text input state.dragging_state = None; @@ -2234,7 +2375,6 @@ pub fn update<'a, Message: Clone + 'static>( }, )) if *rectangle == Some(dnd_id) => { cold(); - let state = state(); let is_clicked = text_layout.bounds().contains(Point { x: *x as f32, y: *y as f32, @@ -2277,8 +2417,6 @@ pub fn update<'a, Message: Clone + 'static>( Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Motion { x, y })) if *rectangle == Some(dnd_id) => { - let state = state(); - let target = { let text_bounds = text_layout.bounds(); @@ -2304,7 +2442,6 @@ pub fn update<'a, Message: Clone + 'static>( #[cfg(wayland_platform)] Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Drop)) if *rectangle == Some(dnd_id) => { cold(); - let state = state(); if let DndOfferState::HandlingOffer(mime_types, _action) = state.dnd_offer.clone() { let Some(mime_type) = SUPPORTED_TEXT_MIME_TYPES .iter() @@ -2327,7 +2464,6 @@ pub fn update<'a, Message: Clone + 'static>( OfferEvent::Leave | OfferEvent::LeaveDestination, )) => { cold(); - let state = state(); // ASHLEY TODO we should be able to reset but for now we don't if we are handling a // drop match state.dnd_offer { @@ -2344,7 +2480,6 @@ pub fn update<'a, Message: Clone + 'static>( if *rectangle == Some(dnd_id) => { cold(); - let state = state(); if matches!(&state.dnd_offer, DndOfferState::Dropped) { state.dnd_offer = DndOfferState::None; if !SUPPORTED_TEXT_MIME_TYPES.contains(&mime_type.as_str()) || data.is_empty() { @@ -2917,7 +3052,7 @@ pub(crate) enum DndOfferState { pub(crate) struct DndOfferState; /// The state of a [`TextInput`]. -#[derive(Debug, Default, Clone)] +#[derive(Default, Clone)] #[must_use] pub struct State { pub tracked_value: Value, @@ -2940,6 +3075,19 @@ pub struct State { preedit: Option, keyboard_modifiers: keyboard::Modifiers, scroll_offset: f32, + context_menu_position: Option, + pub(crate) menu_bar_state: crate::widget::menu::MenuBarState, + pub(crate) pending_action: crate::widget::text_context_menu::PendingAction, +} + +impl std::fmt::Debug for State { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("State") + .field("is_secure", &self.is_secure) + .field("is_read_only", &self.is_read_only) + .field("dirty", &self.dirty) + .finish_non_exhaustive() + } } #[derive(Debug, Clone, Copy)] @@ -2984,7 +3132,7 @@ impl State { cursor::State::Selection { start, end } => { let left = start.min(end); let right = end.max(start); - Some(text[left..right].to_string()) + Some(value.select(left, right).to_string()) } } } @@ -3022,6 +3170,9 @@ impl State { keyboard_modifiers: keyboard::Modifiers::default(), scroll_offset: 0.0, dirty: false, + context_menu_position: None, + menu_bar_state: crate::widget::menu::MenuBarState::default(), + pending_action: crate::widget::text_context_menu::pending_action(), } } @@ -3066,7 +3217,7 @@ impl State { /// Unfocuses the [`TextInput`]. #[cold] pub(super) fn unfocus(&mut self) { - self.move_cursor_to_front(); + self.cursor.clear_selection(); self.last_click = None; self.is_focused = self.is_focused.map(|mut f| { f.focused = false; @@ -3108,6 +3259,33 @@ impl State { self.cursor.select_range(start, end); } + /// Returns the context menu position, if a context menu should be shown. + pub fn context_menu_position(&self) -> Option { + self.context_menu_position + } + + /// Sets or clears the context menu position. + pub fn set_context_menu_position(&mut self, pos: Option) { + self.context_menu_position = pos; + } + + /// Deletes the current selection and returns the new text content. + pub fn delete_selection(&mut self) -> String { + let mut editor = super::editor::Editor::new(&mut self.tracked_value, &mut self.cursor); + editor.delete(); + editor.contents() + } + + /// Pastes text at the current cursor position and returns the new text content. + pub fn paste_text(&mut self, text: &str) -> String { + let paste_value = super::value::Value::new(text); + let mut editor = super::editor::Editor::new(&mut self.tracked_value, &mut self.cursor); + editor.paste(paste_value); + let contents = editor.contents(); + self.tracked_value = super::value::Value::new(&contents); + contents + } + pub(super) fn setting_selection(&mut self, value: &Value, bounds: Rectangle, target: f32) { let (position, affinity) = find_cursor_position(bounds, value, self, target) .unwrap_or((0, text::Affinity::Before)); @@ -3335,3 +3513,47 @@ fn effective_alignment(paragraph: &impl text::Paragraph) -> alignment::Horizonta alignment::Horizontal::Left } } + +use iced_core::widget::tree::Tree as WidgetTree; + +impl iced_core::widget::text::HasSelectableText + for TextInput<'_, Message> +{ + fn selected_text(&self, tree: &WidgetTree) -> Option { + let state = tree.state.downcast_ref::(); + let (start, end) = state.cursor().selection(&state.tracked_value)?; + Some(state.tracked_value.select(start, end).to_string()) + } + + fn select_all(&self, tree: &mut WidgetTree) { + let state = tree.state.downcast_mut::(); + state.select_all(); + } + + fn is_editable(&self) -> bool { + true + } + + fn is_focused(&self, tree: &WidgetTree) -> bool { + tree.state.downcast_ref::().is_focused() + } + + fn context_menu_position(&self, tree: &WidgetTree) -> Option { + tree.state.downcast_ref::().context_menu_position + } + + fn set_context_menu_position(&self, tree: &mut WidgetTree, pos: Option) { + tree.state.downcast_mut::().context_menu_position = pos; + } + + fn delete_selection(&self, tree: &mut WidgetTree) -> Option { + let state = tree.state.downcast_mut::(); + Some(state.delete_selection()) + } + + fn paste_text(&self, tree: &mut WidgetTree, text: &str) -> Option { + let filtered: String = text.chars().filter(|c| !c.is_control()).collect(); + let state = tree.state.downcast_mut::(); + Some(state.paste_text(&filtered)) + } +} diff --git a/src/widget/wayland/tooltip/widget.rs b/src/widget/wayland/tooltip/widget.rs index a5e4520a..b0287eed 100644 --- a/src/widget/wayland/tooltip/widget.rs +++ b/src/widget/wayland/tooltip/widget.rs @@ -301,8 +301,6 @@ impl<'a, Message: 'static + Clone, TopLevelMessage: 'static + Clone> } let content_layout = layout.children().next().unwrap(); - let state = tree.state.downcast_ref::(); - let styling = theme.style(&self.style); let icon_color = styling.icon_color.unwrap_or(renderer_style.icon_color);