diff --git a/Cargo.lock b/Cargo.lock index 9a523b2724..7bd1903c25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2287,6 +2287,7 @@ dependencies = [ "serde", "smol_str 0.2.2", "thiserror 2.0.18", + "unicode-segmentation", "web-time", ] @@ -5670,6 +5671,13 @@ dependencies = [ "iced", ] +[[package]] +name = "text_selection_test" +version = "0.1.0" +dependencies = [ + "iced", +] + [[package]] name = "the_matrix" version = "0.1.0" diff --git a/core/Cargo.toml b/core/Cargo.toml index 7176b7917b..06e6883f92 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -31,6 +31,7 @@ raw-window-handle.workspace = true rustc-hash.workspace = true smol_str.workspace = true thiserror.workspace = true +unicode-segmentation.workspace = true web-time.workspace = true serde.workspace = true diff --git a/core/src/text/paragraph.rs b/core/src/text/paragraph.rs index a7e1439230..654fd27cf1 100644 --- a/core/src/text/paragraph.rs +++ b/core/src/text/paragraph.rs @@ -73,6 +73,27 @@ pub trait Paragraph: Sized + Default { /// Returns the distance to the given grapheme index in the [`Paragraph`]. fn grapheme_position(&self, line: usize, index: usize) -> Option; + /// Returns the visual rectangles covering the byte range + /// `start..end` of the [`Paragraph`]'s source text. Used to paint + /// selection highlights. + fn selection_bounds(&self, _start: usize, _end: usize) -> Vec { + Vec::new() + } + + /// Returns the visual position of the given byte offset in the + /// [`Paragraph`]'s source text. Used by `Shift+Up`/`Down` and + /// `Shift+Home`/`End` to hit-test targets relative to the focus. + fn byte_position(&self, _byte: usize) -> Option { + None + } + + /// The visual line height the [`Paragraph`] is rendered with — + /// the distance to step for `Shift+Up`/`Down`. Returns `None` + /// when the renderer doesn't track per-line geometry. + fn visual_line_height(&self) -> Option { + None + } + /// Returns the minimum width that can fit the contents of the [`Paragraph`]. fn min_width(&self) -> f32 { self.min_bounds().width diff --git a/core/src/widget/operation.rs b/core/src/widget/operation.rs index a21fdba13a..086301478f 100644 --- a/core/src/widget/operation.rs +++ b/core/src/widget/operation.rs @@ -1,10 +1,12 @@ //! Query or update internal widget state. pub mod focusable; pub mod scrollable; +pub mod selectable; pub mod text_input; pub use focusable::Focusable; pub use scrollable::Scrollable; +pub use selectable::Selectable; pub use text_input::TextInput; use crate::widget::Id; @@ -46,6 +48,9 @@ pub trait Operation: Send { /// Operates on a widget that has text input. fn text_input(&mut self, _id: Option<&Id>, _bounds: Rectangle, _state: &mut dyn TextInput) {} + /// Operates on a widget that owns a text selection. + fn selectable(&mut self, _id: Option<&Id>, _bounds: Rectangle, _state: &mut dyn Selectable) {} + /// Operates on a widget that contains some text. fn text(&mut self, _id: Option<&Id>, _bounds: Rectangle, _text: &str) {} @@ -90,6 +95,10 @@ where self.as_mut().text_input(id, bounds, state); } + fn selectable(&mut self, id: Option<&Id>, bounds: Rectangle, state: &mut dyn Selectable) { + self.as_mut().selectable(id, bounds, state); + } + fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) { self.as_mut().text(id, bounds, text); } @@ -171,6 +180,10 @@ where self.operation.text_input(id, bounds, state); } + fn selectable(&mut self, id: Option<&Id>, bounds: Rectangle, state: &mut dyn Selectable) { + self.operation.selectable(id, bounds, state); + } + fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) { self.operation.text(id, bounds, text); } @@ -255,6 +268,15 @@ where self.operation.text_input(id, bounds, state); } + fn selectable( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + state: &mut dyn Selectable, + ) { + self.operation.selectable(id, bounds, state); + } + fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) { self.operation.text(id, bounds, text); } @@ -293,6 +315,10 @@ where self.operation.text_input(id, bounds, state); } + fn selectable(&mut self, id: Option<&Id>, bounds: Rectangle, state: &mut dyn Selectable) { + self.operation.selectable(id, bounds, state); + } + fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) { self.operation.text(id, bounds, text); } @@ -374,6 +400,10 @@ where self.operation.text_input(id, bounds, state); } + fn selectable(&mut self, id: Option<&Id>, bounds: Rectangle, state: &mut dyn Selectable) { + self.operation.selectable(id, bounds, state); + } + fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) { self.operation.text(id, bounds, text); } diff --git a/core/src/widget/operation/selectable.rs b/core/src/widget/operation/selectable.rs new file mode 100644 index 0000000000..a06e54f9cc --- /dev/null +++ b/core/src/widget/operation/selectable.rs @@ -0,0 +1,194 @@ +//! Operate on widgets that can have a text selection. +use crate::widget::{Id, Operation}; +use crate::{Point, Rectangle}; + +/// The internal state of a widget that owns a text selection. +/// +/// Coordinator widgets like `selectable_group` reach into selectables +/// through this trait via the [`Operation::selectable`] hook, the same +/// way [`Focusable`] / [`Operation::focusable`] cooperate. +/// +/// New widgets only need to implement the required accessors: +/// selection state, the text content, the paragraph proxies, and the +/// "externally managed" flag. Codepoint / word / line walking is +/// provided. +/// +/// [`Focusable`]: super::Focusable +/// [`Operation::focusable`]: Operation::focusable +/// [`Operation::selectable`]: Operation::selectable +pub trait Selectable { + /// Returns the current selection as a half-open byte range, or + /// `None` when nothing is selected. + fn selection(&self) -> Option<(usize, usize)>; + + /// Sets the selection range. Pass `None` to clear. + fn set_selection(&mut self, range: Option<(usize, usize)>); + + /// Returns the widget's text content. Used by the default + /// implementations of [`text_len`], [`selection_text`], + /// [`step_byte`], and [`step_byte_word`]. + /// + /// [`text_len`]: Self::text_len + /// [`selection_text`]: Self::selection_text + /// [`step_byte`]: Self::step_byte + /// [`step_byte_word`]: Self::step_byte_word + fn text(&self) -> &str; + + /// Returns the visual position of `byte` in widget-local + /// coordinates. + fn byte_position(&self, byte: usize) -> Option; + + /// Hit-tests a widget-local point and returns the byte at that + /// position. + fn hit_test(&self, point: Point) -> Option; + + /// Returns the visual line height the widget renders with. + fn visual_line_height(&self) -> Option; + + /// Returns the rendered text height. Default vertical stepping + /// uses this to bail out past the last line so a coordinator can + /// cross into a sibling. + fn min_bounds_height(&self) -> f32; + + + /// Marks the widget as externally managed. While `true`, the + /// widget's own event handlers should skip drag-select and + /// `Ctrl+C`, leaving its selection for an external coordinator + /// to fill in. + fn set_externally_managed(&mut self, value: bool); + + /// Returns the total length, in bytes, of the widget's text. + fn text_len(&self) -> usize { + self.text().len() + } + + /// Returns the substring covered by the byte range + /// `[start, end)`. Snaps to UTF-8 boundaries. + fn selection_text(&self, start: usize, end: usize) -> String { + let text = self.text(); + let start = floor_char_boundary(text, start); + let end = floor_char_boundary(text, end); + text.get(start..end).unwrap_or("").to_string() + } + + /// Steps `byte` to the next or previous UTF-8 character + /// boundary. `dir > 0` moves forward; `dir < 0` moves backward. + fn step_byte(&self, byte: usize, dir: i32) -> usize { + let text = self.text(); + let len = text.len(); + + if dir > 0 { + let mut next = (byte + 1).min(len); + while next < len && !text.is_char_boundary(next) { + next += 1; + } + next + } else if byte == 0 { + 0 + } else { + let mut prev = byte - 1; + while prev > 0 && !text.is_char_boundary(prev) { + prev -= 1; + } + prev + } + } + + /// Steps `byte` to the end of the next word (forward) or to the + /// start of the previous word (backward), matching + /// `text_input::next_end_of_word` / `previous_start_of_word`. + fn step_byte_word(&self, byte: usize, dir: i32) -> usize { + use unicode_segmentation::UnicodeSegmentation; + + let text = self.text(); + let len = text.len(); + + if dir > 0 { + let suffix = &text[byte..]; + suffix + .split_word_bound_indices() + .find(|(_, w)| !w.trim_start().is_empty()) + .map(|(i, w)| byte + i + w.len()) + .unwrap_or(len) + } else { + let prefix = &text[..byte]; + prefix + .split_word_bound_indices() + .rfind(|(_, w)| !w.trim_start().is_empty()) + .map(|(i, _)| i) + .unwrap_or(0) + } + } + + /// Steps `byte` up or down one visual line. Returns `None` when + /// the target falls outside the widget's rendered area. + fn step_byte_line(&self, byte: usize, dir: i32) -> Option { + let position = self.byte_position(byte)?; + let line_height = self.visual_line_height()?; + let target = Point::new(position.x, position.y + dir as f32 * line_height); + + if target.y < 0.0 || target.y >= self.min_bounds_height() { + return None; + } + + self.hit_test(target) + } + + /// Returns the byte at the start (`dir < 0`) or end (`dir > 0`) of + /// the logical, `\n`-delimited line containing `byte`. Logical + /// rather than visual, so triple-click and `Home`/`End` cover a + /// whole wrapped line instead of stopping at a soft wrap. + fn line_edge_byte(&self, byte: usize, dir: i32) -> Option { + let text = self.text(); + let byte = byte.min(text.len()); + + if dir < 0 { + Some(text[..byte].rfind('\n').map(|i| i + 1).unwrap_or(0)) + } else { + Some(text[byte..].find('\n').map(|i| byte + i).unwrap_or(text.len())) + } + } +} + +fn floor_char_boundary(s: &str, mut idx: usize) -> usize { + if idx >= s.len() { + return s.len(); + } + + while idx > 0 && !s.is_char_boundary(idx) { + idx -= 1; + } + + idx +} + +/// Produces an [`Operation`] that runs `callback` on every +/// [`Selectable`] in the operated subtree, in tree order. +pub fn visit(callback: F) -> impl Operation +where + F: FnMut(Rectangle, &mut dyn Selectable) + Send, +{ + struct Visit { + callback: F, + } + + impl Operation for Visit + where + F: FnMut(Rectangle, &mut dyn Selectable) + Send, + { + fn selectable( + &mut self, + _id: Option<&Id>, + bounds: Rectangle, + state: &mut dyn Selectable, + ) { + (self.callback)(bounds, state); + } + + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); + } + } + + Visit { callback } +} diff --git a/core/src/widget/text.rs b/core/src/widget/text.rs index 46f2ba1d26..e44976b9f9 100644 --- a/core/src/widget/text.rs +++ b/core/src/widget/text.rs @@ -21,13 +21,17 @@ //! } //! ``` use crate::alignment; +use crate::clipboard; +use crate::keyboard; use crate::layout; use crate::mouse; use crate::renderer; use crate::text; use crate::text::paragraph::{self, Paragraph}; use crate::widget::tree::{self, Tree}; -use crate::{Color, Element, Layout, Length, Pixels, Rectangle, Size, Theme, Widget}; +use crate::{ + Color, Element, Event, Layout, Length, Pixels, Point, Rectangle, Shell, Size, Theme, Widget, +}; pub use text::{Alignment, Ellipsis, LineHeight, Shaping, Wrapping}; @@ -62,6 +66,7 @@ where fragment: text::Fragment<'a>, format: Format, class: Theme::Class<'a>, + selectable: bool, } impl<'a, Theme, Renderer> Text<'a, Theme, Renderer> @@ -75,9 +80,17 @@ where fragment: fragment.into_fragment(), format: Format::default(), class: Theme::default(), + selectable: false, } } + /// Allows the user to drag-select the [`Text`] and copy it with + /// `Ctrl+C` while focused. Off by default. + pub fn selectable(mut self, selectable: bool) -> Self { + self.selectable = selectable; + self + } + /// Sets the size of the [`Text`]. pub fn size(mut self, size: impl Into) -> Self { self.format.size = Some(size.into()); @@ -178,7 +191,14 @@ where { let color = color.map(Into::into); - self.style(move |_theme| Style { color }) + // Inherit the rest of the style (notably `selection`) from the + // theme's default class instead of `Style::default()` — which + // has a transparent `selection` and would silently disable + // selection highlights for any `text(...).color(...)` widget. + self.style(move |theme: &Theme| Style { + color, + ..theme.style(&::default()) + }) } /// Sets the style class of the [`Text`]. @@ -189,20 +209,101 @@ where } } -/// The internal state of a [`Text`] widget. +/// The internal state of a [`Text`] paragraph as used by label-style +/// widgets (e.g. `checkbox`, `radio`, `toggler`). The [`Text`] widget +/// itself uses a private state that wraps this with selection tracking. pub type State

= paragraph::Plain

; +struct Internal { + paragraph: paragraph::Plain

, + /// Cached fragment so the [`Selectable`] trait helpers can walk + /// codepoints / words without a fresh allocation per keystroke + /// and without carrying a reference to the widget. Refreshed on + /// every layout pass. + /// + /// [`Selectable`]: crate::widget::operation::Selectable + text: String, + selection: Option<(usize, usize)>, + selecting: bool, + focused: bool, + /// Set by [`selectable_group`] (or any coordinator using + /// [`Operation::selectable`]) to suppress this widget's own drag + /// and `Ctrl+C` handling — the coordinator owns those while it's + /// in the tree. + /// + /// [`Operation::selectable`]: crate::widget::operation::Operation::selectable + externally_managed: bool, + /// Most recent left-click; chained into `mouse::Click::new` so + /// repeated presses within iced's threshold escalate Single → + /// Double → Triple. + last_click: Option, +} + +impl Default for Internal

{ + fn default() -> Self { + Self { + paragraph: paragraph::Plain::default(), + text: String::new(), + selection: None, + selecting: false, + focused: false, + externally_managed: false, + last_click: None, + } + } +} + +impl crate::widget::operation::Selectable for Internal

{ + fn selection(&self) -> Option<(usize, usize)> { + self.selection + } + + fn set_selection(&mut self, range: Option<(usize, usize)>) { + self.selection = range; + } + + fn text(&self) -> &str { + &self.text + } + + fn byte_position(&self, byte: usize) -> Option { + self.paragraph.raw().byte_position(byte) + } + + fn hit_test(&self, point: Point) -> Option { + self.paragraph + .raw() + .hit_test(point) + .map(text::Hit::cursor) + } + + fn visual_line_height(&self) -> Option { + self.paragraph.raw().visual_line_height() + } + + fn min_bounds_height(&self) -> f32 { + self.paragraph.raw().min_bounds().height + } + + fn set_externally_managed(&mut self, value: bool) { + self.externally_managed = value; + if value { + self.selecting = false; + } + } +} + impl Widget for Text<'_, Theme, Renderer> where Theme: Catalog, Renderer: text::Renderer, { fn tag(&self) -> tree::Tag { - tree::Tag::of::>() + tree::Tag::of::>() } fn state(&self) -> tree::State { - tree::State::new(paragraph::Plain::::default()) + tree::State::new(Internal::::default()) } fn size(&self) -> Size { @@ -218,8 +319,15 @@ where renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { + let state = tree.state.downcast_mut::>(); + + if state.text != *self.fragment { + state.text.clear(); + state.text.push_str(&self.fragment); + } + layout( - tree.state.downcast_mut::>(), + &mut state.paragraph, renderer, limits, &self.fragment, @@ -237,30 +345,279 @@ where _cursor_position: mouse::Cursor, viewport: &Rectangle, ) { - let state = tree.state.downcast_ref::>(); + let state = tree.state.downcast_ref::>(); let style = theme.style(&self.class); + if self.selectable + && let Some((a, b)) = state.selection + { + let (start, end) = if a <= b { (a, b) } else { (b, a) }; + if start < end && style.selection.a > 0.0 { + let raw = state.paragraph.raw(); + let anchor = layout + .bounds() + .anchor(raw.min_bounds(), raw.align_x(), raw.align_y()); + let translation = anchor - Point::ORIGIN; + for bounds in raw.selection_bounds(start, end) { + renderer.fill_quad( + renderer::Quad { + bounds: bounds + translation, + ..Default::default() + }, + style.selection, + ); + } + } + } + draw( renderer, defaults, layout.bounds(), - state.raw(), + state.paragraph.raw(), style, viewport, ); } + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) { + if !self.selectable { + return; + } + + let cursor_in_bounds = cursor.position_in(layout.bounds()); + let externally_managed = tree + .state + .downcast_ref::>() + .externally_managed; + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + if !externally_managed => + { + use crate::widget::operation::Selectable; + + let state = tree.state.downcast_mut::>(); + + if let Some(position) = cursor_in_bounds + && let Some(hit) = state.paragraph.raw().hit_test(position) + { + let cursor_at = hit.cursor(); + let click = + mouse::Click::new(position, mouse::Button::Left, state.last_click); + + match click.kind() { + mouse::click::Kind::Single => { + state.selection = Some((cursor_at, cursor_at)); + state.selecting = true; + } + mouse::click::Kind::Double => { + let start = state.step_byte_word(cursor_at, -1); + let end = state.step_byte_word(cursor_at, 1); + state.selection = Some((start, end)); + state.selecting = false; + } + mouse::click::Kind::Triple => { + let len = state.text.len(); + let start = state.line_edge_byte(cursor_at, -1).unwrap_or(0); + let end = state.line_edge_byte(cursor_at, 1).unwrap_or(len); + state.selection = Some((start, end)); + state.selecting = false; + } + } + + state.last_click = Some(click); + state.focused = true; + shell.capture_event(); + shell.request_redraw(); + } else if state.selection.take().is_some() || state.focused { + // Press outside this widget's text drops focus, so + // siblings can self-clear on the same event. + state.focused = false; + state.last_click = None; + shell.request_redraw(); + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) if !externally_managed => { + let state = tree.state.downcast_mut::>(); + + if state.selecting + && let Some(position) = cursor_in_bounds + && let Some(hit) = state.paragraph.raw().hit_test(position) + { + let new_focus = hit.cursor(); + if let Some((anchor, focus)) = state.selection + && focus != new_focus + { + state.selection = Some((anchor, new_focus)); + shell.request_redraw(); + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + if !externally_managed => + { + let state = tree.state.downcast_mut::>(); + + if state.selecting { + state.selecting = false; + + if let Some((a, b)) = state.selection + && a == b + { + state.selection = None; + } + } + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character(c), + modifiers, + .. + }) if !externally_managed + && modifiers.command() + && matches!(c.as_str(), "c" | "C") => + { + let state = tree.state.downcast_ref::>(); + if state.focused + && let Some((a, b)) = state.selection + { + let (start, end) = if a <= b { (a, b) } else { (b, a) }; + if start < end { + let extracted = self + .fragment + .get( + floor_char_boundary(&self.fragment, start) + ..floor_char_boundary(&self.fragment, end), + ) + .unwrap_or("") + .to_owned(); + if !extracted.is_empty() { + shell.write_clipboard(clipboard::Content::Text(extracted)); + shell.capture_event(); + } + } + } + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character(c), + modifiers, + .. + }) if !externally_managed + && modifiers.command() + && matches!(c.as_str(), "a" | "A") => + { + let state = tree.state.downcast_mut::>(); + if state.focused { + let len = state.text.len(); + if len > 0 { + state.selection = Some((0, len)); + shell.capture_event(); + shell.request_redraw(); + } + } + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(named), + modifiers, + .. + }) if !externally_managed => { + use crate::widget::operation::Selectable; + + let state = tree.state.downcast_mut::>(); + + if !state.focused { + return; + } + + if matches!(named, keyboard::key::Named::Escape) { + if state.selection.take().is_some() { + state.focused = false; + shell.capture_event(); + shell.request_redraw(); + } + return; + } + + if !modifiers.shift() { + return; + } + + let Some((dir, by_word)) = (match named { + keyboard::key::Named::ArrowLeft if modifiers.command() => Some((-1, true)), + keyboard::key::Named::ArrowRight if modifiers.command() => Some((1, true)), + keyboard::key::Named::ArrowLeft => Some((-1, false)), + keyboard::key::Named::ArrowRight => Some((1, false)), + _ => None, + }) else { + return; + }; + + let (anchor, focus) = state.selection.unwrap_or((0, 0)); + let new_focus = if by_word { + state.step_byte_word(focus, dir) + } else { + state.step_byte(focus, dir) + }; + + if new_focus != focus { + state.selection = Some((anchor, new_focus)); + shell.capture_event(); + shell.request_redraw(); + } + } + _ => {} + } + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + if self.selectable && cursor.is_over(layout.bounds()) { + mouse::Interaction::Text + } else { + mouse::Interaction::None + } + } + 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.selectable(None, layout.bounds(), state); + } } } +fn floor_char_boundary(s: &str, mut idx: usize) -> usize { + if idx >= s.len() { + return s.len(); + } + while idx > 0 && !s.is_char_boundary(idx) { + idx -= 1; + } + idx +} + /// The format of some [`Text`]. /// /// Check out the methods of the [`Text`] widget @@ -395,6 +752,8 @@ pub struct Style { /// /// The default, `None`, means using the inherited color. pub color: Option, + /// The [`Color`] used to highlight selected text. + pub selection: Color, } /// The theme catalog of a [`Text`]. @@ -418,7 +777,7 @@ impl Catalog for Theme { type Class<'a> = StyleFn<'a, Self>; fn default<'a>() -> Self::Class<'a> { - Box::new(|_theme| Style::default()) + Box::new(default) } fn style(&self, class: &Self::Class<'_>) -> Style { @@ -427,14 +786,18 @@ impl Catalog for Theme { } /// The default text styling; color is inherited. -pub fn default(_theme: &Theme) -> Style { - Style { color: None } +pub fn default(theme: &Theme) -> Style { + Style { + color: None, + selection: theme.palette().primary.weak.color, + } } /// Text with the default base color. pub fn base(theme: &Theme) -> Style { Style { color: Some(theme.seed().text), + selection: theme.palette().primary.weak.color, } } @@ -442,6 +805,7 @@ pub fn base(theme: &Theme) -> Style { pub fn primary(theme: &Theme) -> Style { Style { color: Some(theme.seed().primary), + selection: theme.palette().primary.weak.color, } } @@ -449,6 +813,7 @@ pub fn primary(theme: &Theme) -> Style { pub fn secondary(theme: &Theme) -> Style { Style { color: Some(theme.palette().secondary.base.color), + selection: theme.palette().primary.weak.color, } } @@ -456,6 +821,7 @@ pub fn secondary(theme: &Theme) -> Style { pub fn success(theme: &Theme) -> Style { Style { color: Some(theme.seed().success), + selection: theme.palette().primary.weak.color, } } @@ -463,6 +829,7 @@ pub fn success(theme: &Theme) -> Style { pub fn warning(theme: &Theme) -> Style { Style { color: Some(theme.seed().warning), + selection: theme.palette().primary.weak.color, } } @@ -470,5 +837,6 @@ pub fn warning(theme: &Theme) -> Style { pub fn danger(theme: &Theme) -> Style { Style { color: Some(theme.seed().danger), + selection: theme.palette().primary.weak.color, } } diff --git a/examples/text_selection_test/Cargo.toml b/examples/text_selection_test/Cargo.toml new file mode 100644 index 0000000000..28238c3a8b --- /dev/null +++ b/examples/text_selection_test/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "text_selection_test" +version = "0.1.0" +authors = ["Wilson Glasser "] +edition = "2024" +publish = false + +[dependencies] +iced.workspace = true +iced.features = ["markdown"] diff --git a/examples/text_selection_test/src/main.rs b/examples/text_selection_test/src/main.rs new file mode 100644 index 0000000000..fb5c9a6f88 --- /dev/null +++ b/examples/text_selection_test/src/main.rs @@ -0,0 +1,259 @@ +//! Manual test harness for the text-selection PR. Exercises the three +//! pieces of reviewer feedback that aren't covered by `cargo test`: +//! +//! 1. Up/Down keyboard nav crossing into siblings (the `Line` / +//! `LineEdge` filter fix). +//! 2. `Ctrl+A` not leaking across separate `selectable_group`s nor +//! into a focused `text_editor`. +//! 3. Single / Double / Triple click dispatch (drag / word / line). +//! 4. Multi-line offsets and wrapped triple-click: a selection on one +//! `\n`-separated line must not paint the same columns on the +//! others, and triple-click must cover a whole wrapped logical line. +//! +//! The example mixes markdown views, a custom `selectable_group` of +//! plain `text` + `rich_text`, standalone selectable widgets, and a +//! `text_editor`, so the keyboard / click flows can be checked across +//! every selection-aware widget at once. +//! +//! Run with `cargo run -p text_selection_test`. + +use iced::widget::{ + SelectableGroup, column, container, markdown, rich_text, row, + scrollable, selectable_group, span, text, text_editor, +}; +use iced::{Element, Fill, Never, Theme}; + +fn never(never: Never) -> T { + match never {} +} + +pub fn main() -> iced::Result { + iced::application(Test::new, Test::update, Test::view) + .theme(Test::theme) + .run() +} + +struct Test { + md_a: markdown::Content, + md_b: markdown::Content, + editor: text_editor::Content, + theme: Theme, +} + +#[derive(Debug, Clone)] +enum Message { + EditorAction(text_editor::Action), + #[allow(dead_code)] + LinkClicked(markdown::Uri), +} + +impl Test { + fn new() -> Self { + Self { + md_a: markdown::Content::parse(MD_A), + md_b: markdown::Content::parse(MD_B), + editor: text_editor::Content::with_text(EDITOR_TEXT), + theme: Theme::TokyoNight, + } + } + + fn theme(&self) -> Theme { + self.theme.clone() + } + + fn update(&mut self, message: Message) { + match message { + Message::EditorAction(action) => self.editor.perform(action), + Message::LinkClicked(_) => {} + } + } + + fn view(&self) -> Element<'_, Message> { + let settings = markdown::Settings { + selectable: true, + group_selection: true, + ..markdown::Settings::with_style(&self.theme) + }; + + let md_a: Element<'_, _> = markdown::view(self.md_a.items(), settings) + .map(Message::LinkClicked); + let md_b: Element<'_, _> = markdown::view(self.md_b.items(), settings) + .map(Message::LinkClicked); + + // Custom selectable_group mixing plain text and rich_text in a + // column, so coordination across heterogeneous children gets + // exercised outside the markdown pipeline too. + let mixed_rich = rich_text![ + span("Then "), + span("a rich_text![...] "), + span("with multiple spans on the same line."), + ] + .on_link_click(never) + .selectable(true); + + let mixed_group: SelectableGroup<'_, Never, _> = selectable_group( + column![ + text("Mixed group · this paragraph is a plain text(...) widget.") + .selectable(true), + mixed_rich, + text( + "And one more text(...) line — try ArrowDown from the \ + last visual line of the rich_text above and the caret \ + should land here, not stick.", + ) + .selectable(true), + ] + .spacing(8), + ); + + let standalone_text = text( + "Standalone plain text(...). Single-click places caret. \ + Double-click selects a word. Triple-click selects the line. \ + Ctrl+A selects all of this once focused.", + ) + .selectable(true); + + let standalone_rich = rich_text![ + span("Standalone rich_text![...] — "), + span("triple-click here selects this whole line; "), + span("double-click selects a word."), + ] + .on_link_click(never) + .selectable(true); + + // Explicit `\n`-separated lines in a monospace font: dragging + // within one row must not highlight the same byte range on the + // others (the per-line vs global offset regression). + let multiline_plain = text(MULTILINE_PLAIN) + .font(iced::Font::MONOSPACE) + .selectable(true); + + // A single long logical line forced to wrap: triple-click should + // select all of it, not stop at the soft wrap boundary. + let wrapped_logical_line = text(WRAPPED_LOGICAL_LINE) + .width(240) + .selectable(true); + + let editor = text_editor(&self.editor) + .placeholder("text_editor — Ctrl+A here must NOT spill into the views above") + .on_action(Message::EditorAction) + .height(120) + .padding(8); + + let header = text( + "Try: drag-select inside one view, then click the other — \ + the previous selection should clear. Ctrl+A only selects \ + the most-recently-clicked widget. Double / triple click \ + a word / line in any selectable widget.", + ) + .size(13); + + let panel = |title, inner| { + container( + column![ + text(title).size(13), + container(scrollable(inner).height(Fill)) + .padding(8) + .style(container::bordered_box), + ] + .spacing(6), + ) + .width(Fill) + .height(Fill) + }; + + column![ + header, + row![ + panel("markdown view A (selectable_group via markdown)", md_a), + panel("markdown view B (selectable_group via markdown)", md_b), + ] + .spacing(12) + .height(Fill), + panel( + "custom selectable_group(column![text + rich_text + text])", + mixed_group.into(), + ), + row![ + panel("standalone text(...).selectable(true)", standalone_text.into()), + panel("standalone rich_text![...].selectable(true)", standalone_rich.into()), + ] + .spacing(12) + .height(140), + row![ + panel( + "multi-line plain text (rows must not bleed into each other)", + multiline_plain.into(), + ), + panel( + "wrapped logical line (triple-click selects the whole line)", + wrapped_logical_line.into(), + ), + ] + .spacing(12) + .height(140), + text("text_editor (focus-stealer test):").size(13), + editor, + ] + .spacing(12) + .padding(16) + .into() + } +} + +const MD_A: &str = "\ +# Markdown A + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod +tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim +veniam, quis nostrud exercitation ullamco laboris. + +## Second paragraph + +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum +dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non +proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + +- list item one with enough text to wrap onto a second visual line +- list item two +- list item three + +```rust +fn drag_select() { + println!(\"triple-click selects this whole line\"); +} +``` +"; + +const MD_B: &str = "\ +# Markdown B + +Sit on a separate paragraph chain so cross-group `Ctrl+A` does **not** +fire across both views — only the most-recently-clicked group should +react. + +> Quote: pressing `ArrowDown` at the end of this paragraph should jump +> into the next sibling, not stick on the current line. + +1. Numbered item one +2. Numbered item two +3. Numbered item three with a [link](https://iced.rs) inside + +The end. +"; + +const MULTILINE_PLAIN: &str = "\ +Very fancy line here +Any another one in here... +Third one +Lets add a final fancy line. ok bye"; + +const WRAPPED_LOGICAL_LINE: &str = + "This is one long logical line with no newlines, so it wraps across \ + several visual rows. Triple-clicking anywhere on it should select \ + the entire logical line at once, not just the wrapped row."; + +const EDITOR_TEXT: &str = "\ +text_editor content. Click into me and press Ctrl+A — none of the \ +markdown views, the mixed group, or the standalone widgets above \ +should react."; diff --git a/examples/todos/src/main.rs b/examples/todos/src/main.rs index cce7fa1202..cfaff69ff6 100644 --- a/examples/todos/src/main.rs +++ b/examples/todos/src/main.rs @@ -457,6 +457,7 @@ fn delete_icon() -> Text<'static> { fn subtle(theme: &Theme) -> text::Style { text::Style { color: Some(theme.palette().background.strongest.color), + ..text::Style::default() } } diff --git a/graphics/src/text/paragraph.rs b/graphics/src/text/paragraph.rs index 823680c0c8..5157904127 100644 --- a/graphics/src/text/paragraph.rs +++ b/graphics/src/text/paragraph.rs @@ -60,6 +60,14 @@ impl Paragraph { } } +// Byte stride of a buffer line in the original content, including its `\n` +// separator. cosmic-text indexes glyphs per line; selection offsets are +// global. The byte-mapping methods below share this to bridge the two. +// Assumes single-byte separators (`\n`), which is what the app produces. +fn buffer_line_byte_len(line: &cosmic_text::BufferLine) -> usize { + line.text().len() + 1 +} + impl core::text::Paragraph for Paragraph { type Font = Font; @@ -297,12 +305,21 @@ impl core::text::Paragraph for Paragraph { } fn hit_test(&self, point: Point) -> Option { - let cursor = self - .internal() + let internal = self.internal(); + let cursor = internal .buffer .hit(point.x * self.0.hint_factor, point.y * self.0.hint_factor)?; - Some(Hit::CharOffset(cursor.index)) + // `index` is relative to the line it landed on; make it global. + let line_base: usize = internal + .buffer + .lines + .iter() + .take(cursor.line) + .map(buffer_line_byte_len) + .sum(); + + Some(Hit::CharOffset(line_base + cursor.index)) } fn hit_span(&self, point: Point) -> Option { @@ -387,6 +404,132 @@ impl core::text::Paragraph for Paragraph { bounds } + fn selection_bounds(&self, start: usize, end: usize) -> Vec { + if start >= end { + return Vec::new(); + } + + let internal = self.internal(); + let buffer = &internal.buffer; + let line_height = buffer.metrics().line_height; + let scroll_y = buffer.scroll().vertical; + + let mut rects = Vec::new(); + let mut visual_line: i32 = 0; + let mut line_byte: usize = 0; + + for buffer_line in buffer.lines.iter() { + let layout = buffer_line + .layout_opt() + .map(Vec::as_slice) + .unwrap_or_default(); + + // Bring the global range into this line's local glyph space, so + // a range on one line can't match the same columns on every line. + let local_start = start.saturating_sub(line_byte); + let local_end = end.saturating_sub(line_byte); + + for vline in layout { + let glyph_start = vline.glyphs.first().map(|g| g.start).unwrap_or(0); + let glyph_end = vline.glyphs.last().map(|g| g.end).unwrap_or(0); + + let range_start = glyph_start.max(local_start); + let range_end = glyph_end.min(local_end); + + if range_start < range_end { + let (x, width) = if range_start == glyph_start && range_end == glyph_end { + (0.0, vline.w) + } else { + let first_glyph_idx = vline + .glyphs + .iter() + .position(|g| range_start <= g.start) + .unwrap_or(0); + let mut iter = vline.glyphs.iter(); + let x: f32 = iter.by_ref().take(first_glyph_idx).map(|g| g.w).sum(); + let w: f32 = iter.take_while(|g| range_end > g.start).map(|g| g.w).sum(); + (x, w) + }; + + if width > 0.0 { + let y = visual_line as f32 * line_height - scroll_y; + rects.push( + Rectangle { + x, + y, + width, + height: line_height, + } * (1.0 / self.0.hint_factor), + ); + } + } + + visual_line += 1; + } + + line_byte += buffer_line_byte_len(buffer_line); + } + + rects + } + + fn byte_position(&self, byte: usize) -> Option { + let internal = self.internal(); + let buffer = &internal.buffer; + let line_height = buffer.metrics().line_height; + let scroll_y = buffer.scroll().vertical; + let inv = 1.0 / self.0.hint_factor; + + let mut visual_line: i32 = 0; + let mut line_byte: usize = 0; + let mut last_line_end: Option<(f32, i32)> = None; + + for buffer_line in buffer.lines.iter() { + let layout = buffer_line + .layout_opt() + .map(Vec::as_slice) + .unwrap_or_default(); + + // Glyph offsets are line-local; bring the global byte into them. + let local = byte.saturating_sub(line_byte); + + for vline in layout { + for glyph in &vline.glyphs { + if local < glyph.start { + let y = visual_line as f32 * line_height - scroll_y; + return Some(Point::new(glyph.x * inv, y * inv)); + } + if local >= glyph.start && local <= glyph.end { + let span = glyph.end.saturating_sub(glyph.start); + let frac = if span == 0 { + 0.0 + } else { + (local - glyph.start) as f32 / span as f32 + }; + let x = glyph.x + glyph.w * frac; + let y = visual_line as f32 * line_height - scroll_y; + return Some(Point::new(x * inv, y * inv)); + } + } + if let Some(last) = vline.glyphs.last() { + last_line_end = Some((last.x + last.w, visual_line)); + } + visual_line += 1; + } + + line_byte += buffer_line_byte_len(buffer_line); + } + + last_line_end.map(|(x, line)| { + let y = line as f32 * line_height - scroll_y; + Point::new(x * inv, y * inv) + }) + } + + fn visual_line_height(&self) -> Option { + Some(self.internal().buffer.metrics().line_height / self.0.hint_factor) + } + fn grapheme_position(&self, line: usize, index: usize) -> Option { use unicode_segmentation::UnicodeSegmentation; @@ -510,3 +653,118 @@ impl PartialEq for Weak { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::text::Paragraph as _; + + fn plain(content: &str, width: f32) -> Paragraph { + Paragraph::with_text(Text { + content, + bounds: Size::new(width, 1000.0), + size: Pixels(16.0), + line_height: LineHeight::default(), + font: Font::default(), + align_x: Alignment::default(), + align_y: alignment::Vertical::Top, + shaping: Shaping::Advanced, + wrapping: Wrapping::default(), + ellipsis: Ellipsis::default(), + hint_factor: None, + }) + } + + // Selecting one `\n`-separated line must not paint the same columns + // on the others. This is the per-line vs global offset regression. + #[test] + fn selection_stays_on_its_own_line() { + let para = plain("AAA\nBBB\nCCC", 1000.0); + + let y0 = para.byte_position(0).unwrap().y; + let y1 = para.byte_position(4).unwrap().y; + let y2 = para.byte_position(8).unwrap().y; + assert!(y0 < y1 && y1 < y2, "lines should stack vertically"); + + // "BBB" is bytes 4..7. Every rect must sit on line 1. + let rects = para.selection_bounds(4, 7); + assert!(!rects.is_empty(), "middle line should be highlighted"); + for r in &rects { + assert!( + (r.y - y1).abs() < (y1 - y0) / 2.0, + "rect at y={} leaked off line 1 (y={y1})", + r.y, + ); + } + } + + // A full selection spans all three lines, not just the first. + #[test] + fn full_selection_spans_every_line() { + let content = "AAA\nBBB\nCCC"; + let para = plain(content, 1000.0); + + let rects = para.selection_bounds(0, content.len()); + let mut ys: Vec = rects.iter().map(|r| r.y).collect(); + ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); + ys.dedup_by(|a, b| (*a - *b).abs() < 0.5); + assert_eq!(ys.len(), 3, "selection should cover all three lines"); + } + + // Geometry round-trips: a byte on a later line maps back to itself, + // not to the same column on line 0 (off by at most a glyph edge). + #[test] + fn hit_test_round_trips_across_lines() { + let para = plain("AAA\nBBB\nCCC", 1000.0); + for byte in [5usize, 9] { + let p = para.byte_position(byte).unwrap(); + let hit = para.hit_test(p).unwrap().cursor() as i64; + assert!( + (hit - byte as i64).abs() <= 1, + "byte {byte} round-tripped to {hit}", + ); + } + } + + // Triple-click / line edges follow the logical line through a wrap. + #[test] + fn line_edges_are_logical_not_visual() { + use crate::core::widget::operation::Selectable; + + struct Probe(String); + impl Selectable for Probe { + fn selection(&self) -> Option<(usize, usize)> { + None + } + fn set_selection(&mut self, _: Option<(usize, usize)>) {} + fn text(&self) -> &str { + &self.0 + } + fn byte_position(&self, _: usize) -> Option { + None + } + fn hit_test(&self, _: Point) -> Option { + None + } + fn visual_line_height(&self) -> Option { + None + } + fn min_bounds_height(&self) -> f32 { + 0.0 + } + fn set_externally_managed(&mut self, _: bool) {} + } + + // One long logical line, no `\n`: both edges reach the ends + // regardless of where it would wrap visually. + let probe = Probe("a really long single logical line".into()); + let len = probe.text().len(); + assert_eq!(probe.line_edge_byte(10, -1), Some(0)); + assert_eq!(probe.line_edge_byte(10, 1), Some(len)); + + // Middle line of a multi-line string stops at its own `\n`s. + let probe = Probe("AAA\nBBB\nCCC".into()); + assert_eq!(probe.line_edge_byte(5, -1), Some(4)); + assert_eq!(probe.line_edge_byte(5, 1), Some(7)); + } +} diff --git a/tester/src/lib.rs b/tester/src/lib.rs index 1ce6635320..ada88c9724 100644 --- a/tester/src/lib.rs +++ b/tester/src/lib.rs @@ -717,6 +717,7 @@ impl Tester

{ } _ => None, }, + ..text::Style::default() }) .into() }), @@ -882,6 +883,7 @@ where text(label).size(14).style(|theme: &core::Theme| { text::Style { color: Some(theme.palette().background.weak.text), + ..text::Style::default() } }), space::horizontal(), diff --git a/widget/src/checkbox.rs b/widget/src/checkbox.rs index d31437a3ec..d0059888b7 100644 --- a/widget/src/checkbox.rs +++ b/widget/src/checkbox.rs @@ -449,6 +449,7 @@ where state.raw(), crate::text::Style { color: style.text_color, + ..crate::text::Style::default() }, viewport, ); diff --git a/widget/src/lib.rs b/widget/src/lib.rs index 3164db2def..789494c365 100644 --- a/widget/src/lib.rs +++ b/widget/src/lib.rs @@ -32,6 +32,7 @@ pub mod radio; pub mod row; pub mod rule; pub mod scrollable; +pub mod selectable_group; pub mod sensor; pub mod slider; pub mod space; @@ -88,6 +89,8 @@ pub use rule::Rule; #[doc(no_inline)] pub use scrollable::Scrollable; #[doc(no_inline)] +pub use selectable_group::{SelectableGroup, selectable_group}; +#[doc(no_inline)] pub use sensor::Sensor; #[doc(no_inline)] pub use slider::Slider; diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index d879790772..5abb320b28 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -1013,6 +1013,15 @@ pub struct Settings { pub spacing: Pixels, /// The styling of the Markdown. pub style: Style, + /// Whether the rendered Markdown should be selectable. + pub selectable: bool, + /// When `true`, the rendered Markdown is wrapped in a + /// [`selectable_group`], allowing the user to drag-select across + /// paragraphs, headings, list items, and code blocks. Has no + /// effect unless `selectable` is also `true`. + /// + /// [`selectable_group`]: crate::selectable_group + pub group_selection: bool, } impl Settings { @@ -1040,6 +1049,8 @@ impl Settings { code_size: text_size * 0.75, spacing: text_size * 0.875, style: style.into(), + selectable: false, + group_selection: false, } } } @@ -1186,7 +1197,13 @@ where .enumerate() .map(|(i, item_)| item(viewer, settings, item_, i)); - Element::new(column(blocks).spacing(settings.spacing)) + let column = column(blocks).spacing(settings.spacing); + + if settings.selectable && settings.group_selection { + crate::selectable_group::selectable_group::(column).into() + } else { + Element::new(column) + } } /// Displays an [`Item`] using the given [`Viewer`]. @@ -1251,6 +1268,7 @@ where container( rich_text(text.spans(settings.style)) .on_link_click(on_link_click) + .selectable(settings.selectable) .size(match level { pulldown_cmark::HeadingLevel::H1 => h1_size, pulldown_cmark::HeadingLevel::H2 => h2_size, @@ -1282,6 +1300,7 @@ where rich_text(text.spans(settings.style)) .size(settings.text_size) .on_link_click(on_link_click) + .selectable(settings.selectable) .into() } @@ -1310,13 +1329,13 @@ where ) } }, - view_with( - bullet.items(), + items( + viewer, Settings { spacing: settings.spacing * 0.6, ..settings }, - viewer, + bullet.items(), ) ] .spacing(settings.spacing) @@ -1348,13 +1367,13 @@ where .size(settings.text_size) .align_x(alignment::Horizontal::Right) .width(settings.text_size * ((digits as f32 / 2.0).ceil() + 1.0)), - view_with( - bullet.items(), + items( + viewer, Settings { spacing: settings.spacing * 0.6, ..settings }, - viewer, + bullet.items(), ) ] .spacing(settings.spacing) @@ -1380,6 +1399,7 @@ where container(column(lines.iter().map(|line| { rich_text(line.spans(settings.style)) .on_link_click(on_link_click.clone()) + .selectable(settings.selectable) .font(settings.style.code_block_font) .size(settings.code_size) .into() @@ -1524,10 +1544,14 @@ where let _url = url; let _title = title; - container(rich_text(alt.spans(settings.style)).on_link_click(Self::on_link_click)) - .padding(settings.spacing.0) - .class(Theme::code_block()) - .into() + container( + rich_text(alt.spans(settings.style)) + .on_link_click(Self::on_link_click) + .selectable(settings.selectable), + ) + .padding(settings.spacing.0) + .class(Theme::code_block()) + .into() } /// Displays a heading. diff --git a/widget/src/radio.rs b/widget/src/radio.rs index 1433f90e4d..1a63744f7c 100644 --- a/widget/src/radio.rs +++ b/widget/src/radio.rs @@ -441,6 +441,7 @@ where state.raw(), crate::text::Style { color: style.text_color, + ..crate::text::Style::default() }, viewport, ); diff --git a/widget/src/selectable_group.rs b/widget/src/selectable_group.rs new file mode 100644 index 0000000000..02abbfd07f --- /dev/null +++ b/widget/src/selectable_group.rs @@ -0,0 +1,858 @@ +//! Coordinate text selection across sibling text widgets. +//! +//! [`selectable_group`] wraps any [`Element`] and lets the user +//! drag-select continuously across the [`text`] / [`rich_text`] +//! children inside it that opted in via `.selectable(true)`. `Ctrl+C` +//! copies the concatenated selection in tree order, joined by +//! newlines. Wrapping is opt-in — non-grouped selectable widgets keep +//! working per-widget exactly as before. +//! +//! [`text`]: crate::text +//! [`rich_text`]: crate::rich_text +//! [`selectable_group`]: fn@selectable_group +use crate::core::clipboard; +use crate::core::keyboard; +use crate::core::layout; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::text; +use crate::core::widget::operation::Selectable; +use crate::core::widget::tree::{self, Tree}; +use crate::core::{self, Element, Event, Layout, Length, Rectangle, Shell, Size, Widget}; + +use std::marker::PhantomData; + +/// A widget that coordinates drag-selection across the selectable +/// `text` and `rich_text` widgets it contains. +pub struct SelectableGroup<'a, Link, Message, Theme = crate::Theme, Renderer = crate::Renderer> +where + Link: Clone + 'static, + Renderer: text::Renderer, +{ + content: Element<'a, Message, Theme, Renderer>, + _link: PhantomData, +} + +/// Wraps `content` so its selectable text widgets share a single +/// drag-selection. Cross-widget selection only works when this +/// wrapper is present; without it, each widget selects on its own. +pub fn selectable_group<'a, Link, Message, Theme, Renderer>( + content: impl Into>, +) -> SelectableGroup<'a, Link, Message, Theme, Renderer> +where + Link: Clone + 'static, + Renderer: text::Renderer, +{ + SelectableGroup { + content: content.into(), + _link: PhantomData, + } +} + +#[derive(Default)] +struct GroupState { + /// Index into the flattened list of selectables where the drag + /// started, plus the byte offset within that selectable. + anchor: Option<(usize, usize)>, + /// The current focus end of the selection — moves on drag and + /// `Shift+Arrow`. Stored alongside `anchor` so keyboard navigation + /// has a starting point even after the drag is over. + focus: Option<(usize, usize)>, + /// Screen X column the user is "tracking" for vertical + /// navigation (`Shift+Up`/`Down`). Updated on click, drag, and + /// horizontal keyboard moves; preserved across vertical moves so + /// repeated `Shift+Down` lands at the same column even when + /// intermediate lines are shorter than the original. + preferred_x: Option, + /// Most recent keyboard modifier state, mirrored from + /// `ModifiersChanged` events. Mouse events don't carry modifier + /// info, so press handlers consult this to detect `Shift+Click`. + modifiers: keyboard::Modifiers, + /// Whether the user is currently extending a selection by drag. + selecting: bool, + /// Most recent left-click; chained into `mouse::Click::new` so + /// repeated presses within iced's threshold escalate Single → + /// Double → Triple. + last_click: Option, +} + +impl<'a, Link, Message, Theme, Renderer> Widget + for SelectableGroup<'a, Link, Message, Theme, Renderer> +where + Link: Clone + 'static, + Renderer: text::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(GroupState::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: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn core::widget::Operation, + ) { + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + defaults: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + defaults, + layout, + cursor, + viewport, + ); + } + + 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 update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + // Mark every selectable in the subtree as externally managed + // so its own `update` skips drag-select and Ctrl+C; we own + // those here. + visit_selectables( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + |_, _, state| state.set_externally_managed(true), + ); + + let cursor_position = cursor.position(); + + match event { + Event::Keyboard(keyboard::Event::ModifiersChanged(m)) => { + tree.state.downcast_mut::().modifiers = *m; + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + let (extend, prior_anchor, last_click) = { + let group = tree.state.downcast_ref::(); + (group.modifiers.shift(), group.anchor, group.last_click) + }; + + let mut hit_index = None; + let mut hit_byte = 0usize; + + if let Some(point) = cursor_position { + visit_selectables( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + |index, bounds, state| { + if hit_index.is_some() { + return; + } + if !bounds.contains(point) { + return; + } + let local = point - bounds.position(); + let byte = state.hit_test(core::Point::ORIGIN + local).unwrap_or(0); + hit_index = Some(index); + hit_byte = byte; + }, + ); + } + + if let Some(focus_idx) = hit_index { + let click_point = cursor_position.unwrap_or(core::Point::ORIGIN); + let click = mouse::Click::new(click_point, mouse::Button::Left, last_click); + // Extend (Shift+click) takes priority over count + // escalation — Single starts/extends a drag, + // Double selects word, Triple selects line. + let kind = if extend { + mouse::click::Kind::Single + } else { + click.kind() + }; + + let mut word_or_line: Option<(usize, usize)> = None; + if matches!( + kind, + mouse::click::Kind::Double | mouse::click::Kind::Triple + ) { + visit_selectables( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + |index, _, state| { + if index != focus_idx { + return; + } + let len = state.text_len(); + word_or_line = Some(match kind { + mouse::click::Kind::Double => ( + state.step_byte_word(hit_byte, -1), + state.step_byte_word(hit_byte, 1), + ), + mouse::click::Kind::Triple => ( + state.line_edge_byte(hit_byte, -1).unwrap_or(0), + state.line_edge_byte(hit_byte, 1).unwrap_or(len), + ), + mouse::click::Kind::Single => unreachable!(), + }); + }, + ); + } + + let (anchor, focus, selecting) = + if let Some((start, end)) = word_or_line { + ((focus_idx, start), (focus_idx, end), false) + } else if extend { + ( + prior_anchor.unwrap_or((focus_idx, hit_byte)), + (focus_idx, hit_byte), + true, + ) + } else { + ( + (focus_idx, hit_byte), + (focus_idx, hit_byte), + true, + ) + }; + let (a_idx, a_byte) = anchor; + let (f_idx, f_byte) = focus; + + visit_selectables( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + |index, _, state| { + let len = state.text_len(); + let range = + selection_range_for(index, a_idx, a_byte, f_idx, f_byte, len); + state.set_selection(range); + }, + ); + + let group = tree.state.downcast_mut::(); + group.anchor = Some(anchor); + group.focus = Some(focus); + group.preferred_x = cursor_position.map(|p| p.x); + group.selecting = selecting; + group.last_click = Some(click); + shell.capture_event(); + shell.request_redraw(); + } else { + visit_selectables( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + |_, _, state| state.set_selection(None), + ); + let group = tree.state.downcast_mut::(); + group.anchor = None; + group.focus = None; + group.preferred_x = None; + group.selecting = false; + group.last_click = None; + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + let group = tree.state.downcast_ref::(); + + if let (true, Some((anchor_idx, anchor_byte)), Some(point)) = + (group.selecting, group.anchor, cursor_position) + { + let mut focus_index = None; + let mut focus_byte = 0usize; + let mut totals: Vec<(Rectangle, usize)> = Vec::new(); + + visit_selectables( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + |index, bounds, state| { + let len = state.text_len(); + totals.push((bounds, len)); + + if focus_index.is_some() { + return; + } + if bounds.y > point.y || bounds.contains(point) { + let local = point - bounds.position(); + let byte = state.hit_test(core::Point::ORIGIN + local).unwrap_or(0); + focus_index = Some(index); + focus_byte = byte; + } + }, + ); + + // Cursor past the last selectable — clamp to its end. + let focus = focus_index + .map(|i| (i, focus_byte)) + .or_else(|| totals.last().map(|(_, len)| (totals.len() - 1, *len))); + + if let Some((focus_idx, focus_byte)) = focus { + visit_selectables( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + |index, _, state| { + let len = state.text_len(); + let range = selection_range_for( + index, + anchor_idx, + anchor_byte, + focus_idx, + focus_byte, + len, + ); + state.set_selection(range); + }, + ); + + let group = tree.state.downcast_mut::(); + group.focus = Some((focus_idx, focus_byte)); + group.preferred_x = Some(point.x); + shell.request_redraw(); + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + let group = tree.state.downcast_mut::(); + group.selecting = false; + + // Collapse zero-width "click only" selections so a + // stray single click doesn't leave a stale 0..0 range. + visit_selectables( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + |_, _, state| { + if let Some((a, b)) = state.selection() + && a == b + { + state.set_selection(None); + } + }, + ); + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character(c), + modifiers, + .. + }) if modifiers.command() && matches!(c.as_str(), "c" | "C") => { + let mut chunks: Vec = Vec::new(); + + visit_selectables( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + |_, _, state| { + if let Some((a, b)) = state.selection() { + let (start, end) = if a <= b { (a, b) } else { (b, a) }; + if start < end { + chunks.push(state.selection_text(start, end)); + } + } + }, + ); + + if !chunks.is_empty() { + let extracted = chunks.join("\n"); + if !extracted.is_empty() { + shell.write_clipboard(clipboard::Content::Text(extracted)); + shell.capture_event(); + } + } + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character(c), + modifiers, + .. + }) if modifiers.command() + && matches!(c.as_str(), "a" | "A") + && { + let group = tree.state.downcast_ref::(); + group.anchor.is_some() || group.focus.is_some() + } => + { + // Select all selectables in tree order. The anchor + // becomes the start of the first one, focus the end + // of the last. Only fires when this group has an + // existing selection / caret — without that gate every + // sibling group would steal `Ctrl+A` from each other + // and from focused `text_editor` widgets. + let mut total_count = 0usize; + let mut last_len = 0usize; + + visit_selectables( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + |_, _, state| { + let len = state.text_len(); + state.set_selection(if len > 0 { Some((0, len)) } else { None }); + total_count += 1; + last_len = len; + }, + ); + + if total_count > 0 { + let group = tree.state.downcast_mut::(); + group.anchor = Some((0, 0)); + group.focus = Some((total_count - 1, last_len)); + shell.capture_event(); + shell.request_redraw(); + } + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(named), + modifiers, + .. + }) => match named { + keyboard::key::Named::Escape => { + let group = tree.state.downcast_mut::(); + let had_anything = group.anchor.is_some() || group.focus.is_some(); + group.anchor = None; + group.focus = None; + group.selecting = false; + + visit_selectables( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + |_, _, state| state.set_selection(None), + ); + + if had_anything { + shell.capture_event(); + shell.request_redraw(); + } + } + _ => { + let action = match named { + keyboard::key::Named::ArrowLeft if modifiers.command() => { + Some(KeyAction::Word(-1)) + } + keyboard::key::Named::ArrowRight if modifiers.command() => { + Some(KeyAction::Word(1)) + } + keyboard::key::Named::ArrowLeft => Some(KeyAction::Char(-1)), + keyboard::key::Named::ArrowRight => Some(KeyAction::Char(1)), + keyboard::key::Named::ArrowUp => Some(KeyAction::Line(-1)), + keyboard::key::Named::ArrowDown => Some(KeyAction::Line(1)), + keyboard::key::Named::Home if modifiers.command() => { + Some(KeyAction::DocEdge(-1)) + } + keyboard::key::Named::End if modifiers.command() => { + Some(KeyAction::DocEdge(1)) + } + keyboard::key::Named::Home => Some(KeyAction::LineEdge(-1)), + keyboard::key::Named::End => Some(KeyAction::LineEdge(1)), + _ => None, + }; + + if let Some(action) = action { + apply_keyboard_action( + &mut self.content, + tree, + layout, + renderer, + action, + modifiers.shift(), + shell, + ); + } + } + }, + _ => {} + } + + // Forward the event to the wrapped content so individual + // widgets (links, etc.) still see it. + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + shell, + viewport, + ); + } +} + +/// Computes the per-selectable selection range during a multi-widget +/// drag. `index` is the selectable being queried; `anchor_*` and +/// `focus_*` define the drag endpoints; `len` is the selectable's +/// total text length. +enum KeyAction { + Char(i32), + Word(i32), + Line(i32), + LineEdge(i32), + DocEdge(i32), +} + +/// Applies a keyboard navigation action to the group, computing the +/// new focus and writing the resulting per-child selection ranges. +fn apply_keyboard_action( + content: &mut Element<'_, Message, Theme, Renderer>, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + action: KeyAction, + extend: bool, + shell: &mut Shell<'_, Message>, +) where + Renderer: text::Renderer, +{ + let (prior_anchor, prior_focus, preferred_x) = { + let group = tree.state.downcast_ref::(); + (group.anchor, group.focus, group.preferred_x) + }; + + let Some((focus_idx, focus_byte)) = prior_focus else { + return; + }; + let anchor = prior_anchor.unwrap_or((focus_idx, focus_byte)); + let is_vertical = matches!(action, KeyAction::Line(_)); + + // Single walk: gather text lengths, the focused widget's screen + // position, and try to step the focus inside its own widget. + let mut lens: Vec = Vec::new(); + let mut focus_x: Option = None; + let mut in_widget_focus: Option<(usize, usize)> = None; + + visit_selectables( + content, + &mut tree.children[0], + layout, + renderer, + |index, bounds, state| { + lens.push(state.text_len()); + + if index != focus_idx { + return; + } + + if let Some(p) = state.byte_position(focus_byte) { + focus_x = Some(bounds.x + p.x); + } + + in_widget_focus = match action { + KeyAction::Char(dir) => { + let stepped = state.step_byte(focus_byte, dir); + (stepped != focus_byte).then_some((index, stepped)) + } + KeyAction::Word(dir) => { + let stepped = state.step_byte_word(focus_byte, dir); + (stepped != focus_byte).then_some((index, stepped)) + } + KeyAction::Line(dir) => state + .step_byte_line(focus_byte, dir) + .filter(|&b| b != focus_byte) + .map(|b| (index, b)), + KeyAction::LineEdge(dir) => state + .line_edge_byte(focus_byte, dir) + .filter(|&b| b != focus_byte) + .map(|b| (index, b)), + KeyAction::DocEdge(_) => None, + }; + }, + ); + + let target_x = preferred_x.or(focus_x).unwrap_or(0.0); + + // If the in-widget step worked, use it. Otherwise fall through to + // the action's cross-sibling rule. + let new_focus = in_widget_focus.or_else(|| match action { + KeyAction::Char(dir) | KeyAction::Word(dir) => { + if dir > 0 && focus_idx + 1 < lens.len() { + Some((focus_idx + 1, 0)) + } else if dir < 0 && focus_idx > 0 { + Some((focus_idx - 1, lens[focus_idx - 1])) + } else { + None + } + } + KeyAction::Line(dir) => hit_test_sibling( + content, tree, layout, renderer, focus_idx, dir, target_x, &lens, + ), + KeyAction::LineEdge(_) => None, + KeyAction::DocEdge(dir) => { + if dir < 0 { + Some((0, 0)) + } else if !lens.is_empty() { + Some((lens.len() - 1, *lens.last().unwrap())) + } else { + None + } + } + }); + + let Some((new_idx, new_byte)) = new_focus else { + return; + }; + + // With Shift: keep the existing anchor and extend. + // Without Shift: collapse — anchor follows focus to the new + // position (no visible selection, but the caret moves). + let (a_idx, a_byte) = if extend { anchor } else { (new_idx, new_byte) }; + + // Apply per-child selection ranges and snapshot the new focus's + // screen X (for non-vertical actions, so chained `Shift+Up`/ + // `Down` keep tracking the original column). + let mut new_focus_x: Option = None; + visit_selectables( + content, + &mut tree.children[0], + layout, + renderer, + |index, bounds, state| { + let len = state.text_len(); + let range = selection_range_for(index, a_idx, a_byte, new_idx, new_byte, len); + state.set_selection(range); + + if !is_vertical + && index == new_idx + && let Some(p) = state.byte_position(new_byte) + { + new_focus_x = Some(bounds.x + p.x); + } + }, + ); + + let group = tree.state.downcast_mut::(); + group.anchor = Some((a_idx, a_byte)); + group.focus = Some((new_idx, new_byte)); + group.preferred_x = if is_vertical { + preferred_x + } else { + new_focus_x.or(preferred_x) + }; + shell.capture_event(); + shell.request_redraw(); +} + +fn hit_test_sibling( + content: &mut Element<'_, Message, Theme, Renderer>, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + focus_idx: usize, + dir: i32, + target_x: f32, + lens: &[usize], +) -> Option<(usize, usize)> +where + Renderer: text::Renderer, +{ + let target_idx = if dir > 0 { + focus_idx + 1 + } else if focus_idx > 0 { + focus_idx - 1 + } else { + return None; + }; + if target_idx >= lens.len() { + return None; + } + + let mut new_focus: Option<(usize, usize)> = None; + visit_selectables( + content, + &mut tree.children[0], + layout, + renderer, + |index, bounds, state| { + if index != target_idx { + return; + } + let lh = state.visual_line_height().unwrap_or(0.0); + let local_y = if dir > 0 { + 0.0 + } else { + (bounds.height - lh).max(0.0) + }; + let local_x = target_x - bounds.x; + if let Some(byte) = state.hit_test(core::Point::new(local_x, local_y)) { + new_focus = Some((index, byte)); + } + }, + ); + + new_focus.or_else(|| { + if dir > 0 { + Some((target_idx, 0)) + } else { + Some((target_idx, lens[target_idx])) + } + }) +} + +fn selection_range_for( + index: usize, + anchor_idx: usize, + anchor_byte: usize, + focus_idx: usize, + focus_byte: usize, + len: usize, +) -> Option<(usize, usize)> { + if anchor_idx == focus_idx { + if index == anchor_idx { + Some((anchor_byte, focus_byte)) + } else { + None + } + } else if anchor_idx < focus_idx { + if index < anchor_idx || index > focus_idx { + None + } else if index == anchor_idx { + Some((anchor_byte, len)) + } else if index == focus_idx { + Some((0, focus_byte)) + } else { + Some((0, len)) + } + } else { + // anchor_idx > focus_idx (dragging upward) + if index < focus_idx || index > anchor_idx { + None + } else if index == anchor_idx { + Some((0, anchor_byte)) + } else if index == focus_idx { + Some((focus_byte, len)) + } else { + Some((0, len)) + } + } +} + +/// Walks the wrapped content via the [`Operation`] system and calls +/// `callback` on each [`Selectable`] in tree order. +fn visit_selectables( + content: &mut Element<'_, Message, Theme, Renderer>, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + callback: F, +) where + Renderer: text::Renderer, + F: FnMut(usize, Rectangle, &mut dyn Selectable) + Send, +{ + struct Visitor { + counter: usize, + callback: F, + } + + impl core::widget::Operation for Visitor + where + F: FnMut(usize, Rectangle, &mut dyn Selectable) + Send, + { + fn selectable( + &mut self, + _id: Option<&core::widget::Id>, + bounds: Rectangle, + state: &mut dyn Selectable, + ) { + (self.callback)(self.counter, bounds, state); + self.counter += 1; + } + + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn core::widget::Operation)) { + operate(self); + } + } + + let mut visitor = Visitor { + counter: 0, + callback, + }; + content + .as_widget_mut() + .operate(tree, layout, renderer, &mut visitor); +} + +impl<'a, Link, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Link: Clone + 'static, + Theme: 'a, + Renderer: text::Renderer + 'a, +{ + fn from( + group: SelectableGroup<'a, Link, Message, Theme, Renderer>, + ) -> Element<'a, Message, Theme, Renderer> { + Element::new(group) + } +} diff --git a/widget/src/text.rs b/widget/src/text.rs index c22434342a..a271d6126a 100644 --- a/widget/src/text.rs +++ b/widget/src/text.rs @@ -1,5 +1,7 @@ //! Draw and interact with text. -mod rich; + +/// Rich text supporting multiple styled spans. +pub mod rich; pub use crate::core::text::{Fragment, Highlighter, IntoFragment, Span}; pub use crate::core::widget::text::*; diff --git a/widget/src/text/rich.rs b/widget/src/text/rich.rs index 0a3704b263..f7f7cfe08d 100644 --- a/widget/src/text/rich.rs +++ b/widget/src/text/rich.rs @@ -1,8 +1,10 @@ use crate::core::alignment; +use crate::core::keyboard; use crate::core::layout; use crate::core::mouse; use crate::core::renderer; use crate::core::text::{Paragraph, Span}; +use crate::core::widget::operation::Selectable; use crate::core::widget::text::{ self, Alignment, Catalog, Ellipsis, LineHeight, Shaping, Style, StyleFn, Wrapping, }; @@ -32,6 +34,7 @@ where class: Theme::Class<'a>, hovered_link: Option, on_link_click: Option Message + 'a>>, + selectable: bool, } impl<'a, Link, Message, Theme, Renderer> Rich<'a, Link, Message, Theme, Renderer> @@ -57,9 +60,17 @@ where class: Theme::default(), hovered_link: None, on_link_click: None, + selectable: false, } } + /// Allows the user to drag-select text inside the [`Rich`] and + /// copy it with `Ctrl+C` while focused. Off by default. + pub fn selectable(mut self, selectable: bool) -> Self { + self.selectable = selectable; + self + } + /// Creates a new [`Rich`] text with the given text spans. pub fn with_spans(spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a) -> Self { Self { @@ -164,7 +175,13 @@ where { let color = color.map(Into::into); - self.style(move |_theme| Style { color }) + // Inherit `selection` (and any other field) from the theme's + // default class so a per-widget color override doesn't silently + // disable the selection highlight. + self.style(move |theme: &Theme| Style { + color, + ..theme.style(&::default()) + }) } /// Sets the default style class of the [`Rich`] text. @@ -188,10 +205,72 @@ where } } +/// The internal state of a [`Rich`] widget. Implements the +/// [`Selectable`] operation hook so coordinator widgets like +/// [`selectable_group`] can read and write the selection without +/// touching this concrete type. +/// +/// [`Selectable`]: core::widget::operation::Selectable +/// [`selectable_group`]: crate::selectable_group struct State { spans: Vec>, + /// Cached concatenation of every span's text, kept in sync with + /// `spans` during layout. Lets the keyboard navigation helpers + /// walk codepoints / words without allocating a fresh `String` + /// per keystroke. + text: String, span_pressed: Option, paragraph: P, + selection: Option<(usize, usize)>, + selecting: bool, + focused: bool, + externally_managed: bool, + /// Most recent left-click; chained into `mouse::Click::new` so + /// repeated presses within iced's threshold escalate Single → + /// Double → Triple. + last_click: Option, +} + +impl core::widget::operation::Selectable for State { + fn selection(&self) -> Option<(usize, usize)> { + self.selection + } + + fn set_selection(&mut self, range: Option<(usize, usize)>) { + self.selection = range; + } + + fn text(&self) -> &str { + &self.text + } + + fn byte_position(&self, byte: usize) -> Option { + self.paragraph.byte_position(byte) + } + + fn hit_test(&self, point: Point) -> Option { + self.paragraph.hit_test(point).map(core::text::Hit::cursor) + } + + fn visual_line_height(&self) -> Option { + self.paragraph.visual_line_height() + } + + fn min_bounds_height(&self) -> f32 { + self.paragraph.min_bounds().height + } + + fn set_externally_managed(&mut self, value: bool) { + self.externally_managed = value; + } + + /// Override: rich text needs to walk per-span to extract selection + /// content, since `self.text` is a flat concatenation that doesn't + /// preserve span boundaries — but the public API has to return + /// what the user *sees* and that's the per-span text. + fn selection_text(&self, start: usize, end: usize) -> String { + collect_selection(&self.spans, start, end) + } } impl Widget @@ -208,8 +287,14 @@ where fn state(&self) -> tree::State { tree::State::new(State:: { spans: Vec::new(), + text: String::new(), span_pressed: None, paragraph: Renderer::Paragraph::default(), + selection: None, + selecting: false, + focused: false, + externally_managed: false, + last_click: None, }) } @@ -244,6 +329,22 @@ where ) } + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + _renderer: &Renderer, + operation: &mut dyn core::widget::Operation, + ) { + if !self.selectable { + return; + } + let state = tree + .state + .downcast_mut::>(); + operation.selectable(None, layout.bounds(), state); + } + fn draw( &self, tree: &Tree, @@ -264,6 +365,29 @@ where let style = theme.style(&self.class); + if self.selectable + && let Some((a, b)) = state.selection + { + let (start, end) = if a <= b { (a, b) } else { (b, a) }; + if start < end && style.selection.a > 0.0 { + let anchor = layout.bounds().anchor( + state.paragraph.min_bounds(), + state.paragraph.align_x(), + state.paragraph.align_y(), + ); + let translation = anchor - Point::ORIGIN; + for bounds in state.paragraph.selection_bounds(start, end) { + renderer.fill_quad( + renderer::Quad { + bounds: bounds + translation, + ..Default::default() + }, + style.selection, + ); + } + } + } + for (index, span) in self.spans.as_ref().as_ref().iter().enumerate() { let is_hovered_link = self.on_link_click.is_some() && Some(index) == self.hovered_link; @@ -357,32 +481,43 @@ where shell: &mut Shell<'_, Message>, _viewport: &Rectangle, ) { - let Some(on_link_clicked) = &self.on_link_click else { + // Bail entirely when neither feature is enabled — keeps the + // hot path for plain decorative `rich_text` allocation-free. + if self.on_link_click.is_none() && !self.selectable { return; - }; + } let was_hovered = self.hovered_link.is_some(); + let cursor_in_bounds = cursor.position_in(layout.bounds()); - if let Some(position) = cursor.position_in(layout.bounds()) { - let state = tree - .state - .downcast_ref::>(); + if self.on_link_click.is_some() { + if let Some(position) = cursor_in_bounds { + let state = tree + .state + .downcast_ref::>(); - self.hovered_link = state.paragraph.hit_span(position).and_then(|span| { - if self.spans.as_ref().as_ref().get(span)?.link.is_some() { - Some(span) - } else { - None - } - }); - } else { - self.hovered_link = None; - } + self.hovered_link = state.paragraph.hit_span(position).and_then(|span| { + if self.spans.as_ref().as_ref().get(span)?.link.is_some() { + Some(span) + } else { + None + } + }); + } else { + self.hovered_link = None; + } - if was_hovered != self.hovered_link.is_some() { - shell.request_redraw(); + if was_hovered != self.hovered_link.is_some() { + shell.request_redraw(); + } } + let externally_managed = tree + .state + .downcast_ref::>() + .externally_managed; + let selectable_self = self.selectable && !externally_managed; + match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { let state = tree @@ -392,6 +527,61 @@ where if self.hovered_link.is_some() { state.span_pressed = self.hovered_link; shell.capture_event(); + } else if selectable_self + && let Some(position) = cursor_in_bounds + && let Some(hit) = state.paragraph.hit_test(position) + { + let cursor_at = hit.cursor(); + let click = + mouse::Click::new(position, mouse::Button::Left, state.last_click); + + match click.kind() { + mouse::click::Kind::Single => { + state.selection = Some((cursor_at, cursor_at)); + state.selecting = true; + } + mouse::click::Kind::Double => { + let start = state.step_byte_word(cursor_at, -1); + let end = state.step_byte_word(cursor_at, 1); + state.selection = Some((start, end)); + state.selecting = false; + } + mouse::click::Kind::Triple => { + let len = state.text_len(); + let start = state.line_edge_byte(cursor_at, -1).unwrap_or(0); + let end = state.line_edge_byte(cursor_at, 1).unwrap_or(len); + state.selection = Some((start, end)); + state.selecting = false; + } + } + + state.last_click = Some(click); + state.focused = true; + shell.capture_event(); + shell.request_redraw(); + } else if selectable_self && (state.selection.take().is_some() || state.focused) { + // Press outside this widget's text drops focus, so + // siblings can self-clear on the same event. + state.focused = false; + state.last_click = None; + shell.request_redraw(); + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) if selectable_self => { + let state = tree + .state + .downcast_mut::>(); + if state.selecting + && let Some(position) = cursor_in_bounds + && let Some(hit) = state.paragraph.hit_test(position) + { + let new_focus = hit.cursor(); + if let Some((anchor, focus)) = state.selection + && focus != new_focus + { + state.selection = Some((anchor, new_focus)); + shell.request_redraw(); + } } } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { @@ -401,12 +591,13 @@ where match state.span_pressed { Some(span) if Some(span) == self.hovered_link => { - if let Some(link) = self - .spans - .as_ref() - .as_ref() - .get(span) - .and_then(|span| span.link.clone()) + if let Some(on_link_clicked) = &self.on_link_click + && let Some(link) = self + .spans + .as_ref() + .as_ref() + .get(span) + .and_then(|span| span.link.clone()) { shell.publish(on_link_clicked(link)); } @@ -415,6 +606,116 @@ where } state.span_pressed = None; + + if selectable_self && state.selecting { + state.selecting = false; + + if let Some((a, b)) = state.selection + && a == b + { + state.selection = None; + } + } + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character(c), + modifiers, + .. + }) if selectable_self && modifiers.command() && matches!(c.as_str(), "c" | "C") => { + let state = tree + .state + .downcast_ref::>(); + if state.focused + && let Some((a, b)) = state.selection + { + let (start, end) = if a <= b { (a, b) } else { (b, a) }; + if start < end { + let extracted = collect_selection(self.spans.as_ref().as_ref(), start, end); + if !extracted.is_empty() { + shell.write_clipboard(core::clipboard::Content::Text(extracted)); + shell.capture_event(); + } + } + } + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character(c), + modifiers, + .. + }) if selectable_self && modifiers.command() && matches!(c.as_str(), "a" | "A") => { + let state = tree + .state + .downcast_mut::>(); + if state.focused { + let len = state.text_len(); + if len > 0 { + state.selection = Some((0, len)); + state.selecting = false; + shell.capture_event(); + shell.request_redraw(); + } + } + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(named), + modifiers, + .. + }) if selectable_self => { + let state = tree + .state + .downcast_mut::>(); + if !state.focused { + return; + } + if matches!(named, keyboard::key::Named::Escape) { + if state.selection.take().is_some() { + shell.capture_event(); + shell.request_redraw(); + } + return; + } + + let len = state.text_len(); + let (anchor, focus) = state + .selection + .unwrap_or((focus_default(*named, len), focus_default(*named, len))); + + let new_focus: Option = match named { + keyboard::key::Named::ArrowLeft if modifiers.command() => { + Some(state.step_byte_word(focus, -1)) + } + keyboard::key::Named::ArrowRight if modifiers.command() => { + Some(state.step_byte_word(focus, 1)) + } + keyboard::key::Named::ArrowLeft => Some(state.step_byte(focus, -1)), + keyboard::key::Named::ArrowRight => Some(state.step_byte(focus, 1)), + keyboard::key::Named::ArrowUp => state + .step_byte_line(focus, -1) + .filter(|&b| b != focus) + .or(Some(0)), + keyboard::key::Named::ArrowDown => state + .step_byte_line(focus, 1) + .filter(|&b| b != focus) + .or(Some(len)), + keyboard::key::Named::Home if modifiers.command() => Some(0), + keyboard::key::Named::End if modifiers.command() => Some(len), + keyboard::key::Named::Home => state.line_edge_byte(focus, -1).or(Some(0)), + keyboard::key::Named::End => state.line_edge_byte(focus, 1).or(Some(len)), + _ => return, + }; + + if let Some(new_focus) = new_focus + && new_focus != focus + { + // With Shift: extend selection (anchor stays). + // Without Shift: collapse to a caret at the new + // focus, mirroring how `text_input` moves a + // non-extending cursor. + let new_anchor = if modifiers.shift() { anchor } else { new_focus }; + state.selection = Some((new_anchor, new_focus)); + shell.capture_event(); + shell.request_redraw(); + } } _ => {} } @@ -423,13 +724,15 @@ where fn mouse_interaction( &self, _tree: &Tree, - _layout: Layout<'_>, - _cursor: mouse::Cursor, + layout: Layout<'_>, + cursor: mouse::Cursor, _viewport: &Rectangle, _renderer: &Renderer, ) -> mouse::Interaction { if self.hovered_link.is_some() { mouse::Interaction::Pointer + } else if self.selectable && cursor.is_over(layout.bounds()) { + mouse::Interaction::Text } else { mouse::Interaction::None } @@ -478,6 +781,7 @@ where if state.spans != spans { state.paragraph = Renderer::Paragraph::with_spans(text_with_spans()); state.spans = spans.iter().cloned().map(Span::to_static).collect(); + state.text = state.spans.iter().map(|s| s.text.as_ref()).collect(); } else { match state.paragraph.compare(core::Text { content: (), @@ -506,6 +810,55 @@ where }) } +/// Default focus byte when no selection exists yet — keys that go +/// rightward start from `0`, keys that go leftward start from the end. +fn focus_default(named: keyboard::key::Named, len: usize) -> usize { + use keyboard::key::Named; + match named { + Named::ArrowLeft | Named::ArrowUp | Named::Home => len, + Named::ArrowRight | Named::ArrowDown | Named::End => 0, + _ => 0, + } +} + +fn collect_selection( + spans: &[Span<'_, Link, Font>], + start: usize, + end: usize, +) -> String { + let mut out = String::new(); + let mut cursor = 0usize; + for span in spans { + let text = span.text.as_ref(); + let len = text.len(); + let span_end = cursor + len; + if span_end <= start { + cursor = span_end; + continue; + } + if cursor >= end { + break; + } + let local_start = floor_char_boundary(text, start.saturating_sub(cursor)); + let local_end = floor_char_boundary(text, (end - cursor).min(len)); + if local_start < local_end { + out.push_str(&text[local_start..local_end]); + } + cursor = span_end; + } + out +} + +fn floor_char_boundary(s: &str, mut idx: usize) -> usize { + if idx >= s.len() { + return s.len(); + } + while idx > 0 && !s.is_char_boundary(idx) { + idx -= 1; + } + idx +} + impl<'a, Link, Message, Theme, Renderer> FromIterator> for Rich<'a, Link, Message, Theme, Renderer> where diff --git a/widget/src/toggler.rs b/widget/src/toggler.rs index e9abfb9f64..f0001fc084 100644 --- a/widget/src/toggler.rs +++ b/widget/src/toggler.rs @@ -411,6 +411,7 @@ where state.raw(), crate::text::Style { color: style.text_color, + ..crate::text::Style::default() }, viewport, );