Merge remote-tracking branch 'upstream/main' into titlebar-unzoom-button

This commit is contained in:
Pete Schaffner
2024-04-03 15:25:51 +02:00
37 changed files with 943 additions and 417 deletions

View File

@ -434,7 +434,8 @@ To build Ghostty, you need [Zig](https://ziglang.org/) installed.
On Linux, you may need to install additional dependencies. See On Linux, you may need to install additional dependencies. See
[Linux Installation Tips](#linux-installation-tips). On macOS, you [Linux Installation Tips](#linux-installation-tips). On macOS, you
need Xcode installed with the macOS and iOS SDKs enabled. need Xcode installed with the macOS and iOS SDKs enabled. See
[Mac `.app`](#mac-app).
The official development environment is defined by Nix. You do not need The official development environment is defined by Nix. You do not need
to use Nix to develop Ghostty, but the Nix environment is the environment to use Nix to develop Ghostty, but the Nix environment is the environment
@ -565,7 +566,22 @@ all features of Ghostty work.
### Mac `.app` ### Mac `.app`
To build the official, fully featured macOS application, you must To build the official, fully featured macOS application, you must
build on a macOS machine with XCode installed: build on a macOS machine with Xcode installed, and the active developer
directory pointing to it. If you're not sure that's the case, check the
output of `xcode-select --print-path`:
```shell-session
$ xcode-select --print-path
/Library/Developer/CommandLineTools # <-- BAD
$ sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
$ xcode-select --print-path
/Applications/Xcode.app/Contents/Developer # <-- GOOD
```
The above can happen if you install the Xcode Command Line Tools _after_ Xcode
is installed. With that out of the way, make sure you have both the macOS and
iOS SDKs installed (from inside Xcode → Settings → Platforms), and let's move
on to building Ghostty:
```shell-session ```shell-session
$ zig build -Doptimize=ReleaseFast $ zig build -Doptimize=ReleaseFast

View File

@ -3,6 +3,7 @@ const builtin = @import("builtin");
const fs = std.fs; const fs = std.fs;
const CompileStep = std.Build.Step.Compile; const CompileStep = std.Build.Step.Compile;
const RunStep = std.Build.Step.Run; const RunStep = std.Build.Step.Run;
const ResolvedTarget = std.Build.ResolvedTarget;
const apprt = @import("src/apprt.zig"); const apprt = @import("src/apprt.zig");
const font = @import("src/font/main.zig"); const font = @import("src/font/main.zig");
@ -706,10 +707,15 @@ pub fn build(b: *std.Build) !void {
const test_step = b.step("test", "Run all tests"); const test_step = b.step("test", "Run all tests");
const test_filter = b.option([]const u8, "test-filter", "Filter for test"); const test_filter = b.option([]const u8, "test-filter", "Filter for test");
// Force all Mac builds to use a `generic` CPU. This avoids
// potential issues with `highway` compile errors due to missing
// `arm_neon` features (see for example https://github.com/mitchellh/ghostty/issues/1640).
const test_target = if (target.result.os.tag == .macos and builtin.target.isDarwin()) genericMacOSTarget(b) else target;
const main_test = b.addTest(.{ const main_test = b.addTest(.{
.name = "ghostty-test", .name = "ghostty-test",
.root_source_file = .{ .path = "src/main.zig" }, .root_source_file = .{ .path = "src/main.zig" },
.target = target, .target = test_target,
.filter = test_filter, .filter = test_filter,
}); });
@ -754,6 +760,16 @@ fn osVersionMin(tag: std.Target.Os.Tag) ?std.Target.Query.OsVersion {
}; };
} }
// Returns a ResolvedTarget for a mac with a `target.result.cpu.model.name` of `generic`.
// `b.standardTargetOptions()` returns a more specific cpu like `apple_a15`.
fn genericMacOSTarget(b: *std.Build) ResolvedTarget {
return b.resolveTargetQuery(.{
.cpu_arch = .aarch64,
.os_tag = .macos,
.os_version_min = osVersionMin(.macos),
});
}
/// Creates a universal macOS libghostty library and returns the path /// Creates a universal macOS libghostty library and returns the path
/// to the final library. /// to the final library.
/// ///
@ -781,11 +797,7 @@ fn createMacOSLib(
const lib = b.addStaticLibrary(.{ const lib = b.addStaticLibrary(.{
.name = "ghostty", .name = "ghostty",
.root_source_file = .{ .path = "src/main_c.zig" }, .root_source_file = .{ .path = "src/main_c.zig" },
.target = b.resolveTargetQuery(.{ .target = genericMacOSTarget(b),
.cpu_arch = .aarch64,
.os_tag = .macos,
.os_version_min = osVersionMin(.macos),
}),
.optimize = optimize, .optimize = optimize,
}); });
lib.bundle_compiler_rt = true; lib.bundle_compiler_rt = true;
@ -1165,6 +1177,43 @@ fn addDeps(
.gtk => { .gtk => {
step.linkSystemLibrary2("gtk4", dynamic_link_opts); step.linkSystemLibrary2("gtk4", dynamic_link_opts);
if (config.libadwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts); if (config.libadwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts);
{
const gresource = @import("src/apprt/gtk/gresource.zig");
const wf = b.addWriteFiles();
const gresource_xml = wf.add(
"gresource.xml",
if (config.libadwaita)
gresource.gresource_xml_libadwaita
else
gresource.gresource_xml_gtk,
);
const generate_resources_c = b.addSystemCommand(&.{
"glib-compile-resources",
"--c-name",
"ghostty",
"--generate-source",
"--target",
});
const ghostty_resources_c = generate_resources_c.addOutputFileArg("ghostty_resources.c");
generate_resources_c.addFileArg(gresource_xml);
generate_resources_c.extra_file_dependencies = if (config.libadwaita) &gresource.dependencies_libadwaita else &gresource.dependencies_gtk;
step.addCSourceFile(.{ .file = ghostty_resources_c, .flags = &.{} });
const generate_resources_h = b.addSystemCommand(&.{
"glib-compile-resources",
"--c-name",
"ghostty",
"--generate-header",
"--target",
});
const ghostty_resources_h = generate_resources_h.addOutputFileArg("ghostty_resources.h");
generate_resources_h.addFileArg(gresource_xml);
generate_resources_h.extra_file_dependencies = if (config.libadwaita) &gresource.dependencies_libadwaita else &gresource.dependencies_gtk;
step.addIncludePath(ghostty_resources_h.dirname());
}
}, },
} }
} }

6
flake.lock generated
View File

@ -147,11 +147,11 @@
}, },
"nixpkgs-zig-0-12": { "nixpkgs-zig-0-12": {
"locked": { "locked": {
"lastModified": 1711143939, "lastModified": 1711789504,
"narHash": "sha256-oT6a81U4NHjJH1hjaMVXKsdTZJwl2dT+MhMESKoevvA=", "narHash": "sha256-1XRwW0MD9LxtHMMlPmF3rDw/Zbv4jLnpGnJEtibO+MQ=",
"owner": "vancluever", "owner": "vancluever",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "c4749393c06e52da4adf42877fdf9bac7141f0de", "rev": "c9e24149cca8215b84fc3ce5bc2bdc1ca823a588",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@ -52,23 +52,19 @@
wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {};
}; };
packages.${system} = rec { packages.${system} = let
ghostty-debug = pkgs-stable.callPackage ./nix/package.nix { mkArgs = optimize: {
inherit (pkgs-zig-0-12) zig_0_12; inherit (pkgs-zig-0-12) zig_0_12;
inherit optimize;
revision = self.shortRev or self.dirtyShortRev or "dirty"; revision = self.shortRev or self.dirtyShortRev or "dirty";
optimize = "Debug";
}; };
ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix { in rec {
inherit (pkgs-zig-0-12) zig_0_12; ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug");
revision = self.shortRev or self.dirtyShortRev or "dirty"; ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
optimize = "ReleaseSafe"; ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
};
ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix { ghostty = ghostty-releasefast;
inherit (pkgs-zig-0-12) zig_0_12;
revision = self.shortRev or self.dirtyShortRev or "dirty";
optimize = "ReleaseFast";
};
ghostty = ghostty-releasesafe;
default = ghostty; default = ghostty;
}; };

View File

@ -175,6 +175,13 @@ class TerminalController: NSWindowController, NSWindowDelegate,
let appearance = NSAppearance(named: color.isLightColor ? .aqua : .darkAqua) let appearance = NSAppearance(named: color.isLightColor ? .aqua : .darkAqua)
window.appearance = appearance window.appearance = appearance
} }
// Set the font for the window and tab titles.
if let titleFontName = ghostty.config.windowTitleFontFamily {
window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize)
} else {
window.titlebarFont = nil
}
} }
/// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about
@ -258,7 +265,6 @@ class TerminalController: NSWindowController, NSWindowDelegate,
if (ghostty.config.macosTitlebarTabs) { if (ghostty.config.macosTitlebarTabs) {
window.tabbingMode = .preferred window.tabbingMode = .preferred
window.titlebarTabs = true window.titlebarTabs = true
syncAppearance()
DispatchQueue.main.async { DispatchQueue.main.async {
window.tabbingMode = .automatic window.tabbingMode = .automatic
} }
@ -290,6 +296,9 @@ class TerminalController: NSWindowController, NSWindowDelegate,
window.tabGroup?.removeWindow(window) window.tabGroup?.removeWindow(window)
} }
} }
// Apply any additional appearance-related properties to the new window.
syncAppearance()
} }
// Shows the "+" button in the tab bar, responds to that click. // Shows the "+" button in the tab bar, responds to that click.

View File

@ -15,6 +15,16 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate {
} }
} }
var titleFont: NSFont? {
get {
titleTextField.font
}
set {
titleTextField.font = newValue
}
}
override init(identifier: NSToolbar.Identifier) { override init(identifier: NSToolbar.Identifier) {
super.init(identifier: identifier) super.init(identifier: identifier)

View File

@ -97,6 +97,12 @@ class TerminalWindow: NSWindow {
// MARK: - NSWindow // MARK: - NSWindow
override var title: String {
didSet {
tab.attributedTitle = attributedTitle
}
}
override func becomeKey() { override func becomeKey() {
// This is required because the removeTitlebarAccessoryViewController hook does not // This is required because the removeTitlebarAccessoryViewController hook does not
// catch the creation of a new window by "tearing off" a tab from a tabbed window. // catch the creation of a new window by "tearing off" a tab from a tabbed window.
@ -109,6 +115,7 @@ class TerminalWindow: NSWindow {
updateNewTabButtonOpacity() updateNewTabButtonOpacity()
resetZoomTabButton.contentTintColor = .controlAccentColor resetZoomTabButton.contentTintColor = .controlAccentColor
resetZoomToolbarButton.contentTintColor = .controlAccentColor resetZoomToolbarButton.contentTintColor = .controlAccentColor
tab.attributedTitle = attributedTitle
} }
override func resignKey() { override func resignKey() {
@ -117,6 +124,7 @@ class TerminalWindow: NSWindow {
updateNewTabButtonOpacity() updateNewTabButtonOpacity()
resetZoomTabButton.contentTintColor = .secondaryLabelColor resetZoomTabButton.contentTintColor = .secondaryLabelColor
resetZoomToolbarButton.contentTintColor = .tertiaryLabelColor resetZoomToolbarButton.contentTintColor = .tertiaryLabelColor
tab.attributedTitle = attributedTitle
} }
override func layoutIfNeeded() { override func layoutIfNeeded() {
@ -172,6 +180,38 @@ class TerminalWindow: NSWindow {
updateResetZoomTitlebarButtonVisibility() updateResetZoomTitlebarButtonVisibility()
} }
// Used to set the titlebar font.
var titlebarFont: NSFont? {
didSet {
titlebarTextField?.font = titlebarFont
tab.attributedTitle = attributedTitle
if let toolbar = toolbar as? TerminalToolbar {
toolbar.titleFont = titlebarFont
}
}
}
// Find the NSTextField responsible for displaying the titlebar's title.
private var titlebarTextField: NSTextField? {
guard let titlebarContainer = contentView?.superview?.subviews
.first(where: { $0.className == "NSTitlebarContainerView" }) else { return nil }
guard let titlebarView = titlebarContainer.subviews
.first(where: { $0.className == "NSTitlebarView" }) else { return nil }
return titlebarView.subviews.first(where: { $0 is NSTextField }) as? NSTextField
}
// Return a styled representation of our title property.
private var attributedTitle: NSAttributedString? {
guard let titlebarFont else { return nil }
let attributes: [NSAttributedString.Key: Any] = [
.font: titlebarFont,
.foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
]
return NSAttributedString(string: title, attributes: attributes)
}
// MARK: - // MARK: -
private var newTabButtonImageLayer: VibrantLayer? = nil private var newTabButtonImageLayer: VibrantLayer? = nil

View File

@ -198,6 +198,15 @@ extension Ghostty {
return v return v
} }
var windowTitleFontFamily: String? {
guard let config = self.config else { return nil }
var v: UnsafePointer<Int8>? = nil
let key = "window-title-font-family"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil }
guard let ptr = v else { return nil }
return String(cString: ptr)
}
var macosTitlebarTabs: Bool { var macosTitlebarTabs: Bool {
guard let config = self.config else { return false } guard let config = self.config else { return false }
var v = false; var v = false;

View File

@ -78,7 +78,7 @@ in
mkShell { mkShell {
name = "ghostty"; name = "ghostty";
nativeBuildInputs = packages =
[ [
# For builds # For builds
llvmPackages_latest.llvm llvmPackages_latest.llvm
@ -120,13 +120,7 @@ in
gdb gdb
valgrind valgrind
wraptest wraptest
];
buildInputs =
[
# TODO: non-linux
]
++ lib.optionals stdenv.isLinux [
bzip2 bzip2
expat expat
fontconfig fontconfig

View File

@ -151,8 +151,16 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
break :app @ptrCast(adw_app); break :app @ptrCast(adw_app);
}; };
errdefer c.g_object_unref(app); errdefer c.g_object_unref(app);
const gapp = @as(*c.GApplication, @ptrCast(app));
// force the resource path to a known value so that it doesn't depend on
// the app id and load in compiled resources
c.g_application_set_resource_base_path(gapp, "/com/mitchellh/ghostty");
c.g_resources_register(c.ghostty_get_resource());
// The `activate` signal is used when Ghostty is first launched and when a
// secondary Ghostty is launched and requests a new window.
_ = c.g_signal_connect_data( _ = c.g_signal_connect_data(
app, app,
"activate", "activate",
@ -169,7 +177,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
if (c.g_main_context_acquire(ctx) == 0) return error.GtkContextAcquireFailed; if (c.g_main_context_acquire(ctx) == 0) return error.GtkContextAcquireFailed;
errdefer c.g_main_context_release(ctx); errdefer c.g_main_context_release(ctx);
const gapp = @as(*c.GApplication, @ptrCast(app));
var err_: ?*c.GError = null; var err_: ?*c.GError = null;
if (c.g_application_register( if (c.g_application_register(
gapp, gapp,

View File

@ -53,6 +53,7 @@ fn init(self: *ConfigErrors, app: *App) !void {
c.gtk_window_set_title(gtk_window, "Configuration Errors"); c.gtk_window_set_title(gtk_window, "Configuration Errors");
c.gtk_window_set_default_size(gtk_window, 600, 275); c.gtk_window_set_default_size(gtk_window, 600, 275);
c.gtk_window_set_resizable(gtk_window, 0); c.gtk_window_set_resizable(gtk_window, 0);
c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty");
_ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);
// Set some state // Set some state

View File

@ -943,32 +943,14 @@ pub fn showDesktopNotification(
0 => "Ghostty", 0 => "Ghostty",
else => title, else => title,
}; };
const notif = c.g_notification_new(t.ptr); const notif = c.g_notification_new(t.ptr);
defer c.g_object_unref(notif); defer c.g_object_unref(notif);
c.g_notification_set_body(notif, body.ptr); c.g_notification_set_body(notif, body.ptr);
// Find our icon in the current icon theme. Not pretty, but the builtin GIO const icon = c.g_themed_icon_new("com.mitchellh.ghostty");
// method "g_themed_icon_new" doesn't search XDG_DATA_DIRS, so any install
// not in /usr/share will be unable to find an icon
const display = c.gdk_display_get_default();
const theme = c.gtk_icon_theme_get_for_display(display);
const icon = c.gtk_icon_theme_lookup_icon(
theme,
"com.mitchellh.ghostty",
null,
48,
1, // Window scale
c.GTK_TEXT_DIR_LTR,
0,
);
defer c.g_object_unref(icon); defer c.g_object_unref(icon);
// Get the filepath of the icon we found c.g_notification_set_icon(notif, icon);
const file = c.gtk_icon_paintable_get_file(icon);
defer c.g_object_unref(file);
// Create a GIO icon
const gicon = c.g_file_icon_new(file);
defer c.g_object_unref(gicon);
c.g_notification_set_icon(notif, gicon);
const g_app: *c.GApplication = @ptrCast(self.app.app); const g_app: *c.GApplication = @ptrCast(self.app.app);
@ -984,6 +966,8 @@ fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void {
c.gtk_gl_area_make_current(area); c.gtk_gl_area_make_current(area);
if (c.gtk_gl_area_get_error(area)) |err| { if (c.gtk_gl_area_get_error(area)) |err| {
log.err("surface failed to realize: {s}", .{err.*.message}); log.err("surface failed to realize: {s}", .{err.*.message});
log.warn("this error is usually due to a driver or gtk bug", .{});
log.warn("this is a common cause of this issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/4950", .{});
return; return;
} }
@ -1624,6 +1608,7 @@ fn gtkInputCommit(
fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) void { fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) void {
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
if (!self.realized) return;
// Notify our IM context // Notify our IM context
c.gtk_im_context_focus_in(self.im_context); c.gtk_im_context_focus_in(self.im_context);
@ -1637,6 +1622,7 @@ fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) vo
fn gtkFocusLeave(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) void { fn gtkFocusLeave(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) void {
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
if (!self.realized) return;
// Notify our IM context // Notify our IM context
c.gtk_im_context_focus_out(self.im_context); c.gtk_im_context_focus_out(self.im_context);

View File

@ -18,7 +18,6 @@ const CoreSurface = @import("../../Surface.zig");
const App = @import("App.zig"); const App = @import("App.zig");
const Surface = @import("Surface.zig"); const Surface = @import("Surface.zig");
const Tab = @import("Tab.zig"); const Tab = @import("Tab.zig");
const icon = @import("icon.zig");
const c = @import("c.zig"); const c = @import("c.zig");
const log = std.log.scoped(.gtk); const log = std.log.scoped(.gtk);
@ -31,10 +30,6 @@ window: *c.GtkWindow,
/// The notebook (tab grouping) for this window. /// The notebook (tab grouping) for this window.
notebook: *c.GtkNotebook, notebook: *c.GtkNotebook,
/// The resources directory for the icon (if any). We need to retain a
/// pointer to this because GTK can use it at any time.
icon: icon.Icon,
pub fn create(alloc: Allocator, app: *App) !*Window { pub fn create(alloc: Allocator, app: *App) !*Window {
// Allocate a fixed pointer for our window. We try to minimize // Allocate a fixed pointer for our window. We try to minimize
// allocations but windows and other GUI requirements are so minimal // allocations but windows and other GUI requirements are so minimal
@ -53,7 +48,6 @@ pub fn init(self: *Window, app: *App) !void {
// Set up our own state // Set up our own state
self.* = .{ self.* = .{
.app = app, .app = app,
.icon = undefined,
.window = undefined, .window = undefined,
.notebook = undefined, .notebook = undefined,
}; };
@ -70,10 +64,7 @@ pub fn init(self: *Window, app: *App) !void {
// to disable this so that terminal programs can capture F10 (such as htop) // to disable this so that terminal programs can capture F10 (such as htop)
c.gtk_window_set_handle_menubar_accel(gtk_window, 0); c.gtk_window_set_handle_menubar_accel(gtk_window, 0);
// If we don't have the icon then we'll try to add our resources dir c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty");
// to the search path and see if we can find it there.
self.icon = try icon.appIcon(self.app, window);
c.gtk_window_set_icon_name(gtk_window, self.icon.name);
// Apply background opacity if we have it // Apply background opacity if we have it
if (app.config.@"background-opacity" < 1) { if (app.config.@"background-opacity" < 1) {
@ -189,9 +180,7 @@ fn initActions(self: *Window) void {
} }
} }
pub fn deinit(self: *Window) void { pub fn deinit(_: *Window) void {}
self.icon.deinit(self.app);
}
/// Add a new tab to this window. /// Add a new tab to this window.
pub fn newTab(self: *Window, parent: ?*CoreSurface) !void { pub fn newTab(self: *Window, parent: ?*CoreSurface) !void {

View File

@ -7,6 +7,9 @@ const c = @cImport({
@cInclude("gdk/x11/gdkx.h"); @cInclude("gdk/x11/gdkx.h");
// Xkb for X11 state handling // Xkb for X11 state handling
@cInclude("X11/XKBlib.h"); @cInclude("X11/XKBlib.h");
// generated header files
@cInclude("ghostty_resources.h");
}); });
pub usingnamespace c; pub usingnamespace c;

124
src/apprt/gtk/gresource.zig Normal file
View File

@ -0,0 +1,124 @@
const std = @import("std");
const css_files = [_][]const u8{
"style.css",
"style-dark.css",
"style-hc.css",
"style-hc-dark.css",
};
const icons = [_]struct {
alias: []const u8,
source: []const u8,
}{
.{
.alias = "16x16",
.source = "16x16",
},
.{
.alias = "16x16@2",
.source = "16x16@2x@2x",
},
.{
.alias = "32x32",
.source = "32x32",
},
.{
.alias = "32x32@2",
.source = "32x32@2x@2x",
},
.{
.alias = "128x128",
.source = "128x128",
},
.{
.alias = "128x128@2",
.source = "128x128@2x@2x",
},
.{
.alias = "256x256",
.source = "256x256",
},
.{
.alias = "256x256@2",
.source = "256x256@2x@2x",
},
.{
.alias = "512x512",
.source = "512x512",
},
};
pub const gresource_xml_gtk = comptimeGenerateGResourceXML(false);
pub const gresource_xml_libadwaita = comptimeGenerateGResourceXML(true);
fn comptimeGenerateGResourceXML(comptime libadwaita: bool) []const u8 {
comptime {
@setEvalBranchQuota(13000);
var counter = std.io.countingWriter(std.io.null_writer);
try writeGResourceXML(libadwaita, &counter.writer());
var buf: [counter.bytes_written]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
try writeGResourceXML(libadwaita, stream.writer());
return stream.getWritten();
}
}
fn writeGResourceXML(libadwaita: bool, writer: anytype) !void {
try writer.writeAll(
\\<?xml version="1.0" encoding="UTF-8"?>
\\<gresources>
\\
);
if (libadwaita) {
try writer.writeAll(
\\ <gresource prefix="/com/mitchellh/ghostty">
\\
);
for (css_files) |css_file| {
try writer.print(
" <file compressed=\"true\" alias=\"{s}\">src/apprt/gtk/{s}</file>\n",
.{ css_file, css_file },
);
}
try writer.writeAll(
\\ </gresource>
\\
);
}
try writer.writeAll(
\\ <gresource prefix="/com/mitchellh/ghostty/icons">
\\
);
for (icons) |icon| {
try writer.print(
" <file alias=\"{s}/apps/com.mitchellh.ghostty.png\">images/icons/icon_{s}.png</file>\n",
.{ icon.alias, icon.source },
);
}
try writer.writeAll(
\\ </gresource>
\\</gresources>
\\
);
}
pub const dependencies_gtk = deps: {
var deps: [icons.len][]const u8 = undefined;
for (icons, 0..) |icon, i| {
deps[i] = std.fmt.comptimePrint("images/icons/icon_{s}.png", .{icon.source});
}
break :deps deps;
};
pub const dependencies_libadwaita = deps: {
var deps: [css_files.len + icons.len][]const u8 = undefined;
for (css_files, 0..) |css_file, i| {
deps[i] = std.fmt.comptimePrint("src/apprt/gtk/{s}", .{css_file});
}
for (icons, css_files.len..) |icon, i| {
deps[i] = std.fmt.comptimePrint("images/icons/icon_{s}.png", .{icon.source});
}
break :deps deps;
};

View File

@ -1,63 +0,0 @@
const std = @import("std");
const App = @import("App.zig");
const c = @import("c.zig");
const global_state = &@import("../../main.zig").state;
const log = std.log.scoped(.gtk_icon);
/// An icon. The icon may be associated with some allocated state so when
/// the icon is no longer in use it should be deinitialized.
pub const Icon = struct {
name: [:0]const u8,
state: ?[:0]const u8 = null,
pub fn deinit(self: *const Icon, app: *App) void {
if (self.state) |v| app.core_app.alloc.free(v);
}
};
/// Returns the application icon that can be used anywhere. This attempts to
/// find the icon in the theme and if it can't be found, it is loaded from
/// the resources dir. If the resources dir can't be found, we'll log a warning
/// and let GTK choose a fallback.
pub fn appIcon(app: *App, widget: *c.GtkWidget) !Icon {
const icon_name = "com.mitchellh.ghostty";
var result: Icon = .{ .name = icon_name };
// If we don't have the icon then we'll try to add our resources dir
// to the search path and see if we can find it there.
const icon_theme = c.gtk_icon_theme_get_for_display(c.gtk_widget_get_display(widget));
if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) icon: {
const resources_dir = global_state.resources_dir orelse {
log.info("gtk app missing Ghostty icon and no resources dir detected", .{});
log.info("gtk app will not have Ghostty icon", .{});
break :icon;
};
// The resources dir usually is `/usr/share/ghostty` but GTK icons
// go into `/usr/share/icons`.
const base = std.fs.path.dirname(resources_dir) orelse {
log.warn(
"unexpected error getting dirname of resources dir dir={s}",
.{resources_dir},
);
break :icon;
};
// Note that this method for adding the icon search path is
// a fallback mechanism. The recommended mechanism is the
// Freedesktop Icon Theme Specification. We distribute a ".desktop"
// file in zig-out/share that should be installed to the proper
// place.
const dir = try std.fmt.allocPrintZ(app.core_app.alloc, "{s}/icons", .{base});
errdefer app.core_app.alloc.free(dir);
result.state = dir;
c.gtk_icon_theme_add_search_path(icon_theme, dir.ptr);
if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) {
log.warn("Ghostty icon for gtk app not found", .{});
}
}
return result;
}

View File

@ -7,7 +7,6 @@ const Surface = @import("Surface.zig");
const TerminalWindow = @import("Window.zig"); const TerminalWindow = @import("Window.zig");
const ImguiWidget = @import("ImguiWidget.zig"); const ImguiWidget = @import("ImguiWidget.zig");
const c = @import("c.zig"); const c = @import("c.zig");
const icon = @import("icon.zig");
const CoreInspector = @import("../../inspector/main.zig").Inspector; const CoreInspector = @import("../../inspector/main.zig").Inspector;
const log = std.log.scoped(.inspector); const log = std.log.scoped(.inspector);
@ -125,14 +124,12 @@ pub const Inspector = struct {
const Window = struct { const Window = struct {
inspector: *Inspector, inspector: *Inspector,
window: *c.GtkWindow, window: *c.GtkWindow,
icon: icon.Icon,
imgui_widget: ImguiWidget, imgui_widget: ImguiWidget,
pub fn init(self: *Window, inspector: *Inspector) !void { pub fn init(self: *Window, inspector: *Inspector) !void {
// Initialize to undefined // Initialize to undefined
self.* = .{ self.* = .{
.inspector = inspector, .inspector = inspector,
.icon = undefined,
.window = undefined, .window = undefined,
.imgui_widget = undefined, .imgui_widget = undefined,
}; };
@ -144,8 +141,7 @@ const Window = struct {
self.window = gtk_window; self.window = gtk_window;
c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector"); c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector");
c.gtk_window_set_default_size(gtk_window, 1000, 600); c.gtk_window_set_default_size(gtk_window, 1000, 600);
self.icon = try icon.appIcon(self.inspector.surface.app, window); c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty");
c.gtk_window_set_icon_name(gtk_window, self.icon.name);
// Initialize our imgui widget // Initialize our imgui widget
try self.imgui_widget.init(); try self.imgui_widget.init();
@ -163,7 +159,6 @@ const Window = struct {
} }
pub fn deinit(self: *Window) void { pub fn deinit(self: *Window) void {
self.icon.deinit(self.inspector.surface.app);
self.inspector.locationDidClose(); self.inspector.locationDidClose();
} }

View File

View File

View File

0
src/apprt/gtk/style.css Normal file
View File

View File

@ -624,6 +624,11 @@ keybind: Keybinds = .{},
/// borders. /// borders.
@"window-decoration": bool = true, @"window-decoration": bool = true,
/// The font that will be used for the application's window and tab titles.
///
/// This is currently only supported on macOS.
@"window-title-font-family": ?[:0]const u8 = null,
/// The theme to use for the windows. Valid values: /// The theme to use for the windows. Valid values:
/// ///
/// * `auto` - Determine the theme based on the configured terminal /// * `auto` - Determine the theme based on the configured terminal

View File

@ -26,7 +26,7 @@ pub inline fn copy(comptime T: type, dest: []T, source: []const T) void {
/// and a tmp var for the single rotated item instead of 3 calls to reverse. /// and a tmp var for the single rotated item instead of 3 calls to reverse.
pub inline fn rotateOnce(comptime T: type, items: []T) void { pub inline fn rotateOnce(comptime T: type, items: []T) void {
const tmp = items[0]; const tmp = items[0];
move(T, items[0..items.len - 1], items[1..items.len]); move(T, items[0 .. items.len - 1], items[1..items.len]);
items[items.len - 1] = tmp; items[items.len - 1] = tmp;
} }

View File

@ -2431,139 +2431,147 @@ fn draw_dash_horizontal(
) void { ) void {
assert(count >= 2 and count <= 4); assert(count >= 2 and count <= 4);
// The number of gaps we have is one less than the number of dashes. // +------------+
// "- - -" => 2 gaps // | |
const gap_count = count - 1; // | |
// | |
// | |
// | -- -- -- |
// | |
// | |
// | |
// | |
// +------------+
// Our dashed line should be made such that when tiled horizontally
// it creates one consistent line with no uneven gap or segment sizes.
// In order to make sure this is the case, we should have half-sized
// gaps on the left and right so that it is centered properly.
// Determine the width of each dash and the gap between them. We try // For N dashes, there are N - 1 gaps between them, but we also have
// to have gap match desired_gap but if our cell is too small then we // half-sized gaps on either side, adding up to N total gaps.
// have to bring it down. const gap_count = count;
const adjusted: struct {
dash_width: u32,
gap: u32,
} = adjusted: {
for (0..desired_gap) |i| {
const gap_width: u32 = desired_gap - @as(u32, @intCast(i));
const total_gap_width: u32 = gap_count * gap_width;
// This would make a negative and overflow our u32. A negative // We need at least 1 pixel for each gap and each dash, if we don't
// dash width is not allowed so we keep trying to fit it. // have that then we can't draw our dashed line correctly so we just
if (total_gap_width >= self.width) continue; // draw a solid line and return.
if (self.width < count + gap_count) {
break :adjusted .{
.dash_width = (self.width - total_gap_width) / count,
.gap = gap_width,
};
}
// In this case, there is no combination of gap width and dash
// width that would fit our desired number of dashes, so we just
// draw a horizontal line.
self.hline_middle(canvas, .light); self.hline_middle(canvas, .light);
return; return;
};
const dash_width = adjusted.dash_width;
const gap = adjusted.gap;
// Our total width should be less than our real width
assert(count * dash_width + gap_count * gap <= self.width);
const remaining = self.width - count * dash_width - gap_count * gap;
var x: [4]u32 = .{0} ** 4;
var w: [4]u32 = .{dash_width} ** 4;
x[1] = x[0] + w[0] + gap;
if (count == 2)
w[1] = self.width - x[1]
else if (count == 3)
w[1] += remaining
else
w[1] += remaining / 2;
if (count >= 3) {
x[2] = x[1] + w[1] + gap;
if (count == 3)
w[2] = self.width - x[2]
else
w[2] += remaining - remaining / 2;
} }
if (count >= 4) { // We never want the gaps to take up more than 50% of the space,
x[3] = x[2] + w[2] + gap; // because if they do the dashes are too small and look wrong.
w[3] = self.width - x[3]; const gap_width = @min(desired_gap, self.width / (2 * count));
} const total_gap_width = gap_count * gap_width;
const total_dash_width = self.width - total_gap_width;
const dash_width = total_dash_width / count;
const remaining = total_dash_width % count;
self.hline(canvas, x[0], x[0] + w[0], (self.height -| thick_px) / 2, thick_px); assert(dash_width * count + gap_width * gap_count + remaining == self.width);
self.hline(canvas, x[1], x[1] + w[1], (self.height -| thick_px) / 2, thick_px);
if (count >= 3) // Our dashes should be centered vertically.
self.hline(canvas, x[2], x[2] + w[2], (self.height -| thick_px) / 2, thick_px); const y: u32 = (self.height -| thick_px) / 2;
if (count >= 4)
self.hline(canvas, x[3], x[3] + w[3], (self.height -| thick_px) / 2, thick_px); // We start at half a gap from the left edge, in order to center
// our dashes properly.
var x: u32 = gap_width / 2;
// We'll distribute the extra space in to dash widths, 1px at a
// time. We prefer this to making gaps larger since that is much
// more visually obvious.
var extra: u32 = remaining;
for (0..count) |_| {
var x1 = x + dash_width;
// We distribute left-over size in to dash widths,
// since it's less obvious there than in the gaps.
if (extra > 0) {
extra -= 1;
x1 += 1;
}
self.hline(canvas, x, x1, y, thick_px);
// Advance by the width of the dash we drew and the width
// of a gap to get the the start of the next dash.
x = x1 + gap_width;
}
} }
fn draw_dash_vertical( fn draw_dash_vertical(
self: Box, self: Box,
canvas: *font.sprite.Canvas, canvas: *font.sprite.Canvas,
count: u8, comptime count: u8,
thick_px: u32, thick_px: u32,
gap: u32, desired_gap: u32,
) void { ) void {
assert(count >= 2 and count <= 4); assert(count >= 2 and count <= 4);
// The number of gaps we have is one less than the number of dashes. // +-----------+
// "- - -" => 2 gaps // | | |
const gap_count = count - 1; // | | |
// | |
// | | |
// | | |
// | |
// | | |
// | | |
// | |
// +-----------+
// Our dashed line should be made such that when tiled verically it
// it creates one consistent line with no uneven gap or segment sizes.
// In order to make sure this is the case, we should have an extra gap
// gap at the bottom.
//
// A single full-sized extra gap is preferred to two half-sized ones for
// vertical to allow better joining to solid characters without creating
// visible half-sized gaps. Unlike horizontal, centering is a lot less
// important, visually.
// Determine the height of our dashes // Because of the extra gap at the bottom, there are as many gaps as
const dash_height = dash_height: { // there are dashes.
var gap_i = gap; const gap_count = count;
var dash_height = (self.height - (gap_count * gap_i)) / count;
while (dash_height <= 0 and gap_i > 1) {
gap_i -= 1;
dash_height = (self.height - (gap_count * gap_i)) / count;
}
// If we can't fit any dashes then we just render a horizontal line. // We need at least 1 pixel for each gap and each dash, if we don't
if (dash_height <= 0) { // have that then we can't draw our dashed line correctly so we just
self.vline_middle(canvas, .light); // draw a solid line and return.
return; if (self.height < count + gap_count) {
} self.vline_middle(canvas, .light);
return;
break :dash_height dash_height;
};
// Our total height should be less than our real height
assert(count * dash_height + gap_count * gap <= self.height);
const remaining = self.height - count * dash_height - gap_count * gap;
var y: [4]u32 = .{0} ** 4;
var h: [4]u32 = .{dash_height} ** 4;
y[1] = y[0] + h[0] + gap;
if (count == 2)
h[1] = self.height - y[1]
else if (count == 3)
h[1] += remaining
else
h[1] += remaining / 2;
if (count >= 3) {
y[2] = y[1] + h[1] + gap;
if (count == 3)
h[2] = self.height - y[2]
else
h[2] += remaining - remaining / 2;
} }
if (count >= 4) { // We never want the gaps to take up more than 50% of the space,
y[3] = y[2] + h[2] + gap; // because if they do the dashes are too small and look wrong.
h[3] = self.height - y[3]; const gap_height = @min(desired_gap, self.height / (2 * count));
} const total_gap_height = gap_count * gap_height;
const total_dash_height = self.height - total_gap_height;
const dash_height = total_dash_height / count;
const remaining = total_dash_height % count;
self.vline(canvas, y[0], y[0] + h[0], (self.width -| thick_px) / 2, thick_px); assert(dash_height * count + gap_height * gap_count + remaining == self.height);
self.vline(canvas, y[1], y[1] + h[1], (self.width -| thick_px) / 2, thick_px);
if (count >= 3) // Our dashes should be centered horizontally.
self.vline(canvas, y[2], y[2] + h[2], (self.width -| thick_px) / 2, thick_px); const x: u32 = (self.width -| thick_px) / 2;
if (count >= 4)
self.vline(canvas, y[3], y[3] + h[3], (self.width -| thick_px) / 2, thick_px); // We start at the top of the cell.
var y: u32 = 0;
// We'll distribute the extra space in to dash heights, 1px at a
// time. We prefer this to making gaps larger since that is much
// more visually obvious.
var extra: u32 = remaining;
inline for (0..count) |_| {
var y1 = y + dash_height;
// We distribute left-over size in to dash widths,
// since it's less obvious there than in the gaps.
if (extra > 0) {
extra -= 1;
y1 += 1;
}
self.vline(canvas, y, y1, x, thick_px);
// Advance by the height of the dash we drew and the height
// of a gap to get the the start of the next dash.
y = y1 + gap_height;
}
} }
fn draw_cursor_rect(self: Box, canvas: *font.sprite.Canvas) void { fn draw_cursor_rect(self: Box, canvas: *font.sprite.Canvas) void {

View File

@ -91,7 +91,7 @@ pub fn main() !MainReturn {
\\ \\
\\We don't have proper help output yet, sorry! Please refer to the \\We don't have proper help output yet, sorry! Please refer to the
\\source code or Discord community for help for now. We'll fix this in time. \\source code or Discord community for help for now. We'll fix this in time.
\\ \\
, ,
.{}, .{},
); );

View File

@ -3,6 +3,7 @@ const builtin = @import("builtin");
const assert = std.debug.assert; const assert = std.debug.assert;
const passwd = @import("passwd.zig"); const passwd = @import("passwd.zig");
const posix = std.posix; const posix = std.posix;
const objc = @import("objc");
const Error = error{ const Error = error{
/// The buffer used for output is not large enough to store the value. /// The buffer used for output is not large enough to store the value.
@ -31,33 +32,26 @@ fn homeUnix(buf: []u8) !?[]u8 {
return buf[0..result.len]; return buf[0..result.len];
} }
// On macOS: [NSFileManager defaultManager].homeDirectoryForCurrentUser.path
if (builtin.os.tag == .macos) {
const NSFileManager = objc.getClass("NSFileManager").?;
const manager = NSFileManager.msgSend(objc.Object, objc.sel("defaultManager"), .{});
const homeURL = manager.getProperty(objc.Object, "homeDirectoryForCurrentUser");
const homePath = homeURL.getProperty(objc.Object, "path");
const c_str = homePath.getProperty([*:0]const u8, "UTF8String");
const result = std.mem.sliceTo(c_str, 0);
if (buf.len < result.len) return Error.BufferTooSmall;
@memcpy(buf[0..result.len], result);
return buf[0..result.len];
}
// Everything below here will require some allocation // Everything below here will require some allocation
var tempBuf: [1024]u8 = undefined; var tempBuf: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&tempBuf); var fba = std.heap.FixedBufferAllocator.init(&tempBuf);
// If we're on darwin, we try the directory service. I'm not sure if there
// is a Mac API to do this but if so we can link to that...
if (builtin.os.tag == .macos) {
const run = try std.ChildProcess.run(.{
.allocator = fba.allocator(),
.argv = &[_][]const u8{
"/bin/sh",
"-c",
"dscl -q . -read /Users/\"$(whoami)\" NFSHomeDirectory | sed 's/^[^ ]*: //'",
},
.max_output_bytes = fba.buffer.len / 2,
});
if (run.term == .Exited and run.term.Exited == 0) {
const result = trimSpace(run.stdout);
if (buf.len < result.len) return Error.BufferTooSmall;
@memcpy(buf[0..result.len], result);
return buf[0..result.len];
}
}
// We try passwd. This doesn't work on multi-user mac but we try it anyways. // We try passwd. This doesn't work on multi-user mac but we try it anyways.
fba.reset();
const pw = try passwd.get(fba.allocator()); const pw = try passwd.get(fba.allocator());
if (pw.home) |result| { if (pw.home) |result| {
if (buf.len < result.len) return Error.BufferTooSmall; if (buf.len < result.len) return Error.BufferTooSmall;

View File

@ -540,6 +540,9 @@ pub fn cursorDownScroll(self: *Screen) !void {
assert(self.cursor.y == self.pages.rows - 1); assert(self.cursor.y == self.pages.rows - 1);
defer self.assertIntegrity(); defer self.assertIntegrity();
// Scrolling dirties the images because it updates their placements pins.
self.kitty_images.dirty = true;
// If we have no scrollback, then we shift all our rows instead. // If we have no scrollback, then we shift all our rows instead.
if (self.no_scrollback) { if (self.no_scrollback) {
// If we have a single-row screen, we have no rows to shift // If we have a single-row screen, we have no rows to shift
@ -891,10 +894,20 @@ pub fn clearUnprotectedCells(
row: *Row, row: *Row,
cells: []Cell, cells: []Cell,
) void { ) void {
for (cells) |*cell| { var x0: usize = 0;
if (cell.protected) continue; var x1: usize = 0;
const cell_multi: [*]Cell = @ptrCast(cell);
self.clearCells(page, row, cell_multi[0..1]); while (x0 < cells.len) clear: {
while (cells[x0].protected) {
x0 += 1;
if (x0 >= cells.len) break :clear;
}
x1 = x0 + 1;
while (x1 < cells.len and !cells[x1].protected) {
x1 += 1;
}
self.clearCells(page, row, cells[x0..x1]);
x0 = x1;
} }
page.assertIntegrity(); page.assertIntegrity();
@ -2137,6 +2150,25 @@ pub fn dumpStringAlloc(
return try builder.toOwnedSlice(); return try builder.toOwnedSlice();
} }
/// You should use dumpString, this is a restricted version mostly for
/// legacy and convenience reasons for unit tests.
pub fn dumpStringAllocUnwrapped(
self: *const Screen,
alloc: Allocator,
tl: point.Point,
) ![]const u8 {
var builder = std.ArrayList(u8).init(alloc);
defer builder.deinit();
try self.dumpString(builder.writer(), .{
.tl = self.pages.getTopLeft(tl),
.br = self.pages.getBottomRight(tl) orelse return error.UnknownPoint,
.unwrap = true,
});
return try builder.toOwnedSlice();
}
/// This is basically a really jank version of Terminal.printString. We /// This is basically a really jank version of Terminal.printString. We
/// have to reimplement it here because we want a way to print to the screen /// have to reimplement it here because we want a way to print to the screen
/// to test it but don't want all the features of Terminal. /// to test it but don't want all the features of Terminal.
@ -2190,6 +2222,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.content_tag = .codepoint, .content_tag = .codepoint,
.content = .{ .codepoint = c }, .content = .{ .codepoint = c },
.style_id = self.cursor.style_id, .style_id = self.cursor.style_id,
.protected = self.cursor.protected,
}; };
// If we have a ref-counted style, increase. // If we have a ref-counted style, increase.
@ -2206,6 +2239,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.content_tag = .codepoint, .content_tag = .codepoint,
.content = .{ .codepoint = 0 }, .content = .{ .codepoint = 0 },
.wide = .spacer_head, .wide = .spacer_head,
.protected = self.cursor.protected,
}; };
self.cursor.page_row.wrap = true; self.cursor.page_row.wrap = true;
@ -2220,6 +2254,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.content = .{ .codepoint = c }, .content = .{ .codepoint = c },
.style_id = self.cursor.style_id, .style_id = self.cursor.style_id,
.wide = .wide, .wide = .wide,
.protected = self.cursor.protected,
}; };
// Write our tail // Write our tail
@ -2228,6 +2263,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.content_tag = .codepoint, .content_tag = .codepoint,
.content = .{ .codepoint = 0 }, .content = .{ .codepoint = 0 },
.wide = .spacer_tail, .wide = .spacer_tail,
.protected = self.cursor.protected,
}; };
}, },
@ -2508,6 +2544,34 @@ test "Screen clearRows active styled line" {
try testing.expectEqualStrings("", str); try testing.expectEqualStrings("", str);
} }
test "Screen clearRows protected" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 1000);
defer s.deinit();
try s.testWriteString("UNPROTECTED");
s.cursor.protected = true;
try s.testWriteString("PROTECTED");
s.cursor.protected = false;
try s.testWriteString("UNPROTECTED");
try s.testWriteString("\n");
s.cursor.protected = true;
try s.testWriteString("PROTECTED");
s.cursor.protected = false;
try s.testWriteString("UNPROTECTED");
s.cursor.protected = true;
try s.testWriteString("PROTECTED");
s.cursor.protected = false;
s.clearRows(.{ .active = .{} }, null, true);
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings(" PROTECTED\nPROTECTED PROTECTED", str);
}
test "Screen eraseRows history" { test "Screen eraseRows history" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;

View File

@ -1075,6 +1075,9 @@ pub fn index(self: *Terminal) !void {
self.screen.cursor.x >= self.scrolling_region.left and self.screen.cursor.x >= self.scrolling_region.left and
self.screen.cursor.x <= self.scrolling_region.right) self.screen.cursor.x <= self.scrolling_region.right)
{ {
// Scrolling dirties the images because it updates their placements pins.
self.screen.kitty_images.dirty = true;
// If our scrolling region is the full screen, we create scrollback. // If our scrolling region is the full screen, we create scrollback.
// Otherwise, we simply scroll the region. // Otherwise, we simply scroll the region.
if (self.scrolling_region.top == 0 and if (self.scrolling_region.top == 0 and
@ -1306,6 +1309,63 @@ pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void {
}); });
} }
/// To be called before shifting a row (as in insertLines and deleteLines)
///
/// Takes care of boundary conditions such as potentially split wide chars
/// across scrolling region boundaries and orphaned spacer heads at line
/// ends.
fn rowWillBeShifted(
self: *Terminal,
page: *Page,
row: *Row,
) void {
const cells = row.cells.ptr(page.memory.ptr);
// If our scrolling region includes the rightmost column then we
// need to turn any spacer heads in to normal empty cells, since
// once we move them they no longer correspond with soft-wrapped
// wide characters.
//
// If it contains either of the 2 leftmost columns, then the wide
// characters in the first column which may be associated with a
// spacer head will be either moved or cleared, so we also need
// to turn the spacer heads in to empty cells in that case.
if (self.scrolling_region.right == self.cols - 1 or
self.scrolling_region.left < 2)
{
const end_cell: *Cell = &cells[page.size.cols - 1];
if (end_cell.wide == .spacer_head) {
end_cell.wide = .narrow;
}
}
// If the leftmost or rightmost cells of our scrolling region
// are parts of wide chars, we need to clear the cells' contents
// since they'd be split by the move.
const left_cell: *Cell = &cells[self.scrolling_region.left];
const right_cell: *Cell = &cells[self.scrolling_region.right];
if (left_cell.wide == .spacer_tail) {
const wide_cell: *Cell = &cells[self.scrolling_region.left - 1];
if (wide_cell.hasGrapheme()) {
page.clearGrapheme(row, wide_cell);
}
wide_cell.content.codepoint = 0;
wide_cell.wide = .narrow;
left_cell.wide = .narrow;
}
if (right_cell.wide == .wide) {
const tail_cell: *Cell = &cells[self.scrolling_region.right + 1];
if (right_cell.hasGrapheme()) {
page.clearGrapheme(row, right_cell);
}
right_cell.content.codepoint = 0;
right_cell.wide = .narrow;
tail_cell.wide = .narrow;
}
}
/// Insert amount lines at the current cursor row. The contents of the line /// Insert amount lines at the current cursor row. The contents of the line
/// at the current cursor row and below (to the bottom-most line in the /// at the current cursor row and below (to the bottom-most line in the
/// scrolling region) are shifted down by amount lines. The contents of the /// scrolling region) are shifted down by amount lines. The contents of the
@ -1334,6 +1394,9 @@ pub fn insertLines(self: *Terminal, count: usize) void {
self.screen.cursor.x < self.scrolling_region.left or self.screen.cursor.x < self.scrolling_region.left or
self.screen.cursor.x > self.scrolling_region.right) return; self.screen.cursor.x > self.scrolling_region.right) return;
// Scrolling dirties the images because it updates their placements pins.
self.screen.kitty_images.dirty = true;
// Remaining rows from our cursor to the bottom of the scroll region. // Remaining rows from our cursor to the bottom of the scroll region.
const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1;
@ -1359,8 +1422,21 @@ pub fn insertLines(self: *Terminal, count: usize) void {
var it = bot.rowIterator(.left_up, top); var it = bot.rowIterator(.left_up, top);
while (it.next()) |p| { while (it.next()) |p| {
const dst_p = p.down(adjusted_count).?; const dst_p = p.down(adjusted_count).?;
const src: *Row = p.rowAndCell().row; const src_rac = p.rowAndCell();
const dst: *Row = dst_p.rowAndCell().row; const dst_rac = dst_p.rowAndCell();
const src: *Row = src_rac.row;
const dst: *Row = dst_rac.row;
self.rowWillBeShifted(&p.page.data, src);
self.rowWillBeShifted(&dst_p.page.data, dst);
// If our scrolling region is full width, then we unset wrap.
if (!left_right) {
dst.wrap = false;
src.wrap = false;
dst.wrap_continuation = false;
src.wrap_continuation = false;
}
// If our page doesn't match, then we need to do a copy from // If our page doesn't match, then we need to do a copy from
// one page to another. This is the slow path. // one page to another. This is the slow path.
@ -1376,9 +1452,6 @@ pub fn insertLines(self: *Terminal, count: usize) void {
@panic("TODO"); @panic("TODO");
}; };
// Row never is wrapped if we're full width.
if (!left_right) dst.wrap = false;
continue; continue;
} }
@ -1389,10 +1462,6 @@ pub fn insertLines(self: *Terminal, count: usize) void {
dst.* = src.*; dst.* = src.*;
src.* = dst_row; src.* = dst_row;
// Row never is wrapped
dst.wrap = false;
src.wrap = false;
// Ensure what we did didn't corrupt the page // Ensure what we did didn't corrupt the page
p.page.data.assertIntegrity(); p.page.data.assertIntegrity();
continue; continue;
@ -1407,9 +1476,6 @@ pub fn insertLines(self: *Terminal, count: usize) void {
self.scrolling_region.left, self.scrolling_region.left,
(self.scrolling_region.right - self.scrolling_region.left) + 1, (self.scrolling_region.right - self.scrolling_region.left) + 1,
); );
// Row never is wrapped
dst.wrap = false;
} }
// The operations above can prune our cursor style so we need to // The operations above can prune our cursor style so we need to
@ -1474,6 +1540,9 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
self.screen.cursor.x < self.scrolling_region.left or self.screen.cursor.x < self.scrolling_region.left or
self.screen.cursor.x > self.scrolling_region.right) return; self.screen.cursor.x > self.scrolling_region.right) return;
// Scrolling dirties the images because it updates their placements pins.
self.screen.kitty_images.dirty = true;
// top is just the cursor position. insertLines starts at the cursor // top is just the cursor position. insertLines starts at the cursor
// so this is our top. We want to shift lines down, down to the bottom // so this is our top. We want to shift lines down, down to the bottom
// of the scroll region. // of the scroll region.
@ -1498,8 +1567,21 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
var it = top.rowIterator(.right_down, bot); var it = top.rowIterator(.right_down, bot);
while (it.next()) |p| { while (it.next()) |p| {
const src_p = p.down(count).?; const src_p = p.down(count).?;
const src: *Row = src_p.rowAndCell().row; const src_rac = src_p.rowAndCell();
const dst: *Row = p.rowAndCell().row; const dst_rac = p.rowAndCell();
const src: *Row = src_rac.row;
const dst: *Row = dst_rac.row;
self.rowWillBeShifted(&src_p.page.data, src);
self.rowWillBeShifted(&p.page.data, dst);
// If our scrolling region is full width, then we unset wrap.
if (!left_right) {
dst.wrap = false;
src.wrap = false;
dst.wrap_continuation = false;
src.wrap_continuation = false;
}
if (src_p.page != p.page) { if (src_p.page != p.page) {
p.page.data.clonePartialRowFrom( p.page.data.clonePartialRowFrom(
@ -1513,9 +1595,6 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
@panic("TODO"); @panic("TODO");
}; };
// Row never is wrapped if we're full width.
if (!left_right) dst.wrap = false;
continue; continue;
} }
@ -1526,9 +1605,6 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
dst.* = src.*; dst.* = src.*;
src.* = dst_row; src.* = dst_row;
// Row never is wrapped
dst.wrap = false;
// Ensure what we did didn't corrupt the page // Ensure what we did didn't corrupt the page
p.page.data.assertIntegrity(); p.page.data.assertIntegrity();
continue; continue;
@ -1543,9 +1619,6 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
self.scrolling_region.left, self.scrolling_region.left,
(self.scrolling_region.right - self.scrolling_region.left) + 1, (self.scrolling_region.right - self.scrolling_region.left) + 1,
); );
// Row never is wrapped
dst.wrap = false;
} }
// The operations above can prune our cursor style so we need to // The operations above can prune our cursor style so we need to
@ -1793,19 +1866,11 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
return; return;
} }
// SLOW PATH self.screen.clearUnprotectedCells(
// We had a protection mode at some point. We must go through each &self.screen.cursor.page_pin.page.data,
// cell and check its protection attribute. self.screen.cursor.page_row,
for (0..end) |x| { cells[0..end],
const cell_multi: [*]Cell = @ptrCast(cells + x); );
const cell: *Cell = @ptrCast(&cell_multi[0]);
if (cell.protected) continue;
self.screen.clearCells(
&self.screen.cursor.page_pin.page.data,
self.screen.cursor.page_row,
cell_multi[0..1],
);
}
} }
/// Erase the line. /// Erase the line.
@ -1878,16 +1943,11 @@ pub fn eraseLine(
return; return;
} }
for (start..end) |x| { self.screen.clearUnprotectedCells(
const cell_multi: [*]Cell = @ptrCast(cells + x); &self.screen.cursor.page_pin.page.data,
const cell: *Cell = @ptrCast(&cell_multi[0]); self.screen.cursor.page_row,
if (cell.protected) continue; cells[start..end],
self.screen.clearCells( );
&self.screen.cursor.page_pin.page.data,
self.screen.cursor.page_row,
cell_multi[0..1],
);
}
} }
/// Erase the display. /// Erase the display.
@ -2385,6 +2445,11 @@ pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 {
return try self.screen.dumpStringAlloc(alloc, .{ .viewport = .{} }); return try self.screen.dumpStringAlloc(alloc, .{ .viewport = .{} });
} }
/// Same as plainString, but respects row wrap state when building the string.
pub fn plainStringUnwrapped(self: *Terminal, alloc: Allocator) ![]const u8 {
return try self.screen.dumpStringAllocUnwrapped(alloc, .{ .viewport = .{} });
}
/// Full reset /// Full reset
pub fn fullReset(self: *Terminal) void { pub fn fullReset(self: *Terminal) void {
// Switch back to primary screen and clear it. We do not restore cursor // Switch back to primary screen and clear it. We do not restore cursor
@ -6119,6 +6184,243 @@ test "Terminal: deleteLines left/right scroll region high count" {
} }
} }
test "Terminal: deleteLines wide character spacer head" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
// Initial value
// +-----+
// |AAAAA| < Wrapped
// |BBBB*| < Wrapped (continued)
// |WWCCC| < Non-wrapped (continued)
// +-----+
// where * represents a spacer head cell
// and WW is the wide character.
try t.printString("AAAAABBBB\u{1F600}CCC");
// Delete the top line
// +-----+
// |BBBB | < Non-wrapped
// |WWCCC| < Non-wrapped
// | | < Non-wrapped
// +-----+
// This should convert the spacer head to
// a regular empty cell, and un-set wrap.
t.setCursorPos(1, 1);
t.deleteLines(1);
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
defer testing.allocator.free(unwrapped_str);
try testing.expectEqualStrings("BBBB \n\u{1F600}CCC", str);
try testing.expectEqualStrings("BBBB \n\u{1F600}CCC", unwrapped_str);
}
}
test "Terminal: deleteLines wide character spacer head left scroll margin" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
// Initial value
// +-----+
// |AAAAA| < Wrapped
// |BBBB*| < Wrapped (continued)
// |WWCCC| < Non-wrapped (continued)
// +-----+
// where * represents a spacer head cell
// and WW is the wide character.
try t.printString("AAAAABBBB\u{1F600}CCC");
t.scrolling_region.left = 2;
// Delete the top line
// ### <- scrolling region
// +-----+
// |AABB | < Wrapped
// |BBCCC| < Wrapped (continued)
// |WW | < Non-wrapped (continued)
// +-----+
// This should convert the spacer head to
// a regular empty cell, but due to the
// left scrolling margin, wrap state should
// remain.
t.setCursorPos(1, 3);
t.deleteLines(1);
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
defer testing.allocator.free(unwrapped_str);
try testing.expectEqualStrings("AABB \nBBCCC\n\u{1F600}", str);
try testing.expectEqualStrings("AABB BBCCC\u{1F600}", unwrapped_str);
}
}
test "Terminal: deleteLines wide character spacer head right scroll margin" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
// Initial value
// +-----+
// |AAAAA| < Wrapped
// |BBBB*| < Wrapped (continued)
// |WWCCC| < Non-wrapped (continued)
// +-----+
// where * represents a spacer head cell
// and WW is the wide character.
try t.printString("AAAAABBBB\u{1F600}CCC");
t.scrolling_region.right = 3;
// Delete the top line
// #### <- scrolling region
// +-----+
// |BBBBA| < Wrapped
// |WWCC | < Wrapped (continued)
// | C| < Non-wrapped (continued)
// +-----+
// This should convert the spacer head to
// a regular empty cell, but due to the
// right scrolling margin, wrap state should
// remain.
t.setCursorPos(1, 1);
t.deleteLines(1);
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
defer testing.allocator.free(unwrapped_str);
try testing.expectEqualStrings("BBBBA\n\u{1F600}CC \n C", str);
try testing.expectEqualStrings("BBBBA\u{1F600}CC C", unwrapped_str);
}
}
test "Terminal: deleteLines wide character spacer head left and right scroll margin" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
// Initial value
// +-----+
// |AAAAA| < Wrapped
// |BBBB*| < Wrapped (continued)
// |WWCCC| < Non-wrapped (continued)
// +-----+
// where * represents a spacer head cell
// and WW is the wide character.
try t.printString("AAAAABBBB\u{1F600}CCC");
t.scrolling_region.right = 3;
t.scrolling_region.left = 2;
// Delete the top line
// ## <- scrolling region
// +-----+
// |AABBA| < Wrapped
// |BBCC*| < Wrapped (continued)
// |WW C| < Non-wrapped (continued)
// +-----+
// Because there is both a left scrolling
// margin > 1 and a right scrolling margin
// the spacer head should remain, and the
// wrap state should be untouched.
t.setCursorPos(1, 3);
t.deleteLines(1);
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
defer testing.allocator.free(unwrapped_str);
try testing.expectEqualStrings("AABBA\nBBCC\n\u{1F600} C", str);
try testing.expectEqualStrings("AABBABBCC\u{1F600} C", unwrapped_str);
}
}
test "Terminal: deleteLines wide character spacer head left (< 2) and right scroll margin" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
// Initial value
// +-----+
// |AAAAA| < Wrapped
// |BBBB*| < Wrapped (continued)
// |WWCCC| < Non-wrapped (continued)
// +-----+
// where * represents a spacer head cell
// and WW is the wide character.
try t.printString("AAAAABBBB\u{1F600}CCC");
t.scrolling_region.right = 3;
t.scrolling_region.left = 1;
// Delete the top line
// ### <- scrolling region
// +-----+
// |ABBBA| < Wrapped
// |B CC | < Wrapped (continued)
// | C| < Non-wrapped (continued)
// +-----+
// Because the left margin is 1, the wide
// char is split, and therefore removed,
// along with the spacer head - however,
// wrap state should be untouched.
t.setCursorPos(1, 2);
t.deleteLines(1);
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
defer testing.allocator.free(unwrapped_str);
try testing.expectEqualStrings("ABBBA\nB CC \n C", str);
try testing.expectEqualStrings("ABBBAB CC C", unwrapped_str);
}
}
test "Terminal: deleteLines wide characters split by left/right scroll region boundaries" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 5, .rows = 2 });
defer t.deinit(alloc);
// Initial value
// +-----+
// |AAAAA|
// |WWBWW|
// +-----+
// where WW represents a wide character
try t.printString("AAAAA\n\u{1F600}B\u{1F600}");
t.scrolling_region.right = 3;
t.scrolling_region.left = 1;
// Delete the top line
// ### <- scrolling region
// +-----+
// |A B A|
// | |
// +-----+
// The two wide chars, because they're
// split by the edge of the scrolling
// region, get removed.
t.setCursorPos(1, 2);
t.deleteLines(1);
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("A B A\n ", str);
}
}
test "Terminal: deleteLines zero" { test "Terminal: deleteLines zero" {
const alloc = testing.allocator; const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 2, .rows = 5 }); var t = try init(alloc, .{ .cols = 2, .rows = 5 });

View File

@ -3,6 +3,8 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator; const ArenaAllocator = std.heap.ArenaAllocator;
const log = std.log.scoped(.kitty_gfx);
/// The key-value pairs for the control information for a command. The /// The key-value pairs for the control information for a command. The
/// keys are always single characters and the values are either single /// keys are always single characters and the values are either single
/// characters or 32-bit unsigned integers. /// characters or 32-bit unsigned integers.
@ -27,8 +29,11 @@ pub const CommandParser = struct {
kv_temp_len: u4 = 0, kv_temp_len: u4 = 0,
kv_current: u8 = 0, // Current kv key kv_current: u8 = 0, // Current kv key
/// This is the list of bytes that contains both KV data and final /// This is the list we use to collect the bytes from the data payload.
/// data. You shouldn't access this directly. /// The Kitty Graphics protocol specification seems to imply that the
/// payload content of a single command should never exceed 4096 bytes,
/// but Kitty itself supports larger payloads, so we use an ArrayList
/// here instead of a fixed buffer so that we can too.
data: std.ArrayList(u8), data: std.ArrayList(u8),
/// Internal state for parsing. /// Internal state for parsing.
@ -42,7 +47,7 @@ pub const CommandParser = struct {
control_value, control_value,
control_value_ignore, control_value_ignore,
/// We're parsing the data blob. /// Collecting the data payload blob.
data, data,
}; };
@ -106,9 +111,6 @@ pub const CommandParser = struct {
.data => try self.data.append(c), .data => try self.data.append(c),
} }
// We always add to our data list because this is our stable
// array of bytes that we'll reference everywhere else.
} }
/// Complete the parsing. This must be called after all the /// Complete the parsing. This must be called after all the
@ -165,12 +167,45 @@ pub const CommandParser = struct {
return .{ return .{
.control = control, .control = control,
.quiet = quiet, .quiet = quiet,
.data = if (self.data.items.len == 0) "" else data: { .data = try self.decodeData(),
break :data try self.data.toOwnedSlice();
},
}; };
} }
/// Decodes the payload data from base64 and returns it as a slice.
/// This function will destroy the contents of self.data, it should
/// only be used once we are done collecting payload bytes.
fn decodeData(self: *CommandParser) ![]const u8 {
if (self.data.items.len == 0) {
return "";
}
const Base64Decoder = std.base64.standard_no_pad.Decoder;
// We remove any padding, since it's optional, and decode without it.
while (self.data.items[self.data.items.len - 1] == '=') {
self.data.items.len -= 1;
}
const size = Base64Decoder.calcSizeForSlice(self.data.items) catch |err| {
log.warn("failed to calculate base64 size for payload: {}", .{err});
return error.InvalidData;
};
// This is kinda cursed, but we can decode the base64 on top of
// itself, since it's guaranteed that the encoded size is larger,
// and any bytes in areas that are written to will have already
// been used (assuming scalar decoding).
Base64Decoder.decode(self.data.items[0..size], self.data.items) catch |err| {
log.warn("failed to decode base64 payload data: {}", .{err});
return error.InvalidData;
};
// Remove the extra bytes.
self.data.items.len = size;
return try self.data.toOwnedSlice();
}
fn accumulateValue(self: *CommandParser, c: u8, overflow_state: State) !void { fn accumulateValue(self: *CommandParser, c: u8, overflow_state: State) !void {
const idx = self.kv_temp_len; const idx = self.kv_temp_len;
self.kv_temp_len += 1; self.kv_temp_len += 1;
@ -855,7 +890,7 @@ test "query command" {
var p = CommandParser.init(alloc); var p = CommandParser.init(alloc);
defer p.deinit(); defer p.deinit();
const input = "i=31,s=1,v=1,a=q,t=d,f=24;AAAA"; const input = "i=31,s=1,v=1,a=q,t=d,f=24;QUFBQQ";
for (input) |c| try p.feed(c); for (input) |c| try p.feed(c);
const command = try p.complete(); const command = try p.complete();
defer command.deinit(alloc); defer command.deinit(alloc);

View File

@ -5,6 +5,7 @@ const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator; const ArenaAllocator = std.heap.ArenaAllocator;
const posix = std.posix; const posix = std.posix;
const fastmem = @import("../../fastmem.zig");
const command = @import("graphics_command.zig"); const command = @import("graphics_command.zig");
const point = @import("../point.zig"); const point = @import("../point.zig");
const PageList = @import("../PageList.zig"); const PageList = @import("../PageList.zig");
@ -56,30 +57,16 @@ pub const LoadingImage = struct {
.display = cmd.display(), .display = cmd.display(),
}; };
// Special case for the direct medium, we just add it directly // Special case for the direct medium, we just add the chunk directly.
// which will handle copying the data, base64 decoding, etc.
if (t.medium == .direct) { if (t.medium == .direct) {
try result.addData(alloc, cmd.data); try result.addData(alloc, cmd.data);
return result; return result;
} }
// For every other medium, we'll need to at least base64 decode // Otherwise, the payload data is guaranteed to be a path.
// the data to make it useful so let's do that. Also, all the data
// has to be path data so we can put it in a stack-allocated buffer.
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
const Base64Decoder = std.base64.standard.Decoder;
const size = Base64Decoder.calcSizeForSlice(cmd.data) catch |err| {
log.warn("failed to calculate base64 size for file path: {}", .{err});
return error.InvalidData;
};
if (size > buf.len) return error.FilePathTooLong;
Base64Decoder.decode(&buf, cmd.data) catch |err| {
log.warn("failed to decode base64 data: {}", .{err});
return error.InvalidData;
};
if (comptime builtin.os.tag != .windows) { if (comptime builtin.os.tag != .windows) {
if (std.mem.indexOfScalar(u8, buf[0..size], 0) != null) { if (std.mem.indexOfScalar(u8, cmd.data, 0) != null) {
// posix.realpath *asserts* that the path does not have // posix.realpath *asserts* that the path does not have
// internal nulls instead of erroring. // internal nulls instead of erroring.
log.warn("failed to get absolute path: BadPathName", .{}); log.warn("failed to get absolute path: BadPathName", .{});
@ -88,7 +75,7 @@ pub const LoadingImage = struct {
} }
var abs_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; var abs_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
const path = posix.realpath(buf[0..size], &abs_buf) catch |err| { const path = posix.realpath(cmd.data, &abs_buf) catch |err| {
log.warn("failed to get absolute path: {}", .{err}); log.warn("failed to get absolute path: {}", .{err});
return error.InvalidData; return error.InvalidData;
}; };
@ -229,42 +216,25 @@ pub const LoadingImage = struct {
alloc.destroy(self); alloc.destroy(self);
} }
/// Adds a chunk of base64-encoded data to the image. Use this if the /// Adds a chunk of data to the image. Use this if the image
/// image is coming in chunks (the "m" parameter in the protocol). /// is coming in chunks (the "m" parameter in the protocol).
pub fn addData(self: *LoadingImage, alloc: Allocator, data: []const u8) !void { pub fn addData(self: *LoadingImage, alloc: Allocator, data: []const u8) !void {
// If no data, skip // If no data, skip
if (data.len == 0) return; if (data.len == 0) return;
// Grow our array list by size capacity if it needs it
const Base64Decoder = std.base64.standard.Decoder;
const size = Base64Decoder.calcSizeForSlice(data) catch |err| {
log.warn("failed to calculate size for base64 data: {}", .{err});
return error.InvalidData;
};
// If our data would get too big, return an error // If our data would get too big, return an error
if (self.data.items.len + size > max_size) { if (self.data.items.len + data.len > max_size) {
log.warn("image data too large max_size={}", .{max_size}); log.warn("image data too large max_size={}", .{max_size});
return error.InvalidData; return error.InvalidData;
} }
try self.data.ensureUnusedCapacity(alloc, size); // Ensure we have enough room to add the data
// to the end of the ArrayList before doing so.
try self.data.ensureUnusedCapacity(alloc, data.len);
// We decode directly into the arraylist
const start_i = self.data.items.len; const start_i = self.data.items.len;
self.data.items.len = start_i + size; self.data.items.len = start_i + data.len;
const buf = self.data.items[start_i..]; fastmem.copy(u8, self.data.items[start_i..], data);
Base64Decoder.decode(buf, data) catch |err| switch (err) {
// We have to ignore invalid padding because lots of encoders
// add the wrong padding. Since we validate image data later
// (PNG decode or simple dimensions check), we can ignore this.
error.InvalidPadding => {},
else => {
log.warn("failed to decode base64 data: {}", .{err});
return error.InvalidData;
},
};
} }
/// Complete the chunked image, returning a completed image. /// Complete the chunked image, returning a completed image.
@ -457,23 +427,6 @@ pub const Rect = struct {
bottom_right: PageList.Pin, bottom_right: PageList.Pin,
}; };
/// Easy base64 encoding function.
fn testB64(alloc: Allocator, data: []const u8) ![]const u8 {
const B64Encoder = std.base64.standard.Encoder;
const b64 = try alloc.alloc(u8, B64Encoder.calcSize(data.len));
errdefer alloc.free(b64);
return B64Encoder.encode(b64, data);
}
/// Easy base64 decoding function.
fn testB64Decode(alloc: Allocator, data: []const u8) ![]const u8 {
const B64Decoder = std.base64.standard.Decoder;
const result = try alloc.alloc(u8, try B64Decoder.calcSizeForSlice(data));
errdefer alloc.free(result);
try B64Decoder.decode(result, data);
return result;
}
// This specifically tests we ALLOW invalid RGB data because Kitty // This specifically tests we ALLOW invalid RGB data because Kitty
// documents that this should work. // documents that this should work.
test "image load with invalid RGB data" { test "image load with invalid RGB data" {
@ -548,7 +501,7 @@ test "image load: rgb, zlib compressed, direct" {
} }, } },
.data = try alloc.dupe( .data = try alloc.dupe(
u8, u8,
@embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"), @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data"),
), ),
}; };
defer cmd.deinit(alloc); defer cmd.deinit(alloc);
@ -576,7 +529,7 @@ test "image load: rgb, not compressed, direct" {
} }, } },
.data = try alloc.dupe( .data = try alloc.dupe(
u8, u8,
@embedFile("testdata/image-rgb-none-20x15-2147483647.data"), @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"),
), ),
}; };
defer cmd.deinit(alloc); defer cmd.deinit(alloc);
@ -593,7 +546,7 @@ test "image load: rgb, zlib compressed, direct, chunked" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"); const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data");
// Setup our initial chunk // Setup our initial chunk
var cmd: command.Command = .{ var cmd: command.Command = .{
@ -630,7 +583,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk"
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"); const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data");
// Setup our initial chunk // Setup our initial chunk
var cmd: command.Command = .{ var cmd: command.Command = .{
@ -668,11 +621,7 @@ test "image load: rgb, not compressed, temporary file" {
var tmp_dir = try internal_os.TempDir.init(); var tmp_dir = try internal_os.TempDir.init();
defer tmp_dir.deinit(); defer tmp_dir.deinit();
const data = try testB64Decode( const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
alloc,
@embedFile("testdata/image-rgb-none-20x15-2147483647.data"),
);
defer alloc.free(data);
try tmp_dir.dir.writeFile("image.data", data); try tmp_dir.dir.writeFile("image.data", data);
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
@ -687,7 +636,7 @@ test "image load: rgb, not compressed, temporary file" {
.height = 15, .height = 15,
.image_id = 31, .image_id = 31,
} }, } },
.data = try testB64(alloc, path), .data = try alloc.dupe(u8, path),
}; };
defer cmd.deinit(alloc); defer cmd.deinit(alloc);
var loading = try LoadingImage.init(alloc, &cmd); var loading = try LoadingImage.init(alloc, &cmd);
@ -706,11 +655,7 @@ test "image load: rgb, not compressed, regular file" {
var tmp_dir = try internal_os.TempDir.init(); var tmp_dir = try internal_os.TempDir.init();
defer tmp_dir.deinit(); defer tmp_dir.deinit();
const data = try testB64Decode( const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
alloc,
@embedFile("testdata/image-rgb-none-20x15-2147483647.data"),
);
defer alloc.free(data);
try tmp_dir.dir.writeFile("image.data", data); try tmp_dir.dir.writeFile("image.data", data);
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
@ -725,7 +670,7 @@ test "image load: rgb, not compressed, regular file" {
.height = 15, .height = 15,
.image_id = 31, .image_id = 31,
} }, } },
.data = try testB64(alloc, path), .data = try alloc.dupe(u8, path),
}; };
defer cmd.deinit(alloc); defer cmd.deinit(alloc);
var loading = try LoadingImage.init(alloc, &cmd); var loading = try LoadingImage.init(alloc, &cmd);
@ -757,7 +702,7 @@ test "image load: png, not compressed, regular file" {
.height = 0, .height = 0,
.image_id = 31, .image_id = 31,
} }, } },
.data = try testB64(alloc, path), .data = try alloc.dupe(u8, path),
}; };
defer cmd.deinit(alloc); defer cmd.deinit(alloc);
var loading = try LoadingImage.init(alloc, &cmd); var loading = try LoadingImage.init(alloc, &cmd);

View File

@ -1 +0,0 @@
DRoeCxgcCxcjEh4qDBgkCxcjChYiCxcjCRclBRMhBxIXHysvTVNRbHJwcXB2Li0zCBYXEyEiCxkaDBobChcbCBUZDxsnBBAcEBwoChYiCxcjDBgkDhwqBxUjDBccm6aqy9HP1NrYzs3UsK+2IjAxCBYXCBYXBxUWFBoaDxUVICYqIyktERcZDxUXDxUVEhgYDhUTCxIQGh8XusC4zM7FvL61q6elmZWTTVtcDBobDRscCxkaKS8vaW9vxMnOur/EiY+RaW5wICYmW2FhfYOBQEZEnqSc4ebeqauilZaOsa2rm5eVcH5/GigpChgZCBYX0NHP3d7c3tzbx8XExsTEvry8wL241dLN0tDF0tDF29nM4d/StbKpzMrAUk5DZmJXeYSGKTU3ER0fDRkb1tfVysvJ0tDPsa+tr6ytop+gmZaRqaahuritw8G2urirqKaZiYZ9paKZZmJXamZbOkZIDhocBxMVBBASxMDBtrKzqqanoZ2ejYeLeHF2eXFvhn58npePta6ml5CKgXp0W1hPaWZdZWdSYmRPFiYADR0AFCQAEyMAt7O0lJCRf3t8eHR1Zl9kY1xhYVpYbGRieXJqeHFpdW1oc2tmcG1kX1xTbW9ajY96jp55kaF8kKB7kaF8sK6rcnFtX11cXFpZW1pWWFdTXVpTXltUaGJgY11bY11da2Vla25dam1ccHtTnqmBorVtp7pypLdvobRsh4aCaGdjWFZVXFpZYWBcZ2ZiaGVeZGFaY11bYlxaV1FRZ2FhdHdmbG9egItjo66GpLdvq752rL93rsF5kpKIZ2ddWFxTW19WbnZdipJ6cnhaaW9RaGhgV1ZPY2Jga2poanFQd35dk6Vpn7B0oLFvorNxm6xqmKlnv760enpwVlpRW19Wc3til5+Hl55/k5p7iIiAcnJqd3Z0bm1rcHdWh45tipxgladrkaJglKVjkaJgkqNh09DJiYZ/YmZdY2deeYZYjJlrj51ijpxhztHClJaIdHNvdHNvanNHi5RpmaxnjKBbmqhrmadqkJ5hi5lcxsO8jImCaGtiYmZdg5Bikp9xjJpfjpxh1djJqq2eamllZ2Zid4BVmKF2kqZhh5tWlaNmlaNmjpxfjJpdw729rqiodnZ0cHBuiplij55nj6FVjJ5SzdC9t7qncW1sXlpZh45iqbCEmKllmapmmqlqnq1unaxtoK9w

File diff suppressed because one or more lines are too long

View File

@ -184,7 +184,7 @@ pub const Page = struct {
pub fn reinit(self: *Page) void { pub fn reinit(self: *Page) void {
// We zero the page memory as u64 instead of u8 because // We zero the page memory as u64 instead of u8 because
// we can and it's empirically quite a bit faster. // we can and it's empirically quite a bit faster.
@memset(@as([*]u64, @ptrCast(self.memory))[0..self.memory.len / 8], 0); @memset(@as([*]u64, @ptrCast(self.memory))[0 .. self.memory.len / 8], 0);
self.* = initBuf(OffsetBuf.init(self.memory), layout(self.capacity)); self.* = initBuf(OffsetBuf.init(self.memory), layout(self.capacity));
} }

View File

@ -1465,6 +1465,16 @@ pub fn Stream(comptime Handler: type) type {
}, },
} else log.warn("unimplemented invokeCharset: {}", .{action}), } else log.warn("unimplemented invokeCharset: {}", .{action}),
// SPA - Start of Guarded Area
'V' => if (@hasDecl(T, "setProtectedMode")) {
try self.handler.setProtectedMode(ansi.ProtectedMode.iso);
} else log.warn("unimplemented ESC callback: {}", .{action}),
// EPA - End of Guarded Area
'W' => if (@hasDecl(T, "setProtectedMode")) {
try self.handler.setProtectedMode(ansi.ProtectedMode.off);
} else log.warn("unimplemented ESC callback: {}", .{action}),
// DECID // DECID
'Z' => if (@hasDecl(T, "deviceAttributes")) { 'Z' => if (@hasDecl(T, "deviceAttributes")) {
try self.handler.deviceAttributes(.primary, &.{}); try self.handler.deviceAttributes(.primary, &.{});

View File

@ -1993,7 +1993,7 @@ const StreamHandler = struct {
pub fn setCursorColRelative(self: *StreamHandler, offset: u16) !void { pub fn setCursorColRelative(self: *StreamHandler, offset: u16) !void {
self.terminal.setCursorPos( self.terminal.setCursorPos(
self.terminal.screen.cursor.y + 1, self.terminal.screen.cursor.y + 1,
self.terminal.screen.cursor.x + 1 + offset, self.terminal.screen.cursor.x + 1 +| offset,
); );
} }
@ -2003,7 +2003,7 @@ const StreamHandler = struct {
pub fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void { pub fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void {
self.terminal.setCursorPos( self.terminal.setCursorPos(
self.terminal.screen.cursor.y + 1 + offset, self.terminal.screen.cursor.y + 1 +| offset,
self.terminal.screen.cursor.x + 1, self.terminal.screen.cursor.x + 1,
); );
} }