Skip to main content

egui/text_selection/
label_text_selection.rs

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