From 06515863392b88a02b912b2aca27f03a01c10752 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:19:55 +0800 Subject: [PATCH 01/27] Reduce ghost emoji flash in title bar --- .../Sources/Features/Terminal/TerminalView.swift | 15 +++++---------- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 15b504875..d72200ef8 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -10,7 +10,7 @@ protocol TerminalViewDelegate: AnyObject { /// The title of the terminal should change. func titleDidChange(to: String) - + /// The URL of the pwd should change. func pwdDidChange(to: URL?) @@ -56,15 +56,10 @@ struct TerminalView: View { // The title for our window private var title: String { - var title = "👻" - - if let surfaceTitle = surfaceTitle { - if (surfaceTitle.count > 0) { - title = surfaceTitle - } + if let surfaceTitle = surfaceTitle, !surfaceTitle.isEmpty { + return surfaceTitle } - - return title + return "👻" } // The pwd of the focused surface as a URL @@ -72,7 +67,7 @@ struct TerminalView: View { guard let surfacePwd, surfacePwd != "" else { return nil } return URL(fileURLWithPath: surfacePwd) } - + var body: some View { switch ghostty.readiness { case .loading: diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index c933eb9bf..8b0fe4352 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -12,7 +12,7 @@ extension Ghostty { // The current title of the surface as defined by the pty. This can be // changed with escape codes. This is public because the callbacks go // to the app level and it is set from there. - @Published private(set) var title: String = "👻" + @Published private(set) var title: String = "" // The current pwd of the surface as defined by the pty. This can be // changed with escape codes. From ea7c54d79daa5a4a067c9588202aca0b7954c84b Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Wed, 8 Jan 2025 21:01:01 +0800 Subject: [PATCH 02/27] Simplify let binding in `TerminalView` title logic --- macos/Sources/Features/Terminal/TerminalView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index d72200ef8..3d4165e91 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -56,7 +56,7 @@ struct TerminalView: View { // The title for our window private var title: String { - if let surfaceTitle = surfaceTitle, !surfaceTitle.isEmpty { + if let surfaceTitle, !surfaceTitle.isEmpty { return surfaceTitle } return "👻" From 5bfb3925baf895f78c5b3233dbe9251bd1839700 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Thu, 9 Jan 2025 06:24:31 +0800 Subject: [PATCH 03/27] Add fallback timer for empty window title --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8b0fe4352..5c4d819e1 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -12,7 +12,14 @@ extension Ghostty { // The current title of the surface as defined by the pty. This can be // changed with escape codes. This is public because the callbacks go // to the app level and it is set from there. - @Published private(set) var title: String = "" + @Published private(set) var title: String = "" { + didSet { + if !title.isEmpty { + titleFallbackTimer?.invalidate() + titleFallbackTimer = nil + } + } + } // The current pwd of the surface as defined by the pty. This can be // changed with escape codes. @@ -113,6 +120,9 @@ extension Ghostty { // A small delay that is introduced before a title change to avoid flickers private var titleChangeTimer: Timer? + // A timer to fallback to ghost emoji if no title is set within the grace period + private var titleFallbackTimer: Timer? + /// Event monitor (see individual events for why) private var eventMonitor: Any? = nil @@ -139,6 +149,13 @@ extension Ghostty { // can do SOMETHING. super.init(frame: NSMakeRect(0, 0, 800, 600)) + // Set a timer to show the ghost emoji after 500ms if no title is set + titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + if let self = self, self.title.isEmpty { + self.title = "👻" + } + } + // Before we initialize the surface we want to register our notifications // so there is no window where we can't receive them. let center = NotificationCenter.default From 03fee2ac33bfcad282e1dafee6808bb2d1395c7e Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 2 Jan 2025 23:53:22 +0800 Subject: [PATCH 04/27] gtk: unify Wayland and X11 platforms --- src/apprt/gtk/App.zig | 53 +------- src/apprt/gtk/Surface.zig | 11 +- src/apprt/gtk/Window.zig | 22 ++-- src/apprt/gtk/key.zig | 26 +--- src/apprt/gtk/protocol.zig | 149 +++++++++++++++++++++++ src/apprt/gtk/{ => protocol}/wayland.zig | 86 ++++++------- src/apprt/gtk/{ => protocol}/x11.zig | 128 ++++++++++++------- src/config/Config.zig | 8 ++ 8 files changed, 304 insertions(+), 179 deletions(-) create mode 100644 src/apprt/gtk/protocol.zig rename src/apprt/gtk/{ => protocol}/wayland.zig (58%) rename src/apprt/gtk/{ => protocol}/x11.zig (50%) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index fa5eb7b9f..ecbe61bce 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -36,8 +36,7 @@ const c = @import("c.zig").c; const version = @import("version.zig"); const inspector = @import("inspector.zig"); const key = @import("key.zig"); -const x11 = @import("x11.zig"); -const wayland = @import("wayland.zig"); +const protocol = @import("protocol.zig"); const testing = std.testing; const log = std.log.scoped(.gtk); @@ -71,11 +70,7 @@ clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null, /// This is set to false when the main loop should exit. running: bool = true, -/// Xkb state (X11 only). Will be null on Wayland. -x11_xkb: ?x11.Xkb = null, - -/// Wayland app state. Will be null on X11. -wayland: ?wayland.AppState = null, +protocol: protocol.App, /// The base path of the transient cgroup used to put all surfaces /// into their own cgroup. This is only set if cgroups are enabled @@ -364,46 +359,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { return error.GtkApplicationRegisterFailed; } - // Perform all X11 initialization. This ultimately returns the X11 - // keyboard state but the block does more than that (i.e. setting up - // WM_CLASS). - const x11_xkb: ?x11.Xkb = x11_xkb: { - if (comptime !build_options.x11) break :x11_xkb null; - if (!x11.is_display(display)) break :x11_xkb null; - - // Set the X11 window class property (WM_CLASS) if are are on an X11 - // display. - // - // Note that we also set the program name here using g_set_prgname. - // This is how the instance name field for WM_CLASS is derived when - // calling gdk_x11_display_set_program_class; there does not seem to be - // a way to set it directly. It does not look like this is being set by - // our other app initialization routines currently, but since we're - // currently deriving its value from x11-instance-name effectively, I - // feel like gating it behind an X11 check is better intent. - // - // This makes the property show up like so when using xprop: - // - // WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty" - // - // Append "-debug" on both when using the debug build. - // - const prgname = if (config.@"x11-instance-name") |pn| - pn - else if (builtin.mode == .Debug) - "ghostty-debug" - else - "ghostty"; - c.g_set_prgname(prgname); - c.gdk_x11_display_set_program_class(display, app_id); - - // Set up Xkb - break :x11_xkb try x11.Xkb.init(display); - }; - - // Initialize Wayland state - var wl = wayland.AppState.init(display); - if (wl) |*w| try w.register(); + const app_protocol = try protocol.App.init(display, &config, app_id); // This just calls the `activate` signal but its part of the normal startup // routine so we just call it, but only if the config allows it (this allows @@ -429,8 +385,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { .config = config, .ctx = ctx, .cursor_none = cursor_none, - .x11_xkb = x11_xkb, - .wayland = wl, + .protocol = app_protocol, .single_instance = single_instance, // If we are NOT the primary instance, then we never want to run. // This means that another instance of the GTK app is running and diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 35932ac5e..97b7bb0f4 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -25,7 +25,6 @@ const ResizeOverlay = @import("ResizeOverlay.zig"); const inspector = @import("inspector.zig"); const gtk_key = @import("key.zig"); const c = @import("c.zig").c; -const x11 = @import("x11.zig"); const log = std.log.scoped(.gtk_surface); @@ -825,9 +824,6 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale { c.g_object_get_property(@ptrCast(@alignCast(settings)), "gtk-xft-dpi", &value); const gtk_xft_dpi = c.g_value_get_int(&value); - // As noted above gtk-xft-dpi is multiplied by 1024, so we divide by - // 1024, then divide by the default value (96) to derive a scale. Note - // gtk-xft-dpi can be fractional, so we use floating point math here. const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024; break :xft_scale xft_dpi / 96; }; @@ -1384,6 +1380,10 @@ fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque) return; }; + if (self.container.window()) |window| window.protocol.onResize() catch |err| { + log.warn("failed to notify X11/Wayland integration of resize={}", .{err}); + }; + self.resize_overlay.maybeShow(); } } @@ -1699,11 +1699,10 @@ pub fn keyEvent( // Get our modifier for the event const mods: input.Mods = gtk_key.eventMods( - @ptrCast(self.gl_area), event, physical_key, gtk_mods, - if (self.app.x11_xkb) |*xkb| xkb else null, + &self.app.protocol, ); // Get our consumed modifiers diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 0f44cee7b..fbba22195 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -25,7 +25,7 @@ const gtk_key = @import("key.zig"); const Notebook = @import("notebook.zig").Notebook; const HeaderBar = @import("headerbar.zig").HeaderBar; const version = @import("version.zig"); -const wayland = @import("wayland.zig"); +const protocol = @import("protocol.zig"); const log = std.log.scoped(.gtk); @@ -56,7 +56,7 @@ toast_overlay: ?*c.GtkWidget, /// See adwTabOverviewOpen for why we have this. adw_tab_overview_focus_timer: ?c.guint = null, -wayland: ?wayland.SurfaceState, +protocol: protocol.Surface, pub fn create(alloc: Allocator, app: *App) !*Window { // Allocate a fixed pointer for our window. We try to minimize @@ -82,7 +82,7 @@ pub fn init(self: *Window, app: *App) !void { .notebook = undefined, .context_menu = undefined, .toast_overlay = undefined, - .wayland = null, + .protocol = undefined, }; // Create the window @@ -396,14 +396,8 @@ pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void { c.gtk_widget_add_css_class(@ptrCast(self.window), "background"); } - if (self.wayland) |*wl| { - const blurred = switch (config.@"background-blur-radius") { - .false => false, - .true => true, - .radius => |v| v > 0, - }; - try wl.setBlur(blurred); - } + // Perform protocol-specific config updates + try self.protocol.onConfigUpdate(config); } /// Sets up the GTK actions for the window scope. Actions are how GTK handles @@ -443,7 +437,7 @@ fn initActions(self: *Window) void { pub fn deinit(self: *Window) void { c.gtk_widget_unparent(@ptrCast(self.context_menu)); - if (self.wayland) |*wl| wl.deinit(); + self.protocol.deinit(); if (self.adw_tab_overview_focus_timer) |timer| { _ = c.g_source_remove(timer); @@ -584,9 +578,7 @@ pub fn sendToast(self: *Window, title: [:0]const u8) void { fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { const self = userdataSelf(ud.?); - if (self.app.wayland) |*wl| { - self.wayland = wayland.SurfaceState.init(v, wl); - } + self.protocol.init(v, &self.app.protocol, &self.app.config); self.syncAppearance(&self.app.config) catch |err| { log.err("failed to initialize appearance={}", .{err}); diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 311bff0da..ef460e62c 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -2,7 +2,7 @@ const std = @import("std"); const build_options = @import("build_options"); const input = @import("../../input.zig"); const c = @import("c.zig").c; -const x11 = @import("x11.zig"); +const protocol = @import("protocol.zig"); /// Returns a GTK accelerator string from a trigger. pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { @@ -105,34 +105,14 @@ pub fn keyvalUnicodeUnshifted( /// This requires a lot of context because the GdkEvent /// doesn't contain enough on its own. pub fn eventMods( - widget: *c.GtkWidget, event: *c.GdkEvent, physical_key: input.Key, gtk_mods: c.GdkModifierType, - x11_xkb: ?*x11.Xkb, + app_protocol: *protocol.App, ) input.Mods { const device = c.gdk_event_get_device(event); - var mods = mods: { - // Add any modifier state events from Xkb if we have them (X11 - // only). Null back from the Xkb call means there was no modifier - // event to read. This likely means that the key event did not - // result in a modifier change and we can safely rely on the GDK - // state. - if (comptime build_options.x11) { - const display = c.gtk_widget_get_display(widget); - if (x11_xkb) |xkb| { - if (xkb.modifier_state_from_notify(display)) |x11_mods| break :mods x11_mods; - break :mods translateMods(gtk_mods); - } - } - - // On Wayland, we have to use the GDK device because the mods sent - // to this event do not have the modifier key applied if it was - // pressed (i.e. left control). - break :mods translateMods(c.gdk_device_get_modifier_state(device)); - }; - + var mods = app_protocol.eventMods(device, gtk_mods); mods.num_lock = c.gdk_device_get_num_lock_state(device) == 1; switch (physical_key) { diff --git a/src/apprt/gtk/protocol.zig b/src/apprt/gtk/protocol.zig new file mode 100644 index 000000000..c7d7247cf --- /dev/null +++ b/src/apprt/gtk/protocol.zig @@ -0,0 +1,149 @@ +const std = @import("std"); +const x11 = @import("protocol/x11.zig"); +const wayland = @import("protocol/wayland.zig"); +const c = @import("c.zig").c; +const build_options = @import("build_options"); +const input = @import("../../input.zig"); +const apprt = @import("../../apprt.zig"); +const Config = @import("../../config.zig").Config; +const adwaita = @import("adwaita.zig"); +const builtin = @import("builtin"); +const key = @import("key.zig"); + +const log = std.log.scoped(.gtk_platform); + +pub const App = struct { + gdk_display: *c.GdkDisplay, + derived_config: DerivedConfig, + + inner: union(enum) { + none, + x11: if (build_options.x11) x11.App else void, + wayland: if (build_options.wayland) wayland.App else void, + }, + + const DerivedConfig = struct { + app_id: [:0]const u8, + x11_program_name: [:0]const u8, + + pub fn init(config: *const Config, app_id: [:0]const u8) DerivedConfig { + return .{ + .app_id = app_id, + .x11_program_name = if (config.@"x11-instance-name") |pn| + pn + else if (builtin.mode == .Debug) + "ghostty-debug" + else + "ghostty", + }; + } + }; + + pub fn init(display: ?*c.GdkDisplay, config: *const Config, app_id: [:0]const u8) !App { + var self: App = .{ + .inner = .none, + .derived_config = DerivedConfig.init(config, app_id), + .gdk_display = display orelse { + // TODO: When does this ever happen...? + std.debug.panic("GDK display is null!", .{}); + }, + }; + + // The X11/Wayland init functions set `self.inner` when successful, + // so we only need to keep trying if `self.inner` stays `.none` + if (self.inner == .none and comptime build_options.wayland) try wayland.App.init(&self); + if (self.inner == .none and comptime build_options.x11) try x11.App.init(&self); + + // Welp, no integration for you + if (self.inner == .none) { + log.warn( + "neither X11 nor Wayland integrations enabled - lots of features would be missing!", + .{}, + ); + } + + return self; + } + + pub fn eventMods(self: *App, device: ?*c.GdkDevice, gtk_mods: c.GdkModifierType) input.Mods { + return switch (self.inner) { + // Add any modifier state events from Xkb if we have them (X11 + // only). Null back from the Xkb call means there was no modifier + // event to read. This likely means that the key event did not + // result in a modifier change and we can safely rely on the GDK + // state. + .x11 => |*x| if (comptime build_options.x11) + x.modifierStateFromNotify() orelse key.translateMods(gtk_mods) + else + unreachable, + + // On Wayland, we have to use the GDK device because the mods sent + // to this event do not have the modifier key applied if it was + // pressed (i.e. left control). + .wayland, .none => key.translateMods(c.gdk_device_get_modifier_state(device)), + }; + } +}; + +pub const Surface = struct { + app: *App, + gtk_window: *c.GtkWindow, + derived_config: DerivedConfig, + + inner: union(enum) { + none, + x11: if (build_options.x11) x11.Surface else void, + wayland: if (build_options.wayland) wayland.Surface else void, + }, + + pub const DerivedConfig = struct { + blur: Config.BackgroundBlur, + adw_enabled: bool, + + pub fn init(config: *const Config) DerivedConfig { + return .{ + .blur = config.@"background-blur-radius", + .adw_enabled = adwaita.enabled(config), + }; + } + }; + + pub fn init(self: *Surface, window: *c.GtkWindow, app: *App, config: *const Config) void { + self.* = .{ + .app = app, + .derived_config = DerivedConfig.init(config), + .gtk_window = window, + .inner = .none, + }; + + switch (app.inner) { + .x11 => if (comptime build_options.x11) x11.Surface.init(self) else unreachable, + .wayland => if (comptime build_options.wayland) wayland.Surface.init(self) else unreachable, + .none => {}, + } + } + + pub fn deinit(self: Surface) void { + switch (self.inner) { + .wayland => |wl| if (comptime build_options.wayland) wl.deinit() else unreachable, + .x11, .none => {}, + } + } + + pub fn onConfigUpdate(self: *Surface, config: *const Config) !void { + self.derived_config = DerivedConfig.init(config); + + switch (self.inner) { + .x11 => |*x| if (comptime build_options.x11) try x.onConfigUpdate() else unreachable, + .wayland => |*wl| if (comptime build_options.wayland) try wl.onConfigUpdate() else unreachable, + .none => {}, + } + } + + pub fn onResize(self: *Surface) !void { + switch (self.inner) { + .x11 => |*x| if (comptime build_options.x11) try x.onResize() else unreachable, + .wayland, .none => {}, + } + } +}; diff --git a/src/apprt/gtk/wayland.zig b/src/apprt/gtk/protocol/wayland.zig similarity index 58% rename from src/apprt/gtk/wayland.zig rename to src/apprt/gtk/protocol/wayland.zig index 92446cc46..985d7c5a8 100644 --- a/src/apprt/gtk/wayland.zig +++ b/src/apprt/gtk/protocol/wayland.zig @@ -1,106 +1,106 @@ const std = @import("std"); -const c = @import("c.zig").c; +const c = @import("../c.zig").c; const wayland = @import("wayland"); +const protocol = @import("../protocol.zig"); +const Config = @import("../../../config.zig").Config; + const wl = wayland.client.wl; const org = wayland.client.org; -const build_options = @import("build_options"); const log = std.log.scoped(.gtk_wayland); /// Wayland state that contains application-wide Wayland objects (e.g. wl_display). -pub const AppState = struct { +pub const App = struct { display: *wl.Display, blur_manager: ?*org.KdeKwinBlurManager = null, - pub fn init(display: ?*c.GdkDisplay) ?AppState { - if (comptime !build_options.wayland) return null; - - // It should really never be null - const display_ = display orelse return null; - + pub fn init(common: *protocol.App) !void { // Check if we're actually on Wayland if (c.g_type_check_instance_is_a( - @ptrCast(@alignCast(display_)), + @ptrCast(@alignCast(common.gdk_display)), c.gdk_wayland_display_get_type(), ) == 0) - return null; + return; - const wl_display: *wl.Display = @ptrCast(c.gdk_wayland_display_get_wl_display(display_) orelse return null); - - return .{ - .display = wl_display, + var self: App = .{ + .display = @ptrCast(c.gdk_wayland_display_get_wl_display(common.gdk_display) orelse return), }; - } - pub fn register(self: *AppState) !void { + log.debug("wayland platform init={}", .{self}); + const registry = try self.display.getRegistry(); - registry.setListener(*AppState, registryListener, self); + registry.setListener(*App, registryListener, &self); if (self.display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - log.debug("app wayland init={}", .{self}); + common.inner = .{ .wayland = self }; } }; /// Wayland state that contains Wayland objects associated with a window (e.g. wl_surface). -pub const SurfaceState = struct { - app_state: *AppState, +pub const Surface = struct { + common: *const protocol.Surface, + app: *App, surface: *wl.Surface, /// A token that, when present, indicates that the window is blurred. blur_token: ?*org.KdeKwinBlur = null, - pub fn init(window: *c.GtkWindow, app_state: *AppState) ?SurfaceState { - if (comptime !build_options.wayland) return null; - - const surface = c.gtk_native_get_surface(@ptrCast(window)) orelse return null; + pub fn init(common: *protocol.Surface) void { + const surface = c.gtk_native_get_surface(@ptrCast(common.gtk_window)) orelse return; // Check if we're actually on Wayland if (c.g_type_check_instance_is_a( @ptrCast(@alignCast(surface)), c.gdk_wayland_surface_get_type(), ) == 0) - return null; + return; - const wl_surface: *wl.Surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface(surface) orelse return null); - - return .{ - .app_state = app_state, - .surface = wl_surface, + const self: Surface = .{ + .common = common, + .app = &common.app.inner.wayland, + .surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface(surface) orelse return), }; + + common.inner = .{ .wayland = self }; } - pub fn deinit(self: *SurfaceState) void { + pub fn deinit(self: Surface) void { if (self.blur_token) |blur| blur.release(); } - pub fn setBlur(self: *SurfaceState, blurred: bool) !void { - log.debug("setting blur={}", .{blurred}); + pub fn onConfigUpdate(self: *Surface) !void { + try self.updateBlur(); + } - const mgr = self.app_state.blur_manager orelse { + fn updateBlur(self: *Surface) !void { + const blur = self.common.derived_config.blur; + log.debug("setting blur={}", .{blur}); + + const mgr = self.app.blur_manager orelse { log.warn("can't set blur: org_kde_kwin_blur_manager protocol unavailable", .{}); return; }; - if (self.blur_token) |blur| { + if (self.blur_token) |tok| { // Only release token when transitioning from blurred -> not blurred - if (!blurred) { + if (!blur.enabled()) { mgr.unset(self.surface); - blur.release(); + tok.release(); self.blur_token = null; } } else { // Only acquire token when transitioning from not blurred -> blurred - if (blurred) { - const blur_token = try mgr.create(self.surface); - blur_token.commit(); - self.blur_token = blur_token; + if (blur.enabled()) { + const tok = try mgr.create(self.surface); + tok.commit(); + self.blur_token = tok; } } } }; -fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, state: *AppState) void { +fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, state: *App) void { switch (event) { .global => |global| { log.debug("got global interface={s}", .{global.interface}); diff --git a/src/apprt/gtk/x11.zig b/src/apprt/gtk/protocol/x11.zig similarity index 50% rename from src/apprt/gtk/x11.zig rename to src/apprt/gtk/protocol/x11.zig index 21ff87b34..be991bcfe 100644 --- a/src/apprt/gtk/x11.zig +++ b/src/apprt/gtk/protocol/x11.zig @@ -1,57 +1,69 @@ /// Utility functions for X11 handling. const std = @import("std"); const build_options = @import("build_options"); -const c = @import("c.zig").c; -const input = @import("../../input.zig"); +const c = @import("../c.zig").c; +const input = @import("../../../input.zig"); +const Config = @import("../../../config.zig").Config; +const protocol = @import("../protocol.zig"); +const adwaita = @import("../adwaita.zig"); const log = std.log.scoped(.gtk_x11); -/// Returns true if the passed in display is an X11 display. -pub fn is_display(display: ?*c.GdkDisplay) bool { - if (comptime !build_options.x11) return false; - return c.g_type_check_instance_is_a( - @ptrCast(@alignCast(display orelse return false)), - c.gdk_x11_display_get_type(), - ) != 0; -} +pub const App = struct { + common: *protocol.App, + display: *c.Display, -/// Returns true if the app is running on X11 -pub fn is_current_display_server() bool { - if (comptime !build_options.x11) return false; - const display = c.gdk_display_get_default(); - return is_display(display); -} - -pub const Xkb = struct { - base_event_code: c_int, + base_event_code: c_int = 0, /// Initialize an Xkb struct for the given GDK display. If the display isn't /// backed by X then this will return null. - pub fn init(display_: ?*c.GdkDisplay) !?Xkb { - if (comptime !build_options.x11) return null; - - // Display should never be null but we just treat that as a non-X11 - // display so that the caller can just ignore it and not unwrap it. - const display = display_ orelse return null; - + pub fn init(common: *protocol.App) !void { // If the display isn't X11, then we don't need to do anything. - if (!is_display(display)) return null; + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(common.gdk_display)), + c.gdk_x11_display_get_type(), + ) == 0) + return; - log.debug("Xkb.init: initializing Xkb", .{}); - const xdisplay = c.gdk_x11_display_get_xdisplay(display); - var result: Xkb = .{ - .base_event_code = 0, + var self: App = .{ + .common = common, + .display = c.gdk_x11_display_get_xdisplay(common.gdk_display) orelse return, }; + log.debug("X11 platform init={}", .{self}); + + // Set the X11 window class property (WM_CLASS) if are are on an X11 + // display. + // + // Note that we also set the program name here using g_set_prgname. + // This is how the instance name field for WM_CLASS is derived when + // calling gdk_x11_display_set_program_class; there does not seem to be + // a way to set it directly. It does not look like this is being set by + // our other app initialization routines currently, but since we're + // currently deriving its value from x11-instance-name effectively, I + // feel like gating it behind an X11 check is better intent. + // + // This makes the property show up like so when using xprop: + // + // WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty" + // + // Append "-debug" on both when using the debug build. + + c.g_set_prgname(common.derived_config.x11_program_name); + c.gdk_x11_display_set_program_class(common.gdk_display, common.derived_config.app_id); + + // XKB + log.debug("Xkb.init: initializing Xkb", .{}); + log.debug("Xkb.init: running XkbQueryExtension", .{}); var opcode: c_int = 0; var base_error_code: c_int = 0; var major = c.XkbMajorVersion; var minor = c.XkbMinorVersion; if (c.XkbQueryExtension( - xdisplay, + self.display, &opcode, - &result.base_event_code, + &self.base_event_code, &base_error_code, &major, &minor, @@ -62,7 +74,7 @@ pub const Xkb = struct { log.debug("Xkb.init: running XkbSelectEventDetails", .{}); if (c.XkbSelectEventDetails( - xdisplay, + self.display, c.XkbUseCoreKbd, c.XkbStateNotify, c.XkbModifierStateMask, @@ -72,7 +84,7 @@ pub const Xkb = struct { return error.XkbInitializationError; } - return result; + common.inner = .{ .x11 = self }; } /// Checks for an immediate pending XKB state update event, and returns the @@ -85,18 +97,13 @@ pub const Xkb = struct { /// Returns null if there is no event. In this case, the caller should fall /// back to the standard GDK modifier state (this likely means the key /// event did not result in a modifier change). - pub fn modifier_state_from_notify(self: Xkb, display_: ?*c.GdkDisplay) ?input.Mods { - if (comptime !build_options.x11) return null; - - const display = display_ orelse return null; - + pub fn modifierStateFromNotify(self: App) ?input.Mods { // Shoutout to Mozilla for figuring out a clean way to do this, this is // paraphrased from Firefox/Gecko in widget/gtk/nsGtkKeyUtils.cpp. - const xdisplay = c.gdk_x11_display_get_xdisplay(display); - if (c.XEventsQueued(xdisplay, c.QueuedAfterReading) == 0) return null; + if (c.XEventsQueued(self.display, c.QueuedAfterReading) == 0) return null; var nextEvent: c.XEvent = undefined; - _ = c.XPeekEvent(xdisplay, &nextEvent); + _ = c.XPeekEvent(self.display, &nextEvent); if (nextEvent.type != self.base_event_code) return null; const xkb_event: *c.XkbEvent = @ptrCast(&nextEvent); @@ -117,3 +124,38 @@ pub const Xkb = struct { return mods; } }; + +pub const Surface = struct { + common: *protocol.Surface, + app: *App, + window: c.Window, + + pub fn init(common: *protocol.Surface) void { + const surface = c.gtk_native_get_surface(@ptrCast(common.gtk_window)) orelse return; + + // Check if we're actually on X11 + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(surface)), + c.gdk_x11_surface_get_type(), + ) == 0) + return; + + common.inner = .{ .x11 = .{ + .common = common, + .app = &common.app.inner.x11, + .window = c.gdk_x11_surface_get_xid(surface), + } }; + } + + pub fn onConfigUpdate(self: *Surface) !void { + _ = self; + } + + pub fn onResize(self: *Surface) !void { + _ = self; + } + + fn updateBlur(self: *Surface) !void { + _ = self; + } +}; diff --git a/src/config/Config.zig b/src/config/Config.zig index eae6541be..e0d25d0fb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5782,6 +5782,14 @@ pub const BackgroundBlur = union(enum) { ) catch return error.InvalidValue }; } + pub fn enabled(self: BackgroundBlur) bool { + return switch (self) { + .false => false, + .true => true, + .radius => |v| v > 0, + }; + } + pub fn cval(self: BackgroundBlur) u8 { return switch (self) { .false => 0, From 405a8972301b14b9f2570beda56c225f9609f1a8 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 2 Jan 2025 23:53:22 +0800 Subject: [PATCH 05/27] gtk(x11): implement background blur for KDE/KWin on X11 --- src/apprt/gtk/c.zig | 2 + src/apprt/gtk/protocol/x11.zig | 68 ++++++++++++++++++++++++++++++++-- src/config/Config.zig | 2 +- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/c.zig b/src/apprt/gtk/c.zig index dde99c78e..4dc8ea57f 100644 --- a/src/apprt/gtk/c.zig +++ b/src/apprt/gtk/c.zig @@ -11,6 +11,8 @@ pub const c = @cImport({ // Add in X11-specific GDK backend which we use for specific things // (e.g. X11 window class). @cInclude("gdk/x11/gdkx.h"); + @cInclude("X11/Xlib.h"); + @cInclude("X11/Xatom.h"); // Xkb for X11 state handling @cInclude("X11/XKBlib.h"); } diff --git a/src/apprt/gtk/protocol/x11.zig b/src/apprt/gtk/protocol/x11.zig index be991bcfe..55762f316 100644 --- a/src/apprt/gtk/protocol/x11.zig +++ b/src/apprt/gtk/protocol/x11.zig @@ -12,6 +12,7 @@ const log = std.log.scoped(.gtk_x11); pub const App = struct { common: *protocol.App, display: *c.Display, + kde_blur_atom: c.Atom, base_event_code: c_int = 0, @@ -28,6 +29,7 @@ pub const App = struct { var self: App = .{ .common = common, .display = c.gdk_x11_display_get_xdisplay(common.gdk_display) orelse return, + .kde_blur_atom = c.gdk_x11_get_xatom_by_name_for_display(common.gdk_display, "_KDE_NET_WM_BLUR_BEHIND_REGION"), }; log.debug("X11 platform init={}", .{self}); @@ -130,6 +132,8 @@ pub const Surface = struct { app: *App, window: c.Window, + blur_region: Region, + pub fn init(common: *protocol.Surface) void { const surface = c.gtk_native_get_surface(@ptrCast(common.gtk_window)) orelse return; @@ -140,22 +144,80 @@ pub const Surface = struct { ) == 0) return; + var blur_region: Region = .{}; + + if ((comptime adwaita.versionAtLeast(0, 0, 0)) and common.derived_config.adw_enabled) { + // NOTE(pluiedev): CSDs are a f--king mistake. + // Please, GNOME, stop this nonsense of making a window ~30% bigger + // internally than how they really are just for your shadows and + // rounded corners and all that fluff. Please. I beg of you. + + var x: f64, var y: f64 = .{ 0, 0 }; + c.gtk_native_get_surface_transform(@ptrCast(common.gtk_window), &x, &y); + blur_region.x, blur_region.y = .{ @intFromFloat(x), @intFromFloat(y) }; + } + common.inner = .{ .x11 = .{ .common = common, .app = &common.app.inner.x11, .window = c.gdk_x11_surface_get_xid(surface), + .blur_region = blur_region, } }; } pub fn onConfigUpdate(self: *Surface) !void { - _ = self; + // Whether background blur is enabled could've changed. Update. + try self.updateBlur(); } pub fn onResize(self: *Surface) !void { - _ = self; + // The blur region must update with window resizes + self.blur_region.width = c.gtk_widget_get_width(@ptrCast(self.common.gtk_window)); + self.blur_region.height = c.gtk_widget_get_height(@ptrCast(self.common.gtk_window)); + try self.updateBlur(); } fn updateBlur(self: *Surface) !void { - _ = self; + // FIXME: This doesn't currently factor in rounded corners on Adwaita, + // which means that the blur region will grow slightly outside of the + // window borders. Unfortunately, actually calculating the rounded + // region can be quite complex without having access to existing APIs + // (cf. https://github.com/cutefishos/fishui/blob/41d4ba194063a3c7fff4675619b57e6ac0504f06/src/platforms/linux/blurhelper/windowblur.cpp#L134) + // and I think it's not really noticable enough to justify the effort. + // (Wayland also has this visual artifact anyway...) + + const blur = self.common.derived_config.blur; + log.debug("set blur={}, window xid={}, region={}", .{ blur, self.window, self.blur_region }); + + if (blur.enabled()) { + _ = c.XChangeProperty( + self.app.display, + self.window, + self.app.kde_blur_atom, + c.XA_CARDINAL, + // Despite what you might think, the "32" here does NOT mean + // that the data should be in u32s. Instead, they should be + // c_longs, which on any 64-bit architecture would be obviously + // 64 bits. WTF?! + 32, + c.PropModeReplace, + // SAFETY: Region is an extern struct that has the same + // representation of 4 c_longs put next to each other. + // Therefore, reinterpretation should be safe. + // We don't have to care about endianness either since + // Xlib converts it to network byte order for us. + @ptrCast(std.mem.asBytes(&self.blur_region)), + 4, + ); + } else { + _ = c.XDeleteProperty(self.app.display, self.window, self.app.kde_blur_atom); + } } }; + +const Region = extern struct { + x: c_long = 0, + y: c_long = 0, + width: c_long = 0, + height: c_long = 0, +}; diff --git a/src/config/Config.zig b/src/config/Config.zig index e0d25d0fb..7751f3e77 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -604,7 +604,7 @@ palette: Palette = .{}, /// /// Supported on macOS and on some Linux desktop environments, including: /// -/// * KDE Plasma (Wayland only) +/// * KDE Plasma (Wayland and X11) /// /// Warning: the exact blur intensity is _ignored_ under KDE Plasma, and setting /// this setting to either `true` or any positive blur intensity value would From c03828e03235a83918d907cf9b3a59928bd825b2 Mon Sep 17 00:00:00 2001 From: Anund Date: Tue, 7 Jan 2025 17:56:21 +1100 Subject: [PATCH 06/27] vim: work with theme config files --- src/config/vim.zig | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/config/vim.zig b/src/config/vim.zig index 62255bd79..ab487f9f9 100644 --- a/src/config/vim.zig +++ b/src/config/vim.zig @@ -3,7 +3,16 @@ const Config = @import("Config.zig"); /// This is the associated Vim file as named by the variable. pub const syntax = comptimeGenSyntax(); -pub const ftdetect = "au BufRead,BufNewFile */ghostty/config set ft=ghostty\n"; +pub const ftdetect = + \\" Vim filetype detect file + \\" Language: Ghostty config file + \\" Maintainer: Ghostty + \\" + \\" THIS FILE IS AUTO-GENERATED + \\ + \\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/* set ft=ghostty + \\ +; pub const ftplugin = \\" Vim filetype plugin file \\" Language: Ghostty config file @@ -31,13 +40,19 @@ pub const ftplugin = \\ ; pub const compiler = + \\" Vim compiler file + \\" Language: Ghostty config file + \\" Maintainer: Ghostty + \\" + \\" THIS FILE IS AUTO-GENERATED + \\ \\if exists("current_compiler") \\ finish \\endif \\let current_compiler = "ghostty" \\ - \\CompilerSet makeprg=ghostty\ +validate-config - \\CompilerSet errorformat=%f:%l:%m + \\CompilerSet makeprg=ghostty\ +validate-config\ --config-file=%:S + \\CompilerSet errorformat=%f:%l:%m,%m \\ ; From 19cfd9943991944e63cf1ce8d305387d8272dac3 Mon Sep 17 00:00:00 2001 From: Onno Siemens Date: Fri, 10 Jan 2025 18:11:57 +0100 Subject: [PATCH 07/27] docs: update copy-on-select documentation --- src/config/Config.zig | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index eae6541be..24e25437d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1387,16 +1387,14 @@ keybind: Keybinds = .{}, @"image-storage-limit": u32 = 320 * 1000 * 1000, /// Whether to automatically copy selected text to the clipboard. `true` -/// will prefer to copy to the selection clipboard if supported by the -/// OS, otherwise it will copy to the system clipboard. +/// will prefer to copy to the selection clipboard, otherwise it will copy to +/// the system clipboard. /// /// The value `clipboard` will always copy text to the selection clipboard -/// (for supported systems) as well as the system clipboard. This is sometimes -/// a preferred behavior on Linux. +/// as well as the system clipboard. /// -/// Middle-click paste will always use the selection clipboard on Linux -/// and the system clipboard on macOS. Middle-click paste is always enabled -/// even if this is `false`. +/// Middle-click paste will always use the selection clipboard. Middle-click +/// paste is always enabled even if this is `false`. /// /// The default value is true on Linux and macOS. @"copy-on-select": CopyOnSelect = switch (builtin.os.tag) { From ed81b62ec24790fea79877d2e2124d82271ee3df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jan 2025 20:00:30 -0800 Subject: [PATCH 08/27] apprt/gtk: winproto Rename "protocol" to "winproto". --- src/apprt/gtk/App.zig | 34 ++- src/apprt/gtk/Surface.zig | 15 +- src/apprt/gtk/Window.zig | 38 +++- src/apprt/gtk/key.zig | 6 +- src/apprt/gtk/protocol.zig | 149 ------------- src/apprt/gtk/protocol/wayland.zig | 125 ----------- src/apprt/gtk/winproto.zig | 128 +++++++++++ src/apprt/gtk/winproto/noop.zig | 56 +++++ src/apprt/gtk/winproto/wayland.zig | 211 +++++++++++++++++++ src/apprt/gtk/{protocol => winproto}/x11.zig | 178 +++++++++++----- 10 files changed, 589 insertions(+), 351 deletions(-) delete mode 100644 src/apprt/gtk/protocol.zig delete mode 100644 src/apprt/gtk/protocol/wayland.zig create mode 100644 src/apprt/gtk/winproto.zig create mode 100644 src/apprt/gtk/winproto/noop.zig create mode 100644 src/apprt/gtk/winproto/wayland.zig rename src/apprt/gtk/{protocol => winproto}/x11.zig (65%) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index ecbe61bce..b041d29fb 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -36,7 +36,7 @@ const c = @import("c.zig").c; const version = @import("version.zig"); const inspector = @import("inspector.zig"); const key = @import("key.zig"); -const protocol = @import("protocol.zig"); +const winproto = @import("winproto.zig"); const testing = std.testing; const log = std.log.scoped(.gtk); @@ -49,6 +49,9 @@ config: Config, app: *c.GtkApplication, ctx: *c.GMainContext, +/// State and logic for the underlying windowing protocol. +winproto: winproto.App, + /// True if the app was launched with single instance mode. single_instance: bool, @@ -70,8 +73,6 @@ clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null, /// This is set to false when the main loop should exit. running: bool = true, -protocol: protocol.App, - /// The base path of the transient cgroup used to put all surfaces /// into their own cgroup. This is only set if cgroups are enabled /// and initialization was successful. @@ -161,7 +162,12 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { } c.gtk_init(); - const display = c.gdk_display_get_default(); + const display: *c.GdkDisplay = c.gdk_display_get_default() orelse { + // I'm unsure of any scenario where this happens. Because we don't + // want to litter null checks everywhere, we just exit here. + log.warn("gdk display is null, exiting", .{}); + std.posix.exit(1); + }; // If we're using libadwaita, log the version if (adwaita.enabled(&config)) { @@ -359,7 +365,14 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { return error.GtkApplicationRegisterFailed; } - const app_protocol = try protocol.App.init(display, &config, app_id); + // Setup our windowing protocol logic + var winproto_app = try winproto.App.init( + core_app.alloc, + display, + app_id, + &config, + ); + errdefer winproto_app.deinit(core_app.alloc); // This just calls the `activate` signal but its part of the normal startup // routine so we just call it, but only if the config allows it (this allows @@ -385,7 +398,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { .config = config, .ctx = ctx, .cursor_none = cursor_none, - .protocol = app_protocol, + .winproto = winproto_app, .single_instance = single_instance, // If we are NOT the primary instance, then we never want to run. // This means that another instance of the GTK app is running and @@ -413,6 +426,8 @@ pub fn terminate(self: *App) void { } self.custom_css_providers.deinit(self.core_app.alloc); + self.winproto.deinit(self.core_app.alloc); + self.config.deinit(); } @@ -837,9 +852,10 @@ fn configChange( new_config: *const Config, ) void { switch (target) { - .surface => |surface| { - if (surface.rt_surface.container.window()) |window| window.syncAppearance(new_config) catch |err| { - log.warn("error syncing appearance changes to window err={}", .{err}); + .surface => |surface| surface: { + const window = surface.rt_surface.container.window() orelse break :surface; + window.updateConfig(new_config) catch |err| { + log.warn("error updating config for window err={}", .{err}); }; }, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 97b7bb0f4..c16f696b1 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -824,6 +824,9 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale { c.g_object_get_property(@ptrCast(@alignCast(settings)), "gtk-xft-dpi", &value); const gtk_xft_dpi = c.g_value_get_int(&value); + // As noted above gtk-xft-dpi is multiplied by 1024, so we divide by + // 1024, then divide by the default value (96) to derive a scale. Note + // gtk-xft-dpi can be fractional, so we use floating point math here. const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024; break :xft_scale xft_dpi / 96; }; @@ -1380,9 +1383,13 @@ fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque) return; }; - if (self.container.window()) |window| window.protocol.onResize() catch |err| { - log.warn("failed to notify X11/Wayland integration of resize={}", .{err}); - }; + if (self.container.window()) |window| { + if (window.winproto) |*winproto| { + winproto.resizeEvent() catch |err| { + log.warn("failed to notify window protocol of resize={}", .{err}); + }; + } + } self.resize_overlay.maybeShow(); } @@ -1702,7 +1709,7 @@ pub fn keyEvent( event, physical_key, gtk_mods, - &self.app.protocol, + &self.app.winproto, ); // Get our consumed modifiers diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index fbba22195..d0e678057 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -25,7 +25,7 @@ const gtk_key = @import("key.zig"); const Notebook = @import("notebook.zig").Notebook; const HeaderBar = @import("headerbar.zig").HeaderBar; const version = @import("version.zig"); -const protocol = @import("protocol.zig"); +const winproto = @import("winproto.zig"); const log = std.log.scoped(.gtk); @@ -56,7 +56,8 @@ toast_overlay: ?*c.GtkWidget, /// See adwTabOverviewOpen for why we have this. adw_tab_overview_focus_timer: ?c.guint = null, -protocol: protocol.Surface, +/// State and logic for windowing protocol for a window. +winproto: ?winproto.Window, pub fn create(alloc: Allocator, app: *App) !*Window { // Allocate a fixed pointer for our window. We try to minimize @@ -82,7 +83,7 @@ pub fn init(self: *Window, app: *App) !void { .notebook = undefined, .context_menu = undefined, .toast_overlay = undefined, - .protocol = undefined, + .winproto = null, }; // Create the window @@ -384,6 +385,16 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_widget_show(window); } +pub fn updateConfig( + self: *Window, + config: *const configpkg.Config, +) !void { + if (self.winproto) |*v| try v.updateConfigEvent(config); + + // We always resync our appearance whenever the config changes. + try self.syncAppearance(config); +} + /// Updates appearance based on config settings. Will be called once upon window /// realization, and every time the config is reloaded. /// @@ -396,8 +407,10 @@ pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void { c.gtk_widget_add_css_class(@ptrCast(self.window), "background"); } - // Perform protocol-specific config updates - try self.protocol.onConfigUpdate(config); + // Window protocol specific appearance updates + if (self.winproto) |*v| v.syncAppearance() catch |err| { + log.warn("failed to sync window protocol appearance error={}", .{err}); + }; } /// Sets up the GTK actions for the window scope. Actions are how GTK handles @@ -437,7 +450,7 @@ fn initActions(self: *Window) void { pub fn deinit(self: *Window) void { c.gtk_widget_unparent(@ptrCast(self.context_menu)); - self.protocol.deinit(); + if (self.winproto) |*v| v.deinit(self.app.core_app.alloc); if (self.adw_tab_overview_focus_timer) |timer| { _ = c.g_source_remove(timer); @@ -578,8 +591,19 @@ pub fn sendToast(self: *Window, title: [:0]const u8) void { fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { const self = userdataSelf(ud.?); - self.protocol.init(v, &self.app.protocol, &self.app.config); + // Initialize our window protocol logic + if (winproto.Window.init( + self.app.core_app.alloc, + &self.app.winproto, + v, + &self.app.config, + )) |winproto_win| { + self.winproto = winproto_win; + } else |err| { + log.warn("failed to initialize window protocol error={}", .{err}); + } + // When we are realized we always setup our appearance self.syncAppearance(&self.app.config) catch |err| { log.err("failed to initialize appearance={}", .{err}); }; diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index ef460e62c..40c9ca9a4 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -2,7 +2,7 @@ const std = @import("std"); const build_options = @import("build_options"); const input = @import("../../input.zig"); const c = @import("c.zig").c; -const protocol = @import("protocol.zig"); +const winproto = @import("winproto.zig"); /// Returns a GTK accelerator string from a trigger. pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { @@ -108,11 +108,11 @@ pub fn eventMods( event: *c.GdkEvent, physical_key: input.Key, gtk_mods: c.GdkModifierType, - app_protocol: *protocol.App, + app_winproto: *winproto.App, ) input.Mods { const device = c.gdk_event_get_device(event); - var mods = app_protocol.eventMods(device, gtk_mods); + var mods = app_winproto.eventMods(device, gtk_mods); mods.num_lock = c.gdk_device_get_num_lock_state(device) == 1; switch (physical_key) { diff --git a/src/apprt/gtk/protocol.zig b/src/apprt/gtk/protocol.zig deleted file mode 100644 index c7d7247cf..000000000 --- a/src/apprt/gtk/protocol.zig +++ /dev/null @@ -1,149 +0,0 @@ -const std = @import("std"); -const x11 = @import("protocol/x11.zig"); -const wayland = @import("protocol/wayland.zig"); -const c = @import("c.zig").c; -const build_options = @import("build_options"); -const input = @import("../../input.zig"); -const apprt = @import("../../apprt.zig"); -const Config = @import("../../config.zig").Config; -const adwaita = @import("adwaita.zig"); -const builtin = @import("builtin"); -const key = @import("key.zig"); - -const log = std.log.scoped(.gtk_platform); - -pub const App = struct { - gdk_display: *c.GdkDisplay, - derived_config: DerivedConfig, - - inner: union(enum) { - none, - x11: if (build_options.x11) x11.App else void, - wayland: if (build_options.wayland) wayland.App else void, - }, - - const DerivedConfig = struct { - app_id: [:0]const u8, - x11_program_name: [:0]const u8, - - pub fn init(config: *const Config, app_id: [:0]const u8) DerivedConfig { - return .{ - .app_id = app_id, - .x11_program_name = if (config.@"x11-instance-name") |pn| - pn - else if (builtin.mode == .Debug) - "ghostty-debug" - else - "ghostty", - }; - } - }; - - pub fn init(display: ?*c.GdkDisplay, config: *const Config, app_id: [:0]const u8) !App { - var self: App = .{ - .inner = .none, - .derived_config = DerivedConfig.init(config, app_id), - .gdk_display = display orelse { - // TODO: When does this ever happen...? - std.debug.panic("GDK display is null!", .{}); - }, - }; - - // The X11/Wayland init functions set `self.inner` when successful, - // so we only need to keep trying if `self.inner` stays `.none` - if (self.inner == .none and comptime build_options.wayland) try wayland.App.init(&self); - if (self.inner == .none and comptime build_options.x11) try x11.App.init(&self); - - // Welp, no integration for you - if (self.inner == .none) { - log.warn( - "neither X11 nor Wayland integrations enabled - lots of features would be missing!", - .{}, - ); - } - - return self; - } - - pub fn eventMods(self: *App, device: ?*c.GdkDevice, gtk_mods: c.GdkModifierType) input.Mods { - return switch (self.inner) { - // Add any modifier state events from Xkb if we have them (X11 - // only). Null back from the Xkb call means there was no modifier - // event to read. This likely means that the key event did not - // result in a modifier change and we can safely rely on the GDK - // state. - .x11 => |*x| if (comptime build_options.x11) - x.modifierStateFromNotify() orelse key.translateMods(gtk_mods) - else - unreachable, - - // On Wayland, we have to use the GDK device because the mods sent - // to this event do not have the modifier key applied if it was - // pressed (i.e. left control). - .wayland, .none => key.translateMods(c.gdk_device_get_modifier_state(device)), - }; - } -}; - -pub const Surface = struct { - app: *App, - gtk_window: *c.GtkWindow, - derived_config: DerivedConfig, - - inner: union(enum) { - none, - x11: if (build_options.x11) x11.Surface else void, - wayland: if (build_options.wayland) wayland.Surface else void, - }, - - pub const DerivedConfig = struct { - blur: Config.BackgroundBlur, - adw_enabled: bool, - - pub fn init(config: *const Config) DerivedConfig { - return .{ - .blur = config.@"background-blur-radius", - .adw_enabled = adwaita.enabled(config), - }; - } - }; - - pub fn init(self: *Surface, window: *c.GtkWindow, app: *App, config: *const Config) void { - self.* = .{ - .app = app, - .derived_config = DerivedConfig.init(config), - .gtk_window = window, - .inner = .none, - }; - - switch (app.inner) { - .x11 => if (comptime build_options.x11) x11.Surface.init(self) else unreachable, - .wayland => if (comptime build_options.wayland) wayland.Surface.init(self) else unreachable, - .none => {}, - } - } - - pub fn deinit(self: Surface) void { - switch (self.inner) { - .wayland => |wl| if (comptime build_options.wayland) wl.deinit() else unreachable, - .x11, .none => {}, - } - } - - pub fn onConfigUpdate(self: *Surface, config: *const Config) !void { - self.derived_config = DerivedConfig.init(config); - - switch (self.inner) { - .x11 => |*x| if (comptime build_options.x11) try x.onConfigUpdate() else unreachable, - .wayland => |*wl| if (comptime build_options.wayland) try wl.onConfigUpdate() else unreachable, - .none => {}, - } - } - - pub fn onResize(self: *Surface) !void { - switch (self.inner) { - .x11 => |*x| if (comptime build_options.x11) try x.onResize() else unreachable, - .wayland, .none => {}, - } - } -}; diff --git a/src/apprt/gtk/protocol/wayland.zig b/src/apprt/gtk/protocol/wayland.zig deleted file mode 100644 index 985d7c5a8..000000000 --- a/src/apprt/gtk/protocol/wayland.zig +++ /dev/null @@ -1,125 +0,0 @@ -const std = @import("std"); -const c = @import("../c.zig").c; -const wayland = @import("wayland"); -const protocol = @import("../protocol.zig"); -const Config = @import("../../../config.zig").Config; - -const wl = wayland.client.wl; -const org = wayland.client.org; - -const log = std.log.scoped(.gtk_wayland); - -/// Wayland state that contains application-wide Wayland objects (e.g. wl_display). -pub const App = struct { - display: *wl.Display, - blur_manager: ?*org.KdeKwinBlurManager = null, - - pub fn init(common: *protocol.App) !void { - // Check if we're actually on Wayland - if (c.g_type_check_instance_is_a( - @ptrCast(@alignCast(common.gdk_display)), - c.gdk_wayland_display_get_type(), - ) == 0) - return; - - var self: App = .{ - .display = @ptrCast(c.gdk_wayland_display_get_wl_display(common.gdk_display) orelse return), - }; - - log.debug("wayland platform init={}", .{self}); - - const registry = try self.display.getRegistry(); - - registry.setListener(*App, registryListener, &self); - if (self.display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - - common.inner = .{ .wayland = self }; - } -}; - -/// Wayland state that contains Wayland objects associated with a window (e.g. wl_surface). -pub const Surface = struct { - common: *const protocol.Surface, - app: *App, - surface: *wl.Surface, - - /// A token that, when present, indicates that the window is blurred. - blur_token: ?*org.KdeKwinBlur = null, - - pub fn init(common: *protocol.Surface) void { - const surface = c.gtk_native_get_surface(@ptrCast(common.gtk_window)) orelse return; - - // Check if we're actually on Wayland - if (c.g_type_check_instance_is_a( - @ptrCast(@alignCast(surface)), - c.gdk_wayland_surface_get_type(), - ) == 0) - return; - - const self: Surface = .{ - .common = common, - .app = &common.app.inner.wayland, - .surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface(surface) orelse return), - }; - - common.inner = .{ .wayland = self }; - } - - pub fn deinit(self: Surface) void { - if (self.blur_token) |blur| blur.release(); - } - - pub fn onConfigUpdate(self: *Surface) !void { - try self.updateBlur(); - } - - fn updateBlur(self: *Surface) !void { - const blur = self.common.derived_config.blur; - log.debug("setting blur={}", .{blur}); - - const mgr = self.app.blur_manager orelse { - log.warn("can't set blur: org_kde_kwin_blur_manager protocol unavailable", .{}); - return; - }; - - if (self.blur_token) |tok| { - // Only release token when transitioning from blurred -> not blurred - if (!blur.enabled()) { - mgr.unset(self.surface); - tok.release(); - self.blur_token = null; - } - } else { - // Only acquire token when transitioning from not blurred -> blurred - if (blur.enabled()) { - const tok = try mgr.create(self.surface); - tok.commit(); - self.blur_token = tok; - } - } - } -}; - -fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, state: *App) void { - switch (event) { - .global => |global| { - log.debug("got global interface={s}", .{global.interface}); - if (bindInterface(org.KdeKwinBlurManager, registry, global, 1)) |iface| { - state.blur_manager = iface; - return; - } - }, - .global_remove => {}, - } -} - -fn bindInterface(comptime T: type, registry: *wl.Registry, global: anytype, version: u32) ?*T { - if (std.mem.orderZ(u8, global.interface, T.interface.name) == .eq) { - return registry.bind(global.name, T, version) catch |err| { - log.warn("encountered error={} while binding interface {s}", .{ err, global.interface }); - return null; - }; - } else { - return null; - } -} diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig new file mode 100644 index 000000000..49d96bb02 --- /dev/null +++ b/src/apprt/gtk/winproto.zig @@ -0,0 +1,128 @@ +const std = @import("std"); +const build_options = @import("build_options"); +const Allocator = std.mem.Allocator; +const c = @import("c.zig").c; +const Config = @import("../../config.zig").Config; +const input = @import("../../input.zig"); +const key = @import("key.zig"); + +pub const noop = @import("winproto/noop.zig"); +pub const x11 = @import("winproto/x11.zig"); +pub const wayland = @import("winproto/wayland.zig"); + +pub const Protocol = enum { + none, + wayland, + x11, +}; + +/// App-state for the underlying windowing protocol. There should be one +/// instance of this struct per application. +pub const App = union(Protocol) { + none: noop.App, + wayland: if (build_options.wayland) wayland.App else noop.App, + x11: if (build_options.x11) x11.App else noop.App, + + pub fn init( + alloc: Allocator, + gdk_display: *c.GdkDisplay, + app_id: [:0]const u8, + config: *const Config, + ) !App { + inline for (@typeInfo(App).Union.fields) |field| { + if (try field.type.init( + alloc, + gdk_display, + app_id, + config, + )) |v| { + return @unionInit(App, field.name, v); + } + } + + return .none; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + switch (self.*) { + inline else => |*v| v.deinit(alloc), + } + } + + pub fn eventMods( + self: *App, + device: ?*c.GdkDevice, + gtk_mods: c.GdkModifierType, + ) input.Mods { + return switch (self.*) { + inline else => |*v| v.eventMods(device, gtk_mods), + } orelse key.translateMods(gtk_mods); + } +}; + +/// Per-Window state for the underlying windowing protocol. +/// +/// In both X and Wayland, the terminology used is "Surface" and this is +/// really "Surface"-specific state. But Ghostty uses the term "Surface" +/// heavily to mean something completely different, so we use "Window" here +/// to better match what it generally maps to in the Ghostty codebase. +pub const Window = union(Protocol) { + none: noop.Window, + wayland: if (build_options.wayland) wayland.Window else noop.Window, + x11: if (build_options.x11) x11.Window else noop.Window, + + pub fn init( + alloc: Allocator, + app: *App, + window: *c.GtkWindow, + config: *const Config, + ) !Window { + return switch (app.*) { + inline else => |*v, tag| { + inline for (@typeInfo(Window).Union.fields) |field| { + if (comptime std.mem.eql( + u8, + field.name, + @tagName(tag), + )) return @unionInit( + Window, + field.name, + try field.type.init( + alloc, + v, + window, + config, + ), + ); + } + }, + }; + } + + pub fn deinit(self: *Window, alloc: Allocator) void { + switch (self.*) { + inline else => |*v| v.deinit(alloc), + } + } + + pub fn resizeEvent(self: *Window) !void { + switch (self.*) { + inline else => |*v| try v.resizeEvent(), + } + } + + pub fn updateConfigEvent( + self: *Window, + config: *const Config, + ) !void { + switch (self.*) { + inline else => |*v| try v.updateConfigEvent(config), + } + } + + pub fn syncAppearance(self: *Window) !void { + switch (self.*) { + inline else => |*v| try v.syncAppearance(), + } + } +}; diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig new file mode 100644 index 000000000..54c14fe14 --- /dev/null +++ b/src/apprt/gtk/winproto/noop.zig @@ -0,0 +1,56 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const c = @import("../c.zig").c; +const Config = @import("../../../config.zig").Config; +const input = @import("../../../input.zig"); + +const log = std.log.scoped(.winproto_noop); + +pub const App = struct { + pub fn init( + _: Allocator, + _: *c.GdkDisplay, + _: [:0]const u8, + _: *const Config, + ) !?App { + return .{}; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + _ = self; + _ = alloc; + } + + pub fn eventMods( + _: *App, + _: ?*c.GdkDevice, + _: c.GdkModifierType, + ) ?input.Mods { + return null; + } +}; + +pub const Window = struct { + pub fn init( + _: Allocator, + _: *App, + _: *c.GtkWindow, + _: *const Config, + ) !Window { + return .{}; + } + + pub fn deinit(self: Window, alloc: Allocator) void { + _ = self; + _ = alloc; + } + + pub fn updateConfigEvent( + _: *Window, + _: *const Config, + ) !void {} + + pub fn resizeEvent(_: *Window) !void {} + + pub fn syncAppearance(_: *Window) !void {} +}; diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig new file mode 100644 index 000000000..3f7ad0068 --- /dev/null +++ b/src/apprt/gtk/winproto/wayland.zig @@ -0,0 +1,211 @@ +//! Wayland protocol implementation for the Ghostty GTK apprt. +const std = @import("std"); +const wayland = @import("wayland"); +const Allocator = std.mem.Allocator; +const c = @import("../c.zig").c; +const Config = @import("../../../config.zig").Config; +const input = @import("../../../input.zig"); + +const wl = wayland.client.wl; +const org = wayland.client.org; + +const log = std.log.scoped(.winproto_wayland); + +/// Wayland state that contains application-wide Wayland objects (e.g. wl_display). +pub const App = struct { + display: *wl.Display, + context: *Context, + + const Context = struct { + kde_blur_manager: ?*org.KdeKwinBlurManager = null, + }; + + pub fn init( + alloc: Allocator, + gdk_display: *c.GdkDisplay, + app_id: [:0]const u8, + config: *const Config, + ) !?App { + _ = config; + _ = app_id; + + // Check if we're actually on Wayland + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(gdk_display)), + c.gdk_wayland_display_get_type(), + ) == 0) return null; + + const display: *wl.Display = @ptrCast(c.gdk_wayland_display_get_wl_display( + gdk_display, + ) orelse return error.NoWaylandDisplay); + + // Create our context for our callbacks so we have a stable pointer. + // Note: at the time of writing this comment, we don't really need + // a stable pointer, but it's too scary that we'd need one in the future + // and not have it and corrupt memory or something so let's just do it. + const context = try alloc.create(Context); + errdefer alloc.destroy(context); + context.* = .{}; + + // Get our display registry so we can get all the available interfaces + // and bind to what we need. + const registry = try display.getRegistry(); + registry.setListener(*Context, registryListener, context); + if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + + return .{ + .display = display, + .context = context, + }; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + alloc.destroy(self.context); + } + + pub fn eventMods( + _: *App, + _: ?*c.GdkDevice, + _: c.GdkModifierType, + ) ?input.Mods { + return null; + } + + fn registryListener( + registry: *wl.Registry, + event: wl.Registry.Event, + context: *Context, + ) void { + switch (event) { + // https://wayland.app/protocols/wayland#wl_registry:event:global + .global => |global| global: { + log.debug("wl_registry.global: interface={s}", .{global.interface}); + + if (registryBind( + org.KdeKwinBlurManager, + registry, + global, + 1, + )) |blur_manager| { + context.kde_blur_manager = blur_manager; + break :global; + } + }, + + // We don't handle removal events + .global_remove => {}, + } + } + + fn registryBind( + comptime T: type, + registry: *wl.Registry, + global: anytype, + version: u32, + ) ?*T { + if (std.mem.orderZ( + u8, + global.interface, + T.interface.name, + ) != .eq) return null; + + return registry.bind(global.name, T, version) catch |err| { + log.warn("error binding interface {s} error={}", .{ + global.interface, + err, + }); + return null; + }; + } +}; + +/// Per-window (wl_surface) state for the Wayland protocol. +pub const Window = struct { + config: DerivedConfig, + + /// The Wayland surface for this window. + surface: *wl.Surface, + + /// The context from the app where we can load our Wayland interfaces. + app_context: *App.Context, + + /// A token that, when present, indicates that the window is blurred. + blur_token: ?*org.KdeKwinBlur = null, + + const DerivedConfig = struct { + blur: bool, + + pub fn init(config: *const Config) DerivedConfig { + return .{ + .blur = config.@"background-blur-radius".enabled(), + }; + } + }; + + pub fn init( + alloc: Allocator, + app: *App, + gtk_window: *c.GtkWindow, + config: *const Config, + ) !Window { + _ = alloc; + + const gdk_surface = c.gtk_native_get_surface( + @ptrCast(gtk_window), + ) orelse return error.NotWaylandSurface; + + // This should never fail, because if we're being called at this point + // then we've already asserted that our app state is Wayland. + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(gdk_surface)), + c.gdk_wayland_surface_get_type(), + ) == 0) return error.NotWaylandSurface; + + const wl_surface: *wl.Surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface( + gdk_surface, + ) orelse return error.NoWaylandSurface); + + return .{ + .config = DerivedConfig.init(config), + .surface = wl_surface, + .app_context = app.context, + }; + } + + pub fn deinit(self: Window, alloc: Allocator) void { + _ = alloc; + if (self.blur_token) |blur| blur.release(); + } + + pub fn updateConfigEvent(self: *Window, config: *const Config) !void { + self.config = DerivedConfig.init(config); + } + + pub fn resizeEvent(_: *Window) !void {} + + pub fn syncAppearance(self: *Window) !void { + try self.syncBlur(); + } + + /// Update the blur state of the window. + fn syncBlur(self: *Window) !void { + const manager = self.app_context.kde_blur_manager orelse return; + const blur = self.config.blur; + + if (self.blur_token) |tok| { + // Only release token when transitioning from blurred -> not blurred + if (!blur) { + manager.unset(self.surface); + tok.release(); + self.blur_token = null; + } + } else { + // Only acquire token when transitioning from not blurred -> blurred + if (blur) { + const tok = try manager.create(self.surface); + tok.commit(); + self.blur_token = tok; + } + } + } +}; diff --git a/src/apprt/gtk/protocol/x11.zig b/src/apprt/gtk/winproto/x11.zig similarity index 65% rename from src/apprt/gtk/protocol/x11.zig rename to src/apprt/gtk/winproto/x11.zig index 55762f316..d896fc051 100644 --- a/src/apprt/gtk/protocol/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -1,38 +1,45 @@ -/// Utility functions for X11 handling. +//! X11 window protocol implementation for the Ghostty GTK apprt. const std = @import("std"); +const builtin = @import("builtin"); const build_options = @import("build_options"); +const Allocator = std.mem.Allocator; const c = @import("../c.zig").c; const input = @import("../../../input.zig"); const Config = @import("../../../config.zig").Config; -const protocol = @import("../protocol.zig"); const adwaita = @import("../adwaita.zig"); const log = std.log.scoped(.gtk_x11); pub const App = struct { - common: *protocol.App, display: *c.Display, + base_event_code: c_int, kde_blur_atom: c.Atom, - base_event_code: c_int = 0, + pub fn init( + alloc: Allocator, + gdk_display: *c.GdkDisplay, + app_id: [:0]const u8, + config: *const Config, + ) !?App { + _ = alloc; - /// Initialize an Xkb struct for the given GDK display. If the display isn't - /// backed by X then this will return null. - pub fn init(common: *protocol.App) !void { // If the display isn't X11, then we don't need to do anything. if (c.g_type_check_instance_is_a( - @ptrCast(@alignCast(common.gdk_display)), + @ptrCast(@alignCast(gdk_display)), c.gdk_x11_display_get_type(), - ) == 0) - return; + ) == 0) return null; - var self: App = .{ - .common = common, - .display = c.gdk_x11_display_get_xdisplay(common.gdk_display) orelse return, - .kde_blur_atom = c.gdk_x11_get_xatom_by_name_for_display(common.gdk_display, "_KDE_NET_WM_BLUR_BEHIND_REGION"), - }; + // Get our X11 display + const display: *c.Display = c.gdk_x11_display_get_xdisplay( + gdk_display, + ) orelse return error.NoX11Display; - log.debug("X11 platform init={}", .{self}); + const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn| + pn + else if (builtin.mode == .Debug) + "ghostty-debug" + else + "ghostty"; // Set the X11 window class property (WM_CLASS) if are are on an X11 // display. @@ -50,22 +57,21 @@ pub const App = struct { // WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty" // // Append "-debug" on both when using the debug build. - - c.g_set_prgname(common.derived_config.x11_program_name); - c.gdk_x11_display_set_program_class(common.gdk_display, common.derived_config.app_id); + c.g_set_prgname(x11_program_name); + c.gdk_x11_display_set_program_class(gdk_display, app_id); // XKB log.debug("Xkb.init: initializing Xkb", .{}); - log.debug("Xkb.init: running XkbQueryExtension", .{}); var opcode: c_int = 0; + var base_event_code: c_int = 0; var base_error_code: c_int = 0; var major = c.XkbMajorVersion; var minor = c.XkbMinorVersion; if (c.XkbQueryExtension( - self.display, + display, &opcode, - &self.base_event_code, + &base_event_code, &base_error_code, &major, &minor, @@ -76,7 +82,7 @@ pub const App = struct { log.debug("Xkb.init: running XkbSelectEventDetails", .{}); if (c.XkbSelectEventDetails( - self.display, + display, c.XkbUseCoreKbd, c.XkbStateNotify, c.XkbModifierStateMask, @@ -86,7 +92,19 @@ pub const App = struct { return error.XkbInitializationError; } - common.inner = .{ .x11 = self }; + return .{ + .display = display, + .base_event_code = base_event_code, + .kde_blur_atom = c.gdk_x11_get_xatom_by_name_for_display( + gdk_display, + "_KDE_NET_WM_BLUR_BEHIND_REGION", + ), + }; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + _ = self; + _ = alloc; } /// Checks for an immediate pending XKB state update event, and returns the @@ -99,7 +117,14 @@ pub const App = struct { /// Returns null if there is no event. In this case, the caller should fall /// back to the standard GDK modifier state (this likely means the key /// event did not result in a modifier change). - pub fn modifierStateFromNotify(self: App) ?input.Mods { + pub fn eventMods( + self: App, + device: ?*c.GdkDevice, + gtk_mods: c.GdkModifierType, + ) ?input.Mods { + _ = device; + _ = gtk_mods; + // Shoutout to Mozilla for figuring out a clean way to do this, this is // paraphrased from Firefox/Gecko in widget/gtk/nsGtkKeyUtils.cpp. if (c.XEventsQueued(self.display, c.QueuedAfterReading) == 0) return null; @@ -127,57 +152,94 @@ pub const App = struct { } }; -pub const Surface = struct { - common: *protocol.Surface, +pub const Window = struct { app: *App, + config: DerivedConfig, window: c.Window, - + gtk_window: *c.GtkWindow, blur_region: Region, - pub fn init(common: *protocol.Surface) void { - const surface = c.gtk_native_get_surface(@ptrCast(common.gtk_window)) orelse return; + const DerivedConfig = struct { + blur: bool, + + pub fn init(config: *const Config) DerivedConfig { + return .{ + .blur = config.@"background-blur-radius".enabled(), + }; + } + }; + + pub fn init( + _: Allocator, + app: *App, + gtk_window: *c.GtkWindow, + config: *const Config, + ) !Window { + const surface = c.gtk_native_get_surface( + @ptrCast(gtk_window), + ) orelse return error.NotX11Surface; // Check if we're actually on X11 if (c.g_type_check_instance_is_a( @ptrCast(@alignCast(surface)), c.gdk_x11_surface_get_type(), - ) == 0) - return; + ) == 0) return error.NotX11Surface; - var blur_region: Region = .{}; + const blur_region: Region = blur: { + if ((comptime !adwaita.versionAtLeast(0, 0, 0)) or + !adwaita.enabled(config)) break :blur .{}; - if ((comptime adwaita.versionAtLeast(0, 0, 0)) and common.derived_config.adw_enabled) { // NOTE(pluiedev): CSDs are a f--king mistake. // Please, GNOME, stop this nonsense of making a window ~30% bigger // internally than how they really are just for your shadows and // rounded corners and all that fluff. Please. I beg of you. + var x: f64 = 0; + var y: f64 = 0; + c.gtk_native_get_surface_transform( + @ptrCast(gtk_window), + &x, + &y, + ); - var x: f64, var y: f64 = .{ 0, 0 }; - c.gtk_native_get_surface_transform(@ptrCast(common.gtk_window), &x, &y); - blur_region.x, blur_region.y = .{ @intFromFloat(x), @intFromFloat(y) }; - } + break :blur .{ + .x = @intFromFloat(x), + .y = @intFromFloat(y), + }; + }; - common.inner = .{ .x11 = .{ - .common = common, - .app = &common.app.inner.x11, + return .{ + .app = app, + .config = DerivedConfig.init(config), .window = c.gdk_x11_surface_get_xid(surface), + .gtk_window = gtk_window, .blur_region = blur_region, - } }; + }; } - pub fn onConfigUpdate(self: *Surface) !void { - // Whether background blur is enabled could've changed. Update. - try self.updateBlur(); + pub fn deinit(self: Window, alloc: Allocator) void { + _ = self; + _ = alloc; } - pub fn onResize(self: *Surface) !void { + pub fn updateConfigEvent( + self: *Window, + config: *const Config, + ) !void { + self.config = DerivedConfig.init(config); + } + + pub fn resizeEvent(self: *Window) !void { // The blur region must update with window resizes - self.blur_region.width = c.gtk_widget_get_width(@ptrCast(self.common.gtk_window)); - self.blur_region.height = c.gtk_widget_get_height(@ptrCast(self.common.gtk_window)); - try self.updateBlur(); + self.blur_region.width = c.gtk_widget_get_width(@ptrCast(self.gtk_window)); + self.blur_region.height = c.gtk_widget_get_height(@ptrCast(self.gtk_window)); + try self.syncBlur(); } - fn updateBlur(self: *Surface) !void { + pub fn syncAppearance(self: *Window) !void { + try self.syncBlur(); + } + + fn syncBlur(self: *Window) !void { // FIXME: This doesn't currently factor in rounded corners on Adwaita, // which means that the blur region will grow slightly outside of the // window borders. Unfortunately, actually calculating the rounded @@ -186,10 +248,14 @@ pub const Surface = struct { // and I think it's not really noticable enough to justify the effort. // (Wayland also has this visual artifact anyway...) - const blur = self.common.derived_config.blur; - log.debug("set blur={}, window xid={}, region={}", .{ blur, self.window, self.blur_region }); + const blur = self.config.blur; + log.debug("set blur={}, window xid={}, region={}", .{ + blur, + self.window, + self.blur_region, + }); - if (blur.enabled()) { + if (blur) { _ = c.XChangeProperty( self.app.display, self.window, @@ -210,7 +276,11 @@ pub const Surface = struct { 4, ); } else { - _ = c.XDeleteProperty(self.app.display, self.window, self.app.kde_blur_atom); + _ = c.XDeleteProperty( + self.app.display, + self.window, + self.app.kde_blur_atom, + ); } } }; From be0370cb0e8bde6bcea056f537c71873a90c54a0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 09:41:07 -0800 Subject: [PATCH 09/27] ci: test gtk-wayland in the GTK matrix --- .github/workflows/test.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b8e79959..1e021af64 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -342,7 +342,8 @@ jobs: matrix: adwaita: ["true", "false"] x11: ["true", "false"] - name: GTK adwaita=${{ matrix.adwaita }} x11=${{ matrix.x11 }} + wayland: ["true", "false"] + name: GTK adwaita=${{ matrix.adwaita }} x11=${{ matrix.x11 }} wayland=${{ matrix.wayland }} runs-on: namespace-profile-ghostty-sm needs: test env: @@ -374,7 +375,8 @@ jobs: zig build \ -Dapp-runtime=gtk \ -Dgtk-adwaita=${{ matrix.adwaita }} \ - -Dgtk-x11=${{ matrix.x11 }} + -Dgtk-x11=${{ matrix.x11 }} \ + -Dgtk-wayland=${{ matrix.wayland }} test-sentry-linux: strategy: From 2f81c360bd66541ebea40dcb1d62b8f8211bad64 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 09:42:36 -0800 Subject: [PATCH 10/27] ci: typos --- src/apprt/gtk/winproto/x11.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index d896fc051..4eac9cdf3 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -245,7 +245,7 @@ pub const Window = struct { // window borders. Unfortunately, actually calculating the rounded // region can be quite complex without having access to existing APIs // (cf. https://github.com/cutefishos/fishui/blob/41d4ba194063a3c7fff4675619b57e6ac0504f06/src/platforms/linux/blurhelper/windowblur.cpp#L134) - // and I think it's not really noticable enough to justify the effort. + // and I think it's not really noticeable enough to justify the effort. // (Wayland also has this visual artifact anyway...) const blur = self.config.blur; From 6e411d60f209300d7f06c3502c085b086c058734 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 09:56:33 -0800 Subject: [PATCH 11/27] Fix wayland-scanner/protocols packaging dependency By updating zig-wayland: https://codeberg.org/ifreund/zig-wayland/issues/67 --- build.zig.zon | 4 ++-- nix/zigCacheHash.nix | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 18a608bb4..3c6ab85d6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -34,8 +34,8 @@ .hash = "12207831bce7d4abce57b5a98e8f3635811cfefd160bca022eb91fe905d36a02cf25", }, .zig_wayland = .{ - .url = "https://codeberg.org/ifreund/zig-wayland/archive/0823d9116b80d65ecfad48a2efbca166c7b03497.tar.gz", - .hash = "12205e05d4db71ef30aeb3517727382c12d294968e541090a762689acbb9038826a1", + .url = "https://codeberg.org/ifreund/zig-wayland/archive/fbfe3b4ac0b472a27b1f1a67405436c58cbee12d.tar.gz", + .hash = "12209ca054cb1919fa276e328967f10b253f7537c4136eb48f3332b0f7cf661cad38", }, .zf = .{ .url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd", diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 48270c6e8..db909a936 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-MeSJiiSDDWZ7vUgY56t9aPSLPTgIKb4jexoHmDhJOGM=" +"sha256-Nx1tOhDnEZ7LVi/pKxYS3sg/Sf8TAUXDmST6EtBgDoQ=" From 010f4d167dc0f53d96fbf81b8b3c03c55d5d7ce0 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 8 Jan 2025 22:53:25 -0600 Subject: [PATCH 12/27] GTK: refactor headerbar into separate Adwaita & GTK structs --- src/apprt/gtk/Window.zig | 127 +++++++++++++------------------- src/apprt/gtk/headerbar.zig | 75 +++++-------------- src/apprt/gtk/headerbar_adw.zig | 77 +++++++++++++++++++ src/apprt/gtk/headerbar_gtk.zig | 52 +++++++++++++ 4 files changed, 201 insertions(+), 130 deletions(-) create mode 100644 src/apprt/gtk/headerbar_adw.zig create mode 100644 src/apprt/gtk/headerbar_gtk.zig diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 0f44cee7b..86640695f 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -37,7 +37,7 @@ window: *c.GtkWindow, /// The header bar for the window. This is possibly null since it can be /// disabled using gtk-titlebar. This is either an AdwHeaderBar or /// GtkHeaderBar depending on if adw is enabled and linked. -header: ?HeaderBar, +headerbar: HeaderBar, /// The tab overview for the window. This is possibly null since there is no /// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0). @@ -77,7 +77,7 @@ pub fn init(self: *Window, app: *App) !void { self.* = .{ .app = app, .window = undefined, - .header = null, + .headerbar = undefined, .tab_overview = null, .notebook = undefined, .context_menu = undefined, @@ -150,64 +150,56 @@ pub fn init(self: *Window, app: *App) !void { break :overview tab_overview; } else null; - // gtk-titlebar can be used to disable the header bar (but keep - // the window manager's decorations). We create this no matter if we - // are decorated or not because we can have a keybind to toggle the - // decorations. - if (app.config.@"gtk-titlebar") { - const header = HeaderBar.init(self); + // gtk-titlebar can be used to disable the header bar (but keep the window + // manager's decorations). We create this no matter if we are decorated or + // not because we can have a keybind to toggle the decorations. + self.headerbar.init(); - // If we are not decorated then we hide the titlebar. - header.setVisible(app.config.@"window-decoration"); + { + const btn = c.gtk_menu_button_new(); + c.gtk_widget_set_tooltip_text(btn, "Main Menu"); + c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic"); + c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu))); + self.headerbar.packEnd(btn); + } - { - const btn = c.gtk_menu_button_new(); - c.gtk_widget_set_tooltip_text(btn, "Main Menu"); - c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic"); - c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu))); - header.packEnd(btn); - } + // If we're using an AdwWindow then we can support the tab overview. + if (self.tab_overview) |tab_overview| { + if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; + assert(self.app.config.@"gtk-adwaita" and adwaita.versionAtLeast(1, 4, 0)); + const btn = switch (app.config.@"gtk-tabs-location") { + .top, .bottom, .left, .right => btn: { + const btn = c.gtk_toggle_button_new(); + c.gtk_widget_set_tooltip_text(btn, "View Open Tabs"); + c.gtk_button_set_icon_name(@ptrCast(btn), "view-grid-symbolic"); + _ = c.g_object_bind_property( + btn, + "active", + tab_overview, + "open", + c.G_BINDING_BIDIRECTIONAL | c.G_BINDING_SYNC_CREATE, + ); - // If we're using an AdwWindow then we can support the tab overview. - if (self.tab_overview) |tab_overview| { - if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; - assert(self.app.config.@"gtk-adwaita" and adwaita.versionAtLeast(1, 4, 0)); - const btn = switch (app.config.@"gtk-tabs-location") { - .top, .bottom, .left, .right => btn: { - const btn = c.gtk_toggle_button_new(); - c.gtk_widget_set_tooltip_text(btn, "View Open Tabs"); - c.gtk_button_set_icon_name(@ptrCast(btn), "view-grid-symbolic"); - _ = c.g_object_bind_property( - btn, - "active", - tab_overview, - "open", - c.G_BINDING_BIDIRECTIONAL | c.G_BINDING_SYNC_CREATE, - ); + break :btn btn; + }, - break :btn btn; - }, + .hidden => btn: { + const btn = c.adw_tab_button_new(); + c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw.tab_view); + c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open"); + break :btn btn; + }, + }; - .hidden => btn: { - const btn = c.adw_tab_button_new(); - c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw.tab_view); - c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open"); - break :btn btn; - }, - }; + c.gtk_widget_set_focus_on_click(btn, c.FALSE); + self.headerbar.packEnd(btn); + } - c.gtk_widget_set_focus_on_click(btn, c.FALSE); - header.packEnd(btn); - } - - { - const btn = c.gtk_button_new_from_icon_name("tab-new-symbolic"); - c.gtk_widget_set_tooltip_text(btn, "New Tab"); - _ = c.g_signal_connect_data(btn, "clicked", c.G_CALLBACK(>kTabNewClick), self, null, c.G_CONNECT_DEFAULT); - header.packStart(btn); - } - - self.header = header; + { + const btn = c.gtk_button_new_from_icon_name("tab-new-symbolic"); + c.gtk_widget_set_tooltip_text(btn, "New Tab"); + _ = c.g_signal_connect_data(btn, "clicked", c.G_CALLBACK(>kTabNewClick), self, null, c.G_CONNECT_DEFAULT); + self.headerbar.packStart(btn); } _ = c.g_signal_connect_data(gtk_window, "notify::decorated", c.G_CALLBACK(>kWindowNotifyDecorated), self, null, c.G_CONNECT_DEFAULT); @@ -220,9 +212,7 @@ pub fn init(self: *Window, app: *App) !void { // If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we // need to stick the headerbar into the content box. if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { - if (self.header) |h| { - c.gtk_box_append(@ptrCast(box), h.asWidget()); - } + c.gtk_box_append(@ptrCast(box), self.headerbar.asWidget()); } // In debug we show a warning and apply the 'devel' class to the window. @@ -297,10 +287,7 @@ pub fn init(self: *Window, app: *App) !void { if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new()); - if (self.header) |header| { - const header_widget = header.asWidget(); - c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget); - } + c.adw_toolbar_view_add_top_bar(toolbar_view, self.headerbar.asWidget()); if (self.app.config.@"gtk-tabs-location" != .hidden) { const tab_bar = c.adw_tab_bar_new(); @@ -373,10 +360,8 @@ pub fn init(self: *Window, app: *App) !void { box, ); } else { + c.gtk_window_set_titlebar(gtk_window, self.headerbar.asWidget()); c.gtk_window_set_child(gtk_window, box); - if (self.header) |h| { - c.gtk_window_set_titlebar(gtk_window, h.asWidget()); - } } } @@ -452,18 +437,12 @@ pub fn deinit(self: *Window) void { /// Set the title of the window. pub fn setTitle(self: *Window, title: [:0]const u8) void { - if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config) and self.app.config.@"gtk-titlebar") { - if (self.header) |header| header.setTitle(title); - } else { - c.gtk_window_set_title(self.window, title); - } + self.headerbar.setTitle(title); } /// Set the subtitle of the window if it has one. pub fn setSubtitle(self: *Window, subtitle: [:0]const u8) void { - if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config) and self.app.config.@"gtk-titlebar") { - if (self.header) |header| header.setSubtitle(subtitle); - } + self.headerbar.setSubtitle(subtitle); } /// Add a new tab to this window. @@ -556,9 +535,7 @@ pub fn toggleWindowDecorations(self: *Window) void { // decorated state. GTK tends to consider the titlebar part of the frame // and hides it with decorations, but libadwaita doesn't. This makes it // explicit. - if (self.header) |headerbar| { - headerbar.setVisible(new_decorated); - } + self.headerbar.setVisible(new_decorated); } /// Grabs focus on the currently selected tab. diff --git a/src/apprt/gtk/headerbar.zig b/src/apprt/gtk/headerbar.zig index 97c48a4c2..2b47ea4b7 100644 --- a/src/apprt/gtk/headerbar.zig +++ b/src/apprt/gtk/headerbar.zig @@ -4,93 +4,58 @@ const c = @import("c.zig").c; const Window = @import("Window.zig"); const adwaita = @import("adwaita.zig"); -const AdwHeaderBar = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwHeaderBar else void; +const HeaderBarAdw = @import("headerbar_adw.zig"); +const HeaderBarGtk = @import("headerbar_gtk.zig"); pub const HeaderBar = union(enum) { - adw: *AdwHeaderBar, - gtk: *c.GtkHeaderBar, + adw: HeaderBarAdw, + gtk: HeaderBarGtk, - pub fn init(window: *Window) HeaderBar { - if ((comptime adwaita.versionAtLeast(1, 4, 0)) and - adwaita.enabled(&window.app.config)) - { - return initAdw(window); + pub fn init(self: *HeaderBar) void { + const window: *Window = @fieldParentPtr("headerbar", self); + if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&window.app.config)) { + HeaderBarAdw.init(self); + } else { + HeaderBarGtk.init(self); } - return initGtk(); - } - - fn initAdw(window: *Window) HeaderBar { - const headerbar = c.adw_header_bar_new(); - c.adw_header_bar_set_title_widget(@ptrCast(headerbar), @ptrCast(c.adw_window_title_new(c.gtk_window_get_title(window.window) orelse "Ghostty", null))); - return .{ .adw = @ptrCast(headerbar) }; - } - - fn initGtk() HeaderBar { - const headerbar = c.gtk_header_bar_new(); - return .{ .gtk = @ptrCast(headerbar) }; + if (!window.app.config.@"gtk-titlebar" or !window.app.config.@"window-decoration") + self.setVisible(false); } pub fn setVisible(self: HeaderBar, visible: bool) void { - c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); + switch (self) { + inline else => |v| v.setVisible(visible), + } } pub fn asWidget(self: HeaderBar) *c.GtkWidget { return switch (self) { - .adw => |headerbar| @ptrCast(@alignCast(headerbar)), - .gtk => |headerbar| @ptrCast(@alignCast(headerbar)), + inline else => |v| v.asWidget(), }; } pub fn packEnd(self: HeaderBar, widget: *c.GtkWidget) void { switch (self) { - .adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { - c.adw_header_bar_pack_end( - @ptrCast(@alignCast(headerbar)), - widget, - ); - }, - .gtk => |headerbar| c.gtk_header_bar_pack_end( - @ptrCast(@alignCast(headerbar)), - widget, - ), + inline else => |v| v.packEnd(widget), } } pub fn packStart(self: HeaderBar, widget: *c.GtkWidget) void { switch (self) { - .adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { - c.adw_header_bar_pack_start( - @ptrCast(@alignCast(headerbar)), - widget, - ); - }, - .gtk => |headerbar| c.gtk_header_bar_pack_start( - @ptrCast(@alignCast(headerbar)), - widget, - ), + inline else => |v| v.packStart(widget), } } pub fn setTitle(self: HeaderBar, title: [:0]const u8) void { switch (self) { - .adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { - const window_title: *c.AdwWindowTitle = @ptrCast(c.adw_header_bar_get_title_widget(@ptrCast(headerbar))); - c.adw_window_title_set_title(window_title, title); - }, - // The title is owned by the window when not using Adwaita - .gtk => unreachable, + inline else => |v| v.setTitle(title), } } pub fn setSubtitle(self: HeaderBar, subtitle: [:0]const u8) void { switch (self) { - .adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { - const window_title: *c.AdwWindowTitle = @ptrCast(c.adw_header_bar_get_title_widget(@ptrCast(headerbar))); - c.adw_window_title_set_subtitle(window_title, subtitle); - }, - // There is no subtitle unless Adwaita is used - .gtk => unreachable, + inline else => |v| v.setSubtitle(subtitle), } } }; diff --git a/src/apprt/gtk/headerbar_adw.zig b/src/apprt/gtk/headerbar_adw.zig new file mode 100644 index 000000000..c0d622207 --- /dev/null +++ b/src/apprt/gtk/headerbar_adw.zig @@ -0,0 +1,77 @@ +const HeaderBarAdw = @This(); + +const std = @import("std"); +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const adwaita = @import("adwaita.zig"); + +const HeaderBar = @import("headerbar.zig").HeaderBar; + +const AdwHeaderBar = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwHeaderBar else anyopaque; +const AdwWindowTitle = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwWindowTitle else anyopaque; + +/// the window that this headerbar is attached to +window: *Window, +/// the Adwaita headerbar widget +headerbar: *AdwHeaderBar, +/// the Adwaita window title widget +title: *AdwWindowTitle, + +pub fn init(headerbar: *HeaderBar) void { + if (!adwaita.versionAtLeast(0, 0, 0)) return; + + const window: *Window = @fieldParentPtr("headerbar", headerbar); + headerbar.* = .{ + .adw = .{ + .window = window, + .headerbar = @ptrCast(@alignCast(c.adw_header_bar_new())), + .title = @ptrCast(@alignCast(c.adw_window_title_new( + c.gtk_window_get_title(window.window) orelse "Ghostty", + null, + ))), + }, + }; + c.adw_header_bar_set_title_widget( + headerbar.adw.headerbar, + @ptrCast(@alignCast(headerbar.adw.title)), + ); +} + +pub fn setVisible(self: HeaderBarAdw, visible: bool) void { + c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); +} + +pub fn asWidget(self: HeaderBarAdw) *c.GtkWidget { + return @ptrCast(@alignCast(self.headerbar)); +} + +pub fn packEnd(self: HeaderBarAdw, widget: *c.GtkWidget) void { + if (comptime adwaita.versionAtLeast(0, 0, 0)) { + c.adw_header_bar_pack_end( + @ptrCast(@alignCast(self.headerbar)), + widget, + ); + } +} + +pub fn packStart(self: HeaderBarAdw, widget: *c.GtkWidget) void { + if (comptime adwaita.versionAtLeast(0, 0, 0)) { + c.adw_header_bar_pack_start( + @ptrCast(@alignCast(self.headerbar)), + widget, + ); + } +} + +pub fn setTitle(self: HeaderBarAdw, title: [:0]const u8) void { + if (comptime adwaita.versionAtLeast(0, 0, 0)) { + c.adw_window_title_set_title(self.title, title); + } +} + +pub fn setSubtitle(self: HeaderBarAdw, subtitle: [:0]const u8) void { + if (comptime adwaita.versionAtLeast(0, 0, 0)) { + c.adw_window_title_set_subtitle(self.title, subtitle); + } +} diff --git a/src/apprt/gtk/headerbar_gtk.zig b/src/apprt/gtk/headerbar_gtk.zig new file mode 100644 index 000000000..63ba8b389 --- /dev/null +++ b/src/apprt/gtk/headerbar_gtk.zig @@ -0,0 +1,52 @@ +const HeaderBarGtk = @This(); + +const std = @import("std"); +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const adwaita = @import("adwaita.zig"); + +const HeaderBar = @import("headerbar.zig").HeaderBar; + +/// the window that this headarbar is attached to +window: *Window, +/// the GTK headerbar widget +headerbar: *c.GtkHeaderBar, + +pub fn init(headerbar: *HeaderBar) void { + const window: *Window = @fieldParentPtr("headerbar", headerbar); + headerbar.* = .{ + .gtk = .{ + .window = window, + .headerbar = @ptrCast(c.gtk_header_bar_new()), + }, + }; +} + +pub fn setVisible(self: HeaderBarGtk, visible: bool) void { + c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); +} + +pub fn asWidget(self: HeaderBarGtk) *c.GtkWidget { + return @ptrCast(@alignCast(self.headerbar)); +} + +pub fn packEnd(self: HeaderBarGtk, widget: *c.GtkWidget) void { + c.gtk_header_bar_pack_end( + @ptrCast(@alignCast(self.headerbar)), + widget, + ); +} + +pub fn packStart(self: HeaderBarGtk, widget: *c.GtkWidget) void { + c.gtk_header_bar_pack_start( + @ptrCast(@alignCast(self.headerbar)), + widget, + ); +} + +pub fn setTitle(self: HeaderBarGtk, title: [:0]const u8) void { + c.gtk_window_set_title(self.window.window, title); +} + +pub fn setSubtitle(_: HeaderBarGtk, _: [:0]const u8) void {} From d26c114b5d013d311929be177f97d2ce46617580 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 12:10:26 -0800 Subject: [PATCH 13/27] apprt/gtk: make sure noop winproto never initializes --- src/apprt/gtk/App.zig | 1 + src/apprt/gtk/winproto.zig | 2 +- src/apprt/gtk/winproto/noop.zig | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index b041d29fb..6fa98a011 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -373,6 +373,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { &config, ); errdefer winproto_app.deinit(core_app.alloc); + log.debug("windowing protocol={s}", .{@tagName(winproto_app)}); // This just calls the `activate` signal but its part of the normal startup // routine so we just call it, but only if the config allows it (this allows diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig index 49d96bb02..cb873fe01 100644 --- a/src/apprt/gtk/winproto.zig +++ b/src/apprt/gtk/winproto.zig @@ -40,7 +40,7 @@ pub const App = union(Protocol) { } } - return .none; + return .{ .none = .{} }; } pub fn deinit(self: *App, alloc: Allocator) void { diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig index 54c14fe14..14f3dc6a7 100644 --- a/src/apprt/gtk/winproto/noop.zig +++ b/src/apprt/gtk/winproto/noop.zig @@ -13,7 +13,7 @@ pub const App = struct { _: [:0]const u8, _: *const Config, ) !?App { - return .{}; + return null; } pub fn deinit(self: *App, alloc: Allocator) void { From 2fb0d99f00a832430e98d55e1b3198a51ca6c9da Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 12:56:17 -0800 Subject: [PATCH 14/27] ci: add required checks jobs This is a hack to make it easier for our GitHub branching rules to require a single check to pass before merging. This lets us describe the required checks in code rather than via the GH UI. --- .github/workflows/nix.yml | 9 +++++++++ .github/workflows/test.yml | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index d5ee328e5..d557fcebd 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -1,6 +1,15 @@ on: [push, pull_request] name: Nix jobs: + required: + name: Required Checks + runs-on: namespace-profile-ghostty-sm + needs: + - check-zig-cache-hash + steps: + - name: Noop + run: echo "Required Checks Met" + check-zig-cache-hash: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-sm diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e021af64..150901cb6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,29 @@ on: name: Test jobs: + required: + name: Required Checks + runs-on: namespace-profile-ghostty-sm + needs: + - build + - build-bench + - build-linux-libghostty + - build-nix + - build-macos + - build-macos-matrix + - build-windows + - test + - test-gtk + - test-sentry-linux + - test-macos + - prettier + - alejandra + - typos + - test-pkg-linux + steps: + - name: Noop + run: echo "Required Checks Met" + build: strategy: fail-fast: false From 13e96c7ec86718dcec2066cf60e7ecbf7011ec17 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Jan 2025 18:49:24 -0600 Subject: [PATCH 15/27] gtk: add config option to disable GTK OpenGL debug logging --- src/apprt/gtk/App.zig | 125 ++++++++++++++++++++++++++++++------------ src/config/Config.zig | 9 +++ 2 files changed, 98 insertions(+), 36 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 6fa98a011..ba01236cc 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -104,42 +104,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { c.gtk_get_micro_version(), }); - // Disabling Vulkan can improve startup times by hundreds of - // milliseconds on some systems. We don't use Vulkan so we can just - // disable it. - if (version.runtimeAtLeast(4, 16, 0)) { - // From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE. - // For the remainder of "why" see the 4.14 comment below. - _ = internal_os.setenv("GDK_DISABLE", "gles-api,vulkan"); - _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-no-fractional"); - } else if (version.runtimeAtLeast(4, 14, 0)) { - // We need to export GDK_DEBUG to run on Wayland after GTK 4.14. - // Older versions of GTK do not support these values so it is safe - // to always set this. Forwards versions are uncertain so we'll have to - // reassess... - // - // Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589 - // - // Specific details about values: - // - "opengl" - output OpenGL debug information - // - "gl-disable-gles" - disable GLES, Ghostty can't use GLES - // - "vulkan-disable" - disable Vulkan, Ghostty can't use Vulkan - // and initializing a Vulkan context was causing a longer delay - // on some systems. - _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable,gl-no-fractional"); - } else { - // Versions prior to 4.14 are a bit of an unknown for Ghostty. It - // is an environment that isn't tested well and we don't have a - // good understanding of what we may need to do. - _ = internal_os.setenv("GDK_DEBUG", "vulkan-disable"); - } - - if (version.runtimeAtLeast(4, 14, 0)) { - // We need to export GSK_RENDERER to opengl because GTK uses ngl by - // default after 4.14 - _ = internal_os.setenv("GSK_RENDERER", "opengl"); - } - // Load our configuration var config = try Config.load(core_app.alloc); errdefer config.deinit(); @@ -161,6 +125,95 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { } } + var gdk_debug: struct { + /// output OpenGL debug information + opengl: bool = false, + /// disable GLES, Ghostty can't use GLES + @"gl-disable-gles": bool = false, + @"gl-no-fractional": bool = false, + /// Disabling Vulkan can improve startup times by hundreds of + /// milliseconds on some systems. We don't use Vulkan so we can just + /// disable it. + @"vulkan-disable": bool = false, + } = .{ + .opengl = config.@"gtk-opengl-debug", + }; + + var gdk_disable: struct { + @"gles-api": bool = false, + /// Disabling Vulkan can improve startup times by hundreds of + /// milliseconds on some systems. We don't use Vulkan so we can just + /// disable it. + vulkan: bool = false, + } = .{}; + + environment: { + if (version.runtimeAtLeast(4, 16, 0)) { + // From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE. + // For the remainder of "why" see the 4.14 comment below. + gdk_disable.@"gles-api" = true; + gdk_disable.vulkan = true; + gdk_debug.@"gl-no-fractional" = true; + break :environment; + } + if (version.runtimeAtLeast(4, 14, 0)) { + // We need to export GDK_DEBUG to run on Wayland after GTK 4.14. + // Older versions of GTK do not support these values so it is safe + // to always set this. Forwards versions are uncertain so we'll have + // to reassess... + // + // Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589 + gdk_debug.@"gl-disable-gles" = true; + gdk_debug.@"gl-no-fractional" = true; + gdk_debug.@"vulkan-disable" = true; + break :environment; + } + // Versions prior to 4.14 are a bit of an unknown for Ghostty. It + // is an environment that isn't tested well and we don't have a + // good understanding of what we may need to do. + gdk_debug.@"vulkan-disable" = true; + } + + { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + const writer = fmt.writer(); + var first: bool = true; + inline for (@typeInfo(@TypeOf(gdk_debug)).Struct.fields) |field| { + if (@field(gdk_debug, field.name)) { + if (!first) try writer.writeAll(","); + try writer.writeAll(field.name); + first = false; + } + } + try writer.writeByte(0); + log.warn("setting GDK_DEBUG={s}", .{fmt.getWritten()}); + _ = internal_os.setenv("GDK_DEBUG", buf[0 .. fmt.pos - 1 :0]); + } + + { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + const writer = fmt.writer(); + var first: bool = true; + inline for (@typeInfo(@TypeOf(gdk_disable)).Struct.fields) |field| { + if (@field(gdk_disable, field.name)) { + if (!first) try writer.writeAll(","); + try writer.writeAll(field.name); + first = false; + } + } + try writer.writeByte(0); + log.warn("setting GDK_DISABLE={s}", .{fmt.getWritten()}); + _ = internal_os.setenv("GDK_DISABLE", buf[0 .. fmt.pos - 1 :0]); + } + + if (version.runtimeAtLeast(4, 14, 0)) { + // We need to export GSK_RENDERER to opengl because GTK uses ngl by + // default after 4.14 + _ = internal_os.setenv("GSK_RENDERER", "opengl"); + } + c.gtk_init(); const display: *c.GdkDisplay = c.gdk_display_get_default() orelse { // I'm unsure of any scenario where this happens. Because we don't diff --git a/src/config/Config.zig b/src/config/Config.zig index 1de0ddaad..6c5d0b1e2 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1975,6 +1975,15 @@ keybind: Keybinds = .{}, /// must always be able to move themselves into an isolated cgroup. @"linux-cgroup-hard-fail": bool = false, +/// Enable or disable GTK's OpenGL debugging logs. The default depends on the +/// optimization level that Ghostty was built with: +/// +/// - `Debug`: `true` +/// - `ReleaseSafe`: `true` +/// - `ReleaseSmall`: `true` +/// - `ReleaseFast`: `false` +@"gtk-opengl-debug": bool = build_config.slow_runtime_safety, + /// If `true`, the Ghostty GTK application will run in single-instance mode: /// each new `ghostty` process launched will result in a new window if there is /// already a running process. From 06a57842af1c3c71d6103e63fe4321eda1c6a556 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Jan 2025 19:27:53 -0600 Subject: [PATCH 16/27] gtk: add config option to control GSK_RENDERER env var --- src/apprt/gtk/App.zig | 13 ++++++++++--- src/config/Config.zig | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index ba01236cc..10b8f756a 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -209,9 +209,16 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { } if (version.runtimeAtLeast(4, 14, 0)) { - // We need to export GSK_RENDERER to opengl because GTK uses ngl by - // default after 4.14 - _ = internal_os.setenv("GSK_RENDERER", "opengl"); + switch (config.@"gtk-gsk-renderer") { + .default => {}, + else => |renderer| { + // Force the GSK renderer to a specific value. After GTK 4.14 the + // `ngl` renderer is used by default which causes artifacts when + // used with Ghostty so it should be avoided. + log.warn("setting GSK_RENDERER={s}", .{@tagName(renderer)}); + _ = internal_os.setenv("GSK_RENDERER", @tagName(renderer)); + }, + } } c.gtk_init(); diff --git a/src/config/Config.zig b/src/config/Config.zig index 6c5d0b1e2..6c10213e8 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1984,6 +1984,14 @@ keybind: Keybinds = .{}, /// - `ReleaseFast`: `false` @"gtk-opengl-debug": bool = build_config.slow_runtime_safety, +/// After GTK 4.14.0, we need to force the GSK renderer to OpenGL as the default +/// GSK renderer is broken on some systems. If you would like to override +/// that bekavior, set `gtk-gsk-renderer=default` and either use your system's +/// default GSK renderer, or set the GSK_RENDERER environment variable to your +/// renderer of choice before launching Ghostty. This setting has no effect when +/// using versions of GTK earlier than 4.14.0. +@"gtk-gsk-renderer": GtkGskRenderer = .opengl, + /// If `true`, the Ghostty GTK application will run in single-instance mode: /// each new `ghostty` process launched will result in a new window if there is /// already a running process. @@ -6167,6 +6175,12 @@ pub const WindowPadding = struct { } }; +/// See the `gtk-gsk-renderer` config. +pub const GtkGskRenderer = enum { + default, + opengl, +}; + test "parse duration" { inline for (Duration.units) |unit| { var buf: [16]u8 = undefined; From cd638588c4e8b0dc9420878aa348e7f19a4995c6 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 8 Jan 2025 08:34:47 -0600 Subject: [PATCH 17/27] gtk: better method for setting GDK env vars --- src/apprt/gtk/App.zig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 10b8f756a..70fc182e5 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -187,8 +187,9 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { } } try writer.writeByte(0); - log.warn("setting GDK_DEBUG={s}", .{fmt.getWritten()}); - _ = internal_os.setenv("GDK_DEBUG", buf[0 .. fmt.pos - 1 :0]); + const value = fmt.getWritten(); + log.warn("setting GDK_DEBUG={s}", .{value[0 .. value.len - 1]}); + _ = internal_os.setenv("GDK_DEBUG", value[0 .. value.len - 1 :0]); } { @@ -204,8 +205,9 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { } } try writer.writeByte(0); - log.warn("setting GDK_DISABLE={s}", .{fmt.getWritten()}); - _ = internal_os.setenv("GDK_DISABLE", buf[0 .. fmt.pos - 1 :0]); + const value = fmt.getWritten(); + log.warn("setting GDK_DISABLE={s}", .{value[0 .. value.len - 1]}); + _ = internal_os.setenv("GDK_DISABLE", value[0 .. value.len - 1 :0]); } if (version.runtimeAtLeast(4, 14, 0)) { From 6237377a59df054657105530271d450bf29523a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 13:22:29 -0800 Subject: [PATCH 18/27] ci: avoid "successful failure" of status check job by inspecting needs Thanks to @ryanec for this tip. --- .github/workflows/nix.yml | 20 ++++++++++++++++++-- .github/workflows/test.yml | 20 ++++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index d557fcebd..ced1df6df 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -7,8 +7,24 @@ jobs: needs: - check-zig-cache-hash steps: - - name: Noop - run: echo "Required Checks Met" + - id: status + name: Determine status + run: | + results=$(tr -d '\n' <<< '${{ toJSON(needs.*.result) }}') + if ! grep -q -v -E '(failure|cancelled)' <<< "$results"; then + result="failed" + else + result="success" + fi + { + echo "result=${result}" + echo "results=${results}" + } | tee -a "$GITHUB_OUTPUT" + - if: always() && steps.status.outputs.result != 'success' + name: Check for failed status + run: | + echo "One or more required build workflows failed: ${{ steps.status.outputs.results }}" + exit 1 check-zig-cache-hash: if: github.repository == 'ghostty-org/ghostty' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 150901cb6..1436339f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,8 +26,24 @@ jobs: - typos - test-pkg-linux steps: - - name: Noop - run: echo "Required Checks Met" + - id: status + name: Determine status + run: | + results=$(tr -d '\n' <<< '${{ toJSON(needs.*.result) }}') + if ! grep -q -v -E '(failure|cancelled)' <<< "$results"; then + result="failed" + else + result="success" + fi + { + echo "result=${result}" + echo "results=${results}" + } | tee -a "$GITHUB_OUTPUT" + - if: always() && steps.status.outputs.result != 'success' + name: Check for failed status + run: | + echo "One or more required build workflows failed: ${{ steps.status.outputs.results }}" + exit 1 build: strategy: From f5add68100b45880105db8126868a8482c83d8df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 13:31:21 -0800 Subject: [PATCH 19/27] ci: required checks must be named separately --- .github/workflows/nix.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index ced1df6df..3339ee71c 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -2,7 +2,7 @@ on: [push, pull_request] name: Nix jobs: required: - name: Required Checks + name: "Required Checks: Nix" runs-on: namespace-profile-ghostty-sm needs: - check-zig-cache-hash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1436339f1..0f32162a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ name: Test jobs: required: - name: Required Checks + name: "Required Checks: Test" runs-on: namespace-profile-ghostty-sm needs: - build From 96e427cd6a86b765ba411135789319f3b8501902 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 10 Jan 2025 15:48:20 -0600 Subject: [PATCH 20/27] gtk: default to opengl debugging only on debug builds --- src/config/Config.zig | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6c10213e8..144796554 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1975,14 +1975,9 @@ keybind: Keybinds = .{}, /// must always be able to move themselves into an isolated cgroup. @"linux-cgroup-hard-fail": bool = false, -/// Enable or disable GTK's OpenGL debugging logs. The default depends on the -/// optimization level that Ghostty was built with: -/// -/// - `Debug`: `true` -/// - `ReleaseSafe`: `true` -/// - `ReleaseSmall`: `true` -/// - `ReleaseFast`: `false` -@"gtk-opengl-debug": bool = build_config.slow_runtime_safety, +/// Enable or disable GTK's OpenGL debugging logs. The default is `true` for +/// debug builds, `false` for all others. +@"gtk-opengl-debug": bool = builtin.mode == .Debug, /// After GTK 4.14.0, we need to force the GSK renderer to OpenGL as the default /// GSK renderer is broken on some systems. If you would like to override From 4dd9fe5cfd53bd60760ce74225c2065625594a89 Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Tue, 7 Jan 2025 22:54:02 +0100 Subject: [PATCH 21/27] fix: ensure terminal tabs are reconstructed in main window after toggling visibility --- macos/Sources/App/macOS/AppDelegate.swift | 38 +++++++++++++++---- .../Features/Terminal/TerminalManager.swift | 2 +- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index a102beb91..eb9734f6c 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -706,20 +706,42 @@ class AppDelegate: NSObject, /// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application @IBAction func toggleVisibility(_ sender: Any) { - // We only care about terminal windows. - for window in NSApp.windows.filter({ $0.windowController is BaseTerminalController }) { - if isVisible { - window.orderOut(nil) - } else { - window.makeKeyAndOrderFront(nil) + if let mainWindow = terminalManager.mainWindow { + guard let parent = mainWindow.controller.window else { + Self.logger.debug("could not get parent window") + return + } + + guard let controller = parent.windowController as? TerminalController, + let primaryWindow = controller.window else { + Self.logger.debug("Could not retrieve primary window") + return + } + + // Fetch all terminal windows controlled by BaseTerminalController + for terminalWindow in NSApp.windows.filter({ $0.windowController is BaseTerminalController }) { + if isVisible { + terminalWindow.orderOut(nil) + } else { + primaryWindow.makeKeyAndOrderFront(nil) + primaryWindow.addTabbedWindow(terminalWindow, ordered: .above) + } + } + + // If our parent tab group already has this window, macOS added it and + // we need to remove it so we can set the correct order in the next line. + // If we don't do this, macOS gets really confused and the tabbedWindows + // state becomes incorrect. + if let tg = parent.tabGroup, tg.windows.firstIndex(of: parent) != nil { + tg.removeWindow(parent) } } - + // After bringing them all to front we make sure our app is active too. if !isVisible { NSApp.activate(ignoringOtherApps: true) } - + isVisible.toggle() } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 42e35b90e..82a5978c7 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -26,7 +26,7 @@ class TerminalManager { /// Returns the main window of the managed window stack. If there is no window /// then an arbitrary window will be chosen. - private var mainWindow: Window? { + var mainWindow: Window? { for window in windows { if (window.controller.window?.isMainWindow ?? false) { return window From 3a5aecc216290cb0f5a50da9808d34bb9ae5bec5 Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Thu, 9 Jan 2025 23:14:00 +0100 Subject: [PATCH 22/27] fix: hide windows without calling orderOut API --- macos/Sources/App/macOS/AppDelegate.swift | 33 +++++------------------ 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index eb9734f6c..776ada63e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -706,34 +706,13 @@ class AppDelegate: NSObject, /// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application @IBAction func toggleVisibility(_ sender: Any) { - if let mainWindow = terminalManager.mainWindow { - guard let parent = mainWindow.controller.window else { - Self.logger.debug("could not get parent window") - return + if isVisible { + NSApp.windows.forEach { window in + window.alphaValue = 0.0 } - - guard let controller = parent.windowController as? TerminalController, - let primaryWindow = controller.window else { - Self.logger.debug("Could not retrieve primary window") - return - } - - // Fetch all terminal windows controlled by BaseTerminalController - for terminalWindow in NSApp.windows.filter({ $0.windowController is BaseTerminalController }) { - if isVisible { - terminalWindow.orderOut(nil) - } else { - primaryWindow.makeKeyAndOrderFront(nil) - primaryWindow.addTabbedWindow(terminalWindow, ordered: .above) - } - } - - // If our parent tab group already has this window, macOS added it and - // we need to remove it so we can set the correct order in the next line. - // If we don't do this, macOS gets really confused and the tabbedWindows - // state becomes incorrect. - if let tg = parent.tabGroup, tg.windows.firstIndex(of: parent) != nil { - tg.removeWindow(parent) + } else { + NSApp.windows.forEach { window in + window.alphaValue = 1.0 } } From 61a78efa83d176c2a81e590425f52f962125c34f Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Thu, 9 Jan 2025 23:15:06 +0100 Subject: [PATCH 23/27] chore: revert on TerminalManager changes --- macos/Sources/Features/Terminal/TerminalManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 82a5978c7..42e35b90e 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -26,7 +26,7 @@ class TerminalManager { /// Returns the main window of the managed window stack. If there is no window /// then an arbitrary window will be chosen. - var mainWindow: Window? { + private var mainWindow: Window? { for window in windows { if (window.controller.window?.isMainWindow ?? false) { return window From 200aee9acf0a4b4ec4d4f57cde12443cef257448 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 14:35:43 -0800 Subject: [PATCH 24/27] macos: rework toggle_visibility to better match iTerm2 Two major changes: 1. Hiding uses `NSApp.hide` which hides all windows, preserves tabs, and yields focus to the next app. 2. Unhiding manually tracks and brings forward only the windows we hid. Proper focus should be retained. --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++ macos/Sources/App/macOS/AppDelegate.swift | 58 ++++++++++++----------- macos/Sources/Helpers/Weak.swift | 9 ++++ src/input/Binding.zig | 6 +-- 4 files changed, 47 insertions(+), 30 deletions(-) create mode 100644 macos/Sources/Helpers/Weak.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 3fa67c48a..efa4a07c9 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; + A5CA378E2D31D6C300931030 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378D2D31D6C100931030 /* Weak.swift */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; @@ -167,6 +168,7 @@ A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; + A5CA378D2D31D6C100931030 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = ""; }; @@ -282,6 +284,7 @@ AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */, A5CC36142C9CDA03004D6760 /* View+Extension.swift */, + A5CA378D2D31D6C100931030 /* Weak.swift */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, C1F26EE82B76CBFC00404083 /* VibrantLayer.m */, A5CEAFDA29B8005900646FDA /* SplitView */, @@ -647,6 +650,7 @@ A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */, A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, + A5CA378E2D31D6C300931030 /* Weak.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 776ada63e..4b11b68aa 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -92,10 +92,8 @@ class AppDelegate: NSObject, return ProcessInfo.processInfo.systemUptime - applicationLaunchTime } - /// Tracks whether the application is currently visible. This can be gamed, i.e. if a user manually - /// brings each window one by one to the front. But at worst its off by one set of toggles and this - /// makes our logic very easy. - private var isVisible: Bool = true + /// Tracks the windows that we hid for toggleVisibility. + private var hiddenWindows: [Weak] = [] /// The observer for the app appearance. private var appearanceObserver: NSKeyValueObservation? = nil @@ -219,15 +217,20 @@ class AppDelegate: NSObject, } func applicationDidBecomeActive(_ notification: Notification) { - guard !applicationHasBecomeActive else { return } - applicationHasBecomeActive = true + // If we're back then clear the hidden windows + self.hiddenWindows = [] - // Let's launch our first window. We only do this if we have no other windows. It - // is possible to have other windows in a few scenarios: - // - if we're opening a URL since `application(_:openFile:)` is called before this. - // - if we're restoring from persisted state - if terminalManager.windows.count == 0 && derivedConfig.initialWindow { - terminalManager.newWindow() + // First launch stuff + if (!applicationHasBecomeActive) { + applicationHasBecomeActive = true + + // Let's launch our first window. We only do this if we have no other windows. It + // is possible to have other windows in a few scenarios: + // - if we're opening a URL since `application(_:openFile:)` is called before this. + // - if we're restoring from persisted state + if terminalManager.windows.count == 0 && derivedConfig.initialWindow { + terminalManager.newWindow() + } } } @@ -706,22 +709,23 @@ class AppDelegate: NSObject, /// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application @IBAction func toggleVisibility(_ sender: Any) { - if isVisible { - NSApp.windows.forEach { window in - window.alphaValue = 0.0 - } - } else { - NSApp.windows.forEach { window in - window.alphaValue = 1.0 - } + // If we have focus, then we hide all windows. + if NSApp.isActive { + // We need to keep track of the windows that were visible because we only + // want to bring back these windows if we remove the toggle. + self.hiddenWindows = NSApp.windows.filter { $0.isVisible }.map { Weak($0) } + NSApp.hide(nil) + return } - - // After bringing them all to front we make sure our app is active too. - if !isVisible { - NSApp.activate(ignoringOtherApps: true) - } - - isVisible.toggle() + + // If we're not active, we want to become active + NSApp.activate(ignoringOtherApps: true) + + // Bring all windows to the front. Note: we don't use NSApp.unhide because + // that will unhide ALL hidden windows. We want to only bring forward the + // ones that we hid. + self.hiddenWindows.forEach { $0.value?.orderFrontRegardless() } + self.hiddenWindows = [] } private struct DerivedConfig { diff --git a/macos/Sources/Helpers/Weak.swift b/macos/Sources/Helpers/Weak.swift new file mode 100644 index 000000000..d5f784844 --- /dev/null +++ b/macos/Sources/Helpers/Weak.swift @@ -0,0 +1,9 @@ +/// A wrapper that holds a weak reference to an object. This lets us create native containers +/// of weak references. +class Weak { + weak var value: T? + + init(_ value: T) { + self.value = value + } +} diff --git a/src/input/Binding.zig b/src/input/Binding.zig index c5faaad06..2fdbc4cba 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -441,10 +441,10 @@ pub const Action = union(enum) { toggle_quick_terminal: void, /// Show/hide all windows. If all windows become shown, we also ensure - /// Ghostty is focused. + /// Ghostty becomes focused. When hiding all windows, focus is yielded + /// to the next application as determined by the OS. /// - /// This currently only works on macOS. When hiding all windows, we do - /// not yield focus to the previous application. + /// This currently only works on macOS. toggle_visibility: void, /// Quit ghostty. From b7b5b9bbf5f570f4669c92e61322469a825d0b33 Mon Sep 17 00:00:00 2001 From: Leigh Oliver Date: Tue, 31 Dec 2024 23:40:49 +0000 Subject: [PATCH 25/27] fix(gtk): add close confirmation for tabs --- src/apprt/gtk/Tab.zig | 67 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index ed0804fd3..1a3b44136 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -121,10 +121,71 @@ pub fn remove(self: *Tab) void { self.window.closeTab(self); } +/// Helper function to check if any surface in the split hierarchy needs close confirmation +const needsConfirm = struct { + fn check(elem: Surface.Container.Elem) bool { + return switch (elem) { + .surface => |s| s.core_surface.needsConfirmQuit(), + .split => |s| check(s.top_left) or check(s.bottom_right), + }; + } +}.check; + +/// Close the tab, asking for confirmation if any surface requests it. +fn closeWithConfirmation(tab: *Tab) void { + switch (tab.elem) { + .surface => |s| s.close(s.core_surface.needsConfirmQuit()), + .split => |s| { + if (needsConfirm(s.top_left) or needsConfirm(s.bottom_right)) { + const alert = c.gtk_message_dialog_new( + tab.window.window, + c.GTK_DIALOG_MODAL, + c.GTK_MESSAGE_QUESTION, + c.GTK_BUTTONS_YES_NO, + "Close this tab?", + ); + c.gtk_message_dialog_format_secondary_text( + @ptrCast(alert), + "All terminal sessions in this tab will be terminated.", + ); + + // We want the "yes" to appear destructive. + const yes_widget = c.gtk_dialog_get_widget_for_response( + @ptrCast(alert), + c.GTK_RESPONSE_YES, + ); + c.gtk_widget_add_css_class(yes_widget, "destructive-action"); + + // We want the "no" to be the default action + c.gtk_dialog_set_default_response( + @ptrCast(alert), + c.GTK_RESPONSE_NO, + ); + + _ = c.g_signal_connect_data(alert, "response", c.G_CALLBACK(>kTabCloseConfirmation), tab, null, c.G_CONNECT_DEFAULT); + c.gtk_widget_show(alert); + return; + } + tab.remove(); + }, + } +} + pub fn gtkTabCloseClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { const tab: *Tab = @ptrCast(@alignCast(ud)); - const window = tab.window; - window.closeTab(tab); + tab.closeWithConfirmation(); +} + +fn gtkTabCloseConfirmation( + alert: *c.GtkMessageDialog, + response: c.gint, + ud: ?*anyopaque, +) callconv(.C) void { + c.gtk_window_destroy(@ptrCast(alert)); + if (response == c.GTK_RESPONSE_YES) { + const tab: *Tab = @ptrCast(@alignCast(ud)); + tab.remove(); + } } fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { @@ -146,6 +207,6 @@ pub fn gtkTabClick( const self: *Tab = @ptrCast(@alignCast(ud)); const gtk_button = c.gtk_gesture_single_get_current_button(@ptrCast(gesture)); if (gtk_button == c.GDK_BUTTON_MIDDLE) { - self.remove(); + self.closeWithConfirmation(); } } From 8c1ad59de761153023fba73913bb168df91c55d5 Mon Sep 17 00:00:00 2001 From: Leigh Oliver Date: Wed, 1 Jan 2025 10:14:16 +0000 Subject: [PATCH 26/27] remove unnecessary struct --- src/apprt/gtk/Tab.zig | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index 1a3b44136..6e28b8644 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -122,14 +122,12 @@ pub fn remove(self: *Tab) void { } /// Helper function to check if any surface in the split hierarchy needs close confirmation -const needsConfirm = struct { - fn check(elem: Surface.Container.Elem) bool { - return switch (elem) { - .surface => |s| s.core_surface.needsConfirmQuit(), - .split => |s| check(s.top_left) or check(s.bottom_right), - }; - } -}.check; +fn needsConfirm(elem: Surface.Container.Elem) bool { + return switch (elem) { + .surface => |s| s.core_surface.needsConfirmQuit(), + .split => |s| needsConfirm(s.top_left) or needsConfirm(s.bottom_right), + }; +} /// Close the tab, asking for confirmation if any surface requests it. fn closeWithConfirmation(tab: *Tab) void { From 00137c41895628cc6a068e40445b6670ae3a9012 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 15:32:25 -0800 Subject: [PATCH 27/27] apprt/gtk: adw tab view close confirmation --- src/apprt/gtk/Tab.zig | 28 ++++--------------------- src/apprt/gtk/notebook_adw.zig | 37 ++++++++++++++++++++++++++++++++++ src/apprt/gtk/notebook_gtk.zig | 23 +++++++++++++++++++-- 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index 6e28b8644..d320daa7c 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -130,7 +130,7 @@ fn needsConfirm(elem: Surface.Container.Elem) bool { } /// Close the tab, asking for confirmation if any surface requests it. -fn closeWithConfirmation(tab: *Tab) void { +pub fn closeWithConfirmation(tab: *Tab) void { switch (tab.elem) { .surface => |s| s.close(s.core_surface.needsConfirmQuit()), .split => |s| { @@ -169,21 +169,15 @@ fn closeWithConfirmation(tab: *Tab) void { } } -pub fn gtkTabCloseClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { - const tab: *Tab = @ptrCast(@alignCast(ud)); - tab.closeWithConfirmation(); -} - fn gtkTabCloseConfirmation( alert: *c.GtkMessageDialog, response: c.gint, ud: ?*anyopaque, ) callconv(.C) void { + const tab: *Tab = @ptrCast(@alignCast(ud)); c.gtk_window_destroy(@ptrCast(alert)); - if (response == c.GTK_RESPONSE_YES) { - const tab: *Tab = @ptrCast(@alignCast(ud)); - tab.remove(); - } + if (response != c.GTK_RESPONSE_YES) return; + tab.remove(); } fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { @@ -194,17 +188,3 @@ fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { const tab: *Tab = @ptrCast(@alignCast(ud)); tab.destroy(tab.window.app.core_app.alloc); } - -pub fn gtkTabClick( - gesture: *c.GtkGestureClick, - _: c.gint, - _: c.gdouble, - _: c.gdouble, - ud: ?*anyopaque, -) callconv(.C) void { - const self: *Tab = @ptrCast(@alignCast(ud)); - const gtk_button = c.gtk_gesture_single_get_current_button(@ptrCast(gesture)); - if (gtk_button == c.GDK_BUTTON_MIDDLE) { - self.closeWithConfirmation(); - } -} diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig index 48f005467..649db9be3 100644 --- a/src/apprt/gtk/notebook_adw.zig +++ b/src/apprt/gtk/notebook_adw.zig @@ -17,6 +17,14 @@ pub const NotebookAdw = struct { /// the tab view tab_view: *AdwTabView, + /// Set to true so that the adw close-page handler knows we're forcing + /// and to allow a close to happen with no confirm. This is a bit of a hack + /// because we currently use GTK alerts to confirm tab close and they + /// don't carry with them the ADW state that we are confirming or not. + /// Long term we should move to ADW alerts so we can know if we are + /// confirming or not. + forcing_close: bool = false, + pub fn init(notebook: *Notebook) void { const window: *Window = @fieldParentPtr("notebook", notebook); const app = window.app; @@ -38,6 +46,7 @@ pub const NotebookAdw = struct { }; _ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(tab_view, "close-page", c.G_CALLBACK(&adwClosePage), window, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), window, null, c.G_CONNECT_DEFAULT); } @@ -112,6 +121,12 @@ pub const NotebookAdw = struct { pub fn closeTab(self: *NotebookAdw, tab: *Tab) void { if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + // closeTab always expects to close unconditionally so we mark this + // as true so that the close_page call below doesn't request + // confirmation. + self.forcing_close = true; + defer self.forcing_close = false; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return; c.adw_tab_view_close_page(self.tab_view, page); @@ -143,6 +158,28 @@ fn adwPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaqu window.focusCurrentTab(); } +fn adwClosePage( + _: *AdwTabView, + page: *c.AdwTabPage, + ud: ?*anyopaque, +) callconv(.C) c.gboolean { + const child = c.adw_tab_page_get_child(page); + const tab: *Tab = @ptrCast(@alignCast(c.g_object_get_data( + @ptrCast(child), + Tab.GHOSTTY_TAB, + ) orelse return 0)); + + const window: *Window = @ptrCast(@alignCast(ud.?)); + const notebook = window.notebook.adw; + c.adw_tab_view_close_page_finish( + notebook.tab_view, + page, + @intFromBool(notebook.forcing_close), + ); + if (!notebook.forcing_close) tab.closeWithConfirmation(); + return 1; +} + fn adwTabViewCreateWindow( _: *AdwTabView, ud: ?*anyopaque, diff --git a/src/apprt/gtk/notebook_gtk.zig b/src/apprt/gtk/notebook_gtk.zig index a2c482500..5f145dc84 100644 --- a/src/apprt/gtk/notebook_gtk.zig +++ b/src/apprt/gtk/notebook_gtk.zig @@ -157,8 +157,8 @@ pub const NotebookGtk = struct { c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0); c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click)); - _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(>kTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(>kTabClick), tab, null, c.G_CONNECT_DEFAULT); // Tab settings c.gtk_notebook_set_tab_reorderable(self.notebook, box_widget, 1); @@ -283,3 +283,22 @@ fn gtkNotebookCreateWindow( return newWindow.notebook.gtk.notebook; } + +fn gtkTabCloseClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { + const tab: *Tab = @ptrCast(@alignCast(ud)); + tab.closeWithConfirmation(); +} + +fn gtkTabClick( + gesture: *c.GtkGestureClick, + _: c.gint, + _: c.gdouble, + _: c.gdouble, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *Tab = @ptrCast(@alignCast(ud)); + const gtk_button = c.gtk_gesture_single_get_current_button(@ptrCast(gesture)); + if (gtk_button == c.GDK_BUTTON_MIDDLE) { + self.closeWithConfirmation(); + } +}