From 938f20b78d3d3b36db02e2c16401d7c21e04d098 Mon Sep 17 00:00:00 2001 From: Dmitrii Krasnov Date: Fri, 29 May 2026 21:42:48 +0400 Subject: [PATCH] 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 --- src/config/Config.zig | 7 +++++++ src/font/Metrics.zig | 4 ++++ src/font/SharedGrid.zig | 15 +++++++++++++++ src/font/sprite.zig | 11 +++++++++++ src/font/sprite/Face.zig | 17 ++++++++++++++++- src/font/sprite/draw/special.zig | 20 ++++++++++++++++++++ src/renderer/cell.zig | 7 ++++++- src/renderer/cursor.zig | 2 ++ src/renderer/generic.zig | 32 +++++++++++++++++++++++++++++++- src/terminal/c/render.zig | 2 ++ src/terminal/cursor.zig | 5 +++++ src/termio/stream_handler.zig | 1 + 12 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 380155127..24582d090 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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`). diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index a72cb7bee..f35060d6c 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -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, diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 5fd729b30..37ba7dd36 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -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); diff --git a/src/font/sprite.zig b/src/font/sprite.zig index cf86fa6dd..971172c0a 100644 --- a/src/font/sprite.zig +++ b/src/font/sprite.zig @@ -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()); } diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index 596a92044..b89a7270a 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -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); diff --git a/src/font/sprite/draw/special.zig b/src/font/sprite/draw/special.zig index 8cad9ceba..284b11cd2 100644 --- a/src/font/sprite/draw/special.zig +++ b/src/font/sprite/draw/special.zig @@ -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); +} diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 196ebb175..e01278ae1 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -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), } } diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index 33992bc55..d347f0002 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -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, }; } }; diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 0f4a294bc..dd038584c 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, + 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 diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index 0f3b2e781..47c9b4d59 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -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, }; } }; diff --git a/src/terminal/cursor.zig b/src/terminal/cursor.zig index 136ee085a..dc8a6d704 100644 --- a/src/terminal/cursor.zig +++ b/src/terminal/cursor.zig @@ -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, }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index fb3a6b3ff..4b3b5d9f0 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -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}); },