mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-03 09:11:10 +00:00
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:
1 parent
c692b6816d
commit
28f4036f3b
9 files changed
+252
-11
No files matched your search
@@ -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, "日");
|
||||
}
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user