From f612e5735f9493f5cd9a649e566fce25e596b2cd Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Thu, 6 Jul 2023 00:33:05 +0200 Subject: [PATCH] Ime support --- Cargo.lock | 8 +- Cargo.toml | 5 +- crates/eframe/src/web/app_runner.rs | 3 +- crates/egui-winit/src/lib.rs | 54 +++++++- crates/egui/src/data/input.rs | 26 ++++ crates/egui/src/data/output.rs | 9 +- crates/egui/src/input_state.rs | 11 ++ crates/egui/src/widgets/text_edit/builder.rs | 135 +++++++++++++++++-- crates/egui/src/widgets/text_edit/state.rs | 3 + 9 files changed, 228 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 12c7560b907..817fde8067d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4676,9 +4676,7 @@ checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" [[package]] name = "winit" -version = "0.29.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c824f11941eeae66ec71111cc2674373c772f482b58939bb4066b642aa2ffcf" +version = "0.29.15" dependencies = [ "ahash", "android-activity", @@ -4785,9 +4783,9 @@ dependencies = [ [[package]] name = "xkbcommon-dl" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6924668544c48c0133152e7eec86d644a056ca3d09275eb8d5cdb9855f9d8699" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ "bitflags 2.5.0", "dlib", diff --git a/Cargo.toml b/Cargo.toml index 77416706487..3b06d284f6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,8 @@ members = [ "crates/egui", "crates/emath", "crates/epaint", - "examples/*", "tests/*", - "xtask", ] @@ -264,3 +262,6 @@ manual_range_contains = "allow" # this one is just worse imho self_named_module_files = "allow" # Disabled waiting on https://github.com/rust-lang/rust-clippy/issues/9602 significant_drop_tightening = "allow" # Too many false positives wildcard_imports = "allow" # we do this a lot in egui + +[patch.crates-io] +winit = { path = "../github/winit" } diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index f683b991aff..5682f8ed04c 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -277,7 +277,8 @@ impl AppRunner { mutable_text_under_cursor: _, // TODO(#4569): https://github.com/emilk/egui/issues/4569 ime, #[cfg(feature = "accesskit")] - accesskit_update: _, // not currently implemented + accesskit_update: _, // not currently implemented + text_input_state: _, // not currently implemented } = platform_output; super::set_cursor_icon(cursor_icon); diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 4854cd2fd68..e4ccb183471 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -23,6 +23,7 @@ mod window_settings; pub use window_settings::WindowSettings; use ahash::HashSet; +use egui::{TextInputState, TextSpan}; use raw_window_handle::HasDisplayHandle; #[allow(unused_imports)] @@ -96,6 +97,7 @@ pub struct State { /// track ime state has_sent_ime_enabled: bool, + text_input_last_frame: bool, #[cfg(feature = "accesskit")] accesskit: Option, @@ -137,7 +139,7 @@ impl State { pointer_touch_id: None, has_sent_ime_enabled: false, - + text_input_last_frame: false, #[cfg(feature = "accesskit")] accesskit: None, @@ -368,6 +370,25 @@ impl State { consumed: self.egui_ctx.wants_keyboard_input(), } } + WindowEvent::TextInputState(state) => { + self.egui_input + .events + .push(egui::Event::TextInputState(TextInputState { + text: state.text.clone(), + selection: TextSpan { + start: state.selection.start, + end: state.selection.end, + }, + compose_region: state.compose_region.as_ref().map(|r| TextSpan { + start: r.start, + end: r.end, + }), + })); + EventResponse { + repaint: true, + consumed: self.egui_ctx.wants_keyboard_input(), + } + } WindowEvent::KeyboardInput { event, is_synthetic, @@ -816,6 +837,7 @@ impl State { ime, #[cfg(feature = "accesskit")] accesskit_update, + text_input_state, } = platform_output; self.set_cursor_icon(window, cursor_icon); @@ -828,12 +850,31 @@ impl State { self.clipboard.set(copied_text); } - let allow_ime = ime.is_some(); - if self.allow_ime != allow_ime { - self.allow_ime = allow_ime; - crate::profile_scope!("set_ime_allowed"); - window.set_ime_allowed(allow_ime); + if let Some(text_input_state) = text_input_state { + window.set_text_input_state(winit::event::TextInputState { + text: text_input_state.text, + selection: winit::event::TextSpan { + start: text_input_state.selection.start, + end: text_input_state.selection.end, + }, + compose_region: text_input_state + .compose_region + .map(|r| winit::event::TextSpan { + start: r.start, + end: r.end, + }), + }); + } + + let text_input_this_frame = ime.is_some(); + if self.text_input_last_frame != text_input_this_frame { + if text_input_this_frame { + window.begin_ime_input(); + } else { + window.end_ime_input(); + } } + self.text_input_last_frame = text_input_this_frame; if let Some(ime) = ime { let pixels_per_point = pixels_per_point(&self.egui_ctx, window); @@ -1823,6 +1864,7 @@ pub fn short_window_event_description(event: &winit::event::WindowEvent) -> &'st WindowEvent::KeyboardInput { .. } => "WindowEvent::KeyboardInput", WindowEvent::ModifiersChanged { .. } => "WindowEvent::ModifiersChanged", WindowEvent::Ime { .. } => "WindowEvent::Ime", + WindowEvent::TextInputState(..) => "WindowEvent::TextInputState", WindowEvent::CursorMoved { .. } => "WindowEvent::CursorMoved", WindowEvent::CursorEntered { .. } => "WindowEvent::CursorEntered", WindowEvent::CursorLeft { .. } => "WindowEvent::CursorLeft", diff --git a/crates/egui/src/data/input.rs b/crates/egui/src/data/input.rs index 50f20b9d6f9..b0dd1e7aee6 100644 --- a/crates/egui/src/data/input.rs +++ b/crates/egui/src/data/input.rs @@ -441,6 +441,8 @@ pub enum Event { /// IME Event Ime(ImeEvent), + TextInputState(TextInputState), + /// On touch screens, report this *in addition to* /// [`Self::PointerMoved`], [`Self::PointerButton`], [`Self::PointerGone`] Touch { @@ -1141,6 +1143,30 @@ impl From for TouchId { } } +/// This struct holds a span within a region of text from `start` (inclusive) to +/// `end` (exclusive). +/// +/// An empty span or cursor position is specified with `start == end`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TextSpan { + /// The start of the span (inclusive) + pub start: usize, + + /// The end of the span (exclusive) + pub end: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TextInputState { + pub text: String, + /// A selection defined on the text. + pub selection: TextSpan, + /// A composing region defined on the text. + pub compose_region: Option, +} + // ---------------------------------------------------------------------------- // TODO(emilk): generalize this to a proper event filter. diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index 58df5da230a..98ec76e7cdc 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -1,6 +1,6 @@ //! All the data egui returns to the backend at the end of each frame. -use crate::{ViewportIdMap, ViewportOutput, WidgetType}; +use crate::{TextInputState, ViewportIdMap, ViewportOutput, WidgetType}; /// What egui emits each frame from [`crate::Context::run`]. /// @@ -118,6 +118,8 @@ pub struct PlatformOutput { /// Useful for IME. pub ime: Option, + pub text_input_state: Option, + /// The difference in the widget tree since last frame. /// /// NOTE: this needs to be per-viewport. @@ -155,6 +157,7 @@ impl PlatformOutput { ime, #[cfg(feature = "accesskit")] accesskit_update, + text_input_state, } = newer; self.cursor_icon = cursor_icon; @@ -168,6 +171,10 @@ impl PlatformOutput { self.mutable_text_under_cursor = mutable_text_under_cursor; self.ime = ime.or(self.ime); + if text_input_state.is_some() { + self.text_input_state = text_input_state; + } + #[cfg(feature = "accesskit")] { // egui produces a complete AccessKit tree for each frame, diff --git a/crates/egui/src/input_state.rs b/crates/egui/src/input_state.rs index 5749e16825f..d247f1ccb42 100644 --- a/crates/egui/src/input_state.rs +++ b/crates/egui/src/input_state.rs @@ -96,6 +96,7 @@ pub struct InputState { // ---------------------------------------------- /// Position and size of the egui area. pub screen_rect: Rect, + previous_screen_rect: Rect, /// Also known as device pixel ratio, > 1 for high resolution screens. pub pixels_per_point: f32, @@ -177,6 +178,7 @@ impl Default for InputState { zoom_factor_delta: 1.0, screen_rect: Rect::from_min_size(Default::default(), vec2(10_000.0, 10_000.0)), + previous_screen_rect: Rect::from_min_size(Default::default(), vec2(10_000.0, 10_000.0)), pixels_per_point: 1.0, max_texture_side: 2048, time: 0.0, @@ -213,6 +215,7 @@ impl InputState { new.predicted_dt }; + let previous_screen_rect = self.screen_rect; let screen_rect = new.screen_rect.unwrap_or(self.screen_rect); self.create_touch_states_for_new_devices(&new.events); for touch_state in self.touch_states.values_mut() { @@ -349,6 +352,7 @@ impl InputState { zoom_factor_delta, screen_rect, + previous_screen_rect, pixels_per_point, max_texture_side: new.max_texture_side.unwrap_or(self.max_texture_side), time, @@ -374,6 +378,11 @@ impl InputState { self.screen_rect } + #[inline(always)] + pub fn screen_rect_changed(&self) -> bool { + self.screen_rect != self.previous_screen_rect + } + /// Zoom scale factor this frame (e.g. from ctrl-scroll or pinch gesture). /// * `zoom = 1`: no change /// * `zoom < 1`: pinch together @@ -1258,6 +1267,7 @@ impl InputState { zoom_factor_delta, screen_rect, + previous_screen_rect, pixels_per_point, max_texture_side, time, @@ -1309,6 +1319,7 @@ impl InputState { ui.label(format!("zoom_factor_delta: {zoom_factor_delta:4.2}x")); ui.label(format!("screen_rect: {screen_rect:?} points")); + ui.label(format!("previous_screen_rect: {:?} points", previous_screen_rect)); ui.label(format!( "{pixels_per_point} physical pixels for each logical point" )); diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 8205da63d0e..20b03364e2b 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -586,6 +586,18 @@ impl<'t> TextEdit<'t> { ui.ctx().set_cursor_icon(CursorIcon::Text); } + // Update the InputState if we're interacting (E.g. updating seleciton or cursor position) + if interactive + && state.soft_keyboard_visible + && (response.drag_released() || response.clicked()) + { + update_text_input( + ui.ctx(), + state.cursor_range(&galley), + text.as_str().to_owned(), + ); + } + let mut cursor_range = None; let prev_cursor_range = state.cursor.range(&galley); if interactive && ui.memory(|mem| mem.has_focus(id)) { @@ -661,7 +673,16 @@ impl<'t> TextEdit<'t> { false }; - if ui.is_rect_visible(rect) { + if ui.memory(|memory| memory.lost_focus(id)) { + state.soft_keyboard_visible = false; + } + + + if ui.memory(|mem| mem.has_focus(id)) && ui.input(|i| i.screen_rect_changed()) { + ui.scroll_to_rect(rect, None); + } + + if ui.is_rect_visible(rect) || ui.memory(|mem| mem.has_focus(id)) { painter.galley(galley_pos, galley.clone(), text_color); if text.as_str().is_empty() && !hint_text.is_empty() { @@ -726,17 +747,25 @@ impl<'t> TextEdit<'t> { ); } - // Set IME output (in screen coords) when text is editable and visible - let transform = ui - .memory(|m| m.layer_transforms.get(&ui.layer_id()).copied()) - .unwrap_or_default(); - - ui.ctx().output_mut(|o| { - o.ime = Some(crate::output::IMEOutput { - rect: transform * rect, - cursor_rect: transform * primary_cursor_rect, + if interactive { + // Send the text input only when the keyboard is initially shown. + if !state.soft_keyboard_visible { + update_text_input( + ui.ctx(), + state.cursor_range(&galley), + text.as_str().to_owned(), + ); + state.soft_keyboard_visible = true; + } + + // For IME, so only set it when text is editable and visible! + ui.ctx().output_mut(|o| { + o.ime = Some(crate::output::IMEOutput { + rect, + cursor_rect: primary_cursor_rect, + }); }); - }); + } } } } @@ -819,6 +848,52 @@ fn mask_if_password(is_password: bool, text: &str) -> String { // ---------------------------------------------------------------------------- +fn update_text_input(ctx: &Context, cursor_range: Option, text: String) { + ctx.output_mut(|o| { + let selection = if let Some(cursor_range) = cursor_range { + TextSpan { + start: cursor_range.primary.ccursor.index, + end: cursor_range.secondary.ccursor.index, + } + } else { + TextSpan { + start: 0, + end: 0, + } + }; + + let output = TextInputState { + text: text.as_str().to_owned(), + selection, + compose_region: None, + }; + + o.text_input_state = Some(output) + }); +} + +#[cfg(feature = "accesskit")] +fn ccursor_from_accesskit_text_position( + id: Id, + galley: &Galley, + position: &accesskit::TextPosition, +) -> Option { + let mut total_length = 0usize; + for (i, row) in galley.rows.iter().enumerate() { + let row_id = id.with(i); + if row_id.accesskit_id() == position.node { + return Some(CCursor { + index: total_length + position.character_index, + prefer_next_row: !(position.character_index == row.glyphs.len() + && !row.ends_with_newline + && (i + 1) < galley.rows.len()), + }); + } + total_length += row.glyphs.len() + (row.ends_with_newline as usize); + } + None +} + /// Check for (keyboard) events to edit the cursor and/or text. #[allow(clippy::too_many_arguments)] fn events( @@ -1025,6 +1100,44 @@ fn events( None } }, + Event::TextInputState(input) => { + text.replace_with(&input.text); + + let mut ccursor = CCursorRange::default(); + ccursor.primary = CCursor::new(input.selection.start); + ccursor.secondary = CCursor::new(input.selection.end); + + if let Some(compose_region) = input.compose_region { + ccursor = CCursorRange::two( + CCursor::new(compose_region.start), + CCursor::new(compose_region.end), + ); + } + + // TODO: Improve selection + Some(ccursor) + } + + #[cfg(feature = "accesskit")] + Event::AccessKitActionRequest(accesskit::ActionRequest { + action: accesskit::Action::SetTextSelection, + target, + data: Some(accesskit::ActionData::SetTextSelection(selection)), + }) => { + if id.accesskit_id() == *target { + let primary = + ccursor_from_accesskit_text_position(id, galley, &selection.focus); + let secondary = + ccursor_from_accesskit_text_position(id, galley, &selection.anchor); + if let (Some(primary), Some(secondary)) = (primary, secondary) { + Some(CCursorRange { primary, secondary }) + } else { + None + } + } else { + None + } + } _ => None, }; diff --git a/crates/egui/src/widgets/text_edit/state.rs b/crates/egui/src/widgets/text_edit/state.rs index c95d676910a..244a40b4a97 100644 --- a/crates/egui/src/widgets/text_edit/state.rs +++ b/crates/egui/src/widgets/text_edit/state.rs @@ -48,6 +48,9 @@ pub struct TextEditState { #[cfg_attr(feature = "serde", serde(skip))] pub(crate) ime_cursor_range: CursorRange, + #[cfg_attr(feature = "serde", serde(skip))] + pub(crate) soft_keyboard_visible: bool, + // Visual offset when editing singleline text bigger than the width. #[cfg_attr(feature = "serde", serde(skip))] pub(crate) singleline_offset: f32,