aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoralex-ds13 <145657253+alex-ds13@users.noreply.github.com>2025-12-04 02:40:12 +0000
committerPolesznyák Márk <contact@pml68.dev>2025-12-30 18:13:59 +0100
commitde65c1dc7285b15c87862198b98b435345715d7d (patch)
treeb864fda857f039595990405c2143d80ebbe8029a
parentfeat: implement selection across bounds with hacky copy solution (diff)
downloadiced_selection-de65c1dc7285b15c87862198b98b435345715d7d.tar.gz
feat(selectable): draft implementation of selectable widget
Diffstat (limited to '')
-rw-r--r--src/lib.rs45
-rw-r--r--src/operation.rs100
-rw-r--r--src/selectable.rs243
-rw-r--r--src/text.rs58
-rw-r--r--src/text/rich.rs85
5 files changed, 487 insertions, 44 deletions
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<Element<'a, Message, Theme, Renderer>>,
+) -> 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<String> {
+ struct CopySelection {
+ contents: Vec<String>,
+ }
+
+ impl Operation<String> for CopySelection {
+
+ fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<String>)) {
+ 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::<Selection>() {
+ self.contents.push(selection.0.clone());
+ }
+ }
+
+ fn finish(&self) -> operation::Outcome<String> {
+ 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::<IndependentSelection<'_>>() {
+ // *selection.0 = false;
+ // }
+ if let Some(selection) = _state.downcast_mut::<bool>() {
+ *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<Element<'a, Message, Theme, Renderer>>,
+ ) -> 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<Message, Theme, Renderer>
+ for Selectable<'a, Message, Theme, Renderer>
+where
+ Renderer: core::Renderer,
+{
+ fn tag(&self) -> tree::Tag {
+ tree::Tag::of::<State>()
+ }
+
+ fn state(&self) -> tree::State {
+ tree::State::new(State::default())
+ }
+
+ fn children(&self) -> Vec<Tree> {
+ vec![Tree::new(&self.content)]
+ }
+
+ fn diff(&self, tree: &mut Tree) {
+ tree.diff_children(std::slice::from_ref(&self.content));
+ }
+
+ fn size(&self) -> Size<Length> {
+ 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::<State>();
+
+ 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<overlay::Element<'b, Message, Theme, Renderer>> {
+ self.content.as_widget_mut().overlay(
+ &mut state.children[0],
+ layout,
+ renderer,
+ viewport,
+ translation,
+ )
+ }
+}
+
+impl<'a, Message, Theme, Renderer>
+ From<Selectable<'a, Message, Theme, Renderer>>
+ 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<Renderer::Font>,
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<Dragging>,
last_click: Option<mouse::Click>,
potential_click: Option<mouse::Click>,
- has_cleared_clipboard: bool,
keyboard_modifiers: keyboard::Modifiers,
visual_lines_bounds: Vec<core::Rectangle>,
}
@@ -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::<State>();
+ 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<Box<dyn Fn(Link) -> Message + 'a>>,
on_link_hover: Option<Box<dyn Fn(Link) -> Message + 'a>>,
on_hover_lost: Option<Box<dyn Fn() -> 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<Link> {
dragging: Option<Dragging>,
last_click: Option<mouse::Click>,
potential_click: Option<mouse::Click>,
- has_cleared_clipboard: bool,
keyboard_modifiers: keyboard::Modifiers,
visual_lines_bounds: Vec<core::Rectangle>,
}
@@ -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::<State<Link>>();
+ 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,