Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

parley: add IME support to text editor example #111

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
3 changes: 2 additions & 1 deletion examples/vello_editor/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
127 changes: 125 additions & 2 deletions examples/vello_editor/src/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@

#[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::time::Instant;
use vello::Scene;
use winit::dpi::{LogicalPosition, LogicalSize};
use winit::event::Ime;
use winit::window::Window;
use winit::{
event::{Modifiers, WindowEvent},
keyboard::{KeyCode, PhysicalKey},
Expand All @@ -26,13 +29,24 @@ pub enum ActiveText<'a> {
Selection(&'a str),
}

#[derive(Default)]
enum ComposeState {
#[default]
None,
Preedit {
/// The location of the (uncommitted) preedit text
text_at: Selection,
},
}

tomcur marked this conversation as resolved.
Show resolved Hide resolved
#[derive(Default)]
pub struct Editor {
font_cx: FontContext,
layout_cx: LayoutContext,
buffer: String,
layout: Layout,
selection: Selection,
compose_state: ComposeState,
cursor_mode: VisualMode,
last_click_time: Option<Instant>,
click_count: u32,
Expand Down Expand Up @@ -126,7 +140,28 @@ impl Editor {
// TODO: support clipboard on Android
}

pub fn handle_event(&mut self, event: &WindowEvent) {
pub fn handle_event(&mut self, window: &Window, event: &WindowEvent) {
if let ComposeState::Preedit { text_at } = self.compose_state {
// Clear old preedit state when handling events that potentially mutate text/selection.
// This is a bit overzealous, e.g., pressing and releasing shift probably shouldnt't
// clear the preedit.
if matches!(
event,
WindowEvent::KeyboardInput { .. }
| WindowEvent::MouseInput { .. }
| WindowEvent::Ime(..)
tomcur marked this conversation as resolved.
Show resolved Hide resolved
) {
let range = text_at.text_range();
self.selection =
Selection::from_index(&self.layout, range.start - 1, Affinity::Upstream);
self.buffer.replace_range(range, "");
self.compose_state = ComposeState::None;
// TODO: defer updating layout. If the event itself also causes an update, we now
// update twice.
self.update_layout(self.width, 1.0);
}
}

match event {
WindowEvent::Resized(size) => {
self.update_layout(size.width as f32, 1.0);
Expand Down Expand Up @@ -265,6 +300,87 @@ impl Editor {

// println!("Active text: {:?}", self.active_text());
}
WindowEvent::Ime(ime) => {
match ime {
Ime::Commit(text) => {
let start = self
.delete_current_selection()
.unwrap_or_else(|| self.selection.focus().text_range().start);
self.buffer.insert_str(start, text);
self.update_layout(self.width, 1.0);
self.selection = Selection::from_index(
&self.layout,
start + text.len() - 1,
Affinity::Upstream,
);
}
Ime::Preedit(text, compose_cursor) => {
if text.is_empty() {
// Winit sends empty preedit text to indicate the preedit was cleared.
return;
}

let start = self
.delete_current_selection()
.unwrap_or_else(|| self.selection.focus().text_range().start);
self.buffer.insert_str(start, text);
self.update_layout(self.width, 1.0);

{
// winit says the cursor should be hidden when compose_cursor is None.
// Do we handle that? We also don't extend the cursor to the end
// indicated by winit, instead IME composing is currently indicated by
// highlighting the entire preedit text. Should we even update the
// selection at all?
let compose_cursor = compose_cursor.unwrap_or((0, 0));
self.selection = Selection::from_index(
&self.layout,
start - 1 + compose_cursor.0,
Affinity::Upstream,
);
}

{
let text_end = Cursor::from_index(
&self.layout,
start - 1 + text.len(),
Affinity::Upstream,
);
let ime_cursor = self.selection.extend_to_cursor(text_end);
self.compose_state = ComposeState::Preedit {
text_at: ime_cursor,
};

// Find the smallest rectangle that contains the entire preedit text.
// Send that rectangle to the platform to suggest placement for the IME
// candidate box.
let mut union_rect = None;
ime_cursor.geometry_with(&self.layout, |rect| {
if union_rect.is_none() {
union_rect = Some(rect);
}
union_rect = Some(union_rect.unwrap().union(rect));
});
if let Some(union_rect) = union_rect {
window.set_ime_cursor_area(
LogicalPosition::new(union_rect.x0, union_rect.y0),
LogicalSize::new(
union_rect.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)
union_rect.height() + 40.0,
),
);
}
}
}
_ => {}
}
}
WindowEvent::MouseInput { state, button, .. } => {
if *button == winit::event::MouseButton::Left {
self.pointer_down = state.is_pressed();
Expand Down Expand Up @@ -350,6 +466,13 @@ impl Editor {
if let Some(cursor) = self.selection.focus().weak_geometry(&self.layout, 1.5) {
scene.fill(Fill::NonZero, transform, Color::LIGHT_GRAY, None, &cursor);
};
if let ComposeState::Preedit { text_at } = self.compose_state {
tomcur marked this conversation as resolved.
Show resolved Hide resolved
// TODO: underline rather than fill, requires access to underline_offset metric for
// each run
text_at.geometry_with(&self.layout, |rect| {
scene.fill(Fill::NonZero, transform, Color::SPRING_GREEN, None, &rect);
});
}
for line in self.layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
Expand Down
10 changes: 10 additions & 0 deletions parley/src/layout/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,16 @@ impl Selection {
}
}

/// Returns a new selection with the focus extended to the given cursor.
#[must_use]
pub fn extend_to_cursor(&self, focus: Cursor) -> Self {
Self {
anchor: self.anchor,
focus,
h_pos: None,
}
}

tomcur marked this conversation as resolved.
Show resolved Hide resolved
/// Returns a new selection with the focus moved to the next cluster in
/// visual order.
///
Expand Down