diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 59389c5c0..183dca544 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -1335,7 +1335,7 @@ extension Ghostty { mode: ghostty_action_inspector_e) { switch (target.tag) { case GHOSTTY_TARGET_APP: - Ghostty.logger.warning("toggle split zoom does nothing with an app target") + Ghostty.logger.warning("toggle inspector does nothing with an app target") return case GHOSTTY_TARGET_SURFACE: diff --git a/macos/Sources/Ghostty/Ghostty.Inspector.swift b/macos/Sources/Ghostty/Ghostty.Inspector.swift new file mode 100644 index 000000000..79567bc4a --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Inspector.swift @@ -0,0 +1,100 @@ +import GhosttyKit +import Metal + +extension Ghostty { + /// Represents the inspector for a surface within Ghostty. + /// + /// Wraps a `ghostty_inspector_t` + final class Inspector: Sendable { + private let inspector: ghostty_inspector_t + + /// Read the underlying C value for this inspector. This is unsafe because the value will be + /// freed when the Inspector class is deinitialized. + var unsafeCValue: ghostty_inspector_t { + inspector + } + + /// Initialize from the C structure. + init(cInspector: ghostty_inspector_t) { + self.inspector = cInspector + } + + /// Set the focus state of the inspector. + @MainActor + func setFocus(_ focused: Bool) { + ghostty_inspector_set_focus(inspector, focused) + } + + /// Set the content scale of the inspector. + @MainActor + func setContentScale(x: Double, y: Double) { + ghostty_inspector_set_content_scale(inspector, x, y) + } + + /// Set the size of the inspector. + @MainActor + func setSize(width: UInt32, height: UInt32) { + ghostty_inspector_set_size(inspector, width, height) + } + + /// Send a mouse button event to the inspector. + @MainActor + func mouseButton( + _ state: ghostty_input_mouse_state_e, + button: ghostty_input_mouse_button_e, + mods: ghostty_input_mods_e + ) { + ghostty_inspector_mouse_button(inspector, state, button, mods) + } + + /// Send a mouse position event to the inspector. + @MainActor + func mousePos(x: Double, y: Double) { + ghostty_inspector_mouse_pos(inspector, x, y) + } + + /// Send a mouse scroll event to the inspector. + @MainActor + func mouseScroll(x: Double, y: Double, mods: ghostty_input_scroll_mods_t) { + ghostty_inspector_mouse_scroll(inspector, x, y, mods) + } + + /// Send a key event to the inspector. + @MainActor + func key( + _ action: ghostty_input_action_e, + key: ghostty_input_key_e, + mods: ghostty_input_mods_e + ) { + ghostty_inspector_key(inspector, action, key, mods) + } + + /// Send text to the inspector. + @MainActor + func text(_ text: String) { + text.withCString { ptr in + ghostty_inspector_text(inspector, ptr) + } + } + + /// Initialize Metal rendering for the inspector. + @MainActor + func metalInit(device: MTLDevice) -> Bool { + let devicePtr = Unmanaged.passRetained(device).toOpaque() + return ghostty_inspector_metal_init(inspector, devicePtr) + } + + /// Render the inspector using Metal. + @MainActor + func metalRender( + commandBuffer: MTLCommandBuffer, + descriptor: MTLRenderPassDescriptor + ) { + ghostty_inspector_metal_render( + inspector, + Unmanaged.passRetained(commandBuffer).toOpaque(), + Unmanaged.passRetained(descriptor).toOpaque() + ) + } + } +} diff --git a/macos/Sources/Ghostty/Surface View/InspectorView.swift b/macos/Sources/Ghostty/Surface View/InspectorView.swift index e8eaf3a80..0ca48371e 100644 --- a/macos/Sources/Ghostty/Surface View/InspectorView.swift +++ b/macos/Sources/Ghostty/Surface View/InspectorView.swift @@ -98,7 +98,7 @@ extension Ghostty { didSet { surfaceViewDidChange() } } - private var inspector: ghostty_inspector_t? { + private var inspector: Ghostty.Inspector? { guard let surfaceView = self.surfaceView else { return nil } return surfaceView.inspector } @@ -150,8 +150,7 @@ extension Ghostty { 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) + _ = inspector.metalInit(device: device) // Register an observer for render requests center.addObserver( @@ -172,10 +171,10 @@ extension Ghostty { 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) + inspector.setContentScale(x: xScale, y: 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)) + inspector.setSize(width: UInt32(fbFrame.size.width), height: UInt32(fbFrame.size.height)) } // MARK: NSView @@ -184,7 +183,7 @@ extension Ghostty { let result = super.becomeFirstResponder() if (result) { if let inspector = self.inspector { - ghostty_inspector_set_focus(inspector, true) + inspector.setFocus(true) } } return result @@ -194,7 +193,7 @@ extension Ghostty { let result = super.resignFirstResponder() if (result) { if let inspector = self.inspector { - ghostty_inspector_set_focus(inspector, false) + inspector.setFocus(false) } } return result @@ -229,25 +228,25 @@ extension Ghostty { 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) + inspector.mouseButton(GHOSTTY_MOUSE_PRESS, button: GHOSTTY_MOUSE_LEFT, mods: 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) + inspector.mouseButton(GHOSTTY_MOUSE_RELEASE, button: GHOSTTY_MOUSE_LEFT, mods: 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) + inspector.mouseButton(GHOSTTY_MOUSE_PRESS, button: GHOSTTY_MOUSE_RIGHT, mods: 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) + inspector.mouseButton(GHOSTTY_MOUSE_RELEASE, button: GHOSTTY_MOUSE_RIGHT, mods: mods) } override func mouseMoved(with event: NSEvent) { @@ -255,7 +254,7 @@ extension Ghostty { // 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) + inspector.mousePos(x: pos.x, y: frame.height - pos.y) } @@ -297,7 +296,7 @@ extension Ghostty { // Pack our momentum value into the mods bitmask mods |= Int32(momentum.rawValue) << 1 - ghostty_inspector_mouse_scroll(inspector, x, y, mods) + inspector.mouseScroll(x: x, y: y, mods: mods) } override func keyDown(with event: NSEvent) { @@ -336,7 +335,7 @@ extension Ghostty { guard let inspector = self.inspector else { return } guard let key = Ghostty.Input.Key(keyCode: event.keyCode) else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_inspector_key(inspector, action, key.cKey, mods) + inspector.key(action, key: key.cKey, mods: mods) } // MARK: NSTextInputClient @@ -406,9 +405,7 @@ extension Ghostty { let len = chars.utf8CString.count if (len == 0) { return } - chars.withCString { ptr in - ghostty_inspector_text(inspector, ptr) - } + inspector.text(chars) } override func doCommand(by selector: Selector) { @@ -435,11 +432,7 @@ extension Ghostty { updateSize() // Render - ghostty_inspector_metal_render( - inspector, - Unmanaged.passRetained(commandBuffer).toOpaque(), - Unmanaged.passRetained(descriptor).toOpaque() - ) + inspector.metalRender(commandBuffer: commandBuffer, descriptor: descriptor) guard let drawable = self.currentDrawable else { return } commandBuffer.present(drawable) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 0ddfe57b8..c856b0163 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -173,10 +173,11 @@ extension Ghostty { } // Returns the inspector instance for this surface, or nil if the - // surface has been closed. - var inspector: ghostty_inspector_t? { + // surface has been closed or no inspector is active. + var inspector: Ghostty.Inspector? { guard let surface = self.surface else { return nil } - return ghostty_surface_inspector(surface) + guard let cInspector = ghostty_surface_inspector(surface) else { return nil } + return Ghostty.Inspector(cInspector: cInspector) } // True if the inspector should be visible diff --git a/pkg/dcimgui/build.zig b/pkg/dcimgui/build.zig index 95c4af303..ae907dac0 100644 --- a/pkg/dcimgui/build.zig +++ b/pkg/dcimgui/build.zig @@ -49,6 +49,7 @@ pub fn build(b: *std.Build) !void { var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); try flags.appendSlice(b.allocator, &.{ + "-DIMGUI_HAS_DOCK=1", "-DIMGUI_USE_WCHAR32=1", "-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1", }); diff --git a/pkg/dcimgui/main.zig b/pkg/dcimgui/main.zig index e709158f5..59bfca4f2 100644 --- a/pkg/dcimgui/main.zig +++ b/pkg/dcimgui/main.zig @@ -5,6 +5,8 @@ pub const c = @cImport({ // during import time to get the right types. Without this // you get stack size mismatches on some structs. @cDefine("IMGUI_USE_WCHAR32", "1"); + + @cDefine("IMGUI_HAS_DOCK", "1"); @cInclude("dcimgui.h"); }); @@ -25,11 +27,39 @@ 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; -// Internal API functions from dcimgui_internal.h +// Internal API types and functions from dcimgui_internal.h // We declare these manually because the internal header contains bitfields // that Zig's cImport cannot translate. +pub const ImGuiDockNodeFlagsPrivate = struct { + pub const DockSpace: c.ImGuiDockNodeFlags = 1 << 10; + pub const CentralNode: c.ImGuiDockNodeFlags = 1 << 11; + pub const NoTabBar: c.ImGuiDockNodeFlags = 1 << 12; + pub const HiddenTabBar: c.ImGuiDockNodeFlags = 1 << 13; + pub const NoWindowMenuButton: c.ImGuiDockNodeFlags = 1 << 14; + pub const NoCloseButton: c.ImGuiDockNodeFlags = 1 << 15; + pub const NoResizeX: c.ImGuiDockNodeFlags = 1 << 16; + pub const NoResizeY: c.ImGuiDockNodeFlags = 1 << 17; + pub const DockedWindowsInFocusRoute: c.ImGuiDockNodeFlags = 1 << 18; + pub const NoDockingSplitOther: c.ImGuiDockNodeFlags = 1 << 19; + pub const NoDockingOverMe: c.ImGuiDockNodeFlags = 1 << 20; + pub const NoDockingOverOther: c.ImGuiDockNodeFlags = 1 << 21; + pub const NoDockingOverEmpty: c.ImGuiDockNodeFlags = 1 << 22; +}; pub extern fn ImGui_DockBuilderDockWindow(window_name: [*:0]const u8, node_id: c.ImGuiID) callconv(.c) void; +pub extern fn ImGui_DockBuilderGetNode(node_id: c.ImGuiID) callconv(.c) ?*anyopaque; +pub extern fn ImGui_DockBuilderGetCentralNode(node_id: c.ImGuiID) callconv(.c) ?*anyopaque; +pub extern fn ImGui_DockBuilderAddNode() callconv(.c) c.ImGuiID; +pub extern fn ImGui_DockBuilderAddNodeEx(node_id: c.ImGuiID, flags: c.ImGuiDockNodeFlags) callconv(.c) c.ImGuiID; +pub extern fn ImGui_DockBuilderRemoveNode(node_id: c.ImGuiID) callconv(.c) void; +pub extern fn ImGui_DockBuilderRemoveNodeDockedWindows(node_id: c.ImGuiID) callconv(.c) void; +pub extern fn ImGui_DockBuilderRemoveNodeDockedWindowsEx(node_id: c.ImGuiID, clear_settings_refs: bool) callconv(.c) void; +pub extern fn ImGui_DockBuilderRemoveNodeChildNodes(node_id: c.ImGuiID) callconv(.c) void; +pub extern fn ImGui_DockBuilderSetNodePos(node_id: c.ImGuiID, pos: c.ImVec2) callconv(.c) void; +pub extern fn ImGui_DockBuilderSetNodeSize(node_id: c.ImGuiID, size: c.ImVec2) callconv(.c) void; pub extern fn ImGui_DockBuilderSplitNode(node_id: c.ImGuiID, split_dir: c.ImGuiDir, size_ratio_for_node_at_dir: f32, out_id_at_dir: *c.ImGuiID, out_id_at_opposite_dir: *c.ImGuiID) callconv(.c) c.ImGuiID; +pub extern fn ImGui_DockBuilderCopyDockSpace(src_dockspace_id: c.ImGuiID, dst_dockspace_id: c.ImGuiID, in_window_remap_pairs: *c.ImVector_const_charPtr) callconv(.c) void; +pub extern fn ImGui_DockBuilderCopyNode(src_node_id: c.ImGuiID, dst_node_id: c.ImGuiID, out_node_remap_pairs: *c.ImVector_ImGuiID) callconv(.c) void; +pub extern fn ImGui_DockBuilderCopyWindowSettings(src_name: [*:0]const u8, dst_name: [*:0]const u8) callconv(.c) void; pub extern fn ImGui_DockBuilderFinish(node_id: c.ImGuiID) callconv(.c) void; // Extension functions from ext.cpp diff --git a/src/Surface.zig b/src/Surface.zig index 0bf3aa008..fa9b04685 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -803,7 +803,7 @@ pub fn deinit(self: *Surface) void { self.io.deinit(); if (self.inspector) |v| { - v.deinit(); + v.deinit(self.alloc); self.alloc.destroy(v); } @@ -879,8 +879,10 @@ pub fn activateInspector(self: *Surface) !void { // Setup the inspector const ptr = try self.alloc.create(inspectorpkg.Inspector); errdefer self.alloc.destroy(ptr); - ptr.* = try inspectorpkg.Inspector.init(self); + ptr.* = try inspectorpkg.Inspector.init(self.alloc); + errdefer ptr.deinit(self.alloc); self.inspector = ptr; + errdefer self.inspector = null; // Put the inspector onto the render state { @@ -912,7 +914,7 @@ pub fn deactivateInspector(self: *Surface) void { self.queueIo(.{ .inspector = false }, .unlocked); // Deinit the inspector - insp.deinit(); + insp.deinit(self.alloc); self.alloc.destroy(insp); self.inspector = null; } @@ -2618,7 +2620,7 @@ pub fn keyCallback( defer crash.sentry.thread_state = null; // Setup our inspector event if we have an inspector. - var insp_ev: ?inspectorpkg.key.Event = if (self.inspector != null) ev: { + var insp_ev: ?inspectorpkg.KeyEvent = if (self.inspector != null) ev: { var copy = event; copy.utf8 = ""; if (event.utf8.len > 0) copy.utf8 = try self.alloc.dupe(u8, event.utf8); @@ -2635,7 +2637,7 @@ pub fn keyCallback( break :ev; }; - if (insp.recordKeyEvent(ev)) { + if (insp.recordKeyEvent(self.alloc, ev)) { self.queueRender() catch {}; } else |err| { log.warn("error adding key event to inspector err={}", .{err}); @@ -2798,7 +2800,7 @@ pub fn keyCallback( fn maybeHandleBinding( self: *Surface, event: input.KeyEvent, - insp_ev: ?*inspectorpkg.key.Event, + insp_ev: ?*inspectorpkg.KeyEvent, ) !?InputEffect { switch (event.action) { // Release events never trigger a binding but we need to check if @@ -3131,7 +3133,7 @@ fn endKeySequence( fn encodeKey( self: *Surface, event: input.KeyEvent, - insp_ev: ?*inspectorpkg.key.Event, + insp_ev: ?*inspectorpkg.KeyEvent, ) !?termio.Message.WriteReq { const write_req: termio.Message.WriteReq = req: { // Build our encoding options, which requires the lock. @@ -3872,36 +3874,8 @@ pub fn mouseButtonCallback( // log.debug("mouse action={} button={} mods={}", .{ action, button, mods }); // If we have an inspector, we always queue a render - if (self.inspector) |insp| { + if (self.inspector != null) { 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 screen: *terminal.Screen = self.renderer_state.terminal.screens.active; - const p = screen.pages.pin(.{ .viewport = point }) orelse { - log.warn("failed to get pin for clicked point", .{}); - return false; - }; - - insp.cell.select( - self.alloc, - p, - point.x, - point.y, - ) catch |err| { - log.warn("error selecting cell for inspector err={}", .{err}); - }; - return false; - } } // Always record our latest mouse state diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 2fbae8fdf..dcf8a6357 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1061,7 +1061,7 @@ pub const Inspector = struct { render: { const surface = &self.surface.core_surface; const inspector = surface.inspector orelse break :render; - inspector.render(); + inspector.render(surface); } // Render diff --git a/src/apprt/gtk/class/inspector_widget.zig b/src/apprt/gtk/class/inspector_widget.zig index 046cd2174..80ac7fc3e 100644 --- a/src/apprt/gtk/class/inspector_widget.zig +++ b/src/apprt/gtk/class/inspector_widget.zig @@ -89,7 +89,7 @@ pub const InspectorWidget = extern struct { const surface = priv.surface orelse return; const core_surface = surface.core() orelse return; const inspector = core_surface.inspector orelse return; - inspector.render(); + inspector.render(core_surface); } //--------------------------------------------------------------- diff --git a/src/inspector/AGENTS.md b/src/inspector/AGENTS.md new file mode 100644 index 000000000..dafc81e0b --- /dev/null +++ b/src/inspector/AGENTS.md @@ -0,0 +1,12 @@ +# Inspector Subsystem + +The inspector is a feature of Ghostty that works similar to a +browser's developer tools. It allows the user to inspect and modify the +terminal state. + +- See the full C API by finding `dcimgui.h` in the `.zig-cache` folder + in the root: `find . -type f -name dcimgui.h`. Use the newest version. +- See full examples of how to use every widget by loading this file: + +- On macOS, run builds with `-Demit-macos-app=false` to verify API usage. +- There are no unit tests in this package. diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 6ffb43d43..22d763f73 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -10,119 +10,16 @@ const builtin = @import("builtin"); const cimgui = @import("dcimgui"); const Surface = @import("../Surface.zig"); const font = @import("../font/main.zig"); -const input = @import("../input.zig"); -const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const inspector = @import("main.zig"); -const units = @import("units.zig"); - -/// The window names. These are used with docking so we need to have access. -const window_cell = "Cell"; -const window_modes = "Modes"; -const window_keyboard = "Keyboard"; -const window_termio = "Terminal IO"; -const window_screen = "Screen"; -const window_size = "Surface Info"; -const window_imgui_demo = "Dear ImGui Demo"; - -/// The surface that we're inspecting. -surface: *Surface, - -/// This is used to track whether we're rendering for the first time. This -/// is used to set up the initial window positions. -first_render: bool = true, +const widgets = @import("widgets.zig"); /// Mouse state that we track in addition to normal mouse states that /// Ghostty always knows about. -mouse: struct { - /// Last hovered x/y - last_xpos: f64 = 0, - last_ypos: f64 = 0, +mouse: widgets.surface.Mouse = .{}, - // Last hovered screen point - last_point: ?terminal.Pin = null, -} = .{}, - -/// A selected cell. -cell: CellInspect = .{ .idle = {} }, - -/// The list of keyboard events -key_events: inspector.key.EventRing, - -/// The VT stream -vt_events: inspector.termio.VTEventRing, -vt_stream: inspector.termio.Stream, - -/// The currently selected event sequence number for keyboard navigation -selected_event_seq: ?u32 = null, - -/// Flag indicating whether we need to scroll to the selected item -need_scroll_to_selected: bool = false, - -/// Flag indicating whether the selection was made by keyboard -is_keyboard_selection: bool = false, - -/// Enum representing keyboard navigation actions -const KeyAction = enum { - down, - none, - up, -}; - -const CellInspect = union(enum) { - /// Idle, no cell inspection is requested - idle: void, - - /// Requested, a cell is being picked. - requested: void, - - /// The cell has been picked and set to this. This is a copy so that - /// if the cell contents change we still have the original cell. - selected: Selected, - - const Selected = struct { - alloc: Allocator, - row: usize, - col: usize, - cell: inspector.Cell, - }; - - pub fn deinit(self: *CellInspect) void { - switch (self.*) { - .idle, .requested => {}, - .selected => |*v| v.cell.deinit(v.alloc), - } - } - - pub fn request(self: *CellInspect) void { - switch (self.*) { - .idle => self.* = .requested, - .selected => |*v| { - v.cell.deinit(v.alloc); - self.* = .requested; - }, - .requested => {}, - } - } - - pub fn select( - self: *CellInspect, - alloc: Allocator, - pin: terminal.Pin, - x: usize, - y: usize, - ) !void { - assert(self.* == .requested); - const cell = try inspector.Cell.init(alloc, pin); - errdefer cell.deinit(alloc); - self.* = .{ .selected = .{ - .alloc = alloc, - .row = y, - .col = x, - .cell = cell, - } }; - } -}; +// ImGui state +gui: widgets.surface.Inspector, /// Setup the ImGui state. This requires an ImGui context to be set. pub fn setup() void { @@ -158,56 +55,42 @@ pub fn setup() void { } } -pub fn init(surface: *Surface) !Inspector { - var key_buf = try inspector.key.EventRing.init(surface.alloc, 2); - errdefer key_buf.deinit(surface.alloc); - - var vt_events = try inspector.termio.VTEventRing.init(surface.alloc, 2); - errdefer vt_events.deinit(surface.alloc); - - var vt_handler = inspector.termio.VTHandler.init(surface); - errdefer vt_handler.deinit(); - - return .{ - .surface = surface, - .key_events = key_buf, - .vt_events = vt_events, - .vt_stream = .initAlloc(surface.alloc, vt_handler), - }; +pub fn init(alloc: Allocator) !Inspector { + var gui: widgets.surface.Inspector = try .init(alloc); + errdefer gui.deinit(alloc); + return .{ .gui = gui }; } -pub fn deinit(self: *Inspector) void { - self.cell.deinit(); +pub fn deinit(self: *Inspector, alloc: Allocator) void { + self.gui.deinit(alloc); +} - { - var it = self.key_events.iterator(.forward); - while (it.next()) |v| v.deinit(self.surface.alloc); - self.key_events.deinit(self.surface.alloc); - } - - { - var it = self.vt_events.iterator(.forward); - while (it.next()) |v| v.deinit(self.surface.alloc); - self.vt_events.deinit(self.surface.alloc); - - self.vt_stream.deinit(); - } +/// Returns the renderer info panel. This is a convenience function +/// to access and find this state to read and modify. +pub fn rendererInfo(self: *Inspector) *widgets.renderer.Info { + return &self.gui.renderer_info; } /// Record a keyboard event. -pub fn recordKeyEvent(self: *Inspector, ev: inspector.key.Event) !void { +pub fn recordKeyEvent( + self: *Inspector, + alloc: Allocator, + ev: inspector.KeyEvent, +) Allocator.Error!void { const max_capacity = 50; - self.key_events.append(ev) catch |err| switch (err) { - error.OutOfMemory => if (self.key_events.capacity() < max_capacity) { + + const events: *widgets.key.EventRing = &self.gui.key_stream.events; + events.append(ev) catch |err| switch (err) { + error.OutOfMemory => if (events.capacity() < max_capacity) { // We're out of memory, but we can allocate to our capacity. - const new_capacity = @min(self.key_events.capacity() * 2, max_capacity); - try self.key_events.resize(self.surface.alloc, new_capacity); - try self.key_events.append(ev); + const new_capacity = @min(events.capacity() * 2, max_capacity); + try events.resize(alloc, new_capacity); + try events.append(ev); } else { - var it = self.key_events.iterator(.forward); - if (it.next()) |old_ev| old_ev.deinit(self.surface.alloc); - self.key_events.deleteOldest(1); - try self.key_events.append(ev); + var it = events.iterator(.forward); + if (it.next()) |old_ev| old_ev.deinit(alloc); + events.deleteOldest(1); + try events.append(ev); }, else => return err, @@ -215,1078 +98,34 @@ pub fn recordKeyEvent(self: *Inspector, ev: inspector.key.Event) !void { } /// Record data read from the pty. -pub fn recordPtyRead(self: *Inspector, data: []const u8) !void { - try self.vt_stream.nextSlice(data); +pub fn recordPtyRead( + self: *Inspector, + alloc: Allocator, + t: *terminal.Terminal, + data: []const u8, +) !void { + try self.gui.vt_stream.recordPtyRead( + alloc, + t, + data, + ); } /// Render the frame. -pub fn render(self: *Inspector) void { - const dock_id = cimgui.c.ImGui_DockSpaceOverViewport(); - - // Render all of our data. We hold the mutex for this duration. This is - // expensive but this is an initial implementation until it doesn't work - // anymore. - { - self.surface.renderer_state.mutex.lock(); - defer self.surface.renderer_state.mutex.unlock(); - self.renderScreenWindow(); - self.renderModesWindow(); - self.renderKeyboardWindow(); - self.renderTermioWindow(); - self.renderCellWindow(); - self.renderSizeWindow(); - } - - // In debug we show the ImGui demo window so we can easily view available - // widgets and such. - if (builtin.mode == .Debug) { - var show: bool = true; - cimgui.c.ImGui_ShowDemoWindow(&show); - } - - // On first render we set up the layout. We can actually do this at - // the end of the frame, allowing the individual rendering to also - // observe the first render flag. - if (self.first_render) { - self.first_render = false; - self.setupLayout(dock_id); - } -} - -fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { - _ = self; - - // Our initial focus - cimgui.c.ImGui_SetWindowFocusStr(window_screen); - - // Setup our initial layout. - const dock_id: struct { - left: cimgui.c.ImGuiID, - right: cimgui.c.ImGuiID, - } = dock_id: { - var dock_id_left: cimgui.c.ImGuiID = undefined; - var dock_id_right: cimgui.c.ImGuiID = undefined; - _ = cimgui.ImGui_DockBuilderSplitNode( - dock_id_main, - cimgui.c.ImGuiDir_Left, - 0.7, - &dock_id_left, - &dock_id_right, - ); - - break :dock_id .{ - .left = dock_id_left, - .right = dock_id_right, - }; - }; - - cimgui.ImGui_DockBuilderDockWindow(window_cell, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_modes, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_keyboard, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_screen, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_imgui_demo, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_size, dock_id.right); - cimgui.ImGui_DockBuilderFinish(dock_id_main); -} - -fn renderScreenWindow(self: *Inspector) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_screen, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - const t = self.surface.renderer_state.terminal; - const screen: *terminal.Screen = t.screens.active; - - { - _ = cimgui.c.ImGui_BeginTable( - "table_screen", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Active Screen"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", @tagName(t.screens.active_key).ptr); - } - } - } - - if (cimgui.c.ImGui_CollapsingHeader( - "Cursor", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) { - { - _ = cimgui.c.ImGui_BeginTable( - "table_cursor", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - inspector.cursor.renderInTable( - self.surface.renderer_state.terminal, - &screen.cursor, - ); - } // table - - cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); - } // cursor - - if (cimgui.c.ImGui_CollapsingHeader( - "Keyboard", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) { - { - _ = cimgui.c.ImGui_BeginTable( - "table_keyboard", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - const kitty_flags = screen.kitty_keyboard.current(); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Mode"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - const mode = if (kitty_flags.int() != 0) "kitty" else "legacy"; - cimgui.c.ImGui_Text("%s", mode.ptr); - } - } - - if (kitty_flags.int() != 0) { - const Flags = @TypeOf(kitty_flags); - inline for (@typeInfo(Flags).@"struct".fields) |field| { - { - const value = @field(kitty_flags, field.name); - - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - const name = std.fmt.comptimePrint("{s}", .{field.name}); - cimgui.c.ImGui_Text("%s", name.ptr); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%s", - if (value) "true".ptr else "false".ptr, - ); - } - } - } - } else { - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Xterm modify keys"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%s", - if (t.flags.modify_other_keys_2) "true".ptr else "false".ptr, - ); - } - } - } // keyboard mode info - } // table - } // keyboard - - if (cimgui.c.ImGui_CollapsingHeader( - "Kitty Graphics", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) kitty_gfx: { - if (!screen.kitty_images.enabled()) { - cimgui.c.ImGui_TextDisabled("(Kitty graphics are disabled)"); - break :kitty_gfx; - } - - { - _ = cimgui.c.ImGui_BeginTable( - "##kitty_graphics", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - const kitty_images = &screen.kitty_images; - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Memory Usage"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d bytes (%d KiB)", kitty_images.total_bytes, units.toKibiBytes(kitty_images.total_bytes)); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Memory Limit"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d bytes (%d KiB)", kitty_images.total_limit, units.toKibiBytes(kitty_images.total_limit)); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Image Count"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", kitty_images.images.count()); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Placement Count"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", kitty_images.placements.count()); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Image Loading"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", if (kitty_images.loading != null) "true".ptr else "false".ptr); - } - } - } // table - } // kitty graphics - - if (cimgui.c.ImGui_CollapsingHeader( - "Internal Terminal State", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) { - const pages = &screen.pages; - - { - _ = cimgui.c.ImGui_BeginTable( - "##terminal_state", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Memory Usage"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.page_size, units.toKibiBytes(pages.page_size)); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Memory Limit"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.maxSize(), units.toKibiBytes(pages.maxSize())); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Viewport Location"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr); - } - } - } // table - // - if (cimgui.c.ImGui_CollapsingHeader( - "Active Page", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) { - inspector.page.render(&pages.pages.last.?.data); - } - } // terminal state -} - -/// The modes window shows the currently active terminal modes and allows -/// users to toggle them on and off. -fn renderModesWindow(self: *Inspector) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_modes, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - _ = cimgui.c.ImGui_BeginTable( - "table_modes", - 3, - cimgui.c.ImGuiTableFlags_SizingFixedFit | - cimgui.c.ImGuiTableFlags_RowBg, +pub fn render( + self: *Inspector, + surface: *Surface, +) void { + // Draw the UI + self.gui.draw( + surface, + self.mouse, ); - defer cimgui.c.ImGui_EndTable(); - { - cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_NoResize); - cimgui.c.ImGui_TableSetupColumn("Number", cimgui.c.ImGuiTableColumnFlags_PreferSortAscending); - cimgui.c.ImGui_TableSetupColumn("Name", cimgui.c.ImGuiTableColumnFlags_WidthStretch); - cimgui.c.ImGui_TableHeadersRow(); - } - - const t = self.surface.renderer_state.terminal; - inline for (@typeInfo(terminal.Mode).@"enum".fields) |field| { - @setEvalBranchQuota(6000); - const tag: terminal.modes.ModeTag = @bitCast(@as(terminal.modes.ModeTag.Backing, field.value)); - - cimgui.c.ImGui_TableNextRow(); - cimgui.c.ImGui_PushIDInt(@intCast(field.value)); - defer cimgui.c.ImGui_PopID(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - var value: bool = t.modes.get(@field(terminal.Mode, field.name)); - _ = cimgui.c.ImGui_Checkbox("##checkbox", &value); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%s%d", - if (tag.ansi) "" else "?", - @as(u32, @intCast(tag.value)), - ); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - const name = std.fmt.comptimePrint("{s}", .{field.name}); - cimgui.c.ImGui_Text("%s", name.ptr); - } - } -} - -fn renderSizeWindow(self: *Inspector) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_size, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - cimgui.c.ImGui_SeparatorText("Dimensions"); - - { - _ = cimgui.c.ImGui_BeginTable( - "table_size", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - // Screen Size - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Screen Size"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%dpx x %dpx", - self.surface.size.screen.width, - self.surface.size.screen.height, - ); - } - } - - // Grid Size - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Grid Size"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - const grid_size = self.surface.size.grid(); - cimgui.c.ImGui_Text( - "%dc x %dr", - grid_size.columns, - grid_size.rows, - ); - } - } - - // Cell Size - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Cell Size"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%dpx x %dpx", - self.surface.size.cell.width, - self.surface.size.cell.height, - ); - } - } - - // Padding - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Window Padding"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "T=%d B=%d L=%d R=%d px", - self.surface.size.padding.top, - self.surface.size.padding.bottom, - self.surface.size.padding.left, - self.surface.size.padding.right, - ); - } - } - } - - cimgui.c.ImGui_SeparatorText("Font"); - - { - _ = cimgui.c.ImGui_BeginTable( - "table_font", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Size (Points)"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%.2f pt", - self.surface.font_size.points, - ); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Size (Pixels)"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%.2f px", - self.surface.font_size.pixels(), - ); - } - } - } - - cimgui.c.ImGui_SeparatorText("Mouse"); - - { - _ = cimgui.c.ImGui_BeginTable( - "table_mouse", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - const mouse = &self.surface.mouse; - const t = self.surface.renderer_state.terminal; - - { - const hover_point: terminal.point.Coordinate = pt: { - const p = self.mouse.last_point orelse break :pt .{}; - const pt = t.screens.active.pages.pointFromPin( - .active, - p, - ) orelse break :pt .{}; - break :pt pt.coord(); - }; - - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Hover Grid"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "row=%d, col=%d", - hover_point.y, - hover_point.x, - ); - } - } - - { - const coord: renderer.Coordinate.Terminal = (renderer.Coordinate{ - .surface = .{ - .x = self.mouse.last_xpos, - .y = self.mouse.last_ypos, - }, - }).convert(.terminal, self.surface.size).terminal; - - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Hover Point"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "(%dpx, %dpx)", - @as(i64, @intFromFloat(coord.x)), - @as(i64, @intFromFloat(coord.y)), - ); - } - } - - const any_click = for (mouse.click_state) |state| { - if (state == .press) break true; - } else false; - - click: { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Click State"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - if (!any_click) { - cimgui.c.ImGui_Text("none"); - break :click; - } - - for (mouse.click_state, 0..) |state, i| { - if (state != .press) continue; - const button: input.MouseButton = @enumFromInt(i); - cimgui.c.ImGui_SameLine(); - cimgui.c.ImGui_Text("%s", (switch (button) { - .unknown => "?", - .left => "L", - .middle => "M", - .right => "R", - .four => "{4}", - .five => "{5}", - .six => "{6}", - .seven => "{7}", - .eight => "{8}", - .nine => "{9}", - .ten => "{10}", - .eleven => "{11}", - }).ptr); - } - } - } - - { - const left_click_point: terminal.point.Coordinate = pt: { - const p = mouse.left_click_pin orelse break :pt .{}; - const pt = t.screens.active.pages.pointFromPin( - .active, - p.*, - ) orelse break :pt .{}; - break :pt pt.coord(); - }; - - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Click Grid"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "row=%d, col=%d", - left_click_point.y, - left_click_point.x, - ); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Click Point"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "(%dpx, %dpx)", - @as(u32, @intFromFloat(mouse.left_click_xpos)), - @as(u32, @intFromFloat(mouse.left_click_ypos)), - ); - } - } - } -} - -fn renderCellWindow(self: *Inspector) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_cell, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - // Our popup for the picker - const popup_picker = "Cell Picker"; - - if (cimgui.c.ImGui_Button("Picker")) { - // Request a cell - self.cell.request(); - - cimgui.c.ImGui_OpenPopup( - popup_picker, - cimgui.c.ImGuiPopupFlags_None, - ); - } - - if (cimgui.c.ImGui_BeginPopupModal( - popup_picker, - null, - cimgui.c.ImGuiWindowFlags_AlwaysAutoResize, - )) popup: { - defer cimgui.c.ImGui_EndPopup(); - - // Once we select a cell, close this popup. - if (self.cell == .selected) { - cimgui.c.ImGui_CloseCurrentPopup(); - break :popup; - } - - cimgui.c.ImGui_Text( - "Click on a cell in the terminal to inspect it.\n" ++ - "The click will be intercepted by the picker, \n" ++ - "so it won't be sent to the terminal.", - ); - cimgui.c.ImGui_Separator(); - - if (cimgui.c.ImGui_Button("Cancel")) { - cimgui.c.ImGui_CloseCurrentPopup(); - } - } // cell pick popup - - cimgui.c.ImGui_Separator(); - - if (self.cell != .selected) { - cimgui.c.ImGui_Text("No cell selected."); - return; - } - - const selected = self.cell.selected; - selected.cell.renderTable( - self.surface.renderer_state.terminal, - selected.col, - selected.row, - ); -} - -fn renderKeyboardWindow(self: *Inspector) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_keyboard, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - list: { - if (self.key_events.empty()) { - cimgui.c.ImGui_Text("No recorded key events. Press a key with the " ++ - "terminal focused to record it."); - break :list; - } - - if (cimgui.c.ImGui_Button("Clear")) { - var it = self.key_events.iterator(.forward); - while (it.next()) |v| v.deinit(self.surface.alloc); - self.key_events.clear(); - self.vt_stream.handler.current_seq = 1; - } - - cimgui.c.ImGui_Separator(); - - _ = cimgui.c.ImGui_BeginTable( - "table_key_events", - 1, - //cimgui.c.ImGuiTableFlags_ScrollY | - cimgui.c.ImGuiTableFlags_RowBg | - cimgui.c.ImGuiTableFlags_Borders, - ); - defer cimgui.c.ImGui_EndTable(); - - var it = self.key_events.iterator(.reverse); - while (it.next()) |ev| { - // Need to push an ID so that our selectable is unique. - cimgui.c.ImGui_PushIDPtr(ev); - defer cimgui.c.ImGui_PopID(); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - - var buf: [1024]u8 = undefined; - const label = ev.label(&buf) catch "Key Event"; - _ = cimgui.c.ImGui_SelectableBoolPtr( - label.ptr, - &ev.imgui_state.selected, - cimgui.c.ImGuiSelectableFlags_None, - ); - - if (!ev.imgui_state.selected) continue; - ev.render(); - } - } // table -} - -/// Helper function to check keyboard state and determine navigation action. -fn getKeyAction(self: *Inspector) KeyAction { - _ = self; - const keys = .{ - .{ .key = cimgui.c.ImGuiKey_J, .action = KeyAction.down }, - .{ .key = cimgui.c.ImGuiKey_DownArrow, .action = KeyAction.down }, - .{ .key = cimgui.c.ImGuiKey_K, .action = KeyAction.up }, - .{ .key = cimgui.c.ImGuiKey_UpArrow, .action = KeyAction.up }, - }; - - inline for (keys) |k| { - if (cimgui.c.ImGui_IsKeyPressed(k.key)) { - return k.action; - } - } - return .none; -} - -fn renderTermioWindow(self: *Inspector) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_termio, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - const popup_filter = "Filter"; - - list: { - const pause_play: [:0]const u8 = if (self.vt_stream.handler.active) - "Pause##pause_play" - else - "Resume##pause_play"; - if (cimgui.c.ImGui_Button(pause_play.ptr)) { - self.vt_stream.handler.active = !self.vt_stream.handler.active; - } - - cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); - if (cimgui.c.ImGui_Button("Filter")) { - cimgui.c.ImGui_OpenPopup( - popup_filter, - cimgui.c.ImGuiPopupFlags_None, - ); - } - - if (!self.vt_events.empty()) { - cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); - if (cimgui.c.ImGui_Button("Clear")) { - var it = self.vt_events.iterator(.forward); - while (it.next()) |v| v.deinit(self.surface.alloc); - self.vt_events.clear(); - - // We also reset the sequence number. - self.vt_stream.handler.current_seq = 1; - } - } - - cimgui.c.ImGui_Separator(); - - if (self.vt_events.empty()) { - cimgui.c.ImGui_Text("Waiting for events..."); - break :list; - } - - _ = cimgui.c.ImGui_BeginTable( - "table_vt_events", - 3, - cimgui.c.ImGuiTableFlags_RowBg | - cimgui.c.ImGuiTableFlags_Borders, - ); - defer cimgui.c.ImGui_EndTable(); - - cimgui.c.ImGui_TableSetupColumn( - "Seq", - cimgui.c.ImGuiTableColumnFlags_WidthFixed, - ); - cimgui.c.ImGui_TableSetupColumn( - "Kind", - cimgui.c.ImGuiTableColumnFlags_WidthFixed, - ); - cimgui.c.ImGui_TableSetupColumn( - "Description", - cimgui.c.ImGuiTableColumnFlags_WidthStretch, - ); - - // Handle keyboard navigation when window is focused - if (cimgui.c.ImGui_IsWindowFocused(cimgui.c.ImGuiFocusedFlags_RootAndChildWindows)) { - const key_pressed = self.getKeyAction(); - - switch (key_pressed) { - .none => {}, - .up, .down => { - // If no event is selected, select the first/last event based on direction - if (self.selected_event_seq == null) { - if (!self.vt_events.empty()) { - var it = self.vt_events.iterator(if (key_pressed == .up) .forward else .reverse); - if (it.next()) |ev| { - self.selected_event_seq = @as(u32, @intCast(ev.seq)); - } - } - } else { - // Find next/previous event based on current selection - var it = self.vt_events.iterator(.reverse); - switch (key_pressed) { - .down => { - var found = false; - while (it.next()) |ev| { - if (found) { - self.selected_event_seq = @as(u32, @intCast(ev.seq)); - break; - } - if (ev.seq == self.selected_event_seq.?) { - found = true; - } - } - }, - .up => { - var prev_ev: ?*const inspector.termio.VTEvent = null; - while (it.next()) |ev| { - if (ev.seq == self.selected_event_seq.?) { - if (prev_ev) |prev| { - self.selected_event_seq = @as(u32, @intCast(prev.seq)); - break; - } - } - prev_ev = ev; - } - }, - .none => unreachable, - } - } - - // Mark that we need to scroll to the newly selected item - self.need_scroll_to_selected = true; - self.is_keyboard_selection = true; - }, - } - } - - var it = self.vt_events.iterator(.reverse); - while (it.next()) |ev| { - // Need to push an ID so that our selectable is unique. - cimgui.c.ImGui_PushIDPtr(ev); - defer cimgui.c.ImGui_PopID(); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableNextColumn(); - - // Store the previous selection state to detect changes - const was_selected = ev.imgui_selected; - - // Update selection state based on keyboard navigation - if (self.selected_event_seq) |seq| { - ev.imgui_selected = (@as(u32, @intCast(ev.seq)) == seq); - } - - // Handle selectable widget - if (cimgui.c.ImGui_SelectableBoolPtr( - "##select", - &ev.imgui_selected, - cimgui.c.ImGuiSelectableFlags_SpanAllColumns, - )) { - // If selection state changed, update keyboard navigation state - if (ev.imgui_selected != was_selected) { - self.selected_event_seq = if (ev.imgui_selected) - @as(u32, @intCast(ev.seq)) - else - null; - self.is_keyboard_selection = false; - } - } - - cimgui.c.ImGui_SameLine(); - cimgui.c.ImGui_Text("%d", ev.seq); - _ = cimgui.c.ImGui_TableNextColumn(); - cimgui.c.ImGui_Text("%s", @tagName(ev.kind).ptr); - _ = cimgui.c.ImGui_TableNextColumn(); - cimgui.c.ImGui_Text("%s", ev.str.ptr); - - // If the event is selected, we render info about it. For now - // we put this in the last column because that's the widest and - // imgui has no way to make a column span. - if (ev.imgui_selected) { - { - _ = cimgui.c.ImGui_BeginTable( - "details", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - inspector.cursor.renderInTable( - self.surface.renderer_state.terminal, - &ev.cursor, - ); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Scroll Region"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "T=%d B=%d L=%d R=%d", - ev.scrolling_region.top, - ev.scrolling_region.bottom, - ev.scrolling_region.left, - ev.scrolling_region.right, - ); - } - } - - var md_it = ev.metadata.iterator(); - while (md_it.next()) |entry| { - var buf: [256]u8 = undefined; - const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch - ""; - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableNextColumn(); - cimgui.c.ImGui_Text("%s", key.ptr); - _ = cimgui.c.ImGui_TableNextColumn(); - cimgui.c.ImGui_Text("%s", entry.value_ptr.ptr); - } - } - - // If this is the selected event and scrolling is needed, scroll to it - if (self.need_scroll_to_selected and self.is_keyboard_selection) { - cimgui.c.ImGui_SetScrollHereY(0.5); - self.need_scroll_to_selected = false; - } - } - } - } // table - - if (cimgui.c.ImGui_BeginPopupModal( - popup_filter, - null, - cimgui.c.ImGuiWindowFlags_AlwaysAutoResize, - )) { - defer cimgui.c.ImGui_EndPopup(); - - cimgui.c.ImGui_Text("Changed filter settings will only affect future events."); - - cimgui.c.ImGui_Separator(); - - { - _ = cimgui.c.ImGui_BeginTable( - "table_filter_kind", - 3, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - inline for (@typeInfo(terminal.Parser.Action.Tag).@"enum".fields) |field| { - const tag = @field(terminal.Parser.Action.Tag, field.name); - if (tag == .apc_put or tag == .dcs_put) continue; - - _ = cimgui.c.ImGui_TableNextColumn(); - var value = !self.vt_stream.handler.filter_exclude.contains(tag); - if (cimgui.c.ImGui_Checkbox(@tagName(tag).ptr, &value)) { - if (value) { - self.vt_stream.handler.filter_exclude.remove(tag); - } else { - self.vt_stream.handler.filter_exclude.insert(tag); - } - } - } - } // Filter kind table - - cimgui.c.ImGui_Separator(); - - cimgui.c.ImGui_Text( - "Filter by string. Empty displays all, \"abc\" finds lines\n" ++ - "containing \"abc\", \"abc,xyz\" finds lines containing \"abc\"\n" ++ - "or \"xyz\", \"-abc\" excludes lines containing \"abc\".", - ); - _ = cimgui.c.ImGuiTextFilter_Draw( - &self.vt_stream.handler.filter_text, - "##filter_text", - 0, - ); - - cimgui.c.ImGui_Separator(); - if (cimgui.c.ImGui_Button("Close")) { - cimgui.c.ImGui_CloseCurrentPopup(); - } - } // filter popup + // We always trigger a rebuild of the surface when the inspector + // is focused because modifying the inspector can change the terminal + // state. This is KIND OF expensive (wasted CPU if nothing was done) + // but the inspector is a development tool and it expressly costs + // more resources while open so its okay. + surface.renderer_thread.wakeup.notify() catch {}; } diff --git a/src/inspector/cell.zig b/src/inspector/cell.zig deleted file mode 100644 index 540e044fd..000000000 --- a/src/inspector/cell.zig +++ /dev/null @@ -1,223 +0,0 @@ -const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; -const Allocator = std.mem.Allocator; -const cimgui = @import("dcimgui"); -const terminal = @import("../terminal/main.zig"); - -/// A cell being inspected. This duplicates much of the data in -/// the terminal data structure because we want the inspector to -/// not have a reference to the terminal state or to grab any -/// locks. -pub const Cell = struct { - /// The main codepoint for this cell. - codepoint: u21, - - /// Codepoints for this cell to produce a single grapheme cluster. - /// This is only non-empty if the cell is part of a multi-codepoint - /// grapheme cluster. This does NOT include the primary codepoint. - cps: []const u21, - - /// The style of this cell. - style: terminal.Style, - - /// Wide state of the terminal cell - wide: terminal.Cell.Wide, - - pub fn init( - alloc: Allocator, - pin: terminal.Pin, - ) !Cell { - const cell = pin.rowAndCell().cell; - const style = pin.style(cell); - const cps: []const u21 = if (cell.hasGrapheme()) cps: { - const src = pin.grapheme(cell).?; - assert(src.len > 0); - break :cps try alloc.dupe(u21, src); - } else &.{}; - errdefer if (cps.len > 0) alloc.free(cps); - - return .{ - .codepoint = cell.codepoint(), - .cps = cps, - .style = style, - .wide = cell.wide, - }; - } - - pub fn deinit(self: *Cell, alloc: Allocator) void { - if (self.cps.len > 0) alloc.free(self.cps); - } - - pub fn renderTable( - self: *const Cell, - t: *const terminal.Terminal, - x: usize, - y: usize, - ) void { - // We have a selected cell, show information about it. - _ = cimgui.c.ImGui_BeginTable( - "table_cursor", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Grid Position"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("row=%d col=%d", y, x); - } - } - - // NOTE: we don't currently write the character itself because - // we haven't hooked up imgui to our font system. That's hard! We - // can/should instead hook up our renderer to imgui and just render - // the single glyph in an image view so it looks _identical_ to the - // terminal. - codepoint: { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Codepoints"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - if (cimgui.c.ImGui_BeginListBox("##codepoints", .{ .x = 0, .y = 0 })) { - defer cimgui.c.ImGui_EndListBox(); - - if (self.codepoint == 0) { - _ = cimgui.c.ImGui_SelectableEx("(empty)", false, 0, .{}); - break :codepoint; - } - - // Primary codepoint - var buf: [256]u8 = undefined; - { - const key = std.fmt.bufPrintZ(&buf, "U+{X}", .{self.codepoint}) catch - ""; - _ = cimgui.c.ImGui_SelectableEx(key.ptr, false, 0, .{}); - } - - // All extras - for (self.cps) |cp| { - const key = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch - ""; - _ = cimgui.c.ImGui_SelectableEx(key.ptr, false, 0, .{}); - } - } - } - } - - // Character width property - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Width Property"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text(@tagName(self.wide)); - - // If we have a color then we show the color - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Foreground Color"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - switch (self.style.fg_color) { - .none => cimgui.c.ImGui_Text("default"), - .palette => |idx| { - const rgb = t.colors.palette.current[idx]; - cimgui.c.ImGui_Text("Palette %d", idx); - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_fg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - - .rgb => |rgb| { - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_fg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Background Color"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - switch (self.style.bg_color) { - .none => cimgui.c.ImGui_Text("default"), - .palette => |idx| { - const rgb = t.colors.palette.current[idx]; - cimgui.c.ImGui_Text("Palette %d", idx); - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_bg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - - .rgb => |rgb| { - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_bg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - - // Boolean styles - const styles = .{ - "bold", "italic", "faint", "blink", - "inverse", "invisible", "strikethrough", - }; - inline for (styles) |style| style: { - if (!@field(self.style.flags, style)) break :style; - - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text(style.ptr); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("true"); - } - } - - cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); - } -}; diff --git a/src/inspector/cursor.zig b/src/inspector/cursor.zig deleted file mode 100644 index 4f8bfb2e0..000000000 --- a/src/inspector/cursor.zig +++ /dev/null @@ -1,142 +0,0 @@ -const cimgui = @import("dcimgui"); -const terminal = @import("../terminal/main.zig"); - -/// Render cursor information with a table already open. -pub fn renderInTable( - t: *const terminal.Terminal, - cursor: *const terminal.Screen.Cursor, -) void { - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Position (x, y)"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("(%d, %d)", cursor.x, cursor.y); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Style"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", @tagName(cursor.cursor_style).ptr); - } - } - - if (cursor.pending_wrap) { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Pending Wrap"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", if (cursor.pending_wrap) "true".ptr else "false".ptr); - } - } - - // If we have a color then we show the color - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Foreground Color"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - switch (cursor.style.fg_color) { - .none => cimgui.c.ImGui_Text("default"), - .palette => |idx| { - const rgb = t.colors.palette.current[idx]; - cimgui.c.ImGui_Text("Palette %d", idx); - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_fg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - - .rgb => |rgb| { - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_fg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Background Color"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - switch (cursor.style.bg_color) { - .none => cimgui.c.ImGui_Text("default"), - .palette => |idx| { - const rgb = t.colors.palette.current[idx]; - cimgui.c.ImGui_Text("Palette %d", idx); - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_bg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - - .rgb => |rgb| { - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_bg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - - // Boolean styles - const styles = .{ - "bold", "italic", "faint", "blink", - "inverse", "invisible", "strikethrough", - }; - inline for (styles) |style| style: { - if (!@field(cursor.style.flags, style)) break :style; - - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text(style.ptr); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("true"); - } - } -} diff --git a/src/inspector/key.zig b/src/inspector/key.zig deleted file mode 100644 index 12d91a107..000000000 --- a/src/inspector/key.zig +++ /dev/null @@ -1,240 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const input = @import("../input.zig"); -const CircBuf = @import("../datastruct/main.zig").CircBuf; -const cimgui = @import("dcimgui"); - -/// 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. - /// Multiple bindings are possible if they are chained. - binding: []const input.Binding.Action = &.{}, - - /// 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 { - alloc.free(self.binding); - 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+"); - - // Write our key. If we have an invalid key we attempt to write - // the utf8 associated with it if we have it to handle non-ascii. - try writer.writeAll(switch (self.event.key) { - .unidentified => if (self.event.utf8.len > 0) self.event.utf8 else @tagName(self.event.key), - else => @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.ImGui_BeginTable( - "##event", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - if (self.binding.len > 0) { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Triggered Binding"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - - const height: f32 = height: { - const item_count: f32 = @floatFromInt(@min(self.binding.len, 5)); - const padding = cimgui.c.ImGui_GetStyle().*.FramePadding.y * 2; - break :height cimgui.c.ImGui_GetTextLineHeightWithSpacing() * item_count + padding; - }; - if (cimgui.c.ImGui_BeginListBox("##bindings", .{ .x = 0, .y = height })) { - defer cimgui.c.ImGui_EndListBox(); - for (self.binding) |action| { - _ = cimgui.c.ImGui_SelectableEx( - @tagName(action).ptr, - false, - cimgui.c.ImGuiSelectableFlags_None, - .{ .x = 0, .y = 0 }, - ); - } - } - } - - pty: { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Encoding to Pty"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - if (self.pty.len == 0) { - cimgui.c.ImGui_TextDisabled("(no data)"); - break :pty; - } - - self.renderPty() catch { - cimgui.c.ImGui_TextDisabled("(error rendering pty data)"); - break :pty; - }; - } - - { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Action"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", @tagName(self.event.action).ptr); - } - { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Key"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", @tagName(self.event.key).ptr); - } - if (!self.event.mods.empty()) { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Mods"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - if (self.event.mods.shift) cimgui.c.ImGui_Text("shift "); - if (self.event.mods.ctrl) cimgui.c.ImGui_Text("ctrl "); - if (self.event.mods.alt) cimgui.c.ImGui_Text("alt "); - if (self.event.mods.super) cimgui.c.ImGui_Text("super "); - } - if (self.event.composing) { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Composing"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("true"); - } - utf8: { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("UTF-8"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - if (self.event.utf8.len == 0) { - cimgui.c.ImGui_TextDisabled("(empty)"); - break :utf8; - } - - self.renderUtf8(self.event.utf8) catch { - cimgui.c.ImGui_TextDisabled("(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.ImGui_InputText( - "##utf8", - &buf, - buf_stream.getWritten().len - 1, - cimgui.c.ImGuiInputTextFlags_ReadOnly, - ); - } - - 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.ImGui_InputText( - "##pty", - &buf, - buf_stream.getWritten().len - 1, - cimgui.c.ImGuiInputTextFlags_ReadOnly, - ); - } -}; - -test "event string" { - const testing = std.testing; - const alloc = testing.allocator; - - var event = try Event.init(alloc, .{ .key = .key_a }); - defer event.deinit(alloc); - - var buf: [1024]u8 = undefined; - try testing.expectEqualStrings("Press: key_a", try event.label(&buf)); -} diff --git a/src/inspector/main.zig b/src/inspector/main.zig index ee871f200..ae185f479 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -1,13 +1,8 @@ -const std = @import("std"); -pub const cell = @import("cell.zig"); -pub const cursor = @import("cursor.zig"); -pub const key = @import("key.zig"); -pub const page = @import("page.zig"); -pub const termio = @import("termio.zig"); - -pub const Cell = cell.Cell; +pub const widgets = @import("widgets.zig"); pub const Inspector = @import("Inspector.zig"); +pub const KeyEvent = widgets.key.Event; + test { @import("std").testing.refAllDecls(@This()); } diff --git a/src/inspector/page.zig b/src/inspector/page.zig deleted file mode 100644 index fd9d3bfb4..000000000 --- a/src/inspector/page.zig +++ /dev/null @@ -1,163 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const cimgui = @import("dcimgui"); -const terminal = @import("../terminal/main.zig"); -const units = @import("units.zig"); - -pub fn render(page: *const terminal.Page) void { - cimgui.c.ImGui_PushIDPtr(page); - defer cimgui.c.ImGui_PopID(); - - _ = cimgui.c.ImGui_BeginTable( - "##page_state", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Memory Size"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d bytes (%d KiB)", page.memory.len, units.toKibiBytes(page.memory.len)); - cimgui.c.ImGui_Text("%d VM pages", page.memory.len / std.heap.page_size_min); - } - } - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Unique Styles"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", page.styles.count()); - } - } - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Grapheme Entries"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", page.graphemeCount()); - } - } - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Capacity"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - _ = cimgui.c.ImGui_BeginTable( - "##capacity", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - const cap = page.capacity; - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Columns"); - } - - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", @as(u32, @intCast(cap.cols))); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Rows"); - } - - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", @as(u32, @intCast(cap.rows))); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Unique Styles"); - } - - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", @as(u32, @intCast(cap.styles))); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Grapheme Bytes"); - } - - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", cap.grapheme_bytes); - } - } - } - } - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Size"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - _ = cimgui.c.ImGui_BeginTable( - "##size", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - const size = page.size; - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Columns"); - } - - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", @as(u32, @intCast(size.cols))); - } - } - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Rows"); - } - - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", @as(u32, @intCast(size.rows))); - } - } - } - } // size table -} diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig deleted file mode 100644 index 934bb6e2d..000000000 --- a/src/inspector/termio.zig +++ /dev/null @@ -1,398 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const cimgui = @import("dcimgui"); -const terminal = @import("../terminal/main.zig"); -const CircBuf = @import("../datastruct/main.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); - - /// Initialize 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.Io.Writer.Allocating = .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.screens.active.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: *const 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: *std.Io.Writer, - 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("{f}", .{action}), - } - } - - fn encodePrint(writer: *std.Io.Writer, action: terminal.Parser.Action) !void { - const ch = action.print; - try writer.print("'{u}' (U+{X})", .{ ch, ch }); - } - - fn encodeExecute(writer: *std.Io.Writer, 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: *std.Io.Writer, 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: *std.Io.Writer, 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: *std.Io.Writer, - 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, - [:0]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), - ); - }, - - .@"union" => |info| { - const Tag = info.tag_type orelse @compileError("Unions must have a tag"); - const tag_name = @tagName(@as(Tag, v)); - inline for (info.fields) |field| { - if (std.mem.eql(u8, field.name, tag_name)) { - if (field.type == void) { - break try md.put("data", tag_name); - } else { - break try encodeMetadataSingle(alloc, md, tag_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)), - ), - - .@"union" => |u| { - const Tag = u.tag_type orelse @compileError("Unions must have a tag"); - const tag_name = @tagName(@as(Tag, value)); - inline for (u.fields) |field| { - if (std.mem.eql(u8, field.name, tag_name)) { - const s = if (field.type == void) - try alloc.dupeZ(u8, tag_name) - else if (field.type == [:0]const u8 or field.type == []const u8) - try std.fmt.allocPrintSentinel(alloc, "{s}={s}", .{ - tag_name, - @field(value, field.name), - }, 0) - else - try std.fmt.allocPrintSentinel(alloc, "{s}={}", .{ - tag_name, - @field(value, field.name), - }, 0); - - try md.put(key, s); - } - } - }, - - .@"struct" => try md.put( - key, - try alloc.dupeZ(u8, @typeName(Value)), - ), - - else => switch (Value) { - []const u8, - [:0]const u8, - => try md.put(key, try alloc.dupeZ(u8, value)), - - else => |T| switch (@typeInfo(T)) { - .int => try md.put( - key, - try std.fmt.allocPrintSentinel(alloc, "{}", .{value}, 0), - ), - else => { - @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 = .initMany(&.{.print}), - filter_text: cimgui.c.ImGuiTextFilter = .{}, - - const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag); - - pub fn init(surface: *Surface) VTHandler { - return .{ - .surface = surface, - }; - } - - pub fn deinit(self: *VTHandler) void { - _ = self; - } - - pub fn vt( - self: *VTHandler, - comptime action: Stream.Action.Tag, - value: Stream.Action.Value(action), - ) !void { - _ = self; - _ = value; - } - - /// 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; - } -}; diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig new file mode 100644 index 000000000..dd8ebc002 --- /dev/null +++ b/src/inspector/widgets.zig @@ -0,0 +1,227 @@ +const cimgui = @import("dcimgui"); + +pub const page = @import("widgets/page.zig"); +pub const pagelist = @import("widgets/pagelist.zig"); +pub const key = @import("widgets/key.zig"); +pub const renderer = @import("widgets/renderer.zig"); +pub const screen = @import("widgets/screen.zig"); +pub const style = @import("widgets/style.zig"); +pub const surface = @import("widgets/surface.zig"); +pub const terminal = @import("widgets/terminal.zig"); +pub const termio = @import("widgets/termio.zig"); + +/// Draws a "(?)" disabled text marker that shows some help text +/// on hover. +pub fn helpMarker(text: [:0]const u8) void { + cimgui.c.ImGui_TextDisabled("(?)"); + if (!cimgui.c.ImGui_BeginItemTooltip()) return; + defer cimgui.c.ImGui_EndTooltip(); + + cimgui.c.ImGui_PushTextWrapPos(cimgui.c.ImGui_GetFontSize() * 35.0); + defer cimgui.c.ImGui_PopTextWrapPos(); + + cimgui.c.ImGui_TextUnformatted(text.ptr); +} + +/// DetachableHeader allows rendering a collapsing header that can be +/// detached into its own window. +pub const DetachableHeader = struct { + /// Set whether the window is detached. + detached: bool = false, + + /// If true, detaching will move the item into a docking position + /// to the right. + dock: bool = true, + + // Internal state do not touch. + window_first: bool = true, + + pub fn windowEnd(self: *DetachableHeader) void { + _ = self; + + // If we started the window, we need to end it. + cimgui.c.ImGui_End(); + } + + /// Returns null if there is no window created (not detached). + /// Otherwise returns whether the window is open. + pub fn window( + self: *DetachableHeader, + label: [:0]const u8, + ) ?bool { + // If we're not detached, we don't create a window. + if (!self.detached) { + self.window_first = true; + return null; + } + + // If this is our first time showing the window then we need to + // setup docking. We only do this on the first time because we + // don't want to reset a user's docking behavior later. + if (self.window_first) dock: { + self.window_first = false; + if (!self.dock) break :dock; + const dock_id = cimgui.c.ImGui_GetWindowDockID(); + if (dock_id == 0) break :dock; + var dock_id_right: cimgui.c.ImGuiID = 0; + var dock_id_left: cimgui.c.ImGuiID = 0; + _ = cimgui.ImGui_DockBuilderSplitNode( + dock_id, + cimgui.c.ImGuiDir_Right, + 0.4, + &dock_id_right, + &dock_id_left, + ); + cimgui.ImGui_DockBuilderDockWindow(label, dock_id_right); + cimgui.ImGui_DockBuilderFinish(dock_id); + } + + return cimgui.c.ImGui_Begin( + label, + &self.detached, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + } + + pub fn header( + self: *DetachableHeader, + label: [:0]const u8, + ) bool { + // If we're detached, create a separate window. + if (self.detached) return false; + + // Make sure all headers have a unique ID in the stack. We only + // need to do this for the header side because creating a window + // automatically creates an ID. + cimgui.c.ImGui_PushID(label); + defer cimgui.c.ImGui_PopID(); + + // Create the collapsing header with the pop out button overlaid. + cimgui.c.ImGui_SetNextItemAllowOverlap(); + const is_open = cimgui.c.ImGui_CollapsingHeader( + label, + cimgui.c.ImGuiTreeNodeFlags_None, + ); + + // Place pop-out button inside the header bar + const header_max = cimgui.c.ImGui_GetItemRectMax(); + const header_min = cimgui.c.ImGui_GetItemRectMin(); + const frame_height = cimgui.c.ImGui_GetFrameHeight(); + const button_size = frame_height - 4; + const padding = 4; + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_SetCursorScreenPos(.{ + .x = header_max.x - button_size - padding, + .y = header_min.y + 2, + }); + { + cimgui.c.ImGui_PushStyleVarImVec2( + cimgui.c.ImGuiStyleVar_FramePadding, + .{ .x = 0, .y = 0 }, + ); + defer cimgui.c.ImGui_PopStyleVar(); + if (cimgui.c.ImGui_ButtonEx( + ">>##detach", + .{ .x = button_size, .y = button_size }, + )) { + self.detached = true; + } + } + + if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) { + cimgui.c.ImGui_SetTooltip("Detach into separate window"); + } + + return is_open; + } +}; + +pub const DetachableHeaderState = struct { + show_window: bool = false, + + /// Internal state. Don't touch. + first_show: bool = false, +}; + +/// Render a collapsing header that can be detached into its own window. +/// When detached, renders as a separate window with a close button. +/// When attached, renders as a collapsing header with a pop-out button. +pub fn detachableHeader( + label: [:0]const u8, + state: *DetachableHeaderState, + ctx: anytype, + comptime contentFn: fn (@TypeOf(ctx)) void, +) void { + cimgui.c.ImGui_PushID(label); + defer cimgui.c.ImGui_PopID(); + + if (state.show_window) { + // On first show, dock this window to the right of the parent window's dock. + // We only do this once so the user can freely reposition the window afterward + // without it snapping back to the right on every frame. + if (!state.first_show) { + state.first_show = true; + const current_dock_id = cimgui.c.ImGui_GetWindowDockID(); + if (current_dock_id != 0) { + var dock_id_right: cimgui.c.ImGuiID = 0; + var dock_id_left: cimgui.c.ImGuiID = 0; + _ = cimgui.ImGui_DockBuilderSplitNode( + current_dock_id, + cimgui.c.ImGuiDir_Right, + 0.3, + &dock_id_right, + &dock_id_left, + ); + cimgui.ImGui_DockBuilderDockWindow(label, dock_id_right); + cimgui.ImGui_DockBuilderFinish(current_dock_id); + } + } + + defer cimgui.c.ImGui_End(); + if (cimgui.c.ImGui_Begin( + label, + &state.show_window, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) contentFn(ctx); + return; + } + + // Reset first_show when window is closed so next open docks again + state.first_show = false; + + cimgui.c.ImGui_SetNextItemAllowOverlap(); + const is_open = cimgui.c.ImGui_CollapsingHeader( + label, + cimgui.c.ImGuiTreeNodeFlags_None, + ); + + // Place pop-out button inside the header bar + const header_max = cimgui.c.ImGui_GetItemRectMax(); + const header_min = cimgui.c.ImGui_GetItemRectMin(); + const frame_height = cimgui.c.ImGui_GetFrameHeight(); + const button_size = frame_height - 4; + const padding = 4; + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_SetCursorScreenPos(.{ + .x = header_max.x - button_size - padding, + .y = header_min.y + 2, + }); + cimgui.c.ImGui_PushStyleVarImVec2( + cimgui.c.ImGuiStyleVar_FramePadding, + .{ .x = 0, .y = 0 }, + ); + if (cimgui.c.ImGui_ButtonEx( + ">>##detach", + .{ .x = button_size, .y = button_size }, + )) { + state.show_window = true; + } + cimgui.c.ImGui_PopStyleVar(); + if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) { + cimgui.c.ImGui_SetTooltip("Pop out into separate window"); + } + + if (is_open) contentFn(ctx); +} diff --git a/src/inspector/widgets/key.zig b/src/inspector/widgets/key.zig new file mode 100644 index 000000000..7d7188a6b --- /dev/null +++ b/src/inspector/widgets/key.zig @@ -0,0 +1,535 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const input = @import("../../input.zig"); +const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const cimgui = @import("dcimgui"); + +/// 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. + /// Multiple bindings are possible if they are chained. + binding: []const input.Binding.Action = &.{}, + + /// 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, ev: input.KeyEvent) !Event { + var copy = ev; + copy.utf8 = ""; + if (ev.utf8.len > 0) copy.utf8 = try alloc.dupe(u8, ev.utf8); + return .{ .event = copy }; + } + + pub fn deinit(self: *const Event, alloc: Allocator) void { + alloc.free(self.binding); + 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+"); + + // Write our key. If we have an invalid key we attempt to write + // the utf8 associated with it if we have it to handle non-ascii. + try writer.writeAll(switch (self.event.key) { + .unidentified => if (self.event.utf8.len > 0) self.event.utf8 else @tagName(self.event.key), + else => @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.ImGui_BeginTable( + "##event", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + if (self.binding.len > 0) { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Triggered Binding"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + + const height: f32 = height: { + const item_count: f32 = @floatFromInt(@min(self.binding.len, 5)); + const padding = cimgui.c.ImGui_GetStyle().*.FramePadding.y * 2; + break :height cimgui.c.ImGui_GetTextLineHeightWithSpacing() * item_count + padding; + }; + if (cimgui.c.ImGui_BeginListBox("##bindings", .{ .x = 0, .y = height })) { + defer cimgui.c.ImGui_EndListBox(); + for (self.binding) |action| { + _ = cimgui.c.ImGui_SelectableEx( + @tagName(action).ptr, + false, + cimgui.c.ImGuiSelectableFlags_None, + .{ .x = 0, .y = 0 }, + ); + } + } + } + + pty: { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Encoding to Pty"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (self.pty.len == 0) { + cimgui.c.ImGui_TextDisabled("(no data)"); + break :pty; + } + + self.renderPty() catch { + cimgui.c.ImGui_TextDisabled("(error rendering pty data)"); + break :pty; + }; + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Action"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(self.event.action).ptr); + } + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Key"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(self.event.key).ptr); + } + if (!self.event.mods.empty()) { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Mods"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (self.event.mods.shift) cimgui.c.ImGui_Text("shift "); + if (self.event.mods.ctrl) cimgui.c.ImGui_Text("ctrl "); + if (self.event.mods.alt) cimgui.c.ImGui_Text("alt "); + if (self.event.mods.super) cimgui.c.ImGui_Text("super "); + } + if (self.event.composing) { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Composing"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("true"); + } + utf8: { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("UTF-8"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (self.event.utf8.len == 0) { + cimgui.c.ImGui_TextDisabled("(empty)"); + break :utf8; + } + + self.renderUtf8(self.event.utf8) catch { + cimgui.c.ImGui_TextDisabled("(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.ImGui_InputText( + "##utf8", + &buf, + buf_stream.getWritten().len - 1, + cimgui.c.ImGuiInputTextFlags_ReadOnly, + ); + } + + 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.ImGui_InputText( + "##pty", + &buf, + buf_stream.getWritten().len - 1, + cimgui.c.ImGuiInputTextFlags_ReadOnly, + ); + } +}; + +fn modsTooltip( + mods: *const input.Mods, + buf: []u8, +) ![:0]const u8 { + var stream = std.io.fixedBufferStream(buf); + const writer = stream.writer(); + var first = true; + if (mods.shift) { + try writer.writeAll("Shift"); + first = false; + } + if (mods.ctrl) { + if (!first) try writer.writeAll("+"); + try writer.writeAll("Ctrl"); + first = false; + } + if (mods.alt) { + if (!first) try writer.writeAll("+"); + try writer.writeAll("Alt"); + first = false; + } + if (mods.super) { + if (!first) try writer.writeAll("+"); + try writer.writeAll("Super"); + } + try writer.writeByte(0); + const written = stream.getWritten(); + return written[0 .. written.len - 1 :0]; +} + +/// Keyboard event stream inspector widget. +pub const Stream = struct { + events: EventRing, + + pub fn init(alloc: Allocator) !Stream { + var events: EventRing = try .init(alloc, 2); + errdefer events.deinit(alloc); + return .{ .events = events }; + } + + pub fn deinit(self: *Stream, alloc: Allocator) void { + var it = self.events.iterator(.forward); + while (it.next()) |v| v.deinit(alloc); + self.events.deinit(alloc); + } + + pub fn draw( + self: *Stream, + open: bool, + alloc: Allocator, + ) void { + if (!open) return; + + if (self.events.empty()) { + cimgui.c.ImGui_Text("No recorded key events. Press a key with the " ++ + "terminal focused to record it."); + return; + } + + if (cimgui.c.ImGui_Button("Clear")) { + var it = self.events.iterator(.forward); + while (it.next()) |v| v.deinit(alloc); + self.events.clear(); + } + + cimgui.c.ImGui_Separator(); + + const table_flags = cimgui.c.ImGuiTableFlags_Borders | + cimgui.c.ImGuiTableFlags_Resizable | + cimgui.c.ImGuiTableFlags_ScrollY | + cimgui.c.ImGuiTableFlags_SizingFixedFit; + + if (!cimgui.c.ImGui_BeginTable("table_key_events", 6, table_flags)) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupScrollFreeze(0, 1); + cimgui.c.ImGui_TableSetupColumnEx("Action", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 80, 0); + cimgui.c.ImGui_TableSetupColumnEx("Key", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 160, 0); + cimgui.c.ImGui_TableSetupColumnEx("Mods", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 150, 0); + cimgui.c.ImGui_TableSetupColumnEx("UTF-8", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 80, 0); + cimgui.c.ImGui_TableSetupColumnEx("PTY Encoding", cimgui.c.ImGuiTableColumnFlags_WidthStretch, 0, 0); + cimgui.c.ImGui_TableSetupColumnEx("Binding", cimgui.c.ImGuiTableColumnFlags_WidthStretch, 0, 0); + cimgui.c.ImGui_TableHeadersRow(); + + var it = self.events.iterator(.reverse); + while (it.next()) |ev| { + cimgui.c.ImGui_PushIDPtr(ev); + defer cimgui.c.ImGui_PopID(); + + cimgui.c.ImGui_TableNextRow(); + const row_min_y = cimgui.c.ImGui_GetCursorScreenPos().y; + + // Set row background color based on action + cimgui.c.ImGui_TableSetBgColor(cimgui.c.ImGuiTableBgTarget_RowBg0, actionColor(ev.event.action), -1); + + // Action column with colored text + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + const action_text_color: cimgui.c.ImVec4 = switch (ev.event.action) { + .press => .{ .x = 0.4, .y = 1.0, .z = 0.4, .w = 1.0 }, // Green + .release => .{ .x = 0.6, .y = 0.6, .z = 1.0, .w = 1.0 }, // Blue + .repeat => .{ .x = 1.0, .y = 1.0, .z = 0.4, .w = 1.0 }, // Yellow + }; + cimgui.c.ImGui_TextColored(action_text_color, "%s", @tagName(ev.event.action).ptr); + + // Key column with consistent key coloring + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + const key_name = switch (ev.event.key) { + .unidentified => if (ev.event.utf8.len > 0) ev.event.utf8 else @tagName(ev.event.key), + else => @tagName(ev.event.key), + }; + const key_rgba = keyColor(ev.event.key); + const key_color: cimgui.c.ImVec4 = .{ + .x = @as(f32, @floatFromInt(key_rgba & 0xFF)) / 255.0, + .y = @as(f32, @floatFromInt((key_rgba >> 8) & 0xFF)) / 255.0, + .z = @as(f32, @floatFromInt((key_rgba >> 16) & 0xFF)) / 255.0, + .w = 1.0, + }; + cimgui.c.ImGui_TextColored(key_color, "%s", key_name.ptr); + + // Composing indicator + if (ev.event.composing) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.6, .z = 0.0, .w = 1.0 }, "*"); + if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None)) { + cimgui.c.ImGui_SetTooltip("Composing (dead key)"); + } + } + + // Mods + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + mods: { + if (ev.event.mods.empty()) { + cimgui.c.ImGui_TextDisabled("-"); + break :mods; + } + + var any_hovered = false; + if (ev.event.mods.shift) { + _ = cimgui.c.ImGui_SmallButton("S"); + any_hovered = any_hovered or cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None); + cimgui.c.ImGui_SameLine(); + } + if (ev.event.mods.ctrl) { + _ = cimgui.c.ImGui_SmallButton("C"); + any_hovered = any_hovered or cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None); + cimgui.c.ImGui_SameLine(); + } + if (ev.event.mods.alt) { + _ = cimgui.c.ImGui_SmallButton("A"); + any_hovered = any_hovered or cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None); + cimgui.c.ImGui_SameLine(); + } + if (ev.event.mods.super) { + _ = cimgui.c.ImGui_SmallButton("M"); + any_hovered = any_hovered or cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None); + cimgui.c.ImGui_SameLine(); + } + cimgui.c.ImGui_NewLine(); + + if (any_hovered) tooltip: { + var tooltip_buf: [64]u8 = undefined; + const tooltip = modsTooltip( + &ev.event.mods, + &tooltip_buf, + ) catch break :tooltip; + cimgui.c.ImGui_SetTooltip("%s", tooltip.ptr); + } + } + + // UTF-8 + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + if (ev.event.utf8.len == 0) { + cimgui.c.ImGui_TextDisabled("-"); + } else { + var utf8_buf: [128]u8 = undefined; + var utf8_stream = std.io.fixedBufferStream(&utf8_buf); + const utf8_writer = utf8_stream.writer(); + if (std.unicode.Utf8View.init(ev.event.utf8)) |view| { + var utf8_it = view.iterator(); + while (utf8_it.nextCodepoint()) |cp| { + utf8_writer.print("U+{X} ", .{cp}) catch break; + } + } else |_| { + utf8_writer.writeAll("?") catch {}; + } + utf8_writer.writeByte(0) catch {}; + cimgui.c.ImGui_Text("%s", &utf8_buf); + } + + // PTY + _ = cimgui.c.ImGui_TableSetColumnIndex(4); + if (ev.pty.len == 0) { + cimgui.c.ImGui_TextDisabled("-"); + } else { + var pty_buf: [256]u8 = undefined; + var pty_stream = std.io.fixedBufferStream(&pty_buf); + const pty_writer = pty_stream.writer(); + for (ev.pty) |byte| { + if (byte == 0x1B) { + pty_writer.writeAll("ESC ") catch break; + } else if (byte > 0x20 and byte < 0x7F) { + pty_writer.writeByte(byte) catch break; + } else { + pty_writer.print("0x{X} ", .{byte}) catch break; + } + } + pty_writer.writeByte(0) catch {}; + cimgui.c.ImGui_Text("%s", &pty_buf); + } + + // Binding + _ = cimgui.c.ImGui_TableSetColumnIndex(5); + if (ev.binding.len == 0) { + cimgui.c.ImGui_TextDisabled("-"); + } else { + var binding_buf: [256]u8 = undefined; + var binding_stream = std.io.fixedBufferStream(&binding_buf); + const binding_writer = binding_stream.writer(); + for (ev.binding, 0..) |action, i| { + if (i > 0) binding_writer.writeAll(", ") catch break; + binding_writer.writeAll(@tagName(action)) catch break; + } + binding_writer.writeByte(0) catch {}; + cimgui.c.ImGui_Text("%s", &binding_buf); + } + + // Row hover highlight + const row_max_y = cimgui.c.ImGui_GetCursorScreenPos().y; + const mouse_pos = cimgui.c.ImGui_GetMousePos(); + if (mouse_pos.y >= row_min_y and mouse_pos.y < row_max_y) { + cimgui.c.ImGui_TableSetBgColor(cimgui.c.ImGuiTableBgTarget_RowBg1, 0x1AFFFFFF, -1); + } + } + } +}; + +/// Returns row background color for an action (ABGR format for ImGui) +fn actionColor(action: input.Action) u32 { + return switch (action) { + .press => 0x1A4A6F4A, // Muted sage green + .release => 0x1A6A5A5A, // Muted slate gray + .repeat => 0x1A4A5A6F, // Muted warm brown + }; +} + +/// Generate a consistent color for a key based on its enum value. +/// Uses HSV color space with fixed saturation and value for pleasing colors. +fn keyColor(key: input.Key) u32 { + const key_int: u32 = @intCast(@intFromEnum(key)); + const hue: f32 = @as(f32, @floatFromInt(key_int *% 47)) / 256.0; + return hsvToRgba(hue, 0.5, 0.9, 1.0); +} + +/// Convert HSV (hue 0-1, saturation 0-1, value 0-1) to RGBA u32. +fn hsvToRgba(h: f32, s: f32, v: f32, a: f32) u32 { + var r: f32 = undefined; + var g: f32 = undefined; + var b: f32 = undefined; + + const i: u32 = @intFromFloat(h * 6.0); + const f = h * 6.0 - @as(f32, @floatFromInt(i)); + const p = v * (1.0 - s); + const q = v * (1.0 - f * s); + const t = v * (1.0 - (1.0 - f) * s); + + switch (i % 6) { + 0 => { + r = v; + g = t; + b = p; + }, + 1 => { + r = q; + g = v; + b = p; + }, + 2 => { + r = p; + g = v; + b = t; + }, + 3 => { + r = p; + g = q; + b = v; + }, + 4 => { + r = t; + g = p; + b = v; + }, + else => { + r = v; + g = p; + b = q; + }, + } + + const ri: u32 = @intFromFloat(r * 255.0); + const gi: u32 = @intFromFloat(g * 255.0); + const bi: u32 = @intFromFloat(b * 255.0); + const ai: u32 = @intFromFloat(a * 255.0); + + return (ai << 24) | (bi << 16) | (gi << 8) | ri; +} diff --git a/src/inspector/widgets/page.zig b/src/inspector/widgets/page.zig new file mode 100644 index 000000000..844abc355 --- /dev/null +++ b/src/inspector/widgets/page.zig @@ -0,0 +1,428 @@ +const std = @import("std"); +const cimgui = @import("dcimgui"); +const terminal = @import("../../terminal/main.zig"); +const units = @import("../units.zig"); +const widgets = @import("../widgets.zig"); + +const PageList = terminal.PageList; +const Page = terminal.Page; + +pub fn inspector(page: *const terminal.Page) void { + cimgui.c.ImGui_SeparatorText("Managed Memory"); + managedMemory(page); + + cimgui.c.ImGui_SeparatorText("Styles"); + stylesList(page); + + cimgui.c.ImGui_SeparatorText("Hyperlinks"); + hyperlinksList(page); + + cimgui.c.ImGui_SeparatorText("Rows"); + rowsTable(page); +} + +/// Draw a tree node header with metadata about this page. Returns if +/// the tree node is open or not. If it is open you must close it with +/// TreePop. +pub fn treeNode(state: struct { + /// The page + page: *const terminal.Page, + /// The index of the page in a page list, used for headers. + index: usize, + /// The range of rows this page covers, inclusive. + row_range: [2]usize, + /// Whether this page is the active or viewport node. + active: bool, + viewport: bool, +}) bool { + // Setup our node. + const open = open: { + var label_buf: [160]u8 = undefined; + const label = std.fmt.bufPrintZ( + &label_buf, + "Page {d}", + .{state.index}, + ) catch "Page"; + + const flags = cimgui.c.ImGuiTreeNodeFlags_AllowOverlap | + cimgui.c.ImGuiTreeNodeFlags_SpanFullWidth | + cimgui.c.ImGuiTreeNodeFlags_FramePadding; + break :open cimgui.c.ImGui_TreeNodeEx(label.ptr, flags); + }; + + // Move our cursor into the tree header so we can add extra info. + const header_min = cimgui.c.ImGui_GetItemRectMin(); + const header_max = cimgui.c.ImGui_GetItemRectMax(); + const header_height = header_max.y - header_min.y; + const text_line = cimgui.c.ImGui_GetTextLineHeight(); + const y_center = header_min.y + (header_height - text_line) * 0.5; + cimgui.c.ImGui_SetCursorScreenPos(.{ .x = header_min.x + 170, .y = y_center }); + + // Metadata + cimgui.c.ImGui_TextDisabled( + "%dc x %dr", + state.page.size.cols, + state.page.size.rows, + ); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("rows %d..%d", state.row_range[0], state.row_range[1]); + + // Labels + if (state.active) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.9, .z = 0.4, .w = 1.0 }, "active"); + } + if (state.viewport) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "viewport"); + } + if (state.page.isDirty()) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + } + + return open; +} + +pub fn managedMemory(page: *const Page) void { + if (cimgui.c.ImGui_BeginTable( + "##overview", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) { + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Size"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker( + "Memory allocated for this page. Note the backing memory " ++ + "may be a larger allocation from which this page " ++ + "uses a portion.", + ); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text( + "%d KiB", + units.toKibiBytes(page.memory.len), + ); + } + + if (cimgui.c.ImGui_BeginTable( + "##managed", + 4, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) { + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn("Resource", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Used", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Capacity", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableHeadersRow(); + + const size = page.size; + const cap = page.capacity; + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Columns"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Number of columns in the terminal grid."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", size.cols); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", cap.cols); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Rows"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Number of rows in this page."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", size.rows); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", cap.rows); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Styles"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Unique text styles (colors, attributes) currently in use."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", page.styles.count()); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", page.styles.layout.cap); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Graphemes"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Extended grapheme clusters for multi-codepoint characters."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", page.graphemeCount()); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", page.graphemeCapacity()); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Strings (bytes)"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("String storage for hyperlink URIs and other text data."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", page.string_alloc.usedBytes(page.memory)); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", page.string_alloc.capacityBytes()); + + const hyperlink_map = page.hyperlink_map.map(page.memory); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hyperlink Map"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Maps cell positions to hyperlink IDs."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", hyperlink_map.count()); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", hyperlink_map.capacity()); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hyperlink IDs"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Unique hyperlink definitions (URI + optional ID)."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", page.hyperlink_set.count()); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", page.hyperlink_set.layout.cap); + } +} + +fn rowsTable(page: *const terminal.Page) void { + const visible_rows: usize = @min(page.size.rows, 12); + const row_height: f32 = cimgui.c.ImGui_GetTextLineHeightWithSpacing(); + const child_height: f32 = row_height * (@as(f32, @floatFromInt(visible_rows)) + 2.0); + + // Child window so scrolling is separate. + // This defer first is not a bug, EndChild always needs to be called. + defer cimgui.c.ImGui_EndChild(); + if (!cimgui.c.ImGui_BeginChild( + "##page_rows", + .{ .x = 0.0, .y = child_height }, + cimgui.c.ImGuiChildFlags_Borders, + cimgui.c.ImGuiWindowFlags_None, + )) return; + + if (!cimgui.c.ImGui_BeginTable( + "##page_rows_table", + 10, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupScrollFreeze(0, 1); + cimgui.c.ImGui_TableSetupColumn("Row", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Text", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Dirty", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Wrap", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Cont", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Styled", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Grapheme", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Link", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Prompt", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Kitty", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableHeadersRow(); + + const rows = page.rows.ptr(page.memory)[0..page.size.rows]; + for (rows, 0..) |*row, row_index| { + var text_cells: usize = 0; + const cells = page.getCells(row); + for (cells) |cell| { + if (cell.hasText()) { + text_cells += 1; + } + } + + cimgui.c.ImGui_TableNextRow(); + + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%d", row_index); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (text_cells == 0) { + cimgui.c.ImGui_TextDisabled("0"); + } else { + cimgui.c.ImGui_Text("%d", text_cells); + } + + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + flagCell(row.dirty); + + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + flagCell(row.wrap); + + _ = cimgui.c.ImGui_TableSetColumnIndex(4); + flagCell(row.wrap_continuation); + + _ = cimgui.c.ImGui_TableSetColumnIndex(5); + flagCell(row.styled); + + _ = cimgui.c.ImGui_TableSetColumnIndex(6); + flagCell(row.grapheme); + + _ = cimgui.c.ImGui_TableSetColumnIndex(7); + flagCell(row.hyperlink); + + _ = cimgui.c.ImGui_TableSetColumnIndex(8); + cimgui.c.ImGui_Text("%s", @tagName(row.semantic_prompt).ptr); + + _ = cimgui.c.ImGui_TableSetColumnIndex(9); + flagCell(row.kitty_virtual_placeholder); + } +} + +fn stylesList(page: *const Page) void { + const items = page.styles.items.ptr(page.memory)[0..page.styles.layout.cap]; + + var count: usize = 0; + for (items, 0..) |item, index| { + if (index == 0) continue; + if (item.meta.ref == 0) continue; + count += 1; + } + + if (count == 0) { + cimgui.c.ImGui_TextDisabled("(no styles in use)"); + return; + } + + const visible_rows: usize = @min(count, 8); + const row_height: f32 = cimgui.c.ImGui_GetTextLineHeightWithSpacing(); + const child_height: f32 = row_height * (@as(f32, @floatFromInt(visible_rows)) + 2.0); + + defer cimgui.c.ImGui_EndChild(); + if (!cimgui.c.ImGui_BeginChild( + "##page_styles", + .{ .x = 0.0, .y = child_height }, + cimgui.c.ImGuiChildFlags_Borders, + cimgui.c.ImGuiWindowFlags_None, + )) return; + + if (!cimgui.c.ImGui_BeginTable( + "##page_styles_table", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupScrollFreeze(0, 1); + cimgui.c.ImGui_TableSetupColumn("ID", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Refs", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Style", cimgui.c.ImGuiTableColumnFlags_WidthStretch); + cimgui.c.ImGui_TableHeadersRow(); + + for (items, 0..) |item, index| { + if (index == 0) continue; + if (item.meta.ref == 0) continue; + + cimgui.c.ImGui_TableNextRow(); + cimgui.c.ImGui_PushIDInt(@intCast(index)); + defer cimgui.c.ImGui_PopID(); + + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%d", index); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", item.meta.ref); + + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + if (cimgui.c.ImGui_TreeNodeEx("Details", cimgui.c.ImGuiTreeNodeFlags_None)) { + defer cimgui.c.ImGui_TreePop(); + widgets.style.table(item.value, null); + } + } +} + +fn hyperlinksList(page: *const Page) void { + const items = page.hyperlink_set.items.ptr(page.memory)[0..page.hyperlink_set.layout.cap]; + + var count: usize = 0; + for (items, 0..) |item, index| { + if (index == 0) continue; + if (item.meta.ref == 0) continue; + count += 1; + } + + if (count == 0) { + cimgui.c.ImGui_TextDisabled("(no hyperlinks in use)"); + return; + } + + const visible_rows: usize = @min(count, 8); + const row_height: f32 = cimgui.c.ImGui_GetTextLineHeightWithSpacing(); + const child_height: f32 = row_height * (@as(f32, @floatFromInt(visible_rows)) + 2.0); + + defer cimgui.c.ImGui_EndChild(); + if (!cimgui.c.ImGui_BeginChild( + "##page_hyperlinks", + .{ .x = 0.0, .y = child_height }, + cimgui.c.ImGuiChildFlags_Borders, + cimgui.c.ImGuiWindowFlags_None, + )) return; + + if (!cimgui.c.ImGui_BeginTable( + "##page_hyperlinks_table", + 4, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupScrollFreeze(0, 1); + cimgui.c.ImGui_TableSetupColumn("ID", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Refs", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Explicit ID", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("URI", cimgui.c.ImGuiTableColumnFlags_WidthStretch); + cimgui.c.ImGui_TableHeadersRow(); + + for (items, 0..) |item, index| { + if (index == 0) continue; + if (item.meta.ref == 0) continue; + + cimgui.c.ImGui_TableNextRow(); + + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%d", index); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", item.meta.ref); + + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + switch (item.value.id) { + .explicit => |slice| { + const explicit_id = slice.slice(page.memory); + cimgui.c.ImGui_Text("%.*s", explicit_id.len, explicit_id.ptr); + }, + .implicit => cimgui.c.ImGui_TextDisabled("-"), + } + + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + const uri = item.value.uri.slice(page.memory); + cimgui.c.ImGui_Text("%.*s", uri.len, uri.ptr); + } +} + +fn flagCell(value: bool) void { + if (value) { + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.9, .z = 0.4, .w = 1.0 }, "yes"); + } else { + cimgui.c.ImGui_TextDisabled("-"); + } +} diff --git a/src/inspector/widgets/pagelist.zig b/src/inspector/widgets/pagelist.zig new file mode 100644 index 000000000..bedbfb599 --- /dev/null +++ b/src/inspector/widgets/pagelist.zig @@ -0,0 +1,852 @@ +const std = @import("std"); +const cimgui = @import("dcimgui"); +const terminal = @import("../../terminal/main.zig"); +const stylepkg = @import("../../terminal/style.zig"); +const widgets = @import("../widgets.zig"); +const units = @import("../units.zig"); + +const PageList = terminal.PageList; + +/// PageList inspector widget. +pub const Inspector = struct { + pub const empty: Inspector = .{}; + + pub fn draw(_: *const Inspector, pages: *PageList) void { + cimgui.c.ImGui_TextWrapped( + "PageList manages the backing pages that hold scrollback and the active " ++ + "terminal grid. Each page is a contiguous memory buffer with its " ++ + "own rows, cells, style set, grapheme map, and hyperlink storage.", + ); + + if (cimgui.c.ImGui_CollapsingHeader( + "Overview", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + summaryTable(pages); + } + + if (cimgui.c.ImGui_CollapsingHeader( + "Scrollbar & Regions", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + cimgui.c.ImGui_SeparatorText("Scrollbar"); + scrollbarInfo(pages); + cimgui.c.ImGui_SeparatorText("Regions"); + regionsTable(pages); + } + + if (cimgui.c.ImGui_CollapsingHeader( + "Tracked Pins", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + trackedPinsTable(pages); + } + + if (cimgui.c.ImGui_CollapsingHeader( + "Pages", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + widgets.helpMarker( + "Pages are shown most-recent first. Each page holds a grid of rows/cells " ++ + "plus metadata tables for styles, graphemes, strings, and hyperlinks.", + ); + + const active_pin = pages.getTopLeft(.active); + const viewport_pin = pages.getTopLeft(.viewport); + + var row_offset = pages.total_rows; + var index: usize = pages.totalPages(); + var node = pages.pages.last; + while (node) |page_node| : (node = page_node.prev) { + const page = &page_node.data; + row_offset -= page.size.rows; + index -= 1; + + // We use our location as the ID so that even if reallocations + // happen we remain open if we're open already. + cimgui.c.ImGui_PushIDInt(@intCast(index)); + defer cimgui.c.ImGui_PopID(); + + // Open up the tree node. + if (!widgets.page.treeNode(.{ + .page = page, + .index = index, + .row_range = .{ row_offset, row_offset + page.size.rows - 1 }, + .active = node == active_pin.node, + .viewport = node == viewport_pin.node, + })) continue; + defer cimgui.c.ImGui_TreePop(); + widgets.page.inspector(page); + } + } + } +}; + +fn summaryTable(pages: *const PageList) void { + if (!cimgui.c.ImGui_BeginTable( + "pagelist_summary", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Active Grid"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Active viewport size in columns x rows."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%dc x %dr", pages.cols, pages.rows); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Pages"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Total number of pages in the linked list."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", pages.totalPages()); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Total Rows"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Total rows represented by scrollback + active area."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", pages.total_rows); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Page Bytes"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Total bytes allocated for active pages."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text( + "%d KiB", + units.toKibiBytes(pages.page_size), + ); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Max Size"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker( + \\Maximum bytes before pages must be evicated. The total + \\used bytes may be higher due to minimum individual page + \\sizes but the next allocation that would exceed this limit + \\will evict pages from the front of the list to free up space. + ); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text( + "%d KiB", + units.toKibiBytes(pages.maxSize()), + ); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Viewport"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Current viewport anchoring mode."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Tracked Pins"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Number of pins tracked for automatic updates."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", pages.countTrackedPins()); +} + +fn scrollbarInfo(pages: *PageList) void { + const scrollbar = pages.scrollbar(); + + // If we have a scrollbar, show it. + if (scrollbar.total > 0) { + var delta_row: isize = 0; + scrollbarWidget(&scrollbar, &delta_row); + if (delta_row != 0) { + pages.scroll(.{ .delta_row = delta_row }); + } + } + + if (!cimgui.c.ImGui_BeginTable( + "scrollbar_info", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Total"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Total number of scrollable rows including scrollback and active area."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", scrollbar.total); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Offset"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Current scroll position as row offset from the top of scrollback."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", scrollbar.offset); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Length"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Number of rows visible in the viewport."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", scrollbar.len); +} + +fn regionsTable(pages: *PageList) void { + if (!cimgui.c.ImGui_BeginTable( + "pagelist_regions", + 4, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn("Region", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Top-Left", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Bottom-Right", cimgui.c.ImGuiTableColumnFlags_WidthStretch); + cimgui.c.ImGui_TableHeadersRow(); + + inline for (comptime std.meta.tags(terminal.point.Tag)) |tag| { + regionRow(pages, tag); + } +} + +fn regionRow(pages: *const PageList, comptime tag: terminal.point.Tag) void { + const tl_pin = pages.getTopLeft(tag); + const br_pin = pages.getBottomRight(tag); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%s", @tagName(tag).ptr); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker(comptime regionHelpText(tag)); + + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + if (pages.pointFromPin(tag, tl_pin)) |pt| { + const coord = pt.coord(); + cimgui.c.ImGui_Text("(%d, %d)", coord.x, coord.y); + } else { + cimgui.c.ImGui_TextDisabled("(n/a)"); + } + + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + if (br_pin) |br| { + if (pages.pointFromPin(tag, br)) |pt| { + const coord = pt.coord(); + cimgui.c.ImGui_Text("(%d, %d)", coord.x, coord.y); + } else { + cimgui.c.ImGui_TextDisabled("(n/a)"); + } + } else { + cimgui.c.ImGui_TextDisabled("(empty)"); + } +} + +fn regionHelpText(comptime tag: terminal.point.Tag) [:0]const u8 { + return switch (tag) { + .active => "The active area where a running program can jump the cursor " ++ + "and make changes. This is the 'editable' part of the screen. " ++ + "Bottom-right includes the full height of the screen, including " ++ + "rows that may not be written yet.", + .viewport => "The visible viewport. If the user has scrolled, top-left changes. " ++ + "Bottom-right is the last written row from the top-left.", + .screen => "Top-left is the furthest back in scrollback history. Bottom-right " ++ + "is the last written row. Unlike 'active', this only contains " ++ + "written rows.", + .history => "Same top-left as 'screen' but bottom-right is the line just before " ++ + "the top of 'active'. Contains only the scrollback history.", + }; +} + +fn trackedPinsTable(pages: *const PageList) void { + if (!cimgui.c.ImGui_BeginTable( + "tracked_pins", + 5, + cimgui.c.ImGuiTableFlags_Borders | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn("Index", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Pin", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Context", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Dirty", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("State", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableHeadersRow(); + + const active_pin = pages.getTopLeft(.active); + const viewport_pin = pages.getTopLeft(.viewport); + + for (pages.trackedPins(), 0..) |tracked, idx| { + const pin = tracked.*; + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%d", idx); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (pin.garbage) { + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.5, .z = 0.3, .w = 1.0 }, "(%d, %d)", pin.x, pin.y); + } else { + cimgui.c.ImGui_Text("(%d, %d)", pin.x, pin.y); + } + + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + if (pages.pointFromPin(.screen, pin)) |pt| { + const coord = pt.coord(); + cimgui.c.ImGui_Text( + "screen (%d, %d)", + coord.x, + coord.y, + ); + } else { + cimgui.c.ImGui_TextDisabled("screen (out of range)"); + } + + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + const dirty = pin.isDirty(); + if (dirty) { + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + } else { + cimgui.c.ImGui_TextDisabled("clean"); + } + + _ = cimgui.c.ImGui_TableSetColumnIndex(4); + if (pin.eql(active_pin)) { + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.9, .z = 0.4, .w = 1.0 }, "active top"); + } else if (pin.eql(viewport_pin)) { + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "viewport top"); + } else if (pin.garbage) { + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.5, .z = 0.3, .w = 1.0 }, "garbage"); + } else if (tracked == pages.viewport_pin) { + cimgui.c.ImGui_Text("viewport pin"); + } else { + cimgui.c.ImGui_TextDisabled("tracked"); + } + } +} + +fn scrollbarWidget( + scrollbar: *const PageList.Scrollbar, + delta_row: *isize, +) void { + delta_row.* = 0; + + const avail_width = cimgui.c.ImGui_GetContentRegionAvail().x; + const bar_height: f32 = cimgui.c.ImGui_GetFrameHeight(); + const cursor_pos = cimgui.c.ImGui_GetCursorScreenPos(); + + const total_f: f32 = @floatFromInt(scrollbar.total); + const offset_f: f32 = @floatFromInt(scrollbar.offset); + const len_f: f32 = @floatFromInt(scrollbar.len); + + const grab_start = (offset_f / total_f) * avail_width; + const grab_width = @max((len_f / total_f) * avail_width, 4.0); + + const draw_list = cimgui.c.ImGui_GetWindowDrawList(); + const bg_color = cimgui.c.ImGui_GetColorU32(cimgui.c.ImGuiCol_ScrollbarBg); + const grab_color = cimgui.c.ImGui_GetColorU32(cimgui.c.ImGuiCol_ScrollbarGrab); + + const bg_min: cimgui.c.ImVec2 = cursor_pos; + const bg_max: cimgui.c.ImVec2 = .{ .x = cursor_pos.x + avail_width, .y = cursor_pos.y + bar_height }; + cimgui.c.ImDrawList_AddRectFilledEx( + draw_list, + bg_min, + bg_max, + bg_color, + 0, + 0, + ); + + const grab_min: cimgui.c.ImVec2 = .{ + .x = cursor_pos.x + grab_start, + .y = cursor_pos.y, + }; + const grab_max: cimgui.c.ImVec2 = .{ + .x = cursor_pos.x + grab_start + grab_width, + .y = cursor_pos.y + bar_height, + }; + cimgui.c.ImDrawList_AddRectFilledEx( + draw_list, + grab_min, + grab_max, + grab_color, + 0, + 0, + ); + _ = cimgui.c.ImGui_InvisibleButton( + "scrollbar_drag", + .{ .x = avail_width, .y = bar_height }, + 0, + ); + if (cimgui.c.ImGui_IsItemActive()) { + const drag_delta = cimgui.c.ImGui_GetMouseDragDelta( + cimgui.c.ImGuiMouseButton_Left, + 0.0, + ); + if (drag_delta.x != 0) { + const row_delta = (drag_delta.x / avail_width) * total_f; + delta_row.* = @intFromFloat(row_delta); + cimgui.c.ImGui_ResetMouseDragDelta(); + } + } + + if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) { + cimgui.c.ImGui_SetTooltip( + "offset=%d len=%d total=%d", + scrollbar.offset, + scrollbar.len, + scrollbar.total, + ); + } +} + +/// Grid inspector widget for choosing and inspecting a specific cell. +pub const CellChooser = struct { + lookup_region: terminal.point.Tag, + lookup_coord: terminal.point.Coordinate, + cell_info: CellInfo, + + pub const empty: CellChooser = .{ + .lookup_region = .viewport, + .lookup_coord = .{ .x = 0, .y = 0 }, + .cell_info = .empty, + }; + + pub fn draw( + self: *CellChooser, + pages: *const PageList, + ) void { + cimgui.c.ImGui_TextWrapped( + "Inspect a cell by choosing a coordinate space and entering the X/Y position. " ++ + "The inspector resolves the point into the page list and displays the cell contents.", + ); + + cimgui.c.ImGui_SeparatorText("Cell Inspector"); + + const region_max = maxCoord(pages, self.lookup_region); + if (region_max) |coord| { + self.lookup_coord.x = @min(self.lookup_coord.x, coord.x); + self.lookup_coord.y = @min(self.lookup_coord.y, coord.y); + } else { + self.lookup_coord = .{ .x = 0, .y = 0 }; + } + + { + const disabled = region_max == null; + cimgui.c.ImGui_BeginDisabled(disabled); + defer cimgui.c.ImGui_EndDisabled(); + + const preview = @tagName(self.lookup_region); + const combo_width = comptime blk: { + var max_len: usize = 0; + for (std.meta.tags(terminal.point.Tag)) |tag| { + max_len = @max(max_len, @tagName(tag).len); + } + break :blk max_len + 4; + }; + cimgui.c.ImGui_SetNextItemWidth(cimgui.c.ImGui_CalcTextSize("X" ** combo_width).x); + if (cimgui.c.ImGui_BeginCombo( + "##grid_region", + preview.ptr, + cimgui.c.ImGuiComboFlags_HeightSmall, + )) { + inline for (comptime std.meta.tags(terminal.point.Tag)) |tag| { + const selected = tag == self.lookup_region; + if (cimgui.c.ImGui_SelectableEx( + @tagName(tag).ptr, + selected, + cimgui.c.ImGuiSelectableFlags_None, + .{ .x = 0, .y = 0 }, + )) { + self.lookup_region = tag; + } + if (selected) cimgui.c.ImGui_SetItemDefaultFocus(); + } + cimgui.c.ImGui_EndCombo(); + } + + cimgui.c.ImGui_SameLine(); + + const width = cimgui.c.ImGui_CalcTextSize("00000").x; + var x_value: terminal.size.CellCountInt = self.lookup_coord.x; + var y_value: u32 = self.lookup_coord.y; + var changed = false; + + cimgui.c.ImGui_AlignTextToFramePadding(); + cimgui.c.ImGui_Text("x:"); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_SetNextItemWidth(width); + if (cimgui.c.ImGui_InputScalar( + "##grid_x", + cimgui.c.ImGuiDataType_U16, + &x_value, + )) changed = true; + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_AlignTextToFramePadding(); + cimgui.c.ImGui_Text("y:"); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_SetNextItemWidth(width); + if (cimgui.c.ImGui_InputScalar( + "##grid_y", + cimgui.c.ImGuiDataType_U32, + &y_value, + )) changed = true; + + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Choose the coordinate space and X/Y position (0-indexed)."); + + if (changed) { + if (region_max) |coord| { + self.lookup_coord.x = @min(x_value, coord.x); + self.lookup_coord.y = @min(y_value, coord.y); + } + } + } + + if (region_max) |coord| { + cimgui.c.ImGui_TextDisabled( + "Range: x 0..%d, y 0..%d", + coord.x, + coord.y, + ); + } else { + cimgui.c.ImGui_TextDisabled("(region has no rows)"); + return; + } + + const pt = switch (self.lookup_region) { + .active => terminal.Point{ .active = self.lookup_coord }, + .viewport => terminal.Point{ .viewport = self.lookup_coord }, + .screen => terminal.Point{ .screen = self.lookup_coord }, + .history => terminal.Point{ .history = self.lookup_coord }, + }; + + const cell = pages.getCell(pt) orelse { + cimgui.c.ImGui_TextDisabled("(cell out of range)"); + return; + }; + + self.cell_info.draw(cell, pt); + + if (cell.cell.style_id != stylepkg.default_id) { + cimgui.c.ImGui_SeparatorText("Style"); + const style = cell.node.data.styles.get( + cell.node.data.memory, + cell.cell.style_id, + ).*; + widgets.style.table(style, null); + } + + if (cell.cell.hyperlink) { + cimgui.c.ImGui_SeparatorText("Hyperlink"); + hyperlinkTable(cell); + } + + if (cell.cell.hasGrapheme()) { + cimgui.c.ImGui_SeparatorText("Grapheme"); + graphemeTable(cell); + } + } +}; + +fn maxCoord( + pages: *const PageList, + tag: terminal.point.Tag, +) ?terminal.point.Coordinate { + const br_pin = pages.getBottomRight(tag) orelse return null; + const br_point = pages.pointFromPin(tag, br_pin) orelse return null; + return br_point.coord(); +} + +fn hyperlinkTable(cell: PageList.Cell) void { + if (!cimgui.c.ImGui_BeginTable( + "cell_hyperlink", + 2, + cimgui.c.ImGuiTableFlags_None, + )) return; + defer cimgui.c.ImGui_EndTable(); + + const page = &cell.node.data; + const link_id = page.lookupHyperlink(cell.cell) orelse { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Status"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_TextDisabled("(missing link data)"); + return; + }; + + const entry = page.hyperlink_set.get(page.memory, link_id); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("ID"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + switch (entry.id) { + .implicit => |value| cimgui.c.ImGui_Text("implicit %d", value), + .explicit => |slice| { + const id = slice.slice(page.memory); + if (id.len == 0) { + cimgui.c.ImGui_TextDisabled("(empty)"); + } else { + cimgui.c.ImGui_Text("%.*s", id.len, id.ptr); + } + }, + } + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("URI"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + const uri = entry.uri.slice(page.memory); + if (uri.len == 0) { + cimgui.c.ImGui_TextDisabled("(empty)"); + } else { + cimgui.c.ImGui_Text("%.*s", uri.len, uri.ptr); + } + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Ref Count"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + const refs = page.hyperlink_set.refCount(page.memory, link_id); + cimgui.c.ImGui_Text("%d", refs); +} + +fn graphemeTable(cell: PageList.Cell) void { + if (!cimgui.c.ImGui_BeginTable( + "cell_grapheme", + 2, + cimgui.c.ImGuiTableFlags_None, + )) return; + defer cimgui.c.ImGui_EndTable(); + + const page = &cell.node.data; + const cps = page.lookupGrapheme(cell.cell) orelse { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Status"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_TextDisabled("(missing grapheme data)"); + return; + }; + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Extra Codepoints"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (cps.len == 0) { + cimgui.c.ImGui_TextDisabled("(none)"); + return; + } + + var buf: [96]u8 = undefined; + if (cimgui.c.ImGui_BeginListBox("##grapheme_list", .{ .x = 0, .y = 0 })) { + defer cimgui.c.ImGui_EndListBox(); + for (cps) |cp| { + const label = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch "U+?"; + _ = cimgui.c.ImGui_SelectableEx( + label.ptr, + false, + cimgui.c.ImGuiSelectableFlags_None, + .{ .x = 0, .y = 0 }, + ); + } + } +} + +/// Cell inspector widget. +pub const CellInfo = struct { + pub const empty: CellInfo = .{}; + + pub fn draw( + _: *const CellInfo, + cell: PageList.Cell, + point: terminal.Point, + ) void { + if (!cimgui.c.ImGui_BeginTable( + "cell_info", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grid Position"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("The cell's X/Y coordinates in the selected region."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + const coord = point.coord(); + cimgui.c.ImGui_Text("(%d, %d)", coord.x, coord.y); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Page Location"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Row and column indices within the backing page."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("row=%d col=%d", cell.row_idx, cell.col_idx); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Content"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Content tag describing how the cell data is stored."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%s", @tagName(cell.cell.content_tag).ptr); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Codepoint"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Primary Unicode codepoint for the cell."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + const cp = cell.cell.codepoint(); + if (cp == 0) { + cimgui.c.ImGui_TextDisabled("(empty)"); + } else { + cimgui.c.ImGui_Text("U+%04X", @as(u32, cp)); + } + } + + if (cell.cell.hasGrapheme()) { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grapheme"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Extra codepoints that combine with the primary codepoint to form the grapheme cluster."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + if (cimgui.c.ImGui_BeginListBox("##cell_grapheme", .{ .x = 0, .y = 0 })) { + defer cimgui.c.ImGui_EndListBox(); + if (cell.node.data.lookupGrapheme(cell.cell)) |cps| { + var buf: [96]u8 = undefined; + for (cps) |cp| { + const label = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch "U+?"; + _ = cimgui.c.ImGui_SelectableEx( + label.ptr, + false, + cimgui.c.ImGuiSelectableFlags_None, + .{ .x = 0, .y = 0 }, + ); + } + } else { + _ = cimgui.c.ImGui_SelectableEx( + "(missing)", + false, + cimgui.c.ImGuiSelectableFlags_None, + .{ .x = 0, .y = 0 }, + ); + } + } + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Width Property"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Character width property (narrow, wide, spacer, etc.)."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%s", @tagName(cell.cell.wide).ptr); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Row Flags"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Flags set on the row containing this cell."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + const row = cell.row; + if (row.wrap or row.wrap_continuation or row.grapheme or row.styled or row.hyperlink) { + if (row.wrap) { + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "wrap"); + cimgui.c.ImGui_SameLine(); + } + if (row.wrap_continuation) { + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "cont"); + cimgui.c.ImGui_SameLine(); + } + if (row.grapheme) { + cimgui.c.ImGui_TextColored(.{ .x = 0.9, .y = 0.7, .z = 0.3, .w = 1.0 }, "grapheme"); + cimgui.c.ImGui_SameLine(); + } + if (row.styled) { + cimgui.c.ImGui_TextColored(.{ .x = 0.7, .y = 0.9, .z = 0.5, .w = 1.0 }, "styled"); + cimgui.c.ImGui_SameLine(); + } + if (row.hyperlink) { + cimgui.c.ImGui_TextColored(.{ .x = 0.8, .y = 0.6, .z = 1.0, .w = 1.0 }, "link"); + cimgui.c.ImGui_SameLine(); + } + } else { + cimgui.c.ImGui_TextDisabled("(none)"); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Style ID"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Internal style reference ID for this cell."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", cell.cell.style_id); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Style"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Resolved style for the cell (colors, attributes, etc.)."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + if (cell.cell.style_id == stylepkg.default_id) { + cimgui.c.ImGui_TextDisabled("(default)"); + } else { + cimgui.c.ImGui_TextDisabled("(see below)"); + } + } + + if (cell.cell.hyperlink) { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hyperlink"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("OSC8 hyperlink ID associated with this cell."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + + const link_id = cell.node.data.lookupHyperlink(cell.cell) orelse 0; + cimgui.c.ImGui_Text("id=%d", link_id); + } + } +}; diff --git a/src/inspector/widgets/renderer.zig b/src/inspector/widgets/renderer.zig new file mode 100644 index 000000000..3c6492dfe --- /dev/null +++ b/src/inspector/widgets/renderer.zig @@ -0,0 +1,71 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const cimgui = @import("dcimgui"); +const widgets = @import("../widgets.zig"); +const renderer = @import("../../renderer.zig"); + +const log = std.log.scoped(.inspector_renderer); + +/// Renderer information inspector widget. +pub const Info = struct { + features: std.AutoArrayHashMapUnmanaged( + std.meta.Tag(renderer.Overlay.Feature), + renderer.Overlay.Feature, + ), + + pub const empty: Info = .{ + .features = .empty, + }; + + pub fn deinit(self: *Info, alloc: Allocator) void { + self.features.deinit(alloc); + } + + /// Grab the features into a new allocated slice. This is used by + pub fn overlayFeatures( + self: *const Info, + alloc: Allocator, + ) Allocator.Error![]renderer.Overlay.Feature { + // The features from our internal state. + const features = self.features.values(); + + // For now we do a dumb copy since the features have no managed + // memory. + const result = try alloc.dupe( + renderer.Overlay.Feature, + features, + ); + errdefer alloc.free(result); + + return result; + } + + /// Draw the renderer info window. + pub fn draw( + self: *Info, + alloc: Allocator, + open: bool, + ) void { + if (!open) return; + + cimgui.c.ImGui_SeparatorText("Overlays"); + + // Hyperlinks + { + var hyperlinks: bool = self.features.contains(.highlight_hyperlinks); + _ = cimgui.c.ImGui_Checkbox("Overlay Hyperlinks", &hyperlinks); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("When enabled, highlights OSC8 hyperlinks."); + + if (!hyperlinks) { + _ = self.features.swapRemove(.highlight_hyperlinks); + } else { + self.features.put( + alloc, + .highlight_hyperlinks, + .highlight_hyperlinks, + ) catch log.warn("error enabling hyperlink overlay feature", .{}); + } + } + } +}; diff --git a/src/inspector/widgets/screen.zig b/src/inspector/widgets/screen.zig new file mode 100644 index 000000000..9365158a1 --- /dev/null +++ b/src/inspector/widgets/screen.zig @@ -0,0 +1,355 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const cimgui = @import("dcimgui"); +const widgets = @import("../widgets.zig"); +const units = @import("../units.zig"); +const terminal = @import("../../terminal/main.zig"); +const stylepkg = @import("../../terminal/style.zig"); + +/// Window names for the screen dockspace. +const window_info = "Info"; +const window_cell = "Cell"; +const window_pagelist = "PageList"; + +/// Screen information inspector widget. +pub const Info = struct { + pagelist: widgets.pagelist.Inspector, + cell_chooser: widgets.pagelist.CellChooser, + + pub const empty: Info = .{ + .pagelist = .empty, + .cell_chooser = .empty, + }; + + /// Draw the screen info contents. + pub fn draw(self: *Info, open: bool, data: struct { + /// The screen that we're inspecting. + screen: *terminal.Screen, + + /// Which screen key we're viewing. + key: terminal.ScreenSet.Key, + + /// Which screen is active (primary or alternate). + active_key: terminal.ScreenSet.Key, + + /// Whether xterm modify other keys mode 2 is enabled. + modify_other_keys_2: bool, + + /// Color palette for cursor color resolution. + color_palette: *const terminal.color.DynamicPalette, + }) void { + // Create the dockspace for this screen + const dockspace_id = cimgui.c.ImGui_GetID("Screen Dockspace"); + _ = createDockSpace(dockspace_id); + + const screen = data.screen; + + // Info window + info: { + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( + window_info, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) break :info; + + if (cimgui.c.ImGui_CollapsingHeader( + "Cursor", + cimgui.c.ImGuiTreeNodeFlags_None, + )) { + cursorTable(&screen.cursor); + cimgui.c.ImGui_Separator(); + cursorStyle( + &screen.cursor, + &data.color_palette.current, + ); + } + + if (cimgui.c.ImGui_CollapsingHeader( + "Keyboard", + cimgui.c.ImGuiTreeNodeFlags_None, + )) keyboardTable( + screen, + data.modify_other_keys_2, + ); + + if (cimgui.c.ImGui_CollapsingHeader( + "Kitty Graphics", + cimgui.c.ImGuiTreeNodeFlags_None, + )) kittyGraphicsTable(&screen.kitty_images); + + if (cimgui.c.ImGui_CollapsingHeader( + "Internal Terminal State", + cimgui.c.ImGuiTreeNodeFlags_None, + )) internalStateTable(&screen.pages); + } + + // Cell window + cell: { + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( + window_cell, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) break :cell; + self.cell_chooser.draw(&screen.pages); + } + + // PageList window + pagelist: { + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( + window_pagelist, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) break :pagelist; + self.pagelist.draw(&screen.pages); + } + + // The remainder is the open state + if (!open) return; + + // Show warning if viewing an inactive screen + if (data.key != data.active_key) { + cimgui.c.ImGui_TextColored( + .{ .x = 1.0, .y = 0.8, .z = 0.0, .w = 1.0 }, + "âš  Viewing inactive screen", + ); + cimgui.c.ImGui_Separator(); + } + } + + /// Create the dock space for the screen inspector. This creates + /// a dedicated dock space for the screen inspector windows. But they + /// can of course be undocked and moved around as desired. + fn createDockSpace(dockspace_id: cimgui.c.ImGuiID) bool { + // Check if we need to set up the dockspace + const setup = cimgui.ImGui_DockBuilderGetNode(dockspace_id) == null; + + if (setup) { + // Register our dockspace node + assert(cimgui.ImGui_DockBuilderAddNodeEx( + dockspace_id, + cimgui.ImGuiDockNodeFlagsPrivate.DockSpace, + ) == dockspace_id); + + // Dock windows into the space + cimgui.ImGui_DockBuilderDockWindow(window_info, dockspace_id); + cimgui.ImGui_DockBuilderDockWindow(window_cell, dockspace_id); + cimgui.ImGui_DockBuilderDockWindow(window_pagelist, dockspace_id); + cimgui.ImGui_DockBuilderFinish(dockspace_id); + } + + // Create the dockspace + assert(cimgui.c.ImGui_DockSpaceEx( + dockspace_id, + .{ .x = 0, .y = 0 }, + cimgui.c.ImGuiDockNodeFlags_None, + null, + ) == dockspace_id); + return setup; + } +}; + +/// Render cursor state with a table of cursor-specific fields. +pub fn cursorTable( + cursor: *const terminal.Screen.Cursor, +) void { + if (!cimgui.c.ImGui_BeginTable( + "table_cursor", + 2, + cimgui.c.ImGuiTableFlags_None, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Position (x, y)"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The current cursor position in the terminal grid (0-indexed)."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("(%d, %d)", cursor.x, cursor.y); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hyperlink"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The active OSC8 hyperlink for newly printed characters."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (cursor.hyperlink) |link| { + cimgui.c.ImGui_Text("%.*s", link.uri.len, link.uri.ptr); + } else { + cimgui.c.ImGui_TextDisabled("(none)"); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Pending Wrap"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The 'last column flag' (LCF). If set, the next character will force a soft-wrap to the next line."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + var value: bool = cursor.pending_wrap; + _ = cimgui.c.ImGui_Checkbox("##pending_wrap", &value); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Protected"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("If enabled, new characters will have the protected attribute set, preventing erasure by certain sequences."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + var value: bool = cursor.protected; + _ = cimgui.c.ImGui_Checkbox("##protected", &value); + } +} + +/// Render cursor style information using the shared style table. +pub fn cursorStyle(cursor: *const terminal.Screen.Cursor, palette: ?*const terminal.color.Palette) void { + widgets.style.table(cursor.style, palette); +} + +/// Render keyboard information with a table. +fn keyboardTable( + screen: *const terminal.Screen, + modify_other_keys_2: bool, +) void { + if (!cimgui.c.ImGui_BeginTable( + "table_keyboard", + 2, + cimgui.c.ImGuiTableFlags_None, + )) return; + defer cimgui.c.ImGui_EndTable(); + + const kitty_flags = screen.kitty_keyboard.current(); + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Mode"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + const mode = if (kitty_flags.int() != 0) "kitty" else "legacy"; + cimgui.c.ImGui_Text("%s", mode.ptr); + } + } + + if (kitty_flags.int() != 0) { + const Flags = @TypeOf(kitty_flags); + inline for (@typeInfo(Flags).@"struct".fields) |field| { + { + const value = @field(kitty_flags, field.name); + + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + const field_name = std.fmt.comptimePrint("{s}", .{field.name}); + cimgui.c.ImGui_Text("%s", field_name.ptr); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%s", + if (value) "true".ptr else "false".ptr, + ); + } + } + } + } else { + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Xterm modify keys"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%s", + if (modify_other_keys_2) "true".ptr else "false".ptr, + ); + } + } + } // keyboard mode info +} + +/// Render kitty graphics information table. +pub fn kittyGraphicsTable( + kitty_images: *const terminal.kitty.graphics.ImageStorage, +) void { + if (!kitty_images.enabled()) { + cimgui.c.ImGui_TextDisabled("(Kitty graphics are disabled)"); + return; + } + + if (!cimgui.c.ImGui_BeginTable( + "##kitty_graphics", + 2, + cimgui.c.ImGuiTableFlags_None, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Usage"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", kitty_images.total_bytes, units.toKibiBytes(kitty_images.total_bytes)); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Limit"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", kitty_images.total_limit, units.toKibiBytes(kitty_images.total_limit)); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Image Count"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", kitty_images.images.count()); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Placement Count"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", kitty_images.placements.count()); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Image Loading"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", if (kitty_images.loading != null) "true".ptr else "false".ptr); +} + +/// Render internal terminal state table. +pub fn internalStateTable( + pages: *const terminal.PageList, +) void { + if (!cimgui.c.ImGui_BeginTable( + "##terminal_state", + 2, + cimgui.c.ImGuiTableFlags_None, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Usage"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.page_size, units.toKibiBytes(pages.page_size)); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Limit"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.maxSize(), units.toKibiBytes(pages.maxSize())); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Viewport Location"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr); +} diff --git a/src/inspector/widgets/style.zig b/src/inspector/widgets/style.zig new file mode 100644 index 000000000..bdc1816e0 --- /dev/null +++ b/src/inspector/widgets/style.zig @@ -0,0 +1,125 @@ +const std = @import("std"); +const cimgui = @import("dcimgui"); +const terminal = @import("../../terminal/main.zig"); +const widgets = @import("../widgets.zig"); + +/// Render a style as a table. +pub fn table( + st: terminal.Style, + palette: ?*const terminal.color.Palette, +) void { + { + _ = cimgui.c.ImGui_BeginTable( + "style", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Foreground"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The foreground (text) color"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + color("fg", st.fg_color, palette); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Background"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The background (cell) color"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + color("bg", st.bg_color, palette); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Underline"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The underline color, if underlines are enabled."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + color("underline", st.underline_color, palette); + } + + const style_flags = .{ + .{ "bold", "Text will be rendered with bold weight." }, + .{ "italic", "Text will be rendered in italic style." }, + .{ "faint", "Text will be rendered with reduced intensity." }, + .{ "blink", "Text will blink." }, + .{ "inverse", "Foreground and background colors are swapped." }, + .{ "invisible", "Text will be invisible (hidden)." }, + .{ "strikethrough", "Text will have a line through it." }, + }; + inline for (style_flags) |entry| entry: { + const style = entry[0]; + const help = entry[1]; + if (!@field(st.flags, style)) break :entry; + + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text(style.ptr); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker(help); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("true"); + } + } + } + + cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); +} + +/// Render a style color. +pub fn color( + id: [:0]const u8, + c: terminal.Style.Color, + palette: ?*const terminal.color.Palette, +) void { + cimgui.c.ImGui_PushID(id); + defer cimgui.c.ImGui_PopID(); + + switch (c) { + .none => cimgui.c.ImGui_Text("default"), + + .palette => |idx| { + cimgui.c.ImGui_Text("Palette %d", idx); + if (palette) |p| { + const rgb = p[idx]; + var data: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, + }; + _ = cimgui.c.ImGui_ColorEdit3( + "color_fg", + &data, + cimgui.c.ImGuiColorEditFlags_DisplayHex | + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + } + }, + + .rgb => |rgb| { + var data: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, + }; + _ = cimgui.c.ImGui_ColorEdit3( + "color_fg", + &data, + cimgui.c.ImGuiColorEditFlags_DisplayHex | + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, + } +} diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig new file mode 100644 index 000000000..3b69f214c --- /dev/null +++ b/src/inspector/widgets/surface.zig @@ -0,0 +1,500 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const cimgui = @import("dcimgui"); +const inspector = @import("../main.zig"); +const widgets = @import("../widgets.zig"); +const input = @import("../../input.zig"); +const renderer = @import("../../renderer.zig"); +const terminal = @import("../../terminal/main.zig"); +const Surface = @import("../../Surface.zig"); + +/// This is discovered via the hardcoded string in the ImGui demo window. +const window_imgui_demo = "Dear ImGui Demo"; +const window_keyboard = "Keyboard"; +const window_terminal = "Terminal"; +const window_surface = "Surface"; +const window_termio = "Terminal IO"; +const window_renderer = "Renderer"; + +pub const Inspector = struct { + /// Internal GUI state + surface_info: Info, + key_stream: widgets.key.Stream, + terminal_info: widgets.terminal.Info, + vt_stream: widgets.termio.Stream, + renderer_info: widgets.renderer.Info, + + pub fn init(alloc: Allocator) !Inspector { + return .{ + .surface_info = .empty, + .key_stream = try .init(alloc), + .terminal_info = .empty, + .vt_stream = try .init(alloc), + .renderer_info = .empty, + }; + } + + pub fn deinit(self: *Inspector, alloc: Allocator) void { + self.key_stream.deinit(alloc); + self.vt_stream.deinit(alloc); + self.renderer_info.deinit(alloc); + } + + pub fn draw( + self: *Inspector, + surface: *const Surface, + mouse: Mouse, + ) void { + // Create our dockspace first. If we had to setup our dockspace, + // then it is a first render. + const dockspace_id = cimgui.c.ImGui_GetID("Main Dockspace"); + const first_render = createDockSpace(dockspace_id); + + // In debug we show the ImGui demo window so we can easily view + // available widgets and such. + if (comptime builtin.mode == .Debug) { + var show: bool = true; // Always show it + cimgui.c.ImGui_ShowDemoWindow(&show); + } + + // Draw everything that requires the terminal state mutex. + { + surface.renderer_state.mutex.lock(); + defer surface.renderer_state.mutex.unlock(); + const t = surface.renderer_state.terminal; + + // Terminal info window + { + const open = cimgui.c.ImGui_Begin( + window_terminal, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + defer cimgui.c.ImGui_End(); + self.terminal_info.draw(open, t); + } + + // Surface info window + { + const open = cimgui.c.ImGui_Begin( + window_surface, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + defer cimgui.c.ImGui_End(); + self.surface_info.draw( + open, + surface, + mouse, + ); + } + + // Keyboard info window + { + const open = cimgui.c.ImGui_Begin( + window_keyboard, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + defer cimgui.c.ImGui_End(); + self.key_stream.draw( + open, + surface.alloc, + ); + } + + // Terminal IO window + { + const open = cimgui.c.ImGui_Begin( + window_termio, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + defer cimgui.c.ImGui_End(); + if (open) { + self.vt_stream.draw( + surface.alloc, + &t.colors.palette.current, + ); + } + } + + // Renderer info window + { + const open = cimgui.c.ImGui_Begin( + window_renderer, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + defer cimgui.c.ImGui_End(); + self.renderer_info.draw( + surface.alloc, + open, + ); + } + } + + if (first_render) { + // On first render, setup our initial focus state. We only + // do this on first render so that we can let the user change + // focus afterward without it snapping back. + cimgui.c.ImGui_SetWindowFocusStr(window_terminal); + } + } + + /// Create the global dock space for the inspector. A dock space + /// is a special area where windows can be docked into. The global + /// dock space fills the entire main viewport. + /// + /// Returns true if this was the first time the dock space was created. + fn createDockSpace(dockspace_id: cimgui.c.ImGuiID) bool { + const viewport: *cimgui.c.ImGuiViewport = cimgui.c.ImGui_GetMainViewport(); + + // Initial Docking setup + const setup = cimgui.ImGui_DockBuilderGetNode(dockspace_id) == null; + if (setup) { + // Register our dockspace node + assert(cimgui.ImGui_DockBuilderAddNodeEx( + dockspace_id, + cimgui.ImGuiDockNodeFlagsPrivate.DockSpace, + ) == dockspace_id); + + // Ensure it is the full size of the viewport + cimgui.ImGui_DockBuilderSetNodeSize( + dockspace_id, + viewport.Size, + ); + + // We only initialize one central docking point now but + // this is the point we'd pre-split and so on for the initial + // layout. + const dock_id_main: cimgui.c.ImGuiID = dockspace_id; + cimgui.ImGui_DockBuilderDockWindow(window_terminal, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_surface, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_keyboard, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_renderer, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_imgui_demo, dock_id_main); + cimgui.ImGui_DockBuilderFinish(dockspace_id); + } + + // Put the dockspace over the viewport. + assert(cimgui.c.ImGui_DockSpaceOverViewportEx( + dockspace_id, + viewport, + cimgui.c.ImGuiDockNodeFlags_PassthruCentralNode, + null, + ) == dockspace_id); + return setup; + } +}; + +pub const Mouse = struct { + /// Last hovered x/y + last_xpos: f64 = 0, + last_ypos: f64 = 0, + + // Last hovered screen point + last_point: ?terminal.Pin = null, +}; + +/// Surface information inspector widget. +pub const Info = struct { + pub const empty: Info = .{}; + + /// Draw the surface info window. + pub fn draw( + self: *Info, + open: bool, + surface: *const Surface, + mouse: Mouse, + ) void { + _ = self; + if (!open) return; + + if (cimgui.c.ImGui_CollapsingHeader( + "Help", + cimgui.c.ImGuiTreeNodeFlags_None, + )) { + cimgui.c.ImGui_TextWrapped( + "This window displays information about the surface (window). " ++ + "A surface is the graphical area that displays the terminal " ++ + "content. It includes dimensions, font sizing, and mouse state " ++ + "information specific to this window instance.", + ); + } + + cimgui.c.ImGui_SeparatorText("Dimensions"); + dimensionsTable(surface); + + cimgui.c.ImGui_SeparatorText("Font"); + fontTable(surface); + + cimgui.c.ImGui_SeparatorText("Mouse"); + mouseTable(surface, mouse); + } +}; + +fn dimensionsTable(surface: *const Surface) void { + _ = cimgui.c.ImGui_BeginTable( + "table_size", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + // Screen Size + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Screen Size"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%dpx x %dpx", + surface.size.screen.width, + surface.size.screen.height, + ); + } + } + + // Grid Size + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grid Size"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + const grid_size = surface.size.grid(); + cimgui.c.ImGui_Text( + "%dc x %dr", + grid_size.columns, + grid_size.rows, + ); + } + } + + // Cell Size + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Cell Size"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%dpx x %dpx", + surface.size.cell.width, + surface.size.cell.height, + ); + } + } + + // Padding + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Window Padding"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "T=%d B=%d L=%d R=%d px", + surface.size.padding.top, + surface.size.padding.bottom, + surface.size.padding.left, + surface.size.padding.right, + ); + } + } +} + +fn fontTable(surface: *const Surface) void { + _ = cimgui.c.ImGui_BeginTable( + "table_font", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Size (Points)"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%.2f pt", + surface.font_size.points, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Size (Pixels)"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%.2f px", + surface.font_size.pixels(), + ); + } + } +} + +fn mouseTable( + surface: *const Surface, + mouse: Mouse, +) void { + _ = cimgui.c.ImGui_BeginTable( + "table_mouse", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + const surface_mouse = &surface.mouse; + const t = surface.renderer_state.terminal; + + { + const hover_point: terminal.point.Coordinate = pt: { + const p = mouse.last_point orelse break :pt .{}; + const pt = t.screens.active.pages.pointFromPin( + .active, + p, + ) orelse break :pt .{}; + break :pt pt.coord(); + }; + + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hover Grid"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "row=%d, col=%d", + hover_point.y, + hover_point.x, + ); + } + } + + { + const coord: renderer.Coordinate.Terminal = (renderer.Coordinate{ + .surface = .{ + .x = mouse.last_xpos, + .y = mouse.last_ypos, + }, + }).convert(.terminal, surface.size).terminal; + + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hover Point"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "(%dpx, %dpx)", + @as(i64, @intFromFloat(coord.x)), + @as(i64, @intFromFloat(coord.y)), + ); + } + } + + const any_click = for (surface_mouse.click_state) |state| { + if (state == .press) break true; + } else false; + + click: { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Click State"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (!any_click) { + cimgui.c.ImGui_Text("none"); + break :click; + } + + for (surface_mouse.click_state, 0..) |state, i| { + if (state != .press) continue; + const button: input.MouseButton = @enumFromInt(i); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("%s", (switch (button) { + .unknown => "?", + .left => "L", + .middle => "M", + .right => "R", + .four => "{4}", + .five => "{5}", + .six => "{6}", + .seven => "{7}", + .eight => "{8}", + .nine => "{9}", + .ten => "{10}", + .eleven => "{11}", + }).ptr); + } + } + } + + { + const left_click_point: terminal.point.Coordinate = pt: { + const p = surface_mouse.left_click_pin orelse break :pt .{}; + const pt = t.screens.active.pages.pointFromPin( + .active, + p.*, + ) orelse break :pt .{}; + break :pt pt.coord(); + }; + + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Click Grid"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "row=%d, col=%d", + left_click_point.y, + left_click_point.x, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Click Point"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "(%dpx, %dpx)", + @as(u32, @intFromFloat(surface_mouse.left_click_xpos)), + @as(u32, @intFromFloat(surface_mouse.left_click_ypos)), + ); + } + } +} diff --git a/src/inspector/widgets/terminal.zig b/src/inspector/widgets/terminal.zig new file mode 100644 index 000000000..3dbea3886 --- /dev/null +++ b/src/inspector/widgets/terminal.zig @@ -0,0 +1,726 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const cimgui = @import("dcimgui"); +const widgets = @import("../widgets.zig"); +const terminal = @import("../../terminal/main.zig"); +const modes = terminal.modes; +const Terminal = terminal.Terminal; + +/// Terminal information inspector widget. +pub const Info = struct { + /// True if we're showing the 256-color palette window. + show_palette: bool, + + /// The various detachable headers. + misc_header: widgets.DetachableHeader, + layout_header: widgets.DetachableHeader, + mouse_header: widgets.DetachableHeader, + color_header: widgets.DetachableHeader, + modes_header: widgets.DetachableHeader, + + /// Screen detail windows for each screen key. + screens: ScreenMap, + + pub const empty: Info = .{ + .show_palette = false, + .misc_header = .{}, + .layout_header = .{}, + .mouse_header = .{}, + .color_header = .{}, + .modes_header = .{}, + .screens = .{}, + }; + + /// Draw the terminal info window. + pub fn draw( + self: *Info, + open: bool, + t: *Terminal, + ) void { + // Draw our open state if we're open. + if (open) self.drawOpen(t); + + // Draw our detached state that draws regardless of if + // we're open or not. + if (self.misc_header.window("Terminal Misc")) |visible| { + defer self.misc_header.windowEnd(); + if (visible) miscTable(t); + } + if (self.layout_header.window("Terminal Layout")) |visible| { + defer self.layout_header.windowEnd(); + if (visible) layoutTable(t); + } + if (self.mouse_header.window("Terminal Mouse")) |visible| { + defer self.mouse_header.windowEnd(); + if (visible) mouseTable(t); + } + if (self.color_header.window("Terminal Color")) |visible| { + defer self.color_header.windowEnd(); + if (visible) colorTable(t, &self.show_palette); + } + if (self.modes_header.window("Terminal Modes")) |visible| { + defer self.modes_header.windowEnd(); + if (visible) modesTable(t); + } + + // Palette pop-out window + if (self.show_palette) { + defer cimgui.c.ImGui_End(); + if (cimgui.c.ImGui_Begin( + "256-Color Palette", + &self.show_palette, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) { + palette("palette", &t.colors.palette.current); + } + } + + // Screen pop-out windows + var it = self.screens.iterator(); + while (it.next()) |entry| { + const screen = t.screens.get(entry.key) orelse { + // Could happen if we opened up a window for a screen + // and that screen was subsequently deinitialized. In + // this case, hide the window. + self.screens.remove(entry.key); + continue; + }; + + var title_buf: [128]u8 = undefined; + const title = std.fmt.bufPrintZ( + &title_buf, + "Screen: {t}", + .{entry.key}, + ) catch "Screen"; + + // Setup our next window so it has some size to it. + const viewport = cimgui.c.ImGui_GetMainViewport(); + cimgui.c.ImGui_SetNextWindowSize( + .{ + .x = @min(400, viewport.*.Size.x), + .y = @min(300, viewport.*.Size.y), + }, + cimgui.c.ImGuiCond_FirstUseEver, + ); + + var screen_open: bool = true; + defer cimgui.c.ImGui_End(); + const screen_draw = cimgui.c.ImGui_Begin( + title, + &screen_open, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + entry.value.draw(screen_draw, .{ + .screen = screen, + .key = entry.key, + .active_key = t.screens.active_key, + .modify_other_keys_2 = t.flags.modify_other_keys_2, + .color_palette = &t.colors.palette, + }); + + // If the window was closed, remove it from our map so future + // renders don't draw it. + if (!screen_open) self.screens.remove(entry.key); + } + } + + fn drawOpen(self: *Info, t: *Terminal) void { + // Show our screens up top. + screensTable(t, &self.screens); + + if (self.misc_header.header("Misc")) miscTable(t); + if (self.layout_header.header("Layout")) layoutTable(t); + if (self.mouse_header.header("Mouse")) mouseTable(t); + if (self.color_header.header("Color")) colorTable(t, &self.show_palette); + if (self.modes_header.header("Modes")) modesTable(t); + } +}; + +pub const ScreenMap = std.EnumMap( + terminal.ScreenSet.Key, + widgets.screen.Info, +); + +/// Render the table of possible screens with various actions. +fn screensTable( + t: *Terminal, + map: *ScreenMap, +) void { + if (!cimgui.c.ImGui_BeginTable( + "screens", + 3, + cimgui.c.ImGuiTableFlags_Borders | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn("Screen", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Status", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + + // Custom header row to include help marker before "Screen" + { + cimgui.c.ImGui_TableNextRowEx(cimgui.c.ImGuiTableRowFlags_Headers, 0.0); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_PushStyleVarImVec2(cimgui.c.ImGuiStyleVar_FramePadding, .{ .x = 0, .y = 0 }); + widgets.helpMarker( + "A terminal can have multiple screens, only one of which is active at " ++ + "a time. Each screen has its own grid, contents, and other state. " ++ + "This section allows you to inspect the different screens managed by " ++ + "the terminal.", + ); + cimgui.c.ImGui_PopStyleVar(); + cimgui.c.ImGui_SameLineEx(0.0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); + cimgui.c.ImGui_TableHeader("Screen"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_TableHeader("Status"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_TableHeader(""); + } + } + + for (std.meta.tags(terminal.ScreenSet.Key)) |key| { + const is_initialized = t.screens.get(key) != null; + const is_active = t.screens.active_key == key; + + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%s", @tagName(key).ptr); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (is_active) { + cimgui.c.ImGui_TextColored( + .{ .x = 0.4, .y = 1.0, .z = 0.4, .w = 1.0 }, + "active", + ); + } else if (is_initialized) { + cimgui.c.ImGui_TextColored( + .{ .x = 0.6, .y = 0.6, .z = 0.6, .w = 1.0 }, + "initialized", + ); + } else { + cimgui.c.ImGui_TextColored( + .{ .x = 0.4, .y = 0.4, .z = 0.4, .w = 1.0 }, + "(not initialized)", + ); + } + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_PushIDInt(@intFromEnum(key)); + defer cimgui.c.ImGui_PopID(); + cimgui.c.ImGui_BeginDisabled(!is_initialized); + defer cimgui.c.ImGui_EndDisabled(); + if (cimgui.c.ImGui_Button("View")) { + if (!map.contains(key)) { + map.put(key, .empty); + } + } + } + } +} + +/// Table of miscellaneous terminal information. +fn miscTable(t: *Terminal) void { + _ = cimgui.c.ImGui_BeginTable( + "table_misc", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Working Directory"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The current working directory reported by the shell."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (t.pwd.items.len > 0) { + cimgui.c.ImGui_Text( + "%.*s", + t.pwd.items.len, + t.pwd.items.ptr, + ); + } else { + cimgui.c.ImGui_TextDisabled("(none)"); + } + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Focused"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Whether the terminal itself is currently focused."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + var value: bool = t.flags.focused; + _ = cimgui.c.ImGui_Checkbox("##focused", &value); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Previous Char"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The previously printed character, used only for the REP sequence."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (t.previous_char) |c| { + cimgui.c.ImGui_Text("U+%04X", @as(u32, c)); + } else { + cimgui.c.ImGui_TextDisabled("(none)"); + } + } + } +} + +/// Table of terminal layout information. +fn layoutTable(t: *Terminal) void { + _ = cimgui.c.ImGui_BeginTable( + "table_layout", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grid"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The size of the terminal grid in columns and rows."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%dc x %dr", + t.cols, + t.rows, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Pixels"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The size of the terminal grid in pixels."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%dw x %dh", + t.width_px, + t.height_px, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Scroll Region"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The scrolling region boundaries (top, bottom, left, right)."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_PushItemWidth(cimgui.c.ImGui_CalcTextSize("00000").x); + defer cimgui.c.ImGui_PopItemWidth(); + + var override = t.scrolling_region; + var changed = false; + + cimgui.c.ImGui_AlignTextToFramePadding(); + cimgui.c.ImGui_Text("T:"); + cimgui.c.ImGui_SameLine(); + if (cimgui.c.ImGui_InputScalar( + "##scroll_top", + cimgui.c.ImGuiDataType_U16, + &override.top, + )) { + override.top = @min(override.top, t.rows -| 1); + changed = true; + } + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("B:"); + cimgui.c.ImGui_SameLine(); + if (cimgui.c.ImGui_InputScalar( + "##scroll_bottom", + cimgui.c.ImGuiDataType_U16, + &override.bottom, + )) { + override.bottom = @min(override.bottom, t.rows -| 1); + changed = true; + } + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("L:"); + cimgui.c.ImGui_SameLine(); + if (cimgui.c.ImGui_InputScalar( + "##scroll_left", + cimgui.c.ImGuiDataType_U16, + &override.left, + )) { + override.left = @min(override.left, t.cols -| 1); + changed = true; + } + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("R:"); + cimgui.c.ImGui_SameLine(); + if (cimgui.c.ImGui_InputScalar( + "##scroll_right", + cimgui.c.ImGuiDataType_U16, + &override.right, + )) { + override.right = @min(override.right, t.cols -| 1); + changed = true; + } + + if (changed and + override.top < override.bottom and + override.left < override.right) + { + t.scrolling_region = override; + } + } + } +} + +/// Table of mouse-related terminal information. +fn mouseTable(t: *Terminal) void { + _ = cimgui.c.ImGui_BeginTable( + "table_mouse", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Event Mode"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The mouse event reporting mode set by the application."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(t.flags.mouse_event).ptr); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Format"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The mouse event encoding format."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(t.flags.mouse_format).ptr); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Shape"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The current mouse cursor shape set by the application."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(t.mouse_shape).ptr); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Shift Capture"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("XTSHIFTESCAPE state for capturing shift in mouse protocol."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (t.flags.mouse_shift_capture == .null) { + cimgui.c.ImGui_TextDisabled("(unset)"); + } else { + cimgui.c.ImGui_Text("%s", @tagName(t.flags.mouse_shift_capture).ptr); + } + } + } +} + +/// Table of color-related terminal information. +fn colorTable( + t: *Terminal, + show_palette: *bool, +) void { + cimgui.c.ImGui_TextWrapped( + "Color state for the terminal. Note these colors only apply " ++ + "to the palette and unstyled colors. Many modern terminal " ++ + "applications use direct RGB colors which are not reflected here.", + ); + cimgui.c.ImGui_Separator(); + + _ = cimgui.c.ImGui_BeginTable( + "table_color", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Background"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Unstyled cell background color."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + _ = dynamicRGB( + "bg_color", + &t.colors.background, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Foreground"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Unstyled cell foreground color."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + _ = dynamicRGB( + "fg_color", + &t.colors.foreground, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Cursor"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Cursor coloring set by escape sequences."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + _ = dynamicRGB( + "cursor_color", + &t.colors.cursor, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Palette"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The 256-color palette."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (cimgui.c.ImGui_Button("View")) { + show_palette.* = true; + } + } + } +} + +/// Table of terminal modes. +fn modesTable(t: *Terminal) void { + _ = cimgui.c.ImGui_BeginTable( + "table_modes", + 3, + cimgui.c.ImGuiTableFlags_SizingFixedFit | + cimgui.c.ImGuiTableFlags_RowBg, + ); + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_NoResize); + cimgui.c.ImGui_TableSetupColumn("Number", cimgui.c.ImGuiTableColumnFlags_PreferSortAscending); + cimgui.c.ImGui_TableSetupColumn("Name", cimgui.c.ImGuiTableColumnFlags_WidthStretch); + cimgui.c.ImGui_TableHeadersRow(); + } + + inline for (@typeInfo(terminal.Mode).@"enum".fields) |field| { + @setEvalBranchQuota(6000); + const tag: modes.ModeTag = @bitCast(@as(modes.ModeTag.Backing, field.value)); + + cimgui.c.ImGui_TableNextRow(); + cimgui.c.ImGui_PushIDInt(@intCast(field.value)); + defer cimgui.c.ImGui_PopID(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + var value: bool = t.modes.get(@field(terminal.Mode, field.name)); + _ = cimgui.c.ImGui_Checkbox("##checkbox", &value); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%s%d", + if (tag.ansi) "" else "?", + @as(u32, @intCast(tag.value)), + ); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + const name = std.fmt.comptimePrint("{s}", .{field.name}); + cimgui.c.ImGui_Text("%s", name.ptr); + } + } +} + +/// Render a DynamicRGB color. +fn dynamicRGB( + label: [:0]const u8, + rgb: *terminal.color.DynamicRGB, +) bool { + _ = cimgui.c.ImGui_BeginTable( + label, + if (rgb.override != null) 2 else 1, + cimgui.c.ImGuiTableFlags_SizingFixedFit, + ); + defer cimgui.c.ImGui_EndTable(); + + if (rgb.override != null) cimgui.c.ImGui_TableSetupColumn( + "##label", + cimgui.c.ImGuiTableColumnFlags_WidthFixed, + ); + cimgui.c.ImGui_TableSetupColumn( + "##value", + cimgui.c.ImGuiTableColumnFlags_WidthStretch, + ); + + if (rgb.override) |c| { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("override:"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Overridden color set by escape sequences."); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + var col = [3]f32{ + @as(f32, @floatFromInt(c.r)) / 255.0, + @as(f32, @floatFromInt(c.g)) / 255.0, + @as(f32, @floatFromInt(c.b)) / 255.0, + }; + _ = cimgui.c.ImGui_ColorEdit3( + "##override", + &col, + cimgui.c.ImGuiColorEditFlags_None, + ); + } + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + if (rgb.default) |c| { + if (rgb.override != null) { + cimgui.c.ImGui_Text("default:"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Default color from configuration."); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + } + + var col = [3]f32{ + @as(f32, @floatFromInt(c.r)) / 255.0, + @as(f32, @floatFromInt(c.g)) / 255.0, + @as(f32, @floatFromInt(c.b)) / 255.0, + }; + _ = cimgui.c.ImGui_ColorEdit3( + "##default", + &col, + cimgui.c.ImGuiColorEditFlags_None, + ); + } else { + cimgui.c.ImGui_TextDisabled("(unset)"); + } + + return false; +} + +/// Render a color palette as a 16x16 grid of color buttons. +fn palette( + label: [:0]const u8, + pal: *const terminal.color.Palette, +) void { + cimgui.c.ImGui_PushID(label); + defer cimgui.c.ImGui_PopID(); + + for (0..16) |row| { + for (0..16) |col| { + const idx = row * 16 + col; + const rgb = pal[idx]; + var col_arr = [3]f32{ + @as(f32, @floatFromInt(rgb.r)) / 255.0, + @as(f32, @floatFromInt(rgb.g)) / 255.0, + @as(f32, @floatFromInt(rgb.b)) / 255.0, + }; + + if (col > 0) cimgui.c.ImGui_SameLine(); + + cimgui.c.ImGui_PushIDInt(@intCast(idx)); + _ = cimgui.c.ImGui_ColorEdit3( + "##color", + &col_arr, + cimgui.c.ImGuiColorEditFlags_NoInputs, + ); + if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) { + cimgui.c.ImGui_SetTooltip( + "%d: #%02X%02X%02X", + idx, + rgb.r, + rgb.g, + rgb.b, + ); + } + cimgui.c.ImGui_PopID(); + } + } +} diff --git a/src/inspector/widgets/termio.zig b/src/inspector/widgets/termio.zig new file mode 100644 index 000000000..a6c8f6081 --- /dev/null +++ b/src/inspector/widgets/termio.zig @@ -0,0 +1,811 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const cimgui = @import("dcimgui"); +const terminal = @import("../../terminal/main.zig"); +const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const Surface = @import("../../Surface.zig"); +const screen = @import("screen.zig"); + +/// VT event stream inspector widget. +pub const Stream = struct { + events: VTEvent.Ring, + parser_stream: VTHandler.Stream, + + /// The currently selected event sequence number for keyboard navigation + selected_event_seq: ?u32 = null, + + /// Flag indicating whether we need to scroll to the selected item + need_scroll_to_selected: bool = false, + + /// Flag indicating whether the selection was made by keyboard + is_keyboard_selection: bool = false, + + pub fn init(alloc: Allocator) !Stream { + var events: VTEvent.Ring = try .init(alloc, 2); + errdefer events.deinit(alloc); + + var handler: VTHandler = .init; + errdefer handler.deinit(); + + return .{ + .events = events, + .parser_stream = .initAlloc(alloc, handler), + }; + } + + pub fn deinit(self: *Stream, alloc: Allocator) void { + var it = self.events.iterator(.forward); + while (it.next()) |v| v.deinit(alloc); + self.events.deinit(alloc); + + self.parser_stream.deinit(); + } + + pub fn recordPtyRead( + self: *Stream, + alloc: Allocator, + t: *terminal.Terminal, + data: []const u8, + ) !void { + self.parser_stream.handler.state = .{ + .alloc = alloc, + .terminal = t, + .events = &self.events, + }; + defer self.parser_stream.handler.state = null; + try self.parser_stream.nextSlice(data); + } + + pub fn draw( + self: *Stream, + alloc: Allocator, + palette: *const terminal.color.Palette, + ) void { + const events = &self.events; + const handler = &self.parser_stream.handler; + const popup_filter = "Filter"; + + // Controls + { + const pause_play: [:0]const u8 = if (!handler.paused) + "Pause##pause_play" + else + "Resume##pause_play"; + if (cimgui.c.ImGui_Button(pause_play.ptr)) { + handler.paused = !handler.paused; + } + + cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); + if (cimgui.c.ImGui_Button("Filter")) { + cimgui.c.ImGui_OpenPopup( + popup_filter, + cimgui.c.ImGuiPopupFlags_None, + ); + } + + if (!events.empty()) { + cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); + if (cimgui.c.ImGui_Button("Clear")) { + var it = events.iterator(.forward); + while (it.next()) |v| v.deinit(alloc); + events.clear(); + + handler.current_seq = 1; + } + } + } + + // Events Table + if (events.empty()) { + cimgui.c.ImGui_Text("Waiting for events..."); + } else { + // TODO: Eventually + // eventTable(events); + } + + { + cimgui.c.ImGui_Separator(); + + _ = cimgui.c.ImGui_BeginTable( + "table_vt_events", + 3, + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_Borders, + ); + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn( + "Seq", + cimgui.c.ImGuiTableColumnFlags_WidthFixed, + ); + cimgui.c.ImGui_TableSetupColumn( + "Kind", + cimgui.c.ImGuiTableColumnFlags_WidthFixed, + ); + cimgui.c.ImGui_TableSetupColumn( + "Description", + cimgui.c.ImGuiTableColumnFlags_WidthStretch, + ); + + // Handle keyboard navigation when window is focused + if (cimgui.c.ImGui_IsWindowFocused(cimgui.c.ImGuiFocusedFlags_RootAndChildWindows)) { + const key_pressed = getKeyAction(); + + switch (key_pressed) { + .none => {}, + .up, .down => { + // If no event is selected, select the first/last event based on direction + if (self.selected_event_seq == null) { + if (!events.empty()) { + var it = events.iterator(if (key_pressed == .up) .forward else .reverse); + if (it.next()) |ev| { + self.selected_event_seq = @as(u32, @intCast(ev.seq)); + } + } + } else { + // Find next/previous event based on current selection + var it = events.iterator(.reverse); + switch (key_pressed) { + .down => { + var found = false; + while (it.next()) |ev| { + if (found) { + self.selected_event_seq = @as(u32, @intCast(ev.seq)); + break; + } + if (ev.seq == self.selected_event_seq.?) { + found = true; + } + } + }, + .up => { + var prev_ev: ?*const VTEvent = null; + while (it.next()) |ev| { + if (ev.seq == self.selected_event_seq.?) { + if (prev_ev) |prev| { + self.selected_event_seq = @as(u32, @intCast(prev.seq)); + break; + } + } + prev_ev = ev; + } + }, + .none => unreachable, + } + } + + // Mark that we need to scroll to the newly selected item + self.need_scroll_to_selected = true; + self.is_keyboard_selection = true; + }, + } + } + + var it = events.iterator(.reverse); + while (it.next()) |ev| { + // Need to push an ID so that our selectable is unique. + cimgui.c.ImGui_PushIDPtr(ev); + defer cimgui.c.ImGui_PopID(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableNextColumn(); + + // Store the previous selection state to detect changes + const was_selected = ev.imgui_selected; + + // Update selection state based on keyboard navigation + if (self.selected_event_seq) |seq| { + ev.imgui_selected = (@as(u32, @intCast(ev.seq)) == seq); + } + + // Handle selectable widget + if (cimgui.c.ImGui_SelectableBoolPtr( + "##select", + &ev.imgui_selected, + cimgui.c.ImGuiSelectableFlags_SpanAllColumns, + )) { + // If selection state changed, update keyboard navigation state + if (ev.imgui_selected != was_selected) { + self.selected_event_seq = if (ev.imgui_selected) + @as(u32, @intCast(ev.seq)) + else + null; + self.is_keyboard_selection = false; + } + } + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("%d", ev.seq); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", @tagName(ev.kind).ptr); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", ev.raw_description.ptr); + + // If the event is selected, we render info about it. For now + // we put this in the last column because that's the widest and + // imgui has no way to make a column span. + if (ev.imgui_selected) { + { + screen.cursorTable(&ev.cursor); + screen.cursorStyle(&ev.cursor, palette); + + _ = cimgui.c.ImGui_BeginTable( + "details", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Scroll Region"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "T=%d B=%d L=%d R=%d", + ev.scrolling_region.top, + ev.scrolling_region.bottom, + ev.scrolling_region.left, + ev.scrolling_region.right, + ); + } + } + + var md_it = ev.metadata.iterator(); + while (md_it.next()) |entry| { + var buf: [256]u8 = undefined; + const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch + ""; + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", key.ptr); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", entry.value_ptr.ptr); + } + } + + // If this is the selected event and scrolling is needed, scroll to it + if (self.need_scroll_to_selected and self.is_keyboard_selection) { + cimgui.c.ImGui_SetScrollHereY(0.5); + self.need_scroll_to_selected = false; + } + } + } + } // table + + if (cimgui.c.ImGui_BeginPopupModal( + popup_filter, + null, + cimgui.c.ImGuiWindowFlags_AlwaysAutoResize, + )) { + defer cimgui.c.ImGui_EndPopup(); + + cimgui.c.ImGui_Text("Changed filter settings will only affect future events."); + + cimgui.c.ImGui_Separator(); + + { + _ = cimgui.c.ImGui_BeginTable( + "table_filter_kind", + 3, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + inline for (@typeInfo(terminal.Parser.Action.Tag).@"enum".fields) |field| { + const tag = @field(terminal.Parser.Action.Tag, field.name); + if (tag == .apc_put or tag == .dcs_put) continue; + + _ = cimgui.c.ImGui_TableNextColumn(); + var value = !handler.filter_exclude.contains(tag); + if (cimgui.c.ImGui_Checkbox(@tagName(tag).ptr, &value)) { + if (value) { + handler.filter_exclude.remove(tag); + } else { + handler.filter_exclude.insert(tag); + } + } + } + } // Filter kind table + + cimgui.c.ImGui_Separator(); + + cimgui.c.ImGui_Text( + "Filter by string. Empty displays all, \"abc\" finds lines\n" ++ + "containing \"abc\", \"abc,xyz\" finds lines containing \"abc\"\n" ++ + "or \"xyz\", \"-abc\" excludes lines containing \"abc\".", + ); + _ = cimgui.c.ImGuiTextFilter_Draw( + &handler.filter_text, + "##filter_text", + 0, + ); + + cimgui.c.ImGui_Separator(); + if (cimgui.c.ImGui_Button("Close")) { + cimgui.c.ImGui_CloseCurrentPopup(); + } + } // filter popup + } +}; + +/// Helper function to check keyboard state and determine navigation action. +fn getKeyAction() KeyAction { + const keys = .{ + .{ .key = cimgui.c.ImGuiKey_J, .action = KeyAction.down }, + .{ .key = cimgui.c.ImGuiKey_DownArrow, .action = KeyAction.down }, + .{ .key = cimgui.c.ImGuiKey_K, .action = KeyAction.up }, + .{ .key = cimgui.c.ImGuiKey_UpArrow, .action = KeyAction.up }, + }; + + inline for (keys) |k| { + if (cimgui.c.ImGui_IsKeyPressed(k.key)) { + return k.action; + } + } + return .none; +} + +pub fn eventTable(events: *const VTEvent.Ring) void { + if (!cimgui.c.ImGui_BeginTable( + "events", + 3, + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_Borders, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn( + "Seq", + cimgui.c.ImGuiTableColumnFlags_WidthFixed, + ); + cimgui.c.ImGui_TableSetupColumn( + "Kind", + cimgui.c.ImGuiTableColumnFlags_WidthFixed, + ); + cimgui.c.ImGui_TableSetupColumn( + "Description", + cimgui.c.ImGuiTableColumnFlags_WidthStretch, + ); + + var it = events.iterator(.reverse); + while (it.next()) |ev| { + // Need to push an ID so that our selectable is unique. + cimgui.c.ImGui_PushIDPtr(ev); + defer cimgui.c.ImGui_PopID(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableNextColumn(); + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("%d", ev.seq); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", @tagName(ev.kind).ptr); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", ev.raw_description.ptr); + } +} + +/// VT event. This isn't public because this is just how we store internal +/// events. +const VTEvent = struct { + /// The arena that all allocated memory for this event is stored. + arena_state: ArenaAllocator.State, + + /// Sequence number, just monotonically increasing and wrapping if + /// it ever overflows. It gives us a nice way to visualize progress. + seq: usize = 1, + + /// Kind of event, for filtering + kind: Kind, + + /// The description of the raw event in a more human-friendly format. + /// For example for control sequences this is the full sequence but + /// control characters are replaced with human-readable names, e.g. + /// 0x07 (bell) becomes BEL. + raw_description: [: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); + + /// Circular buffer of VT events. + pub const Ring = CircBuf(VTEvent, undefined); + + /// Initialize the event information for the given parser action. + pub fn init( + alloc_gpa: Allocator, + t: *const terminal.Terminal, + action: terminal.Parser.Action, + ) !VTEvent { + var arena: ArenaAllocator = .init(alloc_gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + var md = Metadata.init(alloc); + var buf: std.Io.Writer.Allocating = .init(alloc); + try encodeAction(alloc, &buf.writer, &md, action); + const desc = try buf.toOwnedSliceSentinel(0); + + 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, + }; + + return .{ + .arena_state = arena.state, + .kind = kind, + .raw_description = desc, + .cursor = t.screens.active.cursor, + .scrolling_region = t.scrolling_region, + .metadata = md.unmanaged, + }; + } + + pub fn deinit(self: *VTEvent, alloc_gpa: Allocator) void { + var arena = self.arena_state.promote(alloc_gpa); + arena.deinit(); + } + + /// Returns true if the event passes the given filter. + pub fn passFilter( + self: *const VTEvent, + filter: *const cimgui.c.ImGuiTextFilter, + ) bool { + // Check our main string + if (cimgui.c.ImGuiTextFilter_PassFilter( + filter, + self.raw_description.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: *std.Io.Writer, + 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("{f}", .{action}), + } + } + + fn encodePrint(writer: *std.Io.Writer, action: terminal.Parser.Action) !void { + const ch = action.print; + try writer.print("'{u}' (U+{X})", .{ ch, ch }); + } + + fn encodeExecute(writer: *std.Io.Writer, 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: *std.Io.Writer, 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: *std.Io.Writer, 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: *std.Io.Writer, + 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, + [:0]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), + ); + }, + + .@"union" => |info| { + const Tag = info.tag_type orelse @compileError("Unions must have a tag"); + const tag_name = @tagName(@as(Tag, v)); + inline for (info.fields) |field| { + if (std.mem.eql(u8, field.name, tag_name)) { + if (field.type == void) { + break try md.put("data", tag_name); + } else { + break try encodeMetadataSingle(alloc, md, tag_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)), + ), + + .@"union" => |u| { + const Tag = u.tag_type orelse @compileError("Unions must have a tag"); + const tag_name = @tagName(@as(Tag, value)); + inline for (u.fields) |field| { + if (std.mem.eql(u8, field.name, tag_name)) { + const s = if (field.type == void) + try alloc.dupeZ(u8, tag_name) + else if (field.type == [:0]const u8 or field.type == []const u8) + try std.fmt.allocPrintSentinel(alloc, "{s}={s}", .{ + tag_name, + @field(value, field.name), + }, 0) + else + try std.fmt.allocPrintSentinel(alloc, "{s}={}", .{ + tag_name, + @field(value, field.name), + }, 0); + + try md.put(key, s); + } + } + }, + + .@"struct" => try md.put( + key, + try alloc.dupeZ(u8, @typeName(Value)), + ), + + else => switch (Value) { + []const u8, + [:0]const u8, + => try md.put(key, try alloc.dupeZ(u8, value)), + + else => |T| switch (@typeInfo(T)) { + .int => try md.put( + key, + try std.fmt.allocPrintSentinel(alloc, "{}", .{value}, 0), + ), + else => { + @compileLog(T); + @compileError("unsupported type, see log"); + }, + }, + }, + } + } +}; + +/// Our VT stream handler for the Stream widget. This isn't public +/// because there is no reason to use this directly. +const VTHandler = struct { + /// The capture state, must be set before use. If null, then + /// events are dropped. + state: ?State, + + /// True to pause this artificially. + paused: bool, + + /// Current sequence number + current_seq: usize, + + /// Exclude certain actions by tag. + filter_exclude: ActionTagSet, + filter_text: cimgui.c.ImGuiTextFilter, + + const Stream = terminal.Stream(VTHandler); + + pub const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag); + + pub const State = struct { + /// The allocator to use for the events. + alloc: Allocator, + + /// The terminal state at the time of the event. + terminal: *const terminal.Terminal, + + /// The event ring to write events to. + events: *VTEvent.Ring, + }; + + pub const init: VTHandler = .{ + .state = null, + .paused = false, + .current_seq = 1, + .filter_exclude = .initMany(&.{.print}), + .filter_text = .{}, + }; + + pub fn deinit(self: *VTHandler) void { + // Required for the parser stream interface + _ = self; + } + + pub fn vt( + self: *VTHandler, + comptime action: VTHandler.Stream.Action.Tag, + value: VTHandler.Stream.Action.Value(action), + ) !void { + _ = self; + _ = value; + } + + /// This is called with every single terminal action. + pub fn vtRaw(self: *VTHandler, action: terminal.Parser.Action) !bool { + const state: *State = if (self.state) |*s| s else return true; + const alloc = state.alloc; + const vt_events = state.events; + + // 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 manually paused, we ignore all events. + if (self.paused) 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 + var ev: VTEvent = try .init( + alloc, + state.terminal, + 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; + vt_events.append(ev) catch |err| switch (err) { + error.OutOfMemory => if (vt_events.capacity() < max_capacity) { + // We're out of memory, but we can allocate to our capacity. + const new_capacity = @min(vt_events.capacity() * 2, max_capacity); + try vt_events.resize(alloc, new_capacity); + try vt_events.append(ev); + } else { + var it = vt_events.iterator(.forward); + if (it.next()) |old_ev| old_ev.deinit(alloc); + vt_events.deleteOldest(1); + try vt_events.append(ev); + }, + + else => return err, + }; + + // Do NOT skip it, because we want to record more information + // about this event. + return false; + } +}; + +/// Enum representing keyboard navigation actions +const KeyAction = enum { + down, + none, + up, +}; diff --git a/src/renderer.zig b/src/renderer.zig index 2d37ddd4c..9b5164e91 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -19,6 +19,7 @@ pub const Metal = @import("renderer/Metal.zig"); pub const OpenGL = @import("renderer/OpenGL.zig"); pub const WebGL = @import("renderer/WebGL.zig"); pub const Options = @import("renderer/Options.zig"); +pub const Overlay = @import("renderer/Overlay.zig"); pub const Thread = @import("renderer/Thread.zig"); pub const State = @import("renderer/State.zig"); pub const CursorStyle = cursor.Style; diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 7f0e3e00c..3c77e4cdf 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -225,13 +225,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Our overlay state, if any. overlay: ?Overlay = null, - // Right now, the debug overlay is turned on and configured by - // modifying these and recompiling. In the future, we will expose - // all of this at runtime via the inspector. - const overlay_features: []const Overlay.Feature = &.{ - //.highlight_hyperlinks, - }; - const HighlightTag = enum(u8) { search_match, search_match_selected, @@ -1152,6 +1145,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, scrollbar: terminal.Scrollbar, + overlay_features: []const Overlay.Feature, }; // Update all our data as tightly as possible within the mutex. @@ -1231,11 +1225,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; }; + const overlay_features: []const Overlay.Feature = overlay: { + const insp = state.inspector orelse break :overlay &.{}; + const renderer_info = insp.rendererInfo(); + break :overlay renderer_info.overlayFeatures( + arena_alloc, + ) catch &.{}; + }; + break :critical .{ .links = links, .mouse = state.mouse, .preedit = preedit, .scrollbar = scrollbar, + .overlay_features = overlay_features, }; }; @@ -1306,7 +1309,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Rebuild the overlay image if we have one. We can do this // outside of any critical areas. - self.rebuildOverlay() catch |err| { + self.rebuildOverlay( + critical.overlay_features, + ) catch |err| { log.warn( "error rebuilding overlay surface err={}", .{err}, @@ -2241,7 +2246,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Build the overlay as configured. Returns null if there is no /// overlay currently configured. - fn rebuildOverlay(self: *Self) Overlay.InitError!void { + fn rebuildOverlay( + self: *Self, + features: []const Overlay.Feature, + ) Overlay.InitError!void { // const start = std.time.Instant.now() catch unreachable; // const start_micro = std.time.microTimestamp(); // defer { @@ -2256,7 +2264,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If we have no features enabled, don't build an overlay. // If we had a previous overlay, deallocate it. - if (overlay_features.len == 0) { + if (features.len == 0) { if (self.overlay) |*old| { old.deinit(alloc); self.overlay = null; @@ -2277,7 +2285,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { overlay.applyFeatures( alloc, &self.terminal_state, - overlay_features, + features, ); } diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index f7d3c735f..35826d97e 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3889,6 +3889,12 @@ pub fn countTrackedPins(self: *const PageList) usize { return self.tracked_pins.count(); } +/// Returns the tracked pins for this pagelist. The slice is owned by the +/// pagelist and is only valid until the pagelist is modified. +pub fn trackedPins(self: *const PageList) []const *Pin { + return self.tracked_pins.keys(); +} + /// Checks if a pin is valid for this pagelist. This is a very slow and /// expensive operation since we traverse the entire linked list in the /// worst case. Only for runtime safety/debug. @@ -5085,7 +5091,7 @@ pub const Pin = struct { } }; -const Cell = struct { +pub const Cell = struct { node: *List.Node, row: *pagepkg.Row, cell: *pagepkg.Cell, diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index 23a5048e1..deeecf553 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -147,6 +147,20 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { } } + /// Returns the total capacity in bytes. + pub fn capacityBytes(self: Self) usize { + return self.bitmap_count * bitmap_bit_size * chunk_size; + } + + /// Returns the number of bytes currently in use. + pub fn usedBytes(self: Self, base: anytype) usize { + const bitmaps = self.bitmap.ptr(base); + var free_chunks: usize = 0; + for (bitmaps[0..self.bitmap_count]) |bitmap| free_chunks += @popCount(bitmap); + const total_chunks = self.bitmap_count * bitmap_bit_size; + return (total_chunks - free_chunks) * chunk_size; + } + /// For testing only. fn isAllocated(self: *Self, base: anytype, slice: anytype) bool { comptime assert(@import("builtin").is_test); diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index d0d2c1bb3..a78a4c336 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -707,19 +707,21 @@ pub fn Stream(comptime Handler: type) type { const action = action_opt orelse continue; if (comptime debug) log.info("action: {f}", .{action}); - // 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: { + // A handler can expose this to get the raw action before + // it is further parsed. If this returns `true` then we skip + // processing ourselves. + if (@hasDecl(T, "vtRaw")) { + const skip = self.handler.vtRaw(action) catch |err| err: { log.warn("error handling action manually err={} action={f}", .{ err, action, }); - - break :err false; + // Always skip erroneous actions because we can't + // be sure... + break :err true; }; - if (processed) continue; + if (skip) continue; } switch (action) { diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index a1bcea6d3..f46e2ec05 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -694,7 +694,11 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void { // below but at least users only pay for it if they're using the inspector. if (self.renderer_state.inspector) |insp| { for (buf, 0..) |byte, i| { - insp.recordPtyRead(buf[i .. i + 1]) catch |err| { + insp.recordPtyRead( + self.alloc, + &self.terminal, + buf[i .. i + 1], + ) catch |err| { log.err("error recording pty read in inspector err={}", .{err}); };