diff --git a/crates/ui/src/docking/ime_tests.rs b/crates/ui/src/docking/ime_tests.rs new file mode 100644 index 00000000..d16c653d --- /dev/null +++ b/crates/ui/src/docking/ime_tests.rs @@ -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, + /// 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 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 { + Node::Tabs { panels: panels.to_vec(), active: 0 } +} + +/// Frame `[1, 2]`, and a native window with panel 5 above panel 6. +fn layout() -> Layout { + 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 { + 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) -> (u64, Option) { + 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, "日"); +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 1f9f5d64..b7985b82 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -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"))] diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index eaa3f688..f27f6058 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -651,7 +651,14 @@ impl 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); } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index ec3cc049..582151e3 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -885,6 +885,11 @@ pub trait DockContent

{ 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 { @@ -977,6 +982,9 @@ impl + ?Sized> DockContent

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 { self.inner.resolve_drag(ctx, style, request) } diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 7118221c..1d230ecb 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -64,6 +64,7 @@ pub struct DockDriver { input_hook: Option, style: Option, shapes: Vec, + ime: Option, time: f64, } @@ -96,6 +97,7 @@ impl> Do input_hook: None, style: None, shapes: Vec::new(), + ime: None, time: 0.0, }; driver.settle(); @@ -191,6 +193,7 @@ impl> Do host.land(); } self.shapes = full.shapes; + self.ime = full.platform_output.ime; output } @@ -374,6 +377,11 @@ impl> 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, /// Shapes of its last frame. pub shapes: Vec, + /// The IME descriptor its last frame published. + pub ime: Option, /// Where it reports its content while the window system moves it. reported: Option, /// 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; } } diff --git a/crates/ui/src/ime/composer.rs b/crates/ui/src/ime/composer.rs index c20dced1..35d4ed03 100644 --- a/crates/ui/src/ime/composer.rs +++ b/crates/ui/src/ime/composer.rs @@ -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, - output_pass: Option, + 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); }); } diff --git a/crates/ui/src/ime/mod.rs b/crates/ui/src/ime/mod.rs index 1e64d84f..7e364c80 100644 --- a/crates/ui/src/ime/mod.rs +++ b/crates/ui/src/ime/mod.rs @@ -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}; diff --git a/crates/ui/src/ime/window.rs b/crates/ui/src/ime/window.rs new file mode 100644 index 00000000..a0ab51f4 --- /dev/null +++ b/crates/ui/src/ime/window.rs @@ -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, +} + +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 { + let (pass, key) = (ctx.cumulative_pass_nr(), key(ctx.viewport_id())); + ctx.data(|d| d.get_temp::(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::(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); + } +} diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 7fb944ab..3248c094 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -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