Merge branch 'main' of github.com:ghostty-org/ghostty

This commit is contained in:
Anthony
2024-12-28 03:33:45 +11:00
30 changed files with 1148 additions and 238 deletions

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Mitchell Hashimoto
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -43,7 +43,7 @@ comptime {
} }
/// The version of the next release. /// The version of the next release.
const app_version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 }; const app_version = std.SemanticVersion{ .major = 1, .minor = 0, .patch = 1 };
pub fn build(b: *std.Build) !void { pub fn build(b: *std.Build) !void {
const optimize = b.standardOptimizeOption(.{}); const optimize = b.standardOptimizeOption(.{});

View File

@ -1,6 +1,6 @@
.{ .{
.name = "ghostty", .name = "ghostty",
.version = "0.1.0", .version = "1.0.1",
.paths = .{""}, .paths = .{""},
.dependencies = .{ .dependencies = .{
// Zig libs // Zig libs

17
flake.lock generated
View File

@ -1,5 +1,21 @@
{ {
"nodes": { "nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@ -52,6 +68,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"flake-compat": "flake-compat",
"nixpkgs-stable": "nixpkgs-stable", "nixpkgs-stable": "nixpkgs-stable",
"nixpkgs-unstable": "nixpkgs-unstable", "nixpkgs-unstable": "nixpkgs-unstable",
"zig": "zig" "zig": "zig"

View File

@ -9,6 +9,12 @@
# system glibc that the user is building for. # system glibc that the user is building for.
nixpkgs-stable.url = "github:nixos/nixpkgs/release-24.11"; nixpkgs-stable.url = "github:nixos/nixpkgs/release-24.11";
# Used for shell.nix
flake-compat = {
url = "github:edolstra/flake-compat";
flake = false;
};
zig = { zig = {
url = "github:mitchellh/zig-overlay"; url = "github:mitchellh/zig-overlay";
inputs = { inputs = {

View File

@ -5,6 +5,7 @@ enum QuickTerminalPosition : String {
case bottom case bottom
case left case left
case right case right
case center
/// Set the loaded state for a window. /// Set the loaded state for a window.
func setLoaded(_ window: NSWindow) { func setLoaded(_ window: NSWindow) {
@ -25,6 +26,14 @@ enum QuickTerminalPosition : String {
width: screen.frame.width / 4, width: screen.frame.width / 4,
height: screen.frame.height) height: screen.frame.height)
), display: false) ), display: false)
case .center:
window.setFrame(.init(
origin: window.frame.origin,
size: .init(
width: screen.frame.width / 2,
height: screen.frame.height / 3)
), display: false)
} }
} }
@ -61,6 +70,10 @@ enum QuickTerminalPosition : String {
case .left, .right: case .left, .right:
finalSize.height = screen.frame.height finalSize.height = screen.frame.height
case .center:
finalSize.width = screen.frame.width / 2
finalSize.height = screen.frame.height / 3
} }
return finalSize return finalSize
@ -80,6 +93,9 @@ enum QuickTerminalPosition : String {
case .right: case .right:
return .init(x: screen.frame.maxX, y: 0) return .init(x: screen.frame.maxX, y: 0)
case .center:
return .init(x: (screen.visibleFrame.maxX - window.frame.width) / 2, y: screen.visibleFrame.maxY - window.frame.width)
} }
} }
@ -97,6 +113,9 @@ enum QuickTerminalPosition : String {
case .right: case .right:
return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y) return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y)
case .center:
return .init(x: (screen.visibleFrame.maxX - window.frame.width) / 2, y: (screen.visibleFrame.maxY - window.frame.height) / 2)
} }
} }
} }

View File

@ -409,6 +409,11 @@ extension Ghostty {
// ID. If vsync is enabled, this will be used with the CVDisplayLink to ensure // ID. If vsync is enabled, this will be used with the CVDisplayLink to ensure
// the proper refresh rate is going. // the proper refresh rate is going.
ghostty_surface_set_display_id(surface, screen.displayID ?? 0) ghostty_surface_set_display_id(surface, screen.displayID ?? 0)
// We also just trigger a backing property change. Just in case the screen has
// a different scaling factor, this ensures that we update our content scale.
// Issue: https://github.com/ghostty-org/ghostty/issues/2731
viewDidChangeBackingProperties()
} }
// MARK: - NSView // MARK: - NSView
@ -570,6 +575,20 @@ extension Ghostty {
super.rightMouseUp(with: event) super.rightMouseUp(with: event)
} }
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
guard let surface = self.surface else { return }
// On mouse enter we need to reset our cursor position. This is
// super important because we set it to -1/-1 on mouseExit and
// lots of mouse logic (i.e. whether to send mouse reports) depend
// on the position being in the viewport if it is.
let pos = self.convert(event.locationInWindow, from: nil)
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods)
}
override func mouseExited(with event: NSEvent) { override func mouseExited(with event: NSEvent) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }

View File

@ -110,7 +110,7 @@
in in
stdenv.mkDerivation (finalAttrs: { stdenv.mkDerivation (finalAttrs: {
pname = "ghostty"; pname = "ghostty";
version = "0.1.0"; version = "1.0.1";
inherit src; inherit src;
nativeBuildInputs = [ nativeBuildInputs = [

View File

@ -221,6 +221,9 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
switch (config.@"window-theme") { switch (config.@"window-theme") {
.system, .light => {}, .system, .light => {},
.dark => { .dark => {
const settings = c.gtk_settings_get_default();
c.g_object_set(@ptrCast(@alignCast(settings)), "gtk-application-prefer-dark-theme", true, @as([*c]const u8, null));
c.gtk_css_provider_load_from_resource( c.gtk_css_provider_load_from_resource(
provider, provider,
"/com/mitchellh/ghostty/style-dark.css", "/com/mitchellh/ghostty/style-dark.css",
@ -234,6 +237,9 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
.auto, .ghostty => { .auto, .ghostty => {
const lum = config.background.toTerminalRGB().perceivedLuminance(); const lum = config.background.toTerminalRGB().perceivedLuminance();
if (lum <= 0.5) { if (lum <= 0.5) {
const settings = c.gtk_settings_get_default();
c.g_object_set(@ptrCast(@alignCast(settings)), "gtk-application-prefer-dark-theme", true, @as([*c]const u8, null));
c.gtk_css_provider_load_from_resource( c.gtk_css_provider_load_from_resource(
provider, provider,
"/com/mitchellh/ghostty/style-dark.css", "/com/mitchellh/ghostty/style-dark.css",

View File

@ -386,7 +386,8 @@ fn keyEvent(
// Try to process the event as text // Try to process the event as text
const event = c.gtk_event_controller_get_current_event(@ptrCast(ec_key)); const event = c.gtk_event_controller_get_current_event(@ptrCast(ec_key));
_ = c.gtk_im_context_filter_keypress(self.im_context, event); if (event != null)
_ = c.gtk_im_context_filter_keypress(self.im_context, event);
return true; return true;
} }

View File

@ -1341,8 +1341,9 @@ fn gtkMouseDown(
y: c.gdouble, y: c.gdouble,
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
const event = c.gtk_event_controller_get_current_event(@ptrCast(gesture)) orelse return;
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
const event = c.gtk_event_controller_get_current_event(@ptrCast(gesture));
const gtk_mods = c.gdk_event_get_modifier_state(event); const gtk_mods = c.gdk_event_get_modifier_state(event);
const button = translateMouseButton(c.gtk_gesture_single_get_current_button(@ptrCast(gesture))); const button = translateMouseButton(c.gtk_gesture_single_get_current_button(@ptrCast(gesture)));
@ -1374,7 +1375,8 @@ fn gtkMouseUp(
_: c.gdouble, _: c.gdouble,
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
const event = c.gtk_event_controller_get_current_event(@ptrCast(gesture)); const event = c.gtk_event_controller_get_current_event(@ptrCast(gesture)) orelse return;
const gtk_mods = c.gdk_event_get_modifier_state(event); const gtk_mods = c.gdk_event_get_modifier_state(event);
const button = translateMouseButton(c.gtk_gesture_single_get_current_button(@ptrCast(gesture))); const button = translateMouseButton(c.gtk_gesture_single_get_current_button(@ptrCast(gesture)));
@ -1393,6 +1395,8 @@ fn gtkMouseMotion(
y: c.gdouble, y: c.gdouble,
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
const event = c.gtk_event_controller_get_current_event(@ptrCast(ec)) orelse return;
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
const scaled = self.scaledCoordinates(x, y); const scaled = self.scaledCoordinates(x, y);
@ -1401,13 +1405,6 @@ fn gtkMouseMotion(
.y = @floatCast(scaled.y), .y = @floatCast(scaled.y),
}; };
// GTK can send spurious mouse movement events. Ignore them
// because this can cause actual issues:
// https://github.com/ghostty-org/ghostty/issues/2022
if (pos.x == self.cursor_pos.x and pos.y == self.cursor_pos.y) {
return;
}
// Our pos changed, update // Our pos changed, update
self.cursor_pos = pos; self.cursor_pos = pos;
@ -1418,7 +1415,6 @@ fn gtkMouseMotion(
} }
// Get our modifiers // Get our modifiers
const event = c.gtk_event_controller_get_current_event(@ptrCast(ec));
const gtk_mods = c.gdk_event_get_modifier_state(event); const gtk_mods = c.gdk_event_get_modifier_state(event);
const mods = gtk_key.translateMods(gtk_mods); const mods = gtk_key.translateMods(gtk_mods);
@ -1432,10 +1428,11 @@ fn gtkMouseLeave(
ec: *c.GtkEventControllerMotion, ec: *c.GtkEventControllerMotion,
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
const event = c.gtk_event_controller_get_current_event(@ptrCast(ec)) orelse return;
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
// Get our modifiers // Get our modifiers
const event = c.gtk_event_controller_get_current_event(@ptrCast(ec));
const gtk_mods = c.gdk_event_get_modifier_state(event); const gtk_mods = c.gdk_event_get_modifier_state(event);
const mods = gtk_key.translateMods(gtk_mods); const mods = gtk_key.translateMods(gtk_mods);
self.core_surface.cursorPosCallback(.{ .x = -1, .y = -1 }, mods) catch |err| { self.core_surface.cursorPosCallback(.{ .x = -1, .y = -1 }, mods) catch |err| {
@ -1536,11 +1533,12 @@ pub fn keyEvent(
keycode: c.guint, keycode: c.guint,
gtk_mods: c.GdkModifierType, gtk_mods: c.GdkModifierType,
) bool { ) bool {
const keyval_unicode = c.gdk_keyval_to_unicode(keyval);
const event = c.gtk_event_controller_get_current_event( const event = c.gtk_event_controller_get_current_event(
@ptrCast(ec_key), @ptrCast(ec_key),
) orelse return false; ) orelse return false;
const keyval_unicode = c.gdk_keyval_to_unicode(keyval);
// Get the unshifted unicode value of the keyval. This is used // Get the unshifted unicode value of the keyval. This is used
// by the Kitty keyboard protocol. // by the Kitty keyboard protocol.
const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted( const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted(

View File

@ -200,7 +200,7 @@ pub fn init(self: *Window, app: *App) !void {
const btn = c.gtk_button_new_from_icon_name("tab-new-symbolic"); const btn = c.gtk_button_new_from_icon_name("tab-new-symbolic");
c.gtk_widget_set_tooltip_text(btn, "New Tab"); c.gtk_widget_set_tooltip_text(btn, "New Tab");
_ = c.g_signal_connect_data(btn, "clicked", c.G_CALLBACK(&gtkTabNewClick), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(btn, "clicked", c.G_CALLBACK(&gtkTabNewClick), self, null, c.G_CONNECT_DEFAULT);
header.packEnd(btn); header.packStart(btn);
} }
self.header = header; self.header = header;

View File

@ -22,9 +22,19 @@ pub fn genKeybindActions(writer: anytype) !void {
@setEvalBranchQuota(5_000); @setEvalBranchQuota(5_000);
const fields = @typeInfo(KeybindAction).Union.fields; const fields = @typeInfo(KeybindAction).Union.fields;
var buffer = std.ArrayList(u8).init(std.heap.page_allocator);
inline for (fields) |field| { inline for (fields) |field| {
if (field.name[0] == '_') continue; if (field.name[0] == '_') continue;
// Write previously stored doc comment below all related actions
if (@hasDecl(help_strings.KeybindAction, field.name)) {
try writer.writeAll(buffer.items);
try writer.writeAll("\n");
buffer.clearRetainingCapacity();
}
// Write the field name. // Write the field name.
try writer.writeAll("## `"); try writer.writeAll("## `");
try writer.writeAll(field.name); try writer.writeAll(field.name);
@ -37,10 +47,9 @@ pub fn genKeybindActions(writer: anytype) !void {
'\n', '\n',
); );
while (iter.next()) |s| { while (iter.next()) |s| {
try writer.writeAll(s); try buffer.appendSlice(s);
try writer.writeAll("\n"); try buffer.appendSlice("\n");
} }
try writer.writeAll("\n\n");
} }
} }
} }

View File

@ -73,11 +73,11 @@ const ThemeListElement = struct {
/// ///
/// The second directory is the `themes` subdirectory of the Ghostty resources /// The second directory is the `themes` subdirectory of the Ghostty resources
/// directory. Ghostty ships with a multitude of themes that will be installed /// directory. Ghostty ships with a multitude of themes that will be installed
/// into this directory. On macOS, this directory is the `Ghostty.app/Contents/ /// into this directory. On macOS, this directory is the
/// Resources/ghostty/themes`. On Linux, this directory is the `share/ghostty/ /// `Ghostty.app/Contents/Resources/ghostty/themes`. On Linux, this directory
/// themes` (wherever you installed the Ghostty "share" directory). If you're /// is the `share/ghostty/themes` (wherever you installed the Ghostty "share"
/// running Ghostty from the source, this is the `zig-out/share/ghostty/themes` /// directory). If you're running Ghostty from the source, this is the
/// directory. /// `zig-out/share/ghostty/themes` directory.
/// ///
/// You can also set the `GHOSTTY_RESOURCES_DIR` environment variable to point /// You can also set the `GHOSTTY_RESOURCES_DIR` environment variable to point
/// to the resources directory. /// to the resources directory.

View File

@ -351,10 +351,10 @@ const c = @cImport({
/// ///
/// The second directory is the `themes` subdirectory of the Ghostty resources /// The second directory is the `themes` subdirectory of the Ghostty resources
/// directory. Ghostty ships with a multitude of themes that will be installed /// directory. Ghostty ships with a multitude of themes that will be installed
/// into this directory. On macOS, this list is in the `Ghostty.app/Contents/ /// into this directory. On macOS, this list is in the
/// Resources/ghostty/themes` directory. On Linux, this list is in the `share/ /// `Ghostty.app/Contents/Resources/ghostty/themes` directory. On Linux, this
/// ghostty/themes` directory (wherever you installed the Ghostty "share" /// list is in the `share/ghostty/themes` directory (wherever you installed the
/// directory. /// Ghostty "share" directory.
/// ///
/// To see a list of available themes, run `ghostty +list-themes`. /// To see a list of available themes, run `ghostty +list-themes`.
/// ///
@ -1189,12 +1189,12 @@ keybind: Keybinds = .{},
/// value larger than this will be clamped to the maximum value. /// value larger than this will be clamped to the maximum value.
@"resize-overlay-duration": Duration = .{ .duration = 750 * std.time.ns_per_ms }, @"resize-overlay-duration": Duration = .{ .duration = 750 * std.time.ns_per_ms },
// If true, when there are multiple split panes, the mouse selects the pane /// If true, when there are multiple split panes, the mouse selects the pane
// that is focused. This only applies to the currently focused window; i.e. /// that is focused. This only applies to the currently focused window; i.e.
// mousing over a split in an unfocused window will now focus that split /// mousing over a split in an unfocused window will now focus that split
// and bring the window to front. /// and bring the window to front.
// ///
// Default is false. /// Default is false.
@"focus-follows-mouse": bool = false, @"focus-follows-mouse": bool = false,
/// Whether to allow programs running in the terminal to read/write to the /// Whether to allow programs running in the terminal to read/write to the
@ -1377,6 +1377,7 @@ keybind: Keybinds = .{},
/// * `bottom` - Terminal appears at the bottom of the screen. /// * `bottom` - Terminal appears at the bottom of the screen.
/// * `left` - Terminal appears at the left of the screen. /// * `left` - Terminal appears at the left of the screen.
/// * `right` - Terminal appears at the right of the screen. /// * `right` - Terminal appears at the right of the screen.
/// * `center` - Terminal appears at the center of the screen.
/// ///
/// Changing this configuration requires restarting Ghostty completely. /// Changing this configuration requires restarting Ghostty completely.
@"quick-terminal-position": QuickTerminalPosition = .top, @"quick-terminal-position": QuickTerminalPosition = .top,
@ -2532,6 +2533,32 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
.{ .key = .{ .translated = .f }, .mods = .{ .super = true, .ctrl = true } }, .{ .key = .{ .translated = .f }, .mods = .{ .super = true, .ctrl = true } },
.{ .toggle_fullscreen = {} }, .{ .toggle_fullscreen = {} },
); );
// "Natural text editing" keybinds. This forces these keys to go back
// to legacy encoding (not fixterms). It seems macOS users more than
// others are used to these keys so we set them as defaults. If
// people want to get back to the fixterm encoding they can set
// the keybinds to `unbind`.
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .right }, .mods = .{ .super = true } },
.{ .text = "\\x05" },
);
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .left }, .mods = .{ .super = true } },
.{ .text = "\\x01" },
);
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .left }, .mods = .{ .alt = true } },
.{ .esc = "b" },
);
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .right }, .mods = .{ .alt = true } },
.{ .esc = "f" },
);
} }
// Add our default link for URL detection // Add our default link for URL detection
@ -5257,6 +5284,7 @@ pub const QuickTerminalPosition = enum {
bottom, bottom,
left, left,
right, right,
center,
}; };
/// See quick-terminal-screen /// See quick-terminal-screen

View File

@ -1,4 +1,5 @@
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const internal_os = @import("../os/main.zig"); const internal_os = @import("../os/main.zig");
@ -6,7 +7,24 @@ const internal_os = @import("../os/main.zig");
/// paths the main config file could be in. /// paths the main config file could be in.
pub fn open(alloc_gpa: Allocator) !void { pub fn open(alloc_gpa: Allocator) !void {
// default location // default location
const config_path = try internal_os.xdg.config(alloc_gpa, .{ .subdir = "ghostty/config" }); const config_path = config_path: {
const xdg_config_path = try internal_os.xdg.config(alloc_gpa, .{ .subdir = "ghostty/config" });
if (comptime builtin.os.tag == .macos) macos: {
// On macOS, use the application support path if the XDG path doesn't exists.
if (std.fs.accessAbsolute(xdg_config_path, .{})) {
break :macos;
} else |err| switch (err) {
error.BadPathName, error.FileNotFound => {},
else => break :macos,
}
alloc_gpa.free(xdg_config_path);
break :config_path try internal_os.macos.appSupportDir(alloc_gpa, "config");
}
break :config_path xdg_config_path;
};
defer alloc_gpa.free(config_path); defer alloc_gpa.free(config_path);
// Create config directory recursively. // Create config directory recursively.

View File

@ -2515,19 +2515,19 @@ fn draw_smooth_mosaic(
const right: f64 = @floatFromInt(self.metrics.cell_width); const right: f64 = @floatFromInt(self.metrics.cell_width);
var path: z2d.StaticPath(12) = .{}; var path: z2d.StaticPath(12) = .{};
path.init(); path.init(); // nodes.len = 0
if (mosaic.tl) path.lineTo(left, top); if (mosaic.tl) path.lineTo(left, top); // +1, nodes.len = 1
if (mosaic.ul) path.lineTo(left, upper); if (mosaic.ul) path.lineTo(left, upper); // +1, nodes.len = 2
if (mosaic.ll) path.lineTo(left, lower); if (mosaic.ll) path.lineTo(left, lower); // +1, nodes.len = 3
if (mosaic.bl) path.lineTo(left, bottom); if (mosaic.bl) path.lineTo(left, bottom); // +1, nodes.len = 4
if (mosaic.bc) path.lineTo(center, bottom); if (mosaic.bc) path.lineTo(center, bottom); // +1, nodes.len = 5
if (mosaic.br) path.lineTo(right, bottom); if (mosaic.br) path.lineTo(right, bottom); // +1, nodes.len = 6
if (mosaic.lr) path.lineTo(right, lower); if (mosaic.lr) path.lineTo(right, lower); // +1, nodes.len = 7
if (mosaic.ur) path.lineTo(right, upper); if (mosaic.ur) path.lineTo(right, upper); // +1, nodes.len = 8
if (mosaic.tr) path.lineTo(right, top); if (mosaic.tr) path.lineTo(right, top); // +1, nodes.len = 9
if (mosaic.tc) path.lineTo(center, top); if (mosaic.tc) path.lineTo(center, top); // +1, nodes.len = 10
path.close(); path.close(); // +2, nodes.len = 12
try z2d.painter.fill( try z2d.painter.fill(
canvas.alloc, canvas.alloc,
@ -2535,7 +2535,7 @@ fn draw_smooth_mosaic(
&.{ .opaque_pattern = .{ &.{ .opaque_pattern = .{
.pixel = .{ .alpha8 = .{ .a = @intFromEnum(Shade.on) } }, .pixel = .{ .alpha8 = .{ .a = @intFromEnum(Shade.on) } },
} }, } },
&path.nodes, path.wrapped_path.nodes.items,
.{}, .{},
); );
} }
@ -2560,12 +2560,12 @@ fn draw_edge_triangle(
}; };
var path: z2d.StaticPath(5) = .{}; var path: z2d.StaticPath(5) = .{};
path.init(); path.init(); // nodes.len = 0
path.moveTo(center, middle); path.moveTo(center, middle); // +1, nodes.len = 1
path.lineTo(x0, y0); path.lineTo(x0, y0); // +1, nodes.len = 2
path.lineTo(x1, y1); path.lineTo(x1, y1); // +1, nodes.len = 3
path.close(); path.close(); // +2, nodes.len = 5
try z2d.painter.fill( try z2d.painter.fill(
canvas.alloc, canvas.alloc,
@ -2573,7 +2573,7 @@ fn draw_edge_triangle(
&.{ .opaque_pattern = .{ &.{ .opaque_pattern = .{
.pixel = .{ .alpha8 = .{ .a = @intFromEnum(Shade.on) } }, .pixel = .{ .alpha8 = .{ .a = @intFromEnum(Shade.on) } },
} }, } },
&path.nodes, path.wrapped_path.nodes.items,
.{}, .{},
); );
} }

View File

@ -115,7 +115,7 @@ fn draw(self: Powerline, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32)
fn draw_chevron(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void { fn draw_chevron(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void {
const width = self.width; const width = self.width;
const height = self.height; const height = self.height;
var p1_x: u32 = 0; var p1_x: u32 = 0;
var p1_y: u32 = 0; var p1_y: u32 = 0;
var p2_x: u32 = 0; var p2_x: u32 = 0;
@ -123,7 +123,6 @@ fn draw_chevron(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void {
var p3_x: u32 = 0; var p3_x: u32 = 0;
var p3_y: u32 = 0; var p3_y: u32 = 0;
switch (cp) { switch (cp) {
0xE0B1 => { 0xE0B1 => {
p1_x = 0; p1_x = 0;
@ -141,19 +140,15 @@ fn draw_chevron(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void {
p3_x = width; p3_x = width;
p3_y = height; p3_y = height;
}, },
else => unreachable,
else => unreachable,
} }
try canvas.triangle_outline(.{ try canvas.triangle_outline(.{
.p0 = .{ .x = @floatFromInt(p1_x), .y = @floatFromInt(p1_y) }, .p0 = .{ .x = @floatFromInt(p1_x), .y = @floatFromInt(p1_y) },
.p1 = .{ .x = @floatFromInt(p2_x), .y = @floatFromInt(p2_y) }, .p1 = .{ .x = @floatFromInt(p2_x), .y = @floatFromInt(p2_y) },
.p2 = .{ .x = @floatFromInt(p3_x), .y = @floatFromInt(p3_y) }, .p2 = .{ .x = @floatFromInt(p3_x), .y = @floatFromInt(p3_y) },
}, }, @floatFromInt(Thickness.light.height(self.thickness)), .on);
@floatFromInt(Thickness.light.height(self.thickness)),
.on);
} }
fn draw_wedge_triangle(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void { fn draw_wedge_triangle(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void {

View File

@ -184,13 +184,13 @@ pub const Canvas = struct {
/// Draw and fill a quad. /// Draw and fill a quad.
pub fn quad(self: *Canvas, q: Quad(f64), color: Color) !void { pub fn quad(self: *Canvas, q: Quad(f64), color: Color) !void {
var path: z2d.StaticPath(6) = .{}; var path: z2d.StaticPath(6) = .{};
path.init(); path.init(); // nodes.len = 0
path.moveTo(q.p0.x, q.p0.y); path.moveTo(q.p0.x, q.p0.y); // +1, nodes.len = 1
path.lineTo(q.p1.x, q.p1.y); path.lineTo(q.p1.x, q.p1.y); // +1, nodes.len = 2
path.lineTo(q.p2.x, q.p2.y); path.lineTo(q.p2.x, q.p2.y); // +1, nodes.len = 3
path.lineTo(q.p3.x, q.p3.y); path.lineTo(q.p3.x, q.p3.y); // +1, nodes.len = 4
path.close(); path.close(); // +2, nodes.len = 6
try z2d.painter.fill( try z2d.painter.fill(
self.alloc, self.alloc,
@ -198,7 +198,7 @@ pub const Canvas = struct {
&.{ .opaque_pattern = .{ &.{ .opaque_pattern = .{
.pixel = .{ .alpha8 = .{ .a = @intFromEnum(color) } }, .pixel = .{ .alpha8 = .{ .a = @intFromEnum(color) } },
} }, } },
&path.nodes, path.wrapped_path.nodes.items,
.{}, .{},
); );
} }
@ -206,12 +206,12 @@ pub const Canvas = struct {
/// Draw and fill a triangle. /// Draw and fill a triangle.
pub fn triangle(self: *Canvas, t: Triangle(f64), color: Color) !void { pub fn triangle(self: *Canvas, t: Triangle(f64), color: Color) !void {
var path: z2d.StaticPath(5) = .{}; var path: z2d.StaticPath(5) = .{};
path.init(); path.init(); // nodes.len = 0
path.moveTo(t.p0.x, t.p0.y); path.moveTo(t.p0.x, t.p0.y); // +1, nodes.len = 1
path.lineTo(t.p1.x, t.p1.y); path.lineTo(t.p1.x, t.p1.y); // +1, nodes.len = 2
path.lineTo(t.p2.x, t.p2.y); path.lineTo(t.p2.x, t.p2.y); // +1, nodes.len = 3
path.close(); path.close(); // +2, nodes.len = 5
try z2d.painter.fill( try z2d.painter.fill(
self.alloc, self.alloc,
@ -219,18 +219,18 @@ pub const Canvas = struct {
&.{ .opaque_pattern = .{ &.{ .opaque_pattern = .{
.pixel = .{ .alpha8 = .{ .a = @intFromEnum(color) } }, .pixel = .{ .alpha8 = .{ .a = @intFromEnum(color) } },
} }, } },
&path.nodes, path.wrapped_path.nodes.items,
.{}, .{},
); );
} }
pub fn triangle_outline(self: *Canvas, t: Triangle(f64), thickness: f64, color: Color) !void { pub fn triangle_outline(self: *Canvas, t: Triangle(f64), thickness: f64, color: Color) !void {
var path: z2d.StaticPath(5) = .{}; var path: z2d.StaticPath(3) = .{};
path.init(); path.init(); // nodes.len = 0
path.moveTo(t.p0.x, t.p0.y); path.moveTo(t.p0.x, t.p0.y); // +1, nodes.len = 1
path.lineTo(t.p1.x, t.p1.y); path.lineTo(t.p1.x, t.p1.y); // +1, nodes.len = 2
path.lineTo(t.p2.x, t.p2.y); path.lineTo(t.p2.x, t.p2.y); // +1, nodes.len = 3
try z2d.painter.stroke( try z2d.painter.stroke(
self.alloc, self.alloc,
@ -238,7 +238,7 @@ pub const Canvas = struct {
&.{ .opaque_pattern = .{ &.{ .opaque_pattern = .{
.pixel = .{ .alpha8 = .{ .a = @intFromEnum(color) } }, .pixel = .{ .alpha8 = .{ .a = @intFromEnum(color) } },
} }, } },
&path.nodes, path.wrapped_path.nodes.items,
.{ .{
.line_cap_mode = .round, .line_cap_mode = .round,
.line_width = thickness, .line_width = thickness,
@ -248,11 +248,11 @@ pub const Canvas = struct {
/// Stroke a line. /// Stroke a line.
pub fn line(self: *Canvas, l: Line(f64), thickness: f64, color: Color) !void { pub fn line(self: *Canvas, l: Line(f64), thickness: f64, color: Color) !void {
var path: z2d.StaticPath(3) = .{}; var path: z2d.StaticPath(2) = .{};
path.init(); path.init(); // nodes.len = 0
path.moveTo(l.p0.x, l.p0.y); path.moveTo(l.p0.x, l.p0.y); // +1, nodes.len = 1
path.lineTo(l.p1.x, l.p1.y); path.lineTo(l.p1.x, l.p1.y); // +1, nodes.len = 2
try z2d.painter.stroke( try z2d.painter.stroke(
self.alloc, self.alloc,
@ -260,7 +260,7 @@ pub const Canvas = struct {
&.{ .opaque_pattern = .{ &.{ .opaque_pattern = .{
.pixel = .{ .alpha8 = .{ .a = @intFromEnum(color) } }, .pixel = .{ .alpha8 = .{ .a = @intFromEnum(color) } },
} }, } },
&path.nodes, path.wrapped_path.nodes.items,
.{ .{
.line_cap_mode = .round, .line_cap_mode = .round,
.line_width = thickness, .line_width = thickness,

View File

@ -308,7 +308,7 @@ pub const Key = enum(c_int) {
equal, equal,
left_bracket, // [ left_bracket, // [
right_bracket, // ] right_bracket, // ]
backslash, // / backslash, // \
// control // control
up, up,

View File

@ -1721,21 +1721,22 @@ fn prepKittyGraphics(
}.lessThan, }.lessThan,
); );
// Find our indices // Find our indices. The values are sorted by z so we can find the
self.image_bg_end = 0; // first placement out of bounds to find the limits.
self.image_text_end = 0; var bg_end: ?u32 = null;
var text_end: ?u32 = null;
const bg_limit = std.math.minInt(i32) / 2; const bg_limit = std.math.minInt(i32) / 2;
for (self.image_placements.items, 0..) |p, i| { for (self.image_placements.items, 0..) |p, i| {
if (self.image_bg_end == 0 and p.z >= bg_limit) { if (bg_end == null and p.z >= bg_limit) {
self.image_bg_end = @intCast(i); bg_end = @intCast(i);
} }
if (self.image_text_end == 0 and p.z >= 0) { if (text_end == null and p.z >= 0) {
self.image_text_end = @intCast(i); text_end = @intCast(i);
} }
} }
if (self.image_text_end == 0) {
self.image_text_end = @intCast(self.image_placements.items.len); self.image_bg_end = bg_end orelse 0;
} self.image_text_end = text_end orelse self.image_bg_end;
} }
fn prepKittyVirtualPlacement( fn prepKittyVirtualPlacement(
@ -1820,6 +1821,21 @@ fn prepKittyPlacement(
break :offset_y @intCast(offset_pixels); break :offset_y @intCast(offset_pixels);
} else 0; } else 0;
// If we specify `rows` then our offset above is in viewport space
// and not in the coordinate space of the source image. Without `rows`
// that's one and the same.
const source_offset_y: u32 = if (p.rows > 0) source_offset_y: {
// Determine the scale factor to apply for this row height.
const image_height: f64 = @floatFromInt(image.height);
const viewport_height: f64 = @floatFromInt(p.rows * self.grid_metrics.cell_height);
const scale: f64 = image_height / viewport_height;
// Apply the scale to the offset
const offset_y_f64: f64 = @floatFromInt(offset_y);
const source_offset_y_f64: f64 = offset_y_f64 * scale;
break :source_offset_y @intFromFloat(@round(source_offset_y_f64));
} else offset_y;
// We need to prep this image for upload if it isn't in the cache OR // We need to prep this image for upload if it isn't in the cache OR
// it is in the cache but the transmit time doesn't match meaning this // it is in the cache but the transmit time doesn't match meaning this
// image is different. // image is different.
@ -1833,7 +1849,7 @@ fn prepKittyPlacement(
// Calculate the source rectangle // Calculate the source rectangle
const source_x = @min(image.width, p.source_x); const source_x = @min(image.width, p.source_x);
const source_y = @min(image.height, p.source_y + offset_y); const source_y = @min(image.height, p.source_y + source_offset_y);
const source_width = if (p.source_width > 0) const source_width = if (p.source_width > 0)
@min(image.width - source_x, p.source_width) @min(image.width - source_x, p.source_width)
else else
@ -1845,7 +1861,11 @@ fn prepKittyPlacement(
// Calculate the width/height of our image. // Calculate the width/height of our image.
const dest_width = if (p.columns > 0) p.columns * self.grid_metrics.cell_width else source_width; const dest_width = if (p.columns > 0) p.columns * self.grid_metrics.cell_width else source_width;
const dest_height = if (p.rows > 0) p.rows * self.grid_metrics.cell_height else source_height; const dest_height = if (p.rows > 0) rows: {
// Clip to the viewport to handle scrolling. offset_y is already in
// viewport scale so we can subtract it directly.
break :rows (p.rows * self.grid_metrics.cell_height) - offset_y;
} else source_height;
// Accumulate the placement // Accumulate the placement
if (image.width > 0 and image.height > 0) { if (image.width > 0 and image.height > 0) {

View File

@ -952,21 +952,22 @@ fn prepKittyGraphics(
}.lessThan, }.lessThan,
); );
// Find our indices // Find our indices. The values are sorted by z so we can find the
self.image_bg_end = 0; // first placement out of bounds to find the limits.
self.image_text_end = 0; var bg_end: ?u32 = null;
var text_end: ?u32 = null;
const bg_limit = std.math.minInt(i32) / 2; const bg_limit = std.math.minInt(i32) / 2;
for (self.image_placements.items, 0..) |p, i| { for (self.image_placements.items, 0..) |p, i| {
if (self.image_bg_end == 0 and p.z >= bg_limit) { if (bg_end == null and p.z >= bg_limit) {
self.image_bg_end = @intCast(i); bg_end = @intCast(i);
} }
if (self.image_text_end == 0 and p.z >= 0) { if (text_end == null and p.z >= 0) {
self.image_text_end = @intCast(i); text_end = @intCast(i);
} }
} }
if (self.image_text_end == 0) {
self.image_text_end = @intCast(self.image_placements.items.len); self.image_bg_end = bg_end orelse 0;
} self.image_text_end = text_end orelse self.image_bg_end;
} }
fn prepKittyVirtualPlacement( fn prepKittyVirtualPlacement(
@ -1051,6 +1052,21 @@ fn prepKittyPlacement(
break :offset_y @intCast(offset_pixels); break :offset_y @intCast(offset_pixels);
} else 0; } else 0;
// If we specify `rows` then our offset above is in viewport space
// and not in the coordinate space of the source image. Without `rows`
// that's one and the same.
const source_offset_y: u32 = if (p.rows > 0) source_offset_y: {
// Determine the scale factor to apply for this row height.
const image_height: f64 = @floatFromInt(image.height);
const viewport_height: f64 = @floatFromInt(p.rows * self.grid_metrics.cell_height);
const scale: f64 = image_height / viewport_height;
// Apply the scale to the offset
const offset_y_f64: f64 = @floatFromInt(offset_y);
const source_offset_y_f64: f64 = offset_y_f64 * scale;
break :source_offset_y @intFromFloat(@round(source_offset_y_f64));
} else offset_y;
// We need to prep this image for upload if it isn't in the cache OR // We need to prep this image for upload if it isn't in the cache OR
// it is in the cache but the transmit time doesn't match meaning this // it is in the cache but the transmit time doesn't match meaning this
// image is different. // image is different.
@ -1064,7 +1080,7 @@ fn prepKittyPlacement(
// Calculate the source rectangle // Calculate the source rectangle
const source_x = @min(image.width, p.source_x); const source_x = @min(image.width, p.source_x);
const source_y = @min(image.height, p.source_y + offset_y); const source_y = @min(image.height, p.source_y + source_offset_y);
const source_width = if (p.source_width > 0) const source_width = if (p.source_width > 0)
@min(image.width - source_x, p.source_width) @min(image.width - source_x, p.source_width)
else else
@ -1076,7 +1092,11 @@ fn prepKittyPlacement(
// Calculate the width/height of our image. // Calculate the width/height of our image.
const dest_width = if (p.columns > 0) p.columns * self.grid_metrics.cell_width else source_width; const dest_width = if (p.columns > 0) p.columns * self.grid_metrics.cell_width else source_width;
const dest_height = if (p.rows > 0) p.rows * self.grid_metrics.cell_height else source_height; const dest_height = if (p.rows > 0) rows: {
// Clip to the viewport to handle scrolling. offset_y is already in
// viewport scale so we can subtract it directly.
break :rows (p.rows * self.grid_metrics.cell_height) - offset_y;
} else source_height;
// Accumulate the placement // Accumulate the placement
if (image.width > 0 and image.height > 0) { if (image.width > 0 and image.height > 0) {

View File

@ -2406,9 +2406,19 @@ fn erasePage(self: *PageList, node: *List.Node) void {
/// Returns the pin for the given point. The pin is NOT tracked so it /// Returns the pin for the given point. The pin is NOT tracked so it
/// is only valid as long as the pagelist isn't modified. /// is only valid as long as the pagelist isn't modified.
///
/// This will return null if the point is out of bounds. The caller
/// should clamp the point to the bounds of the coordinate space if
/// necessary.
pub fn pin(self: *const PageList, pt: point.Point) ?Pin { pub fn pin(self: *const PageList, pt: point.Point) ?Pin {
// getTopLeft is much more expensive than checking the cols bounds
// so we do this first.
const x = pt.coord().x;
if (x >= self.cols) return null;
// Grab the top left and move to the point.
var p = self.getTopLeft(pt).down(pt.coord().y) orelse return null; var p = self.getTopLeft(pt).down(pt.coord().y) orelse return null;
p.x = pt.coord().x; p.x = x;
return p; return p;
} }

View File

@ -620,7 +620,7 @@ pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt)
self.cursor.x = x; // Must be set before cursorChangePin self.cursor.x = x; // Must be set before cursorChangePin
self.cursor.y = y; self.cursor.y = y;
self.cursorChangePin(page_pin); self.cursorChangePin(page_pin);
const page_rac = page_pin.rowAndCell(); const page_rac = self.cursor.page_pin.rowAndCell();
self.cursor.page_row = page_rac.row; self.cursor.page_row = page_rac.row;
self.cursor.page_cell = page_rac.cell; self.cursor.page_cell = page_rac.cell;
} }
@ -779,9 +779,15 @@ pub fn cursorDownScroll(self: *Screen) !void {
} }
} }
/// This scrolls the active area at and above the cursor. The lines below /// This scrolls the active area at and above the cursor.
/// the cursor are not scrolled. /// The lines below the cursor are not scrolled.
pub fn cursorScrollAbove(self: *Screen) !void { pub fn cursorScrollAbove(self: *Screen) !void {
// We unconditionally mark the cursor row as dirty here because
// the cursor always changes page rows inside this function, and
// when that happens it can mean the text in the old row needs to
// be re-shaped because the cursor splits runs to break ligatures.
self.cursor.page_pin.markDirty();
// If the cursor is on the bottom of the screen, its faster to use // If the cursor is on the bottom of the screen, its faster to use
// our specialized function for that case. // our specialized function for that case.
if (self.cursor.y == self.pages.rows - 1) { if (self.cursor.y == self.pages.rows - 1) {
@ -793,6 +799,14 @@ pub fn cursorScrollAbove(self: *Screen) !void {
// Logic below assumes we always have at least one row that isn't moving // Logic below assumes we always have at least one row that isn't moving
assert(self.cursor.y < self.pages.rows - 1); assert(self.cursor.y < self.pages.rows - 1);
// Explanation:
// We don't actually move everything that's at or above the cursor row,
// since this would require us to shift up our ENTIRE scrollback, which
// would be ridiculously expensive. Instead, we insert a new row at the
// end of the pagelist (`grow()`), and move everything BELOW the cursor
// DOWN by one row. This has the same practical result but it's a whole
// lot cheaper in 99% of cases.
const old_pin = self.cursor.page_pin.*; const old_pin = self.cursor.page_pin.*;
if (try self.pages.grow()) |_| { if (try self.pages.grow()) |_| {
try self.cursorScrollAboveRotate(); try self.cursorScrollAboveRotate();
@ -803,6 +817,9 @@ pub fn cursorScrollAbove(self: *Screen) !void {
// If we're on the last page we can do a very fast path because // If we're on the last page we can do a very fast path because
// all the rows we need to move around are within a single page. // all the rows we need to move around are within a single page.
// Note: we don't need to call cursorChangePin here because
// the pin page is the same so there is no accounting to do
// for styles or any of that.
assert(old_pin.node == self.cursor.page_pin.node); assert(old_pin.node == self.cursor.page_pin.node);
self.cursor.page_pin.* = self.cursor.page_pin.down(1).?; self.cursor.page_pin.* = self.cursor.page_pin.down(1).?;
@ -823,10 +840,6 @@ pub fn cursorScrollAbove(self: *Screen) !void {
const page_rac = self.cursor.page_pin.rowAndCell(); const page_rac = self.cursor.page_pin.rowAndCell();
self.cursor.page_row = page_rac.row; self.cursor.page_row = page_rac.row;
self.cursor.page_cell = page_rac.cell; self.cursor.page_cell = page_rac.cell;
// Note: we don't need to call cursorChangePin here because
// the pin page is the same so there is no accounting to do for
// styles or any of that.
} else { } else {
// We didn't grow pages but our cursor isn't on the last page. // We didn't grow pages but our cursor isn't on the last page.
// In this case we need to do more work because we need to copy // In this case we need to do more work because we need to copy
@ -863,7 +876,7 @@ pub fn cursorScrollAbove(self: *Screen) !void {
} }
fn cursorScrollAboveRotate(self: *Screen) !void { fn cursorScrollAboveRotate(self: *Screen) !void {
self.cursor.page_pin.* = self.cursor.page_pin.down(1).?; self.cursorChangePin(self.cursor.page_pin.down(1).?);
// Go through each of the pages following our pin, shift all rows // Go through each of the pages following our pin, shift all rows
// down by one, and copy the last row of the previous page. // down by one, and copy the last row of the previous page.
@ -1763,10 +1776,15 @@ pub fn manualStyleUpdate(self: *Screen) !void {
// If our new style is the default, just reset to that // If our new style is the default, just reset to that
if (self.cursor.style.default()) { if (self.cursor.style.default()) {
self.cursor.style_id = 0; self.cursor.style_id = style.default_id;
return; return;
} }
// Clear the cursor style ID to prevent weird things from happening
// if the page capacity has to be adjusted which would end up calling
// manualStyleUpdate again.
self.cursor.style_id = style.default_id;
// After setting the style, we need to update our style map. // After setting the style, we need to update our style map.
// Note that we COULD lazily do this in print. We should look into // Note that we COULD lazily do this in print. We should look into
// if that makes a meaningful difference. Our priority is to keep print // if that makes a meaningful difference. Our priority is to keep print
@ -2068,17 +2086,18 @@ pub fn selectionString(self: *Screen, alloc: Allocator, opts: SelectionString) !
}; };
var page_it = sel_start.pageIterator(.right_down, sel_end); var page_it = sel_start.pageIterator(.right_down, sel_end);
var row_count: usize = 0;
while (page_it.next()) |chunk| { while (page_it.next()) |chunk| {
const rows = chunk.rows(); const rows = chunk.rows();
for (rows, chunk.start..) |row, y| { for (rows, chunk.start.., 0..) |row, y, row_i| {
const cells_ptr = row.cells.ptr(chunk.node.data.memory); const cells_ptr = row.cells.ptr(chunk.node.data.memory);
const start_x = if (row_count == 0 or sel_ordered.rectangle) const start_x = if ((row_i == 0 or sel_ordered.rectangle) and
sel_start.node == chunk.node)
sel_start.x sel_start.x
else else
0; 0;
const end_x = if (row_count == rows.len - 1 or sel_ordered.rectangle) const end_x = if ((row_i == rows.len - 1 or sel_ordered.rectangle) and
sel_end.node == chunk.node)
sel_end.x + 1 sel_end.x + 1
else else
self.pages.cols; self.pages.cols;
@ -2133,8 +2152,6 @@ pub fn selectionString(self: *Screen, alloc: Allocator, opts: SelectionString) !
.x = chunk.node.data.size.cols - 1, .x = chunk.node.data.size.cols - 1,
}); });
} }
row_count += 1;
} }
} }
@ -3760,6 +3777,81 @@ test "Screen: cursorAbsolute across pages preserves style" {
} }
} }
test "Screen: cursorAbsolute to page with insufficient capacity" {
// This test checks for a very specific edge case
// which previously resulted in memory corruption.
//
// The conditions for this edge case are as such:
// - The cursor has an associated style or other managed memory.
// - The cursor moves to a different page.
// - The new page is at capacity and must have its capacity adjusted.
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 1);
defer s.deinit();
// Scroll down enough to go to another page
const start_page = &s.pages.pages.last.?.data;
const rem = start_page.capacity.rows;
start_page.pauseIntegrityChecks(true);
for (0..rem) |_| try s.cursorDownOrScroll();
start_page.pauseIntegrityChecks(false);
const new_page = &s.cursor.page_pin.node.data;
// We need our page to change for this test to make sense. If this
// assertion fails then the bug is in the test: we should be scrolling
// above enough for a new page to show up.
try testing.expect(start_page != new_page);
// Add styles to the start page until it reaches capacity.
{
// Pause integrity checks because they're slow and
// we're not testing this, this is just setup.
start_page.pauseIntegrityChecks(true);
defer start_page.pauseIntegrityChecks(false);
defer start_page.assertIntegrity();
var n: u24 = 1;
while (start_page.styles.add(
start_page.memory,
.{ .bg_color = .{ .rgb = @bitCast(n) } },
)) |_| n += 1 else |_| {}
}
// Set a style on the cursor.
try s.setAttribute(.{ .bold = {} });
{
const styleval = new_page.styles.get(
new_page.memory,
s.cursor.style_id,
);
try testing.expect(styleval.flags.bold);
}
// Go back up into the start page and we should still have that style.
s.cursorAbsolute(1, 1);
{
const cur_page = &s.cursor.page_pin.node.data;
// The page we're on now should NOT equal start_page, since its
// capacity should have been adjusted, which invalidates our ptr.
try testing.expect(start_page != cur_page);
// To make sure we DID change pages we check we're not on new_page.
try testing.expect(new_page != cur_page);
const styleval = cur_page.styles.get(
cur_page.memory,
s.cursor.style_id,
);
try testing.expect(styleval.flags.bold);
}
s.cursor.page_pin.node.data.assertIntegrity();
new_page.assertIntegrity();
}
test "Screen: scrolling" { test "Screen: scrolling" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -4313,8 +4405,31 @@ test "Screen: scroll above same page" {
try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
s.cursorAbsolute(0, 1); s.cursorAbsolute(0, 1);
s.pages.clearDirty(); s.pages.clearDirty();
// At this point:
// +-------------+ ACTIVE
// +----------+ : = PAGE 0
// 0 |1ABCD00000| | 0
// 1 |2EFGH00000| | 1
// :^ : : = PIN 0
// 2 |3IJKL00000| | 2
// +----------+ :
// +-------------+
try s.cursorScrollAbove(); try s.cursorScrollAbove();
// +----------+ = PAGE 0
// 0 |1ABCD00000|
// +-------------+ ACTIVE
// 1 |2EFGH00000| | 0
// 2 | | | 1
// :^ : : = PIN 0
// 3 |3IJKL00000| | 2
// +----------+ :
// +-------------+
// try s.pages.diagram(std.io.getStdErr().writer());
{ {
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents); defer alloc.free(contents);
@ -4331,10 +4446,11 @@ test "Screen: scroll above same page" {
}, cell.content.color_rgb); }, cell.content.color_rgb);
} }
// Only y=1,2 are dirty because they are the ones that CHANGED contents // Page 0 row 1 (active row 0) is dirty because the cursor moved off of it.
// (not just scroll). try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(!s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); // Page 0 row 2 (active row 1) is dirty because it was cleared.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
// Page 0 row 3 (active row 2) is dirty because it's new.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
} }
@ -4364,8 +4480,25 @@ test "Screen: scroll above same page but cursor on previous page" {
// +----------+ = PAGE 0 // +----------+ = PAGE 0
// ... : : // ... : :
// +-------------+ ACTIVE // +-------------+ ACTIVE
// 4303 |1A00000000| | 0 // 4305 |1A00000000| | 0
// 4304 |2B00000000| | 1 // 4306 |2B00000000| | 1
// :^ : : = PIN 0
// 4307 |3C00000000| | 2
// +----------+ :
// +----------+ : = PAGE 1
// 0 |4D00000000| | 3
// 1 |5E00000000| | 4
// +----------+ :
// +-------------+
try s.cursorScrollAbove();
// +----------+ = PAGE 0
// ... : :
// 4305 |1A00000000|
// +-------------+ ACTIVE
// 4306 |2B00000000| | 0
// 4307 | | | 1
// :^ : : = PIN 0 // :^ : : = PIN 0
// +----------+ : // +----------+ :
// +----------+ : = PAGE 1 // +----------+ : = PAGE 1
@ -4375,7 +4508,7 @@ test "Screen: scroll above same page but cursor on previous page" {
// +----------+ : // +----------+ :
// +-------------+ // +-------------+
try s.cursorScrollAbove(); // try s.pages.diagram(std.io.getStdErr().writer());
{ {
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
@ -4393,9 +4526,13 @@ test "Screen: scroll above same page but cursor on previous page" {
}, cell.content.color_rgb); }, cell.content.color_rgb);
} }
try testing.expect(!s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); // Page 0's penultimate row is dirty because the cursor moved off of it.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
// The rest of the rows are dirty because they've been modified or are new.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 3 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 4 } }));
} }
test "Screen: scroll above same page but cursor on previous page last row" { test "Screen: scroll above same page but cursor on previous page last row" {
@ -4424,8 +4561,8 @@ test "Screen: scroll above same page but cursor on previous page last row" {
// +----------+ = PAGE 0 // +----------+ = PAGE 0
// ... : : // ... : :
// +-------------+ ACTIVE // +-------------+ ACTIVE
// 4303 |1A00000000| | 0 // 4306 |1A00000000| | 0
// 4304 |2B00000000| | 1 // 4307 |2B00000000| | 1
// :^ : : = PIN 0 // :^ : : = PIN 0
// +----------+ : // +----------+ :
// +----------+ : = PAGE 1 // +----------+ : = PAGE 1
@ -4439,9 +4576,9 @@ test "Screen: scroll above same page but cursor on previous page last row" {
// +----------+ = PAGE 0 // +----------+ = PAGE 0
// ... : : // ... : :
// 4303 |1A00000000| // 4306 |1A00000000|
// +-------------+ ACTIVE // +-------------+ ACTIVE
// 4304 |2B00000000| | 0 // 4307 |2B00000000| | 0
// +----------+ : // +----------+ :
// +----------+ : = PAGE 1 // +----------+ : = PAGE 1
// 0 | | | 1 // 0 | | | 1
@ -4470,9 +4607,22 @@ test "Screen: scroll above same page but cursor on previous page last row" {
}, cell.content.color_rgb); }, cell.content.color_rgb);
} }
try testing.expect(!s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); // Page 0's final row is dirty because the cursor moved off of it.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
// Page 1's rows are all dirty because every row was moved.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 3 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 4 } }));
// Attempt to clear the style from the cursor and
// then assert the integrity of both of our pages.
//
// This catches a case of memory corruption where the cursor
// is moved between pages without accounting for style refs.
try s.setAttribute(.{ .reset_bg = {} });
s.pages.pages.first.?.data.assertIntegrity();
s.pages.pages.last.?.data.assertIntegrity();
} }
test "Screen: scroll above creates new page" { test "Screen: scroll above creates new page" {
@ -4495,8 +4645,34 @@ test "Screen: scroll above creates new page" {
// Ensure we're still on the first page // Ensure we're still on the first page
try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?);
// At this point:
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4305 |1ABCD00000| | 0
// 4306 |2EFGH00000| | 1
// :^ : : = PIN 0
// 4307 |3IJKL00000| | 2
// +----------+ :
// +-------------+
try s.cursorScrollAbove(); try s.cursorScrollAbove();
// +----------+ = PAGE 0
// ... : :
// 4305 |1ABCD00000|
// +-------------+ ACTIVE
// 4306 |2EFGH00000| | 0
// 4307 | | | 1
// :^ : : = PIN 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 |3IJKL00000| | 2
// +----------+ :
// +-------------+
// try s.pages.diagram(std.io.getStdErr().writer());
{ {
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents); defer alloc.free(contents);
@ -4513,9 +4689,11 @@ test "Screen: scroll above creates new page" {
}, cell.content.color_rgb); }, cell.content.color_rgb);
} }
// Only y=1 is dirty because they are the ones that CHANGED contents // Page 0's penultimate row is dirty because the cursor moved off of it.
try testing.expect(!s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
// Page 0's final row is dirty because it was cleared.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
// Page 1's row is dirty because it's new.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
} }
@ -4535,8 +4713,31 @@ test "Screen: scroll above no scrollback bottom of page" {
try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
s.cursorAbsolute(0, 1); s.cursorAbsolute(0, 1);
s.pages.clearDirty(); s.pages.clearDirty();
// At this point:
// +-------------+ ACTIVE
// +----------+ : = PAGE 0
// 0 |1ABCD00000| | 0
// 1 |2EFGH00000| | 1
// :^ : : = PIN 0
// 2 |3IJKL00000| | 2
// +----------+ :
// +-------------+
try s.cursorScrollAbove(); try s.cursorScrollAbove();
// +----------+ = PAGE 0
// 0 |1ABCD00000|
// +-------------+ ACTIVE
// 1 |2EFGH00000| | 0
// 2 | | | 1
// :^ : : = PIN 0
// 3 |3IJKL00000| | 2
// +----------+ :
// +-------------+
//try s.pages.diagram(std.io.getStdErr().writer());
{ {
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents); defer alloc.free(contents);
@ -4553,10 +4754,11 @@ test "Screen: scroll above no scrollback bottom of page" {
}, cell.content.color_rgb); }, cell.content.color_rgb);
} }
// Only y=1,2 are dirty because they are the ones that CHANGED contents // Page 0 row 1 (active row 0) is dirty because the cursor moved off of it.
// (not just scroll). try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(!s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); // Page 0 row 2 (active row 1) is dirty because it was cleared.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
// Page 0 row 3 (active row 2) is dirty because it is new.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
} }
@ -8301,7 +8503,7 @@ test "Screen: selectionString multi-page" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
var s = try init(alloc, 1, 3, 2048); var s = try init(alloc, 10, 3, 2048);
defer s.deinit(); defer s.deinit();
const first_page_size = s.pages.pages.first.?.data.capacity.rows; const first_page_size = s.pages.pages.first.?.data.capacity.rows;
@ -8313,20 +8515,20 @@ test "Screen: selectionString multi-page" {
} }
s.pages.pages.first.?.data.pauseIntegrityChecks(false); s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.testWriteString("y\ny\ny"); try s.testWriteString("123456789\n!@#$%^&*(\n123456789");
{ {
const sel = Selection.init( const sel = Selection.init(
s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .active = .{ .x = 0, .y = 2 } }).?, s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?,
false, false,
); );
const contents = try s.selectionString(alloc, .{ const contents = try s.selectionString(alloc, .{
.sel = sel, .sel = sel,
.trim = false, .trim = true,
}); });
defer alloc.free(contents); defer alloc.free(contents);
const expected = "y\ny\ny"; const expected = "123456789\n!@#$%^&*(\n123";
try testing.expectEqualStrings(expected, contents); try testing.expectEqualStrings(expected, contents);
} }
} }

View File

@ -451,7 +451,7 @@ pub fn adjust(
test "Selection: adjust right" { test "Selection: adjust right" {
const testing = std.testing; const testing = std.testing;
var s = try Screen.init(testing.allocator, 5, 10, 0); var s = try Screen.init(testing.allocator, 10, 10, 0);
defer s.deinit(); defer s.deinit();
try s.testWriteString("A1234\nB5678\nC1234\nD5678"); try s.testWriteString("A1234\nB5678\nC1234\nD5678");
@ -518,7 +518,7 @@ test "Selection: adjust right" {
test "Selection: adjust left" { test "Selection: adjust left" {
const testing = std.testing; const testing = std.testing;
var s = try Screen.init(testing.allocator, 5, 10, 0); var s = try Screen.init(testing.allocator, 10, 10, 0);
defer s.deinit(); defer s.deinit();
try s.testWriteString("A1234\nB5678\nC1234\nD5678"); try s.testWriteString("A1234\nB5678\nC1234\nD5678");
@ -567,7 +567,7 @@ test "Selection: adjust left" {
test "Selection: adjust left skips blanks" { test "Selection: adjust left skips blanks" {
const testing = std.testing; const testing = std.testing;
var s = try Screen.init(testing.allocator, 5, 10, 0); var s = try Screen.init(testing.allocator, 10, 10, 0);
defer s.deinit(); defer s.deinit();
try s.testWriteString("A1234\nB5678\nC12\nD56"); try s.testWriteString("A1234\nB5678\nC12\nD56");
@ -616,7 +616,7 @@ test "Selection: adjust left skips blanks" {
test "Selection: adjust up" { test "Selection: adjust up" {
const testing = std.testing; const testing = std.testing;
var s = try Screen.init(testing.allocator, 5, 10, 0); var s = try Screen.init(testing.allocator, 10, 10, 0);
defer s.deinit(); defer s.deinit();
try s.testWriteString("A\nB\nC\nD\nE"); try s.testWriteString("A\nB\nC\nD\nE");
@ -663,7 +663,7 @@ test "Selection: adjust up" {
test "Selection: adjust down" { test "Selection: adjust down" {
const testing = std.testing; const testing = std.testing;
var s = try Screen.init(testing.allocator, 5, 10, 0); var s = try Screen.init(testing.allocator, 10, 10, 0);
defer s.deinit(); defer s.deinit();
try s.testWriteString("A\nB\nC\nD\nE"); try s.testWriteString("A\nB\nC\nD\nE");
@ -702,7 +702,7 @@ test "Selection: adjust down" {
.y = 1, .y = 1,
} }, s.pages.pointFromPin(.screen, sel.start()).?); } }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{ try testing.expectEqual(point.Point{ .screen = .{
.x = 4, .x = 9,
.y = 4, .y = 4,
} }, s.pages.pointFromPin(.screen, sel.end()).?); } }, s.pages.pointFromPin(.screen, sel.end()).?);
} }
@ -710,7 +710,7 @@ test "Selection: adjust down" {
test "Selection: adjust down with not full screen" { test "Selection: adjust down with not full screen" {
const testing = std.testing; const testing = std.testing;
var s = try Screen.init(testing.allocator, 5, 10, 0); var s = try Screen.init(testing.allocator, 10, 10, 0);
defer s.deinit(); defer s.deinit();
try s.testWriteString("A\nB\nC"); try s.testWriteString("A\nB\nC");
@ -730,7 +730,7 @@ test "Selection: adjust down with not full screen" {
.y = 1, .y = 1,
} }, s.pages.pointFromPin(.screen, sel.start()).?); } }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{ try testing.expectEqual(point.Point{ .screen = .{
.x = 4, .x = 9,
.y = 2, .y = 2,
} }, s.pages.pointFromPin(.screen, sel.end()).?); } }, s.pages.pointFromPin(.screen, sel.end()).?);
} }
@ -738,7 +738,7 @@ test "Selection: adjust down with not full screen" {
test "Selection: adjust home" { test "Selection: adjust home" {
const testing = std.testing; const testing = std.testing;
var s = try Screen.init(testing.allocator, 5, 10, 0); var s = try Screen.init(testing.allocator, 10, 10, 0);
defer s.deinit(); defer s.deinit();
try s.testWriteString("A\nB\nC"); try s.testWriteString("A\nB\nC");
@ -766,7 +766,7 @@ test "Selection: adjust home" {
test "Selection: adjust end with not full screen" { test "Selection: adjust end with not full screen" {
const testing = std.testing; const testing = std.testing;
var s = try Screen.init(testing.allocator, 5, 10, 0); var s = try Screen.init(testing.allocator, 10, 10, 0);
defer s.deinit(); defer s.deinit();
try s.testWriteString("A\nB\nC"); try s.testWriteString("A\nB\nC");
@ -786,7 +786,7 @@ test "Selection: adjust end with not full screen" {
.y = 0, .y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?); } }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{ try testing.expectEqual(point.Point{ .screen = .{
.x = 4, .x = 9,
.y = 2, .y = 2,
} }, s.pages.pointFromPin(.screen, sel.end()).?); } }, s.pages.pointFromPin(.screen, sel.end()).?);
} }
@ -1110,7 +1110,7 @@ test "Selection: order, rectangle" {
test "topLeft" { test "topLeft" {
const testing = std.testing; const testing = std.testing;
var s = try Screen.init(testing.allocator, 5, 10, 0); var s = try Screen.init(testing.allocator, 10, 10, 0);
defer s.deinit(); defer s.deinit();
{ {
// forward // forward
@ -1173,7 +1173,7 @@ test "topLeft" {
test "bottomRight" { test "bottomRight" {
const testing = std.testing; const testing = std.testing;
var s = try Screen.init(testing.allocator, 5, 10, 0); var s = try Screen.init(testing.allocator, 10, 10, 0);
defer s.deinit(); defer s.deinit();
{ {
// forward // forward
@ -1236,7 +1236,7 @@ test "bottomRight" {
test "ordered" { test "ordered" {
const testing = std.testing; const testing = std.testing;
var s = try Screen.init(testing.allocator, 5, 10, 0); var s = try Screen.init(testing.allocator, 10, 10, 0);
defer s.deinit(); defer s.deinit();
{ {
// forward // forward
@ -1317,7 +1317,7 @@ test "ordered" {
test "Selection: contains" { test "Selection: contains" {
const testing = std.testing; const testing = std.testing;
var s = try Screen.init(testing.allocator, 5, 10, 0); var s = try Screen.init(testing.allocator, 10, 10, 0);
defer s.deinit(); defer s.deinit();
{ {
const sel = Selection.init( const sel = Selection.init(
@ -1350,13 +1350,13 @@ test "Selection: contains" {
{ {
const sel = Selection.init( const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?,
s.pages.pin(.{ .screen = .{ .x = 10, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 8, .y = 1 } }).?,
false, false,
); );
try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?));
try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?)); try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?));
try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 12, .y = 1 } }).?)); try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 9, .y = 1 } }).?));
} }
} }

View File

@ -95,7 +95,7 @@ pub const Name = enum(u8) {
}; };
/// RGB /// RGB
pub const RGB = struct { pub const RGB = packed struct(u24) {
r: u8 = 0, r: u8 = 0,
g: u8 = 0, g: u8 = 0,
b: u8 = 0, b: u8 = 0,
@ -155,9 +155,9 @@ pub const RGB = struct {
return 0.299 * (r_f64 / 255) + 0.587 * (g_f64 / 255) + 0.114 * (b_f64 / 255); return 0.299 * (r_f64 / 255) + 0.587 * (g_f64 / 255) + 0.114 * (b_f64 / 255);
} }
test "size" { comptime {
try std.testing.expectEqual(@as(usize, 24), @bitSizeOf(RGB)); assert(@bitSizeOf(RGB) == 24);
try std.testing.expectEqual(@as(usize, 3), @sizeOf(RGB)); assert(@sizeOf(RGB) == 4);
} }
/// Parse a color from a floating point intensity value. /// Parse a color from a floating point intensity value.

View File

@ -158,6 +158,12 @@ pub const Command = union(enum) {
/// End a hyperlink (OSC 8) /// End a hyperlink (OSC 8)
hyperlink_end: void, hyperlink_end: void,
/// Set progress state (OSC 9;4)
progress: struct {
state: ProgressState,
progress: ?u8 = null,
},
pub const ColorKind = union(enum) { pub const ColorKind = union(enum) {
palette: u8, palette: u8,
foreground, foreground,
@ -173,6 +179,14 @@ pub const Command = union(enum) {
}; };
} }
}; };
pub const ProgressState = enum {
remove,
set,
@"error",
indeterminate,
pause,
};
}; };
/// The terminator used to end an OSC command. For OSC commands that demand /// The terminator used to end an OSC command. For OSC commands that demand
@ -322,6 +336,27 @@ pub const Parser = struct {
// https://sw.kovidgoyal.net/kitty/color-stack/#id1 // https://sw.kovidgoyal.net/kitty/color-stack/#id1
kitty_color_protocol_key, kitty_color_protocol_key,
kitty_color_protocol_value, kitty_color_protocol_value,
// OSC 9 is used by ConEmu and iTerm2 for different things.
// iTerm2 uses it to post a notification[1].
// ConEmu uses it to implement many custom functions[2].
//
// Some Linux applications (namely systemd and flatpak) have
// adopted the ConEmu implementation but this causes bogus
// notifications on iTerm2 compatible terminal emulators.
//
// Ghostty supports both by disallowing ConEmu-specific commands
// from being shown as desktop notifications.
//
// [1]: https://iterm2.com/documentation-escape-codes.html
// [2]: https://conemu.github.io/en/AnsiEscapeCodes.html#OSC_Operating_system_commands
osc_9,
// ConEmu specific substates
conemu_progress_prestate,
conemu_progress_state,
conemu_progress_prevalue,
conemu_progress_value,
}; };
/// This must be called to clean up any allocated memory. /// This must be called to clean up any allocated memory.
@ -735,18 +770,103 @@ pub const Parser = struct {
.@"9" => switch (c) { .@"9" => switch (c) {
';' => { ';' => {
self.command = .{ .show_desktop_notification = .{
.title = "",
.body = undefined,
} };
self.temp_state = .{ .str = &self.command.show_desktop_notification.body };
self.buf_start = self.buf_idx; self.buf_start = self.buf_idx;
self.state = .string; self.state = .osc_9;
}, },
else => self.state = .invalid, else => self.state = .invalid,
}, },
.osc_9 => switch (c) {
'4' => {
self.state = .conemu_progress_prestate;
},
// Todo: parse out other ConEmu operating system commands.
// Even if we don't support them we probably don't want
// them showing up as desktop notifications.
else => self.showDesktopNotification(),
},
.conemu_progress_prestate => switch (c) {
';' => {
self.command = .{ .progress = .{
.state = undefined,
} };
self.state = .conemu_progress_state;
},
else => self.showDesktopNotification(),
},
.conemu_progress_state => switch (c) {
'0' => {
self.command.progress.state = .remove;
self.state = .conemu_progress_prevalue;
self.complete = true;
},
'1' => {
self.command.progress.state = .set;
self.command.progress.progress = 0;
self.state = .conemu_progress_prevalue;
},
'2' => {
self.command.progress.state = .@"error";
self.complete = true;
self.state = .conemu_progress_prevalue;
},
'3' => {
self.command.progress.state = .indeterminate;
self.complete = true;
self.state = .conemu_progress_prevalue;
},
'4' => {
self.command.progress.state = .pause;
self.complete = true;
self.state = .conemu_progress_prevalue;
},
else => self.showDesktopNotification(),
},
.conemu_progress_prevalue => switch (c) {
';' => {
self.state = .conemu_progress_value;
},
else => self.showDesktopNotification(),
},
.conemu_progress_value => switch (c) {
'0'...'9' => value: {
// No matter what substate we're in, a number indicates
// a completed ConEmu progress command.
self.complete = true;
// If we aren't a set substate, then we don't care
// about the value.
const p = &self.command.progress;
if (p.state != .set and p.state != .@"error" and p.state != .pause) break :value;
if (p.state == .set)
assert(p.progress != null)
else if (p.progress == null)
p.progress = 0;
// If we're over 100% we're done.
if (p.progress.? >= 100) break :value;
// If we're over 10 then any new digit forces us to
// be 100.
if (p.progress.? >= 10)
p.progress = 100
else {
const d = std.fmt.charToDigit(c, 10) catch 0;
p.progress = @min(100, (p.progress.? * 10) + d);
}
},
else => self.showDesktopNotification(),
},
.query_fg_color => switch (c) { .query_fg_color => switch (c) {
'?' => { '?' => {
self.command = .{ .report_color = .{ .kind = .foreground } }; self.command = .{ .report_color = .{ .kind = .foreground } };
@ -901,6 +1021,16 @@ pub const Parser = struct {
} }
} }
fn showDesktopNotification(self: *Parser) void {
self.command = .{ .show_desktop_notification = .{
.title = "",
.body = undefined,
} };
self.temp_state = .{ .str = &self.command.show_desktop_notification.body };
self.state = .string;
}
fn prepAllocableString(self: *Parser) void { fn prepAllocableString(self: *Parser) void {
assert(self.buf_dynamic == null); assert(self.buf_dynamic == null);
@ -1532,6 +1662,174 @@ test "OSC: show desktop notification with title" {
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body");
} }
test "OSC: OSC9 progress set" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;1;100";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .set);
try testing.expect(cmd.progress.progress == 100);
}
test "OSC: OSC9 progress set overflow" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;1;900";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .set);
try testing.expect(cmd.progress.progress == 100);
}
test "OSC: OSC9 progress set single digit" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;1;9";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .set);
try testing.expect(cmd.progress.progress == 9);
}
test "OSC: OSC9 progress set double digit" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;1;94";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .set);
try testing.expect(cmd.progress.progress == 94);
}
test "OSC: OSC9 progress set extra semicolon triggers desktop notification" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;1;100;";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings(cmd.show_desktop_notification.title, "");
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "4;1;100;");
}
test "OSC: OSC9 progress remove with no progress" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;0;";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .remove);
try testing.expect(cmd.progress.progress == null);
}
test "OSC: OSC9 progress remove ignores progress" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;0;100";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .remove);
try testing.expect(cmd.progress.progress == null);
}
test "OSC: OSC9 progress remove extra semicolon" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;0;100;";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings(cmd.show_desktop_notification.title, "");
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "4;0;100;");
}
test "OSC: OSC9 progress error" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;2";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .@"error");
try testing.expect(cmd.progress.progress == null);
}
test "OSC: OSC9 progress error with progress" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;2;100";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .@"error");
try testing.expect(cmd.progress.progress == 100);
}
test "OSC: OSC9 progress pause" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;4";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .pause);
try testing.expect(cmd.progress.progress == null);
}
test "OSC: OSC9 progress pause with progress" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;4;100";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .pause);
try testing.expect(cmd.progress.progress == 100);
}
test "OSC: empty param" { test "OSC: empty param" {
const testing = std.testing; const testing = std.testing;

View File

@ -103,6 +103,12 @@ pub fn RefCountedSet(
/// unlikely. Roughly a (1/table_cap)^32 -- with any normal /// unlikely. Roughly a (1/table_cap)^32 -- with any normal
/// table capacity that is so unlikely that it's not worth /// table capacity that is so unlikely that it's not worth
/// handling. /// handling.
///
/// However, that assumes a uniform hash function, which
/// is not guaranteed and can be subverted with a crafted
/// input. We handle this gracefully by returning an error
/// anywhere where we're about to insert if there's any
/// item with a PSL in the last slot of the stats array.
psl_stats: [32]Id = [_]Id{0} ** 32, psl_stats: [32]Id = [_]Id{0} ** 32,
/// The backing store of items /// The backing store of items
@ -237,6 +243,16 @@ pub fn RefCountedSet(
return id; return id;
} }
// While it should be statistically impossible to exceed the
// bounds of `psl_stats`, the hash function is not perfect and
// in such a case we want to remain stable. If we're about to
// insert an item and there's something with a PSL of `len - 1`,
// we may end up with a PSL of `len` which would exceed the bounds.
// In such a case, we claim to be out of memory.
if (self.psl_stats[self.psl_stats.len - 1] > 0) {
return AddError.OutOfMemory;
}
// If the item doesn't exist, we need an available ID. // If the item doesn't exist, we need an available ID.
if (self.next_id >= self.layout.cap) { if (self.next_id >= self.layout.cap) {
// Arbitrarily chosen, threshold for rehashing. // Arbitrarily chosen, threshold for rehashing.
@ -284,6 +300,11 @@ pub fn RefCountedSet(
if (id < self.next_id) { if (id < self.next_id) {
if (items[id].meta.ref == 0) { if (items[id].meta.ref == 0) {
// See comment in `addContext` for details.
if (self.psl_stats[self.psl_stats.len - 1] > 0) {
return AddError.OutOfMemory;
}
self.deleteItem(base, id, ctx); self.deleteItem(base, id, ctx);
const added_id = self.upsert(base, value, id, ctx); const added_id = self.upsert(base, value, id, ctx);
@ -419,7 +440,7 @@ pub fn RefCountedSet(
if (item.meta.bucket > self.layout.table_cap) return; if (item.meta.bucket > self.layout.table_cap) return;
if (table[item.meta.bucket] != id) return; assert(table[item.meta.bucket] == id);
if (comptime @hasDecl(Context, "deleted")) { if (comptime @hasDecl(Context, "deleted")) {
// Inform the context struct that we're // Inform the context struct that we're
@ -449,6 +470,8 @@ pub fn RefCountedSet(
} }
table[p] = 0; table[p] = 0;
self.assertIntegrity(base, ctx);
} }
/// Find an item in the table and return its ID. /// Find an item in the table and return its ID.
@ -463,7 +486,7 @@ pub fn RefCountedSet(
const hash: u64 = ctx.hash(value); const hash: u64 = ctx.hash(value);
for (0..self.max_psl + 1) |i| { for (0..self.max_psl + 1) |i| {
const p: usize = @intCast((hash + i) & self.layout.table_mask); const p: usize = @intCast((hash +% i) & self.layout.table_mask);
const id = table[p]; const id = table[p];
// Empty bucket, our item cannot have probed to // Empty bucket, our item cannot have probed to
@ -538,11 +561,10 @@ pub fn RefCountedSet(
var held_id: Id = new_id; var held_id: Id = new_id;
var held_item: *Item = &new_item; var held_item: *Item = &new_item;
var chosen_p: ?Id = null;
var chosen_id: Id = new_id; var chosen_id: Id = new_id;
for (0..self.layout.table_cap - 1) |i| { for (0..self.layout.table_cap - 1) |i| {
const p: Id = @intCast((hash + i) & self.layout.table_mask); const p: Id = @intCast((hash +% i) & self.layout.table_mask);
const id = table[p]; const id = table[p];
// Empty bucket, put our held item in to it and break. // Empty bucket, put our held item in to it and break.
@ -557,7 +579,9 @@ pub fn RefCountedSet(
const item = &items[id]; const item = &items[id];
// If there's a dead item then we resurrect it // If there's a dead item then we resurrect it
// for our value so that we can re-use its ID. // for our value so that we can re-use its ID,
// unless its ID is greater than the one we're
// given (i.e. prefer smaller IDs).
if (item.meta.ref == 0) { if (item.meta.ref == 0) {
if (comptime @hasDecl(Context, "deleted")) { if (comptime @hasDecl(Context, "deleted")) {
// Inform the context struct that we're // Inform the context struct that we're
@ -565,40 +589,41 @@ pub fn RefCountedSet(
ctx.deleted(item.value); ctx.deleted(item.value);
} }
chosen_id = id; // Reap the dead item.
held_item.meta.bucket = p;
self.psl_stats[item.meta.psl] -= 1; self.psl_stats[item.meta.psl] -= 1;
item.* = .{};
// Only resurrect this item if it has a
// smaller id than the one we were given.
if (id < new_id) chosen_id = id;
// Put the currently held item in to the
// bucket of the item that we just reaped.
table[p] = held_id;
held_item.meta.bucket = p;
self.psl_stats[held_item.meta.psl] += 1; self.psl_stats[held_item.meta.psl] += 1;
self.max_psl = @max(self.max_psl, held_item.meta.psl); self.max_psl = @max(self.max_psl, held_item.meta.psl);
// If we're not still holding our new item then we
// need to make sure that we put the re-used ID in
// the right place, where we previously put new_id.
if (chosen_p) |c| {
table[c] = id;
table[p] = held_id;
} else {
// If we're still holding our new item then we
// don't actually have to do anything, because
// the table already has the correct ID here.
}
break; break;
} }
// This item has a lower PSL, swap it out with our held item. // If this item has a lower PSL, or has equal PSL and lower ref
if (item.meta.psl < held_item.meta.psl) { // count, then we swap it out with our held item. By doing this,
if (held_id == new_id) { // items with high reference counts are prioritized for earlier
chosen_p = p; // placement. The assumption is that an item which has a higher
new_item.meta.bucket = p; // reference count will be accessed more frequently, so we want
} // to minimize the time it takes to find it.
if (item.meta.psl < held_item.meta.psl or
item.meta.psl == held_item.meta.psl and
item.meta.ref < held_item.meta.ref)
{
// Put our held item in the bucket.
table[p] = held_id; table[p] = held_id;
items[held_id].meta.bucket = p; held_item.meta.bucket = p;
self.psl_stats[held_item.meta.psl] += 1; self.psl_stats[held_item.meta.psl] += 1;
self.max_psl = @max(self.max_psl, held_item.meta.psl); self.max_psl = @max(self.max_psl, held_item.meta.psl);
// Pick up the item that has a lower PSL.
held_id = id; held_id = id;
held_item = item; held_item = item;
self.psl_stats[item.meta.psl] -= 1; self.psl_stats[item.meta.psl] -= 1;
@ -608,8 +633,60 @@ pub fn RefCountedSet(
held_item.meta.psl += 1; held_item.meta.psl += 1;
} }
// Our chosen ID may have changed if we decided
// to re-use a dead item's ID, so we make sure
// the chosen bucket contains the correct ID.
table[new_item.meta.bucket] = chosen_id;
// Finally place our new item in to our array.
items[chosen_id] = new_item; items[chosen_id] = new_item;
self.assertIntegrity(base, ctx);
return chosen_id; return chosen_id;
} }
fn assertIntegrity(
self: *const Self,
base: anytype,
ctx: Context,
) void {
// Disabled because this is excessively slow, only enable
// if debugging a RefCountedSet issue or modifying its logic.
if (false and std.debug.runtime_safety) {
const table = self.table.ptr(base);
const items = self.items.ptr(base);
var psl_stats: [32]Id = [_]Id{0} ** 32;
for (items[0..self.layout.cap], 0..) |item, id| {
if (item.meta.bucket < std.math.maxInt(Id)) {
assert(table[item.meta.bucket] == id);
psl_stats[item.meta.psl] += 1;
}
}
std.testing.expectEqualSlices(Id, &psl_stats, &self.psl_stats) catch assert(false);
assert(std.mem.eql(Id, &psl_stats, &self.psl_stats));
psl_stats = [_]Id{0} ** 32;
for (table[0..self.layout.table_cap], 0..) |id, bucket| {
const item = items[id];
if (item.meta.bucket < std.math.maxInt(Id)) {
assert(item.meta.bucket == bucket);
const hash: u64 = ctx.hash(item.value);
const p: usize = @intCast((hash +% item.meta.psl) & self.layout.table_mask);
assert(p == bucket);
psl_stats[item.meta.psl] += 1;
}
}
std.testing.expectEqualSlices(Id, &psl_stats, &self.psl_stats) catch assert(false);
}
}
}; };
} }

View File

@ -601,30 +601,37 @@ pub fn Stream(comptime Handler: type) type {
// Cursor Tabulation Control // Cursor Tabulation Control
'W' => { 'W' => {
switch (input.params.len) { switch (input.params.len) {
0 => if (input.intermediates.len == 1 and input.intermediates[0] == '?') { 0 => if (@hasDecl(T, "tabSet"))
if (@hasDecl(T, "tabReset")) try self.handler.tabSet()
try self.handler.tabReset() else
else log.warn("unimplemented tab set callback: {}", .{input}),
log.warn("unimplemented tab reset callback: {}", .{input});
},
1 => switch (input.params[0]) { 1 => if (input.intermediates.len == 1 and input.intermediates[0] == '?') {
0 => if (@hasDecl(T, "tabSet")) if (input.params[0] == 5) {
try self.handler.tabSet() if (@hasDecl(T, "tabReset"))
else try self.handler.tabReset()
log.warn("unimplemented tab set callback: {}", .{input}), else
log.warn("unimplemented tab reset callback: {}", .{input});
} else log.warn("invalid cursor tabulation control: {}", .{input});
} else {
switch (input.params[0]) {
0 => if (@hasDecl(T, "tabSet"))
try self.handler.tabSet()
else
log.warn("unimplemented tab set callback: {}", .{input}),
2 => if (@hasDecl(T, "tabClear")) 2 => if (@hasDecl(T, "tabClear"))
try self.handler.tabClear(.current) try self.handler.tabClear(.current)
else else
log.warn("unimplemented tab clear callback: {}", .{input}), log.warn("unimplemented tab clear callback: {}", .{input}),
5 => if (@hasDecl(T, "tabClear")) 5 => if (@hasDecl(T, "tabClear"))
try self.handler.tabClear(.all) try self.handler.tabClear(.all)
else else
log.warn("unimplemented tab clear callback: {}", .{input}), log.warn("unimplemented tab clear callback: {}", .{input}),
else => {}, else => {},
}
}, },
else => {}, else => {},
@ -1447,6 +1454,10 @@ pub fn Stream(comptime Handler: type) type {
return; return;
} else log.warn("unimplemented OSC callback: {}", .{cmd}); } else log.warn("unimplemented OSC callback: {}", .{cmd});
}, },
.progress => {
log.warn("unimplemented OSC callback: {}", .{cmd});
},
} }
// Fall through for when we don't have a handler. // Fall through for when we don't have a handler.
@ -2327,3 +2338,58 @@ test "stream: CSI t pop title with index" {
.index = 5, .index = 5,
}, s.handler.op.?); }, s.handler.op.?);
} }
test "stream CSI W clear tab stops" {
const H = struct {
op: ?csi.TabClear = null,
pub fn tabClear(self: *@This(), op: csi.TabClear) !void {
self.op = op;
}
};
var s: Stream(H) = .{ .handler = .{} };
try s.nextSlice("\x1b[2W");
try testing.expectEqual(csi.TabClear.current, s.handler.op.?);
try s.nextSlice("\x1b[5W");
try testing.expectEqual(csi.TabClear.all, s.handler.op.?);
}
test "stream CSI W tab set" {
const H = struct {
called: bool = false,
pub fn tabSet(self: *@This()) !void {
self.called = true;
}
};
var s: Stream(H) = .{ .handler = .{} };
try s.nextSlice("\x1b[W");
try testing.expect(s.handler.called);
s.handler.called = false;
try s.nextSlice("\x1b[0W");
try testing.expect(s.handler.called);
}
test "stream CSI ? W reset tab stops" {
const H = struct {
reset: bool = false,
pub fn tabReset(self: *@This()) !void {
self.reset = true;
}
};
var s: Stream(H) = .{ .handler = .{} };
try s.nextSlice("\x1b[?2W");
try testing.expect(!s.handler.reset);
try s.nextSlice("\x1b[?5W");
try testing.expect(s.handler.reset);
}

View File

@ -8,7 +8,7 @@ const Offset = size.Offset;
const OffsetBuf = size.OffsetBuf; const OffsetBuf = size.OffsetBuf;
const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet;
const Wyhash = std.hash.Wyhash; const XxHash3 = std.hash.XxHash3;
const autoHash = std.hash.autoHash; const autoHash = std.hash.autoHash;
/// The unique identifier for a style. This is at most the number of cells /// The unique identifier for a style. This is at most the number of cells
@ -27,7 +27,9 @@ pub const Style = struct {
/// On/off attributes that don't require much bit width so we use /// On/off attributes that don't require much bit width so we use
/// a packed struct to make this take up significantly less space. /// a packed struct to make this take up significantly less space.
flags: packed struct { flags: Flags = .{},
const Flags = packed struct(u16) {
bold: bool = false, bold: bool = false,
italic: bool = false, italic: bool = false,
faint: bool = false, faint: bool = false,
@ -37,16 +39,23 @@ pub const Style = struct {
strikethrough: bool = false, strikethrough: bool = false,
overline: bool = false, overline: bool = false,
underline: sgr.Attribute.Underline = .none, underline: sgr.Attribute.Underline = .none,
} = .{}, _padding: u5 = 0,
};
/// The color for an SGR attribute. A color can come from multiple /// The color for an SGR attribute. A color can come from multiple
/// sources so we use this to track the source plus color value so that /// sources so we use this to track the source plus color value so that
/// we can properly react to things like palette changes. /// we can properly react to things like palette changes.
pub const Color = union(enum) { pub const Color = union(Tag) {
none: void, none: void,
palette: u8, palette: u8,
rgb: color.RGB, rgb: color.RGB,
const Tag = enum(u8) {
none,
palette,
rgb,
};
/// Formatting to make debug logs easier to read /// Formatting to make debug logs easier to read
/// by only including non-default attributes. /// by only including non-default attributes.
pub fn format( pub fn format(
@ -78,7 +87,10 @@ pub const Style = struct {
/// True if the style is equal to another style. /// True if the style is equal to another style.
pub fn eql(self: Style, other: Style) bool { pub fn eql(self: Style, other: Style) bool {
return std.meta.eql(self, other); const packed_self = PackedStyle.fromStyle(self);
const packed_other = PackedStyle.fromStyle(other);
// TODO: in Zig 0.14, equating packed structs is allowed. Remove this work around.
return @as(u128, @bitCast(packed_self)) == @as(u128, @bitCast(packed_other));
} }
/// Returns the bg color for a cell with this style given the cell /// Returns the bg color for a cell with this style given the cell
@ -230,16 +242,84 @@ pub const Style = struct {
_ = try writer.write(" }"); _ = try writer.write(" }");
} }
/// `PackedStyle` represents the same data as `Style` but without padding,
/// which is necessary for hashing via re-interpretation of the underlying
/// bytes.
///
/// `Style` is still preferred for everything else as it has type-safety
/// when using the `Color` tagged union.
///
/// Empirical testing shows that storing all of the tags first and then the
/// data provides a better layout for serializing into and is faster on
/// benchmarks.
const PackedStyle = packed struct(u128) {
tags: packed struct {
fg: Color.Tag,
bg: Color.Tag,
underline: Color.Tag,
},
data: packed struct {
fg: Data,
bg: Data,
underline: Data,
},
flags: Flags,
_padding: u16 = 0,
/// After https://github.com/ziglang/zig/issues/19754 is implemented,
/// it will be an compiler-error to have packed union fields of
/// differing size.
///
/// For now we just need to be careful not to accidentally introduce
/// padding.
const Data = packed union {
none: u24,
palette: packed struct(u24) {
idx: u8,
_padding: u16 = 0,
},
rgb: color.RGB,
fn fromColor(c: Color) Data {
return switch (c) {
inline else => |v, t| @unionInit(
Data,
@tagName(t),
switch (t) {
.none => 0,
.palette => .{ .idx = v },
.rgb => v,
},
),
};
}
};
fn fromStyle(style: Style) PackedStyle {
return .{
.tags = .{
.fg = std.meta.activeTag(style.fg_color),
.bg = std.meta.activeTag(style.bg_color),
.underline = std.meta.activeTag(style.underline_color),
},
.data = .{
.fg = Data.fromColor(style.fg_color),
.bg = Data.fromColor(style.bg_color),
.underline = Data.fromColor(style.underline_color),
},
.flags = style.flags,
};
}
};
pub fn hash(self: *const Style) u64 { pub fn hash(self: *const Style) u64 {
var hasher = Wyhash.init(0); const packed_style = PackedStyle.fromStyle(self.*);
autoHash(&hasher, self.*); return XxHash3.hash(0, std.mem.asBytes(&packed_style));
return hasher.final();
} }
test { comptime {
// The size of the struct so we can be aware of changes. assert(@sizeOf(PackedStyle) == 16);
const testing = std.testing; assert(std.meta.hasUniqueRepresentation(PackedStyle));
try testing.expectEqual(@as(usize, 14), @sizeOf(Style));
} }
}; };