Skip to main content

egui/text_selection/
label_text_selection.rs

1use alloc::sync::Arc;
2
3use emath::TSTransform;
4
5use crate::{
6    Context, CursorIcon, Event, Galley, Id, LayerId, Plugin, Pos2, Rect, Response, Ui,
7    ViewportIdMap, layers::ShapeIdx, text::CCursor, text_selection::CCursorRange,
8};
9
10use super::{
11    TextCursorState,
12    text_cursor_state::{
13        GranularDragSelect, SelectGranularity, cursor_rect, extend_granular_select, select_unit_at,
14    },
15    visuals::{RowVertexIndices, paint_text_selection},
16};
17
18/// Turn on to help debug this
19const DEBUG: bool = false; // Don't merge `true`!
20
21/// One end of a text selection, inside any widget.
22#[derive(Clone, Copy)]
23struct WidgetTextCursor {
24    widget_id: Id,
25    ccursor: CCursor,
26
27    /// Last known screen position
28    pos: Pos2,
29}
30
31impl WidgetTextCursor {
32    fn new(
33        widget_id: Id,
34        cursor: impl Into<CCursor>,
35        global_from_galley: TSTransform,
36        galley: &Galley,
37    ) -> Self {
38        let ccursor = cursor.into();
39        let pos = global_from_galley * pos_in_galley(galley, ccursor);
40        Self {
41            widget_id,
42            ccursor,
43            pos,
44        }
45    }
46}
47
48fn pos_in_galley(galley: &Galley, ccursor: CCursor) -> Pos2 {
49    galley.pos_from_cursor(ccursor).center()
50}
51
52impl core::fmt::Debug for WidgetTextCursor {
53    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
54        let Self {
55            widget_id,
56            ccursor,
57            pos: _,
58        } = self;
59        f.debug_struct("WidgetTextCursor")
60            .field("widget_id", &widget_id.short_debug_format())
61            .field("ccursor", &ccursor.index)
62            .finish_non_exhaustive()
63    }
64}
65
66/// A label selection that started with a double- or triple-click,
67/// remembered so that dragging and shift-clicking extend it by whole words or lines,
68/// while keeping the originally clicked word or line selected.
69///
70/// Both ends of the anchor word/line are always in the same widget,
71/// but the drag may extend the selection into other widgets.
72#[derive(Clone, Copy, Debug)]
73struct GranularDrag {
74    /// The unit of the current gesture (e.g. a shift-click extends by characters).
75    granularity: SelectGranularity,
76
77    /// Start of the word/line that was initially clicked.
78    anchor_min: WidgetTextCursor,
79
80    /// End of the word/line that was initially clicked.
81    anchor_max: WidgetTextCursor,
82}
83
84#[derive(Clone, Copy, Debug)]
85struct CurrentSelection {
86    /// The selection is in this layer.
87    ///
88    /// This is to constrain a selection to a single Window.
89    pub layer_id: LayerId,
90
91    /// When selecting with a mouse, this is where the mouse was released.
92    /// When moving with e.g. shift+arrows, this is what moves.
93    /// Note that the two ends can come in any order, and also be equal (no selection).
94    pub primary: WidgetTextCursor,
95
96    /// When selecting with a mouse, this is where the mouse was first pressed.
97    /// This part of the cursor does not move when shift is down.
98    pub secondary: WidgetTextCursor,
99}
100
101/// Handles text selection in labels (NOT in [`crate::TextEdit`])s.
102///
103/// Each viewport has its own state, because viewports are rendered in separate passes.
104#[derive(Clone, Debug, Default)]
105pub struct LabelSelectionState {
106    states: ViewportIdMap<ViewportLabelSelectionState>,
107}
108
109/// Text selection state for all labels in one viewport.
110#[derive(Clone, Debug)]
111struct ViewportLabelSelectionState {
112    /// The current selection, if any.
113    selection: Option<CurrentSelection>,
114
115    selection_bbox_last_frame: Rect,
116    selection_bbox_this_frame: Rect,
117
118    /// Any label hovered this frame?
119    any_hovered: bool,
120
121    /// Are we in drag-to-select state?
122    is_dragging: bool,
123
124    /// Set if the current selection started with a double- or triple-click,
125    /// so that dragging extends the selection by whole words or lines.
126    granular_drag: Option<GranularDrag>,
127
128    /// Have we reached the widget containing the primary selection?
129    has_reached_primary: bool,
130
131    /// Have we reached the widget containing the secondary selection?
132    has_reached_secondary: bool,
133
134    /// Accumulated text to copy.
135    text_to_copy: String,
136    last_copied_galley_rect: Option<Rect>,
137
138    /// Has the selection changed since it was last reported with [`crate::OutputCommand::TextSelectionSettled`]?
139    selection_is_dirty: bool,
140
141    /// Should we report the selection with [`crate::OutputCommand::TextSelectionSettled`] at the end of this pass?
142    report_selection: bool,
143
144    /// Painted selections this frame.
145    ///
146    /// Kept so we can undo a bad selection visualization if we don't see both ends of the selection this frame.
147    painted_selections: Vec<(ShapeIdx, Vec<RowVertexIndices>)>,
148}
149
150impl Default for ViewportLabelSelectionState {
151    fn default() -> Self {
152        Self {
153            selection: Default::default(),
154            selection_bbox_last_frame: Rect::NOTHING,
155            selection_bbox_this_frame: Rect::NOTHING,
156            any_hovered: Default::default(),
157            is_dragging: Default::default(),
158            granular_drag: Default::default(),
159            has_reached_primary: Default::default(),
160            has_reached_secondary: Default::default(),
161            text_to_copy: Default::default(),
162            last_copied_galley_rect: Default::default(),
163            selection_is_dirty: Default::default(),
164            report_selection: Default::default(),
165            painted_selections: Default::default(),
166        }
167    }
168}
169
170impl Plugin for LabelSelectionState {
171    fn debug_name(&self) -> &'static str {
172        "LabelSelectionState"
173    }
174
175    fn on_begin_pass(&mut self, ui: &mut Ui) {
176        self.states
177            .entry(ui.ctx().viewport_id())
178            .or_default()
179            .on_begin_pass(ui);
180    }
181
182    fn on_end_pass(&mut self, ui: &mut Ui) {
183        let viewport_id = ui.ctx().viewport_id();
184        let state = self.states.entry(viewport_id).or_default();
185        state.on_end_pass(ui);
186        if !state.is_active() {
187            self.states.remove(&viewport_id);
188        }
189    }
190}
191
192impl LabelSelectionState {
193    /// Is there a label text selection in any viewport?
194    pub fn has_selection(&self) -> bool {
195        self.states
196            .values()
197            .any(ViewportLabelSelectionState::has_selection)
198    }
199
200    /// Clear all label text selections in all viewports.
201    pub fn clear_selection(&mut self) {
202        self.states.clear();
203    }
204
205    /// Handle text selection state for a label or similar widget.
206    /// This also takes care of painting the galley.
207    pub fn label_text_selection(
208        ui: &Ui,
209        response: &Response,
210        galley_pos: Pos2,
211        mut galley: Arc<Galley>,
212        fallback_color: epaint::Color32,
213        underline: epaint::Stroke,
214    ) {
215        let plugin = ui.ctx().plugin::<Self>();
216        let mut plugin = plugin.lock();
217        let state = plugin.states.entry(ui.ctx().viewport_id()).or_default();
218        let new_vertex_indices = state.on_label(ui, response, galley_pos, &mut galley);
219
220        let shape_idx = ui.painter().add(
221            epaint::TextShape::new(galley_pos, galley, fallback_color).with_underline(underline),
222        );
223
224        if !new_vertex_indices.is_empty() {
225            state
226                .painted_selections
227                .push((shape_idx, new_vertex_indices));
228        }
229    }
230}
231
232impl ViewportLabelSelectionState {
233    fn on_begin_pass(&mut self, ui: &Ui) {
234        if ui.input(|i| i.pointer.any_pressed() && !i.modifiers.shift) {
235            // Maybe a new selection is about to begin, but the old one is over:
236            // state.selection = None; // TODO(emilk): this makes sense, but doesn't work as expected.
237        }
238
239        if ui.input(|i| i.pointer.any_pressed()) {
240            if ui.input(|i| i.modifiers.shift) && self.selection.is_some() {
241                // A shift-click extends the current selection, keeping any double- or
242                // triple-clicked word/line selected, but now by the granularity of this press:
243                if let Some(granular) = &mut self.granular_drag {
244                    granular.granularity = SelectGranularity::of_press(ui);
245                }
246            } else {
247                // Any other new press ends the previous word/line selection.
248                // If this press is a double- or triple-click on a label,
249                // `on_label` will set this again later this pass:
250                self.granular_drag = None;
251            }
252        }
253
254        self.selection_bbox_last_frame = self.selection_bbox_this_frame;
255        self.selection_bbox_this_frame = Rect::NOTHING;
256
257        self.any_hovered = false;
258        self.has_reached_primary = false;
259        self.has_reached_secondary = false;
260        self.text_to_copy.clear();
261        self.last_copied_galley_rect = None;
262        self.painted_selections.clear();
263        self.report_selection = self.selection_is_dirty && !ui.input(|i| i.pointer.any_down());
264    }
265
266    fn on_end_pass(&mut self, ui: &Ui) {
267        if self.is_dragging {
268            ui.set_cursor_icon(CursorIcon::Text);
269        }
270
271        if !self.has_reached_primary || !self.has_reached_secondary {
272            // We didn't see both cursors this frame,
273            // maybe because they are outside the visible area (scrolling),
274            // or one disappeared. In either case we will have horrible glitches, so let's just deselect.
275
276            let prev_selection = self.selection.take();
277            if let Some(selection) = prev_selection {
278                // This was the first frame of glitch, so hide the
279                // glitching by removing all painted selections:
280                ui.graphics_mut(|layers| {
281                    if let Some(list) = layers.get_mut(selection.layer_id) {
282                        for (shape_idx, row_selections) in self.painted_selections.drain(..) {
283                            list.mutate_shape(shape_idx, |shape| {
284                                if let epaint::Shape::Text(text_shape) = &mut shape.shape {
285                                    let galley = Arc::make_mut(&mut text_shape.galley);
286                                    for row_selection in row_selections {
287                                        if let Some(placed_row) =
288                                            galley.rows.get_mut(row_selection.row)
289                                        {
290                                            let row = Arc::make_mut(&mut placed_row.row);
291                                            for vertex_index in row_selection.vertex_indices {
292                                                if let Some(vertex) = row
293                                                    .visuals
294                                                    .mesh
295                                                    .vertices
296                                                    .get_mut(vertex_index as usize)
297                                                {
298                                                    vertex.color = epaint::Color32::TRANSPARENT;
299                                                }
300                                            }
301                                        }
302                                    }
303                                }
304                            });
305                        }
306                    }
307                });
308            }
309        }
310
311        let pressed_escape = ui.input(|i| i.key_pressed(crate::Key::Escape));
312        let clicked_something_else = ui.input(|i| i.pointer.any_pressed()) && !self.any_hovered;
313        let delected_everything = pressed_escape || clicked_something_else;
314
315        if delected_everything {
316            self.selection = None;
317        }
318
319        if ui.input(|i| i.pointer.any_released()) {
320            self.is_dragging = false;
321        }
322
323        let text_to_copy = core::mem::take(&mut self.text_to_copy);
324        if self.report_selection {
325            self.selection_is_dirty = false;
326            if !text_to_copy.is_empty() {
327                ui.ctx()
328                    .send_cmd(crate::OutputCommand::TextSelectionSettled(
329                        text_to_copy.clone(),
330                    ));
331            }
332        }
333        if !text_to_copy.is_empty() && got_copy_event(ui.ctx()) {
334            ui.copy_text(text_to_copy);
335        }
336    }
337
338    fn is_active(&self) -> bool {
339        self.selection.is_some() || self.is_dragging
340    }
341
342    fn has_selection(&self) -> bool {
343        self.selection.is_some()
344    }
345
346    fn copy_text(&mut self, new_galley_rect: Rect, galley: &Galley, cursor_range: &CCursorRange) {
347        let new_text = selected_text(galley, cursor_range);
348        if new_text.is_empty() {
349            return;
350        }
351
352        if self.text_to_copy.is_empty() {
353            self.text_to_copy = new_text;
354            self.last_copied_galley_rect = Some(new_galley_rect);
355            return;
356        }
357
358        let Some(last_copied_galley_rect) = self.last_copied_galley_rect else {
359            self.text_to_copy = new_text;
360            self.last_copied_galley_rect = Some(new_galley_rect);
361            return;
362        };
363
364        // We need to append or prepend the new text to the already copied text.
365        // We need to do so intelligently.
366
367        if last_copied_galley_rect.bottom() <= new_galley_rect.top() {
368            self.text_to_copy.push('\n');
369            let vertical_distance = new_galley_rect.top() - last_copied_galley_rect.bottom();
370            if estimate_row_height(galley) * 0.5 < vertical_distance {
371                self.text_to_copy.push('\n');
372            }
373        } else {
374            let existing_ends_with_space =
375                self.text_to_copy.chars().last().map(|c| c.is_whitespace());
376
377            let new_text_starts_with_space_or_punctuation = new_text
378                .chars()
379                .next()
380                .is_some_and(|c| c.is_whitespace() || c.is_ascii_punctuation());
381
382            if existing_ends_with_space == Some(false) && !new_text_starts_with_space_or_punctuation
383            {
384                self.text_to_copy.push(' ');
385            }
386        }
387
388        self.text_to_copy.push_str(&new_text);
389        self.last_copied_galley_rect = Some(new_galley_rect);
390    }
391
392    fn cursor_for(
393        &mut self,
394        ui: &Ui,
395        response: &Response,
396        global_from_galley: TSTransform,
397        galley: &Galley,
398    ) -> TextCursorState {
399        let Some(selection) = &mut self.selection else {
400            // Nothing selected.
401            return TextCursorState::default();
402        };
403
404        if selection.layer_id != response.layer_id {
405            // Selection is in another layer
406            return TextCursorState::default();
407        }
408
409        let galley_from_global = global_from_galley.inverse();
410
411        let multi_widget_text_select = ui.style().interaction.multi_widget_text_select;
412
413        let may_select_widget =
414            multi_widget_text_select || selection.primary.widget_id == response.id;
415
416        if self.is_dragging
417            && may_select_widget
418            && let Some(pointer_pos) = ui.ctx().pointer_interact_pos()
419        {
420            let galley_rect = global_from_galley * Rect::from_min_size(Pos2::ZERO, galley.size());
421            let galley_rect = galley_rect.intersect(ui.clip_rect());
422
423            let is_in_same_column = galley_rect
424                .x_range()
425                .intersects(self.selection_bbox_last_frame.x_range());
426
427            let has_reached_primary =
428                self.has_reached_primary || response.id == selection.primary.widget_id;
429            let has_reached_secondary =
430                self.has_reached_secondary || response.id == selection.secondary.widget_id;
431
432            let new_primary = if response.contains_pointer() {
433                // Dragging into this widget - easy case:
434                let cursor_at_pointer =
435                    galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2());
436
437                if let Some(granular) = &self.granular_drag {
438                    // The selection started with a double- or triple-click,
439                    // so extend it by whole words or lines:
440                    if response.id == granular.anchor_min.widget_id {
441                        // We are in the same widget as the anchor word/line:
442                        let anchor = CCursorRange::two(
443                            granular.anchor_min.ccursor,
444                            granular.anchor_max.ccursor,
445                        );
446                        let range = extend_granular_select(
447                            granular.granularity,
448                            anchor,
449                            galley.text(),
450                            cursor_at_pointer,
451                        );
452                        selection.secondary = WidgetTextCursor::new(
453                            response.id,
454                            range.secondary,
455                            global_from_galley,
456                            galley,
457                        );
458                        Some(range.primary)
459                    } else {
460                        // The drag has left the anchor's widget.
461                        // Are we before or after the anchor? Decide by screen position:
462                        let after_anchor = granular.anchor_max.pos.y < pointer_pos.y
463                            || (granular.anchor_min.pos.y <= pointer_pos.y
464                                && granular.anchor_max.pos.x <= pointer_pos.x);
465
466                        let unit =
467                            select_unit_at(granular.granularity, galley.text(), cursor_at_pointer);
468                        let [unit_min, unit_max] = unit.sorted_cursors();
469
470                        if after_anchor {
471                            selection.secondary = granular.anchor_min;
472                            Some(unit_max)
473                        } else {
474                            selection.secondary = granular.anchor_max;
475                            Some(unit_min)
476                        }
477                    }
478                } else {
479                    Some(cursor_at_pointer)
480                }
481            } else if is_in_same_column
482                && !self.has_reached_primary
483                && selection.primary.pos.y <= selection.secondary.pos.y
484                && pointer_pos.y <= galley_rect.top()
485                && galley_rect.top() <= selection.secondary.pos.y
486            {
487                // The user is dragging the text selection upwards, above the first selected widget (this one):
488                if DEBUG {
489                    ui.ctx()
490                        .debug_text(format!("Upwards drag; include {:?}", response.id));
491                }
492                Some(galley.begin())
493            } else if is_in_same_column
494                && has_reached_secondary
495                && has_reached_primary
496                && selection.secondary.pos.y <= selection.primary.pos.y
497                && selection.secondary.pos.y <= galley_rect.bottom()
498                && galley_rect.bottom() <= pointer_pos.y
499            {
500                // The user is dragging the text selection downwards, below this widget.
501                // We move the cursor to the end of this widget,
502                // (and we may do the same for the next widget too).
503                if DEBUG {
504                    ui.ctx()
505                        .debug_text(format!("Downwards drag; include {:?}", response.id));
506                }
507                Some(galley.end())
508            } else {
509                None
510            };
511
512            if let Some(new_primary) = new_primary {
513                selection.primary =
514                    WidgetTextCursor::new(response.id, new_primary, global_from_galley, galley);
515
516                // We don't want the latency of `drag_started`.
517                let drag_started = ui.input(|i| i.pointer.any_pressed());
518                if drag_started {
519                    if selection.layer_id == response.layer_id {
520                        if ui.input(|i| i.modifiers.shift) {
521                            // A continuation of a previous selection.
522                        } else {
523                            // A new selection in the same layer.
524                            selection.secondary = selection.primary;
525                        }
526                    } else {
527                        // A new selection in a new layer.
528                        selection.layer_id = response.layer_id;
529                        selection.secondary = selection.primary;
530                    }
531                }
532            }
533        }
534
535        let has_primary = response.id == selection.primary.widget_id;
536        let has_secondary = response.id == selection.secondary.widget_id;
537
538        if has_primary {
539            selection.primary.pos =
540                global_from_galley * pos_in_galley(galley, selection.primary.ccursor);
541        }
542        if has_secondary {
543            selection.secondary.pos =
544                global_from_galley * pos_in_galley(galley, selection.secondary.ccursor);
545        }
546
547        self.has_reached_primary |= has_primary;
548        self.has_reached_secondary |= has_secondary;
549
550        let primary = has_primary.then_some(selection.primary.ccursor);
551        let secondary = has_secondary.then_some(selection.secondary.ccursor);
552
553        // The following code assumes we will encounter both ends of the cursor
554        // at some point (but in any order).
555        // If we don't (e.g. because one endpoint is outside the visible scroll areas),
556        // we will have annoying failure cases.
557
558        match (primary, secondary) {
559            (Some(primary), Some(secondary)) => {
560                // This is the only selected label.
561                TextCursorState::from(CCursorRange {
562                    primary,
563                    secondary,
564                    h_pos: None,
565                })
566            }
567
568            (Some(primary), None) => {
569                // This labels contains only the primary cursor.
570                let secondary = if self.has_reached_secondary {
571                    // Secondary was before primary.
572                    // Select everything up to the cursor.
573                    // We assume normal left-to-right and top-down layout order here.
574                    galley.begin()
575                } else {
576                    // Select everything from the cursor onward:
577                    galley.end()
578                };
579                TextCursorState::from(CCursorRange {
580                    primary,
581                    secondary,
582                    h_pos: None,
583                })
584            }
585
586            (None, Some(secondary)) => {
587                // This labels contains only the secondary cursor
588                let primary = if self.has_reached_primary {
589                    // Primary was before secondary.
590                    // Select everything up to the cursor.
591                    // We assume normal left-to-right and top-down layout order here.
592                    galley.begin()
593                } else {
594                    // Select everything from the cursor onward:
595                    galley.end()
596                };
597                TextCursorState::from(CCursorRange {
598                    primary,
599                    secondary,
600                    h_pos: None,
601                })
602            }
603
604            (None, None) => {
605                // This widget has neither the primary or secondary cursor.
606                let is_in_middle = self.has_reached_primary != self.has_reached_secondary;
607                if is_in_middle {
608                    if DEBUG {
609                        response.ctx.debug_text(format!(
610                            "widget in middle: {:?}, between {:?} and {:?}",
611                            response.id, selection.primary.widget_id, selection.secondary.widget_id,
612                        ));
613                    }
614                    // …but it is between the two selection endpoints, and so is fully selected.
615                    TextCursorState::from(CCursorRange::two(galley.begin(), galley.end()))
616                } else {
617                    // Outside the selected range
618                    TextCursorState::default()
619                }
620            }
621        }
622    }
623
624    /// Returns the painted selections, if any.
625    fn on_label(
626        &mut self,
627        ui: &Ui,
628        response: &Response,
629        galley_pos_in_layer: Pos2,
630        galley: &mut Arc<Galley>,
631    ) -> Vec<RowVertexIndices> {
632        let widget_id = response.id;
633
634        let global_from_layer = ui
635            .ctx()
636            .layer_transform_to_global(ui.layer_id())
637            .unwrap_or_default();
638        let layer_from_galley = TSTransform::from_translation(galley_pos_in_layer.to_vec2());
639        let galley_from_layer = layer_from_galley.inverse();
640        let layer_from_global = global_from_layer.inverse();
641        let galley_from_global = galley_from_layer * layer_from_global;
642        let global_from_galley = global_from_layer * layer_from_galley;
643
644        if response.hovered() {
645            ui.set_cursor_icon(CursorIcon::Text);
646        }
647
648        self.any_hovered |= response.hovered();
649        self.is_dragging |= response.is_pointer_button_down_on(); // we don't want the initial latency of drag vs click decision
650
651        let old_selection = self.selection;
652
653        let mut cursor_state = self.cursor_for(ui, response, global_from_galley, galley);
654
655        let old_range = cursor_state.range(galley);
656
657        if let Some(pointer_pos) = ui.ctx().pointer_interact_pos()
658            && response.contains_pointer()
659        {
660            let cursor_at_pointer =
661                galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2());
662
663            // This is where we handle start-of-drag and double-click-to-select.
664            // Actual drag-to-select happens elsewhere.
665            let dragged = false;
666            let shift = ui.input(|i| i.modifiers.shift);
667
668            if shift
669                && let Some(granular) = &self.granular_drag
670                && granular.anchor_min.widget_id == response.id
671            {
672                // A shift-click in the widget with the double- or triple-clicked word/line,
673                // which should stay selected:
674                cursor_state.set_granular_drag(Some(GranularDragSelect {
675                    granularity: granular.granularity,
676                    anchor: CCursorRange::two(
677                        granular.anchor_min.ccursor,
678                        granular.anchor_max.ccursor,
679                    ),
680                }));
681            }
682
683            cursor_state.pointer_interaction(ui, response, cursor_at_pointer, galley, dragged);
684
685            if shift && self.granular_drag.is_some() {
686                // A shift-click extending a double- or triple-click selection:
687                // keep the original word/line as the anchor (see `on_begin_pass`).
688                // If the anchor is in another widget, `cursor_for` has already
689                // extended the selection relative to it.
690            } else if let Some(granular) = cursor_state.granular_drag() {
691                // If this was a double- or triple-click, remember the clicked word/line
692                // so that dragging extends the selection by that granularity:
693                let [anchor_min, anchor_max] = if let Some(selection) = &self.selection
694                    && shift
695                    && selection.secondary.widget_id != response.id
696                {
697                    // A shift-double-click extending a selection that is anchored in another widget.
698                    // `cursor_state` only sees the edge of this widget, so use the real anchor:
699                    [selection.secondary; 2]
700                } else {
701                    granular.anchor.sorted_cursors().map(|ccursor| {
702                        WidgetTextCursor::new(response.id, ccursor, global_from_galley, galley)
703                    })
704                };
705                self.granular_drag = Some(GranularDrag {
706                    granularity: granular.granularity,
707                    anchor_min,
708                    anchor_max,
709                });
710            }
711        }
712
713        if let Some(mut cursor_range) = cursor_state.range(galley) {
714            let galley_rect = global_from_galley * Rect::from_min_size(Pos2::ZERO, galley.size());
715            self.selection_bbox_this_frame |= galley_rect;
716
717            if let Some(selection) = &self.selection
718                && selection.primary.widget_id == response.id
719                && process_selection_key_events(ui.ctx(), galley, response.id, &mut cursor_range)
720            {
721                // The keyboard changed the selection, so forget any double- or triple-clicked word/line:
722                self.granular_drag = None;
723            }
724
725            if got_copy_event(ui.ctx()) || self.report_selection {
726                self.copy_text(galley_rect, galley, &cursor_range);
727            }
728
729            cursor_state.set_char_range(Some(cursor_range));
730        }
731
732        // Look for changes due to keyboard and/or mouse interaction:
733        let new_range = cursor_state.range(galley);
734        let selection_changed = old_range != new_range;
735        self.selection_is_dirty |= selection_changed;
736
737        if let (true, Some(range)) = (selection_changed, new_range) {
738            // --------------
739            // Store results:
740
741            if let Some(selection) = &mut self.selection {
742                let primary_changed = Some(range.primary) != old_range.map(|r| r.primary);
743                let secondary_changed = Some(range.secondary) != old_range.map(|r| r.secondary);
744
745                selection.layer_id = response.layer_id;
746
747                if primary_changed || !ui.style().interaction.multi_widget_text_select {
748                    selection.primary =
749                        WidgetTextCursor::new(widget_id, range.primary, global_from_galley, galley);
750                    self.has_reached_primary = true;
751                }
752                if secondary_changed || !ui.style().interaction.multi_widget_text_select {
753                    selection.secondary = WidgetTextCursor::new(
754                        widget_id,
755                        range.secondary,
756                        global_from_galley,
757                        galley,
758                    );
759                    self.has_reached_secondary = true;
760                }
761            } else {
762                // Start of a new selection
763                self.selection = Some(CurrentSelection {
764                    layer_id: response.layer_id,
765                    primary: WidgetTextCursor::new(
766                        widget_id,
767                        range.primary,
768                        global_from_galley,
769                        galley,
770                    ),
771                    secondary: WidgetTextCursor::new(
772                        widget_id,
773                        range.secondary,
774                        global_from_galley,
775                        galley,
776                    ),
777                });
778                self.has_reached_primary = true;
779                self.has_reached_secondary = true;
780            }
781        }
782
783        // Scroll containing ScrollArea on cursor change:
784        if let Some(range) = new_range {
785            let old_primary = old_selection.map(|s| s.primary);
786            let new_primary = self.selection.as_ref().map(|s| s.primary);
787            if let Some(new_primary) = new_primary {
788                let primary_changed = old_primary.is_none_or(|old| {
789                    old.widget_id != new_primary.widget_id || old.ccursor != new_primary.ccursor
790                });
791                if primary_changed && new_primary.widget_id == widget_id {
792                    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
793                    if selection_changed && !is_fully_visible {
794                        // Scroll to keep primary cursor in view:
795                        let row_height = estimate_row_height(galley);
796                        let primary_cursor_rect =
797                            global_from_galley * cursor_rect(galley, &range.primary, row_height);
798                        ui.scroll_to_rect(primary_cursor_rect, None);
799                    }
800                }
801            }
802        }
803
804        let cursor_range = cursor_state.range(galley);
805
806        let mut new_vertex_indices = vec![];
807
808        if let Some(cursor_range) = cursor_range {
809            paint_text_selection(
810                galley,
811                ui.visuals(),
812                &cursor_range,
813                Some(&mut new_vertex_indices),
814            );
815        }
816
817        super::accesskit_text::update_accesskit_for_text_widget(
818            ui.ctx(),
819            response.id,
820            cursor_range,
821            global_from_galley,
822            galley,
823        );
824
825        new_vertex_indices
826    }
827}
828
829fn got_copy_event(ctx: &Context) -> bool {
830    ctx.input(|i| {
831        i.events
832            .iter()
833            .any(|e| matches!(e, Event::Copy | Event::Cut))
834    })
835}
836
837/// Returns true if the cursor changed
838fn process_selection_key_events(
839    ctx: &Context,
840    galley: &Galley,
841    widget_id: Id,
842    cursor_range: &mut CCursorRange,
843) -> bool {
844    let os = ctx.os();
845
846    let mut changed = false;
847
848    ctx.input(|i| {
849        // NOTE: we have a lock on ui/ctx here,
850        // so be careful to not call into `ui` or `ctx` again.
851        for event in &i.events {
852            changed |= cursor_range.on_event(os, event, galley, widget_id);
853        }
854    });
855
856    changed
857}
858
859fn selected_text(galley: &Galley, cursor_range: &CCursorRange) -> String {
860    // This logic means we can select everything in an elided label (including the `…`)
861    // and still copy the entire un-elided text!
862    let everything_is_selected = cursor_range.contains(CCursorRange::select_all(galley));
863
864    let copy_everything = cursor_range.is_empty() || everything_is_selected;
865
866    if copy_everything {
867        galley.text().to_owned()
868    } else {
869        cursor_range.slice_str(galley).to_owned()
870    }
871}
872
873fn estimate_row_height(galley: &Galley) -> f32 {
874    if let Some(placed_row) = galley.rows.first() {
875        placed_row.height()
876    } else {
877        galley.size().y
878    }
879}
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884    use crate::{RawInput, ViewportId, ViewportInfo};
885
886    fn child_viewport_input(viewport_id: ViewportId) -> RawInput {
887        let mut input = RawInput {
888            viewport_id,
889            ..Default::default()
890        };
891        input.viewports.insert(
892            viewport_id,
893            ViewportInfo {
894                parent: Some(ViewportId::ROOT),
895                ..Default::default()
896            },
897        );
898        input
899    }
900
901    fn test_selection() -> CurrentSelection {
902        let cursor = WidgetTextCursor {
903            widget_id: Id::unique("selected_label"),
904            ccursor: CCursor::default(),
905            pos: Pos2::ZERO,
906        };
907        CurrentSelection {
908            layer_id: LayerId::background(),
909            primary: cursor,
910            secondary: cursor,
911        }
912    }
913
914    #[test]
915    fn viewport_passes_only_clean_up_their_own_label_selection() {
916        let ctx = Context::default();
917        let child_viewport_id = ViewportId::from_hash_of("child_viewport");
918        let plugin = ctx.plugin::<LabelSelectionState>();
919        plugin
920            .lock()
921            .states
922            .entry(child_viewport_id)
923            .or_default()
924            .selection = Some(test_selection());
925
926        let output = ctx.run_ui(RawInput::default(), |_| {});
927        assert!(
928            plugin
929                .lock()
930                .states
931                .get(&child_viewport_id)
932                .is_some_and(ViewportLabelSelectionState::has_selection),
933            "a pass in another viewport must not clear the child viewport selection"
934        );
935        output.drop_without_applying_deltas();
936
937        let output = ctx.run_ui(child_viewport_input(child_viewport_id), |_| {});
938        assert!(
939            !plugin.lock().has_selection(),
940            "the selection must be cleared when its labels disappear from the same viewport"
941        );
942        output.drop_without_applying_deltas();
943    }
944}