From 2e1e4bd6e0a8cc46290d9354428bf7a0f3ca752d Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 19 May 2026 19:36:16 -0600 Subject: [PATCH] feat: add HasSelectableText trait to Text widget --- core/src/widget/text.rs | 220 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 208 insertions(+), 12 deletions(-) diff --git a/core/src/widget/text.rs b/core/src/widget/text.rs index 0c0ddc54..2ba0ada4 100644 --- a/core/src/widget/text.rs +++ b/core/src/widget/text.rs @@ -221,6 +221,9 @@ pub struct State { pub paragraph: paragraph::Plain

, /// Lazily allocated when text is selectable and first interacted with. selection: Option>, + focused: bool, + keyboard_focused: bool, + context_menu_position: Option, } impl Default for State

{ @@ -228,6 +231,9 @@ impl Default for State

{ Self { paragraph: paragraph::Plain::default(), selection: None, + focused: false, + keyboard_focused: false, + context_menu_position: None, } } } @@ -236,10 +242,43 @@ impl std::fmt::Debug for State

{ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("State") .field("selection", &self.selection) + .field("focused", &self.focused) .finish_non_exhaustive() } } +impl State

{ + /// Returns `true` if the widget currently has focus. + pub fn is_focused(&self) -> bool { + self.focused + } + + /// Returns `true` if focus was gained via keyboard navigation (Tab). + pub fn is_keyboard_focused(&self) -> bool { + self.keyboard_focused + } + + /// Clears focus, selection, and all interaction state. + pub fn clear_focus(&mut self) { + self.focused = false; + self.keyboard_focused = false; + self.context_menu_position = None; + if let Some(sel) = &mut self.selection { + sel.clear(); + } + } + + /// 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; + } +} + impl std::ops::Deref for State

{ type Target = paragraph::Plain

; @@ -259,11 +298,33 @@ struct SelectionState { anchor: usize, end: usize, dragging: bool, - focused: bool, modifiers: keyboard::Modifiers, last_click: Option, } +impl SelectionState { + fn clear(&mut self) { + self.anchor = 0; + self.end = 0; + self.dragging = false; + } +} + +impl crate::widget::operation::Focusable for State

{ + fn is_focused(&self) -> bool { + self.focused + } + + fn focus(&mut self) { + self.focused = true; + self.keyboard_focused = true; + } + + fn unfocus(&mut self) { + self.clear_focus(); + } +} + impl Widget for Text<'_, Theme, Renderer> where @@ -378,9 +439,29 @@ where let grapheme_count = content.graphemes(true).count(); let paragraph = state.paragraph.raw(); + // Any click outside this widget clears selection and focus. + if matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(_)) + | Event::Touch(touch::Event::FingerPressed { .. }) + ) { + if cursor.position_over(bounds).is_none() { + state.focused = false; + state.keyboard_focused = false; + if let Some(sel) = &mut state.selection { + sel.clear(); + } + } + } + match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { + if state.context_menu_position.take().is_some() { + shell.capture_event(); + return; + } + if let Some(pos) = cursor.position_over(bounds) { let sel = state.selection.get_or_insert_with(|| { Box::new(SelectionState::default()) @@ -427,13 +508,18 @@ where } sel.last_click = Some(new_click); - sel.focused = true; + state.focused = true; + state.keyboard_focused = false; + shell.capture_event(); + } + } + + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { + if let Some(pos) = cursor.position_over(bounds) { + state.context_menu_position = Some(pos); + state.focused = true; + state.keyboard_focused = false; shell.capture_event(); - } else if let Some(sel) = &mut state.selection { - sel.focused = false; - sel.anchor = 0; - sel.end = 0; - sel.dragging = false; } } @@ -471,12 +557,12 @@ where text: _, .. }) => { - let focused = - state.selection.as_ref().is_some_and(|s| s.focused); - if !focused { + if !state.focused { return; } - let sel = state.selection.as_mut().unwrap(); + let sel = state + .selection + .get_or_insert_with(|| Box::new(SelectionState::default())); if modifiers.command() { match key.to_latin(*physical_key) { @@ -597,12 +683,17 @@ where fn operate( &mut self, - _tree: &mut Tree, + tree: &mut Tree, layout: Layout<'_>, _renderer: &Renderer, operation: &mut dyn super::Operation, ) { operation.text(None, layout.bounds(), &self.fragment); + + if self.selectable { + let state = tree.state.downcast_mut::>(); + operation.focusable(None, layout.bounds(), state); + } } #[cfg(feature = "a11y")] @@ -904,6 +995,111 @@ pub fn danger(theme: &Theme) -> Style { } } +use crate::widget::tree::Tree as WidgetTree; + +/// Implement this on a **widget** to enable context menu support for +/// text selection (Copy, Select All, and optionally Cut / Paste) in libcosmic +pub trait HasSelectableText { + /// Returns the currently selected text, if any. + fn selected_text(&self, tree: &WidgetTree) -> Option; + + /// Selects all text. + fn select_all(&self, tree: &mut WidgetTree); + + /// Returns `true` if the widget is editable (enables Cut / Paste). + fn is_editable(&self) -> bool { + false + } + + /// Returns `true` if the widget is currently focused. + fn is_focused(&self, tree: &WidgetTree) -> bool; + + /// Returns the position where the context menu should appear + fn context_menu_position(&self, tree: &WidgetTree) -> Option; + + /// Sets or clears the context menu position. + fn set_context_menu_position( + &self, + tree: &mut WidgetTree, + pos: Option, + ); + + /// Copies the selection to the clipboard. + fn copy_to_clipboard( + &self, + tree: &WidgetTree, + clipboard: &mut dyn Clipboard, + ) { + if let Some(text) = self.selected_text(tree) { + clipboard.write(crate::clipboard::Kind::Standard, text); + } + } + + /// Deletes the selected text and returns the new full content. + /// Only called when [`is_editable`](Self::is_editable) is `true`. + fn delete_selection(&self, _tree: &mut WidgetTree) -> Option { + None + } + + /// Inserts `text` at the cursor (replacing any selection) and returns + /// the new full content. + /// Only called when [`is_editable`](Self::is_editable) is `true`. + fn paste_text( + &self, + _tree: &mut WidgetTree, + _text: &str, + ) -> Option { + None + } +} + +impl HasSelectableText + for Text<'_, Theme, Renderer> +{ + fn selected_text(&self, tree: &WidgetTree) -> Option { + let state = tree.state.downcast_ref::>(); + let sel = state.selection.as_ref()?; + let left = sel.anchor.min(sel.end); + let right = sel.anchor.max(sel.end); + if left == right { + return None; + } + let content = state.paragraph.content(); + let lo = grapheme_to_byte(content, left); + let hi = grapheme_to_byte(content, right); + content.get(lo..hi).map(|s| s.to_owned()) + } + + fn select_all(&self, tree: &mut WidgetTree) { + let state = tree.state.downcast_mut::>(); + let count = state.paragraph.content().graphemes(true).count(); + let sel = state + .selection + .get_or_insert_with(|| Box::new(SelectionState::default())); + sel.anchor = 0; + sel.end = count; + } + + fn is_focused(&self, tree: &WidgetTree) -> bool { + let state = tree.state.downcast_ref::>(); + state.is_focused() + } + + fn context_menu_position(&self, tree: &WidgetTree) -> Option { + let state = tree.state.downcast_ref::>(); + state.context_menu_position() + } + + fn set_context_menu_position( + &self, + tree: &mut WidgetTree, + pos: Option, + ) { + let state = tree.state.downcast_mut::>(); + state.set_context_menu_position(pos); + } +} + const DEFAULT_SELECTION_COLOR: Color = Color { r: 0.0, g: 0.47,