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
160 changes: 158 additions & 2 deletions examples/vello_editor/src/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@

#[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::kurbo::{Line, 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},
Expand All @@ -26,13 +30,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 All @@ -57,6 +72,14 @@ impl Editor {
builder.push_default(&parley::style::StyleProperty::FontStack(
parley::style::FontStack::Source("system-ui"),
));
if let ComposeState::Preedit { text_at } = self.compose_state {
let text_range = text_at.text_range();
builder.push(
&parley::style::StyleProperty::UnderlineBrush(Some(Color::SPRING_GREEN)),
text_range.clone(),
);
builder.push(&parley::style::StyleProperty::Underline(true), text_range);
}
builder.build_into(&mut self.layout);
self.layout.break_all_lines(Some(width - INSET * 2.0));
self.layout
Expand Down Expand Up @@ -126,7 +149,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 +309,88 @@ 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);

{
// 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,
),
);
}
}

self.update_layout(self.width, 1.0);
}
_ => {}
}
}
WindowEvent::MouseInput { state, button, .. } => {
if *button == winit::event::MouseButton::Left {
self.pointer_down = state.is_pressed();
Expand Down Expand Up @@ -364,6 +490,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()
Expand All @@ -390,6 +518,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,
);
}
}
}
}
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