From 7c9e913ca9f7770154add8ed4d53e91504bb904d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 10:42:23 -0700 Subject: [PATCH] apprt/gtk-ng: hook up surface initialization --- src/App.zig | 26 ++- src/Surface.zig | 5 +- src/apprt/embedded.zig | 8 + src/apprt/gtk-ng/App.zig | 10 ++ src/apprt/gtk-ng/Surface.zig | 45 +++-- src/apprt/gtk-ng/class/application.zig | 19 ++ src/apprt/gtk-ng/class/surface.zig | 235 +++++++++++++++++++++++-- src/apprt/gtk/Surface.zig | 8 + src/apprt/surface.zig | 8 +- src/renderer/OpenGL.zig | 10 +- 10 files changed, 338 insertions(+), 36 deletions(-) diff --git a/src/App.zig b/src/App.zig index 02089ae5b..28539c557 100644 --- a/src/App.zig +++ b/src/App.zig @@ -154,7 +154,7 @@ pub fn tick(self: *App, rt_app: *apprt.App) !void { pub fn updateConfig(self: *App, rt_app: *apprt.App, config: *const Config) !void { // Go through and update all of the surface configurations. for (self.surfaces.items) |surface| { - try surface.core_surface.handleMessage(.{ .change_config = config }); + try surface.core().handleMessage(.{ .change_config = config }); } // Apply our conditional state. If we fail to apply the conditional state @@ -190,7 +190,7 @@ pub fn addSurface( // Since we have non-zero surfaces, we can cancel the quit timer. // It is up to the apprt if there is a quit timer at all and if it // should be canceled. - _ = rt_surface.app.performAction( + _ = rt_surface.rtApp().performAction( .app, .quit_timer, .stop, @@ -207,7 +207,7 @@ pub fn deleteSurface(self: *App, rt_surface: *apprt.Surface) void { // just let focused surface be but the allocator was reusing addresses // after free and giving false positives, so we must clear it. if (self.focused_surface) |focused| { - if (focused == &rt_surface.core_surface) { + if (focused == rt_surface.core()) { self.focused_surface = null; } } @@ -224,7 +224,7 @@ pub fn deleteSurface(self: *App, rt_surface: *apprt.Surface) void { // If we have no surfaces, we can start the quit timer. It is up to the // apprt to determine if this is necessary. - if (self.surfaces.items.len == 0) _ = rt_surface.app.performAction( + if (self.surfaces.items.len == 0) _ = rt_surface.rtApp().performAction( .app, .quit_timer, .start, @@ -245,7 +245,7 @@ pub fn focusedSurface(self: *const App) ?*Surface { /// the apprt to call this. pub fn needsConfirmQuit(self: *const App) bool { for (self.surfaces.items) |v| { - if (v.core_surface.needsConfirmQuit()) return true; + if (v.core().needsConfirmQuit()) return true; } return false; @@ -287,12 +287,12 @@ pub fn focusSurface(self: *App, surface: *Surface) void { } fn redrawSurface(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) void { - if (!self.hasSurface(&surface.core_surface)) return; + if (!self.hasRtSurface(surface)) return; rt_app.redrawSurface(surface); } fn redrawInspector(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) void { - if (!self.hasSurface(&surface.core_surface)) return; + if (!self.hasRtSurface(surface)) return; rt_app.redrawInspector(surface); } @@ -482,7 +482,7 @@ pub fn performAllAction( // Surface-scoped actions are performed on all surfaces. Errors // are logged but processing continues. .surface => for (self.surfaces.items) |surface| { - _ = surface.core_surface.performBindingAction(action) catch |err| { + _ = surface.core().performBindingAction(action) catch |err| { log.warn("error performing binding action on surface ptr={X} err={}", .{ @intFromPtr(surface), err, @@ -507,7 +507,15 @@ fn surfaceMessage(self: *App, surface: *Surface, msg: apprt.surface.Message) !vo fn hasSurface(self: *const App, surface: *const Surface) bool { for (self.surfaces.items) |v| { - if (&v.core_surface == surface) return true; + if (v.core() == surface) return true; + } + + return false; +} + +fn hasRtSurface(self: *const App, surface: *apprt.Surface) bool { + for (self.surfaces.items) |v| { + if (v == surface) return true; } return false; diff --git a/src/Surface.zig b/src/Surface.zig index b12750545..a9a7e239f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1738,7 +1738,10 @@ pub fn selectionInfo(self: *const Surface) ?apprt.Selection { /// Returns the pwd of the terminal, if any. This is always copied because /// the pwd can change at any point from termio. If we are calling from the IO /// thread you should just check the terminal directly. -pub fn pwd(self: *const Surface, alloc: Allocator) !?[]const u8 { +pub fn pwd( + self: *const Surface, + alloc: Allocator, +) Allocator.Error!?[]const u8 { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const terminal_pwd = self.io.terminal.getPwd() orelse return null; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 0b6512599..25e344983 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -601,6 +601,14 @@ pub const Surface = struct { } } + pub fn core(self: *Surface) *CoreSurface { + return &self.core_surface; + } + + pub fn rtApp(self: *const Surface) *App { + return self.app; + } + pub fn close(self: *const Surface, process_alive: bool) void { const func = self.app.opts.close_surface orelse { log.info("runtime embedder does not support closing a surface", .{}); diff --git a/src/apprt/gtk-ng/App.zig b/src/apprt/gtk-ng/App.zig index 7ce233359..d6fd02e38 100644 --- a/src/apprt/gtk-ng/App.zig +++ b/src/apprt/gtk-ng/App.zig @@ -19,6 +19,11 @@ const adw_version = @import("adw_version.zig"); const log = std.log.scoped(.gtk); +/// This is detected by the Renderer, in which case it sends a `redraw_surface` +/// message so that we can call `drawFrame` ourselves from the app thread, +/// because GTK's `GLArea` does not support drawing from a different thread. +pub const must_draw_from_app_thread = true; + /// The GObject Application instance app: *Application, @@ -48,6 +53,11 @@ pub fn terminate(self: *App) void { self.app.deinit(); } +/// Called by CoreApp to wake up the event loop. +pub fn wakeup(self: *App) void { + self.app.wakeup(); +} + pub fn performAction( self: *App, target: apprt.Target, diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig index 094334210..917bae157 100644 --- a/src/apprt/gtk-ng/Surface.zig +++ b/src/apprt/gtk-ng/Surface.zig @@ -1,41 +1,62 @@ -const Surface = @This(); +const Self = @This(); +const std = @import("std"); const apprt = @import("../../apprt.zig"); const CoreSurface = @import("../../Surface.zig"); +const ApprtApp = @import("App.zig"); +const Application = @import("class/application.zig").Application; +const Surface = @import("class/surface.zig").Surface; -core_surface: CoreSurface, +/// The GObject Surface +surface: *Surface, -pub fn deinit(self: *Surface) void { +pub fn deinit(self: *Self) void { _ = self; } -pub fn close(self: *Surface, process_active: bool) void { +pub fn core(self: *Self) *CoreSurface { + // This asserts the non-optional because libghostty should only + // be calling this for initialized surfaces. + return self.surface.core().?; +} + +pub fn rtApp(self: *Self) *ApprtApp { + _ = self; + return Application.default().rt(); +} + +pub fn close(self: *Self, process_active: bool) void { _ = self; _ = process_active; } -pub fn shouldClose(self: *Surface) bool { +pub fn shouldClose(self: *Self) bool { _ = self; return false; } -pub fn getTitle(self: *Surface) ?[:0]const u8 { +pub fn getTitle(self: *Self) ?[:0]const u8 { _ = self; return null; } -pub fn getContentScale(self: *const Surface) !apprt.ContentScale { +pub fn getContentScale(self: *const Self) !apprt.ContentScale { _ = self; return .{ .x = 1, .y = 1 }; } -pub fn getCursorPos(self: *const Surface) !apprt.CursorPos { +pub fn getSize(self: *const Self) !apprt.SurfaceSize { + _ = self; + return .{ .width = 800, .height = 600 }; +} + +pub fn getCursorPos(self: *const Self) !apprt.CursorPos { _ = self; return .{ .x = 0, .y = 0 }; } pub fn clipboardRequest( - self: *Surface, + self: *Self, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, ) !void { @@ -45,7 +66,7 @@ pub fn clipboardRequest( } pub fn setClipboardString( - self: *Surface, + self: *Self, val: [:0]const u8, clipboard_type: apprt.Clipboard, confirm: bool, @@ -55,3 +76,7 @@ pub fn setClipboardString( _ = clipboard_type; _ = confirm; } + +pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { + return try self.surface.defaultTermioEnv(); +} diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index ee96ed9ea..1e7a5de25 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -476,6 +476,25 @@ pub const Application = extern struct { return self.private().config; } + /// Returns the core app associated with this application. This is + /// not a reference-counted type so you should not store this. + pub fn core(self: *Self) *CoreApp { + return self.private().core_app; + } + + /// Returns the apprt application associated with this application. + pub fn rt(self: *Self) *ApprtApp { + return self.private().rt_app; + } + + //--------------------------------------------------------------- + // Libghostty Callbacks + + pub fn wakeup(self: *Self) void { + _ = self; + glib.MainContext.wakeup(null); + } + //--------------------------------------------------------------- // Virtual Methods diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index a473931e7..26943560b 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1,11 +1,16 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const apprt = @import("../../../apprt.zig"); +const internal_os = @import("../../../os/main.zig"); const renderer = @import("../../../renderer.zig"); +const CoreSurface = @import("../../../Surface.zig"); const gresource = @import("../build/gresource.zig"); const adw_version = @import("../adw_version.zig"); +const ApprtSurface = @import("../Surface.zig"); const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; const Config = @import("config.zig").Config; @@ -54,6 +59,20 @@ pub const Surface = extern struct { /// to the template so it doesn't have to be unrefed manually. gl_area: *gtk.GLArea, + /// The apprt Surface. + rt_surface: ApprtSurface, + + /// The core surface backing this GTK surface. This starts out + /// null because it can't be initialized until there is an available + /// GLArea that is realized. + // + // NOTE(mitchellh): This is a limitation we should definitely remove + // at some point by modifying our OpenGL renderer for GTK to + // start in an unrealized state. There are other benefits to being + // able to initialize the surface early so we should aim for that, + // eventually. + core_surface: ?*CoreSurface = null, + pub var offset: c_int = 0; }; @@ -61,11 +80,81 @@ pub const Surface = extern struct { return gobject.ext.newInstance(Self, .{}); } + pub fn core(self: *Self) ?*CoreSurface { + const priv = self.private(); + return priv.core_surface; + } + + pub fn rt(self: *Self) *ApprtSurface { + const priv = self.private(); + return &priv.rt_surface; + } + + /// Force the surface to redraw itself. Ghostty often will only redraw + /// the terminal in reaction to internal changes. If there are external + /// events that invalidate the surface, such as the widget moving parents, + /// then we should force a redraw. + fn redraw(self: *Self) void { + const priv = self.private(); + priv.gl_area.queueRender(); + } + + //--------------------------------------------------------------- + // Libghostty Callbacks + + pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { + _ = self; + + const alloc = Application.default().allocator(); + var env = try internal_os.getEnvMap(alloc); + errdefer env.deinit(); + + // Don't leak these GTK environment variables to child processes. + env.remove("GDK_DEBUG"); + env.remove("GDK_DISABLE"); + env.remove("GSK_RENDERER"); + + // Remove some environment variables that are set when Ghostty is launched + // from a `.desktop` file, by D-Bus activation, or systemd. + env.remove("GIO_LAUNCHED_DESKTOP_FILE"); + env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID"); + env.remove("DBUS_STARTER_ADDRESS"); + env.remove("DBUS_STARTER_BUS_TYPE"); + env.remove("INVOCATION_ID"); + env.remove("JOURNAL_STREAM"); + env.remove("NOTIFY_SOCKET"); + + // Unset environment varies set by snaps if we're running in a snap. + // This allows Ghostty to further launch additional snaps. + if (env.get("SNAP")) |_| { + env.remove("SNAP"); + env.remove("DRIRC_CONFIGDIR"); + env.remove("__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS"); + env.remove("__EGL_VENDOR_LIBRARY_DIRS"); + env.remove("LD_LIBRARY_PATH"); + env.remove("LIBGL_DRIVERS_PATH"); + env.remove("LIBVA_DRIVERS_PATH"); + env.remove("VK_LAYER_PATH"); + env.remove("XLOCALEDIR"); + env.remove("GDK_PIXBUF_MODULEDIR"); + env.remove("GDK_PIXBUF_MODULE_FILE"); + env.remove("GTK_PATH"); + } + + return env; + } + + //--------------------------------------------------------------- + // Virtual Methods + fn init(self: *Self, _: *Class) callconv(.C) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); const priv = self.private(); + // Initialize our apprt surface. + priv.rt_surface = .{ .surface = self }; + // If our configuration is null then we get the configuration // from the application. if (priv.config == null) { @@ -85,6 +174,20 @@ pub const Surface = extern struct { gl_area.setHasStencilBuffer(0); gl_area.setHasDepthBuffer(0); gl_area.setUseEs(0); + _ = gtk.Widget.signals.realize.connect( + gl_area, + *Self, + glareaRealize, + self, + .{}, + ); + _ = gtk.Widget.signals.unrealize.connect( + gl_area, + *Self, + glareaUnrealize, + self, + .{}, + ); } fn dispose(self: *Self) callconv(.C) void { @@ -105,24 +208,135 @@ pub const Surface = extern struct { ); } - fn realize(self: *Self) callconv(.C) void { - log.debug("realize", .{}); + fn finalize(self: *Self) callconv(.C) void { + const priv = self.private(); + if (priv.core_surface) |v| { + priv.core_surface = null; - // Call the parent class's realize method. - gtk.Widget.virtual_methods.realize.call( + // Remove ourselves from the list of known surfaces in the app. + // We do this before deinit in case a callback triggers + // searching for this surface. + Application.default().core().deleteSurface(self.rt()); + + // Deinit the surface + v.deinit(); + } + + gobject.Object.virtual_methods.finalize.call( Class.parent, self.as(Parent), ); } - fn unrealize(self: *Self) callconv(.C) void { + //--------------------------------------------------------------- + // Signal Handlers + + fn glareaRealize( + _: *gtk.GLArea, + self: *Self, + ) callconv(.c) void { + log.debug("realize", .{}); + + self.realizeSurface() catch |err| { + log.warn("surface failed to realize err={}", .{err}); + return; + }; + } + + fn glareaUnrealize( + gl_area: *gtk.GLArea, + self: *Self, + ) callconv(.c) void { log.debug("unrealize", .{}); - // Call the parent class's unrealize method. - gtk.Widget.virtual_methods.unrealize.call( - Class.parent, - self.as(Parent), + // Get our surface. If we don't have one, there's no work we + // need to do here. + const priv = self.private(); + const surface = priv.core_surface orelse return; + + // There is no guarantee that our GLArea context is current + // when unrealize is emitted, so we need to make it current. + gl_area.makeCurrent(); + if (gl_area.getError()) |err| { + // I don't know a scenario this can happen, but it means + // we probably leaked memory because displayUnrealized + // below frees resources that aren't specifically OpenGL + // related. I didn't make the OpenGL renderer handle this + // scenario because I don't know if its even possible + // under valid circumstances, so let's log. + log.warn( + "gl_area_make_current failed in unrealize msg={s}", + .{err.f_message orelse "(no message)"}, + ); + log.warn("OpenGL resources and memory likely leaked", .{}); + return; + } + + surface.renderer.displayUnrealized(); + } + + const RealizeError = Allocator.Error || error{ + GLAreaError, + RendererError, + SurfaceError, + }; + + fn realizeSurface(self: *Self) RealizeError!void { + const priv = self.private(); + const gl_area = priv.gl_area; + + // We need to make the context current so we can call GL functions. + // This is required for all surface operations. + gl_area.makeCurrent(); + if (gl_area.getError()) |err| { + log.warn("failed to make GL context current: {s}", .{err.f_message orelse "(no message)"}); + log.warn("this error is usually due to a driver or gtk bug", .{}); + log.warn("this is a common cause of this issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/4950", .{}); + return error.GLAreaError; + } + + // If we already have an initialized surface then we just notify. + if (priv.core_surface) |v| { + v.renderer.displayRealized() catch |err| { + log.warn("core displayRealized failed err={}", .{err}); + return error.RendererError; + }; + self.redraw(); + return; + } + + // Make our pointer to store our surface + const app = Application.default(); + const alloc = app.allocator(); + const surface = try alloc.create(CoreSurface); + errdefer alloc.destroy(surface); + + // Add ourselves to the list of surfaces on the app. + try app.core().addSurface(self.rt()); + errdefer app.core().deleteSurface(self.rt()); + + // Initialize our surface configuration. + var config = try apprt.surface.newConfig( + app.core(), + priv.config.?.get(), ); + defer config.deinit(); + + // Initialize the surface + surface.init( + alloc, + &config, + app.core(), + app.rt(), + &priv.rt_surface, + ) catch |err| { + log.warn("failed to initialize surface err={}", .{err}); + return error.SurfaceError; + }; + errdefer surface.deinit(); + + // Store it! + priv.core_surface = surface; } const C = Common(Self, Private); @@ -156,8 +370,7 @@ pub const Surface = extern struct { // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); - gtk.Widget.virtual_methods.realize.implement(class, &realize); - gtk.Widget.virtual_methods.unrealize.implement(class, &unrealize); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); } pub const as = C.Class.as; diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 4eb86ce79..d3849781f 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -781,6 +781,14 @@ pub fn deinit(self: *Surface) void { self.resize_overlay.deinit(); } +pub fn core(self: *Surface) *CoreSurface { + return &self.core_surface; +} + +pub fn rtApp(self: *const Surface) *App { + return self.app; +} + /// Update our local copy of any configuration that we use. pub fn updateConfig(self: *Surface, config: *const configpkg.Config) !void { self.resize_overlay.updateConfig(config); diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 250675bbb..8c0ae5c91 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -1,3 +1,6 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + const apprt = @import("../apprt.zig"); const App = @import("../App.zig"); const Surface = @import("../Surface.zig"); @@ -133,7 +136,10 @@ pub const Mailbox = struct { /// Returns a new config for a surface for the given app that should be /// used for any new surfaces. The resulting config should be deinitialized /// after the surface is initialized. -pub fn newConfig(app: *const App, config: *const Config) !Config { +pub fn newConfig( + app: *const App, + config: *const Config, +) Allocator.Error!Config { // Create a shallow clone var copy = config.shallowClone(app.alloc); diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 882d6fc03..908e1c828 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -165,7 +165,9 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { else => @compileError("unsupported app runtime for OpenGL"), // GTK uses global OpenGL context so we load from null. - apprt.gtk => try prepareContext(null), + apprt.gtk, + apprt.gtk_ng, + => try prepareContext(null), apprt.embedded => { // TODO(mitchellh): this does nothing today to allow libghostty @@ -199,7 +201,7 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), - apprt.gtk => { + apprt.gtk, apprt.gtk_ng => { // GTK doesn't support threaded OpenGL operations as far as I can // tell, so we use the renderer thread to setup all the state // but then do the actual draws and texture syncs and all that @@ -221,7 +223,7 @@ pub fn threadExit(self: *const OpenGL) void { switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), - apprt.gtk => { + apprt.gtk, apprt.gtk_ng => { // We don't need to do any unloading for GTK because we may // be sharing the global bindings with other windows. }, @@ -236,7 +238,7 @@ pub fn displayRealized(self: *const OpenGL) void { _ = self; switch (apprt.runtime) { - apprt.gtk => prepareContext(null) catch |err| { + apprt.gtk, apprt.gtk_ng => prepareContext(null) catch |err| { log.warn( "Error preparing GL context in displayRealized, err={}", .{err},