diff --git a/include/ghostty.h b/include/ghostty.h index 5d85d8d51..743f3b3b2 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -27,6 +27,7 @@ extern "C" { typedef void *ghostty_app_t; typedef void *ghostty_config_t; typedef void *ghostty_surface_t; +typedef void *ghostty_inspector_t; // Enums are up top so we can reference them later. typedef enum { @@ -399,6 +400,14 @@ void ghostty_surface_split_focus(ghostty_surface_t, ghostty_split_focus_directio bool ghostty_surface_binding_action(ghostty_surface_t, const char *, uintptr_t); void ghostty_surface_complete_clipboard_request(ghostty_surface_t, const char *, uintptr_t, void *); +ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); +void ghostty_inspector_free(ghostty_surface_t); +bool ghostty_inspector_metal_init(ghostty_inspector_t, void *); +void ghostty_inspector_metal_render(ghostty_inspector_t, void *, void *); +bool ghostty_inspector_metal_shutdown(ghostty_inspector_t); +void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double); +void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t); + // APIs I'd like to get rid of eventually but are still needed for now. // Don't use these unless you know what you're doing. void ghostty_set_window_background_blur(ghostty_surface_t, void *); diff --git a/macos/Sources/Ghostty/InspectorView.swift b/macos/Sources/Ghostty/InspectorView.swift index 11c318a5c..7be50434f 100644 --- a/macos/Sources/Ghostty/InspectorView.swift +++ b/macos/Sources/Ghostty/InspectorView.swift @@ -1,6 +1,7 @@ import Foundation import MetalKit import SwiftUI +import GhosttyKit extension Ghostty { /// InspectableSurface is a type of Surface view that allows an inspector to be attached. @@ -13,20 +14,38 @@ extension Ghostty { SplitView(.vertical, left: { SurfaceWrapper(surfaceView: surfaceView, isSplit: isSplit) }, right: { - SurfaceInspector() + InspectorViewRepresentable(surfaceView: surfaceView) }) } } - struct SurfaceInspector: View { - var body: some View { - MetalView() + struct InspectorViewRepresentable: NSViewRepresentable { + /// The surface that this inspector represents. + let surfaceView: SurfaceView + + func makeNSView(context: Context) -> InspectorView { + let view = InspectorView() + view.surfaceView = self.surfaceView + return view + } + + func updateNSView(_ view: InspectorView, context: Context) { + view.surfaceView = self.surfaceView } } class InspectorView: MTKView { let commandQueue: MTLCommandQueue + var surfaceView: SurfaceView? = nil { + didSet { surfaceViewDidChange() } + } + + private var inspector: ghostty_inspector_t? { + guard let surfaceView = self.surfaceView else { return nil } + return surfaceView.inspector + } + override init(frame: CGRect, device: MTLDevice?) { // Initialize our Metal primitives guard @@ -41,7 +60,7 @@ extension Ghostty { // After initializing the parent we can set our own properties self.device = MTLCreateSystemDefaultDevice() - self.clearColor = MTLClearColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0) + self.clearColor = MTLClearColor(red: 0x28 / 0xFF, green: 0x2C / 0xFF, blue: 0x34 / 0xFF, alpha: 1.0) // Setup our tracking areas for mouse events updateTrackingAreas() @@ -55,6 +74,30 @@ extension Ghostty { trackingAreas.forEach { removeTrackingArea($0) } } + // MARK: Internal Inspector Funcs + + private func surfaceViewDidChange() { + guard let inspector = self.inspector else { return } + guard let device = self.device else { return } + let devicePtr = Unmanaged.passRetained(device).toOpaque() + ghostty_inspector_metal_init(inspector, devicePtr) + } + + private func updateSize() { + guard let inspector = self.inspector else { return } + + // Detect our X/Y scale factor so we can update our surface + let fbFrame = self.convertToBacking(self.frame) + let xScale = fbFrame.size.width / self.frame.size.width + let yScale = fbFrame.size.height / self.frame.size.height + ghostty_inspector_set_content_scale(inspector, xScale, yScale) + + // When our scale factor changes, so does our fb size so we send that too + ghostty_inspector_set_size(inspector, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height)) + } + + // MARK: NSView + override func updateTrackingAreas() { // To update our tracking area we just recreate it all. trackingAreas.forEach { removeTrackingArea($0) } @@ -76,17 +119,31 @@ extension Ghostty { userInfo: nil)) } + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + updateSize() + } + + override func resize(withOldSuperviewSize oldSize: NSSize) { + super.resize(withOldSuperviewSize: oldSize) + updateSize() + } + + // MARK: MTKView + override func draw(_ dirtyRect: NSRect) { guard let commandBuffer = self.commandQueue.makeCommandBuffer(), - let descriptor = self.currentRenderPassDescriptor, - let renderEncoder = - commandBuffer.makeRenderCommandEncoder( - descriptor: descriptor) else { + let descriptor = self.currentRenderPassDescriptor else { return } + + ghostty_inspector_metal_render( + inspector, + Unmanaged.passRetained(commandBuffer).toOpaque(), + Unmanaged.passRetained(descriptor).toOpaque() + ) - renderEncoder.endEncoding() guard let drawable = self.currentDrawable else { return } commandBuffer.present(drawable) commandBuffer.commit() diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index f2ea9b7bb..a0bfde298 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -233,7 +233,7 @@ extension Ghostty { return config } } - + /// The NSView implementation for a terminal surface. class SurfaceView: NSView, NSTextInputClient, ObservableObject { // The current title of the surface as defined by the pty. This can be @@ -245,6 +245,13 @@ extension Ghostty { // then the view is moved to a new window. var initialSize: NSSize? = nil + // Returns the inspector instance for this surface, or nil if the + // surface has been closed. + var inspector: ghostty_inspector_t? { + guard let surface = self.surface else { return nil } + return ghostty_surface_inspector(surface) + } + private(set) var surface: ghostty_surface_t? var error: Error? = nil diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index d8f6bf316..2b4732531 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -195,6 +195,7 @@ pub const Surface = struct { cursor_pos: apprt.CursorPos, opts: Options, keymap_state: input.Keymap.State, + inspector: ?*Inspector = null, pub const Options = extern struct { /// Userdata passed to some of the callbacks. @@ -290,6 +291,9 @@ pub const Surface = struct { } pub fn deinit(self: *Surface) void { + // Shut down our inspector + self.freeInspector(); + // Remove ourselves from the list of known surfaces in the app. self.app.core_app.deleteSurface(self); @@ -297,6 +301,28 @@ pub const Surface = struct { self.core_surface.deinit(); } + /// Initialize the inspector instance. A surface can only have one + /// inspector at any given time, so this will return the previous inspector + /// if it was already initialized. + pub fn initInspector(self: *Surface) !*Inspector { + if (self.inspector) |v| return v; + + const alloc = self.app.core_app.alloc; + var inspector = try alloc.create(Inspector); + errdefer alloc.destroy(inspector); + inspector.* = try Inspector.init(self); + self.inspector = inspector; + return inspector; + } + + pub fn freeInspector(self: *Surface) void { + if (self.inspector) |v| { + v.deinit(); + self.app.core_app.alloc.destroy(v); + self.inspector = null; + } + } + pub fn newSplit(self: *const Surface, direction: input.SplitDirection) !void { const func = self.app.opts.new_split orelse { log.info("runtime embedder does not support splits", .{}); @@ -781,6 +807,141 @@ pub const Surface = struct { } }; +/// Inspector is the state required for the terminal inspector. A terminal +/// inspector is 1:1 with a Surface. +pub const Inspector = struct { + const cimgui = @import("cimgui"); + + surface: *Surface, + ig_ctx: *cimgui.c.ImGuiContext, + backend: ?Backend = null, + + /// Our previous instant used to calculate delta time for animations. + instant: ?std.time.Instant = null, + + const Backend = enum { + metal, + + pub fn deinit(self: Backend) void { + switch (self) { + .metal => cimgui.c.ImGui_ImplMetal_Shutdown(), + } + } + }; + + pub fn init(surface: *Surface) !Inspector { + const ig_ctx = cimgui.c.igCreateContext(null); + errdefer cimgui.c.igDestroyContext(ig_ctx); + cimgui.c.igSetCurrentContext(ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + io.BackendPlatformName = "ghostty_embedded"; + + return .{ + .surface = surface, + .ig_ctx = ig_ctx, + }; + } + + pub fn deinit(self: *Inspector) void { + cimgui.c.igSetCurrentContext(self.ig_ctx); + if (self.backend) |v| v.deinit(); + cimgui.c.igDestroyContext(self.ig_ctx); + } + + /// Initialize the inspector for a metal backend. + pub fn initMetal(self: *Inspector, device: objc.Object) bool { + defer device.msgSend(void, objc.sel("release"), .{}); + cimgui.c.igSetCurrentContext(self.ig_ctx); + + if (self.backend) |v| { + v.deinit(); + self.backend = null; + } + + if (!cimgui.c.ImGui_ImplMetal_Init(device.value)) { + log.warn("failed to initialize metal backend", .{}); + return false; + } + self.backend = .metal; + + log.debug("initialized metal backend", .{}); + return true; + } + + pub fn renderMetal( + self: *Inspector, + command_buffer: objc.Object, + desc: objc.Object, + ) !void { + defer { + command_buffer.msgSend(void, objc.sel("release"), .{}); + desc.msgSend(void, objc.sel("release"), .{}); + } + assert(self.backend == .metal); + //log.debug("render", .{}); + + // Setup our imgui frame + { + cimgui.c.ImGui_ImplMetal_NewFrame(desc.value); + try self.newFrame(); + cimgui.c.igNewFrame(); + + // Build our UI + var show: bool = true; + cimgui.c.igShowDemoWindow(&show); + + // Render + cimgui.c.igRender(); + } + + // MTLRenderCommandEncoder + const encoder = command_buffer.msgSend( + objc.Object, + objc.sel("renderCommandEncoderWithDescriptor:"), + .{desc.value}, + ); + defer encoder.msgSend(void, objc.sel("endEncoding"), .{}); + cimgui.c.ImGui_ImplMetal_RenderDrawData( + cimgui.c.igGetDrawData(), + command_buffer.value, + encoder.value, + ); + } + + pub fn updateContentScale(self: *Inspector, x: f64, y: f64) void { + cimgui.c.igSetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + io.DisplayFramebufferScale = .{ + .x = @floatCast(x), + .y = @floatCast(y), + }; + } + + pub fn updateSize(self: *Inspector, width: u32, height: u32) void { + cimgui.c.igSetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const x_scale: u32 = @intFromFloat(io.DisplayFramebufferScale.x); + const y_scale: u32 = @intFromFloat(io.DisplayFramebufferScale.y); + io.DisplaySize = .{ + .x = @floatFromInt(@divFloor(width, x_scale)), + .y = @floatFromInt(@divFloor(height, y_scale)), + }; + } + + fn newFrame(self: *Inspector) !void { + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + + // Determine our delta time + const now = try std.time.Instant.now(); + io.DeltaTime = if (self.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 (1 / 60); + self.instant = now; + } +}; + // C API pub const CAPI = struct { const global = &@import("../main.zig").state; @@ -1047,6 +1208,50 @@ pub const CAPI = struct { }; } + export fn ghostty_surface_inspector(ptr: *Surface) ?*Inspector { + return ptr.initInspector() catch |err| { + log.err("error initializing inspector err={}", .{err}); + return null; + }; + } + + export fn ghostty_inspector_free(ptr: *Surface) void { + ptr.freeInspector(); + } + + export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool { + return ptr.initMetal(objc.Object.fromId(device)); + } + + export fn ghostty_inspector_metal_render( + ptr: *Inspector, + command_buffer: objc.c.id, + descriptor: objc.c.id, + ) void { + return ptr.renderMetal( + objc.Object.fromId(command_buffer), + objc.Object.fromId(descriptor), + ) catch |err| { + log.err("error rendering inspector err={}", .{err}); + return; + }; + } + + export fn ghostty_inspector_metal_shutdown(ptr: *Inspector) void { + if (ptr.backend) |v| { + v.deinit(); + ptr.backend = null; + } + } + + export fn ghostty_inspector_set_size(ptr: *Inspector, w: u32, h: u32) void { + ptr.updateSize(w, h); + } + + export fn ghostty_inspector_set_content_scale(ptr: *Inspector, x: f64, y: f64) void { + ptr.updateContentScale(x, y); + } + /// Sets the window background blur on macOS to the desired value. /// I do this in Zig as an extern function because I don't know how to /// call these functions in Swift.