Refactor: move text selection logic to own module (#3843)
This is a follow-up to https://github.com/emilk/egui/issues/3804 and a pre-requisite for https://github.com/emilk/egui/issues/3816
This commit is contained in:
parent
3936418f0b
commit
f034f6db9f
|
|
@ -364,6 +364,7 @@ pub(crate) mod placer;
|
||||||
mod response;
|
mod response;
|
||||||
mod sense;
|
mod sense;
|
||||||
pub mod style;
|
pub mod style;
|
||||||
|
pub mod text_selection;
|
||||||
mod ui;
|
mod ui;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
pub mod viewport;
|
pub mod viewport;
|
||||||
|
|
@ -398,7 +399,7 @@ pub use epaint::{
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod text {
|
pub mod text {
|
||||||
pub use crate::text_edit::CCursorRange;
|
pub use crate::text_selection::{CCursorRange, CursorRange};
|
||||||
pub use epaint::text::{
|
pub use epaint::text::{
|
||||||
cursor::CCursor, FontData, FontDefinitions, FontFamily, Fonts, Galley, LayoutJob,
|
cursor::CCursor, FontData, FontDefinitions, FontFamily, Fonts, Galley, LayoutJob,
|
||||||
LayoutSection, TextFormat, TextWrapping, TAB_SIZE,
|
LayoutSection, TextFormat, TextWrapping, TAB_SIZE,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use crate::{Context, Galley, Id, Pos2};
|
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.
|
/// Update accesskit with the current text state.
|
||||||
pub fn update_accesskit_for_text_widget(
|
pub fn update_accesskit_for_text_widget(
|
||||||
|
|
@ -2,7 +2,7 @@ use epaint::{text::cursor::*, Galley};
|
||||||
|
|
||||||
use crate::{os::OperatingSystem, Event, Id, Key, Modifiers};
|
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).
|
/// A selected text range (could be a range of length zero).
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
use epaint::{Galley, Pos2};
|
||||||
|
|
||||||
|
use crate::{Context, CursorIcon, Event, Id, Response, Ui};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
text_cursor_state::cursor_rect, visuals::paint_text_selection, CursorRange, TextCursorState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Handle text selection state for a label or similar widget.
|
||||||
|
///
|
||||||
|
/// Make sure the widget senses clicks and drags.
|
||||||
|
///
|
||||||
|
/// This should be called after painting the text, because this will also
|
||||||
|
/// paint the text cursor/selection on top.
|
||||||
|
pub fn label_text_selection(ui: &Ui, response: &Response, galley_pos: Pos2, galley: &Galley) {
|
||||||
|
let mut cursor_state = LabelSelectionState::load(ui.ctx(), response.id);
|
||||||
|
let original_cursor = cursor_state.range(galley);
|
||||||
|
|
||||||
|
if response.hovered {
|
||||||
|
ui.ctx().set_cursor_icon(CursorIcon::Text);
|
||||||
|
} else if !cursor_state.is_empty() && ui.input(|i| i.pointer.any_pressed()) {
|
||||||
|
// We clicked somewhere else - deselect this label.
|
||||||
|
cursor_state = Default::default();
|
||||||
|
LabelSelectionState::store(ui.ctx(), response.id, cursor_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
|
||||||
|
let cursor_at_pointer = galley.cursor_from_pos(pointer_pos - galley_pos);
|
||||||
|
cursor_state.pointer_interaction(ui, response, cursor_at_pointer, galley);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mut cursor_range) = cursor_state.range(galley) {
|
||||||
|
process_selection_key_events(ui.ctx(), galley, response.id, &mut cursor_range);
|
||||||
|
cursor_state.set_range(Some(cursor_range));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor_range = cursor_state.range(galley);
|
||||||
|
|
||||||
|
if let Some(cursor_range) = cursor_range {
|
||||||
|
// We paint the cursor on top of the text, in case
|
||||||
|
// the text galley has backgrounds (as e.g. `code` snippets in markup do).
|
||||||
|
paint_text_selection(
|
||||||
|
ui.painter(),
|
||||||
|
ui.visuals(),
|
||||||
|
galley_pos,
|
||||||
|
galley,
|
||||||
|
&cursor_range,
|
||||||
|
);
|
||||||
|
|
||||||
|
let selection_changed = original_cursor != Some(cursor_range);
|
||||||
|
|
||||||
|
let is_fully_visible = ui.clip_rect().contains_rect(response.rect); // TODO: remove this HACK workaround for https://github.com/emilk/egui/issues/1531
|
||||||
|
|
||||||
|
if selection_changed && !is_fully_visible {
|
||||||
|
// Scroll to keep primary cursor in view:
|
||||||
|
let row_height = estimate_row_height(galley);
|
||||||
|
let primary_cursor_rect =
|
||||||
|
cursor_rect(galley_pos, galley, &cursor_range.primary, row_height);
|
||||||
|
ui.scroll_to_rect(primary_cursor_rect, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "accesskit")]
|
||||||
|
super::accesskit_text::update_accesskit_for_text_widget(
|
||||||
|
ui.ctx(),
|
||||||
|
response.id,
|
||||||
|
cursor_range,
|
||||||
|
accesskit::Role::StaticText,
|
||||||
|
galley_pos,
|
||||||
|
galley,
|
||||||
|
);
|
||||||
|
|
||||||
|
if !cursor_state.is_empty() {
|
||||||
|
LabelSelectionState::store(ui.ctx(), response.id, cursor_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles text selection in labels (NOT in [`crate::TextEdit`])s.
|
||||||
|
///
|
||||||
|
/// One state for all labels, because we only support text selection in one label at a time.
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
struct LabelSelectionState {
|
||||||
|
/// Id of the (only) label with a selection, if any
|
||||||
|
id: Option<Id>,
|
||||||
|
|
||||||
|
/// The current selection, if any.
|
||||||
|
selection: TextCursorState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LabelSelectionState {
|
||||||
|
/// Load the range of text of text that is selected for the given widget.
|
||||||
|
fn load(ctx: &Context, id: Id) -> TextCursorState {
|
||||||
|
ctx.data(|data| data.get_temp::<Self>(Id::NULL))
|
||||||
|
.and_then(|state| (state.id == Some(id)).then_some(state.selection))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the range of text of text that is selected for the given widget.
|
||||||
|
fn store(ctx: &Context, id: Id, selection: TextCursorState) {
|
||||||
|
ctx.data_mut(|data| {
|
||||||
|
data.insert_temp(
|
||||||
|
Id::NULL,
|
||||||
|
Self {
|
||||||
|
id: Some(id),
|
||||||
|
selection,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_selection_key_events(
|
||||||
|
ctx: &Context,
|
||||||
|
galley: &Galley,
|
||||||
|
widget_id: Id,
|
||||||
|
cursor_range: &mut CursorRange,
|
||||||
|
) {
|
||||||
|
let mut copy_text = None;
|
||||||
|
|
||||||
|
ctx.input(|i| {
|
||||||
|
// NOTE: we have a lock on ui/ctx here,
|
||||||
|
// so be careful to not call into `ui` or `ctx` again.
|
||||||
|
|
||||||
|
for event in &i.events {
|
||||||
|
match event {
|
||||||
|
Event::Copy | Event::Cut => {
|
||||||
|
// This logic means we can select everything in an ellided label (including the `…`)
|
||||||
|
// and still copy the entire un-ellided text!
|
||||||
|
let everything_is_selected =
|
||||||
|
cursor_range.contains(&CursorRange::select_all(galley));
|
||||||
|
|
||||||
|
let copy_everything = cursor_range.is_empty() || everything_is_selected;
|
||||||
|
|
||||||
|
if copy_everything {
|
||||||
|
copy_text = Some(galley.text().to_owned());
|
||||||
|
} else {
|
||||||
|
copy_text = Some(cursor_range.slice_str(galley).to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event => {
|
||||||
|
cursor_range.on_event(ctx.os(), event, galley, widget_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(copy_text) = copy_text {
|
||||||
|
ctx.copy_text(copy_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_row_height(galley: &Galley) -> f32 {
|
||||||
|
if let Some(row) = galley.rows.first() {
|
||||||
|
row.rect.height()
|
||||||
|
} else {
|
||||||
|
galley.size().y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,12 +1,73 @@
|
||||||
//! Text cursor changes/interaction, without modifying the text.
|
//! Text cursor changes/interaction, without modifying the text.
|
||||||
|
|
||||||
use epaint::text::{cursor::*, Galley};
|
use epaint::text::{cursor::*, Galley};
|
||||||
use text_edit::state::TextCursorState;
|
|
||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
use super::{CCursorRange, CursorRange};
|
use super::{CCursorRange, CursorRange};
|
||||||
|
|
||||||
|
/// The state of a text cursor selection.
|
||||||
|
///
|
||||||
|
/// Used for [`crate::TextEdit`] and [`crate::Label`].
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||||
|
#[cfg_attr(feature = "serde", serde(default))]
|
||||||
|
pub struct TextCursorState {
|
||||||
|
cursor_range: Option<CursorRange>,
|
||||||
|
|
||||||
|
/// This is what is easiest to work with when editing text,
|
||||||
|
/// so users are more likely to read/write this.
|
||||||
|
ccursor_range: Option<CCursorRange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextCursorState {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.cursor_range.is_none() && self.ccursor_range.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The the currently selected range of characters.
|
||||||
|
pub fn char_range(&self) -> Option<CCursorRange> {
|
||||||
|
self.ccursor_range.or_else(|| {
|
||||||
|
self.cursor_range
|
||||||
|
.map(|cursor_range| cursor_range.as_ccursor_range())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn range(&mut self, galley: &Galley) -> Option<CursorRange> {
|
||||||
|
self.cursor_range
|
||||||
|
.map(|cursor_range| {
|
||||||
|
// We only use the PCursor (paragraph number, and character offset within that paragraph).
|
||||||
|
// This is so that if we resize the [`TextEdit`] region, and text wrapping changes,
|
||||||
|
// we keep the same byte character offset from the beginning of the text,
|
||||||
|
// even though the number of rows changes
|
||||||
|
// (each paragraph can be several rows, due to word wrapping).
|
||||||
|
// The column (character offset) should be able to extend beyond the last word so that we can
|
||||||
|
// go down and still end up on the same column when we return.
|
||||||
|
CursorRange {
|
||||||
|
primary: galley.from_pcursor(cursor_range.primary.pcursor),
|
||||||
|
secondary: galley.from_pcursor(cursor_range.secondary.pcursor),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
self.ccursor_range.map(|ccursor_range| CursorRange {
|
||||||
|
primary: galley.from_ccursor(ccursor_range.primary),
|
||||||
|
secondary: galley.from_ccursor(ccursor_range.secondary),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the currently selected range of characters.
|
||||||
|
pub fn set_char_range(&mut self, ccursor_range: Option<CCursorRange>) {
|
||||||
|
self.cursor_range = None;
|
||||||
|
self.ccursor_range = ccursor_range;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_range(&mut self, cursor_range: Option<CursorRange>) {
|
||||||
|
self.cursor_range = cursor_range;
|
||||||
|
self.ccursor_range = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TextCursorState {
|
impl TextCursorState {
|
||||||
/// Handle clicking and/or dragging text.
|
/// Handle clicking and/or dragging text.
|
||||||
///
|
///
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ impl Widget for Link {
|
||||||
|
|
||||||
let selectable = ui.style().interaction.selectable_labels;
|
let selectable = ui.style().interaction.selectable_labels;
|
||||||
if selectable {
|
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() {
|
if response.hovered() {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{
|
use crate::*;
|
||||||
text_edit::{
|
|
||||||
cursor_interaction::cursor_rect, paint_cursor_selection, CursorRange, TextCursorState,
|
|
||||||
},
|
|
||||||
*,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Static text.
|
/// Static text.
|
||||||
///
|
///
|
||||||
|
|
@ -262,160 +257,10 @@ impl Widget for Label {
|
||||||
|
|
||||||
let selectable = selectable.unwrap_or_else(|| ui.style().interaction.selectable_labels);
|
let selectable = selectable.unwrap_or_else(|| ui.style().interaction.selectable_labels);
|
||||||
if selectable {
|
if selectable {
|
||||||
text_selection(ui, &response, galley_pos, &galley);
|
crate::text_selection::label_text_selection(ui, &response, galley_pos, &galley);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle text selection state for a label or similar widget.
|
|
||||||
///
|
|
||||||
/// Make sure the widget senses to clicks and drags.
|
|
||||||
///
|
|
||||||
/// This should be called after painting the text, because this will also
|
|
||||||
/// paint the text cursor/selection on top.
|
|
||||||
pub fn text_selection(ui: &Ui, response: &Response, galley_pos: Pos2, galley: &Galley) {
|
|
||||||
let mut cursor_state = LabelSelectionState::load(ui.ctx(), response.id);
|
|
||||||
let original_cursor = cursor_state.range(galley);
|
|
||||||
|
|
||||||
if response.hovered {
|
|
||||||
ui.ctx().set_cursor_icon(CursorIcon::Text);
|
|
||||||
} else if !cursor_state.is_empty() && ui.input(|i| i.pointer.any_pressed()) {
|
|
||||||
// We clicked somewhere else - deselect this label.
|
|
||||||
cursor_state = Default::default();
|
|
||||||
LabelSelectionState::store(ui.ctx(), response.id, cursor_state);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
|
|
||||||
let cursor_at_pointer = galley.cursor_from_pos(pointer_pos - galley_pos);
|
|
||||||
cursor_state.pointer_interaction(ui, response, cursor_at_pointer, galley);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(mut cursor_range) = cursor_state.range(galley) {
|
|
||||||
process_selection_key_events(ui, galley, response.id, &mut cursor_range);
|
|
||||||
cursor_state.set_range(Some(cursor_range));
|
|
||||||
}
|
|
||||||
|
|
||||||
let cursor_range = cursor_state.range(galley);
|
|
||||||
|
|
||||||
if let Some(cursor_range) = cursor_range {
|
|
||||||
// We paint the cursor on top of the text, in case
|
|
||||||
// the text galley has backgrounds (as e.g. `code` snippets in markup do).
|
|
||||||
paint_cursor_selection(
|
|
||||||
ui.visuals(),
|
|
||||||
ui.painter(),
|
|
||||||
galley_pos,
|
|
||||||
galley,
|
|
||||||
&cursor_range,
|
|
||||||
);
|
|
||||||
|
|
||||||
let selection_changed = original_cursor != Some(cursor_range);
|
|
||||||
|
|
||||||
let is_fully_visible = ui.clip_rect().contains_rect(response.rect); // TODO: remove this HACK workaround for https://github.com/emilk/egui/issues/1531
|
|
||||||
|
|
||||||
if selection_changed && !is_fully_visible {
|
|
||||||
// Scroll to keep primary cursor in view:
|
|
||||||
let row_height = estimate_row_height(galley);
|
|
||||||
let primary_cursor_rect =
|
|
||||||
cursor_rect(galley_pos, galley, &cursor_range.primary, row_height);
|
|
||||||
ui.scroll_to_rect(primary_cursor_rect, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "accesskit")]
|
|
||||||
text_edit::accesskit_text::update_accesskit_for_text_widget(
|
|
||||||
ui.ctx(),
|
|
||||||
response.id,
|
|
||||||
cursor_range,
|
|
||||||
accesskit::Role::StaticText,
|
|
||||||
galley_pos,
|
|
||||||
galley,
|
|
||||||
);
|
|
||||||
|
|
||||||
if !cursor_state.is_empty() {
|
|
||||||
LabelSelectionState::store(ui.ctx(), response.id, cursor_state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn estimate_row_height(galley: &Galley) -> f32 {
|
|
||||||
if let Some(row) = galley.rows.first() {
|
|
||||||
row.rect.height()
|
|
||||||
} else {
|
|
||||||
galley.size().y
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_selection_key_events(
|
|
||||||
ui: &Ui,
|
|
||||||
galley: &Galley,
|
|
||||||
widget_id: Id,
|
|
||||||
cursor_range: &mut CursorRange,
|
|
||||||
) {
|
|
||||||
let mut copy_text = None;
|
|
||||||
|
|
||||||
ui.input(|i| {
|
|
||||||
// NOTE: we have a lock on ui/ctx here,
|
|
||||||
// so be careful to not call into `ui` or `ctx` again.
|
|
||||||
|
|
||||||
for event in &i.events {
|
|
||||||
match event {
|
|
||||||
Event::Copy | Event::Cut => {
|
|
||||||
// This logic means we can select everything in an ellided label (including the `…`)
|
|
||||||
// and still copy the entire un-ellided text!
|
|
||||||
let everything_is_selected =
|
|
||||||
cursor_range.contains(&CursorRange::select_all(galley));
|
|
||||||
|
|
||||||
let copy_everything = cursor_range.is_empty() || everything_is_selected;
|
|
||||||
|
|
||||||
if copy_everything {
|
|
||||||
copy_text = Some(galley.text().to_owned());
|
|
||||||
} else {
|
|
||||||
copy_text = Some(cursor_range.slice_str(galley).to_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
event => {
|
|
||||||
cursor_range.on_event(ui.ctx().os(), event, galley, widget_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(copy_text) = copy_text {
|
|
||||||
ui.ctx().copy_text(copy_text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// One state for all labels, because we only support text selection in one label at a time.
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
|
||||||
struct LabelSelectionState {
|
|
||||||
/// Id of the (only) label with a selection, if any
|
|
||||||
id: Option<Id>,
|
|
||||||
|
|
||||||
/// The current selection, if any.
|
|
||||||
selection: TextCursorState,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LabelSelectionState {
|
|
||||||
fn load(ctx: &Context, id: Id) -> TextCursorState {
|
|
||||||
ctx.data(|data| data.get_temp::<Self>(Id::NULL))
|
|
||||||
.and_then(|state| (state.id == Some(id)).then_some(state.selection))
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store(ctx: &Context, id: Id, selection: TextCursorState) {
|
|
||||||
ctx.data_mut(|data| {
|
|
||||||
data.insert_temp(
|
|
||||||
Id::NULL,
|
|
||||||
Self {
|
|
||||||
id: Some(id),
|
|
||||||
selection,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,17 @@ use std::sync::Arc;
|
||||||
use epaint::text::{cursor::*, Galley, LayoutJob};
|
use epaint::text::{cursor::*, Galley, LayoutJob};
|
||||||
|
|
||||||
use crate::{
|
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::{
|
use super::{TextEditOutput, TextEditState};
|
||||||
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.
|
/// 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) {
|
if let Some(cursor_range) = state.cursor.range(&galley) {
|
||||||
// We paint the cursor on top of the text, in case
|
// We paint the cursor on top of the text, in case
|
||||||
// the text galley has backgrounds (as e.g. `code` snippets in markup do).
|
// the text galley has backgrounds (as e.g. `code` snippets in markup do).
|
||||||
paint_cursor_selection(
|
paint_text_selection(
|
||||||
ui.visuals(),
|
|
||||||
&painter,
|
&painter,
|
||||||
|
ui.visuals(),
|
||||||
galley_pos,
|
galley_pos,
|
||||||
&galley,
|
&galley,
|
||||||
&cursor_range,
|
&cursor_range,
|
||||||
|
|
@ -722,7 +726,7 @@ impl<'t> TextEdit<'t> {
|
||||||
accesskit::Role::TextInput
|
accesskit::Role::TextInput
|
||||||
};
|
};
|
||||||
|
|
||||||
super::accesskit_text::update_accesskit_for_text_widget(
|
crate::text_selection::accesskit_text::update_accesskit_for_text_widget(
|
||||||
ui.ctx(),
|
ui.ctx(),
|
||||||
id,
|
id,
|
||||||
cursor_range,
|
cursor_range,
|
||||||
|
|
@ -814,14 +818,14 @@ fn events(
|
||||||
Some(CCursorRange::default())
|
Some(CCursorRange::default())
|
||||||
} else {
|
} else {
|
||||||
copy_if_not_password(ui, cursor_range.slice_str(text.as_str()).to_owned());
|
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) => {
|
Event::Paste(text_to_insert) => {
|
||||||
if !text_to_insert.is_empty() {
|
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))
|
Some(CCursorRange::one(ccursor))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -831,9 +835,9 @@ fn events(
|
||||||
Event::Text(text_to_insert) => {
|
Event::Text(text_to_insert) => {
|
||||||
// Newlines are handled by `Key::Enter`.
|
// Newlines are handled by `Key::Enter`.
|
||||||
if !text_to_insert.is_empty() && text_to_insert != "\n" && text_to_insert != "\r" {
|
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))
|
Some(CCursorRange::one(ccursor))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -846,12 +850,12 @@ fn events(
|
||||||
modifiers,
|
modifiers,
|
||||||
..
|
..
|
||||||
} if multiline => {
|
} if multiline => {
|
||||||
let mut ccursor = delete_selected(text, &cursor_range);
|
let mut ccursor = text.delete_selected(&cursor_range);
|
||||||
if modifiers.shift {
|
if modifiers.shift {
|
||||||
// TODO(emilk): support removing indentation over a selection?
|
// TODO(emilk): support removing indentation over a selection?
|
||||||
decrease_indentation(&mut ccursor, text);
|
text.decrease_indentation(&mut ccursor);
|
||||||
} else {
|
} else {
|
||||||
insert_text(&mut ccursor, text, "\t", char_limit);
|
text.insert_text_at(&mut ccursor, "\t", char_limit);
|
||||||
}
|
}
|
||||||
Some(CCursorRange::one(ccursor))
|
Some(CCursorRange::one(ccursor))
|
||||||
}
|
}
|
||||||
|
|
@ -861,8 +865,8 @@ fn events(
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
if multiline {
|
if multiline {
|
||||||
let mut ccursor = delete_selected(text, &cursor_range);
|
let mut ccursor = text.delete_selected(&cursor_range);
|
||||||
insert_text(&mut ccursor, text, "\n", char_limit);
|
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
|
// TODO(emilk): if code editor, auto-indent by same leading tabs, + one if the lines end on an opening bracket
|
||||||
Some(CCursorRange::one(ccursor))
|
Some(CCursorRange::one(ccursor))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -924,10 +928,10 @@ fn events(
|
||||||
// empty prediction can be produced when user press backspace
|
// empty prediction can be produced when user press backspace
|
||||||
// or escape during ime. We should clear current text.
|
// or escape during ime. We should clear current text.
|
||||||
if text_mark != "\n" && text_mark != "\r" && state.has_ime {
|
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;
|
let start_cursor = ccursor;
|
||||||
if !text_mark.is_empty() {
|
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))
|
Some(CCursorRange::two(start_cursor, ccursor))
|
||||||
} else {
|
} 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.
|
// 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" {
|
if prediction != "\n" && prediction != "\r" {
|
||||||
state.has_ime = false;
|
state.has_ime = false;
|
||||||
let mut ccursor = delete_selected(text, &cursor_range);
|
let mut ccursor = text.delete_selected(&cursor_range);
|
||||||
if !prediction.is_empty() {
|
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))
|
Some(CCursorRange::one(ccursor))
|
||||||
} else {
|
} 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`.
|
/// Returns `Some(new_cursor)` if we did mutate `text`.
|
||||||
fn check_for_mutating_key_press(
|
fn check_for_mutating_key_press(
|
||||||
os: OperatingSystem,
|
os: OperatingSystem,
|
||||||
|
|
@ -1157,32 +994,32 @@ fn check_for_mutating_key_press(
|
||||||
match key {
|
match key {
|
||||||
Key::Backspace => {
|
Key::Backspace => {
|
||||||
let ccursor = if modifiers.mac_cmd {
|
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() {
|
} else if let Some(cursor) = cursor_range.single() {
|
||||||
if modifiers.alt || modifiers.ctrl {
|
if modifiers.alt || modifiers.ctrl {
|
||||||
// alt on mac, ctrl on windows
|
// alt on mac, ctrl on windows
|
||||||
delete_previous_word(text, cursor.ccursor)
|
text.delete_previous_word(cursor.ccursor)
|
||||||
} else {
|
} else {
|
||||||
delete_previous_char(text, cursor.ccursor)
|
text.delete_previous_char(cursor.ccursor)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
delete_selected(text, cursor_range)
|
text.delete_selected(cursor_range)
|
||||||
};
|
};
|
||||||
Some(CCursorRange::one(ccursor))
|
Some(CCursorRange::one(ccursor))
|
||||||
}
|
}
|
||||||
|
|
||||||
Key::Delete if !modifiers.shift || os != OperatingSystem::Windows => {
|
Key::Delete if !modifiers.shift || os != OperatingSystem::Windows => {
|
||||||
let ccursor = if modifiers.mac_cmd {
|
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() {
|
} else if let Some(cursor) = cursor_range.single() {
|
||||||
if modifiers.alt || modifiers.ctrl {
|
if modifiers.alt || modifiers.ctrl {
|
||||||
// alt on mac, ctrl on windows
|
// alt on mac, ctrl on windows
|
||||||
delete_next_word(text, cursor.ccursor)
|
text.delete_next_word(cursor.ccursor)
|
||||||
} else {
|
} else {
|
||||||
delete_next_char(text, cursor.ccursor)
|
text.delete_next_char(cursor.ccursor)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
delete_selected(text, cursor_range)
|
text.delete_selected(cursor_range)
|
||||||
};
|
};
|
||||||
let ccursor = CCursor {
|
let ccursor = CCursor {
|
||||||
prefer_next_row: true,
|
prefer_next_row: true,
|
||||||
|
|
@ -1192,25 +1029,25 @@ fn check_for_mutating_key_press(
|
||||||
}
|
}
|
||||||
|
|
||||||
Key::H if modifiers.ctrl => {
|
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))
|
Some(CCursorRange::one(ccursor))
|
||||||
}
|
}
|
||||||
|
|
||||||
Key::K if modifiers.ctrl => {
|
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))
|
Some(CCursorRange::one(ccursor))
|
||||||
}
|
}
|
||||||
|
|
||||||
Key::U if modifiers.ctrl => {
|
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))
|
Some(CCursorRange::one(ccursor))
|
||||||
}
|
}
|
||||||
|
|
||||||
Key::W if modifiers.ctrl => {
|
Key::W if modifiers.ctrl => {
|
||||||
let ccursor = if let Some(cursor) = cursor_range.single() {
|
let ccursor = if let Some(cursor) = cursor_range.single() {
|
||||||
delete_previous_word(text, cursor.ccursor)
|
text.delete_previous_word(cursor.ccursor)
|
||||||
} else {
|
} else {
|
||||||
delete_selected(text, cursor_range)
|
text.delete_selected(cursor_range)
|
||||||
};
|
};
|
||||||
Some(CCursorRange::one(ccursor))
|
Some(CCursorRange::one(ccursor))
|
||||||
}
|
}
|
||||||
|
|
@ -1218,28 +1055,3 @@ fn check_for_mutating_key_press(
|
||||||
_ => None,
|
_ => 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,9 @@
|
||||||
mod builder;
|
mod builder;
|
||||||
pub mod cursor_interaction;
|
|
||||||
mod cursor_range;
|
|
||||||
mod output;
|
mod output;
|
||||||
mod state;
|
mod state;
|
||||||
mod text_buffer;
|
mod text_buffer;
|
||||||
|
|
||||||
#[cfg(feature = "accesskit")]
|
|
||||||
pub mod accesskit_text;
|
|
||||||
|
|
||||||
pub use {
|
pub use {
|
||||||
builder::{paint_cursor_selection, TextEdit},
|
crate::text_selection::TextCursorState, builder::TextEdit, output::TextEditOutput,
|
||||||
cursor_range::*,
|
state::TextEditState, text_buffer::TextBuffer,
|
||||||
output::TextEditOutput,
|
|
||||||
state::TextCursorState,
|
|
||||||
state::TextEditState,
|
|
||||||
text_buffer::TextBuffer,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::text::CursorRange;
|
||||||
|
|
||||||
/// The output from a [`TextEdit`](crate::TextEdit).
|
/// The output from a [`TextEdit`](crate::TextEdit).
|
||||||
pub struct TextEditOutput {
|
pub struct TextEditOutput {
|
||||||
/// The interaction response.
|
/// The interaction response.
|
||||||
|
|
@ -18,7 +20,7 @@ pub struct TextEditOutput {
|
||||||
pub state: super::TextEditState,
|
pub state: super::TextEditState,
|
||||||
|
|
||||||
/// Where the text cursor is.
|
/// Where the text cursor is.
|
||||||
pub cursor_range: Option<super::CursorRange>,
|
pub cursor_range: Option<CursorRange>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextEditOutput {
|
impl TextEditOutput {
|
||||||
|
|
|
||||||
|
|
@ -4,82 +4,19 @@ use crate::mutex::Mutex;
|
||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
use super::{CCursorRange, CursorRange};
|
use self::text_selection::{CCursorRange, CursorRange, TextCursorState};
|
||||||
|
|
||||||
pub type TextEditUndoer = crate::util::undoer::Undoer<(CCursorRange, String)>;
|
pub type TextEditUndoer = crate::util::undoer::Undoer<(CCursorRange, String)>;
|
||||||
|
|
||||||
/// The state of a text cursor selection.
|
|
||||||
///
|
|
||||||
/// Used for [`crate::TextEdit`] and [`crate::Label`].
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
|
||||||
#[cfg_attr(feature = "serde", serde(default))]
|
|
||||||
pub struct TextCursorState {
|
|
||||||
cursor_range: Option<CursorRange>,
|
|
||||||
|
|
||||||
/// This is what is easiest to work with when editing text,
|
|
||||||
/// so users are more likely to read/write this.
|
|
||||||
ccursor_range: Option<CCursorRange>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TextCursorState {
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.cursor_range.is_none() && self.ccursor_range.is_none()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The the currently selected range of characters.
|
|
||||||
pub fn char_range(&self) -> Option<CCursorRange> {
|
|
||||||
self.ccursor_range.or_else(|| {
|
|
||||||
self.cursor_range
|
|
||||||
.map(|cursor_range| cursor_range.as_ccursor_range())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn range(&mut self, galley: &Galley) -> Option<CursorRange> {
|
|
||||||
self.cursor_range
|
|
||||||
.map(|cursor_range| {
|
|
||||||
// We only use the PCursor (paragraph number, and character offset within that paragraph).
|
|
||||||
// This is so that if we resize the [`TextEdit`] region, and text wrapping changes,
|
|
||||||
// we keep the same byte character offset from the beginning of the text,
|
|
||||||
// even though the number of rows changes
|
|
||||||
// (each paragraph can be several rows, due to word wrapping).
|
|
||||||
// The column (character offset) should be able to extend beyond the last word so that we can
|
|
||||||
// go down and still end up on the same column when we return.
|
|
||||||
CursorRange {
|
|
||||||
primary: galley.from_pcursor(cursor_range.primary.pcursor),
|
|
||||||
secondary: galley.from_pcursor(cursor_range.secondary.pcursor),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
self.ccursor_range.map(|ccursor_range| CursorRange {
|
|
||||||
primary: galley.from_ccursor(ccursor_range.primary),
|
|
||||||
secondary: galley.from_ccursor(ccursor_range.secondary),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the currently selected range of characters.
|
|
||||||
pub fn set_char_range(&mut self, ccursor_range: Option<CCursorRange>) {
|
|
||||||
self.cursor_range = None;
|
|
||||||
self.ccursor_range = ccursor_range;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_range(&mut self, cursor_range: Option<CursorRange>) {
|
|
||||||
self.cursor_range = cursor_range;
|
|
||||||
self.ccursor_range = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The text edit state stored between frames.
|
/// The text edit state stored between frames.
|
||||||
///
|
///
|
||||||
/// Attention: You also need to `store` the updated state.
|
/// 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| {
|
/// # egui::__run_test_ui(|ui| {
|
||||||
/// # let mut text = String::new();
|
/// # 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
|
/// // Create a new selection range
|
||||||
/// let min = CCursor::new(0);
|
/// let min = CCursor::new(0);
|
||||||
|
|
@ -87,7 +24,7 @@ impl TextCursorState {
|
||||||
/// let new_range = CCursorRange::two(min, max);
|
/// let new_range = CCursorRange::two(min, max);
|
||||||
///
|
///
|
||||||
/// // Update the state
|
/// // Update the state
|
||||||
/// output.state.set_ccursor_range(Some(new_range));
|
/// output.state.cursor.set_char_range(Some(new_range));
|
||||||
/// // Store the updated state
|
/// // Store the updated state
|
||||||
/// output.state.store(ui.ctx(), output.response.id);
|
/// output.state.store(ui.ctx(), output.response.id);
|
||||||
/// # });
|
/// # });
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
use std::{borrow::Cow, ops::Range};
|
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
|
/// Trait constraining what types [`crate::TextEdit`] may use as
|
||||||
/// an underlying buffer.
|
/// an underlying buffer.
|
||||||
|
|
@ -13,15 +27,6 @@ pub trait TextBuffer {
|
||||||
/// Returns this buffer as a `str`.
|
/// Returns this buffer as a `str`.
|
||||||
fn as_str(&self) -> &str;
|
fn as_str(&self) -> &str;
|
||||||
|
|
||||||
/// Reads the given character range.
|
|
||||||
fn char_range(&self, char_range: Range<usize>) -> &str {
|
|
||||||
slice_char_range(self.as_str(), char_range)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn byte_index_from_char_index(&self, char_index: usize) -> usize {
|
|
||||||
byte_index_from_char_index(self.as_str(), char_index)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inserts text `text` into this buffer at character index `char_index`.
|
/// Inserts text `text` into this buffer at character index `char_index`.
|
||||||
///
|
///
|
||||||
/// # Notes
|
/// # Notes
|
||||||
|
|
@ -37,6 +42,15 @@ pub trait TextBuffer {
|
||||||
/// `char_range` is a *character range*, not a byte range.
|
/// `char_range` is a *character range*, not a byte range.
|
||||||
fn delete_char_range(&mut self, char_range: Range<usize>);
|
fn delete_char_range(&mut self, char_range: Range<usize>);
|
||||||
|
|
||||||
|
/// Reads the given character range.
|
||||||
|
fn char_range(&self, char_range: Range<usize>) -> &str {
|
||||||
|
slice_char_range(self.as_str(), char_range)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_index_from_char_index(&self, char_index: usize) -> usize {
|
||||||
|
byte_index_from_char_index(self.as_str(), char_index)
|
||||||
|
}
|
||||||
|
|
||||||
/// Clears all characters in this buffer
|
/// Clears all characters in this buffer
|
||||||
fn clear(&mut self) {
|
fn clear(&mut self) {
|
||||||
self.delete_char_range(0..self.as_str().len());
|
self.delete_char_range(0..self.as_str().len());
|
||||||
|
|
@ -54,6 +68,119 @@ pub trait TextBuffer {
|
||||||
self.clear();
|
self.clear();
|
||||||
s
|
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 {
|
impl TextBuffer for String {
|
||||||
|
|
|
||||||
|
|
@ -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", derive(serde::Deserialize, serde::Serialize))]
|
||||||
#[cfg_attr(feature = "serde", serde(default))]
|
#[cfg_attr(feature = "serde", serde(default))]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue