mirror of
https://github.com/emilk/egui.git
synced 2026-08-29 04:40:03 -04:00
## Summary - Closes #8309 - [x] I have followed the instructions in the PR template `TextEdit` hint text was always aligned to `Align2::LEFT_TOP`, ignoring the alignment set via `TextEdit::horizontal_align` / `vertical_align`. This caused the hint text, the cursor, and the typed text to disagree on alignment: e.g. a centered `TextEdit` showed a left-aligned hint with a centered cursor. The hint text atoms now use the widget's `align`, so the hint matches the input text alignment. The default `align` is still `LEFT_TOP`, so multi line text edits (and the default styling) are unchanged. ### Root cause In `crates/egui/src/widgets/text_edit/builder.rs`, the hint-text branch hardcoded: ```rust atoms.push_right(atom.atom_align(Align2::LEFT_TOP)); ``` while the input-text branch used `.atom_align(self.align)`. The hint path now uses `align` as well. ### Drive-by: silence `clippy::unnecessary_wraps` in `egui_kittest::app_kind` `AppKind::run` returns `Option<egui::Response>`. The `Option` wrap is required when the `eframe` feature is enabled (the `Eframe` branch returns `None`), but `clippy::unnecessary_wraps` fires when `egui_kittest` is built standalone without the `eframe` feature (e.g. `cargo clippy -p egui_kittest`). The workspace CI run doesn't hit it because feature unification via `egui_demo_app` enables `eframe`, but it's a real annoyance for anyone linting the crate on its own. Added a scoped `#[cfg_attr(not(feature = "eframe"), expect(clippy::unnecessary_wraps))]` with an explanatory comment. ## Test plan - [x] Added `textedit_hint_text_should_follow_text_alignment` kittest regression in `crates/egui_kittest/tests/regression_tests.rs`. It fails before the fix (`hint_center_x=24.25` vs `edit_center_x=100`) and passes after. - [x] `cargo test -p egui` - [x] `cargo test -p egui_kittest --all-features --test regression_tests` - [x] `cargo clippy -p egui_kittest --all-features --test regression_tests -- -D warnings` - [x] `RUSTFLAGS="-D warnings" cargo clippy -p egui_kittest --lib` (pre-existing `unnecessary_wraps` now silenced) - [x] `cargo clippy -p egui -- -D warnings` - [x] `cargo fmt --check` --------- Co-authored-by: Lucas Meurer <hi@lucasmerlin.me>
1434 lines
53 KiB
Rust
1434 lines
53 KiB
Rust
use std::sync::Arc;
|
|
|
|
use emath::{Rect, TSTransform};
|
|
use epaint::text::{Galley, LayoutJob, TextWrapMode, cursor::CCursor};
|
|
|
|
use crate::{
|
|
Align, Align2, AsIdSalt, AtomExt as _, AtomKind, AtomLayout, Atoms, Color32, Context,
|
|
CursorIcon, Event, EventFilter, FontSelection, Frame, IMEPurpose, Id, IdSalt, ImeEvent,
|
|
IntoAtoms, IntoSizedResult, Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response,
|
|
Sense, SizedAtomKind, TextBuffer, TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState,
|
|
epaint,
|
|
os::OperatingSystem,
|
|
output::OutputEvent,
|
|
response,
|
|
text_edit::state::TextEditCursorPurpose,
|
|
text_selection::{
|
|
self, CCursorRange, text_cursor_state::cursor_rect, visuals::paint_text_selection,
|
|
},
|
|
vec2,
|
|
};
|
|
|
|
use super::{TextEditOutput, TextEditState};
|
|
|
|
type LayouterFn<'t> = &'t mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley>;
|
|
|
|
/// A text region that the user can edit the contents of.
|
|
///
|
|
/// See also [`Ui::text_edit_singleline`] and [`Ui::text_edit_multiline`].
|
|
///
|
|
/// Example:
|
|
///
|
|
/// ```
|
|
/// # egui::__run_test_ui(|ui| {
|
|
/// # let mut my_string = String::new();
|
|
/// let response = ui.add(egui::TextEdit::singleline(&mut my_string));
|
|
/// if response.changed() {
|
|
/// // …
|
|
/// }
|
|
/// if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
|
/// // …
|
|
/// }
|
|
/// # });
|
|
/// ```
|
|
///
|
|
/// To fill an [`Ui`] with a [`TextEdit`] use [`Ui::add_sized`]:
|
|
///
|
|
/// ```
|
|
/// # egui::__run_test_ui(|ui| {
|
|
/// # let mut my_string = String::new();
|
|
/// ui.add_sized(ui.available_size(), egui::TextEdit::multiline(&mut my_string));
|
|
/// # });
|
|
/// ```
|
|
///
|
|
///
|
|
/// You can also use [`TextEdit`] to show text that can be selected, but not edited.
|
|
/// To do so, pass in a `&mut` reference to a `&str`, for instance:
|
|
///
|
|
/// ```
|
|
/// fn selectable_text(ui: &mut egui::Ui, mut text: &str) {
|
|
/// ui.add(egui::TextEdit::multiline(&mut text));
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// ## Advanced usage
|
|
/// See [`TextEdit::show`].
|
|
///
|
|
/// ## Other
|
|
/// The background color of a [`crate::TextEdit`] is [`crate::Visuals::text_edit_bg_color`] or can be set with [`crate::TextEdit::background_color`].
|
|
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
|
|
pub struct TextEdit<'t> {
|
|
text: &'t mut dyn TextBuffer,
|
|
prefix: Atoms<'static>,
|
|
suffix: Atoms<'static>,
|
|
hint_text: Atoms<'static>,
|
|
id: Option<Id>,
|
|
id_salt: Option<IdSalt>,
|
|
font_selection: FontSelection,
|
|
text_color: Option<Color32>,
|
|
layouter: Option<LayouterFn<'t>>,
|
|
password: bool,
|
|
frame: Option<Frame>,
|
|
margin: Margin,
|
|
multiline: bool,
|
|
interactive: bool,
|
|
desired_width: Option<f32>,
|
|
desired_height_rows: usize,
|
|
event_filter: EventFilter,
|
|
cursor_at_end: bool,
|
|
min_size: Vec2,
|
|
align: Align2,
|
|
clip_text: bool,
|
|
char_limit: usize,
|
|
return_key: Option<KeyboardShortcut>,
|
|
background_color: Option<Color32>,
|
|
}
|
|
|
|
impl WidgetWithState for TextEdit<'_> {
|
|
type State = TextEditState;
|
|
}
|
|
|
|
impl TextEdit<'_> {
|
|
pub fn load_state(ctx: &Context, id: Id) -> Option<TextEditState> {
|
|
TextEditState::load(ctx, id)
|
|
}
|
|
|
|
pub fn store_state(ctx: &Context, id: Id, state: TextEditState) {
|
|
state.store(ctx, id);
|
|
}
|
|
}
|
|
|
|
impl<'t> TextEdit<'t> {
|
|
/// No newlines (`\n`) allowed. Pressing enter key will result in the [`TextEdit`] losing focus (`response.lost_focus`).
|
|
pub fn singleline(text: &'t mut dyn TextBuffer) -> Self {
|
|
Self {
|
|
desired_height_rows: 1,
|
|
multiline: false,
|
|
clip_text: true,
|
|
..Self::multiline(text)
|
|
}
|
|
}
|
|
|
|
/// A [`TextEdit`] for multiple lines. Pressing enter key will create a new line by default (can be changed with [`return_key`](TextEdit::return_key)).
|
|
pub fn multiline(text: &'t mut dyn TextBuffer) -> Self {
|
|
Self {
|
|
text,
|
|
prefix: Default::default(),
|
|
suffix: Default::default(),
|
|
hint_text: Default::default(),
|
|
id: None,
|
|
id_salt: None,
|
|
font_selection: Default::default(),
|
|
text_color: None,
|
|
layouter: None,
|
|
password: false,
|
|
frame: None,
|
|
margin: Margin::symmetric(4, 2),
|
|
multiline: true,
|
|
interactive: true,
|
|
desired_width: None,
|
|
desired_height_rows: 4,
|
|
event_filter: EventFilter {
|
|
// moving the cursor is really important
|
|
horizontal_arrows: true,
|
|
vertical_arrows: true,
|
|
tab: false, // tab is used to change focus, not to insert a tab character
|
|
..Default::default()
|
|
},
|
|
cursor_at_end: true,
|
|
min_size: Vec2::ZERO,
|
|
align: Align2::LEFT_TOP,
|
|
clip_text: false,
|
|
char_limit: usize::MAX,
|
|
return_key: Some(KeyboardShortcut::new(Modifiers::NONE, Key::Enter)),
|
|
background_color: None,
|
|
}
|
|
}
|
|
|
|
/// Build a [`TextEdit`] focused on code editing.
|
|
/// By default it comes with:
|
|
/// - monospaced font
|
|
/// - focus lock (tab will insert a tab character instead of moving focus)
|
|
pub fn code_editor(self) -> Self {
|
|
self.font(TextStyle::Monospace).lock_focus(true)
|
|
}
|
|
|
|
/// Use if you want to set an explicit [`Id`] for this widget.
|
|
#[inline]
|
|
pub fn id(mut self, id: Id) -> Self {
|
|
self.id = Some(id);
|
|
self
|
|
}
|
|
|
|
/// A source for the unique [`Id`], e.g. `.id_source("second_text_edit_field")` or `.id_source(loop_index)`.
|
|
#[inline]
|
|
pub fn id_source(self, id_salt: impl AsIdSalt) -> Self {
|
|
self.id_salt(id_salt)
|
|
}
|
|
|
|
/// A source for the unique [`Id`], e.g. `.id_salt("second_text_edit_field")` or `.id_salt(loop_index)`.
|
|
#[inline]
|
|
pub fn id_salt(mut self, id_salt: impl AsIdSalt) -> Self {
|
|
self.id_salt = Some(IdSalt::new(id_salt));
|
|
self
|
|
}
|
|
|
|
/// Show a faint hint text when the text field is empty.
|
|
///
|
|
/// If the hint text needs to be persisted even when the text field has input,
|
|
/// the following workaround can be used:
|
|
/// ```
|
|
/// # egui::__run_test_ui(|ui| {
|
|
/// # let mut my_string = String::new();
|
|
/// # use egui::{ Color32, FontId };
|
|
/// let text_edit = egui::TextEdit::multiline(&mut my_string)
|
|
/// .desired_width(f32::INFINITY);
|
|
/// let output = text_edit.show(ui);
|
|
/// let painter = ui.painter_at(output.response.rect);
|
|
/// let text_color = Color32::from_rgba_premultiplied(100, 100, 100, 100);
|
|
/// let galley = painter.layout(
|
|
/// String::from("Enter text"),
|
|
/// FontId::default(),
|
|
/// text_color,
|
|
/// f32::INFINITY
|
|
/// );
|
|
/// painter.galley(output.galley_pos, galley, text_color);
|
|
/// # });
|
|
/// ```
|
|
#[inline]
|
|
pub fn hint_text(mut self, hint_text: impl IntoAtoms<'static>) -> Self {
|
|
self.hint_text = hint_text.into_atoms();
|
|
self
|
|
}
|
|
|
|
/// Add a prefix to the text edit. This will always be shown before the editable text.
|
|
#[inline]
|
|
pub fn prefix(mut self, prefix: impl IntoAtoms<'static>) -> Self {
|
|
self.prefix = prefix.into_atoms();
|
|
self
|
|
}
|
|
|
|
/// Add a suffix to the text edit. This will always be shown after the editable text.
|
|
#[inline]
|
|
pub fn suffix(mut self, suffix: impl IntoAtoms<'static>) -> Self {
|
|
self.suffix = suffix.into_atoms();
|
|
self
|
|
}
|
|
|
|
/// Set the background color of the [`TextEdit`]. The default is [`crate::Visuals::text_edit_bg_color`].
|
|
// TODO(bircni): remove this once #3284 is implemented
|
|
#[inline]
|
|
pub fn background_color(mut self, color: Color32) -> Self {
|
|
self.background_color = Some(color);
|
|
self
|
|
}
|
|
|
|
/// If true, hide the letters from view and prevent copying from the field.
|
|
#[inline]
|
|
pub fn password(mut self, password: bool) -> Self {
|
|
self.password = password;
|
|
self
|
|
}
|
|
|
|
/// Pick a [`crate::FontId`] or [`TextStyle`].
|
|
#[inline]
|
|
pub fn font(mut self, font_selection: impl Into<FontSelection>) -> Self {
|
|
self.font_selection = font_selection.into();
|
|
self
|
|
}
|
|
|
|
#[inline]
|
|
pub fn text_color(mut self, text_color: Color32) -> Self {
|
|
self.text_color = Some(text_color);
|
|
self
|
|
}
|
|
|
|
#[inline]
|
|
pub fn text_color_opt(mut self, text_color: Option<Color32>) -> Self {
|
|
self.text_color = text_color;
|
|
self
|
|
}
|
|
|
|
/// Override how text is being shown inside the [`TextEdit`].
|
|
///
|
|
/// This can be used to implement things like syntax highlighting.
|
|
///
|
|
/// This function will be called at least once per frame,
|
|
/// so it is strongly suggested that you cache the results of any syntax highlighter
|
|
/// so as not to waste CPU highlighting the same string every frame.
|
|
///
|
|
/// The arguments is the enclosing [`Ui`] (so you can access e.g. [`Context::fonts`]),
|
|
/// the text and the wrap width.
|
|
///
|
|
/// ```
|
|
/// # egui::__run_test_ui(|ui| {
|
|
/// # let mut my_code = String::new();
|
|
/// # fn my_memoized_highlighter(s: &str) -> egui::text::LayoutJob { Default::default() }
|
|
/// let mut layouter = |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
|
/// let mut layout_job: egui::text::LayoutJob = my_memoized_highlighter(buf.as_str());
|
|
/// layout_job.wrap.max_width = wrap_width;
|
|
/// ui.fonts_mut(|f| f.layout_job(layout_job))
|
|
/// };
|
|
/// ui.add(egui::TextEdit::multiline(&mut my_code).layouter(&mut layouter));
|
|
/// # });
|
|
/// ```
|
|
#[inline]
|
|
pub fn layouter(
|
|
mut self,
|
|
layouter: &'t mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley>,
|
|
) -> Self {
|
|
self.layouter = Some(layouter);
|
|
|
|
self
|
|
}
|
|
|
|
/// Default is `true`. If set to `false` then you cannot interact with the text (neither edit or select it).
|
|
///
|
|
/// Consider using [`Ui::add_enabled`] instead to also give the [`TextEdit`] a greyed out look.
|
|
#[inline]
|
|
pub fn interactive(mut self, interactive: bool) -> Self {
|
|
self.interactive = interactive;
|
|
self
|
|
}
|
|
|
|
/// Customize the [`Frame`] around the text edit.
|
|
#[inline]
|
|
pub fn frame(mut self, frame: Frame) -> Self {
|
|
self.frame = Some(frame);
|
|
self
|
|
}
|
|
|
|
/// Set margin of text. Default is `Margin::symmetric(4.0, 2.0)`
|
|
#[inline]
|
|
pub fn margin(mut self, margin: impl Into<Margin>) -> Self {
|
|
self.margin = margin.into();
|
|
self
|
|
}
|
|
|
|
/// Set to 0.0 to keep as small as possible.
|
|
/// Set to [`f32::INFINITY`] to take up all available space (i.e. disable automatic word wrap).
|
|
#[inline]
|
|
pub fn desired_width(mut self, desired_width: f32) -> Self {
|
|
self.desired_width = Some(desired_width);
|
|
self
|
|
}
|
|
|
|
/// Set the number of rows to show by default.
|
|
/// The default for singleline text is `1`.
|
|
/// The default for multiline text is `4`.
|
|
#[inline]
|
|
pub fn desired_rows(mut self, desired_height_rows: usize) -> Self {
|
|
self.desired_height_rows = desired_height_rows;
|
|
self
|
|
}
|
|
|
|
/// When `false` (default), pressing TAB will move focus
|
|
/// to the next widget.
|
|
///
|
|
/// When `true`, the widget will keep the focus and pressing TAB
|
|
/// will insert the `'\t'` character.
|
|
#[inline]
|
|
pub fn lock_focus(mut self, tab_will_indent: bool) -> Self {
|
|
self.event_filter.tab = tab_will_indent;
|
|
self
|
|
}
|
|
|
|
/// When `true` (default), the cursor will initially be placed at the end of the text.
|
|
///
|
|
/// When `false`, the cursor will initially be placed at the beginning of the text.
|
|
#[inline]
|
|
pub fn cursor_at_end(mut self, b: bool) -> Self {
|
|
self.cursor_at_end = b;
|
|
self
|
|
}
|
|
|
|
/// When `true` (default), overflowing text will be clipped.
|
|
///
|
|
/// When `false`, widget width will expand to make all text visible.
|
|
///
|
|
/// This only works for singleline [`TextEdit`].
|
|
#[inline]
|
|
pub fn clip_text(mut self, b: bool) -> Self {
|
|
// always show everything in multiline
|
|
if !self.multiline {
|
|
self.clip_text = b;
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Sets the limit for the amount of characters can be entered
|
|
///
|
|
/// This only works for singleline [`TextEdit`]
|
|
#[inline]
|
|
pub fn char_limit(mut self, limit: usize) -> Self {
|
|
self.char_limit = limit;
|
|
self
|
|
}
|
|
|
|
/// Set the horizontal align of the inner text.
|
|
#[inline]
|
|
pub fn horizontal_align(mut self, align: Align) -> Self {
|
|
self.align.0[0] = align;
|
|
self
|
|
}
|
|
|
|
/// Set the vertical align of the inner text.
|
|
#[inline]
|
|
pub fn vertical_align(mut self, align: Align) -> Self {
|
|
self.align.0[1] = align;
|
|
self
|
|
}
|
|
|
|
/// Set the minimum size of the [`TextEdit`].
|
|
#[inline]
|
|
pub fn min_size(mut self, min_size: Vec2) -> Self {
|
|
self.min_size = min_size;
|
|
self
|
|
}
|
|
|
|
/// Set the return key combination.
|
|
///
|
|
/// This combination will cause a newline on multiline,
|
|
/// whereas on singleline it will cause the widget to lose focus.
|
|
///
|
|
/// This combination is optional and can be disabled by passing [`None`] into this function.
|
|
#[inline]
|
|
pub fn return_key(mut self, return_key: impl Into<Option<KeyboardShortcut>>) -> Self {
|
|
self.return_key = return_key.into();
|
|
self
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
impl Widget for TextEdit<'_> {
|
|
fn ui(self, ui: &mut Ui) -> Response {
|
|
self.show(ui).response.response
|
|
}
|
|
}
|
|
|
|
impl TextEdit<'_> {
|
|
/// Show the [`TextEdit`], returning a rich [`TextEditOutput`].
|
|
///
|
|
/// ```
|
|
/// # egui::__run_test_ui(|ui| {
|
|
/// # let mut my_string = String::new();
|
|
/// let output = egui::TextEdit::singleline(&mut my_string).show(ui);
|
|
/// 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 = my_string.char_range(selected_chars);
|
|
/// ui.label("Selected text: ");
|
|
/// ui.monospace(selected_text);
|
|
/// }
|
|
/// # });
|
|
/// ```
|
|
pub fn show(self, ui: &mut Ui) -> TextEditOutput {
|
|
let TextEdit {
|
|
text,
|
|
prefix,
|
|
suffix,
|
|
mut hint_text,
|
|
id,
|
|
id_salt,
|
|
font_selection,
|
|
text_color,
|
|
layouter,
|
|
password,
|
|
frame,
|
|
margin,
|
|
multiline,
|
|
interactive,
|
|
desired_width,
|
|
desired_height_rows,
|
|
event_filter,
|
|
cursor_at_end,
|
|
min_size,
|
|
align,
|
|
clip_text,
|
|
char_limit,
|
|
return_key,
|
|
background_color,
|
|
} = self;
|
|
|
|
let text_color = text_color
|
|
.or_else(|| ui.visuals().override_text_color)
|
|
// .unwrap_or_else(|| ui.style().interact(&response).text_color()); // too bright
|
|
.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color());
|
|
|
|
let prev_text = text.as_str().to_owned();
|
|
let hint_text_str = hint_text.text().unwrap_or_default().to_string();
|
|
|
|
let font_id = font_selection.resolve(ui.style());
|
|
let row_height = ui.fonts_mut(|f| f.row_height(&font_id));
|
|
const MIN_WIDTH: f32 = 24.0; // Never make a [`TextEdit`] more narrow than this.
|
|
let available_width = ui.available_width().at_least(MIN_WIDTH);
|
|
let desired_width = desired_width
|
|
.unwrap_or_else(|| ui.spacing().text_edit_width)
|
|
.at_least(min_size.x);
|
|
let allocate_width = desired_width.at_most(available_width);
|
|
|
|
let font_id_clone = font_id.clone();
|
|
let mut default_layouter = move |ui: &Ui, text: &dyn TextBuffer, wrap_width: f32| {
|
|
let text = mask_if_password(password, text.as_str());
|
|
let mut layout_job = if multiline {
|
|
LayoutJob::simple(text, font_id_clone.clone(), text_color, wrap_width)
|
|
} else {
|
|
LayoutJob::simple_singleline(text, font_id_clone.clone(), text_color)
|
|
};
|
|
layout_job.halign = align.x();
|
|
// We want to keep the trailing whitespace, since hiding it feels really weird when typing
|
|
layout_job.keep_trailing_whitespace = true;
|
|
ui.fonts_mut(|f| f.layout_job(layout_job))
|
|
};
|
|
|
|
let layouter = layouter.unwrap_or(&mut default_layouter);
|
|
|
|
let min_inner_height = (desired_height_rows.at_least(1) as f32) * row_height;
|
|
|
|
let id = id.unwrap_or_else(|| {
|
|
if let Some(id_salt) = id_salt {
|
|
ui.make_persistent_id(id_salt)
|
|
} else {
|
|
// Since we are only storing the cursor a persistent Id is not super important
|
|
let id = ui.next_auto_id();
|
|
ui.skip_ahead_auto_ids(1);
|
|
id
|
|
}
|
|
});
|
|
|
|
// 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.has_touch_screen()) || ui.memory(|mem| mem.has_focus(id));
|
|
|
|
let sense = if interactive {
|
|
if allow_drag_to_select {
|
|
Sense::click_and_drag()
|
|
} else {
|
|
Sense::click()
|
|
}
|
|
} else {
|
|
Sense::hover()
|
|
};
|
|
|
|
let mut state = TextEditState::load(ui.ctx(), id).unwrap_or_default();
|
|
let mut cursor_range = None;
|
|
let mut prev_cursor_range = None;
|
|
|
|
let owns_ime_events = ui.memory(|mem| mem.owns_ime_events(id));
|
|
if !owns_ime_events {
|
|
state.cursor_purpose = TextEditCursorPurpose::Selection;
|
|
if !state.cursor.is_empty() {
|
|
state.cursor.set_char_range(
|
|
state
|
|
.cursor
|
|
.char_range()
|
|
.map(|r| CCursorRange::one(r.primary)),
|
|
);
|
|
}
|
|
}
|
|
|
|
let mut text_changed = false;
|
|
let text_mutable = text.is_mutable();
|
|
|
|
let mut handle_events = |ui: &Ui, galley: &mut Arc<Galley>, layouter, wrap_width, text| {
|
|
if interactive && ui.memory(|mem| mem.has_focus(id)) {
|
|
ui.memory_mut(|mem| mem.set_focus_lock_filter(id, event_filter));
|
|
|
|
let default_cursor_range = if cursor_at_end {
|
|
CCursorRange::one(galley.end())
|
|
} else {
|
|
CCursorRange::default()
|
|
};
|
|
prev_cursor_range = state.cursor.range(galley);
|
|
|
|
let (changed, new_cursor_range) = events(
|
|
ui,
|
|
&mut state,
|
|
text,
|
|
galley,
|
|
layouter,
|
|
&EventsOptions {
|
|
id,
|
|
wrap_width,
|
|
multiline,
|
|
password,
|
|
default_cursor_range,
|
|
owns_ime_events,
|
|
char_limit,
|
|
event_filter,
|
|
return_key,
|
|
},
|
|
);
|
|
|
|
if changed {
|
|
text_changed = true;
|
|
}
|
|
cursor_range = Some(new_cursor_range);
|
|
}
|
|
};
|
|
|
|
// We need to calculate the galley within the atom closure, so we can calculate it based on
|
|
// the available width (in case of wrapping multiline text edits). But we show it later,
|
|
// so we can clip it to the available size. Thus, extract it from the atom closure here.
|
|
let mut get_galley = None;
|
|
let inner_rect_id = Id::new("text_edit_rect");
|
|
let mut response = {
|
|
let any_shrink = hint_text.any_shrink();
|
|
// Ideally we could just do `let mut atoms = prefix` here, but that won't compile
|
|
// but due to servo/rust-smallvec#146 (also see the comment below).
|
|
let mut atoms: Atoms<'_> = Atoms::new(());
|
|
|
|
// TODO(servo/rust-smallvec#146): Use extend_right instead of the loop once we have
|
|
// smallvec 2.0. Using `extend_right` here won't compile, due to lifetime issues.
|
|
for atom in prefix {
|
|
atoms.push_right(atom);
|
|
}
|
|
|
|
if text.as_str().is_empty() && !hint_text.is_empty() {
|
|
// Add hint_text (if any):
|
|
let mut shrunk = any_shrink;
|
|
let mut first = true;
|
|
|
|
// Since we can't set a fallback color per atom, we have to override it here.
|
|
// Sucks, since it means users won't be able to override it.
|
|
hint_text.map_texts(|t| t.color(ui.style().visuals.weak_text_color()));
|
|
|
|
for mut atom in hint_text {
|
|
if !shrunk && matches!(atom.kind, AtomKind::Text(_)) {
|
|
// elide the hint_text if needed
|
|
atom = atom.atom_shrink(true);
|
|
atom = atom.atom_grow(true);
|
|
shrunk = true;
|
|
}
|
|
|
|
if first {
|
|
// The first atom in the hint text gets inner_rect_id, so we can know
|
|
// where to paint the cursor
|
|
atom = atom.atom_id(inner_rect_id);
|
|
first = false;
|
|
}
|
|
|
|
// Align the hint text the same as the input text so the hint, the
|
|
// cursor, and the typed text all share one alignment. The default
|
|
// `align` is `LEFT_TOP`, which keeps multi line text edits unchanged.
|
|
atoms.push_right(atom.atom_align(align));
|
|
}
|
|
|
|
// Calculate the empty galley, so it can be read later. The available width is
|
|
// technically wrong, but doesn't matter since the galley is empty
|
|
let available_width = allocate_width - margin.sum().x;
|
|
let galley = layouter(ui, text, available_width);
|
|
|
|
// We can't update the galley immediately here, since it would show both hint text
|
|
// and the newly typed letter. So we pass a clone instead, and accept having a frame
|
|
// delay on the very first keystroke.
|
|
let mut galley_clone = Arc::clone(&galley);
|
|
handle_events(ui, &mut galley_clone, layouter, available_width, text);
|
|
|
|
get_galley = Some(galley);
|
|
} else {
|
|
// We need to shrink when clip_text, so that we don't exceed the available size
|
|
// and thus clip. We also need to shrink in multi line text edits, so text can
|
|
// wrap appropriately.
|
|
let should_shrink = clip_text || multiline;
|
|
|
|
// We need a closure here, so we can calculate the galley based on the available
|
|
// width (after adding suffix and prefix), for correct wrapping in multi line text
|
|
// edits
|
|
atoms.push_right(
|
|
AtomKind::closure(|ui, args| {
|
|
let mut galley = layouter(ui, text, args.available_size.x);
|
|
|
|
// Handling events here allows us to update the galley immediately on
|
|
// keystrokes, avoiding frame delays, and ensuring the scroll_to within
|
|
// ScrollAreas works correctly.
|
|
handle_events(ui, &mut galley, layouter, args.available_size.x, text);
|
|
|
|
let intrinsic_size = galley.intrinsic_size();
|
|
let mut size = galley.size();
|
|
size.y = size.y.at_least(min_inner_height);
|
|
if clip_text {
|
|
size.x = size.x.at_most(args.available_size.x);
|
|
}
|
|
|
|
// We paint the galley later, so we can do clipping and offsetting
|
|
get_galley = Some(galley);
|
|
IntoSizedResult {
|
|
intrinsic_size,
|
|
sized: SizedAtomKind::Empty { size: Some(size) },
|
|
}
|
|
})
|
|
.atom_grow(true)
|
|
.atom_align(self.align)
|
|
.atom_id(inner_rect_id)
|
|
.atom_shrink(should_shrink),
|
|
);
|
|
}
|
|
|
|
// TODO(servo/rust-smallvec#146): Use extend_right instead of the loop once we have
|
|
// smallvec 2.0. Using `extend_right` here won't compile, due to lifetime issues.
|
|
for atom in suffix {
|
|
atoms.push_right(atom);
|
|
}
|
|
|
|
let custom_frame = frame.is_some();
|
|
let frame = frame.unwrap_or_else(|| Frame::new().inner_margin(margin));
|
|
|
|
let min_height = min_inner_height + frame.total_margin().sum().y;
|
|
|
|
// This wrap mode only affects the hint_text
|
|
let wrap_mode = if multiline {
|
|
TextWrapMode::Wrap
|
|
} else {
|
|
TextWrapMode::Truncate
|
|
};
|
|
|
|
let mut allocated = AtomLayout::new(atoms)
|
|
.id(id)
|
|
.min_size(Vec2::new(allocate_width, min_height))
|
|
.max_width(allocate_width)
|
|
.sense(sense)
|
|
.frame(frame)
|
|
.align2(align)
|
|
.wrap_mode(wrap_mode)
|
|
.allocate(ui);
|
|
|
|
allocated.frame = if custom_frame {
|
|
allocated.frame
|
|
} else {
|
|
let visuals = ui.style().interact(&allocated.response);
|
|
let background_color =
|
|
background_color.unwrap_or_else(|| ui.visuals().text_edit_bg_color());
|
|
|
|
let (corner_radius, background_color, stroke) = if text_mutable {
|
|
if allocated.response.has_focus() {
|
|
(
|
|
visuals.corner_radius,
|
|
background_color,
|
|
ui.visuals().selection.stroke,
|
|
)
|
|
} else {
|
|
(visuals.corner_radius, background_color, visuals.bg_stroke)
|
|
}
|
|
} else {
|
|
let visuals = &ui.style().visuals.widgets.inactive;
|
|
(
|
|
visuals.corner_radius,
|
|
Color32::TRANSPARENT,
|
|
visuals.bg_stroke,
|
|
)
|
|
};
|
|
allocated
|
|
.frame
|
|
.fill(background_color)
|
|
.corner_radius(corner_radius)
|
|
.inner_margin(
|
|
allocated.frame.inner_margin
|
|
+ Margin::same((visuals.expansion - stroke.width).round() as i8),
|
|
)
|
|
.outer_margin(Margin::same(-(visuals.expansion as i8)))
|
|
.stroke(stroke)
|
|
};
|
|
|
|
allocated.paint(ui)
|
|
};
|
|
|
|
let inner_rect = response.rect(inner_rect_id).unwrap_or(Rect::ZERO);
|
|
|
|
// Our atom closure was now called, so the galley should always be available here
|
|
let mut galley = get_galley.expect("Galley should be available here");
|
|
|
|
// Don't send `OutputEvent::Clicked` when a user presses the space bar
|
|
response.flags -= response::Flags::FAKE_PRIMARY_CLICKED;
|
|
let text_clip_rect = inner_rect;
|
|
let painter = ui.painter_at(text_clip_rect.expand(1.0)); // expand to avoid clipping cursor
|
|
|
|
if interactive && let Some(pointer_pos) = response.interact_pointer_pos() {
|
|
if response.hovered() && text.is_mutable() {
|
|
ui.output_mut(|o| o.mutable_text_under_cursor = true);
|
|
}
|
|
|
|
// TODO(emilk): drag selected text to either move or clone (ctrl on windows, alt on mac)
|
|
|
|
let cursor_at_pointer = galley.cursor_from_pos(
|
|
pointer_pos - inner_rect.min + state.text_offset + vec2(galley.rect.left(), 0.0),
|
|
);
|
|
|
|
if ui.visuals().text_cursor.preview
|
|
&& response.hovered()
|
|
&& ui.input(|i| i.pointer.is_moving())
|
|
{
|
|
// text cursor preview:
|
|
let cursor_rect = TSTransform::from_translation(
|
|
inner_rect.min.to_vec2() - vec2(galley.rect.left(), 0.0),
|
|
) * cursor_rect(&galley, &cursor_at_pointer, row_height);
|
|
text_selection::visuals::paint_cursor_end(&painter, ui.visuals(), cursor_rect);
|
|
}
|
|
|
|
let is_being_dragged = ui.is_being_dragged(response.id);
|
|
let did_interact = state.cursor.pointer_interaction(
|
|
ui,
|
|
&response,
|
|
cursor_at_pointer,
|
|
&galley,
|
|
is_being_dragged,
|
|
);
|
|
|
|
if did_interact || response.clicked() {
|
|
ui.memory_mut(|mem| mem.request_focus(response.id));
|
|
state.cursor_purpose = TextEditCursorPurpose::Selection;
|
|
|
|
state.last_interaction_time = ui.input(|i| i.time);
|
|
}
|
|
}
|
|
|
|
if interactive && response.hovered() {
|
|
ui.set_cursor_icon(CursorIcon::Text);
|
|
}
|
|
|
|
if text_changed {
|
|
response.mark_changed();
|
|
}
|
|
|
|
let mut galley_pos = align
|
|
.align_size_within_rect(galley.size(), inner_rect)
|
|
.intersect(inner_rect) // limit pos to the response rect area
|
|
.min;
|
|
let align_offset = inner_rect.left_top() - galley_pos;
|
|
|
|
// Visual clipping for singleline text editor with text larger than width
|
|
if clip_text && align_offset.x == 0.0 {
|
|
let cursor_pos = match (cursor_range, ui.memory(|mem| mem.has_focus(id))) {
|
|
(Some(cursor_range), true) => galley.pos_from_cursor(cursor_range.primary).min.x,
|
|
_ => 0.0,
|
|
};
|
|
|
|
let mut offset_x = state.text_offset.x;
|
|
let visible_range = offset_x..=offset_x + inner_rect.width();
|
|
|
|
if !visible_range.contains(&cursor_pos) {
|
|
if cursor_pos < *visible_range.start() {
|
|
offset_x = cursor_pos;
|
|
} else {
|
|
offset_x = cursor_pos - inner_rect.width();
|
|
}
|
|
}
|
|
|
|
offset_x = offset_x
|
|
.at_most(galley.size().x - inner_rect.width())
|
|
.at_least(0.0);
|
|
|
|
state.text_offset = vec2(offset_x, align_offset.y);
|
|
galley_pos -= vec2(offset_x, 0.0);
|
|
} else {
|
|
state.text_offset = align_offset;
|
|
}
|
|
|
|
let selection_changed = if let (Some(cursor_range), Some(prev_cursor_range)) =
|
|
(cursor_range, prev_cursor_range)
|
|
{
|
|
prev_cursor_range != cursor_range
|
|
} else {
|
|
false
|
|
};
|
|
|
|
let should_paint_ime_visuals_the_legacy_way = ui.visuals().ime_composition.legacy_visuals;
|
|
|
|
if ui.is_rect_visible(inner_rect) {
|
|
let has_focus = ui.memory(|mem| mem.has_focus(id));
|
|
|
|
if has_focus
|
|
&& (state.cursor_purpose.is_selection() || should_paint_ime_visuals_the_legacy_way)
|
|
&& let Some(cursor_range) = state.cursor.range(&galley)
|
|
{
|
|
// Add text selection rectangles to the galley:
|
|
paint_text_selection(&mut galley, ui.visuals(), &cursor_range, None);
|
|
}
|
|
|
|
painter.galley(
|
|
galley_pos - vec2(galley.rect.left(), 0.0),
|
|
Arc::clone(&galley),
|
|
text_color,
|
|
);
|
|
|
|
if has_focus && let Some(cursor_range) = state.cursor.range(&galley) {
|
|
let primary_cursor_rect = cursor_rect(&galley, &cursor_range.primary, row_height)
|
|
.translate(galley_pos.to_vec2() - vec2(galley.rect.left(), 0.0));
|
|
|
|
if response.changed() || selection_changed {
|
|
// Scroll to keep primary cursor in view:
|
|
ui.scroll_to_rect(primary_cursor_rect, None);
|
|
}
|
|
|
|
if text.is_mutable() && interactive {
|
|
let now = ui.input(|i| i.time);
|
|
if response.changed() || selection_changed {
|
|
state.last_interaction_time = now;
|
|
}
|
|
|
|
// Only show (and blink) cursor if the egui viewport has focus.
|
|
// This is for two reasons:
|
|
// * Don't give the impression that the user can type into a window without focus
|
|
// * Don't repaint the ui because of a blinking cursor in an app that is not in focus
|
|
let viewport_has_focus = ui.input(|i| i.focused);
|
|
if viewport_has_focus {
|
|
let time_since_last_interaction = now - state.last_interaction_time;
|
|
let cursor_purpose = if should_paint_ime_visuals_the_legacy_way {
|
|
&TextEditCursorPurpose::Selection
|
|
} else {
|
|
&state.cursor_purpose
|
|
};
|
|
match cursor_purpose {
|
|
TextEditCursorPurpose::Selection => {
|
|
text_selection::visuals::paint_text_cursor(
|
|
ui,
|
|
&painter,
|
|
primary_cursor_rect,
|
|
time_since_last_interaction,
|
|
);
|
|
}
|
|
TextEditCursorPurpose::ImeComposition { active_range } => {
|
|
text_selection::visuals::paint_ime_preedit_text_visuals(
|
|
galley_pos,
|
|
ui,
|
|
&painter,
|
|
&galley,
|
|
row_height,
|
|
{
|
|
let [start, end] = cursor_range.sorted_cursors();
|
|
start..end
|
|
},
|
|
active_range.clone(),
|
|
time_since_last_interaction,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if ui.memory(|mem| mem.owns_ime_events(id)) {
|
|
// Set IME output (in screen coords) when text is editable and visible
|
|
let to_global = ui
|
|
.ctx()
|
|
.layer_transform_to_global(ui.layer_id())
|
|
.unwrap_or_default();
|
|
ui.output_mut(|o| {
|
|
o.ime = Some(crate::output::IMEOutput {
|
|
purpose: if password {
|
|
IMEPurpose::Password
|
|
} else {
|
|
IMEPurpose::Normal
|
|
},
|
|
rect: to_global * inner_rect,
|
|
cursor_rect: to_global * primary_cursor_rect,
|
|
should_interrupt_composition: false,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
state.clone().store(ui.ctx(), id);
|
|
|
|
if response.changed() {
|
|
response.widget_info(|| {
|
|
WidgetInfo::text_edit(
|
|
ui.is_enabled(),
|
|
mask_if_password(password, prev_text.as_str()),
|
|
mask_if_password(password, text.as_str()),
|
|
hint_text_str.as_str(),
|
|
)
|
|
});
|
|
} else if selection_changed && let Some(cursor_range) = cursor_range {
|
|
let char_range = cursor_range.as_sorted_char_range();
|
|
let info = WidgetInfo::text_selection_changed(
|
|
ui.is_enabled(),
|
|
char_range,
|
|
mask_if_password(password, text.as_str()),
|
|
);
|
|
response.output_event(OutputEvent::TextSelectionChanged(info));
|
|
} else {
|
|
response.widget_info(|| {
|
|
WidgetInfo::text_edit(
|
|
ui.is_enabled(),
|
|
mask_if_password(password, prev_text.as_str()),
|
|
mask_if_password(password, text.as_str()),
|
|
hint_text_str.as_str(),
|
|
)
|
|
});
|
|
}
|
|
|
|
let role = if password {
|
|
accesskit::Role::PasswordInput
|
|
} else if multiline {
|
|
accesskit::Role::MultilineTextInput
|
|
} else {
|
|
accesskit::Role::TextInput
|
|
};
|
|
|
|
crate::text_selection::accesskit_text::update_accesskit_for_text_widget(
|
|
ui.ctx(),
|
|
id,
|
|
cursor_range,
|
|
role,
|
|
TSTransform::from_translation(galley_pos.to_vec2()),
|
|
&galley,
|
|
);
|
|
|
|
TextEditOutput {
|
|
response,
|
|
galley,
|
|
galley_pos,
|
|
text_clip_rect,
|
|
state,
|
|
cursor_range,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mask_if_password(is_password: bool, text: &str) -> String {
|
|
fn mask_password(text: &str) -> String {
|
|
std::iter::repeat_n(
|
|
epaint::text::PASSWORD_REPLACEMENT_CHAR,
|
|
text.chars().count(),
|
|
)
|
|
.collect::<String>()
|
|
}
|
|
|
|
if is_password {
|
|
mask_password(text)
|
|
} else {
|
|
text.to_owned()
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
/// Bundles parameters for [`events`] to avoid `clippy::too_many_arguments` and
|
|
/// `clippy::fn_params_excessive_bools`.
|
|
struct EventsOptions {
|
|
id: Id,
|
|
wrap_width: f32,
|
|
multiline: bool,
|
|
password: bool,
|
|
default_cursor_range: CCursorRange,
|
|
owns_ime_events: bool,
|
|
char_limit: usize,
|
|
event_filter: EventFilter,
|
|
return_key: Option<KeyboardShortcut>,
|
|
}
|
|
|
|
/// Check for (keyboard) events to edit the cursor and/or text.
|
|
fn events(
|
|
ui: &crate::Ui,
|
|
state: &mut TextEditState,
|
|
text: &mut dyn TextBuffer,
|
|
galley: &mut Arc<Galley>,
|
|
layouter: &mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley>,
|
|
opts: &EventsOptions,
|
|
) -> (bool, CCursorRange) {
|
|
let EventsOptions {
|
|
id,
|
|
wrap_width,
|
|
multiline,
|
|
password,
|
|
default_cursor_range,
|
|
owns_ime_events,
|
|
char_limit,
|
|
event_filter,
|
|
return_key,
|
|
} = *opts;
|
|
|
|
let os = ui.os();
|
|
|
|
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.
|
|
state.undoer.lock().feed_state(
|
|
ui.input(|i| i.time),
|
|
&(cursor_range, text.as_str().to_owned()),
|
|
);
|
|
|
|
let copy_if_not_password = |ui: &Ui, text: String| {
|
|
if !password {
|
|
ui.copy_text(text);
|
|
}
|
|
};
|
|
|
|
let mut any_change = false;
|
|
|
|
let events = ui.input(|i| i.filtered_events(&event_filter));
|
|
|
|
enum CursorMutation {
|
|
Selection(CCursorRange),
|
|
ImeComposition {
|
|
cursor_range: CCursorRange,
|
|
active_range: Option<std::ops::Range<CCursor>>,
|
|
},
|
|
ImeCompositionCursorRange(CCursorRange),
|
|
}
|
|
|
|
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(os, event, galley, id) => None,
|
|
|
|
Event::Copy => {
|
|
if !cursor_range.is_empty() {
|
|
copy_if_not_password(ui, cursor_range.slice_str(text.as_str()).to_owned());
|
|
}
|
|
None
|
|
}
|
|
Event::Cut => {
|
|
if cursor_range.is_empty() {
|
|
None
|
|
} else {
|
|
copy_if_not_password(ui, cursor_range.slice_str(text.as_str()).to_owned());
|
|
Some(CursorMutation::Selection(CCursorRange::one(
|
|
text.delete_selected(&cursor_range),
|
|
)))
|
|
}
|
|
}
|
|
Event::Paste(text_to_insert) => {
|
|
if text_to_insert.is_empty() {
|
|
None
|
|
} else {
|
|
let mut ccursor = text.delete_selected(&cursor_range);
|
|
if multiline {
|
|
text.insert_text_at(&mut ccursor, text_to_insert, char_limit);
|
|
} else {
|
|
let single_line = text_to_insert.replace(['\r', '\n'], " ");
|
|
text.insert_text_at(&mut ccursor, &single_line, char_limit);
|
|
}
|
|
|
|
Some(CursorMutation::Selection(CCursorRange::one(ccursor)))
|
|
}
|
|
}
|
|
Event::Text(text_to_insert) => {
|
|
// Newlines are handled by `Key::Enter`.
|
|
if !text_to_insert.is_empty() && text_to_insert != "\n" && text_to_insert != "\r" {
|
|
let mut ccursor = text.delete_selected(&cursor_range);
|
|
|
|
text.insert_text_at(&mut ccursor, text_to_insert, char_limit);
|
|
|
|
Some(CursorMutation::Selection(CCursorRange::one(ccursor)))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
Event::Key {
|
|
key: Key::Tab,
|
|
pressed: true,
|
|
modifiers,
|
|
..
|
|
} if multiline => {
|
|
let mut ccursor = text.delete_selected(&cursor_range);
|
|
if modifiers.shift {
|
|
// TODO(emilk): support removing indentation over a selection?
|
|
text.decrease_indentation(&mut ccursor);
|
|
} else {
|
|
text.insert_text_at(&mut ccursor, "\t", char_limit);
|
|
}
|
|
Some(CursorMutation::Selection(CCursorRange::one(ccursor)))
|
|
}
|
|
Event::Key {
|
|
key,
|
|
pressed: true,
|
|
modifiers,
|
|
..
|
|
} if return_key.is_some_and(|return_key| {
|
|
*key == return_key.logical_key && modifiers.matches_logically(return_key.modifiers)
|
|
}) =>
|
|
{
|
|
if multiline {
|
|
let mut ccursor = text.delete_selected(&cursor_range);
|
|
text.insert_text_at(&mut ccursor, "\n", char_limit);
|
|
// TODO(emilk): if code editor, auto-indent by same leading tabs, + one if the lines end on an opening bracket
|
|
Some(CursorMutation::Selection(CCursorRange::one(ccursor)))
|
|
} else {
|
|
ui.memory_mut(|mem| mem.surrender_focus(id)); // End input with enter
|
|
break;
|
|
}
|
|
}
|
|
|
|
Event::Key {
|
|
key,
|
|
pressed: true,
|
|
modifiers,
|
|
..
|
|
} if (modifiers.matches_logically(Modifiers::COMMAND) && *key == Key::Y)
|
|
|| (modifiers.matches_logically(Modifiers::SHIFT | Modifiers::COMMAND)
|
|
&& *key == Key::Z) =>
|
|
{
|
|
if let Some((redo_ccursor_range, redo_txt)) = state
|
|
.undoer
|
|
.lock()
|
|
.redo(&(cursor_range, text.as_str().to_owned()))
|
|
{
|
|
text.replace_with(redo_txt);
|
|
Some(CursorMutation::Selection(*redo_ccursor_range))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
Event::Key {
|
|
key: Key::Z,
|
|
pressed: true,
|
|
modifiers,
|
|
..
|
|
} if modifiers.matches_logically(Modifiers::COMMAND) => {
|
|
if let Some((undo_ccursor_range, undo_txt)) = state
|
|
.undoer
|
|
.lock()
|
|
.undo(&(cursor_range, text.as_str().to_owned()))
|
|
{
|
|
text.replace_with(undo_txt);
|
|
Some(CursorMutation::Selection(*undo_ccursor_range))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
Event::Key {
|
|
modifiers,
|
|
key,
|
|
pressed: true,
|
|
..
|
|
} => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key)
|
|
.map(CursorMutation::Selection),
|
|
Event::Ime(ime_event) if owns_ime_events => {
|
|
/// Both `ImeEvent::Preedit("")` and `ImeEvent::Commit("")`
|
|
/// might be emitted from different integrations to signify that
|
|
/// the current IME composition should be cleared.
|
|
///
|
|
/// Example integrations where only `ImeEvent::Preedit("")` of
|
|
/// those two events is emitted when the last character is
|
|
/// deleted with a backspace:
|
|
/// - `egui-winit` on macOS 15.7.3.
|
|
/// - `egui-winit` on Debian13 with gnome48 and wayland.
|
|
///
|
|
/// An example integration where only `ImeEvent::Commit("")` of
|
|
/// those two events is emitted when the last character is
|
|
/// deleted with a backspace:
|
|
/// - `eframe`'s web integration on Safari 26.2 (on macOS
|
|
/// 15.7.3).
|
|
///
|
|
/// ## Note
|
|
///
|
|
/// The term “pre-edit string” is used by X11 and Wayland, and
|
|
/// we use “pre-edit text” and “pre-edit range” here in the
|
|
/// same manner.
|
|
/// See: <https://wayland.app/protocols/input-method-unstable-v2>
|
|
///
|
|
/// We previously referred to “pre-edit text” as “prediction”,
|
|
/// which is not standard and can mean different things.
|
|
fn clear_preedit_text(
|
|
text: &mut dyn TextBuffer,
|
|
preedit_range: &CCursorRange,
|
|
) -> CCursor {
|
|
text.delete_selected(preedit_range)
|
|
}
|
|
|
|
match ime_event {
|
|
#[expect(deprecated)]
|
|
ImeEvent::Enabled | ImeEvent::Disabled => None,
|
|
// Ignore `Preedit`/`Commit` events with empty text when
|
|
// there is no active IME composition.
|
|
//
|
|
// Some integrations may emit these events when there is no
|
|
// active IME composition (e.g. when `set_ime_allowed` or
|
|
// `set_ime_cursor_area` is called on `winit`'s `Window` on
|
|
// Wayland). Without this guard, they would clear any
|
|
// selected text.
|
|
//
|
|
// TODO(umajho): Ideally this would be handled by the
|
|
// integration, but since this guard is harmless for well-
|
|
// behaved integrations and also fixes the issue described
|
|
// above, it is good enough for now.
|
|
ImeEvent::Preedit {
|
|
text: composition_text,
|
|
..
|
|
}
|
|
| ImeEvent::Commit(composition_text)
|
|
if composition_text.is_empty()
|
|
&& !state.cursor_purpose.is_ime_composition() =>
|
|
{
|
|
None
|
|
}
|
|
ImeEvent::Preedit {
|
|
text: composition_text,
|
|
..
|
|
}
|
|
| ImeEvent::Commit(composition_text)
|
|
if composition_text == "\n" || composition_text == "\r" =>
|
|
{
|
|
None
|
|
}
|
|
ImeEvent::Preedit {
|
|
text: preedit_text,
|
|
active_range_chars,
|
|
} => {
|
|
let mut ccursor = clear_preedit_text(text, &cursor_range);
|
|
|
|
if preedit_text.is_empty() {
|
|
Some(CursorMutation::Selection(CCursorRange::one(ccursor)))
|
|
} else {
|
|
let start_cursor = ccursor;
|
|
text.insert_text_at(&mut ccursor, preedit_text, char_limit);
|
|
Some(CursorMutation::ImeComposition {
|
|
cursor_range: CCursorRange::two(start_cursor, ccursor),
|
|
active_range: active_range_chars.clone().map(|range| {
|
|
CCursor::new(range.start)..CCursor::new(range.end)
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
ImeEvent::Commit(commit_text) => {
|
|
state.cursor_purpose = TextEditCursorPurpose::Selection;
|
|
let mut ccursor = clear_preedit_text(text, &cursor_range);
|
|
|
|
if !commit_text.is_empty() {
|
|
text.insert_text_at(&mut ccursor, commit_text, char_limit);
|
|
}
|
|
|
|
Some(CursorMutation::Selection(CCursorRange::one(ccursor)))
|
|
}
|
|
ImeEvent::DeleteSurrounding {
|
|
before_chars,
|
|
after_chars,
|
|
} => Some(CursorMutation::ImeCompositionCursorRange(
|
|
text.delete_surrounding_chars(cursor_range, *before_chars, *after_chars),
|
|
)),
|
|
}
|
|
}
|
|
|
|
_ => None,
|
|
};
|
|
|
|
if let Some(cursor_mutation) = did_mutate_text {
|
|
any_change = true;
|
|
|
|
// Layout again to avoid frame delay, and to keep `text` and `galley` in sync.
|
|
*galley = layouter(ui, text, wrap_width);
|
|
|
|
// Set cursor_range using new galley:
|
|
match cursor_mutation {
|
|
CursorMutation::Selection(new_cursor_range) => {
|
|
cursor_range = new_cursor_range;
|
|
state.cursor_purpose = TextEditCursorPurpose::Selection;
|
|
}
|
|
CursorMutation::ImeComposition {
|
|
cursor_range: new_cursor_range,
|
|
active_range,
|
|
} => {
|
|
cursor_range = new_cursor_range;
|
|
state.cursor_purpose = TextEditCursorPurpose::ImeComposition { active_range };
|
|
}
|
|
CursorMutation::ImeCompositionCursorRange(new_cursor_range) => {
|
|
cursor_range = new_cursor_range;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
state.cursor.set_char_range(Some(cursor_range));
|
|
|
|
state.undoer.lock().feed_state(
|
|
ui.input(|i| i.time),
|
|
&(cursor_range, text.as_str().to_owned()),
|
|
);
|
|
|
|
(any_change, cursor_range)
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
/// Returns `Some(new_cursor)` if we did mutate `text`.
|
|
fn check_for_mutating_key_press(
|
|
os: OperatingSystem,
|
|
cursor_range: &CCursorRange,
|
|
text: &mut dyn TextBuffer,
|
|
galley: &Galley,
|
|
modifiers: &Modifiers,
|
|
key: Key,
|
|
) -> Option<CCursorRange> {
|
|
match key {
|
|
Key::Backspace => {
|
|
let ccursor = if modifiers.mac_cmd {
|
|
text.delete_paragraph_before_cursor(galley, cursor_range)
|
|
} else if let Some(cursor) = cursor_range.single() {
|
|
if modifiers.alt || modifiers.ctrl {
|
|
// alt on mac, ctrl on windows
|
|
text.delete_previous_word(cursor)
|
|
} else {
|
|
text.delete_previous_char(cursor)
|
|
}
|
|
} else {
|
|
text.delete_selected(cursor_range)
|
|
};
|
|
Some(CCursorRange::one(ccursor))
|
|
}
|
|
|
|
Key::Delete if !modifiers.shift || os != OperatingSystem::Windows => {
|
|
let ccursor = if modifiers.mac_cmd {
|
|
text.delete_paragraph_after_cursor(galley, cursor_range)
|
|
} else if let Some(cursor) = cursor_range.single() {
|
|
if modifiers.alt || modifiers.ctrl {
|
|
// alt on mac, ctrl on windows
|
|
text.delete_next_word(cursor)
|
|
} else {
|
|
text.delete_next_char(cursor)
|
|
}
|
|
} else {
|
|
text.delete_selected(cursor_range)
|
|
};
|
|
let ccursor = CCursor {
|
|
prefer_next_row: true,
|
|
..ccursor
|
|
};
|
|
Some(CCursorRange::one(ccursor))
|
|
}
|
|
|
|
Key::H if modifiers.ctrl => {
|
|
let ccursor = text.delete_previous_char(cursor_range.primary);
|
|
Some(CCursorRange::one(ccursor))
|
|
}
|
|
|
|
Key::K if modifiers.ctrl => {
|
|
let ccursor = text.delete_paragraph_after_cursor(galley, cursor_range);
|
|
Some(CCursorRange::one(ccursor))
|
|
}
|
|
|
|
Key::U if modifiers.ctrl => {
|
|
let ccursor = text.delete_paragraph_before_cursor(galley, cursor_range);
|
|
Some(CCursorRange::one(ccursor))
|
|
}
|
|
|
|
Key::W if modifiers.ctrl => {
|
|
let ccursor = if let Some(cursor) = cursor_range.single() {
|
|
text.delete_previous_word(cursor)
|
|
} else {
|
|
text.delete_selected(cursor_range)
|
|
};
|
|
Some(CCursorRange::one(ccursor))
|
|
}
|
|
|
|
_ => None,
|
|
}
|
|
}
|