Merge pull request #728 from mitchellh/cimgui

Terminal Inspector v1
This commit is contained in:
Mitchell Hashimoto
2023-10-26 10:12:39 -07:00
committed by GitHub
55 changed files with 14563 additions and 254 deletions

1
.gitattributes vendored
View File

@ -1,2 +1,3 @@
vendor/** linguist-vendored
website/** linguist-documentation
pkg/cimgui/vendor/** linguist-vendored

View File

@ -621,6 +621,7 @@ fn addDeps(
};
// Dependencies
const cimgui_dep = b.dependency("cimgui", .{ .target = step.target, .optimize = step.optimize });
const js_dep = b.dependency("zig_js", .{ .target = step.target, .optimize = step.optimize });
const libxev_dep = b.dependency("libxev", .{ .target = step.target, .optimize = step.optimize });
const objc_dep = b.dependency("zig_objc", .{ .target = step.target, .optimize = step.optimize });
@ -724,6 +725,11 @@ fn addDeps(
try static_libs.append(macos_dep.artifact("macos").getEmittedBin());
}
// cimgui
step.addModule("cimgui", cimgui_dep.module("cimgui"));
step.linkLibrary(cimgui_dep.artifact("cimgui"));
try static_libs.append(cimgui_dep.artifact("cimgui").getEmittedBin());
// Tracy
step.addModule("tracy", tracy_dep.module("tracy"));
if (tracy) {

View File

@ -27,6 +27,7 @@
},
// C libs
.cimgui = .{ .path = "./pkg/cimgui" },
.fontconfig = .{ .path = "./pkg/fontconfig" },
.freetype = .{ .path = "./pkg/freetype" },
.harfbuzz = .{ .path = "./pkg/harfbuzz" },

View File

@ -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 {
@ -48,6 +49,12 @@ typedef enum {
GHOSTTY_SPLIT_FOCUS_RIGHT,
} ghostty_split_focus_direction_e;
typedef enum {
GHOSTTY_INSPECTOR_TOGGLE,
GHOSTTY_INSPECTOR_SHOW,
GHOSTTY_INSPECTOR_HIDE,
} ghostty_inspector_mode_e;
typedef enum {
GHOSTTY_MOUSE_RELEASE,
GHOSTTY_MOUSE_PRESS,
@ -322,12 +329,14 @@ typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *, ghostty
typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e, ghostty_surface_config_s);
typedef void (*ghostty_runtime_new_tab_cb)(void *, ghostty_surface_config_s);
typedef void (*ghostty_runtime_new_window_cb)(void *, ghostty_surface_config_s);
typedef void (*ghostty_runtime_control_inspector_cb)(void *, ghostty_inspector_mode_e);
typedef void (*ghostty_runtime_close_surface_cb)(void *, bool);
typedef void (*ghostty_runtime_focus_split_cb)(void *, ghostty_split_focus_direction_e);
typedef void (*ghostty_runtime_toggle_split_zoom_cb)(void *);
typedef void (*ghostty_runtime_goto_tab_cb)(void *, int32_t);
typedef void (*ghostty_runtime_toggle_fullscreen_cb)(void *, ghostty_non_native_fullscreen_e);
typedef void (*ghostty_runtime_set_initial_window_size_cb)(void *, uint32_t, uint32_t);
typedef void (*ghostty_runtime_render_inspector_cb)(void *);
typedef struct {
void *userdata;
@ -342,12 +351,14 @@ typedef struct {
ghostty_runtime_new_split_cb new_split_cb;
ghostty_runtime_new_tab_cb new_tab_cb;
ghostty_runtime_new_window_cb new_window_cb;
ghostty_runtime_control_inspector_cb control_inspector_cb;
ghostty_runtime_close_surface_cb close_surface_cb;
ghostty_runtime_focus_split_cb focus_split_cb;
ghostty_runtime_toggle_split_zoom_cb toggle_split_zoom_cb;
ghostty_runtime_goto_tab_cb goto_tab_cb;
ghostty_runtime_toggle_fullscreen_cb toggle_fullscreen_cb;
ghostty_runtime_set_initial_window_size_cb set_initial_window_size_cb;
ghostty_runtime_render_inspector_cb render_inspector_cb;
} ghostty_runtime_config_s;
//-------------------------------------------------------------------
@ -399,6 +410,20 @@ 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_focus(ghostty_inspector_t, bool);
void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double);
void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t);
void ghostty_inspector_mouse_button(ghostty_inspector_t, ghostty_input_mouse_state_e, ghostty_input_mouse_button_e, ghostty_input_mods_e);
void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double);
void ghostty_inspector_mouse_scroll(ghostty_inspector_t, double, double, ghostty_input_scroll_mods_t);
void ghostty_inspector_key(ghostty_inspector_t, ghostty_input_action_e, ghostty_input_key_e, ghostty_input_mods_e);
void ghostty_inspector_text(ghostty_inspector_t, const char *);
// 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 *);

View File

@ -26,6 +26,8 @@
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */; };
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; };
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; };
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; };
@ -59,6 +61,8 @@
A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProvider.swift; sourceTree = "<group>"; };
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = "<group>"; };
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = "<group>"; };
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = "<group>"; };
A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = "<group>"; };
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -115,6 +119,7 @@
children = (
A5CEAFFE29C2410700646FDA /* Backport.swift */,
8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A5FECBD829D2010400022361 /* WindowAccessor.swift */,
A5CEAFDA29B8005900646FDA /* SplitView */,
);
@ -151,6 +156,7 @@
A55B7BB729B6F53A0055DE60 /* Package.swift */,
A55B7BB529B6F47F0055DE60 /* AppState.swift */,
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */,
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */,
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */,
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */,
A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */,
@ -288,6 +294,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
A53426392A7DC55C00EBB7A2 /* PrimaryWindowManager.swift in Sources */,
85DE1C922A6A3DCA00493853 /* PrimaryWindow.swift in Sources */,
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
@ -304,6 +311,7 @@
A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */,
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
A55685E029A03A9F004303CE /* AppError.swift in Sources */,
A5FECBD929D2010400022361 /* WindowAccessor.swift in Sources */,
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,

View File

@ -36,6 +36,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
@IBOutlet private var menuIncreaseFontSize: NSMenuItem?
@IBOutlet private var menuDecreaseFontSize: NSMenuItem?
@IBOutlet private var menuResetFontSize: NSMenuItem?
@IBOutlet private var menuTerminalInspector: NSMenuItem?
/// The dock menu
private var dockMenu: NSMenu = NSMenu()
@ -216,6 +217,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
syncMenuShortcut(action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize)
syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize)
syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector)
// Dock menu
reloadDockMenu()
@ -394,4 +396,9 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
guard let surface = focusedSurface() else { return }
ghostty.changeFontSize(surface: surface, .reset)
}
@IBAction func toggleTerminalInspector(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.toggleTerminalInspector(surface: surface)
}
}

View File

@ -136,12 +136,14 @@ extension Ghostty {
new_split_cb: { userdata, direction, surfaceConfig in AppState.newSplit(userdata, direction: direction, config: surfaceConfig) },
new_tab_cb: { userdata, surfaceConfig in AppState.newTab(userdata, config: surfaceConfig) },
new_window_cb: { userdata, surfaceConfig in AppState.newWindow(userdata, config: surfaceConfig) },
control_inspector_cb: { userdata, mode in AppState.controlInspector(userdata, mode: mode) },
close_surface_cb: { userdata, processAlive in AppState.closeSurface(userdata, processAlive: processAlive) },
focus_split_cb: { userdata, direction in AppState.focusSplit(userdata, direction: direction) },
toggle_split_zoom_cb: { userdata in AppState.toggleSplitZoom(userdata) },
goto_tab_cb: { userdata, n in AppState.gotoTab(userdata, n: n) },
toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) },
set_initial_window_size_cb: { userdata, width, height in AppState.setInitialWindowSize(userdata, width: width, height: height) }
set_initial_window_size_cb: { userdata, width, height in AppState.setInitialWindowSize(userdata, width: width, height: height) },
render_inspector_cb: { userdata in AppState.renderInspector(userdata) }
)
// Create the ghostty app.
@ -299,6 +301,13 @@ extension Ghostty {
}
}
func toggleTerminalInspector(surface: ghostty_surface_t) {
let action = "inspector:toggle"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
// Called when the selected keyboard changes. We have to notify Ghostty so that
// it can reload the keyboard mapping for input.
@objc private func keyboardSelectionDidChange(notification: NSNotification) {
@ -416,6 +425,14 @@ extension Ghostty {
DispatchQueue.main.async { state.appTick() }
}
static func renderInspector(_ userdata: UnsafeMutableRawPointer?) {
guard let surface = self.surfaceUserdata(from: userdata) else { return }
NotificationCenter.default.post(
name: Notification.inspectorNeedsDisplay,
object: surface
)
}
static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?) {
let surfaceView = Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
guard let titleStr = String(cString: title!, encoding: .utf8) else { return }
@ -486,6 +503,13 @@ extension Ghostty {
)
}
static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) {
guard let surface = self.surfaceUserdata(from: userdata) else { return }
NotificationCenter.default.post(name: Notification.didControlInspector, object: surface, userInfo: [
"mode": mode,
])
}
/// Returns the GhosttyState from the given userdata value.
static private func appState(fromView view: SurfaceView) -> AppState? {
guard let surface = view.surface else { return nil }

View File

@ -32,6 +32,27 @@ extension Ghostty {
return flags
}
/// Translate event modifier flags to a ghostty mods enum.
static func ghosttyMods(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e {
var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue
if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue }
if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue }
if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue }
if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue }
// Handle sided input. We can't tell that both are pressed in the
// Ghostty structure but thats okay -- we don't use that information.
let rawFlags = flags.rawValue
if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue }
if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue }
if (rawFlags & UInt(NX_DEVICERALTKEYMASK) != 0) { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue }
if (rawFlags & UInt(NX_DEVICERCMDKEYMASK) != 0) { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue }
return ghostty_input_mods_e(mods)
}
/// A map from the Ghostty key enum to the keyEquivalent string for shortcuts.
static let keyToEquivalent: [ghostty_input_key_e : String] = [
// 0-9
@ -214,5 +235,122 @@ extension Ghostty {
0x3B: GHOSTTY_KEY_SEMICOLON,
0x2F: GHOSTTY_KEY_SLASH,
]
// Mapping of event keyCode to ghostty input key values. This is cribbed from
// glfw mostly since we started as a glfw-based app way back in the day!
static let keycodeToKey: [UInt16 : ghostty_input_key_e] = [
0x1D: GHOSTTY_KEY_ZERO,
0x12: GHOSTTY_KEY_ONE,
0x13: GHOSTTY_KEY_TWO,
0x14: GHOSTTY_KEY_THREE,
0x15: GHOSTTY_KEY_FOUR,
0x17: GHOSTTY_KEY_FIVE,
0x16: GHOSTTY_KEY_SIX,
0x1A: GHOSTTY_KEY_SEVEN,
0x1C: GHOSTTY_KEY_EIGHT,
0x19: GHOSTTY_KEY_NINE,
0x00: GHOSTTY_KEY_A,
0x0B: GHOSTTY_KEY_B,
0x08: GHOSTTY_KEY_C,
0x02: GHOSTTY_KEY_D,
0x0E: GHOSTTY_KEY_E,
0x03: GHOSTTY_KEY_F,
0x05: GHOSTTY_KEY_G,
0x04: GHOSTTY_KEY_H,
0x22: GHOSTTY_KEY_I,
0x26: GHOSTTY_KEY_J,
0x28: GHOSTTY_KEY_K,
0x25: GHOSTTY_KEY_L,
0x2E: GHOSTTY_KEY_M,
0x2D: GHOSTTY_KEY_N,
0x1F: GHOSTTY_KEY_O,
0x23: GHOSTTY_KEY_P,
0x0C: GHOSTTY_KEY_Q,
0x0F: GHOSTTY_KEY_R,
0x01: GHOSTTY_KEY_S,
0x11: GHOSTTY_KEY_T,
0x20: GHOSTTY_KEY_U,
0x09: GHOSTTY_KEY_V,
0x0D: GHOSTTY_KEY_W,
0x07: GHOSTTY_KEY_X,
0x10: GHOSTTY_KEY_Y,
0x06: GHOSTTY_KEY_Z,
0x27: GHOSTTY_KEY_APOSTROPHE,
0x2A: GHOSTTY_KEY_BACKSLASH,
0x2B: GHOSTTY_KEY_COMMA,
0x18: GHOSTTY_KEY_EQUAL,
0x32: GHOSTTY_KEY_GRAVE_ACCENT,
0x21: GHOSTTY_KEY_LEFT_BRACKET,
0x1B: GHOSTTY_KEY_MINUS,
0x2F: GHOSTTY_KEY_PERIOD,
0x1E: GHOSTTY_KEY_RIGHT_BRACKET,
0x29: GHOSTTY_KEY_SEMICOLON,
0x2C: GHOSTTY_KEY_SLASH,
0x33: GHOSTTY_KEY_BACKSPACE,
0x39: GHOSTTY_KEY_CAPS_LOCK,
0x75: GHOSTTY_KEY_DELETE,
0x7D: GHOSTTY_KEY_DOWN,
0x77: GHOSTTY_KEY_END,
0x24: GHOSTTY_KEY_ENTER,
0x35: GHOSTTY_KEY_ESCAPE,
0x7A: GHOSTTY_KEY_F1,
0x78: GHOSTTY_KEY_F2,
0x63: GHOSTTY_KEY_F3,
0x76: GHOSTTY_KEY_F4,
0x60: GHOSTTY_KEY_F5,
0x61: GHOSTTY_KEY_F6,
0x62: GHOSTTY_KEY_F7,
0x64: GHOSTTY_KEY_F8,
0x65: GHOSTTY_KEY_F9,
0x6D: GHOSTTY_KEY_F10,
0x67: GHOSTTY_KEY_F11,
0x6F: GHOSTTY_KEY_F12,
0x69: GHOSTTY_KEY_PRINT_SCREEN,
0x6B: GHOSTTY_KEY_F14,
0x71: GHOSTTY_KEY_F15,
0x6A: GHOSTTY_KEY_F16,
0x40: GHOSTTY_KEY_F17,
0x4F: GHOSTTY_KEY_F18,
0x50: GHOSTTY_KEY_F19,
0x5A: GHOSTTY_KEY_F20,
0x73: GHOSTTY_KEY_HOME,
0x72: GHOSTTY_KEY_INSERT,
0x7B: GHOSTTY_KEY_LEFT,
0x3A: GHOSTTY_KEY_LEFT_ALT,
0x3B: GHOSTTY_KEY_LEFT_CONTROL,
0x38: GHOSTTY_KEY_LEFT_SHIFT,
0x37: GHOSTTY_KEY_LEFT_SUPER,
0x47: GHOSTTY_KEY_NUM_LOCK,
0x79: GHOSTTY_KEY_PAGE_DOWN,
0x74: GHOSTTY_KEY_PAGE_UP,
0x7C: GHOSTTY_KEY_RIGHT,
0x3D: GHOSTTY_KEY_RIGHT_ALT,
0x3E: GHOSTTY_KEY_RIGHT_CONTROL,
0x3C: GHOSTTY_KEY_RIGHT_SHIFT,
0x36: GHOSTTY_KEY_RIGHT_SUPER,
0x31: GHOSTTY_KEY_SPACE,
0x30: GHOSTTY_KEY_TAB,
0x7E: GHOSTTY_KEY_UP,
0x52: GHOSTTY_KEY_KP_0,
0x53: GHOSTTY_KEY_KP_1,
0x54: GHOSTTY_KEY_KP_2,
0x55: GHOSTTY_KEY_KP_3,
0x56: GHOSTTY_KEY_KP_4,
0x57: GHOSTTY_KEY_KP_5,
0x58: GHOSTTY_KEY_KP_6,
0x59: GHOSTTY_KEY_KP_7,
0x5B: GHOSTTY_KEY_KP_8,
0x5C: GHOSTTY_KEY_KP_9,
0x45: GHOSTTY_KEY_KP_ADD,
0x41: GHOSTTY_KEY_KP_DECIMAL,
0x4B: GHOSTTY_KEY_KP_DIVIDE,
0x4C: GHOSTTY_KEY_KP_ENTER,
0x51: GHOSTTY_KEY_KP_EQUAL,
0x43: GHOSTTY_KEY_KP_MULTIPLY,
0x4E: GHOSTTY_KEY_KP_SUBTRACT,
];
}

View File

@ -30,7 +30,7 @@ extension Ghostty {
// surface. We need to keep the split root around so that we don't
// lose all of the surface state so this must be a ZStack.
if let surfaceView = zoomedSurface {
SurfaceWrapper(surfaceView: surfaceView)
InspectableSurface(surfaceView: surfaceView)
}
}
.focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil)
@ -343,7 +343,7 @@ extension Ghostty {
let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface)
let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: leaf.surface)
SurfaceWrapper(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty())
InspectableSurface(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty())
.onReceive(pub) { onNewSplit(notification: $0) }
.onReceive(pubClose) { onClose(notification: $0) }
.onReceive(pubFocus) { onMoveFocus(notification: $0) }

View File

@ -0,0 +1,443 @@
import Foundation
import MetalKit
import SwiftUI
import GhosttyKit
extension Ghostty {
/// InspectableSurface is a type of Surface view that allows an inspector to be attached.
struct InspectableSurface: View {
/// Same as SurfaceWrapper, see the doc comments there.
@ObservedObject var surfaceView: SurfaceView
var isSplit: Bool = false
// Maintain whether our view has focus or not
@FocusState private var inspectorFocus: Bool
var body: some View {
let center = NotificationCenter.default
let pubInspector = center.publisher(for: Notification.didControlInspector, object: surfaceView)
ZStack {
if (!surfaceView.inspectorVisible) {
SurfaceWrapper(surfaceView: surfaceView, isSplit: isSplit)
} else {
SplitView(.vertical, left: {
SurfaceWrapper(surfaceView: surfaceView, isSplit: isSplit)
}, right: {
InspectorViewRepresentable(surfaceView: surfaceView)
.focused($inspectorFocus)
.focusedValue(\.ghosttySurfaceTitle, surfaceView.title)
.focusedValue(\.ghosttySurfaceView, surfaceView)
})
}
}
.onReceive(pubInspector) { onControlInspector($0) }
}
private func onControlInspector(_ notification: SwiftUI.Notification) {
// Determine our mode
guard let modeAny = notification.userInfo?["mode"] else { return }
guard let mode = modeAny as? ghostty_inspector_mode_e else { return }
switch (mode) {
case GHOSTTY_INSPECTOR_TOGGLE:
surfaceView.inspectorVisible = !surfaceView.inspectorVisible
case GHOSTTY_INSPECTOR_SHOW:
surfaceView.inspectorVisible = true
case GHOSTTY_INSPECTOR_HIDE:
surfaceView.inspectorVisible = false
default:
return
}
// When we show the inspector, we want to focus on the inspector.
// When we hide the inspector, we want to move focus back to the surface.
if (surfaceView.inspectorVisible) {
// We need to delay this until SwiftUI shows the inspector.
DispatchQueue.main.async {
_ = surfaceView.resignFirstResponder()
inspectorFocus = true
}
} else {
Ghostty.moveFocus(to: surfaceView)
}
}
}
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
}
}
/// Inspector view is the view for the surface inspector (similar to a web inspector).
class InspectorView: MTKView, NSTextInputClient {
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
}
private var markedText: NSMutableAttributedString = NSMutableAttributedString()
// We need to support being a first responder so that we can get input events
override var acceptsFirstResponder: Bool { return true }
override init(frame: CGRect, device: MTLDevice?) {
// Initialize our Metal primitives
guard
let device = device ?? MTLCreateSystemDefaultDevice(),
let commandQueue = device.makeCommandQueue() else {
fatalError("GPU not available")
}
// Setup our properties before initializing the parent
self.commandQueue = commandQueue
super.init(frame: frame, device: device)
// This makes it so renders only happen when we request
self.enableSetNeedsDisplay = true
self.isPaused = true
// After initializing the parent we can set our own properties
self.device = MTLCreateSystemDefaultDevice()
self.clearColor = MTLClearColor(red: 0x28 / 0xFF, green: 0x2C / 0xFF, blue: 0x34 / 0xFF, alpha: 1.0)
// Setup our tracking areas for mouse events
updateTrackingAreas()
}
required init(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
deinit {
trackingAreas.forEach { removeTrackingArea($0) }
NotificationCenter.default.removeObserver(self)
}
// MARK: Internal Inspector Funcs
private func surfaceViewDidChange() {
let center = NotificationCenter.default
center.removeObserver(self)
guard let surfaceView = self.surfaceView else { return }
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)
// Register an observer for render requests
center.addObserver(
self,
selector: #selector(didRequestRender),
name: Ghostty.Notification.inspectorNeedsDisplay,
object: surfaceView)
}
@objc private func didRequestRender(notification: SwiftUI.Notification) {
self.needsDisplay = true
}
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 becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
if (result) {
if let inspector = self.inspector {
ghostty_inspector_set_focus(inspector, true)
}
}
return result
}
override func resignFirstResponder() -> Bool {
let result = super.resignFirstResponder()
if (result) {
if let inspector = self.inspector {
ghostty_inspector_set_focus(inspector, false)
}
}
return result
}
override func updateTrackingAreas() {
// To update our tracking area we just recreate it all.
trackingAreas.forEach { removeTrackingArea($0) }
// This tracking area is across the entire frame to notify us of mouse movements.
addTrackingArea(NSTrackingArea(
rect: frame,
options: [
.mouseMoved,
// Only send mouse events that happen in our visible (not obscured) rect
.inVisibleRect,
// We want active always because we want to still send mouse reports
// even if we're not focused or key.
.activeAlways,
],
owner: self,
userInfo: nil))
}
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
updateSize()
}
override func mouseDown(with event: NSEvent) {
guard let inspector = self.inspector else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods)
}
override func mouseUp(with event: NSEvent) {
guard let inspector = self.inspector else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods)
}
override func rightMouseDown(with event: NSEvent) {
guard let inspector = self.inspector else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods)
}
override func rightMouseUp(with event: NSEvent) {
guard let inspector = self.inspector else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods)
}
override func mouseMoved(with event: NSEvent) {
guard let inspector = self.inspector else { return }
// Convert window position to view position. Note (0, 0) is bottom left.
let pos = self.convert(event.locationInWindow, from: nil)
ghostty_inspector_mouse_pos(inspector, pos.x, frame.height - pos.y)
}
override func mouseDragged(with event: NSEvent) {
self.mouseMoved(with: event)
}
override func scrollWheel(with event: NSEvent) {
guard let inspector = self.inspector else { return }
// Builds up the "input.ScrollMods" bitmask
var mods: Int32 = 0
var x = event.scrollingDeltaX
var y = event.scrollingDeltaY
if event.hasPreciseScrollingDeltas {
mods = 1
// We do a 2x speed multiplier. This is subjective, it "feels" better to me.
x *= 2;
y *= 2;
// TODO(mitchellh): do we have to scale the x/y here by window scale factor?
}
// Determine our momentum value
var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE
switch (event.momentumPhase) {
case .began:
momentum = GHOSTTY_MOUSE_MOMENTUM_BEGAN
case .stationary:
momentum = GHOSTTY_MOUSE_MOMENTUM_STATIONARY
case .changed:
momentum = GHOSTTY_MOUSE_MOMENTUM_CHANGED
case .ended:
momentum = GHOSTTY_MOUSE_MOMENTUM_ENDED
case .cancelled:
momentum = GHOSTTY_MOUSE_MOMENTUM_CANCELLED
case .mayBegin:
momentum = GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN
default:
break
}
// Pack our momentum value into the mods bitmask
mods |= Int32(momentum.rawValue) << 1
ghostty_inspector_mouse_scroll(inspector, x, y, mods)
}
override func keyDown(with event: NSEvent) {
let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
keyAction(action, event: event)
self.interpretKeyEvents([event])
}
override func keyUp(with event: NSEvent) {
keyAction(GHOSTTY_ACTION_RELEASE, event: event)
}
override func flagsChanged(with event: NSEvent) {
let mod: UInt32;
switch (event.keyCode) {
case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue
case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue
case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue
case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue
case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue
default: return
}
// The keyAction function will do this AGAIN below which sucks to repeat
// but this is super cheap and flagsChanged isn't that common.
let mods = Ghostty.ghosttyMods(event.modifierFlags)
// If the key that pressed this is active, its a press, else release
var action = GHOSTTY_ACTION_RELEASE
if (mods.rawValue & mod != 0) { action = GHOSTTY_ACTION_PRESS }
keyAction(action, event: event)
}
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) {
guard let inspector = self.inspector else { return }
guard let key = Ghostty.keycodeToKey[event.keyCode] else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_key(inspector, action, key, mods)
}
// MARK: NSTextInputClient
func hasMarkedText() -> Bool {
return markedText.length > 0
}
func markedRange() -> NSRange {
guard markedText.length > 0 else { return NSRange() }
return NSRange(0...(markedText.length-1))
}
func selectedRange() -> NSRange {
return NSRange()
}
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
switch string {
case let v as NSAttributedString:
self.markedText = NSMutableAttributedString(attributedString: v)
case let v as String:
self.markedText = NSMutableAttributedString(string: v)
default:
print("unknown marked text: \(string)")
}
}
func unmarkText() {
self.markedText.mutableString.setString("")
}
func validAttributesForMarkedText() -> [NSAttributedString.Key] {
return []
}
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
return nil
}
func characterIndex(for point: NSPoint) -> Int {
return 0
}
func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0)
}
func insertText(_ string: Any, replacementRange: NSRange) {
// We must have an associated event
guard NSApp.currentEvent != nil else { return }
guard let inspector = self.inspector else { return }
// We want the string view of the any value
var chars = ""
switch (string) {
case let v as NSAttributedString:
chars = v.string
case let v as String:
chars = v
default:
return
}
let len = chars.utf8CString.count
if (len == 0) { return }
chars.withCString { ptr in
ghostty_inspector_text(inspector, ptr)
}
}
override func doCommand(by selector: Selector) {
// This currently just prevents NSBeep from interpretKeyEvents but in the future
// we may want to make some of this work.
}
// MARK: MTKView
override func draw(_ dirtyRect: NSRect) {
guard
let commandBuffer = self.commandQueue.makeCommandBuffer(),
let descriptor = self.currentRenderPassDescriptor else {
return
}
// We always update our size because sometimes draw is called
// between resize events and if our size is wrong with the underlying
// drawable we will crash.
updateSize()
// Render
ghostty_inspector_metal_render(
inspector,
Unmanaged.passRetained(commandBuffer).toOpaque(),
Unmanaged.passRetained(descriptor).toOpaque()
)
guard let drawable = self.currentDrawable else { return }
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
}

View File

@ -102,6 +102,12 @@ extension Ghostty.Notification {
/// Notification
static let didReceiveInitialWindowFrame = Notification.Name("com.mitchellh.ghostty.didReceiveInitialWindowFrame")
static let FrameKey = "com.mitchellh.ghostty.frame"
/// Notification to render the inspector for a surface
static let inspectorNeedsDisplay = Notification.Name("com.mitchellh.ghostty.inspectorNeedsDisplay")
/// Notification to show/hide the inspector
static let didControlInspector = Notification.Name("com.mitchellh.ghostty.didControlInspector")
}
// Make the input enum hashable.

View File

@ -245,6 +245,23 @@ 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)
}
// True if the inspector should be visible
@Published var inspectorVisible: Bool = false {
didSet {
if (oldValue && !inspectorVisible) {
guard let surface = self.surface else { return }
ghostty_inspector_free(surface)
}
}
}
private(set) var surface: ghostty_surface_t?
var error: Error? = nil
@ -561,25 +578,25 @@ extension Ghostty {
override func mouseDown(with event: NSEvent) {
guard let surface = self.surface else { return }
let mods = Self.translateFlags(event.modifierFlags)
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods)
}
override func mouseUp(with event: NSEvent) {
guard let surface = self.surface else { return }
let mods = Self.translateFlags(event.modifierFlags)
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods)
}
override func rightMouseDown(with event: NSEvent) {
guard let surface = self.surface else { return }
let mods = Self.translateFlags(event.modifierFlags)
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods)
}
override func rightMouseUp(with event: NSEvent) {
guard let surface = self.surface else { return }
let mods = Self.translateFlags(event.modifierFlags)
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods)
}
@ -723,7 +740,7 @@ extension Ghostty {
// The keyAction function will do this AGAIN below which sucks to repeat
// but this is super cheap and flagsChanged isn't that common.
let mods = Self.translateFlags(event.modifierFlags)
let mods = Ghostty.ghosttyMods(event.modifierFlags)
// If the key that pressed this is active, its a press, else release
var action = GHOSTTY_ACTION_RELEASE
@ -734,7 +751,7 @@ extension Ghostty {
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) {
guard let surface = self.surface else { return }
let mods = Self.translateFlags(event.modifierFlags)
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_key(surface, action, UInt32(event.keyCode), mods)
}
@ -858,143 +875,6 @@ extension Ghostty {
print("SEL: \(selector)")
}
private static func translateFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e {
var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue
if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue }
if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue }
if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue }
if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue }
// Handle sided input. We can't tell that both are pressed in the
// Ghostty structure but thats okay -- we don't use that information.
let rawFlags = flags.rawValue
if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue }
if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue }
if (rawFlags & UInt(NX_DEVICERALTKEYMASK) != 0) { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue }
if (rawFlags & UInt(NX_DEVICERCMDKEYMASK) != 0) { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue }
return ghostty_input_mods_e(mods)
}
// Mapping of event keyCode to ghostty input key values. This is cribbed from
// glfw mostly since we started as a glfw-based app way back in the day!
static let keycodes: [UInt16 : ghostty_input_key_e] = [
0x1D: GHOSTTY_KEY_ZERO,
0x12: GHOSTTY_KEY_ONE,
0x13: GHOSTTY_KEY_TWO,
0x14: GHOSTTY_KEY_THREE,
0x15: GHOSTTY_KEY_FOUR,
0x17: GHOSTTY_KEY_FIVE,
0x16: GHOSTTY_KEY_SIX,
0x1A: GHOSTTY_KEY_SEVEN,
0x1C: GHOSTTY_KEY_EIGHT,
0x19: GHOSTTY_KEY_NINE,
0x00: GHOSTTY_KEY_A,
0x0B: GHOSTTY_KEY_B,
0x08: GHOSTTY_KEY_C,
0x02: GHOSTTY_KEY_D,
0x0E: GHOSTTY_KEY_E,
0x03: GHOSTTY_KEY_F,
0x05: GHOSTTY_KEY_G,
0x04: GHOSTTY_KEY_H,
0x22: GHOSTTY_KEY_I,
0x26: GHOSTTY_KEY_J,
0x28: GHOSTTY_KEY_K,
0x25: GHOSTTY_KEY_L,
0x2E: GHOSTTY_KEY_M,
0x2D: GHOSTTY_KEY_N,
0x1F: GHOSTTY_KEY_O,
0x23: GHOSTTY_KEY_P,
0x0C: GHOSTTY_KEY_Q,
0x0F: GHOSTTY_KEY_R,
0x01: GHOSTTY_KEY_S,
0x11: GHOSTTY_KEY_T,
0x20: GHOSTTY_KEY_U,
0x09: GHOSTTY_KEY_V,
0x0D: GHOSTTY_KEY_W,
0x07: GHOSTTY_KEY_X,
0x10: GHOSTTY_KEY_Y,
0x06: GHOSTTY_KEY_Z,
0x27: GHOSTTY_KEY_APOSTROPHE,
0x2A: GHOSTTY_KEY_BACKSLASH,
0x2B: GHOSTTY_KEY_COMMA,
0x18: GHOSTTY_KEY_EQUAL,
0x32: GHOSTTY_KEY_GRAVE_ACCENT,
0x21: GHOSTTY_KEY_LEFT_BRACKET,
0x1B: GHOSTTY_KEY_MINUS,
0x2F: GHOSTTY_KEY_PERIOD,
0x1E: GHOSTTY_KEY_RIGHT_BRACKET,
0x29: GHOSTTY_KEY_SEMICOLON,
0x2C: GHOSTTY_KEY_SLASH,
0x33: GHOSTTY_KEY_BACKSPACE,
0x39: GHOSTTY_KEY_CAPS_LOCK,
0x75: GHOSTTY_KEY_DELETE,
0x7D: GHOSTTY_KEY_DOWN,
0x77: GHOSTTY_KEY_END,
0x24: GHOSTTY_KEY_ENTER,
0x35: GHOSTTY_KEY_ESCAPE,
0x7A: GHOSTTY_KEY_F1,
0x78: GHOSTTY_KEY_F2,
0x63: GHOSTTY_KEY_F3,
0x76: GHOSTTY_KEY_F4,
0x60: GHOSTTY_KEY_F5,
0x61: GHOSTTY_KEY_F6,
0x62: GHOSTTY_KEY_F7,
0x64: GHOSTTY_KEY_F8,
0x65: GHOSTTY_KEY_F9,
0x6D: GHOSTTY_KEY_F10,
0x67: GHOSTTY_KEY_F11,
0x6F: GHOSTTY_KEY_F12,
0x69: GHOSTTY_KEY_PRINT_SCREEN,
0x6B: GHOSTTY_KEY_F14,
0x71: GHOSTTY_KEY_F15,
0x6A: GHOSTTY_KEY_F16,
0x40: GHOSTTY_KEY_F17,
0x4F: GHOSTTY_KEY_F18,
0x50: GHOSTTY_KEY_F19,
0x5A: GHOSTTY_KEY_F20,
0x73: GHOSTTY_KEY_HOME,
0x72: GHOSTTY_KEY_INSERT,
0x7B: GHOSTTY_KEY_LEFT,
0x3A: GHOSTTY_KEY_LEFT_ALT,
0x3B: GHOSTTY_KEY_LEFT_CONTROL,
0x38: GHOSTTY_KEY_LEFT_SHIFT,
0x37: GHOSTTY_KEY_LEFT_SUPER,
0x47: GHOSTTY_KEY_NUM_LOCK,
0x79: GHOSTTY_KEY_PAGE_DOWN,
0x74: GHOSTTY_KEY_PAGE_UP,
0x7C: GHOSTTY_KEY_RIGHT,
0x3D: GHOSTTY_KEY_RIGHT_ALT,
0x3E: GHOSTTY_KEY_RIGHT_CONTROL,
0x3C: GHOSTTY_KEY_RIGHT_SHIFT,
0x36: GHOSTTY_KEY_RIGHT_SUPER,
0x31: GHOSTTY_KEY_SPACE,
0x30: GHOSTTY_KEY_TAB,
0x7E: GHOSTTY_KEY_UP,
0x52: GHOSTTY_KEY_KP_0,
0x53: GHOSTTY_KEY_KP_1,
0x54: GHOSTTY_KEY_KP_2,
0x55: GHOSTTY_KEY_KP_3,
0x56: GHOSTTY_KEY_KP_4,
0x57: GHOSTTY_KEY_KP_5,
0x58: GHOSTTY_KEY_KP_6,
0x59: GHOSTTY_KEY_KP_7,
0x5B: GHOSTTY_KEY_KP_8,
0x5C: GHOSTTY_KEY_KP_9,
0x45: GHOSTTY_KEY_KP_ADD,
0x41: GHOSTTY_KEY_KP_DECIMAL,
0x4B: GHOSTTY_KEY_KP_DIVIDE,
0x4C: GHOSTTY_KEY_KP_ENTER,
0x51: GHOSTTY_KEY_KP_EQUAL,
0x43: GHOSTTY_KEY_KP_MULTIPLY,
0x4E: GHOSTTY_KEY_KP_SUBTRACT,
];
}
}

View File

@ -0,0 +1,26 @@
import SwiftUI
import MetalKit
/// Renders an MTKView with the given renderer class.
struct MetalView<V: MTKView>: View {
@State private var metalView = V()
var body: some View {
MetalViewRepresentable(metalView: $metalView)
}
}
fileprivate struct MetalViewRepresentable<V: MTKView>: NSViewRepresentable {
@Binding var metalView: V
func makeNSView(context: Context) -> some NSView {
metalView
}
func updateNSView(_ view: NSViewType, context: Context) {
updateMetalView()
}
func updateMetalView() {
}
}

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="22154" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="22155" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22154"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22155"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@ -33,6 +33,7 @@
<outlet property="menuSelectSplitRight" destination="upj-mc-L7X" id="nLY-o1-lky"/>
<outlet property="menuSplitHorizontal" destination="VUR-Ld-nLx" id="RxO-Zw-ovb"/>
<outlet property="menuSplitVertical" destination="UDZ-4y-6xL" id="fgZ-Wb-8OR"/>
<outlet property="menuTerminalInspector" destination="QwP-M5-fvh" id="wJi-Dh-S9f"/>
<outlet property="menuToggleFullScreen" destination="8kY-Pi-KaY" id="yQg-6V-OO6"/>
<outlet property="menuZoomSplit" destination="oPd-mn-IEH" id="wTu-jK-egI"/>
</connections>
@ -153,6 +154,13 @@
<action selector="decreaseFontSize:" target="bbz-4X-AYv" id="rlw-0o-kA2"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="L3L-I8-sqk"/>
<menuItem title="Terminal Inspector" id="QwP-M5-fvh">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleTerminalInspector:" target="bbz-4X-AYv" id="DON-fR-wyr"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>

View File

@ -3,8 +3,8 @@
.version = "0.1.0",
.dependencies = .{
.macos_sdk = .{
.url = "https://github.com/mitchellh/zig-build-macos-sdk/archive/7e50d6ea241403615d5ebc9f1df4680d3907fa92.tar.gz",
.hash = "1220eb266898413ecfe5aaf7f29cc17eb479d046adecc94ebc7d5e1e807d2aabdd70",
.url = "https://github.com/mitchellh/zig-build-macos-sdk/archive/7a7cb3816617dbaf87ecbc3fd90ad56a6f828275.tar.gz",
.hash = "1220a1dd91457d0131b50db15fb1bc51208ecadf1a23ad5dfa2d3a6bb3d1de5230c1",
},
},
}

91
pkg/cimgui/build.zig Normal file
View File

@ -0,0 +1,91 @@
const std = @import("std");
const NativeTargetInfo = std.zig.system.NativeTargetInfo;
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
_ = b.addModule("cimgui", .{ .source_file = .{ .path = "main.zig" } });
const imgui = b.dependency("imgui", .{});
const freetype = b.dependency("freetype", .{
.target = target,
.optimize = optimize,
.@"enable-libpng" = true,
});
const lib = b.addStaticLibrary(.{
.name = "cimgui",
.target = target,
.optimize = optimize,
});
lib.linkLibC();
lib.linkLibCpp();
lib.linkLibrary(freetype.artifact("freetype"));
if (target.isWindows()) {
lib.linkSystemLibrary("imm32");
}
lib.addIncludePath(imgui.path(""));
var flags = std.ArrayList([]const u8).init(b.allocator);
defer flags.deinit();
try flags.appendSlice(&.{
"-DCIMGUI_FREETYPE=1",
"-DIMGUI_USE_WCHAR32=1",
"-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1",
});
if (target.isWindows()) {
try flags.appendSlice(&.{
"-DIMGUI_IMPL_API=extern\t\"C\"\t__declspec(dllexport)",
});
} else {
try flags.appendSlice(&.{
"-DIMGUI_IMPL_API=extern\t\"C\"",
});
}
lib.addCSourceFile(.{ .file = .{ .path = "vendor/cimgui.cpp" }, .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("imgui.cpp"), .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("imgui_draw.cpp"), .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("imgui_demo.cpp"), .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("imgui_widgets.cpp"), .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("imgui_tables.cpp"), .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("misc/freetype/imgui_freetype.cpp"), .flags = flags.items });
lib.addCSourceFile(.{
.file = imgui.path("backends/imgui_impl_opengl3.cpp"),
.flags = flags.items,
});
if (target.isDarwin()) {
if (!target.isNative()) try @import("apple_sdk").addPaths(b, lib);
lib.addCSourceFile(.{
.file = imgui.path("backends/imgui_impl_metal.mm"),
.flags = flags.items,
});
lib.addCSourceFile(.{
.file = imgui.path("backends/imgui_impl_osx.mm"),
.flags = flags.items,
});
}
lib.installHeadersDirectoryOptions(.{
.source_dir = .{ .path = "vendor" },
.install_dir = .header,
.install_subdir = "",
.include_extensions = &.{".h"},
});
b.installArtifact(lib);
const test_exe = b.addTest(.{
.name = "test",
.root_source_file = .{ .path = "main.zig" },
.target = target,
.optimize = optimize,
});
test_exe.linkLibrary(lib);
const tests_run = b.addRunArtifact(test_exe);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&tests_run.step);
}

16
pkg/cimgui/build.zig.zon Normal file
View File

@ -0,0 +1,16 @@
.{
.name = "cimgui",
.version = "1.89.9",
.paths = .{""},
.dependencies = .{
// This should be kept in sync with the submodule in the cimgui source
// code to be safe that they're compatible.
.imgui = .{
.url = "https://github.com/ocornut/imgui/archive/1d8e48c161370c37628c4f37f3f87cb19fbcb723.tar.gz",
.hash = "12205e93e208aada4c835acdc3e2c1fac95b3ad92b47abe6412ab043f9f13817ad9b",
},
.apple_sdk = .{ .path = "../apple-sdk" },
.freetype = .{ .path = "../freetype" },
},
}

24
pkg/cimgui/c.zig Normal file
View File

@ -0,0 +1,24 @@
const c = @cImport({
@cDefine("CIMGUI_DEFINE_ENUMS_AND_STRUCTS", "1");
@cInclude("cimgui.h");
});
// Export all of the C API
pub usingnamespace c;
// OpenGL
pub extern fn ImGui_ImplOpenGL3_Init(?[*:0]const u8) callconv(.C) bool;
pub extern fn ImGui_ImplOpenGL3_Shutdown() callconv(.C) void;
pub extern fn ImGui_ImplOpenGL3_NewFrame() callconv(.C) void;
pub extern fn ImGui_ImplOpenGL3_RenderDrawData(*c.ImDrawData) callconv(.C) void;
// Metal
pub extern fn ImGui_ImplMetal_Init(*anyopaque) callconv(.C) bool;
pub extern fn ImGui_ImplMetal_Shutdown() callconv(.C) void;
pub extern fn ImGui_ImplMetal_NewFrame(*anyopaque) callconv(.C) void;
pub extern fn ImGui_ImplMetal_RenderDrawData(*c.ImDrawData, *anyopaque, *anyopaque) callconv(.C) void;
// OSX
pub extern fn ImGui_ImplOSX_Init(*anyopaque) callconv(.C) bool;
pub extern fn ImGui_ImplOSX_Shutdown() callconv(.C) void;
pub extern fn ImGui_ImplOSX_NewFrame(*anyopaque) callconv(.C) void;

3
pkg/cimgui/main.zig Normal file
View File

@ -0,0 +1,3 @@
pub const c = @import("c.zig");
test {}

5554
pkg/cimgui/vendor/cimgui.cpp vendored Normal file

File diff suppressed because it is too large Load Diff

4577
pkg/cimgui/vendor/cimgui.h vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -205,6 +205,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
.quit => try self.setQuit(),
.surface_message => |msg| try self.surfaceMessage(msg.surface, msg.message),
.redraw_surface => |surface| try self.redrawSurface(rt_app, surface),
.redraw_inspector => |surface| try self.redrawInspector(rt_app, surface),
}
}
}
@ -232,6 +233,11 @@ fn redrawSurface(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) !void
rt_app.redrawSurface(surface);
}
fn redrawInspector(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) !void {
if (!self.hasSurface(&surface.core_surface)) return;
rt_app.redrawInspector(surface);
}
/// Create a new window
pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void {
if (!@hasDecl(apprt.App, "newWindow")) {
@ -304,6 +310,10 @@ pub const Message = union(enum) {
/// message if it needs to.
redraw_surface: *apprt.Surface,
/// Redraw the inspector. This is called whenever some non-OS event
/// causes the inspector to need to be redrawn.
redraw_inspector: *apprt.Surface,
const NewWindow = struct {
/// The parent surface
parent: ?*Surface = null,

View File

@ -33,6 +33,7 @@ const configpkg = @import("config.zig");
const input = @import("input.zig");
const App = @import("App.zig");
const internal_os = @import("os/main.zig");
const inspector = @import("inspector/main.zig");
const log = std.log.scoped(.surface);
@ -74,6 +75,9 @@ io: termio.Impl,
io_thread: termio.Thread,
io_thr: std.Thread,
/// Terminal inspector
inspector: ?*inspector.Inspector = null,
/// All the cached sizes since we need them at various times.
screen_size: renderer.ScreenSize,
grid_size: renderer.GridSize,
@ -550,8 +554,14 @@ pub fn deinit(self: *Surface) void {
self.font_lib.deinit();
self.alloc.destroy(self.font_group);
if (self.inspector) |v| {
v.deinit();
self.alloc.destroy(v);
}
self.alloc.destroy(self.renderer_state.mutex);
self.config.deinit();
log.info("surface closed addr={x}", .{@intFromPtr(self)});
}
@ -561,6 +571,53 @@ pub fn close(self: *Surface) void {
self.rt_surface.close(self.needsConfirmQuit());
}
/// Activate the inspector. This will begin collecting inspection data.
/// This will not affect the GUI. The GUI must use performAction to
/// show/hide the inspector UI.
pub fn activateInspector(self: *Surface) !void {
if (self.inspector != null) return;
// Setup the inspector
var ptr = try self.alloc.create(inspector.Inspector);
errdefer self.alloc.destroy(ptr);
ptr.* = try inspector.Inspector.init(self);
self.inspector = ptr;
// Put the inspector onto the render state
{
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
assert(self.renderer_state.inspector == null);
self.renderer_state.inspector = self.inspector;
}
// Notify our components we have an inspector active
_ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} });
_ = self.io_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} });
}
/// Deactivate the inspector and stop collecting any information.
pub fn deactivateInspector(self: *Surface) void {
const insp = self.inspector orelse return;
// Remove the inspector from the render state
{
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
assert(self.renderer_state.inspector != null);
self.renderer_state.inspector = null;
}
// Notify our components we have deactivated inspector
_ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} });
_ = self.io_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} });
// Deinit the inspector
insp.deinit();
self.alloc.destroy(insp);
self.inspector = null;
}
/// True if the surface requires confirmation to quit. This should be called
/// by apprt to determine if the surface should confirm before quitting.
pub fn needsConfirmQuit(self: *Surface) bool {
@ -947,6 +1004,24 @@ pub fn keyCallback(
) !bool {
// log.debug("keyCallback event={}", .{event});
// Setup our inspector event if we have an inspector.
var insp_ev: ?inspector.key.Event = if (self.inspector != null) ev: {
var copy = event;
copy.utf8 = "";
if (event.utf8.len > 0) copy.utf8 = try self.alloc.dupe(u8, event.utf8);
break :ev .{ .event = copy };
} else null;
// When we're done processing, we always want to add the event to
// the inspector.
defer if (insp_ev) |ev| {
if (self.inspector.?.recordKeyEvent(ev)) {
self.queueRender() catch {};
} else |err| {
log.warn("error adding key event to inspector err={}", .{err});
}
};
// Before encoding, we see if we have any keybindings for this
// key. Those always intercept before any encoding tasks.
binding: {
@ -984,7 +1059,10 @@ pub fn keyCallback(
// If we consume this event, then we are done. If we don't consume
// it, we processed the action but we still want to process our
// encodings, too.
if (consumed and performed) return true;
if (consumed and performed) {
if (insp_ev) |*ev| ev.binding = binding_action;
return true;
}
}
// If we allow KAM and KAM is enabled then we do nothing.
@ -1030,6 +1108,12 @@ pub fn keyCallback(
.len = @intCast(seq.len),
},
}, .{ .forever = {} });
if (insp_ev) |*ev| {
ev.pty = self.alloc.dupe(u8, seq) catch |err| err: {
log.warn("error copying pty data for inspector err={}", .{err});
break :err "";
};
}
try self.io_thread.wakeup.notify();
// If our event is any keypress that isn't a modifier and we generated
@ -1554,6 +1638,36 @@ pub fn mouseButtonCallback(
const tracy = trace(@src());
defer tracy.end();
// If we have an inspector, we always queue a render
if (self.inspector) |insp| {
defer self.queueRender() catch {};
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
// If the inspector is requesting a cell, then we intercept
// left mouse clicks and send them to the inspector.
if (insp.cell == .requested and
button == .left and
action == .press)
{
const pos = try self.rt_surface.getCursorPos();
const point = self.posToViewport(pos.x, pos.y);
const cell = self.renderer_state.terminal.screen.getCell(
.viewport,
point.y,
point.x,
);
insp.cell = .{ .selected = .{
.row = point.y,
.col = point.x,
.cell = cell,
} };
return;
}
}
// Always record our latest mouse state
self.mouse.click_state[@intCast(@intFromEnum(button))] = action;
self.mouse.mods = @bitCast(mods);
@ -1731,6 +1845,17 @@ pub fn cursorPosCallback(
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
// If we have an inspector, we need to always record position information
if (self.inspector) |insp| {
insp.mouse.last_xpos = pos.x;
insp.mouse.last_ypos = pos.y;
const point = self.posToViewport(pos.x, pos.y);
insp.mouse.last_point = point.toScreen(&self.io.terminal.screen);
try self.queueRender();
}
// Do a mouse report
if (self.io.terminal.flags.mouse_event != .none) report: {
// Shift overrides mouse "grabbing" in the window, taken from Kitty.
@ -2278,6 +2403,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
} else log.warn("runtime doesn't implement toggleFullscreen", .{});
},
.inspector => |mode| {
if (@hasDecl(apprt.Surface, "controlInspector")) {
self.rt_surface.controlInspector(mode);
} else log.warn("runtime doesn't implement controlInspector", .{});
},
.close_surface => self.close(),
.close_window => try self.app.closeSurface(self),
@ -2413,7 +2544,7 @@ fn completeClipboardReadOSC52(self: *Surface, data: []const u8, kind: u8) !void
self.io_thread.wakeup.notify() catch {};
}
const face_ttf = @embedFile("font/res/FiraCode-Regular.ttf");
const face_bold_ttf = @embedFile("font/res/FiraCode-Bold.ttf");
const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf");
const face_emoji_text_ttf = @embedFile("font/res/NotoEmoji-Regular.ttf");
pub const face_ttf = @embedFile("font/res/FiraCode-Regular.ttf");
pub const face_bold_ttf = @embedFile("font/res/FiraCode-Bold.ttf");
pub const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf");
pub const face_emoji_text_ttf = @embedFile("font/res/NotoEmoji-Regular.ttf");

View File

@ -15,6 +15,7 @@ const build_config = @import("build_config.zig");
pub usingnamespace @import("apprt/structs.zig");
pub const glfw = @import("apprt/glfw.zig");
pub const gtk = @import("apprt/gtk.zig");
pub const none = @import("apprt/none.zig");
pub const browser = @import("apprt/browser.zig");
pub const embedded = @import("apprt/embedded.zig");
pub const surface = @import("apprt/surface.zig");
@ -25,7 +26,7 @@ pub const surface = @import("apprt/surface.zig");
/// Window or something.
pub const runtime = switch (build_config.artifact) {
.exe => switch (build_config.app_runtime) {
.none => struct {},
.none => none,
.glfw => glfw,
.gtk => gtk,
},

View File

@ -13,6 +13,7 @@ const apprt = @import("../apprt.zig");
const input = @import("../input.zig");
const terminal = @import("../terminal/main.zig");
const CoreApp = @import("../App.zig");
const CoreInspector = @import("../inspector/main.zig").Inspector;
const CoreSurface = @import("../Surface.zig");
const configpkg = @import("../config.zig");
const Config = configpkg.Config;
@ -73,6 +74,9 @@ pub const App = struct {
/// New window with options.
new_window: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null,
/// Control the inspector visibility
control_inspector: ?*const fn (SurfaceUD, input.InspectorMode) callconv(.C) void = null,
/// Close the current surface given by this function.
close_surface: ?*const fn (SurfaceUD, bool) callconv(.C) void = null,
@ -91,6 +95,9 @@ pub const App = struct {
/// Set the initial window size. It is up to the user of libghostty to
/// determine if it is the initial window and set this appropriately.
set_initial_window_size: ?*const fn (SurfaceUD, u32, u32) callconv(.C) void = null,
/// Render the inspector for the given surface.
render_inspector: ?*const fn (SurfaceUD) callconv(.C) void = null,
};
/// Special values for the goto_tab callback.
@ -174,6 +181,11 @@ pub const App = struct {
// No-op, we use a threaded interface so we're constantly drawing.
}
pub fn redrawInspector(self: *App, surface: *Surface) void {
_ = self;
surface.queueInspectorRender();
}
pub fn newWindow(self: *App, parent: ?*CoreSurface) !void {
_ = self;
@ -195,6 +207,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 +303,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 +313,37 @@ 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 controlInspector(self: *const Surface, mode: input.InspectorMode) void {
const func = self.app.opts.control_inspector orelse {
log.info("runtime embedder does not support the terminal inspector", .{});
return;
};
func(self.opts.userdata, mode);
}
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", .{});
@ -762,6 +809,15 @@ pub const Surface = struct {
func(self.opts.userdata, width, height);
}
fn queueInspectorRender(self: *const Surface) void {
const func = self.app.opts.render_inspector orelse {
log.info("runtime embedder does not render_inspector", .{});
return;
};
func(self.opts.userdata);
}
fn newSurfaceOptions(self: *const Surface) apprt.Surface.Options {
const font_size: u16 = font_size: {
if (!self.app.config.@"window-inherit-font-size") break :font_size 0;
@ -781,6 +837,252 @@ 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,
keymap_state: input.Keymap.State = .{},
content_scale: f64 = 1,
/// 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";
// Setup our core inspector
CoreInspector.setup();
surface.core_surface.activateInspector() catch |err| {
log.err("failed to activate inspector err={}", .{err});
};
return .{
.surface = surface,
.ig_ctx = ig_ctx,
};
}
pub fn deinit(self: *Inspector) void {
self.surface.core_surface.deactivateInspector();
cimgui.c.igSetCurrentContext(self.ig_ctx);
if (self.backend) |v| v.deinit();
cimgui.c.igDestroyContext(self.ig_ctx);
}
/// Queue a render for the next frame.
pub fn queueRender(self: *Inspector) void {
self.surface.queueInspectorRender();
}
/// 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. We need to render multiple frames to ensure
// ImGui completes all its state processing. I don't know how to fix
// this.
for (0..2) |_| {
cimgui.c.ImGui_ImplMetal_NewFrame(desc.value);
try self.newFrame();
cimgui.c.igNewFrame();
// Build our UI
render: {
const surface = &self.surface.core_surface;
const inspector = surface.inspector orelse break :render;
inspector.render();
}
// 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 {
_ = y;
cimgui.c.igSetCurrentContext(self.ig_ctx);
// Cache our scale because we use it for cursor position calculations.
self.content_scale = x;
// 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, @floatCast(x));
const active_style = cimgui.c.igGetStyle();
active_style.* = style.*;
}
pub fn updateSize(self: *Inspector, width: u32, height: u32) void {
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) };
}
pub fn mouseButtonCallback(
self: *Inspector,
action: input.MouseButtonState,
button: input.MouseButton,
mods: input.Mods,
) void {
_ = mods;
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const imgui_button = switch (button) {
.left => cimgui.c.ImGuiMouseButton_Left,
.middle => cimgui.c.ImGuiMouseButton_Middle,
.right => cimgui.c.ImGuiMouseButton_Right,
else => return, // unsupported
};
cimgui.c.ImGuiIO_AddMouseButtonEvent(io, imgui_button, action == .press);
}
pub fn scrollCallback(
self: *Inspector,
xoff: f64,
yoff: f64,
mods: input.ScrollMods,
) void {
_ = mods;
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddMouseWheelEvent(
io,
@floatCast(xoff),
@floatCast(yoff),
);
}
pub fn cursorPosCallback(self: *Inspector, x: f64, y: f64) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddMousePosEvent(
io,
@floatCast(x * self.content_scale),
@floatCast(y * self.content_scale),
);
}
pub fn focusCallback(self: *Inspector, focused: bool) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddFocusEvent(io, focused);
}
pub fn textCallback(self: *Inspector, text: [:0]const u8) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, text.ptr);
}
pub fn keyCallback(
self: *Inspector,
action: input.Action,
key: input.Key,
mods: input.Mods,
) !void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
// Update all our modifiers
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);
// Send our keypress
if (key.imguiKey()) |imgui_key| {
cimgui.c.ImGuiIO_AddKeyEvent(
io,
imgui_key,
action == .press or action == .repeat,
);
}
}
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 +1349,113 @@ 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);
}
export fn ghostty_inspector_mouse_button(
ptr: *Inspector,
action: input.MouseButtonState,
button: input.MouseButton,
mods: c_int,
) void {
ptr.mouseButtonCallback(
action,
button,
@bitCast(@as(
input.Mods.Backing,
@truncate(@as(c_uint, @bitCast(mods))),
)),
);
}
export fn ghostty_inspector_mouse_pos(ptr: *Inspector, x: f64, y: f64) void {
ptr.cursorPosCallback(x, y);
}
export fn ghostty_inspector_mouse_scroll(
ptr: *Inspector,
x: f64,
y: f64,
scroll_mods: c_int,
) void {
ptr.scrollCallback(
x,
y,
@bitCast(@as(u8, @truncate(@as(c_uint, @bitCast(scroll_mods))))),
);
}
export fn ghostty_inspector_key(
ptr: *Inspector,
action: input.Action,
key: input.Key,
c_mods: c_int,
) void {
ptr.keyCallback(
action,
key,
@bitCast(@as(
input.Mods.Backing,
@truncate(@as(c_uint, @bitCast(c_mods))),
)),
) catch |err| {
log.err("error processing key event err={}", .{err});
return;
};
}
export fn ghostty_inspector_text(
ptr: *Inspector,
str: [*:0]const u8,
) void {
ptr.textCallback(std.mem.sliceTo(str, 0));
}
export fn ghostty_inspector_set_focus(ptr: *Inspector, focused: bool) void {
ptr.focusCallback(focused);
}
/// 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.

View File

@ -215,6 +215,13 @@ pub const App = struct {
@panic("This should never be called for GLFW.");
}
pub fn redrawInspector(self: *App, surface: *Surface) void {
_ = self;
_ = surface;
// GLFW doesn't support the inspector
}
fn glfwErrorCallback(code: glfw.ErrorCode, desc: [:0]const u8) void {
std.log.warn("glfw error={} message={s}", .{ code, desc });

View File

@ -6,5 +6,6 @@ pub const Surface = @import("gtk/Surface.zig");
test {
@import("std").testing.refAllDecls(@This());
_ = @import("gtk/inspector.zig");
_ = @import("gtk/key.zig");
}

View File

@ -25,6 +25,7 @@ const Surface = @import("Surface.zig");
const Window = @import("Window.zig");
const ConfigErrorsWindow = @import("ConfigErrorsWindow.zig");
const c = @import("c.zig");
const inspector = @import("inspector.zig");
const key = @import("key.zig");
const log = std.log.scoped(.gtk);
@ -215,6 +216,7 @@ fn updateConfigErrors(self: *App) !void {
fn syncActionAccelerators(self: *App) !void {
try self.syncActionAccelerator("app.quit", .{ .quit = {} });
try self.syncActionAccelerator("app.reload_config", .{ .reload_config = {} });
try self.syncActionAccelerator("app.toggle_inspector", .{ .inspector = .toggle });
try self.syncActionAccelerator("win.close", .{ .close_surface = {} });
try self.syncActionAccelerator("win.new_window", .{ .new_window = {} });
try self.syncActionAccelerator("win.new_tab", .{ .new_tab = {} });
@ -277,6 +279,12 @@ pub fn redrawSurface(self: *App, surface: *Surface) void {
surface.redraw();
}
/// Redraw the inspector for the given surface.
pub fn redrawInspector(self: *App, surface: *Surface) void {
_ = self;
surface.queueInspectorRender();
}
/// Called by CoreApp to create a new window with a new surface.
pub fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
const alloc = self.core_app.alloc;
@ -458,6 +466,7 @@ fn initMenu(self: *App) void {
const section = c.g_menu_new();
defer c.g_object_unref(section);
c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section)));
c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector");
c.g_menu_append(section, "Reload Configuration", "app.reload_config");
c.g_menu_append(section, "About Ghostty", "win.about");
}

View File

@ -0,0 +1,392 @@
const ImguiWidget = @This();
const std = @import("std");
const assert = std.debug.assert;
const cimgui = @import("cimgui");
const c = @import("c.zig");
const key = @import("key.zig");
const gl = @import("../../renderer/opengl/main.zig");
const input = @import("../../input.zig");
const log = std.log.scoped(.gtk_imgui_widget);
/// This is called every frame to populate the ImGui frame.
render_callback: ?*const fn (?*anyopaque) void = null,
render_userdata: ?*anyopaque = null,
/// Our OpenGL widget
gl_area: *c.GtkGLArea,
im_context: *c.GtkIMContext,
/// ImGui Context
ig_ctx: *cimgui.c.ImGuiContext,
/// Our previous instant used to calculate delta time for animations.
instant: ?std.time.Instant = null,
/// Initialize the widget. This must have a stable pointer for events.
pub fn init(self: *ImguiWidget) !void {
// Each widget gets its own imgui context so we can have multiple
// imgui views in the same application.
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_gtk";
// Our OpenGL area for drawing
const gl_area = c.gtk_gl_area_new();
c.gtk_gl_area_set_auto_render(@ptrCast(gl_area), 1);
// The GL area has to be focusable so that it can receive events
c.gtk_widget_set_focusable(@ptrCast(gl_area), 1);
c.gtk_widget_set_focus_on_click(@ptrCast(gl_area), 1);
// Clicks
const gesture_click = c.gtk_gesture_click_new();
errdefer c.g_object_unref(gesture_click);
c.gtk_gesture_single_set_button(@ptrCast(gesture_click), 0);
c.gtk_widget_add_controller(@ptrCast(gl_area), @ptrCast(gesture_click));
// Mouse movement
const ec_motion = c.gtk_event_controller_motion_new();
errdefer c.g_object_unref(ec_motion);
c.gtk_widget_add_controller(@ptrCast(gl_area), ec_motion);
// Scroll events
const ec_scroll = c.gtk_event_controller_scroll_new(
c.GTK_EVENT_CONTROLLER_SCROLL_BOTH_AXES |
c.GTK_EVENT_CONTROLLER_SCROLL_DISCRETE,
);
errdefer c.g_object_unref(ec_scroll);
c.gtk_widget_add_controller(@ptrCast(gl_area), ec_scroll);
// Focus controller will tell us about focus enter/exit events
const ec_focus = c.gtk_event_controller_focus_new();
errdefer c.g_object_unref(ec_focus);
c.gtk_widget_add_controller(@ptrCast(gl_area), ec_focus);
// Key event controller will tell us about raw keypress events.
const ec_key = c.gtk_event_controller_key_new();
errdefer c.g_object_unref(ec_key);
c.gtk_widget_add_controller(@ptrCast(gl_area), ec_key);
errdefer c.gtk_widget_remove_controller(@ptrCast(gl_area), ec_key);
// The input method context that we use to translate key events into
// characters. This doesn't have an event key controller attached because
// we call it manually from our own key controller.
const im_context = c.gtk_im_multicontext_new();
errdefer c.g_object_unref(im_context);
// Signals
_ = c.g_signal_connect_data(gl_area, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gl_area, "realize", c.G_CALLBACK(&gtkRealize), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gl_area, "unrealize", c.G_CALLBACK(&gtkUnrealize), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gl_area, "render", c.G_CALLBACK(&gtkRender), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gl_area, "resize", c.G_CALLBACK(&gtkResize), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_focus, "enter", c.G_CALLBACK(&gtkFocusEnter), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_focus, "leave", c.G_CALLBACK(&gtkFocusLeave), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_key, "key-pressed", c.G_CALLBACK(&gtkKeyPressed), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_key, "key-released", c.G_CALLBACK(&gtkKeyReleased), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_motion, "motion", c.G_CALLBACK(&gtkMouseMotion), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_scroll, "scroll", c.G_CALLBACK(&gtkMouseScroll), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gesture_click, "pressed", c.G_CALLBACK(&gtkMouseDown), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gesture_click, "released", c.G_CALLBACK(&gtkMouseUp), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(im_context, "commit", c.G_CALLBACK(&gtkInputCommit), self, null, c.G_CONNECT_DEFAULT);
self.* = .{
.gl_area = @ptrCast(gl_area),
.im_context = @ptrCast(im_context),
.ig_ctx = ig_ctx,
};
}
/// Deinitialize the widget. This should ONLY be called if the widget gl_area
/// was never added to a parent. Otherwise, cleanup automatically happens
/// when the widget is destroyed and this should NOT be called.
pub fn deinit(self: *ImguiWidget) void {
cimgui.c.igDestroyContext(self.ig_ctx);
}
/// This should be called anytime the underlying data for the UI changes
/// so that the UI can be refreshed.
pub fn queueRender(self: *const ImguiWidget) void {
c.gtk_gl_area_queue_render(self.gl_area);
}
/// Initialize the frame. Expects that the context is already current.
fn newFrame(self: *ImguiWidget) !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;
}
fn translateMouseButton(button: c.guint) ?c_int {
return switch (button) {
1 => cimgui.c.ImGuiMouseButton_Left,
2 => cimgui.c.ImGuiMouseButton_Middle,
3 => cimgui.c.ImGuiMouseButton_Right,
else => null,
};
}
fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
_ = v;
log.debug("imgui widget destroy", .{});
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
self.deinit();
}
fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void {
log.debug("gl surface realized", .{});
// We need to make the context current so we can call GL functions.
c.gtk_gl_area_make_current(area);
if (c.gtk_gl_area_get_error(area)) |err| {
log.err("surface failed to realize: {s}", .{err.*.message});
return;
}
// realize means that our OpenGL context is ready, so we can now
// initialize the ImgUI OpenGL backend for our context.
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
cimgui.c.igSetCurrentContext(self.ig_ctx);
_ = cimgui.c.ImGui_ImplOpenGL3_Init(null);
}
fn gtkUnrealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void {
_ = area;
log.debug("gl surface unrealized", .{});
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
cimgui.c.igSetCurrentContext(self.ig_ctx);
cimgui.c.ImGui_ImplOpenGL3_Shutdown();
}
fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque) callconv(.C) void {
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const scale_factor = c.gtk_widget_get_scale_factor(@ptrCast(area));
log.debug("gl resize width={} height={} scale={}", .{
width,
height,
scale_factor,
});
// 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.*;
}
fn gtkRender(area: *c.GtkGLArea, ctx: *c.GdkGLContext, ud: ?*anyopaque) callconv(.C) c.gboolean {
_ = area;
_ = ctx;
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
cimgui.c.igSetCurrentContext(self.ig_ctx);
// 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.c.ImGui_ImplOpenGL3_NewFrame();
self.newFrame() catch |err| {
log.err("failed to setup frame: {}", .{err});
return 0;
};
cimgui.c.igNewFrame();
// Build our UI
if (self.render_callback) |cb| cb(self.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.c.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.igGetDrawData());
return 1;
}
fn gtkMouseMotion(
_: *c.GtkEventControllerMotion,
x: c.gdouble,
y: c.gdouble,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const scale_factor: f64 = @floatFromInt(c.gtk_widget_get_scale_factor(
@ptrCast(self.gl_area),
));
cimgui.c.ImGuiIO_AddMousePosEvent(
io,
@floatCast(x * scale_factor),
@floatCast(y * scale_factor),
);
self.queueRender();
}
fn gtkMouseDown(
gesture: *c.GtkGestureClick,
_: c.gint,
_: c.gdouble,
_: c.gdouble,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const gdk_button = c.gtk_gesture_single_get_current_button(@ptrCast(gesture));
if (translateMouseButton(gdk_button)) |button| {
cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, true);
}
}
fn gtkMouseUp(
gesture: *c.GtkGestureClick,
_: c.gint,
_: c.gdouble,
_: c.gdouble,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const gdk_button = c.gtk_gesture_single_get_current_button(@ptrCast(gesture));
if (translateMouseButton(gdk_button)) |button| {
cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, false);
}
}
fn gtkMouseScroll(
_: *c.GtkEventControllerScroll,
x: c.gdouble,
y: c.gdouble,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddMouseWheelEvent(
io,
@floatCast(x),
@floatCast(-y),
);
}
fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) void {
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddFocusEvent(io, true);
}
fn gtkFocusLeave(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) void {
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddFocusEvent(io, false);
}
fn gtkInputCommit(
_: *c.GtkIMContext,
bytes: [*:0]u8,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes);
}
fn gtkKeyPressed(
ec_key: *c.GtkEventControllerKey,
keyval: c.guint,
keycode: c.guint,
gtk_mods: c.GdkModifierType,
ud: ?*anyopaque,
) callconv(.C) c.gboolean {
return if (keyEvent(.press, ec_key, keyval, keycode, gtk_mods, ud)) 1 else 0;
}
fn gtkKeyReleased(
ec_key: *c.GtkEventControllerKey,
keyval: c.guint,
keycode: c.guint,
state: c.GdkModifierType,
ud: ?*anyopaque,
) callconv(.C) c.gboolean {
return if (keyEvent(.release, ec_key, keyval, keycode, state, ud)) 1 else 0;
}
fn keyEvent(
action: input.Action,
ec_key: *c.GtkEventControllerKey,
keyval: c.guint,
keycode: c.guint,
gtk_mods: c.GdkModifierType,
ud: ?*anyopaque,
) bool {
_ = keycode;
const self: *ImguiWidget = @ptrCast(@alignCast(ud.?));
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
// Translate the GTK mods and update the modifiers on every keypress
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
const event = c.gtk_event_controller_get_current_event(@ptrCast(ec_key));
_ = c.gtk_im_context_filter_keypress(self.im_context, event);
return true;
}

View File

@ -14,6 +14,7 @@ const CoreSurface = @import("../../Surface.zig");
const App = @import("App.zig");
const Window = @import("Window.zig");
const inspector = @import("inspector.zig");
const c = @import("c.zig");
const log = std.log.scoped(.gtk);
@ -76,6 +77,9 @@ font_size: ?font.face.DesiredSize = null,
size: apprt.SurfaceSize,
cursor_pos: apprt.CursorPos,
/// Inspector state.
inspector: ?*inspector.Inspector = null,
/// Key input states. See gtkKeyPressed for detailed descriptions.
in_keypress: bool = false,
im_context: *c.GtkIMContext,
@ -218,6 +222,9 @@ pub fn deinit(self: *Surface) void {
// We don't allocate anything if we aren't realized.
if (!self.realized) return;
// Delete our inspector if we have one
self.controlInspector(.hide);
// Remove ourselves from the list of known surfaces in the app.
self.app.core_app.deleteSurface(self);
@ -235,6 +242,11 @@ fn render(self: *Surface) !void {
try self.core_surface.renderer.draw();
}
/// Queue the inspector to render if we have one.
pub fn queueInspectorRender(self: *Surface) void {
if (self.inspector) |v| v.queueRender();
}
/// Invalidate the surface so that it forces a redraw on the next tick.
pub fn redraw(self: *Surface) void {
c.gtk_gl_area_queue_render(self.gl_area);
@ -281,6 +293,33 @@ pub fn close(self: *Surface, processActive: bool) void {
c.gtk_widget_show(alert);
}
pub fn controlInspector(self: *Surface, mode: input.InspectorMode) void {
const show = switch (mode) {
.toggle => self.inspector == null,
.show => true,
.hide => false,
};
if (!show) {
if (self.inspector) |v| {
v.close();
self.inspector = null;
}
return;
}
// If we already have an inspector, we don't need to show anything.
if (self.inspector != null) return;
self.inspector = inspector.Inspector.create(
self,
.{ .window = {} },
) catch |err| {
log.err("failed to control inspector err={}", .{err});
return;
};
}
pub fn toggleFullscreen(self: *Surface, mac_non_native: configpkg.NonNativeFullscreen) void {
self.window.toggleFullscreen(mac_non_native);
}

View File

@ -12,6 +12,7 @@ const CoreSurface = @import("../../Surface.zig");
const App = @import("App.zig");
const Surface = @import("Surface.zig");
const icon = @import("icon.zig");
const c = @import("c.zig");
const log = std.log.scoped(.gtk);
@ -28,7 +29,7 @@ notebook: *c.GtkNotebook,
/// The resources directory for the icon (if any). We need to retain a
/// pointer to this because GTK can use it at any time.
icon_search_dir: ?[:0]const u8 = null,
icon: icon.Icon,
pub fn create(alloc: Allocator, app: *App) !*Window {
// Allocate a fixed pointer for our window. We try to minimize
@ -48,6 +49,7 @@ pub fn init(self: *Window, app: *App) !void {
// Set up our own state
self.* = .{
.app = app,
.icon = undefined,
.window = undefined,
.notebook = undefined,
};
@ -62,28 +64,8 @@ pub fn init(self: *Window, app: *App) !void {
// If we don't have the icon then we'll try to add our resources dir
// to the search path and see if we can find it there.
const icon_name = "com.mitchellh.ghostty";
const icon_theme = c.gtk_icon_theme_get_for_display(c.gtk_widget_get_display(window));
if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) icon: {
const base = self.app.core_app.resources_dir orelse {
log.info("gtk app missing Ghostty icon and no resources dir detected", .{});
log.info("gtk app will not have Ghostty icon", .{});
break :icon;
};
// Note that this method for adding the icon search path is
// a fallback mechanism. The recommended mechanism is the
// Freedesktop Icon Theme Specification. We distribute a ".desktop"
// file in zig-out/share that should be installed to the proper
// place.
const dir = try std.fmt.allocPrintZ(app.core_app.alloc, "{s}/icons", .{base});
self.icon_search_dir = dir;
c.gtk_icon_theme_add_search_path(icon_theme, dir.ptr);
if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) {
log.warn("Ghostty icon for gtk app not found", .{});
}
}
c.gtk_window_set_icon_name(gtk_window, icon_name);
self.icon = try icon.appIcon(self.app, window);
c.gtk_window_set_icon_name(gtk_window, self.icon.name);
// Apply background opacity if we have it
if (app.config.@"background-opacity" < 1) {
@ -167,6 +149,7 @@ fn initActions(self: *Window) void {
.{ "close", &gtkActionClose },
.{ "new_window", &gtkActionNewWindow },
.{ "new_tab", &gtkActionNewTab },
.{ "toggle_inspector", &gtkActionToggleInspector },
};
inline for (actions) |entry| {
@ -185,7 +168,7 @@ fn initActions(self: *Window) void {
}
pub fn deinit(self: *Window) void {
if (self.icon_search_dir) |ptr| self.app.core_app.alloc.free(ptr);
self.icon.deinit(self.app);
}
/// Add a new tab to this window.
@ -564,6 +547,19 @@ fn gtkActionNewTab(
};
}
fn gtkActionToggleInspector(
_: *c.GSimpleAction,
_: *c.GVariant,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *Window = @ptrCast(@alignCast(ud orelse return));
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .inspector = .toggle }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
}
/// Returns the surface to use for an action.
fn actionSurface(self: *Window) ?*CoreSurface {
const page_idx = c.gtk_notebook_get_current_page(self.notebook);

52
src/apprt/gtk/icon.zig Normal file
View File

@ -0,0 +1,52 @@
const std = @import("std");
const App = @import("App.zig");
const c = @import("c.zig");
const log = std.log.scoped(.gtk_icon);
/// An icon. The icon may be associated with some allocated state so when
/// the icon is no longer in use it should be deinitialized.
pub const Icon = struct {
name: [:0]const u8,
state: ?[:0]const u8 = null,
pub fn deinit(self: *const Icon, app: *App) void {
if (self.state) |v| app.core_app.alloc.free(v);
}
};
/// Returns the application icon that can be used anywhere. This attempts to
/// find the icon in the theme and if it can't be found, it is loaded from
/// the resources dir. If the resources dir can't be found, we'll log a warning
/// and let GTK choose a fallback.
pub fn appIcon(app: *App, widget: *c.GtkWidget) !Icon {
const icon_name = "com.mitchellh.ghostty";
var result: Icon = .{ .name = icon_name };
// If we don't have the icon then we'll try to add our resources dir
// to the search path and see if we can find it there.
const icon_theme = c.gtk_icon_theme_get_for_display(c.gtk_widget_get_display(widget));
if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) icon: {
const base = app.core_app.resources_dir orelse {
log.info("gtk app missing Ghostty icon and no resources dir detected", .{});
log.info("gtk app will not have Ghostty icon", .{});
break :icon;
};
// Note that this method for adding the icon search path is
// a fallback mechanism. The recommended mechanism is the
// Freedesktop Icon Theme Specification. We distribute a ".desktop"
// file in zig-out/share that should be installed to the proper
// place.
const dir = try std.fmt.allocPrintZ(app.core_app.alloc, "{s}/icons", .{base});
errdefer app.core_app.alloc.free(dir);
result.state = dir;
c.gtk_icon_theme_add_search_path(icon_theme, dir.ptr);
if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) {
log.warn("Ghostty icon for gtk app not found", .{});
}
}
return result;
}

189
src/apprt/gtk/inspector.zig Normal file
View File

@ -0,0 +1,189 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const App = @import("App.zig");
const Surface = @import("Surface.zig");
const TerminalWindow = @import("Window.zig");
const ImguiWidget = @import("ImguiWidget.zig");
const c = @import("c.zig");
const icon = @import("icon.zig");
const CoreInspector = @import("../../inspector/main.zig").Inspector;
const log = std.log.scoped(.inspector);
/// Inspector is the primary stateful object that represents a terminal
/// inspector. An inspector is 1:1 with a Surface and is owned by a Surface.
/// Closing a surface must close its inspector.
pub const Inspector = struct {
/// The surface that owns this inspector.
surface: *Surface,
/// The current state of where this inspector is rendered. The Inspector
/// is the state of the inspector but this is the state of the GUI.
location: LocationState,
/// This is true if we want to destroy this inspector as soon as the
/// location is closed. For example: set this to true, request the
/// window be closed, let GTK do its cleanup, then note this to destroy
/// the inner state.
destroy_on_close: bool = true,
/// Location where the inspector will be launched.
pub const Location = union(LocationKey) {
hidden: void,
window: void,
};
/// The internal state for each possible location.
const LocationState = union(LocationKey) {
hidden: void,
window: Window,
};
const LocationKey = enum {
/// No GUI, but load the inspector state.
hidden,
/// A dedicated window for the inspector.
window,
};
/// Create an inspector for the given surface in the given location.
pub fn create(surface: *Surface, location: Location) !*Inspector {
const alloc = surface.app.core_app.alloc;
var ptr = try alloc.create(Inspector);
errdefer alloc.destroy(ptr);
try ptr.init(surface, location);
return ptr;
}
/// Destroy all memory associated with this inspector. You generally
/// should NOT call this publicly and should call `close` instead to
/// use the GTK lifecycle.
pub fn destroy(self: *Inspector) void {
assert(self.location == .hidden);
const alloc = self.allocator();
self.surface.inspector = null;
self.deinit();
alloc.destroy(self);
}
fn init(self: *Inspector, surface: *Surface, location: Location) !void {
self.* = .{
.surface = surface,
.location = undefined,
};
// Activate the inspector. If it doesn't work we ignore the error
// because we can just show an error in the inspector window.
self.surface.core_surface.activateInspector() catch |err| {
log.err("failed to activate inspector err={}", .{err});
};
switch (location) {
.hidden => self.location = .{ .hidden = {} },
.window => try self.initWindow(),
}
}
fn deinit(self: *Inspector) void {
self.surface.core_surface.deactivateInspector();
}
/// Request the inspector is closed.
pub fn close(self: *Inspector) void {
switch (self.location) {
.hidden => self.locationDidClose(),
.window => |v| v.close(),
}
}
fn locationDidClose(self: *Inspector) void {
self.location = .{ .hidden = {} };
if (self.destroy_on_close) self.destroy();
}
pub fn queueRender(self: *const Inspector) void {
switch (self.location) {
.hidden => {},
.window => |v| v.imgui_widget.queueRender(),
}
}
fn allocator(self: *const Inspector) Allocator {
return self.surface.app.core_app.alloc;
}
fn initWindow(self: *Inspector) !void {
self.location = .{ .window = undefined };
try self.location.window.init(self);
}
};
/// A dedicated window to hold an inspector instance.
const Window = struct {
inspector: *Inspector,
window: *c.GtkWindow,
icon: icon.Icon,
imgui_widget: ImguiWidget,
pub fn init(self: *Window, inspector: *Inspector) !void {
// Initialize to undefined
self.* = .{
.inspector = inspector,
.icon = undefined,
.window = undefined,
.imgui_widget = undefined,
};
// Create the window
const window = c.gtk_application_window_new(inspector.surface.app.app);
const gtk_window: *c.GtkWindow = @ptrCast(window);
errdefer c.gtk_window_destroy(gtk_window);
self.window = gtk_window;
c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector");
c.gtk_window_set_default_size(gtk_window, 1000, 600);
self.icon = try icon.appIcon(self.inspector.surface.app, window);
c.gtk_window_set_icon_name(gtk_window, self.icon.name);
// Initialize our imgui widget
try self.imgui_widget.init();
errdefer self.imgui_widget.deinit();
self.imgui_widget.render_callback = &imguiRender;
self.imgui_widget.render_userdata = self;
CoreInspector.setup();
// Signals
_ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);
// Show the window
c.gtk_window_set_child(gtk_window, @ptrCast(self.imgui_widget.gl_area));
c.gtk_widget_show(window);
}
pub fn deinit(self: *Window) void {
self.icon.deinit(self.inspector.surface.app);
self.inspector.locationDidClose();
}
pub fn close(self: *const Window) void {
c.gtk_window_destroy(self.window);
}
fn imguiRender(ud: ?*anyopaque) void {
const self: *Window = @ptrCast(@alignCast(ud orelse return));
const surface = &self.inspector.surface.core_surface;
const inspector = surface.inspector orelse return;
inspector.render();
}
/// "destroy" signal for the window
fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
_ = v;
log.debug("window destroy", .{});
const self: *Window = @ptrCast(@alignCast(ud.?));
self.deinit();
}
};

2
src/apprt/none.zig Normal file
View File

@ -0,0 +1,2 @@
pub const App = struct {};
pub const Surface = struct {};

View File

@ -1,8 +1,7 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const trace = @import("tracy").trace;
const fastmem = @import("../fastmem.zig");
const fastmem = @import("fastmem.zig");
/// Returns a circular buffer containing type T.
pub fn CircBuf(comptime T: type, comptime default: T) type {
@ -25,6 +24,29 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
// this minor overhead is not worth optimizing out.
full: bool,
pub const Iterator = struct {
buf: Self,
idx: usize,
direction: Direction,
pub const Direction = enum { forward, reverse };
pub fn next(self: *Iterator) ?*T {
if (self.idx >= self.buf.len()) return null;
// Get our index from the tail
const tail_idx = switch (self.direction) {
.forward => self.idx,
.reverse => self.buf.len() - self.idx - 1,
};
// Translate the tail index to a storage index
const storage_idx = (self.buf.tail + tail_idx) % self.buf.capacity();
self.idx += 1;
return &self.buf.storage[storage_idx];
}
};
/// Initialize a new circular buffer that can store size elements.
pub fn init(alloc: Allocator, size: usize) !Self {
var buf = try alloc.alloc(T, size);
@ -43,12 +65,35 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
self.* = undefined;
}
/// Append a single value to the buffer. If the buffer is full,
/// an error will be returned.
pub fn append(self: *Self, v: T) !void {
if (self.full) return error.OutOfMemory;
self.storage[self.head] = v;
self.head += 1;
if (self.head >= self.storage.len) self.head = 0;
self.full = self.head == self.tail;
}
/// Clear the buffer.
pub fn clear(self: *Self) void {
self.head = 0;
self.tail = 0;
self.full = false;
}
/// Iterate over the circular buffer.
pub fn iterator(self: Self, direction: Iterator.Direction) Iterator {
return Iterator{
.buf = self,
.idx = 0,
.direction = direction,
};
}
/// Resize the buffer to the given size (larger or smaller).
/// If larger, new values will be set to the default value.
pub fn resize(self: *Self, alloc: Allocator, size: usize) !void {
const tracy = trace(@src());
defer tracy.end();
// Rotate to zero so it is aligned.
try self.rotateToZero(alloc);
@ -72,9 +117,6 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
/// Rotate the data so that it is zero-aligned.
fn rotateToZero(self: *Self, alloc: Allocator) !void {
const tracy = trace(@src());
defer tracy.end();
// TODO: this does this in the worst possible way by allocating.
// rewrite to not allocate, its possible, I'm just lazy right now.
@ -122,9 +164,6 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
pub fn deleteOldest(self: *Self, n: usize) void {
assert(n <= self.storage.len);
const tracy = trace(@src());
defer tracy.end();
// Clear the values back to default
const slices = self.getPtrSlice(0, n);
inline for (slices) |slice| @memset(slice, default);
@ -141,9 +180,6 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
/// the end of our buffer. This never "rotates" the buffer because
/// the offset can only be within the size of the buffer.
pub fn getPtrSlice(self: *Self, offset: usize, slice_len: usize) [2][]T {
const tracy = trace(@src());
defer tracy.end();
// Note: this assertion is very important, it hints the compiler
// which generates ~10% faster code than without it.
assert(offset + slice_len <= self.capacity());
@ -220,6 +256,115 @@ test {
try testing.expectEqual(@as(usize, 0), buf.len());
}
test "append" {
const testing = std.testing;
const alloc = testing.allocator;
const Buf = CircBuf(u8, 0);
var buf = try Buf.init(alloc, 3);
defer buf.deinit(alloc);
try buf.append(1);
try buf.append(2);
try buf.append(3);
try testing.expectError(error.OutOfMemory, buf.append(4));
buf.deleteOldest(1);
try buf.append(4);
try testing.expectError(error.OutOfMemory, buf.append(5));
}
test "forward iterator" {
const testing = std.testing;
const alloc = testing.allocator;
const Buf = CircBuf(u8, 0);
var buf = try Buf.init(alloc, 3);
defer buf.deinit(alloc);
// Empty
{
var it = buf.iterator(.forward);
try testing.expect(it.next() == null);
}
// Partially full
try buf.append(1);
try buf.append(2);
{
var it = buf.iterator(.forward);
try testing.expect(it.next().?.* == 1);
try testing.expect(it.next().?.* == 2);
try testing.expect(it.next() == null);
}
// Full
try buf.append(3);
{
var it = buf.iterator(.forward);
try testing.expect(it.next().?.* == 1);
try testing.expect(it.next().?.* == 2);
try testing.expect(it.next().?.* == 3);
try testing.expect(it.next() == null);
}
// Delete and add
buf.deleteOldest(1);
try buf.append(4);
{
var it = buf.iterator(.forward);
try testing.expect(it.next().?.* == 2);
try testing.expect(it.next().?.* == 3);
try testing.expect(it.next().?.* == 4);
try testing.expect(it.next() == null);
}
}
test "reverse iterator" {
const testing = std.testing;
const alloc = testing.allocator;
const Buf = CircBuf(u8, 0);
var buf = try Buf.init(alloc, 3);
defer buf.deinit(alloc);
// Empty
{
var it = buf.iterator(.reverse);
try testing.expect(it.next() == null);
}
// Partially full
try buf.append(1);
try buf.append(2);
{
var it = buf.iterator(.reverse);
try testing.expect(it.next().?.* == 2);
try testing.expect(it.next().?.* == 1);
try testing.expect(it.next() == null);
}
// Full
try buf.append(3);
{
var it = buf.iterator(.reverse);
try testing.expect(it.next().?.* == 3);
try testing.expect(it.next().?.* == 2);
try testing.expect(it.next().?.* == 1);
try testing.expect(it.next() == null);
}
// Delete and add
buf.deleteOldest(1);
try buf.append(4);
{
var it = buf.iterator(.reverse);
try testing.expect(it.next().?.* == 4);
try testing.expect(it.next().?.* == 3);
try testing.expect(it.next().?.* == 2);
try testing.expect(it.next() == null);
}
}
test "getPtrSlice fits" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@ -8,6 +8,7 @@ pub const keycodes = @import("input/keycodes.zig");
pub const kitty = @import("input/kitty.zig");
pub const Binding = @import("input/Binding.zig");
pub const KeyEncoder = @import("input/KeyEncoder.zig");
pub const InspectorMode = Binding.Action.InspectorMode;
pub const SplitDirection = Binding.Action.SplitDirection;
pub const SplitFocusDirection = Binding.Action.SplitFocusDirection;

View File

@ -176,6 +176,10 @@ pub const Action = union(enum) {
/// zoom/unzoom the current split.
toggle_split_zoom: void,
/// Show, hide, or toggle the terminal inspector for the currently
/// focused terminal.
inspector: InspectorMode,
/// Reload the configuration. The exact meaning depends on the app runtime
/// in use but this usually involves re-reading the configuration file
/// and applying any changes. Note that not all changes can be applied at
@ -220,6 +224,13 @@ pub const Action = union(enum) {
right,
};
// Extern because it is used in the embedded runtime ABI.
pub const InspectorMode = enum(c_int) {
toggle,
show,
hide,
};
/// Parse an action in the format of "key=value" where key is the
/// action name and value is the action parameter. The parameter
/// is optional depending on the action.

View File

@ -1,5 +1,6 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const cimgui = @import("cimgui");
/// A generic key input event. This is the information that is necessary
/// regardless of apprt in order to generate the proper terminal
@ -377,6 +378,137 @@ pub const Key = enum(c_int) {
};
}
/// Returns the cimgui key constant for this key.
pub fn imguiKey(self: Key) ?c_uint {
return switch (self) {
.a => cimgui.c.ImGuiKey_A,
.b => cimgui.c.ImGuiKey_B,
.c => cimgui.c.ImGuiKey_C,
.d => cimgui.c.ImGuiKey_D,
.e => cimgui.c.ImGuiKey_E,
.f => cimgui.c.ImGuiKey_F,
.g => cimgui.c.ImGuiKey_G,
.h => cimgui.c.ImGuiKey_H,
.i => cimgui.c.ImGuiKey_I,
.j => cimgui.c.ImGuiKey_J,
.k => cimgui.c.ImGuiKey_K,
.l => cimgui.c.ImGuiKey_L,
.m => cimgui.c.ImGuiKey_M,
.n => cimgui.c.ImGuiKey_N,
.o => cimgui.c.ImGuiKey_O,
.p => cimgui.c.ImGuiKey_P,
.q => cimgui.c.ImGuiKey_Q,
.r => cimgui.c.ImGuiKey_R,
.s => cimgui.c.ImGuiKey_S,
.t => cimgui.c.ImGuiKey_T,
.u => cimgui.c.ImGuiKey_U,
.v => cimgui.c.ImGuiKey_V,
.w => cimgui.c.ImGuiKey_W,
.x => cimgui.c.ImGuiKey_X,
.y => cimgui.c.ImGuiKey_Y,
.z => cimgui.c.ImGuiKey_Z,
.zero => cimgui.c.ImGuiKey_0,
.one => cimgui.c.ImGuiKey_1,
.two => cimgui.c.ImGuiKey_2,
.three => cimgui.c.ImGuiKey_3,
.four => cimgui.c.ImGuiKey_4,
.five => cimgui.c.ImGuiKey_5,
.six => cimgui.c.ImGuiKey_6,
.seven => cimgui.c.ImGuiKey_7,
.eight => cimgui.c.ImGuiKey_8,
.nine => cimgui.c.ImGuiKey_9,
.semicolon => cimgui.c.ImGuiKey_Semicolon,
.space => cimgui.c.ImGuiKey_Space,
.apostrophe => cimgui.c.ImGuiKey_Apostrophe,
.comma => cimgui.c.ImGuiKey_Comma,
.grave_accent => cimgui.c.ImGuiKey_GraveAccent,
.period => cimgui.c.ImGuiKey_Period,
.slash => cimgui.c.ImGuiKey_Slash,
.minus => cimgui.c.ImGuiKey_Minus,
.equal => cimgui.c.ImGuiKey_Equal,
.left_bracket => cimgui.c.ImGuiKey_LeftBracket,
.right_bracket => cimgui.c.ImGuiKey_RightBracket,
.backslash => cimgui.c.ImGuiKey_Backslash,
.up => cimgui.c.ImGuiKey_UpArrow,
.down => cimgui.c.ImGuiKey_DownArrow,
.left => cimgui.c.ImGuiKey_LeftArrow,
.right => cimgui.c.ImGuiKey_RightArrow,
.home => cimgui.c.ImGuiKey_Home,
.end => cimgui.c.ImGuiKey_End,
.insert => cimgui.c.ImGuiKey_Insert,
.delete => cimgui.c.ImGuiKey_Delete,
.caps_lock => cimgui.c.ImGuiKey_CapsLock,
.scroll_lock => cimgui.c.ImGuiKey_ScrollLock,
.num_lock => cimgui.c.ImGuiKey_NumLock,
.page_up => cimgui.c.ImGuiKey_PageUp,
.page_down => cimgui.c.ImGuiKey_PageDown,
.escape => cimgui.c.ImGuiKey_Escape,
.enter => cimgui.c.ImGuiKey_Enter,
.tab => cimgui.c.ImGuiKey_Tab,
.backspace => cimgui.c.ImGuiKey_Backspace,
.print_screen => cimgui.c.ImGuiKey_PrintScreen,
.pause => cimgui.c.ImGuiKey_Pause,
.f1 => cimgui.c.ImGuiKey_F1,
.f2 => cimgui.c.ImGuiKey_F2,
.f3 => cimgui.c.ImGuiKey_F3,
.f4 => cimgui.c.ImGuiKey_F4,
.f5 => cimgui.c.ImGuiKey_F5,
.f6 => cimgui.c.ImGuiKey_F6,
.f7 => cimgui.c.ImGuiKey_F7,
.f8 => cimgui.c.ImGuiKey_F8,
.f9 => cimgui.c.ImGuiKey_F9,
.f10 => cimgui.c.ImGuiKey_F10,
.f11 => cimgui.c.ImGuiKey_F11,
.f12 => cimgui.c.ImGuiKey_F12,
.kp_0 => cimgui.c.ImGuiKey_Keypad0,
.kp_1 => cimgui.c.ImGuiKey_Keypad1,
.kp_2 => cimgui.c.ImGuiKey_Keypad2,
.kp_3 => cimgui.c.ImGuiKey_Keypad3,
.kp_4 => cimgui.c.ImGuiKey_Keypad4,
.kp_5 => cimgui.c.ImGuiKey_Keypad5,
.kp_6 => cimgui.c.ImGuiKey_Keypad6,
.kp_7 => cimgui.c.ImGuiKey_Keypad7,
.kp_8 => cimgui.c.ImGuiKey_Keypad8,
.kp_9 => cimgui.c.ImGuiKey_Keypad9,
.kp_decimal => cimgui.c.ImGuiKey_KeypadDecimal,
.kp_divide => cimgui.c.ImGuiKey_KeypadDivide,
.kp_multiply => cimgui.c.ImGuiKey_KeypadMultiply,
.kp_subtract => cimgui.c.ImGuiKey_KeypadSubtract,
.kp_add => cimgui.c.ImGuiKey_KeypadAdd,
.kp_enter => cimgui.c.ImGuiKey_KeypadEnter,
.kp_equal => cimgui.c.ImGuiKey_KeypadEqual,
.left_shift => cimgui.c.ImGuiKey_LeftShift,
.left_control => cimgui.c.ImGuiKey_LeftCtrl,
.left_alt => cimgui.c.ImGuiKey_LeftAlt,
.left_super => cimgui.c.ImGuiKey_LeftSuper,
.right_shift => cimgui.c.ImGuiKey_RightShift,
.right_control => cimgui.c.ImGuiKey_RightCtrl,
.right_alt => cimgui.c.ImGuiKey_RightAlt,
.right_super => cimgui.c.ImGuiKey_RightSuper,
.invalid,
.f13,
.f14,
.f15,
.f16,
.f17,
.f18,
.f19,
.f20,
.f21,
.f22,
.f23,
.f24,
.f25,
=> null,
};
}
test "fromASCII should not return keypad keys" {
const testing = std.testing;
try testing.expect(Key.fromASCII('0').? == .zero);

1203
src/inspector/Inspector.zig Normal file

File diff suppressed because it is too large Load Diff

95
src/inspector/cursor.zig Normal file
View File

@ -0,0 +1,95 @@
const std = @import("std");
const cimgui = @import("cimgui");
const terminal = @import("../terminal/main.zig");
/// Render cursor information with a table already open.
pub fn renderInTable(cursor: *const terminal.Screen.Cursor) void {
{
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
{
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("Position (x, y)");
}
{
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("(%d, %d)", cursor.x, cursor.y);
}
}
if (cursor.pending_wrap) {
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
{
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("Pending Wrap");
}
{
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("%s", if (cursor.pending_wrap) "true".ptr else "false".ptr);
}
}
// If we have a color then we show the color
if (cursor.pen.attrs.has_fg) color: {
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("Foreground Color");
_ = cimgui.c.igTableSetColumnIndex(1);
if (!cursor.pen.attrs.has_fg) {
cimgui.c.igText("default");
break :color;
}
var color: [3]f32 = .{
@as(f32, @floatFromInt(cursor.pen.fg.r)) / 255,
@as(f32, @floatFromInt(cursor.pen.fg.g)) / 255,
@as(f32, @floatFromInt(cursor.pen.fg.b)) / 255,
};
_ = cimgui.c.igColorEdit3(
"color_fg",
&color,
cimgui.c.ImGuiColorEditFlags_NoPicker |
cimgui.c.ImGuiColorEditFlags_NoLabel,
);
}
if (cursor.pen.attrs.has_bg) color: {
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("Background Color");
_ = cimgui.c.igTableSetColumnIndex(1);
if (!cursor.pen.attrs.has_bg) {
cimgui.c.igText("default");
break :color;
}
var color: [3]f32 = .{
@as(f32, @floatFromInt(cursor.pen.bg.r)) / 255,
@as(f32, @floatFromInt(cursor.pen.bg.g)) / 255,
@as(f32, @floatFromInt(cursor.pen.bg.b)) / 255,
};
_ = cimgui.c.igColorEdit3(
"color_bg",
&color,
cimgui.c.ImGuiColorEditFlags_NoPicker |
cimgui.c.ImGuiColorEditFlags_NoLabel,
);
}
// Boolean styles
const styles = .{
"bold", "italic", "faint", "blink",
"inverse", "invisible", "protected", "strikethrough",
};
inline for (styles) |style| style: {
if (!@field(cursor.pen.attrs, style)) break :style;
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
{
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText(style.ptr);
}
{
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("true");
}
}
}

229
src/inspector/key.zig Normal file
View File

@ -0,0 +1,229 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const input = @import("../input.zig");
const CircBuf = @import("../circ_buf.zig").CircBuf;
const cimgui = @import("cimgui");
/// Circular buffer of key events.
pub const EventRing = CircBuf(Event, undefined);
/// Represents a recorded keyboard event.
pub const Event = struct {
/// The input event.
event: input.KeyEvent,
/// The binding that was triggered as a result of this event.
binding: ?input.Binding.Action = null,
/// The data sent to the pty as a result of this keyboard event.
/// This is allocated using the inspector allocator.
pty: []const u8 = "",
/// State for the inspector GUI. Do not set this unless you're the inspector.
imgui_state: struct {
selected: bool = false,
} = .{},
pub fn init(alloc: Allocator, event: input.KeyEvent) !Event {
var copy = event;
copy.utf8 = "";
if (event.utf8.len > 0) copy.utf8 = try alloc.dupe(u8, event.utf8);
return .{ .event = copy };
}
pub fn deinit(self: *const Event, alloc: Allocator) void {
if (self.event.utf8.len > 0) alloc.free(self.event.utf8);
if (self.pty.len > 0) alloc.free(self.pty);
}
/// Returns a label that can be used for this event. This is null-terminated
/// so it can be easily used with C APIs.
pub fn label(self: *const Event, buf: []u8) ![:0]const u8 {
var buf_stream = std.io.fixedBufferStream(buf);
const writer = buf_stream.writer();
switch (self.event.action) {
.press => try writer.writeAll("Press: "),
.release => try writer.writeAll("Release: "),
.repeat => try writer.writeAll("Repeat: "),
}
if (self.event.mods.shift) try writer.writeAll("Shift+");
if (self.event.mods.ctrl) try writer.writeAll("Ctrl+");
if (self.event.mods.alt) try writer.writeAll("Alt+");
if (self.event.mods.super) try writer.writeAll("Super+");
try writer.writeAll(@tagName(self.event.key));
// Deadkey
if (self.event.composing) try writer.writeAll(" (composing)");
// Null-terminator
try writer.writeByte(0);
return buf[0..(buf_stream.getWritten().len - 1) :0];
}
/// Render this event in the inspector GUI.
pub fn render(self: *const Event) void {
_ = cimgui.c.igBeginTable(
"##event",
2,
cimgui.c.ImGuiTableFlags_None,
.{ .x = 0, .y = 0 },
0,
);
defer cimgui.c.igEndTable();
if (self.binding) |binding| {
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("Triggered Binding");
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("%s", @tagName(binding).ptr);
}
pty: {
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("Encoding to Pty");
_ = cimgui.c.igTableSetColumnIndex(1);
if (self.pty.len == 0) {
cimgui.c.igTextDisabled("(no data)");
break :pty;
}
self.renderPty() catch {
cimgui.c.igTextDisabled("(error rendering pty data)");
break :pty;
};
}
{
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("Action");
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("%s", @tagName(self.event.action).ptr);
}
{
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("Key");
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("%s", @tagName(self.event.key).ptr);
}
if (self.event.physical_key != self.event.key) {
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("Physical Key");
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("%s", @tagName(self.event.physical_key).ptr);
}
if (!self.event.mods.empty()) {
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("Mods");
_ = cimgui.c.igTableSetColumnIndex(1);
if (self.event.mods.shift) cimgui.c.igText("shift ");
if (self.event.mods.ctrl) cimgui.c.igText("ctrl ");
if (self.event.mods.alt) cimgui.c.igText("alt ");
if (self.event.mods.super) cimgui.c.igText("super ");
}
if (self.event.composing) {
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("Composing");
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("true");
}
utf8: {
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("UTF-8");
_ = cimgui.c.igTableSetColumnIndex(1);
if (self.event.utf8.len == 0) {
cimgui.c.igTextDisabled("(empty)");
break :utf8;
}
self.renderUtf8(self.event.utf8) catch {
cimgui.c.igTextDisabled("(error rendering utf-8)");
break :utf8;
};
}
}
fn renderUtf8(self: *const Event, utf8: []const u8) !void {
_ = self;
// Format the codepoint sequence
var buf: [1024]u8 = undefined;
var buf_stream = std.io.fixedBufferStream(&buf);
const writer = buf_stream.writer();
if (std.unicode.Utf8View.init(utf8)) |view| {
var it = view.iterator();
while (it.nextCodepoint()) |cp| {
try writer.print("U+{X} ", .{cp});
}
} else |_| {
try writer.writeAll("(invalid utf-8)");
}
try writer.writeByte(0);
// Render as a textbox
_ = cimgui.c.igInputText(
"##utf8",
&buf,
buf_stream.getWritten().len - 1,
cimgui.c.ImGuiInputTextFlags_ReadOnly,
null,
null,
);
}
fn renderPty(self: *const Event) !void {
// Format the codepoint sequence
var buf: [1024]u8 = undefined;
var buf_stream = std.io.fixedBufferStream(&buf);
const writer = buf_stream.writer();
for (self.pty) |byte| {
// Print ESC special because its so common
if (byte == 0x1B) {
try writer.writeAll("ESC ");
continue;
}
// Print ASCII as-is
if (byte > 0x20 and byte < 0x7F) {
try writer.writeByte(byte);
continue;
}
// Everything else as a hex byte
try writer.print("0x{X} ", .{byte});
}
try writer.writeByte(0);
// Render as a textbox
_ = cimgui.c.igInputText(
"##pty",
&buf,
buf_stream.getWritten().len - 1,
cimgui.c.ImGuiInputTextFlags_ReadOnly,
null,
null,
);
}
};
test "event string" {
const testing = std.testing;
const alloc = testing.allocator;
var event = try Event.init(alloc, .{ .key = .a });
defer event.deinit(alloc);
var buf: [1024]u8 = undefined;
try testing.expectEqualStrings("Press: a", try event.label(&buf));
}

8
src/inspector/main.zig Normal file
View File

@ -0,0 +1,8 @@
pub const cursor = @import("cursor.zig");
pub const key = @import("key.zig");
pub const termio = @import("termio.zig");
pub const Inspector = @import("Inspector.zig");
test {
@import("std").testing.refAllDecls(@This());
}

343
src/inspector/termio.zig Normal file
View File

@ -0,0 +1,343 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const cimgui = @import("cimgui");
const terminal = @import("../terminal/main.zig");
const CircBuf = @import("../circ_buf.zig").CircBuf;
const Surface = @import("../Surface.zig");
/// The stream handler for our inspector.
pub const Stream = terminal.Stream(VTHandler);
/// VT event circular buffer.
pub const VTEventRing = CircBuf(VTEvent, undefined);
/// VT event
pub const VTEvent = struct {
/// Sequence number, just monotonically increasing.
seq: usize = 1,
/// Kind of event, for filtering
kind: Kind,
/// The formatted string of the event. This is allocated. We format the
/// event for now because there is so much data to copy if we wanted to
/// store the raw event.
str: [:0]const u8,
/// Various metadata at the time of the event (before processing).
cursor: terminal.Screen.Cursor,
scrolling_region: terminal.Terminal.ScrollingRegion,
metadata: Metadata.Unmanaged = .{},
/// imgui selection state
imgui_selected: bool = false,
const Kind = enum { print, execute, csi, esc, osc, dcs, apc };
const Metadata = std.StringHashMap([:0]const u8);
/// Initiaze the event information for the given parser action.
pub fn init(
alloc: Allocator,
surface: *Surface,
action: terminal.Parser.Action,
) !VTEvent {
var md = Metadata.init(alloc);
errdefer md.deinit();
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
try encodeAction(alloc, buf.writer(), &md, action);
const str = try buf.toOwnedSliceSentinel(0);
errdefer alloc.free(str);
const kind: Kind = switch (action) {
.print => .print,
.execute => .execute,
.csi_dispatch => .csi,
.esc_dispatch => .esc,
.osc_dispatch => .osc,
.dcs_hook, .dcs_put, .dcs_unhook => .dcs,
.apc_start, .apc_put, .apc_end => .apc,
};
const t = surface.renderer_state.terminal;
return .{
.kind = kind,
.str = str,
.cursor = t.screen.cursor,
.scrolling_region = t.scrolling_region,
.metadata = md.unmanaged,
};
}
pub fn deinit(self: *VTEvent, alloc: Allocator) void {
{
var it = self.metadata.valueIterator();
while (it.next()) |v| alloc.free(v.*);
self.metadata.deinit(alloc);
}
alloc.free(self.str);
}
/// Returns true if the event passes the given filter.
pub fn passFilter(
self: *const VTEvent,
filter: *cimgui.c.ImGuiTextFilter,
) bool {
// Check our main string
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
self.str.ptr,
null,
)) return true;
// We also check all metadata keys and values
var it = self.metadata.iterator();
while (it.next()) |entry| {
var buf: [256]u8 = undefined;
const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch continue;
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
key.ptr,
null,
)) return true;
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
entry.value_ptr.ptr,
null,
)) return true;
}
return false;
}
/// Encode a parser action as a string that we show in the logs.
fn encodeAction(
alloc: Allocator,
writer: anytype,
md: *Metadata,
action: terminal.Parser.Action,
) !void {
switch (action) {
.print => try encodePrint(writer, action),
.execute => try encodeExecute(writer, action),
.csi_dispatch => |v| try encodeCSI(writer, v),
.esc_dispatch => |v| try encodeEsc(writer, v),
.osc_dispatch => |v| try encodeOSC(alloc, writer, md, v),
else => try writer.print("{}", .{action}),
}
}
fn encodePrint(writer: anytype, action: terminal.Parser.Action) !void {
const ch = action.print;
try writer.print("'{u}' (U+{X})", .{ ch, ch });
}
fn encodeExecute(writer: anytype, action: terminal.Parser.Action) !void {
const ch = action.execute;
switch (ch) {
0x00 => try writer.writeAll("NUL"),
0x01 => try writer.writeAll("SOH"),
0x02 => try writer.writeAll("STX"),
0x03 => try writer.writeAll("ETX"),
0x04 => try writer.writeAll("EOT"),
0x05 => try writer.writeAll("ENQ"),
0x06 => try writer.writeAll("ACK"),
0x07 => try writer.writeAll("BEL"),
0x08 => try writer.writeAll("BS"),
0x09 => try writer.writeAll("HT"),
0x0A => try writer.writeAll("LF"),
0x0B => try writer.writeAll("VT"),
0x0C => try writer.writeAll("FF"),
0x0D => try writer.writeAll("CR"),
0x0E => try writer.writeAll("SO"),
0x0F => try writer.writeAll("SI"),
else => try writer.writeAll("?"),
}
try writer.print(" (0x{X})", .{ch});
}
fn encodeCSI(writer: anytype, csi: terminal.Parser.Action.CSI) !void {
for (csi.intermediates) |v| try writer.print("{c} ", .{v});
for (csi.params, 0..) |v, i| {
if (i != 0) try writer.writeByte(';');
try writer.print("{d}", .{v});
}
if (csi.intermediates.len > 0 or csi.params.len > 0) try writer.writeByte(' ');
try writer.writeByte(csi.final);
}
fn encodeEsc(writer: anytype, esc: terminal.Parser.Action.ESC) !void {
for (esc.intermediates) |v| try writer.print("{c} ", .{v});
try writer.writeByte(esc.final);
}
fn encodeOSC(
alloc: Allocator,
writer: anytype,
md: *Metadata,
osc: terminal.osc.Command,
) !void {
// The description is just the tag
try writer.print("{s} ", .{@tagName(osc)});
// Add additional fields to metadata
switch (osc) {
inline else => |v, tag| if (tag == osc) {
try encodeMetadata(alloc, md, v);
},
}
}
fn encodeMetadata(
alloc: Allocator,
md: *Metadata,
v: anytype,
) !void {
switch (@TypeOf(v)) {
void => {},
[]const u8 => try md.put("data", try alloc.dupeZ(u8, v)),
else => |T| switch (@typeInfo(T)) {
.Struct => |info| inline for (info.fields) |field| {
try encodeMetadataSingle(
alloc,
md,
field.name,
@field(v, field.name),
);
},
else => {
@compileLog(T);
@compileError("unsupported type, see log");
},
},
}
}
fn encodeMetadataSingle(
alloc: Allocator,
md: *Metadata,
key: []const u8,
value: anytype,
) !void {
const Value = @TypeOf(value);
const info = @typeInfo(Value);
switch (info) {
.Optional => if (value) |unwrapped| {
try encodeMetadataSingle(alloc, md, key, unwrapped);
} else {
try md.put(key, try alloc.dupeZ(u8, "(unset)"));
},
.Bool => try md.put(
key,
try alloc.dupeZ(u8, if (value) "true" else "false"),
),
.Enum => try md.put(
key,
try alloc.dupeZ(u8, @tagName(value)),
),
else => switch (Value) {
u8 => try md.put(
key,
try std.fmt.allocPrintZ(alloc, "{}", .{value}),
),
[]const u8 => try md.put(key, try alloc.dupeZ(u8, value)),
else => |T| {
@compileLog(T);
@compileError("unsupported type, see log");
},
},
}
}
};
/// Our VT stream handler.
pub const VTHandler = struct {
/// The surface that the inspector is attached to. We use this instead
/// of the inspector because this is pointer-stable.
surface: *Surface,
/// True if the handler is currently recording.
active: bool = true,
/// Current sequence number
current_seq: usize = 1,
/// Exclude certain actions by tag.
filter_exclude: ActionTagSet = ActionTagSet.initMany(&.{.print}),
filter_text: *cimgui.c.ImGuiTextFilter,
const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag);
pub fn init(surface: *Surface) VTHandler {
return .{
.surface = surface,
.filter_text = cimgui.c.ImGuiTextFilter_ImGuiTextFilter(""),
};
}
pub fn deinit(self: *VTHandler) void {
cimgui.c.ImGuiTextFilter_destroy(self.filter_text);
}
/// This is called with every single terminal action.
pub fn handleManually(self: *VTHandler, action: terminal.Parser.Action) !bool {
const insp = self.surface.inspector orelse return false;
// We always increment the sequence number, even if we're paused or
// filter out the event. This helps show the user that there is a gap
// between events and roughly how large that gap was.
defer self.current_seq +%= 1;
// If we're pausing, then we ignore all events.
if (!self.active) return true;
// We ignore certain action types that are too noisy.
switch (action) {
.dcs_put, .apc_put => return true,
else => {},
}
// If we requested a specific type to be ignored, ignore it.
// We return true because we did "handle" it by ignoring it.
if (self.filter_exclude.contains(std.meta.activeTag(action))) return true;
// Build our event
const alloc = self.surface.alloc;
var ev = try VTEvent.init(alloc, self.surface, action);
ev.seq = self.current_seq;
errdefer ev.deinit(alloc);
// Check if the event passes the filter
if (!ev.passFilter(self.filter_text)) {
ev.deinit(alloc);
return true;
}
const max_capacity = 100;
insp.vt_events.append(ev) catch |err| switch (err) {
error.OutOfMemory => if (insp.vt_events.capacity() < max_capacity) {
// We're out of memory, but we can allocate to our capacity.
const new_capacity = @min(insp.vt_events.capacity() * 2, max_capacity);
try insp.vt_events.resize(insp.surface.alloc, new_capacity);
try insp.vt_events.append(ev);
} else {
var it = insp.vt_events.iterator(.forward);
if (it.next()) |old_ev| old_ev.deinit(insp.surface.alloc);
insp.vt_events.deleteOldest(1);
try insp.vt_events.append(ev);
},
else => return err,
};
return true;
}
};

View File

@ -283,6 +283,7 @@ pub const GlobalState = struct {
}
};
test {
_ = @import("circ_buf.zig");
_ = @import("Pty.zig");
_ = @import("Command.zig");
_ = @import("font/main.zig");
@ -294,6 +295,7 @@ test {
// Libraries
_ = @import("segmented_pool.zig");
_ = @import("inspector/main.zig");
_ = @import("terminal/main.zig");
_ = @import("terminfo/main.zig");

View File

@ -2,6 +2,7 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const Inspector = @import("../inspector/main.zig").Inspector;
const terminal = @import("../terminal/main.zig");
const renderer = @import("../renderer.zig");
@ -14,6 +15,10 @@ mutex: *std.Thread.Mutex,
/// The terminal data.
terminal: *terminal.Terminal,
/// The terminal inspector, if any. This will be null while the inspector
/// is not active and will be set when it is active.
inspector: ?*Inspector = null,
/// Dead key state. This will render the current dead key preedit text
/// over the cursor. This currently only ever renders a single codepoint.
/// Preedit can in theory be multiple codepoints long but that is left as

View File

@ -48,11 +48,6 @@ cursor_h: xev.Timer,
cursor_c: xev.Completion = .{},
cursor_c_cancel: xev.Completion = .{},
/// This is true when a blinking cursor should be visible and false
/// when it should not be visible. This is toggled on a timer by the
/// thread automatically.
cursor_blink_visible: bool = false,
/// The surface we're rendering to.
surface: *apprt.Surface,
@ -69,6 +64,16 @@ mailbox: *Mailbox,
/// Mailbox to send messages to the app thread
app_mailbox: App.Mailbox,
flags: packed struct {
/// This is true when a blinking cursor should be visible and false
/// when it should not be visible. This is toggled on a timer by the
/// thread automatically.
cursor_blink_visible: bool = false,
/// This is true when the inspector is active.
has_inspector: bool = false,
} = .{},
/// Initialize the thread. This does not START the thread. This only sets
/// up all the internal state necessary prior to starting the thread. It
/// is up to the caller to start the thread with the threadMain entrypoint.
@ -225,7 +230,7 @@ fn drainMailbox(self: *Thread) !void {
// If we're focused, we immediately show the cursor again
// and then restart the timer.
if (self.cursor_c.state() != .active) {
self.cursor_blink_visible = true;
self.flags.cursor_blink_visible = true;
self.cursor_h.run(
&self.loop,
&self.cursor_c,
@ -239,7 +244,7 @@ fn drainMailbox(self: *Thread) !void {
},
.reset_cursor_blink => {
self.cursor_blink_visible = true;
self.flags.cursor_blink_visible = true;
if (self.cursor_c.state() == .active) {
self.cursor_h.reset(
&self.loop,
@ -265,6 +270,8 @@ fn drainMailbox(self: *Thread) !void {
defer config.alloc.destroy(config.ptr);
try self.renderer.changeConfig(config.ptr);
},
.inspector => |v| self.flags.has_inspector = v,
}
}
}
@ -322,10 +329,15 @@ fn renderCallback(
return .disarm;
};
// If we have an inspector, let the app know we want to rerender that.
if (t.flags.has_inspector) {
_ = t.app_mailbox.push(.{ .redraw_inspector = t.surface }, .{ .instant = {} });
}
t.renderer.render(
t.surface,
t.state,
t.cursor_blink_visible,
t.flags.cursor_blink_visible,
) catch |err|
log.warn("error rendering err={}", .{err});
@ -365,7 +377,7 @@ fn cursorTimerCallback(
return .disarm;
};
t.cursor_blink_visible = !t.cursor_blink_visible;
t.flags.cursor_blink_visible = !t.flags.cursor_blink_visible;
t.wakeup.notify() catch {};
t.cursor_h.run(&t.loop, &t.cursor_c, CURSOR_BLINK_INTERVAL, Thread, t, cursorTimerCallback);

View File

@ -34,4 +34,7 @@ pub const Message = union(enum) {
alloc: Allocator,
ptr: *renderer.Renderer.DerivedConfig,
},
/// Activate or deactivate the inspector.
inspector: bool,
};

View File

@ -54,6 +54,8 @@ pub const TransitionAction = enum {
/// Action is the action that a caller of the parser is expected to
/// take as a result of some input character.
pub const Action = union(enum) {
pub const Tag = std.meta.FieldEnum(Action);
/// Draw character to the screen. This is a unicode codepoint.
print: u21,

View File

@ -62,7 +62,7 @@ const sgr = @import("sgr.zig");
const color = @import("color.zig");
const kitty = @import("kitty.zig");
const point = @import("point.zig");
const CircBuf = @import("circ_buf.zig").CircBuf;
const CircBuf = @import("../circ_buf.zig").CircBuf;
const Selection = @import("Selection.zig");
const fastmem = @import("../fastmem.zig");
const charsets = @import("charsets.zig");

View File

@ -136,7 +136,7 @@ pub const MouseFormat = enum(u3) {
/// Scrolling region is the area of the screen designated where scrolling
/// occurs. Wen scrolling the screen, only this viewport is scrolled.
const ScrollingRegion = struct {
pub const ScrollingRegion = struct {
// Top and bottom of the scroll region (0-indexed)
// Precondition: top < bottom
top: usize,

View File

@ -120,8 +120,8 @@ pub const Mode = mode_enum: {
/// The tag type for our enum is a u16 but we use a packed struct
/// in order to pack the ansi bit into the tag.
const ModeTag = packed struct(u16) {
const Backing = @typeInfo(@This()).Struct.backing_integer.?;
pub const ModeTag = packed struct(u16) {
pub const Backing = @typeInfo(@This()).Struct.backing_integer.?;
value: u15,
ansi: bool = false,

View File

@ -58,11 +58,29 @@ pub fn Stream(comptime Handler: type) type {
// log.debug("char: {c}", .{c});
const actions = self.parser.next(c);
for (actions) |action_opt| {
const action = action_opt orelse continue;
// If this handler handles everything manually then we do nothing
// if it can be processed.
if (@hasDecl(T, "handleManually")) {
const processed = self.handler.handleManually(action) catch |err| err: {
log.warn("error handling action manually err={} action={}", .{
err,
action,
});
break :err false;
};
if (processed) continue;
}
// if (action_opt) |action| {
// if (action != .print)
// log.info("action: {}", .{action});
// }
switch (action_opt orelse continue) {
switch (action) {
.print => |p| if (@hasDecl(T, "print")) try self.handler.print(p),
.execute => |code| try self.execute(code),
.csi_dispatch => |csi_action| try self.csiDispatch(csi_action),

View File

@ -1133,6 +1133,20 @@ const ReadThread = struct {
// Schedule a render
ev.queueRender() catch unreachable;
// If we have an inspector, we enter SLOW MODE because we need to
// process a byte at a time alternating between the inspector handler
// and the termio handler. This is very slow compared to our optimizations
// below but at least users only pay for it if they're using the inspector.
if (ev.renderer_state.inspector) |insp| {
for (buf, 0..) |byte, i| {
insp.recordPtyRead(buf[i .. i + 1]) catch |err| {
log.err("error recording pty read in inspector err={}", .{err});
};
ev.terminal_stream.next(byte) catch |err|
log.err("error processing terminal data: {}", .{err});
}
} else {
// Process the terminal data. This is an extremely hot part of the
// terminal emulator, so we do some abstraction leakage to avoid
// function calls and unnecessary logic.
@ -1171,6 +1185,7 @@ const ReadThread = struct {
ev.terminal_stream.nextSlice(buf[i..end]) catch |err|
log.err("error processing terminal data: {}", .{err});
}
}
// If our stream handling caused messages to be sent to the writer
// thread, then we need to wake it up so that it processes them.

View File

@ -62,13 +62,18 @@ sync_reset_cancel_c: xev.Completion = .{},
/// The underlying IO implementation.
impl: *termio.Impl,
/// The mailbox that can be used to send this thread messages. Note
/// this is a blocking queue so if it is full you will get errors (or block).
mailbox: *Mailbox,
flags: packed struct {
/// True if linefeed mode is enabled. This is duplicated here so that the
/// write thread doesn't need to grab a lock to check this on every write.
linefeed_mode: bool = false,
/// The mailbox that can be used to send this thread messages. Note
/// this is a blocking queue so if it is full you will get errors (or block).
mailbox: *Mailbox,
/// This is true when the inspector is active.
has_inspector: bool = false,
} = .{},
/// Initialize the thread. This does not START the thread. This only sets
/// up all the internal state necessary prior to starting the thread. It
@ -174,17 +179,18 @@ fn drainMailbox(self: *Thread) !void {
defer config.alloc.destroy(config.ptr);
try self.impl.changeConfig(config.ptr);
},
.inspector => |v| self.flags.has_inspector = v,
.resize => |v| self.handleResize(v),
.clear_screen => |v| try self.impl.clearScreen(v.history),
.scroll_viewport => |v| try self.impl.scrollViewport(v),
.jump_to_prompt => |v| try self.impl.jumpToPrompt(v),
.start_synchronized_output => self.startSynchronizedOutput(),
.linefeed_mode => |v| self.linefeed_mode = v,
.write_small => |v| try self.impl.queueWrite(v.data[0..v.len], self.linefeed_mode),
.write_stable => |v| try self.impl.queueWrite(v, self.linefeed_mode),
.linefeed_mode => |v| self.flags.linefeed_mode = v,
.write_small => |v| try self.impl.queueWrite(v.data[0..v.len], self.flags.linefeed_mode),
.write_stable => |v| try self.impl.queueWrite(v, self.flags.linefeed_mode),
.write_alloc => |v| {
defer v.alloc.free(v.data);
try self.impl.queueWrite(v.data, self.linefeed_mode);
try self.impl.queueWrite(v.data, self.flags.linefeed_mode);
},
}
}

View File

@ -36,6 +36,9 @@ pub const Message = union(enum) {
ptr: *termio.Impl.DerivedConfig,
},
/// Activate or deactivate the inspector.
inspector: bool,
/// Resize the window.
resize: Resize,