egui/text_selection/
label_text_selection.rs

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