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
18const DEBUG: bool = false; #[derive(Clone, Copy)]
23struct WidgetTextCursor {
24 widget_id: Id,
25 ccursor: CCursor,
26
27 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#[derive(Clone, Copy, Debug)]
73struct GranularDrag {
74 granularity: SelectGranularity,
76
77 anchor_min: WidgetTextCursor,
79
80 anchor_max: WidgetTextCursor,
82}
83
84#[derive(Clone, Copy, Debug)]
85struct CurrentSelection {
86 pub layer_id: LayerId,
90
91 pub primary: WidgetTextCursor,
95
96 pub secondary: WidgetTextCursor,
99}
100
101#[derive(Clone, Debug, Default)]
105pub struct LabelSelectionState {
106 states: ViewportIdMap<ViewportLabelSelectionState>,
107}
108
109#[derive(Clone, Debug)]
111struct ViewportLabelSelectionState {
112 selection: Option<CurrentSelection>,
114
115 selection_bbox_last_frame: Rect,
116 selection_bbox_this_frame: Rect,
117
118 any_hovered: bool,
120
121 is_dragging: bool,
123
124 granular_drag: Option<GranularDrag>,
127
128 has_reached_primary: bool,
130
131 has_reached_secondary: bool,
133
134 text_to_copy: String,
136 last_copied_galley_rect: Option<Rect>,
137
138 selection_is_dirty: bool,
140
141 report_selection: bool,
143
144 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 pub fn has_selection(&self) -> bool {
195 self.states
196 .values()
197 .any(ViewportLabelSelectionState::has_selection)
198 }
199
200 pub fn clear_selection(&mut self) {
202 self.states.clear();
203 }
204
205 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 }
238
239 if ui.input(|i| i.pointer.any_pressed()) {
240 if ui.input(|i| i.modifiers.shift) && self.selection.is_some() {
241 if let Some(granular) = &mut self.granular_drag {
244 granular.granularity = SelectGranularity::of_press(ui);
245 }
246 } else {
247 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 let prev_selection = self.selection.take();
277 if let Some(selection) = prev_selection {
278 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 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 return TextCursorState::default();
402 };
403
404 if selection.layer_id != response.layer_id {
405 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 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 if response.id == granular.anchor_min.widget_id {
441 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 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 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 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 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 } else {
523 selection.secondary = selection.primary;
525 }
526 } else {
527 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 match (primary, secondary) {
559 (Some(primary), Some(secondary)) => {
560 TextCursorState::from(CCursorRange {
562 primary,
563 secondary,
564 h_pos: None,
565 })
566 }
567
568 (Some(primary), None) => {
569 let secondary = if self.has_reached_secondary {
571 galley.begin()
575 } else {
576 galley.end()
578 };
579 TextCursorState::from(CCursorRange {
580 primary,
581 secondary,
582 h_pos: None,
583 })
584 }
585
586 (None, Some(secondary)) => {
587 let primary = if self.has_reached_primary {
589 galley.begin()
593 } else {
594 galley.end()
596 };
597 TextCursorState::from(CCursorRange {
598 primary,
599 secondary,
600 h_pos: None,
601 })
602 }
603
604 (None, None) => {
605 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 TextCursorState::from(CCursorRange::two(galley.begin(), galley.end()))
616 } else {
617 TextCursorState::default()
619 }
620 }
621 }
622 }
623
624 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(); 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 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 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 } else if let Some(granular) = cursor_state.granular_drag() {
691 let [anchor_min, anchor_max] = if let Some(selection) = &self.selection
694 && shift
695 && selection.secondary.widget_id != response.id
696 {
697 [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 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 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 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 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 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); if selection_changed && !is_fully_visible {
794 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
837fn 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 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 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}