diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt index d217e0a94..bbb8e74f1 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt @@ -52,6 +52,8 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.skia.BackendRenderTarget @@ -126,6 +128,15 @@ internal class TaoComposeSceneHostLinux( if (fullyTransparent) 0 else 0xFFFFFFFF.toInt(), ) + /** + * IME preedit / commit routing (#558). + * + * No typed-key fallback: that argument exists for the macOS PressAndHold + * accent picker, which has no GTK counterpart — an input method only ever + * delivers text while a text-input session is up. + */ + private val imeSession = TaoImeSession() + /** App-level pre-dispatch hook. See [TaoComposeSceneHost.previewKeyHandler]. */ var previewKeyHandler: ((KeyEvent) -> Boolean)? = null @@ -455,6 +466,8 @@ internal class TaoComposeSceneHostLinux( getRootNode = { scene!!.rootDragAndDropNode }, outboundLauncher = ::launchLinuxOutboundDrag, ) + window.imePreedit = imeSession::preedit + window.imeCommit = imeSession::commit val platformContext = LinuxTaoPlatformContext( windowHandle = window.handle, @@ -479,6 +492,7 @@ internal class TaoComposeSceneHostLinux( semanticsOwnerListener = semanticsOwnerListener, dragAndDropManager = dndManager, textToolbar = textToolbar, + onInputSession = { imeSession.onInputSession(it) }, isWindowTransparent = fullyTransparent, ) sceneBundle = @@ -2260,6 +2274,9 @@ internal class TaoComposeSceneHostLinux( } fun detach() { + window.imePreedit = null + window.imeCommit = null + imeSession.onInputSession(null) shutdownA11yScheduler() textToolbar.hide() if (dev.nucleusframework.window.tao.ffi.NativeTaoLinuxDndBridge.isLoaded && @@ -2534,6 +2551,8 @@ private class LinuxTaoPlatformContext( override val semanticsOwnerListener: androidx.compose.ui.platform.PlatformContext.SemanticsOwnerListener?, override val dragAndDropManager: androidx.compose.ui.platform.PlatformDragAndDropManager, override val textToolbar: androidx.compose.ui.platform.TextToolbar, + /** Publishes the active text-input session to the host's [TaoImeSession] (#558). */ + private val onInputSession: (androidx.compose.ui.platform.PlatformTextInputMethodRequest?) -> Unit = {}, // #559: forwarded to Compose so `CanvasLayersComposeScene` picks the // alpha-aware dialog-scrim blend mode (`BlendMode.SrcAtop`) on windows // created with `transparent = true` — same as Compose Desktop's @@ -2548,6 +2567,45 @@ private class LinuxTaoPlatformContext( override val captionBar: androidx.compose.ui.platform.PlatformInsets get() = systemBars } + /** + * Keeps the GTK input context anchored to the caret for as long as a field + * owns the input (#558). + * + * The macOS twin also has to activate the view's `NSTextInputContext` + * first; GTK needs no such step, because the context is created with — and + * follows the focus of — the window itself. So this only mirrors the caret + * rect, through the same `nativeSetImeRect` contract Windows uses. + */ + @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) + override suspend fun startInputMethod( + request: androidx.compose.ui.platform.PlatformTextInputMethodRequest, + ): Nothing { + onInputSession(request) + try { + coroutineScope { + launch { + androidx.compose.runtime + .snapshotFlow { + request.focusedRectInRoot() + }.collect { rect -> + if (rect != null) { + NativeTaoBridge.nativeSetImeRect( + windowHandle, + rect.left.toInt(), + rect.top.toInt(), + rect.width.toInt().coerceAtLeast(1), + rect.height.toInt().coerceAtLeast(1), + ) + } + } + } + awaitCancellation() + } + } finally { + onInputSession(null) + } + } + override fun setPointerIcon(pointerIcon: androidx.compose.ui.input.pointer.PointerIcon) { // The Rust side maps the code to a freedesktop cursor name and goes // through `gdk_window_set_device_cursor` for every master pointer of diff --git a/decorated-window-tao/src/main/native/src/platform/linux/ime.rs b/decorated-window-tao/src/main/native/src/platform/linux/ime.rs new file mode 100644 index 000000000..234e2c2ac --- /dev/null +++ b/decorated-window-tao/src/main/native/src/platform/linux/ime.rs @@ -0,0 +1,52 @@ +// Caret-rect plumbing for the Linux IME (#558). +// +// The three backends split by how the platform asks for the caret. macOS is +// pull-based, so `platform/macos/ime.rs` swizzles +// `firstRectForCharacterRange:` because AppKit asks the view where the caret +// is. Windows and Linux are push-based — the app tells the input context — and +// tao already owns that call. +// +// Where Linux differs from Windows is the shape of the answer. IMM32 takes a +// *point* and hangs the candidate list off it, so `platform/windows/ime.rs` +// sends the caret's bottom edge and is done. GTK takes the *area* the cursor +// covers and the input method keeps its own windows off that area, so the full +// rect goes through: pass a bare point and the "Tab to select" hint sits on top +// of the composition it is describing. Hence `set_ime_cursor_area` rather than +// `set_ime_position` here, and no bottom-edge adjustment — GTK derives the +// placement from the rect itself. + +use jni::objects::JClass; +use jni::sys::{jint, jlong}; +use jni::JNIEnv; + +use tao::dpi::{PhysicalPosition, PhysicalSize}; +use tao::platform::unix::WindowExtUnix; + +use crate::state::WINDOWS; + +/// Reports the caret rectangle to the input method, in *window-local physical +/// pixels* with a top-left origin — the same contract as the macOS and Windows +/// implementations, which is why the JVM side passes the same four numbers to +/// all three. +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeSetImeRect( + _env: JNIEnv, + _class: JClass, + handle: jlong, + x_px: jint, + y_px: jint, + w_px: jint, + h_px: jint, +) { + let guard = match WINDOWS.lock() { + Ok(g) => g, + Err(_) => return, + }; + let Some(map) = guard.as_ref() else { return }; + if let Some(window) = map.get(&(handle as u64)) { + window.set_ime_cursor_area( + PhysicalPosition::new(x_px, y_px), + PhysicalSize::new(w_px.max(0), h_px.max(0)), + ); + } +} diff --git a/decorated-window-tao/src/main/native/src/platform/linux/mod.rs b/decorated-window-tao/src/main/native/src/platform/linux/mod.rs index 711d0c17c..7f38ac901 100644 --- a/decorated-window-tao/src/main/native/src/platform/linux/mod.rs +++ b/decorated-window-tao/src/main/native/src/platform/linux/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod a11y; pub(crate) mod decoration; pub(crate) mod dnd; pub(crate) mod handles; +pub(crate) mod ime; pub(crate) mod monitor; pub(crate) mod scroll; pub(crate) mod touch; diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/event.rs b/decorated-window-tao/src/main/native/vendor/tao/src/event.rs index 3c61c124c..864747764 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/event.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/event.rs @@ -345,6 +345,8 @@ pub enum WindowEvent<'a> { /// - **macOS**: `setMarkedText:` / `unmarkText` (nucleusframework#595). /// - **Windows**: `WM_IME_COMPOSITION` with `GCS_COMPSTR` /// (nucleusframework#558). + /// - **Linux**: the GTK input context's `preedit-changed`, and + /// `preedit-end` as an empty string (nucleusframework#558). /// - Not emitted on other platforms. ImePreedit(String), @@ -359,6 +361,8 @@ pub enum WindowEvent<'a> { /// (nucleusframework#595). /// - **Windows**: `WM_IME_COMPOSITION` with `GCS_RESULTSTR` /// (nucleusframework#558). + /// - **Linux**: the GTK input context's `commit`, when it arrives while a + /// composition is in flight (nucleusframework#558). /// - Not emitted on other platforms. ImeCommit(String), diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform/unix.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform/unix.rs index 7558b5ac8..7e151f692 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform/unix.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform/unix.rs @@ -22,6 +22,7 @@ pub use crate::platform_impl::x11; use crate::platform_impl::x11::xdisplay::XError; pub use crate::platform_impl::EventLoop as UnixEventLoop; use crate::{ + dpi::{Position, Size}, error::{ExternalError, OsError}, event_loop::{EventLoopBuilder, EventLoopWindowTarget}, monitor::MonitorHandle, @@ -86,6 +87,17 @@ pub trait WindowExtUnix { fn set_skip_taskbar(&self, skip: bool) -> Result<(), ExternalError>; fn set_badge_count(&self, count: Option, desktop_filename: Option); + + /// Tells the input method the rectangle the text caret occupies, in window + /// coordinates, so it keeps its preedit and candidate windows clear of the + /// text being typed (nucleusframework#558). + /// + /// The cross-platform [`Window::set_ime_position`] carries only a point, + /// which is all IMM32 and AppKit need. GTK is area-based instead: the input + /// method is told the region the cursor covers and stays off it, so a bare + /// point leaves the candidate window free to sit on top of the composition. + /// Callers that know the caret's size should use this. + fn set_ime_cursor_area, S: Into>(&self, position: P, size: S); } impl WindowExtUnix for Window { @@ -112,6 +124,10 @@ impl WindowExtUnix for Window { fn set_badge_count(&self, count: Option, desktop_filename: Option) { self.window.set_badge_count(count, desktop_filename); } + + fn set_ime_cursor_area, S: Into>(&self, position: P, size: S) { + self.window.set_ime_cursor_area(position, size); + } } pub trait WindowBuilderExtUnix { diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs index f8eeba4c4..9d0ab3fb9 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs @@ -26,6 +26,7 @@ use gtk::{ #[cfg(feature = "x11")] use crate::platform_impl::platform::device; +use crate::platform_impl::platform::ime::{Commit, ImeState}; use crate::{ dpi::{LogicalPosition, LogicalSize, PhysicalPosition}, error::ExternalError, @@ -305,6 +306,12 @@ impl EventLoop { // Window Request let popup_windows_ = popup_windows.clone(); + // Nucleus patch (nucleusframework#558): input contexts keyed by window id. + // The context is created in the `WireUpEvents` arm and read back by the + // `SetImePosition` arm, both of which live inside this closure — so it + // needs no home on `EventLoopWindowTarget`. + let ime_contexts: Rc>> = + Rc::new(RefCell::new(std::collections::HashMap::new())); window_requests_rx.attach(Some(&context), move |(id, request)| { // Nucleus patch: popup overlay windows are plain gtk::Windows with // synthesized ids — resolve them from the popup map when the @@ -494,6 +501,35 @@ impl EventLoop { } } } + // Nucleus patch (nucleusframework#558): hand the input method the + // area the caret covers so its preedit and candidate windows are + // placed clear of the text being typed, instead of over it. + WindowRequest::SetImeCursorArea((x, y, w, h)) => { + // The caret arrives in client-area coordinates — the contract + // `set_ime_position` documents. GTK wants it relative to the + // toplevel GdkWindow, and on a client-side-decorated window that + // window also spans the invisible resize border and drop shadow, + // so the two origins are apart by the content widget's allocation. + // Skipping the translation puts the caret a shadow's height too + // high, and the input method draws its candidate list straight + // over the composition it belongs to. + let (dx, dy) = window + .child() + .map(|child| { + let alloc = child.allocation(); + // Before the first allocation the child reports a 1x1 dummy at + // the origin; (0, 0) is the right answer then anyway. + if alloc.width() > 1 || alloc.height() > 1 { + (alloc.x(), alloc.y()) + } else { + (0, 0) + } + }) + .unwrap_or((0, 0)); + if let Some(ime) = ime_contexts.borrow().get(&id.0) { + ime.set_cursor_location(&gdk::Rectangle::new(x + dx, y + dy, w, h)); + } + } WindowRequest::CursorIgnoreEvents(ignore) => { // PATCH(nucleus): an *empty* region, not a 1x1 rectangle at the // origin — upstream leaves the top-left pixel clickable. Both @@ -936,16 +972,28 @@ impl EventLoop { }); let tx_clone = event_tx.clone(); + let modifier_state = Rc::new(RefCell::new(ImeState::new())); let keyboard_handler = Rc::new(move |event_key: EventKey, element_state| { - // if we have a modifier lets send it - if !keyboard::get_modifiers(event_key.clone()).is_empty() { - // Nucleus patch: emit the FULL modifier state, not just the - // pressed key's own bit — upstream sent `{SHIFT}` when Shift - // was pressed while Ctrl was held, dropping Ctrl from the - // state and breaking every Ctrl+Shift+ shortcut. - let mods = - keyboard::get_modifier_state(&event_key, ElementState::Pressed == element_state); - + // Nucleus patch: emit the FULL modifier state, not just the + // pressed key's own bit — upstream sent `{SHIFT}` when Shift + // was pressed while Ctrl was held, dropping Ctrl from the + // state and breaking every Ctrl+Shift+ shortcut. + // + // Nucleus patch (nucleusframework#558): recompute it on *every* + // key, not just on modifier keys, and publish it whenever it + // changed. GDK reports the live modifier mask on every event, so + // deriving the state from the event instead of from press/release + // bookkeeping self-heals when a modifier's release goes missing. + // That is not hypothetical: on X11 an input method sits in the + // event path and re-injects what it forwards (ibus marks those + // events with its own reserved bits), and modifier releases are + // dropped along the way. The old code only ever revisited the + // state on a modifier key, so a lost Control release left Compose + // believing Ctrl was held — and a plain Return then read as + // Ctrl+Return for the rest of the session. + let mods = + keyboard::get_modifier_state(&event_key, ElementState::Pressed == element_state); + if let Some(mods) = modifier_state.borrow_mut().modifiers_changed(mods) { if let Err(e) = tx_clone.send(Event::WindowEvent { window_id: RootWindowId(id), event: WindowEvent::ModifiersChanged(mods), @@ -955,14 +1003,14 @@ impl EventLoop { e ); } - // Nucleus patch: fall through and *also* emit `KeyboardInput` - // for modifier-only keypresses so the JVM side can observe Alt - // / Ctrl / Shift / Super press/release as plain Compose key - // events (needed by app-level handlers like - // `(ev.key == Key.AltLeft) && ev.type == KeyEventType.KeyUp`). - // Upstream tao stops here, which makes those handlers dead on - // the Linux backend. } + // Nucleus patch: fall through and *also* emit `KeyboardInput` + // for modifier-only keypresses so the JVM side can observe Alt + // / Ctrl / Shift / Super press/release as plain Compose key + // events (needed by app-level handlers like + // `(ev.key == Key.AltLeft) && ev.type == KeyEventType.KeyUp`). + // Upstream tao stops here, which makes those handlers dead on + // the Linux backend. // todo: implement repeat? let event = keyboard::make_key_event(&event_key, false, None, element_state); @@ -982,33 +1030,139 @@ impl EventLoop { glib::ControlFlow::Continue }); - let tx_clone = event_tx.clone(); - // TODO Add actual IME from system - let ime = gtk::IMContextSimple::default(); + // Nucleus patch (nucleusframework#558): the stock backend pinned + // `IMContextSimple`, GTK's built-in fallback that only knows + // Compose sequences and Ctrl+Shift+U — it never reaches the system + // input method, so CJK input was impossible. `IMMulticontext` + // resolves the platform module the same way GTK's own text widgets + // do (ibus / fcitx5 through the GTK immodule on X11, the + // text-input-v3 client on Wayland). + let ime = gtk::IMMulticontext::new(); ime.set_client_window(window.window().as_ref()); - ime.focus_in(); - ime.connect_commit(move |_, s| { - if let Err(e) = tx_clone.send(Event::WindowEvent { - window_id: RootWindowId(id), - event: WindowEvent::ReceivedImeText(s.to_string()), - }) { - log::warn!( - "Failed to send received IME text event to event channel: {}", - e - ); - } - }); + + // Everything about this window's input method that is state + // rather than plumbing — composition flag, the press/release + // pairing gate, and the last published modifier state. Split out + // so the behaviour can be unit-tested without a display; see + // `platform_impl::linux::ime`. + let ime_state = Rc::new(RefCell::new(ImeState::new())); + + { + let ime_state = ime_state.clone(); + ime.connect_preedit_start(move |_| ime_state.borrow_mut().preedit_started()); + } + + { + let tx_clone = event_tx.clone(); + ime.connect_preedit_changed(move |ime| { + let (text, _, _) = ime.preedit_string(); + if let Err(e) = tx_clone.send(Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ImePreedit(text.to_string()), + }) { + log::warn!("Failed to send IME preedit event to event channel: {}", e); + } + }); + } + + { + let ime_state = ime_state.clone(); + let tx_clone = event_tx.clone(); + ime.connect_preedit_end(move |_| { + ime_state.borrow_mut().preedit_ended(); + // Empty preedit = "drop the marked text". A commit, when there + // is one, has already been delivered by `commit` above. + if let Err(e) = tx_clone.send(Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ImePreedit(String::new()), + }) { + log::warn!("Failed to send IME preedit end event to event channel: {}", e); + } + }); + } + + { + let ime_state = ime_state.clone(); + let tx_clone = event_tx.clone(); + ime.connect_commit(move |_, s| { + let event = match ime_state.borrow().commit() { + Commit::Ime => WindowEvent::ImeCommit(s.to_string()), + Commit::Text => WindowEvent::ReceivedImeText(s.to_string()), + }; + if let Err(e) = tx_clone.send(Event::WindowEvent { + window_id: RootWindowId(id), + event, + }) { + log::warn!( + "Failed to send received IME text event to event channel: {}", + e + ); + } + }); + } + + // Follow the window's focus instead of latching `focus_in` once at + // construction: an input context that still believes it is focused + // keeps receiving key events meant for another window. + { + let ime = ime.clone(); + window.connect_focus_in_event(move |_, _| { + ime.focus_in(); + glib::Propagation::Proceed + }); + } + { + let ime = ime.clone(); + window.connect_focus_out_event(move |_, _| { + ime.focus_out(); + glib::Propagation::Proceed + }); + } + if window.is_active() { + ime.focus_in(); + } + + // Published so `WindowRequest::SetImePosition` can move the + // candidate window to the caret; dropped with the window. + { + let ime_contexts = ime_contexts.clone(); + window.connect_destroy(move |_| { + ime_contexts.borrow_mut().remove(&id.0); + }); + } + ime_contexts.borrow_mut().insert(id.0, ime.clone()); let handler = keyboard_handler.clone(); + let ime_ = ime.clone(); + let ime_state_press = ime_state.clone(); window.connect_key_press_event(move |_, event_key| { + // The IME gets first refusal, and a key it consumed must not also + // reach Compose — otherwise the Enter that confirms a conversion + // also inserts a newline, and the BackSpace that edits the + // composition also deletes committed text (the Linux twin of the + // VK_PROCESSKEY leak fixed for Windows in nucleusframework#558). + let filtered = ime_.filter_keypress(event_key); + if !ime_state_press + .borrow_mut() + .key_pressed(event_key.hardware_keycode(), filtered) + { + return glib::Propagation::Stop; + } handler(event_key.to_owned(), ElementState::Pressed); - ime.filter_keypress(event_key); glib::Propagation::Proceed }); let handler = keyboard_handler.clone(); + let ime_state_release = ime_state; window.connect_key_release_event(move |_, event_key| { + let filtered = ime.filter_keypress(event_key); + if !ime_state_release + .borrow_mut() + .key_released(event_key.hardware_keycode(), filtered) + { + return glib::Propagation::Stop; + } handler(event_key.to_owned(), ElementState::Released); glib::Propagation::Proceed }); diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/ime.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/ime.rs new file mode 100644 index 000000000..5cc4630a7 --- /dev/null +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/ime.rs @@ -0,0 +1,250 @@ +//! Input-method state for the Linux backend (nucleusframework#558). +//! +//! The GTK signal handlers in [`event_loop`](super::event_loop) own one +//! [`ImeState`] per window and do nothing but translate callbacks into calls on +//! it. Everything that is actually a *decision* — whether a key event reaches +//! Compose, whether committed text replaces a preedit or stands on its own, +//! whether the modifier state needs republishing — lives here, in plain data +//! with no GdkWindow, no input method and no main loop behind it. +//! +//! That split is what makes the behaviour testable. The interesting cases are +//! all sequences (a press withheld but its release delivered, a modifier whose +//! release never arrives, a commit that lands with no composition open), and +//! none of them can be reproduced from a unit test while the state is spread +//! across GTK closures. The Windows backend draws the same line with its +//! `ImeSource` trait; the Linux one needs no trait, because there is nothing to +//! read back — GTK pushes everything. + +use std::collections::HashSet; + +use crate::keyboard::ModifiersState; + +/// What the input method's `commit` signal should turn into. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Commit { + /// Confirmed a composition: replaces the preedit Compose is showing. + Ime, + /// Ordinary character insert with no composition behind it — an input + /// method still delivers plain typing this way once it is in the path. + Text, +} + +/// Per-window input-method state. See the module docs. +#[derive(Debug, Default)] +pub(crate) struct ImeState { + /// Whether a composition is in flight (`preedit-start` .. `preedit-end`). + composing: bool, + /// Hardware keycodes whose *press* was forwarded to Compose. + /// + /// An input method withholds the keys it consumes, but only the press: on + /// Wayland the compositor filters them out of the stream before the client + /// sees them (text-input-v3 cannot say "filtered"), and on X11 + /// `gtk_im_context_filter_keypress` does the same in-process. The matching + /// release arrives either way. Compose fires `clickable`'s onClick on KeyUp, + /// so a release with no press behind it activates whatever holds focus — + /// the Return that merely confirmed a conversion would press a button. + pressed: HashSet, + /// Last modifier state published to Compose. + modifiers: ModifiersState, +} + +impl ImeState { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn composing(&self) -> bool { + self.composing + } + + pub(crate) fn preedit_started(&mut self) { + self.composing = true; + } + + pub(crate) fn preedit_ended(&mut self) { + self.composing = false; + } + + /// Which event the `commit` signal should produce. + pub(crate) fn commit(&self) -> Commit { + if self.composing { + Commit::Ime + } else { + Commit::Text + } + } + + /// Whether a key press should reach Compose. `filtered` is what + /// `gtk_im_context_filter_keypress` returned. + pub(crate) fn key_pressed(&mut self, keycode: u16, filtered: bool) -> bool { + if filtered { + return false; + } + self.pressed.insert(keycode); + true + } + + /// Whether a key release should reach Compose. Releases whose press was + /// withheld are dropped, so Compose never sees a KeyUp without its KeyDown. + pub(crate) fn key_released(&mut self, keycode: u16, filtered: bool) -> bool { + let paired = self.pressed.remove(&keycode); + paired && !filtered + } + + /// The modifier state to publish, or `None` when it did not change. + /// + /// Callers pass the state derived from the event's own modifier mask rather + /// than one accumulated from press/release pairs. GDK reports the live mask + /// on every event, so a lost release — routine once an input method + /// re-injects events on X11 — is corrected by the next keystroke instead of + /// leaving Compose convinced that Ctrl is still down. + pub(crate) fn modifiers_changed(&mut self, mods: ModifiersState) -> Option { + if mods == self.modifiers { + return None; + } + self.modifiers = mods; + Some(mods) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Keycodes are opaque here; these just have to be distinct. + const KEY_A: u16 = 38; + const KEY_RETURN: u16 = 36; + const KEY_BACKSPACE: u16 = 22; + const KEY_CTRL: u16 = 37; + + #[test] + fn plain_typing_round_trips() { + let mut s = ImeState::new(); + assert!(s.key_pressed(KEY_A, false)); + assert!(s.key_released(KEY_A, false)); + } + + /// Wayland: the compositor withholds the press of a key the input method + /// consumed and delivers the release anyway. Compose must see neither. + #[test] + fn release_without_a_press_is_dropped() { + let mut s = ImeState::new(); + assert!(!s.key_released(KEY_RETURN, false)); + } + + /// X11: the press reaches the client but `filter_keypress` claims it. The + /// release that follows must not leak either. + #[test] + fn filtered_press_withholds_its_release() { + let mut s = ImeState::new(); + assert!(!s.key_pressed(KEY_BACKSPACE, true)); + assert!(!s.key_released(KEY_BACKSPACE, false)); + } + + /// The observed failure: confirming a conversion with Return delivered only + /// the release, which Compose read as a click on the focused control. + #[test] + fn confirming_return_does_not_reach_compose() { + let mut s = ImeState::new(); + s.preedit_started(); + assert_eq!(s.commit(), Commit::Ime); + s.preedit_ended(); + // Only the release arrives; its press was consumed by the input method. + assert!(!s.key_released(KEY_RETURN, false)); + // The next Return is a real one and must go through. + assert!(s.key_pressed(KEY_RETURN, false)); + assert!(s.key_released(KEY_RETURN, false)); + } + + #[test] + fn autorepeat_press_stays_paired() { + let mut s = ImeState::new(); + assert!(s.key_pressed(KEY_A, false)); + assert!(s.key_pressed(KEY_A, false)); + assert!(s.key_released(KEY_A, false)); + // The repeat collapsed into one entry, so a second release is unpaired. + assert!(!s.key_released(KEY_A, false)); + } + + #[test] + fn commit_routes_on_composition_state() { + let mut s = ImeState::new(); + // Plain typing through the input method, no composition open. + assert_eq!(s.commit(), Commit::Text); + s.preedit_started(); + assert_eq!(s.commit(), Commit::Ime); + s.preedit_ended(); + assert_eq!(s.commit(), Commit::Text); + } + + #[test] + fn modifiers_publish_only_on_change() { + let mut s = ImeState::new(); + assert_eq!( + s.modifiers_changed(ModifiersState::CONTROL), + Some(ModifiersState::CONTROL) + ); + assert_eq!(s.modifiers_changed(ModifiersState::CONTROL), None); + assert_eq!( + s.modifiers_changed(ModifiersState::empty()), + Some(ModifiersState::empty()) + ); + } + + /// A Control release swallowed on the way through the input method used to + /// leave Compose reading every later Return as Ctrl+Return. Deriving the + /// state from each event's mask heals it on the next key. + #[test] + fn lost_modifier_release_recovers_on_the_next_key() { + let mut s = ImeState::new(); + assert_eq!( + s.modifiers_changed(ModifiersState::CONTROL), + Some(ModifiersState::CONTROL) + ); + // The release never arrives. The next key carries an empty mask, which is + // the truth GDK reports. + assert_eq!( + s.modifiers_changed(ModifiersState::empty()), + Some(ModifiersState::empty()) + ); + assert_eq!(s.modifiers_changed(ModifiersState::empty()), None); + } + + /// Traces from ibus on XWayland answer a `Meta_L` press with an `Alt_L` + /// release. Pair-based bookkeeping cannot survive that; mask-based state can. + #[test] + fn asymmetric_modifier_reports_settle() { + let mut s = ImeState::new(); + assert_eq!( + s.modifiers_changed(ModifiersState::SHIFT), + Some(ModifiersState::SHIFT) + ); + assert_eq!( + s.modifiers_changed(ModifiersState::SHIFT | ModifiersState::ALT), + Some(ModifiersState::SHIFT | ModifiersState::ALT) + ); + assert_eq!( + s.modifiers_changed(ModifiersState::empty()), + Some(ModifiersState::empty()) + ); + } + + /// Shortcuts must survive the gate: the input method claims neither the + /// modifier nor the letter, so both halves reach Compose. + #[test] + fn shortcuts_pass_through() { + let mut s = ImeState::new(); + assert!(s.key_pressed(KEY_CTRL, false)); + assert_eq!( + s.modifiers_changed(ModifiersState::CONTROL), + Some(ModifiersState::CONTROL) + ); + assert!(s.key_pressed(KEY_A, false)); + assert!(s.key_released(KEY_A, false)); + assert!(s.key_released(KEY_CTRL, false)); + assert_eq!( + s.modifiers_changed(ModifiersState::empty()), + Some(ModifiersState::empty()) + ); + } +} diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/mod.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/mod.rs index d10f625b6..c4f5426eb 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/mod.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/mod.rs @@ -6,6 +6,7 @@ mod device; mod event_loop; mod icon; +mod ime; mod keyboard; mod keycode; mod monitor; diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs index 6c8554304..c1310cabd 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs @@ -976,8 +976,33 @@ impl Window { } } - pub fn set_ime_position>(&self, _position: P) { - //TODO + /// Nucleus patch (nucleusframework#558): forward the caret position to the + /// window's input context so the candidate list follows the text cursor. + /// The context itself lives on the event-loop side (it is created when the + /// window's events are wired up), so this goes through the request channel + /// like every other window mutation. + pub fn set_ime_position>(&self, position: P) { + self.set_ime_cursor_area(position, LogicalSize::new(0, 0)); + } + + /// Nucleus patch (nucleusframework#558): tell the input method the rectangle + /// the caret occupies, so it can keep its own windows clear of the text. + /// + /// GTK is area-based where IMM32 is point-based: `set_cursor_location` takes + /// the region the cursor covers and the input method keeps its own windows + /// off it, which is why the caret's *size* matters here and not on Windows. + /// GDK works in logical pixels, so the caller's physical rect is scaled down + /// on the way in. + pub fn set_ime_cursor_area, S: Into>(&self, position: P, size: S) { + let scale_factor = self.scale_factor(); + let (x, y): (i32, i32) = position.into().to_logical::(scale_factor).into(); + let (w, h): (i32, i32) = size.into().to_logical::(scale_factor).into(); + if let Err(e) = self + .window_requests_tx + .send((self.window_id, WindowRequest::SetImeCursorArea((x, y, w, h)))) + { + log::warn!("Fail to send ime cursor area request: {}", e); + } } pub fn request_user_attention(&self, request_type: Option) { @@ -1306,6 +1331,9 @@ pub enum WindowRequest { CursorIcon(Option), CursorPosition((i32, i32)), CursorIgnoreEvents(bool), + /// Nucleus patch (nucleusframework#558): the rectangle the caret occupies, + /// in window-local logical pixels, for the input method to steer clear of. + SetImeCursorArea((i32, i32, i32, i32)), WireUpEvents { transparent: bool, fullscreen: bool,