Skip to content

Commit

Permalink
Refactor: move text selection logic to own module (#3843)
Browse files Browse the repository at this point in the history
This is a follow-up to #3804 and a
pre-requisite for #3816
  • Loading branch information
emilk authored Jan 19, 2024
1 parent 3936418 commit f034f6d
Show file tree
Hide file tree
Showing 15 changed files with 497 additions and 480 deletions.
3 changes: 2 additions & 1 deletion crates/egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ pub(crate) mod placer;
mod response;
mod sense;
pub mod style;
pub mod text_selection;
mod ui;
pub mod util;
pub mod viewport;
Expand Down Expand Up @@ -398,7 +399,7 @@ pub use epaint::{
};

pub mod text {
pub use crate::text_edit::CCursorRange;
pub use crate::text_selection::{CCursorRange, CursorRange};
pub use epaint::text::{
cursor::CCursor, FontData, FontDefinitions, FontFamily, Fonts, Galley, LayoutJob,
LayoutSection, TextFormat, TextWrapping, TAB_SIZE,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{Context, Galley, Id, Pos2};

use super::{cursor_interaction::is_word_char, CursorRange};
use super::{text_cursor_state::is_word_char, CursorRange};

/// Update accesskit with the current text state.
pub fn update_accesskit_for_text_widget(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use epaint::{text::cursor::*, Galley};

use crate::{os::OperatingSystem, Event, Id, Key, Modifiers};

use super::cursor_interaction::{ccursor_next_word, ccursor_previous_word, slice_char_range};
use super::text_cursor_state::{ccursor_next_word, ccursor_previous_word, slice_char_range};

/// A selected text range (could be a range of length zero).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
Expand Down
159 changes: 159 additions & 0 deletions crates/egui/src/text_selection/label_text_selection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
use epaint::{Galley, Pos2};

use crate::{Context, CursorIcon, Event, Id, 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));
}

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,
);

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,
cursor_range,
accesskit::Role::StaticText,
galley_pos,
galley,
);

if !cursor_state.is_empty() {
LabelSelectionState::store(ui.ctx(), response.id, cursor_state);
}
}

/// 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<Id>,

/// The current selection, if any.
selection: TextCursorState,
}

impl LabelSelectionState {
/// Load the range of text of text that is selected for the given widget.
fn load(ctx: &Context, id: Id) -> TextCursorState {
ctx.data(|data| data.get_temp::<Self>(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) {
ctx.data_mut(|data| {
data.insert_temp(
Id::NULL,
Self {
id: Some(id),
selection,
},
);
});
}
}

fn process_selection_key_events(
ctx: &Context,
galley: &Galley,
widget_id: Id,
cursor_range: &mut CursorRange,
) {
let mut copy_text = None;

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(ctx.os(), event, galley, widget_id);
}
}
}
});

if let Some(copy_text) = copy_text {
ctx.copy_text(copy_text);
}
}

fn estimate_row_height(galley: &Galley) -> f32 {
if let Some(row) = galley.rows.first() {
row.rect.height()
} else {
galley.size().y
}
}
13 changes: 13 additions & 0 deletions crates/egui/src/text_selection/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! Helpers regarding text selection for labels and text edit.

#[cfg(feature = "accesskit")]
pub mod accesskit_text;

mod cursor_range;
mod label_text_selection;
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 text_cursor_state::TextCursorState;
Original file line number Diff line number Diff line change
@@ -1,12 +1,73 @@
//! Text cursor changes/interaction, without modifying the text.

use epaint::text::{cursor::*, Galley};
use text_edit::state::TextCursorState;

use crate::*;

use super::{CCursorRange, CursorRange};

/// The state of a text cursor selection.
///
/// Used for [`crate::TextEdit`] and [`crate::Label`].
#[derive(Clone, Copy, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct TextCursorState {
cursor_range: Option<CursorRange>,

/// This is what is easiest to work with when editing text,
/// so users are more likely to read/write this.
ccursor_range: Option<CCursorRange>,
}

impl TextCursorState {
pub fn is_empty(&self) -> bool {
self.cursor_range.is_none() && self.ccursor_range.is_none()
}

/// The the currently selected range of characters.
pub fn char_range(&self) -> Option<CCursorRange> {
self.ccursor_range.or_else(|| {
self.cursor_range
.map(|cursor_range| cursor_range.as_ccursor_range())
})
}

pub fn range(&mut self, galley: &Galley) -> Option<CursorRange> {
self.cursor_range
.map(|cursor_range| {
// We only use the PCursor (paragraph number, and character offset within that paragraph).
// This is so that if we resize the [`TextEdit`] region, and text wrapping changes,
// we keep the same byte character offset from the beginning of the text,
// even though the number of rows changes
// (each paragraph can be several rows, due to word wrapping).
// The column (character offset) should be able to extend beyond the last word so that we can
// go down and still end up on the same column when we return.
CursorRange {
primary: galley.from_pcursor(cursor_range.primary.pcursor),
secondary: galley.from_pcursor(cursor_range.secondary.pcursor),
}
})
.or_else(|| {
self.ccursor_range.map(|ccursor_range| CursorRange {
primary: galley.from_ccursor(ccursor_range.primary),
secondary: galley.from_ccursor(ccursor_range.secondary),
})
})
}

/// Sets the currently selected range of characters.
pub fn set_char_range(&mut self, ccursor_range: Option<CCursorRange>) {
self.cursor_range = None;
self.ccursor_range = ccursor_range;
}

pub fn set_range(&mut self, cursor_range: Option<CursorRange>) {
self.cursor_range = cursor_range;
self.ccursor_range = None;
}
}

impl TextCursorState {
/// Handle clicking and/or dragging text.
///
Expand Down
69 changes: 69 additions & 0 deletions crates/egui/src/text_selection/visuals.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use crate::*;

use super::CursorRange;

pub fn paint_text_selection(
painter: &Painter,
visuals: &Visuals,
galley_pos: Pos2,
galley: &Galley,
cursor_range: &CursorRange,
) {
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);
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 left = if ri == min.row {
row.x_offset(min.column)
} else {
row.rect.left()
};
let right = if ri == max.row {
row.x_offset(max.column)
} else {
let newline_size = if row.ends_with_newline {
row.height() / 2.0 // visualize that we select the newline
} else {
0.0
};
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()),
);
painter.rect_filled(rect, 0.0, color);
}
}

/// Paint one end of the selection, e.g. the primary cursor.
pub fn paint_cursor(painter: &Painter, visuals: &Visuals, cursor_rect: Rect) {
let stroke = visuals.text_cursor;

let top = cursor_rect.center_top();
let bottom = cursor_rect.center_bottom();

painter.line_segment([top, bottom], (stroke.width, stroke.color));

if false {
// Roof/floor:
let extrusion = 3.0;
let width = 1.0;
painter.line_segment(
[top - vec2(extrusion, 0.0), top + vec2(extrusion, 0.0)],
(width, stroke.color),
);
painter.line_segment(
[bottom - vec2(extrusion, 0.0), bottom + vec2(extrusion, 0.0)],
(width, stroke.color),
);
}
}
2 changes: 1 addition & 1 deletion crates/egui/src/widgets/hyperlink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ impl Widget for Link {

let selectable = ui.style().interaction.selectable_labels;
if selectable {
crate::widgets::label::text_selection(ui, &response, galley_pos, &galley);
crate::text_selection::label_text_selection(ui, &response, galley_pos, &galley);
}

if response.hovered() {
Expand Down
Loading

0 comments on commit f034f6d

Please sign in to comment.