diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index b5ec0a0ae11..86330546113 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -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, diff --git a/crates/egui/src/widgets/text_edit/accesskit_text.rs b/crates/egui/src/text_selection/accesskit_text.rs similarity index 98% rename from crates/egui/src/widgets/text_edit/accesskit_text.rs rename to crates/egui/src/text_selection/accesskit_text.rs index d9d54c5d16e..99599d018e7 100644 --- a/crates/egui/src/widgets/text_edit/accesskit_text.rs +++ b/crates/egui/src/text_selection/accesskit_text.rs @@ -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( diff --git a/crates/egui/src/widgets/text_edit/cursor_range.rs b/crates/egui/src/text_selection/cursor_range.rs similarity index 99% rename from crates/egui/src/widgets/text_edit/cursor_range.rs rename to crates/egui/src/text_selection/cursor_range.rs index 1d5db9d0809..8cdadfbbb9c 100644 --- a/crates/egui/src/widgets/text_edit/cursor_range.rs +++ b/crates/egui/src/text_selection/cursor_range.rs @@ -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)] diff --git a/crates/egui/src/text_selection/label_text_selection.rs b/crates/egui/src/text_selection/label_text_selection.rs new file mode 100644 index 00000000000..47604c1f21a --- /dev/null +++ b/crates/egui/src/text_selection/label_text_selection.rs @@ -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, + + /// 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::(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 + } +} diff --git a/crates/egui/src/text_selection/mod.rs b/crates/egui/src/text_selection/mod.rs new file mode 100644 index 00000000000..e9796e220fa --- /dev/null +++ b/crates/egui/src/text_selection/mod.rs @@ -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; diff --git a/crates/egui/src/widgets/text_edit/cursor_interaction.rs b/crates/egui/src/text_selection/text_cursor_state.rs similarity index 78% rename from crates/egui/src/widgets/text_edit/cursor_interaction.rs rename to crates/egui/src/text_selection/text_cursor_state.rs index d66d7fe46cd..bdc4736c583 100644 --- a/crates/egui/src/widgets/text_edit/cursor_interaction.rs +++ b/crates/egui/src/text_selection/text_cursor_state.rs @@ -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, + + /// This is what is easiest to work with when editing text, + /// so users are more likely to read/write this. + ccursor_range: Option, +} + +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 { + self.ccursor_range.or_else(|| { + self.cursor_range + .map(|cursor_range| cursor_range.as_ccursor_range()) + }) + } + + pub fn range(&mut self, galley: &Galley) -> Option { + 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) { + self.cursor_range = None; + self.ccursor_range = ccursor_range; + } + + pub fn set_range(&mut self, cursor_range: Option) { + self.cursor_range = cursor_range; + self.ccursor_range = None; + } +} + impl TextCursorState { /// Handle clicking and/or dragging text. /// diff --git a/crates/egui/src/text_selection/visuals.rs b/crates/egui/src/text_selection/visuals.rs new file mode 100644 index 00000000000..a8aba914449 --- /dev/null +++ b/crates/egui/src/text_selection/visuals.rs @@ -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), + ); + } +} diff --git a/crates/egui/src/widgets/hyperlink.rs b/crates/egui/src/widgets/hyperlink.rs index a1214b23d46..ce8089ba60c 100644 --- a/crates/egui/src/widgets/hyperlink.rs +++ b/crates/egui/src/widgets/hyperlink.rs @@ -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() { diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index a6a0e04803c..b780c40b999 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -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, - - /// The current selection, if any. - selection: TextCursorState, -} - -impl LabelSelectionState { - fn load(ctx: &Context, id: Id) -> TextCursorState { - ctx.data(|data| data.get_temp::(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, - }, - ); - }); - } -} diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 155d6344d6a..eae8a6e4159 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -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; - } - } -} diff --git a/crates/egui/src/widgets/text_edit/mod.rs b/crates/egui/src/widgets/text_edit/mod.rs index 3e2e7fba6d1..698a551acf3 100644 --- a/crates/egui/src/widgets/text_edit/mod.rs +++ b/crates/egui/src/widgets/text_edit/mod.rs @@ -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, }; diff --git a/crates/egui/src/widgets/text_edit/output.rs b/crates/egui/src/widgets/text_edit/output.rs index 2ad24b0f96f..d02c1d1c59a 100644 --- a/crates/egui/src/widgets/text_edit/output.rs +++ b/crates/egui/src/widgets/text_edit/output.rs @@ -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, + pub cursor_range: Option, } impl TextEditOutput { diff --git a/crates/egui/src/widgets/text_edit/state.rs b/crates/egui/src/widgets/text_edit/state.rs index e5865bd73f7..819688289c9 100644 --- a/crates/egui/src/widgets/text_edit/state.rs +++ b/crates/egui/src/widgets/text_edit/state.rs @@ -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, - - /// This is what is easiest to work with when editing text, - /// so users are more likely to read/write this. - ccursor_range: Option, -} - -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 { - self.ccursor_range.or_else(|| { - self.cursor_range - .map(|cursor_range| cursor_range.as_ccursor_range()) - }) - } - - pub fn range(&mut self, galley: &Galley) -> Option { - 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) { - self.cursor_range = None; - self.ccursor_range = ccursor_range; - } - - pub fn set_range(&mut self, cursor_range: Option) { - 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); /// # }); diff --git a/crates/egui/src/widgets/text_edit/text_buffer.rs b/crates/egui/src/widgets/text_edit/text_buffer.rs index f02981b6a6a..f28878a1dd3 100644 --- a/crates/egui/src/widgets/text_edit/text_buffer.rs +++ b/crates/egui/src/widgets/text_edit/text_buffer.rs @@ -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) -> &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); + /// Reads the given character range. + fn char_range(&self, char_range: Range) -> &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 { diff --git a/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs b/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs index 95fb5b7d5ad..0971abd45b2 100644 --- a/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs +++ b/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs @@ -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))]