docking: native floating windows publish their IME descriptor

A native floating window is an egui pass of its own, and can draw several
panel bodies. A Composer drawn there records its descriptor, and the dock
area publishes it once, after the window's last body, so a body drawn after
the editor's cannot clear it and end the composition. DockContent::window_drawn
runs at the same point for apps that publish their own descriptor.
This commit is contained in:
aiongg committed 2026-10-26 02:21:16 +00:00
1 parent c692b6816d
commit 28f4036f3b
9 files changed
+252 -11

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
//! IME descriptors of editors in native floating windows (feature `native-windows`).
use egui::{Event, Id, ImeEvent, Rect, ViewportId, output::IMEOutput, pos2, vec2};
use super::testing::DockDriver;
use super::{DockContent, Floating, Layout, Node};
use crate::ime::{Composer, Edit};
use crate::layout::{SplitAxis, SplitSize};
/// Desktop position of the main window's content.
const ORIGIN: egui::Pos2 = pos2(100.0, 50.0);
struct Content {
/// The panel whose canvas takes text through `composer`.
editor: u32,
composer: Composer,
typed: String,
/// A panel whose body clears the pass's IME descriptor, as an app's own editor that
/// publishes after each body does when it has nothing to show.
clears: Option<u32>,
/// The bodies drawn in the last frame, with the viewport each was drawn in.
drawn: Vec<(u32, ViewportId)>,
}
impl Content {
fn new(editor: u32) -> Self {
Self { editor, composer: Composer::new().with_owner(Id::unique("editor")), typed: String::new(), clears: None, drawn: Vec::new() }
}
}
impl DockContent<u32> for Content {
fn body(&mut self, ui: &mut egui::Ui, panel: &u32) {
self.drawn.push((*panel, ui.ctx().viewport_id()));
if *panel == self.editor {
for edit in self.composer.update(ui.ctx(), true) {
if let Edit::Insert(text) = edit {
self.typed.push_str(&text);
}
}
self.composer.set_caret(ui.ctx(), Rect::from_min_size(ui.max_rect().min + vec2(8.0, 8.0), vec2(1.0, 16.0)));
}
if self.clears == Some(*panel) {
ui.ctx().output_mut(|o| o.ime = None);
}
}
}
fn tabs(panels: &[u32]) -> Node<u32> {
Node::Tabs { panels: panels.to_vec(), active: 0 }
}
/// Frame `[1, 2]`, and a native window with panel 5 above panel 6.
fn layout() -> Layout<u32> {
let split = Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(0.5), first: Box::new(tabs(&[5])), second: Box::new(tabs(&[6])) };
Layout { root: Some(tabs(&[1, 2])), floating: vec![Floating::new(split, [300.0, 80.0, 240.0, 300.0])] }
}
fn driver(content: Content) -> DockDriver<u32, Content> {
DockDriver::new(layout(), content).with_native_host(ORIGIN)
}
fn preedit(text: &str) -> Event {
Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: None })
}
/// The window's id and the descriptor its OS window published last.
fn window_ime(d: &DockDriver<u32, Content>) -> (u64, Option<IMEOutput>) {
let id = d.layout.floating[0].id;
let viewport = d.viewport(id);
(id, d.host().windows.get(&viewport).and_then(|w| w.ime.clone()))
}
#[test]
#[allow(clippy::expect_used)]
fn a_composition_in_a_native_window_publishes_on_that_window() {
let mut d = driver(Content::new(5));
let (id, _) = window_ime(&d);
d.window_step(id, vec![preedit("に")]);
assert_eq!(d.content.composer.preedit().map(|p| p.text), Some("に"));
let (_, ime) = window_ime(&d);
let ime = ime.expect("the window publishes the editor's descriptor");
assert_eq!(ime.owner, Id::unique("editor"));
// At the caret, in the window's own coordinates.
let caret = ime.cursor_rect;
assert!(caret.min.x < 240.0 && caret.min.y < 300.0, "{caret:?}");
assert!(d.ime().is_none(), "the main window publishes nothing");
// Docked, the editor publishes on the main window.
let mut d = driver(Content::new(1));
d.step(vec![preedit("に")]);
assert_eq!(d.ime().map(|ime| ime.owner), Some(Id::unique("editor")));
assert!(window_ime(&d).1.is_none());
}
#[test]
#[allow(clippy::expect_used)]
fn a_body_after_the_editors_keeps_the_window_composing() {
let mut d = driver(Content { clears: Some(6), ..Content::new(5) });
let (id, _) = window_ime(&d);
d.content.drawn.clear();
d.window_step(id, vec![preedit("に")]);
let viewport = d.viewport(id);
assert!(d.content.drawn.ends_with(&[(5, viewport), (6, viewport)]), "{:?}", d.content.drawn);
let first = window_ime(&d).1.expect("the editor publishes");
for _ in 0..2 {
d.window_step(id, Vec::new());
let ime = window_ime(&d).1.expect("the window keeps publishing");
assert_eq!((ime.owner, ime.context_generation), (first.owner, first.context_generation), "the same IME context");
assert!(!ime.should_interrupt_composition);
}
d.window_step(id, vec![Event::Ime(ImeEvent::Commit("日".into()))]);
assert_eq!(d.content.typed, "日");
}
+2
View File
@@ -54,6 +54,8 @@ mod floating_tests;
mod group_tests;
#[cfg(test)]
mod iconic_tests;
#[cfg(all(test, feature = "native-windows"))]
mod ime_tests;
#[cfg(test)]
mod model_tests;
#[cfg(all(test, feature = "native-windows"))]
+7
View File
@@ -651,7 +651,14 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let buttons = self.native_bar(ui, window);
let body = Rect::from_min_max(pos2(full.left(), (full.top() + BAR_HEIGHT).min(full.bottom())), full.max);
self.cross.pointed = None;
if own {
crate::ime::window::begin_window(ui.ctx());
}
self.floating_body(ui, window, body, full);
if own {
self.content.window_drawn(ui);
crate::ime::window::finish_window(ui.ctx());
}
if own && !blocked && ui.ctx().text_edit_focused() {
let _ = body_input(ui.ctx()).lock().typing_next.insert(viewport);
}
+8
View File
@@ -885,6 +885,11 @@ pub trait DockContent<P> {
fn window_kind(&self, _panel: &P) -> WindowKind {
WindowKind::Palette
}
/// A native floating window drew its panel bodies (feature `native-windows`): called once
/// per pass of the window, after its last body, with the window's `ui`. An app that
/// publishes its own IME descriptor, outside [`crate::ime::Composer`], publishes it here for
/// the window. Windows drawn in the main window's pass get no call.
fn window_drawn(&mut self, _ui: &mut Ui) {}
/// Host-owned drop resolution for targets the renderer cannot know about. Returning
/// feedback consumes the gesture, including no-op targets; the renderer then emits no move.
fn resolve_drag(&mut self, _ctx: &Context, _style: &DockStyle, _request: &DropRequest<'_, P>) -> Option<DropFeedback> {
@@ -977,6 +982,9 @@ impl<P, C: DockContent<P> + ?Sized> DockContent<P> for Overrides<'_, P, C> {
fn window_kind(&self, panel: &P) -> WindowKind {
self.inner.window_kind(panel)
}
fn window_drawn(&mut self, ui: &mut Ui) {
self.inner.window_drawn(ui);
}
fn resolve_drag(&mut self, ctx: &Context, style: &DockStyle, request: &DropRequest<'_, P>) -> Option<DropFeedback> {
self.inner.resolve_drag(ctx, style, request)
}
+12
View File
@@ -64,6 +64,7 @@ pub struct DockDriver<P, C> {
input_hook: Option<InputHook>,
style: Option<StyleFn>,
shapes: Vec<ClippedShape>,
ime: Option<egui::output::IMEOutput>,
time: f64,
}
@@ -96,6 +97,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static, C: DockContent<P>> Do
input_hook: None,
style: None,
shapes: Vec::new(),
ime: None,
time: 0.0,
};
driver.settle();
@@ -191,6 +193,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static, C: DockContent<P>> Do
host.land();
}
self.shapes = full.shapes;
self.ime = full.platform_output.ime;
output
}
@@ -374,6 +377,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static, C: DockContent<P>> Do
&self.shapes
}
/// The IME descriptor the main window's last frame published.
pub fn ime(&self) -> Option<&egui::output::IMEOutput> {
self.ime.as_ref()
}
/// Whether the last frame filled `rect` (an insertion line or a split preview).
pub fn filled(&self, rect: Rect) -> bool {
self.shapes.iter().any(|clipped| match &clipped.shape {
@@ -471,6 +479,8 @@ pub struct HostWindow {
pub dropped_files: Vec<egui::DroppedFileHandle>,
/// Shapes of its last frame.
pub shapes: Vec<ClippedShape>,
/// The IME descriptor its last frame published.
pub ime: Option<egui::output::IMEOutput>,
/// Where it reports its content while the window system moves it.
reported: Option<Pos2>,
/// The last pointer position it was given, in its coordinates.
@@ -568,6 +578,7 @@ impl NativeHost {
hovered_files: Vec::new(),
dropped_files: Vec::new(),
shapes: Vec::new(),
ime: None,
reported: None,
pointer: None,
});
@@ -599,6 +610,7 @@ impl NativeHost {
host.apply(&full);
if let Some(window) = host.windows.get_mut(&id) {
window.shapes = full.shapes;
window.ime = full.platform_output.ime;
}
}
+22 -11
View File
@@ -115,9 +115,10 @@ pub struct Composer {
interrupt: bool,
/// The composer ended a composition the IME may still hold, for [`Self::interrupted`].
interrupted: bool,
/// The pass [`Self::interrupted`] is for, and the pass of the composer's last IME output.
/// The pass [`Self::interrupted`] is for, and the viewport and pass of the composer's last
/// IME output.
pass: Option<u64>,
output_pass: Option<u64>,
output_pass: Option<(egui::ViewportId, u64)>,
on: bool,
/// The IME context's owner, as egui's `IMEOutput::owner`.
owner: Id,
@@ -210,8 +211,12 @@ impl Composer {
if !editing || ctx.text_edit_focused() {
self.on = false;
self.context.observe(ctx.viewport_id(), None);
if self.output_pass == Some(pass) && !ctx.text_edit_focused() {
ctx.output_mut(|o| o.ime = None);
if self.output_pass == Some((ctx.viewport_id(), pass)) {
if super::window::recording(ctx) {
super::window::withdraw(ctx, self.owner);
} else if !ctx.text_edit_focused() {
ctx.output_mut(|o| o.ime = None);
}
}
let time = ctx.input(|i| i.time);
let kept = self.end_at(time);
@@ -510,9 +515,9 @@ impl Composer {
fn write_output(&mut self, ctx: &Context) {
let interrupt = std::mem::take(&mut self.interrupt);
self.output_pass = Some(ctx.cumulative_pass_nr());
let rect = self.caret.unwrap_or(Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1.0, 1.0)));
let viewport = ctx.viewport_id();
self.output_pass = Some((viewport, ctx.cumulative_pass_nr()));
let rect = self.caret.unwrap_or(Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1.0, 1.0)));
let observation =
ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), ImeTarget::new(self.owner, 0))
.purpose(self.purpose);
@@ -520,14 +525,20 @@ impl Composer {
let Some(context) = self.context.observe(viewport, Some(observation)) else {
return;
};
let mut ime = IMEOutput::new(context.owner, context.context_generation, context.purpose, rect, rect);
ime.should_interrupt_composition = interrupt;
ime.change_cause = context.change_cause;
ime.surrounding_text = context.surrounding_text;
// In a native floating window docking publishes the descriptor after the window's last
// body (see `window.rs`).
if super::window::recording(ctx) {
super::window::record(ctx, ime);
return;
}
// egui-winit turns the IME on only in passes that set `ime`.
ctx.output_mut(|o| {
// A later write in the same pass (the caret after edits) keeps the interrupt.
let interrupt = interrupt || o.ime.as_ref().is_some_and(|ime| ime.should_interrupt_composition);
let mut ime = IMEOutput::new(context.owner, context.context_generation, context.purpose, rect, rect);
ime.should_interrupt_composition = interrupt;
ime.change_cause = context.change_cause;
ime.surrounding_text = context.surrounding_text;
ime.should_interrupt_composition |= o.ime.as_ref().is_some_and(|ime| ime.should_interrupt_composition);
o.ime = Some(ime);
});
}
+6
View File
@@ -40,6 +40,11 @@
//! [`Composer::interrupted`] says when the platform IME should drop its marked text, for hosts
//! that talk to it directly.
//!
//! An editor in a panel body of a native floating window ([`crate::docking`], feature
//! `native-windows`) uses its composer the same way. The window is an egui pass of its own:
//! there the composer records its descriptor, and docking publishes it once, after the
//! window's last body, so another body drawn after the editor's cannot clear it.
//!
//! The composer covers what the apps each handled on their own:
//!
//! - Events stay in the order the platform sent them, so a Telex Backspace that replaces a
@@ -66,6 +71,7 @@
//! winit and are fixed there; the composer doesn't work around them.
mod composer;
pub(crate) mod window;
pub use composer::{Composer, Edit, Preedit, Processed, surrounding_chars};
+73
View File
@@ -0,0 +1,73 @@
//! The IME descriptor of a native floating window, published by docking once per pass.
//!
//! Docking draws each native window's panel bodies in that window's own egui pass. A composer
//! drawn there records its descriptor for the pass instead of writing it into the output, and
//! docking writes what was recorded after the window's last body. So a body drawn after the
//! editor's cannot clear the descriptor: egui-winit turns the IME off in a pass without one,
//! which ends the composition.
use egui::{Context, Id, ViewportId, output::IMEOutput};
/// The descriptor the composers of one native-window pass recorded.
#[derive(Clone)]
struct Recorded {
pass: u64,
ime: Option<IMEOutput>,
}
fn key(viewport: ViewportId) -> Id {
Id::unique(("craft_ui::ime::window", viewport))
}
/// The recording for the current pass of the current viewport, if docking began one.
fn current(ctx: &Context) -> Option<Recorded> {
let (pass, key) = (ctx.cumulative_pass_nr(), key(ctx.viewport_id()));
ctx.data(|d| d.get_temp::<Recorded>(key)).filter(|r| r.pass == pass)
}
fn store(ctx: &Context, recorded: Recorded) {
let key = key(ctx.viewport_id());
ctx.data_mut(|d| d.insert_temp(key, recorded));
}
/// Docking starts drawing a native window's bodies in this pass of the current viewport.
#[cfg_attr(not(feature = "native-windows"), allow(dead_code))]
pub(crate) fn begin_window(ctx: &Context) {
let (recorded, key) = (Recorded { pass: ctx.cumulative_pass_nr(), ime: None }, key(ctx.viewport_id()));
ctx.data_mut(|d| d.insert_temp(key, recorded));
}
/// Docking has drawn the window's last body: the recorded descriptor goes out, unless something
/// wrote one directly in this pass (a focused egui `TextEdit`, an app's own editor).
#[cfg_attr(not(feature = "native-windows"), allow(dead_code))]
pub(crate) fn finish_window(ctx: &Context) {
let (recorded, key) = (current(ctx), key(ctx.viewport_id()));
ctx.data_mut(|d| d.remove::<Recorded>(key));
if let Some(ime) = recorded.and_then(|r| r.ime) {
ctx.output_mut(|o| {
o.ime.get_or_insert(ime);
});
}
}
/// Whether composers record their descriptor in this pass rather than write it.
pub(super) fn recording(ctx: &Context) -> bool {
current(ctx).is_some()
}
/// Record `ime` for the pass. A later record in the same pass keeps an earlier interrupt.
pub(super) fn record(ctx: &Context, mut ime: IMEOutput) {
let Some(mut recorded) = current(ctx) else { return };
ime.should_interrupt_composition |= recorded.ime.as_ref().is_some_and(|ime| ime.should_interrupt_composition);
recorded.ime = Some(ime);
store(ctx, recorded);
}
/// Drop the pass's descriptor if `owner` recorded it.
pub(super) fn withdraw(ctx: &Context, owner: Id) {
let Some(mut recorded) = current(ctx) else { return };
if recorded.ime.as_ref().is_some_and(|ime| ime.owner == owner) {
recorded.ime = None;
store(ctx, recorded);
}
}
+9
View File
@@ -571,6 +571,15 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho
`Paste` without the key). The app feeds them to the shortcut handling it runs on the main
window. A window where any widget has keyboard focus forwards nothing, and an Escape that
cancels a drag is not forwarded.
- **Input methods.** egui's IME descriptor (`PlatformOutput::ime`) goes out from the pass of
the window whose editor has the keyboard. A `craft_ui::ime::Composer` in a panel body of a
native window records its descriptor while the window draws, and the dock area publishes it
once per pass, after the window's last body. A body drawn after the editor's cannot clear it
(egui-winit turns the IME off in a pass without one, ending the composition). A descriptor
written directly in that pass, as a focused `TextEdit` writes one, is kept. An app whose own
editor publishes the descriptor does it in `DockContent::window_drawn`, called once per
window pass after the last body. Floating windows drawn in the main window (the web,
Wayland) are part of its pass, which publishes as it does without docking.
- **Modal dialogs.** egui's `Modal` blocks input only in the viewport it is shown in. While the
app shows one, it passes `DockArea::input_blocked(true)`. Each native window is then drawn
under `DockStyle::blocked_dim` (by default the panel fill at 60 %), and its bar, tabs, body