From 9f2ff0cb9c38693ffa37350f58db6045c4be0d9a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 07:01:52 -0700 Subject: [PATCH 1/6] apprt/gtk-ng: introduce a basic surface --- src/apprt/gtk-ng/build/gresource.zig | 1 + src/apprt/gtk-ng/class.zig | 26 ++++ src/apprt/gtk-ng/class/application.zig | 107 ++++++++++++++-- src/apprt/gtk-ng/class/surface.zig | 166 +++++++++++++++++++++++++ src/apprt/gtk-ng/class/window.zig | 3 + src/apprt/gtk-ng/ui/1.2/surface.blp | 18 +++ src/apprt/gtk-ng/ui/1.5/window.blp | 4 +- valgrind.supp | 33 +++++ 8 files changed, 343 insertions(+), 15 deletions(-) create mode 100644 src/apprt/gtk-ng/class/surface.zig create mode 100644 src/apprt/gtk-ng/ui/1.2/surface.blp diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index f38e73b01..ca3f762cc 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -31,6 +31,7 @@ pub const icon_sizes: []const comptime_int = &.{ 16, 32, 128, 256, 512, 1024 }; /// These will be asserted to exist at runtime. pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 2, .name = "config-errors-dialog" }, + .{ .major = 1, .minor = 2, .name = "surface" }, .{ .major = 1, .minor = 5, .name = "config-errors-dialog" }, .{ .major = 1, .minor = 5, .name = "window" }, }; diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk-ng/class.zig index db403610e..2c3626a16 100644 --- a/src/apprt/gtk-ng/class.zig +++ b/src/apprt/gtk-ng/class.zig @@ -3,10 +3,12 @@ const glib = @import("glib"); const gobject = @import("gobject"); +const gtk = @import("gtk"); pub const Application = @import("class/application.zig").Application; pub const Window = @import("class/window.zig").Window; pub const Config = @import("class/config.zig").Config; +pub const Surface = @import("class/surface.zig").Surface; /// Unrefs the given GObject on the next event loop tick. /// @@ -60,6 +62,30 @@ pub fn Common( ); } }).private else {}; + + /// Common class functions. + pub const Class = struct { + pub fn as(class: *Self.Class, comptime T: type) *T { + return gobject.ext.as(T, class); + } + + /// Bind a template child to a private entry in the class. + pub const bindTemplateChildPrivate = if (Private) |P| (struct { + pub fn bindTemplateChildPrivate( + class: *Self.Class, + comptime name: [:0]const u8, + comptime options: gtk.ext.BindTemplateChildOptions, + ) void { + gtk.ext.impl_helpers.bindTemplateChildPrivate( + class, + name, + P, + P.offset, + options, + ); + } + }).bindTemplateChildPrivate else {}; + }; }; } diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index df86c13b0..ee96ed9ea 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -16,6 +16,7 @@ const configpkg = @import("../../../config.zig"); const internal_os = @import("../../../os/main.zig"); const xev = @import("../../../global.zig").xev; const CoreConfig = configpkg.Config; +const CoreSurface = @import("../../../Surface.zig"); const adw_version = @import("../adw_version.zig"); const gtk_version = @import("../gtk_version.zig"); @@ -58,6 +59,25 @@ pub const Application = extern struct { .private = .{ .Type = Private, .offset = &Private.offset }, }); + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + "config", + Self, + ?*Config, + .{ + .nick = "Config", + .blurb = "The current active configuration for the application.", + .default = null, + .accessor = .{ + .getter = Self.getPropConfig, + }, + }, + ); + }; + }; + const Private = struct { /// The apprt App. This is annoying that we need this it'd be /// nicer to just make THIS the apprt app but the current libghostty @@ -88,6 +108,17 @@ pub const Application = extern struct { pub var offset: c_int = 0; }; + /// Get this application as the default, allowing access to its + /// properties globally. + /// + /// This asserts that there is a default application and that the + /// default application is a GhosttyApplication. The program would have + /// to be in a very bad state for this to be violated. + pub fn default() *Self { + const app = gio.Application.getDefault().?; + return gobject.ext.cast(Self, app).?; + } + /// Creates a new Application instance. /// /// This does a lot more work than a typical class instantiation, @@ -121,14 +152,14 @@ pub const Application = extern struct { // the error in the diagnostics so it can be shown to the user. // We can still load a default which only fails for OOM, allowing // us to startup. - var default: CoreConfig = try .default(alloc); - errdefer default.deinit(); - try default.addDiagnosticFmt( + var def: CoreConfig = try .default(alloc); + errdefer def.deinit(); + try def.addDiagnosticFmt( "error loading user configuration: {}", .{err}, ); - break :err default; + break :err def; }; defer config.deinit(); @@ -223,6 +254,13 @@ pub const Application = extern struct { if (priv.transient_cgroup_base) |base| alloc.free(base); } + /// The global allocator that all other classes should use by + /// calling `Application.default().allocator()`. Zig code should prefer + /// this wherever possible so we get leak detection in debug/tests. + pub fn allocator(self: *Self) std.mem.Allocator { + return self.private().core_app.alloc; + } + /// Run the application. This is a replacement for `gio.Application.run` /// because we want more tight control over our event loop so we can /// integrate it with libghostty. @@ -332,9 +370,16 @@ pub const Application = extern struct { value.config, ), + .new_window => try Action.newWindow( + self, + switch (target) { + .app => null, + .surface => |v| v, + }, + ), + // Unimplemented .quit, - .new_window, .close_window, .toggle_maximize, .toggle_fullscreen, @@ -410,6 +455,27 @@ pub const Application = extern struct { try priv.core_app.updateConfig(priv.rt_app, &config); } + /// Returns the configuration for this application. + /// + /// The reference count is increased. + pub fn getConfig(self: *Self) *Config { + var value = gobject.ext.Value.zero; + gobject.Object.getProperty( + self.as(gobject.Object), + properties.config.name, + &value, + ); + + const obj = value.getObject().?; + return gobject.ext.cast(Config, obj).?; + } + + fn getPropConfig(self: *Self) *Config { + // Property return must not increase reference count since + // the gobject getter handles this automatically. + return self.private().config; + } + //--------------------------------------------------------------- // Virtual Methods @@ -421,6 +487,9 @@ pub const Application = extern struct { self.as(Parent), ); + // Set ourselves as the default application. + gio.Application.setDefault(self.as(gio.Application)); + // Setup our event loop self.startupXev(); @@ -581,14 +650,17 @@ pub const Application = extern struct { fn activate(self: *Self) callconv(.C) void { log.debug("activate", .{}); + // Queue a new window + const priv = self.private(); + _ = priv.core_app.mailbox.push(.{ + .new_window = .{}, + }, .{ .forever = {} }); + // Call the parent activate method. gio.Application.virtual_methods.activate.call( Class.parent, self.as(Parent), ); - - // const win = Window.new(self); - // gtk.Window.present(win.as(gtk.Window)); } fn dispose(self: *Self) callconv(.C) void { @@ -697,10 +769,6 @@ pub const Application = extern struct { //---------------------------------------------------------------- // Boilerplate/Noise - fn allocator(self: *Self) std.mem.Allocator { - return self.private().core_app.alloc; - } - const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -729,6 +797,11 @@ pub const Application = extern struct { } } + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + }); + // Virtual methods gio.Application.virtual_methods.activate.implement(class, &activate); gio.Application.virtual_methods.startup.implement(class, &startup); @@ -765,6 +838,16 @@ const Action = struct { }, } } + + pub fn newWindow( + self: *Application, + parent: ?*CoreSurface, + ) !void { + _ = parent; + + const win = Window.new(self); + gtk.Window.present(win.as(gtk.Window)); + } }; /// This sets various GTK-related environment variables as necessary diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig new file mode 100644 index 000000000..a473931e7 --- /dev/null +++ b/src/apprt/gtk-ng/class/surface.zig @@ -0,0 +1,166 @@ +const std = @import("std"); +const adw = @import("adw"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const renderer = @import("../../../renderer.zig"); +const gresource = @import("../build/gresource.zig"); +const adw_version = @import("../adw_version.zig"); +const Common = @import("../class.zig").Common; +const Application = @import("application.zig").Application; +const Config = @import("config.zig").Config; + +const log = std.log.scoped(.gtk_ghostty_surface); + +pub const Surface = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySurface", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .nick = "Config", + .blurb = "The configuration that this surface is using.", + .default = null, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "config", + ), + }, + ); + }; + }; + + const Private = struct { + /// The configuration that this surface is using. + config: ?*Config = null, + + /// The GLAarea that renders the actual surface. This is a binding + /// to the template so it doesn't have to be unrefed manually. + gl_area: *gtk.GLArea, + + pub var offset: c_int = 0; + }; + + pub fn new() *Self { + return gobject.ext.newInstance(Self, .{}); + } + + fn init(self: *Self, _: *Class) callconv(.C) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + const priv = self.private(); + + // If our configuration is null then we get the configuration + // from the application. + if (priv.config == null) { + const app = Application.default(); + priv.config = app.getConfig(); + } + + // Initialize our GLArea. We could do a lot of this in + // the Blueprint file but I think its cleaner to separate + // the "UI" part of the blueprint file from the internal logic/config + // part. + const gl_area = priv.gl_area; + gl_area.setRequiredVersion( + renderer.OpenGL.MIN_VERSION_MAJOR, + renderer.OpenGL.MIN_VERSION_MINOR, + ); + gl_area.setHasStencilBuffer(0); + gl_area.setHasDepthBuffer(0); + gl_area.setUseEs(0); + } + + fn dispose(self: *Self) callconv(.C) void { + const priv = self.private(); + if (priv.config) |v| { + v.unref(); + priv.config = null; + } + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn realize(self: *Self) callconv(.C) void { + log.debug("realize", .{}); + + // Call the parent class's realize method. + gtk.Widget.virtual_methods.realize.call( + Class.parent, + self.as(Parent), + ); + } + + fn unrealize(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), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.C) void { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 2, + .name = "surface", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("gl_area", .{}); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + }); + + // 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); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + }; +}; diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 0eab18ee1..81ea376fa 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -6,6 +6,7 @@ const gtk = @import("gtk"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; +const Surface = @import("surface.zig").Surface; const log = std.log.scoped(.gtk_ghostty_window); @@ -58,6 +59,8 @@ pub const Window = extern struct { pub const Instance = Self; fn init(class: *Class) callconv(.C) void { + gobject.ext.ensureType(Surface); + gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), comptime gresource.blueprint(.{ diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp new file mode 100644 index 000000000..3fde1d1c0 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -0,0 +1,18 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySurface: Adw.Bin { + Box { + orientation: vertical; + hexpand: true; + + Label { + label: "Hello"; + } + + GLArea gl_area { + hexpand: true; + vexpand: true; + } + } +} diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk-ng/ui/1.5/window.blp index d6321537e..9f3a0c45f 100644 --- a/src/apprt/gtk-ng/ui/1.5/window.blp +++ b/src/apprt/gtk-ng/ui/1.5/window.blp @@ -2,7 +2,5 @@ using Gtk 4.0; using Adw 1; template $GhosttyWindow: Adw.ApplicationWindow { - content: Label { - label: "Hello, Ghostty!"; - }; + content: $GhosttySurface {}; } diff --git a/valgrind.supp b/valgrind.supp index 0e1a90f6a..4c5d6b4a0 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -13,6 +13,39 @@ # You must gracefully exit Ghostty (do not SIGINT) by closing all windows # and quitting. Otherwise, we leave a number of GTK resources around. +{ + GSK Renderer GPU Stuff + Memcheck:Leak + match-leak-kinds: possible + ... + fun:gsk_gpu_image_toggle_ref_texture + fun:gsk_gl_image_new_for_texture + fun:gsk_gl_frame_upload_texture + fun:gsk_gpu_frame_do_upload_texture + fun:gsk_gpu_lookup_texture + ... + fun:gsk_gpu_node_processor_add_first_node + fun:gsk_gpu_node_processor_process + fun:gsk_gpu_frame_render + fun:gsk_gpu_renderer_render + fun:gsk_renderer_render + fun:gtk_widget_render + fun:surface_render + ... +} + +{ + GTK Shader Selector + Memcheck:Leak + match-leak-kinds: possible + ... + fun:_ZL29si_init_shader_selector_asyncPvS_i + fun:util_queue_thread_func + fun:impl_thrd_routine + fun:start_thread + fun:clone +} + # Weird gtk_tooltip_init leak I can't figure out { Non-builder tooltip create From 7c9e913ca9f7770154add8ed4d53e91504bb904d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 10:42:23 -0700 Subject: [PATCH 2/6] 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}, From 2ab5d3cd816645e967622e3e061ef57cb206e6e6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 11:19:21 -0700 Subject: [PATCH 3/6] apprt/gtk-ng: implement the quit_timer action to just quit --- src/apprt/action.zig | 13 ++++++++++--- src/apprt/gtk-ng/class/application.zig | 18 +++++++++++++++++- src/apprt/gtk-ng/class/surface.zig | 2 ++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 1afd59869..425e39974 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -203,9 +203,16 @@ pub const Action = union(Key) { open_config, /// Called when there are no more surfaces and the app should quit - /// after the configured delay. This can be cancelled by sending - /// another quit_timer action with "stop". Multiple "starts" shouldn't - /// happen and can be ignored or cause a restart it isn't that important. + /// after the configured delay. + /// + /// Despite the name, this is the notification that libghostty sends + /// when there are no more surfaces regardless of if the configuration + /// wants to quit after close, has any delay set, etc. It's up to the + /// apprt to implement the proper logic based on the config. + /// + /// This can be cancelled by sending another quit_timer action with "stop". + /// Multiple "starts" shouldn't happen and can be ignored or cause a + /// restart it isn't that important. quit_timer: QuitTimer, /// Set the window floating state. A floating window is one that is diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 1e7a5de25..c115d59cc 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -378,6 +378,8 @@ pub const Application = extern struct { }, ), + .quit_timer => try Action.quitTimer(self, value), + // Unimplemented .quit, .close_window, @@ -407,7 +409,6 @@ pub const Application = extern struct { .toggle_tab_overview, .toggle_split_zoom, .toggle_window_decorations, - .quit_timer, .prompt_title, .toggle_quick_terminal, .secure_input, @@ -867,6 +868,21 @@ const Action = struct { const win = Window.new(self); gtk.Window.present(win.as(gtk.Window)); } + + pub fn quitTimer( + self: *Application, + mode: apprt.action.QuitTimer, + ) !void { + // TODO: An actual quit timer implementation. For now, we immediately + // quit on no windows regardless of the config. + switch (mode) { + .start => { + self.private().running = false; + }, + + .stop => {}, + } + } }; /// This sets various GTK-related environment variables as necessary diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 26943560b..d515bfd65 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -220,6 +220,8 @@ pub const Surface = extern struct { // Deinit the surface v.deinit(); + const alloc = Application.default().allocator(); + alloc.destroy(v); } gobject.Object.virtual_methods.finalize.call( From f0a0333bc0ab285de372ab1f218296b1954650d7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 12:22:17 -0700 Subject: [PATCH 4/6] apprt/gtk-ng: hook up surface render --- include/ghostty.h | 1 + src/App.zig | 23 ++++++++++++++++++--- src/apprt/action.zig | 6 ++++++ src/apprt/gtk-ng/App.zig | 6 ------ src/apprt/gtk-ng/class/application.zig | 9 +++++++++ src/apprt/gtk-ng/class/surface.zig | 28 +++++++++++++++++++++++++- src/apprt/gtk-ng/ui/1.2/surface.blp | 4 ---- src/apprt/gtk/App.zig | 14 +++++++------ 8 files changed, 71 insertions(+), 20 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index fb4c850dc..c422c3584 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -729,6 +729,7 @@ typedef enum { GHOSTTY_ACTION_RESET_WINDOW_SIZE, GHOSTTY_ACTION_INITIAL_SIZE, GHOSTTY_ACTION_CELL_SIZE, + GHOSTTY_ACTION_RENDER, GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, diff --git a/src/App.zig b/src/App.zig index 28539c557..c4c7ad55b 100644 --- a/src/App.zig +++ b/src/App.zig @@ -260,7 +260,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void { .new_window => |msg| try self.newWindow(rt_app, msg), .close => |surface| self.closeSurface(surface), .surface_message => |msg| try self.surfaceMessage(msg.surface, msg.message), - .redraw_surface => |surface| self.redrawSurface(rt_app, surface), + .redraw_surface => |surface| try self.redrawSurface(rt_app, surface), .redraw_inspector => |surface| self.redrawInspector(rt_app, surface), // If we're quitting, then we set the quit flag and stop @@ -286,9 +286,26 @@ pub fn focusSurface(self: *App, surface: *Surface) void { self.focused_surface = surface; } -fn redrawSurface(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) void { +fn redrawSurface( + self: *App, + rt_app: *apprt.App, + surface: *apprt.Surface, +) !void { if (!self.hasRtSurface(surface)) return; - rt_app.redrawSurface(surface); + + // TODO: Remove this in a separate PR. We should transition to + // the `render` apprt action completely. This is only to make + // our initial gtk-ng work touch less things. + if (@hasDecl(apprt.App, "redrawSurface")) { + rt_app.redrawSurface(surface); + return; + } + + _ = try rt_app.performAction( + .{ .surface = surface.core() }, + .render, + {}, + ); } fn redrawInspector(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) void { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 425e39974..da97fc04b 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -162,6 +162,11 @@ pub const Action = union(Key) { /// The cell size has changed to the given dimensions in pixels. cell_size: CellSize, + /// The target should be re-rendered. This usually has a specific + /// surface target but if the app is targeted then all active + /// surfaces should be redrawn. + render, + /// Control whether the inspector is shown or hidden. inspector: Inspector, @@ -311,6 +316,7 @@ pub const Action = union(Key) { reset_window_size, initial_size, cell_size, + render, inspector, show_gtk_inspector, render_inspector, diff --git a/src/apprt/gtk-ng/App.zig b/src/apprt/gtk-ng/App.zig index d6fd02e38..f630f7533 100644 --- a/src/apprt/gtk-ng/App.zig +++ b/src/apprt/gtk-ng/App.zig @@ -79,12 +79,6 @@ pub fn performIpc( return false; } -/// Close the given surface. -pub fn redrawSurface(self: *App, surface: *Surface) void { - _ = self; - _ = surface; -} - /// Redraw the inspector for the given surface. pub fn redrawInspector(self: *App, surface: *Surface) void { _ = self; diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index c115d59cc..de7715e74 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -380,6 +380,8 @@ pub const Application = extern struct { .quit_timer => try Action.quitTimer(self, value), + .render => Action.render(self, target), + // Unimplemented .quit, .close_window, @@ -883,6 +885,13 @@ const Action = struct { .stop => {}, } } + + pub fn render(_: *Application, target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.redraw(), + } + } }; /// This sets various GTK-related environment variables as necessary diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index d515bfd65..d88748fd1 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const adw = @import("adw"); +const gdk = @import("gdk"); const gobject = @import("gobject"); const gtk = @import("gtk"); @@ -94,7 +95,7 @@ pub const Surface = extern struct { /// 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 { + pub fn redraw(self: *Self) void { const priv = self.private(); priv.gl_area.queueRender(); } @@ -188,6 +189,13 @@ pub const Surface = extern struct { self, .{}, ); + _ = gtk.GLArea.signals.render.connect( + gl_area, + *Self, + glareaRender, + self, + .{}, + ); } fn dispose(self: *Self) callconv(.C) void { @@ -277,6 +285,24 @@ pub const Surface = extern struct { surface.renderer.displayUnrealized(); } + fn glareaRender( + _: *gtk.GLArea, + _: *gdk.GLContext, + self: *Self, + ) callconv(.c) c_int { + // If we don't have a surface then we failed to initialize for + // some reason and there's nothing to draw to the GLArea. + const priv = self.private(); + const surface = priv.core_surface orelse return 1; + + surface.renderer.drawFrame(true) catch |err| { + log.warn("failed to draw frame err={}", .{err}); + return 0; + }; + + return 1; + } + const RealizeError = Allocator.Error || error{ GLAreaError, RendererError, diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 3fde1d1c0..2b406ce49 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -6,10 +6,6 @@ template $GhosttySurface: Adw.Bin { orientation: vertical; hexpand: true; - Label { - label: "Hello"; - } - GLArea gl_area { hexpand: true; vexpand: true; diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 99120992e..e32d0c8ca 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -524,6 +524,7 @@ pub fn performAction( .open_url => self.openUrl(value), .show_child_exited => return try self.showChildExited(target, value), .progress_report => return try self.handleProgressReport(target, value), + .render => self.render(target), // Unimplemented .close_all_windows, @@ -881,6 +882,13 @@ fn handleProgressReport(_: *App, target: apprt.Target, value: terminal.osc.Comma } } +fn render(_: *App, target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.redraw(), + } +} + fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void { switch (mode) { .start => self.startQuitTimer(), @@ -1479,12 +1487,6 @@ fn stopQuitTimer(self: *App) void { } } -/// Close the given surface. -pub fn redrawSurface(self: *App, surface: *Surface) void { - _ = self; - surface.redraw(); -} - /// Redraw the inspector for the given surface. pub fn redrawInspector(self: *App, surface: *Surface) void { _ = self; From 7c77133a8316880fef27d76393d0609f0a6c1416 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 13:07:26 -0700 Subject: [PATCH 5/6] apprt/gtk-ng: implement size callbacks for surface --- src/apprt/gtk-ng/Surface.zig | 6 +- src/apprt/gtk-ng/class/surface.zig | 116 ++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig index 917bae157..ce7b0ced8 100644 --- a/src/apprt/gtk-ng/Surface.zig +++ b/src/apprt/gtk-ng/Surface.zig @@ -41,13 +41,11 @@ pub fn getTitle(self: *Self) ?[:0]const u8 { } pub fn getContentScale(self: *const Self) !apprt.ContentScale { - _ = self; - return .{ .x = 1, .y = 1 }; + return self.surface.getContentScale(); } pub fn getSize(self: *const Self) !apprt.SurfaceSize { - _ = self; - return .{ .width = 800, .height = 600 }; + return self.surface.getSize(); } pub fn getCursorPos(self: *const Self) !apprt.CursorPos { diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index d88748fd1..0331a3d3f 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -74,6 +74,9 @@ pub const Surface = extern struct { // eventually. core_surface: ?*CoreSurface = null, + /// Cached metrics for libghostty callbacks + size: apprt.SurfaceSize, + pub var offset: c_int = 0; }; @@ -103,6 +106,58 @@ pub const Surface = extern struct { //--------------------------------------------------------------- // Libghostty Callbacks + pub fn getContentScale(self: *Self) apprt.ContentScale { + const priv = self.private(); + const gl_area = priv.gl_area; + + const gtk_scale: f32 = scale: { + const widget = gl_area.as(gtk.Widget); + // Future: detect GTK version 4.12+ and use gdk_surface_get_scale so we + // can support fractional scaling. + const scale = widget.getScaleFactor(); + if (scale <= 0) { + log.warn("gtk_widget_get_scale_factor returned a non-positive number: {}", .{scale}); + break :scale 1.0; + } + break :scale @floatFromInt(scale); + }; + + // Also scale using font-specific DPI, which is often exposed to the user + // via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html). + const xft_dpi_scale = xft_scale: { + // gtk-xft-dpi is font DPI multiplied by 1024. See + // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html + const settings = gtk.Settings.getDefault() orelse break :xft_scale 1.0; + var value = std.mem.zeroes(gobject.Value); + defer value.unset(); + _ = value.init(gobject.ext.typeFor(c_int)); + settings.as(gobject.Object).getProperty("gtk-xft-dpi", &value); + const gtk_xft_dpi = value.getInt(); + + // Use a value of 1.0 for the XFT DPI scale if the setting is <= 0 + // See: + // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/a7738a4d269bfdf4d8d5429ca73ccdd9b2450421 + // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/9759d3fd81129608dd78116001928f2aed974ead + if (gtk_xft_dpi <= 0) { + log.warn("gtk-xft-dpi was not set, using default value", .{}); + break :xft_scale 1.0; + } + + // 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.0; + break :xft_scale xft_dpi / 96.0; + }; + + const scale = gtk_scale * xft_dpi_scale; + return .{ .x = scale, .y = scale }; + } + + pub fn getSize(self: *Self) apprt.SurfaceSize { + return self.private().size; + } + pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { _ = self; @@ -153,8 +208,14 @@ pub const Surface = extern struct { const priv = self.private(); - // Initialize our apprt surface. + // Initialize some private fields so they aren't undefined priv.rt_surface = .{ .surface = self }; + priv.size = .{ + // Funky numbers on purpose so they stand out if for some reason + // our size doesn't get properly set. + .width = 111, + .height = 111, + }; // If our configuration is null then we get the configuration // from the application. @@ -196,6 +257,13 @@ pub const Surface = extern struct { self, .{}, ); + _ = gtk.GLArea.signals.resize.connect( + gl_area, + *Self, + glareaResize, + self, + .{}, + ); } fn dispose(self: *Self) callconv(.C) void { @@ -303,6 +371,52 @@ pub const Surface = extern struct { return 1; } + fn glareaResize( + gl_area: *gtk.GLArea, + width: c_int, + height: c_int, + self: *Surface, + ) callconv(.c) void { + // Some debug output to help understand what GTK is telling us. + { + const widget = gl_area.as(gtk.Widget); + const scale_factor = widget.getScaleFactor(); + const window_scale_factor = scale: { + const root = widget.getRoot() orelse break :scale 0; + const gtk_native = root.as(gtk.Native); + const gdk_surface = gtk_native.getSurface() orelse break :scale 0; + break :scale gdk_surface.getScaleFactor(); + }; + + log.debug("gl resize width={} height={} scale={} window_scale={}", .{ + width, + height, + scale_factor, + window_scale_factor, + }); + } + + // Store our cached size + const priv = self.private(); + priv.size = .{ + .width = @intCast(width), + .height = @intCast(height), + }; + + // If our surface is realize, we send callbacks. + if (priv.core_surface) |surface| { + // We also update the content scale because there is no signal for + // content scale change and it seems to trigger a resize event. + surface.contentScaleCallback(self.getContentScale()) catch |err| { + log.warn("error in content scale callback err={}", .{err}); + }; + + surface.sizeCallback(priv.size) catch |err| { + log.warn("error in size callback err={}", .{err}); + }; + } + } + const RealizeError = Allocator.Error || error{ GLAreaError, RendererError, From 432fec7065b6155827eaa83cc0ea7f0825047cde Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 13:12:06 -0700 Subject: [PATCH 6/6] comments --- src/apprt/gtk-ng/ui/1.2/surface.blp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 2b406ce49..a13e0c073 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -2,6 +2,8 @@ using Gtk 4.0; using Adw 1; template $GhosttySurface: Adw.Bin { + // A box isn't strictly necessary right now but there will be more + // stuff here in the future. There's still a lot to do with surfaces. Box { orientation: vertical; hexpand: true;