diff --git a/include/ghostty.h b/include/ghostty.h index afc20bb3f5b..8b5e985a066 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1124,7 +1124,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, diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index f9448cd0d8e..0f3afc6aaf6 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -218,6 +218,7 @@ extension Ghostty { var notificationIdentifiers: Set = [] private var markedText: NSMutableAttributedString + private var markedTextSelectedLocation: Int = 0 private(set) var focused: Bool = true private var prevPressureStage: Int = 0 private var appearanceObserver: NSKeyValueObservation? @@ -1835,9 +1836,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)") @@ -2026,13 +2029,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) } } } diff --git a/src/Surface.zig b/src/Surface.zig index dfc3a50ea13..e9bb67cdc55 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2499,7 +2499,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 @@ -2566,6 +2566,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(); } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 519a35f2bd1..bc8f0b497cf 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -889,8 +889,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; }; @@ -1810,8 +1810,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 diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 179c779d7be..832761d4aa5 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -637,6 +637,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, @@ -1420,7 +1421,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 @@ -3038,6 +3039,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( @@ -3061,17 +3063,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}, @@ -3091,7 +3104,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}); }; } @@ -3160,7 +3173,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}); }; diff --git a/src/config/Config.zig b/src/config/Config.zig index 13f78eea686..661bb5d907c 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -914,6 +914,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, diff --git a/src/renderer/State.zig b/src/renderer/State.zig index e882ddceb26..882d06100bc 100644 --- a/src/renderer/State.zig +++ b/src/renderer/State.zig @@ -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 }; } diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 0f4a294bc71..e14e570daa3 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -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: { + + // If there isn't a cursor visual style requested then + // we don't render a cursor. + const style = cursor_style_ orelse break :cursor; + + 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 :cursor_style if (cell.raw.hasStyling()) + break :cell_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; - - // 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, - }; - }, + 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; + + // 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(