Refactor: move text selection logic to own module (#3843)

This is a follow-up to https://github.com/emilk/egui/issues/3804 and a
pre-requisite for https://github.com/emilk/egui/issues/3816
This commit is contained in:
Emil Ernerfeldt 2024-01-19 15:38:53 +01:00 committed by GitHub
parent 3936418f0b
commit f034f6db9f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 497 additions and 480 deletions

View File

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

View File

@ -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(

View File

@ -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)]

View File

@ -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
}
}

View File

@ -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;

View File

@ -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.
///

View File

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

View File

@ -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() {

View File

@ -1,11 +1,6 @@
use std::sync::Arc;
use crate::{
text_edit::{
cursor_interaction::cursor_rect, paint_cursor_selection, CursorRange, TextCursorState,
},
*,
};
use crate::*;
/// Static text.
///
@ -262,160 +257,10 @@ impl Widget for Label {
let selectable = selectable.unwrap_or_else(|| ui.style().interaction.selectable_labels);
if selectable {
text_selection(ui, &response, galley_pos, &galley);
crate::text_selection::label_text_selection(ui, &response, galley_pos, &galley);
}
}
response
}
}
/// Handle text selection state for a label or similar widget.
///
/// Make sure the widget senses to clicks and drags.
///
/// This should be called after painting the text, because this will also
/// paint the text cursor/selection on top.
pub fn 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, 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_cursor_selection(
ui.visuals(),
ui.painter(),
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")]
text_edit::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);
}
}
fn estimate_row_height(galley: &Galley) -> f32 {
if let Some(row) = galley.rows.first() {
row.rect.height()
} else {
galley.size().y
}
}
fn process_selection_key_events(
ui: &Ui,
galley: &Galley,
widget_id: Id,
cursor_range: &mut CursorRange,
) {
let mut copy_text = None;
ui.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(ui.ctx().os(), event, galley, widget_id);
}
}
}
});
if let Some(copy_text) = copy_text {
ui.ctx().copy_text(copy_text);
}
}
// ----------------------------------------------------------------------------
/// 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 {
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()
}
fn store(ctx: &Context, id: Id, selection: TextCursorState) {
ctx.data_mut(|data| {
data.insert_temp(
Id::NULL,
Self {
id: Some(id),
selection,
},
);
});
}
}

View File

@ -3,13 +3,17 @@ use std::sync::Arc;
use epaint::text::{cursor::*, Galley, LayoutJob};
use crate::{
os::OperatingSystem, output::OutputEvent, text_edit::cursor_interaction::cursor_rect, *,
os::OperatingSystem,
output::OutputEvent,
text_selection::{
text_cursor_state::cursor_rect,
visuals::{paint_cursor, paint_text_selection},
CCursorRange, CursorRange,
},
*,
};
use super::{
cursor_interaction::{ccursor_next_word, ccursor_previous_word, find_line_start},
CCursorRange, CursorRange, TextEditOutput, TextEditState,
};
use super::{TextEditOutput, TextEditState};
/// A text region that the user can edit the contents of.
///
@ -651,9 +655,9 @@ impl<'t> TextEdit<'t> {
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_cursor_selection(
ui.visuals(),
paint_text_selection(
&painter,
ui.visuals(),
galley_pos,
&galley,
&cursor_range,
@ -722,7 +726,7 @@ impl<'t> TextEdit<'t> {
accesskit::Role::TextInput
};
super::accesskit_text::update_accesskit_for_text_widget(
crate::text_selection::accesskit_text::update_accesskit_for_text_widget(
ui.ctx(),
id,
cursor_range,
@ -814,14 +818,14 @@ fn events(
Some(CCursorRange::default())
} else {
copy_if_not_password(ui, cursor_range.slice_str(text.as_str()).to_owned());
Some(CCursorRange::one(delete_selected(text, &cursor_range)))
Some(CCursorRange::one(text.delete_selected(&cursor_range)))
}
}
Event::Paste(text_to_insert) => {
if !text_to_insert.is_empty() {
let mut ccursor = delete_selected(text, &cursor_range);
let mut ccursor = text.delete_selected(&cursor_range);
insert_text(&mut ccursor, text, text_to_insert, char_limit);
text.insert_text_at(&mut ccursor, text_to_insert, char_limit);
Some(CCursorRange::one(ccursor))
} else {
@ -831,9 +835,9 @@ fn events(
Event::Text(text_to_insert) => {
// Newlines are handled by `Key::Enter`.
if !text_to_insert.is_empty() && text_to_insert != "\n" && text_to_insert != "\r" {
let mut ccursor = delete_selected(text, &cursor_range);
let mut ccursor = text.delete_selected(&cursor_range);
insert_text(&mut ccursor, text, text_to_insert, char_limit);
text.insert_text_at(&mut ccursor, text_to_insert, char_limit);
Some(CCursorRange::one(ccursor))
} else {
@ -846,12 +850,12 @@ fn events(
modifiers,
..
} if multiline => {
let mut ccursor = delete_selected(text, &cursor_range);
let mut ccursor = text.delete_selected(&cursor_range);
if modifiers.shift {
// TODO(emilk): support removing indentation over a selection?
decrease_indentation(&mut ccursor, text);
text.decrease_indentation(&mut ccursor);
} else {
insert_text(&mut ccursor, text, "\t", char_limit);
text.insert_text_at(&mut ccursor, "\t", char_limit);
}
Some(CCursorRange::one(ccursor))
}
@ -861,8 +865,8 @@ fn events(
..
} => {
if multiline {
let mut ccursor = delete_selected(text, &cursor_range);
insert_text(&mut ccursor, text, "\n", char_limit);
let mut ccursor = text.delete_selected(&cursor_range);
text.insert_text_at(&mut ccursor, "\n", char_limit);
// TODO(emilk): if code editor, auto-indent by same leading tabs, + one if the lines end on an opening bracket
Some(CCursorRange::one(ccursor))
} else {
@ -924,10 +928,10 @@ fn events(
// empty prediction can be produced when user press backspace
// or escape during ime. We should clear current text.
if text_mark != "\n" && text_mark != "\r" && state.has_ime {
let mut ccursor = delete_selected(text, &cursor_range);
let mut ccursor = text.delete_selected(&cursor_range);
let start_cursor = ccursor;
if !text_mark.is_empty() {
insert_text(&mut ccursor, text, text_mark, char_limit);
text.insert_text_at(&mut ccursor, text_mark, char_limit);
}
Some(CCursorRange::two(start_cursor, ccursor))
} else {
@ -939,9 +943,9 @@ fn events(
// CompositionEnd only characters may be typed into TextEdit without trigger CompositionStart first, so do not check `state.has_ime = true` in the following statement.
if prediction != "\n" && prediction != "\r" {
state.has_ime = false;
let mut ccursor = delete_selected(text, &cursor_range);
let mut ccursor = text.delete_selected(&cursor_range);
if !prediction.is_empty() {
insert_text(&mut ccursor, text, prediction, char_limit);
text.insert_text_at(&mut ccursor, prediction, char_limit);
}
Some(CCursorRange::one(ccursor))
} else {
@ -978,173 +982,6 @@ fn events(
// ----------------------------------------------------------------------------
pub fn paint_cursor_selection(
visuals: &Visuals,
painter: &Painter,
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.
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),
);
}
}
// ----------------------------------------------------------------------------
fn insert_text(
ccursor: &mut CCursor,
text: &mut dyn TextBuffer,
text_to_insert: &str,
char_limit: usize,
) {
if char_limit < usize::MAX {
let mut new_string = text_to_insert;
// Avoid subtract with overflow panic
let cutoff = char_limit.saturating_sub(text.as_str().chars().count());
new_string = match new_string.char_indices().nth(cutoff) {
None => new_string,
Some((idx, _)) => &new_string[..idx],
};
ccursor.index += text.insert_text(new_string, ccursor.index);
} else {
ccursor.index += text.insert_text(text_to_insert, ccursor.index);
}
}
// ----------------------------------------------------------------------------
fn delete_selected(text: &mut dyn TextBuffer, cursor_range: &CursorRange) -> CCursor {
let [min, max] = cursor_range.sorted_cursors();
delete_selected_ccursor_range(text, [min.ccursor, max.ccursor])
}
fn delete_selected_ccursor_range(text: &mut dyn TextBuffer, [min, max]: [CCursor; 2]) -> CCursor {
text.delete_char_range(min.index..max.index);
CCursor {
index: min.index,
prefer_next_row: true,
}
}
fn delete_previous_char(text: &mut dyn TextBuffer, ccursor: CCursor) -> CCursor {
if ccursor.index > 0 {
let max_ccursor = ccursor;
let min_ccursor = max_ccursor - 1;
delete_selected_ccursor_range(text, [min_ccursor, max_ccursor])
} else {
ccursor
}
}
fn delete_next_char(text: &mut dyn TextBuffer, ccursor: CCursor) -> CCursor {
delete_selected_ccursor_range(text, [ccursor, ccursor + 1])
}
fn delete_previous_word(text: &mut dyn TextBuffer, max_ccursor: CCursor) -> CCursor {
let min_ccursor = ccursor_previous_word(text.as_str(), max_ccursor);
delete_selected_ccursor_range(text, [min_ccursor, max_ccursor])
}
fn delete_next_word(text: &mut dyn TextBuffer, min_ccursor: CCursor) -> CCursor {
let max_ccursor = ccursor_next_word(text.as_str(), min_ccursor);
delete_selected_ccursor_range(text, [min_ccursor, max_ccursor])
}
fn delete_paragraph_before_cursor(
text: &mut dyn TextBuffer,
galley: &Galley,
cursor_range: &CursorRange,
) -> CCursor {
let [min, max] = cursor_range.sorted_cursors();
let min = galley.from_pcursor(PCursor {
paragraph: min.pcursor.paragraph,
offset: 0,
prefer_next_row: true,
});
if min.ccursor == max.ccursor {
delete_previous_char(text, min.ccursor)
} else {
delete_selected(text, &CursorRange::two(min, max))
}
}
fn delete_paragraph_after_cursor(
text: &mut dyn TextBuffer,
galley: &Galley,
cursor_range: &CursorRange,
) -> CCursor {
let [min, max] = cursor_range.sorted_cursors();
let max = galley.from_pcursor(PCursor {
paragraph: max.pcursor.paragraph,
offset: usize::MAX, // end of paragraph
prefer_next_row: false,
});
if min.ccursor == max.ccursor {
delete_next_char(text, min.ccursor)
} else {
delete_selected(text, &CursorRange::two(min, max))
}
}
// ----------------------------------------------------------------------------
/// Returns `Some(new_cursor)` if we did mutate `text`.
fn check_for_mutating_key_press(
os: OperatingSystem,
@ -1157,32 +994,32 @@ fn check_for_mutating_key_press(
match key {
Key::Backspace => {
let ccursor = if modifiers.mac_cmd {
delete_paragraph_before_cursor(text, galley, cursor_range)
text.delete_paragraph_before_cursor(galley, cursor_range)
} else if let Some(cursor) = cursor_range.single() {
if modifiers.alt || modifiers.ctrl {
// alt on mac, ctrl on windows
delete_previous_word(text, cursor.ccursor)
text.delete_previous_word(cursor.ccursor)
} else {
delete_previous_char(text, cursor.ccursor)
text.delete_previous_char(cursor.ccursor)
}
} else {
delete_selected(text, cursor_range)
text.delete_selected(cursor_range)
};
Some(CCursorRange::one(ccursor))
}
Key::Delete if !modifiers.shift || os != OperatingSystem::Windows => {
let ccursor = if modifiers.mac_cmd {
delete_paragraph_after_cursor(text, galley, cursor_range)
text.delete_paragraph_after_cursor(galley, cursor_range)
} else if let Some(cursor) = cursor_range.single() {
if modifiers.alt || modifiers.ctrl {
// alt on mac, ctrl on windows
delete_next_word(text, cursor.ccursor)
text.delete_next_word(cursor.ccursor)
} else {
delete_next_char(text, cursor.ccursor)
text.delete_next_char(cursor.ccursor)
}
} else {
delete_selected(text, cursor_range)
text.delete_selected(cursor_range)
};
let ccursor = CCursor {
prefer_next_row: true,
@ -1192,25 +1029,25 @@ fn check_for_mutating_key_press(
}
Key::H if modifiers.ctrl => {
let ccursor = delete_previous_char(text, cursor_range.primary.ccursor);
let ccursor = text.delete_previous_char(cursor_range.primary.ccursor);
Some(CCursorRange::one(ccursor))
}
Key::K if modifiers.ctrl => {
let ccursor = delete_paragraph_after_cursor(text, galley, cursor_range);
let ccursor = text.delete_paragraph_after_cursor(galley, cursor_range);
Some(CCursorRange::one(ccursor))
}
Key::U if modifiers.ctrl => {
let ccursor = delete_paragraph_before_cursor(text, galley, cursor_range);
let ccursor = text.delete_paragraph_before_cursor(galley, cursor_range);
Some(CCursorRange::one(ccursor))
}
Key::W if modifiers.ctrl => {
let ccursor = if let Some(cursor) = cursor_range.single() {
delete_previous_word(text, cursor.ccursor)
text.delete_previous_word(cursor.ccursor)
} else {
delete_selected(text, cursor_range)
text.delete_selected(cursor_range)
};
Some(CCursorRange::one(ccursor))
}
@ -1218,28 +1055,3 @@ fn check_for_mutating_key_press(
_ => None,
}
}
// ----------------------------------------------------------------------------
fn decrease_indentation(ccursor: &mut CCursor, text: &mut dyn TextBuffer) {
let line_start = find_line_start(text.as_str(), *ccursor);
let remove_len = if text.as_str()[line_start.index..].starts_with('\t') {
Some(1)
} else if text.as_str()[line_start.index..]
.chars()
.take(text::TAB_SIZE)
.all(|c| c == ' ')
{
Some(text::TAB_SIZE)
} else {
None
};
if let Some(len) = remove_len {
text.delete_char_range(line_start.index..(line_start.index + len));
if *ccursor != line_start {
*ccursor -= len;
}
}
}

View File

@ -1,18 +1,9 @@
mod builder;
pub mod cursor_interaction;
mod cursor_range;
mod output;
mod state;
mod text_buffer;
#[cfg(feature = "accesskit")]
pub mod accesskit_text;
pub use {
builder::{paint_cursor_selection, TextEdit},
cursor_range::*,
output::TextEditOutput,
state::TextCursorState,
state::TextEditState,
text_buffer::TextBuffer,
crate::text_selection::TextCursorState, builder::TextEdit, output::TextEditOutput,
state::TextEditState, text_buffer::TextBuffer,
};

View File

@ -1,5 +1,7 @@
use std::sync::Arc;
use crate::text::CursorRange;
/// The output from a [`TextEdit`](crate::TextEdit).
pub struct TextEditOutput {
/// The interaction response.
@ -18,7 +20,7 @@ pub struct TextEditOutput {
pub state: super::TextEditState,
/// Where the text cursor is.
pub cursor_range: Option<super::CursorRange>,
pub cursor_range: Option<CursorRange>,
}
impl TextEditOutput {

View File

@ -4,82 +4,19 @@ use crate::mutex::Mutex;
use crate::*;
use super::{CCursorRange, CursorRange};
use self::text_selection::{CCursorRange, CursorRange, TextCursorState};
pub type TextEditUndoer = crate::util::undoer::Undoer<(CCursorRange, String)>;
/// 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;
}
}
/// The text edit state stored between frames.
///
/// Attention: You also need to `store` the updated state.
/// ```
/// # use egui::text::CCursor;
/// # use egui::text_edit::{CCursorRange, TextEditOutput};
/// # use egui::TextEdit;
/// # egui::__run_test_ui(|ui| {
/// # let mut text = String::new();
/// let mut output = TextEdit::singleline(&mut text).show(ui);
/// use egui::text::{CCursor, CCursorRange};
///
/// let mut output = egui::TextEdit::singleline(&mut text).show(ui);
///
/// // Create a new selection range
/// let min = CCursor::new(0);
@ -87,7 +24,7 @@ impl TextCursorState {
/// let new_range = CCursorRange::two(min, max);
///
/// // Update the state
/// output.state.set_ccursor_range(Some(new_range));
/// output.state.cursor.set_char_range(Some(new_range));
/// // Store the updated state
/// output.state.store(ui.ctx(), output.response.id);
/// # });

View File

@ -1,6 +1,20 @@
use std::{borrow::Cow, ops::Range};
use super::cursor_interaction::{byte_index_from_char_index, slice_char_range};
use epaint::{
text::{
cursor::{CCursor, PCursor},
TAB_SIZE,
},
Galley,
};
use crate::text_selection::{
text_cursor_state::{
byte_index_from_char_index, ccursor_next_word, ccursor_previous_word, find_line_start,
slice_char_range,
},
CursorRange,
};
/// Trait constraining what types [`crate::TextEdit`] may use as
/// an underlying buffer.
@ -13,15 +27,6 @@ pub trait TextBuffer {
/// Returns this buffer as a `str`.
fn as_str(&self) -> &str;
/// Reads the given character range.
fn char_range(&self, char_range: Range<usize>) -> &str {
slice_char_range(self.as_str(), char_range)
}
fn byte_index_from_char_index(&self, char_index: usize) -> usize {
byte_index_from_char_index(self.as_str(), char_index)
}
/// Inserts text `text` into this buffer at character index `char_index`.
///
/// # Notes
@ -37,6 +42,15 @@ pub trait TextBuffer {
/// `char_range` is a *character range*, not a byte range.
fn delete_char_range(&mut self, char_range: Range<usize>);
/// Reads the given character range.
fn char_range(&self, char_range: Range<usize>) -> &str {
slice_char_range(self.as_str(), char_range)
}
fn byte_index_from_char_index(&self, char_index: usize) -> usize {
byte_index_from_char_index(self.as_str(), char_index)
}
/// Clears all characters in this buffer
fn clear(&mut self) {
self.delete_char_range(0..self.as_str().len());
@ -54,6 +68,119 @@ pub trait TextBuffer {
self.clear();
s
}
fn insert_text_at(&mut self, ccursor: &mut CCursor, text_to_insert: &str, char_limit: usize) {
if char_limit < usize::MAX {
let mut new_string = text_to_insert;
// Avoid subtract with overflow panic
let cutoff = char_limit.saturating_sub(self.as_str().chars().count());
new_string = match new_string.char_indices().nth(cutoff) {
None => new_string,
Some((idx, _)) => &new_string[..idx],
};
ccursor.index += self.insert_text(new_string, ccursor.index);
} else {
ccursor.index += self.insert_text(text_to_insert, ccursor.index);
}
}
fn decrease_indentation(&mut self, ccursor: &mut CCursor) {
let line_start = find_line_start(self.as_str(), *ccursor);
let remove_len = if self.as_str()[line_start.index..].starts_with('\t') {
Some(1)
} else if self.as_str()[line_start.index..]
.chars()
.take(TAB_SIZE)
.all(|c| c == ' ')
{
Some(TAB_SIZE)
} else {
None
};
if let Some(len) = remove_len {
self.delete_char_range(line_start.index..(line_start.index + len));
if *ccursor != line_start {
*ccursor -= len;
}
}
}
fn delete_selected(&mut self, cursor_range: &CursorRange) -> CCursor {
let [min, max] = cursor_range.sorted_cursors();
self.delete_selected_ccursor_range([min.ccursor, max.ccursor])
}
fn delete_selected_ccursor_range(&mut self, [min, max]: [CCursor; 2]) -> CCursor {
self.delete_char_range(min.index..max.index);
CCursor {
index: min.index,
prefer_next_row: true,
}
}
fn delete_previous_char(&mut self, ccursor: CCursor) -> CCursor {
if ccursor.index > 0 {
let max_ccursor = ccursor;
let min_ccursor = max_ccursor - 1;
self.delete_selected_ccursor_range([min_ccursor, max_ccursor])
} else {
ccursor
}
}
fn delete_next_char(&mut self, ccursor: CCursor) -> CCursor {
self.delete_selected_ccursor_range([ccursor, ccursor + 1])
}
fn delete_previous_word(&mut self, max_ccursor: CCursor) -> CCursor {
let min_ccursor = ccursor_previous_word(self.as_str(), max_ccursor);
self.delete_selected_ccursor_range([min_ccursor, max_ccursor])
}
fn delete_next_word(&mut self, min_ccursor: CCursor) -> CCursor {
let max_ccursor = ccursor_next_word(self.as_str(), min_ccursor);
self.delete_selected_ccursor_range([min_ccursor, max_ccursor])
}
fn delete_paragraph_before_cursor(
&mut self,
galley: &Galley,
cursor_range: &CursorRange,
) -> CCursor {
let [min, max] = cursor_range.sorted_cursors();
let min = galley.from_pcursor(PCursor {
paragraph: min.pcursor.paragraph,
offset: 0,
prefer_next_row: true,
});
if min.ccursor == max.ccursor {
self.delete_previous_char(min.ccursor)
} else {
self.delete_selected(&CursorRange::two(min, max))
}
}
fn delete_paragraph_after_cursor(
&mut self,
galley: &Galley,
cursor_range: &CursorRange,
) -> CCursor {
let [min, max] = cursor_range.sorted_cursors();
let max = galley.from_pcursor(PCursor {
paragraph: max.pcursor.paragraph,
offset: usize::MAX, // end of paragraph
prefer_next_row: false,
});
if min.ccursor == max.ccursor {
self.delete_next_char(min.ccursor)
} else {
self.delete_selected(&CursorRange::two(min, max))
}
}
}
impl TextBuffer for String {

View File

@ -1,4 +1,4 @@
use egui::{text_edit::CCursorRange, *};
use egui::{text::CCursorRange, *};
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]