diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 7c7cd4f4..0381c83e 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -7,7 +7,9 @@ //! share one route. use crate::state::UiState; -use craft_ui::docking::{Action, Divider, DockArea, DockCommand, DockContent, DockError, DockEvent, DockStyle, FloatSpace, Hit, ResizeEdge, UiDock}; +use craft_ui::docking::{ + Action, Divider, DockArea, DockCommand, DockContent, DockError, DockEvent, DockStyle, FloatSpace, Floating, Hit, Node, ResizeEdge, UiDock, +}; use egui::{Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value, json}; @@ -435,6 +437,7 @@ impl EffectcraftApp { child.set_clip_rect(body.intersect(ui.clip_rect())); let docs = self.tab_docs(); let doc_width = dock::doc_tabs_width(ui, &docs); + let ime = NativeIme::new(&layout.floating, &self.unpublished_viewports); let mut content = EffectContent { titles: self.tab_titles(), swatches: self.tab_swatches(), @@ -446,13 +449,14 @@ impl EffectcraftApp { locks: Vec::new(), menu: None, bodies: Vec::new(), - viewports: Vec::new(), + ime, }; let mut output = area.show_content(&mut child, &layout, &style, &mut content); - let EffectContent { doc_actions, locks, menu, bodies, viewports, .. } = content; + let EffectContent { doc_actions, locks, menu, bodies, ime, .. } = content; self.dock_rects = bodies; + self.unpublished_viewports = ime.unpublished(); // A window that closed or docked takes its composition with it. - let previous = std::mem::replace(&mut self.native_viewports, viewports); + let previous = std::mem::replace(&mut self.native_viewports, ime.begun()); let gone: Vec<_> = previous.into_iter().filter(|v| !self.native_viewports.contains(v)).collect(); for viewport in gone { panels::viewer_text::end_viewport(self, &ctx, viewport); @@ -637,8 +641,110 @@ struct EffectContent<'a> { menu: Option<(PanelKind, egui::Pos2)>, /// Where each panel body was drawn, in drawing order (floating windows last). bodies: Vec<(PanelKind, Rect)>, - /// The floating panels' OS windows whose pass has begun this frame. - viewports: Vec, + /// Where each native window's IME descriptor is set up and published. + ime: NativeIme, +} + +/// Which body of a native floating window publishes its IME descriptor. +/// +/// Each OS window is an egui pass of its own. `viewer_text::finish_ime` publishes what the pass's +/// text editor recorded, and with nothing recorded it clears the composition and the preedit. A +/// window with another group before the Viewer that published after every body would clear the +/// preedit before the Viewer drew, and the Viewer's publication would then be a new context that +/// interrupts the composition, every pass. So a window publishes after the body in which its +/// editor has recorded its geometry and after its last body. +pub(crate) struct NativeIme { + /// The bodies each native window draws this pass, as its layout says. + expected: Vec<(egui::ViewportId, usize)>, + /// The bodies each native window has drawn so far, in the order the windows began. + drawn: Vec<(egui::ViewportId, usize)>, + /// Windows that published this pass. + published: Vec, +} + +impl NativeIme { + /// `unpublished` are the windows that drew last pass but never published: the layout's count + /// was too high for them, so they publish after every body this pass. + pub(crate) fn new(floating: &[Floating], unpublished: &[egui::ViewportId]) -> Self { + let expected = floating + .iter() + .filter_map(|window| { + let viewport = window_viewport(window.id)?; + let count = if unpublished.contains(&viewport) { 0 } else { window_bodies(window) }; + Some((viewport, count)) + }) + .collect(); + Self { expected, drawn: Vec::new(), published: Vec::new() } + } + + /// Count a body of `viewport` that starts drawing. True for the window's first body: its + /// pass sets up the IME before it. + pub(crate) fn begin_body(&mut self, viewport: egui::ViewportId) -> bool { + match self.drawn.iter_mut().find(|(v, _)| *v == viewport) { + Some((_, n)) => { + *n = n.saturating_add(1); + false + } + None => { + self.drawn.push((viewport, 1)); + true + } + } + } + + /// Whether the body of `viewport` that just drew publishes the IME descriptor: once the + /// window's editor has `recorded` its geometry this pass, and after the window's last body. + /// A window the layout does not name publishes after every body. + pub(crate) fn publishes(&mut self, viewport: egui::ViewportId, recorded: bool) -> bool { + let drawn = self.drawn.iter().find(|(v, _)| *v == viewport).map_or(0, |(_, n)| *n); + let expected = self.expected.iter().find(|(v, _)| *v == viewport).map_or(0, |(_, n)| *n); + let publishes = recorded || drawn >= expected; + if publishes && !self.published.contains(&viewport) { + self.published.push(viewport); + } + publishes + } + + /// The native windows whose pass began. + pub(crate) fn begun(&self) -> Vec { + self.drawn.iter().map(|(v, _)| *v).collect() + } + + /// The native windows that drew bodies but did not publish. + pub(crate) fn unpublished(&self) -> Vec { + self.drawn.iter().map(|(v, _)| *v).filter(|v| !self.published.contains(v)).collect() + } +} + +/// The OS window craft-ui opens for the floating window `id`. +#[cfg(not(target_arch = "wasm32"))] +fn window_viewport(id: u64) -> Option { + Some(DockArea::::window_viewport(area_id(), id)) +} + +/// The web keeps floating windows in the page. +#[cfg(target_arch = "wasm32")] +fn window_viewport(_id: u64) -> Option { + None +} + +/// The panel bodies craft-ui draws in a floating window: the active panel of each group and each +/// open stack entry. A minimized window shows its bar only. +fn window_bodies(window: &Floating) -> usize { + if window.minimized { + return 0; + } + let mut count = 0usize; + let mut nodes = vec![&window.root]; + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Tabs { panels, active } => count = count.saturating_add(usize::from(panels.get(*active).is_some())), + Node::Stack { entries } => count = count.saturating_add(entries.iter().filter(|e| e.open).count()), + _ => {} + } + } + count } impl EffectContent<'_> { @@ -655,15 +761,15 @@ impl DockContent for EffectContent<'_> { let rect = ui.max_rect(); self.bodies.push((*panel, rect)); // A floating panel's OS window is a pass of its own: its text IME descriptor is set up - // before its body draws and published after (the main window does both in `draw`). + // before its first body and published after its bodies (the main window does both in + // `draw`). let viewport = ui.ctx().viewport_id(); let native = viewport != egui::ViewportId::ROOT && !ui.ctx().embed_viewports(); - if native && !self.viewports.contains(&viewport) { - self.viewports.push(viewport); + if native && self.ime.begin_body(viewport) { panels::viewer_text::begin_ui(ui.ctx()); } self.app.panel_body(ui, *panel, rect); - if native { + if native && self.ime.publishes(viewport, panels::viewer_text::ime_recorded(ui.ctx())) { panels::viewer_text::finish_ime(self.app, ui); } // The body fills its frame; allocating it lets a floated panel keep its full size. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6b39d73..5f93418d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -509,6 +509,8 @@ pub struct EffectcraftApp { /// The floating panels' OS windows that drew a panel last frame; one that is gone releases /// its IME state. pub(crate) native_viewports: Vec, + /// The native windows that drew a panel last frame without publishing their IME descriptor. + pub(crate) unpublished_viewports: Vec, /// A floating panel shown by a command: its window comes to the front next frame. pub(crate) pending_panel_raise: Option, /// Home screen: recent-project thumbnail textures by path (None = no thumbnail). @@ -625,6 +627,7 @@ impl EffectcraftApp { recovery: None, dock_rects: vec![], native_viewports: vec![], + unpublished_viewports: vec![], pending_panel_raise: None, home_thumbs: Default::default(), home_thumb_saved: None, diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index c410d8e7..9a287b3f 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -241,6 +241,13 @@ pub(crate) fn begin_ui(ctx: &egui::Context) { ctx.data_mut(|data| data.remove::(id)); } +/// Whether the text editor recorded its IME geometry in this pass of the current viewport. +pub(crate) fn ime_recorded(ctx: &egui::Context) -> bool { + let viewport = ctx.viewport_id(); + let pass = ctx.cumulative_pass_nr_for(viewport); + ctx.data(|d| d.get_temp::(ime_geometry_id(viewport))).is_some_and(|(p, _, _)| p == pass) +} + pub(crate) fn finish_logic(app: &EffectcraftApp, ctx: &egui::Context) { let id = mode_id(ctx.viewport_id()); if !ctx.data(|data| data.get_temp::(id).unwrap_or(false)) { @@ -1509,6 +1516,95 @@ mod tests { } } + /// One native floating window holding the Timeline's group and then the Viewer's. + fn two_group_window() -> craft_ui::docking::Floating { + let groups = vec![craft_ui::docking::Group::new(vec![PanelKind::Timeline], 0), craft_ui::docking::Group::new(vec![PanelKind::Viewer(1)], 0)]; + craft_ui::docking::Floating::stack(groups, [0.0, 0.0, 320.0, 360.0]).expect("two groups make a window") + } + + impl ImePass { + /// One pass of a native window with a body that edits no text and the Viewer's editor, + /// in the order given, publishing as `NativeIme` decides or, with `every`, after each + /// body. Returns the descriptor and the preedit the pass ended with. + fn window( + &mut self, + app: &mut EffectcraftApp, + window: &craft_ui::docking::Floating, + events: Vec, + viewer_first: bool, + every: bool, + ) -> (Option, Option) { + let ctx = self.ctx.clone(); + let (rect, viewport) = (self.rect, self.viewport); + let mut viewports = egui::ViewportIdMap::default(); + viewports.insert(viewport, egui::ViewportInfo::default()); + let raw = egui::RawInput { viewport_id: viewport, viewports, screen_rect: Some(rect), events, ..Default::default() }; + let mut preedit = None; + let mut out = ctx.run_ui(raw, |ui| { + let mut ime = crate::dock_ui::NativeIme::new(std::slice::from_ref(window), &[]); + let order = if viewer_first { [true, false] } else { [false, true] }; + for viewer in order { + if ime.begin_body(viewport) { + begin_ui(ui.ctx()); + } + if viewer { + ui.interact(rect, focus_id(), egui::Sense::focusable_noninteractive()); + ui.memory_mut(|m| m.request_focus(focus_id())); + sync_ime_target(ui, app); + keyboard(app, ui, rect); + let pass = ui.ctx().cumulative_pass_nr_for(viewport); + ui.data_mut(|d| d.insert_temp(ime_geometry_id(viewport), (pass, rect, rect))); + } + if ime.publishes(viewport, ime_recorded(ui.ctx())) || every { + finish_ime(app, ui); + } + } + assert!(ime.unpublished().is_empty(), "the window published"); + preedit = read_preedit(ui).map(|p| p.text); + }); + out.textures_delta.clear(); + (out.platform_output.ime, preedit) + } + } + + #[test] + fn native_window_with_a_group_before_the_viewer_keeps_the_composition() { + let window = two_group_window(); + let viewport = craft_ui::docking::DockArea::::window_viewport(crate::dock_ui::area_id(), window.id); + let preview = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "é".into(), active_range_chars: None }); + for viewer_first in [false, true] { + let (mut app, _) = editor("abcd", 4, 4); + let mut pass = ImePass::new(viewport); + let (first, preedit) = pass.window(&mut app, &window, vec![preview()], viewer_first, false); + let first = first.expect("the editor publishes"); + assert_eq!(preedit.as_deref(), Some("é")); + for _ in 0..2 { + let (ime, preedit) = pass.window(&mut app, &window, vec![], viewer_first, false); + let ime = ime.expect("the editor keeps publishing"); + assert!(!ime.should_interrupt_composition, "viewer first: {viewer_first}"); + assert_eq!((ime.owner, ime.context_generation), (first.owner, first.context_generation), "the same IME context"); + assert_eq!(preedit.as_deref(), Some("é"), "the preedit survives the other body"); + } + } + // Publishing after the body drawn before the Viewer clears the preedit and interrupts. + let (mut app, _) = editor("abcd", 4, 4); + let mut pass = ImePass::new(viewport); + pass.window(&mut app, &window, vec![preview()], false, true); + let (ime, preedit) = pass.window(&mut app, &window, vec![], false, true); + assert!(ime.expect("the editor publishes").should_interrupt_composition); + assert_eq!(preedit, None); + // A window that drew fewer bodies than its layout counts publishes after every body in + // its next pass. + let mut ime = crate::dock_ui::NativeIme::new(std::slice::from_ref(&window), &[]); + ime.begin_body(viewport); + assert!(!ime.publishes(viewport, false)); + let unpublished = ime.unpublished(); + assert_eq!(unpublished, vec![viewport]); + let mut ime = crate::dock_ui::NativeIme::new(std::slice::from_ref(&window), &unpublished); + ime.begin_body(viewport); + assert!(ime.publishes(viewport, false)); + } + fn ime_text(ime: &egui::output::IMEOutput) -> (&str, usize, usize) { let p = ime.surrounding_text.available().expect("committed surrounding text"); (p.text.as_str(), p.cursor, p.anchor)