From 95aae8b68cc608c39073207a3e936c7295cf756e Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 22 Jan 2024 15:14:56 +0100 Subject: [PATCH] WIP: implement text selection across multiple labels --- crates/egui/src/context.rs | 13 +- crates/egui/src/input_state.rs | 1 + .../egui/src/text_selection/cursor_range.rs | 6 +- .../text_selection/label_text_selection.rs | 495 +++++++++++++++--- crates/egui/src/text_selection/mod.rs | 2 +- .../src/text_selection/text_cursor_state.rs | 6 +- crates/egui/src/widgets/text_edit/builder.rs | 12 +- crates/emath/src/range.rs | 15 + 8 files changed, 459 insertions(+), 91 deletions(-) diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index f1b808b765b..ac0f854e729 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -625,7 +625,7 @@ impl Context { /// ``` pub fn begin_frame(&self, new_input: RawInput) { crate::profile_function!(); - + crate::text_selection::LabelSelectionState::begin_frame(self); self.write(|ctx| ctx.begin_frame_mut(new_input)); } } @@ -1610,6 +1610,8 @@ impl Context { crate::gui_zoom::zoom_with_keyboard(self); } + crate::text_selection::LabelSelectionState::end_frame(self); + let debug_texts = self.write(|ctx| std::mem::take(&mut ctx.debug_texts)); if !debug_texts.is_empty() { // Show debug-text next to the cursor. @@ -2346,6 +2348,15 @@ impl Context { let font_image_size = self.fonts(|f| f.font_image_size()); crate::introspection::font_texture_ui(ui, font_image_size); }); + + CollapsingHeader::new("Label text selection state") + .default_open(false) + .show(ui, |ui| { + ui.label(format!( + "{:#?}", + crate::text_selection::LabelSelectionState::load(ui.ctx()) + )); + }); } /// Show stats about the allocated textures. diff --git a/crates/egui/src/input_state.rs b/crates/egui/src/input_state.rs index 260d6354b47..cfc2073c537 100644 --- a/crates/egui/src/input_state.rs +++ b/crates/egui/src/input_state.rs @@ -840,6 +840,7 @@ impl PointerState { } /// Was any pointer button pressed (`!down -> down`) this frame? + /// /// This can sometimes return `true` even if `any_down() == false` /// because a press can be shorted than one frame. pub fn any_pressed(&self) -> bool { diff --git a/crates/egui/src/text_selection/cursor_range.rs b/crates/egui/src/text_selection/cursor_range.rs index b0680fdf203..cebcf3fb56d 100644 --- a/crates/egui/src/text_selection/cursor_range.rs +++ b/crates/egui/src/text_selection/cursor_range.rs @@ -234,10 +234,10 @@ impl CCursorRange { } #[inline] - pub fn two(min: CCursor, max: CCursor) -> Self { + pub fn two(min: impl Into, max: impl Into) -> Self { Self { - primary: max, - secondary: min, + primary: max.into(), + secondary: min.into(), } } diff --git a/crates/egui/src/text_selection/label_text_selection.rs b/crates/egui/src/text_selection/label_text_selection.rs index b62f34715e8..254153e3bdb 100644 --- a/crates/egui/src/text_selection/label_text_selection.rs +++ b/crates/egui/src/text_selection/label_text_selection.rs @@ -1,6 +1,7 @@ -use epaint::{Galley, Pos2}; - -use crate::{Context, CursorIcon, Event, Id, Response, Ui}; +use crate::{ + text::CCursor, text_selection::CCursorRange, Context, CursorIcon, Event, Galley, Id, LayerId, + Pos2, Rect, Response, Ui, +}; use super::{ text_cursor_state::cursor_rect, visuals::paint_text_selection, CursorRange, TextCursorState, @@ -13,27 +14,16 @@ use super::{ /// This should be called after painting the text, because this will also /// paint the text cursor/selection on top. pub fn label_text_selection(ui: &Ui, response: &Response, galley_pos: Pos2, galley: &Galley) { - let mut cursor_state = LabelSelectionState::load(ui.ctx(), response.id); - let original_cursor = cursor_state.range(galley); - - if response.hovered { - ui.ctx().set_cursor_icon(CursorIcon::Text); - } else if !cursor_state.is_empty() && ui.input(|i| i.pointer.any_pressed()) { - // We clicked somewhere else - deselect this label. - cursor_state = Default::default(); - LabelSelectionState::store(ui.ctx(), response.id, cursor_state); - } - - if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { - let cursor_at_pointer = galley.cursor_from_pos(pointer_pos - galley_pos); - cursor_state.pointer_interaction(ui, response, cursor_at_pointer, galley); - } - - if let Some(mut cursor_range) = cursor_state.range(galley) { - process_selection_key_events(ui.ctx(), galley, response.id, &mut cursor_range); - cursor_state.set_range(Some(cursor_range)); - } + LabelSelectionState::label_text_selection(ui, response, galley_pos, galley); +} +fn paint_selection( + ui: &Ui, + _response: &Response, + galley_pos: Pos2, + galley: &Galley, + cursor_state: &TextCursorState, +) { let cursor_range = cursor_state.range(galley); if let Some(cursor_range) = cursor_range { @@ -46,67 +36,423 @@ pub fn label_text_selection(ui: &Ui, response: &Response, galley_pos: Pos2, gall galley, &cursor_range, ); - - let selection_changed = original_cursor != Some(cursor_range); - - let is_fully_visible = ui.clip_rect().contains_rect(response.rect); // TODO: remove this HACK workaround for https://github.com/emilk/egui/issues/1531 - - if selection_changed && !is_fully_visible { - // Scroll to keep primary cursor in view: - let row_height = estimate_row_height(galley); - let primary_cursor_rect = - cursor_rect(galley_pos, galley, &cursor_range.primary, row_height); - ui.scroll_to_rect(primary_cursor_rect, None); - } } #[cfg(feature = "accesskit")] super::accesskit_text::update_accesskit_for_text_widget( ui.ctx(), - response.id, + _response.id, cursor_range, accesskit::Role::StaticText, galley_pos, galley, ); +} + +/// One end of a text selection, inside any widget. +#[derive(Clone, Copy)] +struct WidgetTextCursor { + widget_id: Id, + ccursor: CCursor, +} - if !cursor_state.is_empty() { - LabelSelectionState::store(ui.ctx(), response.id, cursor_state); +impl WidgetTextCursor { + fn new(widget_id: Id, cursor: impl Into) -> Self { + Self { + widget_id, + ccursor: cursor.into(), + } } } +impl std::fmt::Debug for WidgetTextCursor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WidgetTextCursor") + .field("widget_id", &self.widget_id.short_debug_format()) + .field("ccursor", &self.ccursor.index) + .finish() + } +} + +#[derive(Clone, Copy, Debug)] +struct CurrentSelection { + /// The selection is in this layer. + /// + /// This is to constrain a selection to a single Window. + pub layer_id: LayerId, + + /// When selecting with a mouse, this is where the mouse was released. + /// When moving with e.g. shift+arrows, this is what moves. + /// Note that the two ends can come in any order, and also be equal (no selection). + pub primary: WidgetTextCursor, + + /// When selecting with a mouse, this is where the mouse was first pressed. + /// This part of the cursor does not move when shift is down. + pub secondary: WidgetTextCursor, +} + /// Handles text selection in labels (NOT in [`crate::TextEdit`])s. /// /// One state for all labels, because we only support text selection in one label at a time. -#[derive(Clone, Copy, Debug, Default)] -struct LabelSelectionState { - /// Id of the (only) label with a selection, if any - id: Option, - +#[derive(Clone, Debug)] +pub struct LabelSelectionState { /// The current selection, if any. - selection: TextCursorState, + selection: Option, + + selection_bbox_last_frame: Rect, + selection_bbox_this_frame: Rect, + + /// Any label hovered this frame? + any_hovered: bool, + + /// Are we in drag-to-select state? + is_dragging: bool, + + /// Have we reached the widget containing the primary selection? + has_reached_primary: bool, + + /// Have we reached the widget containing the secondary selection? + has_reached_secondary: bool, + + /// Did we reach the primary cursor before the secondary? + reached_primary_first: bool, + + /// Accumulated text to copy. + text_to_copy: String, + last_copied_galley_rect: Option, +} + +impl Default for LabelSelectionState { + fn default() -> Self { + Self { + selection: Default::default(), + selection_bbox_last_frame: Rect::NOTHING, + selection_bbox_this_frame: Rect::NOTHING, + any_hovered: Default::default(), + is_dragging: Default::default(), + has_reached_primary: Default::default(), + has_reached_secondary: Default::default(), + reached_primary_first: Default::default(), + text_to_copy: Default::default(), + last_copied_galley_rect: Default::default(), + } + } } impl LabelSelectionState { - /// Load the range of text of text that is selected for the given widget. - fn load(ctx: &Context, id: Id) -> TextCursorState { + pub fn load(ctx: &Context) -> Self { ctx.data(|data| data.get_temp::(Id::NULL)) - .and_then(|state| (state.id == Some(id)).then_some(state.selection)) .unwrap_or_default() } - /// Load the range of text of text that is selected for the given widget. - fn store(ctx: &Context, id: Id, selection: TextCursorState) { + pub fn store(self, ctx: &Context) { ctx.data_mut(|data| { - data.insert_temp( - Id::NULL, - Self { - id: Some(id), - selection, - }, - ); + data.insert_temp(Id::NULL, self); }); } + + pub fn begin_frame(ctx: &Context) { + let mut state = Self::load(ctx); + + if ctx.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: this makes sense, but doesn't work as expected. + } + + state.selection_bbox_last_frame = state.selection_bbox_this_frame; + state.selection_bbox_this_frame = Rect::NOTHING; + + state.any_hovered = false; + state.reached_primary_first = false; + state.has_reached_primary = false; + state.has_reached_secondary = false; + state.text_to_copy.clear(); + state.last_copied_galley_rect = None; + + state.store(ctx); + } + + pub fn end_frame(ctx: &Context) { + let mut state = Self::load(ctx); + + if state.is_dragging { + ctx.set_cursor_icon(CursorIcon::Text); + } + + let pressed_escape = ctx.input(|i| i.key_pressed(crate::Key::Escape)); + let clicked_something_else = ctx.input(|i| i.pointer.any_pressed()) && !state.any_hovered; + let delected_everything = pressed_escape || clicked_something_else; + + if delected_everything { + state.selection = None; + } + + if ctx.input(|i| i.pointer.any_released()) { + state.is_dragging = false; + } + + let text_to_copy = std::mem::take(&mut state.text_to_copy); + if !text_to_copy.is_empty() { + ctx.copy_text(text_to_copy); + } + + state.store(ctx); + } + + fn copy_text(&mut self, galley_pos: Pos2, galley: &Galley, cursor_range: &CursorRange) { + let new_galley_rect = Rect::from_min_size(galley_pos, galley.size()); + let new_text = selected_text(galley, cursor_range); + if new_text.is_empty() { + return; + } + + if self.text_to_copy.is_empty() { + self.text_to_copy = new_text; + self.last_copied_galley_rect = Some(new_galley_rect); + return; + } + + let Some(last_copied_galley_rect) = self.last_copied_galley_rect else { + self.text_to_copy = new_text; + self.last_copied_galley_rect = Some(new_galley_rect); + return; + }; + + // We need to append or prepend the new text to the already copied text. + // We need to do so intelligently. + + if last_copied_galley_rect.bottom() <= new_galley_rect.top() { + self.text_to_copy.push('\n'); + let vertical_distance = new_galley_rect.top() - last_copied_galley_rect.bottom(); + if estimate_row_height(galley) * 0.5 < vertical_distance { + self.text_to_copy.push('\n'); + } + } else { + let existing_ends_with_space = + self.text_to_copy.chars().last().map(|c| c.is_whitespace()); + + let new_text_starts_with_space_or_punctuation = new_text + .chars() + .next() + .map_or(false, |c| c.is_whitespace() || c.is_ascii_punctuation()); + + if existing_ends_with_space == Some(false) && !new_text_starts_with_space_or_punctuation + { + self.text_to_copy.push(' '); + } + } + + self.text_to_copy.push_str(&new_text); + self.last_copied_galley_rect = Some(new_galley_rect); + } + + pub fn label_text_selection(ui: &Ui, response: &Response, galley_pos: Pos2, galley: &Galley) { + let mut state = Self::load(ui.ctx()); + state.on_label(ui, response, galley_pos, galley); + state.store(ui.ctx()); + } + + fn cursor_for(&mut self, response: &Response, galley: &Galley) -> TextCursorState { + if let Some(selection) = &mut self.selection { + if selection.layer_id == response.layer_id { + let has_primary = response.id == selection.primary.widget_id; + let has_secondary = response.id == selection.secondary.widget_id; + + let primary = has_primary.then_some(selection.primary.ccursor); + let secondary = has_secondary.then_some(selection.secondary.ccursor); + + self.has_reached_primary |= has_primary; + self.has_reached_secondary |= has_secondary; + + let cursor_state = match (primary, secondary) { + (Some(primary), Some(secondary)) => { + // This is the only selected label. + TextCursorState::from(CCursorRange { primary, secondary }) + } + + (Some(primary), None) => { + // This labels contains only the primary cursor. + let secondary = if self.has_reached_secondary { + // Secondary was before primary. + // Select everything up to the cursor. + // We assume normal left-to-right and top-down layout order here. + galley.begin().ccursor + } else { + // Select everything from the cursor onward: + self.reached_primary_first = true; + galley.end().ccursor + }; + TextCursorState::from(CCursorRange { primary, secondary }) + } + + (None, Some(secondary)) => { + // This labels contains only the secondary cursor + let primary = if self.has_reached_primary { + // Primary was before secondary. + // Select everything up to the cursor. + // We assume normal left-to-right and top-down layout order here. + galley.begin().ccursor + } else { + // Select everything from the cursor onward: + galley.end().ccursor + }; + TextCursorState::from(CCursorRange { primary, secondary }) + } + + (None, None) => { + // This widget has neither the primary or secondary cursor. + let is_in_middle = self.has_reached_primary != self.has_reached_secondary; + if is_in_middle { + // …but it is between the two selection endpoints, and so is fully selected. + TextCursorState::from(CCursorRange::two(galley.begin(), galley.end())) + } else { + // Outside the selected range + TextCursorState::default() + } + } + }; + + return cursor_state; + } + } + + // Nothing selected… yet + TextCursorState::default() + } + + fn on_label(&mut self, ui: &Ui, response: &Response, galley_pos: Pos2, galley: &Galley) { + let widget_id = response.id; + + if response.hovered { + ui.ctx().set_cursor_icon(CursorIcon::Text); + } + + self.any_hovered |= response.hovered(); + self.is_dragging |= response.dragged(); + + if let Some(selection) = &mut self.selection { + if self.is_dragging && selection.layer_id == response.layer_id + || response.drag_started() + { + if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { + let galley_rect = Rect::from_min_size(galley_pos, galley.size()); + + let pointer_is_above_galley = pointer_pos.y <= galley_pos.y; + let pointer_is_below_galley = galley_pos.y + galley.size().y <= pointer_pos.y; + let start_of_drag = ui.input(|i| i.pointer.any_pressed()); // start of drag + + let is_in_same_column = galley_rect + .x_range() + .intersects(self.selection_bbox_last_frame.x_range()); + + let new_primary = if response.contains_pointer() { + // Dragging into this widget - easy case: + let cursor = galley.cursor_from_pos(pointer_pos - galley_pos); + Some(cursor) + } else if is_in_same_column + && !self.has_reached_primary + && pointer_is_above_galley + { + // The user is dragging the text selection upwards, above the first selected widget (this one): + Some(galley.begin()) + } else if is_in_same_column + && self.has_reached_secondary + && self.has_reached_primary + && !self.reached_primary_first + && pointer_is_below_galley + { + // The user is dragging the text selection downwards, below the last widget (maybe this one): + Some(galley.end()) + } else { + None + }; + + if let Some(new_primary) = new_primary { + selection.layer_id = response.layer_id; + + selection.primary = WidgetTextCursor::new(widget_id, new_primary); + + if start_of_drag { + selection.secondary = selection.primary; + } + } + } + } + } + + let mut cursor_state = self.cursor_for(response, galley); + + let old_range = cursor_state.range(galley); + + if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { + if response.contains_pointer() { + // Handle start-if-drag and double-click-to-select: + let cursor_at_pointer = galley.cursor_from_pos(pointer_pos - galley_pos); + cursor_state.pointer_interaction(ui, response, cursor_at_pointer, galley, false); + } + } + + if let Some(mut cursor_range) = cursor_state.range(galley) { + let galley_rect = Rect::from_min_size(galley_pos, galley.size()); + self.selection_bbox_this_frame = self.selection_bbox_this_frame.union(galley_rect); + + // TODO: only if we contain primary cursor! + process_selection_key_events(ui.ctx(), galley, response.id, &mut cursor_range); + + if got_copy_event(ui.ctx()) { + self.copy_text(galley_pos, galley, &cursor_range); + } + + cursor_state.set_range(Some(cursor_range)); + } + + paint_selection(ui, response, galley_pos, galley, &cursor_state); + + let new_range = cursor_state.range(galley); + let selection_changed = old_range != new_range; + + if let (true, Some(range)) = (selection_changed, new_range) { + // Scroll containing ScrollArea on cursor change + let is_fully_visible = ui.clip_rect().contains_rect(response.rect); // TODO: remove this HACK workaround for https://github.com/emilk/egui/issues/1531 + if selection_changed && !is_fully_visible { + // Scroll to keep primary cursor in view: + let row_height = estimate_row_height(galley); + let primary_cursor_rect = + cursor_rect(galley_pos, galley, &range.primary, row_height); + ui.scroll_to_rect(primary_cursor_rect, None); + } + + // -------------- + // Store results: + + if let Some(selection) = &mut self.selection { + let primary_changed = Some(range.primary) != old_range.map(|r| r.primary); + let secondary_changed = Some(range.secondary) != old_range.map(|r| r.secondary); + + selection.layer_id = response.layer_id; + if primary_changed { + selection.primary = WidgetTextCursor::new(widget_id, range.primary); + } + if secondary_changed { + selection.secondary = WidgetTextCursor::new(widget_id, range.secondary); + } + } else { + self.selection = Some(CurrentSelection { + layer_id: response.layer_id, + primary: WidgetTextCursor::new(widget_id, range.primary), + secondary: WidgetTextCursor::new(widget_id, range.secondary), + }); + } + } + } +} + +fn got_copy_event(ctx: &Context) -> bool { + ctx.input(|i| { + i.events + .iter() + .any(|e| matches!(e, Event::Copy | Event::Cut)) + }) } fn process_selection_key_events( @@ -115,39 +461,28 @@ fn process_selection_key_events( widget_id: Id, cursor_range: &mut CursorRange, ) { - let mut copy_text = None; let os = ctx.os(); ctx.input(|i| { // NOTE: we have a lock on ui/ctx here, // so be careful to not call into `ui` or `ctx` again. - for event in &i.events { - match event { - Event::Copy | Event::Cut => { - // This logic means we can select everything in an ellided label (including the `…`) - // and still copy the entire un-ellided text! - let everything_is_selected = - cursor_range.contains(&CursorRange::select_all(galley)); - - let copy_everything = cursor_range.is_empty() || everything_is_selected; - - if copy_everything { - copy_text = Some(galley.text().to_owned()); - } else { - copy_text = Some(cursor_range.slice_str(galley).to_owned()); - } - } - - event => { - cursor_range.on_event(os, event, galley, widget_id); - } - } + cursor_range.on_event(os, event, galley, widget_id); } }); +} + +fn selected_text(galley: &Galley, cursor_range: &CursorRange) -> String { + // This logic means we can select everything in an ellided label (including the `…`) + // and still copy the entire un-ellided text! + let everything_is_selected = cursor_range.contains(&CursorRange::select_all(galley)); - if let Some(copy_text) = copy_text { - ctx.copy_text(copy_text); + let copy_everything = cursor_range.is_empty() || everything_is_selected; + + if copy_everything { + galley.text().to_owned() + } else { + cursor_range.slice_str(galley).to_owned() } } diff --git a/crates/egui/src/text_selection/mod.rs b/crates/egui/src/text_selection/mod.rs index e9796e220fa..bf193fbcbef 100644 --- a/crates/egui/src/text_selection/mod.rs +++ b/crates/egui/src/text_selection/mod.rs @@ -9,5 +9,5 @@ pub mod text_cursor_state; pub mod visuals; pub use cursor_range::{CCursorRange, CursorRange, PCursorRange}; -pub use label_text_selection::label_text_selection; +pub use label_text_selection::{label_text_selection, LabelSelectionState}; pub use text_cursor_state::TextCursorState; diff --git a/crates/egui/src/text_selection/text_cursor_state.rs b/crates/egui/src/text_selection/text_cursor_state.rs index d8adfb83376..7aca96f8b19 100644 --- a/crates/egui/src/text_selection/text_cursor_state.rs +++ b/crates/egui/src/text_selection/text_cursor_state.rs @@ -99,6 +99,7 @@ impl TextCursorState { response: &Response, cursor_at_pointer: Cursor, galley: &Galley, + is_being_dragged: bool, ) -> bool { let text = galley.text(); @@ -120,6 +121,7 @@ impl TextCursorState { true } else if response.sense.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; @@ -131,8 +133,8 @@ impl TextCursorState { self.set_range(Some(CursorRange::one(cursor_at_pointer))); } true - } else if ui.input(|i| i.pointer.any_down()) && response.is_pointer_button_down_on() { - // drag to select text: + } else if is_being_dragged { + // Drag to select text: if let Some(mut cursor_range) = self.range(galley) { cursor_range.primary = cursor_at_pointer; self.set_range(Some(cursor_range)); diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index eae8a6e4159..c856ce33988 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -549,10 +549,14 @@ impl<'t> TextEdit<'t> { paint_cursor(&painter, ui.visuals(), cursor_rect); } - let did_interact = - state - .cursor - .pointer_interaction(ui, &response, cursor_at_pointer, &galley); + let is_being_dragged = ui.ctx().memory(|m| m.is_being_dragged(response.id)); + let did_interact = state.cursor.pointer_interaction( + ui, + &response, + cursor_at_pointer, + &galley, + is_being_dragged, + ); if did_interact { ui.memory_mut(|mem| mem.request_focus(response.id)); diff --git a/crates/emath/src/range.rs b/crates/emath/src/range.rs index 861a3e3940c..ffd34dc20e4 100644 --- a/crates/emath/src/range.rs +++ b/crates/emath/src/range.rs @@ -125,6 +125,21 @@ impl Rangef { max: self.max.min(other.max), } } + + /// Do the two ranges intersect? + /// + /// ``` + /// # use emath::Rangef; + /// assert!(Rangef::new(0.0, 10.0).intersects(Rangef::new(5.0, 15.0))); + /// assert!(Rangef::new(0.0, 10.0).intersects(Rangef::new(5.0, 6.0))); + /// assert!(Rangef::new(0.0, 10.0).intersects(Rangef::new(10.0, 20.0))); + /// assert!(!Rangef::new(0.0, 10.0).intersects(Rangef::new(20.0, 30.0))); + /// ``` + #[inline] + #[must_use] + pub fn intersects(self, other: Self) -> bool { + other.min <= self.max && self.min <= other.max + } } impl From for RangeInclusive {