1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 14:49:06 -04:00

Fix label selection in deferred viewports (#8242)

Label text selection before the fix in a deferred viewport:

<img width="484" height="172" alt="before_the_fix"
src="https://github.com/user-attachments/assets/2214a7d9-9585-497d-9920-dd336a7df7ea"
/>

After the fix:

<img width="484" height="172" alt="after_the_fix"
src="https://github.com/user-attachments/assets/0999ed8e-22d4-4109-a5b5-f468f99e692d"
/>

## What changed

- Keep label text-selection state separate for each viewport.
- Route pass lifecycle and label painting through the current
`ViewportId`.
- Drop inactive per-viewport state after its pass.
- Add a regression test that verifies an unrelated viewport pass cannot
clear a child viewport's
selection, while the owning viewport still clears selections whose
labels disappear.

## Why

Issue #4758 identified that deferred viewports need independent
label-selection state. PR #4760
fixed it by keying the temporary state by viewport. The plugin refactor
in PR #7385 moved that
state into one context-wide `LabelSelectionState`, which accidentally
removed the viewport
isolation. A pass in another viewport then fails to encounter the
selected widgets and clears the
selection.

This restores the behavior of #4760 within the current plugin
architecture. Applications do not
need any special handling.
This commit is contained in:
Vitaly Kravchenko
2026-06-22 02:37:32 +01:00
committed by GitHub
parent 13d6b5afcf
commit 2c8c27c5df

View File

@@ -4,7 +4,7 @@ use emath::TSTransform;
use crate::{
Context, CursorIcon, Event, Galley, Id, LayerId, Plugin, Pos2, Rect, Response, Ui,
layers::ShapeIdx, text::CCursor, text_selection::CCursorRange,
ViewportIdMap, layers::ShapeIdx, text::CCursor, text_selection::CCursorRange,
};
use super::{
@@ -80,9 +80,15 @@ struct CurrentSelection {
/// Handles text selection in labels (NOT in [`crate::TextEdit`])s.
///
/// One state for all labels, because we only support text selection in one label at a time.
#[derive(Clone, Debug)]
/// Each viewport has its own state, because viewports are rendered in separate passes.
#[derive(Clone, Debug, Default)]
pub struct LabelSelectionState {
states: ViewportIdMap<ViewportLabelSelectionState>,
}
/// Text selection state for all labels in one viewport.
#[derive(Clone, Debug)]
struct ViewportLabelSelectionState {
/// The current selection, if any.
selection: Option<CurrentSelection>,
@@ -111,7 +117,7 @@ pub struct LabelSelectionState {
painted_selections: Vec<(ShapeIdx, Vec<RowVertexIndices>)>,
}
impl Default for LabelSelectionState {
impl Default for ViewportLabelSelectionState {
fn default() -> Self {
Self {
selection: Default::default(),
@@ -134,6 +140,64 @@ impl Plugin for LabelSelectionState {
}
fn on_begin_pass(&mut self, ui: &mut Ui) {
self.states
.entry(ui.ctx().viewport_id())
.or_default()
.on_begin_pass(ui);
}
fn on_end_pass(&mut self, ui: &mut Ui) {
let viewport_id = ui.ctx().viewport_id();
let state = self.states.entry(viewport_id).or_default();
state.on_end_pass(ui);
if !state.is_active() {
self.states.remove(&viewport_id);
}
}
}
impl LabelSelectionState {
/// Is there a label text selection in any viewport?
pub fn has_selection(&self) -> bool {
self.states
.values()
.any(ViewportLabelSelectionState::has_selection)
}
/// Clear all label text selections in all viewports.
pub fn clear_selection(&mut self) {
self.states.clear();
}
/// Handle text selection state for a label or similar widget.
/// This also takes care of painting the galley.
pub fn label_text_selection(
ui: &Ui,
response: &Response,
galley_pos: Pos2,
mut galley: Arc<Galley>,
fallback_color: epaint::Color32,
underline: epaint::Stroke,
) {
let plugin = ui.ctx().plugin::<Self>();
let mut plugin = plugin.lock();
let state = plugin.states.entry(ui.ctx().viewport_id()).or_default();
let new_vertex_indices = state.on_label(ui, response, galley_pos, &mut galley);
let shape_idx = ui.painter().add(
epaint::TextShape::new(galley_pos, galley, fallback_color).with_underline(underline),
);
if !new_vertex_indices.is_empty() {
state
.painted_selections
.push((shape_idx, new_vertex_indices));
}
}
}
impl ViewportLabelSelectionState {
fn on_begin_pass(&mut self, ui: &Ui) {
if ui.input(|i| i.pointer.any_pressed() && !i.modifiers.shift) {
// Maybe a new selection is about to begin, but the old one is over:
// state.selection = None; // TODO(emilk): this makes sense, but doesn't work as expected.
@@ -150,7 +214,7 @@ impl Plugin for LabelSelectionState {
self.painted_selections.clear();
}
fn on_end_pass(&mut self, ui: &mut Ui) {
fn on_end_pass(&mut self, ui: &Ui) {
if self.is_dragging {
ui.set_cursor_icon(CursorIcon::Text);
}
@@ -212,15 +276,13 @@ impl Plugin for LabelSelectionState {
ui.copy_text(text_to_copy);
}
}
}
impl LabelSelectionState {
pub fn has_selection(&self) -> bool {
self.selection.is_some()
fn is_active(&self) -> bool {
self.selection.is_some() || self.is_dragging
}
pub fn clear_selection(&mut self) {
self.selection = None;
fn has_selection(&self) -> bool {
self.selection.is_some()
}
fn copy_text(&mut self, new_galley_rect: Rect, galley: &Galley, cursor_range: &CCursorRange) {
@@ -269,34 +331,6 @@ impl LabelSelectionState {
self.last_copied_galley_rect = Some(new_galley_rect);
}
/// Handle text selection state for a label or similar widget.
///
/// Make sure the widget senses clicks and drags.
///
/// This also takes care of painting the galley.
pub fn label_text_selection(
ui: &Ui,
response: &Response,
galley_pos: Pos2,
mut galley: Arc<Galley>,
fallback_color: epaint::Color32,
underline: epaint::Stroke,
) {
let plugin = ui.ctx().plugin::<Self>();
let mut state = plugin.lock();
let new_vertex_indices = state.on_label(ui, response, galley_pos, &mut galley);
let shape_idx = ui.painter().add(
epaint::TextShape::new(galley_pos, galley, fallback_color).with_underline(underline),
);
if !new_vertex_indices.is_empty() {
state
.painted_selections
.push((shape_idx, new_vertex_indices));
}
}
fn cursor_for(
&mut self,
ui: &Ui,
@@ -693,3 +727,66 @@ fn estimate_row_height(galley: &Galley) -> f32 {
galley.size().y
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{RawInput, ViewportId, ViewportInfo};
fn child_viewport_input(viewport_id: ViewportId) -> RawInput {
let mut input = RawInput {
viewport_id,
..Default::default()
};
input.viewports.insert(
viewport_id,
ViewportInfo {
parent: Some(ViewportId::ROOT),
..Default::default()
},
);
input
}
fn test_selection() -> CurrentSelection {
let cursor = WidgetTextCursor {
widget_id: Id::new("selected_label"),
ccursor: CCursor::default(),
pos: Pos2::ZERO,
};
CurrentSelection {
layer_id: LayerId::background(),
primary: cursor,
secondary: cursor,
}
}
#[test]
fn viewport_passes_only_clean_up_their_own_label_selection() {
let ctx = Context::default();
let child_viewport_id = ViewportId::from_hash_of("child_viewport");
let plugin = ctx.plugin::<LabelSelectionState>();
plugin
.lock()
.states
.entry(child_viewport_id)
.or_default()
.selection = Some(test_selection());
let _ = ctx.run_ui(RawInput::default(), |_| {});
assert!(
plugin
.lock()
.states
.get(&child_viewport_id)
.is_some_and(ViewportLabelSelectionState::has_selection),
"a pass in another viewport must not clear the child viewport selection"
);
let _ = ctx.run_ui(child_viewport_input(child_viewport_id), |_| {});
assert!(
!plugin.lock().has_selection(),
"the selection must be cleared when its labels disappear from the same viewport"
);
}
}