Skip to content

text: drag-select and Ctrl+C copy#3315

Open
wilsonglasser wants to merge 4 commits into
iced-rs:masterfrom
wilsonglasser:text-selection
Open

text: drag-select and Ctrl+C copy#3315
wilsonglasser wants to merge 4 commits into
iced-rs:masterfrom
wilsonglasser:text-selection

Conversation

@wilsonglasser
Copy link
Copy Markdown

@wilsonglasser wilsonglasser commented Apr 26, 2026

Closes #36.
Captura de tela 2026-04-26 161754

Drag-select + Ctrl+C on text(...) and rich_text![...], plus a selectable_group to drag across siblings (which is what markdown::view_with uses). Off by default.

text("hello").selectable(true)
rich_text![span("foo")].selectable(true)
markdown::view_with(&items, Settings { selectable: true, group_selection: true, ..s }, &viewer)

What's in here

  • Per-widget drag selection on Rich / Text, Ctrl+C to copy. UTF-8 boundary snapping at the edges.
  • selectable_group(content) walks its subtree and coordinates one drag across its Rich children. Ctrl+C joins them with \n.
  • Keyboard: Shift+Arrows, Ctrl+Shift+Arrows (word), Shift+Home/End, Ctrl+Shift+Home/End, Ctrl+A, Esc. Shift+Click extends.
  • Selection rectangles reuse text_editor's highlight_line glyph walk, so wrapping + alignment just work.
  • markdown::Settings::{selectable, group_selection} to opt in.
  • New Paragraph::selection_bounds trait method, default empty impl.
  • text::Style::selection: Color, defaults to palette.primary.weak.color.

Coordination

Goes through Operation, mirroring Focusable / Operation::focusable:

  • Selectable trait in core::widget::operation (selection, length, hit-test, byte stepping, set_externally_managed).
  • Operation::selectable(id, bounds, &mut dyn Selectable) hook, default empty impl.
  • Rich::State implements it and routes through Widget::operate. State is fully private; the group never sees the concrete type.
  • selectable_group runs an Operation that visits every Selectable in tree order and reads/writes per child.

set_externally_managed is what tells individual widgets to skip their own drag/copy while the group is driving. Group flips it at the top of each update.

Out of scope

  • Plain text(...) inside a group (only rich_text is coordinated).
  • RTL / bidi, same level as existing rich_text.
  • Programmatic access to a group's selection via Operations.
  • Screen reader narration.

Migration

  • text::Style gained selection. Touched checkbox / radio / toggler and the color_maybe helpers to spread ..Style::default().
  • markdown::Settings gained selectable and group_selection. Builders are fine; struct literals need the fields.
  • core::widget::text::State<P> still aliases paragraph::Plain<P>, so label-style widgets compile unchanged. Text uses a private Internal<P> wrapping Plain<P> plus selection state.
  • unordered_list / ordered_list were calling view_with recursively for nested bullets, which would nest groups and break cross-list selection. Switched to items().

Tested

Running it in oryxis. Screenshot is its AI chat panel via view_with. Selection works across paragraphs, headings, code blocks, lists (incl. nested), and tables.

@pml68
Copy link
Copy Markdown
Contributor

pml68 commented Apr 26, 2026

Can't really look through the code right now, does this have keyboard selection as well?

@wilsonglasser wilsonglasser mentioned this pull request Apr 26, 2026
@wilsonglasser
Copy link
Copy Markdown
Author

wilsonglasser commented Apr 26, 2026

No, but I can try to look into that! Will come back later.

@wilsonglasser wilsonglasser force-pushed the text-selection branch 11 times, most recently from 5146b02 to a71794d Compare April 26, 2026 22:35
@wilsonglasser
Copy link
Copy Markdown
Author

Can't really look through the code right now, does this have keyboard selection as well?

Implemented keyboard navigation/selection

@pml68
Copy link
Copy Markdown
Contributor

pml68 commented Apr 27, 2026

I was only able to gloss over the code for now, but testing it out (with selection enabled for the markdown example) everything looks pretty good! It feels a bit weird that selecting to e.g. the end of the next word selects the space after that word as well, but works nicely nonetheless. One small note is that CI will fail because two text::Styles (tester/src/lib.rs:{695,883}) lack the selection field.

@wilsonglasser
Copy link
Copy Markdown
Author

wilsonglasser commented Apr 27, 2026

Thanks! Fixed both text::Style literals (and a third one in that I caught locally in an example file). Also tweaked step_byte_word to land at the end of the next word instead of the start of the one after, mirroring text_input::next_end_of_word. The trailing space is no longer pulled into the selection.
E.g.: Foo bar baz

  1. [Foo]
  2. [Foo bar]
  3. [Foo bar baz]

@pml68
Copy link
Copy Markdown
Contributor

pml68 commented Apr 27, 2026

Is there a specific reason you only have grouping and (as far as I can see) keyboard selection for Rich and not for Text?

@wilsonglasser
Copy link
Copy Markdown
Author

That was my use case and the effort was focused there; I forgot to extend it further. I can extend it to Text, yes, I'll do it now. Thanks.

@wilsonglasser wilsonglasser force-pushed the text-selection branch 5 times, most recently from c748ebd to 23a5d40 Compare April 27, 2026 12:25
@wilsonglasser
Copy link
Copy Markdown
Author

wilsonglasser commented Apr 27, 2026

@pml68 Done, extended to plain text(...) and refactored the trait so adding Selectable to a new widget is minimal. Required methods are just selection accessors, a text() -> &str, five paragraph proxies (byte_position, hit_test, visual_line_height, min_bounds_height, bounds_width), and set_externally_managed. Codepoint / word / line walks are provided as defaults.

Two extras while I was in there:

  • BlackBox, Map and Chain weren't forwarding Operation::selectable. They are now, so then(...) / map(...) / black_box(...) over a selectable pipeline don't silently drop calls.
  • Spotted a pre-existing bug while testing: Text::color_maybe (and Rich::color_maybe) built Style { color, ..Style::default() }, and Style::default().selection is Color::TRANSPARENT. Result: any text(...).color(..) had its selection highlight silently disabled. Both now pull the rest of the style from the theme's default class so a per-widget color override doesn't clobber selection.
Captura de tela 2026-04-27 091630

@inanc-g
Copy link
Copy Markdown

inanc-g commented May 4, 2026

@wilsonglasser Great work!
I've tested this in a project and I think this is already a great UX win.

Some feedback:

  • keyboard navigation seems to be a bit weird across siblings. left/right nav (by character or word) works correctly, but up/down nav (by line) seems to stop on siblings when going forwards (down) - up works. KeyAction::Line / LineEdge seem to have a different behaviour compared to Word/Char and don't use the sibling fallback on same_byte. a simple filter(|&b| b != focus_byte) fixes this for me locally.

  • I think Keyboard Events should check focus.? Mouse area / drag works only within the markdown view bounds, but Ctrl+A for example selects all markdown view texts if you have multiple / separate markdown widgets. Might also interfere with text_editor widgets that grab for similar key combos

  • A common pattern is Single/Double/Triple click for Drag/Word/Line select. I have tried this locally and was able to add it by dispatching by mouse::click::Kind in the ButtonPressed handler:

    • Single: drag behaviour as is
    • Double: step_byte_word + selecting = false
    • Triple: line_edge_byte + selecting = false

@wilsonglasser
Copy link
Copy Markdown
Author

wilsonglasser commented May 4, 2026

@inanc-g Thanks for the review! Pushed b785a0b addressing the three points.

  1. Up/Down sibling fallback. Fixed. KeyAction::Line and LineEdge now apply .filter(|&b| b != focus_byte) on the in-widget step result so the sibling fallback runs when the step lands on the same byte, matching the existing Char / Word behavior. Same filter on step_byte_line in standalone Rich forArrowUp / ArrowDown so the .or(Some(0/len)) clamp kicks in. I left line_edge_byte (Home / End) in standalone Rich unfiltered on purpose so Home stays idempotent at line start.

  2. Focus check on keyboard events. Ctrl+A in selectable_group is now gated on anchor.is_some() || focus.is_some(), so only the most-recently-clicked group reacts and a focused text_editor keeps the event. Ctrl+C was already implicitly gated (it only captures when there's a non-empty selection).

Known residual: the gate clears via mouse press dispatch. If the user transitions to a text_editor via Tab or programmatic focus (no click), the group keeps its anchor and would still steal Ctrl+A. Properly fixing that needs Operation::focusable integration, which felt like a separate change.

  1. Single / Double / Triple click. Implemented via mouse::Click::new and dispatch by Kind in all three places (selectable_group, Rich, plain Text):
  • Single: existing drag start
  • Double: select word at click (step_byte_word(-1..+1)), selecting = false
  • Triple: select line at click (line_edge_byte(-1..+1)), selecting = false

Shift+click takes priority over the count escalation, so it still extends from the existing anchor.

Added examples/text_selection_test to exercise the flows side by side: two markdown views, a custom mixed selectable_group, standalone text and rich_text, and a text_editor.

One UX gap I noticed but didn't tackle: dragging past the parent scrollable's viewport doesn't auto-scroll. Selection clamps to the start / end of the content, so it isn't broken, just limited. Happy to follow up in a separate PR if useful. Either a new shell.scroll_ancestor primitive or coordination from scrollable itself, both bigger than this fix bundle.

Closes iced-rs#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.
@OlofBlomqvist
Copy link
Copy Markdown

Been trying this out for a bit and found some issues that may be worth looking in to

  • Triple-click doesn’t select to the end of the logical line, it stops at next line-wrapping
  • When having text items with new-lines, selection also includes text on rows other than the one im trying to select (see pic)
bild

OlofBlomqvist@78b45e8

The selection model works in byte offsets into the whole fragment, but
the cosmic-text paragraph methods worked in per-buffer-line offsets,
which restart near 0 on every newline. For single-line text the two
coincide; multi-line broke:

- selection_bounds applied the global range to every buffer line, so a
  selection on one line painted the same columns on all of them.
- hit_test returned the per-line index, dropping the line component, so
  drag and click landed on the wrong byte past the first line.
- byte_position had the same flaw, breaking vertical nav and Home/End.

All three now share a buffer_line_byte_len stride to translate between
global and per-line offsets (single-byte \n separators).

line_edge_byte now walks the logical, \n-delimited line from the text
itself instead of hit-testing at the visual y, so triple-click and
Home/End cover a whole wrapped line instead of stopping at a soft wrap.
The unused bounds_width trait method (its only caller) is removed.

Adds headless regression tests for the per-line offset and logical
edge behavior, plus multi-line and wrapped cases to the example.
@wilsonglasser
Copy link
Copy Markdown
Author

@OlofBlomqvist updated to apply a fix for those cases

@OlofBlomqvist
Copy link
Copy Markdown

@wilsonglasser works great, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Text Selection

4 participants