From 730ca0aa1587c6d824eca3404723d174cbe693ee Mon Sep 17 00:00:00 2001 From: Manuel Chamorro Date: Mon, 25 May 2026 16:56:23 +0200 Subject: [PATCH] fix(terminal): route dead-key compose through IMContextSimple on Wayland MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GTK4's IMMulticontext defaults to the "wayland" slave on Plasma 6 Wayland sessions that don't have ibus or fcitx5 running. That slave claims dead-key events over text-input-v3 (filter_keypress returns true and a preedit_changed signal fires with the bare dead-key glyph), but KWin/Plasma 6 never delivers the follow-up commit. The dead-key glyph flashes on screen, the follow-up key is consumed too, and the composed character never appears. AZERTY users get raw `^e` instead of `ê`. Pair the existing IMMulticontext with a GtkIMContextSimple fallback that drives libxkbcommon's compose tables in-process. When the incoming keysym is a compose initiator (XK_dead_*, Multi_key) or the fallback is already mid-compose, bypass the multicontext entirely so KWin can't intercept the event; otherwise the multicontext keeps priority so ibus / fcitx5 / CJK IMEs continue to work. The IME plumbing lives in a new limux-host-linux::ime module split into three submodules so pure logic is testable in isolation from the GTK wiring: ime/state.rs TerminalImeState machine + 6 unit tests ime/routing.rs decide_routing, is_compose_initiator, update_latch_after_fallback_first + 8 unit tests ime/contexts.rs PaneIme, GTK signal wiring, ghostty FFI helpers terminal.rs sheds ~270 lines. Regression tests pin the routing rules (initiator and in-flight compose route to fallback first; plain keys route to the multicontext first; the latch arms / disarms / stays put under the expected fallback responses) and include a full state-machine trace of the `^` + `e` → `ê` sequence. A CLAUDE.md anchor entry points at the new module and a Pitfalls entry warns against routing dead-key events through IMMulticontext alone on Wayland. Builds on the partial fix from PR #20 (commit 536e6e9), which wired IMMulticontext but did not cover the Wayland-default case. Fixes #89 Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 11 + rust/limux-host-linux/src/ime.rs | 35 +++ rust/limux-host-linux/src/ime/contexts.rs | 229 ++++++++++++++++ rust/limux-host-linux/src/ime/routing.rs | 186 +++++++++++++ rust/limux-host-linux/src/ime/state.rs | 223 ++++++++++++++++ rust/limux-host-linux/src/main.rs | 1 + rust/limux-host-linux/src/terminal.rs | 303 +++------------------- 7 files changed, 716 insertions(+), 272 deletions(-) create mode 100644 rust/limux-host-linux/src/ime.rs create mode 100644 rust/limux-host-linux/src/ime/contexts.rs create mode 100644 rust/limux-host-linux/src/ime/routing.rs create mode 100644 rust/limux-host-linux/src/ime/state.rs diff --git a/CLAUDE.md b/CLAUDE.md index 9197973b..bc0cb429 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ rg -n "PaneCallbacks \{" rust/limux-host-linux/src/win | GUI bridge routing | `rust/limux-host-linux/src/control_bridge.rs` | | Full-vocabulary control (no GUI) | `limux-core::Dispatcher` + `ControlState` | | Pane / surface UI state | `rust/limux-host-linux/src/window.rs` (`PaneCallbacks`) | +| Terminal IME / dead-key / compose | `rust/limux-host-linux/src/ime/` (`state.rs` state machine, `routing.rs` decide_routing / is_compose_initiator, `contexts.rs` GTK + ghostty wiring) | | Agent-hook installers + templates | `hooks/` + `limux hooks setup` | | Packaging (AppImage / AUR) | `scripts/package.sh`, `scripts/appimage-webkit.sh`, `PKGBUILD.template` | @@ -71,6 +72,16 @@ rg -n "PaneCallbacks \{" rust/limux-host-linux/src/win `ghostty_surface_new` call. - **Vendored `ghostty/` is read-only.** Work through the C API in `ghostty/include/ghostty.h`. +- **Don't route dead-key / compose events through `IMMulticontext` + on Wayland.** GTK's "wayland" slave (default on Plasma 6 without + ibus/fcitx5) claims dead keys over text-input-v3 without ever + committing — the dead-key glyph flashes and the compose silently + aborts. The terminal pane pairs `IMMulticontext` with a parallel + `IMContextSimple` that drives libxkbcommon's compose tables; for + compose initiators (`XK_dead_*`, `Multi_key`) and in-flight + compose sequences, `filter_key_event` in `ime.rs` bypasses the + multicontext entirely. Preserve that bypass when touching keyboard + routing. - **Clippy is a hard gate** (`-D warnings`). Fix lints, don't suppress. - **Don't commit** `target/` or other build artifacts. diff --git a/rust/limux-host-linux/src/ime.rs b/rust/limux-host-linux/src/ime.rs new file mode 100644 index 00000000..70c127c2 --- /dev/null +++ b/rust/limux-host-linux/src/ime.rs @@ -0,0 +1,35 @@ +//! IME plumbing for the embedded ghostty terminal surface. +//! +//! Two GTK input-method contexts run side-by-side per pane: +//! +//! 1. `gtk::IMMulticontext` — the primary, which routes to whichever +//! slave GTK picks (ibus / fcitx5 / wayland / simple). It handles +//! full IMEs (CJK and friends). +//! +//! 2. `gtk::IMContextSimple` — a fallback that runs libxkbcommon's +//! compose tables in-process. It only sees keypresses the +//! multicontext didn't claim, and exists because the "wayland" +//! slave (GTK's default on Wayland sessions without ibus/fcitx5) +//! defers compose to the compositor, and KWin/Plasma 6 does not +//! drive xkb_compose for AZERTY dead keys over text-input-v3. +//! +//! Both contexts feed the same `TerminalImeState` through the signal +//! handlers wired in [`contexts`]. Only one of the two is ever in a +//! composing state at a time in practice — the multicontext, when it +//! actively handles input; otherwise the fallback, when the +//! multicontext lets a dead key through. +//! +//! Module layout: +//! +//! * [`state`] — pure state machine, no GTK or FFI; unit-tested. +//! * [`routing`] — pure routing decisions and compose-initiator +//! detection; unit-tested. +//! * [`contexts`] — GTK signal wiring and ghostty FFI bridge; only +//! exercisable with a real GTK runtime. + +mod contexts; +mod routing; +mod state; + +pub use contexts::{create_pane_ime, filter_key_event, reset_after_consumed_compose}; +pub use state::ImeFilterOutcome; diff --git a/rust/limux-host-linux/src/ime/contexts.rs b/rust/limux-host-linux/src/ime/contexts.rs new file mode 100644 index 00000000..e9472cfe --- /dev/null +++ b/rust/limux-host-linux/src/ime/contexts.rs @@ -0,0 +1,229 @@ +//! GTK + ghostty wiring for the per-pane IME. Everything in this +//! module touches either a `gtk::IMContext` or the libghostty C API, +//! so it can only be exercised with a real GTK runtime — see the +//! pure-logic tests in [`super::state`] and [`super::routing`] for +//! everything that can be unit-tested in isolation. + +use gtk::prelude::*; +use gtk4 as gtk; + +use std::cell::{Cell, RefCell}; +use std::ffi::CString; +use std::ptr; +use std::rc::Rc; + +use limux_ghostty_sys::*; + +use super::routing::{ + decide_routing, is_compose_initiator, update_latch_after_fallback_first, ComposeRouting, +}; +use super::state::{ImeCommitOutcome, ImeFilterOutcome, TerminalImeState}; + +/// Per-pane IME contexts and state. Returned by [`create_pane_ime`] +/// and threaded through [`filter_key_event`] / [`reset_after_consumed_compose`]. +pub struct PaneIme { + pub primary: gtk::IMMulticontext, + pub fallback: gtk::IMContextSimple, + pub state: Rc>, + /// True while [`Self::fallback`] holds a partial compose + /// sequence — either the initiator (a dead key or Multi_key) has + /// been consumed by it but the sequence has not yet completed, + /// or it is in a longer in-flight compose. While set, subsequent + /// keypresses are routed through [`Self::fallback`] first so the + /// compose can finish without [`Self::primary`] (the Wayland + /// slave) intercepting and swallowing the follow-up. + fallback_composing: Rc>, +} + +/// Build the two IM contexts for a terminal pane and wire their +/// preedit / commit signals into a shared [`TerminalImeState`]. +/// +/// The caller is responsible for routing keypresses through both +/// contexts (see [`filter_key_event`]) and calling `focus_in` / +/// `focus_out` / `set_client_widget(None)` on both at the right +/// lifecycle points. +pub fn create_pane_ime( + gl_area: >k::GLArea, + surface_cell: &Rc>>, +) -> PaneIme { + let primary = gtk::IMMulticontext::new(); + primary.set_client_widget(Some(gl_area)); + primary.set_use_preedit(true); + + let fallback = gtk::IMContextSimple::new(); + fallback.set_client_widget(Some(gl_area)); + fallback.set_use_preedit(true); + + let state = Rc::new(RefCell::new(TerminalImeState::default())); + let fallback_composing = Rc::new(Cell::new(false)); + + register_im_signal_handlers(primary.upcast_ref(), surface_cell, &state); + register_im_signal_handlers(fallback.upcast_ref(), surface_cell, &state); + + // The compose sequence has completed when the fallback commits + // its result, so clear the latch so the next keypress goes back + // through the multicontext first. + { + let fallback_composing = fallback_composing.clone(); + fallback.connect_commit(move |_, _| { + fallback_composing.set(false); + }); + } + + PaneIme { + primary, + fallback, + state, + fallback_composing, + } +} + +/// Run a single keypress through the IM contexts and return the +/// resulting filter outcome. +/// +/// The routing decision is made by [`decide_routing`]; see its +/// documentation for the rules. +/// +/// The caller must already have called +/// [`TerminalImeState::begin_key_event`] for this key event, and must +/// call [`TerminalImeState::finish_key_event`] after acting on the +/// returned outcome. +pub fn filter_key_event( + surface: ghostty_surface_t, + ime: &PaneIme, + event: >k::gdk::KeyEvent, +) -> ImeFilterOutcome { + update_ime_cursor_location(surface, ime.primary.upcast_ref()); + update_ime_cursor_location(surface, ime.fallback.upcast_ref()); + + let in_compose = ime.fallback_composing.get(); + let is_initiator = is_compose_initiator(event.keyval()); + + let im_handled = match decide_routing(in_compose, is_initiator) { + ComposeRouting::FallbackFirst => { + let handled = ime.fallback.filter_keypress(event); + if let Some(new_latch) = update_latch_after_fallback_first(is_initiator, handled) { + ime.fallback_composing.set(new_latch); + } + handled || ime.primary.filter_keypress(event) + } + ComposeRouting::PrimaryFirst => { + ime.primary.filter_keypress(event) || ime.fallback.filter_keypress(event) + } + }; + + ime.state.borrow().filter_outcome(im_handled) +} + +/// Reset both IM contexts and clear any preedit visible in ghostty. +/// Use after ghostty consumes a key while a composition is in flight. +pub fn reset_after_consumed_compose(surface: ghostty_surface_t, ime: &PaneIme) { + ime.primary.reset(); + ime.fallback.reset(); + ime.fallback_composing.set(false); + clear_ghostty_preedit(surface); +} + +pub fn clear_ghostty_preedit(surface: ghostty_surface_t) { + unsafe { ghostty_surface_preedit(surface, ptr::null(), 0) }; +} + +pub fn update_ime_cursor_location(surface: ghostty_surface_t, im_context: >k::IMContext) { + let mut x = 0.0; + let mut y = 0.0; + let mut width = 1.0; + let mut height = 1.0; + unsafe { + ghostty_surface_ime_point(surface, &mut x, &mut y, &mut width, &mut height); + } + im_context.set_cursor_location(>k::gdk::Rectangle::new( + x.round() as i32, + y.round() as i32, + width.max(1.0).round() as i32, + height.max(1.0).round() as i32, + )); +} + +pub fn send_committed_text(surface: ghostty_surface_t, text: &str) { + let Ok(c_text) = CString::new(text) else { + return; + }; + + let event = ghostty_input_key_s { + action: GHOSTTY_ACTION_PRESS, + mods: GHOSTTY_MODS_NONE, + consumed_mods: GHOSTTY_MODS_NONE, + keycode: 0, + text: c_text.as_ptr(), + unshifted_codepoint: 0, + composing: false, + }; + + unsafe { + ghostty_surface_key(surface, event); + } +} + +fn update_ghostty_preedit( + surface_cell: &Rc>>, + im_context: >k::IMContext, +) { + let Some(surface) = *surface_cell.borrow() else { + return; + }; + + let (preedit, _, cursor_pos) = im_context.preedit_string(); + if preedit.is_empty() { + clear_ghostty_preedit(surface); + return; + } + + if let Ok(text) = CString::new(preedit.as_str()) { + unsafe { + ghostty_surface_preedit(surface, text.as_ptr(), cursor_pos.max(0) as usize); + } + } +} + +fn register_im_signal_handlers( + im_context: >k::IMContext, + surface_cell: &Rc>>, + ime_state: &Rc>, +) { + { + let surface_cell = surface_cell.clone(); + let ime_state = ime_state.clone(); + im_context.connect_preedit_changed(move |ctx| { + ime_state.borrow_mut().preedit_changed(); + update_ghostty_preedit(&surface_cell, ctx); + }); + } + { + let surface_cell = surface_cell.clone(); + let ime_state = ime_state.clone(); + im_context.connect_preedit_end(move |_| { + ime_state.borrow_mut().preedit_ended(); + let Some(surface) = *surface_cell.borrow() else { + return; + }; + clear_ghostty_preedit(surface); + }); + } + { + let surface_cell = surface_cell.clone(); + let ime_state = ime_state.clone(); + im_context.connect_commit(move |_, text| { + let Some(surface) = *surface_cell.borrow() else { + return; + }; + + match ime_state.borrow_mut().commit_text(text) { + ImeCommitOutcome::BufferForKeyEvent => {} + ImeCommitOutcome::CommitDirectly(text) => { + clear_ghostty_preedit(surface); + send_committed_text(surface, &text); + } + } + }); + } +} diff --git a/rust/limux-host-linux/src/ime/routing.rs b/rust/limux-host-linux/src/ime/routing.rs new file mode 100644 index 00000000..118f4345 --- /dev/null +++ b/rust/limux-host-linux/src/ime/routing.rs @@ -0,0 +1,186 @@ +//! Routing decisions for the per-pane IME — pure logic, no GTK or +//! ghostty FFI. +//! +//! Two GTK input-method contexts run side-by-side per pane: the +//! `IMMulticontext` (primary, owns ibus / fcitx5 / wayland slaves) +//! and a `GtkIMContextSimple` (fallback, drives libxkbcommon's +//! compose tables in-process). For every keypress we have to decide +//! which one sees the event first, and how the "compose is in +//! flight" latch transitions afterwards. +//! +//! The bug this exists to avoid: on Plasma 6 Wayland without +//! ibus/fcitx5, GTK's `wayland` slave claims AZERTY dead keys over +//! text-input-v3 (`filter_keypress` returns `true` and a preedit +//! fires with the bare dead-key glyph), but KWin never commits the +//! composed glyph. If we route a dead-key initiator through the +//! multicontext first, the fallback never sees it and the compose +//! silently aborts. So we bypass the multicontext for compose +//! initiators and for the follow-up keystrokes that complete the +//! sequence. + +use gtk::glib::translate::IntoGlib; +use gtk4 as gtk; + +/// X11 keysym for the Compose / `Multi_key` initiator. +const MULTI_KEY_KEYSYM: u32 = 0xFF20; + +/// Returns true for keysyms that initiate a compose sequence — X11 +/// dead keys (`XK_dead_*`, 0xFE50–0xFEFF) and the Compose key +/// (`XK_Multi_key`, 0xFF20). +pub fn is_compose_initiator(keyval: gtk::gdk::Key) -> bool { + let raw: u32 = keyval.into_glib(); + matches!(raw, 0xFE50..=0xFEFF | MULTI_KEY_KEYSYM) +} + +/// Which IM context should see a keypress first. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ComposeRouting { + /// Run the simple-compose fallback before the multicontext. + /// Used when the keysym opens a compose sequence (dead key, + /// Multi_key) or when one is already in flight — running the + /// multicontext for these on Wayland would let KWin grab the + /// event over text-input-v3 and swallow the compose. + FallbackFirst, + /// Run the multicontext (ibus / fcitx5 / wayland slave) first. + /// The fallback only sees the event as a safety net if the + /// multicontext doesn't claim it. + PrimaryFirst, +} + +/// Decide which context sees a keypress first given the current +/// compose latch state and the incoming keysym. Pure function; the +/// caller applies the result by calling `filter_keypress` on the +/// chosen context. +pub fn decide_routing(fallback_composing: bool, is_initiator: bool) -> ComposeRouting { + if fallback_composing || is_initiator { + ComposeRouting::FallbackFirst + } else { + ComposeRouting::PrimaryFirst + } +} + +/// What to do with the `fallback_composing` latch after the fallback +/// context has filtered a keypress that we routed to it first. +/// +/// * `Some(true)` — the fallback just consumed a compose initiator, +/// so the next keypress must also be routed through it. +/// * `Some(false)` — the fallback declined the event; the compose +/// (if any) is abandoned and routing returns to the multicontext. +/// * `None` — fallback consumed a non-initiator; the result +/// (compose completed or still pending) is communicated through +/// the fallback's `commit` signal, which clears the latch from +/// there. +pub fn update_latch_after_fallback_first( + is_initiator: bool, + fallback_handled: bool, +) -> Option { + match (is_initiator, fallback_handled) { + (true, true) => Some(true), + (_, false) => Some(false), + (false, true) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compose_initiator_detection_covers_dead_keys_and_multi_key() { + // A few representative dead keys plus Multi_key. + assert!(is_compose_initiator(gtk::gdk::Key::dead_grave)); + assert!(is_compose_initiator(gtk::gdk::Key::dead_acute)); + assert!(is_compose_initiator(gtk::gdk::Key::dead_circumflex)); + assert!(is_compose_initiator(gtk::gdk::Key::dead_tilde)); + assert!(is_compose_initiator(gtk::gdk::Key::dead_diaeresis)); + assert!(is_compose_initiator(gtk::gdk::Key::Multi_key)); + + // Plain printable + control keys must not be misclassified. + assert!(!is_compose_initiator(gtk::gdk::Key::a)); + assert!(!is_compose_initiator(gtk::gdk::Key::e)); + assert!(!is_compose_initiator(gtk::gdk::Key::space)); + assert!(!is_compose_initiator(gtk::gdk::Key::Return)); + assert!(!is_compose_initiator(gtk::gdk::Key::BackSpace)); + } + + // ----------------------------------------------------------------- + // Routing-decision regression tests. + // + // The bug fixed here was that GTK4's IMMulticontext on Plasma 6 + // Wayland (without ibus / fcitx5) claims dead-key events over + // text-input-v3 without ever delivering a commit, masking the + // compose. These tests pin the routing rules so a refactor can't + // silently reintroduce that behavior. + // ----------------------------------------------------------------- + + #[test] + fn routing_sends_compose_initiator_to_fallback_first() { + assert_eq!( + decide_routing(false, true), + ComposeRouting::FallbackFirst, + "dead-key / Multi_key must bypass the multicontext so KWin's \ + text-input-v3 cannot grab and swallow it" + ); + } + + #[test] + fn routing_stays_on_fallback_while_compose_pending() { + assert_eq!( + decide_routing(true, false), + ComposeRouting::FallbackFirst, + "the compose follow-up keystroke (e.g. `e` after `^`) is not \ + a compose initiator, but it must still go through the \ + fallback so libxkbcommon's compose tables can complete the \ + sequence" + ); + } + + #[test] + fn routing_uses_multicontext_first_for_plain_keys() { + assert_eq!( + decide_routing(false, false), + ComposeRouting::PrimaryFirst, + "ASCII typing and modifier shortcuts must hit the multicontext \ + first so ibus / fcitx5 / CJK IMEs keep working — only when \ + the multicontext declines does the simple-compose fallback \ + see the key" + ); + } + + #[test] + fn routing_does_not_resurrect_compose_after_completion() { + // After commit, fallback_composing is cleared (by the commit + // signal). The next plain key must return to multicontext-first. + assert_eq!(decide_routing(false, false), ComposeRouting::PrimaryFirst); + } + + #[test] + fn latch_arms_when_initiator_is_consumed() { + assert_eq!( + update_latch_after_fallback_first(true, true), + Some(true), + "after the fallback consumes a dead-key press, the latch \ + must arm so the follow-up keystroke is also routed through \ + the fallback" + ); + } + + #[test] + fn latch_disarms_when_fallback_declines() { + // Initiator declined — e.g. an obscure dead key with no compose + // entry in libxkbcommon's tables. We must fall back to the + // multicontext for this key and for the next one. + assert_eq!(update_latch_after_fallback_first(true, false), Some(false)); + // Follow-up key declined — compose abandoned. + assert_eq!(update_latch_after_fallback_first(false, false), Some(false)); + } + + #[test] + fn latch_left_alone_on_successful_follow_up() { + // The fallback consumed a non-initiator; whether it completed + // the compose or kept it open is signalled via its `commit` + // signal, which clears the latch from elsewhere. The routing + // helper must not touch the latch here. + assert_eq!(update_latch_after_fallback_first(false, true), None); + } +} diff --git a/rust/limux-host-linux/src/ime/state.rs b/rust/limux-host-linux/src/ime/state.rs new file mode 100644 index 00000000..e4823f59 --- /dev/null +++ b/rust/limux-host-linux/src/ime/state.rs @@ -0,0 +1,223 @@ +//! Per-pane IME state machine — pure logic, no GTK or ghostty FFI. +//! +//! Models the lifetime of a single key event from press through +//! filter / commit / forward, so the GTK keypress handler in +//! [`super::contexts`] can serialize signals coming from either of +//! the two IM contexts (multicontext and simple-compose fallback) +//! into one consistent stream for the ghostty surface. +//! +//! The four states (`Idle`, `NotComposing`, `Composing`) describe +//! where we are inside a single keypress, not the IM's compose +//! state. `composing` (the boolean field) tracks the latter and is +//! set by `preedit_changed` / cleared by `preedit_ended`. + +use std::ffi::CString; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ImeKeyEventPhase { + #[default] + Idle, + NotComposing, + Composing, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TerminalImeState { + pub composing: bool, + pub key_event_phase: ImeKeyEventPhase, + pub pending_key_text: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ImeCommitOutcome { + BufferForKeyEvent, + CommitDirectly(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ImeFilterOutcome { + ForwardToGhostty, + ConsumeForIme, +} + +impl TerminalImeState { + pub fn begin_key_event(&mut self) { + self.key_event_phase = if self.composing { + ImeKeyEventPhase::Composing + } else { + ImeKeyEventPhase::NotComposing + }; + self.pending_key_text = None; + } + + pub fn finish_key_event(&mut self) { + self.key_event_phase = ImeKeyEventPhase::Idle; + self.pending_key_text = None; + } + + pub fn preedit_changed(&mut self) { + self.composing = true; + } + + pub fn preedit_ended(&mut self) { + self.composing = false; + } + + pub fn commit_text(&mut self, text: &str) -> ImeCommitOutcome { + match self.key_event_phase { + ImeKeyEventPhase::Idle | ImeKeyEventPhase::Composing => { + self.composing = false; + ImeCommitOutcome::CommitDirectly(text.to_string()) + } + ImeKeyEventPhase::NotComposing => { + self.pending_key_text = Some(text.to_string()); + ImeCommitOutcome::BufferForKeyEvent + } + } + } + + pub fn filter_outcome(&self, im_handled: bool) -> ImeFilterOutcome { + if !im_handled { + return ImeFilterOutcome::ForwardToGhostty; + } + + if self.composing + || self.key_event_phase == ImeKeyEventPhase::Composing + || self.pending_key_text.is_none() + { + ImeFilterOutcome::ConsumeForIme + } else { + ImeFilterOutcome::ForwardToGhostty + } + } + + pub fn take_event_text(&mut self, fallback: Option) -> Option { + match self.pending_key_text.take() { + Some(text) => CString::new(text).ok(), + None => fallback, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ime_state_consumes_composing_key_events() { + let mut state = TerminalImeState::default(); + state.preedit_changed(); + state.begin_key_event(); + + assert_eq!(state.filter_outcome(true), ImeFilterOutcome::ConsumeForIme); + + state.finish_key_event(); + assert_eq!(state.key_event_phase, ImeKeyEventPhase::Idle); + } + + #[test] + fn ime_state_buffers_plain_commit_for_key_event_text() { + let mut state = TerminalImeState::default(); + state.begin_key_event(); + + assert_eq!(state.commit_text("a"), ImeCommitOutcome::BufferForKeyEvent); + assert_eq!( + state.filter_outcome(true), + ImeFilterOutcome::ForwardToGhostty + ); + + let text = state + .take_event_text(None) + .and_then(|text| text.into_string().ok()); + assert_eq!(text.as_deref(), Some("a")); + } + + #[test] + fn ime_state_commits_composed_text_outside_key_event() { + let mut state = TerminalImeState::default(); + state.preedit_changed(); + + assert_eq!( + state.commit_text("á"), + ImeCommitOutcome::CommitDirectly("á".to_string()) + ); + assert!(!state.composing); + } + + /// Dead-key press path: the simple-compose fallback claims the + /// event (`filter_keypress` → true) without ever emitting a + /// preedit-changed signal, and no commit fires on this keystroke. + /// We must still consume the event so the raw dead-key glyph + /// (`^`, `¨`, `~`, …) never reaches ghostty. + #[test] + fn ime_state_consumes_dead_key_press_without_preedit() { + let mut state = TerminalImeState::default(); + state.begin_key_event(); + + assert_eq!(state.filter_outcome(true), ImeFilterOutcome::ConsumeForIme); + } + + /// Dead-key follow-up path: on the second keystroke of a compose + /// sequence (e.g. `e` after `^`), the simple-compose fallback + /// synchronously emits the commit signal with the composed glyph + /// before `filter_keypress` returns. We buffer that text, forward + /// the key event to ghostty, and use the buffered glyph in place + /// of the key's own text payload. + #[test] + fn ime_state_buffers_compose_commit_synchronously() { + let mut state = TerminalImeState::default(); + state.begin_key_event(); + + assert_eq!(state.commit_text("ê"), ImeCommitOutcome::BufferForKeyEvent); + assert_eq!( + state.filter_outcome(true), + ImeFilterOutcome::ForwardToGhostty + ); + + let text = state + .take_event_text(None) + .and_then(|text| text.into_string().ok()); + assert_eq!(text.as_deref(), Some("ê")); + } + + /// Full state-machine trace for `^` then `e` → `ê`, the scenario + /// the user originally reported. Mirrors what `filter_key_event` + /// drives through `TerminalImeState` when the simple-compose + /// fallback handles both keystrokes. + #[test] + fn state_machine_compose_trace_caret_e_to_e_circumflex() { + let mut state = TerminalImeState::default(); + + // ---- Keystroke 1: dead circumflex ----------------------- + // begin: not yet composing → phase NotComposing. + state.begin_key_event(); + assert_eq!(state.key_event_phase, ImeKeyEventPhase::NotComposing); + // Fallback claims, no commit signal fires on this keystroke. + // filter_outcome with im_handled=true must consume so the raw + // `^` glyph never reaches ghostty. + assert_eq!(state.filter_outcome(true), ImeFilterOutcome::ConsumeForIme); + state.finish_key_event(); + + // ---- Keystroke 2: `e` ---------------------------------- + // Latch is still set externally; state machine doesn't know. + // begin: composing is still false (no preedit was emitted by + // the simple module), so phase is NotComposing. + state.begin_key_event(); + assert_eq!(state.key_event_phase, ImeKeyEventPhase::NotComposing); + // Compose completes synchronously inside filter_keypress — + // the commit signal fires with "ê" while we're still + // processing this key event, so commit_text buffers it. + assert_eq!(state.commit_text("ê"), ImeCommitOutcome::BufferForKeyEvent); + // filter_keypress returns true; we forward the key event + // (with `text = "ê"`) to ghostty rather than consume it. + assert_eq!( + state.filter_outcome(true), + ImeFilterOutcome::ForwardToGhostty + ); + let composed = state + .take_event_text(None) + .and_then(|text| text.into_string().ok()); + assert_eq!(composed.as_deref(), Some("ê")); + state.finish_key_event(); + } +} diff --git a/rust/limux-host-linux/src/main.rs b/rust/limux-host-linux/src/main.rs index 5440df6b..90c53d4a 100644 --- a/rust/limux-host-linux/src/main.rs +++ b/rust/limux-host-linux/src/main.rs @@ -1,6 +1,7 @@ mod app_config; mod control_bridge; mod ghostty_config; +mod ime; mod keybind_editor; mod layout_state; mod pane; diff --git a/rust/limux-host-linux/src/terminal.rs b/rust/limux-host-linux/src/terminal.rs index 4bd00101..47c871ba 100644 --- a/rust/limux-host-linux/src/terminal.rs +++ b/rust/limux-host-linux/src/terminal.rs @@ -73,92 +73,6 @@ struct ClipboardContext { surface: Cell, } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -enum ImeKeyEventPhase { - #[default] - Idle, - NotComposing, - Composing, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -struct TerminalImeState { - composing: bool, - key_event_phase: ImeKeyEventPhase, - pending_key_text: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum ImeCommitOutcome { - BufferForKeyEvent, - CommitDirectly(String), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ImeFilterOutcome { - ForwardToGhostty, - ConsumeForIme, -} - -impl TerminalImeState { - fn begin_key_event(&mut self) { - self.key_event_phase = if self.composing { - ImeKeyEventPhase::Composing - } else { - ImeKeyEventPhase::NotComposing - }; - self.pending_key_text = None; - } - - fn finish_key_event(&mut self) { - self.key_event_phase = ImeKeyEventPhase::Idle; - self.pending_key_text = None; - } - - fn preedit_changed(&mut self) { - self.composing = true; - } - - fn preedit_ended(&mut self) { - self.composing = false; - } - - fn commit_text(&mut self, text: &str) -> ImeCommitOutcome { - match self.key_event_phase { - ImeKeyEventPhase::Idle | ImeKeyEventPhase::Composing => { - self.composing = false; - ImeCommitOutcome::CommitDirectly(text.to_string()) - } - ImeKeyEventPhase::NotComposing => { - self.pending_key_text = Some(text.to_string()); - ImeCommitOutcome::BufferForKeyEvent - } - } - } - - fn filter_outcome(&self, im_handled: bool) -> ImeFilterOutcome { - if !im_handled { - return ImeFilterOutcome::ForwardToGhostty; - } - - if self.composing - || self.key_event_phase == ImeKeyEventPhase::Composing - || self.pending_key_text.is_none() - { - ImeFilterOutcome::ConsumeForIme - } else { - ImeFilterOutcome::ForwardToGhostty - } - } - - fn take_event_text(&mut self, fallback: Option) -> Option { - match self.pending_key_text.take() { - Some(text) => CString::new(text).ok(), - None => fallback, - } - } -} - thread_local! { static SURFACE_MAP: RefCell> = RefCell::new(HashMap::new()); } @@ -437,67 +351,6 @@ fn refresh_realized_surface_display(surface: ghostty_surface_t, gl_area: >k::G refresh_surface_display(surface, gl_area); } -fn clear_ghostty_preedit(surface: ghostty_surface_t) { - unsafe { ghostty_surface_preedit(surface, ptr::null(), 0) }; -} - -fn update_ime_cursor_location(surface: ghostty_surface_t, im_context: >k::IMMulticontext) { - let mut x = 0.0; - let mut y = 0.0; - let mut width = 1.0; - let mut height = 1.0; - unsafe { - ghostty_surface_ime_point(surface, &mut x, &mut y, &mut width, &mut height); - } - im_context.set_cursor_location(>k::gdk::Rectangle::new( - x.round() as i32, - y.round() as i32, - width.max(1.0).round() as i32, - height.max(1.0).round() as i32, - )); -} - -fn update_ghostty_preedit( - surface_cell: &Rc>>, - im_context: >k::IMMulticontext, -) { - let Some(surface) = *surface_cell.borrow() else { - return; - }; - - let (preedit, _, cursor_pos) = im_context.preedit_string(); - if preedit.is_empty() { - clear_ghostty_preedit(surface); - return; - } - - if let Ok(text) = CString::new(preedit.as_str()) { - unsafe { - ghostty_surface_preedit(surface, text.as_ptr(), cursor_pos.max(0) as usize); - } - } -} - -fn send_committed_text(surface: ghostty_surface_t, text: &str) { - let Ok(c_text) = CString::new(text) else { - return; - }; - - let event = ghostty_input_key_s { - action: GHOSTTY_ACTION_PRESS, - mods: GHOSTTY_MODS_NONE, - consumed_mods: GHOSTTY_MODS_NONE, - keycode: 0, - text: c_text.as_ptr(), - unshifted_codepoint: 0, - composing: false, - }; - - unsafe { - ghostty_surface_key(surface, event); - } -} - fn load_ghostty_config() -> ghostty_config_t { unsafe { let config = ghostty_config_new(); @@ -1179,10 +1032,11 @@ pub fn create_terminal( search_bar.set_margin_end(8); overlay.add_overlay(&search_bar); - let im_context = gtk::IMMulticontext::new(); - im_context.set_client_widget(Some(&gl_area)); - im_context.set_use_preedit(true); - let ime_state = Rc::new(RefCell::new(TerminalImeState::default())); + let pane_ime = Rc::new(crate::ime::create_pane_ime(&gl_area, &surface_cell)); + // Aliases for the focus / lifecycle wiring further down. Cheap + // GObject clones, not state copies. + let im_context = pane_ime.primary.clone(); + let im_fallback = pane_ime.fallback.clone(); let handle = TerminalHandle { surface_cell: surface_cell.clone(), @@ -1215,44 +1069,6 @@ pub fn create_terminal( handle.hide_find(); }); } - { - let surface_cell = surface_cell.clone(); - let im_context = im_context.clone(); - let im_context_for_signal = im_context.clone(); - let ime_state = ime_state.clone(); - im_context_for_signal.connect_preedit_changed(move |_| { - ime_state.borrow_mut().preedit_changed(); - update_ghostty_preedit(&surface_cell, &im_context); - }); - } - { - let surface_cell = surface_cell.clone(); - let ime_state = ime_state.clone(); - im_context.connect_preedit_end(move |_| { - ime_state.borrow_mut().preedit_ended(); - let Some(surface) = *surface_cell.borrow() else { - return; - }; - clear_ghostty_preedit(surface); - }); - } - { - let surface_cell = surface_cell.clone(); - let ime_state = ime_state.clone(); - im_context.connect_commit(move |_, text| { - let Some(surface) = *surface_cell.borrow() else { - return; - }; - - match ime_state.borrow_mut().commit_text(text) { - ImeCommitOutcome::BufferForKeyEvent => {} - ImeCommitOutcome::CommitDirectly(text) => { - clear_ghostty_preedit(surface); - send_committed_text(surface, &text); - } - } - }); - } { let surface_cell = surface_cell.clone(); let scrollbar_syncing = scrollbar_syncing.clone(); @@ -1505,10 +1321,8 @@ pub fn create_terminal( { let sc_press = surface_cell.clone(); let sc_release = surface_cell.clone(); - let im_context_press = im_context.clone(); - let im_context_release = im_context.clone(); - let ime_state_press = ime_state.clone(); - let ime_state_release = ime_state.clone(); + let pane_ime_press = pane_ime.clone(); + let pane_ime_release = pane_ime.clone(); let key_controller = gtk::EventControllerKey::new(); key_controller.connect_key_pressed(move |ctrl, keyval, keycode, modifier| { if let Some(surface) = *sc_press.borrow() { @@ -1519,19 +1333,12 @@ pub fn create_terminal( let fallback_text = key_event_text(keyval); if let Some(current_event) = current_event.as_ref() { - { - let mut ime_state = ime_state_press.borrow_mut(); - ime_state.begin_key_event(); - } + pane_ime_press.state.borrow_mut().begin_key_event(); - update_ime_cursor_location(surface, &im_context_press); - let im_handled = im_context_press.filter_keypress(current_event); - let filter_outcome = { - let ime_state = ime_state_press.borrow(); - ime_state.filter_outcome(im_handled) - }; - if filter_outcome == ImeFilterOutcome::ConsumeForIme { - ime_state_press.borrow_mut().finish_key_event(); + let filter_outcome = + crate::ime::filter_key_event(surface, &pane_ime_press, current_event); + if filter_outcome == crate::ime::ImeFilterOutcome::ConsumeForIme { + pane_ime_press.state.borrow_mut().finish_key_event(); return glib::Propagation::Stop; } } @@ -1544,17 +1351,19 @@ pub fn create_terminal( keycode, modifier, ); - let c_text = ime_state_press.borrow_mut().take_event_text(fallback_text); + let c_text = pane_ime_press + .state + .borrow_mut() + .take_event_text(fallback_text); if let Some(ref ct) = c_text { event.text = ct.as_ptr(); } let consumed = unsafe { ghostty_surface_key(surface, event) }; - if consumed && ime_state_press.borrow().composing { - im_context_press.reset(); - clear_ghostty_preedit(surface); + if consumed && pane_ime_press.state.borrow().composing { + crate::ime::reset_after_consumed_compose(surface, &pane_ime_press); } - ime_state_press.borrow_mut().finish_key_event(); + pane_ime_press.state.borrow_mut().finish_key_event(); if consumed { return glib::Propagation::Stop; } @@ -1570,19 +1379,12 @@ pub fn create_terminal( let widget = ctrl.widget(); if let Some(current_event) = current_event.as_ref() { - { - let mut ime_state = ime_state_release.borrow_mut(); - ime_state.begin_key_event(); - } + pane_ime_release.state.borrow_mut().begin_key_event(); - update_ime_cursor_location(surface, &im_context_release); - let im_handled = im_context_release.filter_keypress(current_event); - let filter_outcome = { - let ime_state = ime_state_release.borrow(); - ime_state.filter_outcome(im_handled) - }; - if filter_outcome == ImeFilterOutcome::ConsumeForIme { - ime_state_release.borrow_mut().finish_key_event(); + let filter_outcome = + crate::ime::filter_key_event(surface, &pane_ime_release, current_event); + if filter_outcome == crate::ime::ImeFilterOutcome::ConsumeForIme { + pane_ime_release.state.borrow_mut().finish_key_event(); return; } } @@ -1596,7 +1398,7 @@ pub fn create_terminal( modifier, ); unsafe { ghostty_surface_key(surface, event) }; - ime_state_release.borrow_mut().finish_key_event(); + pane_ime_release.state.borrow_mut().finish_key_event(); } }); @@ -1730,11 +1532,14 @@ pub fn create_terminal( let had_focus_leave = had_focus.clone(); let im_context_enter = im_context.clone(); let im_context_leave = im_context.clone(); + let im_fallback_enter = im_fallback.clone(); + let im_fallback_leave = im_fallback.clone(); let focus_ctrl = gtk::EventControllerFocus::new(); let sc = surface_cell.clone(); focus_ctrl.connect_enter(move |_| { had_focus_enter.set(true); im_context_enter.focus_in(); + im_fallback_enter.focus_in(); if let Some(surface) = *sc.borrow() { unsafe { ghostty_surface_set_focus(surface, true) }; } @@ -1742,6 +1547,7 @@ pub fn create_terminal( focus_ctrl.connect_leave(move |_| { had_focus_leave.set(false); im_context_leave.focus_out(); + im_fallback_leave.focus_out(); if let Some(surface) = *surface_cell.borrow() { unsafe { ghostty_surface_set_focus(surface, false) }; } @@ -1795,8 +1601,10 @@ pub fn create_terminal( let surface_cell = surface_cell.clone(); let clipboard_context_cell = clipboard_context_cell.clone(); let im_context = im_context.clone(); + let im_fallback = im_fallback.clone(); overlay.connect_destroy(move |_| { im_context.set_client_widget(gtk::Widget::NONE); + im_fallback.set_client_widget(gtk::Widget::NONE); if let Some(surface) = surface_cell.borrow_mut().take() { let surface_key = surface as usize; SURFACE_MAP.with(|map| { @@ -2352,55 +2160,6 @@ mod tests { assert!(key_event_text(gtk::gdk::Key::BackSpace).is_none()); } - #[test] - fn ime_state_consumes_composing_key_events() { - let mut state = TerminalImeState::default(); - state.preedit_changed(); - state.begin_key_event(); - - assert_eq!(state.filter_outcome(true), ImeFilterOutcome::ConsumeForIme); - - state.finish_key_event(); - assert_eq!(state.key_event_phase, ImeKeyEventPhase::Idle); - } - - #[test] - fn ime_state_buffers_plain_commit_for_key_event_text() { - let mut state = TerminalImeState::default(); - state.begin_key_event(); - - assert_eq!(state.commit_text("a"), ImeCommitOutcome::BufferForKeyEvent); - assert_eq!( - state.filter_outcome(true), - ImeFilterOutcome::ForwardToGhostty - ); - - let text = state - .take_event_text(None) - .and_then(|text| text.into_string().ok()); - assert_eq!(text.as_deref(), Some("a")); - } - - #[test] - fn ime_state_commits_composed_text_outside_key_event() { - let mut state = TerminalImeState::default(); - state.preedit_changed(); - - assert_eq!( - state.commit_text("á"), - ImeCommitOutcome::CommitDirectly("á".to_string()) - ); - assert!(!state.composing); - } - - #[test] - fn ime_state_consumes_handled_events_without_text() { - let mut state = TerminalImeState::default(); - state.begin_key_event(); - - assert_eq!(state.filter_outcome(true), ImeFilterOutcome::ConsumeForIme); - } - #[test] fn shell_escape_preserves_simple_paths() { assert_eq!(