From 3d58c52793581ada0048621ce1d6d9ed9eb3a473 Mon Sep 17 00:00:00 2001 From: takke Date: Fri, 28 Aug 2026 14:27:47 +0900 Subject: [PATCH 1/4] fix(tao): forward Linux IME preedit into Compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux backend pinned `gtk::IMContextSimple` — GTK's built-in fallback, which only knows Compose sequences and Ctrl+Shift+U. It never reaches the system input method, so CJK input was not merely degraded on Linux, it was impossible: typing `aiueo` and confirming left a single stray `a` behind. `connect_preedit_changed` was never wired either, and `filter_keypress` ran *after* the key had already been handed to the app, with its result discarded. Switch to `gtk::IMMulticontext`, which 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), and route its signals into the `ImePreedit` / `ImeCommit` events added for macOS in #595 and extended to Windows in #558: - `preedit-changed` -> ImePreedit, `preedit-end` -> ImePreedit("") - `commit` -> ImeCommit while composing, ReceivedImeText otherwise (the same split as the Windows backend's GCS_RESULTSTR vs. plain WM_CHAR) - the input context now follows the window's focus instead of latching `focus_in` once at construction Keys an input method consumes must not also reach Compose, or the Return that confirms a conversion goes on to insert a newline and the BackSpace that edits the composition deletes committed text. `filter_keypress` now runs first and stops propagation — but on its own that is not enough, because only the *press* is withheld: on Wayland the compositor filters those keys out of the stream before the client sees them (text-input-v3 has no notion of "filtered"), while the matching release arrives regardless. Compose fires `clickable`'s onClick on KeyUp, so a release with no press behind it activates whatever holds focus. Track the keycodes whose press was actually forwarded and drop releases that have no match; this covers the X11 path too, where `filter_keypress` withholds the press in-process for the same effect. Finally, implement `set_ime_position`, which was a `//TODO` stub, so the candidate window follows the caret (#558). The input context lives on the event-loop side, so the caret goes through the window request channel like every other window mutation, and `platform/linux/ime.rs` supplies the `nativeSetImeRect` JNI entry point the JVM side already calls on macOS and Windows — same contract, the caret's bottom edge in window-local physical pixels. Verified on GNOME 46 (Wayland session) with ibus + Mozc, on both the Wayland path and the XWayland one (`NUCLEUS_TAO_LINUX_RENDERER=x11`): inline preedit, no newline from the confirming Return, BackSpace confined to the composition, committed text inserted exactly once, the candidate window tracking the caret, and no regression in ASCII input or in Ctrl+C / Ctrl+V / Ctrl+Return. --- .../tao/scene/TaoComposeSceneHostLinux.kt | 58 ++++++ .../src/main/native/src/platform/linux/ime.rs | 46 +++++ .../src/main/native/src/platform/linux/mod.rs | 1 + .../src/main/native/vendor/tao/src/event.rs | 4 + .../tao/src/platform_impl/linux/event_loop.rs | 170 ++++++++++++++++-- .../tao/src/platform_impl/linux/window.rs | 21 ++- 6 files changed, 281 insertions(+), 19 deletions(-) create mode 100644 decorated-window-tao/src/main/native/src/platform/linux/ime.rs 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..3ce74c529 --- /dev/null +++ b/decorated-window-tao/src/main/native/src/platform/linux/ime.rs @@ -0,0 +1,46 @@ +// Caret-rect plumbing for the Linux IME (#558). +// +// The three backends split like this: 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, so both +// only have to convert Compose's caret rect into the anchor point and hand it +// over. This file is therefore the twin of `platform/windows/ime.rs`; the +// GTK-specific part (turning the point back into a `GdkRectangle` and calling +// `gtk_im_context_set_cursor_location`) lives in tao's Linux event loop, +// which is where the input context is owned. + +use jni::objects::JClass; +use jni::sys::{jint, jlong}; +use jni::JNIEnv; + +use tao::dpi::PhysicalPosition; + +use crate::state::WINDOWS; + +/// Anchors the IME candidate window to the caret, in *window-local physical +/// pixels* with a top-left origin — the same contract as the macOS and Windows +/// implementations. +/// +/// The anchor is the caret's **bottom** edge (`y + height`): GTK places the +/// candidate list below the point it is given, so passing the caret's top +/// would draw the list over the line being typed. +#[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_position(PhysicalPosition::new(x_px, y_px + h_px)); + } +} 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_impl/linux/event_loop.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs index f8eeba4c4..94bd6c550 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 @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 use std::{ - cell::RefCell, + cell::{Cell, RefCell}, collections::{HashSet, VecDeque}, error::Error, process, @@ -305,6 +305,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 +500,16 @@ impl EventLoop { } } } + // Nucleus patch (nucleusframework#558): anchor the IME candidate + // window to the caret. GTK wants the area the cursor occupies; tao's + // cross-platform `set_ime_position` carries a point, and the JNI + // caller already passes the caret's bottom edge (same contract as + // macOS and Windows), so a 1x1 rect there is the whole story. + WindowRequest::SetImePosition((x, y)) => { + if let Some(ime) = ime_contexts.borrow().get(&id.0) { + ime.set_cursor_location(&gdk::Rectangle::new(x, y, 1, 1)); + } + } WindowRequest::CursorIgnoreEvents(ignore) => { // PATCH(nucleus): an *empty* region, not a 1x1 rectangle at the // origin — upstream leaves the top-left pixel clickable. Both @@ -982,33 +998,153 @@ 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 - ); - } - }); + + // Whether a composition is in flight. Lets `commit` tell an IME + // confirmation (-> ImeCommit, which replaces the preedit Compose is + // showing) from an ordinary character insert (-> ReceivedImeText, + // forwarded as KEY_TYPED). Same split as the Windows backend's + // GCS_RESULTSTR vs. a plain WM_CHAR. + let composing = Rc::new(Cell::new(false)); + + // Hardware keycodes whose *press* we actually forwarded to Compose. + // + // An input method swallows the keys it consumes, but only the press: + // on Wayland the compositor filters them out of the key stream + // before the client ever sees them (text-input-v3 has no notion of + // "filtered"), and on X11 `filter_keypress` does the same job + // in-process. The matching *release* arrives either way. Forwarding + // it would hand Compose a KeyUp for a KeyDown it never got — and + // Compose fires `clickable`'s onClick on KeyUp, so the Return that + // merely confirmed a conversion would go on to activate whatever + // holds focus. + let pressed_keys: Rc>> = + Rc::new(RefCell::new(std::collections::HashSet::new())); + + { + let composing = composing.clone(); + ime.connect_preedit_start(move |_| composing.set(true)); + } + + { + 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 composing = composing.clone(); + let tx_clone = event_tx.clone(); + ime.connect_preedit_end(move |_| { + composing.set(false); + // 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 composing = composing.clone(); + let tx_clone = event_tx.clone(); + ime.connect_commit(move |_, s| { + let event = if composing.get() { + WindowEvent::ImeCommit(s.to_string()) + } else { + 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 pressed_keys_press = pressed_keys.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). + if ime_.filter_keypress(event_key) { + return glib::Propagation::Stop; + } + pressed_keys_press + .borrow_mut() + .insert(event_key.hardware_keycode()); handler(event_key.to_owned(), ElementState::Pressed); - ime.filter_keypress(event_key); glib::Propagation::Proceed }); let handler = keyboard_handler.clone(); + let pressed_keys_release = pressed_keys; window.connect_key_release_event(move |_, event_key| { + let filtered = ime.filter_keypress(event_key); + let was_pressed = pressed_keys_release + .borrow_mut() + .remove(&event_key.hardware_keycode()); + if filtered || !was_pressed { + 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/window.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs index 6c8554304..eeb1f4c2c 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,22 @@ 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) { + let (x, y): (i32, i32) = position + .into() + .to_physical::(self.scale_factor()) + .into(); + if let Err(e) = self + .window_requests_tx + .send((self.window_id, WindowRequest::SetImePosition((x, y)))) + { + log::warn!("Fail to send ime position request: {}", e); + } } pub fn request_user_attention(&self, request_type: Option) { @@ -1306,6 +1320,9 @@ pub enum WindowRequest { CursorIcon(Option), CursorPosition((i32, i32)), CursorIgnoreEvents(bool), + /// Nucleus patch (nucleusframework#558): caret position, in window-local + /// physical pixels, for the IME candidate window. + SetImePosition((i32, i32)), WireUpEvents { transparent: bool, fullscreen: bool, From 241ff176742be25f1d824c4ea350dffe049e1821 Mon Sep 17 00:00:00 2001 From: takke Date: Fri, 28 Aug 2026 14:28:25 +0900 Subject: [PATCH 2/4] fix(tao): derive the Linux modifier state from every key event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ModifiersChanged` was only ever emitted when the key *itself* was a modifier, so Compose's view of Ctrl / Shift / Alt / Super was built up from press and release bookkeeping. That holds only as long as every release arrives, and it does not: put an input method in the event path on X11 and modifier releases go missing, because ibus re-injects what it forwards rather than passing the original events through. A dropped Control release then leaves Compose believing Ctrl is held for the rest of the session — a plain Return reads as Ctrl+Return, and the app fires whatever it binds to that instead of inserting a newline. Pressing and releasing Ctrl by hand was the only way out. GDK reports the live modifier mask on every event, so derive the state from the event each time and publish it whenever it changed. That self-heals on the very next keystroke no matter which release was lost, and it costs nothing when nothing changed. It also handles the asymmetric reports seen in the same traces, where a `Meta_L` press is answered by an `Alt_L` release. Found while verifying #558 on the XWayland path, but it is not IME-specific: any lost modifier release had the same effect. It only stayed hidden because the Linux backend never connected to a system input method until now. --- .../tao/src/platform_impl/linux/event_loop.rs | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) 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 94bd6c550..c933d06e2 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 @@ -952,15 +952,29 @@ impl EventLoop { }); let tx_clone = event_tx.clone(); + let last_modifiers = Rc::new(Cell::new(ModifiersState::empty())); 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 mods != last_modifiers.get() { + last_modifiers.set(mods); if let Err(e) = tx_clone.send(Event::WindowEvent { window_id: RootWindowId(id), @@ -971,14 +985,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); From 105b6c9e3d62e9a7258729ad67a5850b886894dc Mon Sep 17 00:00:00 2001 From: takke Date: Sat, 29 Aug 2026 00:21:30 +0900 Subject: [PATCH 3/4] test(tao): cover the Linux IME state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decisions the Linux input-method path makes were spread across GTK closures: whether a key event reaches Compose, whether committed text replaces a preedit or stands on its own, whether the modifier state needs republishing. All three are about *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 could be reproduced from a unit test while the state lived in the closures. Move that state into `platform_impl::linux::ime::ImeState` and leave the signal handlers doing nothing but translating GTK callbacks into calls on it. The Windows backend draws the same line with its `ImeSource` trait; Linux needs no trait, because there is nothing to read back — GTK pushes everything, so plain data suffices. The tests replay sequences taken from real traces on GNOME 46 with ibus + Mozc: - `release_without_a_press_is_dropped` — Wayland withholds the press of a key the input method consumed and delivers the release anyway - `filtered_press_withholds_its_release` — the X11 shape of the same thing, where `filter_keypress` claims the press in-process - `confirming_return_does_not_reach_compose` — the observed failure: the Return that confirmed a conversion also activated the focused control - `lost_modifier_release_recovers_on_the_next_key` and `asymmetric_modifier_reports_settle` — a swallowed Control release, and the `Meta_L` press answered by an `Alt_L` release that the same traces show - `shortcuts_pass_through` — Ctrl+C and friends must survive the key gate No behaviour change; this is the same logic addressed through one owner. Run with `cargo test --lib` from `src/main/native/vendor/tao`. --- .../tao/src/platform_impl/linux/event_loop.rs | 71 ++--- .../vendor/tao/src/platform_impl/linux/ime.rs | 250 ++++++++++++++++++ .../vendor/tao/src/platform_impl/linux/mod.rs | 1 + 3 files changed, 279 insertions(+), 43 deletions(-) create mode 100644 decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/ime.rs 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 c933d06e2..917d2afac 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 @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 use std::{ - cell::{Cell, RefCell}, + cell::RefCell, collections::{HashSet, VecDeque}, error::Error, process, @@ -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, @@ -952,7 +953,7 @@ impl EventLoop { }); let tx_clone = event_tx.clone(); - let last_modifiers = Rc::new(Cell::new(ModifiersState::empty())); + let modifier_state = Rc::new(RefCell::new(ImeState::new())); let keyboard_handler = Rc::new(move |event_key: EventKey, element_state| { // Nucleus patch: emit the FULL modifier state, not just the // pressed key's own bit — upstream sent `{SHIFT}` when Shift @@ -973,9 +974,7 @@ impl EventLoop { // Ctrl+Return for the rest of the session. let mods = keyboard::get_modifier_state(&event_key, ElementState::Pressed == element_state); - if mods != last_modifiers.get() { - last_modifiers.set(mods); - + 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), @@ -1022,30 +1021,16 @@ impl EventLoop { let ime = gtk::IMMulticontext::new(); ime.set_client_window(window.window().as_ref()); - // Whether a composition is in flight. Lets `commit` tell an IME - // confirmation (-> ImeCommit, which replaces the preedit Compose is - // showing) from an ordinary character insert (-> ReceivedImeText, - // forwarded as KEY_TYPED). Same split as the Windows backend's - // GCS_RESULTSTR vs. a plain WM_CHAR. - let composing = Rc::new(Cell::new(false)); - - // Hardware keycodes whose *press* we actually forwarded to Compose. - // - // An input method swallows the keys it consumes, but only the press: - // on Wayland the compositor filters them out of the key stream - // before the client ever sees them (text-input-v3 has no notion of - // "filtered"), and on X11 `filter_keypress` does the same job - // in-process. The matching *release* arrives either way. Forwarding - // it would hand Compose a KeyUp for a KeyDown it never got — and - // Compose fires `clickable`'s onClick on KeyUp, so the Return that - // merely confirmed a conversion would go on to activate whatever - // holds focus. - let pressed_keys: Rc>> = - Rc::new(RefCell::new(std::collections::HashSet::new())); + // 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 composing = composing.clone(); - ime.connect_preedit_start(move |_| composing.set(true)); + let ime_state = ime_state.clone(); + ime.connect_preedit_start(move |_| ime_state.borrow_mut().preedit_started()); } { @@ -1062,10 +1047,10 @@ impl EventLoop { } { - let composing = composing.clone(); + let ime_state = ime_state.clone(); let tx_clone = event_tx.clone(); ime.connect_preedit_end(move |_| { - composing.set(false); + 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 { @@ -1078,13 +1063,12 @@ impl EventLoop { } { - let composing = composing.clone(); + let ime_state = ime_state.clone(); let tx_clone = event_tx.clone(); ime.connect_commit(move |_, s| { - let event = if composing.get() { - WindowEvent::ImeCommit(s.to_string()) - } else { - WindowEvent::ReceivedImeText(s.to_string()) + 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), @@ -1131,32 +1115,33 @@ impl EventLoop { let handler = keyboard_handler.clone(); let ime_ = ime.clone(); - let pressed_keys_press = pressed_keys.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). - if ime_.filter_keypress(event_key) { + let filtered = ime_.filter_keypress(event_key); + if !ime_state_press + .borrow_mut() + .key_pressed(event_key.hardware_keycode(), filtered) + { return glib::Propagation::Stop; } - pressed_keys_press - .borrow_mut() - .insert(event_key.hardware_keycode()); handler(event_key.to_owned(), ElementState::Pressed); glib::Propagation::Proceed }); let handler = keyboard_handler.clone(); - let pressed_keys_release = pressed_keys; + let ime_state_release = ime_state; window.connect_key_release_event(move |_, event_key| { let filtered = ime.filter_keypress(event_key); - let was_pressed = pressed_keys_release + if !ime_state_release .borrow_mut() - .remove(&event_key.hardware_keycode()); - if filtered || !was_pressed { + .key_released(event_key.hardware_keycode(), filtered) + { return glib::Propagation::Stop; } handler(event_key.to_owned(), ElementState::Released); 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; From 7bc9ea60208204070c06c58eef991d86220c9154 Mon Sep 17 00:00:00 2001 From: takke Date: Sat, 29 Aug 2026 00:22:14 +0900 Subject: [PATCH 4/4] fix(tao): keep the Linux IME candidate window off the caret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The candidate window tracked the caret and covered it: Mozc's "Tab to select" hint sat exactly on the composition it was describing, so the underlined preedit was never visible. Three separate mistakes stacked up to land it there. The first is that the caret was reduced to a point. IMM32 takes a point and hangs the candidate list off it, which is all `set_ime_position` can carry, so the Windows implementation passes the caret's bottom edge and is done. GTK is area-based instead: `gtk_im_context_set_cursor_location` is given the region the cursor covers and the input method keeps its own windows off that area. A 1x1 rect tells it there is nothing to avoid. `WindowExtUnix` grows `set_ime_cursor_area`, mirroring the winit API of the same name, and `set_ime_position` becomes a zero-sized area at the point — the documented behaviour of the cross-platform call is unchanged. The second is the coordinate space: GDK works in logical pixels and the caret was handed over in physical ones. Invisible at scale 1, wrong on HiDPI. The third is what actually put the window on top of the text. The caret arrives in client-area coordinates — what `set_ime_position` documents — but GTK wants it relative to the toplevel GdkWindow, which on a client-side-decorated window also spans the invisible resize border and drop shadow. Measured here that is (26, 23), so the input method placed the caret a shadow's height too high and drew its list over the line being typed. Translating by the content widget's allocation is the same correction the Wayland subsurface already applies to position the Compose surface, so the two now agree on where the caret is. Verified on GNOME 46 (Wayland session) with ibus + Mozc: the hint and the candidate list both sit below the composition and follow the caret across lines. --- .../src/main/native/src/platform/linux/ime.rs | 42 +++++++++++-------- .../native/vendor/tao/src/platform/unix.rs | 16 +++++++ .../tao/src/platform_impl/linux/event_loop.rs | 33 +++++++++++---- .../tao/src/platform_impl/linux/window.rs | 29 +++++++++---- 4 files changed, 86 insertions(+), 34 deletions(-) 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 index 3ce74c529..234e2c2ac 100644 --- a/decorated-window-tao/src/main/native/src/platform/linux/ime.rs +++ b/decorated-window-tao/src/main/native/src/platform/linux/ime.rs @@ -1,30 +1,33 @@ // Caret-rect plumbing for the Linux IME (#558). // -// The three backends split like this: 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, so both -// only have to convert Compose's caret rect into the anchor point and hand it -// over. This file is therefore the twin of `platform/windows/ime.rs`; the -// GTK-specific part (turning the point back into a `GdkRectangle` and calling -// `gtk_im_context_set_cursor_location`) lives in tao's Linux event loop, -// which is where the input context is owned. +// 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; +use tao::dpi::{PhysicalPosition, PhysicalSize}; +use tao::platform::unix::WindowExtUnix; use crate::state::WINDOWS; -/// Anchors the IME candidate window to the caret, in *window-local physical +/// 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. -/// -/// The anchor is the caret's **bottom** edge (`y + height`): GTK places the -/// candidate list below the point it is given, so passing the caret's top -/// would draw the list over the line being typed. +/// 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, @@ -32,7 +35,7 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ handle: jlong, x_px: jint, y_px: jint, - _w_px: jint, + w_px: jint, h_px: jint, ) { let guard = match WINDOWS.lock() { @@ -41,6 +44,9 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ }; let Some(map) = guard.as_ref() else { return }; if let Some(window) = map.get(&(handle as u64)) { - window.set_ime_position(PhysicalPosition::new(x_px, y_px + h_px)); + 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/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 917d2afac..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 @@ -501,14 +501,33 @@ impl EventLoop { } } } - // Nucleus patch (nucleusframework#558): anchor the IME candidate - // window to the caret. GTK wants the area the cursor occupies; tao's - // cross-platform `set_ime_position` carries a point, and the JNI - // caller already passes the caret's bottom edge (same contract as - // macOS and Windows), so a 1x1 rect there is the whole story. - WindowRequest::SetImePosition((x, y)) => { + // 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, y, 1, 1)); + ime.set_cursor_location(&gdk::Rectangle::new(x + dx, y + dy, w, h)); } } WindowRequest::CursorIgnoreEvents(ignore) => { 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 eeb1f4c2c..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 @@ -982,15 +982,26 @@ impl Window { /// 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) { - let (x, y): (i32, i32) = position - .into() - .to_physical::(self.scale_factor()) - .into(); + 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::SetImePosition((x, y)))) + .send((self.window_id, WindowRequest::SetImeCursorArea((x, y, w, h)))) { - log::warn!("Fail to send ime position request: {}", e); + log::warn!("Fail to send ime cursor area request: {}", e); } } @@ -1320,9 +1331,9 @@ pub enum WindowRequest { CursorIcon(Option), CursorPosition((i32, i32)), CursorIgnoreEvents(bool), - /// Nucleus patch (nucleusframework#558): caret position, in window-local - /// physical pixels, for the IME candidate window. - SetImePosition((i32, i32)), + /// 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,