gtk-ng: port the terminal inspector (#8212)

This is a (relatively) straightforward port of the terminal inspector
from the old GTK application runtime. It's split into three widgets. At
the lowest level is a widget designed for showing a generic Dear ImGui
application. Above that is a widget that embeds the ImGui widget and
plumbs it into the core Inspector. At the top is a custom Window widget
that embeds the Inspector widget.

And then there's all the plumbing necessary to hook everything into the
rest of Ghostty.

In theory this design _should_ allow showing the Inspector in a split or
a tab in the future, not just in a separate window. It should also make
it easier to display _other_ Dear ImGui applications if they are ever
needed.
pull/8233/head
Mitchell Hashimoto 2025-08-14 09:47:09 -07:00 committed by GitHub
commit 3eda14e2d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1161 additions and 10 deletions

View File

@ -99,7 +99,6 @@ pub fn performIpc(
}
/// Redraw the inspector for the given surface.
pub fn redrawInspector(self: *App, surface: *Surface) void {
_ = self;
_ = surface;
pub fn redrawInspector(_: *App, surface: *Surface) void {
surface.redrawInspector();
}

View File

@ -95,3 +95,8 @@ pub fn setClipboardString(
pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap {
return try self.surface.defaultTermioEnv();
}
/// Redraw the inspector for our surface.
pub fn redrawInspector(self: *Self) void {
self.surface.redrawInspector();
}

View File

@ -39,6 +39,9 @@ pub const blueprints: []const Blueprint = &.{
.{ .major = 1, .minor = 2, .name = "config-errors-dialog" },
.{ .major = 1, .minor = 2, .name = "debug-warning" },
.{ .major = 1, .minor = 3, .name = "debug-warning" },
.{ .major = 1, .minor = 5, .name = "imgui-widget" },
.{ .major = 1, .minor = 5, .name = "inspector-widget" },
.{ .major = 1, .minor = 5, .name = "inspector-window" },
.{ .major = 1, .minor = 2, .name = "resize-overlay" },
.{ .major = 1, .minor = 5, .name = "split-tree" },
.{ .major = 1, .minor = 5, .name = "split-tree-split" },

View File

@ -561,6 +561,8 @@ pub const Application = extern struct {
.initial_size => return Action.initialSize(target, value),
.inspector => return Action.controlInspector(target, value),
.mouse_over_link => Action.mouseOverLink(target, value),
.mouse_shape => Action.mouseShape(target, value),
.mouse_visibility => Action.mouseVisibility(target, value),
@ -620,13 +622,6 @@ pub const Application = extern struct {
.toggle_split_zoom => return Action.toggleSplitZoom(target),
.show_on_screen_keyboard => return Action.showOnScreenKeyboard(target),
// Unimplemented but todo on gtk-ng branch
.inspector,
=> {
log.warn("unimplemented action={}", .{action});
return false;
},
// Unimplemented
.secure_input,
.close_all_windows,
@ -2235,6 +2230,15 @@ const Action = struct {
},
}
}
pub fn controlInspector(target: apprt.Target, value: apprt.Action.Value(.inspector)) bool {
switch (target) {
.app => return false,
.surface => |surface| {
return surface.rt_surface.gobj().controlInspector(value);
},
}
}
};
/// This sets various GTK-related environment variables as necessary

View File

@ -0,0 +1,478 @@
const std = @import("std");
const assert = std.debug.assert;
const cimgui = @import("cimgui");
const gl = @import("opengl");
const adw = @import("adw");
const gdk = @import("gdk");
const gobject = @import("gobject");
const gtk = @import("gtk");
const input = @import("../../../input.zig");
const gresource = @import("../build/gresource.zig");
const key = @import("../key.zig");
const Common = @import("../class.zig").Common;
const log = std.log.scoped(.gtk_ghostty_imgui_widget);
/// A widget for embedding a Dear ImGui application.
///
/// It'd be a lot cleaner to use inheritance here but zig-gobject doesn't
/// currently have a way to define virtual methods, so we have to use
/// composition and signals instead.
pub const ImguiWidget = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttyImguiWidget",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {};
pub const signals = struct {
/// Emitted when the child widget should render. During the callback,
/// the Imgui context is valid.
pub const render = struct {
pub const name = "render";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
/// Emitted when first realized to allow the embedded ImGui application
/// to initialize itself. When this is called, the ImGui context
/// is properly set.
///
/// This might be called multiple times, but each time it is
/// called a new Imgui context will be created.
pub const setup = struct {
pub const name = "setup";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
};
const Private = struct {
/// GL area where we display the Dear ImGui application.
gl_area: *gtk.GLArea,
/// GTK input method context
im_context: *gtk.IMMulticontext,
/// Dear ImGui context. We create a context per widget so that we can
/// have multiple active imgui views in the same application.
ig_context: ?*cimgui.c.ImGuiContext = null,
/// Our previous instant used to calculate delta time for animations.
instant: ?std.time.Instant = null,
pub var offset: c_int = 0;
};
//---------------------------------------------------------------
// Virtual Methods
fn init(self: *Self, _: *Class) callconv(.c) void {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
fn dispose(self: *Self) callconv(.c) void {
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
);
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
);
}
//---------------------------------------------------------------
// Public methods
/// This should be called anytime the underlying data for the UI changes
/// so that the UI can be refreshed.
pub fn queueRender(self: *ImguiWidget) void {
const priv = self.private();
priv.gl_area.queueRender();
}
//---------------------------------------------------------------
// Private Methods
/// Set our imgui context to be current, or return an error. This must be
/// called before any Dear ImGui API calls so that they're made against
/// the proper context.
fn setCurrentContext(self: *Self) error{ContextNotInitialized}!void {
const priv = self.private();
const ig_context = priv.ig_context orelse {
log.warn("Dear ImGui context not initialized", .{});
return error.ContextNotInitialized;
};
cimgui.c.igSetCurrentContext(ig_context);
}
/// Initialize the frame. Expects that the context is already current.
fn newFrame(self: *Self) void {
// If we can't determine the time since the last frame we default to
// 1/60th of a second.
const default_delta_time = 1 / 60;
const priv = self.private();
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
// Determine our delta time
const now = std.time.Instant.now() catch unreachable;
io.DeltaTime = if (priv.instant) |prev| delta: {
const since_ns = now.since(prev);
const since_s: f32 = @floatFromInt(since_ns / std.time.ns_per_s);
break :delta @max(0.00001, since_s);
} else default_delta_time;
priv.instant = now;
}
/// Handle key press/release events.
fn keyEvent(
self: *ImguiWidget,
action: input.Action,
ec_key: *gtk.EventControllerKey,
keyval: c_uint,
_: c_uint,
gtk_mods: gdk.ModifierType,
) bool {
self.queueRender();
self.setCurrentContext() catch return false;
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const mods = key.translateMods(gtk_mods);
cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift);
cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftCtrl, mods.ctrl);
cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftAlt, mods.alt);
cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftSuper, mods.super);
// If our keyval has a key, then we send that key event
if (key.keyFromKeyval(keyval)) |inputkey| {
if (inputkey.imguiKey()) |imgui_key| {
cimgui.c.ImGuiIO_AddKeyEvent(io, imgui_key, action == .press);
}
}
// Try to process the event as text
if (ec_key.as(gtk.EventController).getCurrentEvent()) |event| {
const priv = self.private();
_ = priv.im_context.as(gtk.IMContext).filterKeypress(event);
}
return true;
}
/// Translate a GTK mouse button to a Dear ImGui mouse button.
fn translateMouseButton(button: c_uint) ?c_int {
return switch (button) {
1 => cimgui.c.ImGuiMouseButton_Left,
2 => cimgui.c.ImGuiMouseButton_Middle,
3 => cimgui.c.ImGuiMouseButton_Right,
else => null,
};
}
/// Get the scale factor that the display is operating at.
fn getScaleFactor(self: *Self) f64 {
const priv = self.private();
return @floatFromInt(priv.gl_area.as(gtk.Widget).getScaleFactor());
}
//---------------------------------------------------------------
// Properties
//---------------------------------------------------------------
// Signal Handlers
fn glAreaRealize(_: *gtk.GLArea, self: *Self) callconv(.c) void {
const priv = self.private();
assert(priv.ig_context == null);
priv.gl_area.makeCurrent();
if (priv.gl_area.getError()) |err| {
log.warn("GLArea for Dear ImGui widget failed to realize: {s}", .{err.f_message orelse "(unknown)"});
return;
}
priv.ig_context = cimgui.c.igCreateContext(null) orelse {
log.warn("unable to initialize Dear ImGui context", .{});
return;
};
self.setCurrentContext() catch return;
// Setup some basic config
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
io.BackendPlatformName = "ghostty_gtk";
// Realize means that our OpenGL context is ready, so we can now
// initialize the ImgUI OpenGL backend for our context.
_ = cimgui.ImGui_ImplOpenGL3_Init(null);
// Setup our app
signals.setup.impl.emit(
self,
null,
.{},
null,
);
}
/// Handle a request to unrealize the GLArea
fn glAreaUnrealize(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void {
assert(self.private().ig_context != null);
self.setCurrentContext() catch return;
cimgui.ImGui_ImplOpenGL3_Shutdown();
}
/// Handle a request to resize the GLArea
fn glAreaResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *Self) callconv(.c) void {
self.setCurrentContext() catch return;
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const scale_factor = area.as(gtk.Widget).getScaleFactor();
// Our display size is always unscaled. We'll do the scaling in the
// style instead. This creates crisper looking fonts.
io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) };
io.DisplayFramebufferScale = .{ .x = 1, .y = 1 };
// Setup a new style and scale it appropriately.
const style = cimgui.c.ImGuiStyle_ImGuiStyle();
defer cimgui.c.ImGuiStyle_destroy(style);
cimgui.c.ImGuiStyle_ScaleAllSizes(style, @floatFromInt(scale_factor));
const active_style = cimgui.c.igGetStyle();
active_style.* = style.*;
}
/// Handle a request to render the contents of our GLArea
fn glAreaRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Self) callconv(.c) c_int {
self.setCurrentContext() catch return @intFromBool(false);
// Setup our frame. We render twice because some ImGui behaviors
// take multiple renders to process. I don't know how to make this
// more efficient.
for (0..2) |_| {
cimgui.ImGui_ImplOpenGL3_NewFrame();
self.newFrame();
cimgui.c.igNewFrame();
// Use the callback to draw the UI.
signals.render.impl.emit(
self,
null,
.{},
null,
);
// Render
cimgui.c.igRender();
}
// OpenGL final render
gl.clearColor(0x28 / 0xFF, 0x2C / 0xFF, 0x34 / 0xFF, 1.0);
gl.clear(gl.c.GL_COLOR_BUFFER_BIT);
cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.igGetDrawData());
return @intFromBool(true);
}
fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void {
self.setCurrentContext() catch return;
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddFocusEvent(io, true);
self.queueRender();
}
fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void {
self.setCurrentContext() catch return;
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddFocusEvent(io, false);
self.queueRender();
}
fn ecKeyPressed(
ec_key: *gtk.EventControllerKey,
keyval: c_uint,
keycode: c_uint,
gtk_mods: gdk.ModifierType,
self: *ImguiWidget,
) callconv(.c) c_int {
return @intFromBool(self.keyEvent(
.press,
ec_key,
keyval,
keycode,
gtk_mods,
));
}
fn ecKeyReleased(
ec_key: *gtk.EventControllerKey,
keyval: c_uint,
keycode: c_uint,
gtk_mods: gdk.ModifierType,
self: *ImguiWidget,
) callconv(.c) void {
_ = self.keyEvent(
.release,
ec_key,
keyval,
keycode,
gtk_mods,
);
}
fn ecMousePressed(
gesture: *gtk.GestureClick,
_: c_int,
_: f64,
_: f64,
self: *ImguiWidget,
) callconv(.c) void {
self.queueRender();
self.setCurrentContext() catch return;
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton();
if (translateMouseButton(gdk_button)) |button| {
cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, true);
}
}
fn ecMouseReleased(
gesture: *gtk.GestureClick,
_: c_int,
_: f64,
_: f64,
self: *ImguiWidget,
) callconv(.c) void {
self.queueRender();
self.setCurrentContext() catch return;
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton();
if (translateMouseButton(gdk_button)) |button| {
cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, false);
}
}
fn ecMouseMotion(
_: *gtk.EventControllerMotion,
x: f64,
y: f64,
self: *ImguiWidget,
) callconv(.c) void {
self.queueRender();
self.setCurrentContext() catch return;
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const scale_factor = self.getScaleFactor();
cimgui.c.ImGuiIO_AddMousePosEvent(
io,
@floatCast(x * scale_factor),
@floatCast(y * scale_factor),
);
}
fn ecMouseScroll(
_: *gtk.EventControllerScroll,
x: f64,
y: f64,
self: *ImguiWidget,
) callconv(.c) c_int {
self.queueRender();
self.setCurrentContext() catch return @intFromBool(false);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddMouseWheelEvent(
io,
@floatCast(x),
@floatCast(-y),
);
return @intFromBool(true);
}
fn imCommit(
_: *gtk.IMMulticontext,
bytes: [*:0]u8,
self: *ImguiWidget,
) callconv(.c) void {
self.queueRender();
self.setCurrentContext() catch return;
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes);
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
pub const refSink = C.refSink;
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 = 5,
.name = "imgui-widget",
}),
);
// Bindings
class.bindTemplateChildPrivate("gl_area", .{});
class.bindTemplateChildPrivate("im_context", .{});
// Template Callbacks
class.bindTemplateCallback("realize", &glAreaRealize);
class.bindTemplateCallback("unrealize", &glAreaUnrealize);
class.bindTemplateCallback("resize", &glAreaResize);
class.bindTemplateCallback("render", &glAreaRender);
class.bindTemplateCallback("focus_enter", &ecFocusEnter);
class.bindTemplateCallback("focus_leave", &ecFocusLeave);
class.bindTemplateCallback("key_pressed", &ecKeyPressed);
class.bindTemplateCallback("key_released", &ecKeyReleased);
class.bindTemplateCallback("mouse_pressed", &ecMousePressed);
class.bindTemplateCallback("mouse_released", &ecMouseReleased);
class.bindTemplateCallback("mouse_motion", &ecMouseMotion);
class.bindTemplateCallback("scroll", &ecMouseScroll);
class.bindTemplateCallback("im_commit", &imCommit);
// Signals
signals.render.impl.register(.{});
signals.setup.impl.register(.{});
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
};
};

View File

@ -0,0 +1,255 @@
const std = @import("std");
const adw = @import("adw");
const gobject = @import("gobject");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const Inspector = @import("../../../inspector/Inspector.zig");
const Common = @import("../class.zig").Common;
const Surface = @import("surface.zig").Surface;
const ImguiWidget = @import("imgui_widget.zig").ImguiWidget;
const log = std.log.scoped(.gtk_ghostty_inspector_widget);
/// Widget for displaying the Ghostty inspector.
pub const InspectorWidget = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttyInspectorWidget",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const surface = struct {
pub const name = "surface";
const impl = gobject.ext.defineProperty(
name,
Self,
?*Surface,
.{
.accessor = .{
.getter = getSurfaceValue,
.setter = setSurfaceValue,
},
},
);
};
};
pub const signals = struct {};
const Private = struct {
/// The surface that we are attached to. This is NOT referenced.
/// We attach a weak notify to the object.
surface: ?*Surface = null,
/// The embedded Dear ImGui widget.
imgui_widget: *ImguiWidget,
pub var offset: c_int = 0;
};
//---------------------------------------------------------------
// Virtual Methods
fn init(self: *Self, _: *Class) callconv(.c) void {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
fn dispose(self: *Self) callconv(.c) void {
// Clear our surface so it deactivates the inspector.
self.setSurface(null);
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
);
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
);
}
//---------------------------------------------------------------
// Public methods
/// Queue a render of the Dear ImGui widget.
pub fn queueRender(self: *Self) void {
const priv = self.private();
priv.imgui_widget.queueRender();
}
//---------------------------------------------------------------
// Properties
fn getSurfaceValue(self: *Self, value: *gobject.Value) void {
gobject.ext.Value.set(
value,
self.private().surface,
);
}
fn setSurfaceValue(self: *Self, value: *const gobject.Value) void {
self.setSurface(gobject.ext.Value.get(
value,
?*Surface,
));
}
pub fn getSurface(self: *Self) ?*Surface {
return self.private().surface;
}
pub fn setSurface(self: *Self, surface_: ?*Surface) void {
const priv = self.private();
// Do nothing if we're not changing the value.
if (surface_ == priv.surface) return;
// Setup our notification to happen at the end because we're
// changing values no matter what.
self.as(gobject.Object).freezeNotify();
defer self.as(gobject.Object).thawNotify();
self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec);
// Deactivate the inspector on the old surface if it exists
// and set our value to null.
if (priv.surface) |old| old: {
priv.surface = null;
// Remove our weak ref
old.as(gobject.Object).weakUnref(
surfaceWeakNotify,
self,
);
// Deactivate the inspector
const core_surface = old.core() orelse break :old;
core_surface.deactivateInspector();
}
// Activate the inspector on the new surface.
const surface = surface_ orelse return;
const core_surface = surface.core() orelse return;
core_surface.activateInspector() catch |err| {
log.warn("failed to activate inspector err={}", .{err});
return;
};
// We use a weak reference on surface to determine if the surface
// was closed while our inspector was active.
surface.as(gobject.Object).weakRef(
surfaceWeakNotify,
self,
);
// Store our surface. We don't need to ref this because we setup
// the weak notify above.
priv.surface = surface;
self.queueRender();
}
//---------------------------------------------------------------
// Signal Handlers
fn surfaceWeakNotify(
ud: ?*anyopaque,
surface: *gobject.Object,
) callconv(.c) void {
const self: *Self = @ptrCast(@alignCast(ud orelse return));
const priv = self.private();
// The weak notify docs call out that we can specifically use the
// pointer values for comparison, but the objects themselves are unsafe.
if (@intFromPtr(priv.surface) != @intFromPtr(surface)) return;
// According to weak notify docs, "surface" is in the "dispose" state.
// Our surface doesn't clear the core surface until the "finalize"
// state so we should be able to safely access it here. We need to
// be really careful though.
const old = priv.surface orelse return;
const core_surface = old.core() orelse return;
core_surface.deactivateInspector();
priv.surface = null;
self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec);
// Note: in the future we should probably show some content on our
// window to note that the surface went away in case our embedding
// widget doesn't close itself. As I type this, our window closes
// immediately when the surface goes away so you don't see this, but
// for completeness sake we should clean this up.
}
fn imguiRender(
_: *ImguiWidget,
self: *Self,
) callconv(.c) void {
const priv = self.private();
const surface = priv.surface orelse return;
const core_surface = surface.core() orelse return;
const inspector = core_surface.inspector orelse return;
inspector.render();
}
fn imguiSetup(
_: *ImguiWidget,
_: *Self,
) callconv(.c) void {
Inspector.setup();
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
pub const refSink = C.refSink;
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 {
gobject.ext.ensureType(ImguiWidget);
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
.major = 1,
.minor = 5,
.name = "inspector-widget",
}),
);
// Bindings
class.bindTemplateChildPrivate("imgui_widget", .{});
// Template callbacks
class.bindTemplateCallback("imgui_render", &imguiRender);
class.bindTemplateCallback("imgui_setup", &imguiSetup);
// Properties
gobject.ext.registerProperties(class, &.{
properties.surface.impl,
});
// Signals
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
};
};

View File

@ -0,0 +1,220 @@
const std = @import("std");
const build_config = @import("../../../build_config.zig");
const adw = @import("adw");
const gdk = @import("gdk");
const gobject = @import("gobject");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const key = @import("../key.zig");
const Common = @import("../class.zig").Common;
const Application = @import("application.zig").Application;
const Surface = @import("surface.zig").Surface;
const DebugWarning = @import("debug_warning.zig").DebugWarning;
const InspectorWidget = @import("inspector_widget.zig").InspectorWidget;
const WeakRef = @import("../weak_ref.zig").WeakRef;
const log = std.log.scoped(.gtk_ghostty_inspector_window);
/// Window for displaying the Ghostty inspector.
pub const InspectorWindow = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.ApplicationWindow;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttyInspectorWindow",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const surface = struct {
pub const name = "surface";
const impl = gobject.ext.defineProperty(
name,
Self,
?*Surface,
.{
.accessor = .{
.getter = getSurfaceValue,
.setter = setSurfaceValue,
},
},
);
};
pub const debug = struct {
pub const name = "debug";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = build_config.is_debug,
.accessor = gobject.ext.typedAccessor(Self, bool, .{
.getter = struct {
pub fn getter(_: *Self) bool {
return build_config.is_debug;
}
}.getter,
}),
},
);
};
};
pub const signals = struct {};
const Private = struct {
/// The surface that we are attached to
surface: WeakRef(Surface) = .empty,
/// The embedded inspector widget.
inspector_widget: *InspectorWidget,
pub var offset: c_int = 0;
};
//---------------------------------------------------------------
// Virtual Methods
fn init(self: *Self, _: *Class) callconv(.c) void {
gtk.Widget.initTemplate(self.as(gtk.Widget));
// Add our dev CSS class if we're in debug mode.
if (comptime build_config.is_debug) {
self.as(gtk.Widget).addCssClass("devel");
}
// Set our window icon. We can't set this in the blueprint file
// because its dependent on the build config.
self.as(gtk.Window).setIconName(build_config.bundle_id);
}
fn dispose(self: *Self) callconv(.c) void {
// You MUST clear all weak refs in dispose, otherwise it causes
// memory corruption on dispose on the TARGET (weak referenced)
// object. The only way we caught this is via Valgrind. Its not a leak,
// its an invalid memory read. In practice, I found this sometimes
// caused hanging!
self.setSurface(null);
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
);
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
);
}
//---------------------------------------------------------------
// Public methods
pub fn new(surface: *Surface) *Self {
return gobject.ext.newInstance(Self, .{
.surface = surface,
});
}
/// Present the window.
pub fn present(self: *Self) void {
self.as(gtk.Window).present();
}
/// Queue a render of the embedded widget.
pub fn queueRender(self: *Self) void {
const priv = self.private();
priv.inspector_widget.queueRender();
}
//---------------------------------------------------------------
// Properties
fn setSurface(self: *Self, newvalue: ?*Surface) void {
const priv = self.private();
priv.surface.set(newvalue);
}
fn getSurfaceValue(self: *Self, value: *gobject.Value) void {
// Important: get() refs, so we take to not increase ref twice
gobject.ext.Value.take(
value,
self.private().surface.get(),
);
}
fn setSurfaceValue(self: *Self, value: *const gobject.Value) void {
self.setSurface(gobject.ext.Value.get(
value,
?*Surface,
));
}
//---------------------------------------------------------------
// Signal Handlers
fn propInspectorSurface(
inspector: *InspectorWidget,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
// If the inspector's surface went away, we destroy the window.
// The inspector has a weak notify on the surface so it knows
// if it goes nil.
if (inspector.getSurface() == null) {
self.as(gtk.Window).destroy();
}
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
pub const refSink = C.refSink;
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 {
gobject.ext.ensureType(DebugWarning);
gobject.ext.ensureType(InspectorWidget);
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
.major = 1,
.minor = 5,
.name = "inspector-window",
}),
);
// Template Bindings
class.bindTemplateChildPrivate("inspector_widget", .{});
// Template callbacks
class.bindTemplateCallback("notify_inspector_surface", &propInspectorSurface);
// Properties
gobject.ext.registerProperties(class, &.{
properties.surface.impl,
properties.debug.impl,
});
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
};
};

View File

@ -29,6 +29,8 @@ const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited;
const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog;
const Window = @import("window.zig").Window;
const WeakRef = @import("../weak_ref.zig").WeakRef;
const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
const log = std.log.scoped(.gtk_ghostty_surface);
@ -470,6 +472,9 @@ pub const Surface = extern struct {
// false by a parent widget.
bell_ringing: bool = false,
/// A weak reference to an inspector window.
inspector: ?*InspectorWindow = null,
// Template binds
child_exited_overlay: *ChildExited,
context_menu: *gtk.PopoverMenu,
@ -573,6 +578,36 @@ pub const Surface = extern struct {
return self.as(gtk.Widget).activateAction("win.toggle-command-palette", null) != 0;
}
pub fn controlInspector(
self: *Self,
value: apprt.Action.Value(.inspector),
) bool {
// Let's see if we have an inspector already.
const priv = self.private();
if (priv.inspector) |inspector| switch (value) {
.show => {},
// Our weak ref will set our private value to null
.toggle, .hide => inspector.as(gtk.Window).destroy(),
} else switch (value) {
.toggle, .show => {
const inspector = InspectorWindow.new(self);
inspector.present();
inspector.as(gobject.Object).weakRef(inspectorWeakNotify, self);
priv.inspector = inspector;
},
.hide => {},
}
return true;
}
/// Redraw our inspector, if there is one associated with this surface.
pub fn redrawInspector(self: *Self) void {
const priv = self.private();
if (priv.inspector) |v| v.queueRender();
}
pub fn showOnScreenKeyboard(self: *Self, event: ?*gdk.Event) bool {
const priv = self.private();
return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0;
@ -1287,10 +1322,12 @@ pub const Surface = extern struct {
fn dispose(self: *Self) callconv(.c) void {
const priv = self.private();
if (priv.config) |v| {
v.unref();
priv.config = null;
}
if (priv.progress_bar_timer) |timer| {
if (glib.Source.remove(timer) == 0) {
log.warn("unable to remove progress bar timer", .{});
@ -1317,6 +1354,10 @@ pub const Surface = extern struct {
// searching for this surface.
Application.default().core().deleteSurface(self.rt());
// NOTE: We must deinit the surface in the finalize call and NOT
// the dispose call because the inspector widget relies on this
// behavior with a weakRef to properly deactivate.
// Deinit the surface
v.deinit();
const alloc = Application.default().allocator();
@ -1721,6 +1762,15 @@ pub const Surface = extern struct {
self.grabFocus();
}
fn inspectorWeakNotify(
ud: ?*anyopaque,
_: *gobject.Object,
) callconv(.c) void {
const self: *Self = @ptrCast(@alignCast(ud orelse return));
const priv = self.private();
priv.inspector = null;
}
fn dtDrop(
_: *gtk.DropTarget,
value: *gobject.Value,

View File

@ -28,6 +28,7 @@ const Surface = @import("surface.zig").Surface;
const Tab = @import("tab.zig").Tab;
const DebugWarning = @import("debug_warning.zig").DebugWarning;
const CommandPalette = @import("command_palette.zig").CommandPalette;
const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
const WeakRef = @import("../weak_ref.zig").WeakRef;
const log = std.log.scoped(.gtk_ghostty_window);
@ -347,6 +348,7 @@ pub const Window = extern struct {
.{ "clear", actionClear, null },
// TODO: accept the surface that toggled the command palette
.{ "toggle-command-palette", actionToggleCommandPalette, null },
.{ "toggle-inspector", actionToggleInspector, null },
};
const action_map = self.as(gio.ActionMap);
@ -1820,6 +1822,23 @@ pub const Window = extern struct {
self.toggleCommandPalette();
}
/// Toggle the Ghostty inspector for the active surface.
fn toggleInspector(self: *Self) void {
const surface = self.getActiveSurface() orelse return;
_ = surface.controlInspector(.toggle);
}
/// React to a GTK action requesting that the Ghostty inspector be toggled.
fn actionToggleInspector(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
// TODO: accept the surface that toggled the command palette as a
// parameter
self.toggleInspector();
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;

View File

@ -0,0 +1,51 @@
using Gtk 4.0;
using Adw 1;
template $GhosttyImguiWidget: Adw.Bin {
styles [
"imgui",
]
Adw.Bin {
Gtk.GLArea gl_area {
auto-render: true;
// needs to be focusable so that we can receive events
focusable: true;
focus-on-click: true;
allowed-apis: gl;
realize => $realize();
unrealize => $unrealize();
resize => $resize();
render => $render();
EventControllerFocus {
enter => $focus_enter();
leave => $focus_leave();
}
EventControllerKey {
key-pressed => $key_pressed();
key-released => $key_released();
}
GestureClick {
pressed => $mouse_pressed();
released => $mouse_released();
button: 0;
}
EventControllerMotion {
motion => $mouse_motion();
}
EventControllerScroll {
scroll => $scroll();
flags: both_axes;
}
}
}
}
IMMulticontext im_context {
commit => $im_commit();
}

View File

@ -0,0 +1,18 @@
using Gtk 4.0;
using Adw 1;
template $GhosttyInspectorWidget: Adw.Bin {
styles [
"inspector",
]
hexpand: true;
vexpand: true;
Adw.Bin {
$GhosttyImguiWidget imgui_widget {
render => $imgui_render();
setup => $imgui_setup();
}
}
}

View File

@ -0,0 +1,38 @@
using Gtk 4.0;
using Adw 1;
template $GhosttyInspectorWindow: Adw.ApplicationWindow {
title: _("Ghostty: Terminal Inspector");
icon-name: "com.mitchellh.ghostty";
default-width: 1000;
default-height: 600;
styles [
"inspector",
]
content: Adw.ToolbarView {
[top]
Adw.HeaderBar {
title-widget: Adw.WindowTitle {
title: bind template.title;
};
}
Gtk.Box {
orientation: vertical;
spacing: 0;
hexpand: true;
vexpand: true;
$GhosttyDebugWarning {
visible: bind template.debug;
}
$GhosttyInspectorWidget inspector_widget {
notify::surface => $notify_inspector_surface();
surface: bind template.surface;
}
}
};
}

View File

@ -529,6 +529,17 @@
...
}
{
pango fontset
Memcheck:Leak
match-leak-kinds: possible
fun:*alloc
...
fun:FcFontRenderPrepare
fun:pango_fc_fontset_get_font_at
...
}
{
pango and fontconfig
Memcheck:Leak