From 1dec5afe330c676e4f2b392d876789b02b2b896a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1niel=20Sipka?= Date: Sat, 20 Jun 2026 18:00:26 +0900 Subject: [PATCH 1/3] Fix IME composition key routing --- build.zig | 2 +- src/ime_state.zig | 78 +++++++++++++++++++++++++++++++++++++++ src/pane.zig | 93 +++++++++++++++++++++++++++++++++++++---------- 3 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 src/ime_state.zig diff --git a/build.zig b/build.zig index 2db6478..58b37de 100644 --- a/build.zig +++ b/build.zig @@ -274,7 +274,7 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "Run unit tests"); // Standalone tests (no external dependencies) - for ([_][]const u8{ "src/osc_parser.zig", "src/port_scan.zig" }) |src| { + for ([_][]const u8{ "src/ime_state.zig", "src/osc_parser.zig", "src/port_scan.zig" }) |src| { const mod = b.createModule(.{ .root_source_file = b.path(src), .target = target, diff --git a/src/ime_state.zig b/src/ime_state.zig new file mode 100644 index 0000000..933dfbc --- /dev/null +++ b/src/ime_state.zig @@ -0,0 +1,78 @@ +const std = @import("std"); + +pub const InKeyEvent = enum { none, composing, not_composing }; + +pub const CommitRoute = enum { + associate_with_key, + send_direct, +}; + +pub const FilterDecision = enum { + continue_key_event, + consume, +}; + +pub fn beginKeyEvent(im_composing: bool) InKeyEvent { + return if (im_composing) .composing else .not_composing; +} + +pub fn commitRoute(in_keyevent: InKeyEvent) CommitRoute { + return switch (in_keyevent) { + .not_composing => .associate_with_key, + .none, .composing => .send_direct, + }; +} + +pub fn filterDecision( + im_handled: bool, + im_composing: bool, + in_keyevent: InKeyEvent, + im_len: usize, +) FilterDecision { + if (!im_handled) return .continue_key_event; + if (im_composing) return .consume; + if (in_keyevent == .composing) return .consume; + if (im_len == 0) return .consume; + return .continue_key_event; +} + +test "IME state: plain key commit stays associated with key event" { + const in_keyevent = beginKeyEvent(false); + try std.testing.expectEqual(InKeyEvent.not_composing, in_keyevent); + try std.testing.expectEqual(CommitRoute.associate_with_key, commitRoute(in_keyevent)); + try std.testing.expectEqual( + FilterDecision.continue_key_event, + filterDecision(true, false, in_keyevent, 1), + ); +} + +test "IME state: Korean composition commit is sent directly and consumes key" { + const in_keyevent = beginKeyEvent(true); + try std.testing.expectEqual(InKeyEvent.composing, in_keyevent); + try std.testing.expectEqual(CommitRoute.send_direct, commitRoute(in_keyevent)); + try std.testing.expectEqual( + FilterDecision.consume, + filterDecision(true, false, in_keyevent, 0), + ); +} + +test "IME state: active preedit consumes key without encoding" { + try std.testing.expectEqual( + FilterDecision.consume, + filterDecision(true, true, .not_composing, 0), + ); +} + +test "IME state: handled empty event is consumed" { + try std.testing.expectEqual( + FilterDecision.consume, + filterDecision(true, false, .not_composing, 0), + ); +} + +test "IME state: unhandled event continues to normal key encoding" { + try std.testing.expectEqual( + FilterDecision.continue_key_event, + filterDecision(false, false, .not_composing, 0), + ); +} diff --git a/src/pane.zig b/src/pane.zig index 23fe877..00c3a05 100644 --- a/src/pane.zig +++ b/src/pane.zig @@ -3,6 +3,7 @@ const c = @import("c.zig").c; const config_mod = @import("config.zig"); const SearchOverlay = @import("search_overlay.zig").SearchOverlay; const ghostty_bridge = @import("ghostty_bridge.zig"); +const ime_state = @import("ime_state.zig"); pub const Pane = struct { pub const cwd_cap = 512; @@ -24,9 +25,10 @@ pub const Pane = struct { // IME state im_context: ?*c.GtkIMContext = null, im_composing: bool = false, - im_buf: [32]u8 = undefined, + im_focused: bool = false, + im_buf: [256]u8 = undefined, im_len: usize = 0, - in_keyevent: bool = false, + in_keyevent: ime_state.InKeyEvent = .none, surface_initialized: bool = false, pending_init_width: u32 = 0, pending_init_height: u32 = 0, @@ -221,6 +223,7 @@ pub const Pane = struct { if (self.gl_area) |gl| { _ = c.gtk_widget_grab_focus(@as(*c.GtkWidget, @ptrCast(gl))); } + self.focusImContext(); c.gtk_widget_remove_css_class(self.widget, "pane-unfocused"); c.gtk_widget_add_css_class(self.widget, "pane-focused"); @@ -243,6 +246,7 @@ pub const Pane = struct { } pub fn unfocus(self: *Pane) void { + self.unfocusImContext(); c.gtk_widget_remove_css_class(self.widget, "pane-focused"); if (self.surface) |s| { c.ghostty_surface_set_focus(s, false); @@ -254,6 +258,29 @@ pub const Pane = struct { } } + fn focusImContext(self: *Pane) void { + if (self.im_focused) return; + const ctx = self.im_context orelse return; + if (self.gl_area) |gl| { + c.gtk_im_context_set_client_widget(@ptrCast(ctx), @as(*c.GtkWidget, @ptrCast(gl))); + } + c.gtk_im_context_focus_in(@ptrCast(ctx)); + self.im_focused = true; + } + + fn unfocusImContext(self: *Pane) void { + if (!self.im_focused) return; + const ctx = self.im_context orelse { + self.im_focused = false; + return; + }; + c.gtk_im_context_focus_out(@ptrCast(ctx)); + c.gtk_im_context_reset(@ptrCast(ctx)); + self.im_composing = false; + self.im_len = 0; + self.im_focused = false; + } + pub fn clearScrollback(self: *Pane) void { if (self.surface) |s| { // Send clear screen escape sequences through ghostty @@ -459,6 +486,7 @@ fn setupInputControllers(gl_area_widget: *c.GtkWidget, pane: *Pane) void { // Focus controller const focus_ctrl = c.gtk_event_controller_focus_new(); connectSignal(focus_ctrl, "enter", &onFocusEnter, pane); + connectSignal(focus_ctrl, "leave", &onFocusLeave, pane); c.gtk_widget_add_controller(gl_area_widget, @ptrCast(focus_ctrl)); } @@ -529,6 +557,9 @@ fn onGlRealize(_: *c.GtkGLArea, user_data: c.gpointer) callconv(.c) void { // Set up IM context with the widget if (pane.im_context) |ctx| { c.gtk_im_context_set_client_widget(@ptrCast(ctx), @as(*c.GtkWidget, @ptrCast(gl_area))); + if (c.gtk_widget_has_focus(@as(*c.GtkWidget, @ptrCast(gl_area))) != 0) { + pane.focusImContext(); + } } } @@ -550,6 +581,7 @@ fn onGlUnrealize(_: *c.GtkGLArea, user_data: c.gpointer) callconv(.c) void { } } + pane.unfocusImContext(); if (pane.im_context) |ctx| { c.gtk_im_context_set_client_widget(@ptrCast(ctx), null); } @@ -911,20 +943,17 @@ fn handleKeyEvent( ) bool { const surface = pane.surface orelse return false; const event = c.gtk_event_controller_get_current_event(@ptrCast(controller)); + defer pane.im_len = 0; // IME handling if (pane.im_context) |ctx| { - const was_composing = pane.im_composing; - pane.in_keyevent = true; - defer pane.in_keyevent = false; + pane.in_keyevent = ime_state.beginKeyEvent(pane.im_composing); + defer pane.in_keyevent = .none; const im_handled = c.gtk_im_context_filter_keypress(@ptrCast(ctx), event) != 0; - defer pane.im_len = 0; - if (im_handled) { - if (pane.im_composing) return true; - if (was_composing) return true; - if (pane.im_len == 0) return true; + if (ime_state.filterDecision(im_handled, pane.im_composing, pane.in_keyevent, pane.im_len) == .consume) { + return true; } } @@ -980,16 +1009,32 @@ fn onImCommit(_: *c.GtkIMContext, text: [*:0]const u8, user_data: c.gpointer) ca const pane: *Pane = @ptrCast(@alignCast(user_data)); const text_slice = std.mem.sliceTo(text, 0); - if (pane.in_keyevent) { - // Store for association with the key event - const len = @min(text_slice.len, pane.im_buf.len - 1); - @memcpy(pane.im_buf[0..len], text_slice[0..len]); - pane.im_len = len; - } else { - // Outside key event: send directly to ghostty - if (pane.surface) |s| { - c.ghostty_surface_text(s, text, text_slice.len); - } + switch (ime_state.commitRoute(pane.in_keyevent)) { + .associate_with_key => { + // Plain key translations such as "a" should stay associated with + // the key event so Ghostty still sees the physical key metadata. + const len = @min(text_slice.len, pane.im_buf.len - 1); + @memcpy(pane.im_buf[0..len], text_slice[0..len]); + pane.im_len = len; + return; + }, + .send_direct => {}, + } + + pane.im_composing = false; + if (pane.surface) |s| { + c.ghostty_surface_preedit(s, null, 0); + + const ev = c.ghostty_input_key_s{ + .action = c.GHOSTTY_ACTION_PRESS, + .mods = 0, + .consumed_mods = 0, + .keycode = 0, + .text = text, + .unshifted_codepoint = 0, + .composing = false, + }; + _ = c.ghostty_surface_key(s, ev); } } @@ -1000,6 +1045,7 @@ fn onImPreeditStart(_: *c.GtkIMContext, user_data: c.gpointer) callconv(.c) void fn onImPreeditChanged(ctx: *c.GtkIMContext, user_data: c.gpointer) callconv(.c) void { const pane: *Pane = @ptrCast(@alignCast(user_data)); + pane.im_composing = true; if (pane.surface) |s| { var preedit_text: [*c]u8 = null; var cursor_pos: c.gint = 0; @@ -1177,6 +1223,8 @@ fn drawScrollbarCb( fn onFocusEnter(_: *c.GtkEventControllerFocus, user_data: c.gpointer) callconv(.c) void { const pane: *Pane = @ptrCast(@alignCast(user_data)); + pane.focusImContext(); + const Window = @import("window.zig"); const wm = Window.window_manager orelse return; const state = wm.findByWorkspaceId(pane.workspace_id) orelse return; @@ -1208,6 +1256,11 @@ fn onFocusEnter(_: *c.GtkEventControllerFocus, user_data: c.gpointer) callconv(. state.updateWindowTitle(); } +fn onFocusLeave(_: *c.GtkEventControllerFocus, user_data: c.gpointer) callconv(.c) void { + const pane: *Pane = @ptrCast(@alignCast(user_data)); + pane.unfocusImContext(); +} + fn onMouseEnter( _: *c.GtkEventControllerMotion, _: f64, From ffcbdcf9efc953f0815dcfde21e5391875c806c3 Mon Sep 17 00:00:00 2001 From: sg Date: Sat, 18 Jul 2026 10:35:54 +0900 Subject: [PATCH 2/3] Sync IME cursor location before CJK input --- src/pane.zig | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/pane.zig b/src/pane.zig index 00c3a05..9d447d9 100644 --- a/src/pane.zig +++ b/src/pane.zig @@ -911,6 +911,25 @@ fn addSidedMods(mods: *c_uint, keyval: c.guint, is_release: bool) void { } } +fn syncImCursorLocation(pane: *Pane) void { + const ctx = pane.im_context orelse return; + const surface = pane.surface orelse return; + + var x: f64 = 0; + var y: f64 = 0; + var width: f64 = 1; + var height: f64 = 1; + c.ghostty_surface_ime_point(surface, &x, &y, &width, &height); + + const rect = c.GdkRectangle{ + .x = @intFromFloat(x), + .y = @intFromFloat(y), + .width = @max(@as(c.gint, 1), @as(c.gint, @intFromFloat(width))), + .height = @max(@as(c.gint, 1), @as(c.gint, @intFromFloat(height))), + }; + c.gtk_im_context_set_cursor_location(@ptrCast(ctx), &rect); +} + fn onKeyPressed( controller: *c.GtkEventControllerKey, keyval: c.guint, @@ -950,6 +969,7 @@ fn handleKeyEvent( pane.in_keyevent = ime_state.beginKeyEvent(pane.im_composing); defer pane.in_keyevent = .none; + syncImCursorLocation(pane); const im_handled = c.gtk_im_context_filter_keypress(@ptrCast(ctx), event) != 0; if (ime_state.filterDecision(im_handled, pane.im_composing, pane.in_keyevent, pane.im_len) == .consume) { @@ -1121,6 +1141,7 @@ fn onMousePress( const mods = translateMods(gtk_mods); _ = c.ghostty_surface_mouse_button(surface, c.GHOSTTY_MOUSE_PRESS, button, mods); + syncImCursorLocation(pane); } fn onMouseRelease( @@ -1140,6 +1161,7 @@ fn onMouseRelease( const mods = translateMods(gtk_mods); _ = c.ghostty_surface_mouse_button(surface, c.GHOSTTY_MOUSE_RELEASE, button, mods); + syncImCursorLocation(pane); } fn onScroll( From f1a1d40520876289714c4ae391b4594fbc62bc9e Mon Sep 17 00:00:00 2001 From: sg Date: Sat, 22 Aug 2026 16:21:57 +0900 Subject: [PATCH 3/3] codex: use canonical hooks feature flag --- resources/bin/codex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/bin/codex b/resources/bin/codex index ac98266..0586c99 100755 --- a/resources/bin/codex +++ b/resources/bin/codex @@ -88,7 +88,7 @@ export SEANCE_CODEX_PID=$$ # ── Launch codex (no exec — wrapper must survive for cleanup) ─────────────── -CODEX_HOME="$SESSION_DIR" "$REAL_CODEX" --enable codex_hooks "$@" +CODEX_HOME="$SESSION_DIR" "$REAL_CODEX" --enable hooks "$@" rc=$? # ── Session-end cleanup ─────────────────────────────────────────────────────