feat: add HasSelectableText trait to Text widget
This commit is contained in:
parent
9ae155b8e2
commit
2e1e4bd6e0
1 changed files with 208 additions and 12 deletions
|
|
@ -221,6 +221,9 @@ pub struct State<P: Paragraph> {
|
||||||
pub paragraph: paragraph::Plain<P>,
|
pub paragraph: paragraph::Plain<P>,
|
||||||
/// Lazily allocated when text is selectable and first interacted with.
|
/// Lazily allocated when text is selectable and first interacted with.
|
||||||
selection: Option<Box<SelectionState>>,
|
selection: Option<Box<SelectionState>>,
|
||||||
|
focused: bool,
|
||||||
|
keyboard_focused: bool,
|
||||||
|
context_menu_position: Option<Point>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<P: Paragraph> Default for State<P> {
|
impl<P: Paragraph> Default for State<P> {
|
||||||
|
|
@ -228,6 +231,9 @@ impl<P: Paragraph> Default for State<P> {
|
||||||
Self {
|
Self {
|
||||||
paragraph: paragraph::Plain::default(),
|
paragraph: paragraph::Plain::default(),
|
||||||
selection: None,
|
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 {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("State")
|
f.debug_struct("State")
|
||||||
.field("selection", &self.selection)
|
.field("selection", &self.selection)
|
||||||
|
.field("focused", &self.focused)
|
||||||
.finish_non_exhaustive()
|
.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> {
|
impl<P: Paragraph> std::ops::Deref for State<P> {
|
||||||
type Target = paragraph::Plain<P>;
|
type Target = paragraph::Plain<P>;
|
||||||
|
|
||||||
|
|
@ -259,11 +298,33 @@ struct SelectionState {
|
||||||
anchor: usize,
|
anchor: usize,
|
||||||
end: usize,
|
end: usize,
|
||||||
dragging: bool,
|
dragging: bool,
|
||||||
focused: bool,
|
|
||||||
modifiers: keyboard::Modifiers,
|
modifiers: keyboard::Modifiers,
|
||||||
last_click: Option<click::Click>,
|
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>
|
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||||
for Text<'_, Theme, Renderer>
|
for Text<'_, Theme, Renderer>
|
||||||
where
|
where
|
||||||
|
|
@ -378,9 +439,29 @@ where
|
||||||
let grapheme_count = content.graphemes(true).count();
|
let grapheme_count = content.graphemes(true).count();
|
||||||
let paragraph = state.paragraph.raw();
|
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 {
|
match event {
|
||||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||||
| Event::Touch(touch::Event::FingerPressed { .. }) => {
|
| 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) {
|
if let Some(pos) = cursor.position_over(bounds) {
|
||||||
let sel = state.selection.get_or_insert_with(|| {
|
let sel = state.selection.get_or_insert_with(|| {
|
||||||
Box::new(SelectionState::default())
|
Box::new(SelectionState::default())
|
||||||
|
|
@ -427,13 +508,18 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
sel.last_click = Some(new_click);
|
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();
|
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: _,
|
text: _,
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
let focused =
|
if !state.focused {
|
||||||
state.selection.as_ref().is_some_and(|s| s.focused);
|
|
||||||
if !focused {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let sel = state.selection.as_mut().unwrap();
|
let sel = state
|
||||||
|
.selection
|
||||||
|
.get_or_insert_with(|| Box::new(SelectionState::default()));
|
||||||
|
|
||||||
if modifiers.command() {
|
if modifiers.command() {
|
||||||
match key.to_latin(*physical_key) {
|
match key.to_latin(*physical_key) {
|
||||||
|
|
@ -597,12 +683,17 @@ where
|
||||||
|
|
||||||
fn operate(
|
fn operate(
|
||||||
&mut self,
|
&mut self,
|
||||||
_tree: &mut Tree,
|
tree: &mut Tree,
|
||||||
layout: Layout<'_>,
|
layout: Layout<'_>,
|
||||||
_renderer: &Renderer,
|
_renderer: &Renderer,
|
||||||
operation: &mut dyn super::Operation,
|
operation: &mut dyn super::Operation,
|
||||||
) {
|
) {
|
||||||
operation.text(None, layout.bounds(), &self.fragment);
|
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")]
|
#[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 {
|
const DEFAULT_SELECTION_COLOR: Color = Color {
|
||||||
r: 0.0,
|
r: 0.0,
|
||||||
g: 0.47,
|
g: 0.47,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue