From bd30378737cfbe642a657be6cc8a5d000cd14231 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 27 Aug 2024 19:09:44 +0200 Subject: [PATCH] Nicer looking text selection, especially in light mode (#5017) * Closes https://github.com/emilk/egui/issues/4727 This changes the text selection painting from being painted on top of the text, to being painted behind the text, but in front of any text background. The result is much nicer looking text selection, especially in light mode: ### The new selections Screenshot 2024-08-27 at 18 58 35 Screenshot 2024-08-27 at 18 59 26 ### What selections used to look like Screenshot 2024-08-27 at 19 03 08 Screenshot 2024-08-27 at 19 03 23 ### New selection of some text with a background Screenshot 2024-08-27 at 18 59 12 --- crates/egui/src/layers.rs | 5 + .../text_selection/label_text_selection.rs | 130 +++++++++++------- crates/egui/src/text_selection/visuals.rs | 70 +++++++--- crates/egui/src/widgets/hyperlink.rs | 12 +- crates/egui/src/widgets/label.rs | 19 ++- crates/egui/src/widgets/text_edit/builder.rs | 27 ++-- crates/epaint/src/text/text_layout.rs | 2 + crates/epaint/src/text/text_layout_types.rs | 7 + 8 files changed, 181 insertions(+), 91 deletions(-) diff --git a/crates/egui/src/layers.rs b/crates/egui/src/layers.rs index 768099e23d6..6d5490082de 100644 --- a/crates/egui/src/layers.rs +++ b/crates/egui/src/layers.rs @@ -158,6 +158,11 @@ impl PaintList { self.0[idx.0].shape = Shape::Noop; } + /// Mutate the shape at the given index, if any. + pub fn mutate_shape(&mut self, idx: ShapeIdx, f: impl FnOnce(&mut ClippedShape)) { + self.0.get_mut(idx.0).map(f); + } + /// Transform each [`Shape`] and clip rectangle by this much, in-place pub fn transform(&mut self, transform: TSTransform) { for ClippedShape { clip_rect, shape } in &mut self.0 { diff --git a/crates/egui/src/text_selection/label_text_selection.rs b/crates/egui/src/text_selection/label_text_selection.rs index c80b72023d9..a2d78da5dea 100644 --- a/crates/egui/src/text_selection/label_text_selection.rs +++ b/crates/egui/src/text_selection/label_text_selection.rs @@ -1,49 +1,19 @@ +use std::sync::Arc; + 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, + text_cursor_state::cursor_rect, + visuals::{paint_text_selection, RowVertexIndices}, + CursorRange, TextCursorState, }; /// Turn on to help debug this const DEBUG: bool = false; // Don't merge `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 { - // We paint the cursor on top of the text, in case - // the text galley has backgrounds (as e.g. `code` snippets in markup do). - paint_text_selection( - ui.painter(), - ui.visuals(), - galley_pos, - galley, - &cursor_range, - Some(painted_shape_idx), - ); - } - - #[cfg(feature = "accesskit")] - super::accesskit_text::update_accesskit_for_text_widget( - ui.ctx(), - _response.id, - cursor_range, - accesskit::Role::Label, - galley_pos, - galley, - ); -} - /// One end of a text selection, inside any widget. #[derive(Clone, Copy)] struct WidgetTextCursor { @@ -124,7 +94,9 @@ pub struct LabelSelectionState { last_copied_galley_rect: Option, /// Painted selections this frame. - painted_shape_idx: Vec, + /// + /// Kept so we can undo a bad selection visualization if we don't see both ends of the selection this frame. + painted_selections: Vec<(ShapeIdx, Vec)>, } impl Default for LabelSelectionState { @@ -139,7 +111,7 @@ impl Default for LabelSelectionState { has_reached_secondary: Default::default(), text_to_copy: Default::default(), last_copied_galley_rect: Default::default(), - painted_shape_idx: Default::default(), + painted_selections: Default::default(), } } } @@ -182,7 +154,7 @@ impl LabelSelectionState { state.has_reached_secondary = false; state.text_to_copy.clear(); state.last_copied_galley_rect = None; - state.painted_shape_idx.clear(); + state.painted_selections.clear(); state.store(ctx); } @@ -205,8 +177,26 @@ impl LabelSelectionState { // 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); + for (shape_idx, row_selections) in state.painted_selections.drain(..) { + list.mutate_shape(shape_idx, |shape| { + if let epaint::Shape::Text(text_shape) = &mut shape.shape { + let galley = Arc::make_mut(&mut text_shape.galley); + for row_selection in row_selections { + if let Some(row) = galley.rows.get_mut(row_selection.row) { + for vertex_index in row_selection.vertex_indices { + if let Some(vertex) = row + .visuals + .mesh + .vertices + .get_mut(vertex_index as usize) + { + vertex.color = epaint::Color32::TRANSPARENT; + } + } + } + } + } + }); } } }); @@ -292,11 +282,28 @@ impl LabelSelectionState { /// /// 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) { + /// This also takes care of painting the galley. + pub fn label_text_selection( + ui: &Ui, + response: &Response, + galley_pos: Pos2, + mut galley: Arc, + fallback_color: epaint::Color32, + underline: epaint::Stroke, + ) { let mut state = Self::load(ui.ctx()); - state.on_label(ui, response, galley_pos, galley); + 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)); + } + state.store(ui.ctx()); } @@ -470,7 +477,14 @@ impl LabelSelectionState { } } - fn on_label(&mut self, ui: &Ui, response: &Response, galley_pos: Pos2, galley: &Galley) { + /// Returns the painted selections, if any. + fn on_label( + &mut self, + ui: &Ui, + response: &Response, + galley_pos: Pos2, + galley: &mut Arc, + ) -> Vec { let widget_id = response.id; if response.hovered { @@ -576,14 +590,30 @@ impl LabelSelectionState { } } - paint_selection( - ui, - response, + let cursor_range = cursor_state.range(galley); + + let mut new_vertex_indices = vec![]; + + if let Some(cursor_range) = cursor_range { + paint_text_selection( + galley, + ui.visuals(), + &cursor_range, + Some(&mut new_vertex_indices), + ); + } + + #[cfg(feature = "accesskit")] + super::accesskit_text::update_accesskit_for_text_widget( + ui.ctx(), + response.id, + cursor_range, + accesskit::Role::Label, galley_pos, galley, - &cursor_state, - &mut self.painted_shape_idx, ); + + new_vertex_indices } } diff --git a/crates/egui/src/text_selection/visuals.rs b/crates/egui/src/text_selection/visuals.rs index 252f727a650..499d501e052 100644 --- a/crates/egui/src/text_selection/visuals.rs +++ b/crates/egui/src/text_selection/visuals.rs @@ -1,29 +1,37 @@ -use crate::*; +use std::sync::Arc; -use self::layers::ShapeIdx; +use crate::*; use super::CursorRange; +#[derive(Clone, Debug)] +pub struct RowVertexIndices { + pub row: usize, + pub vertex_indices: [u32; 6], +} + +/// Adds text selection rectangles to the galley. pub fn paint_text_selection( - painter: &Painter, + galley: &mut Arc, visuals: &Visuals, - galley_pos: Pos2, - galley: &Galley, cursor_range: &CursorRange, - mut out_shaped_idx: Option<&mut Vec>, + mut new_vertex_indices: Option<&mut Vec>, ) { if cursor_range.is_empty() { return; } - // We paint the cursor selection on top of the text, so make it transparent: - let color = visuals.selection.bg_fill.linear_multiply(0.5); + // We need to modify the galley (add text selection painting to it), + // and so we need to clone it if it is shared: + let galley: &mut Galley = Arc::make_mut(galley); + + let color = visuals.selection.bg_fill; let [min, max] = cursor_range.sorted_cursors(); let min = min.rcursor; let max = max.rcursor; for ri in min.row..=max.row { - let row = &galley.rows[ri]; + let row = &mut galley.rows[ri]; let left = if ri == min.row { row.x_offset(min.column) } else { @@ -39,13 +47,43 @@ pub fn paint_text_selection( }; row.rect.right() + newline_size }; - let rect = Rect::from_min_max( - galley_pos + vec2(left, row.min_y()), - galley_pos + vec2(right, row.max_y()), - ); - 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); + + let rect = Rect::from_min_max(pos2(left, row.min_y()), pos2(right, row.max_y())); + let mesh = &mut row.visuals.mesh; + + // Time to insert the selection rectangle into the row mesh. + // It should be on top (after) of any background in the galley, + // but behind (before) any glyphs. The row visuals has this information: + let glyph_index_start = row.visuals.glyph_index_start; + + // Start by appending the selection rectangle to end of the mesh, as two triangles (= 6 indices): + let num_indices_before = mesh.indices.len(); + mesh.add_colored_rect(rect, color); + assert_eq!(num_indices_before + 6, mesh.indices.len()); + + // Copy out the new triangles: + let selection_triangles = [ + mesh.indices[num_indices_before], + mesh.indices[num_indices_before + 1], + mesh.indices[num_indices_before + 2], + mesh.indices[num_indices_before + 3], + mesh.indices[num_indices_before + 4], + mesh.indices[num_indices_before + 5], + ]; + + // Move every old triangle forwards by 6 indices to make room for the new triangle: + for i in (glyph_index_start..num_indices_before).rev() { + mesh.indices.swap(i, i + 6); + } + // Put the new triangle in place: + mesh.indices[glyph_index_start..glyph_index_start + 6] + .clone_from_slice(&selection_triangles); + + if let Some(new_vertex_indices) = &mut new_vertex_indices { + new_vertex_indices.push(RowVertexIndices { + row: ri, + vertex_indices: selection_triangles, + }); } } } diff --git a/crates/egui/src/widgets/hyperlink.rs b/crates/egui/src/widgets/hyperlink.rs index 038cc761fe1..59c84227707 100644 --- a/crates/egui/src/widgets/hyperlink.rs +++ b/crates/egui/src/widgets/hyperlink.rs @@ -50,13 +50,15 @@ impl Widget for Link { Stroke::NONE }; - ui.painter().add( - epaint::TextShape::new(galley_pos, galley.clone(), color).with_underline(underline), - ); - let selectable = ui.style().interaction.selectable_labels; if selectable { - LabelSelectionState::label_text_selection(ui, &response, galley_pos, &galley); + LabelSelectionState::label_text_selection( + ui, &response, galley_pos, galley, color, underline, + ); + } else { + ui.painter().add( + epaint::TextShape::new(galley_pos, galley, color).with_underline(underline), + ); } if response.hovered() { diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index 55e20136379..e8d146388d9 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -267,14 +267,21 @@ impl Widget for Label { Stroke::NONE }; - ui.painter().add( - epaint::TextShape::new(galley_pos, galley.clone(), response_color) - .with_underline(underline), - ); - let selectable = selectable.unwrap_or_else(|| ui.style().interaction.selectable_labels); if selectable { - LabelSelectionState::label_text_selection(ui, &response, galley_pos, &galley); + LabelSelectionState::label_text_selection( + ui, + &response, + galley_pos, + galley, + response_color, + underline, + ); + } else { + ui.painter().add( + epaint::TextShape::new(galley_pos, galley, response_color) + .with_underline(underline), + ); } } diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 2a6eb14c6af..8763bd1a3ae 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -662,8 +662,6 @@ impl<'t> TextEdit<'t> { }; if ui.is_rect_visible(rect) { - painter.galley(galley_pos, galley.clone(), text_color); - if text.as_str().is_empty() && !hint_text.is_empty() { let hint_text_color = ui.visuals().weak_text_color(); let hint_text_font_id = hint_text_font.unwrap_or(font_id.into()); @@ -689,19 +687,19 @@ impl<'t> TextEdit<'t> { painter.galley(galley_pos, galley, hint_text_color); } - if ui.memory(|mem| mem.has_focus(id)) { + let has_focus = ui.memory(|mem| mem.has_focus(id)); + + if has_focus { if let Some(cursor_range) = state.cursor.range(&galley) { - // We paint the cursor on top of the text, in case - // the text galley has backgrounds (as e.g. `code` snippets in markup do). - paint_text_selection( - &painter, - ui.visuals(), - galley_pos, - &galley, - &cursor_range, - None, - ); + // Add text selection rectangles to the galley: + paint_text_selection(&mut galley, ui.visuals(), &cursor_range, None); + } + } + + painter.galley(galley_pos, galley.clone(), text_color); + if has_focus { + if let Some(cursor_range) = state.cursor.range(&galley) { let primary_cursor_rect = cursor_rect(galley_pos, &galley, &cursor_range.primary, row_height); @@ -721,7 +719,8 @@ impl<'t> TextEdit<'t> { // This is for two reasons: // * Don't give the impression that the user can type into a window without focus // * Don't repaint the ui because of a blinking cursor in an app that is not in focus - if ui.ctx().input(|i| i.focused) { + let viewport_has_focus = ui.ctx().input(|i| i.focused); + if viewport_has_focus { text_selection::visuals::paint_text_cursor( ui, &painter, diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 7f1cd865710..87be7cd0076 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -703,6 +703,7 @@ fn tessellate_row( add_row_backgrounds(job, row, &mut mesh); } + let glyph_index_start = mesh.indices.len(); let glyph_vertex_start = mesh.vertices.len(); tessellate_glyphs(point_scale, job, row, &mut mesh); let glyph_vertex_end = mesh.vertices.len(); @@ -730,6 +731,7 @@ fn tessellate_row( RowVisuals { mesh, mesh_bounds, + glyph_index_start, glyph_vertex_range: glyph_vertex_start..glyph_vertex_end, } } diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 7011e0c7f1a..108a2c2fb1a 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -554,6 +554,12 @@ pub struct RowVisuals { /// Does NOT include leading or trailing whitespace glyphs!! pub mesh_bounds: Rect, + /// The number of triangle indices added before the first glyph triangle. + /// + /// This can be used to insert more triangles after the background but before the glyphs, + /// i.e. for text selection visualization. + pub glyph_index_start: usize, + /// The range of vertices in the mesh that contain glyphs (as opposed to background, underlines, strikethorugh, etc). /// /// The glyph vertices comes after backgrounds (if any), but before any underlines and strikethrough. @@ -565,6 +571,7 @@ impl Default for RowVisuals { Self { mesh: Default::default(), mesh_bounds: Rect::NOTHING, + glyph_index_start: 0, glyph_vertex_range: 0..0, } }