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