Skip to content

Commit

Permalink
Add PlainEditor method to get IME cursor area (#224)
Browse files Browse the repository at this point in the history
The area reported is the area of text the user is currently editing. It
is usually used by platforms to place a candidate box for IME near it,
while ensuring it is not obscured.

The implementation here ensures the area is usually near the focus, that
the area contains at least some surrounding context (in case the
platform places something to the side of the area, instead of above or
under), and that the IME candidate box usually does not need to jump
around when the IME starts or continues composing.
  • Loading branch information
tomcur authored Dec 10, 2024
1 parent 85a824a commit 1a8740d
Show file tree
Hide file tree
Showing 3 changed files with 80 additions and 3 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ This release has an [MSRV] of 1.75.

- `Generation` on `PlainEditor` to help implement lazy drawing. ([#143] by [@xorgy])
- Support for preedit for input methods in `PlainEditor` ([#192][], [#198][] by [@tomcur][])
- `PlainEditor` method to get a cursor area for use by the platform's input method ([#224][] by [@tomcur][])

### Changed

Expand Down Expand Up @@ -113,6 +114,7 @@ This release has an [MSRV] of 1.70.
[#198]: https://github.com/linebender/parley/pull/198
[#211]: https://github.com/linebender/parley/pull/211
[#223]: https://github.com/linebender/parley/pull/223
[#224]: https://github.com/linebender/parley/pull/224

[Unreleased]: https://github.com/linebender/parley/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/linebender/parley/releases/tag/v0.2.0
Expand Down
25 changes: 22 additions & 3 deletions examples/vello_editor/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@
#![allow(clippy::shadow_unrelated)]
#![allow(clippy::unseparated_literal_suffix)]

use accesskit::{Node, Rect, Role, Tree, TreeUpdate};
use accesskit::{Node, Role, Tree, TreeUpdate};
use anyhow::Result;
use std::num::NonZeroUsize;
use std::sync::Arc;
use vello::kurbo;
use vello::peniko::Color;
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::{StartCause, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
use winit::window::Window;
Expand Down Expand Up @@ -55,7 +56,7 @@ impl ActiveRenderState<'_> {
}
let mut node = Node::new(Role::TextInput);
let size = self.window.inner_size();
node.set_bounds(Rect {
node.set_bounds(accesskit::Rect {
x0: 0.0,
y0: 0.0,
x1: size.width as _,
Expand Down Expand Up @@ -94,6 +95,9 @@ struct SimpleVelloApp<'s> {
/// The last generation of the editor layout that we drew.
last_drawn_generation: text::Generation,

/// The IME cursor area we last sent to the platform.
last_sent_ime_cursor_area: kurbo::Rect,

/// The event loop proxy required by the AccessKit winit adapter.
event_loop_proxy: EventLoopProxy<accesskit_winit::Event>,
}
Expand Down Expand Up @@ -215,6 +219,20 @@ impl ApplicationHandler<accesskit_winit::Event> for SimpleVelloApp<'_> {
self.editor.handle_event(event.clone());
if self.last_drawn_generation != self.editor.generation() {
render_state.window.request_redraw();
let area = self.editor.editor().ime_cursor_area();
if self.last_sent_ime_cursor_area != area {
self.last_sent_ime_cursor_area = area;
// Note: on X11 `set_ime_cursor_area` may cause the exclusion area to be obscured
// until https://github.com/rust-windowing/winit/pull/3966 is in the Winit release
// used by this example.
render_state.window.set_ime_cursor_area(
PhysicalPosition::new(
area.x0 + text::INSET as f64,
area.y0 + text::INSET as f64,
),
PhysicalSize::new(area.width(), area.height()),
);
}
}
// render_state
// .window
Expand Down Expand Up @@ -340,6 +358,7 @@ fn main() -> Result<()> {
scene: Scene::new(),
editor: text::Editor::new(text::LOREM),
last_drawn_generation: Default::default(),
last_sent_ime_cursor_area: kurbo::Rect::new(f64::NAN, f64::NAN, f64::NAN, f64::NAN),
event_loop_proxy: event_loop.create_proxy(),
};

Expand Down
56 changes: 56 additions & 0 deletions parley/src/layout/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::{
cursor::{Cursor, Selection},
Affinity, Alignment, Layout,
},
resolve::ResolvedStyle,
style::Brush,
FontContext, LayoutContext, Rect, StyleProperty, StyleSet,
};
Expand Down Expand Up @@ -770,6 +771,61 @@ where
.then(|| self.selection.focus().geometry(&self.layout, size))
}

/// Get a rectangle bounding the text the user is currently editing.
///
/// This is useful for suggesting an exclusion area to the platform for, e.g., IME candidate
/// box placement. This bounds the area of the preedit text if present, otherwise it bounds the
/// selection on the focused line.
pub fn ime_cursor_area(&self) -> Rect {
let (area, focus) = if let Some(preedit_range) = &self.compose {
let selection = Selection::new(
self.cursor_at(preedit_range.start),
self.cursor_at(preedit_range.end),
);

// Bound the entire preedit text.
let mut area = None;
selection.geometry_with(&self.layout, |rect| {
let area = area.get_or_insert(rect);
*area = area.union(rect);
});

(
area.unwrap_or_else(|| selection.focus().geometry(&self.layout, 0.)),
selection.focus(),
)
} else {
// Bound the selected parts of the focused line only.
let focus = self.selection.focus().geometry(&self.layout, 0.);
let mut area = focus;
self.selection.geometry_with(&self.layout, |rect| {
if rect.y0 == focus.y0 {
area = area.union(rect);
}
});

(area, self.selection.focus())
};

// Ensure some context is captured even for tiny or collapsed selections by including a
// region surrounding the selection. Doing this unconditionally, the IME candidate box
// usually does not need to jump around when composing starts or the preedit is added to.
let [upstream, downstream] = focus.logical_clusters(&self.layout);
let font_size = downstream
.or(upstream)
.map(|cluster| cluster.run().font_size())
.unwrap_or(ResolvedStyle::<T>::default().font_size);
// Using 0.6 as an estimate of the average advance
let inflate = 3. * 0.6 * font_size as f64;
let editor_width = self.width.map(f64::from).unwrap_or(f64::INFINITY);
Rect {
x0: (area.x0 - inflate).max(0.),
x1: (area.x1 + inflate).min(editor_width),
y0: area.y0,
y1: area.y1,
}
}

/// Borrow the text content of the buffer.
///
/// The return value is a `SplitString` because it
Expand Down

0 comments on commit 1a8740d

Please sign in to comment.