diff --git a/examples/vello_editor/src/main.rs b/examples/vello_editor/src/main.rs index 4fc2e071..d3ef17d5 100644 --- a/examples/vello_editor/src/main.rs +++ b/examples/vello_editor/src/main.rs @@ -60,6 +60,7 @@ impl<'s> ApplicationHandler for SimpleVelloApp<'s> { let window = cached_window .take() .unwrap_or_else(|| create_winit_window(event_loop)); + window.set_ime_allowed(true); // Create a vello Surface let size = window.inner_size(); @@ -107,7 +108,7 @@ impl<'s> ApplicationHandler for SimpleVelloApp<'s> { _ => return, }; - self.editor.handle_event(&event); + self.editor.handle_event(&render_state.window, &event); render_state.window.request_redraw(); // render_state // .window diff --git a/examples/vello_editor/src/text.rs b/examples/vello_editor/src/text.rs index 897654a8..cf2262ad 100644 --- a/examples/vello_editor/src/text.rs +++ b/examples/vello_editor/src/text.rs @@ -3,12 +3,17 @@ #[cfg(not(target_os = "android"))] use clipboard_rs::{Clipboard, ClipboardContext}; -use parley::layout::cursor::{Selection, VisualMode}; +use parley::layout::cursor::{Cursor, Selection, VisualMode}; use parley::layout::Affinity; use parley::{layout::PositionedLayoutItem, FontContext}; use peniko::{kurbo::Affine, Color, Fill}; +use std::ops::Range; use std::time::Instant; +use vello::kurbo::{Line, Rect, Stroke}; use vello::Scene; +use winit::dpi::{LogicalPosition, LogicalSize}; +use winit::event::Ime; +use winit::window::Window; use winit::{ event::{Modifiers, WindowEvent}, keyboard::{KeyCode, PhysicalKey}, @@ -26,6 +31,22 @@ pub enum ActiveText<'a> { Selection(&'a str), } +/// The input method state of the text editor. +/// +/// If `Enabled` or `Preedit`, the editor should send appropriate +/// [IME candidate box areas](`Window::set_ime_cursor_area`) to the platform. +#[derive(Default, PartialEq, Eq)] +enum ImeState { + /// The IME is disabled. + #[default] + Disabled, + /// The IME is enabled. + Enabled, + /// The IME is enabled and is composing text. The range is non-empty, encoding the byte offsets + /// of the text currently marked as preedit by the IME. + Preedit(Range), +} + #[derive(Default)] pub struct Editor { font_cx: FontContext, @@ -33,6 +54,10 @@ pub struct Editor { buffer: String, layout: Layout, selection: Selection, + ime: ImeState, + /// The IME candidate area last sent to the platform (through [`Self::set_ime_cursor_area`]). + /// Cached to avoid repeatedly sending unchanged areas. + last_sent_ime_candidate_area: Rect, cursor_mode: VisualMode, last_click_time: Option, click_count: u32, @@ -42,6 +67,32 @@ pub struct Editor { width: f32, } +/// Shrink the selection by the given amount of bytes by moving the focus towards the anchor. +#[must_use] +fn shrink_selection(layout: &Layout, selection: Selection, bytes: usize) -> Selection { + let mut selection = selection; + let shrink = bytes.min(selection.text_range().len()); + if shrink == 0 { + return selection; + } + + let anchor = *selection.anchor(); + let focus = *selection.focus(); + + let new_focus_index = if focus.text_range().start > anchor.text_range().start { + focus.index() - shrink + } else { + focus.index() + shrink + }; + + selection = Selection::from_cursors( + anchor, + Cursor::from_index(layout, new_focus_index, focus.affinity()), + ); + + selection +} + impl Editor { pub fn set_text(&mut self, text: &str) { self.buffer.clear(); @@ -57,6 +108,16 @@ impl Editor { builder.push_default(&parley::style::StyleProperty::FontStack( parley::style::FontStack::Source("system-ui"), )); + if let ImeState::Preedit(ref text_range) = self.ime { + builder.push( + &parley::style::StyleProperty::UnderlineBrush(Some(Color::SPRING_GREEN)), + text_range.clone(), + ); + builder.push( + &parley::style::StyleProperty::Underline(true), + text_range.clone(), + ); + } builder.build_into(&mut self.layout); self.layout.break_all_lines(Some(width - INSET * 2.0)); self.layout @@ -126,7 +187,57 @@ impl Editor { // TODO: support clipboard on Android } - pub fn handle_event(&mut self, event: &WindowEvent) { + /// Suggest an area for IME candidate box placement based on the current IME state. + fn set_ime_cursor_area(&mut self, window: &Window) { + let selection = match self.ime { + ImeState::Preedit(ref text_range) => { + debug_assert!(!text_range.is_empty()); + Selection::from_cursors( + Cursor::from_index(&self.layout, text_range.start, Affinity::Downstream), + Cursor::from_index(&self.layout, text_range.end - 1, Affinity::Upstream), + ) + } + _ => self.selection, + }; + + let area = if selection.is_collapsed() { + selection.focus().strong_geometry(&self.layout, 1.5) + } else { + // Find the smallest rectangle that contains the entire selection. + let mut union_rect = None; + selection.geometry_with(&self.layout, |rect| { + if union_rect.is_none() { + union_rect = Some(rect); + } + union_rect = Some(union_rect.unwrap().union(rect)); + }); + union_rect + }; + + if let Some(area) = area { + if area != self.last_sent_ime_candidate_area { + self.last_sent_ime_candidate_area = area; + + window.set_ime_cursor_area( + LogicalPosition::new(area.x0, area.y0), + LogicalSize::new( + area.width(), + // TODO: an offset is added here to prevent the IME candidate box from + // overlapping with the IME cursor. From the Winit docs I would've expected the + // IME candidate box not to overlap the indicated IME cursor area, but for some + // reason it does (tested using fcitx5 on wayland) + area.height() + 40.0, + ), + ); + } + } + } + + fn ime_is_composing(&self) -> bool { + matches!(&self.ime, ImeState::Preedit(_)) + } + + pub fn handle_event(&mut self, window: &Window, event: &WindowEvent) { match event { WindowEvent::Resized(size) => { self.update_layout(size.width as f32, 1.0); @@ -157,12 +268,21 @@ impl Editor { if let PhysicalKey::Code(code) = event.physical_key { match code { KeyCode::KeyC if action_mod => { + if self.ime_is_composing() { + return; + } self.handle_clipboard(code); } KeyCode::KeyX if action_mod => { + if self.ime_is_composing() { + return; + } self.handle_clipboard(code); } KeyCode::KeyV if action_mod => { + if self.ime_is_composing() { + return; + } self.handle_clipboard(code); } KeyCode::ArrowLeft => { @@ -207,6 +327,11 @@ impl Editor { } } KeyCode::Delete => { + // The IME or Winit probably intercepts this event when composing, but + // I'm not sure. + if self.ime_is_composing() { + return; + } let start = if self.selection.is_collapsed() { let range = self.selection.focus().text_range(); let start = range.start; @@ -222,6 +347,11 @@ impl Editor { } } KeyCode::Backspace => { + // The IME or Winit probably intercepts this event when composing, but + // I'm not sure. + if self.ime_is_composing() { + return; + } let start = if self.selection.is_collapsed() { let end = self.selection.focus().text_range().start; if let Some((start, _)) = @@ -247,6 +377,9 @@ impl Editor { } } _ => { + if self.ime_is_composing() { + return; + } if let Some(text) = &event.text { let start = self .delete_current_selection() @@ -265,6 +398,141 @@ impl Editor { // println!("Active text: {:?}", self.active_text()); } + WindowEvent::Ime(ime) => { + match ime { + Ime::Enabled => { + debug_assert!(self.ime == ImeState::Disabled); + self.ime = ImeState::Enabled; + } + Ime::Commit(text) => { + debug_assert!(self.ime != ImeState::Disabled); + + let commit_start = if self.selection.is_collapsed() { + let start = self.selection.insertion_index(); + self.buffer.insert_str(start, text); + start + } else { + let range = self.selection.text_range(); + self.buffer.replace_range(range.clone(), text); + range.start + }; + + self.update_layout(self.width, 1.0); + + if text.is_empty() { + // is this case ever hit? + self.selection = Selection::from_index( + &self.layout, + commit_start, + Affinity::Downstream, + ); + } else { + self.selection = Selection::from_index( + &self.layout, + commit_start + text.len() - 1, + Affinity::Upstream, + ); + } + } + Ime::Preedit(text, compose_cursor) => { + debug_assert!(self.ime != ImeState::Disabled); + + let preedit_start = match self.ime { + ImeState::Preedit(ref text_range) => { + self.buffer.replace_range(text_range.clone(), text); + text_range.start + } + _ => { + let insertion_idx = self + .delete_current_selection() + .unwrap_or(self.selection.insertion_index()); + self.buffer.insert_str(insertion_idx, text); + insertion_idx + } + }; + + if text.is_empty() { + self.ime = ImeState::Enabled; + self.update_layout(self.width, 1.0); + + if preedit_start > 0 { + self.selection = Selection::from_index( + &self.layout, + preedit_start - 1, + Affinity::Upstream, + ); + } else { + self.selection = Selection::from_index( + &self.layout, + preedit_start, + Affinity::Downstream, + ); + } + } else { + self.ime = ImeState::Preedit(preedit_start..preedit_start + text.len()); + self.update_layout(self.width, 1.0); + + if let Some(compose_cursor) = compose_cursor { + // Select the location indicated by the IME. + if compose_cursor.0 == compose_cursor.1 { + self.selection = Selection::from_index( + &self.layout, + preedit_start + compose_cursor.0, + Affinity::Downstream, + ); + } else { + self.selection = Selection::from_cursors( + Cursor::from_index( + &self.layout, + preedit_start + compose_cursor.0, + Affinity::Downstream, + ), + Cursor::from_index( + &self.layout, + preedit_start + compose_cursor.1 - 1, + Affinity::Upstream, + ), + ); + } + } else { + // IME indicates nothing is to be selected: collapse the selection to a + // caret just in front of the preedit. + self.selection = Selection::from_index( + &self.layout, + preedit_start, + Affinity::Downstream, + ); + } + } + } + Ime::Disabled => { + debug_assert!(matches!(self.ime, ImeState::Enabled | ImeState::Preedit(_))); + + self.last_sent_ime_candidate_area = Rect::default(); + if let ImeState::Preedit(text_range) = + std::mem::replace(&mut self.ime, ImeState::Disabled) + { + if !text_range.is_empty() { + self.buffer.replace_range(text_range.clone(), ""); + self.update_layout(self.width, 1.0); + + // Invariant: the selection anchor and start of preedit text are at the same + // position. + // If the focus extends into the preedit range, shrink the selection. + if self.selection.focus().text_range().start > text_range.start { + self.selection = shrink_selection( + &self.layout, + self.selection, + text_range.len(), + ); + } else { + self.selection = self.selection.refresh(&self.layout); + } + } + } + } + } + } WindowEvent::MouseInput { state, button, .. } => { if *button == winit::event::MouseButton::Left { self.pointer_down = state.is_pressed(); @@ -326,6 +594,56 @@ impl Editor { } _ => {} } + + if let ImeState::Preedit(ref text_range) = self.ime { + if text_range.start != self.selection.anchor().text_range().start { + // If the selection anchor is no longer at the same position as the preedit text, the + // selection has been moved. Move the preedit to the selection's new anchor position. + + // TODO: we can be smarter here to prevent need of the String allocation + let text = self.buffer[text_range.clone()].to_owned(); + self.buffer.replace_range(text_range.clone(), ""); + + if self.selection.anchor().text_range().start > text_range.start { + // shift the selection to the left to account for the preedit text that was + // just removed + let anchor = *self.selection.anchor(); + let focus = *self.selection.focus(); + let shift = text_range + .len() + .min(anchor.text_range().start - text_range.start); + self.selection = Selection::from_cursors( + Cursor::from_index(&self.layout, anchor.index() - shift, anchor.affinity()), + Cursor::from_index(&self.layout, focus.index() - shift, focus.affinity()), + ); + } + + let insertion_index = self.selection.insertion_index(); + self.buffer.insert_str(insertion_index, &text); + self.ime = ImeState::Preedit(insertion_index..insertion_index + text.len()); + + // TODO: events that caused the preedit to be moved may also have updated the + // layout, in that case we're now updating twice. + self.update_layout(self.width, 1.0); + + self.selection = self.selection.refresh(&self.layout); + } + } + + if self.ime != ImeState::Disabled + && !matches!( + event, + WindowEvent::RedrawRequested | WindowEvent::CursorMoved { .. } + ) + { + // TODO: this is a bit overzealous in recalculating the IME cursor area: there are + // cases where it can cheaply be known the area is unchanged. (If the calculated area + // is unchanged from the previous one sent, it is not re-sent to the platform, though). + // + // Ideally this is called only when the layout, selection, or preedit has changed, or + // if the IME has just been enabled. + self.set_ime_cursor_area(window); + } } fn delete_current_selection(&mut self) -> Option { @@ -364,6 +682,8 @@ impl Editor { let glyph_xform = synthesis .skew() .map(|angle| Affine::skew(angle.to_radians().tan() as f64, 0.0)); + + let style = glyph_run.style(); let coords = run .normalized_coords() .iter() @@ -390,6 +710,34 @@ impl Editor { } }), ); + if let Some(underline) = &style.underline { + let underline_brush = &underline.brush; + let run_metrics = glyph_run.run().metrics(); + let offset = match underline.offset { + Some(offset) => offset, + None => run_metrics.underline_offset, + }; + let width = match underline.size { + Some(size) => size, + None => run_metrics.underline_size, + }; + // The `offset` is the distance from the baseline to the *top* of the underline + // so we move the line down by half the width + // Remember that we are using a y-down coordinate system + let y = glyph_run.baseline() - offset + width / 2.; + + let line = Line::new( + (glyph_run.offset() as f64, y as f64), + ((glyph_run.offset() + glyph_run.advance()) as f64, y as f64), + ); + scene.stroke( + &Stroke::new(width.into()), + transform, + underline_brush, + None, + &line, + ); + } } } } diff --git a/parley/src/layout/cursor.rs b/parley/src/layout/cursor.rs index ca3dd5c7..86718594 100644 --- a/parley/src/layout/cursor.rs +++ b/parley/src/layout/cursor.rs @@ -407,6 +407,15 @@ impl Selection { Cursor::from_point(layout, x, y).into() } + /// Creates a selection bounding the given anchor and focus cursors. + pub fn from_cursors(anchor: Cursor, focus: Cursor) -> Self { + Self { + anchor, + focus, + h_pos: None, + } + } + /// Creates a new selection bounding the word at the given point. pub fn word_from_point(layout: &Layout, x: f32, y: f32) -> Self { let mut anchor = Cursor::from_point(layout, x, y);