terminal: move cursor defaults into terminal state (#13370)

Cursor defaults were duplicated across stream handlers and it was a
pretty significant amount of simple and yet non-trivial logic to
understand.

Store these on Terminal itself and have methods to route things like
DECSCUSR through for consistent behaviors.

No AI usage here.
This commit is contained in:
Mitchell Hashimoto
2026-07-17 20:16:21 -07:00
committed by GitHub
5 changed files with 159 additions and 109 deletions

View File

@@ -81,6 +81,9 @@ previous_char: ?u21 = null,
/// The modes that this terminal currently has active.
modes: modespkg.ModeState = .{},
/// Terminal-level cursor state.
cursor: Cursor = .{},
/// The most recently set mouse shape for the terminal.
mouse_shape: mouse.Shape = .text,
@@ -236,6 +239,19 @@ pub const ScrollingRegion = struct {
right: size.CellCountInt,
};
/// Terminal-level cursor state shared by all screens.
pub const Cursor = struct {
/// Whether the current cursor appearance follows the configured defaults.
is_default: bool = true,
/// Configured style restored by DECSCUSR default and RIS.
default_style: Screen.CursorStyle = .block,
/// Configured blink restored by DECSCUSR default and RIS. Null selects
/// the terminal emulator default, which is blinking.
default_blink: ?bool = false,
};
pub const Options = struct {
cols: size.CellCountInt,
rows: size.CellCountInt,
@@ -246,6 +262,10 @@ pub const Options = struct {
/// will revert back to this state.
default_modes: modespkg.ModePacked = .{},
/// Cursor state restored by DECSCUSR default and RIS.
default_cursor_style: Screen.CursorStyle = .block,
default_cursor_blink: ?bool = false,
/// The total storage limit for Kitty images in bytes. Has no effect
/// if kitty images are disabled at build-time.
kitty_image_storage_limit: usize = switch (build_options.artifact) {
@@ -284,7 +304,7 @@ pub fn init(
});
errdefer screen_set.deinit(alloc);
return .{
var result: Terminal = .{
.cols = cols,
.rows = rows,
.screens = screen_set,
@@ -302,7 +322,13 @@ pub fn init(
.values = opts.default_modes,
.default = opts.default_modes,
},
.cursor = .{
.default_style = opts.default_cursor_style,
.default_blink = opts.default_cursor_blink,
},
};
result.setCursorStyle(.default);
return result;
}
pub fn deinit(self: *Terminal, alloc: Allocator) void {
@@ -337,6 +363,63 @@ pub fn vtHandler(self: *Terminal) Stream.Handler {
return .init(self);
}
/// Change the cursor's current shape and blink behavior.
///
/// The terminal parser uses this for DECSCUSR (`CSI Ps SP q`), but the behavior
/// is general: `.default` selects the configured defaults, while any other
/// value selects a concrete appearance until it is changed again or reset.
pub fn setCursorStyle(self: *Terminal, value: ansi.CursorStyle) void {
// Remember whether future configuration changes should update the visible
// cursor. An explicit appearance must remain in effect until the program
// selects the default again.
self.cursor.is_default = value == .default;
// Convert the request into the concrete values used by the renderer and
// terminal mode state. A null default blink means the emulator default.
self.modes.set(.cursor_blinking, switch (value) {
.default => self.cursor.default_blink orelse true,
.steady_block, .steady_bar, .steady_underline => false,
.blinking_block, .blinking_bar, .blinking_underline => true,
});
self.screens.active.cursor.cursor_style = switch (value) {
.default => self.cursor.default_style,
.blinking_block, .steady_block => .block,
.blinking_bar, .steady_bar => .bar,
.blinking_underline, .steady_underline => .underline,
};
}
/// Change the default cursor shape.
///
/// If the cursor currently follows its defaults, the visible shape changes
/// immediately. Otherwise the new shape is saved for the next reset or default
/// selection, such as DECSCUSR `CSI 0 SP q`.
pub fn setDefaultCursorStyle(
self: *Terminal,
configured_style: Screen.CursorStyle,
) void {
// Always retain the new default, even while an explicit appearance is
// active, so a later reset or default request can restore it.
self.cursor.default_style = configured_style;
// Do not overwrite an appearance explicitly selected by the program.
if (self.cursor.is_default) self.setCursorStyle(.default);
}
/// Change the default cursor blink behavior.
///
/// Null selects the terminal emulator default (blinking). Like the default
/// shape, this is applied immediately only when the cursor currently follows
/// its defaults; otherwise it is saved for the next reset or default selection.
pub fn setDefaultCursorBlink(self: *Terminal, blink: ?bool) void {
// Keep the configured value separate from the currently resolved mode so
// null can continue to mean "use the emulator default."
self.cursor.default_blink = blink;
// Do not overwrite blink behavior explicitly selected by the program.
if (self.cursor.is_default) self.setCursorStyle(.default);
}
/// The general allocator we should use for this terminal.
pub fn gpa(self: *Terminal) Allocator {
return self.screens.active.alloc;
@@ -4331,6 +4414,7 @@ pub fn fullReset(self: *Terminal) void {
.left = 0,
.right = self.cols - 1,
};
self.setCursorStyle(.default);
// Always mark dirty so we redraw everything
self.flags.dirty.clear = true;
@@ -14134,6 +14218,64 @@ test "Terminal: cursorIsAtPrompt alternate screen" {
try testing.expect(!t.cursorIsAtPrompt());
}
test "Terminal: cursor defaults update current default cursor" {
var t = try init(testing.allocator, .{
.cols = 10,
.rows = 10,
.default_cursor_style = .bar,
.default_cursor_blink = true,
});
defer t.deinit(testing.allocator);
// Initialization applies the configured defaults.
try testing.expect(t.cursor.is_default);
try testing.expectEqual(.bar, t.screens.active.cursor.cursor_style);
try testing.expect(t.modes.get(.cursor_blinking));
// Configuration changes are immediately visible while the cursor still
// follows its defaults.
t.setDefaultCursorStyle(.underline);
t.setDefaultCursorBlink(false);
try testing.expect(t.cursor.is_default);
try testing.expectEqual(.underline, t.screens.active.cursor.cursor_style);
try testing.expect(!t.modes.get(.cursor_blinking));
// Null restores the terminal emulator's blinking default.
t.setDefaultCursorBlink(null);
try testing.expect(t.modes.get(.cursor_blinking));
}
test "Terminal: cursor defaults do not override explicit cursor" {
var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
t.setCursorStyle(.blinking_bar);
try testing.expect(!t.cursor.is_default);
try testing.expectEqual(.bar, t.screens.active.cursor.cursor_style);
try testing.expect(t.modes.get(.cursor_blinking));
// New defaults are retained without replacing the explicit appearance.
t.setDefaultCursorStyle(.underline);
t.setDefaultCursorBlink(false);
try testing.expectEqual(.underline, t.cursor.default_style);
try testing.expectEqual(false, t.cursor.default_blink);
try testing.expectEqual(.bar, t.screens.active.cursor.cursor_style);
try testing.expect(t.modes.get(.cursor_blinking));
// Selecting the default applies the values that changed above.
t.setCursorStyle(.default);
try testing.expect(t.cursor.is_default);
try testing.expectEqual(.underline, t.screens.active.cursor.cursor_style);
try testing.expect(!t.modes.get(.cursor_blinking));
// A full reset also leaves the cursor on the configured defaults.
t.setCursorStyle(.steady_block);
t.fullReset();
try testing.expect(t.cursor.is_default);
try testing.expectEqual(.underline, t.screens.active.cursor.cursor_style);
try testing.expect(!t.modes.get(.cursor_blinking));
}
test "Terminal: fullReset with a non-empty pen" {
var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 });
defer t.deinit(testing.allocator);

View File

@@ -589,17 +589,11 @@ fn setTyped(
},
.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;
}
wrapper.terminal.setDefaultCursorStyle(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);
}
wrapper.terminal.setDefaultCursorBlink(blink);
},
}
return .success;
@@ -1837,6 +1831,12 @@ test "set default cursor 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));
// RIS also restores cursor defaults from Terminal-owned state.
vt_write(t, "\x1b[2 q", 5);
reset(t);
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" {

View File

@@ -60,11 +60,6 @@ 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
@@ -228,26 +223,7 @@ pub const Handler = struct {
self.terminal.screens.active.cursor.y + 1 +| value.value,
self.terminal.screens.active.cursor.x + 1,
),
.cursor_style => {
self.default_cursor = false;
const blink = switch (value) {
.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 => 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,
};
self.terminal.modes.set(.cursor_blinking, blink);
self.terminal.screens.active.cursor.cursor_style = style;
},
.cursor_style => self.terminal.setCursorStyle(value),
.erase_display_below => self.terminal.eraseDisplay(.below, value),
.erase_display_above => self.terminal.eraseDisplay(.above, value),
.erase_display_complete => self.terminal.eraseDisplay(.complete, value),
@@ -312,12 +288,7 @@ pub const Handler = struct {
},
.active_status_display => self.terminal.status_display = value,
.decaln => try self.terminal.decaln(),
.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;
},
.full_reset => self.terminal.fullReset(),
.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),

View File

@@ -245,6 +245,8 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
.rows = grid_size.rows,
.max_scrollback = opts.full_config.@"scrollback-limit",
.default_modes = default_modes,
.default_cursor_style = opts.config.cursor_style,
.default_cursor_blink = opts.config.cursor_blink,
.colors = .{
.background = .init(opts.config.background.toTerminalRGB()),
.foreground = .init(opts.config.foreground.toTerminalRGB()),
@@ -261,9 +263,6 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
});
errdefer term.deinit(alloc);
// Set our default cursor style
term.screens.active.cursor.cursor_style = opts.config.cursor_style;
// Setup our terminal size in pixels for certain requests.
term.width_px = term.cols * opts.size.cell.width;
term.height_px = term.rows * opts.size.cell.height;
@@ -286,8 +285,6 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
.osc_color_report_format = opts.config.osc_color_report_format,
.clipboard_write = opts.config.clipboard_write,
.enquiry_response = opts.config.enquiry_response,
.default_cursor_style = opts.config.cursor_style,
.default_cursor_blink = opts.config.cursor_blink,
};
const thread_enter_state = try ThreadEnterState.create(

View File

@@ -40,12 +40,6 @@ pub const StreamHandler = struct {
/// a repaint should happen.
renderer_wakeup: xev.Async,
/// The default cursor state. This is used with CSI q. This is
/// set to true when we're currently in the default cursor state.
default_cursor: bool = true,
default_cursor_style: terminal.CursorStyle,
default_cursor_blink: ?bool,
/// The response to use for ENQ requests. The memory is owned by
/// whoever owns StreamHandler.
enquiry_response: []const u8,
@@ -110,13 +104,8 @@ pub const StreamHandler = struct {
self.osc_color_report_format = config.osc_color_report_format;
self.clipboard_write = config.clipboard_write;
self.enquiry_response = config.enquiry_response;
self.default_cursor_style = config.cursor_style;
self.default_cursor_blink = config.cursor_blink;
// If our cursor is the default, then we update it immediately.
if (self.default_cursor) self.setCursorStyle(.default) catch |err| {
log.warn("failed to set default cursor style: {}", .{err});
};
self.terminal.setDefaultCursorStyle(config.cursor_style);
self.terminal.setDefaultCursorBlink(config.cursor_blink);
// The config could have changed any of our colors so update mode 2031
self.messageWriter(.{ .color_scheme_report = .{ .force = false } });
@@ -242,7 +231,7 @@ pub const StreamHandler = struct {
self.terminal.screens.active.cursor.y + 1 +| value.value,
self.terminal.screens.active.cursor.x + 1,
),
.cursor_style => try self.setCursorStyle(value),
.cursor_style => self.terminal.setCursorStyle(value),
.erase_display_below => self.terminal.eraseDisplay(.below, value),
.erase_display_above => self.terminal.eraseDisplay(.above, value),
.erase_display_complete => {
@@ -687,7 +676,7 @@ pub const StreamHandler = struct {
// their shell config when they render prompts to ensure the
// cursor is exactly as they request.
if (mode == .cursor_blinking and
self.default_cursor_blink != null)
self.terminal.cursor.default_blink != null)
{
return;
}
@@ -894,55 +883,6 @@ pub const StreamHandler = struct {
}
}
pub fn setCursorStyle(
self: *StreamHandler,
style: terminal.CursorStyleReq,
) !void {
// Assume we're setting to a non-default.
self.default_cursor = false;
switch (style) {
.default => {
self.default_cursor = true;
self.terminal.screens.active.cursor.cursor_style = self.default_cursor_style;
self.terminal.modes.set(
.cursor_blinking,
self.default_cursor_blink orelse true,
);
},
.blinking_block => {
self.terminal.screens.active.cursor.cursor_style = .block;
self.terminal.modes.set(.cursor_blinking, true);
},
.steady_block => {
self.terminal.screens.active.cursor.cursor_style = .block;
self.terminal.modes.set(.cursor_blinking, false);
},
.blinking_underline => {
self.terminal.screens.active.cursor.cursor_style = .underline;
self.terminal.modes.set(.cursor_blinking, true);
},
.steady_underline => {
self.terminal.screens.active.cursor.cursor_style = .underline;
self.terminal.modes.set(.cursor_blinking, false);
},
.blinking_bar => {
self.terminal.screens.active.cursor.cursor_style = .bar;
self.terminal.modes.set(.cursor_blinking, true);
},
.steady_bar => {
self.terminal.screens.active.cursor.cursor_style = .bar;
self.terminal.modes.set(.cursor_blinking, false);
},
}
}
pub inline fn decaln(self: *StreamHandler) !void {
try self.terminal.decaln();
}