Skip to content

Instantly share code, notes, and snippets.

@shelaf
Last active September 7, 2026 03:12
Show Gist options
  • Select an option

  • Save shelaf/23ec3d22269afbe623477b27774aeb39 to your computer and use it in GitHub Desktop.

Select an option

Save shelaf/23ec3d22269afbe623477b27774aeb39 to your computer and use it in GitHub Desktop.
candidate window pos. fix + show preedit cursor patch for Ghostty
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