From e9edd38462760d6d9a9b0d42da1cb06422adca87 Mon Sep 17 00:00:00 2001 From: Tom Churchman Date: Sat, 30 Nov 2024 13:45:27 +0100 Subject: [PATCH] Re-add IME support (#762) This adds IME support back into Masonry. This sticks close to https://github.com/linebender/parley/pull/111, except that during IME compose, this version doesn't allow changing the selection anchor, making the code simpler. For reference, there's also https://github.com/linebender/parley/pull/136. This tweaks the focus update pass: when a widget with IME is unfocused, Masonry sends the platform's IME disable event to the newly focused widget. As a workaround, we synthesize an IME disable event in the focus pass and send it to the widget that is about to be unfocused. A complication is that the handling of that event can request focus to a different widget, and in particular, can request itself to be focused again. This handles that case, too. Remaining work is setting the IME candidate region to be near the current selection and to make a decision on cursor/selection hiding when the platform sends a `None` cursor. --------- Co-authored-by: Daniel McNab <36049421+DJMcNab@users.noreply.github.com> --- masonry/src/passes/update.rs | 76 ++++++++++----- masonry/src/text/editor.rs | 159 ++++++++++++++++++++++++++++++-- masonry/src/widget/text_area.rs | 58 +++++++++++- 3 files changed, 261 insertions(+), 32 deletions(-) diff --git a/masonry/src/passes/update.rs b/masonry/src/passes/update.rs index fd76e56e5..6444f4bb9 100644 --- a/masonry/src/passes/update.rs +++ b/masonry/src/passes/update.rs @@ -6,12 +6,13 @@ use std::collections::HashSet; use cursor_icon::CursorIcon; use tracing::{info_span, trace}; -use crate::passes::event::run_on_pointer_event_pass; +use crate::passes::event::{run_on_pointer_event_pass, run_on_text_event_pass}; use crate::passes::{enter_span, enter_span_if, merge_state_up, recurse_on_children}; use crate::render_root::{RenderRoot, RenderRootSignal, RenderRootState}; use crate::tree_arena::ArenaMut; use crate::{ - PointerEvent, QueryCtx, RegisterCtx, Update, UpdateCtx, Widget, WidgetId, WidgetState, + PointerEvent, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, WidgetId, + WidgetState, }; // --- MARK: HELPERS --- @@ -393,7 +394,7 @@ pub(crate) fn run_update_focus_chain_pass(root: &mut RenderRoot) { // --- MARK: UPDATE FOCUS --- pub(crate) fn run_update_focus_pass(root: &mut RenderRoot) { let _span = info_span!("update_focus").entered(); - // If the focused widget is disabled, stashed or removed, we set + // If the next-focused widget is disabled, stashed or removed, we set // the focused id to None if let Some(id) = root.global_state.next_focused_widget { if !root.is_still_interactive(id) { @@ -402,6 +403,43 @@ pub(crate) fn run_update_focus_pass(root: &mut RenderRoot) { } let prev_focused = root.global_state.focused_widget; + let was_ime_active = root.global_state.is_ime_active; + + if was_ime_active && prev_focused != root.global_state.next_focused_widget { + // IME was active, but the next focused widget is going to receive the Ime::Disabled event + // sent by the platform. Synthesize an `Ime::Disabled` event here and send it to the widget + // about to be unfocused. + run_on_text_event_pass(root, &TextEvent::Ime(winit::event::Ime::Disabled)); + + // Disable the IME, which was enabled specifically for this widget. Note that if the newly + // focused widget also requires IME, we will request it again - this resets the platform's + // state, ensuring that partial IME inputs do not "travel" between widgets + root.global_state.emit_signal(RenderRootSignal::EndIme); + + // Note: handling of the Ime::Disabled event sent above may have changed the next focused + // widget. In particular, focus may have changed back to the original widget we just + // disabled IME for. + // + // In this unlikely case, the rest of this handler will short-circuit, and IME would not be + // re-enabled for this widget. Re-enable IME here; the resultant `Ime::Enabled` event sent + // by the platform will be routed to this widget as it remains the focused widget. We don't + // handle this as above to avoid loops. + // + // First do the disabled, stashed or removed check again. + if let Some(id) = root.global_state.next_focused_widget { + if !root.is_still_interactive(id) { + root.global_state.next_focused_widget = None; + } + } + if prev_focused == root.global_state.next_focused_widget { + tracing::warn!( + id = prev_focused.map(|id| id.trace()), + "request_focus called whilst handling Ime::Disabled" + ); + root.global_state.emit_signal(RenderRootSignal::StartIme); + } + } + let next_focused = root.global_state.next_focused_widget; // "Focused path" means the focused widget, and all its parents. @@ -458,15 +496,8 @@ pub(crate) fn run_update_focus_pass(root: &mut RenderRoot) { } } + // Refocus if the focused widget changed. if prev_focused != next_focused { - let was_ime_active = root.global_state.is_ime_active; - let is_ime_active = if let Some(id) = next_focused { - root.widget_arena.get_state(id).item.accepts_text_input - } else { - false - }; - root.global_state.is_ime_active = is_ime_active; - // We send FocusChange event to widget that lost and the widget that gained focus. // We also request accessibility, because build_access_node() depends on the focus state. if let Some(prev_focused) = prev_focused { @@ -485,23 +516,24 @@ pub(crate) fn run_update_focus_pass(root: &mut RenderRoot) { ctx.widget_state.request_accessibility = true; ctx.widget_state.needs_accessibility = true; }); - } - if prev_focused.is_some() && was_ime_active { - root.global_state.emit_signal(RenderRootSignal::EndIme); - } - if next_focused.is_some() && is_ime_active { - root.global_state.emit_signal(RenderRootSignal::StartIme); - } + let widget_state = root.widget_arena.get_state(next_focused).item; + + root.global_state.is_ime_active = widget_state.accepts_text_input; + if widget_state.accepts_text_input { + root.global_state.emit_signal(RenderRootSignal::StartIme); + } - if let Some(id) = next_focused { - let ime_area = root.widget_arena.get_state(id).item.get_ime_area(); root.global_state - .emit_signal(RenderRootSignal::new_ime_moved_signal(ime_area)); + .emit_signal(RenderRootSignal::new_ime_moved_signal( + widget_state.get_ime_area(), + )); + } else { + root.global_state.is_ime_active = false; } } - root.global_state.focused_widget = root.global_state.next_focused_widget; + root.global_state.focused_widget = next_focused; root.global_state.focused_path = next_focused_path; } diff --git a/masonry/src/text/editor.rs b/masonry/src/text/editor.rs index 62f94e7dd..eb0c33dc6 100644 --- a/masonry/src/text/editor.rs +++ b/masonry/src/text/editor.rs @@ -5,7 +5,7 @@ //! Import of Parley's `PlainEditor` as the version in Parley is insufficient for our needs. -use core::{cmp::PartialEq, default::Default, fmt::Debug}; +use core::{cmp::PartialEq, default::Default, fmt::Debug, ops::Range}; use accesskit::{Node, NodeId, TreeUpdate}; use parley::layout::LayoutAccessibility; @@ -49,6 +49,9 @@ where layout: Layout, layout_access: LayoutAccessibility, selection: Selection, + /// Byte offsets of IME composing preedit text in the text buffer. + /// `None` if the IME is not currently composing. + compose: Option>, width: Option, scale: f32, // Simple tracking of when the layout needs to be updated @@ -76,6 +79,7 @@ where layout: Default::default(), layout_access: Default::default(), selection: Default::default(), + compose: None, width: None, scale: 1.0, layout_dirty: true, @@ -106,17 +110,23 @@ where // --- MARK: Forced relayout --- /// Insert at cursor, or replace selection. pub fn insert_or_replace_selection(&mut self, s: &str) { + debug_assert!(!self.editor.is_composing()); + self.editor .replace_selection(self.font_cx, self.layout_cx, s); } /// Delete the selection. pub fn delete_selection(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.insert_or_replace_selection(""); } /// Delete the selection or the next cluster (typical ‘delete’ behavior). pub fn delete(&mut self) { + debug_assert!(!self.editor.is_composing()); + if self.editor.selection.is_collapsed() { // Upstream cluster range if let Some(range) = self @@ -138,6 +148,8 @@ where /// Delete the selection or up to the next word boundary (typical ‘ctrl + delete’ behavior). pub fn delete_word(&mut self) { + debug_assert!(!self.editor.is_composing()); + if self.editor.selection.is_collapsed() { let focus = self.editor.selection.focus(); let start = focus.index(); @@ -157,6 +169,8 @@ where /// Delete the selection or the previous cluster (typical ‘backspace’ behavior). pub fn backdelete(&mut self) { + debug_assert!(!self.editor.is_composing()); + if self.editor.selection.is_collapsed() { // Upstream cluster if let Some(cluster) = self @@ -197,6 +211,8 @@ where /// Delete the selection or back to the previous word boundary (typical ‘ctrl + backspace’ behavior). pub fn backdelete_word(&mut self) { + debug_assert!(!self.editor.is_composing()); + if self.editor.selection.is_collapsed() { let focus = self.editor.selection.focus(); let end = focus.index(); @@ -214,9 +230,75 @@ where } } + // --- MARK: IME --- + /// Set the IME preedit composing text. + /// + /// This starts composing. Composing is reset by calling [`PlainEditorTxn::clear_compose`]. + /// While composing, it is a logic error to call anything other than + /// [`PlainEditorTxn::set_compose`] or [`PlainEditorTxn::clear_compose`]. + /// + /// The preedit text replaces the current selection if this call starts composing. + /// + /// The selection is updated based on `cursor`, which contains the byte offsets relative to the + /// start of the preedit text. If `cursor` is `None`, the selection is collapsed to a caret in + /// front of the preedit text. + pub fn set_compose(&mut self, text: &str, cursor: Option<(usize, usize)>) { + debug_assert!(!text.is_empty()); + debug_assert!(cursor.map(|cursor| cursor.1 <= text.len()).unwrap_or(true)); + + let start = if let Some(preedit_range) = self.editor.compose.clone() { + self.editor + .buffer + .replace_range(preedit_range.clone(), text); + preedit_range.start + } else { + if self.editor.selection.is_collapsed() { + self.editor + .buffer + .insert_str(self.editor.selection.text_range().start, text); + } else { + self.editor + .buffer + .replace_range(self.editor.selection.text_range(), text); + } + self.editor.selection.text_range().start + }; + self.editor.compose = Some(start..start + text.len()); + self.update_layout(); + + if let Some(cursor) = cursor { + // Select the location indicated by the IME. + self.editor.set_selection(Selection::new( + self.editor.cursor_at(start + cursor.0), + self.editor.cursor_at(start + cursor.1), + )); + } else { + // IME indicates nothing is to be selected: collapse the selection to a + // caret just in front of the preedit. + self.editor + .set_selection(self.editor.cursor_at(start).into()); + } + } + + /// Stop IME composing. + /// + /// This removes the IME preedit text. + pub fn clear_compose(&mut self) { + if let Some(preedit_range) = self.editor.compose.clone() { + self.editor.buffer.replace_range(preedit_range.clone(), ""); + self.editor.compose = None; + self.update_layout(); + + self.editor + .set_selection(self.editor.cursor_at(preedit_range.start).into()); + } + } + // --- MARK: Cursor Movement --- /// Move the cursor to the cluster boundary nearest this point in the layout. pub fn move_to_point(&mut self, x: f32, y: f32) { + debug_assert!(!self.editor.is_composing()); + self.refresh_layout(); self.editor .set_selection(Selection::from_point(&self.editor.layout, x, y)); @@ -226,6 +308,8 @@ where /// /// No-op if index is not a char boundary. pub fn move_to_byte(&mut self, index: usize) { + debug_assert!(!self.editor.is_composing()); + if self.editor.buffer.is_char_boundary(index) { self.refresh_layout(); self.editor @@ -235,6 +319,8 @@ where /// Move the cursor to the start of the buffer. pub fn move_to_text_start(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection(self.editor.selection.move_lines( &self.editor.layout, isize::MIN, @@ -244,12 +330,16 @@ where /// Move the cursor to the start of the physical line. pub fn move_to_line_start(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor .set_selection(self.editor.selection.line_start(&self.editor.layout, false)); } /// Move the cursor to the end of the buffer. pub fn move_to_text_end(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection(self.editor.selection.move_lines( &self.editor.layout, isize::MAX, @@ -259,12 +349,16 @@ where /// Move the cursor to the end of the physical line. pub fn move_to_line_end(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor .set_selection(self.editor.selection.line_end(&self.editor.layout, false)); } /// Move up to the closest physical cluster boundary on the previous line, preserving the horizontal position for repeated movements. pub fn move_up(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection( self.editor .selection @@ -274,12 +368,16 @@ where /// Move down to the closest physical cluster boundary on the next line, preserving the horizontal position for repeated movements. pub fn move_down(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor .set_selection(self.editor.selection.next_line(&self.editor.layout, false)); } /// Move to the next cluster left in visual order. pub fn move_left(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection( self.editor .selection @@ -289,6 +387,8 @@ where /// Move to the next cluster right in visual order. pub fn move_right(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection( self.editor .selection @@ -298,6 +398,8 @@ where /// Move to the next word boundary left. pub fn move_word_left(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection( self.editor .selection @@ -307,6 +409,8 @@ where /// Move to the next word boundary right. pub fn move_word_right(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection( self.editor .selection @@ -316,6 +420,8 @@ where /// Select the whole buffer. pub fn select_all(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection( Selection::from_byte_index(&self.editor.layout, 0_usize, Affinity::default()) .move_lines(&self.editor.layout, isize::MAX, true), @@ -324,11 +430,15 @@ where /// Collapse selection into caret. pub fn collapse_selection(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection(self.editor.selection.collapse()); } /// Move the selection focus point to the start of the buffer. pub fn select_to_text_start(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection(self.editor.selection.move_lines( &self.editor.layout, isize::MIN, @@ -338,12 +448,16 @@ where /// Move the selection focus point to the start of the physical line. pub fn select_to_line_start(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor .set_selection(self.editor.selection.line_start(&self.editor.layout, true)); } /// Move the selection focus point to the end of the buffer. pub fn select_to_text_end(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection(self.editor.selection.move_lines( &self.editor.layout, isize::MAX, @@ -353,12 +467,16 @@ where /// Move the selection focus point to the end of the physical line. pub fn select_to_line_end(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor .set_selection(self.editor.selection.line_end(&self.editor.layout, true)); } /// Move the selection focus point up to the nearest cluster boundary on the previous line, preserving the horizontal position for repeated movements. pub fn select_up(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection( self.editor .selection @@ -368,12 +486,16 @@ where /// Move the selection focus point down to the nearest cluster boundary on the next line, preserving the horizontal position for repeated movements. pub fn select_down(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor .set_selection(self.editor.selection.next_line(&self.editor.layout, true)); } /// Move the selection focus point to the next cluster left in visual order. pub fn select_left(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection( self.editor .selection @@ -383,12 +505,16 @@ where /// Move the selection focus point to the next cluster right in visual order. pub fn select_right(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor .set_selection(self.editor.selection.next_visual(&self.editor.layout, true)); } /// Move the selection focus point to the next word boundary left. pub fn select_word_left(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection( self.editor .selection @@ -398,6 +524,8 @@ where /// Move the selection focus point to the next word boundary right. pub fn select_word_right(&mut self) { + debug_assert!(!self.editor.is_composing()); + self.editor.set_selection( self.editor .selection @@ -407,6 +535,8 @@ where /// Select the word at the point. pub fn select_word_at_point(&mut self, x: f32, y: f32) { + debug_assert!(!self.editor.is_composing()); + self.refresh_layout(); self.editor .set_selection(Selection::word_from_point(&self.editor.layout, x, y)); @@ -414,6 +544,8 @@ where /// Select the physical line at the point. pub fn select_line_at_point(&mut self, x: f32, y: f32) { + debug_assert!(!self.editor.is_composing()); + self.refresh_layout(); let line = Selection::line_from_point(&self.editor.layout, x, y); self.editor.set_selection(line); @@ -421,6 +553,8 @@ where /// Move the selection focus point to the cluster boundary closest to point. pub fn extend_selection_to_point(&mut self, x: f32, y: f32) { + debug_assert!(!self.editor.is_composing()); + self.refresh_layout(); // FIXME: This is usually the wrong way to handle selection extension for mouse moves, but not a regression. self.editor.set_selection( @@ -434,6 +568,8 @@ where /// /// No-op if index is not a char boundary. pub fn extend_selection_to_byte(&mut self, index: usize) { + debug_assert!(!self.editor.is_composing()); + if self.editor.buffer.is_char_boundary(index) { self.refresh_layout(); self.editor @@ -445,6 +581,8 @@ where /// /// No-op if either index is not a char boundary. pub fn select_byte_range(&mut self, start: usize, end: usize) { + debug_assert!(!self.editor.is_composing()); + if self.editor.buffer.is_char_boundary(start) && self.editor.buffer.is_char_boundary(end) { self.refresh_layout(); self.editor.set_selection(Selection::new( @@ -455,6 +593,8 @@ where } pub fn select_from_accesskit(&mut self, selection: &accesskit::TextSelection) { + debug_assert!(!self.editor.is_composing()); + self.refresh_layout(); if let Some(selection) = Selection::from_access_selection( selection, @@ -514,11 +654,7 @@ where // TODO: Do we need to be non-dirty? // FIXME: `Selection` should make this easier if index >= self.buffer.len() { - Cursor::from_byte_index( - &self.layout, - self.buffer.len().saturating_sub(1), - Affinity::Upstream, - ) + Cursor::from_byte_index(&self.layout, self.buffer.len(), Affinity::Upstream) } else { Cursor::from_byte_index(&self.layout, index, Affinity::Downstream) } @@ -667,6 +803,12 @@ where for prop in self.default_style.inner().values() { builder.push_default(prop.to_owned()); } + if let Some(ref preedit_range) = self.compose { + builder.push( + parley::style::StyleProperty::Underline(true), + preedit_range.clone(), + ); + } self.layout = builder.build(&self.buffer); self.layout.break_all_lines(self.width); self.layout.align(self.width, self.alignment); @@ -675,6 +817,11 @@ where self.generation.nudge(); } + /// Whether the editor is currently in IME composing mode. + pub fn is_composing(&self) -> bool { + self.compose.is_some() + } + pub fn accessibility( &mut self, update: &mut TreeUpdate, diff --git a/masonry/src/widget/text_area.rs b/masonry/src/widget/text_area.rs index 657bc1751..399f0cf83 100644 --- a/masonry/src/widget/text_area.rs +++ b/masonry/src/widget/text_area.rs @@ -448,6 +448,10 @@ impl TextArea { // --- MARK: IMPL WIDGET --- impl Widget for TextArea { fn on_pointer_event(&mut self, ctx: &mut EventCtx, event: &PointerEvent) { + if self.editor.is_composing() { + return; + } + let window_origin = ctx.widget_state.window_origin(); let (fctx, lctx) = ctx.text_contexts(); let is_rtl = self.editor.layout(fctx, lctx).is_rtl(); @@ -508,7 +512,7 @@ impl Widget for TextArea { fn on_text_event(&mut self, ctx: &mut EventCtx, event: &TextEvent) { match event { TextEvent::KeyboardKey(key_event, modifiers_state) => { - if !key_event.state.is_pressed() { + if !key_event.state.is_pressed() || self.editor.is_composing() { return; } #[allow(unused)] @@ -709,7 +713,50 @@ impl Widget for TextArea { TextEvent::FocusChange(_) => {} TextEvent::Ime(e) => { // TODO: Handle the cursor movement things from https://github.com/rust-windowing/winit/pull/3824 - tracing::warn!(event = ?e, "TextArea doesn't accept IME"); + let (fctx, lctx) = ctx.text_contexts(); + // The text to submit as the TextChanged action. We do not send a TextChange action + // for the "virtual" preedit text. + let mut submit_text = None; + match e { + winit::event::Ime::Disabled => { + self.editor.transact(fctx, lctx, |txn| txn.clear_compose()); + } + winit::event::Ime::Preedit(text, cursor) => { + if text.is_empty() { + self.editor.transact(fctx, lctx, |txn| txn.clear_compose()); + } else { + if !self.editor.is_composing() && self.editor.selected_text().is_some() + { + // The IME has started composing. Delete the current selection and + // send a TextChange event with the selection removed, but without + // the composing preedit text. + self.editor.transact(fctx, lctx, |txn| { + txn.delete_selection(); + }); + submit_text = Some(self.text().to_string()); + } + + self.editor + .transact(fctx, lctx, |txn| txn.set_compose(text, *cursor)); + } + } + winit::event::Ime::Commit(text) => { + self.editor + .transact(fctx, lctx, |txn| txn.insert_or_replace_selection(text)); + submit_text = Some(self.text().to_string()); + } + winit::event::Ime::Enabled => {} + } + + ctx.set_handled(); + if let Some(text) = submit_text { + ctx.submit_action(crate::Action::TextChanged(text)); + } + let new_generation = self.editor.generation(); + if new_generation != self.rendered_generation { + ctx.request_layout(); + self.rendered_generation = new_generation; + } } TextEvent::ModifierChange(_) => {} } @@ -720,12 +767,15 @@ impl Widget for TextArea { } fn accepts_text_input(&self) -> bool { - // TODO: Implement IME, then flip back to EDITABLE. - false + EDITABLE } fn on_access_event(&mut self, ctx: &mut EventCtx, event: &AccessEvent) { if event.action == accesskit::Action::SetTextSelection { + if self.editor.is_composing() { + return; + } + if let Some(accesskit::ActionData::SetTextSelection(selection)) = &event.data { let (fctx, lctx) = ctx.text_contexts(); self.editor