Skip to content

Commit a71794d

Browse files
committed
text: drag-select and Ctrl+C copy
Closes #36. <!-- drag-drop the multi-paragraph selection screenshot here --> Drag-select on `text(...)` and `rich_text![...]` widgets, plus a `selectable_group` to coordinate selection across siblings (which is how `markdown::view_with` ends up with multi-paragraph selection). Off by default; existing apps see no behavior change. ## Usage ```rust text("hello").selectable(true) rich_text![span("foo")].selectable(true) markdown::view_with( &items, Settings { selectable: true, group_selection: true, ..settings }, &viewer, ) ``` ## What's in here - Selection on `Rich` and `Text`: per-widget drag, `Ctrl+C` copies the rendered text. Multibyte chars at the selection edges are snapped to UTF-8 boundaries. - New `selectable_group(content)` widget that walks its subtree, finds the `Rich` widgets, and coordinates a single drag across them. When the drag crosses into a sibling, the originator's selection extends to its end, intermediate widgets get fully selected, and the destination is selected from its start to the cursor. `Ctrl+C` joins them with a newline. - `markdown::Settings::{selectable, group_selection}` to opt in for markdown surfaces. - Selection rectangles use the same glyph-walking algorithm as `text_editor`'s `highlight_line`, so wrapping and `align_x` / `align_y` work without extra code. - New `Paragraph::selection_bounds(start, end) -> Vec<Rectangle>` trait method with a default empty impl, so non-glyph renderers (e.g. the null one) are unaffected. - `text::Style::selection: Color` for the highlight, defaulting to `palette.primary.weak.color` to match `text_input` / `text_editor`. - Mouse cursor switches to `Interaction::Text` over selectable widgets. ## Cross-widget coordination When `selectable_group` is in the tree, it sets an `externally_managed` flag on every `Rich::State` it walks into, so individual widgets skip their own drag-select / `Ctrl+C` handling and just render whatever selection the group writes in. The group owns drag origin + focus tracking and recomputes per-widget byte ranges on each `CursorMoved`. `Rich::State` is exposed as a `pub struct` with a minimal accessor set (`selection`, `set_selection`, `paragraph`, `text_len`, `selection_text`, `set_externally_managed`). I couldn't find a clean way to downcast `tree::State` to a `&mut dyn Trait` so the group ends up concretely aware of `Rich`. Open to suggestions if there's a more elegant pattern. The within-widget coordination (clicking outside a `Rich`'s text drops focus, so siblings self-clear on the same event) is the same trick `text_input` uses — every widget sees every press and reacts to its own bounds. ## Out of scope - Plain `text(...)` inside `selectable_group` — only `rich_text` is coordinated. Plain text widgets still select on their own. - RTL / bidi — same level as the existing `rich_text` rendering. - Programmatic access to a group's selection through Operations — could be a follow-up if there's a use case. - Screen reader narration — not regressed, not improved. ## Migration - `text::Style` gained a `selection` field. Three internal callsites in `checkbox` / `radio` / `toggler`, plus the `color_maybe` helpers on `Rich` / `Text`, were updated to spread `..Style::default()`. - `markdown::Settings` gained `selectable` and `group_selection`. Most consumers build it via `with_text_size` / `with_style` and won't notice; explicit struct literals need the new fields. - `core::widget::text::State<P>` keeps its existing `paragraph::Plain<P>` alias so label-style widgets (`checkbox`, `radio`, `toggler`) compile unchanged. The `Text` widget itself uses a private `Internal<P>` that wraps `Plain<P>` with selection state. ## Note on markdown helpers `unordered_list` and `ordered_list` were calling `view_with` recursively for nested bullet content. With the new wrapping behavior that would create a nested `selectable_group` per bullet, breaking cross-list selection. Switched them to call `items()` directly, which produces just the column without wrapping. `quote`, `table`, and `code_block` already used `item()` / `items()` and didn't need changes. ## Tested against I've been running this in [oryxis](https://github.com/wilsonglasser/oryxis), my SSH client. The screenshot above is its AI chat panel rendering markdown via `view_with`. Selection works across paragraphs, headings, code blocks, numbered lists, nested bullets, and tables.
1 parent 4255f61 commit a71794d

13 files changed

Lines changed: 1676 additions & 50 deletions

File tree

core/src/text/paragraph.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,27 @@ pub trait Paragraph: Sized + Default {
7373
/// Returns the distance to the given grapheme index in the [`Paragraph`].
7474
fn grapheme_position(&self, line: usize, index: usize) -> Option<Point>;
7575

76+
/// Returns the visual rectangles covering the byte range
77+
/// `start..end` of the [`Paragraph`]'s source text. Used to paint
78+
/// selection highlights.
79+
fn selection_bounds(&self, _start: usize, _end: usize) -> Vec<Rectangle> {
80+
Vec::new()
81+
}
82+
83+
/// Returns the visual position of the given byte offset in the
84+
/// [`Paragraph`]'s source text. Used by `Shift+Up`/`Down` and
85+
/// `Shift+Home`/`End` to hit-test targets relative to the focus.
86+
fn byte_position(&self, _byte: usize) -> Option<Point> {
87+
None
88+
}
89+
90+
/// The visual line height the [`Paragraph`] is rendered with —
91+
/// the distance to step for `Shift+Up`/`Down`. Returns `None`
92+
/// when the renderer doesn't track per-line geometry.
93+
fn visual_line_height(&self) -> Option<f32> {
94+
None
95+
}
96+
7697
/// Returns the minimum width that can fit the contents of the [`Paragraph`].
7798
fn min_width(&self) -> f32 {
7899
self.min_bounds().width

core/src/widget/operation.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
//! Query or update internal widget state.
22
pub mod focusable;
33
pub mod scrollable;
4+
pub mod selectable;
45
pub mod text_input;
56

67
pub use focusable::Focusable;
78
pub use scrollable::Scrollable;
9+
pub use selectable::Selectable;
810
pub use text_input::TextInput;
911

1012
use crate::widget::Id;
@@ -46,6 +48,15 @@ pub trait Operation<T = ()>: Send {
4648
/// Operates on a widget that has text input.
4749
fn text_input(&mut self, _id: Option<&Id>, _bounds: Rectangle, _state: &mut dyn TextInput) {}
4850

51+
/// Operates on a widget that owns a text selection.
52+
fn selectable(
53+
&mut self,
54+
_id: Option<&Id>,
55+
_bounds: Rectangle,
56+
_state: &mut dyn Selectable,
57+
) {
58+
}
59+
4960
/// Operates on a widget that contains some text.
5061
fn text(&mut self, _id: Option<&Id>, _bounds: Rectangle, _text: &str) {}
5162

@@ -90,6 +101,15 @@ where
90101
self.as_mut().text_input(id, bounds, state);
91102
}
92103

104+
fn selectable(
105+
&mut self,
106+
id: Option<&Id>,
107+
bounds: Rectangle,
108+
state: &mut dyn Selectable,
109+
) {
110+
self.as_mut().selectable(id, bounds, state);
111+
}
112+
93113
fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) {
94114
self.as_mut().text(id, bounds, text);
95115
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
//! Operate on widgets that can have a text selection.
2+
use crate::{Point, Rectangle};
3+
4+
/// The internal state of a widget that owns a text selection.
5+
///
6+
/// Implemented by `rich_text`'s state. Coordinator widgets like
7+
/// `selectable_group` reach into selectables through this trait via
8+
/// the [`Operation::selectable`] hook, mirroring how `Focusable` and
9+
/// `Operation::focusable` cooperate.
10+
///
11+
/// [`Operation::selectable`]: super::Operation::selectable
12+
pub trait Selectable {
13+
/// The current selection as a half-open byte range in the
14+
/// widget's source text. `None` when nothing is selected.
15+
fn selection(&self) -> Option<(usize, usize)>;
16+
17+
/// Sets the selection range. Pass `None` to clear.
18+
fn set_selection(&mut self, range: Option<(usize, usize)>);
19+
20+
/// Total length, in bytes, of the widget's text.
21+
fn text_len(&self) -> usize;
22+
23+
/// The substring covered by the byte range `[start, end)`.
24+
fn selection_text(&self, start: usize, end: usize) -> String;
25+
26+
/// Steps `byte` to the next or previous UTF-8 char boundary.
27+
/// `dir > 0` moves forward, `dir < 0` moves backward.
28+
fn step_byte(&self, byte: usize, dir: i32) -> usize;
29+
30+
/// Steps `byte` to the next or previous word start.
31+
fn step_byte_word(&self, byte: usize, dir: i32) -> usize;
32+
33+
/// Steps `byte` up or down one visual line. Returns `None` when
34+
/// the target falls outside the widget's rendered area.
35+
fn step_byte_line(&self, byte: usize, dir: i32) -> Option<usize>;
36+
37+
/// Byte at the start (`dir < 0`) or end (`dir > 0`) of the
38+
/// visual line containing `byte`.
39+
fn line_edge_byte(&self, byte: usize, dir: i32) -> Option<usize>;
40+
41+
/// Visual position of `byte` in widget-local coordinates.
42+
fn byte_position(&self, byte: usize) -> Option<Point>;
43+
44+
/// Visual line height the widget renders with.
45+
fn visual_line_height(&self) -> Option<f32>;
46+
47+
/// Hit-tests a widget-local point and returns the byte at that
48+
/// position.
49+
fn hit_test(&self, point: Point) -> Option<usize>;
50+
51+
/// Marks the widget as externally managed. While `true`, the
52+
/// widget's own event handlers should skip drag-select and
53+
/// `Ctrl+C`, leaving its `selection` for an external coordinator
54+
/// to fill in.
55+
fn set_externally_managed(&mut self, value: bool);
56+
}
57+
58+
/// Visits every [`Selectable`] in the operated subtree, invoking a
59+
/// callback with each one's bounds and state. Used by
60+
/// `selectable_group` to drive coordinated selection without knowing
61+
/// concrete widget types.
62+
pub fn visit<F>(callback: F) -> Visit<F>
63+
where
64+
F: FnMut(Rectangle, &mut dyn Selectable),
65+
{
66+
Visit { callback }
67+
}
68+
69+
/// An [`Operation`](super::Operation) that runs a callback on every
70+
/// [`Selectable`] it encounters during traversal.
71+
#[derive(Debug)]
72+
pub struct Visit<F> {
73+
callback: F,
74+
}
75+
76+
impl<F> super::Operation for Visit<F>
77+
where
78+
F: FnMut(Rectangle, &mut dyn Selectable) + Send,
79+
{
80+
fn selectable(
81+
&mut self,
82+
_id: Option<&crate::widget::Id>,
83+
bounds: Rectangle,
84+
state: &mut dyn Selectable,
85+
) {
86+
(self.callback)(bounds, state);
87+
}
88+
89+
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn super::Operation)) {
90+
operate(self);
91+
}
92+
}

0 commit comments

Comments
 (0)