pull/12244/merge
Toby She 2026-06-03 11:32:10 -07:00 committed by GitHub
commit 0c19044329
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 175 additions and 141 deletions

View File

@ -1126,7 +1126,7 @@ GHOSTTY_API bool ghostty_surface_key_is_binding(ghostty_surface_t,
ghostty_input_key_s,
ghostty_binding_flags_e*);
GHOSTTY_API void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);
GHOSTTY_API void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t);
GHOSTTY_API void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t, int32_t);
GHOSTTY_API bool ghostty_surface_mouse_captured(ghostty_surface_t);
GHOSTTY_API bool ghostty_surface_mouse_button(ghostty_surface_t,
ghostty_input_mouse_state_e,

View File

@ -181,6 +181,7 @@ extension Ghostty {
var notificationIdentifiers: Set<String> = []
private var markedText: NSMutableAttributedString
private var markedTextSelectedLocation: Int = 0
private(set) var focused: Bool = true
private var prevPressureStage: Int = 0
private var appearanceObserver: NSKeyValueObservation?
@ -1883,9 +1884,11 @@ extension Ghostty.SurfaceView: NSTextInputClient {
switch string {
case let v as NSAttributedString:
self.markedText = NSMutableAttributedString(attributedString: v)
self.markedTextSelectedLocation = selectedRange.location
case let v as String:
self.markedText = NSMutableAttributedString(string: v)
self.markedTextSelectedLocation = selectedRange.location
default:
print("unknown marked text: \(string)")
@ -2060,13 +2063,18 @@ extension Ghostty.SurfaceView: NSTextInputClient {
if len > 0 {
markedText.string.withCString { ptr in
// Subtract 1 for the null terminator
ghostty_surface_preedit(surface, ptr, UInt(len - 1))
ghostty_surface_preedit(
surface,
ptr,
UInt(len - 1),
Int32(markedTextSelectedLocation)
)
}
}
} else if clearIfNeeded {
// If we had marked text before but don't now, we're no longer
// in a preedit state so we can clear it.
ghostty_surface_preedit(surface, nil, 0)
ghostty_surface_preedit(surface, nil, 0, 0)
}
}

View File

@ -2496,7 +2496,7 @@ 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_: ?[]const u8, cursor_pos: i32) !void {
// log.debug("text preeditCallback value={any}", .{preedit_});
// Crash metadata in case we crash in here
@ -2563,6 +2563,7 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void {
self.renderer_state.preedit = .{
.codepoints = try codepoints.toOwnedSlice(self.alloc),
.cursor_pos = cursor_pos
};
try self.queueRender();
}

View File

@ -891,8 +891,8 @@ pub const Surface = struct {
};
}
pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) void {
_ = self.core_surface.preeditCallback(preedit_) catch |err| {
pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8, cursor_pos: i32) void {
_ = self.core_surface.preeditCallback(preedit_, cursor_pos) catch |err| {
log.err("error in preedit callback err={}", .{err});
return;
};
@ -1829,8 +1829,9 @@ pub const CAPI = struct {
surface: *Surface,
ptr: [*]const u8,
len: usize,
cursor_pos: i32
) void {
surface.preeditCallback(if (len == 0) null else ptr[0..len]);
surface.preeditCallback(if (len == 0) null else ptr[0..len], cursor_pos);
}
/// Returns true if the surface currently has mouse capturing

View File

@ -659,6 +659,7 @@ pub const Surface = extern struct {
im_composing: bool = false,
im_buf: [128]u8 = undefined,
im_len: u7 = 0,
im_show_cursor: bool = false,
/// True when we have a precision scroll in progress
precision_scroll: bool = false,
@ -1448,7 +1449,7 @@ pub const Surface = extern struct {
// such as quotation mark ordering for Chinese input.
if (priv.im_composing) {
priv.im_context.as(gtk.IMContext).reset();
surface.preeditCallback(null) catch {};
surface.preeditCallback(null, 0) catch {};
}
// Bell stops ringing when any key is pressed that is used by
@ -3084,6 +3085,7 @@ pub const Surface = extern struct {
const priv = self.private();
priv.im_composing = true;
priv.im_len = 0;
priv.im_show_cursor = false;
}
fn imPreeditChanged(
@ -3107,17 +3109,28 @@ pub const Surface = extern struct {
// Get our pre-edit string that we'll use to show the user.
var buf: [*:0]u8 = undefined;
var cursor_pos: i32 = 0;
ctx.as(gtk.IMContext).getPreeditString(
&buf,
null,
null,
&cursor_pos,
);
defer glib.free(buf);
const str = std.mem.sliceTo(buf, 0);
// some IME may hard code the cursor as a bar,
// in which case, cursor_pos would always be 0
// if cursor_pos has never been non-zero,
// set it to -1 to hide the cursor to avoid conflict
if (cursor_pos > 0) {
priv.im_show_cursor = true;
} else if (!priv.im_show_cursor) {
cursor_pos = -1;
}
// Update our preedit state in Ghostty core
// log.warn("GTKIM: preedit change str={s}", .{str});
surface.preeditCallback(str) catch |err| {
surface.preeditCallback(str, cursor_pos) catch |err| {
log.warn(
"error in preedit callback err={}",
.{err},
@ -3137,7 +3150,7 @@ pub const Surface = extern struct {
// End our preedit state in Ghostty core
const surface = priv.core_surface orelse return;
surface.preeditCallback(null) catch |err| {
surface.preeditCallback(null, 0) catch |err| {
log.warn("error in preedit callback err={}", .{err});
};
}
@ -3206,7 +3219,7 @@ pub const Surface = extern struct {
if (priv.core_surface) |surface| {
// End our preedit state. Well-behaved input methods do this for us
// by triggering a preedit-end event but some do not (ibus 1.5.29).
surface.preeditCallback(null) catch |err| {
surface.preeditCallback(null, 0) catch |err| {
log.warn("error in preedit callback err={}", .{err});
};

View File

@ -915,6 +915,23 @@ palette: Palette = .{},
/// behavior around edge cases is possible.
@"cursor-click-to-move": bool = true,
/// The style of the cursor when editing the preedit text using
/// an IME (Input Method Editor).
///
/// All other cursor configs are applicable to IME cursor as well,
/// with the exception of `cursor-click-to-move` and `cursor-style-blink`
///
/// Note: Some IME hardcodes the cursor as a bar as a part of the preedit text.
/// To avoid showing two cursors, the native Ghostty cursor would be hidden.
///
/// Valid values are:
///
/// * `block`
/// * `bar`
/// * `underline`
/// * `block_hollow`
@"ime-cursor-style": terminal.CursorStyle = .bar,
/// Hide the mouse immediately when typing. The mouse becomes visible again
/// when the mouse is used (button, movement, etc.). Platform-specific behavior
/// may dictate other scenarios where the mouse is shown. For example on macOS,

View File

@ -46,6 +46,7 @@ pub const Mouse = struct {
pub const Preedit = struct {
/// The codepoints to render as preedit text.
codepoints: []const Codepoint = &.{},
cursor_pos: i32,
/// A single codepoint to render as preedit text.
pub const Codepoint = struct {
@ -62,6 +63,7 @@ pub const Preedit = struct {
pub fn clone(self: *const Preedit, alloc: Allocator) !Preedit {
return .{
.codepoints = try alloc.dupe(Codepoint, self.codepoints),
.cursor_pos = self.cursor_pos
};
}

View File

@ -544,6 +544,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
cursor_color: ?configpkg.Config.TerminalColor,
cursor_opacity: f64,
cursor_text: ?configpkg.Config.TerminalColor,
ime_cursor_style: terminal.CursorStyle,
background: terminal.color.RGB,
background_opacity: f64,
background_opacity_cells: bool,
@ -616,6 +617,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.cursor_color = config.@"cursor-color",
.cursor_text = config.@"cursor-text",
.cursor_opacity = @max(0, @min(1, config.@"cursor-opacity")),
.ime_cursor_style = config.@"ime-cursor-style",
.background = config.background.toTerminalRGB(),
.foreground = config.foreground.toTerminalRGB(),
@ -2444,6 +2446,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Setup our cursor rendering information.
cursor: {
// Preedit cursor has custom logic
if (preedit != null) break :cursor;
// Clear our cursor by default.
self.cells.setCursor(null, null);
self.uniforms.cursor_pos = .{
@ -2451,136 +2456,62 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
std.math.maxInt(u16),
};
// If the cursor isn't visible on the viewport, don't show
// a cursor. Otherwise, get our cursor cell, because we may
// need it for styling.
// If the cursor isn't visible on the viewport, don't show a cursor.
const cursor_vp = state.cursor.viewport orelse break :cursor;
const cursor_style: terminal.Style = cursor_style: {
const cells = state.row_data.items(.cells);
const cell = cells[cursor_vp.y].get(cursor_vp.x);
break :cursor_style if (cell.raw.hasStyling())
cell.style
else
.{};
};
// 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;
// Determine the cursor color.
const cursor_color = cursor_color: {
// If an explicit cursor color was set by OSC 12, use that.
if (state.colors.cursor) |v| break :cursor_color v;
const cell_style: terminal.Style = cell_style: {
const cells = state.row_data.items(.cells);
const cell = cells[cursor_vp.y].get(cursor_vp.x);
break :cell_style if (cell.raw.hasStyling())
cell.style
else
.{};
};
const cell_fg = cell_style.fg(.{
.default = state.colors.foreground,
.palette = &state.colors.palette,
.bold = self.config.bold_color,
});
const cell_bg = cell_style.bg(
&state.cursor.cell,
&state.colors.palette,
) orelse state.colors.background;
// Use our configured color if specified
if (self.config.cursor_color) |v| switch (v) {
.color => |color| break :cursor_color color.toTerminalRGB(),
inline .@"cell-foreground",
.@"cell-background",
=> |_, tag| {
const fg_style = cursor_style.fg(.{
.default = state.colors.foreground,
.palette = &state.colors.palette,
.bold = self.config.bold_color,
});
const bg_style = cursor_style.bg(
&state.cursor.cell,
&state.colors.palette,
) orelse state.colors.background;
break :cursor_color switch (tag) {
.color => unreachable,
.@"cell-foreground" => if (cursor_style.flags.inverse)
bg_style
else
fg_style,
.@"cell-background" => if (cursor_style.flags.inverse)
fg_style
else
bg_style,
};
},
// Add the cursor. We render the cursor over the wide character if
// we're on the wide character tail.
const wide, const x = cell: {
// The cursor goes over the screen cursor position.
if (!cursor_vp.wide_tail) break :cell .{
state.cursor.cell.wide == .wide,
cursor_vp.x,
};
break :cursor_color state.colors.foreground;
// If we're part of a wide character, we move the cursor back
// to the actual character.
break :cell .{ true, cursor_vp.x - 1 };
};
self.addCursor(
&state.cursor,
style,
cursor_color,
if (cell_style.flags.inverse) cell_bg else cell_fg,
if (cell_style.flags.inverse) cell_fg else cell_bg,
wide,
x,
cursor_vp.y,
);
// If the cursor is visible then we set our uniforms.
if (style == .block) {
const wide = state.cursor.cell.wide;
self.uniforms.cursor_pos = .{
// If we are a spacer tail of a wide cell, our cursor needs
// to move back one cell. The saturate is to ensure we don't
// overflow but this shouldn't happen with well-formed input.
switch (wide) {
.narrow, .spacer_head, .wide => cursor_vp.x,
.spacer_tail => cursor_vp.x -| 1,
},
@intCast(cursor_vp.y),
};
self.uniforms.bools.cursor_wide = switch (wide) {
.narrow, .spacer_head => false,
.wide, .spacer_tail => true,
};
const uniform_color = if (self.config.cursor_text) |txt| blk: {
// If cursor-text is set, then compute the correct color.
// Otherwise, use the background color.
if (txt == .color) {
// Use the color set by cursor-text, if any.
break :blk txt.color.toTerminalRGB();
}
const fg_style = cursor_style.fg(.{
.default = state.colors.foreground,
.palette = &state.colors.palette,
.bold = self.config.bold_color,
});
const bg_style = cursor_style.bg(
&state.cursor.cell,
&state.colors.palette,
) orelse state.colors.background;
break :blk switch (txt) {
// If the cell is reversed, use the opposite cell color instead.
.@"cell-foreground" => if (cursor_style.flags.inverse)
bg_style
else
fg_style,
.@"cell-background" => if (cursor_style.flags.inverse)
fg_style
else
bg_style,
else => unreachable,
};
} else state.colors.background;
self.uniforms.cursor_color = .{
uniform_color.r,
uniform_color.g,
uniform_color.b,
255,
};
}
}
// Setup our preedit text.
if (preedit) |preedit_v| preedit: {
const range = preedit_range orelse break :preedit;
var x = range.x[0];
var cp_count: i32 = 0;
var cursor_x = x;
var cursor_wide = false;
for (preedit_v.codepoints[range.cp_offset..]) |cp| {
self.addPreeditCell(
cp,
@ -2594,7 +2525,30 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
});
};
if (cp_count == preedit_v.cursor_pos) {
cursor_x = x;
cursor_wide = cp.wide;
}
x += if (cp.wide) 2 else 1;
cp_count += 1;
}
if (cp_count == preedit_v.cursor_pos) cursor_x = x;
// Clear our cursor by default.
self.cells.setCursor(null, null);
self.uniforms.cursor_pos = .{
std.math.maxInt(u16),
std.math.maxInt(u16),
};
if (preedit_v.cursor_pos >= 0) {
self.addCursor(
.fromTerminal(self.config.ime_cursor_style),
state.colors.foreground,
state.colors.background,
cursor_wide,
cursor_x,
range.y
);
}
}
@ -3223,26 +3177,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
fn addCursor(
self: *Self,
cursor_state: *const terminal.RenderState.Cursor,
cursor_style: renderer.CursorStyle,
cursor_color: terminal.color.RGB,
cell_fg: terminal.color.RGB,
cell_bg: terminal.color.RGB,
wide: bool,
x: u16,
y: u16,
) void {
const cursor_vp = cursor_state.viewport orelse return;
// Add the cursor. We render the cursor over the wide character if
// we're on the wide character tail.
const wide, const x = cell: {
// The cursor goes over the screen cursor position.
if (!cursor_vp.wide_tail) break :cell .{
cursor_state.cell.wide == .wide,
cursor_vp.x,
};
// If we're part of a wide character, we move the cursor back
// to the actual character.
break :cell .{ true, cursor_vp.x - 1 };
};
const state: *terminal.RenderState = &self.terminal_state;
const alpha: u8 = if (!self.focused) 255 else alpha: {
const alpha = 255 * self.config.cursor_opacity;
break :alpha @intFromFloat(@ceil(alpha));
@ -3296,10 +3238,32 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
},
};
const cursor_color = cursor_color: {
// If an explicit cursor color was set by OSC 12, use that.
if (state.colors.cursor) |v| break :cursor_color v;
// Use our configured color if specified
if (self.config.cursor_color) |v| switch (v) {
.color => |color| break :cursor_color color.toTerminalRGB(),
inline .@"cell-foreground",
.@"cell-background",
=> |_, tag| {
break :cursor_color switch (tag) {
.color => unreachable,
.@"cell-foreground" => cell_fg,
.@"cell-background" => cell_bg,
};
},
};
break :cursor_color state.colors.foreground;
};
self.cells.setCursor(.{
.atlas = .grayscale,
.bools = .{ .is_cursor_glyph = true },
.grid_pos = .{ x, cursor_vp.y },
.grid_pos = .{ x, y },
.color = .{ cursor_color.r, cursor_color.g, cursor_color.b, alpha },
.glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
.glyph_size = .{ render.glyph.width, render.glyph.height },
@ -3308,6 +3272,34 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
@intCast(render.glyph.offset_y),
},
}, cursor_style);
if (cursor_style != .block) return;
// set uniform for block cursor
const uniform_color = if (self.config.cursor_text) |txt| blk: {
// If cursor-text is set, then compute the correct color.
// Otherwise, use the background color.
if (txt == .color) {
// Use the color set by cursor-text, if any.
break :blk txt.color.toTerminalRGB();
}
break :blk switch (txt) {
// If the cell is reversed, use the opposite cell color instead.
.@"cell-foreground" => cell_fg,
.@"cell-background" => cell_bg,
else => unreachable,
};
} else state.colors.background;
self.uniforms.cursor_pos = .{ x, y, };
self.uniforms.bools.cursor_wide = wide;
self.uniforms.cursor_color = .{
uniform_color.r,
uniform_color.g,
uniform_color.b,
255,
};
}
fn addPreeditCell(