feat(cursor): add vintage cursor style with configurable height

Add new `vintage` cursor style that renders a partial-height block
from the bottom of the cell, similar to classic terminal cursors.

New config options:
- cursor-style = vintage
- cursor-style-vintage-height = 1..100 (percent of cell height, default 25)

Height is encoded in the sprite codepoint offset so each height value
gets its own cache entry. The renderer injects cursor_vintage_height
into grid_metrics before sprite rendering so Face.zig passes the
correct value to the draw function. Sprite cache is invalidated on
config reload so height changes apply immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pull/12860/head
Dmitrii Krasnov 2026-05-29 21:42:48 +04:00
parent 90175950d5
commit 938f20b78d
12 changed files with 120 additions and 3 deletions

View File

@ -872,8 +872,15 @@ palette: Palette = .{},
/// * `bar`
/// * `underline`
/// * `block_hollow`
/// * `vintage`
@"cursor-style": terminal.CursorStyle = .block,
/// The height of the vintage cursor as a percentage of the cell height.
/// This only takes effect when `cursor-style` is set to `vintage`.
///
/// Valid values are integers from 1 to 100.
@"cursor-style-vintage-height": u8 = 25,
/// Sets the default blinking state of the cursor. This is just the default
/// state; running programs may override the cursor style using `DECSCUSR` (`CSI
/// q`).

View File

@ -36,6 +36,9 @@ cursor_thickness: u32 = 1,
/// The height in pixels of the cursor sprite.
cursor_height: u32,
/// The height of the vintage cursor as a percentage (1-100).
cursor_vintage_height: u32 = 25,
/// The constraint height for nerd fonts icons.
icon_height: f64,
@ -619,6 +622,7 @@ fn init() Metrics {
.overline_thickness = 0,
.box_thickness = 0,
.cursor_height = 0,
.cursor_vintage_height = 25,
.icon_height = 0.0,
.icon_height_single = 0.0,
.face_width = 0.0,

View File

@ -116,6 +116,21 @@ pub fn init(
}
/// Deinit. Assumes no concurrent access so no lock is taken.
/// Remove all cached sprite glyphs so they are redrawn on next use.
/// Call this when sprite drawing parameters change (e.g. cursor height).
pub fn invalidateSpriteGlyphs(self: *SharedGrid, alloc: Allocator) void {
self.lock.lock();
defer self.lock.unlock();
var it = self.glyphs.iterator();
while (it.next()) |entry| {
if (entry.key_ptr.index.special() == .sprite) {
self.glyphs.removeByPtr(entry.key_ptr);
it = self.glyphs.iterator();
}
}
_ = alloc;
}
pub fn deinit(self: *SharedGrid, alloc: Allocator) void {
self.codepoints.deinit(alloc);
self.glyphs.deinit(alloc);

View File

@ -33,6 +33,10 @@ pub const Sprite = enum(u32) {
cursor_hollow_rect,
cursor_bar,
cursor_underline,
// cursor_vintage is the base codepoint for vintage cursor sprites.
// Heights 1..100 are encoded as cursor_vintage+0 .. cursor_vintage+99.
// Use sprite.cursorVintageCp() to get the codepoint for a given height.
cursor_vintage,
test {
const testing = std.testing;
@ -40,6 +44,13 @@ pub const Sprite = enum(u32) {
}
};
/// Returns the codepoint for a vintage cursor of the given height percent.
/// Height 1..100 maps to cursor_vintage+0 .. cursor_vintage+99.
pub fn cursorVintageCp(height_pct: u32) u32 {
const h = @max(1, @min(100, height_pct));
return @intFromEnum(Sprite.cursor_vintage) + h - 1;
}
test {
@import("std").testing.refAllDecls(@This());
}

View File

@ -145,6 +145,13 @@ const ranges: []const Range = ranges: {
};
fn getDrawFn(cp: u32) ?*const DrawFn {
// cursor_vintage occupies a range of 100 codepoints (heights 1..100).
if (cp >= @intFromEnum(Sprite.cursor_vintage) and
cp <= font.sprite.cursorVintageCp(100))
{
return special.cursor_vintage;
}
// For special sprites (cursors, underlines, etc.) all sprites are drawn
// by functions from `Special` that share the name of the enum field.
if (cp >= Sprite.start) switch (@as(Sprite, @enumFromInt(cp))) {
@ -214,7 +221,15 @@ pub fn renderGlyph(
var canvas = try font.sprite.Canvas.init(alloc, width, height, padding_x, padding_y);
defer canvas.deinit();
try draw(cp, &canvas, width, height, metrics);
// For cursor_vintage we pass opts.grid_metrics so the caller can
// inject cursor_vintage_height without affecting the cache key.
const draw_metrics = if (cp >= @intFromEnum(Sprite.cursor_vintage) and
cp <= font.sprite.cursorVintageCp(100))
opts.grid_metrics
else
metrics;
try draw(cp, &canvas, width, height, draw_metrics);
// Write the drawing to the atlas
const region = try canvas.writeAtlas(alloc, atlas);

View File

@ -370,3 +370,23 @@ pub fn cursor_underline(
.height = @intCast(metrics.cursor_thickness),
}, .on);
}
pub fn cursor_vintage(
cp: u32,
canvas: *font.sprite.Canvas,
width: u32,
height: u32,
metrics: font.Metrics,
) !void {
_ = cp;
const pct: u32 = @max(1, @min(100, metrics.cursor_vintage_height));
const cursor_h: u32 = @max(1, height * pct / 100);
const y: u32 = height -| cursor_h;
canvas.rect(.{
.x = 0,
.y = @intCast(y),
.width = @intCast(width),
.height = @intCast(cursor_h),
}, .on);
}

View File

@ -152,7 +152,12 @@ pub const Contents = struct {
// Block cursors should be drawn first
.block => self.fg_rows.lists[0].appendAssumeCapacity(cell),
// Other cursor styles should be drawn last
.block_hollow, .bar, .underline, .lock => self.fg_rows.lists[self.size.rows + 1].appendAssumeCapacity(cell),
.block_hollow,
.bar,
.underline,
.vintage,
.lock,
=> self.fg_rows.lists[self.size.rows + 1].appendAssumeCapacity(cell),
}
}

View File

@ -10,6 +10,7 @@ pub const Style = enum {
block_hollow,
bar,
underline,
vintage,
// Special cursor styles
lock,
@ -21,6 +22,7 @@ pub const Style = enum {
.block => .block,
.block_hollow => .block_hollow,
.underline => .underline,
.vintage => .vintage,
};
}
};

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,
cursor_vintage_height: u32,
background: terminal.color.RGB,
background_opacity: f64,
background_opacity_cells: bool,
@ -616,6 +617,9 @@ 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")),
.cursor_vintage_height = @max(
1, @min(100, config.@"cursor-style-vintage-height"),
),
.background = config.background.toTerminalRGB(),
.foreground = config.foreground.toTerminalRGB(),
@ -1888,6 +1892,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.config.deinit();
self.config = config.*;
// Always invalidate sprite cache on config change so cursor
// vintage height is always re-rendered with current settings.
self.font_grid.invalidateSpriteGlyphs(self.alloc);
// If our background image path changed, prepare the new bg image.
if (bg_image_changed) try self.prepBackgroundImage();
@ -3259,7 +3266,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.block_hollow => .cursor_hollow_rect,
.bar => .cursor_bar,
.underline => .cursor_underline,
.lock => unreachable,
.lock, .vintage => unreachable,
};
break :render self.font_grid.renderGlyph(
@ -3276,6 +3283,29 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
};
},
.vintage => render: {
var vintage_metrics = self.grid_metrics;
vintage_metrics.cursor_vintage_height =
self.config.cursor_vintage_height;
break :render self.font_grid.renderGlyph(
self.alloc,
font.sprite_index,
font.sprite.cursorVintageCp(
self.config.cursor_vintage_height,
),
.{
.cell_width = if (wide) 2 else 1,
.grid_metrics = vintage_metrics,
},
) catch |err| {
log.warn(
"error rendering vintage cursor err={}",
.{err},
);
return;
};
},
.lock => self.font_grid.renderCodepoint(
self.alloc,
0xF023, // lock symbol

View File

@ -83,6 +83,8 @@ pub const CursorVisualStyle = enum(c_int) {
.block => .block,
.underline => .underline,
.block_hollow => .block_hollow,
// vintage is a Ghostty-only style; report as underline
.vintage => .underline,
};
}
};

View File

@ -12,4 +12,9 @@ pub const Style = enum {
/// Hollow block cursor. This is a block cursor with the center empty.
/// Reported as DECSCUSR 1 or 2 (block).
block_hollow,
/// Vintage cursor. A partial-height block that fills the cell from
/// the bottom up. Height is controlled by cursor-style-vintage-height.
/// Reported as DECSCUSR 3 or 4 (underline).
vintage,
};

View File

@ -504,6 +504,7 @@ pub const StreamHandler = struct {
// Below here, the cursor styles aren't represented by
// DECSCUSR so we map it to some other style.
.block_hollow => if (blink) 1 else 2,
.vintage => if (blink) 3 else 4,
};
try writer.print("{d} q", .{style});
},