diff --git a/src/apprt/gtk-ng/App.zig b/src/apprt/gtk-ng/App.zig index bc6c11102..4d2006fbb 100644 --- a/src/apprt/gtk-ng/App.zig +++ b/src/apprt/gtk-ng/App.zig @@ -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(); } diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig index d1a5cbec3..1614d74d0 100644 --- a/src/apprt/gtk-ng/Surface.zig +++ b/src/apprt/gtk-ng/Surface.zig @@ -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(); +} diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index f606435c6..2d2738fdb 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -48,6 +48,9 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "tab" }, .{ .major = 1, .minor = 5, .name = "window" }, .{ .major = 1, .minor = 5, .name = "command-palette" }, + .{ .major = 1, .minor = 5, .name = "imgui-widget" }, + .{ .major = 1, .minor = 5, .name = "inspector-widget" }, + .{ .major = 1, .minor = 5, .name = "inspector-window" }, }; /// CSS files in css_path diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index b762eff12..1fa61f791 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -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 diff --git a/src/apprt/gtk-ng/class/imgui_widget.zig b/src/apprt/gtk-ng/class/imgui_widget.zig new file mode 100644 index 000000000..56c5370c7 --- /dev/null +++ b/src/apprt/gtk-ng/class/imgui_widget.zig @@ -0,0 +1,492 @@ +const std = @import("std"); + +const adw = @import("adw"); +const gdk = @import("gdk"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const cimgui = @import("cimgui"); +const gl = @import("opengl"); + +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); + +pub const RenderCallback = *const fn (?*anyopaque) void; +pub const RenderUserdata = *anyopaque; + +/// A widget for embedding a Dear ImGui application. +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 {}; + + 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 + ig_context: ?*cimgui.c.ImGuiContext = null, + + /// True if the the Dear ImGui OpenGL backend was initialized. + ig_gl_backend_initialized: bool = false, + + /// Our previous instant used to calculate delta time for animations. + instant: ?std.time.Instant = null, + + /// This is called every frame to populate the Dear ImGui frame. + render_callback: ?RenderCallback = null, + render_userdata: ?RenderUserdata = null, + + pub var offset: c_int = 0; + }; + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + const priv = self.private(); + + priv.ig_context = ig_context: { + const ig_context = cimgui.c.igCreateContext(null) orelse { + log.warn("unable to initialize Dear ImGui context", .{}); + break :ig_context null; + }; + errdefer cimgui.c.igDestroyContext(ig_context); + cimgui.c.igSetCurrentContext(ig_context); + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + io.BackendPlatformName = "ghostty_gtk"; + + break :ig_context ig_context; + }; + } + + 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), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + + // If the the Dear ImGui OpenGL backend was never initialized then we + // need to destroy the Dear ImGui context manually here. If it _was_ + // initialized cleanup will be handled when the GLArea is unrealized. + if (!priv.ig_gl_backend_initialized) { + if (priv.ig_context) |ig_context| { + cimgui.c.igDestroyContext(ig_context); + priv.ig_context = null; + } + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Public methods + + pub fn new() *Self { + return gobject.ext.newInstance(Self, .{}); + } + + /// Use to setup the Dear ImGui application. + pub fn setup(self: *Self, callback: *const fn () void) void { + self.setCurrentContext() catch return; + callback(); + } + + /// Set the callback used to render every frame. + pub fn setRenderCallback( + self: *Self, + callback: ?RenderCallback, + userdata: ?RenderUserdata, + ) void { + const priv = self.private(); + priv.render_callback = callback; + priv.render_userdata = userdata; + } + + /// 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. + 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(); + + priv.gl_area.makeCurrent(); + if (priv.gl_area.getError()) |err| { + log.err("GLArea for Dear ImGui widget failed to realize: {s}", .{err.f_message orelse "(unknown)"}); + return; + } + + self.setCurrentContext() catch return; + + // 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); + + priv.ig_gl_backend_initialized = true; + } + + /// Handle a request to unrealize the GLArea + fn glAreaUnrealize(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { + 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. + const priv = self.private(); + if (priv.render_callback) |cb| cb(priv.render_userdata); + + // 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.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGuiIO_AddFocusEvent(io, true); + } + + fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGuiIO_AddFocusEvent(io, false); + } + + 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); + + // Properties + + // Signals + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk-ng/class/inspector_widget.zig b/src/apprt/gtk-ng/class/inspector_widget.zig new file mode 100644 index 000000000..b36d6b3c0 --- /dev/null +++ b/src/apprt/gtk-ng/class/inspector_widget.zig @@ -0,0 +1,210 @@ +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 WeakRef = @import("../weak_ref.zig").WeakRef; +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 = gobject.ext.typedAccessor(Self, ?*Surface, .{ + .getter = getSurface, + .getter_transfer = .full, + .setter = setSurface, + .setter_transfer = .none, + }), + }, + ); + }; + }; + + pub const signals = struct {}; + + const Private = struct { + /// The surface that we are attached to + surface: WeakRef(Surface) = .empty, + + /// 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)); + + const priv = self.private(); + priv.imgui_widget.setup(Inspector.setup); + priv.imgui_widget.setRenderCallback(imguiRender, self); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + deactivate: { + const surface = priv.surface.get() orelse break :deactivate; + defer surface.unref(); + + const core_surface = surface.core() orelse break :deactivate; + core_surface.deactivateInspector(); + } + + 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, + }); + } + + /// Queue a render of the Dear ImGui widget. + pub fn queueRender(self: *Self) void { + const priv = self.private(); + priv.imgui_widget.queueRender(); + } + + //--------------------------------------------------------------- + // Private Methods + + /// This is the callback from the embedded Dear ImGui widget that is called + /// to do the actual drawing. + fn imguiRender(ud: ?*anyopaque) void { + const self: *Self = @ptrCast(@alignCast(ud orelse return)); + const priv = self.private(); + const surface = priv.surface.get() orelse return; + defer surface.unref(); + const core_surface = surface.core() orelse return; + const inspector = core_surface.inspector orelse return; + inspector.render(); + } + + //--------------------------------------------------------------- + // Properties + + fn getSurface(self: *Self) ?*Surface { + const priv = self.private(); + return priv.surface.get(); + } + + fn setSurface(self: *Self, newvalue_: ?*Surface) void { + const priv = self.private(); + + if (priv.surface.get()) |oldvalue| oldvalue: { + defer oldvalue.unref(); + + // We don't need to do anything if we're just setting the same surface. + if (newvalue_) |newvalue| if (newvalue == oldvalue) return; + + // Deactivate the inspector on the old surface. + const core_surface = oldvalue.core() orelse break :oldvalue; + core_surface.deactivateInspector(); + } + + const newvalue = newvalue_ orelse { + priv.surface.set(null); + return; + }; + + const core_surface = newvalue.core() orelse { + priv.surface.set(null); + return; + }; + + // Activate the inspector on the new surface. + core_surface.activateInspector() catch |err| { + log.err("failed to activate inspector err={}", .{err}); + }; + + priv.surface.set(newvalue); + + self.queueRender(); + } + + //--------------------------------------------------------------- + // Signal Handlers + + 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", .{}); + + // 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; + }; +}; diff --git a/src/apprt/gtk-ng/class/inspector_window.zig b/src/apprt/gtk-ng/class/inspector_window.zig new file mode 100644 index 000000000..7f5c8fe10 --- /dev/null +++ b/src/apprt/gtk-ng/class/inspector_window.zig @@ -0,0 +1,225 @@ +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 = gobject.ext.typedAccessor(Self, ?*Surface, .{ + .getter = getSurface, + .getter_transfer = .full, + .setter = setSurface, + .setter_transfer = .none, + }), + }, + ); + }; + + 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 { + 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 { + const self = gobject.ext.newInstance(Self, .{ + .surface = surface, + }); + + // Bump the ref so that we aren't immediately closed. + return self.ref(); + } + + /// 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(); + } + + /// The surface we are connected to is going away, shut ourselves down. + pub fn shutdown(self: *Self) void { + const priv = self.private(); + priv.surface.set(null); + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); + self.as(gtk.Window).close(); + } + + //--------------------------------------------------------------- + // Private Methods + + fn isFullscreen(self: *Self) bool { + return self.as(gtk.Window).isFullscreen() != 0; + } + + fn isMaximized(self: *Self) bool { + return self.as(gtk.Window).isMaximized() != 0; + } + + //--------------------------------------------------------------- + // Properties + + fn getSurface(self: *Self) ?*Surface { + const priv = self.private(); + return priv.surface.get(); + } + + fn setSurface(self: *Self, newvalue: ?*Surface) void { + const priv = self.private(); + priv.surface.set(newvalue); + } + + //--------------------------------------------------------------- + // Signal Handlers + + /// The user has clicked on the close button. + fn closeRequest(_: *gtk.Window, self: *Self) callconv(.c) c_int { + const priv = self.private(); + priv.surface.set(null); + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); + self.as(gtk.Window).destroy(); + return @intFromBool(false); + } + + 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("close_request", &closeRequest); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.surface.impl, + properties.debug.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; + }; +}; diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 2398f1502..65b128bdf 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -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: WeakRef(InspectorWindow) = .empty, + // Template binds child_exited_overlay: *ChildExited, context_menu: *gtk.PopoverMenu, @@ -573,6 +578,58 @@ pub const Surface = extern struct { return self.as(gtk.Widget).activateAction("win.toggle-command-palette", null) != 0; } + pub fn toggleInspector(self: *Self) bool { + const priv = self.private(); + if (priv.inspector.get()) |inspector| { + defer inspector.unref(); + inspector.shutdown(); + priv.inspector.set(null); + return true; + } + const inspector = InspectorWindow.new(self); + defer inspector.unref(); + priv.inspector.set(inspector); + inspector.present(); + return true; + } + + pub fn showInspector(self: *Self) bool { + const priv = self.private(); + const inspector = priv.inspector.get() orelse inspector: { + const inspector = InspectorWindow.new(self); + priv.inspector.set(inspector); + break :inspector inspector; + }; + defer inspector.unref(); + inspector.present(); + return true; + } + + pub fn hideInspector(self: *Self) bool { + const priv = self.private(); + if (priv.inspector.get()) |inspector| { + defer inspector.unref(); + inspector.shutdown(); + priv.inspector.set(null); + } + return true; + } + + pub fn controlInspector(self: *Self, value: apprt.Action.Value(.inspector)) bool { + switch (value) { + .toggle => return self.toggleInspector(), + .show => return self.showInspector(), + .hide => return self.hideInspector(), + } + } + /// Redraw our inspector, if there is one associated with this surface. + pub fn redrawInspector(self: *Self) void { + const priv = self.private(); + const inspector = priv.inspector.get() orelse return; + defer inspector.unref(); + inspector.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 +1344,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", .{}); @@ -1298,6 +1357,11 @@ pub const Surface = extern struct { priv.progress_bar_timer = null; } + if (priv.inspector.get()) |inspector| { + defer inspector.unref(); + inspector.shutdown(); + } + gtk.Widget.disposeTemplate( self.as(gtk.Widget), getGObjectType(), diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index a480ed217..da4e9574f 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -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.toggleInspector(); + } + + /// 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; diff --git a/src/apprt/gtk-ng/ui/1.5/imgui-widget.blp b/src/apprt/gtk-ng/ui/1.5/imgui-widget.blp new file mode 100644 index 000000000..d5b973a70 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/imgui-widget.blp @@ -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(); +} diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp b/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp new file mode 100644 index 000000000..0cbd45d8a --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp @@ -0,0 +1,15 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyInspectorWidget: Adw.Bin { + styles [ + "inspector", + ] + + hexpand: true; + vexpand: true; + + Adw.Bin { + $GhosttyImguiWidget imgui_widget {} + } +} diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-window.blp b/src/apprt/gtk-ng/ui/1.5/inspector-window.blp new file mode 100644 index 000000000..a67e26622 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/inspector-window.blp @@ -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; + close-request => $close_request(); + + 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 { + surface: bind template.surface; + } + } + }; +}