From de65c1dc7285b15c87862198b98b435345715d7d Mon Sep 17 00:00:00 2001 From: alex-ds13 <145657253+alex-ds13@users.noreply.github.com> Date: Thu, 4 Dec 2025 02:40:12 +0000 Subject: feat(selectable): draft implementation of selectable widget --- src/lib.rs | 45 ++++++++++ src/operation.rs | 100 ++++++++++++++++++++++ src/selectable.rs | 243 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/text.rs | 58 ++++++++----- src/text/rich.rs | 85 ++++++++++++++----- 5 files changed, 487 insertions(+), 44 deletions(-) create mode 100644 src/operation.rs create mode 100644 src/selectable.rs (limited to 'src') diff --git a/src/lib.rs b/src/lib.rs index 34c1f73..c1c41cf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,9 +5,12 @@ #[cfg(feature = "markdown")] pub mod markdown; +pub mod operation; +pub mod selectable; pub mod selection; pub mod text; +use core::Element; use iced_widget::core; use iced_widget::graphics::text::Paragraph; #[cfg(feature = "markdown")] @@ -225,3 +228,45 @@ pub fn span<'a, Link>( ) -> text::Span<'a, Link, core::Font> { text::Span::new(text) } + +/// Creates some [`Selectable`] with the given content. +/// +/// [`Selectable`]: crate::selectable::Selectable +/// +/// # Example +/// ```no_run,ignore +/// use iced::font; +/// use iced_selection::{rich_text, selectable, span}; +/// use iced::{color, column, never, Font}; +/// +/// #[derive(Debug, Clone)] +/// enum Message { +/// LinkClicked(&'static str), +/// // ... +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// selectable( +/// column![ +/// rich_text([ +/// span("I am red!").color(color!(0xff0000)), +/// span(" "), +/// span("And I am bold!").font(Font { weight: font::Weight::Bold, ..Font::default() }), +/// ]) +/// .on_link_click(never) +/// .size(20), +/// text("Hello, this is iced!"), +/// ] +/// ) +/// .into() +/// } +/// ``` +pub fn selectable<'a, Message, Theme, Renderer>( + content: impl Into>, +) -> selectable::Selectable<'a, Message, Theme, Renderer> +where + Theme: 'a, + Renderer: core::Renderer, +{ + selectable::Selectable::new(content) +} diff --git a/src/operation.rs b/src/operation.rs new file mode 100644 index 0000000..584f61f --- /dev/null +++ b/src/operation.rs @@ -0,0 +1,100 @@ +//! Provides some operations to be used on the [`Text`] or [`Rich`]. +//! +//! [`Text`]: crate::text::Text +//! [`Rich`]: crate::text::Rich + +use crate::core::widget::operation::{self, Operation}; + +/// Some selected text +pub struct Selection(pub String); + +/// Some mutable independent selection field of a [`Text`] or [`Rich`] that can be changed through +/// an operation. +/// +/// [`Text`]: crate::text::Text +/// [`Rich`]: crate::text::Rich +pub struct IndependentSelection<'a>(pub &'a mut bool); + +impl<'a> IndependentSelection<'a> { + /// Creates a new [`IndependentSelection`] with the given mutable reference + pub fn new(isel: &'a mut bool) -> Self { + Self(isel) + } +} + +/// Gets all the currently selected text +pub fn selected() -> impl Operation { + struct CopySelection { + contents: Vec, + } + + impl Operation for CopySelection { + + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); + } + + fn custom( + &mut self, + _id: Option<&iced_widget::Id>, + _bounds: iced_widget::core::Rectangle, + _state: &mut dyn std::any::Any, + ) { + if let Some(selection) = _state.downcast_ref::() { + self.contents.push(selection.0.clone()); + } + } + + fn finish(&self) -> operation::Outcome { + if !self.contents.is_empty() { + let clipboard = self.contents.iter().fold(String::new(), |mut str, s| { + if str.is_empty() { + str = s.to_owned(); + } else { + str.push('\n'); + str.push_str(s); + } + str + }); + operation::Outcome::Some(clipboard) + } else { + operation::Outcome::None + } + } + } + + CopySelection { + contents: Vec::new(), + } +} + +/// Sets all children instances of [`Text`] or [`Rich`] to be globally selectable instead of +/// independently selectable. +/// +/// [`Text`]: crate::text::Text +/// [`Rich`]: crate::text::Rich +pub fn global_selection() -> impl Operation { + struct SetGlobalSelection; + + impl Operation for SetGlobalSelection { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); + } + + fn custom( + &mut self, + _id: Option<&iced_widget::Id>, + _bounds: iced_widget::core::Rectangle, + _state: &mut dyn std::any::Any, + ) { + // if let Some(selection) = _state.downcast_mut::>() { + // *selection.0 = false; + // } + if let Some(selection) = _state.downcast_mut::() { + *selection = false; + } + } + } + + SetGlobalSelection +} diff --git a/src/selectable.rs b/src/selectable.rs new file mode 100644 index 0000000..217f913 --- /dev/null +++ b/src/selectable.rs @@ -0,0 +1,243 @@ +//! A [`Selectable`] widget that wraps some elements (preferrably all your apps elements) and makes +//! all its children instances of [`Text`] and [`Rich`] became globally selectable instead of +//! independently selectable. This widget also handles the copy shortcut ('ctrl + c') by checking +//! for all selections contained inside it and writing to the [`Standard`] clipboard when there is +//! some selection. +//! +//! +//! [`Text`]: crate::Text +//! [`Rich`]: crate::text::Rich +//! [`Standard`]: crate::core::clipboard::Kind::Standard + +use crate::core::{ + self, Element, Event, Length, Rectangle, Shell, Size, Vector, Widget, + clipboard::{self, Clipboard}, + keyboard, + layout::{Layout, Limits, Node}, + mouse, overlay, + renderer::Style, + widget::{Tree, operation::Operation, tree}, +}; + +/// A [`Selectable`] widget that wraps some elements (preferrably all your apps elements) and makes +/// all its children instances of [`Text`] and [`Rich`] became globally selectable instead of +/// independently selectable. This widget also handles the copy shortcut ('ctrl + c') by checking +/// for all selections contained inside it and writing to the [`Standard`] clipboard when there is +/// some selection. +/// +/// +/// [`Text`]: crate::Text +/// [`Rich`]: crate::text::Rich +/// [`Standard`]: crate::core::clipboard::Kind::Standard +pub struct Selectable<'a, Message, Theme, Renderer> { + content: Element<'a, Message, Theme, Renderer>, +} + +impl<'a, Message, Theme, Renderer> Selectable<'a, Message, Theme, Renderer> { + /// Creates a new [`Selectable`] with the given content. + pub fn new( + content: impl Into>, + ) -> Self { + Self { + content: content.into(), + } + } +} + +/// The internal state of a [`Selectable`] widget. +#[derive(Debug, Default, Clone)] +struct State { + keyboard_modifiers: keyboard::Modifiers, +} + +impl<'a, Message, Theme, Renderer> Widget + for Selectable<'a, Message, Theme, Renderer> +where + Renderer: core::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &Limits, + ) -> Node { + let layout = self.content.as_widget_mut().layout( + &mut tree.children[0], + renderer, + limits, + ); + self.content.as_widget_mut().operate( + &mut tree.children[0], + Layout::new(&layout), + renderer, + &mut crate::operation::global_selection(), + ); + + layout + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + operation.traverse(&mut |operation| { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + operation, + ); + }); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + + if shell.is_event_captured() { + return; + } + + let state = tree.state.downcast_mut::(); + + if let Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) = event + && let keyboard::Key::Character("c") = key.as_ref() + && state.keyboard_modifiers.command() + { + let mut selected = crate::operation::selected(); + + selected.traverse(&mut |operation| { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + &mut core::widget::operation::black_box(operation), + ); + }); + + if let core::widget::operation::Outcome::Some(selection) = + selected.finish() + { + clipboard.write(clipboard::Kind::Standard, selection); + + shell.capture_event(); + } + } else if let Event::Keyboard(keyboard::Event::ModifiersChanged( + modifiers, + )) = event + { + state.keyboard_modifiers = *modifiers; + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + state: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut state.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} + +impl<'a, Message, Theme, Renderer> + From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: core::Renderer + 'a, +{ + fn from( + selectable: Selectable<'a, Message, Theme, Renderer>, + ) -> Element<'a, Message, Theme, Renderer> { + Element::new(selectable) + } +} diff --git a/src/text.rs b/src/text.rs index acc7d2c..76560ce 100644 --- a/src/text.rs +++ b/src/text.rs @@ -70,6 +70,7 @@ pub struct Text< fragment: Fragment<'a>, format: Format, class: Theme::Class<'a>, + independent_selection: bool, } impl<'a, Theme, Renderer> Text<'a, Theme, Renderer> @@ -83,6 +84,7 @@ where fragment: fragment.into_fragment(), format: Format::default(), class: Theme::default(), + independent_selection: true, } } @@ -150,6 +152,15 @@ where self } + /// Makes this [`Text`] selection independent of others. + /// + /// Independent texts handle their own selection copy and don't allow being selected from + /// outside its bounds. + pub fn independent(mut self, independent_selection: bool) -> Self { + self.independent_selection = independent_selection; + self + } + /// Sets the style of the [`Text`]. #[must_use] pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self @@ -178,7 +189,6 @@ pub struct State { dragging: Option, last_click: Option, potential_click: Option, - has_cleared_clipboard: bool, keyboard_modifiers: keyboard::Modifiers, visual_lines_bounds: Vec, } @@ -436,7 +446,9 @@ where shell.capture_event(); } else { - if let Some(position) = cursor.land().position() { + if let Some(position) = cursor.land().position() + && !self.independent_selection + { let potential_click = mouse::Click::new( position, mouse::Button::Left, @@ -537,33 +549,20 @@ where state.last_click = Some(potential_click); state.potential_click = None; - - // Clear the clipboard so that we can append to it if the user presses the copy - // shortcut to be able to include all selected texts. - clipboard.write(clipboard::Kind::Standard, String::new()); - state.has_cleared_clipboard = true; } } Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) => { match key.as_ref() { keyboard::Key::Character("c") if state.keyboard_modifiers.command() + && !shell.is_event_captured() + && self.independent_selection && !state.selection.is_empty() => { - let contents = - if state.has_cleared_clipboard { - state.has_cleared_clipboard = false; - clipboard - .read(clipboard::Kind::Standard) - .map(|c| { - if !c.is_empty() { c + "\n" } else { c } - }) - .unwrap_or_default() - + &state.selection.text(&state.paragraph) - } else { - state.selection.text(&state.paragraph) - }; - clipboard.write(clipboard::Kind::Standard, contents); + clipboard.write( + clipboard::Kind::Standard, + state.selection.text(&state.paragraph), + ); shell.capture_event(); } @@ -734,12 +733,27 @@ where fn operate( &mut self, - _state: &mut Tree, + state: &mut Tree, layout: Layout<'_>, _renderer: &Renderer, operation: &mut dyn Operation, ) { operation.text(None, layout.bounds(), &self.fragment); + + if !self.independent_selection { + let state = state.state.downcast_ref::(); + let selection = state.selection.text(&state.paragraph); + if !selection.is_empty() { + operation.custom( + None, + layout.bounds(), + &mut crate::operation::Selection(selection), + ); + } + } + + // let mut isel = crate::operation::IndependentSelection::new(&mut self.independent_selection); + operation.custom(None, layout.bounds(), &mut self.independent_selection); } fn mouse_interaction( diff --git a/src/text/rich.rs b/src/text/rich.rs index 2285b4e..b49adbd 100644 --- a/src/text/rich.rs +++ b/src/text/rich.rs @@ -10,6 +10,7 @@ use crate::core::mouse; use crate::core::renderer; use crate::core::text::{Paragraph as _, Span}; use crate::core::touch; +use crate::core::widget::Operation; use crate::core::widget::text::{Alignment, LineHeight, Shaping, Wrapping}; use crate::core::widget::tree::{self, Tree}; use crate::core::{ @@ -44,6 +45,7 @@ pub struct Rich< on_link_click: Option Message + 'a>>, on_link_hover: Option Message + 'a>>, on_hover_lost: Option Message + 'a>>, + independent_selection: bool, } impl<'a, Link, Message, Theme, Renderer> @@ -70,6 +72,7 @@ where on_link_click: None, on_link_hover: None, on_hover_lost: None, + independent_selection: true, } } @@ -184,6 +187,15 @@ where self } + /// Makes this [`Rich`] selection independent of others. + /// + /// Independent texts handle their own selection copy and don't allow being selected from + /// outside its bounds. + pub fn independent(mut self, independent_selection: bool) -> Self { + self.independent_selection = independent_selection; + self + } + /// Sets the default style of the [`Rich`] text. #[must_use] pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self @@ -225,7 +237,6 @@ struct State { dragging: Option, last_click: Option, potential_click: Option, - has_cleared_clipboard: bool, keyboard_modifiers: keyboard::Modifiers, visual_lines_bounds: Vec, } @@ -367,7 +378,6 @@ where dragging: None, last_click: None, potential_click: None, - has_cleared_clipboard: false, keyboard_modifiers: keyboard::Modifiers::default(), visual_lines_bounds: Vec::new(), }) @@ -632,7 +642,9 @@ where shell.capture_event(); } else { - if let Some(position) = cursor.land().position() { + if let Some(position) = cursor.land().position() + && !self.independent_selection + { let potential_click = mouse::Click::new( position, mouse::Button::Left, @@ -756,33 +768,20 @@ where state.last_click = Some(potential_click); state.potential_click = None; - - // Clear the clipboard so that we can append to it if the user presses the copy - // shortcut to be able to include all selected texts. - clipboard.write(clipboard::Kind::Standard, String::new()); - state.has_cleared_clipboard = true; } } Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) => { match key.as_ref() { keyboard::Key::Character("c") if state.keyboard_modifiers.command() + && !shell.is_event_captured() + && self.independent_selection && !state.selection.is_empty() => { - let contents = - if state.has_cleared_clipboard { - state.has_cleared_clipboard = false; - clipboard - .read(clipboard::Kind::Standard) - .map(|c| { - if !c.is_empty() { c + "\n" } else { c } - }) - .unwrap_or_default() - + &state.selection.text(&state.paragraph) - } else { - state.selection.text(&state.paragraph) - }; - clipboard.write(clipboard::Kind::Standard, contents); + clipboard.write( + clipboard::Kind::Standard, + state.selection.text(&state.paragraph), + ); shell.capture_event(); } @@ -921,6 +920,48 @@ where } } + fn operate( + &mut self, + state: &mut Tree, + layout: Layout<'_>, + _renderer: &Renderer, + operation: &mut dyn Operation, + ) { + let state = state.state.downcast_ref::>(); + operation.text( + None, + layout.bounds(), + &state.paragraph.buffer().lines.iter().fold( + String::new(), + |mut str, l| { + if !str.is_empty() { + str.push('\n'); + } + str.push_str(l.text()); + str + }, + ), + ); + + if !self.independent_selection { + let selection = state.selection.text(&state.paragraph); + if !selection.is_empty() { + operation.custom( + None, + layout.bounds(), + &mut crate::operation::Selection(selection), + ); + } + } + + // let mut isel = crate::operation::IndependentSelection::new(&mut self.independent_selection); + operation.custom( + None, + layout.bounds(), + &mut self.independent_selection, + ); + } + fn mouse_interaction( &self, tree: &Tree, -- cgit v1.2.3