egui/text_selection/
label_text_selection.rs

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