From baf75dfaaf94d8426b86f2198672330bee3c0994 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 8 Feb 2024 13:25:54 -0800 Subject: [PATCH 01/26] macos: configurable titlebar fonts Add support for configurable fonts for window and tab titles. This is only implemented for macOS (and could be macOS-only if other platforms aren't able to support this using their windowing toolkits). It plays nicely with regular and titlebar tabs. --- .../Terminal/TerminalController.swift | 11 ++++- .../Features/Terminal/TerminalToolbar.swift | 12 +++++- .../Features/Terminal/TerminalWindow.swift | 42 +++++++++++++++++++ macos/Sources/Ghostty/Ghostty.Config.swift | 11 ++++- src/config/Config.zig | 5 +++ 5 files changed, 78 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5bb44f341..ea9705e47 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -184,6 +184,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 @@ -261,7 +268,6 @@ class TerminalController: NSWindowController, NSWindowDelegate, if (ghostty.config.macosTitlebarTabs) { window.tabbingMode = .preferred window.titlebarTabs = true - syncAppearance() DispatchQueue.main.async { window.tabbingMode = .automatic } @@ -305,6 +311,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. diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift index b0857cb24..7da8de0cc 100644 --- a/macos/Sources/Features/Terminal/TerminalToolbar.swift +++ b/macos/Sources/Features/Terminal/TerminalToolbar.swift @@ -15,7 +15,17 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate { titleTextField.stringValue = newValue } } - + + var titleFont: NSFont? { + get { + titleTextField.font + } + + set { + titleTextField.font = newValue + } + } + override init(identifier: NSToolbar.Identifier) { super.init(identifier: identifier) diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 5fa06cfb0..339a5fde0 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -8,6 +8,12 @@ class TerminalWindow: NSWindow { // MARK: - NSWindow + override var title: String { + didSet { + tab.attributedTitle = attributedTitle + } + } + override func becomeKey() { // This is required because the removeTitlebarAccessoryViewControlle hook does not // catch the creation of a new window by "tearing off" a tab from a tabbed window. @@ -20,6 +26,8 @@ class TerminalWindow: NSWindow { if titlebarTabs { updateNewTabButtonOpacity() } + + tab.attributedTitle = attributedTitle } override func resignKey() { @@ -28,6 +36,8 @@ class TerminalWindow: NSWindow { if titlebarTabs { updateNewTabButtonOpacity() } + + tab.attributedTitle = attributedTitle } // MARK: - Titlebar Tabs @@ -39,6 +49,38 @@ class TerminalWindow: NSWindow { } } + // 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) + } + private var windowButtonsBackdrop: WindowButtonsBackdropView? = nil private var windowDragHandle: WindowDragView? = nil private var storedTitlebarBackgroundColor: CGColor? = nil diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index c26b525eb..a444c1d9a 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -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? = 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; diff --git a/src/config/Config.zig b/src/config/Config.zig index b3b5d446c..d30492ace 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -603,6 +603,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 From f943a4cf873061771eed097fb6faea2fc30e3931 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 11 Feb 2024 15:06:22 -0600 Subject: [PATCH 02/26] GTK: Add compiled-in GTK resources and use them for icons. Use glib-compile-resources to compile CSS style sheets and icons into the Ghostty GTK binary. Makes for simpler access to icons and sets things up for customizing the look of Ghostty with CSS in the future. The CSS style sheets are blank for now so there will be no visual changes. --- build.zig | 81 +++++++++++++++++++++++++++++++++ src/apprt/gtk/App.zig | 13 +++++- src/apprt/gtk/Surface.zig | 24 ++-------- src/apprt/gtk/Window.zig | 15 +----- src/apprt/gtk/c.zig | 3 ++ src/apprt/gtk/gresource.xml | 20 ++++++++ src/apprt/gtk/icon.zig | 63 ------------------------- src/apprt/gtk/inspector.zig | 7 +-- src/apprt/gtk/style-dark.css | 0 src/apprt/gtk/style-hc-dark.css | 0 src/apprt/gtk/style-hc.css | 0 src/apprt/gtk/style.css | 0 12 files changed, 122 insertions(+), 104 deletions(-) create mode 100644 src/apprt/gtk/gresource.xml delete mode 100644 src/apprt/gtk/icon.zig create mode 100644 src/apprt/gtk/style-dark.css create mode 100644 src/apprt/gtk/style-hc-dark.css create mode 100644 src/apprt/gtk/style-hc.css create mode 100644 src/apprt/gtk/style.css diff --git a/build.zig b/build.zig index de008af35..a03d0d8a1 100644 --- a/build.zig +++ b/build.zig @@ -1165,6 +1165,87 @@ fn addDeps( .gtk => { step.linkSystemLibrary2("gtk4", dynamic_link_opts); if (config.libadwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts); + + { + // TODO: find a way to dynamically update this from the output + // of `glib-compile-resources --generate-dependencies` + const extra_file_dependencies = &.{ + "src/apprt/gtk/gresource.xml", + "src/apprt/gtk/style.css", + "src/apprt/gtk/style-dark.css", + "src/apprt/gtk/style-hc.css", + "src/apprt/gtk/style-hc-dark.css", + "images/icons/icon_16x16@2x@2x.png", + "images/icons/icon_16x16.png", + "images/icons/icon_32x32@2x@2x.png", + "images/icons/icon_32x32.png", + "images/icons/icon_128x128@2x@2x.png", + "images/icons/icon_128x128.png", + "images/icons/icon_256x256@2x@2x.png", + "images/icons/icon_256x256.png", + "images/icons/icon_512x512.png", + }; + + const generate_resources_d = b.addSystemCommand(&.{ + "glib-compile-resources", + "--generate-dependencies", + "src/apprt/gtk/gresource.xml", + }); + + _ = generate_resources_d.captureStdOut(); + + generate_resources_d.extra_file_dependencies = &.{ + "src/apprt/gtk/gresource.xml", + }; + + 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.addArgs(&.{ + "src/apprt/gtk/gresource.xml", + }); + + generate_resources_c.step.dependOn(&generate_resources_d.step); + + // TODO: find a way to dynamically update this from the output + // of `glib-compile-resources --generate-dependencies` + generate_resources_c.extra_file_dependencies = extra_file_dependencies; + + 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.addArgs(&.{ + "src/apprt/gtk/gresource.xml", + }); + + generate_resources_h.step.dependOn(&generate_resources_d.step); + generate_resources_h.step.dependOn(&generate_resources_c.step); + + // TODO: find a way to dynamically update this from the output + // of `glib-compile-resources --generate-dependencies` + generate_resources_h.extra_file_dependencies = extra_file_dependencies; + + step.addIncludePath(ghostty_resources_h.dirname()); + } }, } } diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 622764d14..8ae896c3d 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -153,6 +153,18 @@ pub fn init(core_app: *CoreApp, opts: Options) !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 + c.g_application_set_resource_base_path(gapp, "/com/mitchellh/ghostty"); + + // load compiled-in resources + 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 +181,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, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index fe8968acd..041d3900d 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -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); diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 6e24c9468..4adccac4b 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -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 { diff --git a/src/apprt/gtk/c.zig b/src/apprt/gtk/c.zig index ffe7b1d0e..5ac8d0fe8 100644 --- a/src/apprt/gtk/c.zig +++ b/src/apprt/gtk/c.zig @@ -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; diff --git a/src/apprt/gtk/gresource.xml b/src/apprt/gtk/gresource.xml new file mode 100644 index 000000000..8a3394ae7 --- /dev/null +++ b/src/apprt/gtk/gresource.xml @@ -0,0 +1,20 @@ + + + + src/apprt/gtk/style.css + src/apprt/gtk/style-dark.css + src/apprt/gtk/style-hc.css + src/apprt/gtk/style-hc-dark.css + + + images/icons/icon_16x16.png + images/icons/icon_16x16@2x@2x.png + images/icons/icon_32x32.png + images/icons/icon_32x32@2x@2x.png + images/icons/icon_128x128.png + images/icons/icon_128x128@2x@2x.png + images/icons/icon_256x256.png + images/icons/icon_256x256@2x@2x.png + images/icons/icon_512x512.png + + diff --git a/src/apprt/gtk/icon.zig b/src/apprt/gtk/icon.zig deleted file mode 100644 index 6a7ced3ea..000000000 --- a/src/apprt/gtk/icon.zig +++ /dev/null @@ -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; -} diff --git a/src/apprt/gtk/inspector.zig b/src/apprt/gtk/inspector.zig index 8eee7a540..ae7856df7 100644 --- a/src/apprt/gtk/inspector.zig +++ b/src/apprt/gtk/inspector.zig @@ -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(); } diff --git a/src/apprt/gtk/style-dark.css b/src/apprt/gtk/style-dark.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/apprt/gtk/style-hc-dark.css b/src/apprt/gtk/style-hc-dark.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/apprt/gtk/style-hc.css b/src/apprt/gtk/style-hc.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css new file mode 100644 index 000000000..e69de29bb From 9f0468f950ab48b5f1faccb491858277fab23a52 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 12 Feb 2024 16:40:06 -0600 Subject: [PATCH 03/26] generate gresource xml and dependencies at comptime rather than hardcoding --- build.zig | 56 +++--------- src/apprt/gtk/ConfigErrorsWindow.zig | 1 + src/apprt/gtk/gresource.xml | 20 ----- src/apprt/gtk/gresource.zig | 124 +++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 66 deletions(-) delete mode 100644 src/apprt/gtk/gresource.xml create mode 100644 src/apprt/gtk/gresource.zig diff --git a/build.zig b/build.zig index a03d0d8a1..c8fcd2915 100644 --- a/build.zig +++ b/build.zig @@ -1167,36 +1167,13 @@ fn addDeps( if (config.libadwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts); { - // TODO: find a way to dynamically update this from the output - // of `glib-compile-resources --generate-dependencies` - const extra_file_dependencies = &.{ - "src/apprt/gtk/gresource.xml", - "src/apprt/gtk/style.css", - "src/apprt/gtk/style-dark.css", - "src/apprt/gtk/style-hc.css", - "src/apprt/gtk/style-hc-dark.css", - "images/icons/icon_16x16@2x@2x.png", - "images/icons/icon_16x16.png", - "images/icons/icon_32x32@2x@2x.png", - "images/icons/icon_32x32.png", - "images/icons/icon_128x128@2x@2x.png", - "images/icons/icon_128x128.png", - "images/icons/icon_256x256@2x@2x.png", - "images/icons/icon_256x256.png", - "images/icons/icon_512x512.png", - }; + const gresource = @import("src/apprt/gtk/gresource.zig"); - const generate_resources_d = b.addSystemCommand(&.{ - "glib-compile-resources", - "--generate-dependencies", - "src/apprt/gtk/gresource.xml", - }); - - _ = generate_resources_d.captureStdOut(); - - generate_resources_d.extra_file_dependencies = &.{ - "src/apprt/gtk/gresource.xml", - }; + 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", @@ -1208,15 +1185,9 @@ fn addDeps( const ghostty_resources_c = generate_resources_c.addOutputFileArg("ghostty_resources.c"); - generate_resources_c.addArgs(&.{ - "src/apprt/gtk/gresource.xml", - }); + generate_resources_c.addFileArg(gresource_xml); - generate_resources_c.step.dependOn(&generate_resources_d.step); - - // TODO: find a way to dynamically update this from the output - // of `glib-compile-resources --generate-dependencies` - generate_resources_c.extra_file_dependencies = extra_file_dependencies; + generate_resources_c.extra_file_dependencies = if (config.libadwaita) &gresource.dependencies_libadwaita else &gresource.dependencies_gtk; step.addCSourceFile(.{ .file = ghostty_resources_c, @@ -1233,16 +1204,9 @@ fn addDeps( const ghostty_resources_h = generate_resources_h.addOutputFileArg("ghostty_resources.h"); - generate_resources_h.addArgs(&.{ - "src/apprt/gtk/gresource.xml", - }); + generate_resources_h.addFileArg(gresource_xml); - generate_resources_h.step.dependOn(&generate_resources_d.step); - generate_resources_h.step.dependOn(&generate_resources_c.step); - - // TODO: find a way to dynamically update this from the output - // of `glib-compile-resources --generate-dependencies` - generate_resources_h.extra_file_dependencies = extra_file_dependencies; + generate_resources_h.extra_file_dependencies = if (config.libadwaita) &gresource.dependencies_libadwaita else &gresource.dependencies_gtk; step.addIncludePath(ghostty_resources_h.dirname()); } diff --git a/src/apprt/gtk/ConfigErrorsWindow.zig b/src/apprt/gtk/ConfigErrorsWindow.zig index 5fe371c1d..563a3e51b 100644 --- a/src/apprt/gtk/ConfigErrorsWindow.zig +++ b/src/apprt/gtk/ConfigErrorsWindow.zig @@ -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(>kDestroy), self, null, c.G_CONNECT_DEFAULT); // Set some state diff --git a/src/apprt/gtk/gresource.xml b/src/apprt/gtk/gresource.xml deleted file mode 100644 index 8a3394ae7..000000000 --- a/src/apprt/gtk/gresource.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - src/apprt/gtk/style.css - src/apprt/gtk/style-dark.css - src/apprt/gtk/style-hc.css - src/apprt/gtk/style-hc-dark.css - - - images/icons/icon_16x16.png - images/icons/icon_16x16@2x@2x.png - images/icons/icon_32x32.png - images/icons/icon_32x32@2x@2x.png - images/icons/icon_128x128.png - images/icons/icon_128x128@2x@2x.png - images/icons/icon_256x256.png - images/icons/icon_256x256@2x@2x.png - images/icons/icon_512x512.png - - diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig new file mode 100644 index 000000000..a0e3806b8 --- /dev/null +++ b/src/apprt/gtk/gresource.zig @@ -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( + \\ + \\ + \\ + ); + if (libadwaita) { + try writer.writeAll( + \\ + \\ + ); + for (css_files) |css_file| { + try writer.print( + " src/apprt/gtk/{s}\n", + .{ css_file, css_file }, + ); + } + try writer.writeAll( + \\ + \\ + ); + } + try writer.writeAll( + \\ + \\ + ); + for (icons) |icon| { + try writer.print( + " images/icons/icon_{s}.png\n", + .{ icon.alias, icon.source }, + ); + } + try writer.writeAll( + \\ + \\ + \\ + ); +} + +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; +}; From b6f597026495ec53bbf4cb516dea7bcfc21d6537 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 28 Mar 2024 14:45:44 -0700 Subject: [PATCH 04/26] minor style changes --- build.zig | 18 +++++------------- src/apprt/gtk/App.zig | 6 +----- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/build.zig b/build.zig index c8fcd2915..0b225e6d8 100644 --- a/build.zig +++ b/build.zig @@ -1172,7 +1172,10 @@ fn addDeps( const wf = b.addWriteFiles(); const gresource_xml = wf.add( "gresource.xml", - if (config.libadwaita) gresource.gresource_xml_libadwaita else gresource.gresource_xml_gtk, + if (config.libadwaita) + gresource.gresource_xml_libadwaita + else + gresource.gresource_xml_gtk, ); const generate_resources_c = b.addSystemCommand(&.{ @@ -1182,17 +1185,10 @@ fn addDeps( "--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 = &.{}, - }); + step.addCSourceFile(.{ .file = ghostty_resources_c, .flags = &.{} }); const generate_resources_h = b.addSystemCommand(&.{ "glib-compile-resources", @@ -1201,13 +1197,9 @@ fn addDeps( "--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()); } }, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 8ae896c3d..6da314c56 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -151,16 +151,12 @@ 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 + // the app id and load in compiled resources c.g_application_set_resource_base_path(gapp, "/com/mitchellh/ghostty"); - - // load compiled-in resources c.g_resources_register(c.ghostty_get_resource()); // The `activate` signal is used when Ghostty is first launched and when a From 77c8a5998f91b2591cb8e6da8ce86427e3c9b543 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 28 Mar 2024 15:11:45 -0700 Subject: [PATCH 05/26] apprt/gtk: handle gtk realize error more gracefully Fixes #1606 This improves our logging when this occurs and prevents a crash. The program will just run indefinitely with no windows (you can try to create another but it will probably fail) but the logs are much more helpful now. --- src/apprt/gtk/Surface.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 041d3900d..34108a6f1 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -966,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; } @@ -1606,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); @@ -1619,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); From 5df5fb4a563914c6154c1b4a03c4c8e2c1ceec53 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 28 Mar 2024 20:05:10 -0400 Subject: [PATCH 06/26] fix(terminal/stream): add SPA and EPA handlers --- src/terminal/stream.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index d32149cc1..4f4f36ad5 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -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, &.{}); From 0adbe097ed462a12431edd7f5533f6861b2af5ae Mon Sep 17 00:00:00 2001 From: Tristan Partin Date: Thu, 28 Mar 2024 21:12:45 -0500 Subject: [PATCH 07/26] fix: remove deprecated to-pixdata gresource preprocessor It was deprecated in gdk-pixbuf 2.32. Link: https://docs.gtk.org/gio/struct.Resource.html --- src/apprt/gtk/gresource.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig index a0e3806b8..e0dc6f549 100644 --- a/src/apprt/gtk/gresource.zig +++ b/src/apprt/gtk/gresource.zig @@ -93,7 +93,7 @@ fn writeGResourceXML(libadwaita: bool, writer: anytype) !void { ); for (icons) |icon| { try writer.print( - " images/icons/icon_{s}.png\n", + " images/icons/icon_{s}.png\n", .{ icon.alias, icon.source }, ); } From e55f2daf9075e567b8287ddb0ba049712731ff1d Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 29 Mar 2024 13:08:38 -0400 Subject: [PATCH 08/26] perf(terminal): clear unprotected cells in spans Previous behavior of clearing one at a time hit a page integrity assertion after clearing a wide character but not its tail. This fixes that and should also be - in theory - significantly more performant as well by identifying spans of unprotected cells and clearing them in bulk. --- src/terminal/Screen.zig | 18 ++++++++++++++---- src/terminal/Terminal.zig | 33 ++++++++++----------------------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index a94a24c7a..61af34338 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -891,10 +891,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(); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 2c17c051a..0c91cb428 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1793,19 +1793,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 +1870,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. From 20ab4ec01fef4098364404010d552082a5550acd Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 29 Mar 2024 13:12:52 -0400 Subject: [PATCH 09/26] fix(terminal): correct wrap logic in insert/deleteLines Appropriately handles clearing spacer heads if shifted lines include rightmost column, and centralizes clearing of row wrap state for full width scrolling regions. --- src/terminal/Terminal.zig | 73 +++++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 0c91cb428..6dbe2345a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1359,8 +1359,31 @@ 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; + + // 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 (self.scrolling_region.right == self.cols - 1) { + const src_end_cell: *Cell = @ptrCast(@as([*]Cell, @ptrCast(src_rac.cell)) + p.page.data.size.cols - 1); + const dst_end_cell: *Cell = @ptrCast(@as([*]Cell, @ptrCast(dst_rac.cell)) + dst_p.page.data.size.cols - 1); + if (dst_end_cell.wide == .spacer_head) { + dst_end_cell.wide = .narrow; + } + if (src_end_cell.wide == .spacer_head) { + src_end_cell.wide = .narrow; + } + + // If our scrolling region is full width, then we unset wrap. + if (self.scrolling_region.left == 0) { + dst.wrap = false; + src.wrap = 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 +1399,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 +1409,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 +1423,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 @@ -1498,8 +1511,31 @@ 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; + + // 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 (self.scrolling_region.right == self.cols - 1) { + const src_end_cell: *Cell = @ptrCast(@as([*]Cell, @ptrCast(src_rac.cell)) + src_p.page.data.size.cols - 1); + const dst_end_cell: *Cell = @ptrCast(@as([*]Cell, @ptrCast(dst_rac.cell)) + p.page.data.size.cols - 1); + if (dst_end_cell.wide == .spacer_head) { + dst_end_cell.wide = .narrow; + } + if (src_end_cell.wide == .spacer_head) { + src_end_cell.wide = .narrow; + } + + // If our scrolling region is full width, then we unset wrap. + if (self.scrolling_region.left == 0) { + dst.wrap = false; + src.wrap = false; + } + } if (src_p.page != p.page) { p.page.data.clonePartialRowFrom( @@ -1513,9 +1549,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 +1559,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 +1573,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 From 4c9e238c3f32bc3ba49afd484bfcde48f2a84f94 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 29 Mar 2024 13:15:24 -0400 Subject: [PATCH 10/26] fix(termio/exec): avoid overflow in setCursorRow/ColRelative Using a saturating addition here just to avoid overflow, since setCursorPos handles proper clamping to the screen size so we don't need to duplicate that logic. --- src/termio/Exec.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 43708ed2b..27ac5a078 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -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, ); } From 314106ec52778050c225e1d003741ffda5e80134 Mon Sep 17 00:00:00 2001 From: Angelo Tata Date: Fri, 29 Mar 2024 18:48:30 +0000 Subject: [PATCH 11/26] Clarify macOS build requirements --- README.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 05e9a65c7..1f0c246e8 100644 --- a/README.md +++ b/README.md @@ -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 From a4913811913a11af85924c0ef780d3fe910c01d6 Mon Sep 17 00:00:00 2001 From: RGBCube Date: Fri, 29 Mar 2024 22:24:32 +0300 Subject: [PATCH 12/26] nix: make ReleaseFast the default package --- flake.nix | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/flake.nix b/flake.nix index ad7d4da17..347cd1de8 100644 --- a/flake.nix +++ b/flake.nix @@ -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; }; From aa928b8d874d96e089f8ee7728cf387d9cf8da00 Mon Sep 17 00:00:00 2001 From: RGBCube Date: Fri, 29 Mar 2024 22:27:32 +0300 Subject: [PATCH 13/26] nix: use packages = instead of (native)buildInputs as that is recommended --- nix/devShell.nix | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/nix/devShell.nix b/nix/devShell.nix index c5bd045d5..e36a2f873 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -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 From 925c7e86a274ce5d952845bcc8d788848eab4fa5 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 29 Mar 2024 16:29:27 -0400 Subject: [PATCH 14/26] fix(terminal): insert/deleteLines boundary cond.s Introduced a helper function for correctly handling boundary conditions in insertLines and deleteLines. Also adds a whole host of tests for said conditions in deleteLines, tests not duplicated for insertLines because they both use the same helper function. --- src/terminal/Screen.zig | 19 +++ src/terminal/Terminal.zig | 351 ++++++++++++++++++++++++++++++++++---- 2 files changed, 334 insertions(+), 36 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 61af34338..4467db866 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2147,6 +2147,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. diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 6dbe2345a..70bf9cfcc 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1306,6 +1306,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 @@ -1364,25 +1421,15 @@ pub fn insertLines(self: *Terminal, count: usize) void { const src: *Row = src_rac.row; const dst: *Row = dst_rac.row; - // 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 (self.scrolling_region.right == self.cols - 1) { - const src_end_cell: *Cell = @ptrCast(@as([*]Cell, @ptrCast(src_rac.cell)) + p.page.data.size.cols - 1); - const dst_end_cell: *Cell = @ptrCast(@as([*]Cell, @ptrCast(dst_rac.cell)) + dst_p.page.data.size.cols - 1); - if (dst_end_cell.wide == .spacer_head) { - dst_end_cell.wide = .narrow; - } - if (src_end_cell.wide == .spacer_head) { - src_end_cell.wide = .narrow; - } + 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 (self.scrolling_region.left == 0) { - dst.wrap = false; - src.wrap = false; - } + // 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 @@ -1516,25 +1563,15 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { const src: *Row = src_rac.row; const dst: *Row = dst_rac.row; - // 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 (self.scrolling_region.right == self.cols - 1) { - const src_end_cell: *Cell = @ptrCast(@as([*]Cell, @ptrCast(src_rac.cell)) + src_p.page.data.size.cols - 1); - const dst_end_cell: *Cell = @ptrCast(@as([*]Cell, @ptrCast(dst_rac.cell)) + p.page.data.size.cols - 1); - if (dst_end_cell.wide == .spacer_head) { - dst_end_cell.wide = .narrow; - } - if (src_end_cell.wide == .spacer_head) { - src_end_cell.wide = .narrow; - } + 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 (self.scrolling_region.left == 0) { - dst.wrap = false; - src.wrap = false; - } + // 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) { @@ -2399,6 +2436,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 @@ -6133,6 +6175,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 }); From 5b509f929595f80f45a65123053b387d43ce7756 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 29 Mar 2024 16:47:53 -0400 Subject: [PATCH 15/26] test(terminal/Screen): clearRows with protected cells --- src/terminal/Screen.zig | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 4467db866..96c84ce9b 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2219,6 +2219,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. @@ -2235,6 +2236,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; @@ -2249,6 +2251,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 @@ -2257,6 +2260,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .content_tag = .codepoint, .content = .{ .codepoint = 0 }, .wide = .spacer_tail, + .protected = self.cursor.protected, }; }, @@ -2537,6 +2541,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; From e8c8c3ca7460c0e6c6450340097de1e80fae34db Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Sat, 30 Mar 2024 02:18:09 -0700 Subject: [PATCH 16/26] nix: update nixpkgs-zig-0-12 (security, ff to staging-next) This fast-forwards the nixpkgs-zig-0-12 flake input to follow staging-next instead of nixos-unstable, in response to CVE-2024-3094. Nixpkgs PR: https://github.com/NixOS/nixpkgs/pull/300028 --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index e00216332..a2db820f3 100644 --- a/flake.lock +++ b/flake.lock @@ -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": { From 55b611e4cb86dfa12b2af4f51bbdf411f9e956cd Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sat, 30 Mar 2024 20:24:43 -0700 Subject: [PATCH 17/26] os/homedir: use NSFileManager on macOS NSFileManager has offered a homeDirectoryForCurrentUser property since macOS 10.12. Using that is preferable to running a directory service child process. --- src/os/homedir.zig | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/src/os/homedir.zig b/src/os/homedir.zig index 54409da4a..2d690da9f 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -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,31 +32,25 @@ 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()); From 29a5b52885981a84f1dd67daeb07b191cc41d4c4 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 31 Mar 2024 08:53:49 -0700 Subject: [PATCH 18/26] os/homedir: remove now unnecessary fba.reset() --- src/os/homedir.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/os/homedir.zig b/src/os/homedir.zig index 2d690da9f..567a96ccd 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -52,7 +52,6 @@ fn homeUnix(buf: []u8) !?[]u8 { var fba = std.heap.FixedBufferAllocator.init(&tempBuf); // 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; From 5dee7e1430c186ba72d1fa8610a9711601b86b61 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 31 Mar 2024 19:29:56 -0400 Subject: [PATCH 19/26] terminal/kitty_graphics: update outdated comments --- src/terminal/kitty/graphics_command.zig | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index ca7a4d674..9f321c447 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -27,8 +27,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 +45,7 @@ pub const CommandParser = struct { control_value, control_value_ignore, - /// We're parsing the data blob. + /// Collecting the data payload blob. data, }; @@ -106,9 +109,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 From ca4b55b486779c7523d1478a93de953b110b90ec Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 31 Mar 2024 21:09:37 -0400 Subject: [PATCH 20/26] terminal/kitty_graphics: ignore base64 padding Also move all base64 decoding to inside of the command parser. --- src/terminal/kitty/graphics_command.zig | 41 ++++++++++++++++-- src/terminal/kitty/graphics_image.zig | 56 ++++++------------------- 2 files changed, 51 insertions(+), 46 deletions(-) diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index 9f321c447..fe8149d9a 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -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. @@ -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; diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index 4f3e3e48f..c661a8a24 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -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. From 51d3c2cf351cbf378269f89561f3d412d90a387c Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 31 Mar 2024 21:49:28 -0400 Subject: [PATCH 21/26] fix(kitty_graphics): set dirty state on various scroll operations --- src/terminal/Screen.zig | 3 +++ src/terminal/Terminal.zig | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 96c84ce9b..a295f0ce5 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -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 diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 70bf9cfcc..d002b47ec 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -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 @@ -1391,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; @@ -1534,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. From 04ec8599251db4dcb23b3e5070d60a378cbdb4fd Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 31 Mar 2024 22:28:53 -0400 Subject: [PATCH 22/26] terminal/kitty_graphics: update tests Kitty Graphics command structures have been changed to hold decoded payloads not base64 strings. --- src/terminal/kitty/graphics_command.zig | 2 +- src/terminal/kitty/graphics_image.zig | 43 ++++-------------- .../image-rgb-none-20x15-2147483647-raw.data | Bin 0 -> 900 bytes .../image-rgb-none-20x15-2147483647.data | 1 - ...gb-zlib_deflate-128x96-2147483647-raw.data | Bin 0 -> 30455 bytes ...ge-rgb-zlib_deflate-128x96-2147483647.data | 1 - 6 files changed, 10 insertions(+), 37 deletions(-) create mode 100644 src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647-raw.data delete mode 100644 src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647.data create mode 100644 src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data delete mode 100644 src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index fe8149d9a..b8d0b0b7d 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -890,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); diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index c661a8a24..09e9376e2 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -427,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" { @@ -518,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); @@ -546,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); @@ -563,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 = .{ @@ -600,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 = .{ @@ -638,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; @@ -657,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); @@ -676,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; @@ -695,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); @@ -727,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); diff --git a/src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647-raw.data b/src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647-raw.data new file mode 100644 index 0000000000000000000000000000000000000000..2c070cb2a1a30ef256c40d5909376e1d049a40a6 GIT binary patch literal 900 zcmV-~1AF`p8XgN6919mC5*{iH7$gf9BMKHG3l}2^7bOJ~AqNr{A1g0SQ&DVkaB*;U zE-f<%78esCA`2NB3>q5>7aIr_84nvL1P~k$94HDFA`2HI3>YL194ZGDBMcWDo2IJE z(a+S{*v`$=u&=fvFfj-g7YG&?2Nf0+8X6B36(A-mBPlHr7a0!~7Y`K`5*Qc`6%z{* z5E>sBy1=;1&c(dGwX3J4nU#}GTU-np8x0#A3mFbj6 z1P~I$z`?e%v#O@2p`D(Mhl_Y|c6o7chJJjWmyflsrk9Y4fqHaXSWjtYU1eudVq{Mi zCIAf`02Cwu6C(h(v$T|uk$-!9cyx7UUu0ukVPRTWY-D12a%y;SX?1OAb8BXBZDe0u zQ*CcrjgNYco_Ud>e2}1fk)eFBuB&o!ZC_nnTv}OMT2@$BQ(an9URzXXVqjxkTVq{a zYh`6?Ze40^TyT3+o~ePNwQZ-ma-_F!p|os=hJt8kV^~&ITv}OSU|eTrVrXSvWMNuk zU0Y&YT31m~XJKJU5NjG$YZsB4+0 zYLK2`i(7m_1r*Um;URqg) aj$)~>gqW#inW|=*scN3BZk?=cps#SQ#E#qm literal 0 HcmV?d00001 diff --git a/src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647.data b/src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647.data deleted file mode 100644 index f65d40ce8..000000000 --- a/src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647.data +++ /dev/null @@ -1 +0,0 @@ -DRoeCxgcCxcjEh4qDBgkCxcjChYiCxcjCRclBRMhBxIXHysvTVNRbHJwcXB2Li0zCBYXEyEiCxkaDBobChcbCBUZDxsnBBAcEBwoChYiCxcjDBgkDhwqBxUjDBccm6aqy9HP1NrYzs3UsK+2IjAxCBYXCBYXBxUWFBoaDxUVICYqIyktERcZDxUXDxUVEhgYDhUTCxIQGh8XusC4zM7FvL61q6elmZWTTVtcDBobDRscCxkaKS8vaW9vxMnOur/EiY+RaW5wICYmW2FhfYOBQEZEnqSc4ebeqauilZaOsa2rm5eVcH5/GigpChgZCBYX0NHP3d7c3tzbx8XExsTEvry8wL241dLN0tDF0tDF29nM4d/StbKpzMrAUk5DZmJXeYSGKTU3ER0fDRkb1tfVysvJ0tDPsa+tr6ytop+gmZaRqaahuritw8G2urirqKaZiYZ9paKZZmJXamZbOkZIDhocBxMVBBASxMDBtrKzqqanoZ2ejYeLeHF2eXFvhn58npePta6ml5CKgXp0W1hPaWZdZWdSYmRPFiYADR0AFCQAEyMAt7O0lJCRf3t8eHR1Zl9kY1xhYVpYbGRieXJqeHFpdW1oc2tmcG1kX1xTbW9ajY96jp55kaF8kKB7kaF8sK6rcnFtX11cXFpZW1pWWFdTXVpTXltUaGJgY11bY11da2Vla25dam1ccHtTnqmBorVtp7pypLdvobRsh4aCaGdjWFZVXFpZYWBcZ2ZiaGVeZGFaY11bYlxaV1FRZ2FhdHdmbG9egItjo66GpLdvq752rL93rsF5kpKIZ2ddWFxTW19WbnZdipJ6cnhaaW9RaGhgV1ZPY2Jga2poanFQd35dk6Vpn7B0oLFvorNxm6xqmKlnv760enpwVlpRW19Wc3til5+Hl55/k5p7iIiAcnJqd3Z0bm1rcHdWh45tipxgladrkaJglKVjkaJgkqNh09DJiYZ/YmZdY2deeYZYjJlrj51ijpxhztHClJaIdHNvdHNvanNHi5RpmaxnjKBbmqhrmadqkJ5hi5lcxsO8jImCaGtiYmZdg5Bikp9xjJpfjpxh1djJqq2eamllZ2Zid4BVmKF2kqZhh5tWlaNmlaNmjpxfjJpdw729rqiodnZ0cHBuiplij55nj6FVjJ5SzdC9t7qncW1sXlpZh45iqbCEmKllmapmmqlqnq1unaxtoK9w \ No newline at end of file diff --git a/src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data b/src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data new file mode 100644 index 0000000000000000000000000000000000000000..ea816083afba88810f195212d887aca26036e0f7 GIT binary patch literal 30455 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 y6J!6tUhayUF=e Date: Mon, 1 Apr 2024 11:45:06 -0700 Subject: [PATCH 23/26] fix(build): solve issue with building tests on certain apple cpus. --- build.zig | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/build.zig b/build.zig index 0b225e6d8..28b5c11ef 100644 --- a/build.zig +++ b/build.zig @@ -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; From 5abc63193ec515b73d7150c0f37f05c4da29f4b4 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 1 Apr 2024 18:35:34 -0400 Subject: [PATCH 24/26] font/sprite: improve rendering of dashed lines Previous implementation would draw dashes to the edge of the character cell, which would result in double-wide dashes at the point where they tiled. This fixes that, and also generally implements it in a cleaner way than before. --- src/font/sprite/Box.zig | 234 +++++++++++++++++++++------------------- 1 file changed, 121 insertions(+), 113 deletions(-) diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig index a38c4b76c..03237c68d 100644 --- a/src/font/sprite/Box.zig +++ b/src/font/sprite/Box.zig @@ -2425,145 +2425,153 @@ fn draw_light_arc( fn draw_dash_horizontal( self: Box, canvas: *font.sprite.Canvas, - count: u8, + comptime count: u8, thick_px: 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 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; + + inline 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 { From 555f6e159ff69c862c48057785ba718e658e2d6d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 2 Apr 2024 08:38:36 -0700 Subject: [PATCH 25/26] font/sprite: remove comptime arg for box drawing --- src/font/sprite/Box.zig | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig index 03237c68d..ae991f4b5 100644 --- a/src/font/sprite/Box.zig +++ b/src/font/sprite/Box.zig @@ -2425,7 +2425,7 @@ fn draw_light_arc( fn draw_dash_horizontal( self: Box, canvas: *font.sprite.Canvas, - comptime count: u8, + count: u8, thick_px: u32, desired_gap: u32, ) void { @@ -2461,11 +2461,11 @@ fn draw_dash_horizontal( // 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 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; + const dash_width = total_dash_width / count; + const remaining = total_dash_width % count; assert(dash_width * count + gap_width * gap_count + remaining == self.width); @@ -2481,7 +2481,7 @@ fn draw_dash_horizontal( // more visually obvious. var extra: u32 = remaining; - inline for (0..count) |_| { + 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. @@ -2540,11 +2540,11 @@ fn draw_dash_vertical( // 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 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; + const dash_height = total_dash_height / count; + const remaining = total_dash_height % count; assert(dash_height * count + gap_height * gap_count + remaining == self.height); From eb2a2e39317529d41055a952bee7c462fa2dc6e5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 2 Apr 2024 08:38:51 -0700 Subject: [PATCH 26/26] fmt --- src/fastmem.zig | 2 +- src/main_ghostty.zig | 2 +- src/terminal/Terminal.zig | 10 +++++----- src/terminal/page.zig | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/fastmem.zig b/src/fastmem.zig index 8f32bc3c8..53c9e1122 100644 --- a/src/fastmem.zig +++ b/src/fastmem.zig @@ -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; } diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 32458ada9..17c521d1e 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -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. - \\ + \\ , .{}, ); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d002b47ec..e29a4003d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1331,8 +1331,8 @@ fn rowWillBeShifted( // 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 - ) { + self.scrolling_region.left < 2) + { const end_cell: *Cell = &cells[page.size.cols - 1]; if (end_cell.wide == .spacer_head) { end_cell.wide = .narrow; @@ -6318,7 +6318,7 @@ test "Terminal: deleteLines wide character spacer head left and right scroll mar try t.printString("AAAAABBBB\u{1F600}CCC"); t.scrolling_region.right = 3; - t.scrolling_region.left = 2; + t.scrolling_region.left = 2; // Delete the top line // ## <- scrolling region @@ -6360,7 +6360,7 @@ test "Terminal: deleteLines wide character spacer head left (< 2) and right scro try t.printString("AAAAABBBB\u{1F600}CCC"); t.scrolling_region.right = 3; - t.scrolling_region.left = 1; + t.scrolling_region.left = 1; // Delete the top line // ### <- scrolling region @@ -6400,7 +6400,7 @@ test "Terminal: deleteLines wide characters split by left/right scroll region bo try t.printString("AAAAA\n\u{1F600}B\u{1F600}"); t.scrolling_region.right = 3; - t.scrolling_region.left = 1; + t.scrolling_region.left = 1; // Delete the top line // ### <- scrolling region diff --git a/src/terminal/page.zig b/src/terminal/page.zig index d100acc89..e70695213 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -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)); }