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
[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
to use Nix to develop Ghostty, but the Nix environment is the environment
@ -565,7 +566,22 @@ all features of Ghostty work.
### Mac `.app`
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
$ zig build -Doptimize=ReleaseFast

View File

@ -3,6 +3,7 @@ const builtin = @import("builtin");
const fs = std.fs;
const CompileStep = std.Build.Step.Compile;
const RunStep = std.Build.Step.Run;
const ResolvedTarget = std.Build.ResolvedTarget;
const apprt = @import("src/apprt.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_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(.{
.name = "ghostty-test",
.root_source_file = .{ .path = "src/main.zig" },
.target = target,
.target = test_target,
.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
/// to the final library.
///
@ -781,11 +797,7 @@ fn createMacOSLib(
const lib = b.addStaticLibrary(.{
.name = "ghostty",
.root_source_file = .{ .path = "src/main_c.zig" },
.target = b.resolveTargetQuery(.{
.cpu_arch = .aarch64,
.os_tag = .macos,
.os_version_min = osVersionMin(.macos),
}),
.target = genericMacOSTarget(b),
.optimize = optimize,
});
lib.bundle_compiler_rt = true;
@ -1165,6 +1177,43 @@ fn addDeps(
.gtk => {
step.linkSystemLibrary2("gtk4", 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": {
"locked": {
"lastModified": 1711143939,
"narHash": "sha256-oT6a81U4NHjJH1hjaMVXKsdTZJwl2dT+MhMESKoevvA=",
"lastModified": 1711789504,
"narHash": "sha256-1XRwW0MD9LxtHMMlPmF3rDw/Zbv4jLnpGnJEtibO+MQ=",
"owner": "vancluever",
"repo": "nixpkgs",
"rev": "c4749393c06e52da4adf42877fdf9bac7141f0de",
"rev": "c9e24149cca8215b84fc3ce5bc2bdc1ca823a588",
"type": "github"
},
"original": {

View File

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

View File

@ -175,6 +175,13 @@ class TerminalController: NSWindowController, NSWindowDelegate,
let appearance = NSAppearance(named: color.isLightColor ? .aqua : .darkAqua)
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
@ -258,7 +265,6 @@ class TerminalController: NSWindowController, NSWindowDelegate,
if (ghostty.config.macosTitlebarTabs) {
window.tabbingMode = .preferred
window.titlebarTabs = true
syncAppearance()
DispatchQueue.main.async {
window.tabbingMode = .automatic
}
@ -290,6 +296,9 @@ class TerminalController: NSWindowController, NSWindowDelegate,
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.

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) {
super.init(identifier: identifier)

View File

@ -96,6 +96,12 @@ class TerminalWindow: NSWindow {
}
// MARK: - NSWindow
override var title: String {
didSet {
tab.attributedTitle = attributedTitle
}
}
override func becomeKey() {
// This is required because the removeTitlebarAccessoryViewController hook does not
@ -109,6 +115,7 @@ class TerminalWindow: NSWindow {
updateNewTabButtonOpacity()
resetZoomTabButton.contentTintColor = .controlAccentColor
resetZoomToolbarButton.contentTintColor = .controlAccentColor
tab.attributedTitle = attributedTitle
}
override func resignKey() {
@ -117,6 +124,7 @@ class TerminalWindow: NSWindow {
updateNewTabButtonOpacity()
resetZoomTabButton.contentTintColor = .secondaryLabelColor
resetZoomToolbarButton.contentTintColor = .tertiaryLabelColor
tab.attributedTitle = attributedTitle
}
override func layoutIfNeeded() {
@ -171,6 +179,38 @@ class TerminalWindow: NSWindow {
updateNewTabButtonImage()
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: -

View File

@ -197,7 +197,16 @@ extension Ghostty {
_ = ghostty_config_get(config, &v, key, UInt(key.count))
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 {
guard let config = self.config else { return false }
var v = false;

View File

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

View File

@ -151,8 +151,16 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
break :app @ptrCast(adw_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(
app,
"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;
errdefer c.g_main_context_release(ctx);
const gapp = @as(*c.GApplication, @ptrCast(app));
var err_: ?*c.GError = null;
if (c.g_application_register(
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_default_size(gtk_window, 600, 275);
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);
// Set some state

View File

@ -943,32 +943,14 @@ pub fn showDesktopNotification(
0 => "Ghostty",
else => title,
};
const notif = c.g_notification_new(t.ptr);
defer c.g_object_unref(notif);
c.g_notification_set_body(notif, body.ptr);
// Find our icon in the current icon theme. Not pretty, but the builtin GIO
// 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,
);
const icon = c.g_themed_icon_new("com.mitchellh.ghostty");
defer c.g_object_unref(icon);
// Get the filepath of the icon we found
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);
c.g_notification_set_icon(notif, icon);
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);
if (c.gtk_gl_area_get_error(area)) |err| {
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;
}
@ -1624,6 +1608,7 @@ fn gtkInputCommit(
fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) void {
const self = userdataSelf(ud.?);
if (!self.realized) return;
// Notify our 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 {
const self = userdataSelf(ud.?);
if (!self.realized) return;
// Notify our 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 Surface = @import("Surface.zig");
const Tab = @import("Tab.zig");
const icon = @import("icon.zig");
const c = @import("c.zig");
const log = std.log.scoped(.gtk);
@ -31,10 +30,6 @@ window: *c.GtkWindow,
/// The notebook (tab grouping) for this window.
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 {
// Allocate a fixed pointer for our window. We try to minimize
// 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
self.* = .{
.app = app,
.icon = undefined,
.window = 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)
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
// 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);
c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty");
// Apply background opacity if we have it
if (app.config.@"background-opacity" < 1) {
@ -189,9 +180,7 @@ fn initActions(self: *Window) void {
}
}
pub fn deinit(self: *Window) void {
self.icon.deinit(self.app);
}
pub fn deinit(_: *Window) void {}
/// Add a new tab to this window.
pub fn newTab(self: *Window, parent: ?*CoreSurface) !void {

View File

@ -7,6 +7,9 @@ const c = @cImport({
@cInclude("gdk/x11/gdkx.h");
// Xkb for X11 state handling
@cInclude("X11/XKBlib.h");
// generated header files
@cInclude("ghostty_resources.h");
});
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 ImguiWidget = @import("ImguiWidget.zig");
const c = @import("c.zig");
const icon = @import("icon.zig");
const CoreInspector = @import("../../inspector/main.zig").Inspector;
const log = std.log.scoped(.inspector);
@ -125,14 +124,12 @@ pub const Inspector = struct {
const Window = struct {
inspector: *Inspector,
window: *c.GtkWindow,
icon: icon.Icon,
imgui_widget: ImguiWidget,
pub fn init(self: *Window, inspector: *Inspector) !void {
// Initialize to undefined
self.* = .{
.inspector = inspector,
.icon = undefined,
.window = undefined,
.imgui_widget = undefined,
};
@ -144,8 +141,7 @@ const Window = struct {
self.window = gtk_window;
c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector");
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, self.icon.name);
c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty");
// Initialize our imgui widget
try self.imgui_widget.init();
@ -163,7 +159,6 @@ const Window = struct {
}
pub fn deinit(self: *Window) void {
self.icon.deinit(self.inspector.surface.app);
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.
@"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:
///
/// * `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.
pub inline fn rotateOnce(comptime T: type, items: []T) void {
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;
}

View File

@ -2431,139 +2431,147 @@ fn draw_dash_horizontal(
) void {
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
// to have gap match desired_gap but if our cell is too small then we
// have to bring it down.
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;
// For N dashes, there are N - 1 gaps between them, but we also have
// half-sized gaps on either side, adding up to N total gaps.
const gap_count = count;
// This would make a negative and overflow our u32. A negative
// dash width is not allowed so we keep trying to fit it.
if (total_gap_width >= self.width) continue;
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.
// We need at least 1 pixel for each gap and each dash, if we don't
// have that then we can't draw our dashed line correctly so we just
// draw a solid line and return.
if (self.width < count + gap_count) {
self.hline_middle(canvas, .light);
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) {
x[3] = x[2] + w[2] + gap;
w[3] = self.width - x[3];
}
// We never want the gaps to take up more than 50% of the space,
// because if they do the dashes are too small and look wrong.
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);
self.hline(canvas, x[1], x[1] + w[1], (self.height -| thick_px) / 2, thick_px);
if (count >= 3)
self.hline(canvas, x[2], x[2] + w[2], (self.height -| thick_px) / 2, thick_px);
if (count >= 4)
self.hline(canvas, x[3], x[3] + w[3], (self.height -| thick_px) / 2, thick_px);
assert(dash_width * count + gap_width * gap_count + remaining == self.width);
// Our dashes should be centered vertically.
const y: u32 = (self.height -| thick_px) / 2;
// 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(
self: Box,
canvas: *font.sprite.Canvas,
count: u8,
comptime count: u8,
thick_px: u32,
gap: u32,
desired_gap: u32,
) void {
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
const dash_height = dash_height: {
var gap_i = gap;
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;
}
// Because of the extra gap at the bottom, there are as many gaps as
// there are dashes.
const gap_count = count;
// If we can't fit any dashes then we just render a horizontal line.
if (dash_height <= 0) {
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;
// We need at least 1 pixel for each gap and each dash, if we don't
// have that then we can't draw our dashed line correctly so we just
// draw a solid line and return.
if (self.height < count + gap_count) {
self.vline_middle(canvas, .light);
return;
}
if (count >= 4) {
y[3] = y[2] + h[2] + gap;
h[3] = self.height - y[3];
}
// We never want the gaps to take up more than 50% of the space,
// because if they do the dashes are too small and look wrong.
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);
self.vline(canvas, y[1], y[1] + h[1], (self.width -| thick_px) / 2, thick_px);
if (count >= 3)
self.vline(canvas, y[2], y[2] + h[2], (self.width -| thick_px) / 2, thick_px);
if (count >= 4)
self.vline(canvas, y[3], y[3] + h[3], (self.width -| thick_px) / 2, thick_px);
assert(dash_height * count + gap_height * gap_count + remaining == self.height);
// Our dashes should be centered horizontally.
const x: u32 = (self.width -| thick_px) / 2;
// 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 {

View File

@ -91,7 +91,7 @@ pub fn main() !MainReturn {
\\
\\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.
\\
\\
,
.{},
);

View File

@ -3,6 +3,7 @@ const builtin = @import("builtin");
const assert = std.debug.assert;
const passwd = @import("passwd.zig");
const posix = std.posix;
const objc = @import("objc");
const Error = error{
/// 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];
}
// 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
var tempBuf: [1024]u8 = undefined;
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.
fba.reset();
const pw = try passwd.get(fba.allocator());
if (pw.home) |result| {
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);
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 (self.no_scrollback) {
// If we have a single-row screen, we have no rows to shift
@ -891,10 +894,20 @@ pub fn clearUnprotectedCells(
row: *Row,
cells: []Cell,
) void {
for (cells) |*cell| {
if (cell.protected) continue;
const cell_multi: [*]Cell = @ptrCast(cell);
self.clearCells(page, row, cell_multi[0..1]);
var x0: usize = 0;
var x1: usize = 0;
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();
@ -2137,6 +2150,25 @@ pub fn dumpStringAlloc(
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
/// 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.
@ -2190,6 +2222,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.content_tag = .codepoint,
.content = .{ .codepoint = c },
.style_id = self.cursor.style_id,
.protected = self.cursor.protected,
};
// 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 = .{ .codepoint = 0 },
.wide = .spacer_head,
.protected = self.cursor.protected,
};
self.cursor.page_row.wrap = true;
@ -2220,6 +2254,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.content = .{ .codepoint = c },
.style_id = self.cursor.style_id,
.wide = .wide,
.protected = self.cursor.protected,
};
// Write our tail
@ -2228,6 +2263,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.content_tag = .codepoint,
.content = .{ .codepoint = 0 },
.wide = .spacer_tail,
.protected = self.cursor.protected,
};
},
@ -2508,6 +2544,34 @@ test "Screen clearRows active styled line" {
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" {
const testing = std.testing;
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.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.
// Otherwise, we simply scroll the region.
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
/// 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
@ -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.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.
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);
while (it.next()) |p| {
const dst_p = p.down(adjusted_count).?;
const src: *Row = p.rowAndCell().row;
const dst: *Row = dst_p.rowAndCell().row;
const src_rac = p.rowAndCell();
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
// one page to another. This is the slow path.
@ -1376,9 +1452,6 @@ pub fn insertLines(self: *Terminal, count: usize) void {
@panic("TODO");
};
// Row never is wrapped if we're full width.
if (!left_right) dst.wrap = false;
continue;
}
@ -1389,10 +1462,6 @@ pub fn insertLines(self: *Terminal, count: usize) void {
dst.* = src.*;
src.* = dst_row;
// Row never is wrapped
dst.wrap = false;
src.wrap = false;
// Ensure what we did didn't corrupt the page
p.page.data.assertIntegrity();
continue;
@ -1407,9 +1476,6 @@ pub fn insertLines(self: *Terminal, count: usize) void {
self.scrolling_region.left,
(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
@ -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.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
// so this is our top. We want to shift lines down, down to the bottom
// of the scroll region.
@ -1498,8 +1567,21 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
var it = top.rowIterator(.right_down, bot);
while (it.next()) |p| {
const src_p = p.down(count).?;
const src: *Row = src_p.rowAndCell().row;
const dst: *Row = p.rowAndCell().row;
const src_rac = src_p.rowAndCell();
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) {
p.page.data.clonePartialRowFrom(
@ -1513,9 +1595,6 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
@panic("TODO");
};
// Row never is wrapped if we're full width.
if (!left_right) dst.wrap = false;
continue;
}
@ -1526,9 +1605,6 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
dst.* = src.*;
src.* = dst_row;
// Row never is wrapped
dst.wrap = false;
// Ensure what we did didn't corrupt the page
p.page.data.assertIntegrity();
continue;
@ -1543,9 +1619,6 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
self.scrolling_region.left,
(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
@ -1793,19 +1866,11 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
return;
}
// SLOW PATH
// We had a protection mode at some point. We must go through each
// cell and check its protection attribute.
for (0..end) |x| {
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],
);
}
self.screen.clearUnprotectedCells(
&self.screen.cursor.page_pin.page.data,
self.screen.cursor.page_row,
cells[0..end],
);
}
/// Erase the line.
@ -1878,16 +1943,11 @@ pub fn eraseLine(
return;
}
for (start..end) |x| {
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],
);
}
self.screen.clearUnprotectedCells(
&self.screen.cursor.page_pin.page.data,
self.screen.cursor.page_row,
cells[start..end],
);
}
/// Erase the display.
@ -2385,6 +2445,11 @@ pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 {
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
pub fn fullReset(self: *Terminal) void {
// 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" {
const alloc = testing.allocator;
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 ArenaAllocator = std.heap.ArenaAllocator;
const log = std.log.scoped(.kitty_gfx);
/// The key-value pairs for the control information for a command. The
/// keys are always single characters and the values are either single
/// characters or 32-bit unsigned integers.
@ -27,8 +29,11 @@ pub const CommandParser = struct {
kv_temp_len: u4 = 0,
kv_current: u8 = 0, // Current kv key
/// This is the list of bytes that contains both KV data and final
/// data. You shouldn't access this directly.
/// This is the list we use to collect the bytes from the data payload.
/// 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),
/// Internal state for parsing.
@ -42,7 +47,7 @@ pub const CommandParser = struct {
control_value,
control_value_ignore,
/// We're parsing the data blob.
/// Collecting the data payload blob.
data,
};
@ -106,9 +111,6 @@ pub const CommandParser = struct {
.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
@ -165,12 +167,45 @@ pub const CommandParser = struct {
return .{
.control = control,
.quiet = quiet,
.data = if (self.data.items.len == 0) "" else data: {
break :data try self.data.toOwnedSlice();
},
.data = try self.decodeData(),
};
}
/// 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 {
const idx = self.kv_temp_len;
self.kv_temp_len += 1;
@ -855,7 +890,7 @@ test "query command" {
var p = CommandParser.init(alloc);
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);
const command = try p.complete();
defer command.deinit(alloc);

View File

@ -5,6 +5,7 @@ const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const posix = std.posix;
const fastmem = @import("../../fastmem.zig");
const command = @import("graphics_command.zig");
const point = @import("../point.zig");
const PageList = @import("../PageList.zig");
@ -56,30 +57,16 @@ pub const LoadingImage = struct {
.display = cmd.display(),
};
// Special case for the direct medium, we just add it directly
// which will handle copying the data, base64 decoding, etc.
// Special case for the direct medium, we just add the chunk directly.
if (t.medium == .direct) {
try result.addData(alloc, cmd.data);
return result;
}
// For every other medium, we'll need to at least base64 decode
// 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;
};
// Otherwise, the payload data is guaranteed to be a path.
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
// internal nulls instead of erroring.
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;
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});
return error.InvalidData;
};
@ -229,42 +216,25 @@ pub const LoadingImage = struct {
alloc.destroy(self);
}
/// Adds a chunk of base64-encoded data to the image. Use this if the
/// image is coming in chunks (the "m" parameter in the protocol).
/// Adds a chunk of data to the image. Use this if the image
/// is coming in chunks (the "m" parameter in the protocol).
pub fn addData(self: *LoadingImage, alloc: Allocator, data: []const u8) !void {
// If no data, skip
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 (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});
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;
self.data.items.len = start_i + size;
const buf = self.data.items[start_i..];
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;
},
};
self.data.items.len = start_i + data.len;
fastmem.copy(u8, self.data.items[start_i..], data);
}
/// Complete the chunked image, returning a completed image.
@ -457,23 +427,6 @@ pub const Rect = struct {
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
// documents that this should work.
test "image load with invalid RGB data" {
@ -548,7 +501,7 @@ test "image load: rgb, zlib compressed, direct" {
} },
.data = try alloc.dupe(
u8,
@embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"),
@embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data"),
),
};
defer cmd.deinit(alloc);
@ -576,7 +529,7 @@ test "image load: rgb, not compressed, direct" {
} },
.data = try alloc.dupe(
u8,
@embedFile("testdata/image-rgb-none-20x15-2147483647.data"),
@embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"),
),
};
defer cmd.deinit(alloc);
@ -593,7 +546,7 @@ test "image load: rgb, zlib compressed, direct, chunked" {
const testing = std.testing;
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
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 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
var cmd: command.Command = .{
@ -668,11 +621,7 @@ test "image load: rgb, not compressed, temporary file" {
var tmp_dir = try internal_os.TempDir.init();
defer tmp_dir.deinit();
const data = try testB64Decode(
alloc,
@embedFile("testdata/image-rgb-none-20x15-2147483647.data"),
);
defer alloc.free(data);
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
try tmp_dir.dir.writeFile("image.data", data);
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
@ -687,7 +636,7 @@ test "image load: rgb, not compressed, temporary file" {
.height = 15,
.image_id = 31,
} },
.data = try testB64(alloc, path),
.data = try alloc.dupe(u8, path),
};
defer cmd.deinit(alloc);
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();
defer tmp_dir.deinit();
const data = try testB64Decode(
alloc,
@embedFile("testdata/image-rgb-none-20x15-2147483647.data"),
);
defer alloc.free(data);
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
try tmp_dir.dir.writeFile("image.data", data);
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
@ -725,7 +670,7 @@ test "image load: rgb, not compressed, regular file" {
.height = 15,
.image_id = 31,
} },
.data = try testB64(alloc, path),
.data = try alloc.dupe(u8, path),
};
defer cmd.deinit(alloc);
var loading = try LoadingImage.init(alloc, &cmd);
@ -757,7 +702,7 @@ test "image load: png, not compressed, regular file" {
.height = 0,
.image_id = 31,
} },
.data = try testB64(alloc, path),
.data = try alloc.dupe(u8, path),
};
defer cmd.deinit(alloc);
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 {
// We zero the page memory as u64 instead of u8 because
// 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));
}

View File

@ -1465,6 +1465,16 @@ pub fn Stream(comptime Handler: type) type {
},
} 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
'Z' => if (@hasDecl(T, "deviceAttributes")) {
try self.handler.deviceAttributes(.primary, &.{});

View File

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