From c1518753b6dcc7b6470bc9db1a1ff636183a1275 Mon Sep 17 00:00:00 2001 From: Aaron Muir Hamilton Date: Wed, 23 Oct 2024 13:25:24 -0400 Subject: [PATCH] Implement IME in PlainEditor --- examples/vello_editor/src/main.rs | 21 ++- examples/vello_editor/src/text.rs | 39 +++++- parley/src/layout/cursor.rs | 28 ++-- parley/src/layout/editor.rs | 221 +++++++++++++++++++++++++++--- 4 files changed, 275 insertions(+), 34 deletions(-) diff --git a/examples/vello_editor/src/main.rs b/examples/vello_editor/src/main.rs index f59c118c..0e145c15 100644 --- a/examples/vello_editor/src/main.rs +++ b/examples/vello_editor/src/main.rs @@ -9,14 +9,14 @@ use vello::util::{RenderContext, RenderSurface}; use vello::wgpu; use vello::{AaConfig, Renderer, RendererOptions, Scene}; use winit::application::ApplicationHandler; -use winit::dpi::LogicalSize; +use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize}; use winit::event::*; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; use winit::window::Window; // #[path = "text2.rs"] mod text; -use parley::{GenericFamily, PlainEditorOp, StyleProperty}; +use parley::{GenericFamily, PlainEditorOp, Rect, StyleProperty}; // Simple struct to hold the state of the renderer pub struct ActiveRenderState<'s> { @@ -85,6 +85,8 @@ impl ApplicationHandler for SimpleVelloApp<'_> { self.renderers[surface.dev_id] .get_or_insert_with(|| create_vello_renderer(&self.context, &surface)); + window.set_ime_allowed(true); + // Save the Window and Surface to a state variable self.state = RenderState::Active(ActiveRenderState { window, surface }); @@ -116,6 +118,21 @@ impl ApplicationHandler for SimpleVelloApp<'_> { self.editor.handle_event(event.clone()); if self.last_drawn_generation != self.editor.generation() { + if let Some(Rect { x0, y0, x1, y1 }) = self.editor.preedit_area() { + // `Window::set_ime_cursor_area` is broken on X11 as of winit 0.30.5 + // see . + // + // render_state.window.set_ime_cursor_area( + // PhysicalPosition::new(x0, y0), + // PhysicalSize::new(x1 - x0, y1 - y0), + // ); + + // Not providing a size, and putting the position at the bottom left corner + // instead of the top left corner (as docs suggest) works around this for now. + render_state + .window + .set_ime_cursor_area(PhysicalPosition::new(x0, y1), PhysicalSize::new(0, 0)); + } render_state.window.request_redraw(); } // render_state diff --git a/examples/vello_editor/src/text.rs b/examples/vello_editor/src/text.rs index c6a59339..8e5c029e 100644 --- a/examples/vello_editor/src/text.rs +++ b/examples/vello_editor/src/text.rs @@ -1,12 +1,11 @@ // Copyright 2024 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use parley::layout::PositionedLayoutItem; use peniko::{kurbo::Affine, Color, Fill}; use std::time::Instant; use vello::Scene; use winit::{ - event::{Modifiers, Touch, WindowEvent}, + event::{Ime, Modifiers, Touch, WindowEvent}, keyboard::{Key, NamedKey}, }; @@ -16,7 +15,7 @@ use alloc::{sync::Arc, vec}; use core::{default::Default, iter::IntoIterator}; pub use parley::layout::editor::Generation; -use parley::{FontContext, LayoutContext, PlainEditor, PlainEditorOp}; +use parley::{FontContext, LayoutContext, PlainEditor, PlainEditorOp, PositionedLayoutItem, Rect}; pub const INSET: f32 = 32.0; @@ -42,6 +41,17 @@ impl Editor { self.editor.text() } + pub fn preedit_area(&self) -> Option { + self.editor.preedit_area().map(|r| { + Rect::new( + r.x0 + INSET as f64, + r.y0 + INSET as f64, + r.x1 + INSET as f64, + r.y1 + INSET as f64, + ) + }) + } + pub fn handle_event(&mut self, event: WindowEvent) { match event { WindowEvent::Resized(size) => { @@ -56,6 +66,26 @@ impl Editor { WindowEvent::ModifiersChanged(modifiers) => { self.modifiers = Some(modifiers); } + WindowEvent::Ime(ime) => { + self.editor.transact( + &mut self.font_cx, + &mut self.layout_cx, + match ime { + Ime::Commit(text) => { + Some(PlainEditorOp::InsertOrReplaceSelection(text.into())) + } + Ime::Disabled => Some(PlainEditorOp::ClearCompose), + Ime::Preedit(text, _) if text.is_empty() => { + Some(PlainEditorOp::ClearCompose) + } + Ime::Preedit(text, cursor) => Some(PlainEditorOp::SetCompose { + text: text.into(), + cursor, + }), + _ => None, + }, + ); + } WindowEvent::KeyboardInput { event, .. } => { if !event.state.is_pressed() { return; @@ -305,6 +335,9 @@ impl Editor { if let Some(cursor) = self.editor.selection_weak_geometry(1.5) { scene.fill(Fill::NonZero, transform, Color::LIGHT_GRAY, None, &cursor); }; + for rect in self.editor.preedit_underline_geometry(1.5).iter() { + scene.fill(Fill::NonZero, transform, Color::WHITE, None, &rect); + } for line in self.editor.lines() { for item in line.items() { let PositionedLayoutItem::GlyphRun(glyph_run) = item else { diff --git a/parley/src/layout/cursor.rs b/parley/src/layout/cursor.rs index ca3dd5c7..9d98188a 100644 --- a/parley/src/layout/cursor.rs +++ b/parley/src/layout/cursor.rs @@ -466,7 +466,10 @@ impl Selection { /// Returns the index where text should be inserted based on this /// selection. pub fn insertion_index(&self) -> usize { - self.focus.text_start as usize + (match self.focus.affinity { + Affinity::Upstream => self.focus.text_end, + Affinity::Downstream => self.focus.text_start, + }) as usize } /// Returns a new collapsed selection at the position of the current @@ -531,7 +534,7 @@ impl Selection { /// visual order. /// /// If `extend` is `true` then the current anchor will be retained, - /// otherwise the new selection will be collapsed. + /// otherwise the new selection will be collapsed. #[must_use] pub fn previous_visual( &self, @@ -552,7 +555,7 @@ impl Selection { /// Returns a new selection with the focus moved to the next word. /// /// If `extend` is `true` then the current anchor will be retained, - /// otherwise the new selection will be collapsed. + /// otherwise the new selection will be collapsed. #[must_use] pub fn next_word(&self, layout: &Layout, extend: bool) -> Self { self.maybe_extend(self.focus.next_word(layout), extend) @@ -561,13 +564,18 @@ impl Selection { /// Returns a new selection with the focus moved to the previous word. /// /// If `extend` is `true` then the current anchor will be retained, - /// otherwise the new selection will be collapsed. + /// otherwise the new selection will be collapsed. #[must_use] pub fn previous_word(&self, layout: &Layout, extend: bool) -> Self { self.maybe_extend(self.focus.previous_word(layout), extend) } - fn maybe_extend(&self, focus: Cursor, extend: bool) -> Self { + /// Returns a new selection with the focus moved to another cursor. + /// + /// If `extend` is `true` then the current anchor will be retained, + /// otherwise the new selection will be collapsed. + #[must_use] + pub fn maybe_extend(&self, focus: Cursor, extend: bool) -> Self { if extend { Self { anchor: self.anchor, @@ -583,7 +591,7 @@ impl Selection { /// current line. /// /// If `extend` is `true` then the current anchor will be retained, - /// otherwise the new selection will be collapsed. + /// otherwise the new selection will be collapsed. #[must_use] pub fn line_start(&self, layout: &Layout, extend: bool) -> Self { if let Some(line) = self.focus.path.line(layout) { @@ -600,7 +608,7 @@ impl Selection { /// current line. /// /// If `extend` is `true` then the current anchor will be retained, - /// otherwise the new selection will be collapsed. + /// otherwise the new selection will be collapsed. #[must_use] pub fn line_end(&self, layout: &Layout, extend: bool) -> Self { if let Some(line) = self.focus.path.line(layout) { @@ -621,7 +629,7 @@ impl Selection { /// current horizontal position will be maintained. /// /// If `extend` is `true` then the current anchor will be retained, - /// otherwise the new selection will be collapsed. + /// otherwise the new selection will be collapsed. #[must_use] pub fn next_line(&self, layout: &Layout, extend: bool) -> Self { self.move_lines(layout, 1, extend) @@ -631,7 +639,7 @@ impl Selection { /// current horizontal position will be maintained. /// /// If `extend` is `true` then the current anchor will be retained, - /// otherwise the new selection will be collapsed. + /// otherwise the new selection will be collapsed. #[must_use] pub fn previous_line(&self, layout: &Layout, extend: bool) -> Self { self.move_lines(layout, -1, extend) @@ -645,7 +653,7 @@ impl Selection { /// toward next lines. /// /// If `extend` is `true` then the current anchor will be retained, - /// otherwise the new selection will be collapsed. + /// otherwise the new selection will be collapsed. #[must_use] pub fn move_lines(&self, layout: &Layout, delta: isize, extend: bool) -> Self { if delta == 0 { diff --git a/parley/src/layout/editor.rs b/parley/src/layout/editor.rs index 9f310c28..afec4f2a 100644 --- a/parley/src/layout/editor.rs +++ b/parley/src/layout/editor.rs @@ -38,6 +38,13 @@ impl Generation { } } +/// Composing state during compose. +#[derive(Clone)] +struct ComposeState { + text: Arc, + cursor: Option<(usize, usize)>, +} + /// Basic plain text editor with a single default style. #[derive(Clone)] pub struct PlainEditor @@ -58,6 +65,7 @@ where // clean layout, and not all operations trigger a layout. layout_dirty: bool, generation: Generation, + compose: Option, } // TODO: When MSRV >= 1.80 we can remove this. Default was not implemented for Arc<[T]> where T: !Default until 1.80 @@ -79,6 +87,7 @@ where // will choose to use that as their initial value, but will probably need // to redraw if they haven't already. generation: Generation(1), + compose: Default::default(), } } } @@ -97,8 +106,33 @@ where SetScale(f32), /// Set the default style for the layout. SetDefaultStyle(Arc<[StyleProperty<'static, T>]>), - /// Insert at cursor, or replace selection. + /// Insert at cursor or replace selection. + /// + /// In composing mode will collapse the composing region and commit the text. InsertOrReplaceSelection(Arc), + /// Set content of composing region at cursor (often called ‘preedit’ text); + /// erasing the selection and entering composing mode. + /// + /// `cursor` is an optional cursor/selection expressed as byte offsets relative + /// to the start of the composing text. + /// It will be rendered as the primary selection/cursor during composing. + /// + /// During composing, the only supported operations are: + /// - [`PlainEditorOp::SetCompose`] + /// - [`PlainEditorOp::ClearCompose`] + /// - [`PlainEditorOp::InsertOrReplaceSelection`] + /// - [`PlainEditorOp::SetWidth`] + /// - [`PlainEditorOp::SetScale`], + /// - [`PlainEditorOp::SetDefaultStyle`] + /// + /// Issuing either [`PlainEditorOp::InsertOrReplaceSelection`] or + /// [`PlainEditorOp::ClearCompose`] will exit composing. + SetCompose { + text: Arc, + cursor: Option<(usize, usize)>, + }, + /// Clear composing region and exit composing. + ClearCompose, /// Delete the selection. DeleteSelection, /// Delete the selection or the next cluster (typical ‘delete’ behavior). @@ -175,6 +209,24 @@ where t: impl IntoIterator>, ) { for op in t.into_iter() { + { + use PlainEditorOp::*; + + // Only allow some operations during composing. + if self.compose.is_some() + && !matches!( + op, + InsertOrReplaceSelection(..) + | SetCompose { .. } + | SetWidth(..) + | SetScale(..) + | SetDefaultStyle(..) + ) + { + continue; + } + } + match op { PlainEditorOp::SetText(is) => { self.buffer.clear(); @@ -222,6 +274,7 @@ where } } PlainEditorOp::DeleteWord => { + self.refresh_layout(font_cx, layout_cx); let start = self.selection.insertion_index(); if self.selection.is_collapsed() { let end = self @@ -244,6 +297,7 @@ where } } PlainEditorOp::Backdelete => { + self.refresh_layout(font_cx, layout_cx); let end = self.selection.focus().text_range().start; if self.selection.is_collapsed() { if let Some(start) = self @@ -274,6 +328,7 @@ where } } PlainEditorOp::BackdeleteWord => { + self.refresh_layout(font_cx, layout_cx); let end = self.selection.focus().text_range().start; if self.selection.is_collapsed() { let start = self @@ -296,7 +351,53 @@ where } } PlainEditorOp::InsertOrReplaceSelection(s) => { - self.replace_selection(font_cx, layout_cx, &s); + if let Some(ComposeState { text, .. }) = self.compose.clone() { + self.buffer.replace_range( + self.selection.insertion_index() + ..(self.selection.insertion_index() + text.len()), + &s, + ); + self.update_layout(font_cx, layout_cx); + self.insert_cursor(self.selection.insertion_index() + s.len()); + self.compose = None; + self.layout_dirty = true; + } else { + self.replace_selection(font_cx, layout_cx, &s); + } + } + PlainEditorOp::SetCompose { text, cursor } => { + if let Some(ComposeState { text: oldtext, .. }) = self.compose.clone() { + self.buffer.replace_range( + self.selection.insertion_index() + ..(self.selection.insertion_index() + oldtext.len()), + &text, + ); + self.layout_dirty = true; + } else { + if !self.selection.is_collapsed() { + self.buffer + .replace_range(self.selection.text_range(), &text); + } else { + self.buffer + .insert_str(self.selection.insertion_index(), &text); + } + self.layout_dirty = true; + } + self.compose = Some(ComposeState { + text: text.clone(), + cursor, + }); + } + PlainEditorOp::ClearCompose => { + if let Some(ComposeState { text, .. }) = self.compose.clone() { + self.buffer.replace_range( + self.selection.insertion_index() + ..(self.selection.insertion_index() + text.len()), + "", + ); + } + self.compose = None; + self.layout_dirty = true; } PlainEditorOp::MoveToPoint(x, y) => { self.refresh_layout(font_cx, layout_cx); @@ -423,20 +524,7 @@ where } self.update_layout(font_cx, layout_cx); - let new_start = start.saturating_add(s.len()); - self.selection = if new_start == self.buffer.len() { - Selection::from_index( - &self.layout, - new_start.saturating_sub(1), - Affinity::Upstream, - ) - } else { - Selection::from_index( - &self.layout, - new_start.min(self.buffer.len()), - Affinity::Downstream, - ) - }; + self.insert_cursor(start.saturating_add(s.len())); } /// Update the selection, and nudge the `Generation` if something other than `h_pos` changed. @@ -449,6 +537,26 @@ where self.selection = new_sel; } + /// Caret `Selection` at index. + fn caret_at_index(&self, index: usize) -> Selection { + if index == self.buffer.len() { + Selection::from_index(&self.layout, index.saturating_sub(1), Affinity::Upstream) + } else { + Selection::from_index(&self.layout, index, Affinity::Downstream) + } + } + + /// Selection between two indices. + fn sel_between_indices(&self, from: usize, to: usize) -> Selection { + self.caret_at_index(from) + .maybe_extend(*self.caret_at_index(to).focus(), true) + } + + /// Insert cursor at index. + fn insert_cursor(&mut self, index: usize) { + self.set_selection(self.caret_at_index(index)); + } + /// Get either the contents of the current selection, or the text of the cluster at the caret. pub fn active_text(&self) -> ActiveText { if self.selection.is_collapsed() { @@ -465,18 +573,93 @@ where } } + /// Get a selection representing either the actual selection, or the preedit cursor. + fn selection_or_preedit_selection(&self) -> Selection { + self.compose + .clone() + .and_then(|s| { + s.cursor.map(|(s, e)| { + self.sel_between_indices( + self.selection.insertion_index().saturating_add(s), + self.selection.insertion_index().saturating_add(e), + ) + }) + }) + .unwrap_or(self.selection) + } + + pub fn preedit_area(&self) -> Option { + self.compose + .clone() + .and_then(|ComposeState { cursor, text }| { + if cursor.map(|(s, e)| s == e).unwrap_or(false) { + println!("yes a caret"); + // otherwise the whole preedit area is the whole composing text region + let geom = self + .sel_between_indices( + self.selection.insertion_index(), + self.selection.insertion_index() + text.len(), + ) + .geometry(&self.layout); + if geom.is_empty() { + None + } else { + let mut r = Rect::new( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ); + for rect in geom { + r.x0 = r.x0.min(rect.x0); + r.y0 = r.y0.min(rect.y0); + r.x1 = r.x1.max(rect.x1); + r.y1 = r.y1.max(rect.y1); + } + Some(r) + } + } else { + println!("not a caret"); + // compose selection is not a caret, so preedit area is the selection + self.selection_or_preedit_selection() + .focus() + .strong_geometry(&self.layout, 1.0) + } + }) + } + + pub fn preedit_underline_geometry(&self, size: f32) -> Vec { + self.compose + .clone() + .map(|ComposeState { text, .. }| { + self.sel_between_indices( + self.selection.insertion_index(), + self.selection.insertion_index().saturating_add(text.len()), + ) + .geometry(&self.layout) + .iter() + .map(|r| Rect::new(r.x0, r.y1 - size as f64, r.x1, r.y1)) + .collect() + }) + .unwrap_or_default() + } + /// Get rectangles representing the selected portions of text. pub fn selection_geometry(&self) -> Vec { - self.selection.geometry(&self.layout) + self.selection_or_preedit_selection().geometry(&self.layout) } /// Get a rectangle representing the current caret cursor position. pub fn selection_strong_geometry(&self, size: f32) -> Option { - self.selection.focus().strong_geometry(&self.layout, size) + self.selection_or_preedit_selection() + .focus() + .strong_geometry(&self.layout, size) } pub fn selection_weak_geometry(&self, size: f32) -> Option { - self.selection.focus().weak_geometry(&self.layout, size) + self.selection_or_preedit_selection() + .focus() + .weak_geometry(&self.layout, size) } /// Get the lines from the `Layout`.