feat: add HasSelectableText trait to Text widget

This commit is contained in:
Hojjat 2026-05-19 19:36:16 -06:00 committed by Michael Murphy
parent 9ae155b8e2
commit 2e1e4bd6e0

View file

@ -221,6 +221,9 @@ pub struct State<P: Paragraph> {
pub paragraph: paragraph::Plain<P>,
/// Lazily allocated when text is selectable and first interacted with.
selection: Option<Box<SelectionState>>,
focused: bool,
keyboard_focused: bool,
context_menu_position: Option<Point>,
}
impl<P: Paragraph> Default for State<P> {
@ -228,6 +231,9 @@ impl<P: Paragraph> Default for State<P> {
Self {
paragraph: paragraph::Plain::default(),
selection: None,
focused: false,
keyboard_focused: false,
context_menu_position: None,
}
}
}
@ -236,10 +242,43 @@ impl<P: Paragraph> std::fmt::Debug for State<P> {
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<P: Paragraph> State<P> {
/// 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<Point> {
self.context_menu_position
}
/// Sets or clears the context menu position.
pub fn set_context_menu_position(&mut self, pos: Option<Point>) {
self.context_menu_position = pos;
}
}
impl<P: Paragraph> std::ops::Deref for State<P> {
type Target = paragraph::Plain<P>;
@ -259,11 +298,33 @@ struct SelectionState {
anchor: usize,
end: usize,
dragging: bool,
focused: bool,
modifiers: keyboard::Modifiers,
last_click: Option<click::Click>,
}
impl SelectionState {
fn clear(&mut self) {
self.anchor = 0;
self.end = 0;
self.dragging = false;
}
}
impl<P: Paragraph> crate::widget::operation::Focusable for State<P> {
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<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
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::<State<Renderer::Paragraph>>();
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<String>;
/// 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<Point>;
/// Sets or clears the context menu position.
fn set_context_menu_position(
&self,
tree: &mut WidgetTree,
pos: Option<Point>,
);
/// 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<String> {
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<String> {
None
}
}
impl<Theme: Catalog, Renderer: text::Renderer> HasSelectableText
for Text<'_, Theme, Renderer>
{
fn selected_text(&self, tree: &WidgetTree) -> Option<String> {
let state = tree.state.downcast_ref::<State<Renderer::Paragraph>>();
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::<State<Renderer::Paragraph>>();
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<Renderer::Paragraph>>();
state.is_focused()
}
fn context_menu_position(&self, tree: &WidgetTree) -> Option<Point> {
let state = tree.state.downcast_ref::<State<Renderer::Paragraph>>();
state.context_menu_position()
}
fn set_context_menu_position(
&self,
tree: &mut WidgetTree,
pos: Option<Point>,
) {
let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
state.set_context_menu_position(pos);
}
}
const DEFAULT_SELECTION_COLOR: Color = Color {
r: 0.0,
g: 0.47,