libghostty-vt: add options to configure default cursor's style and blink (#12900)

This PR adds 2 options to `libghostty-vt` to configure the style and
blink status of the default cursor. They control how the terminal
renders the cursor when a program doesn't request any explicit style or
when it resets it to the terminal's default state by sending a DECSCUSR
reset sequence (`CSI 0 q`).
This commit is contained in:
Mitchell Hashimoto
2026-06-04 11:39:50 -07:00
committed by GitHub
3 changed files with 136 additions and 3 deletions

View File

@@ -232,6 +232,26 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_TERMINAL_SCREEN_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalScreen;
/**
* Visual style of the terminal cursor.
*
* @ingroup terminal
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** Bar cursor (DECSCUSR 5, 6). */
GHOSTTY_TERMINAL_CURSOR_STYLE_BAR = 0,
/** Block cursor (DECSCUSR 1, 2). */
GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK = 1,
/** Underline cursor (DECSCUSR 3, 4). */
GHOSTTY_TERMINAL_CURSOR_STYLE_UNDERLINE = 2,
/** Hollow block cursor. */
GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK_HOLLOW = 3,
GHOSTTY_TERMINAL_CURSOR_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalCursorStyle;
/**
* Scrollbar state for the terminal viewport.
*
@@ -608,6 +628,25 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: GhosttySelection*
*/
GHOSTTY_TERMINAL_OPT_SELECTION = 21,
/**
* Set the default cursor style used by DECSCUSR reset (CSI 0 q).
*
* A NULL value pointer resets to the built-in default block cursor.
*
* Input type: GhosttyTerminalCursorStyle*
*/
GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_STYLE = 22,
/**
* Set whether the default cursor should blink when reset by DECSCUSR
* (CSI 0 q).
*
* A NULL value pointer resets to the built-in default of not blinking.
*
* Input type: bool*
*/
GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_BLINK = 23,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;

View File

@@ -5,6 +5,7 @@ const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
pub const ZigTerminal = @import("../Terminal.zig");
const Stream = @import("../stream_terminal.zig").Stream;
const Screen = @import("../Screen.zig");
const ScreenSet = @import("../ScreenSet.zig");
const PageList = @import("../PageList.zig");
const apc = @import("../apc.zig");
@@ -326,6 +327,8 @@ pub const Option = enum(c_int) {
apc_max_bytes = 19,
apc_max_bytes_kitty = 20,
selection = 21,
default_cursor_style = 22,
default_cursor_blink = 23,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -349,6 +352,8 @@ pub const Option = enum(c_int) {
=> ?*const bool,
.apc_max_bytes, .apc_max_bytes_kitty => ?*const usize,
.selection => ?*const selection_c.CSelection,
.default_cursor_style => ?*const TerminalCursorStyle,
.default_cursor_blink => ?*const bool,
};
}
};
@@ -464,10 +469,43 @@ fn setTyped(
wrapper.terminal.screens.active.clearSelection();
}
},
.default_cursor_style => {
const style = (if (value) |ptr| ptr.* else TerminalCursorStyle.block).toZig() orelse return .invalid_value;
wrapper.stream.handler.default_cursor_style = style;
if (wrapper.stream.handler.default_cursor) {
wrapper.terminal.screens.active.cursor.cursor_style = style;
}
},
.default_cursor_blink => {
const blink = if (value) |ptr| ptr.* else false;
wrapper.stream.handler.default_cursor_blink = blink;
if (wrapper.stream.handler.default_cursor) {
wrapper.terminal.modes.set(.cursor_blinking, blink);
}
},
}
return .success;
}
/// C: GhosttyTerminalCursorStyle
pub const TerminalCursorStyle = enum(c_int) {
bar = 0,
block = 1,
underline = 2,
block_hollow = 3,
_,
fn toZig(self: TerminalCursorStyle) ?Screen.CursorStyle {
return switch (self) {
.bar => .bar,
.block => .block,
.underline => .underline,
.block_hollow => .block_hollow,
_ => null,
};
}
};
/// C: GhosttyDeviceAttributes
pub const DeviceAttributes = Effects.CDeviceAttributes;
@@ -1401,6 +1439,45 @@ test "get invalid" {
try testing.expectEqual(Result.invalid_value, get(t, .invalid, null));
}
test "set default cursor style and blink" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
var default_style: TerminalCursorStyle = .bar;
var default_blink = true;
try testing.expectEqual(Result.success, set(t, .default_cursor_style, @ptrCast(&default_style)));
try testing.expectEqual(Result.success, set(t, .default_cursor_blink, @ptrCast(&default_blink)));
// Setting defaults applies them immediately while the cursor is still default.
try testing.expectEqual(Screen.CursorStyle.bar, t.?.terminal.screens.active.cursor.cursor_style);
try testing.expect(t.?.terminal.modes.get(.cursor_blinking));
// An explicit DECSCUSR style overrides the configured defaults.
vt_write(t, "\x1b[2 q", 5);
try testing.expectEqual(Screen.CursorStyle.block, t.?.terminal.screens.active.cursor.cursor_style);
try testing.expect(!t.?.terminal.modes.get(.cursor_blinking));
// Changing defaults does not override an explicit cursor style.
default_style = .underline;
try testing.expectEqual(Result.success, set(t, .default_cursor_style, @ptrCast(&default_style)));
try testing.expectEqual(Screen.CursorStyle.block, t.?.terminal.screens.active.cursor.cursor_style);
try testing.expect(!t.?.terminal.modes.get(.cursor_blinking));
// DECSCUSR reset restores the configured default style and blink.
vt_write(t, "\x1b[0 q", 5);
try testing.expectEqual(Screen.CursorStyle.underline, t.?.terminal.screens.active.cursor.cursor_style);
try testing.expect(t.?.terminal.modes.get(.cursor_blinking));
}
test "set and get selection" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(

View File

@@ -43,6 +43,11 @@ pub const Handler = struct {
/// the kitty graphics protocol.
apc_handler: apc.Handler = .{},
/// Default cursor style used by DECSCUSR reset (CSI 0 q).
default_cursor: bool = true,
default_cursor_style: Screen.CursorStyle = .block,
default_cursor_blink: bool = false,
pub const Effects = struct {
/// Called when the terminal needs to write data back to the pty,
/// e.g. in response to a DECRQM query. The data is only valid
@@ -152,12 +157,19 @@ pub const Handler = struct {
self.terminal.screens.active.cursor.x + 1,
),
.cursor_style => {
self.default_cursor = false;
const blink = switch (value) {
.default, .steady_block, .steady_bar, .steady_underline => false,
.default => self.default_cursor_blink,
.steady_block, .steady_bar, .steady_underline => false,
.blinking_block, .blinking_bar, .blinking_underline => true,
};
const style: Screen.CursorStyle = switch (value) {
.default, .blinking_block, .steady_block => .block,
.default => style: {
self.default_cursor = true;
break :style self.default_cursor_style;
},
.blinking_block, .steady_block => .block,
.blinking_bar, .steady_bar => .bar,
.blinking_underline, .steady_underline => .underline,
};
@@ -228,7 +240,12 @@ pub const Handler = struct {
},
.active_status_display => self.terminal.status_display = value,
.decaln => try self.terminal.decaln(),
.full_reset => self.terminal.fullReset(),
.full_reset => {
self.terminal.fullReset();
self.default_cursor = true;
self.terminal.modes.set(.cursor_blinking, self.default_cursor_blink);
self.terminal.screens.active.cursor.cursor_style = self.default_cursor_style;
},
.start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id),
.end_hyperlink => self.terminal.screens.active.endHyperlink(),
.semantic_prompt => try self.terminal.semanticPrompt(value),