Skip to content

Commit

Permalink
Nicer looking text selection, especially in light mode (emilk#5017)
Browse files Browse the repository at this point in the history
* Closes emilk#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
<img width="198" alt="Screenshot 2024-08-27 at 18 58 35"
src="https://github.com/user-attachments/assets/bd342946-299c-44ab-bc2d-2aa8ddbca8eb">
<img width="187" alt="Screenshot 2024-08-27 at 18 59 26"
src="https://github.com/user-attachments/assets/352bed32-5150-49b9-a9f9-c7679a0d30b2">


### What selections used to look like
<img width="143" alt="Screenshot 2024-08-27 at 19 03 08"
src="https://github.com/user-attachments/assets/f3cbd798-cfed-4ad4-aa3a-d7480efcfa3c">
<img width="143" alt="Screenshot 2024-08-27 at 19 03 23"
src="https://github.com/user-attachments/assets/9925d18d-da82-4a44-8a98-ea6857ecc14f">


### New selection of some text with a background
<img width="134" alt="Screenshot 2024-08-27 at 18 59 12"
src="https://github.com/user-attachments/assets/1d291d7f-efbd-4efd-b6d2-cd63c9fc4fa4">
  • Loading branch information
emilk authored and 486c committed Oct 9, 2024
1 parent 3de70ff commit bd30378
Show file tree
Hide file tree
Showing 8 changed files with 181 additions and 91 deletions.
5 changes: 5 additions & 0 deletions crates/egui/src/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
130 changes: 80 additions & 50 deletions crates/egui/src/text_selection/label_text_selection.rs
Original file line number Diff line number Diff line change
@@ -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<ShapeIdx>,
) {
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 {
Expand Down Expand Up @@ -124,7 +94,9 @@ pub struct LabelSelectionState {
last_copied_galley_rect: Option<Rect>,

/// Painted selections this frame.
painted_shape_idx: Vec<ShapeIdx>,
///
/// 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<RowVertexIndices>)>,
}

impl Default for LabelSelectionState {
Expand All @@ -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(),
}
}
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
}
}
}
}
}
});
}
}
});
Expand Down Expand Up @@ -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<Galley>,
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());
}

Expand Down Expand Up @@ -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<Galley>,
) -> Vec<RowVertexIndices> {
let widget_id = response.id;

if response.hovered {
Expand Down Expand Up @@ -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
}
}

Expand Down
70 changes: 54 additions & 16 deletions crates/egui/src/text_selection/visuals.rs
Original file line number Diff line number Diff line change
@@ -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<Galley>,
visuals: &Visuals,
galley_pos: Pos2,
galley: &Galley,
cursor_range: &CursorRange,
mut out_shaped_idx: Option<&mut Vec<ShapeIdx>>,
mut new_vertex_indices: Option<&mut Vec<RowVertexIndices>>,
) {
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 {
Expand All @@ -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,
});
}
}
}
Expand Down
12 changes: 7 additions & 5 deletions crates/egui/src/widgets/hyperlink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
19 changes: 13 additions & 6 deletions crates/egui/src/widgets/label.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}
}

Expand Down
Loading

0 comments on commit bd30378

Please sign in to comment.