diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 4bc121893..ab1091216 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -625,7 +625,7 @@ impl Context { /// ``` pub fn begin_frame(&self, new_input: RawInput) { crate::profile_function!(); - + crate::text_selection::LabelSelectionState::begin_frame(self); self.write(|ctx| ctx.begin_frame_mut(new_input)); } } @@ -697,13 +697,13 @@ impl Context { /// Read-write access to [`GraphicLayers`], where painted [`crate::Shape`]s are written to. #[inline] - pub(crate) fn graphics_mut(&self, writer: impl FnOnce(&mut GraphicLayers) -> R) -> R { + pub fn graphics_mut(&self, writer: impl FnOnce(&mut GraphicLayers) -> R) -> R { self.write(move |ctx| writer(&mut ctx.viewport().graphics)) } /// Read-only access to [`GraphicLayers`], where painted [`crate::Shape`]s are written to. #[inline] - pub(crate) fn graphics(&self, reader: impl FnOnce(&GraphicLayers) -> R) -> R { + pub fn graphics(&self, reader: impl FnOnce(&GraphicLayers) -> R) -> R { self.write(move |ctx| reader(&ctx.viewport().graphics)) } @@ -1616,6 +1616,8 @@ impl Context { crate::gui_zoom::zoom_with_keyboard(self); } + crate::text_selection::LabelSelectionState::end_frame(self); + let debug_texts = self.write(|ctx| std::mem::take(&mut ctx.debug_texts)); if !debug_texts.is_empty() { // Show debug-text next to the cursor. @@ -2041,7 +2043,7 @@ impl Context { /// Can be used to implement drag-and-drop (see relevant demo). pub fn translate_layer(&self, layer_id: LayerId, delta: Vec2) { if delta != Vec2::ZERO { - self.graphics_mut(|g| g.list(layer_id).translate(delta)); + self.graphics_mut(|g| g.entry(layer_id).translate(delta)); } } @@ -2352,6 +2354,15 @@ impl Context { let font_image_size = self.fonts(|f| f.font_image_size()); crate::introspection::font_texture_ui(ui, font_image_size); }); + + CollapsingHeader::new("Label text selection state") + .default_open(false) + .show(ui, |ui| { + ui.label(format!( + "{:#?}", + crate::text_selection::LabelSelectionState::load(ui.ctx()) + )); + }); } /// Show stats about the allocated textures. diff --git a/crates/egui/src/input_state.rs b/crates/egui/src/input_state.rs index 260d6354b..cfc2073c5 100644 --- a/crates/egui/src/input_state.rs +++ b/crates/egui/src/input_state.rs @@ -840,6 +840,7 @@ impl PointerState { } /// Was any pointer button pressed (`!down -> down`) this frame? + /// /// This can sometimes return `true` even if `any_down() == false` /// because a press can be shorted than one frame. pub fn any_pressed(&self) -> bool { diff --git a/crates/egui/src/layers.rs b/crates/egui/src/layers.rs index 3df282848..74eb45a3f 100644 --- a/crates/egui/src/layers.rs +++ b/crates/egui/src/layers.rs @@ -110,7 +110,8 @@ impl LayerId { } /// A unique identifier of a specific [`Shape`] in a [`PaintList`]. -#[derive(Clone, Copy, PartialEq, Eq)] + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ShapeIdx(pub usize); /// A list of [`Shape`]s paired with a clip rectangle. @@ -151,6 +152,12 @@ impl PaintList { self.0[idx.0] = ClippedShape { clip_rect, shape }; } + /// Set the given shape to be empty (a `Shape::Noop`). + #[inline(always)] + pub fn reset_shape(&mut self, idx: ShapeIdx) { + self.0[idx.0].shape = Shape::Noop; + } + /// Translate each [`Shape`] and clip rectangle by this much, in-place pub fn translate(&mut self, delta: Vec2) { for ClippedShape { clip_rect, shape } in &mut self.0 { @@ -165,20 +172,28 @@ impl PaintList { } } +/// This is where painted [`Shape`]s end up during a frame. #[derive(Clone, Default)] -pub(crate) struct GraphicLayers([IdMap; Order::COUNT]); +pub struct GraphicLayers([IdMap; Order::COUNT]); impl GraphicLayers { - pub fn list(&mut self, layer_id: LayerId) -> &mut PaintList { + /// Get or insert the [`PaintList`] for the given [`LayerId`]. + pub fn entry(&mut self, layer_id: LayerId) -> &mut PaintList { self.0[layer_id.order as usize] .entry(layer_id.id) .or_default() } + /// Get the [`PaintList`] for the given [`LayerId`]. pub fn get(&self, layer_id: LayerId) -> Option<&PaintList> { self.0[layer_id.order as usize].get(&layer_id.id) } + /// Get the [`PaintList`] for the given [`LayerId`]. + pub fn get_mut(&mut self, layer_id: LayerId) -> Option<&mut PaintList> { + self.0[layer_id.order as usize].get_mut(&layer_id.id) + } + pub fn drain(&mut self, area_order: &[LayerId]) -> Vec { crate::profile_function!(); diff --git a/crates/egui/src/painter.rs b/crates/egui/src/painter.rs index e4dcf1176..c0eb8e12f 100644 --- a/crates/egui/src/painter.rs +++ b/crates/egui/src/painter.rs @@ -144,7 +144,7 @@ impl Painter { impl Painter { #[inline] fn paint_list(&self, writer: impl FnOnce(&mut PaintList) -> R) -> R { - self.ctx.graphics_mut(|g| writer(g.list(self.layer_id))) + self.ctx.graphics_mut(|g| writer(g.entry(self.layer_id))) } fn transform_shape(&self, shape: &mut Shape) { @@ -257,21 +257,21 @@ impl Painter { /// # Paint different primitives impl Painter { /// Paints a line from the first point to the second. - pub fn line_segment(&self, points: [Pos2; 2], stroke: impl Into) { + pub fn line_segment(&self, points: [Pos2; 2], stroke: impl Into) -> ShapeIdx { self.add(Shape::LineSegment { points, stroke: stroke.into(), - }); + }) } /// Paints a horizontal line. - pub fn hline(&self, x: impl Into, y: f32, stroke: impl Into) { - self.add(Shape::hline(x, y, stroke)); + pub fn hline(&self, x: impl Into, y: f32, stroke: impl Into) -> ShapeIdx { + self.add(Shape::hline(x, y, stroke)) } /// Paints a vertical line. - pub fn vline(&self, x: f32, y: impl Into, stroke: impl Into) { - self.add(Shape::vline(x, y, stroke)); + pub fn vline(&self, x: f32, y: impl Into, stroke: impl Into) -> ShapeIdx { + self.add(Shape::vline(x, y, stroke)) } pub fn circle( @@ -280,31 +280,36 @@ impl Painter { radius: f32, fill_color: impl Into, stroke: impl Into, - ) { + ) -> ShapeIdx { self.add(CircleShape { center, radius, fill: fill_color.into(), stroke: stroke.into(), - }); + }) } - pub fn circle_filled(&self, center: Pos2, radius: f32, fill_color: impl Into) { + pub fn circle_filled( + &self, + center: Pos2, + radius: f32, + fill_color: impl Into, + ) -> ShapeIdx { self.add(CircleShape { center, radius, fill: fill_color.into(), stroke: Default::default(), - }); + }) } - pub fn circle_stroke(&self, center: Pos2, radius: f32, stroke: impl Into) { + pub fn circle_stroke(&self, center: Pos2, radius: f32, stroke: impl Into) -> ShapeIdx { self.add(CircleShape { center, radius, fill: Default::default(), stroke: stroke.into(), - }); + }) } pub fn rect( @@ -313,8 +318,8 @@ impl Painter { rounding: impl Into, fill_color: impl Into, stroke: impl Into, - ) { - self.add(RectShape::new(rect, rounding, fill_color, stroke)); + ) -> ShapeIdx { + self.add(RectShape::new(rect, rounding, fill_color, stroke)) } pub fn rect_filled( @@ -322,8 +327,8 @@ impl Painter { rect: Rect, rounding: impl Into, fill_color: impl Into, - ) { - self.add(RectShape::filled(rect, rounding, fill_color)); + ) -> ShapeIdx { + self.add(RectShape::filled(rect, rounding, fill_color)) } pub fn rect_stroke( @@ -331,8 +336,8 @@ impl Painter { rect: Rect, rounding: impl Into, stroke: impl Into, - ) { - self.add(RectShape::stroke(rect, rounding, stroke)); + ) -> ShapeIdx { + self.add(RectShape::stroke(rect, rounding, stroke)) } /// Show an arrow starting at `origin` and going in the direction of `vec`, with the length `vec.length()`. @@ -366,8 +371,14 @@ impl Painter { /// .paint_at(ui, rect); /// # }); /// ``` - pub fn image(&self, texture_id: epaint::TextureId, rect: Rect, uv: Rect, tint: Color32) { - self.add(Shape::image(texture_id, rect, uv, tint)); + pub fn image( + &self, + texture_id: epaint::TextureId, + rect: Rect, + uv: Rect, + tint: Color32, + ) -> ShapeIdx { + self.add(Shape::image(texture_id, rect, uv, tint)) } } diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 6422e6547..46531f9c8 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -720,6 +720,12 @@ pub struct Interaction { /// Can you select the text on a [`crate::Label`] by default? pub selectable_labels: bool, + + /// Can the user select text that span multiple labels? + /// + /// The default is `true`, but text seelction can be slightly glitchy, + /// so you may want to disable it. + pub multi_widget_text_select: bool, } /// Controls the visual style (colors etc) of egui. @@ -1120,6 +1126,7 @@ impl Default for Interaction { show_tooltips_only_when_still: true, tooltip_delay: 0.0, selectable_labels: true, + multi_widget_text_select: true, } } } @@ -1580,6 +1587,7 @@ impl Interaction { show_tooltips_only_when_still, tooltip_delay, selectable_labels, + multi_widget_text_select, } = self; ui.add(Slider::new(resize_grab_radius_side, 0.0..=20.0).text("resize_grab_radius_side")); ui.add( @@ -1590,7 +1598,13 @@ impl Interaction { "Only show tooltips if mouse is still", ); ui.add(Slider::new(tooltip_delay, 0.0..=1.0).text("tooltip_delay")); - ui.checkbox(selectable_labels, "Selectable text in labels"); + + ui.horizontal(|ui| { + ui.checkbox(selectable_labels, "Selectable text in labels"); + if *selectable_labels { + ui.checkbox(multi_widget_text_select, "Across multiple labels"); + } + }); ui.vertical_centered(|ui| reset_button(ui, self)); } diff --git a/crates/egui/src/text_selection/cursor_range.rs b/crates/egui/src/text_selection/cursor_range.rs index b0680fdf2..cebcf3fb5 100644 --- a/crates/egui/src/text_selection/cursor_range.rs +++ b/crates/egui/src/text_selection/cursor_range.rs @@ -234,10 +234,10 @@ impl CCursorRange { } #[inline] - pub fn two(min: CCursor, max: CCursor) -> Self { + pub fn two(min: impl Into, max: impl Into) -> Self { Self { - primary: max, - secondary: min, + primary: max.into(), + secondary: min.into(), } } diff --git a/crates/egui/src/text_selection/label_text_selection.rs b/crates/egui/src/text_selection/label_text_selection.rs index b62f34715..45730375b 100644 --- a/crates/egui/src/text_selection/label_text_selection.rs +++ b/crates/egui/src/text_selection/label_text_selection.rs @@ -1,39 +1,23 @@ -use epaint::{Galley, Pos2}; - -use crate::{Context, CursorIcon, Event, Id, Response, Ui}; +use crate::{ + layers::ShapeIdx, text::CCursor, text_selection::CCursorRange, Context, CursorIcon, Event, + Galley, Id, LayerId, Pos2, Rect, Response, Ui, +}; use super::{ text_cursor_state::cursor_rect, visuals::paint_text_selection, CursorRange, TextCursorState, }; -/// Handle text selection state for a label or similar widget. -/// -/// Make sure the widget senses clicks and drags. -/// -/// This should be called after painting the text, because this will also -/// paint the text cursor/selection on top. -pub fn label_text_selection(ui: &Ui, response: &Response, galley_pos: Pos2, galley: &Galley) { - let mut cursor_state = LabelSelectionState::load(ui.ctx(), response.id); - let original_cursor = cursor_state.range(galley); - - if response.hovered { - ui.ctx().set_cursor_icon(CursorIcon::Text); - } else if !cursor_state.is_empty() && ui.input(|i| i.pointer.any_pressed()) { - // We clicked somewhere else - deselect this label. - cursor_state = Default::default(); - LabelSelectionState::store(ui.ctx(), response.id, cursor_state); - } - - if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { - let cursor_at_pointer = galley.cursor_from_pos(pointer_pos - galley_pos); - cursor_state.pointer_interaction(ui, response, cursor_at_pointer, galley); - } - - if let Some(mut cursor_range) = cursor_state.range(galley) { - process_selection_key_events(ui.ctx(), galley, response.id, &mut cursor_range); - cursor_state.set_range(Some(cursor_range)); - } +/// Turn on to help debug this +const DEBUG: bool = false; // TODO: don't merge this while `true` +fn paint_selection( + ui: &Ui, + _response: &Response, + galley_pos: Pos2, + galley: &Galley, + cursor_state: &TextCursorState, + painted_shape_idx: &mut Vec, +) { let cursor_range = cursor_state.range(galley); if let Some(cursor_range) = cursor_range { @@ -45,109 +29,593 @@ pub fn label_text_selection(ui: &Ui, response: &Response, galley_pos: Pos2, gall galley_pos, galley, &cursor_range, + Some(painted_shape_idx), ); - - let selection_changed = original_cursor != Some(cursor_range); - - let is_fully_visible = ui.clip_rect().contains_rect(response.rect); // TODO: remove this HACK workaround for https://github.com/emilk/egui/issues/1531 - - if selection_changed && !is_fully_visible { - // Scroll to keep primary cursor in view: - let row_height = estimate_row_height(galley); - let primary_cursor_rect = - cursor_rect(galley_pos, galley, &cursor_range.primary, row_height); - ui.scroll_to_rect(primary_cursor_rect, None); - } } #[cfg(feature = "accesskit")] super::accesskit_text::update_accesskit_for_text_widget( ui.ctx(), - response.id, + _response.id, cursor_range, accesskit::Role::StaticText, galley_pos, galley, ); +} - if !cursor_state.is_empty() { - LabelSelectionState::store(ui.ctx(), response.id, cursor_state); +/// One end of a text selection, inside any widget. +#[derive(Clone, Copy)] +struct WidgetTextCursor { + widget_id: Id, + ccursor: CCursor, + + /// Last known screen position + pos: Pos2, +} + +impl WidgetTextCursor { + fn new(widget_id: Id, cursor: impl Into, galley_pos: Pos2, galley: &Galley) -> Self { + let ccursor = cursor.into(); + let pos = pos_in_galley(galley_pos, galley, ccursor); + Self { + widget_id, + ccursor, + pos, + } } } +fn pos_in_galley(galley_pos: Pos2, galley: &Galley, ccursor: CCursor) -> Pos2 { + galley_pos + galley.pos_from_ccursor(ccursor).center().to_vec2() +} + +impl std::fmt::Debug for WidgetTextCursor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WidgetTextCursor") + .field("widget_id", &self.widget_id.short_debug_format()) + .field("ccursor", &self.ccursor.index) + .finish() + } +} + +#[derive(Clone, Copy, Debug)] +struct CurrentSelection { + /// The selection is in this layer. + /// + /// This is to constrain a selection to a single Window. + pub layer_id: LayerId, + + /// When selecting with a mouse, this is where the mouse was released. + /// When moving with e.g. shift+arrows, this is what moves. + /// Note that the two ends can come in any order, and also be equal (no selection). + pub primary: WidgetTextCursor, + + /// When selecting with a mouse, this is where the mouse was first pressed. + /// This part of the cursor does not move when shift is down. + pub secondary: WidgetTextCursor, +} + /// 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, Copy, Debug, Default)] -struct LabelSelectionState { - /// Id of the (only) label with a selection, if any - id: Option, - +#[derive(Clone, Debug)] +pub struct LabelSelectionState { /// The current selection, if any. - selection: TextCursorState, + selection: Option, + + selection_bbox_last_frame: Rect, + selection_bbox_this_frame: Rect, + + /// Any label hovered this frame? + any_hovered: bool, + + /// Are we in drag-to-select state? + is_dragging: bool, + + /// Have we reached the widget containing the primary selection? + has_reached_primary: bool, + + /// Have we reached the widget containing the secondary selection? + has_reached_secondary: bool, + + /// Accumulated text to copy. + text_to_copy: String, + last_copied_galley_rect: Option, + + /// Painted selections this frame. + painted_shape_idx: Vec, +} + +impl Default for LabelSelectionState { + fn default() -> Self { + Self { + selection: Default::default(), + selection_bbox_last_frame: Rect::NOTHING, + selection_bbox_this_frame: Rect::NOTHING, + any_hovered: Default::default(), + is_dragging: Default::default(), + has_reached_primary: Default::default(), + has_reached_secondary: Default::default(), + text_to_copy: Default::default(), + last_copied_galley_rect: Default::default(), + painted_shape_idx: Default::default(), + } + } } impl LabelSelectionState { - /// Load the range of text of text that is selected for the given widget. - fn load(ctx: &Context, id: Id) -> TextCursorState { + pub fn load(ctx: &Context) -> Self { ctx.data(|data| data.get_temp::(Id::NULL)) - .and_then(|state| (state.id == Some(id)).then_some(state.selection)) .unwrap_or_default() } - /// Load the range of text of text that is selected for the given widget. - fn store(ctx: &Context, id: Id, selection: TextCursorState) { + pub fn store(self, ctx: &Context) { ctx.data_mut(|data| { - data.insert_temp( - Id::NULL, - Self { - id: Some(id), - selection, - }, - ); + data.insert_temp(Id::NULL, self); }); } + + pub fn begin_frame(ctx: &Context) { + let mut state = Self::load(ctx); + + if ctx.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: this makes sense, but doesn't work as expected. + } + + state.selection_bbox_last_frame = state.selection_bbox_this_frame; + state.selection_bbox_this_frame = Rect::NOTHING; + + state.any_hovered = false; + state.has_reached_primary = false; + state.has_reached_secondary = false; + state.text_to_copy.clear(); + state.last_copied_galley_rect = None; + state.painted_shape_idx.clear(); + + state.store(ctx); + } + + pub fn end_frame(ctx: &Context) { + let mut state = Self::load(ctx); + + if state.is_dragging { + ctx.set_cursor_icon(CursorIcon::Text); + } + + if !state.has_reached_primary || !state.has_reached_secondary { + // We didn't see both cursors this frame, + // maybe because they are outside the visible area (scrolling), + // or one disappeared. In either case we will have horrible glitches, so let's just deselect. + + let prev_selection = state.selection.take(); + if let Some(selection) = prev_selection { + // This was the first frame of glitch, so hide the + // glitching by removing all painted selections: + ctx.graphics_mut(|layers| { + if let Some(list) = layers.get_mut(selection.layer_id) { + for shape_idx in state.painted_shape_idx.drain(..) { + list.reset_shape(shape_idx); + } + } + }); + } + } + + let pressed_escape = ctx.input(|i| i.key_pressed(crate::Key::Escape)); + let clicked_something_else = ctx.input(|i| i.pointer.any_pressed()) && !state.any_hovered; + let delected_everything = pressed_escape || clicked_something_else; + + if delected_everything { + state.selection = None; + } + + if ctx.input(|i| i.pointer.any_released()) { + state.is_dragging = false; + } + + let text_to_copy = std::mem::take(&mut state.text_to_copy); + if !text_to_copy.is_empty() { + ctx.copy_text(text_to_copy); + } + + state.store(ctx); + } + + pub fn has_selection(&self) -> bool { + self.selection.is_some() + } + + pub fn clear_selection(&mut self) { + self.selection = None; + } + + fn copy_text(&mut self, galley_pos: Pos2, galley: &Galley, cursor_range: &CursorRange) { + let new_galley_rect = Rect::from_min_size(galley_pos, galley.size()); + let new_text = selected_text(galley, cursor_range); + if new_text.is_empty() { + return; + } + + if self.text_to_copy.is_empty() { + self.text_to_copy = new_text; + self.last_copied_galley_rect = Some(new_galley_rect); + return; + } + + let Some(last_copied_galley_rect) = self.last_copied_galley_rect else { + self.text_to_copy = new_text; + self.last_copied_galley_rect = Some(new_galley_rect); + return; + }; + + // We need to append or prepend the new text to the already copied text. + // We need to do so intelligently. + + if last_copied_galley_rect.bottom() <= new_galley_rect.top() { + self.text_to_copy.push('\n'); + let vertical_distance = new_galley_rect.top() - last_copied_galley_rect.bottom(); + if estimate_row_height(galley) * 0.5 < vertical_distance { + self.text_to_copy.push('\n'); + } + } else { + let existing_ends_with_space = + self.text_to_copy.chars().last().map(|c| c.is_whitespace()); + + let new_text_starts_with_space_or_punctuation = new_text + .chars() + .next() + .map_or(false, |c| c.is_whitespace() || c.is_ascii_punctuation()); + + if existing_ends_with_space == Some(false) && !new_text_starts_with_space_or_punctuation + { + self.text_to_copy.push(' '); + } + } + + self.text_to_copy.push_str(&new_text); + 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 should be called after painting the text, because this will also + /// paint the text cursor/selection on top. + pub fn label_text_selection(ui: &Ui, response: &Response, galley_pos: Pos2, galley: &Galley) { + let mut state = Self::load(ui.ctx()); + state.on_label(ui, response, galley_pos, galley); + state.store(ui.ctx()); + } + + fn cursor_for( + &mut self, + ui: &Ui, + response: &Response, + galley_pos: Pos2, + galley: &Galley, + ) -> TextCursorState { + let Some(selection) = &mut self.selection else { + // Nothing selected. + return TextCursorState::default(); + }; + + if selection.layer_id != response.layer_id { + // Selection is in another layer + return TextCursorState::default(); + } + + let multi_widget_text_select = ui.style().interaction.multi_widget_text_select; + + let may_select_widget = + multi_widget_text_select || selection.primary.widget_id == response.id; + + if self.is_dragging && may_select_widget { + if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { + let galley_rect = Rect::from_min_size(galley_pos, galley.size()); + let galley_rect = galley_rect.intersect(ui.clip_rect()); + + let is_in_same_column = galley_rect + .x_range() + .intersects(self.selection_bbox_last_frame.x_range()); + + let has_reached_primary = + self.has_reached_primary || response.id == selection.primary.widget_id; + let has_reached_secondary = + self.has_reached_secondary || response.id == selection.secondary.widget_id; + + let new_primary = if response.contains_pointer() { + // Dragging into this widget - easy case: + Some(galley.cursor_from_pos(pointer_pos - galley_pos)) + } else if is_in_same_column + && !self.has_reached_primary + && selection.primary.pos.y <= selection.secondary.pos.y + && pointer_pos.y <= galley_rect.top() + && galley_rect.top() <= selection.secondary.pos.y + { + // The user is dragging the text selection upwards, above the first selected widget (this one): + if DEBUG { + ui.ctx() + .debug_text(format!("Upwards drag; include {:?}", response.id)); + } + Some(galley.begin()) + } else if is_in_same_column + && has_reached_secondary + && has_reached_primary + && selection.secondary.pos.y <= selection.primary.pos.y + && selection.secondary.pos.y <= galley_rect.bottom() + && galley_rect.bottom() <= pointer_pos.y + { + // The user is dragging the text selection downwards, below this widget. + // We move the cursor to the end of this widget, + // (and we may do the same for the next widget too). + if DEBUG { + ui.ctx() + .debug_text(format!("Downwards drag; include {:?}", response.id)); + } + Some(galley.end()) + } else { + None + }; + + if let Some(new_primary) = new_primary { + selection.primary = + WidgetTextCursor::new(response.id, new_primary, galley_pos, galley); + + if response.drag_started() { + if selection.layer_id == response.layer_id { + if ui.input(|i| i.modifiers.shift) { + // A continuation of a previous selection? + } else { + // A new selection. + selection.secondary = selection.primary; + } + } else { + // A new selection. + selection.layer_id = response.layer_id; + selection.secondary = selection.primary; + } + } + } + } + } + + let has_primary = response.id == selection.primary.widget_id; + let has_secondary = response.id == selection.secondary.widget_id; + + if has_primary { + selection.primary.pos = pos_in_galley(galley_pos, galley, selection.primary.ccursor); + } + if has_secondary { + selection.secondary.pos = + pos_in_galley(galley_pos, galley, selection.secondary.ccursor); + } + + self.has_reached_primary |= has_primary; + self.has_reached_secondary |= has_secondary; + + let primary = has_primary.then_some(selection.primary.ccursor); + let secondary = has_secondary.then_some(selection.secondary.ccursor); + + // The following code assumes we will encounter both ends of the cursor + // at some point (but in any order). + // If we don't (e.g. because one endpoint is outside the visible scroll areas), + // we will have annoying failure cases. + + match (primary, secondary) { + (Some(primary), Some(secondary)) => { + // This is the only selected label. + TextCursorState::from(CCursorRange { primary, secondary }) + } + + (Some(primary), None) => { + // This labels contains only the primary cursor. + let secondary = if self.has_reached_secondary { + // Secondary was before primary. + // Select everything up to the cursor. + // We assume normal left-to-right and top-down layout order here. + galley.begin().ccursor + } else { + // Select everything from the cursor onward: + galley.end().ccursor + }; + TextCursorState::from(CCursorRange { primary, secondary }) + } + + (None, Some(secondary)) => { + // This labels contains only the secondary cursor + let primary = if self.has_reached_primary { + // Primary was before secondary. + // Select everything up to the cursor. + // We assume normal left-to-right and top-down layout order here. + galley.begin().ccursor + } else { + // Select everything from the cursor onward: + galley.end().ccursor + }; + TextCursorState::from(CCursorRange { primary, secondary }) + } + + (None, None) => { + // This widget has neither the primary or secondary cursor. + let is_in_middle = self.has_reached_primary != self.has_reached_secondary; + if is_in_middle { + if DEBUG { + response.ctx.debug_text(format!( + "widget in middle: {:?}, between {:?} and {:?}", + response.id, selection.primary.widget_id, selection.secondary.widget_id, + )); + } + // …but it is between the two selection endpoints, and so is fully selected. + TextCursorState::from(CCursorRange::two(galley.begin(), galley.end())) + } else { + // Outside the selected range + TextCursorState::default() + } + } + } + } + + fn on_label(&mut self, ui: &Ui, response: &Response, galley_pos: Pos2, galley: &Galley) { + let widget_id = response.id; + + if response.hovered { + ui.ctx().set_cursor_icon(CursorIcon::Text); + } + + self.any_hovered |= response.hovered(); + self.is_dragging |= response.dragged(); + + let old_selection = self.selection; + + let mut cursor_state = self.cursor_for(ui, response, galley_pos, galley); + + let old_range = cursor_state.range(galley); + + if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { + if response.contains_pointer() { + let cursor_at_pointer = galley.cursor_from_pos(pointer_pos - galley_pos); + + // This is where we handle start-of-drag and double-click-to-select. + // Actual drag-to-select happens elsewhere. + let dragged = false; + cursor_state.pointer_interaction(ui, response, cursor_at_pointer, galley, dragged); + } + } + + if let Some(mut cursor_range) = cursor_state.range(galley) { + let galley_rect = Rect::from_min_size(galley_pos, galley.size()); + self.selection_bbox_this_frame = self.selection_bbox_this_frame.union(galley_rect); + + if let Some(selection) = &self.selection { + if selection.primary.widget_id == response.id { + process_selection_key_events(ui.ctx(), galley, response.id, &mut cursor_range); + } + } + + if got_copy_event(ui.ctx()) { + self.copy_text(galley_pos, galley, &cursor_range); + } + + cursor_state.set_range(Some(cursor_range)); + } + + // Look for changes due to keyboard and/or mouse interaction: + let new_range = cursor_state.range(galley); + let selection_changed = old_range != new_range; + + if let (true, Some(range)) = (selection_changed, new_range) { + // -------------- + // Store results: + + if let Some(selection) = &mut self.selection { + let primary_changed = Some(range.primary) != old_range.map(|r| r.primary); + let secondary_changed = Some(range.secondary) != old_range.map(|r| r.secondary); + + selection.layer_id = response.layer_id; + + if primary_changed || !ui.style().interaction.multi_widget_text_select { + selection.primary = + WidgetTextCursor::new(widget_id, range.primary, galley_pos, galley); + self.has_reached_primary = true; + } + if secondary_changed || !ui.style().interaction.multi_widget_text_select { + selection.secondary = + WidgetTextCursor::new(widget_id, range.secondary, galley_pos, galley); + self.has_reached_secondary = true; + } + } else { + // Start of a new selection + self.selection = Some(CurrentSelection { + layer_id: response.layer_id, + primary: WidgetTextCursor::new(widget_id, range.primary, galley_pos, galley), + secondary: WidgetTextCursor::new( + widget_id, + range.secondary, + galley_pos, + galley, + ), + }); + self.has_reached_primary = true; + self.has_reached_secondary = true; + } + } + + // Scroll containing ScrollArea on cursor change: + if let Some(range) = new_range { + let old_primary = old_selection.map(|s| s.primary); + let new_primary = self.selection.as_ref().map(|s| s.primary); + if let Some(new_primary) = new_primary { + let primary_changed = old_primary.map_or(true, |old| { + old.widget_id != new_primary.widget_id || old.ccursor != new_primary.ccursor + }); + if primary_changed && new_primary.widget_id == widget_id { + let is_fully_visible = ui.clip_rect().contains_rect(response.rect); // TODO: remove this HACK workaround for https://github.com/emilk/egui/issues/1531 + if selection_changed && !is_fully_visible { + // Scroll to keep primary cursor in view: + let row_height = estimate_row_height(galley); + let primary_cursor_rect = + cursor_rect(galley_pos, galley, &range.primary, row_height); + ui.scroll_to_rect(primary_cursor_rect, None); + } + } + } + } + + paint_selection( + ui, + response, + galley_pos, + galley, + &cursor_state, + &mut self.painted_shape_idx, + ); + } } +fn got_copy_event(ctx: &Context) -> bool { + ctx.input(|i| { + i.events + .iter() + .any(|e| matches!(e, Event::Copy | Event::Cut)) + }) +} + +/// Returns true if the cursor changed fn process_selection_key_events( ctx: &Context, galley: &Galley, widget_id: Id, cursor_range: &mut CursorRange, -) { - let mut copy_text = None; +) -> bool { let os = ctx.os(); + let mut changed = false; + ctx.input(|i| { // NOTE: we have a lock on ui/ctx here, // so be careful to not call into `ui` or `ctx` again. - for event in &i.events { - match event { - Event::Copy | Event::Cut => { - // This logic means we can select everything in an ellided label (including the `…`) - // and still copy the entire un-ellided text! - let everything_is_selected = - cursor_range.contains(&CursorRange::select_all(galley)); - - let copy_everything = cursor_range.is_empty() || everything_is_selected; - - if copy_everything { - copy_text = Some(galley.text().to_owned()); - } else { - copy_text = Some(cursor_range.slice_str(galley).to_owned()); - } - } - - event => { - cursor_range.on_event(os, event, galley, widget_id); - } - } + changed |= cursor_range.on_event(os, event, galley, widget_id); } }); - if let Some(copy_text) = copy_text { - ctx.copy_text(copy_text); + changed +} + +fn selected_text(galley: &Galley, cursor_range: &CursorRange) -> String { + // This logic means we can select everything in an ellided label (including the `…`) + // and still copy the entire un-ellided text! + let everything_is_selected = cursor_range.contains(&CursorRange::select_all(galley)); + + let copy_everything = cursor_range.is_empty() || everything_is_selected; + + if copy_everything { + galley.text().to_owned() + } else { + cursor_range.slice_str(galley).to_owned() } } diff --git a/crates/egui/src/text_selection/mod.rs b/crates/egui/src/text_selection/mod.rs index e9796e220..5be95eb53 100644 --- a/crates/egui/src/text_selection/mod.rs +++ b/crates/egui/src/text_selection/mod.rs @@ -9,5 +9,5 @@ pub mod text_cursor_state; pub mod visuals; pub use cursor_range::{CCursorRange, CursorRange, PCursorRange}; -pub use label_text_selection::label_text_selection; +pub use label_text_selection::LabelSelectionState; pub use text_cursor_state::TextCursorState; diff --git a/crates/egui/src/text_selection/text_cursor_state.rs b/crates/egui/src/text_selection/text_cursor_state.rs index d8adfb833..7aca96f8b 100644 --- a/crates/egui/src/text_selection/text_cursor_state.rs +++ b/crates/egui/src/text_selection/text_cursor_state.rs @@ -99,6 +99,7 @@ impl TextCursorState { response: &Response, cursor_at_pointer: Cursor, galley: &Galley, + is_being_dragged: bool, ) -> bool { let text = galley.text(); @@ -120,6 +121,7 @@ impl TextCursorState { true } else if response.sense.drag { if response.hovered() && ui.input(|i| i.pointer.any_pressed()) { + // The start of a drag (or a click). if ui.input(|i| i.modifiers.shift) { if let Some(mut cursor_range) = self.range(galley) { cursor_range.primary = cursor_at_pointer; @@ -131,8 +133,8 @@ impl TextCursorState { self.set_range(Some(CursorRange::one(cursor_at_pointer))); } true - } else if ui.input(|i| i.pointer.any_down()) && response.is_pointer_button_down_on() { - // drag to select text: + } else if is_being_dragged { + // Drag to select text: if let Some(mut cursor_range) = self.range(galley) { cursor_range.primary = cursor_at_pointer; self.set_range(Some(cursor_range)); diff --git a/crates/egui/src/text_selection/visuals.rs b/crates/egui/src/text_selection/visuals.rs index a8aba9144..ca85e59ef 100644 --- a/crates/egui/src/text_selection/visuals.rs +++ b/crates/egui/src/text_selection/visuals.rs @@ -1,5 +1,7 @@ use crate::*; +use self::layers::ShapeIdx; + use super::CursorRange; pub fn paint_text_selection( @@ -8,6 +10,7 @@ pub fn paint_text_selection( galley_pos: Pos2, galley: &Galley, cursor_range: &CursorRange, + mut out_shaped_idx: Option<&mut Vec>, ) { if cursor_range.is_empty() { return; @@ -40,7 +43,10 @@ pub fn paint_text_selection( galley_pos + vec2(left, row.min_y()), galley_pos + vec2(right, row.max_y()), ); - painter.rect_filled(rect, 0.0, color); + let shape_idx = painter.rect_filled(rect, 0.0, color); + if let Some(out_shaped_idx) = &mut out_shaped_idx { + out_shaped_idx.push(shape_idx); + } } } diff --git a/crates/egui/src/widgets/hyperlink.rs b/crates/egui/src/widgets/hyperlink.rs index ce8089ba6..f4a508e64 100644 --- a/crates/egui/src/widgets/hyperlink.rs +++ b/crates/egui/src/widgets/hyperlink.rs @@ -1,5 +1,7 @@ use crate::*; +use self::text_selection::LabelSelectionState; + /// Clickable text, that looks like a hyperlink. /// /// To link to a web page, use [`Hyperlink`], [`Ui::hyperlink`] or [`Ui::hyperlink_to`]. @@ -53,7 +55,7 @@ impl Widget for Link { let selectable = ui.style().interaction.selectable_labels; if selectable { - crate::text_selection::label_text_selection(ui, &response, galley_pos, &galley); + LabelSelectionState::label_text_selection(ui, &response, galley_pos, &galley); } if response.hovered() { diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index b780c40b9..b68fb4337 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use crate::*; +use self::text_selection::LabelSelectionState; + /// Static text. /// /// Usually it is more convenient to use [`Ui::label`]. @@ -257,7 +259,7 @@ impl Widget for Label { let selectable = selectable.unwrap_or_else(|| ui.style().interaction.selectable_labels); if selectable { - crate::text_selection::label_text_selection(ui, &response, galley_pos, &galley); + LabelSelectionState::label_text_selection(ui, &response, galley_pos, &galley); } } diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index eae8a6e41..9ee17bace 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -549,10 +549,14 @@ impl<'t> TextEdit<'t> { paint_cursor(&painter, ui.visuals(), cursor_rect); } - let did_interact = - state - .cursor - .pointer_interaction(ui, &response, cursor_at_pointer, &galley); + let is_being_dragged = ui.ctx().memory(|m| m.is_being_dragged(response.id)); + let did_interact = state.cursor.pointer_interaction( + ui, + &response, + cursor_at_pointer, + &galley, + is_being_dragged, + ); if did_interact { ui.memory_mut(|mem| mem.request_focus(response.id)); @@ -661,6 +665,7 @@ impl<'t> TextEdit<'t> { galley_pos, &galley, &cursor_range, + None, ); let primary_cursor_rect = diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index 42e768f8e..5f4cd001b 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -32,6 +32,7 @@ impl Default for Demos { Box::::default(), Box::::default(), Box::::default(), + Box::::default(), Box::::default(), Box::::default(), Box::::default(), @@ -42,7 +43,6 @@ impl Default for Demos { Box::::default(), Box::::default(), Box::::default(), - Box::::default(), ]) } } diff --git a/crates/egui_demo_lib/src/demo/mod.rs b/crates/egui_demo_lib/src/demo/mod.rs index 0ee0eadfd..f041b00db 100644 --- a/crates/egui_demo_lib/src/demo/mod.rs +++ b/crates/egui_demo_lib/src/demo/mod.rs @@ -19,6 +19,7 @@ pub mod misc_demo_window; pub mod multi_touch; pub mod paint_bezier; pub mod painting; +pub mod panels; pub mod password; pub mod plot_demo; pub mod scrolling; @@ -31,7 +32,6 @@ pub mod text_layout; pub mod toggle_switch; pub mod widget_gallery; pub mod window_options; -pub mod window_with_panels; pub use { about::About, demo_app_windows::DemoWindows, misc_demo_window::MiscDemoWindow, diff --git a/crates/egui_demo_lib/src/demo/window_with_panels.rs b/crates/egui_demo_lib/src/demo/panels.rs similarity index 93% rename from crates/egui_demo_lib/src/demo/window_with_panels.rs rename to crates/egui_demo_lib/src/demo/panels.rs index 4334d04c2..6c86f9820 100644 --- a/crates/egui_demo_lib/src/demo/window_with_panels.rs +++ b/crates/egui_demo_lib/src/demo/panels.rs @@ -1,15 +1,15 @@ #[derive(Clone, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct WindowWithPanels {} +pub struct Panels {} -impl super::Demo for WindowWithPanels { +impl super::Demo for Panels { fn name(&self) -> &'static str { - "🗖 Window With Panels" + "🗖 Panels" } fn show(&mut self, ctx: &egui::Context, open: &mut bool) { use super::View as _; - let window = egui::Window::new("Window with Panels") + let window = egui::Window::new("Panels") .default_width(600.0) .default_height(400.0) .vscroll(false) @@ -18,7 +18,7 @@ impl super::Demo for WindowWithPanels { } } -impl super::View for WindowWithPanels { +impl super::View for Panels { fn ui(&mut self, ui: &mut egui::Ui) { // Note that the order we add the panels is very important! diff --git a/crates/emath/src/range.rs b/crates/emath/src/range.rs index 861a3e394..ffd34dc20 100644 --- a/crates/emath/src/range.rs +++ b/crates/emath/src/range.rs @@ -125,6 +125,21 @@ impl Rangef { max: self.max.min(other.max), } } + + /// Do the two ranges intersect? + /// + /// ``` + /// # use emath::Rangef; + /// assert!(Rangef::new(0.0, 10.0).intersects(Rangef::new(5.0, 15.0))); + /// assert!(Rangef::new(0.0, 10.0).intersects(Rangef::new(5.0, 6.0))); + /// assert!(Rangef::new(0.0, 10.0).intersects(Rangef::new(10.0, 20.0))); + /// assert!(!Rangef::new(0.0, 10.0).intersects(Rangef::new(20.0, 30.0))); + /// ``` + #[inline] + #[must_use] + pub fn intersects(self, other: Self) -> bool { + other.min <= self.max && self.min <= other.max + } } impl From for RangeInclusive { diff --git a/crates/epaint/src/text/cursor.rs b/crates/epaint/src/text/cursor.rs index a994aa49a..2158695ec 100644 --- a/crates/epaint/src/text/cursor.rs +++ b/crates/epaint/src/text/cursor.rs @@ -17,6 +17,7 @@ pub struct CCursor { } impl CCursor { + #[inline] pub fn new(index: usize) -> Self { Self { index, @@ -25,9 +26,17 @@ impl CCursor { } } +impl From for CCursor { + #[inline] + fn from(c: Cursor) -> Self { + c.ccursor + } +} + /// Two `CCursor`s are considered equal if they refer to the same character boundary, /// even if one prefers the start of the next row. impl PartialEq for CCursor { + #[inline] fn eq(&self, other: &Self) -> bool { self.index == other.index } @@ -106,6 +115,7 @@ pub struct PCursor { /// Two `PCursor`s are considered equal if they refer to the same character boundary, /// even if one prefers the start of the next row. impl PartialEq for PCursor { + #[inline] fn eq(&self, other: &Self) -> bool { self.paragraph == other.paragraph && self.offset == other.offset } diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 4e44a0641..b6fab06b4 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -426,6 +426,9 @@ impl TextWrapping { /// from `egui::InputState` and can change at any time. /// - The atlas has become full. This can happen any time a new glyph is added /// to the atlas, which in turn can happen any time new text is laid out. +/// +/// The name comes from typography, where a "galley" is a metal tray +/// containing a column of set type, usually the size of a page of text. #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Galley { @@ -672,6 +675,11 @@ impl Galley { } } + /// Returns a 0-width Rect. + pub fn pos_from_cursor(&self, cursor: &Cursor) -> Rect { + self.pos_from_pcursor(cursor.pcursor) // pcursor is what TextEdit stores + } + /// Returns a 0-width Rect. pub fn pos_from_pcursor(&self, pcursor: PCursor) -> Rect { let mut it = PCursor::default(); @@ -708,8 +716,13 @@ impl Galley { } /// Returns a 0-width Rect. - pub fn pos_from_cursor(&self, cursor: &Cursor) -> Rect { - self.pos_from_pcursor(cursor.pcursor) // pcursor is what TextEdit stores + pub fn pos_from_ccursor(&self, ccursor: CCursor) -> Rect { + self.pos_from_cursor(&self.from_ccursor(ccursor)) + } + + /// Returns a 0-width Rect. + pub fn pos_from_rcursor(&self, rcursor: RCursor) -> Rect { + self.pos_from_cursor(&self.from_rcursor(rcursor)) } /// Cursor at the given position within the galley.