Last active
September 7, 2026 03:12
-
-
Save shelaf/23ec3d22269afbe623477b27774aeb39 to your computer and use it in GitHub Desktop.
candidate window pos. fix + show preedit cursor patch for Ghostty
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/src/Surface.zig b/src/Surface.zig | |
| index 775292127..25620cb0a 100644 | |
| --- a/src/Surface.zig | |
| +++ b/src/Surface.zig | |
| @@ -1861,29 +1861,38 @@ fn recomputeInitialSize( | |
| if (self.config.window_height <= 0 or | |
| self.config.window_width <= 0) return; | |
| - const scale = self.rt_surface.getContentScale() catch | |
| + // Our cell size and padding are in rendered pixels but the apprt wants | |
| + // its own coordinate space, so we convert with the device pixel ratio. | |
| + // Not the content scale: an apprt may fold a font DPI scale into that | |
| + // (GTK adds gtk-xft-dpi) and that part isn't a coordinate difference. | |
| + const dpr = self.rt_surface.getDevicePixelRatio() catch | |
| return error.ContentScaleUnavailable; | |
| + | |
| + // We use the configured padding instead of `size.padding` because the | |
| + // latter may have been balanced against the current window size, which | |
| + // is the size we're replacing. | |
| + const content_scale = self.rt_surface.getContentScale() catch | |
| + return error.ContentScaleUnavailable; | |
| + const padding = self.config.scaledPadding( | |
| + content_scale.x * font.face.default_dpi, | |
| + content_scale.y * font.face.default_dpi, | |
| + ); | |
| + | |
| + // Padding is in rendered pixels too, so it must be included before the | |
| + // conversion to get the exact correct grid size. | |
| const height = @max( | |
| self.config.window_height, | |
| min_window_height_cells, | |
| - ) * self.size.cell.height; | |
| + ) * self.size.cell.height + padding.top + padding.bottom; | |
| const width = @max( | |
| self.config.window_width, | |
| min_window_width_cells, | |
| - ) * self.size.cell.width; | |
| + ) * self.size.cell.width + padding.left + padding.right; | |
| const width_f32: f32 = @floatFromInt(width); | |
| const height_f32: f32 = @floatFromInt(height); | |
| - // The final values are affected by content scale and we need to | |
| - // account for the padding so we get the exact correct grid size. | |
| - const final_width: u32 = | |
| - @as(u32, @intFromFloat(@ceil(width_f32 / scale.x))) + | |
| - self.size.padding.left + | |
| - self.size.padding.right; | |
| - const final_height: u32 = | |
| - @as(u32, @intFromFloat(@ceil(height_f32 / scale.y))) + | |
| - self.size.padding.top + | |
| - self.size.padding.bottom; | |
| + const final_width: u32 = @intFromFloat(@ceil(width_f32 / dpr)); | |
| + const final_height: u32 = @intFromFloat(@ceil(height_f32 / dpr)); | |
| _ = self.rt_app.performAction( | |
| .{ .surface = self }, | |
| @@ -2117,17 +2126,33 @@ fn resolvePathForOpening( | |
| /// Returns the x/y coordinate of where the IME (Input Method Editor) | |
| /// keyboard should be rendered. | |
| +/// | |
| +/// `y` is the *bottom* edge of the cursor cell, per macOS's bottom-left | |
| +/// origin; top-left origin apprts (e.g. GTK) want `y - height`. | |
| +/// | |
| +/// `x`, `y`, `height` and `caret_x` are in the apprt's coordinate space, | |
| +/// i.e. divided by the device pixel ratio. `width` is not, see below. | |
| pub fn imePoint(self: *const Surface) apprt.IMEPos { | |
| + const cols = self.size.grid().columns; | |
| + | |
| self.renderer_state.mutex.lockUncancelable(global.io()); | |
| const cursor = self.renderer_state.terminal.screens.active.cursor; | |
| - const preedit_width: usize = if (self.renderer_state.preedit) |preedit| preedit.width() else 0; | |
| + const preedit_width: usize, const caret_cell: usize = preedit: { | |
| + const preedit = self.renderer_state.preedit orelse | |
| + break :preedit .{ 0, cursor.x }; | |
| + break :preedit .{ | |
| + preedit.width(), | |
| + preedit.caretCell(cursor.x, cols -| 1), | |
| + }; | |
| + }; | |
| self.renderer_state.mutex.unlock(global.io()); | |
| // TODO: need to handle when scrolling and the cursor is not | |
| // in the visible portion of the screen. | |
| - // Our sizes are all scaled so we need to send the unscaled values back. | |
| - const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 }; | |
| + // Our sizes are all in rendered pixels so we need to convert them back | |
| + // to the apprt's coordinate space. | |
| + const dpr: f64 = self.rt_surface.getDevicePixelRatio() catch 1; | |
| const x: f64 = x: { | |
| // Simple x * cell width gives the top-left corner, then add padding offset | |
| @@ -2137,7 +2162,7 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { | |
| x += @as(f64, @floatFromInt(self.size.cell.width)) / 2; | |
| // And scale it | |
| - x /= content_scale.x; | |
| + x /= dpr; | |
| break :x x; | |
| }; | |
| @@ -2150,7 +2175,7 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { | |
| y += @floatFromInt(self.size.cell.height); | |
| // And scale it | |
| - y /= content_scale.y; | |
| + y /= dpr; | |
| break :y y; | |
| }; | |
| @@ -2159,9 +2184,19 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { | |
| // rendering only renders in a single line. | |
| const height: f64 = height: { | |
| var height: f64 = @floatFromInt(self.size.cell.height); | |
| - height /= content_scale.y; | |
| + height /= dpr; | |
| break :height height; | |
| }; | |
| + const caret_x: f64 = caret_x: { | |
| + // No midpoint here unlike `x`: the caret is a boundary between | |
| + // cells, so an apprt anchoring to it wants the cell edge. | |
| + var caret_x: f64 = @floatFromInt( | |
| + caret_cell * self.size.cell.width + self.size.padding.left, | |
| + ); | |
| + caret_x /= dpr; | |
| + break :caret_x caret_x; | |
| + }; | |
| + | |
| const width: f64 = width: { | |
| var width: f64 = @floatFromInt(preedit_width * self.size.cell.width); | |
| @@ -2186,6 +2221,7 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { | |
| .y = y, | |
| .width = width, | |
| .height = height, | |
| + .caret_x = caret_x, | |
| }; | |
| } | |
| @@ -2536,7 +2572,10 @@ fn balancePaddingIfNeeded(self: *Surface) void { | |
| /// the preedit state correctly. | |
| /// | |
| /// The preedit input must be UTF-8 encoded. | |
| -pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { | |
| +pub fn preeditCallback( | |
| + self: *Surface, | |
| + preedit_: ?apprt.Preedit, | |
| +) !void { | |
| // log.debug("text preeditCallback value={any}", .{preedit_}); | |
| // Crash metadata in case we crash in here | |
| @@ -2568,10 +2607,11 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { | |
| // If we have no text, we're done. We queue a render in case we cleared | |
| // a prior preedit (likely). | |
| - const text = preedit_ orelse { | |
| + const preedit = preedit_ orelse { | |
| try self.queueRender(); | |
| return; | |
| }; | |
| + const text = preedit.text; | |
| // We convert the UTF-8 text to codepoints. | |
| const view = try std.unicode.Utf8View.init(text); | |
| @@ -2581,7 +2621,17 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { | |
| const Codepoint = rendererpkg.State.Preedit.Codepoint; | |
| var codepoints: std.ArrayList(Codepoint) = .empty; | |
| defer codepoints.deinit(self.alloc); | |
| - while (it.nextCodepoint()) |cp| { | |
| + | |
| + // The caret and target range are offsets into the codepoints of `text`, | |
| + // but we drop the zero-width ones below, so we translate the caret as we | |
| + // go. An apprt that doesn't report a caret gets one at the end of the | |
| + // preedit, which is where input methods put it while composing. | |
| + const caret_src = preedit.caret orelse std.math.maxInt(usize); | |
| + var caret: usize = 0; | |
| + var i: usize = 0; | |
| + while (it.nextCodepoint()) |cp| : (i += 1) { | |
| + if (i == caret_src) caret = codepoints.items.len; | |
| + | |
| const width: usize = @intCast(unicode.table.get(cp).width); | |
| // I've never seen a preedit text with a zero-width character. In | |
| @@ -2589,11 +2639,13 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { | |
| // Let's just ignore it. | |
| if (width <= 0) continue; | |
| - try codepoints.append( | |
| - self.alloc, | |
| - .{ .codepoint = cp, .wide = width >= 2 }, | |
| - ); | |
| + try codepoints.append(self.alloc, .{ | |
| + .codepoint = cp, | |
| + .wide = width >= 2, | |
| + .target = if (preedit.target) |t| i >= t.start and i < t.end else false, | |
| + }); | |
| } | |
| + if (caret_src >= i) caret = codepoints.items.len; | |
| // If we have no codepoints, then we're done. | |
| if (codepoints.items.len == 0) { | |
| @@ -2603,6 +2655,7 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { | |
| self.renderer_state.preedit = .{ | |
| .codepoints = try codepoints.toOwnedSlice(self.alloc), | |
| + .caret = caret, | |
| }; | |
| try self.queueRender(); | |
| } | |
| diff --git a/src/apprt.zig b/src/apprt.zig | |
| index 22256cc97..d328875b4 100644 | |
| --- a/src/apprt.zig | |
| +++ b/src/apprt.zig | |
| @@ -33,6 +33,7 @@ pub const ClipboardRequestType = structs.ClipboardRequestType; | |
| pub const ColorScheme = structs.ColorScheme; | |
| pub const CursorPos = structs.CursorPos; | |
| pub const IMEPos = structs.IMEPos; | |
| +pub const Preedit = structs.Preedit; | |
| pub const Selection = structs.Selection; | |
| pub const SurfaceSize = structs.SurfaceSize; | |
| diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig | |
| index 525bc856d..a9b5cdf07 100644 | |
| --- a/src/apprt/embedded.zig | |
| +++ b/src/apprt/embedded.zig | |
| @@ -688,6 +688,13 @@ pub const Surface = struct { | |
| return self.content_scale; | |
| } | |
| + /// The embedded apprt sets its content scale from the device pixel | |
| + /// ratio (macOS passes `backingScaleFactor`), so there is nothing else | |
| + /// folded into it to remove here. | |
| + pub fn getDevicePixelRatio(self: *const Surface) !f32 { | |
| + return self.content_scale.y; | |
| + } | |
| + | |
| pub fn getSize(self: *const Surface) !apprt.SurfaceSize { | |
| return self.size; | |
| } | |
| @@ -1120,7 +1127,12 @@ pub const Surface = struct { | |
| } | |
| pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) void { | |
| - _ = self.core_surface.preeditCallback(preedit_) catch |err| { | |
| + // No caret or target range: AppKit asks for the rect of a character | |
| + // range instead of reading a caret out of us (see | |
| + // `firstRect(forCharacterRange:)`). | |
| + _ = self.core_surface.preeditCallback(if (preedit_) |text| .{ | |
| + .text = text, | |
| + } else null) catch |err| { | |
| log.err("error in preedit callback err={}", .{err}); | |
| return; | |
| }; | |
| diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig | |
| index 4a59ab21d..1adbb5c78 100644 | |
| --- a/src/apprt/gtk/Surface.zig | |
| +++ b/src/apprt/gtk/Surface.zig | |
| @@ -49,6 +49,10 @@ pub fn getContentScale(self: *const Self) !apprt.ContentScale { | |
| return self.surface.getContentScale(); | |
| } | |
| +pub fn getDevicePixelRatio(self: *const Self) !f32 { | |
| + return self.surface.getDevicePixelRatio(); | |
| +} | |
| + | |
| pub fn getSize(self: *const Self) !apprt.SurfaceSize { | |
| return self.surface.getSize(); | |
| } | |
| diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig | |
| index 9d45bc6ce..126e9081b 100644 | |
| --- a/src/apprt/gtk/class/surface.zig | |
| +++ b/src/apprt/gtk/class/surface.zig | |
| @@ -7,6 +7,7 @@ const gio = @import("gio"); | |
| const glib = @import("glib"); | |
| const gobject = @import("gobject"); | |
| const gtk = @import("gtk"); | |
| +const pango = @import("pango"); | |
| const apprt = @import("../../../apprt.zig"); | |
| const build_config = @import("../../../build_config.zig"); | |
| @@ -21,6 +22,7 @@ const CoreSurface = @import("../../../Surface.zig"); | |
| const gresource = @import("../build/gresource.zig"); | |
| const ext = @import("../ext.zig"); | |
| const gsettings = @import("../gsettings.zig"); | |
| +const winprotopkg = @import("../winproto.zig"); | |
| const gtk_key = @import("../key.zig"); | |
| const ApprtSurface = @import("../Surface.zig"); | |
| const Common = @import("../class.zig").Common; | |
| @@ -659,6 +661,17 @@ pub const Surface = extern struct { | |
| in_keyevent: IMKeyEvent = .false, | |
| im_context: *gtk.IMMulticontext, | |
| im_composing: bool = false, | |
| + | |
| + /// State for keeping the input method's candidate window in place | |
| + /// while composing. See `imStartMoveWatch`. | |
| + im_move_timer: ?c_uint = null, | |
| + im_window_position: ?winprotopkg.Position = null, | |
| + im_width_nudge: bool = false, | |
| + | |
| + /// The last caret rect we gave the input method, so that repeated | |
| + /// updates for an unmoved caret cost nothing. See | |
| + /// `updateImeCursorLocation`. | |
| + im_cursor_rect: ?gdk.Rectangle = null, | |
| im_buf: [128]u8 = undefined, | |
| im_len: u7 = 0, | |
| @@ -1232,6 +1245,151 @@ pub const Surface = extern struct { | |
| return true; | |
| } | |
| + /// Notify the input method where the composition caret is so it can | |
| + /// place its candidate window against the text being typed. | |
| + /// | |
| + /// Pass `force` when the rect has to be sent even though it didn't | |
| + /// change: the window moved under X11 (the rect is widget relative, so a | |
| + /// move leaves it unchanged while the candidate window still has to | |
| + /// follow), or we need to be sure the input method has a rect at all, | |
| + /// such as on focus in. An input method that drops an unchanged rect | |
| + /// (fcitx5 does, in both its GTK module and its own core) needs the width | |
| + /// nudged to take it; the width itself doesn't place the candidate window. | |
| + /// | |
| + /// We send a rect whenever we can rather than only while composing: the | |
| + /// keystroke that turns an input method on is the first that input | |
| + /// method hears of us and it places its popup right away, so a rect | |
| + /// that only arrives with the composition is already too late. See | |
| + /// `imFlushCursorLocation` for how the rect actually gets out. | |
| + fn updateImeCursorLocation(self: *Self, force: bool) void { | |
| + const priv = self.private(); | |
| + const surface = priv.core_surface orelse return; | |
| + | |
| + // An unfocused surface has no input method to inform, and its caret | |
| + // can move for a long time (a build scrolling by in another split). | |
| + // Focusing in sends a rect of its own, so nothing goes stale here. | |
| + if (!priv.focused) return; | |
| + | |
| + const ime_point = surface.imePoint(); | |
| + | |
| + // `imePoint`'s `y` is the *bottom* edge of the cursor cell (macOS | |
| + // bottom-left origin), so shift it up for GTK's top-left origin. The | |
| + // rect covers the whole cell rather than a point because input methods | |
| + // flip the candidate window above it when there's no room below, and | |
| + // that flipped placement must clear the line being typed. | |
| + // | |
| + // The width is 1 rather than `ime_point.width`: that one is the | |
| + // preedit width in rendered pixels (it is deliberately left unscaled | |
| + // for macOS) and it is 0 when there is no preedit. | |
| + var rect: gdk.Rectangle = .{ | |
| + .f_x = @intFromFloat(@round(ime_point.caret_x)), | |
| + .f_y = @intFromFloat(@round(ime_point.y - ime_point.height)), | |
| + .f_width = 1, | |
| + .f_height = @max(1, @as(c_int, @intFromFloat(@round(ime_point.height)))), | |
| + }; | |
| + | |
| + // Nothing moved and nobody asked us to insist. We compare everything | |
| + // but the width because the width is where the nudge lives. | |
| + if (!force) { | |
| + if (priv.im_cursor_rect) |prev| { | |
| + if (prev.f_x == rect.f_x and | |
| + prev.f_y == rect.f_y and | |
| + prev.f_height == rect.f_height) return; | |
| + } | |
| + } else priv.im_width_nudge = !priv.im_width_nudge; | |
| + if (priv.im_width_nudge) rect.f_width = 2; | |
| + | |
| + priv.im_cursor_rect = rect; | |
| + priv.im_context.as(gtk.IMContext).setCursorLocation(&rect); | |
| + self.imFlushCursorLocation(); | |
| + } | |
| + | |
| + /// Make GTK's Wayland input method actually send the caret rect we just | |
| + /// gave it. | |
| + /// | |
| + /// That input method only *stores* the rect | |
| + /// `gtk_im_context_set_cursor_location` hands it. The rect reaches the | |
| + /// compositor from GTK's `notify_im_change`, which a client can only | |
| + /// trigger with `reset`, so without this the compositor keeps whatever | |
| + /// rect was current the last time the input method itself had something | |
| + /// to say: a popup opened by a keystroke lands at the caret from an | |
| + /// earlier keystroke, and one opened after output moved the caret lands | |
| + /// where the caret used to be. Newer GTK sends the rect from its frame | |
| + /// clock, where this call is redundant but harmless. | |
| + /// | |
| + /// On that context `reset` sends the input method state — surrounding | |
| + /// text, content type, caret rect — and nothing else, so it is a flush. | |
| + /// Every other input method reads `reset` as "cancel the composition", | |
| + /// so we only do this for GTK's own Wayland context, and only when | |
| + /// nothing is being composed. While composing we don't need it: the | |
| + /// compositor's `done` events make GTK flush the rect for us. | |
| + fn imFlushCursorLocation(self: *Self) void { | |
| + const priv = self.private(); | |
| + if (priv.im_composing) return; | |
| + | |
| + const id = std.mem.span(priv.im_context.getContextId()); | |
| + if (!std.mem.eql(u8, id, "wayland")) return; | |
| + | |
| + priv.im_context.as(gtk.IMContext).reset(); | |
| + } | |
| + | |
| + /// Watch for the window moving while an input method is composing. | |
| + /// | |
| + /// X11 has no notification for a window move (and GTK4 exposes none), so | |
| + /// we poll while there is a candidate window to keep in place. This is | |
| + /// only armed during composition, which is short and interactive. | |
| + fn imStartMoveWatch(self: *Self) void { | |
| + const priv = self.private(); | |
| + if (priv.im_move_timer != null) return; | |
| + | |
| + // Fast enough to follow a window drag without the poll itself | |
| + // being noticeable work. | |
| + const poll_ms = 100; | |
| + | |
| + priv.im_window_position = self.rootPosition(); | |
| + priv.im_move_timer = glib.timeoutAdd( | |
| + poll_ms, | |
| + imMoveWatch, | |
| + self, | |
| + ); | |
| + } | |
| + | |
| + fn imStopMoveWatch(self: *Self) void { | |
| + const priv = self.private(); | |
| + const timer = priv.im_move_timer orelse return; | |
| + if (glib.Source.remove(timer) == 0) { | |
| + log.warn("unable to remove im move timer", .{}); | |
| + } | |
| + priv.im_move_timer = null; | |
| + priv.im_window_position = null; | |
| + } | |
| + | |
| + fn imMoveWatch(ud: ?*anyopaque) callconv(.c) c_int { | |
| + const self: *Self = @ptrCast(@alignCast(ud.?)); | |
| + const priv = self.private(); | |
| + | |
| + const position = self.rootPosition() orelse { | |
| + // The protocol doesn't report window positions, so there is | |
| + // nothing for us to watch. | |
| + priv.im_move_timer = null; | |
| + return @intFromBool(glib.SOURCE_REMOVE); | |
| + }; | |
| + | |
| + const prev = priv.im_window_position; | |
| + priv.im_window_position = position; | |
| + if (prev) |p| if (p.x != position.x or p.y != position.y) { | |
| + self.updateImeCursorLocation(true); | |
| + }; | |
| + | |
| + return @intFromBool(glib.SOURCE_CONTINUE); | |
| + } | |
| + | |
| + fn rootPosition(self: *Self) ?winprotopkg.Position { | |
| + const root = self.as(gtk.Widget).getRoot() orelse return null; | |
| + const window = gobject.ext.cast(Window, root) orelse return null; | |
| + return window.winproto().rootPosition(); | |
| + } | |
| + | |
| /// Key press event (press or release). | |
| /// | |
| /// At a high level, we want to construct an `input.KeyEvent` and | |
| @@ -1282,15 +1440,7 @@ pub const Surface = extern struct { | |
| // This can trigger an input method so we need to notify the im context | |
| // where the cursor is so it can render the dropdowns in the correct | |
| // place. | |
| - if (priv.core_surface) |surface| { | |
| - const ime_point = surface.imePoint(); | |
| - priv.im_context.as(gtk.IMContext).setCursorLocation(&.{ | |
| - .f_x = @intFromFloat(ime_point.x), | |
| - .f_y = @intFromFloat(ime_point.y), | |
| - .f_width = 1, | |
| - .f_height = 1, | |
| - }); | |
| - } | |
| + self.updateImeCursorLocation(false); | |
| // We note that we're in a keypress because we want some logic to | |
| // depend on this. For example, we don't want to send character events | |
| @@ -1596,6 +1746,15 @@ pub const Surface = extern struct { | |
| return .{ .x = scale, .y = scale }; | |
| } | |
| + pub fn getDevicePixelRatio(self: *Self) f32 { | |
| + const scale = self.private().gl_area.as(gtk.Widget).getScaleFactor(); | |
| + if (scale <= 0) { | |
| + log.warn("gtk_widget_get_scale_factor returned a non-positive number: {}", .{scale}); | |
| + return 1.0; | |
| + } | |
| + return @floatFromInt(scale); | |
| + } | |
| + | |
| pub fn getSize(self: *Self) apprt.SurfaceSize { | |
| const priv = self.private(); | |
| // By the time this is called, we should be in a widget tree. | |
| @@ -1924,6 +2083,8 @@ pub const Surface = extern struct { | |
| priv.progress_bar_timer = null; | |
| } | |
| + self.imStopMoveWatch(); | |
| + | |
| if (priv.idle_rechild) |v| { | |
| if (glib.Source.remove(v) == 0) { | |
| log.warn("unable to remove idle source", .{}); | |
| @@ -2219,8 +2380,12 @@ pub const Surface = extern struct { | |
| const width_f32: f32 = @floatFromInt(width); | |
| const height_f32: f32 = @floatFromInt(height); | |
| - const final_width: u32 = @intFromFloat(@ceil(width_f32 / content_scale.x)); | |
| - const final_height: u32 = @intFromFloat(@ceil(height_f32 / content_scale.y)); | |
| + // `cell` comes back in rendered pixels, and `setDefaultSize` wants GTK | |
| + // logical pixels: those differ by the device pixel ratio alone, not | |
| + // the gtk-xft-dpi font scale that `content_scale` also folds in. | |
| + const dpr = self.getDevicePixelRatio(); | |
| + const final_width: u32 = @intFromFloat(@ceil(width_f32 / dpr)); | |
| + const final_height: u32 = @intFromFloat(@ceil(height_f32 / dpr)); | |
| self.setDefaultSize(.{ .width = final_width, .height = final_height }); | |
| } | |
| @@ -2845,6 +3010,17 @@ pub const Surface = extern struct { | |
| surface.focusCallback(priv.focused) catch |err| { | |
| log.warn("error in focus callback err={}", .{err}); | |
| }; | |
| + | |
| + // Focus is where the input method starts from, and it may have to | |
| + // place a popup before we ever see a key event, so give it a rect | |
| + // now. `force` because focusing in resets the input method's state | |
| + // (GTK re-enables the Wayland text input, which drops the rect the | |
| + // compositor was holding) while our cached rect may still match it. | |
| + // | |
| + // This has to happen on the idle source with the focus callback | |
| + // rather than in `updateFocus`: the rect comes from `imePoint`, which | |
| + // takes the renderer lock that may be held when focus changes. | |
| + if (priv.focused) self.updateImeCursorLocation(true); | |
| } | |
| fn gcMouseDown( | |
| @@ -3174,6 +3350,12 @@ pub const Surface = extern struct { | |
| const priv = self.private(); | |
| priv.im_composing = true; | |
| priv.im_len = 0; | |
| + | |
| + self.imStartMoveWatch(); | |
| + | |
| + // A candidate window is about to appear, so make sure the input | |
| + // method is placing it against the current caret. | |
| + self.updateImeCursorLocation(true); | |
| } | |
| fn imPreeditChanged( | |
| @@ -3189,30 +3371,165 @@ pub const Surface = extern struct { | |
| // composing set to false we won't commit this text. Therefore, we must | |
| // ensure it is set here. | |
| priv.im_composing = true; | |
| + self.imStartMoveWatch(); | |
| // We can't set our preedit on our surface unless we're realized. | |
| // We do this now because we want to still keep our input method | |
| // state coherent. | |
| const surface = priv.core_surface orelse return; | |
| - // Get our pre-edit string that we'll use to show the user. | |
| + // Get our pre-edit string that we'll use to show the user. The | |
| + // cursor position is in characters and is what the input method | |
| + // wants its candidate window anchored to. | |
| var buf: [*:0]u8 = undefined; | |
| + var caret: c_int = 0; | |
| + // GTK and every input method we know of fill the attribute list in, | |
| + // but one that doesn't would leave us reading uninitialized memory, | |
| + // so we start from null instead. | |
| + var attrs: ?*pango.AttrList = null; | |
| ctx.as(gtk.IMContext).getPreeditString( | |
| &buf, | |
| - null, | |
| - null, | |
| + @ptrCast(&attrs), | |
| + &caret, | |
| ); | |
| defer glib.free(buf); | |
| + defer if (attrs) |list| list.unref(); | |
| const str = std.mem.sliceTo(buf, 0); | |
| // Update our preedit state in Ghostty core | |
| // log.warn("GTKIM: preedit change str={s}", .{str}); | |
| - surface.preeditCallback(str) catch |err| { | |
| + surface.preeditCallback(.{ | |
| + .text = str, | |
| + .caret = if (caret >= 0) @intCast(caret) else null, | |
| + .target = if (attrs) |list| preeditTarget(str, list) else null, | |
| + }) catch |err| { | |
| log.warn( | |
| "error in preedit callback err={}", | |
| .{err}, | |
| ); | |
| }; | |
| + | |
| + // The caret moved within the preedit, so the input method needs a | |
| + // fresh rect to keep its candidate window under the text. | |
| + self.updateImeCursorLocation(false); | |
| + } | |
| + | |
| + /// The codepoint range of the preedit the input method is converting. | |
| + /// | |
| + /// Input methods mark it the way they would mark a selection, but how | |
| + /// exactly depends on the input method, and on Wayland also on GTK: | |
| + /// | |
| + /// * fcitx5's GTK module and ibus set a background, usually with a | |
| + /// matching foreground. | |
| + /// * GTK's own Wayland input method sets `PANGO_WEIGHT_BOLD` over the | |
| + /// `[cursor_begin, cursor_end)` of the `zwp_text_input_v3` preedit, | |
| + /// which is where input methods put the converting segment because | |
| + /// that protocol has nowhere else to put it. | |
| + /// * Newer GTK takes the segment from the protocol's `preedit_hint` | |
| + /// event instead and turns the hints into foreground and background | |
| + /// attributes from CSS. A compositor that sends no hints leaves | |
| + /// nothing to go on, and we return null: only the caret then shows | |
| + /// where in the preedit the input method is working. | |
| + /// | |
| + /// The underline is no help here: it usually covers the whole preedit. | |
| + fn preeditTarget( | |
| + text: []const u8, | |
| + attrs: *pango.AttrList, | |
| + ) ?apprt.Preedit.Range { | |
| + const bytes = for (std.enums.values(Highlight)) |highlight| { | |
| + if (highlightRange(text, attrs, highlight)) |v| break v; | |
| + } else return null; | |
| + | |
| + // Pango works in bytes, the core surface in codepoints. | |
| + return .{ | |
| + .start = std.unicode.utf8CountCodepoints(text[0..bytes.start]) catch return null, | |
| + .end = std.unicode.utf8CountCodepoints(text[0..bytes.end]) catch return null, | |
| + }; | |
| + } | |
| + | |
| + /// How an input method might mark the segment it is converting, in the | |
| + /// order we trust them. See `preeditTarget`. | |
| + const Highlight = enum { background, foreground, weight }; | |
| + | |
| + const ByteRange = struct { start: usize, end: usize }; | |
| + | |
| + /// The byte range of `text` that `highlight` marks, if it marks one. | |
| + fn highlightRange( | |
| + text: []const u8, | |
| + attrs: *pango.AttrList, | |
| + highlight: Highlight, | |
| + ) ?ByteRange { | |
| + var it = attrs.getIterator(); | |
| + defer it.destroy(); | |
| + | |
| + var start_byte: usize = std.math.maxInt(usize); | |
| + var end_byte: usize = 0; | |
| + | |
| + // Whether any of the preedit is left unmarked. Without that contrast | |
| + // the attribute isn't marking a segment, it's styling the preedit, | |
| + // and we'd reverse all of it. | |
| + var unmarked: bool = false; | |
| + | |
| + while (true) { | |
| + var start: c_int = 0; | |
| + var end: c_int = 0; | |
| + it.range(&start, &end); | |
| + | |
| + // Pango clamps the end of the last range to G_MAXINT rather than | |
| + // the length of the text. | |
| + const range_start: usize = @min(@as(usize, @intCast(@max(start, 0))), text.len); | |
| + const range_end: usize = @min(@as(usize, @intCast(@max(end, 0))), text.len); | |
| + | |
| + if (range_start < range_end) { | |
| + if (highlighted(it, highlight)) { | |
| + // An unrelated attribute boundary can split one marked run | |
| + // into several segments, so we take the union. | |
| + start_byte = @min(start_byte, range_start); | |
| + end_byte = @max(end_byte, range_end); | |
| + } else unmarked = true; | |
| + } | |
| + | |
| + if (it.next() == 0) break; | |
| + } | |
| + | |
| + if (start_byte >= end_byte) return null; | |
| + | |
| + // A background over the whole preedit we take at face value: input | |
| + // methods use one the way they use a selection, and converting the | |
| + // whole preedit as a single segment is the common case. A foreground | |
| + // or a bold over everything is just how the preedit is styled, and | |
| + // GTK always adds a whole-preedit attribute of its own. | |
| + if (!unmarked and highlight != .background) return null; | |
| + | |
| + return .{ .start = start_byte, .end = end_byte }; | |
| + } | |
| + | |
| + /// Whether the input method marked the iterator's current segment in the | |
| + /// way `highlight` describes. | |
| + fn highlighted( | |
| + it: *pango.AttrIterator, | |
| + highlight: Highlight, | |
| + ) bool { | |
| + return switch (highlight) { | |
| + .background => background: { | |
| + if (it.get(.background) == null) break :background false; | |
| + | |
| + // GTK derives the `preedit_hint` colors from CSS, where an | |
| + // unstyled preedit's background is the transparent text | |
| + // background. Pango keeps that alpha in its own attribute. | |
| + const attr = it.get(.background_alpha) orelse break :background true; | |
| + const value = attr.asInt() orelse break :background true; | |
| + break :background value.f_value > 0; | |
| + }, | |
| + | |
| + .foreground => it.get(.foreground) != null, | |
| + | |
| + .weight => weight: { | |
| + const attr = it.get(.weight) orelse break :weight false; | |
| + const value = attr.asInt() orelse break :weight false; | |
| + break :weight value.f_value >= @intFromEnum(pango.Weight.bold); | |
| + }, | |
| + }; | |
| } | |
| fn imPreeditEnd( | |
| @@ -3224,6 +3541,7 @@ pub const Surface = extern struct { | |
| // End our composing state for GTK, allowing us to commit the text. | |
| const priv = self.private(); | |
| priv.im_composing = false; | |
| + self.imStopMoveWatch(); | |
| // End our preedit state in Ghostty core | |
| const surface = priv.core_surface orelse return; | |
| @@ -3289,6 +3607,7 @@ pub const Surface = extern struct { | |
| // Committing ends composing state | |
| priv.im_composing = false; | |
| + self.imStopMoveWatch(); | |
| // We can't set our preedit on our surface unless we're realized. | |
| // We do this now because we want to still keep our input method | |
| @@ -3349,7 +3668,12 @@ pub const Surface = extern struct { | |
| // Setup our input method. We do this here because this will | |
| // create a strong reference back to ourself and we want to be | |
| // able to release that in unrealize. | |
| - priv.im_context.as(gtk.IMContext).setClientWidget(self.as(gtk.Widget)); | |
| + // | |
| + // The client widget is the GLArea rather than us because GTK resolves | |
| + // the caret rect we send in `updateImeCursorLocation` against it, and | |
| + // that rect is in `imePoint`'s space, which is the GL framebuffer's. | |
| + // The GLArea is also the widget that holds keyboard focus. | |
| + priv.im_context.as(gtk.IMContext).setClientWidget(priv.gl_area.as(gtk.Widget)); | |
| } | |
| fn glareaUnrealize( | |
| @@ -3382,7 +3706,9 @@ pub const Surface = extern struct { | |
| surface.renderer.displayUnrealized(); | |
| } | |
| - // Unset our input method | |
| + // Unset our input method. The cached caret rect goes with it: a | |
| + // re-realized surface has to send a fresh one. | |
| + priv.im_cursor_rect = null; | |
| priv.im_context.as(gtk.IMContext).setClientWidget(null); | |
| } | |
| @@ -3440,6 +3766,18 @@ pub const Surface = extern struct { | |
| return 0; | |
| }; | |
| + // The caret moves for reasons we never see as key events: the shell | |
| + // echoing what was typed, a prompt redrawing, a full-screen program | |
| + // moving the cursor. Whatever moved it, we are drawing the result | |
| + // right now, so this is the one place that always has the current | |
| + // position. | |
| + // | |
| + // Key input alone isn't enough either: the key that turns an input | |
| + // method on can be consumed by the input method before we ever see | |
| + // it, so the rect has to be current before it arrives. The rect is | |
| + // cached, so a frame that didn't move the caret costs nothing. | |
| + self.updateImeCursorLocation(false); | |
| + | |
| return 1; | |
| } | |
| @@ -3492,6 +3830,10 @@ pub const Surface = extern struct { | |
| }; | |
| // Setup our resize overlay if configured | |
| self.resizeOverlaySchedule(); | |
| + | |
| + // The caret's position in the input method's coordinate space | |
| + // moved along with the geometry. | |
| + self.updateImeCursorLocation(true); | |
| } | |
| return; | |
| @@ -4464,3 +4806,161 @@ test "command and shell integration overrides" { | |
| try applyCommandOverrides(&config, null, .none); | |
| try testing.expectEqual(.none, config.@"shell-integration"); | |
| } | |
| + | |
| +/// Set an attribute's byte range, for the `preeditTarget` tests below. | |
| +/// | |
| +/// Pango works in bytes and the tests use text whose every character is three | |
| +/// bytes long, so a range of `0, 6` is the first two characters. | |
| +fn testAttrRange( | |
| + attr: *pango.Attribute, | |
| + start: usize, | |
| + end: usize, | |
| +) *pango.Attribute { | |
| + attr.f_start_index = @intCast(start); | |
| + attr.f_end_index = @intCast(end); | |
| + return attr; | |
| +} | |
| + | |
| +test "preeditTarget: nothing but the attributes GTK always adds" { | |
| + const testing = std.testing; | |
| + | |
| + // What GTK's Wayland input method gives us when the compositor sends no | |
| + // preedit hints: a whole-preedit fallback and a whole-preedit underline, | |
| + // and no way to tell what is being converted. | |
| + const text = "きょうは"; | |
| + const list = pango.AttrList.new(); | |
| + defer list.unref(); | |
| + list.insert(testAttrRange(pango.attrFallbackNew(@intFromBool(true)), 0, text.len)); | |
| + list.insert(testAttrRange(pango.attrUnderlineNew(.single), 0, text.len)); | |
| + | |
| + try testing.expectEqual( | |
| + @as(?apprt.Preedit.Range, null), | |
| + Surface.preeditTarget(text, list), | |
| + ); | |
| +} | |
| + | |
| +test "preeditTarget: background marks the converting segment" { | |
| + const testing = std.testing; | |
| + | |
| + const text = "きょうは"; | |
| + const list = pango.AttrList.new(); | |
| + defer list.unref(); | |
| + list.insert(testAttrRange(pango.attrUnderlineNew(.single), 0, text.len)); | |
| + list.insert(testAttrRange(pango.attrBackgroundNew(0, 0, 0xffff), 0, 6)); | |
| + | |
| + // Bytes to codepoints: the first two of four characters. | |
| + try testing.expectEqual( | |
| + apprt.Preedit.Range{ .start = 0, .end = 2 }, | |
| + Surface.preeditTarget(text, list).?, | |
| + ); | |
| +} | |
| + | |
| +test "preeditTarget: a background over the whole preedit still counts" { | |
| + const testing = std.testing; | |
| + | |
| + // Typing "kyou" and pressing space converts the whole preedit as one | |
| + // segment, so a background over all of it is a target, not styling. | |
| + const text = "きょう"; | |
| + const list = pango.AttrList.new(); | |
| + defer list.unref(); | |
| + list.insert(testAttrRange(pango.attrBackgroundNew(0, 0, 0xffff), 0, text.len)); | |
| + | |
| + try testing.expectEqual( | |
| + apprt.Preedit.Range{ .start = 0, .end = 3 }, | |
| + Surface.preeditTarget(text, list).?, | |
| + ); | |
| +} | |
| + | |
| +test "preeditTarget: a transparent background marks nothing" { | |
| + const testing = std.testing; | |
| + | |
| + // GTK derives the `preedit_hint` colors from CSS, so an unstyled preedit | |
| + // comes back with the transparent text background rather than no | |
| + // background attribute at all. | |
| + const text = "きょう"; | |
| + const list = pango.AttrList.new(); | |
| + defer list.unref(); | |
| + list.insert(testAttrRange(pango.attrBackgroundNew(0, 0, 0), 0, text.len)); | |
| + list.insert(testAttrRange(pango.attrBackgroundAlphaNew(0), 0, text.len)); | |
| + | |
| + try testing.expectEqual( | |
| + @as(?apprt.Preedit.Range, null), | |
| + Surface.preeditTarget(text, list), | |
| + ); | |
| +} | |
| + | |
| +test "preeditTarget: foreground only marks a segment, never the whole preedit" { | |
| + const testing = std.testing; | |
| + | |
| + const text = "きょうは"; | |
| + { | |
| + const list = pango.AttrList.new(); | |
| + defer list.unref(); | |
| + list.insert(testAttrRange(pango.attrForegroundNew(0, 0, 0xffff), 0, text.len)); | |
| + | |
| + try testing.expectEqual( | |
| + @as(?apprt.Preedit.Range, null), | |
| + Surface.preeditTarget(text, list), | |
| + ); | |
| + } | |
| + { | |
| + const list = pango.AttrList.new(); | |
| + defer list.unref(); | |
| + list.insert(testAttrRange(pango.attrForegroundNew(0, 0, 0xffff), 6, text.len)); | |
| + | |
| + try testing.expectEqual( | |
| + apprt.Preedit.Range{ .start = 2, .end = 4 }, | |
| + Surface.preeditTarget(text, list).?, | |
| + ); | |
| + } | |
| +} | |
| + | |
| +test "preeditTarget: bold marks the segment GTK <= 4.20 reported" { | |
| + const testing = std.testing; | |
| + | |
| + const text = "きょうは"; | |
| + { | |
| + // GTK's Wayland input method puts the protocol's | |
| + // [cursor_begin, cursor_end) here. | |
| + const list = pango.AttrList.new(); | |
| + defer list.unref(); | |
| + list.insert(testAttrRange(pango.attrUnderlineNew(.single), 0, text.len)); | |
| + list.insert(testAttrRange(pango.attrWeightNew(.bold), 3, 9)); | |
| + | |
| + try testing.expectEqual( | |
| + apprt.Preedit.Range{ .start = 1, .end = 3 }, | |
| + Surface.preeditTarget(text, list).?, | |
| + ); | |
| + } | |
| + { | |
| + // A bold preedit is styled, not converting. | |
| + const list = pango.AttrList.new(); | |
| + defer list.unref(); | |
| + list.insert(testAttrRange(pango.attrWeightNew(.bold), 0, text.len)); | |
| + | |
| + try testing.expectEqual( | |
| + @as(?apprt.Preedit.Range, null), | |
| + Surface.preeditTarget(text, list), | |
| + ); | |
| + } | |
| +} | |
| + | |
| +test "preeditTarget: an open ended attribute is clamped to the text" { | |
| + const testing = std.testing; | |
| + | |
| + // Pango clamps the end of the last range to G_MAXINT rather than the | |
| + // length of the text. | |
| + const text = "きょうは"; | |
| + const list = pango.AttrList.new(); | |
| + defer list.unref(); | |
| + list.insert(testAttrRange( | |
| + pango.attrBackgroundNew(0, 0, 0xffff), | |
| + 6, | |
| + std.math.maxInt(u32), | |
| + )); | |
| + | |
| + try testing.expectEqual( | |
| + apprt.Preedit.Range{ .start = 2, .end = 4 }, | |
| + Surface.preeditTarget(text, list).?, | |
| + ); | |
| +} | |
| diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig | |
| index abf46d99b..dbb024d1a 100644 | |
| --- a/src/apprt/gtk/winproto.zig | |
| +++ b/src/apprt/gtk/winproto.zig | |
| @@ -20,6 +20,12 @@ pub const Protocol = enum { | |
| x11, | |
| }; | |
| +/// A position in root (screen) coordinates. | |
| +pub const Position = struct { | |
| + x: i32, | |
| + y: i32, | |
| +}; | |
| + | |
| /// App-state for the underlying windowing protocol. There should be one | |
| /// instance of this struct per application. | |
| pub const App = union(Protocol) { | |
| @@ -173,4 +179,12 @@ pub const Window = union(Protocol) { | |
| inline else => |*v| try v.setUrgent(urgent), | |
| } | |
| } | |
| + | |
| + /// The position of the window in root coordinates, or null if the | |
| + /// protocol doesn't tell clients where they are (Wayland). | |
| + pub fn rootPosition(self: *Window) ?Position { | |
| + return switch (self.*) { | |
| + inline else => |*v| v.rootPosition(), | |
| + }; | |
| + } | |
| }; | |
| diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig | |
| index e5f59dc40..f02ac96a7 100644 | |
| --- a/src/apprt/gtk/winproto/noop.zig | |
| +++ b/src/apprt/gtk/winproto/noop.zig | |
| @@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator; | |
| const gdk = @import("gdk"); | |
| +const winproto = @import("../winproto.zig"); | |
| const Config = @import("../../../config.zig").Config; | |
| const input = @import("../../../input.zig"); | |
| const ApprtWindow = @import("../class/window.zig").Window; | |
| @@ -81,4 +82,8 @@ pub const Window = struct { | |
| pub fn addSubprocessEnv(_: *Window, _: *std.process.Environ.Map) !void {} | |
| pub fn setUrgent(_: *Window, _: bool) !void {} | |
| + | |
| + pub fn rootPosition(_: *Window) ?winproto.Position { | |
| + return null; | |
| + } | |
| }; | |
| diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig | |
| index 81481514e..dcf89e38a 100644 | |
| --- a/src/apprt/gtk/winproto/wayland.zig | |
| +++ b/src/apprt/gtk/winproto/wayland.zig | |
| @@ -15,6 +15,7 @@ const kde = wayland.client.kde; | |
| const org = wayland.client.org; | |
| const xdg = wayland.client.xdg; | |
| +const winproto = @import("../winproto.zig"); | |
| const Config = @import("../../../config.zig").Config; | |
| const Globals = @import("wayland/Globals.zig"); | |
| const Hotkeys = @import("wayland/Hotkeys.zig"); | |
| @@ -258,6 +259,13 @@ pub const Window = struct { | |
| _ = env; | |
| } | |
| + /// Wayland deliberately doesn't tell clients where they are, and it | |
| + /// doesn't need to: the cursor rect we hand an input method is surface | |
| + /// relative and the compositor tracks the surface itself. | |
| + pub fn rootPosition(_: *Window) ?winproto.Position { | |
| + return null; | |
| + } | |
| + | |
| pub fn setUrgent(self: *Window, urgent: bool) !void { | |
| const activation = self.globals.get(.xdg_activation) orelse return; | |
| diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig | |
| index fe0e6f613..98226cfee 100644 | |
| --- a/src/apprt/gtk/winproto/x11.zig | |
| +++ b/src/apprt/gtk/winproto/x11.zig | |
| @@ -16,6 +16,7 @@ pub const c = @cImport({ | |
| @cInclude("X11/XKBlib.h"); | |
| }); | |
| +const winproto = @import("../winproto.zig"); | |
| const input = @import("../../../input.zig"); | |
| const Config = @import("../../../config.zig").Config; | |
| const ApprtWindow = @import("../class/window.zig").Window; | |
| @@ -233,6 +234,28 @@ pub const Window = struct { | |
| }; | |
| } | |
| + /// The position of the window in root coordinates. An input method | |
| + /// needs this to notice that the window moved: see the IME cursor | |
| + /// location handling in the surface class. | |
| + pub fn rootPosition(self: *Window) ?winproto.Position { | |
| + const display: *c.Display = @ptrCast(@alignCast(self.app.display)); | |
| + var x: c_int = 0; | |
| + var y: c_int = 0; | |
| + var child: c.Window = undefined; | |
| + if (c.XTranslateCoordinates( | |
| + display, | |
| + self.x11_surface.getXid(), | |
| + c.XDefaultRootWindow(display), | |
| + 0, | |
| + 0, | |
| + &x, | |
| + &y, | |
| + &child, | |
| + ) == 0) return null; | |
| + | |
| + return .{ .x = x, .y = y }; | |
| + } | |
| + | |
| pub fn clientSideDecorationEnabled(self: Window) bool { | |
| return switch (self.apprt_window.getWindowDecoration()) { | |
| .auto, .client => true, | |
| diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig | |
| index d8510fb84..806535814 100644 | |
| --- a/src/apprt/structs.zig | |
| +++ b/src/apprt/structs.zig | |
| @@ -32,6 +32,34 @@ pub const IMEPos = struct { | |
| y: f64, | |
| width: f64, | |
| height: f64, | |
| + | |
| + /// The x an input method should anchor its candidate window to: the | |
| + /// right edge of the character the composition caret follows, so the | |
| + /// window sits below-right of the text as it is typed and converted. | |
| + /// Without a preedit this is the left edge of the cursor cell. | |
| + caret_x: f64, | |
| +}; | |
| + | |
| +/// The pre-edit (composition) state reported by an input method. | |
| +pub const Preedit = struct { | |
| + /// The composition text, UTF-8 encoded. | |
| + text: []const u8, | |
| + | |
| + /// The codepoint index in `text` that the composition caret sits in | |
| + /// front of. Null when the apprt can't report one, in which case the | |
| + /// caret is assumed to be at the end of the text, which is where input | |
| + /// methods put it while composing. | |
| + caret: ?usize = null, | |
| + | |
| + /// The codepoint range `[start, end)` in `text` that the input method is | |
| + /// converting right now. Input methods highlight this segment so the user | |
| + /// can see how far the conversion reaches. | |
| + target: ?Range = null, | |
| + | |
| + pub const Range = struct { | |
| + start: usize, | |
| + end: usize, | |
| + }; | |
| }; | |
| /// The clipboard type. | |
| diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig | |
| index 86cfecc05..103be6a10 100644 | |
| --- a/src/build/SharedDeps.zig | |
| +++ b/src/build/SharedDeps.zig | |
| @@ -722,6 +722,7 @@ fn addGtkNg( | |
| .{ "glibunix", "glibunix2" }, | |
| .{ "gobject", "gobject2" }, | |
| .{ "gtk", "gtk4" }, | |
| + .{ "pango", "pango1" }, | |
| .{ "xlib", "xlib2" }, | |
| }; | |
| inline for (gobject_imports) |import| { | |
| diff --git a/src/renderer/State.zig b/src/renderer/State.zig | |
| index 1e38d64a9..a40266946 100644 | |
| --- a/src/renderer/State.zig | |
| +++ b/src/renderer/State.zig | |
| @@ -121,10 +121,20 @@ pub const Preedit = struct { | |
| /// The codepoints to render as preedit text. | |
| codepoints: []const Codepoint = &.{}, | |
| + /// The index in `codepoints` the composition caret sits in front of. | |
| + /// Input methods use this to place their candidate window against the | |
| + /// character being composed rather than the start of the preedit. | |
| + caret: usize = 0, | |
| + | |
| /// A single codepoint to render as preedit text. | |
| pub const Codepoint = struct { | |
| codepoint: u21, | |
| wide: bool = false, | |
| + | |
| + /// Part of the segment the input method is converting right now. | |
| + /// We render these differently so the extent of the conversion is | |
| + /// visible, the way input methods highlight it themselves. | |
| + target: bool = false, | |
| }; | |
| /// Deinit this preedit that was cre | |
| @@ -136,6 +146,7 @@ pub const Preedit = struct { | |
| pub fn clone(self: *const Preedit, alloc: Allocator) !Preedit { | |
| return .{ | |
| .codepoints = try alloc.dupe(Codepoint, self.codepoints), | |
| + .caret = self.caret, | |
| }; | |
| } | |
| @@ -194,6 +205,30 @@ pub const Preedit = struct { | |
| .cp_offset = cp_offset, | |
| }; | |
| } | |
| + | |
| + /// The cell the composition caret sits at, for the same start/max as | |
| + /// `range`. This walks the cells `range` lays out so it stays on the | |
| + /// text even when the preedit was shifted or truncated. | |
| + /// | |
| + /// The caret sits *between* characters, so this is the cell after the | |
| + /// character it follows: both the caret we draw and the candidate window | |
| + /// an input method anchors here belong to the right of that character, | |
| + /// not on top of it. | |
| + pub fn caretCell( | |
| + self: *const Preedit, | |
| + start: terminalpkg.size.CellCountInt, | |
| + max: terminalpkg.size.CellCountInt, | |
| + ) terminalpkg.size.CellCountInt { | |
| + const r = self.range(start, max); | |
| + const caret = @min(self.caret, self.codepoints.len); | |
| + | |
| + var cell: terminalpkg.size.CellCountInt = r.start; | |
| + for (self.codepoints[@min(r.cp_offset, caret)..caret]) |cp| { | |
| + cell += if (cp.wide) 2 else 1; | |
| + } | |
| + | |
| + return @min(cell, max); | |
| + } | |
| }; | |
| const test_hangul_ga: u21 = 0xAC00; // U+AC00 HANGUL SYLLABLE GA | |
| @@ -233,3 +268,80 @@ test "preedit range shifts left at right edge" { | |
| try testing.expectEqual(@as(terminalpkg.size.CellCountInt, 9), range.end); | |
| try testing.expectEqual(@as(usize, 0), range.cp_offset); | |
| } | |
| + | |
| +test "preedit caret cell sits after the composed character" { | |
| + const testing = std.testing; | |
| + const Cell = terminalpkg.size.CellCountInt; | |
| + | |
| + // Caret at the end of the preedit, which is where input methods put it | |
| + // while composing: it sits right after the last character typed, three | |
| + // cells in because the wide codepoint before it takes two. | |
| + { | |
| + const p: Preedit = .{ | |
| + .codepoints = &.{ | |
| + .{ .codepoint = test_hangul_ga, .wide = true }, | |
| + .{ .codepoint = 'a' }, | |
| + }, | |
| + .caret = 2, | |
| + }; | |
| + try testing.expectEqual(@as(Cell, 5), p.caretCell(2, 9)); | |
| + } | |
| + | |
| + // Caret in the middle sits on the boundary between the two characters, | |
| + // which is one wide character in from the start. | |
| + { | |
| + const p: Preedit = .{ | |
| + .codepoints = &.{ | |
| + .{ .codepoint = test_hangul_ga, .wide = true }, | |
| + .{ .codepoint = 'a' }, | |
| + }, | |
| + .caret = 1, | |
| + }; | |
| + try testing.expectEqual(@as(Cell, 4), p.caretCell(2, 9)); | |
| + } | |
| + | |
| + // A caret past the end of the preedit is treated as one at the end | |
| + // rather than walking off the codepoints. | |
| + { | |
| + const p: Preedit = .{ | |
| + .codepoints = &.{.{ .codepoint = 'a' }}, | |
| + .caret = 5, | |
| + }; | |
| + try testing.expectEqual(@as(Cell, 3), p.caretCell(2, 9)); | |
| + } | |
| +} | |
| + | |
| +test "preedit caret cell follows a shifted preedit" { | |
| + const testing = std.testing; | |
| + const Cell = terminalpkg.size.CellCountInt; | |
| + | |
| + // The preedit doesn't fit so it shifts left and drops the codepoints | |
| + // that fell off the start. The caret walks only the cells that are | |
| + // drawn, so it lands on the text instead of where an unshifted preedit | |
| + // would have put it (cell 10, off the right edge). | |
| + { | |
| + const p: Preedit = .{ | |
| + .codepoints = &.{ | |
| + .{ .codepoint = test_hangul_ga, .wide = true }, | |
| + .{ .codepoint = test_hangul_ga, .wide = true }, | |
| + .{ .codepoint = test_hangul_ga, .wide = true }, | |
| + }, | |
| + .caret = 1, | |
| + }; | |
| + try testing.expectEqual(@as(usize, 2), p.range(9, 9).cp_offset); | |
| + try testing.expectEqual(@as(Cell, 8), p.caretCell(9, 9)); | |
| + } | |
| + | |
| + // A caret at the end of a preedit that reaches the right edge clamps to | |
| + // the last cell rather than pointing past it. | |
| + { | |
| + const p: Preedit = .{ | |
| + .codepoints = &.{ | |
| + .{ .codepoint = test_hangul_ga, .wide = true }, | |
| + .{ .codepoint = test_hangul_ga, .wide = true }, | |
| + }, | |
| + .caret = 2, | |
| + }; | |
| + try testing.expectEqual(@as(Cell, 9), p.caretCell(9, 9)); | |
| + } | |
| +} | |
| diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig | |
| index 2a81f42b4..8fe2ac4a4 100644 | |
| --- a/src/renderer/generic.zig | |
| +++ b/src/renderer/generic.zig | |
| @@ -2651,13 +2651,36 @@ pub fn Renderer(comptime GraphicsAPI: type) type { | |
| .{}; | |
| }; | |
| - // If we have preedit text, we don't setup a cursor | |
| - if (preedit != null) break :cursor; | |
| - | |
| // If there isn't a cursor visual style requested then | |
| // we don't render a cursor. | |
| const style = cursor_style_ orelse break :cursor; | |
| + // While composing we draw the input method's caret instead of | |
| + // the terminal cursor. The terminal cursor sits at the start | |
| + // of the preedit and stays there, so it says nothing about | |
| + // where in the composition the user is editing. | |
| + if (preedit) |preedit_v| { | |
| + const caret_color = state.colors.cursor orelse color: { | |
| + const v = self.config.cursor_color orelse | |
| + break :color state.colors.foreground; | |
| + | |
| + // The cell-relative colors are about the cell under | |
| + // the terminal cursor, which the caret isn't on. | |
| + break :color switch (v) { | |
| + .color => |c| c.toTerminalRGB(), | |
| + else => state.colors.foreground, | |
| + }; | |
| + }; | |
| + | |
| + self.addPreeditCaret( | |
| + preedit_v.caretCell(cursor_vp.x, state.cols - 1), | |
| + @intCast(cursor_vp.y), | |
| + caret_color, | |
| + ); | |
| + | |
| + break :cursor; | |
| + } | |
| + | |
| // Determine the cursor color. | |
| const cursor_color = cursor_color: { | |
| // If an explicit cursor color was set by OSC 12, use that. | |
| @@ -2773,6 +2796,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { | |
| cp, | |
| .{ .x = x, .y = range.y }, | |
| state.colors.foreground, | |
| + state.colors.background, | |
| ) catch |err| { | |
| log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{ | |
| x, | |
| @@ -3497,11 +3521,48 @@ pub fn Renderer(comptime GraphicsAPI: type) type { | |
| }, cursor_style); | |
| } | |
| + /// Draw the composition caret, the cell boundary the input method | |
| + /// reports as its caret. We use a bar because it marks a boundary | |
| + /// between characters rather than a cell, unlike the block cursor. | |
| + fn addPreeditCaret( | |
| + self: *Self, | |
| + x: terminal.size.CellCountInt, | |
| + y: terminal.size.CellCountInt, | |
| + color: terminal.color.RGB, | |
| + ) void { | |
| + const render = self.font_grid.renderGlyph( | |
| + self.alloc, | |
| + font.sprite_index, | |
| + @intFromEnum(font.Sprite.cursor_bar), | |
| + .{ | |
| + .cell_width = 1, | |
| + .grid_metrics = self.grid_metrics, | |
| + }, | |
| + ) catch |err| { | |
| + log.warn("error rendering preedit caret err={}", .{err}); | |
| + return; | |
| + }; | |
| + | |
| + self.cells.setCursor(.{ | |
| + .atlas = .grayscale, | |
| + .bools = .{ .is_cursor_glyph = true }, | |
| + .grid_pos = .{ @intCast(x), @intCast(y) }, | |
| + .color = .{ color.r, color.g, color.b, 255 }, | |
| + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, | |
| + .glyph_size = .{ render.glyph.width, render.glyph.height }, | |
| + .bearings = .{ | |
| + @intCast(render.glyph.offset_x), | |
| + @intCast(render.glyph.offset_y), | |
| + }, | |
| + }, .bar); | |
| + } | |
| + | |
| fn addPreeditCell( | |
| self: *Self, | |
| cp: renderer.State.Preedit.Codepoint, | |
| coord: terminal.Coordinate, | |
| screen_fg: terminal.color.RGB, | |
| + screen_bg: terminal.color.RGB, | |
| ) !void { | |
| // Render the glyph for our preedit text | |
| const render_ = self.font_grid.renderCodepoint( | |
| @@ -3519,11 +3580,29 @@ pub fn Renderer(comptime GraphicsAPI: type) type { | |
| return; | |
| }; | |
| + // The segment being converted is reversed instead of underlined. | |
| + // Underlining it too would only compete with the reverse for the | |
| + // reader's attention, and the point of the reverse is that the | |
| + // extent of the conversion is obvious at a glance. | |
| + const fg = if (cp.target) screen_bg else screen_fg; | |
| + const width: u8 = if (cp.wide) 2 else 1; | |
| + const last_x = self.cells.size.columns - 1; | |
| + | |
| + if (cp.target) { | |
| + for (0..width) |i| { | |
| + const x = coord.x + i; | |
| + if (x > last_x) break; | |
| + self.cells.bgCell(@intCast(coord.y), @intCast(x)).* = .{ | |
| + screen_fg.r, screen_fg.g, screen_fg.b, 255, | |
| + }; | |
| + } | |
| + } | |
| + | |
| // Add our text | |
| try self.cells.add(self.alloc, .text, .{ | |
| .atlas = .grayscale, | |
| .grid_pos = .{ @intCast(coord.x), @intCast(coord.y) }, | |
| - .color = .{ screen_fg.r, screen_fg.g, screen_fg.b, 255 }, | |
| + .color = .{ fg.r, fg.g, fg.b, 255 }, | |
| .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, | |
| .glyph_size = .{ render.glyph.width, render.glyph.height }, | |
| .bearings = .{ | |
| @@ -3532,9 +3611,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { | |
| }, | |
| }); | |
| + if (cp.target) return; | |
| + | |
| // Add underline | |
| try self.addUnderline(@intCast(coord.x), @intCast(coord.y), .single, screen_fg, 255); | |
| - if (cp.wide and coord.x < self.cells.size.columns - 1) { | |
| + if (cp.wide and coord.x < last_x) { | |
| try self.addUnderline(@intCast(coord.x + 1), @intCast(coord.y), .single, screen_fg, 255); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment