egui/text_selection/
label_text_selection.rs

1use std::sync::Arc;
2
3use emath::TSTransform;
4
5use crate::{
6    Context, CursorIcon, Event, Galley, Id, LayerId, Pos2, Rect, Response, Ui, layers::ShapeIdx,
7    text::CCursor, text_selection::CCursorRange,
8};
9
10use super::{
11    TextCursorState,
12    text_cursor_state::cursor_rect,
13    visuals::{RowVertexIndices, paint_text_selection},
14};
15
16/// Turn on to help debug this
17const DEBUG: bool = false; // Don't merge `true`!
18
19/// One end of a text selection, inside any widget.
20#[derive(Clone, Copy)]
21struct WidgetTextCursor {
22    widget_id: Id,
23    ccursor: CCursor,
24
25    /// Last known screen position
26    pos: Pos2,
27}
28
29impl WidgetTextCursor {
30    fn new(
31        widget_id: Id,
32        cursor: impl Into<CCursor>,
33        global_from_galley: TSTransform,
34        galley: &Galley,
35    ) -> Self {
36        let ccursor = cursor.into();
37        let pos = global_from_galley * pos_in_galley(galley, ccursor);
38        Self {
39            widget_id,
40            ccursor,
41            pos,
42        }
43    }
44}
45
46fn pos_in_galley(galley: &Galley, ccursor: CCursor) -> Pos2 {
47    galley.pos_from_cursor(ccursor).center()
48}
49
50impl std::fmt::Debug for WidgetTextCursor {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("WidgetTextCursor")
53            .field("widget_id", &self.widget_id.short_debug_format())
54            .field("ccursor", &self.ccursor.index)
55            .finish()
56    }
57}
58
59#[derive(Clone, Copy, Debug)]
60struct CurrentSelection {
61    /// The selection is in this layer.
62    ///
63    /// This is to constrain a selection to a single Window.
64    pub layer_id: LayerId,
65
66    /// When selecting with a mouse, this is where the mouse was released.
67    /// When moving with e.g. shift+arrows, this is what moves.
68    /// Note that the two ends can come in any order, and also be equal (no selection).
69    pub primary: WidgetTextCursor,
70
71    /// When selecting with a mouse, this is where the mouse was first pressed.
72    /// This part of the cursor does not move when shift is down.
73    pub secondary: WidgetTextCursor,
74}
75
76/// Handles text selection in labels (NOT in [`crate::TextEdit`])s.
77///
78/// One state for all labels, because we only support text selection in one label at a time.
79#[derive(Clone, Debug)]
80pub struct LabelSelectionState {
81    /// The current selection, if any.
82    selection: Option<CurrentSelection>,
83
84    selection_bbox_last_frame: Rect,
85    selection_bbox_this_frame: Rect,
86
87    /// Any label hovered this frame?
88    any_hovered: bool,
89
90    /// Are we in drag-to-select state?
91    is_dragging: bool,
92
93    /// Have we reached the widget containing the primary selection?
94    has_reached_primary: bool,
95
96    /// Have we reached the widget containing the secondary selection?
97    has_reached_secondary: bool,
98
99    /// Accumulated text to copy.
100    text_to_copy: String,
101    last_copied_galley_rect: Option<Rect>,
102
103    /// Painted selections this frame.
104    ///
105    /// Kept so we can undo a bad selection visualization if we don't see both ends of the selection this frame.
106    painted_selections: Vec<(ShapeIdx, Vec<RowVertexIndices>)>,
107}
108
109impl Default for LabelSelectionState {
110    fn default() -> Self {
111        Self {
112            selection: Default::default(),
113            selection_bbox_last_frame: Rect::NOTHING,
114            selection_bbox_this_frame: Rect::NOTHING,
115            any_hovered: Default::default(),
116            is_dragging: Default::default(),
117            has_reached_primary: Default::default(),
118            has_reached_secondary: Default::default(),
119            text_to_copy: Default::default(),
120            last_copied_galley_rect: Default::default(),
121            painted_selections: Default::default(),
122        }
123    }
124}
125
126impl LabelSelectionState {
127    pub(crate) fn register(ctx: &Context) {
128        ctx.on_begin_pass("LabelSelectionState", std::sync::Arc::new(Self::begin_pass));
129        ctx.on_end_pass("LabelSelectionState", std::sync::Arc::new(Self::end_pass));
130    }
131
132    pub fn load(ctx: &Context) -> Self {
133        let id = Id::new(ctx.viewport_id());
134        ctx.data(|data| data.get_temp::<Self>(id))
135            .unwrap_or_default()
136    }
137
138    pub fn store(self, ctx: &Context) {
139        let id = Id::new(ctx.viewport_id());
140        ctx.data_mut(|data| {
141            data.insert_temp(id, self);
142        });
143    }
144
145    fn begin_pass(ctx: &Context) {
146        let mut state = Self::load(ctx);
147
148        if ctx.input(|i| i.pointer.any_pressed() && !i.modifiers.shift) {
149            // Maybe a new selection is about to begin, but the old one is over:
150            // state.selection = None; // TODO(emilk): this makes sense, but doesn't work as expected.
151        }
152
153        state.selection_bbox_last_frame = state.selection_bbox_this_frame;
154        state.selection_bbox_this_frame = Rect::NOTHING;
155
156        state.any_hovered = false;
157        state.has_reached_primary = false;
158        state.has_reached_secondary = false;
159        state.text_to_copy.clear();
160        state.last_copied_galley_rect = None;
161        state.painted_selections.clear();
162
163        state.store(ctx);
164    }
165
166    fn end_pass(ctx: &Context) {
167        let mut state = Self::load(ctx);
168
169        if state.is_dragging {
170            ctx.set_cursor_icon(CursorIcon::Text);
171        }
172
173        if !state.has_reached_primary || !state.has_reached_secondary {
174            // We didn't see both cursors this frame,
175            // maybe because they are outside the visible area (scrolling),
176            // or one disappeared. In either case we will have horrible glitches, so let's just deselect.
177
178            let prev_selection = state.selection.take();
179            if let Some(selection) = prev_selection {
180                // This was the first frame of glitch, so hide the
181                // glitching by removing all painted selections:
182                ctx.graphics_mut(|layers| {
183                    if let Some(list) = layers.get_mut(selection.layer_id) {
184                        for (shape_idx, row_selections) in state.painted_selections.drain(..) {
185                            list.mutate_shape(shape_idx, |shape| {
186                                if let epaint::Shape::Text(text_shape) = &mut shape.shape {
187                                    let galley = Arc::make_mut(&mut text_shape.galley);
188                                    for row_selection in row_selections {
189                                        if let Some(placed_row) =
190                                            galley.rows.get_mut(row_selection.row)
191                                        {
192                                            let row = Arc::make_mut(&mut placed_row.row);
193                                            for vertex_index in row_selection.vertex_indices {
194                                                if let Some(vertex) = row
195                                                    .visuals
196                                                    .mesh
197                                                    .vertices
198                                                    .get_mut(vertex_index as usize)
199                                                {
200                                                    vertex.color = epaint::Color32::TRANSPARENT;
201                                                }
202                                            }
203                                        }
204                                    }
205                                }
206                            });
207                        }
208                    }
209                });
210            }
211        }
212
213        let pressed_escape = ctx.input(|i| i.key_pressed(crate::Key::Escape));
214        let clicked_something_else = ctx.input(|i| i.pointer.any_pressed()) && !state.any_hovered;
215        let delected_everything = pressed_escape || clicked_something_else;
216
217        if delected_everything {
218            state.selection = None;
219        }
220
221        if ctx.input(|i| i.pointer.any_released()) {
222            state.is_dragging = false;
223        }
224
225        let text_to_copy = std::mem::take(&mut state.text_to_copy);
226        if !text_to_copy.is_empty() {
227            ctx.copy_text(text_to_copy);
228        }
229
230        state.store(ctx);
231    }
232
233    pub fn has_selection(&self) -> bool {
234        self.selection.is_some()
235    }
236
237    pub fn clear_selection(&mut self) {
238        self.selection = None;
239    }
240
241    fn copy_text(&mut self, new_galley_rect: Rect, galley: &Galley, cursor_range: &CCursorRange) {
242        let new_text = selected_text(galley, cursor_range);
243        if new_text.is_empty() {
244            return;
245        }
246
247        if self.text_to_copy.is_empty() {
248            self.text_to_copy = new_text;
249            self.last_copied_galley_rect = Some(new_galley_rect);
250            return;
251        }
252
253        let Some(last_copied_galley_rect) = self.last_copied_galley_rect else {
254            self.text_to_copy = new_text;
255            self.last_copied_galley_rect = Some(new_galley_rect);
256            return;
257        };
258
259        // We need to append or prepend the new text to the already copied text.
260        // We need to do so intelligently.
261
262        if last_copied_galley_rect.bottom() <= new_galley_rect.top() {
263            self.text_to_copy.push('\n');
264            let vertical_distance = new_galley_rect.top() - last_copied_galley_rect.bottom();
265            if estimate_row_height(galley) * 0.5 < vertical_distance {
266                self.text_to_copy.push('\n');
267            }
268        } else {
269            let existing_ends_with_space =
270                self.text_to_copy.chars().last().map(|c| c.is_whitespace());
271
272            let new_text_starts_with_space_or_punctuation = new_text
273                .chars()
274                .next()
275                .is_some_and(|c| c.is_whitespace() || c.is_ascii_punctuation());
276
277            if existing_ends_with_space == Some(false) && !new_text_starts_with_space_or_punctuation
278            {
279                self.text_to_copy.push(' ');
280            }
281        }
282
283        self.text_to_copy.push_str(&new_text);
284        self.last_copied_galley_rect = Some(new_galley_rect);
285    }
286
287    /// Handle text selection state for a label or similar widget.
288    ///
289    /// Make sure the widget senses clicks and drags.
290    ///
291    /// This also takes care of painting the galley.
292    pub fn label_text_selection(
293        ui: &Ui,
294        response: &Response,
295        galley_pos: Pos2,
296        mut galley: Arc<Galley>,
297        fallback_color: epaint::Color32,
298        underline: epaint::Stroke,
299    ) {
300        let mut state = Self::load(ui.ctx());
301        let new_vertex_indices = state.on_label(ui, response, galley_pos, &mut galley);
302
303        let shape_idx = ui.painter().add(
304            epaint::TextShape::new(galley_pos, galley, fallback_color).with_underline(underline),
305        );
306
307        if !new_vertex_indices.is_empty() {
308            state
309                .painted_selections
310                .push((shape_idx, new_vertex_indices));
311        }
312
313        state.store(ui.ctx());
314    }
315
316    fn cursor_for(
317        &mut self,
318        ui: &Ui,
319        response: &Response,
320        global_from_galley: TSTransform,
321        galley: &Galley,
322    ) -> TextCursorState {
323        let Some(selection) = &mut self.selection else {
324            // Nothing selected.
325            return TextCursorState::default();
326        };
327
328        if selection.layer_id != response.layer_id {
329            // Selection is in another layer
330            return TextCursorState::default();
331        }
332
333        let galley_from_global = global_from_galley.inverse();
334
335        let multi_widget_text_select = ui.style().interaction.multi_widget_text_select;
336
337        let may_select_widget =
338            multi_widget_text_select || selection.primary.widget_id == response.id;
339
340        if self.is_dragging && may_select_widget {
341            if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
342                let galley_rect =
343                    global_from_galley * Rect::from_min_size(Pos2::ZERO, galley.size());
344                let galley_rect = galley_rect.intersect(ui.clip_rect());
345
346                let is_in_same_column = galley_rect
347                    .x_range()
348                    .intersects(self.selection_bbox_last_frame.x_range());
349
350                let has_reached_primary =
351                    self.has_reached_primary || response.id == selection.primary.widget_id;
352                let has_reached_secondary =
353                    self.has_reached_secondary || response.id == selection.secondary.widget_id;
354
355                let new_primary = if response.contains_pointer() {
356                    // Dragging into this widget - easy case:
357                    Some(galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2()))
358                } else if is_in_same_column
359                    && !self.has_reached_primary
360                    && selection.primary.pos.y <= selection.secondary.pos.y
361                    && pointer_pos.y <= galley_rect.top()
362                    && galley_rect.top() <= selection.secondary.pos.y
363                {
364                    // The user is dragging the text selection upwards, above the first selected widget (this one):
365                    if DEBUG {
366                        ui.ctx()
367                            .debug_text(format!("Upwards drag; include {:?}", response.id));
368                    }
369                    Some(galley.begin())
370                } else if is_in_same_column
371                    && has_reached_secondary
372                    && has_reached_primary
373                    && selection.secondary.pos.y <= selection.primary.pos.y
374                    && selection.secondary.pos.y <= galley_rect.bottom()
375                    && galley_rect.bottom() <= pointer_pos.y
376                {
377                    // The user is dragging the text selection downwards, below this widget.
378                    // We move the cursor to the end of this widget,
379                    // (and we may do the same for the next widget too).
380                    if DEBUG {
381                        ui.ctx()
382                            .debug_text(format!("Downwards drag; include {:?}", response.id));
383                    }
384                    Some(galley.end())
385                } else {
386                    None
387                };
388
389                if let Some(new_primary) = new_primary {
390                    selection.primary =
391                        WidgetTextCursor::new(response.id, new_primary, global_from_galley, galley);
392
393                    // We don't want the latency of `drag_started`.
394                    let drag_started = ui.input(|i| i.pointer.any_pressed());
395                    if drag_started {
396                        if selection.layer_id == response.layer_id {
397                            if ui.input(|i| i.modifiers.shift) {
398                                // A continuation of a previous selection.
399                            } else {
400                                // A new selection in the same layer.
401                                selection.secondary = selection.primary;
402                            }
403                        } else {
404                            // A new selection in a new layer.
405                            selection.layer_id = response.layer_id;
406                            selection.secondary = selection.primary;
407                        }
408                    }
409                }
410            }
411        }
412
413        let has_primary = response.id == selection.primary.widget_id;
414        let has_secondary = response.id == selection.secondary.widget_id;
415
416        if has_primary {
417            selection.primary.pos =
418                global_from_galley * pos_in_galley(galley, selection.primary.ccursor);
419        }
420        if has_secondary {
421            selection.secondary.pos =
422                global_from_galley * pos_in_galley(galley, selection.secondary.ccursor);
423        }
424
425        self.has_reached_primary |= has_primary;
426        self.has_reached_secondary |= has_secondary;
427
428        let primary = has_primary.then_some(selection.primary.ccursor);
429        let secondary = has_secondary.then_some(selection.secondary.ccursor);
430
431        // The following code assumes we will encounter both ends of the cursor
432        // at some point (but in any order).
433        // If we don't (e.g. because one endpoint is outside the visible scroll areas),
434        // we will have annoying failure cases.
435
436        match (primary, secondary) {
437            (Some(primary), Some(secondary)) => {
438                // This is the only selected label.
439                TextCursorState::from(CCursorRange {
440                    primary,
441                    secondary,
442                    h_pos: None,
443                })
444            }
445
446            (Some(primary), None) => {
447                // This labels contains only the primary cursor.
448                let secondary = if self.has_reached_secondary {
449                    // Secondary was before primary.
450                    // Select everything up to the cursor.
451                    // We assume normal left-to-right and top-down layout order here.
452                    galley.begin()
453                } else {
454                    // Select everything from the cursor onward:
455                    galley.end()
456                };
457                TextCursorState::from(CCursorRange {
458                    primary,
459                    secondary,
460                    h_pos: None,
461                })
462            }
463
464            (None, Some(secondary)) => {
465                // This labels contains only the secondary cursor
466                let primary = if self.has_reached_primary {
467                    // Primary was before secondary.
468                    // Select everything up to the cursor.
469                    // We assume normal left-to-right and top-down layout order here.
470                    galley.begin()
471                } else {
472                    // Select everything from the cursor onward:
473                    galley.end()
474                };
475                TextCursorState::from(CCursorRange {
476                    primary,
477                    secondary,
478                    h_pos: None,
479                })
480            }
481
482            (None, None) => {
483                // This widget has neither the primary or secondary cursor.
484                let is_in_middle = self.has_reached_primary != self.has_reached_secondary;
485                if is_in_middle {
486                    if DEBUG {
487                        response.ctx.debug_text(format!(
488                            "widget in middle: {:?}, between {:?} and {:?}",
489                            response.id, selection.primary.widget_id, selection.secondary.widget_id,
490                        ));
491                    }
492                    // …but it is between the two selection endpoints, and so is fully selected.
493                    TextCursorState::from(CCursorRange::two(galley.begin(), galley.end()))
494                } else {
495                    // Outside the selected range
496                    TextCursorState::default()
497                }
498            }
499        }
500    }
501
502    /// Returns the painted selections, if any.
503    fn on_label(
504        &mut self,
505        ui: &Ui,
506        response: &Response,
507        galley_pos_in_layer: Pos2,
508        galley: &mut Arc<Galley>,
509    ) -> Vec<RowVertexIndices> {
510        let widget_id = response.id;
511
512        let global_from_layer = ui
513            .ctx()
514            .layer_transform_to_global(ui.layer_id())
515            .unwrap_or_default();
516        let layer_from_galley = TSTransform::from_translation(galley_pos_in_layer.to_vec2());
517        let galley_from_layer = layer_from_galley.inverse();
518        let layer_from_global = global_from_layer.inverse();
519        let galley_from_global = galley_from_layer * layer_from_global;
520        let global_from_galley = global_from_layer * layer_from_galley;
521
522        if response.hovered() {
523            ui.ctx().set_cursor_icon(CursorIcon::Text);
524        }
525
526        self.any_hovered |= response.hovered();
527        self.is_dragging |= response.is_pointer_button_down_on(); // we don't want the initial latency of drag vs click decision
528
529        let old_selection = self.selection;
530
531        let mut cursor_state = self.cursor_for(ui, response, global_from_galley, galley);
532
533        let old_range = cursor_state.range(galley);
534
535        if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
536            if response.contains_pointer() {
537                let cursor_at_pointer =
538                    galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2());
539
540                // This is where we handle start-of-drag and double-click-to-select.
541                // Actual drag-to-select happens elsewhere.
542                let dragged = false;
543                cursor_state.pointer_interaction(ui, response, cursor_at_pointer, galley, dragged);
544            }
545        }
546
547        if let Some(mut cursor_range) = cursor_state.range(galley) {
548            let galley_rect = global_from_galley * Rect::from_min_size(Pos2::ZERO, galley.size());
549            self.selection_bbox_this_frame |= galley_rect;
550
551            if let Some(selection) = &self.selection {
552                if selection.primary.widget_id == response.id {
553                    process_selection_key_events(ui.ctx(), galley, response.id, &mut cursor_range);
554                }
555            }
556
557            if got_copy_event(ui.ctx()) {
558                self.copy_text(galley_rect, galley, &cursor_range);
559            }
560
561            cursor_state.set_char_range(Some(cursor_range));
562        }
563
564        // Look for changes due to keyboard and/or mouse interaction:
565        let new_range = cursor_state.range(galley);
566        let selection_changed = old_range != new_range;
567
568        if let (true, Some(range)) = (selection_changed, new_range) {
569            // --------------
570            // Store results:
571
572            if let Some(selection) = &mut self.selection {
573                let primary_changed = Some(range.primary) != old_range.map(|r| r.primary);
574                let secondary_changed = Some(range.secondary) != old_range.map(|r| r.secondary);
575
576                selection.layer_id = response.layer_id;
577
578                if primary_changed || !ui.style().interaction.multi_widget_text_select {
579                    selection.primary =
580                        WidgetTextCursor::new(widget_id, range.primary, global_from_galley, galley);
581                    self.has_reached_primary = true;
582                }
583                if secondary_changed || !ui.style().interaction.multi_widget_text_select {
584                    selection.secondary = WidgetTextCursor::new(
585                        widget_id,
586                        range.secondary,
587                        global_from_galley,
588                        galley,
589                    );
590                    self.has_reached_secondary = true;
591                }
592            } else {
593                // Start of a new selection
594                self.selection = Some(CurrentSelection {
595                    layer_id: response.layer_id,
596                    primary: WidgetTextCursor::new(
597                        widget_id,
598                        range.primary,
599                        global_from_galley,
600                        galley,
601                    ),
602                    secondary: WidgetTextCursor::new(
603                        widget_id,
604                        range.secondary,
605                        global_from_galley,
606                        galley,
607                    ),
608                });
609                self.has_reached_primary = true;
610                self.has_reached_secondary = true;
611            }
612        }
613
614        // Scroll containing ScrollArea on cursor change:
615        if let Some(range) = new_range {
616            let old_primary = old_selection.map(|s| s.primary);
617            let new_primary = self.selection.as_ref().map(|s| s.primary);
618            if let Some(new_primary) = new_primary {
619                let primary_changed = old_primary.is_none_or(|old| {
620                    old.widget_id != new_primary.widget_id || old.ccursor != new_primary.ccursor
621                });
622                if primary_changed && new_primary.widget_id == widget_id {
623                    let is_fully_visible = ui.clip_rect().contains_rect(response.rect); // TODO(emilk): remove this HACK workaround for https://github.com/emilk/egui/issues/1531
624                    if selection_changed && !is_fully_visible {
625                        // Scroll to keep primary cursor in view:
626                        let row_height = estimate_row_height(galley);
627                        let primary_cursor_rect =
628                            global_from_galley * cursor_rect(galley, &range.primary, row_height);
629                        ui.scroll_to_rect(primary_cursor_rect, None);
630                    }
631                }
632            }
633        }
634
635        let cursor_range = cursor_state.range(galley);
636
637        let mut new_vertex_indices = vec![];
638
639        if let Some(cursor_range) = cursor_range {
640            paint_text_selection(
641                galley,
642                ui.visuals(),
643                &cursor_range,
644                Some(&mut new_vertex_indices),
645            );
646        }
647
648        #[cfg(feature = "accesskit")]
649        super::accesskit_text::update_accesskit_for_text_widget(
650            ui.ctx(),
651            response.id,
652            cursor_range,
653            accesskit::Role::Label,
654            global_from_galley,
655            galley,
656        );
657
658        new_vertex_indices
659    }
660}
661
662fn got_copy_event(ctx: &Context) -> bool {
663    ctx.input(|i| {
664        i.events
665            .iter()
666            .any(|e| matches!(e, Event::Copy | Event::Cut))
667    })
668}
669
670/// Returns true if the cursor changed
671fn process_selection_key_events(
672    ctx: &Context,
673    galley: &Galley,
674    widget_id: Id,
675    cursor_range: &mut CCursorRange,
676) -> bool {
677    let os = ctx.os();
678
679    let mut changed = false;
680
681    ctx.input(|i| {
682        // NOTE: we have a lock on ui/ctx here,
683        // so be careful to not call into `ui` or `ctx` again.
684        for event in &i.events {
685            changed |= cursor_range.on_event(os, event, galley, widget_id);
686        }
687    });
688
689    changed
690}
691
692fn selected_text(galley: &Galley, cursor_range: &CCursorRange) -> String {
693    // This logic means we can select everything in an elided label (including the `…`)
694    // and still copy the entire un-elided text!
695    let everything_is_selected = cursor_range.contains(CCursorRange::select_all(galley));
696
697    let copy_everything = cursor_range.is_empty() || everything_is_selected;
698
699    if copy_everything {
700        galley.text().to_owned()
701    } else {
702        cursor_range.slice_str(galley).to_owned()
703    }
704}
705
706fn estimate_row_height(galley: &Galley) -> f32 {
707    if let Some(placed_row) = galley.rows.first() {
708        placed_row.height()
709    } else {
710        galley.size().y
711    }
712}