Skip to content

Allow doubleclick-and-drag and tripleclick-and-drag#8166

Open
hallyhaa wants to merge 5 commits into
emilk:mainfrom
hallyhaa:feature/click-drag-selection
Open

Allow doubleclick-and-drag and tripleclick-and-drag#8166
hallyhaa wants to merge 5 commits into
emilk:mainfrom
hallyhaa:feature/click-drag-selection

Conversation

@hallyhaa
Copy link
Copy Markdown
Contributor

Fix for the current behaviour where a double-click selects a word and a triple-click selects a line, but dragging with multi-click looses the initial selection and then extends the selection character by character.

The fix works for both TextEdit and selectable Labels (single- and multi-widget selection). Plain single-click drag, double/triple-click without dragging, and shift-click are all unchanged.

How

The click count (1/2/3) is normally only known on pointer release, but a double-/triple-click-and-drag needs it while the button is still held. So PointerState now also computes the click count at press time (press_click_count), reusing the same double/triple-click logic the release path already used. TextCursorState (shared by TextEdit and Label) gains a SelectionMode (Char / Word / Line) and an anchored unit, so the selection extends and shrinks in whole words/lines as the pointer moves back and forth.

Known limitation

For multi-widget label selection, if the anchor widget (where the drag started) is scrolled out of view, the selection endpoint freezes at its last valid value. This is a limitation of egui's multi-widget text selection (char-mode selection on main has the same failure mode).

This PR addresses #2550

  • I have followed the instructions in the PR template
  • cargo test -p egui passes (added unit tests for the new logic)
  • cargo clippy -p egui --all-features is clean
  • cargo fmt is clean
  • Builds with and without the serde feature

hallyhaa and others added 5 commits May 17, 2026 23:02
Implements egui issue emilk#2550: when the user double-clicks and keeps the
button held while dragging, the selection now extends word-by-word, and
triple-click-and-drag extends line-by-line. Plain click-and-drag still
selects character-by-character.

egui only computes a click `count` on release, but to know the drag
granularity we need that count while the button is still held. So
`PointerState` now computes a `press_click_count` at press time using the
same double-/triple-click logic the release path uses.

`TextCursorState` gains a `SelectionMode` (Char/Word/Line) and an anchor
unit so dragging back and forth across the anchor re-derives the selection
correctly. `pointer_interaction` picks the mode from `press_click_count`
on the start of a drag and, while dragging in Word/Line mode, takes the
union of the anchor word/line and the word/line under the pointer.

`LabelSelectionState` carries the same mode and anchor so label drag
respects word/line granularity too. The single-galley case is fully
correct; cross-galley label drags snap each galley's moving end to a
word/line boundary.

Existing single/double/triple-click behavior is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Fix 1: In multi-widget label selection, a word/line drag anchored in one
galley failed to keep the anchor unit selected when dragged upward into a
previous label. `selection.secondary` was set once at drag start to the
anchor unit's MIN, which is only correct for forward/downward drags.

`cursor_for` now recomputes `selection.secondary` every frame it processes
the anchor galley (matched via the stored `drag_anchor` widget Id) whenever
the primary is in a different galley. The far end of the anchor unit is
chosen by `anchor_secondary_index` based on whether the primary has been
dragged before the anchor in document order (compared via screen position).
The single-galley case still flows through `combine_units` unchanged.

Fix 2: `extend_word_line_drag` ran an O(n) word/line scan every frame while
dragging. A `last_drag_pointer` cache on `TextCursorState` now stores the
pointer char index and resulting range from the previous frame, skipping the
scan while the pointer stays on the same character. `begin_drag` clears the
cache so a new drag never reuses a stale range.

Adds unit tests for the secondary-flip direction logic and the cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Fix 1 (behavior regression): label shift-click now resets the
drag-selection granularity to Char. Previously a shift-click in a
label left `selection_mode` at whatever a prior word/line drag had
set, so a later shift-click extended by whole words/lines instead of
by character, diverging from `TextEdit` and from `main`.

Fix 2 (performance): add a stationary-pointer cache (`last_drag_unit`)
to `LabelSelectionState`, mirroring `TextCursorState::last_drag_pointer`.
The label word/line drag path called `unit_bounds_at` every frame,
running an O(n) scan and heap-allocating a line-sized `String` even
when the pointer was stationary. The cache (widget Id + pointer char
index -> unit bounds) skips the scan while the pointer stays put, and
is cleared on `drag_started` so no stale reuse.

Fix 3 (API hygiene): `SelectionMode` and `SelectionMode::from_click_count`
were accidentally `pub`, enlarging egui's public API; they are now
`pub(crate)`. The unused `TextCursorState::selection_mode()` accessor
(no callers anywhere) is removed. The three drag-transient fields
(`selection_mode`, `drag_anchor_unit`, `last_drag_pointer`) now carry
`#[serde(skip)]` so ephemeral drag state is not persisted.

Fix 4 (cleanup): the double-/triple-click-count arithmetic that was
duplicated between the press and release paths in `input_state/mod.rs`
is extracted into a single private `PointerState::compute_click_count`
helper.

Tests: add `test_from_click_count` and `test_shift_click_resets_to_char_mode`;
existing cache test still holds. `cargo test -p egui`, clippy
(`--all-features`), fmt, and both default / `--no-default-features`
builds are clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Follow-up to the double-/triple-click-and-drag feature (egui issue emilk#2550).

Edge case 1 (FIXED): cache not invalidated on mid-drag text change.
`TextCursorState::last_drag_pointer` was keyed only on the pointer char
index, so an IME composition or programmatic edit that changed the galley
text while the pointer stayed on the same char returned a stale range. The
cache key now also includes `text.len()`; a differing length is treated as
a miss. A new unit test covers this.

Edge case 2 (INVESTIGATED, no code change needed): stale selection mode
when a drag starts without a hovered press. Confirmed unreachable: the
`is_being_dragged` branch in `pointer_interaction` can only run for a
widget whose id equals `dragged_id()`, which requires `potential_drag_id`
to have been set from a press-time drag hit, on a frame where the widget
is also in the `hovered` set and `any_pressed()` is true -- so the press
branch is guaranteed to have run first and overwritten `selection_mode`/
`drag_anchor_unit`. (Labels never reach this branch; they always pass
`is_being_dragged = false`.) Added an explanatory comment instead of dead
defensive code.

Edge case 3 (PRE-EXISTING egui LIMITATION, documented): anchor galley
scrolled out of view. The cross-galley secondary-flip can only run on the
frame the anchor galley is visited. If it scrolls out, `selection.secondary`
freezes at its last valid value. Verified there is no crash, panic or
cross-galley index misuse; this degrades no worse than `main`'s char-mode
multi-widget selection, which has the identical failure mode. Documented
precisely in a code comment; a proper fix needs a multi-widget-selection
rewrite, out of scope.

Edge case 4 (FIXED): one-frame flicker on fast direction reversal. The
cross-galley secondary-flip decided drag direction from
`selection.primary.pos`, which is one frame stale when the anchor galley
is processed before the pointer's galley. It now uses the live pointer
position from `pointer_interact_pos()`, removing the lag at its source.

Tests: `cargo test -p egui` all pass (38 unit + 166 doctests). `cargo
clippy -p egui --all-features`, `cargo fmt`, `cargo build -p egui` and
`cargo build -p egui --no-default-features` all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
egui's CONTRIBUTING.md asks for each type in its own file unless trivial.
Move the `SelectionMode` enum and its impl out of `text_cursor_state.rs`
into a new `selection_mode.rs`, and make the `select_word_at`,
`select_line_at` and `range_bounds` helpers `pub(crate)` so the new
module can use them. The `from_click_count` test moves along with it.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 18, 2026

Preview available at https://egui-pr-preview.github.io/pr/8166-featureclick-drag-selection
Note that it might take a couple seconds for the update to show up after the preview_build workflow has completed.

View snapshot changes at kitdiff

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.

1 participant