From 4bb504180e04d1b99904cba8e07d7938aa9c4d8e Mon Sep 17 00:00:00 2001 From: mudbungie Date: Sat, 1 Aug 2026 16:48:24 -0700 Subject: [PATCH] Add double-click-and-drag and triple-click-and-drag text selection --- crates/egui/src/input_state/mod.rs | 41 ++++ .../text_selection/label_text_selection.rs | 96 ++++++++- .../src/text_selection/text_cursor_state.rs | 163 ++++++++++++++- tests/egui_tests/tests/test_text_selection.rs | 197 ++++++++++++++++++ 4 files changed, 488 insertions(+), 9 deletions(-) create mode 100644 tests/egui_tests/tests/test_text_selection.rs diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index 36d6f9bc9fc..0ed8f68cc04 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -1043,6 +1043,14 @@ pub struct PointerState { /// This could also be the trigger point for a long-touch. pub(crate) started_decidedly_dragging: bool, + /// The click count that a click ending the latest press would have: + /// 2 for the second press of a double-click, 3 for the third press of a triple-click, etc. + /// + /// Unlike [`Click::count`] this is known already at the start of the press, + /// which is what e.g. double-click-and-drag text selection needs. + #[cfg_attr(feature = "serde", serde(default))] + press_click_count: usize, + /// Where did the last click originate? /// `None` if no mouse click occurred. last_click_pos: Option, @@ -1084,6 +1092,7 @@ impl Default for PointerState { press_start_time: None, has_moved_too_much_for_a_click: false, started_decidedly_dragging: false, + press_click_count: 0, last_click_pos: None, last_click_time: f64::NEG_INFINITY, last_last_click_time: f64::NEG_INFINITY, @@ -1151,6 +1160,26 @@ impl PointerState { self.press_origin = Some(pos); self.press_start_time = Some(time); self.has_moved_too_much_for_a_click = false; + + // Would a click ending this press be a double- or triple-click? + // Uses the same heuristics as the click counting on release. + let close_to_last_click = self.last_click_pos.is_some_and(|last_pos| { + last_pos.distance_sq(pos) + < self.options.max_click_dist * self.options.max_click_dist + }); + self.press_click_count = if close_to_last_click + && (time - self.last_last_click_time) + < (self.options.max_double_click_delay * 2.0) + { + 3 + } else if close_to_last_click + && (time - self.last_click_time) < self.options.max_double_click_delay + { + 2 + } else { + 1 + }; + self.pointer_events.push(PointerEvent::Pressed { position: pos, button, @@ -1363,6 +1392,16 @@ impl PointerState { (self.time - self.last_click_time) as f32 } + /// The click count that a click ending the latest press would have: + /// 2 for the second press of a double-click, 3 for the third press of a triple-click, etc. + /// + /// Unlike [`Self::button_double_clicked`] this is known already at the start of the press, + /// which is what e.g. double-click-and-drag text selection needs. + #[inline(always)] + pub(crate) fn press_click_count(&self) -> usize { + self.press_click_count + } + /// Was any pointer button pressed (`!down -> down`) this frame? /// /// This can sometimes return `true` even if `any_down() == false` @@ -1669,6 +1708,7 @@ impl PointerState { press_start_time, has_moved_too_much_for_a_click, started_decidedly_dragging, + press_click_count, last_click_pos, last_click_time, last_last_click_time, @@ -1695,6 +1735,7 @@ impl PointerState { ui.label(format!( "started_decidedly_dragging: {started_decidedly_dragging}" )); + ui.label(format!("press_click_count: {press_click_count}")); ui.label(format!("last_click_pos: {last_click_pos:#?}")); ui.label(format!("last_click_time: {last_click_time:#?}")); ui.label(format!("last_last_click_time: {last_last_click_time:#?}")); diff --git a/crates/egui/src/text_selection/label_text_selection.rs b/crates/egui/src/text_selection/label_text_selection.rs index 80cc90c8aaf..cd2856066ec 100644 --- a/crates/egui/src/text_selection/label_text_selection.rs +++ b/crates/egui/src/text_selection/label_text_selection.rs @@ -9,7 +9,7 @@ use crate::{ use super::{ TextCursorState, - text_cursor_state::cursor_rect, + text_cursor_state::{SelectGranularity, cursor_rect, extend_granular_select, select_unit_at}, visuals::{RowVertexIndices, paint_text_selection}, }; @@ -61,6 +61,22 @@ impl std::fmt::Debug for WidgetTextCursor { } } +/// A label selection that started with a double- or triple-click, +/// remembered so that dragging extends it by whole words or lines. +/// +/// Both ends of the anchor word/line are always in the same widget, +/// but the drag may extend the selection into other widgets. +#[derive(Clone, Copy, Debug)] +struct GranularDrag { + granularity: SelectGranularity, + + /// Start of the word/line that was initially clicked. + anchor_min: WidgetTextCursor, + + /// End of the word/line that was initially clicked. + anchor_max: WidgetTextCursor, +} + #[derive(Clone, Copy, Debug)] struct CurrentSelection { /// The selection is in this layer. @@ -101,6 +117,10 @@ struct ViewportLabelSelectionState { /// Are we in drag-to-select state? is_dragging: bool, + /// Set if the current selection started with a double- or triple-click, + /// so that dragging extends the selection by whole words or lines. + granular_drag: Option, + /// Have we reached the widget containing the primary selection? has_reached_primary: bool, @@ -125,6 +145,7 @@ impl Default for ViewportLabelSelectionState { selection_bbox_this_frame: Rect::NOTHING, any_hovered: Default::default(), is_dragging: Default::default(), + granular_drag: Default::default(), has_reached_primary: Default::default(), has_reached_secondary: Default::default(), text_to_copy: Default::default(), @@ -201,6 +222,10 @@ impl ViewportLabelSelectionState { if ui.input(|i| i.pointer.any_pressed() && !i.modifiers.shift) { // Maybe a new selection is about to begin, but the old one is over: // state.selection = None; // TODO(emilk): this makes sense, but doesn't work as expected. + + // If this press is a double- or triple-click on a label, + // `on_label` will set this again later this pass: + self.granular_drag = None; } self.selection_bbox_last_frame = self.selection_bbox_this_frame; @@ -373,7 +398,53 @@ impl ViewportLabelSelectionState { let new_primary = if response.contains_pointer() { // Dragging into this widget - easy case: - Some(galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2())) + let cursor_at_pointer = + galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2()); + + if let Some(granular) = &self.granular_drag { + // The selection started with a double- or triple-click, + // so extend it by whole words or lines: + if response.id == granular.anchor_min.widget_id { + // We are in the same widget as the anchor word/line: + let anchor = CCursorRange::two( + granular.anchor_min.ccursor, + granular.anchor_max.ccursor, + ); + let range = extend_granular_select( + granular.granularity, + anchor, + galley.text(), + cursor_at_pointer, + ); + selection.secondary = WidgetTextCursor::new( + response.id, + range.secondary, + global_from_galley, + galley, + ); + Some(range.primary) + } else { + // The drag has left the anchor's widget. + // Are we before or after the anchor? Decide by screen position: + let after_anchor = granular.anchor_max.pos.y < pointer_pos.y + || (granular.anchor_min.pos.y <= pointer_pos.y + && granular.anchor_max.pos.x <= pointer_pos.x); + + let unit = + select_unit_at(granular.granularity, galley.text(), cursor_at_pointer); + let [unit_min, unit_max] = unit.sorted_cursors(); + + if after_anchor { + selection.secondary = granular.anchor_min; + Some(unit_max) + } else { + selection.secondary = granular.anchor_max; + Some(unit_min) + } + } + } else { + Some(cursor_at_pointer) + } } else if is_in_same_column && !self.has_reached_primary && selection.primary.pos.y <= selection.secondary.pos.y @@ -560,6 +631,27 @@ impl ViewportLabelSelectionState { // Actual drag-to-select happens elsewhere. let dragged = false; cursor_state.pointer_interaction(ui, response, cursor_at_pointer, galley, dragged); + + // If this was a double- or triple-click, remember the clicked word/line + // so that dragging extends the selection by that granularity: + if let Some(granular) = cursor_state.granular_drag() { + let [anchor_min, anchor_max] = granular.anchor.sorted_cursors(); + self.granular_drag = Some(GranularDrag { + granularity: granular.granularity, + anchor_min: WidgetTextCursor::new( + response.id, + anchor_min, + global_from_galley, + galley, + ), + anchor_max: WidgetTextCursor::new( + response.id, + anchor_max, + global_from_galley, + galley, + ), + }); + } } if let Some(mut cursor_range) = cursor_state.range(galley) { diff --git a/crates/egui/src/text_selection/text_cursor_state.rs b/crates/egui/src/text_selection/text_cursor_state.rs index f88368f2214..6319692bd8d 100644 --- a/crates/egui/src/text_selection/text_cursor_state.rs +++ b/crates/egui/src/text_selection/text_cursor_state.rs @@ -7,6 +7,24 @@ use crate::{NumExt as _, Rect, Response, Ui, epaint}; use super::CCursorRange; +/// The unit by which a mouse-drag extends a text selection: +/// whole words after a double-click, whole lines after a triple-click. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SelectGranularity { + Word, + Line, +} + +/// A selection that started with a double- or triple-click, +/// remembered so that dragging extends it by whole words or lines. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct GranularDragSelect { + pub granularity: SelectGranularity, + + /// The word or line that was initially clicked. + pub anchor: CCursorRange, +} + /// The state of a text cursor selection. /// /// Used for [`crate::TextEdit`] and [`crate::Label`]. @@ -15,12 +33,17 @@ use super::CCursorRange; #[cfg_attr(feature = "serde", serde(default))] pub struct TextCursorState { ccursor_range: Option, + + /// Transient state of the current mouse gesture, so not serialized. + #[cfg_attr(feature = "serde", serde(skip))] + granular_drag: Option, } impl From for TextCursorState { fn from(ccursor_range: CCursorRange) -> Self { Self { ccursor_range: Some(ccursor_range), + granular_drag: None, } } } @@ -49,6 +72,11 @@ impl TextCursorState { pub fn set_char_range(&mut self, ccursor_range: Option) { self.ccursor_range = ccursor_range; } + + /// If the selection started with a double- or triple-click, the word or line that anchors it. + pub(crate) fn granular_drag(&self) -> Option { + self.granular_drag + } } impl TextCursorState { @@ -78,20 +106,39 @@ impl TextCursorState { } else if response.sense.senses_drag() { if response.hovered() && ui.input(|i| i.pointer.any_pressed()) { // The start of a drag (or a click). - if ui.input(|i| i.modifiers.shift) { - if let Some(mut cursor_range) = self.range(galley) { - cursor_range.primary = cursor_at_pointer; - self.set_char_range(Some(cursor_range)); + // Clicks are counted on release, but for double-click-and-drag + // we need to select the word (or line) already on the second (or third) press: + let press_click_count = ui.input(|i| i.pointer.press_click_count()); + if 3 <= press_click_count { + self.begin_granular_drag(SelectGranularity::Line, text, cursor_at_pointer); + } else if press_click_count == 2 { + self.begin_granular_drag(SelectGranularity::Word, text, cursor_at_pointer); + } else { + self.granular_drag = None; + if ui.input(|i| i.modifiers.shift) { + if let Some(mut cursor_range) = self.range(galley) { + cursor_range.primary = cursor_at_pointer; + self.set_char_range(Some(cursor_range)); + } else { + self.set_char_range(Some(CCursorRange::one(cursor_at_pointer))); + } } else { self.set_char_range(Some(CCursorRange::one(cursor_at_pointer))); } - } else { - self.set_char_range(Some(CCursorRange::one(cursor_at_pointer))); } true } else if is_being_dragged { // Drag to select text: - if let Some(mut cursor_range) = self.range(galley) { + if let Some(granular) = self.granular_drag { + // Extend the selection by whole words or lines: + let new_range = extend_granular_select( + granular.granularity, + granular.anchor, + text, + cursor_at_pointer, + ); + self.set_char_range(Some(new_range)); + } else if let Some(mut cursor_range) = self.range(galley) { cursor_range.primary = cursor_at_pointer; self.set_char_range(Some(cursor_range)); } @@ -103,6 +150,68 @@ impl TextCursorState { false } } + + /// Start a double- or triple-click selection: select the whole word (or line) at the pointer, + /// and remember it so that a subsequent drag extends the selection by that granularity. + fn begin_granular_drag( + &mut self, + granularity: SelectGranularity, + text: &str, + cursor_at_pointer: CCursor, + ) { + let anchor = select_unit_at(granularity, text, cursor_at_pointer); + self.granular_drag = Some(GranularDragSelect { + granularity, + anchor, + }); + self.set_char_range(Some(anchor)); + } +} + +/// The whole word or line at the given position. +pub(crate) fn select_unit_at( + granularity: SelectGranularity, + text: &str, + ccursor: CCursor, +) -> CCursorRange { + match granularity { + SelectGranularity::Word => select_word_at(text, ccursor), + SelectGranularity::Line => select_line_at(text, ccursor), + } +} + +/// Extend a double- or triple-click selection to also cover the word (or line) at the pointer. +/// +/// Returns the union of the anchor word/line and the word/line at the pointer, +/// with `primary` at the pointer end. +pub(crate) fn extend_granular_select( + granularity: SelectGranularity, + anchor: CCursorRange, + text: &str, + cursor_at_pointer: CCursor, +) -> CCursorRange { + let unit = select_unit_at(granularity, text, cursor_at_pointer); + let [anchor_min, anchor_max] = anchor.sorted_cursors(); + let [unit_min, unit_max] = unit.sorted_cursors(); + + if unit_min.index < anchor_min.index { + // Dragging backwards, before the anchor: + CCursorRange { + primary: unit_min, + secondary: anchor_max, + h_pos: None, + } + } else if anchor_max.index < unit_max.index { + // Dragging forwards, after the anchor: + CCursorRange { + primary: unit_max, + secondary: anchor_min, + h_pos: None, + } + } else { + // The pointer is inside the anchor word/line: + anchor + } } fn select_word_at(text: &str, ccursor: CCursor) -> CCursorRange { @@ -431,6 +540,46 @@ mod test { assert_eq!(hi.0, 11); } + #[test] + fn test_extend_granular_select_words() { + let text = "alpha beta gamma"; + let anchor = select_word_at(text, CCursor::new(8)); // "beta" + assert_eq!(anchor.slice_str(text), "beta"); + + // Dragging forward into "gamma" extends the selection to the end of that word: + let range = extend_granular_select(SelectGranularity::Word, anchor, text, CCursor::new(13)); + assert_eq!(range.slice_str(text), "beta gamma"); + assert_eq!( + range.primary.index.0, 16, + "primary should be at the pointer end" + ); + + // Dragging backward into "alpha" extends the selection to the start of that word: + let range = extend_granular_select(SelectGranularity::Word, anchor, text, CCursor::new(2)); + assert_eq!(range.slice_str(text), "alpha beta"); + assert_eq!( + range.primary.index.0, 0, + "primary should be at the pointer end" + ); + + // With the pointer still inside the anchor word, the selection stays the anchor word: + let range = extend_granular_select(SelectGranularity::Word, anchor, text, CCursor::new(7)); + assert_eq!(range.slice_str(text), "beta"); + } + + #[test] + fn test_extend_granular_select_lines() { + let text = "first\nsecond\nthird"; + let anchor = select_line_at(text, CCursor::new(8)); // "second" + assert_eq!(anchor.slice_str(text), "second"); + + let range = extend_granular_select(SelectGranularity::Line, anchor, text, CCursor::new(15)); + assert_eq!(range.slice_str(text), "second\nthird"); + + let range = extend_granular_select(SelectGranularity::Line, anchor, text, CCursor::new(2)); + assert_eq!(range.slice_str(text), "first\nsecond"); + } + #[test] fn test_word_boundary_large_text_performance() { // Before the O(n²) → O(n) fix, this would take minutes on large text. diff --git a/tests/egui_tests/tests/test_text_selection.rs b/tests/egui_tests/tests/test_text_selection.rs new file mode 100644 index 00000000000..580ca44b000 --- /dev/null +++ b/tests/egui_tests/tests/test_text_selection.rs @@ -0,0 +1,197 @@ +//! Tests for double-click-and-drag (select by words) and +//! triple-click-and-drag (select by lines) text selection. +//! See . + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::text::CCursor; +use egui::{Event, Modifiers, OutputCommand, PointerButton, Pos2, RichText, TextEdit, Vec2, vec2}; +use egui_kittest::{Harness, HarnessBuilder}; + +/// Short enough that a few frames stay well within the double-click window (0.3 s). +const STEP_DT: f32 = 0.01; + +fn press(harness: &mut Harness<'_, S>, pos: Pos2) { + harness.event(Event::PointerMoved(pos)); + harness.event(Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed: true, + modifiers: Modifiers::NONE, + }); + harness.step(); +} + +fn release(harness: &mut Harness<'_, S>, pos: Pos2) { + harness.event(Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed: false, + modifiers: Modifiers::NONE, + }); + harness.step(); +} + +fn drag_to(harness: &mut Harness<'_, S>, pos: Pos2) { + harness.event(Event::PointerMoved(pos)); + harness.step(); + // Give the drag state a few frames to settle: + harness.step(); + harness.step(); +} + +/// Double-click at `from` (keeping the button down), then drag to `to` and release. +fn double_click_drag(harness: &mut Harness<'_, S>, from: Pos2, to: Pos2) { + press(harness, from); + release(harness, from); + press(harness, from); + drag_to(harness, to); + release(harness, to); +} + +/// Triple-click at `from` (keeping the button down), then drag to `to` and release. +fn triple_click_drag(harness: &mut Harness<'_, S>, from: Pos2, to: Pos2) { + press(harness, from); + release(harness, from); + press(harness, from); + release(harness, from); + press(harness, from); + drag_to(harness, to); + release(harness, to); +} + +/// Send a copy event and return the text that was copied to the clipboard, if any. +fn copied_text(harness: &mut Harness<'_, S>) -> Option { + harness.event(Event::Copy); + harness.step(); + harness + .output() + .platform_output + .commands + .iter() + .find_map(|cmd| match cmd { + OutputCommand::CopyText(text) => Some(text.clone()), + _ => None, + }) +} + +/// A [`TextEdit`] harness, plus the screen position of each character of its text. +fn text_edit_harness(text: &str) -> (Harness<'static, String>, Rc>>) { + let char_pos = Rc::new(RefCell::new(Vec::new())); + let char_pos_clone = Rc::clone(&char_pos); + + let mut harness = HarnessBuilder::default() + .with_step_dt(STEP_DT) + .with_size(Vec2::new(400.0, 200.0)) + .build_ui_state( + move |ui, text: &mut String| { + let output = TextEdit::multiline(text).show(ui); + *char_pos_clone.borrow_mut() = (0..text.chars().count()) + .map(|i| { + output.galley_pos + + output + .galley + .pos_from_cursor(CCursor::new(i)) + .center() + .to_vec2() + }) + .collect(); + }, + text.to_owned(), + ); + harness.run(); + (harness, char_pos) +} + +#[test] +fn double_click_drag_should_select_words_forward() { + let (mut harness, char_pos) = text_edit_harness("alpha beta gamma delta"); + let pos = |i: usize| char_pos.borrow()[i]; + + // Double-click on "beta", drag into "gamma": + double_click_drag(&mut harness, pos(8), pos(13)); + + assert_eq!(copied_text(&mut harness).as_deref(), Some("beta gamma")); +} + +#[test] +fn double_click_drag_should_select_words_backward() { + let (mut harness, char_pos) = text_edit_harness("alpha beta gamma delta"); + let pos = |i: usize| char_pos.borrow()[i]; + + // Double-click on "gamma", drag backward into "alpha": + double_click_drag(&mut harness, pos(13), pos(2)); + + assert_eq!( + copied_text(&mut harness).as_deref(), + Some("alpha beta gamma") + ); +} + +#[test] +fn triple_click_drag_should_select_lines() { + let (mut harness, char_pos) = text_edit_harness("alpha beta\ncarrot\ndelta epsilon"); + let pos = |i: usize| char_pos.borrow()[i]; + + // Triple-click on "carrot", drag down into "delta epsilon": + triple_click_drag(&mut harness, pos(13), pos(24)); + + assert_eq!( + copied_text(&mut harness).as_deref(), + Some("carrot\ndelta epsilon") + ); +} + +/// Two stacked labels, plus a function mapping (label index, char index) to screen position. +fn labels_harness() -> (Harness<'static>, impl Fn(usize, usize) -> Pos2) { + let label_info = Rc::new(RefCell::new(Vec::new())); + let label_info_clone = Rc::clone(&label_info); + + let mut harness = HarnessBuilder::default() + .with_step_dt(STEP_DT) + .with_size(Vec2::new(400.0, 200.0)) + .build_ui(move |ui| { + let char_width = ui + .fonts_mut(|f| f.glyph_width(&egui::TextStyle::Monospace.resolve(ui.style()), 'x')); + let mut info = label_info_clone.borrow_mut(); + info.clear(); + for text in ["alpha beta gamma", "delta epsilon zeta"] { + let rect = ui.label(RichText::new(text).monospace()).rect; + info.push((rect, char_width)); + } + }); + harness.run(); + + let pos = move |label: usize, char_index: usize| { + let (rect, char_width) = label_info.borrow()[label]; + rect.left_top() + vec2((char_index as f32 + 0.5) * char_width, rect.height() / 2.0) + }; + (harness, pos) +} + +#[test] +fn double_click_drag_should_select_words_across_labels() { + let (mut harness, pos) = labels_harness(); + + // Double-click on "beta" in the first label, drag into "epsilon" in the second: + double_click_drag(&mut harness, pos(0, 8), pos(1, 9)); + + assert_eq!( + copied_text(&mut harness).as_deref(), + Some("beta gamma\ndelta epsilon") + ); +} + +#[test] +fn double_click_drag_should_select_words_across_labels_backward() { + let (mut harness, pos) = labels_harness(); + + // Double-click on "epsilon" in the second label, drag up into "beta" in the first: + double_click_drag(&mut harness, pos(1, 9), pos(0, 8)); + + assert_eq!( + copied_text(&mut harness).as_deref(), + Some("beta gamma\ndelta epsilon") + ); +}