Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand All @@ -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.

Expand Down
35 changes: 35 additions & 0 deletions rust/limux-host-linux/src/ime.rs
Original file line number Diff line number Diff line change
@@ -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;
229 changes: 229 additions & 0 deletions rust/limux-host-linux/src/ime/contexts.rs
Original file line number Diff line number Diff line change
@@ -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<RefCell<TerminalImeState>>,
/// 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<Cell<bool>>,
}

/// 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: &gtk::GLArea,
surface_cell: &Rc<RefCell<Option<ghostty_surface_t>>>,
) -> 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: &gtk::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: &gtk::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(&gtk::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<RefCell<Option<ghostty_surface_t>>>,
im_context: &gtk::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: &gtk::IMContext,
surface_cell: &Rc<RefCell<Option<ghostty_surface_t>>>,
ime_state: &Rc<RefCell<TerminalImeState>>,
) {
{
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);
}
}
});
}
}
Loading