From 5d2e1929276b811ef322c2e7f77d05eb8c474b7a Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sun, 14 Jan 2024 15:17:55 +0100 Subject: [PATCH] Selectable text in Labels (#3814) * Closes https://github.com/emilk/egui/issues/3804 Add ability to select the text in labels with mouse-drag, double-click, and keyboard (once clicked). Hit Cmd+C to copy the text. If everything of a label with elided text is selected, the copy command will copy the full non-elided text. IME and accesskit _should_ work, but is untested. You can control wether or not text in labels is selected globally in `style.interaction.selectable_labels` or on a per-label basis in `Label::selectable`. The default is ON. This also cleans up the `TextEdit` code somewhat, fixing a couple smaller bugs along the way. This does _not_ implement selecting text across multiple widgets. Text selection is only supported within a single `Label`, `TextEdit`, `Link` or `Hyperlink`. ![label-text-selection](https://github.com/emilk/egui/assets/1148717/c161e819-50da-4b97-9686-042e6abf3564) ## TODO * [x] Test --- crates/egui/src/data/output.rs | 2 +- crates/egui/src/style.rs | 7 + crates/egui/src/widgets/drag_value.rs | 2 +- crates/egui/src/widgets/hyperlink.rs | 20 +- crates/egui/src/widgets/label.rs | 233 ++++++- .../src/widgets/text_edit/accesskit_text.rs | 99 +++ crates/egui/src/widgets/text_edit/builder.rs | 594 +++--------------- .../widgets/text_edit/cursor_interaction.rs | 261 ++++++++ .../src/widgets/text_edit/cursor_range.rs | 212 ++++++- crates/egui/src/widgets/text_edit/mod.rs | 10 +- crates/egui/src/widgets/text_edit/output.rs | 9 +- crates/egui/src/widgets/text_edit/state.rs | 105 +++- .../egui/src/widgets/text_edit/text_buffer.rs | 22 +- crates/egui_demo_app/src/apps/http_app.rs | 40 +- crates/egui_demo_lib/src/demo/text_edit.rs | 12 +- .../src/easy_mark/easy_mark_editor.rs | 4 +- crates/epaint/src/text/text_layout_types.rs | 29 +- scripts/check.sh | 2 +- 18 files changed, 1031 insertions(+), 632 deletions(-) create mode 100644 crates/egui/src/widgets/text_edit/accesskit_text.rs create mode 100644 crates/egui/src/widgets/text_edit/cursor_interaction.rs diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index 5b37916114e..9960d51590d 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -73,7 +73,7 @@ pub struct IMEOutput { /// Where the [`crate::TextEdit`] is located on screen. pub rect: crate::Rect, - /// Where the cursor is. + /// Where the primary cursor is. /// /// This is a very thin rectangle. pub cursor_rect: crate::Rect, diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index d3785a975ba..ebadb286d17 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -717,6 +717,9 @@ pub struct Interaction { /// Delay in seconds before showing tooltips after the mouse stops moving pub tooltip_delay: f64, + + /// Can you select the text on a [`crate::Label`] by default? + pub selectable_labels: bool, } /// Controls the visual style (colors etc) of egui. @@ -846,6 +849,7 @@ impl Visuals { &self.widgets.noninteractive } + // Non-interactive text color. pub fn text_color(&self) -> Color32 { self.override_text_color .unwrap_or_else(|| self.widgets.noninteractive.text_color()) @@ -1114,6 +1118,7 @@ impl Default for Interaction { resize_grab_radius_corner: 10.0, show_tooltips_only_when_still: true, tooltip_delay: 0.0, + selectable_labels: true, } } } @@ -1573,6 +1578,7 @@ impl Interaction { resize_grab_radius_corner, show_tooltips_only_when_still, tooltip_delay, + selectable_labels, } = self; ui.add(Slider::new(resize_grab_radius_side, 0.0..=20.0).text("resize_grab_radius_side")); ui.add( @@ -1583,6 +1589,7 @@ impl Interaction { "Only show tooltips if mouse is still", ); ui.add(Slider::new(tooltip_delay, 0.0..=1.0).text("tooltip_delay")); + ui.checkbox(selectable_labels, "Selectable text in labels"); ui.vertical_centered(|ui| reset_button(ui, self)); } diff --git a/crates/egui/src/widgets/drag_value.rs b/crates/egui/src/widgets/drag_value.rs index c4834a03fbb..b5ae6545800 100644 --- a/crates/egui/src/widgets/drag_value.rs +++ b/crates/egui/src/widgets/drag_value.rs @@ -544,7 +544,7 @@ impl<'a> Widget for DragValue<'a> { ui.data_mut(|data| data.remove::(id)); ui.memory_mut(|mem| mem.request_focus(id)); let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); - state.set_ccursor_range(Some(text::CCursorRange::two( + state.cursor.set_char_range(Some(text::CCursorRange::two( text::CCursor::default(), text::CCursor::new(value_text.chars().count()), ))); diff --git a/crates/egui/src/widgets/hyperlink.rs b/crates/egui/src/widgets/hyperlink.rs index 77c9b297966..a1214b23d46 100644 --- a/crates/egui/src/widgets/hyperlink.rs +++ b/crates/egui/src/widgets/hyperlink.rs @@ -34,13 +34,9 @@ impl Widget for Link { let Self { text } = self; let label = Label::new(text).sense(Sense::click()); - let (pos, galley, response) = label.layout_in_ui(ui); + let (galley_pos, galley, response) = label.layout_in_ui(ui); response.widget_info(|| WidgetInfo::labeled(WidgetType::Link, galley.text())); - if response.hovered() { - ui.ctx().set_cursor_icon(CursorIcon::PointingHand); - } - if ui.is_rect_visible(response.rect) { let color = ui.visuals().hyperlink_color; let visuals = ui.style().interact(&response); @@ -51,8 +47,18 @@ impl Widget for Link { Stroke::NONE }; - ui.painter() - .add(epaint::TextShape::new(pos, galley, color).with_underline(underline)); + ui.painter().add( + epaint::TextShape::new(galley_pos, galley.clone(), color).with_underline(underline), + ); + + let selectable = ui.style().interaction.selectable_labels; + if selectable { + crate::widgets::label::text_selection(ui, &response, galley_pos, &galley); + } + + if response.hovered() { + ui.ctx().set_cursor_icon(CursorIcon::PointingHand); + } } response diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index dee9b1c303f..526ff57188c 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -1,6 +1,11 @@ use std::sync::Arc; -use crate::*; +use crate::{ + text_edit::{ + cursor_interaction::cursor_rect, paint_cursor_selection, CursorRange, TextCursorState, + }, + *, +}; /// Static text. /// @@ -23,6 +28,7 @@ pub struct Label { wrap: Option, truncate: bool, sense: Option, + selectable: Option, } impl Label { @@ -32,6 +38,7 @@ impl Label { wrap: None, truncate: false, sense: None, + selectable: None, } } @@ -73,6 +80,15 @@ impl Label { self } + /// Can the user select the text with the mouse? + /// + /// Overrides [`crate::style::Interaction::selectable_labels`]. + #[inline] + pub fn selectable(mut self, selectable: bool) -> Self { + self.selectable = Some(selectable); + self + } + /// Make the label respond to clicks and/or drags. /// /// By default, a label is inert and does not respond to click or drags. @@ -97,14 +113,35 @@ impl Label { impl Label { /// Do layout and position the galley in the ui, without painting it or adding widget info. pub fn layout_in_ui(self, ui: &mut Ui) -> (Pos2, Arc, Response) { - let sense = self.sense.unwrap_or_else(|| { - // We only want to focus labels if the screen reader is on. + let selectable = self + .selectable + .unwrap_or_else(|| ui.style().interaction.selectable_labels); + + let mut sense = self.sense.unwrap_or_else(|| { if ui.memory(|mem| mem.options.screen_reader) { + // We only want to focus labels if the screen reader is on. Sense::focusable_noninteractive() } else { Sense::hover() } }); + + if selectable { + // On touch screens (e.g. mobile in `eframe` web), should + // dragging select text, or scroll the enclosing [`ScrollArea`] (if any)? + // Since currently copying selected text in not supported on `eframe` web, + // we prioritize touch-scrolling: + let allow_drag_to_select = ui.input(|i| !i.any_touches()); + + let select_sense = if allow_drag_to_select { + Sense::click_and_drag() + } else { + Sense::click() + }; + + sense = sense.union(select_sense); + } + if let WidgetText::Galley(galley) = self.text { // If the user said "use this specific galley", then just use it: let (rect, response) = ui.allocate_exact_size(galley.size(), sense); @@ -178,28 +215,39 @@ impl Label { let galley = ui.fonts(|fonts| fonts.layout_job(layout_job)); let (rect, response) = ui.allocate_exact_size(galley.size(), sense); - let pos = match galley.job.halign { + let galley_pos = match galley.job.halign { Align::LEFT => rect.left_top(), Align::Center => rect.center_top(), Align::RIGHT => rect.right_top(), }; - (pos, galley, response) + (galley_pos, galley, response) } } } impl Widget for Label { fn ui(self, ui: &mut Ui) -> Response { - let (pos, galley, mut response) = self.layout_in_ui(ui); - response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, galley.text())); + // Interactive = the uses asked to sense interaction. + // We DON'T want to have the color respond just because the text is selectable; + // the cursor is enough to communicate that. + let interactive = self.sense.map_or(false, |sense| sense != Sense::hover()); - if galley.elided { - // Show the full (non-elided) text on hover: - response = response.on_hover_text(galley.text()); - } + let selectable = self.selectable; + + let (galley_pos, galley, mut response) = self.layout_in_ui(ui); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, galley.text())); if ui.is_rect_visible(response.rect) { - let response_color = ui.style().interact(&response).text_color(); + if galley.elided { + // Show the full (non-elided) text on hover: + response = response.on_hover_text(galley.text()); + } + + let response_color = if interactive { + ui.style().interact(&response).text_color() + } else { + ui.style().visuals.text_color() + }; let underline = if response.has_focus() || response.highlighted() { Stroke::new(1.0, response_color) @@ -207,10 +255,167 @@ impl Widget for Label { Stroke::NONE }; - ui.painter() - .add(epaint::TextShape::new(pos, galley, response_color).with_underline(underline)); + 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 { + 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(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/accesskit_text.rs b/crates/egui/src/widgets/text_edit/accesskit_text.rs new file mode 100644 index 00000000000..d9d54c5d16e --- /dev/null +++ b/crates/egui/src/widgets/text_edit/accesskit_text.rs @@ -0,0 +1,99 @@ +use crate::{Context, Galley, Id, Pos2}; + +use super::{cursor_interaction::is_word_char, CursorRange}; + +/// Update accesskit with the current text state. +pub fn update_accesskit_for_text_widget( + ctx: &Context, + widget_id: Id, + cursor_range: Option, + role: accesskit::Role, + galley_pos: Pos2, + galley: &Galley, +) { + let parent_id = ctx.accesskit_node_builder(widget_id, |builder| { + let parent_id = widget_id; + + if let Some(cursor_range) = &cursor_range { + let anchor = &cursor_range.secondary.rcursor; + let focus = &cursor_range.primary.rcursor; + builder.set_text_selection(accesskit::TextSelection { + anchor: accesskit::TextPosition { + node: parent_id.with(anchor.row).accesskit_id(), + character_index: anchor.column, + }, + focus: accesskit::TextPosition { + node: parent_id.with(focus.row).accesskit_id(), + character_index: focus.column, + }, + }); + } + + builder.set_default_action_verb(accesskit::DefaultActionVerb::Focus); + + builder.set_role(role); + + parent_id + }); + + let Some(parent_id) = parent_id else { + return; + }; + + ctx.with_accessibility_parent(parent_id, || { + for (row_index, row) in galley.rows.iter().enumerate() { + let row_id = parent_id.with(row_index); + ctx.accesskit_node_builder(row_id, |builder| { + builder.set_role(accesskit::Role::InlineTextBox); + let rect = row.rect.translate(galley_pos.to_vec2()); + builder.set_bounds(accesskit::Rect { + x0: rect.min.x.into(), + y0: rect.min.y.into(), + x1: rect.max.x.into(), + y1: rect.max.y.into(), + }); + builder.set_text_direction(accesskit::TextDirection::LeftToRight); + // TODO(mwcampbell): Set more node fields for the row + // once AccessKit adapters expose text formatting info. + + let glyph_count = row.glyphs.len(); + let mut value = String::new(); + value.reserve(glyph_count); + let mut character_lengths = Vec::::with_capacity(glyph_count); + let mut character_positions = Vec::::with_capacity(glyph_count); + let mut character_widths = Vec::::with_capacity(glyph_count); + let mut word_lengths = Vec::::new(); + let mut was_at_word_end = false; + let mut last_word_start = 0usize; + + for glyph in &row.glyphs { + let is_word_char = is_word_char(glyph.chr); + if is_word_char && was_at_word_end { + word_lengths.push((character_lengths.len() - last_word_start) as _); + last_word_start = character_lengths.len(); + } + was_at_word_end = !is_word_char; + let old_len = value.len(); + value.push(glyph.chr); + character_lengths.push((value.len() - old_len) as _); + character_positions.push(glyph.pos.x - row.rect.min.x); + character_widths.push(glyph.size.x); + } + + if row.ends_with_newline { + value.push('\n'); + character_lengths.push(1); + character_positions.push(row.rect.max.x - row.rect.min.x); + character_widths.push(0.0); + } + word_lengths.push((character_lengths.len() - last_word_start) as _); + + builder.set_value(value); + builder.set_character_lengths(character_lengths); + builder.set_character_positions(character_positions); + builder.set_character_widths(character_widths); + builder.set_word_lengths(word_lengths); + }); + } + }); +} diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 663f48d719b..400aea51937 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -1,12 +1,13 @@ use std::sync::Arc; -#[cfg(feature = "accesskit")] -use accesskit::Role; use epaint::text::{cursor::*, Galley, LayoutJob}; -use crate::{output::OutputEvent, *}; +use crate::{output::OutputEvent, text_edit::cursor_interaction::cursor_rect, *}; -use super::{CCursorRange, CursorRange, TextEditOutput, TextEditState}; +use super::{ + cursor_interaction::{ccursor_next_word, ccursor_previous_word, find_line_start}, + CCursorRange, CursorRange, TextEditOutput, TextEditState, +}; /// A text region that the user can edit the contents of. /// @@ -173,7 +174,7 @@ impl<'t> TextEdit<'t> { /// text_color, /// f32::INFINITY /// ); - /// painter.galley(output.text_draw_pos, galley, text_color); + /// painter.galley(output.galley_pos, galley, text_color); /// # }); /// ``` #[inline] @@ -527,6 +528,7 @@ impl<'t> TextEdit<'t> { } // TODO(emilk): drag selected text to either move or clone (ctrl on windows, alt on mac) + let singleline_offset = vec2(state.singleline_offset, 0.0); let cursor_at_pointer = galley.cursor_from_pos(pointer_pos - response.rect.min + singleline_offset); @@ -536,54 +538,18 @@ impl<'t> TextEdit<'t> { && ui.input(|i| i.pointer.is_moving()) { // preview: - paint_cursor_end( - ui, - row_height, - &painter, - response.rect.min, - &galley, - &cursor_at_pointer, - ); + let cursor_rect = + cursor_rect(response.rect.min, &galley, &cursor_at_pointer, row_height); + paint_cursor(&painter, ui.visuals(), cursor_rect); } - if response.double_clicked() { - // Select word: - let center = cursor_at_pointer; - let ccursor_range = select_word_at(text.as_str(), center.ccursor); - state.set_cursor_range(Some(CursorRange { - primary: galley.from_ccursor(ccursor_range.primary), - secondary: galley.from_ccursor(ccursor_range.secondary), - })); - } else if response.triple_clicked() { - // Select line: - let center = cursor_at_pointer; - let ccursor_range = select_line_at(text.as_str(), center.ccursor); - state.set_cursor_range(Some(CursorRange { - primary: galley.from_ccursor(ccursor_range.primary), - secondary: galley.from_ccursor(ccursor_range.secondary), - })); - } else if allow_drag_to_select { - if response.hovered() && ui.input(|i| i.pointer.any_pressed()) { - ui.memory_mut(|mem| mem.request_focus(id)); - if ui.input(|i| i.modifiers.shift) { - if let Some(mut cursor_range) = state.cursor_range(&galley) { - cursor_range.primary = cursor_at_pointer; - state.set_cursor_range(Some(cursor_range)); - } else { - state.set_cursor_range(Some(CursorRange::one(cursor_at_pointer))); - } - } else { - state.set_cursor_range(Some(CursorRange::one(cursor_at_pointer))); - } - } else if ui.input(|i| i.pointer.any_down()) - && response.is_pointer_button_down_on() - { - // drag to select text: - if let Some(mut cursor_range) = state.cursor_range(&galley) { - cursor_range.primary = cursor_at_pointer; - state.set_cursor_range(Some(cursor_range)); - } - } + let did_interact = + state + .cursor + .pointer_interaction(ui, &response, cursor_at_pointer, &galley); + + if did_interact { + ui.memory_mut(|mem| mem.request_focus(response.id)); } } } @@ -593,7 +559,7 @@ impl<'t> TextEdit<'t> { } let mut cursor_range = None; - let prev_cursor_range = state.cursor_range(&galley); + let prev_cursor_range = state.cursor.range(&galley); if interactive && ui.memory(|mem| mem.has_focus(id)) { ui.memory_mut(|mem| mem.set_focus_lock_filter(id, event_filter)); @@ -624,11 +590,11 @@ impl<'t> TextEdit<'t> { cursor_range = Some(new_cursor_range); } - let mut text_draw_pos = align + let mut galley_pos = align .align_size_within_rect(galley.size(), response.rect) .intersect(response.rect) // limit pos to the response rect area .min; - let align_offset = response.rect.left() - text_draw_pos.x; + let align_offset = response.rect.left() - galley_pos.x; // Visual clipping for singleline text editor with text larger than width if clip_text && align_offset == 0.0 { @@ -653,7 +619,7 @@ impl<'t> TextEdit<'t> { .at_least(0.0); state.singleline_offset = offset_x; - text_draw_pos -= vec2(offset_x, 0.0); + galley_pos -= vec2(offset_x, 0.0); } else { state.singleline_offset = align_offset; } @@ -667,7 +633,7 @@ impl<'t> TextEdit<'t> { }; if ui.is_rect_visible(rect) { - painter.galley(text_draw_pos, galley.clone(), text_color); + painter.galley(galley_pos, galley.clone(), text_color); if text.as_str().is_empty() && !hint_text.is_empty() { let hint_text_color = ui.visuals().weak_text_color(); @@ -680,30 +646,36 @@ impl<'t> TextEdit<'t> { } if ui.memory(|mem| mem.has_focus(id)) { - if let Some(cursor_range) = state.cursor_range(&galley) { + 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, &painter, text_draw_pos, &galley, &cursor_range); + paint_cursor_selection( + ui.visuals(), + &painter, + galley_pos, + &galley, + &cursor_range, + ); + + let primary_cursor_rect = + cursor_rect(galley_pos, &galley, &cursor_range.primary, row_height); + + let is_fully_visible = ui.clip_rect().contains_rect(rect); // TODO: remove this HACK workaround for https://github.com/emilk/egui/issues/1531 + if (response.changed || selection_changed) && !is_fully_visible { + // Scroll to keep primary cursor in view: + ui.scroll_to_rect(primary_cursor_rect, None); + } if text.is_mutable() { - let cursor_rect = paint_cursor_end( - ui, - row_height, - &painter, - text_draw_pos, - &galley, - &cursor_range.primary, - ); - - let is_fully_visible = ui.clip_rect().contains_rect(rect); // TODO: remove this HACK workaround for https://github.com/emilk/egui/issues/1531 - if (response.changed || selection_changed) && !is_fully_visible { - ui.scroll_to_rect(cursor_rect, None); // keep cursor in view - } + paint_cursor(&painter, ui.visuals(), primary_cursor_rect); if interactive { // For IME, so only set it when text is editable and visible! ui.ctx().output_mut(|o| { - o.ime = Some(crate::output::IMEOutput { rect, cursor_rect }); + o.ime = Some(crate::output::IMEOutput { + rect, + cursor_rect: primary_cursor_rect, + }); }); } } @@ -740,102 +712,28 @@ impl<'t> TextEdit<'t> { #[cfg(feature = "accesskit")] { - let parent_id = ui.ctx().accesskit_node_builder(response.id, |builder| { - use accesskit::{TextPosition, TextSelection}; - - let parent_id = response.id; - - if let Some(cursor_range) = &cursor_range { - let anchor = &cursor_range.secondary.rcursor; - let focus = &cursor_range.primary.rcursor; - builder.set_text_selection(TextSelection { - anchor: TextPosition { - node: parent_id.with(anchor.row).accesskit_id(), - character_index: anchor.column, - }, - focus: TextPosition { - node: parent_id.with(focus.row).accesskit_id(), - character_index: focus.column, - }, - }); - } - - builder.set_default_action_verb(accesskit::DefaultActionVerb::Focus); - if self.multiline { - builder.set_role(Role::MultilineTextInput); - } - - parent_id - }); + let role = if password { + accesskit::Role::PasswordInput + } else if multiline { + accesskit::Role::MultilineTextInput + } else { + accesskit::Role::TextInput + }; - if let Some(parent_id) = parent_id { - // drop ctx lock before further processing - use accesskit::TextDirection; - - ui.ctx().with_accessibility_parent(parent_id, || { - for (i, row) in galley.rows.iter().enumerate() { - let id = parent_id.with(i); - ui.ctx().accesskit_node_builder(id, |builder| { - builder.set_role(Role::InlineTextBox); - let rect = row.rect.translate(text_draw_pos.to_vec2()); - builder.set_bounds(accesskit::Rect { - x0: rect.min.x.into(), - y0: rect.min.y.into(), - x1: rect.max.x.into(), - y1: rect.max.y.into(), - }); - builder.set_text_direction(TextDirection::LeftToRight); - // TODO(mwcampbell): Set more node fields for the row - // once AccessKit adapters expose text formatting info. - - let glyph_count = row.glyphs.len(); - let mut value = String::new(); - value.reserve(glyph_count); - let mut character_lengths = Vec::::with_capacity(glyph_count); - let mut character_positions = Vec::::with_capacity(glyph_count); - let mut character_widths = Vec::::with_capacity(glyph_count); - let mut word_lengths = Vec::::new(); - let mut was_at_word_end = false; - let mut last_word_start = 0usize; - - for glyph in &row.glyphs { - let is_word_char = is_word_char(glyph.chr); - if is_word_char && was_at_word_end { - word_lengths - .push((character_lengths.len() - last_word_start) as _); - last_word_start = character_lengths.len(); - } - was_at_word_end = !is_word_char; - let old_len = value.len(); - value.push(glyph.chr); - character_lengths.push((value.len() - old_len) as _); - character_positions.push(glyph.pos.x - row.rect.min.x); - character_widths.push(glyph.size.x); - } - - if row.ends_with_newline { - value.push('\n'); - character_lengths.push(1); - character_positions.push(row.rect.max.x - row.rect.min.x); - character_widths.push(0.0); - } - word_lengths.push((character_lengths.len() - last_word_start) as _); - - builder.set_value(value); - builder.set_character_lengths(character_lengths); - builder.set_character_positions(character_positions); - builder.set_character_widths(character_widths); - builder.set_word_lengths(word_lengths); - }); - } - }); - } + super::accesskit_text::update_accesskit_for_text_widget( + ui.ctx(), + id, + cursor_range, + role, + galley_pos, + &galley, + ); } TextEditOutput { response, galley, - text_draw_pos, + galley_pos, text_clip_rect, state, cursor_range, @@ -859,28 +757,6 @@ fn mask_if_password(is_password: bool, text: &str) -> String { // ---------------------------------------------------------------------------- -#[cfg(feature = "accesskit")] -fn ccursor_from_accesskit_text_position( - id: Id, - galley: &Galley, - position: &accesskit::TextPosition, -) -> Option { - let mut total_length = 0usize; - for (i, row) in galley.rows.iter().enumerate() { - let row_id = id.with(i); - if row_id.accesskit_id() == position.node { - return Some(CCursor { - index: total_length + position.character_index, - prefer_next_row: !(position.character_index == row.glyphs.len() - && !row.ends_with_newline - && (i + 1) < galley.rows.len()), - }); - } - total_length += row.glyphs.len() + (row.ends_with_newline as usize); - } - None -} - /// Check for (keyboard) events to edit the cursor and/or text. #[allow(clippy::too_many_arguments)] fn events( @@ -897,7 +773,7 @@ fn events( char_limit: usize, event_filter: EventFilter, ) -> (bool, CursorRange) { - let mut cursor_range = state.cursor_range(galley).unwrap_or(default_cursor_range); + let mut cursor_range = state.cursor.range(galley).unwrap_or(default_cursor_range); // We feed state to the undoer both before and after handling input // so that the undoer creates automatic saves even when there are no events for a while. @@ -917,11 +793,14 @@ fn events( let events = ui.input(|i| i.filtered_events(&event_filter)); for event in &events { let did_mutate_text = match event { + // First handle events that only changes the selection cursor, not the text: + event if cursor_range.on_event(event, galley, id) => None, + Event::Copy => { if cursor_range.is_empty() { copy_if_not_password(ui, text.as_str().to_owned()); } else { - copy_if_not_password(ui, selected_str(text, &cursor_range).to_owned()); + copy_if_not_password(ui, cursor_range.slice_str(text.as_str()).to_owned()); } None } @@ -930,7 +809,7 @@ fn events( copy_if_not_password(ui, text.take()); Some(CCursorRange::default()) } else { - copy_if_not_password(ui, selected_str(text, &cursor_range).to_owned()); + copy_if_not_password(ui, cursor_range.slice_str(text.as_str()).to_owned()); Some(CCursorRange::one(delete_selected(text, &cursor_range))) } } @@ -1026,11 +905,11 @@ fn events( } Event::Key { + modifiers, key, pressed: true, - modifiers, .. - } => on_key_press(&mut cursor_range, text, galley, *key, modifiers), + } => check_for_mutating_key_press(&mut cursor_range, text, galley, modifiers, *key), Event::CompositionStart => { state.has_ime = true; @@ -1066,27 +945,6 @@ fn events( } } - #[cfg(feature = "accesskit")] - Event::AccessKitActionRequest(accesskit::ActionRequest { - action: accesskit::Action::SetTextSelection, - target, - data: Some(accesskit::ActionData::SetTextSelection(selection)), - }) => { - if id.accesskit_id() == *target { - let primary = - ccursor_from_accesskit_text_position(id, galley, &selection.focus); - let secondary = - ccursor_from_accesskit_text_position(id, galley, &selection.anchor); - if let (Some(primary), Some(secondary)) = (primary, secondary) { - Some(CCursorRange { primary, secondary }) - } else { - None - } - } else { - None - } - } - _ => None, }; @@ -1104,7 +962,7 @@ fn events( } } - state.set_cursor_range(Some(cursor_range)); + state.cursor.set_range(Some(cursor_range)); state.undoer.lock().feed_state( ui.input(|i| i.time), @@ -1116,10 +974,10 @@ fn events( // ---------------------------------------------------------------------------- -fn paint_cursor_selection( - ui: &Ui, +pub fn paint_cursor_selection( + visuals: &Visuals, painter: &Painter, - pos: Pos2, + galley_pos: Pos2, galley: &Galley, cursor_range: &CursorRange, ) { @@ -1128,7 +986,7 @@ fn paint_cursor_selection( } // We paint the cursor selection on top of the text, so make it transparent: - let color = ui.visuals().selection.bg_fill.linear_multiply(0.5); + 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; @@ -1151,29 +1009,19 @@ fn paint_cursor_selection( row.rect.right() + newline_size }; let rect = Rect::from_min_max( - pos + vec2(left, row.min_y()), - pos + vec2(right, row.max_y()), + galley_pos + vec2(left, row.min_y()), + galley_pos + vec2(right, row.max_y()), ); painter.rect_filled(rect, 0.0, color); } } -fn paint_cursor_end( - ui: &Ui, - row_height: f32, - painter: &Painter, - pos: Pos2, - galley: &Galley, - cursor: &Cursor, -) -> Rect { - let stroke = ui.visuals().text_cursor; - - let mut cursor_pos = galley.pos_from_cursor(cursor).translate(pos.to_vec2()); - cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height); // Handle completely empty galleys - cursor_pos = cursor_pos.expand(1.5); // slightly above/below row +/// 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_pos.center_top(); - let bottom = cursor_pos.center_bottom(); + let top = cursor_rect.center_top(); + let bottom = cursor_rect.center_bottom(); painter.line_segment([top, bottom], (stroke.width, stroke.color)); @@ -1190,17 +1038,10 @@ fn paint_cursor_end( (width, stroke.color), ); } - - cursor_pos } // ---------------------------------------------------------------------------- -fn selected_str<'s>(text: &'s dyn TextBuffer, cursor_range: &CursorRange) -> &'s str { - let [min, max] = cursor_range.sorted_cursors(); - text.char_range(min.ccursor.index..max.ccursor.index) -} - fn insert_text( ccursor: &mut CCursor, text: &mut dyn TextBuffer, @@ -1301,12 +1142,12 @@ fn delete_paragraph_after_cursor( // ---------------------------------------------------------------------------- /// Returns `Some(new_cursor)` if we did mutate `text`. -fn on_key_press( +fn check_for_mutating_key_press( cursor_range: &mut CursorRange, text: &mut dyn TextBuffer, galley: &Galley, - key: Key, modifiers: &Modifiers, + key: Key, ) -> Option { match key { Key::Backspace => { @@ -1324,6 +1165,7 @@ fn on_key_press( }; Some(CCursorRange::one(ccursor)) } + Key::Delete if !modifiers.shift || !cfg!(target_os = "windows") => { let ccursor = if modifiers.mac_cmd { delete_paragraph_after_cursor(text, galley, cursor_range) @@ -1344,12 +1186,6 @@ fn on_key_press( Some(CCursorRange::one(ccursor)) } - Key::A if modifiers.command => { - // select all - *cursor_range = CursorRange::two(Cursor::default(), galley.end()); - None - } - Key::H if modifiers.ctrl => { let ccursor = delete_previous_char(text, cursor_range.primary.ccursor); Some(CCursorRange::one(ccursor)) @@ -1374,276 +1210,12 @@ fn on_key_press( Some(CCursorRange::one(ccursor)) } - Key::ArrowLeft | Key::ArrowRight if modifiers.is_none() && !cursor_range.is_empty() => { - if key == Key::ArrowLeft { - *cursor_range = CursorRange::one(cursor_range.sorted_cursors()[0]); - } else { - *cursor_range = CursorRange::one(cursor_range.sorted_cursors()[1]); - } - None - } - - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End => { - move_single_cursor(&mut cursor_range.primary, galley, key, modifiers); - if !modifiers.shift { - cursor_range.secondary = cursor_range.primary; - } - None - } - - Key::P | Key::N | Key::B | Key::F | Key::A | Key::E - if cfg!(target_os = "macos") && modifiers.ctrl && !modifiers.shift => - { - move_single_cursor(&mut cursor_range.primary, galley, key, modifiers); - cursor_range.secondary = cursor_range.primary; - None - } - _ => None, } } -fn move_single_cursor(cursor: &mut Cursor, galley: &Galley, key: Key, modifiers: &Modifiers) { - if cfg!(target_os = "macos") && modifiers.ctrl && !modifiers.shift { - match key { - Key::A => *cursor = galley.cursor_begin_of_row(cursor), - Key::E => *cursor = galley.cursor_end_of_row(cursor), - Key::P => *cursor = galley.cursor_up_one_row(cursor), - Key::N => *cursor = galley.cursor_down_one_row(cursor), - Key::B => *cursor = galley.cursor_left_one_character(cursor), - Key::F => *cursor = galley.cursor_right_one_character(cursor), - _ => (), - } - return; - } - match key { - Key::ArrowLeft => { - if modifiers.alt || modifiers.ctrl { - // alt on mac, ctrl on windows - *cursor = galley.from_ccursor(ccursor_previous_word(galley.text(), cursor.ccursor)); - } else if modifiers.mac_cmd { - *cursor = galley.cursor_begin_of_row(cursor); - } else { - *cursor = galley.cursor_left_one_character(cursor); - } - } - Key::ArrowRight => { - if modifiers.alt || modifiers.ctrl { - // alt on mac, ctrl on windows - *cursor = galley.from_ccursor(ccursor_next_word(galley.text(), cursor.ccursor)); - } else if modifiers.mac_cmd { - *cursor = galley.cursor_end_of_row(cursor); - } else { - *cursor = galley.cursor_right_one_character(cursor); - } - } - Key::ArrowUp => { - if modifiers.command { - // mac and windows behavior - *cursor = Cursor::default(); - } else { - *cursor = galley.cursor_up_one_row(cursor); - } - } - Key::ArrowDown => { - if modifiers.command { - // mac and windows behavior - *cursor = galley.end(); - } else { - *cursor = galley.cursor_down_one_row(cursor); - } - } - - Key::Home => { - if modifiers.ctrl { - // windows behavior - *cursor = Cursor::default(); - } else { - *cursor = galley.cursor_begin_of_row(cursor); - } - } - Key::End => { - if modifiers.ctrl { - // windows behavior - *cursor = galley.end(); - } else { - *cursor = galley.cursor_end_of_row(cursor); - } - } - - _ => unreachable!(), - } -} - // ---------------------------------------------------------------------------- -fn select_word_at(text: &str, ccursor: CCursor) -> CCursorRange { - if ccursor.index == 0 { - CCursorRange::two(ccursor, ccursor_next_word(text, ccursor)) - } else { - let it = text.chars(); - let mut it = it.skip(ccursor.index - 1); - if let Some(char_before_cursor) = it.next() { - if let Some(char_after_cursor) = it.next() { - if is_word_char(char_before_cursor) && is_word_char(char_after_cursor) { - let min = ccursor_previous_word(text, ccursor + 1); - let max = ccursor_next_word(text, min); - CCursorRange::two(min, max) - } else if is_word_char(char_before_cursor) { - let min = ccursor_previous_word(text, ccursor); - let max = ccursor_next_word(text, min); - CCursorRange::two(min, max) - } else if is_word_char(char_after_cursor) { - let max = ccursor_next_word(text, ccursor); - CCursorRange::two(ccursor, max) - } else { - let min = ccursor_previous_word(text, ccursor); - let max = ccursor_next_word(text, ccursor); - CCursorRange::two(min, max) - } - } else { - let min = ccursor_previous_word(text, ccursor); - CCursorRange::two(min, ccursor) - } - } else { - let max = ccursor_next_word(text, ccursor); - CCursorRange::two(ccursor, max) - } - } -} - -fn select_line_at(text: &str, ccursor: CCursor) -> CCursorRange { - if ccursor.index == 0 { - CCursorRange::two(ccursor, ccursor_next_line(text, ccursor)) - } else { - let it = text.chars(); - let mut it = it.skip(ccursor.index - 1); - if let Some(char_before_cursor) = it.next() { - if let Some(char_after_cursor) = it.next() { - if (!is_linebreak(char_before_cursor)) && (!is_linebreak(char_after_cursor)) { - let min = ccursor_previous_line(text, ccursor + 1); - let max = ccursor_next_line(text, min); - CCursorRange::two(min, max) - } else if !is_linebreak(char_before_cursor) { - let min = ccursor_previous_line(text, ccursor); - let max = ccursor_next_line(text, min); - CCursorRange::two(min, max) - } else if !is_linebreak(char_after_cursor) { - let max = ccursor_next_line(text, ccursor); - CCursorRange::two(ccursor, max) - } else { - let min = ccursor_previous_line(text, ccursor); - let max = ccursor_next_line(text, ccursor); - CCursorRange::two(min, max) - } - } else { - let min = ccursor_previous_line(text, ccursor); - CCursorRange::two(min, ccursor) - } - } else { - let max = ccursor_next_line(text, ccursor); - CCursorRange::two(ccursor, max) - } - } -} - -fn ccursor_next_word(text: &str, ccursor: CCursor) -> CCursor { - CCursor { - index: next_word_boundary_char_index(text.chars(), ccursor.index), - prefer_next_row: false, - } -} - -fn ccursor_next_line(text: &str, ccursor: CCursor) -> CCursor { - CCursor { - index: next_line_boundary_char_index(text.chars(), ccursor.index), - prefer_next_row: false, - } -} - -fn ccursor_previous_word(text: &str, ccursor: CCursor) -> CCursor { - let num_chars = text.chars().count(); - CCursor { - index: num_chars - - next_word_boundary_char_index(text.chars().rev(), num_chars - ccursor.index), - prefer_next_row: true, - } -} - -fn ccursor_previous_line(text: &str, ccursor: CCursor) -> CCursor { - let num_chars = text.chars().count(); - CCursor { - index: num_chars - - next_line_boundary_char_index(text.chars().rev(), num_chars - ccursor.index), - prefer_next_row: true, - } -} - -fn next_word_boundary_char_index(it: impl Iterator, mut index: usize) -> usize { - let mut it = it.skip(index); - if let Some(_first) = it.next() { - index += 1; - - if let Some(second) = it.next() { - index += 1; - for next in it { - if is_word_char(next) != is_word_char(second) { - break; - } - index += 1; - } - } - } - index -} - -fn next_line_boundary_char_index(it: impl Iterator, mut index: usize) -> usize { - let mut it = it.skip(index); - if let Some(_first) = it.next() { - index += 1; - - if let Some(second) = it.next() { - index += 1; - for next in it { - if is_linebreak(next) != is_linebreak(second) { - break; - } - index += 1; - } - } - } - index -} - -fn is_word_char(c: char) -> bool { - c.is_ascii_alphanumeric() || c == '_' -} - -fn is_linebreak(c: char) -> bool { - c == '\r' || c == '\n' -} - -/// Accepts and returns character offset (NOT byte offset!). -fn find_line_start(text: &str, current_index: CCursor) -> CCursor { - // We know that new lines, '\n', are a single byte char, but we have to - // work with char offsets because before the new line there may be any - // number of multi byte chars. - // We need to know the char index to be able to correctly set the cursor - // later. - let chars_count = text.chars().count(); - - let position = text - .chars() - .rev() - .skip(chars_count - current_index.index) - .position(|x| x == '\n'); - - match position { - Some(pos) => CCursor::new(current_index.index - pos), - None => CCursor::new(0), - } -} - fn decrease_indentation(ccursor: &mut CCursor, text: &mut dyn TextBuffer) { let line_start = find_line_start(text.as_str(), *ccursor); diff --git a/crates/egui/src/widgets/text_edit/cursor_interaction.rs b/crates/egui/src/widgets/text_edit/cursor_interaction.rs new file mode 100644 index 00000000000..d66d7fe46cd --- /dev/null +++ b/crates/egui/src/widgets/text_edit/cursor_interaction.rs @@ -0,0 +1,261 @@ +//! Text cursor changes/interaction, without modifying the text. + +use epaint::text::{cursor::*, Galley}; +use text_edit::state::TextCursorState; + +use crate::*; + +use super::{CCursorRange, CursorRange}; + +impl TextCursorState { + /// Handle clicking and/or dragging text. + /// + /// Returns `true` if there was interaction. + pub fn pointer_interaction( + &mut self, + ui: &Ui, + response: &Response, + cursor_at_pointer: Cursor, + galley: &Galley, + ) -> bool { + let text = galley.text(); + + if response.double_clicked() { + // Select word: + let ccursor_range = select_word_at(text, cursor_at_pointer.ccursor); + self.set_range(Some(CursorRange { + primary: galley.from_ccursor(ccursor_range.primary), + secondary: galley.from_ccursor(ccursor_range.secondary), + })); + true + } else if response.triple_clicked() { + // Select line: + let ccursor_range = select_line_at(text, cursor_at_pointer.ccursor); + self.set_range(Some(CursorRange { + primary: galley.from_ccursor(ccursor_range.primary), + secondary: galley.from_ccursor(ccursor_range.secondary), + })); + true + } else if response.sense.drag { + if response.hovered() && ui.input(|i| i.pointer.any_pressed()) { + if ui.input(|i| i.modifiers.shift) { + if let Some(mut cursor_range) = self.range(galley) { + cursor_range.primary = cursor_at_pointer; + self.set_range(Some(cursor_range)); + } else { + self.set_range(Some(CursorRange::one(cursor_at_pointer))); + } + } else { + self.set_range(Some(CursorRange::one(cursor_at_pointer))); + } + true + } else if ui.input(|i| i.pointer.any_down()) && response.is_pointer_button_down_on() { + // drag to select text: + if let Some(mut cursor_range) = self.range(galley) { + cursor_range.primary = cursor_at_pointer; + self.set_range(Some(cursor_range)); + } + true + } else { + false + } + } else { + false + } + } +} + +fn select_word_at(text: &str, ccursor: CCursor) -> CCursorRange { + if ccursor.index == 0 { + CCursorRange::two(ccursor, ccursor_next_word(text, ccursor)) + } else { + let it = text.chars(); + let mut it = it.skip(ccursor.index - 1); + if let Some(char_before_cursor) = it.next() { + if let Some(char_after_cursor) = it.next() { + if is_word_char(char_before_cursor) && is_word_char(char_after_cursor) { + let min = ccursor_previous_word(text, ccursor + 1); + let max = ccursor_next_word(text, min); + CCursorRange::two(min, max) + } else if is_word_char(char_before_cursor) { + let min = ccursor_previous_word(text, ccursor); + let max = ccursor_next_word(text, min); + CCursorRange::two(min, max) + } else if is_word_char(char_after_cursor) { + let max = ccursor_next_word(text, ccursor); + CCursorRange::two(ccursor, max) + } else { + let min = ccursor_previous_word(text, ccursor); + let max = ccursor_next_word(text, ccursor); + CCursorRange::two(min, max) + } + } else { + let min = ccursor_previous_word(text, ccursor); + CCursorRange::two(min, ccursor) + } + } else { + let max = ccursor_next_word(text, ccursor); + CCursorRange::two(ccursor, max) + } + } +} + +fn select_line_at(text: &str, ccursor: CCursor) -> CCursorRange { + if ccursor.index == 0 { + CCursorRange::two(ccursor, ccursor_next_line(text, ccursor)) + } else { + let it = text.chars(); + let mut it = it.skip(ccursor.index - 1); + if let Some(char_before_cursor) = it.next() { + if let Some(char_after_cursor) = it.next() { + if (!is_linebreak(char_before_cursor)) && (!is_linebreak(char_after_cursor)) { + let min = ccursor_previous_line(text, ccursor + 1); + let max = ccursor_next_line(text, min); + CCursorRange::two(min, max) + } else if !is_linebreak(char_before_cursor) { + let min = ccursor_previous_line(text, ccursor); + let max = ccursor_next_line(text, min); + CCursorRange::two(min, max) + } else if !is_linebreak(char_after_cursor) { + let max = ccursor_next_line(text, ccursor); + CCursorRange::two(ccursor, max) + } else { + let min = ccursor_previous_line(text, ccursor); + let max = ccursor_next_line(text, ccursor); + CCursorRange::two(min, max) + } + } else { + let min = ccursor_previous_line(text, ccursor); + CCursorRange::two(min, ccursor) + } + } else { + let max = ccursor_next_line(text, ccursor); + CCursorRange::two(ccursor, max) + } + } +} + +pub fn ccursor_next_word(text: &str, ccursor: CCursor) -> CCursor { + CCursor { + index: next_word_boundary_char_index(text.chars(), ccursor.index), + prefer_next_row: false, + } +} + +fn ccursor_next_line(text: &str, ccursor: CCursor) -> CCursor { + CCursor { + index: next_line_boundary_char_index(text.chars(), ccursor.index), + prefer_next_row: false, + } +} + +pub fn ccursor_previous_word(text: &str, ccursor: CCursor) -> CCursor { + let num_chars = text.chars().count(); + CCursor { + index: num_chars + - next_word_boundary_char_index(text.chars().rev(), num_chars - ccursor.index), + prefer_next_row: true, + } +} + +fn ccursor_previous_line(text: &str, ccursor: CCursor) -> CCursor { + let num_chars = text.chars().count(); + CCursor { + index: num_chars + - next_line_boundary_char_index(text.chars().rev(), num_chars - ccursor.index), + prefer_next_row: true, + } +} + +fn next_word_boundary_char_index(it: impl Iterator, mut index: usize) -> usize { + let mut it = it.skip(index); + if let Some(_first) = it.next() { + index += 1; + + if let Some(second) = it.next() { + index += 1; + for next in it { + if is_word_char(next) != is_word_char(second) { + break; + } + index += 1; + } + } + } + index +} + +fn next_line_boundary_char_index(it: impl Iterator, mut index: usize) -> usize { + let mut it = it.skip(index); + if let Some(_first) = it.next() { + index += 1; + + if let Some(second) = it.next() { + index += 1; + for next in it { + if is_linebreak(next) != is_linebreak(second) { + break; + } + index += 1; + } + } + } + index +} + +pub fn is_word_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +fn is_linebreak(c: char) -> bool { + c == '\r' || c == '\n' +} + +/// Accepts and returns character offset (NOT byte offset!). +pub fn find_line_start(text: &str, current_index: CCursor) -> CCursor { + // We know that new lines, '\n', are a single byte char, but we have to + // work with char offsets because before the new line there may be any + // number of multi byte chars. + // We need to know the char index to be able to correctly set the cursor + // later. + let chars_count = text.chars().count(); + + let position = text + .chars() + .rev() + .skip(chars_count - current_index.index) + .position(|x| x == '\n'); + + match position { + Some(pos) => CCursor::new(current_index.index - pos), + None => CCursor::new(0), + } +} + +pub fn byte_index_from_char_index(s: &str, char_index: usize) -> usize { + for (ci, (bi, _)) in s.char_indices().enumerate() { + if ci == char_index { + return bi; + } + } + s.len() +} + +pub fn slice_char_range(s: &str, char_range: std::ops::Range) -> &str { + assert!(char_range.start <= char_range.end); + let start_byte = byte_index_from_char_index(s, char_range.start); + let end_byte = byte_index_from_char_index(s, char_range.end); + &s[start_byte..end_byte] +} + +/// The thin rectangle of one end of the selection, e.g. the primary cursor. +pub fn cursor_rect(galley_pos: Pos2, galley: &Galley, cursor: &Cursor, row_height: f32) -> Rect { + let mut cursor_pos = galley + .pos_from_cursor(cursor) + .translate(galley_pos.to_vec2()); + cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height); + // Handle completely empty galleys + cursor_pos = cursor_pos.expand(1.5); + // slightly above/below row + cursor_pos +} diff --git a/crates/egui/src/widgets/text_edit/cursor_range.rs b/crates/egui/src/widgets/text_edit/cursor_range.rs index 10668a562c1..e0ddfd6953f 100644 --- a/crates/egui/src/widgets/text_edit/cursor_range.rs +++ b/crates/egui/src/widgets/text_edit/cursor_range.rs @@ -1,4 +1,8 @@ -use epaint::text::cursor::*; +use epaint::{text::cursor::*, Galley}; + +use crate::{Event, Id, Key, Modifiers}; + +use super::cursor_interaction::{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)] @@ -16,6 +20,7 @@ pub struct CursorRange { impl CursorRange { /// The empty range. + #[inline] pub fn one(cursor: Cursor) -> Self { Self { primary: cursor, @@ -23,6 +28,7 @@ impl CursorRange { } } + #[inline] pub fn two(min: Cursor, max: Cursor) -> Self { Self { primary: max, @@ -30,6 +36,11 @@ impl CursorRange { } } + /// Select all the text in a galley + pub fn select_all(galley: &Galley) -> Self { + Self::two(Cursor::default(), galley.end()) + } + pub fn as_ccursor_range(&self) -> CCursorRange { CCursorRange { primary: self.primary.ccursor, @@ -47,10 +58,19 @@ impl CursorRange { } /// True if the selected range contains no characters. + #[inline] pub fn is_empty(&self) -> bool { self.primary.ccursor == self.secondary.ccursor } + /// Is `self` a super-set of the the other range? + pub fn contains(&self, other: &Self) -> bool { + let [self_min, self_max] = self.sorted_cursors(); + let [other_min, other_max] = other.sorted_cursors(); + self_min.ccursor.index <= other_min.ccursor.index + && other_max.ccursor.index <= self_max.ccursor.index + } + /// If there is a selection, None is returned. /// If the two ends is the same, that is returned. pub fn single(&self) -> Option { @@ -86,6 +106,93 @@ impl CursorRange { [self.secondary, self.primary] } } + + pub fn slice_str<'s>(&self, text: &'s str) -> &'s str { + let [min, max] = self.sorted_cursors(); + slice_char_range(text, min.ccursor.index..max.ccursor.index) + } + + /// Check for key presses that are moving the cursor. + /// + /// Returns `true` if we did mutate `self`. + pub fn on_key_press(&mut self, galley: &Galley, modifiers: &Modifiers, key: Key) -> bool { + match key { + Key::A if modifiers.command => { + *self = Self::select_all(galley); + true + } + + Key::ArrowLeft | Key::ArrowRight if modifiers.is_none() && !self.is_empty() => { + if key == Key::ArrowLeft { + *self = Self::one(self.sorted_cursors()[0]); + } else { + *self = Self::one(self.sorted_cursors()[1]); + } + true + } + + Key::ArrowLeft + | Key::ArrowRight + | Key::ArrowUp + | Key::ArrowDown + | Key::Home + | Key::End => { + move_single_cursor(&mut self.primary, galley, key, modifiers); + if !modifiers.shift { + self.secondary = self.primary; + } + true + } + + Key::P | Key::N | Key::B | Key::F | Key::A | Key::E + if cfg!(target_os = "macos") && modifiers.ctrl && !modifiers.shift => + { + move_single_cursor(&mut self.primary, galley, key, modifiers); + self.secondary = self.primary; + true + } + + _ => false, + } + } + + /// Check for events that modify the cursor range. + /// + /// Returns `true` if such an event was found and handled. + pub fn on_event(&mut self, event: &Event, galley: &Galley, _widget_id: Id) -> bool { + match event { + Event::Key { + modifiers, + key, + pressed: true, + .. + } => self.on_key_press(galley, modifiers, *key), + + #[cfg(feature = "accesskit")] + Event::AccessKitActionRequest(accesskit::ActionRequest { + action: accesskit::Action::SetTextSelection, + target, + data: Some(accesskit::ActionData::SetTextSelection(selection)), + }) => { + if _widget_id.accesskit_id() == *target { + let primary = + ccursor_from_accesskit_text_position(_widget_id, galley, &selection.focus); + let secondary = + ccursor_from_accesskit_text_position(_widget_id, galley, &selection.anchor); + if let (Some(primary), Some(secondary)) = (primary, secondary) { + *self = Self { + primary: galley.from_ccursor(primary), + secondary: galley.from_ccursor(secondary), + }; + return true; + } + } + false + } + + _ => false, + } + } } /// A selected text range (could be a range of length zero). @@ -106,6 +213,7 @@ pub struct CCursorRange { impl CCursorRange { /// The empty range. + #[inline] pub fn one(ccursor: CCursor) -> Self { Self { primary: ccursor, @@ -113,6 +221,7 @@ impl CCursorRange { } } + #[inline] pub fn two(min: CCursor, max: CCursor) -> Self { Self { primary: max, @@ -120,6 +229,7 @@ impl CCursorRange { } } + #[inline] pub fn is_sorted(&self) -> bool { let p = self.primary; let s = self.secondary; @@ -127,6 +237,7 @@ impl CCursorRange { } /// returns the two ends ordered + #[inline] pub fn sorted(&self) -> [CCursor; 2] { if self.is_sorted() { [self.primary, self.secondary] @@ -148,3 +259,102 @@ pub struct PCursorRange { /// This part of the cursor does not move when shift is down. pub secondary: PCursor, } + +// ---------------------------------------------------------------------------- + +#[cfg(feature = "accesskit")] +fn ccursor_from_accesskit_text_position( + id: Id, + galley: &Galley, + position: &accesskit::TextPosition, +) -> Option { + let mut total_length = 0usize; + for (i, row) in galley.rows.iter().enumerate() { + let row_id = id.with(i); + if row_id.accesskit_id() == position.node { + return Some(CCursor { + index: total_length + position.character_index, + prefer_next_row: !(position.character_index == row.glyphs.len() + && !row.ends_with_newline + && (i + 1) < galley.rows.len()), + }); + } + total_length += row.glyphs.len() + (row.ends_with_newline as usize); + } + None +} + +// ---------------------------------------------------------------------------- + +/// Move a text cursor based on keyboard +fn move_single_cursor(cursor: &mut Cursor, galley: &Galley, key: Key, modifiers: &Modifiers) { + if cfg!(target_os = "macos") && modifiers.ctrl && !modifiers.shift { + match key { + Key::A => *cursor = galley.cursor_begin_of_row(cursor), + Key::E => *cursor = galley.cursor_end_of_row(cursor), + Key::P => *cursor = galley.cursor_up_one_row(cursor), + Key::N => *cursor = galley.cursor_down_one_row(cursor), + Key::B => *cursor = galley.cursor_left_one_character(cursor), + Key::F => *cursor = galley.cursor_right_one_character(cursor), + _ => (), + } + return; + } + match key { + Key::ArrowLeft => { + if modifiers.alt || modifiers.ctrl { + // alt on mac, ctrl on windows + *cursor = galley.from_ccursor(ccursor_previous_word(galley, cursor.ccursor)); + } else if modifiers.mac_cmd { + *cursor = galley.cursor_begin_of_row(cursor); + } else { + *cursor = galley.cursor_left_one_character(cursor); + } + } + Key::ArrowRight => { + if modifiers.alt || modifiers.ctrl { + // alt on mac, ctrl on windows + *cursor = galley.from_ccursor(ccursor_next_word(galley, cursor.ccursor)); + } else if modifiers.mac_cmd { + *cursor = galley.cursor_end_of_row(cursor); + } else { + *cursor = galley.cursor_right_one_character(cursor); + } + } + Key::ArrowUp => { + if modifiers.command { + // mac and windows behavior + *cursor = Cursor::default(); + } else { + *cursor = galley.cursor_up_one_row(cursor); + } + } + Key::ArrowDown => { + if modifiers.command { + // mac and windows behavior + *cursor = galley.end(); + } else { + *cursor = galley.cursor_down_one_row(cursor); + } + } + + Key::Home => { + if modifiers.ctrl { + // windows behavior + *cursor = Cursor::default(); + } else { + *cursor = galley.cursor_begin_of_row(cursor); + } + } + Key::End => { + if modifiers.ctrl { + // windows behavior + *cursor = galley.end(); + } else { + *cursor = galley.cursor_end_of_row(cursor); + } + } + + _ => unreachable!(), + } +} diff --git a/crates/egui/src/widgets/text_edit/mod.rs b/crates/egui/src/widgets/text_edit/mod.rs index 3db990a11a8..3e2e7fba6d1 100644 --- a/crates/egui/src/widgets/text_edit/mod.rs +++ b/crates/egui/src/widgets/text_edit/mod.rs @@ -1,10 +1,18 @@ 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::TextEdit, cursor_range::*, output::TextEditOutput, state::TextEditState, + builder::{paint_cursor_selection, TextEdit}, + cursor_range::*, + output::TextEditOutput, + state::TextCursorState, + 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 0ff45d040aa..2ad24b0f96f 100644 --- a/crates/egui/src/widgets/text_edit/output.rs +++ b/crates/egui/src/widgets/text_edit/output.rs @@ -9,7 +9,7 @@ pub struct TextEditOutput { pub galley: Arc, /// Where the text in [`Self::galley`] ended up on the screen. - pub text_draw_pos: crate::Pos2, + pub galley_pos: crate::Pos2, /// The text was clipped to this rectangle when painted. pub text_clip_rect: crate::Rect, @@ -21,4 +21,11 @@ pub struct TextEditOutput { pub cursor_range: Option, } +impl TextEditOutput { + #[deprecated = "Renamed `self.galley_pos`"] + pub fn text_draw_pos(&self) -> crate::Pos2 { + self.galley_pos + } +} + // TODO(emilk): add `output.paint` and `output.store` and split out that code from `TextEdit::show`. diff --git a/crates/egui/src/widgets/text_edit/state.rs b/crates/egui/src/widgets/text_edit/state.rs index ef863eded2e..e5865bd73f7 100644 --- a/crates/egui/src/widgets/text_edit/state.rs +++ b/crates/egui/src/widgets/text_edit/state.rs @@ -8,6 +8,68 @@ use super::{CCursorRange, CursorRange}; 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. @@ -34,11 +96,8 @@ pub type TextEditUndoer = crate::util::undoer::Undoer<(CCursorRange, String)>; #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] pub struct TextEditState { - 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, + /// Controls the text selection. + pub cursor: TextCursorState, /// Wrapped in Arc for cheaper clones. #[cfg_attr(feature = "serde", serde(skip))] @@ -63,22 +122,20 @@ impl TextEditState { } /// The the currently selected range of characters. + #[deprecated = "Use `self.cursor.char_range` instead"] pub fn ccursor_range(&self) -> Option { - self.ccursor_range.or_else(|| { - self.cursor_range - .map(|cursor_range| cursor_range.as_ccursor_range()) - }) + self.cursor.char_range() } /// Sets the currently selected range of characters. + #[deprecated = "Use `self.cursor.set_char_range` instead"] pub fn set_ccursor_range(&mut self, ccursor_range: Option) { - self.cursor_range = None; - self.ccursor_range = ccursor_range; + self.cursor.set_char_range(ccursor_range); } + #[deprecated = "Use `self.cursor.set_range` instead"] pub fn set_cursor_range(&mut self, cursor_range: Option) { - self.cursor_range = cursor_range; - self.ccursor_range = None; + self.cursor.set_range(cursor_range); } pub fn undoer(&self) -> TextEditUndoer { @@ -93,26 +150,8 @@ impl TextEditState { self.set_undoer(TextEditUndoer::default()); } + #[deprecated = "Use `self.cursor.range` instead"] pub fn cursor_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), - }) - }) + self.cursor.range(galley) } } diff --git a/crates/egui/src/widgets/text_edit/text_buffer.rs b/crates/egui/src/widgets/text_edit/text_buffer.rs index 58f5afd30fa..f02981b6a6a 100644 --- a/crates/egui/src/widgets/text_edit/text_buffer.rs +++ b/crates/egui/src/widgets/text_edit/text_buffer.rs @@ -1,5 +1,7 @@ use std::{borrow::Cow, ops::Range}; +use super::cursor_interaction::{byte_index_from_char_index, slice_char_range}; + /// Trait constraining what types [`crate::TextEdit`] may use as /// an underlying buffer. /// @@ -13,10 +15,7 @@ pub trait TextBuffer { /// Reads the given character range. fn char_range(&self, char_range: Range) -> &str { - assert!(char_range.start <= char_range.end); - let start_byte = self.byte_index_from_char_index(char_range.start); - let end_byte = self.byte_index_from_char_index(char_range.end); - &self.as_str()[start_byte..end_byte] + slice_char_range(self.as_str(), char_range) } fn byte_index_from_char_index(&self, char_index: usize) -> usize { @@ -68,7 +67,7 @@ impl TextBuffer for String { fn insert_text(&mut self, text: &str, char_index: usize) -> usize { // Get the byte index from the character index - let byte_idx = self.byte_index_from_char_index(char_index); + let byte_idx = byte_index_from_char_index(self.as_str(), char_index); // Then insert the string self.insert_str(byte_idx, text); @@ -80,8 +79,8 @@ impl TextBuffer for String { assert!(char_range.start <= char_range.end); // Get both byte indices - let byte_start = self.byte_index_from_char_index(char_range.start); - let byte_end = self.byte_index_from_char_index(char_range.end); + let byte_start = byte_index_from_char_index(self.as_str(), char_range.start); + let byte_end = byte_index_from_char_index(self.as_str(), char_range.end); // Then drain all characters within this range self.drain(byte_start..byte_end); @@ -146,12 +145,3 @@ impl<'a> TextBuffer for &'a str { fn delete_char_range(&mut self, _ch_range: Range) {} } - -fn byte_index_from_char_index(s: &str, char_index: usize) -> usize { - for (ci, (bi, _)) in s.char_indices().enumerate() { - if ci == char_index { - return bi; - } - } - s.len() -} diff --git a/crates/egui_demo_app/src/apps/http_app.rs b/crates/egui_demo_app/src/apps/http_app.rs index 7e8d7e54c48..afc378aa46c 100644 --- a/crates/egui_demo_app/src/apps/http_app.rs +++ b/crates/egui_demo_app/src/apps/http_app.rs @@ -205,21 +205,13 @@ fn ui_resource(ui: &mut egui::Ui, resource: &Resource) { } else if let Some(colored_text) = colored_text { colored_text.ui(ui); } else if let Some(text) = &text { - selectable_text(ui, text); + ui.add(egui::Label::new(text).selectable(true)); } else { ui.monospace("[binary]"); } }); } -fn selectable_text(ui: &mut egui::Ui, mut text: &str) { - ui.add( - egui::TextEdit::multiline(&mut text) - .desired_width(f32::INFINITY) - .font(egui::TextStyle::Monospace), - ); -} - // ---------------------------------------------------------------------------- // Syntax highlighting: @@ -240,31 +232,9 @@ struct ColoredText(egui::text::LayoutJob); impl ColoredText { pub fn ui(&self, ui: &mut egui::Ui) { - if true { - // Selectable text: - let mut layouter = |ui: &egui::Ui, _string: &str, wrap_width: f32| { - let mut layout_job = self.0.clone(); - layout_job.wrap.max_width = wrap_width; - ui.fonts(|f| f.layout_job(layout_job)) - }; - - let mut text = self.0.text.as_str(); - ui.add( - egui::TextEdit::multiline(&mut text) - .font(egui::TextStyle::Monospace) - .desired_width(f32::INFINITY) - .layouter(&mut layouter), - ); - } else { - let mut job = self.0.clone(); - job.wrap.max_width = ui.available_width(); - let galley = ui.fonts(|f| f.layout_job(job)); - let (response, painter) = ui.allocate_painter(galley.size(), egui::Sense::hover()); - painter.add(egui::Shape::galley( - response.rect.min, - galley, - ui.visuals().text_color(), - )); - } + let mut job = self.0.clone(); + job.wrap.max_width = ui.available_width(); + let galley = ui.fonts(|f| f.layout_job(job)); + ui.add(egui::Label::new(galley).selectable(true)); } } diff --git a/crates/egui_demo_lib/src/demo/text_edit.rs b/crates/egui_demo_lib/src/demo/text_edit.rs index 5f9e882a55f..520416cc5b3 100644 --- a/crates/egui_demo_lib/src/demo/text_edit.rs +++ b/crates/egui_demo_lib/src/demo/text_edit.rs @@ -53,9 +53,7 @@ impl super::View for TextEditDemo { ui.spacing_mut().item_spacing.x = 0.0; ui.label("Selected text: "); if let Some(text_cursor_range) = output.cursor_range { - use egui::TextBuffer as _; - let selected_chars = text_cursor_range.as_sorted_char_range(); - let selected_text = text.char_range(selected_chars); + let selected_text = text_cursor_range.slice_str(text); ui.code(selected_text); } }); @@ -92,7 +90,9 @@ impl super::View for TextEditDemo { let text_edit_id = output.response.id; if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { let ccursor = egui::text::CCursor::new(0); - state.set_ccursor_range(Some(egui::text::CCursorRange::one(ccursor))); + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::one(ccursor))); state.store(ui.ctx(), text_edit_id); ui.ctx().memory_mut(|mem| mem.request_focus(text_edit_id)); // give focus back to the [`TextEdit`]. } @@ -102,7 +102,9 @@ impl super::View for TextEditDemo { let text_edit_id = output.response.id; if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { let ccursor = egui::text::CCursor::new(text.chars().count()); - state.set_ccursor_range(Some(egui::text::CCursorRange::one(ccursor))); + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::one(ccursor))); state.store(ui.ctx(), text_edit_id); ui.ctx().memory_mut(|mem| mem.request_focus(text_edit_id)); // give focus back to the [`TextEdit`]. } 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 04cbe5a16e2..95fb5b7d5ad 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 @@ -95,10 +95,10 @@ impl EasyMarkEditor { }; if let Some(mut state) = TextEdit::load_state(ui.ctx(), response.id) { - if let Some(mut ccursor_range) = state.ccursor_range() { + if let Some(mut ccursor_range) = state.cursor.char_range() { let any_change = shortcuts(ui, code, &mut ccursor_range); if any_change { - state.set_ccursor_range(Some(ccursor_range)); + state.cursor.set_char_range(Some(ccursor_range)); state.store(ui.ctx(), response.id); } } diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 6d9748aa33a..e1566becfb7 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -142,7 +142,7 @@ impl LayoutJob { } } - #[inline(always)] + #[inline] pub fn is_empty(&self) -> bool { self.sections.is_empty() } @@ -618,22 +618,45 @@ impl Row { } impl Galley { - #[inline(always)] + #[inline] pub fn is_empty(&self) -> bool { self.job.is_empty() } /// The full, non-elided text of the input job. - #[inline(always)] + #[inline] pub fn text(&self) -> &str { &self.job.text } + #[inline] pub fn size(&self) -> Vec2 { self.rect.size() } } +impl AsRef for Galley { + #[inline] + fn as_ref(&self) -> &str { + self.text() + } +} + +impl std::borrow::Borrow for Galley { + #[inline] + fn borrow(&self) -> &str { + self.text() + } +} + +impl std::ops::Deref for Galley { + type Target = str; + #[inline] + fn deref(&self) -> &str { + self.text() + } +} + // ---------------------------------------------------------------------------- /// ## Physical positions diff --git a/scripts/check.sh b/scripts/check.sh index ab3063d91ca..06db5f87e89 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -10,7 +10,7 @@ set -x # Basically does what the CI does. cargo install --quiet cargo-cranky # Uses lints defined in Cranky.toml. See https://github.com/ericseppanen/cargo-cranky -cargo install --quiet typos-cli +cargo +1.75.0 install --quiet typos-cli # web_sys_unstable_apis is required to enable the web_sys clipboard API which eframe web uses, # as well as by the wasm32-backend of the wgpu crate.