feat: add Wayland popup context menu to TextInput

This commit is contained in:
Hojjat 2026-05-19 19:36:50 -06:00 • committed by Michael Murphy
parent c6cf3db29d
commit d751855c74
3 changed files with 301 additions and 91 deletions

View file

@ -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`].
///

View file

@ -43,6 +43,12 @@ thread_local! {
static LAST_FOCUS_UPDATE: LazyCell<Cell<Instant>> = 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<Message> Widget<Message, crate::Theme, crate::Renderer> for TextInput<'_, Message>
@ -843,6 +860,24 @@ where
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, crate::Theme, crate::Renderer>> {
if !self.uses_popup_context_menu() {
let has_context_menu = tree
.state
.downcast_ref::<State>()
.context_menu_position
.is_some();
if has_context_menu {
let menu_bar_state = tree.state.downcast_ref::<State>().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<overlay::Element<'_, Message, crate::Theme, crate::Renderer>> = 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::<Vec<_>>();
.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>();
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::<State>();
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::<State>();
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>();
state.context_menu_position = None;
}
// Process deferred actions from the popup.
let state = tree.state.downcast_ref::<State>();
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::<State>();
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::<State>();
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::<State>();
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<Preedit>,
keyboard_modifiers: keyboard::Modifiers,
scroll_offset: f32,
context_menu_position: Option<iced_core::Point>,
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<iced_core::Point> {
self.context_menu_position
}
/// Sets or clears the context menu position.
pub fn set_context_menu_position(&mut self, pos: Option<iced_core::Point>) {
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<f32>, 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<Message: Clone + 'static> iced_core::widget::text::HasSelectableText
for TextInput<'_, Message>
{
fn selected_text(&self, tree: &WidgetTree) -> Option<String> {
let state = tree.state.downcast_ref::<State>();
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>();
state.select_all();
}
fn is_editable(&self) -> bool {
true
}
fn is_focused(&self, tree: &WidgetTree) -> bool {
tree.state.downcast_ref::<State>().is_focused()
}
fn context_menu_position(&self, tree: &WidgetTree) -> Option<iced_core::Point> {
tree.state.downcast_ref::<State>().context_menu_position
}
fn set_context_menu_position(&self, tree: &mut WidgetTree, pos: Option<iced_core::Point>) {
tree.state.downcast_mut::<State>().context_menu_position = pos;
}
fn delete_selection(&self, tree: &mut WidgetTree) -> Option<String> {
let state = tree.state.downcast_mut::<State>();
Some(state.delete_selection())
}
fn paste_text(&self, tree: &mut WidgetTree, text: &str) -> Option<String> {
let filtered: String = text.chars().filter(|c| !c.is_control()).collect();
let state = tree.state.downcast_mut::<State>();
Some(state.paste_text(&filtered))
}
}

View file

@ -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::<State>();
let styling = theme.style(&self.style);
let icon_color = styling.icon_color.unwrap_or(renderer_style.icon_color);