From 0ed63dffd37fe5b4183b2a24530f1eed5d05af35 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 29 Jan 2026 13:10:00 -0600 Subject: [PATCH 001/108] core: don't log redraw_surface or redraw_inspector app messages They are _very_ verbose and make other debug logs difficult to read. --- src/App.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/App.zig b/src/App.zig index 00be56f49..d3e37e86d 100644 --- a/src/App.zig +++ b/src/App.zig @@ -237,7 +237,11 @@ pub fn needsConfirmQuit(self: *const App) bool { /// Drain the mailbox. fn drainMailbox(self: *App, rt_app: *apprt.App) !void { while (self.mailbox.pop()) |message| { - log.debug("mailbox message={s}", .{@tagName(message)}); + switch (message) { + // these tend to be way too verbose for normal debugging + .redraw_surface, .redraw_inspector => {}, + else => log.debug("mailbox message={s}", .{@tagName(message)}), + } switch (message) { .open_config => try self.performAction(rt_app, .open_config), .new_window => |msg| try self.newWindow(rt_app, msg), From f04ac78624e256c8d9d351b5160c778a1f1b9dea Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 29 Jan 2026 13:14:11 -0600 Subject: [PATCH 002/108] core: use 0.15 native tag name conversion --- src/App.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.zig b/src/App.zig index d3e37e86d..cd8b67164 100644 --- a/src/App.zig +++ b/src/App.zig @@ -240,7 +240,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void { switch (message) { // these tend to be way too verbose for normal debugging .redraw_surface, .redraw_inspector => {}, - else => log.debug("mailbox message={s}", .{@tagName(message)}), + else => log.debug("mailbox message={t}", .{message}), } switch (message) { .open_config => try self.performAction(rt_app, .open_config), From 8bf03ff64ab8dc4329ff8441da3d192b13da06c9 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 29 Jan 2026 13:18:13 -0600 Subject: [PATCH 003/108] core: guard app message logging to ensure it's optimized away in release builds --- src/App.zig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/App.zig b/src/App.zig index cd8b67164..2ead3b9ad 100644 --- a/src/App.zig +++ b/src/App.zig @@ -237,10 +237,12 @@ pub fn needsConfirmQuit(self: *const App) bool { /// Drain the mailbox. fn drainMailbox(self: *App, rt_app: *apprt.App) !void { while (self.mailbox.pop()) |message| { - switch (message) { - // these tend to be way too verbose for normal debugging - .redraw_surface, .redraw_inspector => {}, - else => log.debug("mailbox message={t}", .{message}), + if (std.log.logEnabled(.debug, .app)) { + switch (message) { + // these tend to be way too verbose for normal debugging + .redraw_surface, .redraw_inspector => {}, + else => log.debug("mailbox message={t}", .{message}), + } } switch (message) { .open_config => try self.performAction(rt_app, .open_config), From 9df9374e9068ced2482bc79c7675b93f90427d33 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 29 Jan 2026 13:31:48 -0600 Subject: [PATCH 004/108] core: ensure comptime evaluation --- src/App.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.zig b/src/App.zig index 2ead3b9ad..3e83e414d 100644 --- a/src/App.zig +++ b/src/App.zig @@ -237,7 +237,7 @@ pub fn needsConfirmQuit(self: *const App) bool { /// Drain the mailbox. fn drainMailbox(self: *App, rt_app: *apprt.App) !void { while (self.mailbox.pop()) |message| { - if (std.log.logEnabled(.debug, .app)) { + if (comptime std.log.logEnabled(.debug, .app)) { switch (message) { // these tend to be way too verbose for normal debugging .redraw_surface, .redraw_inspector => {}, From db8623e257406c62f1bf5482cd9dc250ff48a808 Mon Sep 17 00:00:00 2001 From: faukah Date: Fri, 30 Jan 2026 20:37:11 +0100 Subject: [PATCH 005/108] flake.nix: drop desc argument for runVM function The current runVM function gets called only with a module and without a desc, resulting in an error when running `nix flake show` or similar commands. This commit drops the `desc` argument to `runVM` and gets rid of that problem. --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index c96004a09..945dcd946 100644 --- a/flake.nix +++ b/flake.nix @@ -101,7 +101,7 @@ formatter = forAllPlatforms (pkgs: pkgs.alejandra); apps = forBuildablePlatforms (pkgs: let - runVM = module: desc: let + runVM = module: let vm = import ./nix/vm/create.nix { inherit (pkgs.stdenv.hostPlatform) system; inherit module nixpkgs; From 025885aa25a118f130c85e3fb879d3d041e51f2e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 12:22:24 -0800 Subject: [PATCH 006/108] renderer: generalize and extract image renderer state This extracts all our image renderer state into a separate struct, blandly named `renderer.image.State`. This structure owns all the storage of images and placements and exposes a limited public API to manage them. One motivation was to limit state access by our Kitty graphics functions within the generic renderer. Another was to limit our own generic renderer from getting our image system into an incoherent state. This is prevented now on both sides due to some encapsulation. This currently only supports Kitty images, since that's the only image protocol we support. But I intend to add additional image types to this, namely the ability to add overlay images for debug information. **There are no plans to add new image protocols to the terminal,** the extraction is purely to support some internal features. But, it could be used for other protocols one day. --- src/renderer/generic.zig | 402 ++------------------------- src/renderer/image.zig | 579 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 597 insertions(+), 384 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index e75171721..6c083b6c2 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -18,9 +18,7 @@ const constraintWidth = cellpkg.constraintWidth; const isCovering = cellpkg.isCovering; const rowNeverExtendBg = @import("row.zig").neverExtendBg; const imagepkg = @import("image.zig"); -const Image = imagepkg.Image; -const ImageMap = imagepkg.ImageMap; -const ImagePlacementList = std.ArrayListUnmanaged(imagepkg.Placement); +const ImageState = imagepkg.State; const shadertoy = @import("shadertoy.zig"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; @@ -169,11 +167,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { font_shaper_cache: font.ShaperCache, /// The images that we may render. - images: ImageMap = .{}, - image_placements: ImagePlacementList = .{}, - image_bg_end: u32 = 0, - image_text_end: u32 = 0, - image_virtual: bool = false, + images: ImageState = .empty, /// Background image, if we have one. bg_image: ?imagepkg.Image = null, @@ -806,12 +800,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.config.deinit(); - { - var it = self.images.iterator(); - while (it.next()) |kv| kv.value_ptr.image.deinit(self.alloc); - self.images.deinit(self.alloc); - } - self.image_placements.deinit(self.alloc); + self.images.deinit(self.alloc); if (self.bg_image) |img| img.deinit(self.alloc); @@ -1190,10 +1179,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If we have any virtual references, we must also rebuild our // kitty state on every frame because any cell change can move // an image. - if (state.terminal.screens.active.kitty_images.dirty or - self.image_virtual) - { - self.prepKittyGraphics(state.terminal); + if (self.images.kittyRequiresUpdate(state.terminal)) { + self.images.kittyUpdate( + self.alloc, + state.terminal, + .{ + .width = self.grid_metrics.cell_width, + .height = self.grid_metrics.cell_height, + }, + ); } // Get our OSC8 links we're hovering if we have a mouse. @@ -1460,7 +1454,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Upload images to the GPU as necessary. - try self.uploadKittyImages(); + _ = self.images.upload(self.alloc, &self.api); // Upload the background image to the GPU as necessary. try self.uploadBackgroundImage(); @@ -1542,9 +1536,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Then we draw any kitty images that need // to be behind text AND cell backgrounds. - try self.drawImagePlacements( + self.images.draw( + &self.api, + self.shaders.pipelines.image, &pass, - self.image_placements.items[0..self.image_bg_end], + .kitty_below_bg, ); // Then we draw any opaque cell backgrounds. @@ -1556,9 +1552,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }); // Kitty images between cell backgrounds and text. - try self.drawImagePlacements( + self.images.draw( + &self.api, + self.shaders.pipelines.image, &pass, - self.image_placements.items[self.image_bg_end..self.image_text_end], + .kitty_below_text, ); // Text. @@ -1581,9 +1579,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }); // Kitty images in front of text. - try self.drawImagePlacements( + self.images.draw( + &self.api, + self.shaders.pipelines.image, &pass, - self.image_placements.items[self.image_text_end..], + .kitty_above_text, ); } @@ -1707,358 +1707,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - /// This goes through the Kitty graphic placements and accumulates the - /// placements we need to render on our viewport. - fn prepKittyGraphics( - self: *Self, - t: *terminal.Terminal, - ) void { - self.draw_mutex.lock(); - defer self.draw_mutex.unlock(); - - const storage = &t.screens.active.kitty_images; - defer storage.dirty = false; - - // We always clear our previous placements no matter what because - // we rebuild them from scratch. - self.image_placements.clearRetainingCapacity(); - self.image_virtual = false; - - // Go through our known images and if there are any that are no longer - // in use then mark them to be freed. - // - // This never conflicts with the below because a placement can't - // reference an image that doesn't exist. - { - var it = self.images.iterator(); - while (it.next()) |kv| { - if (storage.imageById(kv.key_ptr.*) == null) { - kv.value_ptr.image.markForUnload(); - } - } - } - - // The top-left and bottom-right corners of our viewport in screen - // points. This lets us determine offsets and containment of placements. - const top = t.screens.active.pages.getTopLeft(.viewport); - const bot = t.screens.active.pages.getBottomRight(.viewport).?; - const top_y = t.screens.active.pages.pointFromPin(.screen, top).?.screen.y; - const bot_y = t.screens.active.pages.pointFromPin(.screen, bot).?.screen.y; - - // Go through the placements and ensure the image is - // on the GPU or else is ready to be sent to the GPU. - var it = storage.placements.iterator(); - while (it.next()) |kv| { - const p = kv.value_ptr; - - // Special logic based on location - switch (p.location) { - .pin => {}, - .virtual => { - // We need to mark virtual placements on our renderer so that - // we know to rebuild in more scenarios since cell changes can - // now trigger placement changes. - self.image_virtual = true; - - // We also continue out because virtual placements are - // only triggered by the unicode placeholder, not by the - // placement itself. - continue; - }, - } - - // Get the image for the placement - const image = storage.imageById(kv.key_ptr.image_id) orelse { - log.warn( - "missing image for placement, ignoring image_id={}", - .{kv.key_ptr.image_id}, - ); - continue; - }; - - self.prepKittyPlacement( - t, - top_y, - bot_y, - &image, - p, - ) catch |err| { - // For errors we log and continue. We try to place - // other placements even if one fails. - log.warn("error preparing kitty placement err={}", .{err}); - }; - } - - // If we have virtual placements then we need to scan for placeholders. - if (self.image_virtual) { - var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot); - while (v_it.next()) |virtual_p| { - self.prepKittyVirtualPlacement( - t, - &virtual_p, - ) catch |err| { - // For errors we log and continue. We try to place - // other placements even if one fails. - log.warn("error preparing kitty placement err={}", .{err}); - }; - } - } - - // Sort the placements by their Z value. - std.mem.sortUnstable( - imagepkg.Placement, - self.image_placements.items, - {}, - struct { - fn lessThan( - ctx: void, - lhs: imagepkg.Placement, - rhs: imagepkg.Placement, - ) bool { - _ = ctx; - return lhs.z < rhs.z or (lhs.z == rhs.z and lhs.image_id < rhs.image_id); - } - }.lessThan, - ); - - // Find our indices. The values are sorted by z so we can - // find the first placement out of bounds to find the limits. - var bg_end: ?u32 = null; - var text_end: ?u32 = null; - const bg_limit = std.math.minInt(i32) / 2; - for (self.image_placements.items, 0..) |p, i| { - if (bg_end == null and p.z >= bg_limit) { - bg_end = @intCast(i); - } - if (text_end == null and p.z >= 0) { - text_end = @intCast(i); - } - } - - // If we didn't see any images with a z > the bg limit, - // then our bg end is the end of our placement list. - self.image_bg_end = - bg_end orelse @intCast(self.image_placements.items.len); - - // Same idea for the image_text_end. - self.image_text_end = - text_end orelse @intCast(self.image_placements.items.len); - } - - fn prepKittyVirtualPlacement( - self: *Self, - t: *terminal.Terminal, - p: *const terminal.kitty.graphics.unicode.Placement, - ) PrepKittyImageError!void { - const storage = &t.screens.active.kitty_images; - const image = storage.imageById(p.image_id) orelse { - log.warn( - "missing image for virtual placement, ignoring image_id={}", - .{p.image_id}, - ); - return; - }; - - const rp = p.renderPlacement( - storage, - &image, - self.grid_metrics.cell_width, - self.grid_metrics.cell_height, - ) catch |err| { - log.warn("error rendering virtual placement err={}", .{err}); - return; - }; - - // If our placement is zero sized then we don't do anything. - if (rp.dest_width == 0 or rp.dest_height == 0) return; - - const viewport: terminal.point.Point = t.screens.active.pages.pointFromPin( - .viewport, - rp.top_left, - ) orelse { - // This is unreachable with virtual placements because we should - // only ever be looking at virtual placements that are in our - // viewport in the renderer and virtual placements only ever take - // up one row. - unreachable; - }; - - // Prepare the image for the GPU and store the placement. - try self.prepKittyImage(&image); - try self.image_placements.append(self.alloc, .{ - .image_id = image.id, - .x = @intCast(rp.top_left.x), - .y = @intCast(viewport.viewport.y), - .z = -1, - .width = rp.dest_width, - .height = rp.dest_height, - .cell_offset_x = rp.offset_x, - .cell_offset_y = rp.offset_y, - .source_x = rp.source_x, - .source_y = rp.source_y, - .source_width = rp.source_width, - .source_height = rp.source_height, - }); - } - - /// Get the viewport-relative position for this - /// placement and add it to the placements list. - fn prepKittyPlacement( - self: *Self, - t: *terminal.Terminal, - top_y: u32, - bot_y: u32, - image: *const terminal.kitty.graphics.Image, - p: *const terminal.kitty.graphics.ImageStorage.Placement, - ) PrepKittyImageError!void { - // Get the rect for the placement. If this placement doesn't have - // a rect then its virtual or something so skip it. - const rect = p.rect(image.*, t) orelse return; - - // This is expensive but necessary. - const img_top_y = t.screens.active.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - const img_bot_y = t.screens.active.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; - - // If the selection isn't within our viewport then skip it. - if (img_top_y > bot_y) return; - if (img_bot_y < top_y) return; - - // We need to prep this image for upload if it isn't in the - // cache OR it is in the cache but the transmit time doesn't - // match meaning this image is different. - try self.prepKittyImage(image); - - // Calculate the dimensions of our image, taking in to - // account the rows / columns specified by the placement. - const dest_size = p.calculatedSize(image.*, t); - - // Calculate the source rectangle - const source_x = @min(image.width, p.source_x); - const source_y = @min(image.height, p.source_y); - const source_width = if (p.source_width > 0) - @min(image.width - source_x, p.source_width) - else - image.width; - const source_height = if (p.source_height > 0) - @min(image.height - source_y, p.source_height) - else - image.height; - - // Get the viewport-relative Y position of the placement. - const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); - - // Accumulate the placement - if (dest_size.width > 0 and dest_size.height > 0) { - try self.image_placements.append(self.alloc, .{ - .image_id = image.id, - .x = @intCast(rect.top_left.x), - .y = y_pos, - .z = p.z, - .width = dest_size.width, - .height = dest_size.height, - .cell_offset_x = p.x_offset, - .cell_offset_y = p.y_offset, - .source_x = source_x, - .source_y = source_y, - .source_width = source_width, - .source_height = source_height, - }); - } - } - - const PrepKittyImageError = error{ - OutOfMemory, - ImageConversionError, - }; - - /// Prepare the provided image for upload to the GPU by copying its - /// data with our allocator and setting it to the pending state. - fn prepKittyImage( - self: *Self, - image: *const terminal.kitty.graphics.Image, - ) PrepKittyImageError!void { - // If this image exists and its transmit time is the same we assume - // it is the identical image so we don't need to send it to the GPU. - const gop = try self.images.getOrPut(self.alloc, image.id); - if (gop.found_existing and - gop.value_ptr.transmit_time.order(image.transmit_time) == .eq) - { - return; - } - - // Copy the data into the pending state. - const data = if (self.alloc.dupe( - u8, - image.data, - )) |v| v else |_| { - if (!gop.found_existing) { - // If this is a new entry we can just remove it since it - // was never sent to the GPU. - _ = self.images.remove(image.id); - } else { - // If this was an existing entry, it is invalid and - // we must unload it. - gop.value_ptr.image.markForUnload(); - } - - return error.OutOfMemory; - }; - // Note: we don't need to errdefer free the data because it is - // put into the map immediately below and our errdefer to - // handle our map state will fix this up. - - // Store it in the map - const new_image: Image = .{ - .pending = .{ - .width = image.width, - .height = image.height, - .pixel_format = switch (image.format) { - .gray => .gray, - .gray_alpha => .gray_alpha, - .rgb => .rgb, - .rgba => .rgba, - .png => unreachable, // should be decoded by now - }, - .data = data.ptr, - }, - }; - if (!gop.found_existing) { - gop.value_ptr.* = .{ - .image = new_image, - .transmit_time = undefined, - }; - } else { - gop.value_ptr.image.markForReplace( - self.alloc, - new_image, - ); - } - - // If any error happens, we unload the image and it is invalid. - errdefer gop.value_ptr.image.markForUnload(); - - gop.value_ptr.image.prepForUpload(self.alloc) catch |err| { - log.warn("error preparing kitty image for upload err={}", .{err}); - return error.ImageConversionError; - }; - gop.value_ptr.transmit_time = image.transmit_time; - } - - /// Upload any images to the GPU that need to be uploaded, - /// and remove any images that are no longer needed on the GPU. - fn uploadKittyImages(self: *Self) !void { - var image_it = self.images.iterator(); - while (image_it.next()) |kv| { - const img = &kv.value_ptr.image; - if (img.isUnloading()) { - img.deinit(self.alloc); - self.images.removeByPtr(kv.key_ptr); - continue; - } - if (img.isPending()) try img.upload(self.alloc, &self.api); - } - } - /// Call this any time the background image path changes. /// /// Caller must hold the draw mutex. diff --git a/src/renderer/image.zig b/src/renderer/image.zig index bf0f7b736..dd5d8bed9 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -2,16 +2,546 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = @import("../quirks.zig").inlineAssert; const wuffs = @import("wuffs"); +const terminal = @import("../terminal/main.zig"); const Renderer = @import("../renderer.zig").Renderer; const GraphicsAPI = Renderer.API; const Texture = GraphicsAPI.Texture; +const CellSize = @import("size.zig").CellSize; + +const log = std.log.scoped(.renderer_image); + +/// Generic image rendering state for the renderer. This stores all +/// images and their placements and exposes only a limited public API +/// for adding images and placements and drawing them. +pub const State = struct { + /// The full image state for the renderer that specifies what images + /// need to be uploaded, pruned, etc. + images: ImageMap, + + /// The placements for the Kitty image protocol. + kitty_placements: std.ArrayListUnmanaged(Placement), + + /// The end index (exclusive) for placements that should be + /// drawn below the background, below the text, etc. + kitty_bg_end: u32, + kitty_text_end: u32, + + /// True if there are any virtual placements. This needs to be known + /// because virtual placements need to be recalculated more often + /// on frame builds and are generally more expensive to handle. + kitty_virtual: bool, + + pub const empty: State = .{ + .images = .empty, + .kitty_placements = .empty, + .kitty_bg_end = 0, + .kitty_text_end = 0, + .kitty_virtual = false, + }; + + pub fn deinit(self: *State, alloc: Allocator) void { + { + var it = self.images.iterator(); + while (it.next()) |kv| kv.value_ptr.image.deinit(alloc); + self.images.deinit(alloc); + } + self.kitty_placements.deinit(alloc); + } + + /// Upload any images to the GPU that need to be uploaded, + /// and remove any images that are no longer needed on the GPU. + /// + /// If any uploads fail, they are ignored. The return value + /// can be used to detect if upload was a total success (true) + /// or not (false). + pub fn upload( + self: *State, + alloc: Allocator, + api: *GraphicsAPI, + ) bool { + var success: bool = true; + var image_it = self.images.iterator(); + while (image_it.next()) |kv| { + const img = &kv.value_ptr.image; + if (img.isUnloading()) { + img.deinit(alloc); + self.images.removeByPtr(kv.key_ptr); + continue; + } + + if (img.isPending()) { + img.upload( + alloc, + api, + ) catch |err| { + log.warn("error uploading image to GPU err={}", .{err}); + success = false; + }; + } + } + + return success; + } + + pub const DrawPlacements = enum { + kitty_below_bg, + kitty_below_text, + kitty_above_text, + }; + + /// Draw the given named set of placements. + /// + /// Any placements that have non-uploaded images are ignored. Any + /// graphics API errors during drawing are also ignored. + pub fn draw( + self: *State, + api: *GraphicsAPI, + pipeline: GraphicsAPI.Pipeline, + pass: *GraphicsAPI.RenderPass, + placement_type: DrawPlacements, + ) void { + const placements: []const Placement = switch (placement_type) { + .kitty_below_bg => self.kitty_placements.items[0..self.kitty_bg_end], + .kitty_below_text => self.kitty_placements.items[self.kitty_bg_end..self.kitty_text_end], + .kitty_above_text => self.kitty_placements.items[self.kitty_text_end..], + }; + + for (placements) |p| { + // Look up the image + const image = self.images.get(p.image_id) orelse { + log.warn("image not found for placement image_id={}", .{p.image_id}); + continue; + }; + + // Get the texture + const texture = switch (image.image) { + .ready, + .unload_ready, + => |t| t, + else => { + log.warn("image not ready for placement image_id={}", .{p.image_id}); + continue; + }, + }; + + // Create our vertex buffer, which is always exactly one item. + // future(mitchellh): we can group rendering multiple instances of a single image + var buf = GraphicsAPI.Buffer(GraphicsAPI.shaders.Image).initFill( + api.imageBufferOptions(), + &.{.{ + .grid_pos = .{ + @as(f32, @floatFromInt(p.x)), + @as(f32, @floatFromInt(p.y)), + }, + + .cell_offset = .{ + @as(f32, @floatFromInt(p.cell_offset_x)), + @as(f32, @floatFromInt(p.cell_offset_y)), + }, + + .source_rect = .{ + @as(f32, @floatFromInt(p.source_x)), + @as(f32, @floatFromInt(p.source_y)), + @as(f32, @floatFromInt(p.source_width)), + @as(f32, @floatFromInt(p.source_height)), + }, + + .dest_size = .{ + @as(f32, @floatFromInt(p.width)), + @as(f32, @floatFromInt(p.height)), + }, + }}, + ) catch |err| { + log.warn("error creating image vertex buffer err={}", .{err}); + continue; + }; + defer buf.deinit(); + + pass.step(.{ + .pipeline = pipeline, + .buffers = &.{buf.buffer}, + .textures = &.{texture}, + .draw = .{ + .type = .triangle_strip, + .vertex_count = 4, + }, + }); + } + } + + /// Returns true if the Kitty graphics state requires an update based + /// on the terminal state and our internal state. + /// + /// This does not read/write state used by drawing. + pub fn kittyRequiresUpdate( + self: *const State, + t: *const terminal.Terminal, + ) bool { + // If the terminal kitty image state is dirty, we must update. + if (t.screens.active.kitty_images.dirty) return true; + + // If we have any virtual references, we must also rebuild our + // kitty state on every frame because any cell change can move + // an image. If the virtual placements were removed, this will + // be set to false on the next update. + if (self.kitty_virtual) return true; + + return false; + } + + /// Update the Kitty graphics state from the terminal. + /// + /// This reads/writes state used by drawing. + pub fn kittyUpdate( + self: *State, + alloc: Allocator, + t: *const terminal.Terminal, + cell_size: CellSize, + ) void { + const storage = &t.screens.active.kitty_images; + defer storage.dirty = false; + + // We always clear our previous placements no matter what because + // we rebuild them from scratch. + self.kitty_placements.clearRetainingCapacity(); + self.kitty_virtual = false; + + // Go through our known images and if there are any that are no longer + // in use then mark them to be freed. + // + // This never conflicts with the below because a placement can't + // reference an image that doesn't exist. + { + var it = self.images.iterator(); + while (it.next()) |kv| { + switch (kv.key_ptr.*) { + // We're only looking at Kitty images + .kitty => |id| if (storage.imageById(id) == null) { + kv.value_ptr.image.markForUnload(); + }, + } + } + } + + // The top-left and bottom-right corners of our viewport in screen + // points. This lets us determine offsets and containment of placements. + const top = t.screens.active.pages.getTopLeft(.viewport); + const bot = t.screens.active.pages.getBottomRight(.viewport).?; + const top_y = t.screens.active.pages.pointFromPin(.screen, top).?.screen.y; + const bot_y = t.screens.active.pages.pointFromPin(.screen, bot).?.screen.y; + + // Go through the placements and ensure the image is + // on the GPU or else is ready to be sent to the GPU. + var it = storage.placements.iterator(); + while (it.next()) |kv| { + const p = kv.value_ptr; + + // Special logic based on location + switch (p.location) { + .pin => {}, + .virtual => { + // We need to mark virtual placements on our renderer so that + // we know to rebuild in more scenarios since cell changes can + // now trigger placement changes. + self.kitty_virtual = true; + + // We also continue out because virtual placements are + // only triggered by the unicode placeholder, not by the + // placement itself. + continue; + }, + } + + // Get the image for the placement + const image = storage.imageById(kv.key_ptr.image_id) orelse { + log.warn( + "missing image for placement, ignoring image_id={}", + .{kv.key_ptr.image_id}, + ); + continue; + }; + + self.prepKittyPlacement( + alloc, + t, + top_y, + bot_y, + &image, + p, + ) catch |err| { + // For errors we log and continue. We try to place + // other placements even if one fails. + log.warn("error preparing kitty placement err={}", .{err}); + }; + } + + // If we have virtual placements then we need to scan for placeholders. + if (self.kitty_virtual) { + var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot); + while (v_it.next()) |virtual_p| { + self.prepKittyVirtualPlacement( + alloc, + t, + &virtual_p, + cell_size, + ) catch |err| { + // For errors we log and continue. We try to place + // other placements even if one fails. + log.warn("error preparing kitty placement err={}", .{err}); + }; + } + } + + // Sort the placements by their Z value. + std.mem.sortUnstable( + Placement, + self.kitty_placements.items, + {}, + struct { + fn lessThan( + ctx: void, + lhs: Placement, + rhs: Placement, + ) bool { + _ = ctx; + return lhs.z < rhs.z or + (lhs.z == rhs.z and lhs.image_id.zLessThan(rhs.image_id)); + } + }.lessThan, + ); + + // Find our indices. The values are sorted by z so we can + // find the first placement out of bounds to find the limits. + const bg_limit = std.math.minInt(i32) / 2; + var bg_end: ?u32 = null; + var text_end: ?u32 = null; + for (self.kitty_placements.items, 0..) |p, i| { + if (bg_end == null and p.z >= bg_limit) bg_end = @intCast(i); + if (text_end == null and p.z >= 0) text_end = @intCast(i); + } + + // If we didn't see any images with a z > the bg limit, + // then our bg end is the end of our placement list. + self.kitty_bg_end = + bg_end orelse @intCast(self.kitty_placements.items.len); + // Same idea for the image_text_end. + self.kitty_text_end = + text_end orelse @intCast(self.kitty_placements.items.len); + } + + const PrepKittyImageError = error{ + OutOfMemory, + ImageConversionError, + }; + + /// Get the viewport-relative position for this + /// placement and add it to the placements list. + fn prepKittyPlacement( + self: *State, + alloc: Allocator, + t: *const terminal.Terminal, + top_y: u32, + bot_y: u32, + image: *const terminal.kitty.graphics.Image, + p: *const terminal.kitty.graphics.ImageStorage.Placement, + ) PrepKittyImageError!void { + // Get the rect for the placement. If this placement doesn't have + // a rect then its virtual or something so skip it. + const rect = p.rect(image.*, t) orelse return; + + // This is expensive but necessary. + const img_top_y = t.screens.active.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const img_bot_y = t.screens.active.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; + + // If the selection isn't within our viewport then skip it. + if (img_top_y > bot_y) return; + if (img_bot_y < top_y) return; + + // We need to prep this image for upload if it isn't in the + // cache OR it is in the cache but the transmit time doesn't + // match meaning this image is different. + try self.prepKittyImage(alloc, image); + + // Calculate the dimensions of our image, taking in to + // account the rows / columns specified by the placement. + const dest_size = p.calculatedSize(image.*, t); + + // Calculate the source rectangle + const source_x = @min(image.width, p.source_x); + const source_y = @min(image.height, p.source_y); + const source_width = if (p.source_width > 0) + @min(image.width - source_x, p.source_width) + else + image.width; + const source_height = if (p.source_height > 0) + @min(image.height - source_y, p.source_height) + else + image.height; + + // Get the viewport-relative Y position of the placement. + const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); + + // Accumulate the placement + if (dest_size.width > 0 and dest_size.height > 0) { + try self.kitty_placements.append(alloc, .{ + .image_id = .{ .kitty = image.id }, + .x = @intCast(rect.top_left.x), + .y = y_pos, + .z = p.z, + .width = dest_size.width, + .height = dest_size.height, + .cell_offset_x = p.x_offset, + .cell_offset_y = p.y_offset, + .source_x = source_x, + .source_y = source_y, + .source_width = source_width, + .source_height = source_height, + }); + } + } + + fn prepKittyVirtualPlacement( + self: *State, + alloc: Allocator, + t: *const terminal.Terminal, + p: *const terminal.kitty.graphics.unicode.Placement, + cell_size: CellSize, + ) PrepKittyImageError!void { + const storage = &t.screens.active.kitty_images; + const image = storage.imageById(p.image_id) orelse { + log.warn( + "missing image for virtual placement, ignoring image_id={}", + .{p.image_id}, + ); + return; + }; + + const rp = p.renderPlacement( + storage, + &image, + cell_size.width, + cell_size.height, + ) catch |err| { + log.warn("error rendering virtual placement err={}", .{err}); + return; + }; + + // If our placement is zero sized then we don't do anything. + if (rp.dest_width == 0 or rp.dest_height == 0) return; + + const viewport: terminal.point.Point = t.screens.active.pages.pointFromPin( + .viewport, + rp.top_left, + ) orelse { + // This is unreachable with virtual placements because we should + // only ever be looking at virtual placements that are in our + // viewport in the renderer and virtual placements only ever take + // up one row. + unreachable; + }; + + // Prepare the image for the GPU and store the placement. + try self.prepKittyImage(alloc, &image); + try self.kitty_placements.append(alloc, .{ + .image_id = .{ .kitty = image.id }, + .x = @intCast(rp.top_left.x), + .y = @intCast(viewport.viewport.y), + .z = -1, + .width = rp.dest_width, + .height = rp.dest_height, + .cell_offset_x = rp.offset_x, + .cell_offset_y = rp.offset_y, + .source_x = rp.source_x, + .source_y = rp.source_y, + .source_width = rp.source_width, + .source_height = rp.source_height, + }); + } + + /// Prepare the provided image for upload to the GPU by copying its + /// data with our allocator and setting it to the pending state. + fn prepKittyImage( + self: *State, + alloc: Allocator, + image: *const terminal.kitty.graphics.Image, + ) PrepKittyImageError!void { + // If this image exists and its transmit time is the same we assume + // it is the identical image so we don't need to send it to the GPU. + const gop = try self.images.getOrPut( + alloc, + .{ .kitty = image.id }, + ); + if (gop.found_existing and + gop.value_ptr.transmit_time.order(image.transmit_time) == .eq) + { + return; + } + + // Copy the data into the pending state. + const data = if (alloc.dupe( + u8, + image.data, + )) |v| v else |_| { + if (!gop.found_existing) { + // If this is a new entry we can just remove it since it + // was never sent to the GPU. + _ = self.images.remove(.{ .kitty = image.id }); + } else { + // If this was an existing entry, it is invalid and + // we must unload it. + gop.value_ptr.image.markForUnload(); + } + + return error.OutOfMemory; + }; + // Note: we don't need to errdefer free the data because it is + // put into the map immediately below and our errdefer to + // handle our map state will fix this up. + + // Store it in the map + const new_image: Image = .{ + .pending = .{ + .width = image.width, + .height = image.height, + .pixel_format = switch (image.format) { + .gray => .gray, + .gray_alpha => .gray_alpha, + .rgb => .rgb, + .rgba => .rgba, + .png => unreachable, // should be decoded by now + }, + .data = data.ptr, + }, + }; + if (!gop.found_existing) { + gop.value_ptr.* = .{ + .image = new_image, + .transmit_time = undefined, + }; + } else { + gop.value_ptr.image.markForReplace( + alloc, + new_image, + ); + } + + // If any error happens, we unload the image and it is invalid. + errdefer gop.value_ptr.image.markForUnload(); + + gop.value_ptr.image.prepForUpload(alloc) catch |err| { + log.warn("error preparing kitty image for upload err={}", .{err}); + return error.ImageConversionError; + }; + gop.value_ptr.transmit_time = image.transmit_time; + } +}; /// Represents a single image placement on the grid. /// A placement is a request to render an instance of an image. pub const Placement = struct { /// The image being rendered. This MUST be in the image map. - image_id: u32, + image_id: Id, /// The grid x/y where this placement is located. x: i32, @@ -34,8 +564,37 @@ pub const Placement = struct { source_height: u32, }; +/// Image identifier used to store and lookup images. +/// +/// This is tagged by different image types to make it easier to +/// store different kinds of images in the same map without having +/// to worry about ID collisions. +pub const Id = union(enum) { + /// Image sent to the terminal state via the kitty graphics protocol. + /// The value is the ID assigned by the terminal. + kitty: u32, + + /// Z-ordering tie-breaker for images with the same z value. + pub fn zLessThan(lhs: Id, rhs: Id) bool { + // If our tags aren't the same, we sort by tag. + if (std.meta.activeTag(lhs) != std.meta.activeTag(rhs)) { + return switch (lhs) { + // Kitty images always sort before (lower z) non-kitty images. + .kitty => false, + }; + } + + switch (lhs) { + .kitty => |lhs_id| { + const rhs_id = rhs.kitty; + return lhs_id < rhs_id; + }, + } + } +}; + /// The map used for storing images. -pub const ImageMap = std.AutoHashMapUnmanaged(u32, struct { +pub const ImageMap = std.AutoHashMapUnmanaged(Id, struct { image: Image, transmit_time: std.time.Instant, }); @@ -221,27 +780,33 @@ pub const Image = union(enum) { try self.convert(alloc); } - /// Upload the pending image to the GPU and - /// change the state of this image to ready. + /// Upload the pending image to the GPU and change the state of this + /// image to ready. pub fn upload( self: *Image, alloc: Allocator, api: *const GraphicsAPI, - ) !void { + ) (wuffs.Error || error{ + /// Texture creation failed, usually a GPU memory issue. + UploadFailed, + })!void { assert(self.isPending()); + // No error recover is required after this call because it just + // converts in place and is idempotent. try self.prepForUpload(alloc); // Get our pending info const p = self.getPending().?; // Create our texture - const texture = try Texture.init( + const texture = Texture.init( api.imageTextureOptions(.rgba, true), @intCast(p.width), @intCast(p.height), p.dataSlice(), - ); + ) catch return error.UploadFailed; + errdefer comptime unreachable; // Uploaded. We can now clear our data and change our state. // From f176342537ffd0c4a62d7717345d829b703094fa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 11:23:45 -0800 Subject: [PATCH 007/108] renderer: overlay system --- src/renderer/Overlay.zig | 182 +++++++++++++++++++++++++++++++++++++++ src/renderer/generic.zig | 31 +++++++ 2 files changed, 213 insertions(+) create mode 100644 src/renderer/Overlay.zig diff --git a/src/renderer/Overlay.zig b/src/renderer/Overlay.zig new file mode 100644 index 000000000..9a1a0d3ac --- /dev/null +++ b/src/renderer/Overlay.zig @@ -0,0 +1,182 @@ +const Overlay = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const z2d = @import("z2d"); +const terminal = @import("../terminal/main.zig"); +const size = @import("size.zig"); +const Size = size.Size; +const CellSize = size.CellSize; + +/// The surface we're drawing our overlay to. +surface: z2d.Surface, + +/// Cell size information so we can map grid coordinates to pixels. +cell_size: CellSize, + +/// The transformation to apply to the overlay to account for the +/// screen padding. +padding_transformation: z2d.Transformation, + +/// Initialize a new, blank overlay. +pub fn init(alloc: Allocator, sz: Size) !Overlay { + var sfc: z2d.Surface = try .initPixel( + .{ .rgba = .{ .r = 0, .g = 0, .b = 0, .a = 0 } }, + alloc, + std.math.cast(i32, sz.screen.width).?, + std.math.cast(i32, sz.screen.height).?, + ); + errdefer sfc.deinit(alloc); + + return .{ + .surface = sfc, + .cell_size = sz.cell, + .padding_transformation = .{ + .ax = 1, + .by = 0, + .cx = 0, + .dy = 1, + .tx = @as(f64, @floatFromInt(sz.padding.left)), + .ty = @as(f64, @floatFromInt(sz.padding.top)), + }, + }; +} + +pub fn deinit(self: *Overlay, alloc: Allocator) void { + self.surface.deinit(alloc); +} + +/// Add rectangles around continguous hyperlinks in the render state. +/// +/// Note: this currently doesn't take into account unique hyperlink IDs +/// because the render state doesn't contain this. This will be added +/// later. +pub fn highlightHyperlinks( + self: *Overlay, + alloc: Allocator, + state: *const terminal.RenderState, +) void { + // Border and fill colors (premultiplied alpha, 50% alpha for fill) + const border_color: z2d.Pixel = .{ .rgba = .{ + .r = 128, + .g = 128, + .b = 255, + .a = 255, + } }; + // Fill: 50% alpha (128/255), so premultiply RGB by 128/255 + const fill_color: z2d.Pixel = .{ .rgba = .{ + .r = 64, + .g = 64, + .b = 128, + .a = 128, + } }; + + const row_slice = state.row_data.slice(); + const row_cells = row_slice.items(.cells); + for (row_cells, 0..) |cells, y| { + const cells_slice = cells.slice(); + const raw_cells = cells_slice.items(.raw); + + var x: usize = 0; + while (x < raw_cells.len) { + // Skip cells without hyperlinks + if (!raw_cells[x].hyperlink) { + x += 1; + continue; + } + + // Found start of a hyperlink run + const start_x = x; + + // Find end of contiguous hyperlink cells + while (x < raw_cells.len and raw_cells[x].hyperlink) x += 1; + const end_x = x; + + self.highlightRect( + alloc, + start_x, + y, + end_x - start_x, + 1, + border_color, + fill_color, + ) catch |err| { + std.log.warn("Error drawing hyperlink border: {}", .{err}); + }; + } + } +} + +/// Creates a rectangle for highlighting a grid region. x/y/width/height +/// are all in grid cells. +fn highlightRect( + self: *Overlay, + alloc: Allocator, + x: usize, + y: usize, + width: usize, + height: usize, + border_color: z2d.Pixel, + fill_color: z2d.Pixel, +) !void { + // All math below uses checked arithmetic to avoid overflows. The + // inputs aren't trusted and the path this is in isn't hot enough + // to wrarrant unsafe optimizations. + + // Calculate our width/height in pixels. + const px_width = std.math.cast(i32, try std.math.mul( + usize, + width, + self.cell_size.width, + )) orelse return error.Overflow; + const px_height = std.math.cast(i32, try std.math.mul( + usize, + height, + self.cell_size.height, + )) orelse return error.Overflow; + + // Calculate pixel coordinates + const start_x: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + x, + self.cell_size.width, + )) orelse return error.Overflow); + const start_y: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + y, + self.cell_size.height, + )) orelse return error.Overflow); + const end_x: f64 = start_x + @as(f64, @floatFromInt(px_width)); + const end_y: f64 = start_y + @as(f64, @floatFromInt(px_height)); + + // Grab our context to draw + var ctx = self.newContext(alloc); + defer ctx.deinit(); + + // Draw rectangle path + try ctx.moveTo(start_x, start_y); + try ctx.lineTo(end_x, start_y); + try ctx.lineTo(end_x, end_y); + try ctx.lineTo(start_x, end_y); + try ctx.closePath(); + + // Fill + ctx.setSourceToPixel(fill_color); + try ctx.fill(); + + // Border + ctx.setLineWidth(1); + ctx.setSourceToPixel(border_color); + try ctx.stroke(); +} + +/// Creates a new context for drawing to the overlay that takes into +/// account the padding transformation so you can work directly in the +/// terminal's coordinate space. +/// +/// Caller must deinit the context when done. +fn newContext(self: *Overlay, alloc: Allocator) z2d.Context { + var ctx: z2d.Context = .init(alloc, &self.surface); + ctx.setTransformation(self.padding_transformation); + return ctx; +} diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 6c083b6c2..e40e55632 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1282,6 +1282,29 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Reset our dirty state after updating. defer self.terminal_state.dirty = .false; + // Rebuild the overlay image set. + overlay: { + const alloc = arena_alloc; + + // Create a surface that is the size of the entire screen, + // including padding. It is transparent, since we'll overlay + // it on top of our screen. + var overlay: Overlay = self.rebuildOverlay(alloc) catch |err| { + log.warn("error rebuilding overlay surface err={}", .{err}); + break :overlay; + }; + defer overlay.deinit(alloc); + + // Grab our mutex so we can upload some images. + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // IMPORTANT: This must be done AFTER kitty graphics + // are setup because Kitty graphics will clear all our + // "unused" images and our overlay will appear unused since + // its not part of the Kitty state. + } + // Acquire the draw mutex for all remaining state updates. { self.draw_mutex.lock(); @@ -2179,6 +2202,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } + const Overlay = @import("Overlay.zig"); + + fn rebuildOverlay(self: *Self, alloc: Allocator) !Overlay { + var overlay: Overlay = try .init(alloc, self.size); + overlay.highlightHyperlinks(alloc, &self.terminal_state); + return overlay; + } + const PreeditRange = struct { y: terminal.size.CellCountInt, x: [2]terminal.size.CellCountInt, From 3931c45c6aa8aa193bc791dd87e2213010c40265 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 14:21:47 -0800 Subject: [PATCH 008/108] renderer: image state supports overlay --- src/renderer/image.zig | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/renderer/image.zig b/src/renderer/image.zig index dd5d8bed9..3ef7c31c5 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -220,6 +220,8 @@ pub const State = struct { .kitty => |id| if (storage.imageById(id) == null) { kv.value_ptr.image.markForUnload(); }, + + .overlay => {}, } } } @@ -574,13 +576,19 @@ pub const Id = union(enum) { /// The value is the ID assigned by the terminal. kitty: u32, + /// Debug overlay. This is always composited down to a single + /// image for now. In the future we can support layers here if we want. + overlay, + /// Z-ordering tie-breaker for images with the same z value. pub fn zLessThan(lhs: Id, rhs: Id) bool { // If our tags aren't the same, we sort by tag. if (std.meta.activeTag(lhs) != std.meta.activeTag(rhs)) { return switch (lhs) { // Kitty images always sort before (lower z) non-kitty images. - .kitty => false, + .kitty => true, + + .overlay => false, }; } @@ -589,6 +597,9 @@ pub const Id = union(enum) { const rhs_id = rhs.kitty; return lhs_id < rhs_id; }, + + // No sensical ordering + .overlay => return false, } } }; From f5c652a488ba1880287f6bd50a6b8c7d9640c6af Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 14:58:04 -0800 Subject: [PATCH 009/108] renderer: image can draw overlays --- src/renderer/Overlay.zig | 11 ++++ src/renderer/generic.zig | 51 ++++++++------- src/renderer/image.zig | 135 +++++++++++++++++++++++++++++++-------- 3 files changed, 146 insertions(+), 51 deletions(-) diff --git a/src/renderer/Overlay.zig b/src/renderer/Overlay.zig index 9a1a0d3ac..347ab34c5 100644 --- a/src/renderer/Overlay.zig +++ b/src/renderer/Overlay.zig @@ -7,6 +7,7 @@ const terminal = @import("../terminal/main.zig"); const size = @import("size.zig"); const Size = size.Size; const CellSize = size.CellSize; +const Image = @import("image.zig").Image; /// The surface we're drawing our overlay to. surface: z2d.Surface, @@ -46,6 +47,16 @@ pub fn deinit(self: *Overlay, alloc: Allocator) void { self.surface.deinit(alloc); } +/// Returns a pending image that can be used to copy, convert, upload, etc. +pub fn pendingImage(self: *const Overlay) Image.Pending { + return .{ + .width = @intCast(self.surface.getWidth()), + .height = @intCast(self.surface.getHeight()), + .pixel_format = .rgba, + .data = @ptrCast(self.surface.image_surface_rgba.buf.ptr), + }; +} + /// Add rectangles around continguous hyperlinks in the render state. /// /// Note: this currently doesn't take into account unique hyperlink IDs diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index e40e55632..b817a8cf9 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -17,6 +17,7 @@ const noMinContrast = cellpkg.noMinContrast; const constraintWidth = cellpkg.constraintWidth; const isCovering = cellpkg.isCovering; const rowNeverExtendBg = @import("row.zig").neverExtendBg; +const Overlay = @import("Overlay.zig"); const imagepkg = @import("image.zig"); const ImageState = imagepkg.State; const shadertoy = @import("shadertoy.zig"); @@ -1282,28 +1283,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Reset our dirty state after updating. defer self.terminal_state.dirty = .false; - // Rebuild the overlay image set. - overlay: { - const alloc = arena_alloc; - - // Create a surface that is the size of the entire screen, - // including padding. It is transparent, since we'll overlay - // it on top of our screen. - var overlay: Overlay = self.rebuildOverlay(alloc) catch |err| { - log.warn("error rebuilding overlay surface err={}", .{err}); - break :overlay; - }; - defer overlay.deinit(alloc); - - // Grab our mutex so we can upload some images. - self.draw_mutex.lock(); - defer self.draw_mutex.unlock(); - - // IMPORTANT: This must be done AFTER kitty graphics - // are setup because Kitty graphics will clear all our - // "unused" images and our overlay will appear unused since - // its not part of the Kitty state. - } + // Rebuild the overlay image if we have one. + const overlay: ?Overlay = self.rebuildOverlay( + arena_alloc, + ) catch |err| overlay: { + log.warn("error rebuilding overlay surface err={}", .{err}); + break :overlay null; + }; // Acquire the draw mutex for all remaining state updates. { @@ -1354,6 +1340,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { else => {}, }; + // Prepare our overlay image for upload (or unload). This + // has to use our general allocator since it modifies + // state that survives frames. + self.images.overlayUpdate( + self.alloc, + overlay, + ) catch |err| { + log.warn("error updating overlay images err={}", .{err}); + }; + // Update custom shader uniforms that depend on terminal state. self.updateCustomShaderUniformsFromState(); } @@ -1608,6 +1604,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { &pass, .kitty_above_text, ); + + // Debug overlay. We do this before any custom shader state + // because our debug overlay is aligned with the grid. + self.images.draw( + &self.api, + self.shaders.pipelines.image, + &pass, + .overlay, + ); } // If we have custom shaders, then we render them. @@ -2202,8 +2207,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - const Overlay = @import("Overlay.zig"); - fn rebuildOverlay(self: *Self, alloc: Allocator) !Overlay { var overlay: Overlay = try .init(alloc, self.size); overlay.highlightHyperlinks(alloc, &self.terminal_state); diff --git a/src/renderer/image.zig b/src/renderer/image.zig index 3ef7c31c5..85f3a01ed 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -8,6 +8,7 @@ const Renderer = @import("../renderer.zig").Renderer; const GraphicsAPI = Renderer.API; const Texture = GraphicsAPI.Texture; const CellSize = @import("size.zig").CellSize; +const Overlay = @import("Overlay.zig"); const log = std.log.scoped(.renderer_image); @@ -32,12 +33,16 @@ pub const State = struct { /// on frame builds and are generally more expensive to handle. kitty_virtual: bool, + /// Overlays + overlay_placements: std.ArrayListUnmanaged(Placement), + pub const empty: State = .{ .images = .empty, .kitty_placements = .empty, .kitty_bg_end = 0, .kitty_text_end = 0, .kitty_virtual = false, + .overlay_placements = .empty, }; pub fn deinit(self: *State, alloc: Allocator) void { @@ -47,6 +52,7 @@ pub const State = struct { self.images.deinit(alloc); } self.kitty_placements.deinit(alloc); + self.overlay_placements.deinit(alloc); } /// Upload any images to the GPU that need to be uploaded, @@ -88,6 +94,7 @@ pub const State = struct { kitty_below_bg, kitty_below_text, kitty_above_text, + overlay, }; /// Draw the given named set of placements. @@ -105,6 +112,7 @@ pub const State = struct { .kitty_below_bg => self.kitty_placements.items[0..self.kitty_bg_end], .kitty_below_text => self.kitty_placements.items[self.kitty_bg_end..self.kitty_text_end], .kitty_above_text => self.kitty_placements.items[self.kitty_text_end..], + .overlay => self.overlay_placements.items, }; for (placements) |p| { @@ -170,6 +178,57 @@ pub const State = struct { } } + /// Update our overlay state. Null value deletes any existing overlay. + pub fn overlayUpdate( + self: *State, + alloc: Allocator, + overlay_: ?Overlay, + ) !void { + const overlay = overlay_ orelse { + // If we don't have an overlay, remove any existing one. + if (self.images.getPtr(.overlay)) |data| { + data.image.markForUnload(); + } + return; + }; + + // For transmit time we always just use the current time + // and overwrite the overlay. + const transmit_time = try std.time.Instant.now(); + + // Ensure we have space for our overlay placement. Do this before + // we upload our image so we don't have to deal with cleaning + // that up. + self.overlay_placements.clearRetainingCapacity(); + try self.overlay_placements.ensureUnusedCapacity(alloc, 1); + + // Setup our image. + const pending = overlay.pendingImage(); + try self.prepImage( + alloc, + .overlay, + transmit_time, + pending, + ); + errdefer comptime unreachable; + + // Setup our placement + self.overlay_placements.appendAssumeCapacity(.{ + .image_id = .overlay, + .x = 0, + .y = 0, + .z = 0, + .width = pending.width, + .height = pending.height, + .cell_offset_x = 0, + .cell_offset_y = 0, + .source_x = 0, + .source_y = 0, + .source_width = pending.width, + .source_height = pending.height, + }); + } + /// Returns true if the Kitty graphics state requires an update based /// on the terminal state and our internal state. /// @@ -332,7 +391,7 @@ pub const State = struct { text_end orelse @intCast(self.kitty_placements.items.len); } - const PrepKittyImageError = error{ + const PrepImageError = error{ OutOfMemory, ImageConversionError, }; @@ -347,7 +406,7 @@ pub const State = struct { bot_y: u32, image: *const terminal.kitty.graphics.Image, p: *const terminal.kitty.graphics.ImageStorage.Placement, - ) PrepKittyImageError!void { + ) PrepImageError!void { // Get the rect for the placement. If this placement doesn't have // a rect then its virtual or something so skip it. const rect = p.rect(image.*, t) orelse return; @@ -409,7 +468,7 @@ pub const State = struct { t: *const terminal.Terminal, p: *const terminal.kitty.graphics.unicode.Placement, cell_size: CellSize, - ) PrepKittyImageError!void { + ) PrepImageError!void { const storage = &t.screens.active.kitty_images; const image = storage.imageById(p.image_id) orelse { log.warn( @@ -461,34 +520,32 @@ pub const State = struct { }); } - /// Prepare the provided image for upload to the GPU by copying its - /// data with our allocator and setting it to the pending state. - fn prepKittyImage( + /// Prepare an image for upload to the GPU. + fn prepImage( self: *State, alloc: Allocator, - image: *const terminal.kitty.graphics.Image, - ) PrepKittyImageError!void { + id: Id, + transmit_time: std.time.Instant, + pending: Image.Pending, + ) PrepImageError!void { // If this image exists and its transmit time is the same we assume // it is the identical image so we don't need to send it to the GPU. - const gop = try self.images.getOrPut( - alloc, - .{ .kitty = image.id }, - ); + const gop = try self.images.getOrPut(alloc, id); if (gop.found_existing and - gop.value_ptr.transmit_time.order(image.transmit_time) == .eq) + gop.value_ptr.transmit_time.order(transmit_time) == .eq) { return; } - // Copy the data into the pending state. + // Copy the data so we own it. const data = if (alloc.dupe( u8, - image.data, + pending.dataSlice(), )) |v| v else |_| { if (!gop.found_existing) { // If this is a new entry we can just remove it since it // was never sent to the GPU. - _ = self.images.remove(.{ .kitty = image.id }); + _ = self.images.remove(id); } else { // If this was an existing entry, it is invalid and // we must unload it. @@ -504,15 +561,9 @@ pub const State = struct { // Store it in the map const new_image: Image = .{ .pending = .{ - .width = image.width, - .height = image.height, - .pixel_format = switch (image.format) { - .gray => .gray, - .gray_alpha => .gray_alpha, - .rgb => .rgb, - .rgba => .rgba, - .png => unreachable, // should be decoded by now - }, + .width = pending.width, + .height = pending.height, + .pixel_format = pending.pixel_format, .data = data.ptr, }, }; @@ -532,10 +583,40 @@ pub const State = struct { errdefer gop.value_ptr.image.markForUnload(); gop.value_ptr.image.prepForUpload(alloc) catch |err| { - log.warn("error preparing kitty image for upload err={}", .{err}); + log.warn("error preparing image for upload err={}", .{err}); return error.ImageConversionError; }; - gop.value_ptr.transmit_time = image.transmit_time; + gop.value_ptr.transmit_time = transmit_time; + } + + /// Prepare the provided Kitty image for upload to the GPU by copying its + /// data with our allocator and setting it to the pending state. + fn prepKittyImage( + self: *State, + alloc: Allocator, + image: *const terminal.kitty.graphics.Image, + ) PrepImageError!void { + try self.prepImage( + alloc, + .{ .kitty = image.id }, + image.transmit_time, + .{ + .width = image.width, + .height = image.height, + .pixel_format = switch (image.format) { + .gray => .gray, + .gray_alpha => .gray_alpha, + .rgb => .rgb, + .rgba => .rgba, + .png => unreachable, // should be decoded by now + }, + + // constCasts are always gross but this one is safe is because + // the data is only read from here and copied into its own + // buffer. + .data = @constCast(image.data.ptr), + }, + ); } }; From ed7f190fff75d74fea986c42263375f24ce319a1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 15:03:36 -0800 Subject: [PATCH 010/108] renderer: overlay doesn't need to account for padding --- src/renderer/Overlay.zig | 45 ++++++++++++---------------------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/src/renderer/Overlay.zig b/src/renderer/Overlay.zig index 347ab34c5..130d33361 100644 --- a/src/renderer/Overlay.zig +++ b/src/renderer/Overlay.zig @@ -15,31 +15,23 @@ surface: z2d.Surface, /// Cell size information so we can map grid coordinates to pixels. cell_size: CellSize, -/// The transformation to apply to the overlay to account for the -/// screen padding. -padding_transformation: z2d.Transformation, - /// Initialize a new, blank overlay. pub fn init(alloc: Allocator, sz: Size) !Overlay { + // Our surface does NOT need to take into account padding because + // we render the overlay using the image subsystem and shaders which + // already take that into account. + const term_size = sz.terminal(); var sfc: z2d.Surface = try .initPixel( .{ .rgba = .{ .r = 0, .g = 0, .b = 0, .a = 0 } }, alloc, - std.math.cast(i32, sz.screen.width).?, - std.math.cast(i32, sz.screen.height).?, + std.math.cast(i32, term_size.width).?, + std.math.cast(i32, term_size.height).?, ); errdefer sfc.deinit(alloc); return .{ .surface = sfc, .cell_size = sz.cell, - .padding_transformation = .{ - .ax = 1, - .by = 0, - .cx = 0, - .dy = 1, - .tx = @as(f64, @floatFromInt(sz.padding.left)), - .ty = @as(f64, @floatFromInt(sz.padding.top)), - }, }; } @@ -57,7 +49,7 @@ pub fn pendingImage(self: *const Overlay) Image.Pending { }; } -/// Add rectangles around continguous hyperlinks in the render state. +/// Add rectangles around contiguous hyperlinks in the render state. /// /// Note: this currently doesn't take into account unique hyperlink IDs /// because the render state doesn't contain this. This will be added @@ -69,16 +61,16 @@ pub fn highlightHyperlinks( ) void { // Border and fill colors (premultiplied alpha, 50% alpha for fill) const border_color: z2d.Pixel = .{ .rgba = .{ - .r = 128, - .g = 128, + .r = 180, + .g = 180, .b = 255, .a = 255, } }; // Fill: 50% alpha (128/255), so premultiply RGB by 128/255 const fill_color: z2d.Pixel = .{ .rgba = .{ - .r = 64, - .g = 64, - .b = 128, + .r = 90, + .g = 90, + .b = 180, .a = 128, } }; @@ -161,7 +153,7 @@ fn highlightRect( const end_y: f64 = start_y + @as(f64, @floatFromInt(px_height)); // Grab our context to draw - var ctx = self.newContext(alloc); + var ctx: z2d.Context = .init(alloc, &self.surface); defer ctx.deinit(); // Draw rectangle path @@ -180,14 +172,3 @@ fn highlightRect( ctx.setSourceToPixel(border_color); try ctx.stroke(); } - -/// Creates a new context for drawing to the overlay that takes into -/// account the padding transformation so you can work directly in the -/// terminal's coordinate space. -/// -/// Caller must deinit the context when done. -fn newContext(self: *Overlay, alloc: Allocator) z2d.Context { - var ctx: z2d.Context = .init(alloc, &self.surface); - ctx.setTransformation(self.padding_transformation); - return ctx; -} From fa06849dcc54c51bfbafe7bead55fb48f17d469b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 15:11:12 -0800 Subject: [PATCH 011/108] renderer: overlay explicit error sets --- src/renderer/Overlay.zig | 21 ++++++++++++++++----- src/renderer/generic.zig | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/renderer/Overlay.zig b/src/renderer/Overlay.zig index 130d33361..898e4cf93 100644 --- a/src/renderer/Overlay.zig +++ b/src/renderer/Overlay.zig @@ -15,18 +15,29 @@ surface: z2d.Surface, /// Cell size information so we can map grid coordinates to pixels. cell_size: CellSize, +pub const InitError = Allocator.Error || error{ + // The terminal dimensions are invalid to support an overlay. + // Either too small or too big. + InvalidDimensions, +}; + /// Initialize a new, blank overlay. -pub fn init(alloc: Allocator, sz: Size) !Overlay { +pub fn init(alloc: Allocator, sz: Size) InitError!Overlay { // Our surface does NOT need to take into account padding because // we render the overlay using the image subsystem and shaders which // already take that into account. const term_size = sz.terminal(); - var sfc: z2d.Surface = try .initPixel( + var sfc = z2d.Surface.initPixel( .{ .rgba = .{ .r = 0, .g = 0, .b = 0, .a = 0 } }, alloc, - std.math.cast(i32, term_size.width).?, - std.math.cast(i32, term_size.height).?, - ); + std.math.cast(i32, term_size.width) orelse + return error.InvalidDimensions, + std.math.cast(i32, term_size.height) orelse + return error.InvalidDimensions, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidWidth, error.InvalidHeight => return error.InvalidDimensions, + }; errdefer sfc.deinit(alloc); return .{ diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index b817a8cf9..3585706c0 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2207,7 +2207,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - fn rebuildOverlay(self: *Self, alloc: Allocator) !Overlay { + fn rebuildOverlay(self: *Self, alloc: Allocator) Overlay.InitError!Overlay { var overlay: Overlay = try .init(alloc, self.size); overlay.highlightHyperlinks(alloc, &self.terminal_state); return overlay; From daed17c58a4774972130a9a6ed4520cdde774513 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 15:24:46 -0800 Subject: [PATCH 012/108] renderer: make overlay features configurable --- src/renderer/Overlay.zig | 35 ++++++++++++++++++++++++++++++++++- src/renderer/generic.zig | 23 +++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/renderer/Overlay.zig b/src/renderer/Overlay.zig index 898e4cf93..922cf76e8 100644 --- a/src/renderer/Overlay.zig +++ b/src/renderer/Overlay.zig @@ -1,3 +1,15 @@ +/// The debug overlay that can be drawn on top of the terminal +/// during the rendering process. +/// +/// This is implemented by doing all the drawing on the CPU via z2d, +/// since the debug overlay isn't that common, z2d is pretty fast, and +/// it simplifies our implementation quite a bit by not relying on us +/// having a bunch of shaders that we have to write per-platform. +/// +/// Initialize the overlay, apply features with `applyFeatures`, then +/// get the resulting image with `pendingImage` to upload to the GPU. +/// This works in concert with `renderer.image.State` to simplify. Draw +/// it on the GPU as an image composited on top of the terminal output. const Overlay = @This(); const std = @import("std"); @@ -15,6 +27,11 @@ surface: z2d.Surface, /// Cell size information so we can map grid coordinates to pixels. cell_size: CellSize, +/// The set of available features and their configuration. +pub const Feature = union(enum) { + highlight_hyperlinks, +}; + pub const InitError = Allocator.Error || error{ // The terminal dimensions are invalid to support an overlay. // Either too small or too big. @@ -60,12 +77,28 @@ pub fn pendingImage(self: *const Overlay) Image.Pending { }; } +/// Apply the given features to this overlay. This will draw on top of +/// any pre-existing content in the overlay. +pub fn applyFeatures( + self: *Overlay, + alloc: Allocator, + state: *const terminal.RenderState, + features: []const Feature, +) void { + for (features) |f| switch (f) { + .highlight_hyperlinks => self.highlightHyperlinks( + alloc, + state, + ), + }; +} + /// Add rectangles around contiguous hyperlinks in the render state. /// /// Note: this currently doesn't take into account unique hyperlink IDs /// because the render state doesn't contain this. This will be added /// later. -pub fn highlightHyperlinks( +fn highlightHyperlinks( self: *Overlay, alloc: Allocator, state: *const terminal.RenderState, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 3585706c0..77c3cc257 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2207,9 +2207,28 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - fn rebuildOverlay(self: *Self, alloc: Allocator) Overlay.InitError!Overlay { + /// Build the overlay as configured. Returns null if there is no + /// overlay currently configured. + fn rebuildOverlay( + self: *Self, + alloc: Allocator, + ) Overlay.InitError!?Overlay { + // 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 features: []const Overlay.Feature = &.{ + //.highlight_hyperlinks, + }; + + // If we have no features enabled, don't build an overlay. + if (features.len == 0) return null; + var overlay: Overlay = try .init(alloc, self.size); - overlay.highlightHyperlinks(alloc, &self.terminal_state); + overlay.applyFeatures( + alloc, + &self.terminal_state, + features, + ); return overlay; } From d4f7c11a383c7c23a8420e4d806c42c954e6de15 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 15:28:11 -0800 Subject: [PATCH 013/108] renderer: cache the overlay between calls --- src/renderer/Overlay.zig | 15 +++++++- src/renderer/generic.zig | 81 +++++++++++++++++++++++++++++----------- 2 files changed, 74 insertions(+), 22 deletions(-) diff --git a/src/renderer/Overlay.zig b/src/renderer/Overlay.zig index 922cf76e8..67bf84705 100644 --- a/src/renderer/Overlay.zig +++ b/src/renderer/Overlay.zig @@ -77,6 +77,16 @@ pub fn pendingImage(self: *const Overlay) Image.Pending { }; } +/// Clear the overlay. +pub fn reset(self: *Overlay) void { + self.surface.paintPixel(.{ .rgba = .{ + .r = 0, + .g = 0, + .b = 0, + .a = 0, + } }); +} + /// Apply the given features to this overlay. This will draw on top of /// any pre-existing content in the overlay. pub fn applyFeatures( @@ -119,8 +129,11 @@ fn highlightHyperlinks( } }; const row_slice = state.row_data.slice(); + const row_raw = row_slice.items(.raw); const row_cells = row_slice.items(.cells); - for (row_cells, 0..) |cells, y| { + for (row_raw, row_cells, 0..) |row, cells, y| { + if (!row.hyperlink) continue; + const cells_slice = cells.slice(); const raw_cells = cells_slice.items(.raw); diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 77c3cc257..4b63927ed 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -222,6 +222,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// a large screen. terminal_state_frame_count: usize = 0, + /// 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, @@ -782,6 +792,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } pub fn deinit(self: *Self) void { + if (self.overlay) |*overlay| overlay.deinit(self.alloc); self.terminal_state.deinit(self.alloc); if (self.search_selected_match) |*m| m.arena.deinit(); if (self.search_matches) |*m| m.arena.deinit(); @@ -1108,6 +1119,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { state: *renderer.State, cursor_blink_visible: bool, ) Allocator.Error!void { + const start = std.time.Instant.now() catch unreachable; + const start_micro = std.time.microTimestamp(); + defer { + const end = std.time.Instant.now() catch unreachable; + log.warn( + "[updateFrame time] start_micro={} duration={}ns", + .{ start_micro, end.since(start) / std.time.ns_per_us }, + ); + } + // We fully deinit and reset the terminal state every so often // so that a particularly large terminal state doesn't cause // the renderer to hold on to retained memory. @@ -1283,12 +1304,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Reset our dirty state after updating. defer self.terminal_state.dirty = .false; - // Rebuild the overlay image if we have one. - const overlay: ?Overlay = self.rebuildOverlay( - arena_alloc, - ) catch |err| overlay: { - log.warn("error rebuilding overlay surface err={}", .{err}); - break :overlay null; + // Rebuild the overlay image if we have one. We can do this + // outside of any critical areas. + self.rebuildOverlay() catch |err| { + log.warn( + "error rebuilding overlay surface err={}", + .{err}, + ); }; // Acquire the draw mutex for all remaining state updates. @@ -1345,7 +1367,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // state that survives frames. self.images.overlayUpdate( self.alloc, - overlay, + self.overlay, ) catch |err| { log.warn("error updating overlay images err={}", .{err}); }; @@ -2209,27 +2231,44 @@ 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, - alloc: Allocator, - ) Overlay.InitError!?Overlay { - // 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 features: []const Overlay.Feature = &.{ - //.highlight_hyperlinks, - }; + fn rebuildOverlay(self: *Self) Overlay.InitError!void { + const start = std.time.Instant.now() catch unreachable; + const start_micro = std.time.microTimestamp(); + defer { + const end = std.time.Instant.now() catch unreachable; + log.warn( + "[rebuildOverlay time] start_micro={} duration={}ns", + .{ start_micro, end.since(start) / std.time.ns_per_us }, + ); + } + + const alloc = self.alloc; // If we have no features enabled, don't build an overlay. - if (features.len == 0) return null; + // If we had a previous overlay, deallocate it. + if (overlay_features.len == 0) { + if (self.overlay) |*old| { + old.deinit(alloc); + self.overlay = null; + } - var overlay: Overlay = try .init(alloc, self.size); + return null; + } + + // If we had a previous overlay, clear it. Otherwise, init. + const overlay: *Overlay = if (self.overlay) |*v| overlay: { + v.reset(); + break :overlay v; + } else overlay: { + const new: Overlay = try .init(alloc, self.size); + self.overlay = new; + break :overlay &self.overlay.?; + }; overlay.applyFeatures( alloc, &self.terminal_state, - features, + overlay_features, ); - return overlay; } const PreeditRange = struct { From 693035eaaf2b115f60310526412f9881f9f14310 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 20:37:56 -0800 Subject: [PATCH 014/108] renderer: turn off AA and turn on hairline --- src/renderer/Overlay.zig | 26 ++++++++++++-------------- src/renderer/generic.zig | 10 ++++++++++ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/renderer/Overlay.zig b/src/renderer/Overlay.zig index 67bf84705..7eb94acb5 100644 --- a/src/renderer/Overlay.zig +++ b/src/renderer/Overlay.zig @@ -113,20 +113,13 @@ fn highlightHyperlinks( alloc: Allocator, state: *const terminal.RenderState, ) void { - // Border and fill colors (premultiplied alpha, 50% alpha for fill) - const border_color: z2d.Pixel = .{ .rgba = .{ - .r = 180, - .g = 180, - .b = 255, - .a = 255, - } }; - // Fill: 50% alpha (128/255), so premultiply RGB by 128/255 - const fill_color: z2d.Pixel = .{ .rgba = .{ - .r = 90, - .g = 90, - .b = 180, - .a = 128, - } }; + const border_fill_rgb: z2d.pixel.RGB = .{ .r = 180, .g = 180, .b = 255 }; + const border_color = border_fill_rgb.asPixel(); + const fill_color: z2d.Pixel = px: { + var rgba: z2d.pixel.RGBA = .fromPixel(border_color); + rgba.a = 128; + break :px rgba.multiply().asPixel(); + }; const row_slice = state.row_data.slice(); const row_raw = row_slice.items(.raw); @@ -213,6 +206,11 @@ fn highlightRect( var ctx: z2d.Context = .init(alloc, &self.surface); defer ctx.deinit(); + // Don't need AA because we use sharp edges + ctx.setAntiAliasingMode(.none); + // Can use hairline since we have 1px borders + ctx.setHairline(true); + // Draw rectangle path try ctx.moveTo(start_x, start_y); try ctx.lineTo(end_x, start_y); diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 4b63927ed..9144f3427 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1389,6 +1389,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, sync: bool, ) !void { + // const start = std.time.Instant.now() catch unreachable; + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // log.warn( + // "[drawFrame time] start_micro={} duration={}ns", + // .{ start_micro, end.since(start) / std.time.ns_per_us }, + // ); + // } + // We hold a the draw mutex to prevent changes to any // data we access while we're in the middle of drawing. self.draw_mutex.lock(); From d3e1b1bc19272f5cb1db30cb305a5070551e6bf2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 20:42:45 -0800 Subject: [PATCH 015/108] disable debug --- src/renderer/generic.zig | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 9144f3427..7f0e3e00c 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -229,7 +229,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // 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, + //.highlight_hyperlinks, }; const HighlightTag = enum(u8) { @@ -1119,15 +1119,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { state: *renderer.State, cursor_blink_visible: bool, ) Allocator.Error!void { - const start = std.time.Instant.now() catch unreachable; - const start_micro = std.time.microTimestamp(); - defer { - const end = std.time.Instant.now() catch unreachable; - log.warn( - "[updateFrame time] start_micro={} duration={}ns", - .{ start_micro, end.since(start) / std.time.ns_per_us }, - ); - } + // const start = std.time.Instant.now() catch unreachable; + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // log.warn( + // "[updateFrame time] start_micro={} duration={}ns", + // .{ start_micro, end.since(start) / std.time.ns_per_us }, + // ); + // } // We fully deinit and reset the terminal state every so often // so that a particularly large terminal state doesn't cause @@ -2242,15 +2242,15 @@ 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 { - const start = std.time.Instant.now() catch unreachable; - const start_micro = std.time.microTimestamp(); - defer { - const end = std.time.Instant.now() catch unreachable; - log.warn( - "[rebuildOverlay time] start_micro={} duration={}ns", - .{ start_micro, end.since(start) / std.time.ns_per_us }, - ); - } + // const start = std.time.Instant.now() catch unreachable; + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // log.warn( + // "[rebuildOverlay time] start_micro={} duration={}ns", + // .{ start_micro, end.since(start) / std.time.ns_per_us }, + // ); + // } const alloc = self.alloc; @@ -2262,7 +2262,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.overlay = null; } - return null; + return; } // If we had a previous overlay, clear it. Otherwise, init. From 377c6d770078d3d388f716a775f5275a9da55466 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 10:00:36 -0800 Subject: [PATCH 016/108] inspector: add AGENTS.md --- src/inspector/AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/inspector/AGENTS.md diff --git a/src/inspector/AGENTS.md b/src/inspector/AGENTS.md new file mode 100644 index 000000000..3b6281438 --- /dev/null +++ b/src/inspector/AGENTS.md @@ -0,0 +1,8 @@ +# Inspector Subsystem + +- 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. From 41b5dc492881c79ce96cab9fa60d8fca3393cf9f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 09:08:37 -0800 Subject: [PATCH 017/108] inspector: terminal tab --- src/inspector/Inspector.zig | 10 +- src/inspector/main.zig | 1 + src/inspector/terminal.zig | 215 ++++++++++++++++++++++++++++++++++++ src/inspector/widgets.zig | 14 +++ 4 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 src/inspector/terminal.zig create mode 100644 src/inspector/widgets.zig diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 6ffb43d43..a5204ef89 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -62,6 +62,11 @@ need_scroll_to_selected: bool = false, /// Flag indicating whether the selection was made by keyboard is_keyboard_selection: bool = false, +/// Windows +windows: struct { + terminal: inspector.terminal.Window = .{}, +} = .{}, + /// Enum representing keyboard navigation actions const KeyAction = enum { down, @@ -229,6 +234,8 @@ pub fn render(self: *Inspector) void { { self.surface.renderer_state.mutex.lock(); defer self.surface.renderer_state.mutex.unlock(); + const t = self.surface.renderer_state.terminal; + self.windows.terminal.render(t); self.renderScreenWindow(); self.renderModesWindow(); self.renderKeyboardWindow(); @@ -257,7 +264,7 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { _ = self; // Our initial focus - cimgui.c.ImGui_SetWindowFocusStr(window_screen); + cimgui.c.ImGui_SetWindowFocusStr(inspector.terminal.Window.name); // Setup our initial layout. const dock_id: struct { @@ -285,6 +292,7 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { 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(inspector.terminal.Window.name, 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); diff --git a/src/inspector/main.zig b/src/inspector/main.zig index ee871f200..8299c0a0d 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -4,6 +4,7 @@ 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 terminal = @import("terminal.zig"); pub const Cell = cell.Cell; pub const Inspector = @import("Inspector.zig"); diff --git a/src/inspector/terminal.zig b/src/inspector/terminal.zig new file mode 100644 index 000000000..981fb1293 --- /dev/null +++ b/src/inspector/terminal.zig @@ -0,0 +1,215 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const cimgui = @import("dcimgui"); +const terminal = @import("../terminal/main.zig"); +const Terminal = terminal.Terminal; +const widgets = @import("widgets.zig"); + +/// Window to show terminal state information. +pub const Window = struct { + /// Window name/id. + pub const name = "Terminal"; + + // Render + pub fn render(self: *Window, t: *Terminal) void { + _ = self; + + // Start our window. If we're collapsed we do nothing. + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( + name, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) return; + + if (cimgui.c.ImGui_CollapsingHeader( + "General", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + _ = cimgui.c.ImGui_BeginTable( + "table_general", + 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)"); + } + } + } + } + + if (cimgui.c.ImGui_CollapsingHeader( + "Layout", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + _ = 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 we modified it then update our scrolling region + // directly. + if (changed and + override.top < override.bottom and + override.left < override.right) + { + t.scrolling_region = override; + } + } + } + } // cursor + } +}; diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig new file mode 100644 index 000000000..224e8b1ba --- /dev/null +++ b/src/inspector/widgets.zig @@ -0,0 +1,14 @@ +const cimgui = @import("dcimgui"); + +/// 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); +} From 03f012f567a03f48bf0e5f7b6f9245c709ee4164 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 12:41:22 -0800 Subject: [PATCH 018/108] inspector: terminal colors --- src/inspector/terminal.zig | 215 ++++++++++++++++++++++++++++++++++++- 1 file changed, 213 insertions(+), 2 deletions(-) diff --git a/src/inspector/terminal.zig b/src/inspector/terminal.zig index 981fb1293..5310afc89 100644 --- a/src/inspector/terminal.zig +++ b/src/inspector/terminal.zig @@ -10,9 +10,11 @@ pub const Window = struct { /// Window name/id. pub const name = "Terminal"; + /// Whether the palette window is open. + show_palette: bool = false, + // Render pub fn render(self: *Window, t: *Terminal) void { - _ = self; // Start our window. If we're collapsed we do nothing. defer cimgui.c.ImGui_End(); @@ -210,6 +212,215 @@ pub const Window = struct { } } } - } // cursor + } // layout + + if (cimgui.c.ImGui_CollapsingHeader( + "Color", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + 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")) { + self.show_palette = true; + } + } + } + } // color + + 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); + } + } } }; + +/// Render a DynamicRGB color. +/// +/// Note: this currently can't be modified but we plan to allow that +/// and return a boolean letting you know if anything was modified. +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(); + } + } +} From 061730ef97b88d41cced727c816565188b2486f2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 13:08:31 -0800 Subject: [PATCH 019/108] inspector: terminal mouse section --- src/inspector/terminal.zig | 90 +++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/src/inspector/terminal.zig b/src/inspector/terminal.zig index 5310afc89..cdebf4ab5 100644 --- a/src/inspector/terminal.zig +++ b/src/inspector/terminal.zig @@ -25,11 +25,25 @@ pub const Window = struct { )) return; if (cimgui.c.ImGui_CollapsingHeader( - "General", + "Help", + cimgui.c.ImGuiTreeNodeFlags_None, + )) { + cimgui.c.ImGui_TextWrapped( + "This window displays the internal state of the terminal. " ++ + "The terminal state is global to this terminal. Some state " ++ + "is specific to the active screen or other subsystems. Values " ++ + "here reflect the running state and will update as the terminal " ++ + "application modifies them via escape sequences or shell integration. " ++ + "Some can be modified directly for debugging purposes.", + ); + } + + if (cimgui.c.ImGui_CollapsingHeader( + "Misc", cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) { _ = cimgui.c.ImGui_BeginTable( - "table_general", + "table_misc", 2, cimgui.c.ImGuiTableFlags_None, ); @@ -214,6 +228,78 @@ pub const Window = struct { } } // layout + if (cimgui.c.ImGui_CollapsingHeader( + "Mouse", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + _ = 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); + } + } + } + } // mouse + if (cimgui.c.ImGui_CollapsingHeader( "Color", cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, From d58703a245e222cca1cb4c1df75cbc5c58a1f7e9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 13:15:30 -0800 Subject: [PATCH 020/108] update AGENTS.md --- src/inspector/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/inspector/AGENTS.md b/src/inspector/AGENTS.md index 3b6281438..ad6ff676b 100644 --- a/src/inspector/AGENTS.md +++ b/src/inspector/AGENTS.md @@ -3,6 +3,6 @@ - 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. From ff149383892d85c4d2184150228b60d84fb24773 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 13:18:17 -0800 Subject: [PATCH 021/108] inspector: detachable collapsing headers --- src/inspector/terminal.zig | 714 +++++++++++++++++++------------------ src/inspector/widgets.zig | 55 +++ 2 files changed, 426 insertions(+), 343 deletions(-) diff --git a/src/inspector/terminal.zig b/src/inspector/terminal.zig index cdebf4ab5..91a13f4da 100644 --- a/src/inspector/terminal.zig +++ b/src/inspector/terminal.zig @@ -5,6 +5,12 @@ const terminal = @import("../terminal/main.zig"); const Terminal = terminal.Terminal; const widgets = @import("widgets.zig"); +/// Context for our detachable collapsing headers. +const RenderContext = struct { + window: *Window, + terminal: *Terminal, +}; + /// Window to show terminal state information. pub const Window = struct { /// Window name/id. @@ -13,6 +19,12 @@ pub const Window = struct { /// Whether the palette window is open. show_palette: bool = false, + /// Whether sections are shown in their own windows. + show_misc_window: bool = false, + show_layout_window: bool = false, + show_mouse_window: bool = false, + show_color_window: bool = false, + // Render pub fn render(self: *Window, t: *Terminal) void { @@ -38,353 +50,31 @@ pub const Window = struct { ); } - if (cimgui.c.ImGui_CollapsingHeader( + const ctx: RenderContext = .{ .window = self, .terminal = t }; + widgets.collapsingHeaderDetachable( "Misc", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) { - _ = 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)"); - } - } - } - } - - if (cimgui.c.ImGui_CollapsingHeader( + &self.show_misc_window, + ctx, + renderMiscContent, + ); + widgets.collapsingHeaderDetachable( "Layout", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) { - _ = 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 we modified it then update our scrolling region - // directly. - if (changed and - override.top < override.bottom and - override.left < override.right) - { - t.scrolling_region = override; - } - } - } - } // layout - - if (cimgui.c.ImGui_CollapsingHeader( + &self.show_layout_window, + ctx, + renderLayoutContent, + ); + widgets.collapsingHeaderDetachable( "Mouse", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) { - _ = 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); - } - } - } - } // mouse - - if (cimgui.c.ImGui_CollapsingHeader( + &self.show_mouse_window, + ctx, + renderMouseContent, + ); + widgets.collapsingHeaderDetachable( "Color", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) { - 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")) { - self.show_palette = true; - } - } - } - } // color + &self.show_color_window, + ctx, + renderColorContent, + ); if (self.show_palette) { defer cimgui.c.ImGui_End(); @@ -399,6 +89,344 @@ pub const Window = struct { } }; +fn renderMiscContent(ctx: RenderContext) void { + const t = ctx.terminal; + _ = 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)"); + } + } + } +} + +fn renderLayoutContent(ctx: RenderContext) void { + const t = ctx.terminal; + _ = 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; + } + } + } +} + +fn renderMouseContent(ctx: RenderContext) void { + const t = ctx.terminal; + _ = 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); + } + } + } +} + +fn renderColorContent(ctx: RenderContext) void { + const t = ctx.terminal; + 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")) { + ctx.window.show_palette = true; + } + } + } +} + /// Render a DynamicRGB color. /// /// Note: this currently can't be modified but we plan to allow that diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig index 224e8b1ba..a332aa538 100644 --- a/src/inspector/widgets.zig +++ b/src/inspector/widgets.zig @@ -12,3 +12,58 @@ pub fn helpMarker(text: [:0]const u8) void { cimgui.c.ImGui_TextUnformatted(text.ptr); } + +/// 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 collapsingHeaderDetachable( + label: [:0]const u8, + show: *bool, + ctx: anytype, + comptime contentFn: fn (@TypeOf(ctx)) void, +) void { + if (show.*) { + defer cimgui.c.ImGui_End(); + if (cimgui.c.ImGui_Begin( + label, + show, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) contentFn(ctx); + return; + } + + cimgui.c.ImGui_SetNextItemAllowOverlap(); + const is_open = cimgui.c.ImGui_CollapsingHeader( + label, + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + ); + + // 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 }, + )) { + show.* = 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); +} From 86af16b081d5e4cb03da46ce2bb06a4b29c30c88 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 13:38:57 -0800 Subject: [PATCH 022/108] inspector: move surface window out to its own file --- src/inspector/Inspector.zig | 330 ++---------------------------------- src/inspector/main.zig | 1 + src/inspector/surface.zig | 319 ++++++++++++++++++++++++++++++++++ 3 files changed, 336 insertions(+), 314 deletions(-) create mode 100644 src/inspector/surface.zig diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index a5204ef89..9e1fd9155 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -22,7 +22,6 @@ 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. @@ -34,14 +33,7 @@ first_render: bool = true, /// 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, - - // Last hovered screen point - last_point: ?terminal.Pin = null, -} = .{}, +mouse: inspector.surface.Mouse = .{}, /// A selected cell. cell: CellInspect = .{ .idle = {} }, @@ -64,6 +56,7 @@ is_keyboard_selection: bool = false, /// Windows windows: struct { + surface: inspector.surface.Window = .{}, terminal: inspector.terminal.Window = .{}, } = .{}, @@ -236,12 +229,15 @@ pub fn render(self: *Inspector) void { defer self.surface.renderer_state.mutex.unlock(); const t = self.surface.renderer_state.terminal; self.windows.terminal.render(t); + self.windows.surface.render(.{ + .surface = self.surface, + .mouse = self.mouse, + }); 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 @@ -266,35 +262,16 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { // Our initial focus cimgui.c.ImGui_SetWindowFocusStr(inspector.terminal.Window.name); - // 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(inspector.terminal.Window.name, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_imgui_demo, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_size, dock_id.right); + // Setup our initial layout - all windows in a single dock as tabs. + // Surface is docked first so it appears as the first tab. + cimgui.ImGui_DockBuilderDockWindow(inspector.surface.Window.name, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(inspector.terminal.Window.name, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_screen, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_modes, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_keyboard, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_cell, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_imgui_demo, dock_id_main); cimgui.ImGui_DockBuilderFinish(dock_id_main); } @@ -614,281 +591,6 @@ fn renderModesWindow(self: *Inspector) void { } } -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(); diff --git a/src/inspector/main.zig b/src/inspector/main.zig index 8299c0a0d..3fca5a804 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -3,6 +3,7 @@ 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 surface = @import("surface.zig"); pub const termio = @import("termio.zig"); pub const terminal = @import("terminal.zig"); diff --git a/src/inspector/surface.zig b/src/inspector/surface.zig new file mode 100644 index 000000000..8286156f3 --- /dev/null +++ b/src/inspector/surface.zig @@ -0,0 +1,319 @@ +const cimgui = @import("dcimgui"); +const input = @import("../input.zig"); +const renderer = @import("../renderer.zig"); +const terminal = @import("../terminal/main.zig"); +const Surface = @import("../Surface.zig"); + +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, +}; + +/// Window to show surface information. +pub const Window = struct { + /// Window name/id. + pub const name = "Surface Info"; + + pub const FrameData = struct { + /// The surface that we're inspecting. + surface: *Surface, + + /// Mouse state that we track in addition to normal mouse states that + /// Ghostty always knows about. + mouse: Mouse = .{}, + }; + + /// Render + pub fn render(self: *Window, data: FrameData) void { + _ = self; + + // Start our window. If we're collapsed we do nothing. + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( + name, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) 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"); + + { + _ = 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", + data.surface.size.screen.width, + data.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 = data.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", + data.surface.size.cell.width, + data.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", + data.surface.size.padding.top, + data.surface.size.padding.bottom, + data.surface.size.padding.left, + data.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", + data.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", + data.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 = &data.surface.mouse; + const t = data.surface.renderer_state.terminal; + + { + const hover_point: terminal.point.Coordinate = pt: { + const p = data.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 = data.mouse.last_xpos, + .y = data.mouse.last_ypos, + }, + }).convert(.terminal, data.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)), + ); + } + } + } + } +}; From 1d7def0d2e5c871474a8617e805866d8cd7b273d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 13:46:42 -0800 Subject: [PATCH 023/108] inspector: move modes out to separate header --- src/inspector/Inspector.zig | 58 ------------------------------------- src/inspector/terminal.zig | 54 ++++++++++++++++++++++++++++++++++ src/inspector/widgets.zig | 3 ++ 3 files changed, 57 insertions(+), 58 deletions(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 9e1fd9155..f3c31ef05 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -18,7 +18,6 @@ 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"; @@ -234,7 +233,6 @@ pub fn render(self: *Inspector) void { .mouse = self.mouse, }); self.renderScreenWindow(); - self.renderModesWindow(); self.renderKeyboardWindow(); self.renderTermioWindow(); self.renderCellWindow(); @@ -267,7 +265,6 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { cimgui.ImGui_DockBuilderDockWindow(inspector.surface.Window.name, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(inspector.terminal.Window.name, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_screen, dock_id_main); - cimgui.ImGui_DockBuilderDockWindow(window_modes, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_keyboard, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_cell, dock_id_main); @@ -536,61 +533,6 @@ fn renderScreenWindow(self: *Inspector) void { } // 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, - ); - 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 renderCellWindow(self: *Inspector) void { // Start our window. If we're collapsed we do nothing. defer cimgui.c.ImGui_End(); diff --git a/src/inspector/terminal.zig b/src/inspector/terminal.zig index 91a13f4da..352ae6a03 100644 --- a/src/inspector/terminal.zig +++ b/src/inspector/terminal.zig @@ -4,6 +4,7 @@ const cimgui = @import("dcimgui"); const terminal = @import("../terminal/main.zig"); const Terminal = terminal.Terminal; const widgets = @import("widgets.zig"); +const modes = terminal.modes; /// Context for our detachable collapsing headers. const RenderContext = struct { @@ -24,6 +25,7 @@ pub const Window = struct { show_layout_window: bool = false, show_mouse_window: bool = false, show_color_window: bool = false, + show_modes_window: bool = false, // Render pub fn render(self: *Window, t: *Terminal) void { @@ -75,6 +77,12 @@ pub const Window = struct { ctx, renderColorContent, ); + widgets.collapsingHeaderDetachable( + "Modes", + &self.show_modes_window, + ctx, + renderModesContent, + ); if (self.show_palette) { defer cimgui.c.ImGui_End(); @@ -538,3 +546,49 @@ fn palette( } } } + +fn renderModesContent(ctx: RenderContext) void { + const t = ctx.terminal; + + _ = 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); + } + } +} diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig index a332aa538..d3424e9ef 100644 --- a/src/inspector/widgets.zig +++ b/src/inspector/widgets.zig @@ -22,6 +22,9 @@ pub fn collapsingHeaderDetachable( ctx: anytype, comptime contentFn: fn (@TypeOf(ctx)) void, ) void { + cimgui.c.ImGui_PushID(label); + defer cimgui.c.ImGui_PopID(); + if (show.*) { defer cimgui.c.ImGui_End(); if (cimgui.c.ImGui_Begin( From 4c2ea6a9d8936aa6668ccfee4aee3d3817204b46 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 13:53:28 -0800 Subject: [PATCH 024/108] inspector: fix up detachable headers to dock --- src/inspector/terminal.zig | 47 +++++++++----------------------------- src/inspector/widgets.zig | 43 +++++++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/src/inspector/terminal.zig b/src/inspector/terminal.zig index 352ae6a03..9b601f1a1 100644 --- a/src/inspector/terminal.zig +++ b/src/inspector/terminal.zig @@ -20,12 +20,12 @@ pub const Window = struct { /// Whether the palette window is open. show_palette: bool = false, - /// Whether sections are shown in their own windows. - show_misc_window: bool = false, - show_layout_window: bool = false, - show_mouse_window: bool = false, - show_color_window: bool = false, - show_modes_window: bool = false, + /// State for detachable headers. + misc_state: widgets.DetachableHeaderState = .{}, + layout_state: widgets.DetachableHeaderState = .{}, + mouse_state: widgets.DetachableHeaderState = .{}, + color_state: widgets.DetachableHeaderState = .{}, + modes_state: widgets.DetachableHeaderState = .{}, // Render pub fn render(self: *Window, t: *Terminal) void { @@ -53,36 +53,11 @@ pub const Window = struct { } const ctx: RenderContext = .{ .window = self, .terminal = t }; - widgets.collapsingHeaderDetachable( - "Misc", - &self.show_misc_window, - ctx, - renderMiscContent, - ); - widgets.collapsingHeaderDetachable( - "Layout", - &self.show_layout_window, - ctx, - renderLayoutContent, - ); - widgets.collapsingHeaderDetachable( - "Mouse", - &self.show_mouse_window, - ctx, - renderMouseContent, - ); - widgets.collapsingHeaderDetachable( - "Color", - &self.show_color_window, - ctx, - renderColorContent, - ); - widgets.collapsingHeaderDetachable( - "Modes", - &self.show_modes_window, - ctx, - renderModesContent, - ); + widgets.detachableHeader("Misc", &self.misc_state, ctx, renderMiscContent); + widgets.detachableHeader("Layout", &self.layout_state, ctx, renderLayoutContent); + widgets.detachableHeader("Mouse", &self.mouse_state, ctx, renderMouseContent); + widgets.detachableHeader("Color", &self.color_state, ctx, renderColorContent); + widgets.detachableHeader("Modes", &self.modes_state, ctx, renderModesContent); if (self.show_palette) { defer cimgui.c.ImGui_End(); diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig index d3424e9ef..45c35f6c4 100644 --- a/src/inspector/widgets.zig +++ b/src/inspector/widgets.zig @@ -13,32 +13,63 @@ pub fn helpMarker(text: [:0]const u8) void { cimgui.c.ImGui_TextUnformatted(text.ptr); } +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 collapsingHeaderDetachable( +pub fn detachableHeader( label: [:0]const u8, - show: *bool, + state: *DetachableHeaderState, ctx: anytype, comptime contentFn: fn (@TypeOf(ctx)) void, ) void { cimgui.c.ImGui_PushID(label); defer cimgui.c.ImGui_PopID(); - if (show.*) { + 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, - show, + &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_DefaultOpen, + cimgui.c.ImGuiTreeNodeFlags_None, ); // Place pop-out button inside the header bar @@ -61,7 +92,7 @@ pub fn collapsingHeaderDetachable( ">>##detach", .{ .x = button_size, .y = button_size }, )) { - show.* = true; + state.show_window = true; } cimgui.c.ImGui_PopStyleVar(); if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) { From 4c5af7073ec11c63f89927c544f8ce76c059de3b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 14:03:20 -0800 Subject: [PATCH 025/108] inspector: move screen window to dedicated file --- src/inspector/Inspector.zig | 275 ++-------------------------------- src/inspector/cursor.zig | 6 +- src/inspector/main.zig | 1 + src/inspector/screen.zig | 288 ++++++++++++++++++++++++++++++++++++ 4 files changed, 301 insertions(+), 269 deletions(-) create mode 100644 src/inspector/screen.zig diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index f3c31ef05..b881bf04d 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -14,13 +14,11 @@ 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_keyboard = "Keyboard"; const window_termio = "Terminal IO"; -const window_screen = "Screen"; const window_imgui_demo = "Dear ImGui Demo"; /// The surface that we're inspecting. @@ -55,6 +53,7 @@ is_keyboard_selection: bool = false, /// Windows windows: struct { + screen: inspector.screen.Window = .{}, surface: inspector.surface.Window = .{}, terminal: inspector.terminal.Window = .{}, } = .{}, @@ -232,7 +231,12 @@ pub fn render(self: *Inspector) void { .surface = self.surface, .mouse = self.mouse, }); - self.renderScreenWindow(); + self.windows.screen.render(.{ + .screen = t.screens.active, + .active_key = t.screens.active_key, + .modify_other_keys_2 = t.flags.modify_other_keys_2, + .color_palette = &t.colors.palette, + }); self.renderKeyboardWindow(); self.renderTermioWindow(); self.renderCellWindow(); @@ -264,7 +268,7 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { // Surface is docked first so it appears as the first tab. cimgui.ImGui_DockBuilderDockWindow(inspector.surface.Window.name, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(inspector.terminal.Window.name, dock_id_main); - cimgui.ImGui_DockBuilderDockWindow(window_screen, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(inspector.screen.Window.name, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_keyboard, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_cell, dock_id_main); @@ -272,267 +276,6 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { 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 -} - fn renderCellWindow(self: *Inspector) void { // Start our window. If we're collapsed we do nothing. defer cimgui.c.ImGui_End(); @@ -844,7 +587,7 @@ fn renderTermioWindow(self: *Inspector) void { ); defer cimgui.c.ImGui_EndTable(); inspector.cursor.renderInTable( - self.surface.renderer_state.terminal, + &self.surface.renderer_state.terminal.colors.palette, &ev.cursor, ); diff --git a/src/inspector/cursor.zig b/src/inspector/cursor.zig index 4f8bfb2e0..28590dcc1 100644 --- a/src/inspector/cursor.zig +++ b/src/inspector/cursor.zig @@ -3,7 +3,7 @@ const terminal = @import("../terminal/main.zig"); /// Render cursor information with a table already open. pub fn renderInTable( - t: *const terminal.Terminal, + color_palette: *const terminal.color.DynamicPalette, cursor: *const terminal.Screen.Cursor, ) void { { @@ -50,7 +50,7 @@ pub fn renderInTable( switch (cursor.style.fg_color) { .none => cimgui.c.ImGui_Text("default"), .palette => |idx| { - const rgb = t.colors.palette.current[idx]; + const rgb = color_palette.current[idx]; cimgui.c.ImGui_Text("Palette %d", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @@ -89,7 +89,7 @@ pub fn renderInTable( switch (cursor.style.bg_color) { .none => cimgui.c.ImGui_Text("default"), .palette => |idx| { - const rgb = t.colors.palette.current[idx]; + const rgb = color_palette.current[idx]; cimgui.c.ImGui_Text("Palette %d", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, diff --git a/src/inspector/main.zig b/src/inspector/main.zig index 3fca5a804..b26d75088 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -3,6 +3,7 @@ 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 screen = @import("screen.zig"); pub const surface = @import("surface.zig"); pub const termio = @import("termio.zig"); pub const terminal = @import("terminal.zig"); diff --git a/src/inspector/screen.zig b/src/inspector/screen.zig new file mode 100644 index 000000000..3ecdfd7b4 --- /dev/null +++ b/src/inspector/screen.zig @@ -0,0 +1,288 @@ +const std = @import("std"); +const cimgui = @import("dcimgui"); +const terminal = @import("../terminal/main.zig"); +const inspector = @import("main.zig"); +const units = @import("units.zig"); + +/// Window to show screen information. +pub const Window = struct { + /// Window name/id. + pub const name = "Screen"; + + pub const FrameData = struct { + /// The screen that we're inspecting. + screen: *const terminal.Screen, + + /// 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, + }; + + /// Render + pub fn render(self: *Window, data: FrameData) void { + _ = self; + + // Start our window. If we're collapsed we do nothing. + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( + name, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) return; + + const screen = data.screen; + + { + _ = 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(data.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( + data.color_palette, + &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 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 (data.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 + } +}; From 0b3f4ae340feca461ae3d8245c55d144dd7af1a6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 14:09:38 -0800 Subject: [PATCH 026/108] inspector: move screens to terminal section --- src/inspector/Inspector.zig | 7 --- src/inspector/screen.zig | 21 +++++--- src/inspector/terminal.zig | 105 +++++++++++++++++++++++++++++++++++- 3 files changed, 118 insertions(+), 15 deletions(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index b881bf04d..804fc17f0 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -53,7 +53,6 @@ is_keyboard_selection: bool = false, /// Windows windows: struct { - screen: inspector.screen.Window = .{}, surface: inspector.surface.Window = .{}, terminal: inspector.terminal.Window = .{}, } = .{}, @@ -231,12 +230,6 @@ pub fn render(self: *Inspector) void { .surface = self.surface, .mouse = self.mouse, }); - self.windows.screen.render(.{ - .screen = t.screens.active, - .active_key = t.screens.active_key, - .modify_other_keys_2 = t.flags.modify_other_keys_2, - .color_palette = &t.colors.palette, - }); self.renderKeyboardWindow(); self.renderTermioWindow(); self.renderCellWindow(); diff --git a/src/inspector/screen.zig b/src/inspector/screen.zig index 3ecdfd7b4..a31770ea1 100644 --- a/src/inspector/screen.zig +++ b/src/inspector/screen.zig @@ -23,18 +23,25 @@ pub const Window = struct { color_palette: *const terminal.color.DynamicPalette, }; - /// Render - pub fn render(self: *Window, data: FrameData) void { - _ = self; - - // Start our window. If we're collapsed we do nothing. + /// Render with custom label and close button. + pub fn render( + self: *Window, + label: [:0]const u8, + open: *bool, + data: FrameData, + ) void { defer cimgui.c.ImGui_End(); if (!cimgui.c.ImGui_Begin( - name, - null, + label, + open, cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, )) return; + self.renderContent(data); + } + + fn renderContent(self: *Window, data: FrameData) void { + _ = self; const screen = data.screen; { diff --git a/src/inspector/terminal.zig b/src/inspector/terminal.zig index 9b601f1a1..8d1d3b050 100644 --- a/src/inspector/terminal.zig +++ b/src/inspector/terminal.zig @@ -5,6 +5,7 @@ const terminal = @import("../terminal/main.zig"); const Terminal = terminal.Terminal; const widgets = @import("widgets.zig"); const modes = terminal.modes; +const inspector = @import("main.zig"); /// Context for our detachable collapsing headers. const RenderContext = struct { @@ -26,10 +27,16 @@ pub const Window = struct { mouse_state: widgets.DetachableHeaderState = .{}, color_state: widgets.DetachableHeaderState = .{}, modes_state: widgets.DetachableHeaderState = .{}, + screens_state: widgets.DetachableHeaderState = .{}, + + /// Screen detail windows for each screen key. + screen_windows: std.EnumMap( + terminal.ScreenSet.Key, + inspector.screen.Window, + ) = .{}, // Render pub fn render(self: *Window, t: *Terminal) void { - // Start our window. If we're collapsed we do nothing. defer cimgui.c.ImGui_End(); if (!cimgui.c.ImGui_Begin( @@ -58,6 +65,27 @@ pub const Window = struct { widgets.detachableHeader("Mouse", &self.mouse_state, ctx, renderMouseContent); widgets.detachableHeader("Color", &self.color_state, ctx, renderColorContent); widgets.detachableHeader("Modes", &self.modes_state, ctx, renderModesContent); + widgets.detachableHeader("Screens", &self.screens_state, ctx, renderScreensContent); + + // Pop-out screen windows + inline for (@typeInfo(terminal.ScreenSet.Key).@"enum".fields) |field| { + const key: terminal.ScreenSet.Key = @enumFromInt(field.value); + if (self.screen_windows.getPtr(key)) |screen_window| { + if (t.screens.get(key)) |screen| { + const label = comptime std.fmt.comptimePrint("Screen: {s}", .{field.name}); + var open: bool = true; + screen_window.render(label, &open, .{ + .screen = screen, + .active_key = t.screens.active_key, + .modify_other_keys_2 = t.flags.modify_other_keys_2, + .color_palette = &t.colors.palette, + }); + if (!open) { + self.screen_windows.remove(key); + } + } + } + } if (self.show_palette) { defer cimgui.c.ImGui_End(); @@ -567,3 +595,78 @@ fn renderModesContent(ctx: RenderContext) void { } } } + +fn renderScreensContent(ctx: RenderContext) void { + const t = ctx.terminal; + + cimgui.c.ImGui_Text("Screens"); + cimgui.c.ImGui_SameLine(); + 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_Separator(); + + _ = cimgui.c.ImGui_BeginTable( + "table_screens", + 3, + cimgui.c.ImGuiTableFlags_Borders | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + ); + 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); + cimgui.c.ImGui_TableHeadersRow(); + + inline for (@typeInfo(terminal.ScreenSet.Key).@"enum".fields) |field| { + const key: terminal.ScreenSet.Key = @enumFromInt(field.value); + 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); + const name = comptime std.fmt.comptimePrint("{s}", .{field.name}); + cimgui.c.ImGui_Text("%s", name.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); + const id = comptime std.fmt.comptimePrint("{s}", .{field.name}); + cimgui.c.ImGui_PushID(id.ptr); + defer cimgui.c.ImGui_PopID(); + if (is_initialized) { + if (cimgui.c.ImGui_Button("View")) { + ctx.window.screen_windows.put(key, .{}); + } + } else { + cimgui.c.ImGui_BeginDisabled(true); + _ = cimgui.c.ImGui_Button("View"); + cimgui.c.ImGui_EndDisabled(); + } + } + } +} From e5a03a103c9602919f402af975975752e1da8d92 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 14:20:27 -0800 Subject: [PATCH 027/108] inspector: show warning if inactive screen being viewed --- src/inspector/screen.zig | 27 +++++++++------------------ src/inspector/terminal.zig | 1 + 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/inspector/screen.zig b/src/inspector/screen.zig index a31770ea1..c3de777d6 100644 --- a/src/inspector/screen.zig +++ b/src/inspector/screen.zig @@ -13,6 +13,9 @@ pub const Window = struct { /// The screen that we're inspecting. screen: *const terminal.Screen, + /// Which screen key we're viewing. + key: terminal.ScreenSet.Key, + /// Which screen is active (primary or alternate). active_key: terminal.ScreenSet.Key, @@ -44,25 +47,13 @@ pub const Window = struct { _ = self; const screen = data.screen; - { - _ = cimgui.c.ImGui_BeginTable( - "table_screen", - 2, - cimgui.c.ImGuiTableFlags_None, + // 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", ); - 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(data.active_key).ptr); - } - } + cimgui.c.ImGui_Separator(); } if (cimgui.c.ImGui_CollapsingHeader( diff --git a/src/inspector/terminal.zig b/src/inspector/terminal.zig index 8d1d3b050..e21cb2b7f 100644 --- a/src/inspector/terminal.zig +++ b/src/inspector/terminal.zig @@ -76,6 +76,7 @@ pub const Window = struct { var open: bool = true; screen_window.render(label, &open, .{ .screen = screen, + .key = key, .active_key = t.screens.active_key, .modify_other_keys_2 = t.flags.modify_other_keys_2, .color_palette = &t.colors.palette, From 4992212ecd85c4e0342b6a12a6358e452f0f96bb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 14:24:33 -0800 Subject: [PATCH 028/108] inspector: add more cursor state --- src/inspector/cursor.zig | 72 ++++++++++++++++++++++++++++++++++------ src/inspector/screen.zig | 10 +++--- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/inspector/cursor.zig b/src/inspector/cursor.zig index 28590dcc1..222cc140d 100644 --- a/src/inspector/cursor.zig +++ b/src/inspector/cursor.zig @@ -1,5 +1,6 @@ const cimgui = @import("dcimgui"); const terminal = @import("../terminal/main.zig"); +const widgets = @import("widgets.zig"); /// Render cursor information with a table already open. pub fn renderInTable( @@ -11,6 +12,8 @@ pub fn renderInTable( { _ = 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); @@ -23,6 +26,8 @@ pub fn renderInTable( { _ = cimgui.c.ImGui_TableSetColumnIndex(0); cimgui.c.ImGui_Text("Style"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The visual style of the cursor (block, underline, bar, etc.)."); } { _ = cimgui.c.ImGui_TableSetColumnIndex(1); @@ -30,22 +35,29 @@ pub fn renderInTable( } } - if (cursor.pending_wrap) { + { cimgui.c.ImGui_TableNextRow(); { _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Pending Wrap"); + cimgui.c.ImGui_Text("Hyperlink"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The active OSC8 hyperlink for newly printed characters."); } { _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", if (cursor.pending_wrap) "true".ptr else "false".ptr); + if (cursor.hyperlink) |link| { + cimgui.c.ImGui_Text("%.*s", link.uri.len, link.uri.ptr); + } else { + cimgui.c.ImGui_TextDisabled("(none)"); + } } } - // 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_SameLine(); + widgets.helpMarker("The foreground (text) color for newly printed characters."); _ = cimgui.c.ImGui_TableSetColumnIndex(1); switch (cursor.style.fg_color) { .none => cimgui.c.ImGui_Text("default"), @@ -85,6 +97,8 @@ pub fn renderInTable( cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); cimgui.c.ImGui_Text("Background Color"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The background color for newly printed characters."); _ = cimgui.c.ImGui_TableSetColumnIndex(1); switch (cursor.style.bg_color) { .none => cimgui.c.ImGui_Text("default"), @@ -121,22 +135,60 @@ pub fn renderInTable( }, } - // Boolean styles - const styles = .{ - "bold", "italic", "faint", "blink", - "inverse", "invisible", "strikethrough", + 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 (if supported by the renderer)." }, + .{ "inverse", "Foreground and background colors are swapped." }, + .{ "invisible", "Text will be invisible (hidden)." }, + .{ "strikethrough", "Text will have a line through it." }, }; - inline for (styles) |style| style: { - if (!@field(cursor.style.flags, style)) break :style; + inline for (style_flags) |entry| entry: { + const style = entry[0]; + const help = entry[1]; + if (!@field(cursor.style.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_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); + } + } } diff --git a/src/inspector/screen.zig b/src/inspector/screen.zig index c3de777d6..b2448f95e 100644 --- a/src/inspector/screen.zig +++ b/src/inspector/screen.zig @@ -58,7 +58,7 @@ pub const Window = struct { if (cimgui.c.ImGui_CollapsingHeader( "Cursor", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + cimgui.c.ImGuiTreeNodeFlags_None, )) { { _ = cimgui.c.ImGui_BeginTable( @@ -78,7 +78,7 @@ pub const Window = struct { if (cimgui.c.ImGui_CollapsingHeader( "Keyboard", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + cimgui.c.ImGuiTreeNodeFlags_None, )) { { _ = cimgui.c.ImGui_BeginTable( @@ -145,7 +145,7 @@ pub const Window = struct { if (cimgui.c.ImGui_CollapsingHeader( "Kitty Graphics", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + cimgui.c.ImGuiTreeNodeFlags_None, )) kitty_gfx: { if (!screen.kitty_images.enabled()) { cimgui.c.ImGui_TextDisabled("(Kitty graphics are disabled)"); @@ -226,7 +226,7 @@ pub const Window = struct { if (cimgui.c.ImGui_CollapsingHeader( "Internal Terminal State", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + cimgui.c.ImGuiTreeNodeFlags_None, )) { const pages = &screen.pages; @@ -277,7 +277,7 @@ pub const Window = struct { // if (cimgui.c.ImGui_CollapsingHeader( "Active Page", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + cimgui.c.ImGuiTreeNodeFlags_None, )) { inspector.page.render(&pages.pages.last.?.data); } From f23e67388d574f649bb3a29f84144cd9a08eb85e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 14:44:22 -0800 Subject: [PATCH 029/108] inspector: add grid section to screen --- src/inspector/Inspector.zig | 1 - src/inspector/screen.zig | 14 +++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 804fc17f0..675538491 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -261,7 +261,6 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { // Surface is docked first so it appears as the first tab. cimgui.ImGui_DockBuilderDockWindow(inspector.surface.Window.name, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(inspector.terminal.Window.name, dock_id_main); - cimgui.ImGui_DockBuilderDockWindow(inspector.screen.Window.name, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_keyboard, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_cell, dock_id_main); diff --git a/src/inspector/screen.zig b/src/inspector/screen.zig index b2448f95e..6e658e5db 100644 --- a/src/inspector/screen.zig +++ b/src/inspector/screen.zig @@ -44,7 +44,6 @@ pub const Window = struct { } fn renderContent(self: *Window, data: FrameData) void { - _ = self; const screen = data.screen; // Show warning if viewing an inactive screen @@ -143,6 +142,13 @@ pub const Window = struct { } // table } // keyboard + if (cimgui.c.ImGui_CollapsingHeader( + "Grid", + cimgui.c.ImGuiTreeNodeFlags_None, + )) { + self.renderGrid(); + } // grid + if (cimgui.c.ImGui_CollapsingHeader( "Kitty Graphics", cimgui.c.ImGuiTreeNodeFlags_None, @@ -283,4 +289,10 @@ pub const Window = struct { } } // terminal state } + + /// Render the grid section. + fn renderGrid(self: *Window) void { + _ = self; + cimgui.c.ImGui_Text("Mode"); + } }; From 1fa74b19e83908881c1a440b1335c3e294ca6c94 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 14:57:05 -0800 Subject: [PATCH 030/108] inspector: grid section lets you pick a cell --- src/inspector/screen.zig | 193 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 4 deletions(-) diff --git a/src/inspector/screen.zig b/src/inspector/screen.zig index 6e658e5db..5d23f0852 100644 --- a/src/inspector/screen.zig +++ b/src/inspector/screen.zig @@ -9,6 +9,10 @@ pub const Window = struct { /// Window name/id. pub const name = "Screen"; + /// Grid position inputs for cell inspection. + grid_pos_x: c_int = 0, + grid_pos_y: c_int = 0, + pub const FrameData = struct { /// The screen that we're inspecting. screen: *const terminal.Screen, @@ -146,7 +150,7 @@ pub const Window = struct { "Grid", cimgui.c.ImGuiTreeNodeFlags_None, )) { - self.renderGrid(); + self.renderGrid(data); } // grid if (cimgui.c.ImGui_CollapsingHeader( @@ -291,8 +295,189 @@ pub const Window = struct { } /// Render the grid section. - fn renderGrid(self: *Window) void { - _ = self; - cimgui.c.ImGui_Text("Mode"); + fn renderGrid(self: *Window, data: FrameData) void { + const screen = data.screen; + const pages = &screen.pages; + + // Clamp values to valid range + const max_x: c_int = @intCast(pages.cols -| 1); + const max_y: c_int = @intCast(pages.rows -| 1); + self.grid_pos_x = std.math.clamp(self.grid_pos_x, 0, max_x); + self.grid_pos_y = std.math.clamp(self.grid_pos_y, 0, max_y); + + // Position inputs - calculate width to split available space evenly + const imgui_style = cimgui.c.ImGui_GetStyle(); + const avail_width = cimgui.c.ImGui_GetContentRegionAvail().x; + const item_spacing = imgui_style.*.ItemSpacing.x; + const label_width = cimgui.c.ImGui_CalcTextSize("x").x + imgui_style.*.ItemInnerSpacing.x; + const item_width = (avail_width - item_spacing - label_width * 2.0) / 2.0; + + cimgui.c.ImGui_PushItemWidth(item_width); + _ = cimgui.c.ImGui_DragIntEx("x", &self.grid_pos_x, 1.0, 0, max_x, "%d", cimgui.c.ImGuiSliderFlags_None); + cimgui.c.ImGui_SameLine(); + _ = cimgui.c.ImGui_DragIntEx("y", &self.grid_pos_y, 1.0, 0, max_y, "%d", cimgui.c.ImGuiSliderFlags_None); + cimgui.c.ImGui_PopItemWidth(); + + // Get pin for the cell at position + const pt: terminal.point.Point = .{ .viewport = .{ + .x = @intCast(self.grid_pos_x), + .y = @intCast(self.grid_pos_y), + } }; + + cimgui.c.ImGui_Separator(); + + if (pages.pin(pt)) |pin| { + _ = cimgui.c.ImGui_BeginTable( + "##grid_cell_table", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + const row_and_cell = pin.rowAndCell(); + const cell = row_and_cell.cell; + const style = pin.style(cell); + + // Codepoint + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Codepoint"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + const cp = cell.codepoint(); + if (cp == 0) { + cimgui.c.ImGui_Text("(empty)"); + } else { + cimgui.c.ImGui_Text("U+%X", @as(c_uint, cp)); + } + } + + // Grapheme extras + if (cell.hasGrapheme()) { + if (pin.grapheme(cell)) |cps| { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grapheme"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + for (cps) |cp| { + cimgui.c.ImGui_Text("U+%X", @as(c_uint, cp)); + } + } + } + + // Width property + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Width"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(cell.wide).ptr); + } + + // Foreground color + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Foreground"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + switch (style.fg_color) { + .none => cimgui.c.ImGui_Text("default"), + .palette => |idx| { + const rgb = data.color_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( + "##fg_color", + &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( + "##fg_color", + &color, + cimgui.c.ImGuiColorEditFlags_DisplayHex | + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, + } + } + + // Background color + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Background"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + switch (style.bg_color) { + .none => cimgui.c.ImGui_Text("default"), + .palette => |idx| { + const rgb = data.color_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( + "##bg_color", + &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( + "##bg_color", + &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_name| { + if (@field(style.flags, style_name)) { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text(style_name.ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("true"); + } + } + + cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); + } else { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_TextColored( + .{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, + "Invalid position", + ); + } } }; From bdd0295e0e41e8bad0c87c5ca28c19ae3d988136 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 15:17:53 -0800 Subject: [PATCH 031/108] inspector: add style file for style widgets --- src/inspector/screen.zig | 1 + src/inspector/style.zig | 125 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/inspector/style.zig diff --git a/src/inspector/screen.zig b/src/inspector/screen.zig index 5d23f0852..4ded2a9d8 100644 --- a/src/inspector/screen.zig +++ b/src/inspector/screen.zig @@ -2,6 +2,7 @@ const std = @import("std"); const cimgui = @import("dcimgui"); const terminal = @import("../terminal/main.zig"); const inspector = @import("main.zig"); +const style = @import("style.zig"); const units = @import("units.zig"); /// Window to show screen information. diff --git a/src/inspector/style.zig b/src/inspector/style.zig new file mode 100644 index 000000000..a0c192f83 --- /dev/null +++ b/src/inspector/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(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(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(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"); + } + } + } +} + +/// 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, + ); + }, + } + + cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); +} From 7073fb88f421f4c5eef0223d6722043561400d97 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 15:32:12 -0800 Subject: [PATCH 032/108] inspector: remove cursor helper, move it to screen, style helper --- src/inspector/Inspector.zig | 10 +- src/inspector/cursor.zig | 194 -------------------------------- src/inspector/main.zig | 1 - src/inspector/screen.zig | 214 ++++++++++++++---------------------- src/inspector/style.zig | 10 +- 5 files changed, 94 insertions(+), 335 deletions(-) delete mode 100644 src/inspector/cursor.zig diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 675538491..0b9660654 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -572,17 +572,17 @@ fn renderTermioWindow(self: *Inspector) void { // imgui has no way to make a column span. if (ev.imgui_selected) { { + inspector.screen.cursorTable( + &ev.cursor, + &self.surface.renderer_state.terminal.colors.palette.current, + ); + _ = cimgui.c.ImGui_BeginTable( "details", 2, cimgui.c.ImGuiTableFlags_None, ); defer cimgui.c.ImGui_EndTable(); - inspector.cursor.renderInTable( - &self.surface.renderer_state.terminal.colors.palette, - &ev.cursor, - ); - { cimgui.c.ImGui_TableNextRow(); { diff --git a/src/inspector/cursor.zig b/src/inspector/cursor.zig deleted file mode 100644 index 222cc140d..000000000 --- a/src/inspector/cursor.zig +++ /dev/null @@ -1,194 +0,0 @@ -const cimgui = @import("dcimgui"); -const terminal = @import("../terminal/main.zig"); -const widgets = @import("widgets.zig"); - -/// Render cursor information with a table already open. -pub fn renderInTable( - color_palette: *const terminal.color.DynamicPalette, - 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_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("Style"); - cimgui.c.ImGui_SameLine(); - widgets.helpMarker("The visual style of the cursor (block, underline, bar, etc.)."); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", @tagName(cursor.cursor_style).ptr); - } - } - - { - 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("Foreground Color"); - cimgui.c.ImGui_SameLine(); - widgets.helpMarker("The foreground (text) color for newly printed characters."); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - switch (cursor.style.fg_color) { - .none => cimgui.c.ImGui_Text("default"), - .palette => |idx| { - const rgb = color_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_SameLine(); - widgets.helpMarker("The background color for newly printed characters."); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - switch (cursor.style.bg_color) { - .none => cimgui.c.ImGui_Text("default"), - .palette => |idx| { - const rgb = color_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, - ); - }, - } - - 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 (if supported by the renderer)." }, - .{ "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(cursor.style.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_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); - } - } -} diff --git a/src/inspector/main.zig b/src/inspector/main.zig index b26d75088..88d11ee20 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -1,6 +1,5 @@ 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 screen = @import("screen.zig"); diff --git a/src/inspector/screen.zig b/src/inspector/screen.zig index 4ded2a9d8..b5d43d77e 100644 --- a/src/inspector/screen.zig +++ b/src/inspector/screen.zig @@ -4,6 +4,7 @@ const terminal = @import("../terminal/main.zig"); const inspector = @import("main.zig"); const style = @import("style.zig"); const units = @import("units.zig"); +const widgets = @import("widgets.zig"); /// Window to show screen information. pub const Window = struct { @@ -64,20 +65,10 @@ pub const Window = struct { "Cursor", cimgui.c.ImGuiTreeNodeFlags_None, )) { - { - _ = cimgui.c.ImGui_BeginTable( - "table_cursor", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - inspector.cursor.renderInTable( - data.color_palette, - &screen.cursor, - ); - } // table - - cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); + cursorTable( + &screen.cursor, + &data.color_palette.current, + ); } // cursor if (cimgui.c.ImGui_CollapsingHeader( @@ -319,24 +310,30 @@ pub const Window = struct { _ = cimgui.c.ImGui_DragIntEx("y", &self.grid_pos_y, 1.0, 0, max_y, "%d", cimgui.c.ImGuiSliderFlags_None); cimgui.c.ImGui_PopItemWidth(); - // Get pin for the cell at position - const pt: terminal.point.Point = .{ .viewport = .{ - .x = @intCast(self.grid_pos_x), - .y = @intCast(self.grid_pos_y), - } }; - cimgui.c.ImGui_Separator(); - if (pages.pin(pt)) |pin| { + const pin = pages.pin(.{ .viewport = .{ + .x = @intCast(self.grid_pos_x), + .y = @intCast(self.grid_pos_y), + } }) orelse { + cimgui.c.ImGui_TextColored( + .{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, + "Invalid position", + ); + return; + }; + + const row_and_cell = pin.rowAndCell(); + const cell = row_and_cell.cell; + const st = pin.style(cell); + + { _ = cimgui.c.ImGui_BeginTable( "##grid_cell_table", 2, cimgui.c.ImGuiTableFlags_None, ); defer cimgui.c.ImGui_EndTable(); - const row_and_cell = pin.rowAndCell(); - const cell = row_and_cell.cell; - const style = pin.style(cell); // Codepoint { @@ -373,112 +370,69 @@ pub const Window = struct { _ = cimgui.c.ImGui_TableSetColumnIndex(1); cimgui.c.ImGui_Text("%s", @tagName(cell.wide).ptr); } - - // Foreground color - { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Foreground"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - switch (style.fg_color) { - .none => cimgui.c.ImGui_Text("default"), - .palette => |idx| { - const rgb = data.color_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( - "##fg_color", - &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( - "##fg_color", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - } - - // Background color - { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Background"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - switch (style.bg_color) { - .none => cimgui.c.ImGui_Text("default"), - .palette => |idx| { - const rgb = data.color_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( - "##bg_color", - &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( - "##bg_color", - &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_name| { - if (@field(style.flags, style_name)) { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text(style_name.ptr); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("true"); - } - } - - cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); - } else { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_TextColored( - .{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, - "Invalid position", - ); } + + cimgui.c.ImGui_Separator(); + style.table(st, &data.color_palette.current); } }; + +pub fn cursorTable( + cursor: *const terminal.Screen.Cursor, + palette: ?*const terminal.color.Palette, +) void { + { + _ = 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("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); + } + } + + cimgui.c.ImGui_Separator(); + + style.table(cursor.style, palette); +} diff --git a/src/inspector/style.zig b/src/inspector/style.zig index a0c192f83..36884c8e0 100644 --- a/src/inspector/style.zig +++ b/src/inspector/style.zig @@ -22,7 +22,7 @@ pub fn table( cimgui.c.ImGui_SameLine(); widgets.helpMarker("The foreground (text) color"); _ = cimgui.c.ImGui_TableSetColumnIndex(1); - color(st.fg_color, palette); + color("fg", st.fg_color, palette); } { @@ -32,7 +32,7 @@ pub fn table( cimgui.c.ImGui_SameLine(); widgets.helpMarker("The background (cell) color"); _ = cimgui.c.ImGui_TableSetColumnIndex(1); - color(st.bg_color, palette); + color("bg", st.bg_color, palette); } { @@ -42,7 +42,7 @@ pub fn table( cimgui.c.ImGui_SameLine(); widgets.helpMarker("The underline color, if underlines are enabled."); _ = cimgui.c.ImGui_TableSetColumnIndex(1); - color(st.underline_color, palette); + color("underline", st.underline_color, palette); } const style_flags = .{ @@ -72,6 +72,8 @@ pub fn table( } } } + + cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); } /// Render a style color. @@ -120,6 +122,4 @@ pub fn color( ); }, } - - cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); } From a25e91bb25a1e94cff2e53cad7475fe2f458d1cd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 20:47:21 -0800 Subject: [PATCH 033/108] pkg/dcimgui: expose more private dockbuilder stuff --- pkg/dcimgui/build.zig | 1 + pkg/dcimgui/main.zig | 32 +++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) 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 From 38aae2325d3602c41123bd0ec2eb274d0ce22dc0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Jan 2026 20:47:21 -0800 Subject: [PATCH 034/108] inspector: trying new logic layout --- src/inspector/Inspector.zig | 5 ++ src/inspector/widgets.zig | 3 + src/inspector/widgets/surface.zig | 103 +++++++++++++++++++++++++++++ src/inspector/widgets/terminal.zig | 29 ++++++++ 4 files changed, 140 insertions(+) create mode 100644 src/inspector/widgets/surface.zig create mode 100644 src/inspector/widgets/terminal.zig diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 0b9660654..7f1d8cddf 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -216,6 +216,11 @@ pub fn recordPtyRead(self: *Inspector, data: []const u8) !void { /// Render the frame. pub fn render(self: *Inspector) void { + const widgets = @import("widgets.zig"); + var s: widgets.surface.Inspector = .{ .surface = self.surface }; + s.draw(); + if (true) return; + const dock_id = cimgui.c.ImGui_DockSpaceOverViewport(); // Render all of our data. We hold the mutex for this duration. This is diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig index 45c35f6c4..d23346a8c 100644 --- a/src/inspector/widgets.zig +++ b/src/inspector/widgets.zig @@ -1,5 +1,8 @@ const cimgui = @import("dcimgui"); +pub const surface = @import("widgets/surface.zig"); +pub const terminal = @import("widgets/terminal.zig"); + /// Draws a "(?)" disabled text marker that shows some help text /// on hover. pub fn helpMarker(text: [:0]const u8) void { diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig new file mode 100644 index 000000000..00dc3992c --- /dev/null +++ b/src/inspector/widgets/surface.zig @@ -0,0 +1,103 @@ +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 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_terminal = "Terminal"; + +pub const Inspector = struct { + /// The surface being inspected. + surface: *const Surface, + + pub fn draw(self: *Inspector) 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. + { + self.surface.renderer_state.mutex.lock(); + defer self.surface.renderer_state.mutex.unlock(); + const t = self.surface.renderer_state.terminal; + drawTerminalWindow(.{ .terminal = t }); + } + + 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_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; + } +}; + +fn drawTerminalWindow(state: struct { + terminal: *terminal.Terminal, +}) void { + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( + window_terminal, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) return; + + widgets.terminal.drawInfo(.{ + .terminal = state.terminal, + }); +} diff --git a/src/inspector/widgets/terminal.zig b/src/inspector/widgets/terminal.zig new file mode 100644 index 000000000..af3da07b5 --- /dev/null +++ b/src/inspector/widgets/terminal.zig @@ -0,0 +1,29 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const cimgui = @import("dcimgui"); +const terminal = @import("../../terminal/main.zig"); +const Terminal = terminal.Terminal; + +pub const Info = struct { + terminal: *Terminal, +}; + +pub fn drawInfo(data: Info) void { + if (cimgui.c.ImGui_CollapsingHeader( + "Help", + cimgui.c.ImGuiTreeNodeFlags_None, + )) { + cimgui.c.ImGui_TextWrapped( + "This window displays the internal state of the terminal. " ++ + "The terminal state is global to this terminal. Some state " ++ + "is specific to the active screen or other subsystems. Values " ++ + "here reflect the running state and will update as the terminal " ++ + "application modifies them via escape sequences or shell integration. " ++ + "Some can be modified directly for debugging purposes.", + ); + } + + _ = data; +} From 7cfac87fc43d3f00b99bd7ed943ec3449c789eec Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Jan 2026 08:52:08 -0800 Subject: [PATCH 035/108] inspector: trying new stuff --- src/inspector/Inspector.zig | 8 +- src/inspector/widgets.zig | 114 +++++++++++++++++++++++++++ src/inspector/widgets/surface.zig | 43 +++++------ src/inspector/widgets/terminal.zig | 120 +++++++++++++++++++++++++---- 4 files changed, 244 insertions(+), 41 deletions(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 7f1d8cddf..dfe03bf50 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -14,6 +14,7 @@ const input = @import("../input.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const inspector = @import("main.zig"); +const widgets = @import("widgets.zig"); /// The window names. These are used with docking so we need to have access. const window_cell = "Cell"; @@ -57,6 +58,9 @@ windows: struct { terminal: inspector.terminal.Window = .{}, } = .{}, +// ImGui state +gui: widgets.surface.Inspector = .empty, + /// Enum representing keyboard navigation actions const KeyAction = enum { down, @@ -216,9 +220,7 @@ pub fn recordPtyRead(self: *Inspector, data: []const u8) !void { /// Render the frame. pub fn render(self: *Inspector) void { - const widgets = @import("widgets.zig"); - var s: widgets.surface.Inspector = .{ .surface = self.surface }; - s.draw(); + self.gui.draw(self.surface); if (true) return; const dock_id = cimgui.c.ImGui_DockSpaceOverViewport(); diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig index d23346a8c..af3bd414f 100644 --- a/src/inspector/widgets.zig +++ b/src/inspector/widgets.zig @@ -16,6 +16,120 @@ pub fn helpMarker(text: [:0]const u8) void { 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, diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig index 00dc3992c..e22ddc6d4 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -12,10 +12,14 @@ const window_imgui_demo = "Dear ImGui Demo"; const window_terminal = "Terminal"; pub const Inspector = struct { - /// The surface being inspected. - surface: *const Surface, + /// Internal GUI state + terminal_info: widgets.terminal.Info, - pub fn draw(self: *Inspector) void { + pub const empty: Inspector = .{ + .terminal_info = .empty, + }; + + pub fn draw(self: *Inspector, surface: *const Surface) 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"); @@ -30,10 +34,20 @@ pub const Inspector = struct { // Draw everything that requires the terminal state mutex. { - self.surface.renderer_state.mutex.lock(); - defer self.surface.renderer_state.mutex.unlock(); - const t = self.surface.renderer_state.terminal; - drawTerminalWindow(.{ .terminal = t }); + 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); + } } if (first_render) { @@ -86,18 +100,3 @@ pub const Inspector = struct { return setup; } }; - -fn drawTerminalWindow(state: struct { - terminal: *terminal.Terminal, -}) void { - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_terminal, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - widgets.terminal.drawInfo(.{ - .terminal = state.terminal, - }); -} diff --git a/src/inspector/widgets/terminal.zig b/src/inspector/widgets/terminal.zig index af3da07b5..9025e1e63 100644 --- a/src/inspector/widgets/terminal.zig +++ b/src/inspector/widgets/terminal.zig @@ -3,27 +3,115 @@ 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 Terminal = terminal.Terminal; pub const Info = struct { - terminal: *Terminal, -}; + misc_header: widgets.DetachableHeader, -pub fn drawInfo(data: Info) void { - if (cimgui.c.ImGui_CollapsingHeader( - "Help", - cimgui.c.ImGuiTreeNodeFlags_None, - )) { - cimgui.c.ImGui_TextWrapped( - "This window displays the internal state of the terminal. " ++ - "The terminal state is global to this terminal. Some state " ++ - "is specific to the active screen or other subsystems. Values " ++ - "here reflect the running state and will update as the terminal " ++ - "application modifies them via escape sequences or shell integration. " ++ - "Some can be modified directly for debugging purposes.", - ); + pub const empty: Info = .{ + .misc_header = .{}, + }; + + const misc_header_label = "Misc"; + + /// 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(misc_header_label)) |visible| { + defer self.misc_header.windowEnd(); + if (visible) miscTable(t); + } } - _ = data; + fn drawOpen(self: *Info, t: *Terminal) void { + if (cimgui.c.ImGui_CollapsingHeader( + "Help", + cimgui.c.ImGuiTreeNodeFlags_None, + )) { + cimgui.c.ImGui_TextWrapped( + "This window displays the internal state of the terminal. " ++ + "The terminal state is global to this terminal. Some state " ++ + "is specific to the active screen or other subsystems. Values " ++ + "here reflect the running state and will update as the terminal " ++ + "application modifies them via escape sequences or shell integration. " ++ + "Some can be modified directly for debugging purposes.", + ); + } + + if (self.misc_header.header(misc_header_label)) miscTable(t); + } +}; + +pub 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)"); + } + } + } } From 28086a7adc60dcedea40100ea795d13a345afaeb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Jan 2026 11:15:06 -0800 Subject: [PATCH 036/108] inspector: terminal migrate content --- src/inspector/widgets/terminal.zig | 493 ++++++++++++++++++++++++++++- 1 file changed, 483 insertions(+), 10 deletions(-) diff --git a/src/inspector/widgets/terminal.zig b/src/inspector/widgets/terminal.zig index 9025e1e63..9382335fd 100644 --- a/src/inspector/widgets/terminal.zig +++ b/src/inspector/widgets/terminal.zig @@ -5,17 +5,30 @@ 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, pub const empty: Info = .{ + .show_palette = false, .misc_header = .{}, + .layout_header = .{}, + .mouse_header = .{}, + .color_header = .{}, + .modes_header = .{}, }; - const misc_header_label = "Misc"; - /// Draw the terminal info window. pub fn draw( self: *Info, @@ -27,18 +40,43 @@ pub const Info = struct { // Draw our detached state that draws regardless of if // we're open or not. - if (self.misc_header.window(misc_header_label)) |visible| { + 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); + } + } } fn drawOpen(self: *Info, t: *Terminal) void { - if (cimgui.c.ImGui_CollapsingHeader( - "Help", - cimgui.c.ImGuiTreeNodeFlags_None, - )) { - cimgui.c.ImGui_TextWrapped( + { + widgets.helpMarker( "This window displays the internal state of the terminal. " ++ "The terminal state is global to this terminal. Some state " ++ "is specific to the active screen or other subsystems. Values " ++ @@ -48,11 +86,16 @@ pub const Info = struct { ); } - if (self.misc_header.header(misc_header_label)) miscTable(t); + 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 fn miscTable(t: *Terminal) void { +/// Table of miscellaneous terminal information. +fn miscTable(t: *Terminal) void { _ = cimgui.c.ImGui_BeginTable( "table_misc", 2, @@ -115,3 +158,433 @@ pub fn miscTable(t: *Terminal) void { } } } + +/// 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(); + } + } +} From 82dd9021bf63a13b10b3559ce7d40bdcc23e70f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Jan 2026 11:32:08 -0800 Subject: [PATCH 037/108] inspector: starting screen --- src/inspector/widgets.zig | 1 + src/inspector/widgets/screen.zig | 120 ++++++++++++++++++++++ src/inspector/widgets/terminal.zig | 156 +++++++++++++++++++++++++++-- 3 files changed, 267 insertions(+), 10 deletions(-) create mode 100644 src/inspector/widgets/screen.zig diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig index af3bd414f..d46e3fde4 100644 --- a/src/inspector/widgets.zig +++ b/src/inspector/widgets.zig @@ -1,5 +1,6 @@ const cimgui = @import("dcimgui"); +pub const screen = @import("widgets/screen.zig"); pub const surface = @import("widgets/surface.zig"); pub const terminal = @import("widgets/terminal.zig"); diff --git a/src/inspector/widgets/screen.zig b/src/inspector/widgets/screen.zig new file mode 100644 index 000000000..d7b7b455c --- /dev/null +++ b/src/inspector/widgets/screen.zig @@ -0,0 +1,120 @@ +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"); + +/// Screen information inspector widget. +pub const Info = struct { + pub const empty: Info = .{}; + + /// Draw the screen info contents. + pub fn draw(self: *Info, open: bool, data: struct { + /// The screen that we're inspecting. + screen: *const 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 { + _ = self; + const screen = data.screen; + + // 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(); + } + + if (cimgui.c.ImGui_CollapsingHeader( + "Keyboard", + cimgui.c.ImGuiTreeNodeFlags_None, + )) keyboardTable( + screen, + data.modify_other_keys_2, + ); + } +}; + +/// 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 + +} diff --git a/src/inspector/widgets/terminal.zig b/src/inspector/widgets/terminal.zig index 9382335fd..3dbea3886 100644 --- a/src/inspector/widgets/terminal.zig +++ b/src/inspector/widgets/terminal.zig @@ -20,6 +20,9 @@ pub const Info = struct { 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 = .{}, @@ -27,6 +30,7 @@ pub const Info = struct { .mouse_header = .{}, .color_header = .{}, .modes_header = .{}, + .screens = .{}, }; /// Draw the terminal info window. @@ -72,19 +76,59 @@ pub const Info = struct { 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 { - { - widgets.helpMarker( - "This window displays the internal state of the terminal. " ++ - "The terminal state is global to this terminal. Some state " ++ - "is specific to the active screen or other subsystems. Values " ++ - "here reflect the running state and will update as the terminal " ++ - "application modifies them via escape sequences or shell integration. " ++ - "Some can be modified directly for debugging purposes.", - ); - } + // 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); @@ -94,6 +138,98 @@ pub const Info = struct { } }; +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( From 5c867bf1d7e8e605cf46e103e90ff251fb9fd277 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Jan 2026 12:19:13 -0800 Subject: [PATCH 038/108] inspector: move style to widgets dir --- src/inspector/screen.zig | 2 +- src/inspector/widgets.zig | 1 + src/inspector/{ => widgets}/style.zig | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) rename src/inspector/{ => widgets}/style.zig (97%) diff --git a/src/inspector/screen.zig b/src/inspector/screen.zig index b5d43d77e..a8ff1c459 100644 --- a/src/inspector/screen.zig +++ b/src/inspector/screen.zig @@ -2,7 +2,7 @@ const std = @import("std"); const cimgui = @import("dcimgui"); const terminal = @import("../terminal/main.zig"); const inspector = @import("main.zig"); -const style = @import("style.zig"); +const style = @import("widgets/style.zig"); const units = @import("units.zig"); const widgets = @import("widgets.zig"); diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig index d46e3fde4..6857dee1a 100644 --- a/src/inspector/widgets.zig +++ b/src/inspector/widgets.zig @@ -1,6 +1,7 @@ const cimgui = @import("dcimgui"); 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"); diff --git a/src/inspector/style.zig b/src/inspector/widgets/style.zig similarity index 97% rename from src/inspector/style.zig rename to src/inspector/widgets/style.zig index 36884c8e0..bdc1816e0 100644 --- a/src/inspector/style.zig +++ b/src/inspector/widgets/style.zig @@ -1,7 +1,7 @@ const std = @import("std"); const cimgui = @import("dcimgui"); -const terminal = @import("../terminal/main.zig"); -const widgets = @import("widgets.zig"); +const terminal = @import("../../terminal/main.zig"); +const widgets = @import("../widgets.zig"); /// Render a style as a table. pub fn table( From b37ac8b287100a1de5093cfbb067ab81a003bdb1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Jan 2026 12:23:31 -0800 Subject: [PATCH 039/108] inspector: screen cursor info --- src/inspector/Inspector.zig | 3 +- src/inspector/screen.zig | 66 +------------ src/inspector/widgets/screen.zig | 157 ++++++++++++++++++++++++++++++- 3 files changed, 161 insertions(+), 65 deletions(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index dfe03bf50..cfe6d8771 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -579,7 +579,8 @@ fn renderTermioWindow(self: *Inspector) void { // imgui has no way to make a column span. if (ev.imgui_selected) { { - inspector.screen.cursorTable( + widgets.screen.cursorTable(&ev.cursor); + widgets.screen.cursorStyle( &ev.cursor, &self.surface.renderer_state.terminal.colors.palette.current, ); diff --git a/src/inspector/screen.zig b/src/inspector/screen.zig index a8ff1c459..63170722a 100644 --- a/src/inspector/screen.zig +++ b/src/inspector/screen.zig @@ -2,7 +2,6 @@ const std = @import("std"); const cimgui = @import("dcimgui"); const terminal = @import("../terminal/main.zig"); const inspector = @import("main.zig"); -const style = @import("widgets/style.zig"); const units = @import("units.zig"); const widgets = @import("widgets.zig"); @@ -65,7 +64,8 @@ pub const Window = struct { "Cursor", cimgui.c.ImGuiTreeNodeFlags_None, )) { - cursorTable( + widgets.screen.cursorTable(&screen.cursor); + widgets.screen.cursorStyle( &screen.cursor, &data.color_palette.current, ); @@ -373,66 +373,6 @@ pub const Window = struct { } cimgui.c.ImGui_Separator(); - style.table(st, &data.color_palette.current); + widgets.style.table(st, &data.color_palette.current); } }; - -pub fn cursorTable( - cursor: *const terminal.Screen.Cursor, - palette: ?*const terminal.color.Palette, -) void { - { - _ = 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("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); - } - } - - cimgui.c.ImGui_Separator(); - - style.table(cursor.style, palette); -} diff --git a/src/inspector/widgets/screen.zig b/src/inspector/widgets/screen.zig index d7b7b455c..b394872d8 100644 --- a/src/inspector/widgets/screen.zig +++ b/src/inspector/widgets/screen.zig @@ -4,6 +4,7 @@ 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"); /// Screen information inspector widget. @@ -42,6 +43,15 @@ pub const Info = struct { cimgui.c.ImGui_Separator(); } + 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, @@ -49,9 +59,78 @@ pub const Info = struct { 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); } }; +/// 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, @@ -116,5 +195,81 @@ fn keyboardTable( } } } // 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); } From fdbe4343c25ca7d47eee1200b3c0dee79a57018d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Jan 2026 12:34:03 -0800 Subject: [PATCH 040/108] remove some unused files --- src/inspector/Inspector.zig | 6 - src/inspector/main.zig | 2 - src/inspector/screen.zig | 378 -------------------- src/inspector/terminal.zig | 673 ------------------------------------ 4 files changed, 1059 deletions(-) delete mode 100644 src/inspector/screen.zig delete mode 100644 src/inspector/terminal.zig diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index cfe6d8771..25d46597b 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -52,12 +52,6 @@ need_scroll_to_selected: bool = false, /// Flag indicating whether the selection was made by keyboard is_keyboard_selection: bool = false, -/// Windows -windows: struct { - surface: inspector.surface.Window = .{}, - terminal: inspector.terminal.Window = .{}, -} = .{}, - // ImGui state gui: widgets.surface.Inspector = .empty, diff --git a/src/inspector/main.zig b/src/inspector/main.zig index 88d11ee20..636db1829 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -2,10 +2,8 @@ const std = @import("std"); pub const cell = @import("cell.zig"); pub const key = @import("key.zig"); pub const page = @import("page.zig"); -pub const screen = @import("screen.zig"); pub const surface = @import("surface.zig"); pub const termio = @import("termio.zig"); -pub const terminal = @import("terminal.zig"); pub const Cell = cell.Cell; pub const Inspector = @import("Inspector.zig"); diff --git a/src/inspector/screen.zig b/src/inspector/screen.zig deleted file mode 100644 index 63170722a..000000000 --- a/src/inspector/screen.zig +++ /dev/null @@ -1,378 +0,0 @@ -const std = @import("std"); -const cimgui = @import("dcimgui"); -const terminal = @import("../terminal/main.zig"); -const inspector = @import("main.zig"); -const units = @import("units.zig"); -const widgets = @import("widgets.zig"); - -/// Window to show screen information. -pub const Window = struct { - /// Window name/id. - pub const name = "Screen"; - - /// Grid position inputs for cell inspection. - grid_pos_x: c_int = 0, - grid_pos_y: c_int = 0, - - pub const FrameData = struct { - /// The screen that we're inspecting. - screen: *const 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, - }; - - /// Render with custom label and close button. - pub fn render( - self: *Window, - label: [:0]const u8, - open: *bool, - data: FrameData, - ) void { - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - label, - open, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - self.renderContent(data); - } - - fn renderContent(self: *Window, data: FrameData) void { - const screen = data.screen; - - // 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(); - } - - if (cimgui.c.ImGui_CollapsingHeader( - "Cursor", - cimgui.c.ImGuiTreeNodeFlags_None, - )) { - widgets.screen.cursorTable(&screen.cursor); - widgets.screen.cursorStyle( - &screen.cursor, - &data.color_palette.current, - ); - } // cursor - - if (cimgui.c.ImGui_CollapsingHeader( - "Keyboard", - cimgui.c.ImGuiTreeNodeFlags_None, - )) { - { - _ = 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 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 (data.modify_other_keys_2) "true".ptr else "false".ptr, - ); - } - } - } // keyboard mode info - } // table - } // keyboard - - if (cimgui.c.ImGui_CollapsingHeader( - "Grid", - cimgui.c.ImGuiTreeNodeFlags_None, - )) { - self.renderGrid(data); - } // grid - - if (cimgui.c.ImGui_CollapsingHeader( - "Kitty Graphics", - cimgui.c.ImGuiTreeNodeFlags_None, - )) 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_None, - )) { - 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_None, - )) { - inspector.page.render(&pages.pages.last.?.data); - } - } // terminal state - } - - /// Render the grid section. - fn renderGrid(self: *Window, data: FrameData) void { - const screen = data.screen; - const pages = &screen.pages; - - // Clamp values to valid range - const max_x: c_int = @intCast(pages.cols -| 1); - const max_y: c_int = @intCast(pages.rows -| 1); - self.grid_pos_x = std.math.clamp(self.grid_pos_x, 0, max_x); - self.grid_pos_y = std.math.clamp(self.grid_pos_y, 0, max_y); - - // Position inputs - calculate width to split available space evenly - const imgui_style = cimgui.c.ImGui_GetStyle(); - const avail_width = cimgui.c.ImGui_GetContentRegionAvail().x; - const item_spacing = imgui_style.*.ItemSpacing.x; - const label_width = cimgui.c.ImGui_CalcTextSize("x").x + imgui_style.*.ItemInnerSpacing.x; - const item_width = (avail_width - item_spacing - label_width * 2.0) / 2.0; - - cimgui.c.ImGui_PushItemWidth(item_width); - _ = cimgui.c.ImGui_DragIntEx("x", &self.grid_pos_x, 1.0, 0, max_x, "%d", cimgui.c.ImGuiSliderFlags_None); - cimgui.c.ImGui_SameLine(); - _ = cimgui.c.ImGui_DragIntEx("y", &self.grid_pos_y, 1.0, 0, max_y, "%d", cimgui.c.ImGuiSliderFlags_None); - cimgui.c.ImGui_PopItemWidth(); - - cimgui.c.ImGui_Separator(); - - const pin = pages.pin(.{ .viewport = .{ - .x = @intCast(self.grid_pos_x), - .y = @intCast(self.grid_pos_y), - } }) orelse { - cimgui.c.ImGui_TextColored( - .{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, - "Invalid position", - ); - return; - }; - - const row_and_cell = pin.rowAndCell(); - const cell = row_and_cell.cell; - const st = pin.style(cell); - - { - _ = cimgui.c.ImGui_BeginTable( - "##grid_cell_table", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - // Codepoint - { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Codepoint"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - const cp = cell.codepoint(); - if (cp == 0) { - cimgui.c.ImGui_Text("(empty)"); - } else { - cimgui.c.ImGui_Text("U+%X", @as(c_uint, cp)); - } - } - - // Grapheme extras - if (cell.hasGrapheme()) { - if (pin.grapheme(cell)) |cps| { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Grapheme"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - for (cps) |cp| { - cimgui.c.ImGui_Text("U+%X", @as(c_uint, cp)); - } - } - } - - // Width property - { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Width"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", @tagName(cell.wide).ptr); - } - } - - cimgui.c.ImGui_Separator(); - widgets.style.table(st, &data.color_palette.current); - } -}; diff --git a/src/inspector/terminal.zig b/src/inspector/terminal.zig deleted file mode 100644 index e21cb2b7f..000000000 --- a/src/inspector/terminal.zig +++ /dev/null @@ -1,673 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const cimgui = @import("dcimgui"); -const terminal = @import("../terminal/main.zig"); -const Terminal = terminal.Terminal; -const widgets = @import("widgets.zig"); -const modes = terminal.modes; -const inspector = @import("main.zig"); - -/// Context for our detachable collapsing headers. -const RenderContext = struct { - window: *Window, - terminal: *Terminal, -}; - -/// Window to show terminal state information. -pub const Window = struct { - /// Window name/id. - pub const name = "Terminal"; - - /// Whether the palette window is open. - show_palette: bool = false, - - /// State for detachable headers. - misc_state: widgets.DetachableHeaderState = .{}, - layout_state: widgets.DetachableHeaderState = .{}, - mouse_state: widgets.DetachableHeaderState = .{}, - color_state: widgets.DetachableHeaderState = .{}, - modes_state: widgets.DetachableHeaderState = .{}, - screens_state: widgets.DetachableHeaderState = .{}, - - /// Screen detail windows for each screen key. - screen_windows: std.EnumMap( - terminal.ScreenSet.Key, - inspector.screen.Window, - ) = .{}, - - // Render - pub fn render(self: *Window, t: *Terminal) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - name, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - if (cimgui.c.ImGui_CollapsingHeader( - "Help", - cimgui.c.ImGuiTreeNodeFlags_None, - )) { - cimgui.c.ImGui_TextWrapped( - "This window displays the internal state of the terminal. " ++ - "The terminal state is global to this terminal. Some state " ++ - "is specific to the active screen or other subsystems. Values " ++ - "here reflect the running state and will update as the terminal " ++ - "application modifies them via escape sequences or shell integration. " ++ - "Some can be modified directly for debugging purposes.", - ); - } - - const ctx: RenderContext = .{ .window = self, .terminal = t }; - widgets.detachableHeader("Misc", &self.misc_state, ctx, renderMiscContent); - widgets.detachableHeader("Layout", &self.layout_state, ctx, renderLayoutContent); - widgets.detachableHeader("Mouse", &self.mouse_state, ctx, renderMouseContent); - widgets.detachableHeader("Color", &self.color_state, ctx, renderColorContent); - widgets.detachableHeader("Modes", &self.modes_state, ctx, renderModesContent); - widgets.detachableHeader("Screens", &self.screens_state, ctx, renderScreensContent); - - // Pop-out screen windows - inline for (@typeInfo(terminal.ScreenSet.Key).@"enum".fields) |field| { - const key: terminal.ScreenSet.Key = @enumFromInt(field.value); - if (self.screen_windows.getPtr(key)) |screen_window| { - if (t.screens.get(key)) |screen| { - const label = comptime std.fmt.comptimePrint("Screen: {s}", .{field.name}); - var open: bool = true; - screen_window.render(label, &open, .{ - .screen = screen, - .key = key, - .active_key = t.screens.active_key, - .modify_other_keys_2 = t.flags.modify_other_keys_2, - .color_palette = &t.colors.palette, - }); - if (!open) { - self.screen_windows.remove(key); - } - } - } - } - - 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); - } - } - } -}; - -fn renderMiscContent(ctx: RenderContext) void { - const t = ctx.terminal; - _ = 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)"); - } - } - } -} - -fn renderLayoutContent(ctx: RenderContext) void { - const t = ctx.terminal; - _ = 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; - } - } - } -} - -fn renderMouseContent(ctx: RenderContext) void { - const t = ctx.terminal; - _ = 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); - } - } - } -} - -fn renderColorContent(ctx: RenderContext) void { - const t = ctx.terminal; - 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")) { - ctx.window.show_palette = true; - } - } - } -} - -/// Render a DynamicRGB color. -/// -/// Note: this currently can't be modified but we plan to allow that -/// and return a boolean letting you know if anything was modified. -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(); - } - } -} - -fn renderModesContent(ctx: RenderContext) void { - const t = ctx.terminal; - - _ = 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); - } - } -} - -fn renderScreensContent(ctx: RenderContext) void { - const t = ctx.terminal; - - cimgui.c.ImGui_Text("Screens"); - cimgui.c.ImGui_SameLine(); - 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_Separator(); - - _ = cimgui.c.ImGui_BeginTable( - "table_screens", - 3, - cimgui.c.ImGuiTableFlags_Borders | - cimgui.c.ImGuiTableFlags_RowBg | - cimgui.c.ImGuiTableFlags_SizingFixedFit, - ); - 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); - cimgui.c.ImGui_TableHeadersRow(); - - inline for (@typeInfo(terminal.ScreenSet.Key).@"enum".fields) |field| { - const key: terminal.ScreenSet.Key = @enumFromInt(field.value); - 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); - const name = comptime std.fmt.comptimePrint("{s}", .{field.name}); - cimgui.c.ImGui_Text("%s", name.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); - const id = comptime std.fmt.comptimePrint("{s}", .{field.name}); - cimgui.c.ImGui_PushID(id.ptr); - defer cimgui.c.ImGui_PopID(); - if (is_initialized) { - if (cimgui.c.ImGui_Button("View")) { - ctx.window.screen_windows.put(key, .{}); - } - } else { - cimgui.c.ImGui_BeginDisabled(true); - _ = cimgui.c.ImGui_Button("View"); - cimgui.c.ImGui_EndDisabled(); - } - } - } -} From 53b2a777e7ba9aa6711df417b9ff8b8ea9cb29e5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Jan 2026 12:39:24 -0800 Subject: [PATCH 041/108] inspector: screen info has its own dockspace --- src/inspector/widgets/screen.zig | 111 ++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 24 deletions(-) diff --git a/src/inspector/widgets/screen.zig b/src/inspector/widgets/screen.zig index b394872d8..164f50153 100644 --- a/src/inspector/widgets/screen.zig +++ b/src/inspector/widgets/screen.zig @@ -7,6 +7,10 @@ const widgets = @import("../widgets.zig"); const units = @import("../units.zig"); const terminal = @import("../../terminal/main.zig"); +/// Window names for the screen dockspace. +const window_info = "Info"; +const window_pagelist = "PageList"; + /// Screen information inspector widget. pub const Info = struct { pub const empty: Info = .{}; @@ -29,8 +33,64 @@ pub const Info = struct { color_palette: *const terminal.color.DynamicPalette, }) void { _ = self; + + // 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); + } + + // PageList window + pagelist: { + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( + window_pagelist, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) break :pagelist; + cimgui.c.ImGui_Text("hello"); + } + // The remainder is the open state if (!open) return; @@ -42,33 +102,36 @@ pub const Info = struct { ); cimgui.c.ImGui_Separator(); } + } - if (cimgui.c.ImGui_CollapsingHeader( - "Cursor", - cimgui.c.ImGuiTreeNodeFlags_None, - )) { - cursorTable(&screen.cursor); - cimgui.c.ImGui_Separator(); - cursorStyle(&screen.cursor, &data.color_palette.current); + /// 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_pagelist, dockspace_id); + cimgui.ImGui_DockBuilderFinish(dockspace_id); } - 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); + // Create the dockspace + assert(cimgui.c.ImGui_DockSpaceEx( + dockspace_id, + .{ .x = 0, .y = 0 }, + cimgui.c.ImGuiDockNodeFlags_None, + null, + ) == dockspace_id); + return setup; } }; From 7f008d126ff09227140d86319f128e4b6af7eaa0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Jan 2026 13:01:25 -0800 Subject: [PATCH 042/108] inspector: surface info is back --- src/inspector/Inspector.zig | 4 +- src/inspector/main.zig | 2 +- src/inspector/surface.zig | 319 ---------------------------- src/inspector/widgets/surface.zig | 331 +++++++++++++++++++++++++++++- 4 files changed, 333 insertions(+), 323 deletions(-) delete mode 100644 src/inspector/surface.zig diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 25d46597b..324f913f1 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -31,7 +31,7 @@ first_render: bool = true, /// Mouse state that we track in addition to normal mouse states that /// Ghostty always knows about. -mouse: inspector.surface.Mouse = .{}, +mouse: widgets.surface.Mouse = .{}, /// A selected cell. cell: CellInspect = .{ .idle = {} }, @@ -214,7 +214,7 @@ pub fn recordPtyRead(self: *Inspector, data: []const u8) !void { /// Render the frame. pub fn render(self: *Inspector) void { - self.gui.draw(self.surface); + self.gui.draw(self.surface, self.mouse); if (true) return; const dock_id = cimgui.c.ImGui_DockSpaceOverViewport(); diff --git a/src/inspector/main.zig b/src/inspector/main.zig index 636db1829..f0376f8a0 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -2,7 +2,7 @@ const std = @import("std"); pub const cell = @import("cell.zig"); pub const key = @import("key.zig"); pub const page = @import("page.zig"); -pub const surface = @import("surface.zig"); + pub const termio = @import("termio.zig"); pub const Cell = cell.Cell; diff --git a/src/inspector/surface.zig b/src/inspector/surface.zig deleted file mode 100644 index 8286156f3..000000000 --- a/src/inspector/surface.zig +++ /dev/null @@ -1,319 +0,0 @@ -const cimgui = @import("dcimgui"); -const input = @import("../input.zig"); -const renderer = @import("../renderer.zig"); -const terminal = @import("../terminal/main.zig"); -const Surface = @import("../Surface.zig"); - -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, -}; - -/// Window to show surface information. -pub const Window = struct { - /// Window name/id. - pub const name = "Surface Info"; - - pub const FrameData = struct { - /// The surface that we're inspecting. - surface: *Surface, - - /// Mouse state that we track in addition to normal mouse states that - /// Ghostty always knows about. - mouse: Mouse = .{}, - }; - - /// Render - pub fn render(self: *Window, data: FrameData) void { - _ = self; - - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - name, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) 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"); - - { - _ = 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", - data.surface.size.screen.width, - data.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 = data.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", - data.surface.size.cell.width, - data.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", - data.surface.size.padding.top, - data.surface.size.padding.bottom, - data.surface.size.padding.left, - data.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", - data.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", - data.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 = &data.surface.mouse; - const t = data.surface.renderer_state.terminal; - - { - const hover_point: terminal.point.Coordinate = pt: { - const p = data.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 = data.mouse.last_xpos, - .y = data.mouse.last_ypos, - }, - }).convert(.terminal, data.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)), - ); - } - } - } - } -}; diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig index e22ddc6d4..a71144081 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -4,22 +4,27 @@ const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const cimgui = @import("dcimgui"); 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_terminal = "Terminal"; +const window_surface = "Surface"; pub const Inspector = struct { /// Internal GUI state + surface_info: Info, terminal_info: widgets.terminal.Info, pub const empty: Inspector = .{ + .surface_info = .empty, .terminal_info = .empty, }; - pub fn draw(self: *Inspector, surface: *const Surface) void { + 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"); @@ -48,6 +53,21 @@ pub const Inspector = struct { 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, + ); + } } if (first_render) { @@ -86,6 +106,7 @@ pub const Inspector = struct { // 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_imgui_demo, dock_id_main); cimgui.ImGui_DockBuilderFinish(dockspace_id); } @@ -100,3 +121,311 @@ pub const Inspector = struct { 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)), + ); + } + } +} From c8e048f309ea39c44bd29ad60b3f12b732d4142c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Jan 2026 13:34:34 -0800 Subject: [PATCH 043/108] inspector: pagelist --- src/inspector/AGENTS.md | 4 + src/inspector/widgets.zig | 1 + src/inspector/widgets/pagelist.zig | 698 +++++++++++++++++++++++++++++ src/inspector/widgets/screen.zig | 8 +- src/terminal/PageList.zig | 6 + 5 files changed, 713 insertions(+), 4 deletions(-) create mode 100644 src/inspector/widgets/pagelist.zig diff --git a/src/inspector/AGENTS.md b/src/inspector/AGENTS.md index ad6ff676b..dafc81e0b 100644 --- a/src/inspector/AGENTS.md +++ b/src/inspector/AGENTS.md @@ -1,5 +1,9 @@ # 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: diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig index 6857dee1a..bea49dda1 100644 --- a/src/inspector/widgets.zig +++ b/src/inspector/widgets.zig @@ -1,5 +1,6 @@ const cimgui = @import("dcimgui"); +pub const pagelist = @import("widgets/pagelist.zig"); pub const screen = @import("widgets/screen.zig"); pub const style = @import("widgets/style.zig"); pub const surface = @import("widgets/surface.zig"); diff --git a/src/inspector/widgets/pagelist.zig b/src/inspector/widgets/pagelist.zig new file mode 100644 index 000000000..604446fa0 --- /dev/null +++ b/src/inspector/widgets/pagelist.zig @@ -0,0 +1,698 @@ +const std = @import("std"); +const cimgui = @import("dcimgui"); +const terminal = @import("../../terminal/main.zig"); +const widgets = @import("../widgets.zig"); +const units = @import("../units.zig"); +const page_inspector = @import("../page.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 linked in scrollback order. 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: usize = 0; + var index: usize = 0; + var node = pages.pages.first; + while (node) |page_node| : (node = page_node.next) { + const page = &page_node.data; + const row_start = row_offset; + const row_end = row_offset + page.size.rows - 1; + const stats = pageStats(page); + + row_offset += page.size.rows; + + cimgui.c.ImGui_PushIDInt(@intCast(index)); + defer cimgui.c.ImGui_PopID(); + + const header_state = pageHeaderRow( + index, + page, + page_node, + row_start, + row_end, + active_pin, + viewport_pin, + stats, + ); + + if (header_state.open) { + pageMetaTable(page_node, row_start, row_end, active_pin, viewport_pin, stats); + cimgui.c.ImGui_Separator(); + page_inspector.render(page); + cimgui.c.ImGui_Separator(); + contentStatsTable(stats); + cimgui.c.ImGui_TreePop(); + } + + index += 1; + } + } + } +}; + +const PageStats = struct { + rows_with_text: usize = 0, + cells_with_text: usize = 0, + dirty_rows: usize = 0, + wrap_rows: usize = 0, + wrap_cont_rows: usize = 0, + styled_rows: usize = 0, + grapheme_rows: usize = 0, + hyperlink_rows: usize = 0, + first_text_row: ?usize = null, + last_text_row: ?usize = null, + hyperlink_cells: usize = 0, + styled_cells: usize = 0, + grapheme_cells: usize = 0, +}; + +fn pageStats(page: *const terminal.Page) PageStats { + var stats: PageStats = .{}; + const rows = page.rows.ptr(page.memory)[0..page.size.rows]; + for (rows, 0..) |*row, row_index| { + if (row.dirty) stats.dirty_rows += 1; + if (row.wrap) stats.wrap_rows += 1; + if (row.wrap_continuation) stats.wrap_cont_rows += 1; + if (row.styled) stats.styled_rows += 1; + if (row.grapheme) stats.grapheme_rows += 1; + if (row.hyperlink) stats.hyperlink_rows += 1; + + const cells = page.getCells(row); + var row_cells_with_text: usize = 0; + for (cells) |cell| { + if (cell.hasText()) row_cells_with_text += 1; + if (cell.hasStyling()) stats.styled_cells += 1; + if (cell.hasGrapheme()) stats.grapheme_cells += 1; + if (cell.hyperlink) stats.hyperlink_cells += 1; + } + + if (row_cells_with_text > 0) { + stats.rows_with_text += 1; + stats.cells_with_text += row_cells_with_text; + if (stats.first_text_row == null) stats.first_text_row = row_index; + stats.last_text_row = row_index; + } + } + + return stats; +} + +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 pageMetaTable( + node: *const PageList.List.Node, + row_start: usize, + row_end: usize, + active_pin: terminal.Pin, + viewport_pin: terminal.Pin, + stats: PageStats, +) void { + if (!cimgui.c.ImGui_BeginTable( + "page_meta", + 2, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + const page = &node.data; + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Row Range"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d..%d", row_start, row_end); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Serial"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", node.serial); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Dirty"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", if (page.isDirty()) "true".ptr else "false".ptr); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Active Top"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", if (node == active_pin.node) "true".ptr else "false".ptr); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Viewport Top"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", if (node == viewport_pin.node) "true".ptr else "false".ptr); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Links"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%d map / %d set", + page.hyperlink_map.map(page.memory).count(), + page.hyperlink_set.count(), + ); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Text Coverage"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d/%d rows", stats.rows_with_text, page.size.rows); +} + +fn contentStatsTable(stats: PageStats) void { + if (!cimgui.c.ImGui_BeginTable( + "page_content_stats", + 2, + 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("Rows w/ Text"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", stats.rows_with_text); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Cells w/ Text"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", stats.cells_with_text); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Text Row Range"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (stats.first_text_row) |first| { + cimgui.c.ImGui_Text("%d..%d", first, stats.last_text_row.?); + } else { + cimgui.c.ImGui_TextDisabled("(none)"); + } + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Dirty Rows"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", stats.dirty_rows); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Wrap Rows"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", stats.wrap_rows); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Wrap Continuations"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", stats.wrap_cont_rows); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Styled Rows"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", stats.styled_rows); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Styled Cells"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", stats.styled_cells); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grapheme Rows"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", stats.grapheme_rows); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grapheme Cells"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", stats.grapheme_cells); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hyperlink Rows"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", stats.hyperlink_rows); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hyperlink Cells"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", stats.hyperlink_cells); +} + +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"); + } + } +} + +const PageHeaderState = struct { + open: bool, +}; + +fn pageHeaderRow( + index: usize, + page: *const terminal.Page, + page_node: *const PageList.List.Node, + row_start: usize, + row_end: usize, + active_pin: terminal.Pin, + viewport_pin: terminal.Pin, + stats: PageStats, +) PageHeaderState { + var label_buf: [160]u8 = undefined; + const label = std.fmt.bufPrintZ( + &label_buf, + "Page {d}", + .{index}, + ) catch "Page"; + + const flags = cimgui.c.ImGuiTreeNodeFlags_AllowOverlap | + cimgui.c.ImGuiTreeNodeFlags_SpanFullWidth | + cimgui.c.ImGuiTreeNodeFlags_FramePadding; + const open = cimgui.c.ImGui_TreeNodeEx(label.ptr, flags); + + 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 }); + cimgui.c.ImGui_TextDisabled("%dc x %dr", page.size.cols, page.size.rows); + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("rows %d..%d", row_start, row_end); + + if (page_node == active_pin.node) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.9, .z = 0.4, .w = 1.0 }, "active"); + } + if (page_node == viewport_pin.node) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "viewport"); + } + if (page.isDirty()) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + } + + const coverage = if (page.size.rows > 0) + @as(f32, @floatFromInt(stats.rows_with_text)) / + @as(f32, @floatFromInt(page.size.rows)) + else + 0.0; + + const bar_width: f32 = 140; + const bar_height: f32 = 0; + cimgui.c.ImGui_SetCursorScreenPos(.{ .x = header_max.x - bar_width - 10, .y = y_center }); + cimgui.c.ImGui_ProgressBar(coverage, .{ .x = bar_width, .y = bar_height }, null); + if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) { + cimgui.c.ImGui_SetTooltip("Text coverage: %d/%d rows", stats.rows_with_text, page.size.rows); + } + + return .{ .open = open }; +} + +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, + ); + } +} diff --git a/src/inspector/widgets/screen.zig b/src/inspector/widgets/screen.zig index 164f50153..a308e9185 100644 --- a/src/inspector/widgets/screen.zig +++ b/src/inspector/widgets/screen.zig @@ -13,12 +13,14 @@ const window_pagelist = "PageList"; /// Screen information inspector widget. pub const Info = struct { + pagelist: widgets.pagelist.Inspector = .{}, + pub const empty: Info = .{}; /// Draw the screen info contents. pub fn draw(self: *Info, open: bool, data: struct { /// The screen that we're inspecting. - screen: *const terminal.Screen, + screen: *terminal.Screen, /// Which screen key we're viewing. key: terminal.ScreenSet.Key, @@ -32,8 +34,6 @@ pub const Info = struct { /// Color palette for cursor color resolution. color_palette: *const terminal.color.DynamicPalette, }) void { - _ = self; - // Create the dockspace for this screen const dockspace_id = cimgui.c.ImGui_GetID("Screen Dockspace"); _ = createDockSpace(dockspace_id); @@ -88,7 +88,7 @@ pub const Info = struct { null, cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, )) break :pagelist; - cimgui.c.ImGui_Text("hello"); + self.pagelist.draw(&screen.pages); } // The remainder is the open state diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index f7d3c735f..1b01f8edb 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. From 9b75f4a7999e4a0c85b4ef9f2c67c8f0157b8188 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 09:48:26 -0800 Subject: [PATCH 044/108] inspector: page improvements --- src/inspector/main.zig | 1 - src/inspector/page.zig | 163 ------------ src/inspector/widgets.zig | 1 + src/inspector/widgets/page.zig | 393 +++++++++++++++++++++++++++++ src/inspector/widgets/pagelist.zig | 320 ++--------------------- 5 files changed, 414 insertions(+), 464 deletions(-) delete mode 100644 src/inspector/page.zig create mode 100644 src/inspector/widgets/page.zig diff --git a/src/inspector/main.zig b/src/inspector/main.zig index f0376f8a0..ae2c3b16f 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -1,7 +1,6 @@ const std = @import("std"); pub const cell = @import("cell.zig"); pub const key = @import("key.zig"); -pub const page = @import("page.zig"); pub const termio = @import("termio.zig"); 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/widgets.zig b/src/inspector/widgets.zig index bea49dda1..3064a3ec4 100644 --- a/src/inspector/widgets.zig +++ b/src/inspector/widgets.zig @@ -1,5 +1,6 @@ const cimgui = @import("dcimgui"); +pub const page = @import("widgets/page.zig"); pub const pagelist = @import("widgets/pagelist.zig"); pub const screen = @import("widgets/screen.zig"); pub const style = @import("widgets/style.zig"); diff --git a/src/inspector/widgets/page.zig b/src/inspector/widgets/page.zig new file mode 100644 index 000000000..b55bf1726 --- /dev/null +++ b/src/inspector/widgets/page.zig @@ -0,0 +1,393 @@ +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); +} + +/// 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", + 3, + 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("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); + cimgui.c.ImGui_Text("%d", size.cols); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + 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); + cimgui.c.ImGui_Text("%d", size.rows); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + 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); + cimgui.c.ImGui_Text("%d", page.styles.count()); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + 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); + cimgui.c.ImGui_Text("%d", page.graphemeCount()); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", page.graphemeCapacity()); + + { + const StringAlloc = @TypeOf(page.string_alloc); + const string_chunk = StringAlloc.bytesRequired(u8, 1); + const string_total_chunks = page.string_alloc.bitmap_count * StringAlloc.bitmap_bit_size; + var string_free_chunks: usize = 0; + const string_bitmaps = page.string_alloc.bitmap.ptr(page.memory); + for (string_bitmaps[0..page.string_alloc.bitmap_count]) |bitmap| { + string_free_chunks += @popCount(bitmap); + } + const string_used_chunks = string_total_chunks - string_free_chunks; + const string_used_bytes = string_used_chunks * string_chunk; + const string_capacity_bytes = string_total_chunks * string_chunk; + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Strings (bytes)"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", string_used_bytes); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", string_capacity_bytes); + } + + { + 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); + cimgui.c.ImGui_Text("%d", hyperlink_map.count()); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + 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); + cimgui.c.ImGui_Text("%d", page.hyperlink_set.count()); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", page.hyperlink_set.layout.cap); + } +} + +pub const Context = struct { + page_node: *const PageList.List.Node, + index: usize, + row_start: usize, + row_end: usize, + active_node: *const PageList.List.Node, + viewport_node: *const PageList.List.Node, +}; + +pub const InspectorState = struct { + open: bool, + rows_with_text: usize, +}; + +const PageStats = struct { + rows_with_text: usize, +}; + +fn pageStats(page: *const terminal.Page) PageStats { + var stats: PageStats = .{ .rows_with_text = 0 }; + const rows = page.rows.ptr(page.memory)[0..page.size.rows]; + for (rows) |*row| { + const cells = page.getCells(row); + for (cells) |cell| { + if (cell.hasText()) { + stats.rows_with_text += 1; + break; + } + } + } + return stats; +} + +pub fn draw(page: *const terminal.Page) void { + cimgui.c.ImGui_PushIDPtr(page); + defer cimgui.c.ImGui_PopID(); + + cimgui.c.ImGui_SeparatorText("Memory"); + memoryTable(page); + + cimgui.c.ImGui_SeparatorText("Grid"); + gridTable(page); + + cimgui.c.ImGui_SeparatorText("Rows"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Per-row metadata. Cells are coming next."); + rowsTable(page); +} + +fn memoryTable(page: *const terminal.Page) void { + if (!cimgui.c.ImGui_BeginTable( + "##page_memory", + 2, + 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("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_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("VM Pages"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", page.memory.len / std.heap.page_size_min); +} + +fn gridTable(page: *const terminal.Page) void { + if (!cimgui.c.ImGui_BeginTable( + "##page_grid", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn("Dimension", 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); + cimgui.c.ImGui_Text("%d", size.cols); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + 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); + cimgui.c.ImGui_Text("%d", size.rows); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", cap.rows); +} + +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); + + _ = cimgui.c.ImGui_BeginChild( + "##page_rows", + .{ .x = 0.0, .y = child_height }, + cimgui.c.ImGuiChildFlags_Borders, + cimgui.c.ImGuiWindowFlags_None, + ); + defer cimgui.c.ImGui_EndChild(); + + 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_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 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 index 604446fa0..6a40c0e4e 100644 --- a/src/inspector/widgets/pagelist.zig +++ b/src/inspector/widgets/pagelist.zig @@ -3,7 +3,6 @@ const cimgui = @import("dcimgui"); const terminal = @import("../../terminal/main.zig"); const widgets = @import("../widgets.zig"); const units = @import("../units.zig"); -const page_inspector = @import("../page.zig"); const PageList = terminal.PageList; @@ -47,100 +46,41 @@ pub const Inspector = struct { cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) { widgets.helpMarker( - "Pages are linked in scrollback order. Each page holds a grid of rows/cells " ++ + "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: usize = 0; - var index: usize = 0; - var node = pages.pages.first; - while (node) |page_node| : (node = page_node.next) { + 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; - const row_start = row_offset; - const row_end = row_offset + page.size.rows - 1; - const stats = pageStats(page); + row_offset -= page.size.rows; + index -= 1; - row_offset += page.size.rows; - - cimgui.c.ImGui_PushIDInt(@intCast(index)); + // Use our node pointer, which is guaranteed to be unique + // in this hierarchy, as the ID. + cimgui.c.ImGui_PushIDPtr(node); defer cimgui.c.ImGui_PopID(); - const header_state = pageHeaderRow( - index, - page, - page_node, - row_start, - row_end, - active_pin, - viewport_pin, - stats, - ); - - if (header_state.open) { - pageMetaTable(page_node, row_start, row_end, active_pin, viewport_pin, stats); - cimgui.c.ImGui_Separator(); - page_inspector.render(page); - cimgui.c.ImGui_Separator(); - contentStatsTable(stats); - cimgui.c.ImGui_TreePop(); - } - - index += 1; + // 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); } } } }; -const PageStats = struct { - rows_with_text: usize = 0, - cells_with_text: usize = 0, - dirty_rows: usize = 0, - wrap_rows: usize = 0, - wrap_cont_rows: usize = 0, - styled_rows: usize = 0, - grapheme_rows: usize = 0, - hyperlink_rows: usize = 0, - first_text_row: ?usize = null, - last_text_row: ?usize = null, - hyperlink_cells: usize = 0, - styled_cells: usize = 0, - grapheme_cells: usize = 0, -}; - -fn pageStats(page: *const terminal.Page) PageStats { - var stats: PageStats = .{}; - const rows = page.rows.ptr(page.memory)[0..page.size.rows]; - for (rows, 0..) |*row, row_index| { - if (row.dirty) stats.dirty_rows += 1; - if (row.wrap) stats.wrap_rows += 1; - if (row.wrap_continuation) stats.wrap_cont_rows += 1; - if (row.styled) stats.styled_rows += 1; - if (row.grapheme) stats.grapheme_rows += 1; - if (row.hyperlink) stats.hyperlink_rows += 1; - - const cells = page.getCells(row); - var row_cells_with_text: usize = 0; - for (cells) |cell| { - if (cell.hasText()) row_cells_with_text += 1; - if (cell.hasStyling()) stats.styled_cells += 1; - if (cell.hasGrapheme()) stats.grapheme_cells += 1; - if (cell.hyperlink) stats.hyperlink_cells += 1; - } - - if (row_cells_with_text > 0) { - stats.rows_with_text += 1; - stats.cells_with_text += row_cells_with_text; - if (stats.first_text_row == null) stats.first_text_row = row_index; - stats.last_text_row = row_index; - } - } - - return stats; -} - fn summaryTable(pages: *const PageList) void { if (!cimgui.c.ImGui_BeginTable( "pagelist_summary", @@ -219,158 +159,6 @@ fn summaryTable(pages: *const PageList) void { cimgui.c.ImGui_Text("%d", pages.countTrackedPins()); } -fn pageMetaTable( - node: *const PageList.List.Node, - row_start: usize, - row_end: usize, - active_pin: terminal.Pin, - viewport_pin: terminal.Pin, - stats: PageStats, -) void { - if (!cimgui.c.ImGui_BeginTable( - "page_meta", - 2, - cimgui.c.ImGuiTableFlags_BordersInnerV | - cimgui.c.ImGuiTableFlags_RowBg | - cimgui.c.ImGuiTableFlags_SizingFixedFit, - )) return; - defer cimgui.c.ImGui_EndTable(); - - const page = &node.data; - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Row Range"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d..%d", row_start, row_end); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Serial"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", node.serial); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Dirty"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", if (page.isDirty()) "true".ptr else "false".ptr); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Active Top"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", if (node == active_pin.node) "true".ptr else "false".ptr); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Viewport Top"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", if (node == viewport_pin.node) "true".ptr else "false".ptr); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Links"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%d map / %d set", - page.hyperlink_map.map(page.memory).count(), - page.hyperlink_set.count(), - ); - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Text Coverage"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d/%d rows", stats.rows_with_text, page.size.rows); -} - -fn contentStatsTable(stats: PageStats) void { - if (!cimgui.c.ImGui_BeginTable( - "page_content_stats", - 2, - 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("Rows w/ Text"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", stats.rows_with_text); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Cells w/ Text"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", stats.cells_with_text); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Text Row Range"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - if (stats.first_text_row) |first| { - cimgui.c.ImGui_Text("%d..%d", first, stats.last_text_row.?); - } else { - cimgui.c.ImGui_TextDisabled("(none)"); - } - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Dirty Rows"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", stats.dirty_rows); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Wrap Rows"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", stats.wrap_rows); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Wrap Continuations"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", stats.wrap_cont_rows); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Styled Rows"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", stats.styled_rows); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Styled Cells"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", stats.styled_cells); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Grapheme Rows"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", stats.grapheme_rows); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Grapheme Cells"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", stats.grapheme_cells); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Hyperlink Rows"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", stats.hyperlink_rows); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Hyperlink Cells"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", stats.hyperlink_cells); -} - fn scrollbarInfo(pages: *PageList) void { const scrollbar = pages.scrollbar(); @@ -554,74 +342,6 @@ fn trackedPinsTable(pages: *const PageList) void { } } -const PageHeaderState = struct { - open: bool, -}; - -fn pageHeaderRow( - index: usize, - page: *const terminal.Page, - page_node: *const PageList.List.Node, - row_start: usize, - row_end: usize, - active_pin: terminal.Pin, - viewport_pin: terminal.Pin, - stats: PageStats, -) PageHeaderState { - var label_buf: [160]u8 = undefined; - const label = std.fmt.bufPrintZ( - &label_buf, - "Page {d}", - .{index}, - ) catch "Page"; - - const flags = cimgui.c.ImGuiTreeNodeFlags_AllowOverlap | - cimgui.c.ImGuiTreeNodeFlags_SpanFullWidth | - cimgui.c.ImGuiTreeNodeFlags_FramePadding; - const open = cimgui.c.ImGui_TreeNodeEx(label.ptr, flags); - - 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 }); - cimgui.c.ImGui_TextDisabled("%dc x %dr", page.size.cols, page.size.rows); - - cimgui.c.ImGui_SameLine(); - cimgui.c.ImGui_Text("rows %d..%d", row_start, row_end); - - if (page_node == active_pin.node) { - cimgui.c.ImGui_SameLine(); - cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.9, .z = 0.4, .w = 1.0 }, "active"); - } - if (page_node == viewport_pin.node) { - cimgui.c.ImGui_SameLine(); - cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "viewport"); - } - if (page.isDirty()) { - cimgui.c.ImGui_SameLine(); - cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); - } - - const coverage = if (page.size.rows > 0) - @as(f32, @floatFromInt(stats.rows_with_text)) / - @as(f32, @floatFromInt(page.size.rows)) - else - 0.0; - - const bar_width: f32 = 140; - const bar_height: f32 = 0; - cimgui.c.ImGui_SetCursorScreenPos(.{ .x = header_max.x - bar_width - 10, .y = y_center }); - cimgui.c.ImGui_ProgressBar(coverage, .{ .x = bar_width, .y = bar_height }, null); - if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) { - cimgui.c.ImGui_SetTooltip("Text coverage: %d/%d rows", stats.rows_with_text, page.size.rows); - } - - return .{ .open = open }; -} - fn scrollbarWidget( scrollbar: *const PageList.Scrollbar, delta_row: *isize, From 51ce21083347ca16fe27111d2ee1bb63561762ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 10:23:54 -0800 Subject: [PATCH 045/108] terminal: add helpers to BitmapAllocator for sizing --- src/inspector/widgets/page.zig | 27 +++++++-------------------- src/terminal/bitmap_allocator.zig | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/inspector/widgets/page.zig b/src/inspector/widgets/page.zig index b55bf1726..a3d568fe9 100644 --- a/src/inspector/widgets/page.zig +++ b/src/inspector/widgets/page.zig @@ -149,26 +149,13 @@ pub fn managedMemory(page: *const Page) void { _ = cimgui.c.ImGui_TableSetColumnIndex(2); cimgui.c.ImGui_Text("%d", page.graphemeCapacity()); - { - const StringAlloc = @TypeOf(page.string_alloc); - const string_chunk = StringAlloc.bytesRequired(u8, 1); - const string_total_chunks = page.string_alloc.bitmap_count * StringAlloc.bitmap_bit_size; - var string_free_chunks: usize = 0; - const string_bitmaps = page.string_alloc.bitmap.ptr(page.memory); - for (string_bitmaps[0..page.string_alloc.bitmap_count]) |bitmap| { - string_free_chunks += @popCount(bitmap); - } - const string_used_chunks = string_total_chunks - string_free_chunks; - const string_used_bytes = string_used_chunks * string_chunk; - const string_capacity_bytes = string_total_chunks * string_chunk; - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Strings (bytes)"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", string_used_bytes); - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", string_capacity_bytes); - } + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Strings (bytes)"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", page.string_alloc.usedBytes(page.memory)); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", page.string_alloc.capacityBytes()); { const hyperlink_map = page.hyperlink_map.map(page.memory); 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); From 3ac4f70e481c6b9c76c06a19e1e8500fc522b210 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 10:25:48 -0800 Subject: [PATCH 046/108] inspector: clean up page --- src/inspector/widgets/page.zig | 170 +++++++---------------------- src/inspector/widgets/pagelist.zig | 6 +- 2 files changed, 42 insertions(+), 134 deletions(-) diff --git a/src/inspector/widgets/page.zig b/src/inspector/widgets/page.zig index a3d568fe9..17dd20ec4 100644 --- a/src/inspector/widgets/page.zig +++ b/src/inspector/widgets/page.zig @@ -10,6 +10,9 @@ const Page = terminal.Page; pub fn inspector(page: *const terminal.Page) void { cimgui.c.ImGui_SeparatorText("Managed Memory"); managedMemory(page); + + cimgui.c.ImGui_SeparatorText("Rows"); + rowsTable(page); } /// Draw a tree node header with metadata about this page. Returns if @@ -103,7 +106,7 @@ pub fn managedMemory(page: *const Page) void { if (cimgui.c.ImGui_BeginTable( "##managed", - 3, + 4, cimgui.c.ImGuiTableFlags_BordersInnerV | cimgui.c.ImGuiTableFlags_RowBg | cimgui.c.ImGuiTableFlags_SizingFixedFit, @@ -111,6 +114,7 @@ pub fn managedMemory(page: *const Page) void { 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(); @@ -121,186 +125,89 @@ pub fn managedMemory(page: *const Page) void { _ = cimgui.c.ImGui_TableSetColumnIndex(0); cimgui.c.ImGui_Text("Columns"); _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", size.cols); + 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); - cimgui.c.ImGui_Text("%d", size.rows); + 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); - cimgui.c.ImGui_Text("%d", page.styles.count()); + 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); - cimgui.c.ImGui_Text("%d", page.graphemeCount()); + 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); - cimgui.c.ImGui_Text("%d", page.string_alloc.usedBytes(page.memory)); + 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); - cimgui.c.ImGui_Text("%d", hyperlink_map.count()); - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", hyperlink_map.capacity()); - } + 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); - cimgui.c.ImGui_Text("%d", page.hyperlink_set.count()); + 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); } } -pub const Context = struct { - page_node: *const PageList.List.Node, - index: usize, - row_start: usize, - row_end: usize, - active_node: *const PageList.List.Node, - viewport_node: *const PageList.List.Node, -}; - -pub const InspectorState = struct { - open: bool, - rows_with_text: usize, -}; - -const PageStats = struct { - rows_with_text: usize, -}; - -fn pageStats(page: *const terminal.Page) PageStats { - var stats: PageStats = .{ .rows_with_text = 0 }; - const rows = page.rows.ptr(page.memory)[0..page.size.rows]; - for (rows) |*row| { - const cells = page.getCells(row); - for (cells) |cell| { - if (cell.hasText()) { - stats.rows_with_text += 1; - break; - } - } - } - return stats; -} - -pub fn draw(page: *const terminal.Page) void { - cimgui.c.ImGui_PushIDPtr(page); - defer cimgui.c.ImGui_PopID(); - - cimgui.c.ImGui_SeparatorText("Memory"); - memoryTable(page); - - cimgui.c.ImGui_SeparatorText("Grid"); - gridTable(page); - - cimgui.c.ImGui_SeparatorText("Rows"); - cimgui.c.ImGui_SameLine(); - widgets.helpMarker("Per-row metadata. Cells are coming next."); - rowsTable(page); -} - -fn memoryTable(page: *const terminal.Page) void { - if (!cimgui.c.ImGui_BeginTable( - "##page_memory", - 2, - 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("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_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("VM Pages"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", page.memory.len / std.heap.page_size_min); -} - -fn gridTable(page: *const terminal.Page) void { - if (!cimgui.c.ImGui_BeginTable( - "##page_grid", - 3, - cimgui.c.ImGuiTableFlags_BordersInnerV | - cimgui.c.ImGuiTableFlags_RowBg | - cimgui.c.ImGuiTableFlags_SizingFixedFit, - )) return; - defer cimgui.c.ImGui_EndTable(); - - cimgui.c.ImGui_TableSetupColumn("Dimension", 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); - cimgui.c.ImGui_Text("%d", size.cols); - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - 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); - cimgui.c.ImGui_Text("%d", size.rows); - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", cap.rows); -} - 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); - _ = cimgui.c.ImGui_BeginChild( + // 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, - ); - defer cimgui.c.ImGui_EndChild(); + )) return; if (!cimgui.c.ImGui_BeginTable( "##page_rows_table", @@ -311,6 +218,7 @@ fn rowsTable(page: *const terminal.Page) void { )) 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); diff --git a/src/inspector/widgets/pagelist.zig b/src/inspector/widgets/pagelist.zig index 6a40c0e4e..0da7c2770 100644 --- a/src/inspector/widgets/pagelist.zig +++ b/src/inspector/widgets/pagelist.zig @@ -61,9 +61,9 @@ pub const Inspector = struct { row_offset -= page.size.rows; index -= 1; - // Use our node pointer, which is guaranteed to be unique - // in this hierarchy, as the ID. - cimgui.c.ImGui_PushIDPtr(node); + // 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. From 3246d1f7efc183a6d887f9634242cf88b679f607 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 10:51:53 -0800 Subject: [PATCH 047/108] inspector: page managed styles --- src/inspector/widgets/page.zig | 67 ++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/inspector/widgets/page.zig b/src/inspector/widgets/page.zig index 17dd20ec4..e612b81aa 100644 --- a/src/inspector/widgets/page.zig +++ b/src/inspector/widgets/page.zig @@ -11,6 +11,9 @@ 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("Rows"); rowsTable(page); } @@ -279,6 +282,70 @@ fn rowsTable(page: *const terminal.Page) void { } } +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 flagCell(value: bool) void { if (value) { cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.9, .z = 0.4, .w = 1.0 }, "yes"); From 76fe2e9fbb93b5817a7701d08ad63678880bf970 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 10:55:20 -0800 Subject: [PATCH 048/108] inspector: hyperlinks --- src/inspector/widgets/page.zig | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/inspector/widgets/page.zig b/src/inspector/widgets/page.zig index e612b81aa..844abc355 100644 --- a/src/inspector/widgets/page.zig +++ b/src/inspector/widgets/page.zig @@ -14,6 +14,9 @@ pub fn inspector(page: *const terminal.Page) void { cimgui.c.ImGui_SeparatorText("Styles"); stylesList(page); + cimgui.c.ImGui_SeparatorText("Hyperlinks"); + hyperlinksList(page); + cimgui.c.ImGui_SeparatorText("Rows"); rowsTable(page); } @@ -346,6 +349,76 @@ fn stylesList(page: *const Page) void { } } +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"); From 19d2fca9c4fa4b41bdc374b57949fa4538026d8f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 11:10:01 -0800 Subject: [PATCH 049/108] inspector: grid --- src/inspector/widgets/screen.zig | 41 +++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/inspector/widgets/screen.zig b/src/inspector/widgets/screen.zig index a308e9185..5dcf2f5a9 100644 --- a/src/inspector/widgets/screen.zig +++ b/src/inspector/widgets/screen.zig @@ -9,13 +9,18 @@ const terminal = @import("../../terminal/main.zig"); /// Window names for the screen dockspace. const window_info = "Info"; +const window_grid = "Grid"; const window_pagelist = "PageList"; /// Screen information inspector widget. pub const Info = struct { - pagelist: widgets.pagelist.Inspector = .{}, + pagelist: widgets.pagelist.Inspector, + grid: Grid, - pub const empty: Info = .{}; + pub const empty: Info = .{ + .pagelist = .empty, + .grid = .empty, + }; /// Draw the screen info contents. pub fn draw(self: *Info, open: bool, data: struct { @@ -80,6 +85,17 @@ pub const Info = struct { )) internalStateTable(&screen.pages); } + // Grid window + grid: { + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( + window_grid, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) break :grid; + self.grid.draw(&screen.pages); + } + // PageList window pagelist: { defer cimgui.c.ImGui_End(); @@ -120,6 +136,7 @@ pub const Info = struct { // Dock windows into the space cimgui.ImGui_DockBuilderDockWindow(window_info, dockspace_id); + cimgui.ImGui_DockBuilderDockWindow(window_grid, dockspace_id); cimgui.ImGui_DockBuilderDockWindow(window_pagelist, dockspace_id); cimgui.ImGui_DockBuilderFinish(dockspace_id); } @@ -329,10 +346,28 @@ pub fn internalStateTable( 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); } + +/// Grid inspector widget for a specific screen. +pub const Grid = struct { + lookup_region: terminal.point.Tag, + lookup_coord: terminal.point.Coordinate, + + pub const empty: Grid = .{ + .lookup_region = .viewport, + .lookup_coord = .{ .x = 0, .y = 0 }, + }; + + pub fn draw( + self: *Grid, + pages: *const terminal.PageList, + ) void { + _ = self; + _ = pages; + } +}; From 9056fa7fd1991307feb58e13e15d29e2aae516bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 11:40:07 -0800 Subject: [PATCH 050/108] inspector: cell inspector --- src/inspector/widgets/pagelist.zig | 434 +++++++++++++++++++++++++++++ src/inspector/widgets/screen.zig | 38 +-- src/terminal/PageList.zig | 2 +- 3 files changed, 445 insertions(+), 29 deletions(-) diff --git a/src/inspector/widgets/pagelist.zig b/src/inspector/widgets/pagelist.zig index 0da7c2770..bedbfb599 100644 --- a/src/inspector/widgets/pagelist.zig +++ b/src/inspector/widgets/pagelist.zig @@ -1,6 +1,7 @@ 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"); @@ -416,3 +417,436 @@ fn scrollbarWidget( ); } } + +/// 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/screen.zig b/src/inspector/widgets/screen.zig index 5dcf2f5a9..9365158a1 100644 --- a/src/inspector/widgets/screen.zig +++ b/src/inspector/widgets/screen.zig @@ -6,20 +6,21 @@ 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_grid = "Grid"; +const window_cell = "Cell"; const window_pagelist = "PageList"; /// Screen information inspector widget. pub const Info = struct { pagelist: widgets.pagelist.Inspector, - grid: Grid, + cell_chooser: widgets.pagelist.CellChooser, pub const empty: Info = .{ .pagelist = .empty, - .grid = .empty, + .cell_chooser = .empty, }; /// Draw the screen info contents. @@ -85,15 +86,15 @@ pub const Info = struct { )) internalStateTable(&screen.pages); } - // Grid window - grid: { + // Cell window + cell: { defer cimgui.c.ImGui_End(); if (!cimgui.c.ImGui_Begin( - window_grid, + window_cell, null, cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) break :grid; - self.grid.draw(&screen.pages); + )) break :cell; + self.cell_chooser.draw(&screen.pages); } // PageList window @@ -136,7 +137,7 @@ pub const Info = struct { // Dock windows into the space cimgui.ImGui_DockBuilderDockWindow(window_info, dockspace_id); - cimgui.ImGui_DockBuilderDockWindow(window_grid, dockspace_id); + cimgui.ImGui_DockBuilderDockWindow(window_cell, dockspace_id); cimgui.ImGui_DockBuilderDockWindow(window_pagelist, dockspace_id); cimgui.ImGui_DockBuilderFinish(dockspace_id); } @@ -352,22 +353,3 @@ pub fn internalStateTable( _ = cimgui.c.ImGui_TableSetColumnIndex(1); cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr); } - -/// Grid inspector widget for a specific screen. -pub const Grid = struct { - lookup_region: terminal.point.Tag, - lookup_coord: terminal.point.Coordinate, - - pub const empty: Grid = .{ - .lookup_region = .viewport, - .lookup_coord = .{ .x = 0, .y = 0 }, - }; - - pub fn draw( - self: *Grid, - pages: *const terminal.PageList, - ) void { - _ = self; - _ = pages; - } -}; diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 1b01f8edb..35826d97e 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -5091,7 +5091,7 @@ pub const Pin = struct { } }; -const Cell = struct { +pub const Cell = struct { node: *List.Node, row: *pagepkg.Row, cell: *pagepkg.Cell, From b2c8cdbc900c1a067d3af7a8a44ea3c8d03548b9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 13:48:44 -0800 Subject: [PATCH 051/108] inspector: key events --- src/inspector/Inspector.zig | 105 ++------ src/inspector/key.zig | 240 ----------------- src/inspector/main.zig | 2 +- src/inspector/widgets.zig | 1 + src/inspector/widgets/key.zig | 434 ++++++++++++++++++++++++++++++ src/inspector/widgets/surface.zig | 39 ++- 6 files changed, 490 insertions(+), 331 deletions(-) delete mode 100644 src/inspector/key.zig create mode 100644 src/inspector/widgets/key.zig diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 324f913f1..b8fd75460 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -10,15 +10,12 @@ 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 widgets = @import("widgets.zig"); /// The window names. These are used with docking so we need to have access. const window_cell = "Cell"; -const window_keyboard = "Keyboard"; const window_termio = "Terminal IO"; const window_imgui_demo = "Dear ImGui Demo"; @@ -36,9 +33,6 @@ mouse: widgets.surface.Mouse = .{}, /// 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, @@ -53,7 +47,7 @@ need_scroll_to_selected: bool = false, is_keyboard_selection: bool = false, // ImGui state -gui: widgets.surface.Inspector = .empty, +gui: widgets.surface.Inspector, /// Enum representing keyboard navigation actions const KeyAction = enum { @@ -152,32 +146,27 @@ 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(); + var gui: widgets.surface.Inspector = try .init(surface.alloc); + errdefer gui.deinit(surface.alloc); + return .{ .surface = surface, - .key_events = key_buf, + .gui = gui, .vt_events = vt_events, .vt_stream = .initAlloc(surface.alloc, vt_handler), }; } pub fn deinit(self: *Inspector) void { + self.gui.deinit(self.surface.alloc); self.cell.deinit(); - { - 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); @@ -190,17 +179,19 @@ pub fn deinit(self: *Inspector) void { /// Record a keyboard event. pub fn recordKeyEvent(self: *Inspector, ev: inspector.key.Event) !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(self.surface.alloc, new_capacity); + try events.append(ev); } else { - var it = self.key_events.iterator(.forward); + var it = 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); + events.deleteOldest(1); + try events.append(ev); }, else => return err, @@ -214,7 +205,10 @@ pub fn recordPtyRead(self: *Inspector, data: []const u8) !void { /// Render the frame. pub fn render(self: *Inspector) void { - self.gui.draw(self.surface, self.mouse); + self.gui.draw( + self.surface, + self.mouse, + ); if (true) return; const dock_id = cimgui.c.ImGui_DockSpaceOverViewport(); @@ -231,7 +225,6 @@ pub fn render(self: *Inspector) void { .surface = self.surface, .mouse = self.mouse, }); - self.renderKeyboardWindow(); self.renderTermioWindow(); self.renderCellWindow(); } @@ -262,7 +255,6 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { // Surface is docked first so it appears as the first tab. cimgui.ImGui_DockBuilderDockWindow(inspector.surface.Window.name, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(inspector.terminal.Window.name, dock_id_main); - cimgui.ImGui_DockBuilderDockWindow(window_keyboard, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_cell, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_imgui_demo, dock_id_main); @@ -331,63 +323,6 @@ fn renderCellWindow(self: *Inspector) void { ); } -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; 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 ae2c3b16f..27b20a41f 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -1,6 +1,6 @@ const std = @import("std"); pub const cell = @import("cell.zig"); -pub const key = @import("key.zig"); +pub const key = @import("widgets/key.zig"); pub const termio = @import("termio.zig"); diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig index 3064a3ec4..8577e731f 100644 --- a/src/inspector/widgets.zig +++ b/src/inspector/widgets.zig @@ -2,6 +2,7 @@ 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 screen = @import("widgets/screen.zig"); pub const style = @import("widgets/style.zig"); pub const surface = @import("widgets/surface.zig"); diff --git a/src/inspector/widgets/key.zig b/src/inspector/widgets/key.zig new file mode 100644 index 000000000..91d1a5d37 --- /dev/null +++ b/src/inspector/widgets/key.zig @@ -0,0 +1,434 @@ +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_RowBg | + 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, 60, 0); + cimgui.c.ImGui_TableSetupColumnEx("Key", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 100, 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(); + + // Action + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%s", @tagName(ev.event.action).ptr); + + // Key + _ = 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), + }; + cimgui.c.ImGui_Text("%s", key_name.ptr); + + // 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); + } + } + } +}; diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig index a71144081..b7953d77b 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -3,6 +3,7 @@ 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"); @@ -11,20 +12,33 @@ 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"; pub const Inspector = struct { /// Internal GUI state surface_info: Info, + key_stream: widgets.key.Stream, terminal_info: widgets.terminal.Info, - pub const empty: Inspector = .{ - .surface_info = .empty, - .terminal_info = .empty, - }; + pub fn init(alloc: Allocator) !Inspector { + return .{ + .surface_info = .empty, + .key_stream = try .init(alloc), + .terminal_info = .empty, + }; + } - pub fn draw(self: *Inspector, surface: *const Surface, mouse: Mouse) void { + pub fn deinit(self: *Inspector, alloc: Allocator) void { + self.key_stream.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"); @@ -68,6 +82,20 @@ pub const Inspector = struct { 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, + ); + } } if (first_render) { @@ -107,6 +135,7 @@ pub const Inspector = struct { 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_imgui_demo, dock_id_main); cimgui.ImGui_DockBuilderFinish(dockspace_id); } From caf301d5487c02c934a523a76ee9bf34f3999f5d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 14:43:05 -0800 Subject: [PATCH 052/108] inspector: move termio to dedicated widget --- src/inspector/Inspector.zig | 332 +------------- src/inspector/main.zig | 2 - src/inspector/termio.zig | 398 ---------------- src/inspector/widgets.zig | 1 + src/inspector/widgets/surface.zig | 23 +- src/inspector/widgets/termio.zig | 728 ++++++++++++++++++++++++++++++ 6 files changed, 753 insertions(+), 731 deletions(-) delete mode 100644 src/inspector/termio.zig create mode 100644 src/inspector/widgets/termio.zig diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index b8fd75460..fc7a7de72 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -33,29 +33,9 @@ mouse: widgets.surface.Mouse = .{}, /// A selected cell. cell: CellInspect = .{ .idle = {} }, -/// 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, - // ImGui state gui: widgets.surface.Inspector, -/// Enum representing keyboard navigation actions -const KeyAction = enum { - down, - none, - up, -}; - const CellInspect = union(enum) { /// Idle, no cell inspection is requested idle: void, @@ -146,34 +126,18 @@ pub fn setup() void { } pub fn init(surface: *Surface) !Inspector { - 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(); - - var gui: widgets.surface.Inspector = try .init(surface.alloc); + var gui: widgets.surface.Inspector = try .init(surface.alloc, surface); errdefer gui.deinit(surface.alloc); return .{ .surface = surface, .gui = gui, - .vt_events = vt_events, - .vt_stream = .initAlloc(surface.alloc, vt_handler), }; } pub fn deinit(self: *Inspector) void { self.gui.deinit(self.surface.alloc); self.cell.deinit(); - - { - 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(); - } } /// Record a keyboard event. @@ -200,7 +164,7 @@ 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); + try self.gui.vt_stream.parser_stream.nextSlice(data); } /// Render the frame. @@ -322,295 +286,3 @@ fn renderCellWindow(self: *Inspector) void { selected.row, ); } - -/// 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) { - { - widgets.screen.cursorTable(&ev.cursor); - widgets.screen.cursorStyle( - &ev.cursor, - &self.surface.renderer_state.terminal.colors.palette.current, - ); - - _ = 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 = !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 -} diff --git a/src/inspector/main.zig b/src/inspector/main.zig index 27b20a41f..2a905b0a4 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -2,8 +2,6 @@ const std = @import("std"); pub const cell = @import("cell.zig"); pub const key = @import("widgets/key.zig"); -pub const termio = @import("termio.zig"); - pub const Cell = cell.Cell; pub const Inspector = @import("Inspector.zig"); 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 index 8577e731f..6343b2d85 100644 --- a/src/inspector/widgets.zig +++ b/src/inspector/widgets.zig @@ -7,6 +7,7 @@ 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. diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig index b7953d77b..1b7394f4b 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -15,23 +15,27 @@ const window_imgui_demo = "Dear ImGui Demo"; const window_keyboard = "Keyboard"; const window_terminal = "Terminal"; const window_surface = "Surface"; +const window_termio = "Terminal IO"; 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, - pub fn init(alloc: Allocator) !Inspector { + pub fn init(alloc: Allocator, surface: *Surface) !Inspector { return .{ .surface_info = .empty, .key_stream = try .init(alloc), .terminal_info = .empty, + .vt_stream = try .init(alloc, surface), }; } pub fn deinit(self: *Inspector, alloc: Allocator) void { self.key_stream.deinit(alloc); + self.vt_stream.deinit(alloc); } pub fn draw( @@ -96,6 +100,22 @@ pub const Inspector = struct { 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, + ); + } + } } if (first_render) { @@ -136,6 +156,7 @@ pub const Inspector = struct { 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_imgui_demo, dock_id_main); cimgui.ImGui_DockBuilderFinish(dockspace_id); } diff --git a/src/inspector/widgets/termio.zig b/src/inspector/widgets/termio.zig new file mode 100644 index 000000000..edcb81cde --- /dev/null +++ b/src/inspector/widgets/termio.zig @@ -0,0 +1,728 @@ +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"); +const screen = @import("screen.zig"); + +/// The stream handler for our inspector. +pub const ParserStream = 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: ParserStream.Action.Tag, + value: ParserStream.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; + const vt_events = &insp.gui.vt_stream.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 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; + 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(self.surface.alloc, new_capacity); + try vt_events.append(ev); + } else { + var it = vt_events.iterator(.forward); + if (it.next()) |old_ev| old_ev.deinit(self.surface.alloc); + vt_events.deleteOldest(1); + try vt_events.append(ev); + }, + + else => return err, + }; + + return true; + } +}; + +/// Enum representing keyboard navigation actions +const KeyAction = enum { + down, + none, + up, +}; + +/// VT event stream inspector widget. +pub const Stream = struct { + events: VTEventRing, + parser_stream: ParserStream, + + /// 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, surface: *Surface) !Stream { + var events: VTEventRing = try .init(alloc, 2); + errdefer events.deinit(alloc); + + var handler = VTHandler.init(surface); + 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 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"; + + list: { + const pause_play: [:0]const u8 = if (handler.active) + "Pause##pause_play" + else + "Resume##pause_play"; + if (cimgui.c.ImGui_Button(pause_play.ptr)) { + handler.active = !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 (!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; + } + } + + cimgui.c.ImGui_Separator(); + + if (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 = 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.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) { + { + 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; +} From e9439533a7f93c019904a488ec7990bdc4c76a00 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 19:53:03 -0800 Subject: [PATCH 053/108] inspector: prettify keyboard stream --- src/inspector/widgets/key.zig | 117 +++++++++++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 8 deletions(-) diff --git a/src/inspector/widgets/key.zig b/src/inspector/widgets/key.zig index 91d1a5d37..7d7188a6b 100644 --- a/src/inspector/widgets/key.zig +++ b/src/inspector/widgets/key.zig @@ -295,8 +295,7 @@ pub const Stream = struct { cimgui.c.ImGui_Separator(); - const table_flags = cimgui.c.ImGuiTableFlags_RowBg | - cimgui.c.ImGuiTableFlags_Borders | + const table_flags = cimgui.c.ImGuiTableFlags_Borders | cimgui.c.ImGuiTableFlags_Resizable | cimgui.c.ImGuiTableFlags_ScrollY | cimgui.c.ImGuiTableFlags_SizingFixedFit; @@ -305,8 +304,8 @@ pub const Stream = struct { defer cimgui.c.ImGui_EndTable(); cimgui.c.ImGui_TableSetupScrollFreeze(0, 1); - cimgui.c.ImGui_TableSetupColumnEx("Action", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 60, 0); - cimgui.c.ImGui_TableSetupColumnEx("Key", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 100, 0); + 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); @@ -319,18 +318,43 @@ pub const Stream = struct { defer cimgui.c.ImGui_PopID(); cimgui.c.ImGui_TableNextRow(); + const row_min_y = cimgui.c.ImGui_GetCursorScreenPos().y; - // Action + // 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); - cimgui.c.ImGui_Text("%s", @tagName(ev.event.action).ptr); + 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 + // 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), }; - cimgui.c.ImGui_Text("%s", key_name.ptr); + 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); @@ -429,6 +453,83 @@ pub const Stream = struct { 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; +} From 3e825dd608d79f4142dffc24c5c94d0855b37e7c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 20:06:36 -0800 Subject: [PATCH 054/108] inspector: clean up Inspector --- src/Surface.zig | 6 +-- src/inspector/Inspector.zig | 74 +++++++------------------------ src/inspector/main.zig | 8 ++-- src/inspector/widgets/surface.zig | 4 +- src/inspector/widgets/termio.zig | 74 +++++++++++++++++++------------ src/termio/Termio.zig | 6 ++- 6 files changed, 77 insertions(+), 95 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 0bf3aa008..23531f387 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2618,7 +2618,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); @@ -2798,7 +2798,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 +3131,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. diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index fc7a7de72..0044556a1 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -22,10 +22,6 @@ 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, - /// Mouse state that we track in addition to normal mouse states that /// Ghostty always knows about. mouse: widgets.surface.Mouse = .{}, @@ -126,7 +122,7 @@ pub fn setup() void { } pub fn init(surface: *Surface) !Inspector { - var gui: widgets.surface.Inspector = try .init(surface.alloc, surface); + var gui: widgets.surface.Inspector = try .init(surface.alloc); errdefer gui.deinit(surface.alloc); return .{ @@ -141,7 +137,7 @@ pub fn deinit(self: *Inspector) void { } /// Record a keyboard event. -pub fn recordKeyEvent(self: *Inspector, ev: inspector.key.Event) !void { +pub fn recordKeyEvent(self: *Inspector, ev: inspector.KeyEvent) !void { const max_capacity = 50; const events: *widgets.key.EventRing = &self.gui.key_stream.events; @@ -163,7 +159,20 @@ 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 { +pub fn recordPtyRead( + self: *Inspector, + alloc: Allocator, + t: *terminal.Terminal, + data: []const u8, +) !void { + // We need to setup our state so that capture works properly. + const handler: *widgets.termio.VTHandler = &self.gui.vt_stream.parser_stream.handler; + handler.state = .{ + .alloc = alloc, + .terminal = t, + .events = &self.gui.vt_stream.events, + }; + try self.gui.vt_stream.parser_stream.nextSlice(data); } @@ -173,58 +182,9 @@ pub fn render(self: *Inspector) void { self.surface, self.mouse, ); - if (true) return; - - 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(); - const t = self.surface.renderer_state.terminal; - self.windows.terminal.render(t); - self.windows.surface.render(.{ - .surface = self.surface, - .mouse = self.mouse, - }); - self.renderTermioWindow(); - self.renderCellWindow(); - } - - // 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(inspector.terminal.Window.name); - - // Setup our initial layout - all windows in a single dock as tabs. - // Surface is docked first so it appears as the first tab. - cimgui.ImGui_DockBuilderDockWindow(inspector.surface.Window.name, dock_id_main); - cimgui.ImGui_DockBuilderDockWindow(inspector.terminal.Window.name, dock_id_main); - cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id_main); - cimgui.ImGui_DockBuilderDockWindow(window_cell, dock_id_main); - cimgui.ImGui_DockBuilderDockWindow(window_imgui_demo, dock_id_main); - cimgui.ImGui_DockBuilderFinish(dock_id_main); } +/// TODO: OLD, REMOVE EVENTUALLY ONCE WE MIGRATE FUNCTIONALITY fn renderCellWindow(self: *Inspector) void { // Start our window. If we're collapsed we do nothing. defer cimgui.c.ImGui_End(); diff --git a/src/inspector/main.zig b/src/inspector/main.zig index 2a905b0a4..05e6e4ba2 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -1,10 +1,12 @@ -const std = @import("std"); +// TODO: Remove pub const cell = @import("cell.zig"); -pub const key = @import("widgets/key.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/widgets/surface.zig b/src/inspector/widgets/surface.zig index 1b7394f4b..9f52501d4 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -24,12 +24,12 @@ pub const Inspector = struct { terminal_info: widgets.terminal.Info, vt_stream: widgets.termio.Stream, - pub fn init(alloc: Allocator, surface: *Surface) !Inspector { + pub fn init(alloc: Allocator) !Inspector { return .{ .surface_info = .empty, .key_stream = try .init(alloc), .terminal_info = .empty, - .vt_stream = try .init(alloc, surface), + .vt_stream = try .init(alloc), }; } diff --git a/src/inspector/widgets/termio.zig b/src/inspector/widgets/termio.zig index edcb81cde..c0d195970 100644 --- a/src/inspector/widgets/termio.zig +++ b/src/inspector/widgets/termio.zig @@ -39,7 +39,7 @@ pub const VTEvent = struct { /// Initialize the event information for the given parser action. pub fn init( alloc: Allocator, - surface: *Surface, + t: *const terminal.Terminal, action: terminal.Parser.Action, ) !VTEvent { var md = Metadata.init(alloc); @@ -60,8 +60,6 @@ pub const VTEvent = struct { .apc_start, .apc_put, .apc_end => .apc, }; - const t = surface.renderer_state.terminal; - return .{ .kind = kind, .str = str, @@ -308,29 +306,43 @@ pub const VTEvent = struct { /// 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, + /// The capture state, must be set before use. If null, then + /// events are dropped. + state: ?State, - /// True if the handler is currently recording. - active: bool = true, + /// True to pause this artificially. + paused: bool, /// Current sequence number - current_seq: usize = 1, + current_seq: usize, /// Exclude certain actions by tag. - filter_exclude: ActionTagSet = .initMany(&.{.print}), - filter_text: cimgui.c.ImGuiTextFilter = .{}, + filter_exclude: ActionTagSet, + filter_text: cimgui.c.ImGuiTextFilter, - const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag); + pub const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag); - pub fn init(surface: *Surface) VTHandler { - return .{ - .surface = surface, - }; - } + 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: *VTEventRing, + }; + + 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; } @@ -345,16 +357,17 @@ pub const VTHandler = struct { /// 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; - const vt_events = &insp.gui.vt_stream.events; + 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 pausing, then we ignore all events. - if (!self.active) return true; + // 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) { @@ -367,8 +380,11 @@ pub const VTHandler = struct { 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); + var ev: VTEvent = try .init( + alloc, + state.terminal, + action, + ); ev.seq = self.current_seq; errdefer ev.deinit(alloc); @@ -383,11 +399,11 @@ pub const VTHandler = struct { 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(self.surface.alloc, new_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(self.surface.alloc); + if (it.next()) |old_ev| old_ev.deinit(alloc); vt_events.deleteOldest(1); try vt_events.append(ev); }, @@ -420,11 +436,11 @@ pub const Stream = struct { /// Flag indicating whether the selection was made by keyboard is_keyboard_selection: bool = false, - pub fn init(alloc: Allocator, surface: *Surface) !Stream { + pub fn init(alloc: Allocator) !Stream { var events: VTEventRing = try .init(alloc, 2); errdefer events.deinit(alloc); - var handler = VTHandler.init(surface); + var handler: VTHandler = .init; errdefer handler.deinit(); return .{ @@ -451,12 +467,12 @@ pub const Stream = struct { const popup_filter = "Filter"; list: { - const pause_play: [:0]const u8 = if (handler.active) + 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.active = !handler.active; + handler.paused = !handler.paused; } cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); 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}); }; From 32ac82c66fb26e2b7a2d8749fdd7ae91fe0b7e1f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 20:43:58 -0800 Subject: [PATCH 055/108] inspector: no longer holds surface pointer --- src/Surface.zig | 10 ++++--- src/apprt/embedded.zig | 2 +- src/apprt/gtk/class/inspector_widget.zig | 2 +- src/inspector/Inspector.zig | 36 ++++++++++++------------ 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 23531f387..fc1e55ec5 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; } @@ -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}); 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/Inspector.zig b/src/inspector/Inspector.zig index 0044556a1..584d108bc 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -19,9 +19,6 @@ const window_cell = "Cell"; const window_termio = "Terminal IO"; const window_imgui_demo = "Dear ImGui Demo"; -/// The surface that we're inspecting. -surface: *Surface, - /// Mouse state that we track in addition to normal mouse states that /// Ghostty always knows about. mouse: widgets.surface.Mouse = .{}, @@ -121,23 +118,23 @@ pub fn setup() void { } } -pub fn init(surface: *Surface) !Inspector { - var gui: widgets.surface.Inspector = try .init(surface.alloc); - errdefer gui.deinit(surface.alloc); - - return .{ - .surface = surface, - .gui = gui, - }; +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.gui.deinit(self.surface.alloc); +pub fn deinit(self: *Inspector, alloc: Allocator) void { + self.gui.deinit(alloc); self.cell.deinit(); } /// Record a keyboard event. -pub fn recordKeyEvent(self: *Inspector, ev: inspector.KeyEvent) !void { +pub fn recordKeyEvent( + self: *Inspector, + alloc: Allocator, + ev: inspector.KeyEvent, +) Allocator.Error!void { const max_capacity = 50; const events: *widgets.key.EventRing = &self.gui.key_stream.events; @@ -145,11 +142,11 @@ pub fn recordKeyEvent(self: *Inspector, ev: inspector.KeyEvent) !void { error.OutOfMemory => if (events.capacity() < max_capacity) { // We're out of memory, but we can allocate to our capacity. const new_capacity = @min(events.capacity() * 2, max_capacity); - try events.resize(self.surface.alloc, new_capacity); + try events.resize(alloc, new_capacity); try events.append(ev); } else { var it = events.iterator(.forward); - if (it.next()) |old_ev| old_ev.deinit(self.surface.alloc); + if (it.next()) |old_ev| old_ev.deinit(alloc); events.deleteOldest(1); try events.append(ev); }, @@ -177,9 +174,12 @@ pub fn recordPtyRead( } /// Render the frame. -pub fn render(self: *Inspector) void { +pub fn render( + self: *Inspector, + surface: *Surface, +) void { self.gui.draw( - self.surface, + surface, self.mouse, ); } From 4fa2dab20dd33e7e4c4188b54efd7bdb1401afae Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 20:57:07 -0800 Subject: [PATCH 056/108] inspector: clean up termio layouts --- src/inspector/Inspector.zig | 14 +- src/inspector/widgets/termio.zig | 854 ++++++++++++++++--------------- 2 files changed, 440 insertions(+), 428 deletions(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 584d108bc..8462a588a 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -162,15 +162,11 @@ pub fn recordPtyRead( t: *terminal.Terminal, data: []const u8, ) !void { - // We need to setup our state so that capture works properly. - const handler: *widgets.termio.VTHandler = &self.gui.vt_stream.parser_stream.handler; - handler.state = .{ - .alloc = alloc, - .terminal = t, - .events = &self.gui.vt_stream.events, - }; - - try self.gui.vt_stream.parser_stream.nextSlice(data); + try self.gui.vt_stream.recordPtyRead( + alloc, + t, + data, + ); } /// Render the frame. diff --git a/src/inspector/widgets/termio.zig b/src/inspector/widgets/termio.zig index c0d195970..7ecdbd7af 100644 --- a/src/inspector/widgets/termio.zig +++ b/src/inspector/widgets/termio.zig @@ -6,426 +6,10 @@ const CircBuf = @import("../../datastruct/main.zig").CircBuf; const Surface = @import("../../Surface.zig"); const screen = @import("screen.zig"); -/// The stream handler for our inspector. -pub const ParserStream = 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, - t: *const terminal.Terminal, - 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, - }; - - 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 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, - - 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: *VTEventRing, - }; - - 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: ParserStream.Action.Tag, - value: ParserStream.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 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, - }; - - return true; - } -}; - -/// Enum representing keyboard navigation actions -const KeyAction = enum { - down, - none, - up, -}; - /// VT event stream inspector widget. pub const Stream = struct { - events: VTEventRing, - parser_stream: ParserStream, + events: VTEvent.Ring, + parser_stream: VTHandler.Stream, /// The currently selected event sequence number for keyboard navigation selected_event_seq: ?u32 = null, @@ -437,7 +21,7 @@ pub const Stream = struct { is_keyboard_selection: bool = false, pub fn init(alloc: Allocator) !Stream { - var events: VTEventRing = try .init(alloc, 2); + var events: VTEvent.Ring = try .init(alloc, 2); errdefer events.deinit(alloc); var handler: VTHandler = .init; @@ -457,6 +41,21 @@ pub const Stream = struct { 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, @@ -742,3 +341,420 @@ fn getKeyAction() KeyAction { } return .none; } + +/// VT event. This isn't public because this is just how we store internal +/// events. +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); + + /// Circular buffer of VT events. + pub const Ring = CircBuf(VTEvent, undefined); + + /// Initialize the event information for the given parser action. + pub fn init( + alloc: Allocator, + t: *const terminal.Terminal, + 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, + }; + + 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 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 handleManually(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, + }; + + return true; + } +}; + +/// Enum representing keyboard navigation actions +const KeyAction = enum { + down, + none, + up, +}; From 75eac6e3eab5bb793b80e4475adde51d6710646c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Jan 2026 21:08:45 -0800 Subject: [PATCH 057/108] terminal: stream handleManually => vtRaw --- src/inspector/widgets/termio.zig | 6 ++++-- src/terminal/stream.zig | 16 +++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/inspector/widgets/termio.zig b/src/inspector/widgets/termio.zig index 7ecdbd7af..d80947394 100644 --- a/src/inspector/widgets/termio.zig +++ b/src/inspector/widgets/termio.zig @@ -693,7 +693,7 @@ const VTHandler = struct { } /// This is called with every single terminal action. - pub fn handleManually(self: *VTHandler, action: terminal.Parser.Action) !bool { + 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; @@ -748,7 +748,9 @@ const VTHandler = struct { else => return err, }; - return true; + // Do NOT skip it, because we want to record more information + // about this event. + return false; } }; 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) { From 3793188e389afd1196ce3550e3c423e77d864dfe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 09:06:57 -0800 Subject: [PATCH 058/108] inspector: termio revamp --- src/inspector/widgets/termio.zig | 103 +++++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 27 deletions(-) diff --git a/src/inspector/widgets/termio.zig b/src/inspector/widgets/termio.zig index d80947394..a6c8f6081 100644 --- a/src/inspector/widgets/termio.zig +++ b/src/inspector/widgets/termio.zig @@ -1,5 +1,6 @@ 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; @@ -65,7 +66,8 @@ pub const Stream = struct { const handler = &self.parser_stream.handler; const popup_filter = "Filter"; - list: { + // Controls + { const pause_play: [:0]const u8 = if (!handler.paused) "Pause##pause_play" else @@ -92,14 +94,19 @@ pub const Stream = struct { 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(); - if (events.empty()) { - cimgui.c.ImGui_Text("Waiting for events..."); - break :list; - } - _ = cimgui.c.ImGui_BeginTable( "table_vt_events", 3, @@ -213,7 +220,7 @@ pub const Stream = struct { _ = 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); + 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 @@ -342,19 +349,64 @@ fn getKeyAction() KeyAction { 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 { - /// Sequence number, just monotonically increasing. + /// 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 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, + /// 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, @@ -372,17 +424,18 @@ const VTEvent = struct { /// Initialize the event information for the given parser action. pub fn init( - alloc: Allocator, + 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); - 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 desc = try buf.toOwnedSliceSentinel(0); const kind: Kind = switch (action) { .print => .print, @@ -395,22 +448,18 @@ const VTEvent = struct { }; return .{ + .arena_state = arena.state, .kind = kind, - .str = str, + .raw_description = desc, .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); + 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. @@ -421,7 +470,7 @@ const VTEvent = struct { // Check our main string if (cimgui.c.ImGuiTextFilter_PassFilter( filter, - self.str.ptr, + self.raw_description.ptr, null, )) return true; From 400d17aa0d00eea65cb4cb65ffe4fb660deb4947 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 09:18:31 -0800 Subject: [PATCH 059/108] inspector: remove cell picker --- src/Surface.zig | 30 +---- src/inspector/Inspector.zig | 127 -------------------- src/inspector/cell.zig | 223 ------------------------------------ src/inspector/main.zig | 4 - 4 files changed, 1 insertion(+), 383 deletions(-) delete mode 100644 src/inspector/cell.zig diff --git a/src/Surface.zig b/src/Surface.zig index fc1e55ec5..fa9b04685 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3874,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/inspector/Inspector.zig b/src/inspector/Inspector.zig index 8462a588a..6035e8c2b 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -14,76 +14,13 @@ const terminal = @import("../terminal/main.zig"); const inspector = @import("main.zig"); const widgets = @import("widgets.zig"); -/// The window names. These are used with docking so we need to have access. -const window_cell = "Cell"; -const window_termio = "Terminal IO"; -const window_imgui_demo = "Dear ImGui Demo"; - /// Mouse state that we track in addition to normal mouse states that /// Ghostty always knows about. mouse: widgets.surface.Mouse = .{}, -/// A selected cell. -cell: CellInspect = .{ .idle = {} }, - // ImGui state gui: widgets.surface.Inspector, -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, - } }; - } -}; - /// Setup the ImGui state. This requires an ImGui context to be set. pub fn setup() void { const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); @@ -126,7 +63,6 @@ pub fn init(alloc: Allocator) !Inspector { pub fn deinit(self: *Inspector, alloc: Allocator) void { self.gui.deinit(alloc); - self.cell.deinit(); } /// Record a keyboard event. @@ -179,66 +115,3 @@ pub fn render( self.mouse, ); } - -/// TODO: OLD, REMOVE EVENTUALLY ONCE WE MIGRATE FUNCTIONALITY -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, - ); -} 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/main.zig b/src/inspector/main.zig index 05e6e4ba2..ae185f479 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -1,7 +1,3 @@ -// TODO: Remove -pub const cell = @import("cell.zig"); -pub const Cell = cell.Cell; - pub const widgets = @import("widgets.zig"); pub const Inspector = @import("Inspector.zig"); From dc2cca6490e036fc9917576840f014e5f1e5f3b7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 Jan 2026 10:38:52 -0800 Subject: [PATCH 060/108] inspector: renderer panel --- src/inspector/Inspector.zig | 6 +++ src/inspector/widgets.zig | 1 + src/inspector/widgets/renderer.zig | 71 ++++++++++++++++++++++++++++++ src/inspector/widgets/surface.zig | 19 ++++++++ src/renderer.zig | 1 + src/renderer/generic.zig | 30 ++++++++----- 6 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 src/inspector/widgets/renderer.zig diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 6035e8c2b..9c56a7920 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -65,6 +65,12 @@ pub fn deinit(self: *Inspector, alloc: Allocator) void { self.gui.deinit(alloc); } +/// 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, diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig index 6343b2d85..dd8ebc002 100644 --- a/src/inspector/widgets.zig +++ b/src/inspector/widgets.zig @@ -3,6 +3,7 @@ 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"); 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/surface.zig b/src/inspector/widgets/surface.zig index 9f52501d4..3b69f214c 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -16,6 +16,7 @@ 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 @@ -23,6 +24,7 @@ pub const Inspector = struct { 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 .{ @@ -30,12 +32,14 @@ pub const Inspector = struct { .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( @@ -116,6 +120,20 @@ pub const Inspector = struct { ); } } + + // 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) { @@ -157,6 +175,7 @@ pub const Inspector = struct { 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); } 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, ); } From 513b55e0a40d4b47afb069ac7239a21d95c6fe66 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 09:13:27 -0800 Subject: [PATCH 061/108] inspector: always render the surface when the inspector is opened --- src/inspector/Inspector.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 9c56a7920..22d763f73 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -116,8 +116,16 @@ pub fn render( self: *Inspector, surface: *Surface, ) void { + // Draw the UI self.gui.draw( surface, self.mouse, ); + + // 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 {}; } From fb8cb162ce5022c96e7a92b1c83b986bd0713217 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 09:18:42 -0800 Subject: [PATCH 062/108] macos: Ghostty.Inspector --- macos/Sources/Ghostty/Ghostty.App.swift | 2 +- macos/Sources/Ghostty/Ghostty.Inspector.swift | 100 ++++++++++++++++++ .../Ghostty/Surface View/InspectorView.swift | 37 +++---- .../Surface View/SurfaceView_AppKit.swift | 7 +- 4 files changed, 120 insertions(+), 26 deletions(-) create mode 100644 macos/Sources/Ghostty/Ghostty.Inspector.swift 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 From 487b84da0ecff7e2888a03692f1dd8af4c9908ab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 13:01:02 -0800 Subject: [PATCH 063/108] terminal: add semantic_content enum to cell --- src/terminal/page.zig | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 6a5958681..2f58bf49c 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1994,7 +1994,12 @@ pub const Cell = packed struct(u64) { /// the hyperlink_set to get the actual hyperlink data. hyperlink: bool = false, - _padding: u18 = 0, + /// The semantic type of the content of this cell. This is used + /// by the semantic prompt (OSC 133) set of sequences to understand + /// boundary points for content. + semantic_content: SemanticContent = .output, + + _padding: u16 = 0, pub const ContentTag = enum(u2) { /// A single codepoint, could be zero to be empty cell. @@ -2033,6 +2038,19 @@ pub const Cell = packed struct(u64) { spacer_head = 3, }; + pub const SemanticContent = enum(u2) { + /// Regular output content, such as command output. + output = 0, + + /// Content that is part of user input, such as the command + /// to execute at a prompt. + input = 1, + + /// Content that is part of prompt emitted by the interactive + /// application, such as "user@host >" + prompt = 2, + }; + /// Helper to make a cell that just has a codepoint. pub fn init(cp: u21) Cell { // We have to use this bitCast here to ensure that our memory is @@ -2166,6 +2184,10 @@ test "Cell is zero by default" { const cell = Cell.init(0); const cell_int: u64 = @bitCast(cell); try std.testing.expectEqual(@as(u64, 0), cell_int); + + // The zero value should be output type for semantic content. + // This is very important for our assumptions elsewhere. + try std.testing.expectEqual(Cell.SemanticContent.output, cell.semantic_content); } test "Page capacity adjust cols down" { From 7a69e2bf868cc2d08d866c9df0b8684de5467473 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 13:10:40 -0800 Subject: [PATCH 064/108] terminal: printCell writes with the current pen's content type --- src/terminal/Screen.zig | 4 ++++ src/terminal/Terminal.zig | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 45fe9dfc6..10d33a3a8 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -134,6 +134,10 @@ pub const Cursor = struct { /// because its most likely null. hyperlink: ?*hyperlink.Hyperlink = null, + /// The current semantic content type for the cursor that will be + /// applied to any newly written cells. + semantic_content: pagepkg.Cell.SemanticContent = .output, + /// The pointers into the page list where the cursor is currently /// located. This makes it faster to move the cursor. page_pin: *PageList.Pin, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a955cbcae..ac11ead3e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -711,6 +711,7 @@ fn printCell( .style_id = self.screens.active.cursor.style_id, .wide = wide, .protected = self.screens.active.cursor.protected, + .semantic_content = self.screens.active.cursor.semantic_content, }; if (style_changed) { @@ -11236,6 +11237,7 @@ test "Terminal: fullReset with a non-empty pen" { try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + t.screens.active.cursor.semantic_content = .input; t.fullReset(); { @@ -11248,6 +11250,7 @@ test "Terminal: fullReset with a non-empty pen" { } try testing.expectEqual(@as(style.Id, 0), t.screens.active.cursor.style_id); + try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); } test "Terminal: fullReset hyperlink" { From 24bf642bdc02f877ba4ea6a5a3aac885921d1aa1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 12:10:17 -0800 Subject: [PATCH 065/108] terminal: start implementing proper semantic prompt behaviors --- src/terminal/Terminal.zig | 60 ++++++++++++++++++++++++++++++++ src/terminal/stream_readonly.zig | 19 ++++++++-- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index ac11ead3e..727b71b58 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -17,6 +17,7 @@ const charsets = @import("charsets.zig"); const csi = @import("csi.zig"); const hyperlink = @import("hyperlink.zig"); const kitty = @import("kitty.zig"); +const osc = @import("osc.zig"); const point = @import("point.zig"); const sgr = @import("sgr.zig"); const Tabstops = @import("Tabstops.zig"); @@ -1058,6 +1059,65 @@ pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { } } +/// Perform a semantic prompt command. +/// +/// If there is an error, we do our best to get the terminal into +/// some coherent state, since callers typically can't handle errors +/// (since they're sending sequences via the pty). +pub fn semanticPrompt( + self: *Terminal, + cmd: osc.Command.SemanticPrompt, +) !void { + switch (cmd.action) { + .fresh_line => try self.semanticPromptFreshLine(), + .fresh_line_new_prompt => { + // "First do a fresh-line." + try self.semanticPromptFreshLine(); + + // "Subsequent text (until a OSC "133;B" or OSC "133;I" command) + // is a prompt string (as if followed by OSC 133;P;k=i\007)." + // TODO + + // The "aid" and "cl" options are also valid for this + // command but we don't yet handle these in any meaningful way. + }, + + else => {}, + } +} + +fn semanticPromptSet( + self: *Terminal, + mode: pagepkg.Cell.SemanticContent, +) void { + // We always reset this when we mode change. The caller can set it + // again after if they care. + self.screens.active.cursor.semantic_content_clear_eol = false; + + // Update our mode + self.screens.active.cursor.semantic_content = mode; +} + +// OSC 133;L +fn semanticPromptFreshLine(self: *Terminal) !void { + const left_margin = if (self.screens.active.cursor.x < self.scrolling_region.left) + 0 + else + self.scrolling_region.left; + + // Spec: "If the cursor is the initial column (left, assuming + // left-to-right writing), do nothing" This specification is very under + // specified. We are taking the liberty to assume that in a left/right + // margin context, if the cursor is outside of the left margin, we treat + // it as being at the left margin for the purposes of this command. + // This is arbitrary. If someone has a better reasonable idea we can + // apply it. + if (self.screens.active.cursor.x == left_margin) return; + + self.carriageReturn(); + try self.index(); +} + /// The semantic prompt type. This is used when tracking a line type and /// requires integration with the shell. By default, we mark a line as "none" /// meaning we don't know what type it is. diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 18ed0dd42..75e7cf129 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -153,7 +153,7 @@ pub const Handler = struct { .full_reset => self.terminal.fullReset(), .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), .end_hyperlink => self.terminal.screens.active.endHyperlink(), - .semantic_prompt => self.semanticPrompt(value), + .semantic_prompt => try self.semanticPrompt(value), .mouse_shape => self.terminal.mouse_shape = value, .color_operation => try self.colorOperation(value.op, &value.requests), .kitty_color_report => try self.kittyColorOperation(value), @@ -212,7 +212,9 @@ pub const Handler = struct { fn semanticPrompt( self: *Handler, cmd: Action.SemanticPrompt, - ) void { + ) !void { + try self.terminal.semanticPrompt(cmd); + switch (cmd.action) { .fresh_line_new_prompt => { const kind = cmd.readOption(.prompt_kind) orelse .initial; @@ -905,3 +907,16 @@ test "palette dirty flag set on color change" { try s.nextSlice("\x1b]21;1=rgb:00/ff/00\x1b\\"); try testing.expect(t.flags.dirty.palette); } + +test "semantic prompt fresh line" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + try s.nextSlice("Hello"); + try s.nextSlice("\x1b]133;L\x07"); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); +} From 3fa63204781a158ac171714b3d619b54c9bcfdf1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 13:14:40 -0800 Subject: [PATCH 066/108] terminal: handle fresh_line_new_prompt --- src/terminal/Terminal.zig | 12 +++++- src/terminal/osc/parsers/semantic_prompt.zig | 1 + src/terminal/stream_readonly.zig | 45 +++++++++++++------- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 727b71b58..0f20beb18 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1076,7 +1076,17 @@ pub fn semanticPrompt( // "Subsequent text (until a OSC "133;B" or OSC "133;I" command) // is a prompt string (as if followed by OSC 133;P;k=i\007)." - // TODO + + // Implementation note: we don't yet differentiate between + // the prompt types (k=) because it isn't of value to us + // currently. This may change in the future. + self.screens.active.cursor.semantic_content = .prompt; + + // This is a kitty-specific flag that notes that the shell + // is capable of redraw. + if (cmd.readOption(.redraw)) |v| { + self.flags.shell_redraws_prompt = v; + } // The "aid" and "cl" options are also valid for this // command but we don't yet handle these in any meaningful way. diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index f6a0cb593..61aed4988 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -47,6 +47,7 @@ pub const Option = enum { cl, prompt_kind, err, + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers // Kitty supports a "redraw" option for prompt_start. I can't find // this documented anywhere but can see in the code that this is used diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 75e7cf129..6eb7353ea 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -216,21 +216,6 @@ pub const Handler = struct { try self.terminal.semanticPrompt(cmd); switch (cmd.action) { - .fresh_line_new_prompt => { - const kind = cmd.readOption(.prompt_kind) orelse .initial; - switch (kind) { - .initial, .right => { - self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; - if (cmd.readOption(.redraw)) |redraw| { - self.terminal.flags.shell_redraws_prompt = redraw; - } - }, - .continuation, .secondary => { - self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation; - }, - } - }, - .end_prompt_start_input => self.terminal.markSemanticPrompt(.input), .end_input_start_output => self.terminal.markSemanticPrompt(.command), .end_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, @@ -240,10 +225,14 @@ pub const Handler = struct { // handling so we just ignore these like we did before, even // though we should handle them eventually. .end_prompt_start_input_terminate_eol, - .fresh_line, .new_command, .prompt_start, => {}, + + // Handled by the new action above + .fresh_line, + .fresh_line_new_prompt, + => {}, } } @@ -920,3 +909,27 @@ test "semantic prompt fresh line" { try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); } + +test "semantic prompt fresh line new prompt" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text and then send OSC 133;A (fresh_line_new_prompt) + try s.nextSlice("Hello"); + try s.nextSlice("\x1b]133;A\x07"); + + // Should do a fresh line (carriage return + index) + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + + // Should set cursor semantic_content to prompt + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); + + // Test with redraw option + try s.nextSlice("prompt$ "); + try s.nextSlice("\x1b]133;A;redraw=1\x07"); + try testing.expect(t.flags.shell_redraws_prompt); +} From acd7a448e1dc466f0d8da8d3fdd3b5cec1b0c4bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 14:05:04 -0800 Subject: [PATCH 067/108] terminal: OSC 133 B handling --- src/terminal/Terminal.zig | 7 +++++++ src/terminal/stream_readonly.zig | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 0f20beb18..29f80c6f7 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1070,6 +1070,7 @@ pub fn semanticPrompt( ) !void { switch (cmd.action) { .fresh_line => try self.semanticPromptFreshLine(), + .fresh_line_new_prompt => { // "First do a fresh-line." try self.semanticPromptFreshLine(); @@ -1092,6 +1093,12 @@ pub fn semanticPrompt( // command but we don't yet handle these in any meaningful way. }, + .end_prompt_start_input => { + // End of prompt and start of user input, terminated by a OSC + // "133;C" or another prompt (OSC "133;P"). + self.screens.active.cursor.semantic_content = .input; + }, + else => {}, } } diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 6eb7353ea..fcb6d123f 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -933,3 +933,18 @@ test "semantic prompt fresh line new prompt" { try s.nextSlice("\x1b]133;A;redraw=1\x07"); try testing.expect(t.flags.shell_redraws_prompt); } + +test "semantic prompt end prompt start input" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text and then send OSC 133;A (fresh_line_new_prompt) + try s.nextSlice("Hello"); + try s.nextSlice("\x1b]133;A\x07"); + try s.nextSlice("prompt$ "); + try s.nextSlice("\x1b]133;B\x07"); + try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); +} From af12241d8887201b750e6340efe99f45d3d05dcd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 14:10:57 -0800 Subject: [PATCH 068/108] terminal: OSC 133 P --- src/terminal/Terminal.zig | 23 +++++++++++++++++++++++ src/terminal/stream_readonly.zig | 21 ++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 29f80c6f7..48d59835c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1093,12 +1093,35 @@ pub fn semanticPrompt( // command but we don't yet handle these in any meaningful way. }, + .prompt_start => { + // Explicit start of prompt. Optional after an A or N command. + // The k (kind) option specifies the type of prompt: + // regular primary prompt (k=i or default), + // right-side prompts (k=r), or prompts for continuation lines (k=c or k=s). + + // As noted above, we don't currently utilize the prompt type. + self.screens.active.cursor.semantic_content = .prompt; + }, + .end_prompt_start_input => { // End of prompt and start of user input, terminated by a OSC // "133;C" or another prompt (OSC "133;P"). self.screens.active.cursor.semantic_content = .input; }, + .end_input_start_output => { + // "End of input, and start of output." + self.screens.active.cursor.semantic_content = .output; + }, + + .end_command => { + // From a terminal state perspective, this doesn't really do + // anything. Other terminals appear to do nothing here. I think + // its reasonable at this point to reset our semantic content + // state but the spec doesn't really say what to do. + self.screens.active.cursor.semantic_content = .output; + }, + else => {}, } } diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index fcb6d123f..af4048973 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -934,7 +934,7 @@ test "semantic prompt fresh line new prompt" { try testing.expect(t.flags.shell_redraws_prompt); } -test "semantic prompt end prompt start input" { +test "semantic prompt end of input, then start output" { var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); @@ -947,4 +947,23 @@ test "semantic prompt end prompt start input" { try s.nextSlice("prompt$ "); try s.nextSlice("\x1b]133;B\x07"); try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); + try s.nextSlice("\x1b]133;C\x07"); + try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); +} + +test "semantic prompt prompt_start" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text + try s.nextSlice("Hello"); + + // OSC 133;P marks the start of a prompt (without fresh line behavior) + try s.nextSlice("\x1b]133;P\x07"); + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); } From 4d555f878e0e5254bb84492ee9fef5801d7ed041 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 14:17:28 -0800 Subject: [PATCH 069/108] terminal: OSC 133 N --- src/terminal/Terminal.zig | 13 +++++++++++++ src/terminal/stream_readonly.zig | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 48d59835c..0e7cc5d92 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1093,6 +1093,19 @@ pub fn semanticPrompt( // command but we don't yet handle these in any meaningful way. }, + .new_command => { + // Same as OSC "133;A" but may first implicitly terminate a + // previous command: if the options specify an aid and there + // is an active (open) command with matching aid, finish the + // innermost such command (as well as any other commands + // nested more deeply). If no aid is specified, treat as an + // aid whose value is the empty string. + try self.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = cmd.options_unvalidated, + }); + }, + .prompt_start => { // Explicit start of prompt. Optional after an A or N command. // The k (kind) option specifies the type of prompt: diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index af4048973..5a5f0d65f 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -967,3 +967,35 @@ test "semantic prompt prompt_start" { try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); } + +test "semantic prompt new_command" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text + try s.nextSlice("Hello"); + try s.nextSlice("\x1b]133;N\x07"); + + // Should behave like fresh_line_new_prompt - cursor moves to column 0 + // on next line since we had content + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); +} + +test "semantic prompt new_command at column zero" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // OSC 133;N when already at column 0 should stay on same line + try s.nextSlice("\x1b]133;N\x07"); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); +} From ae65998d5b8080d1304de7c694f90458564f4b12 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 14:22:25 -0800 Subject: [PATCH 070/108] terminal: OSC 133;I --- src/terminal/Screen.zig | 1 + src/terminal/Terminal.zig | 20 ++++++++++++++++++-- src/terminal/stream_readonly.zig | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 10d33a3a8..e92a81117 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -137,6 +137,7 @@ pub const Cursor = struct { /// The current semantic content type for the cursor that will be /// applied to any newly written cells. semantic_content: pagepkg.Cell.SemanticContent = .output, + semantic_content_clear_eol: bool = false, /// The pointers into the page list where the cursor is currently /// located. This makes it faster to move the cursor. diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 0e7cc5d92..2782b8d0e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1082,6 +1082,7 @@ pub fn semanticPrompt( // the prompt types (k=) because it isn't of value to us // currently. This may change in the future. self.screens.active.cursor.semantic_content = .prompt; + self.screens.active.cursor.semantic_content_clear_eol = false; // This is a kitty-specific flag that notes that the shell // is capable of redraw. @@ -1114,17 +1115,26 @@ pub fn semanticPrompt( // As noted above, we don't currently utilize the prompt type. self.screens.active.cursor.semantic_content = .prompt; + self.screens.active.cursor.semantic_content_clear_eol = false; }, .end_prompt_start_input => { // End of prompt and start of user input, terminated by a OSC // "133;C" or another prompt (OSC "133;P"). self.screens.active.cursor.semantic_content = .input; + self.screens.active.cursor.semantic_content_clear_eol = false; + }, + + .end_prompt_start_input_terminate_eol => { + // End of prompt and start of user input, terminated by end-of-line. + self.semanticPromptSet(.input); + self.screens.active.cursor.semantic_content_clear_eol = true; }, .end_input_start_output => { // "End of input, and start of output." self.screens.active.cursor.semantic_content = .output; + self.screens.active.cursor.semantic_content_clear_eol = false; }, .end_command => { @@ -1133,9 +1143,8 @@ pub fn semanticPrompt( // its reasonable at this point to reset our semantic content // state but the spec doesn't really say what to do. self.screens.active.cursor.semantic_content = .output; + self.screens.active.cursor.semantic_content_clear_eol = false; }, - - else => {}, } } @@ -1295,6 +1304,13 @@ pub fn index(self: *Terminal) !void { // Unset pending wrap state self.screens.active.cursor.pending_wrap = false; + // Always reset any semantic content clear-eol state + if (self.screens.active.cursor.semantic_content_clear_eol) { + @branchHint(.unlikely); + self.screens.active.cursor.semantic_content = .output; + self.screens.active.cursor.semantic_content_clear_eol = false; + } + // Outside of the scroll region we move the cursor one line down. if (self.screens.active.cursor.y < self.scrolling_region.top or self.screens.active.cursor.y > self.scrolling_region.bottom) diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 5a5f0d65f..9ffe617bd 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -999,3 +999,19 @@ test "semantic prompt new_command at column zero" { try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); } + +test "semantic prompt end_prompt_start_input_terminate_eol clears on linefeed" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set input terminated by EOL + try s.nextSlice("\x1b]133;I\x07"); + try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); + + // Linefeed should reset semantic content to output + try s.nextSlice("\n"); + try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); +} From 84cfb9de1c26db0835baeb13a51f01751cad2db2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 15:14:34 -0800 Subject: [PATCH 071/108] restore old marking behavior so everything keeps working --- src/terminal/stream_readonly.zig | 25 ++++++++++++++++++------- src/termio/stream_handler.zig | 8 ++++++-- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 9ffe617bd..87b0d9788 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -213,9 +213,22 @@ pub const Handler = struct { self: *Handler, cmd: Action.SemanticPrompt, ) !void { - try self.terminal.semanticPrompt(cmd); - switch (cmd.action) { + .fresh_line_new_prompt => { + const kind = cmd.readOption(.prompt_kind) orelse .initial; + switch (kind) { + .initial, .right => { + self.terminal.markSemanticPrompt(.prompt); + if (cmd.readOption(.redraw)) |redraw| { + self.terminal.flags.shell_redraws_prompt = redraw; + } + }, + .continuation, .secondary => { + self.terminal.markSemanticPrompt(.prompt_continuation); + }, + } + }, + .end_prompt_start_input => self.terminal.markSemanticPrompt(.input), .end_input_start_output => self.terminal.markSemanticPrompt(.command), .end_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, @@ -226,14 +239,12 @@ pub const Handler = struct { // though we should handle them eventually. .end_prompt_start_input_terminate_eol, .new_command, + .fresh_line, .prompt_start, => {}, - - // Handled by the new action above - .fresh_line, - .fresh_line_new_prompt, - => {}, } + + try self.terminal.semanticPrompt(cmd); } fn setMode(self: *Handler, mode: modes.Mode, enabled: bool) !void { diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 63094b106..b725649f1 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -320,7 +320,7 @@ pub const StreamHandler = struct { .progress_report => self.progressReport(value), .start_hyperlink => try self.startHyperlink(value.uri, value.id), .clipboard_contents => try self.clipboardContents(value.kind, value.data), - .semantic_prompt => self.semanticPrompt(value), + .semantic_prompt => try self.semanticPrompt(value), .mouse_shape => try self.setMouseShape(value), .configure_charset => self.configureCharset(value.slot, value.charset), .set_attribute => { @@ -1069,7 +1069,7 @@ pub const StreamHandler = struct { fn semanticPrompt( self: *StreamHandler, cmd: Stream.Action.SemanticPrompt, - ) void { + ) !void { switch (cmd.action) { .fresh_line_new_prompt => { const kind = cmd.readOption(.prompt_kind) orelse .initial; @@ -1113,6 +1113,10 @@ pub const StreamHandler = struct { .prompt_start, => {}, } + + // We do this last so failures are still processed correctly + // above. + try self.terminal.semanticPrompt(cmd); } fn reportPwd(self: *StreamHandler, url: []const u8) !void { From a80b3f34c019653f2e075b09a3dd5bf228a5c10e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 20:03:36 -0800 Subject: [PATCH 072/108] terminal: add semantic_prompt2 to Row to track prompt state --- src/terminal/Terminal.zig | 141 +++++++++++++++++++++++++++++++++----- src/terminal/osc.zig | 1 + src/terminal/page.zig | 24 ++++++- 3 files changed, 147 insertions(+), 19 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 2782b8d0e..063a240f5 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1077,12 +1077,10 @@ pub fn semanticPrompt( // "Subsequent text (until a OSC "133;B" or OSC "133;I" command) // is a prompt string (as if followed by OSC 133;P;k=i\007)." - - // Implementation note: we don't yet differentiate between - // the prompt types (k=) because it isn't of value to us - // currently. This may change in the future. - self.screens.active.cursor.semantic_content = .prompt; - self.screens.active.cursor.semantic_content_clear_eol = false; + self.semanticPromptSet( + .prompt, + cmd.readOption(.prompt_kind) orelse .initial, + ); // This is a kitty-specific flag that notes that the shell // is capable of redraw. @@ -1112,29 +1110,27 @@ pub fn semanticPrompt( // The k (kind) option specifies the type of prompt: // regular primary prompt (k=i or default), // right-side prompts (k=r), or prompts for continuation lines (k=c or k=s). - - // As noted above, we don't currently utilize the prompt type. - self.screens.active.cursor.semantic_content = .prompt; - self.screens.active.cursor.semantic_content_clear_eol = false; + self.semanticPromptSet( + .prompt, + cmd.readOption(.prompt_kind) orelse .initial, + ); }, .end_prompt_start_input => { // End of prompt and start of user input, terminated by a OSC // "133;C" or another prompt (OSC "133;P"). - self.screens.active.cursor.semantic_content = .input; - self.screens.active.cursor.semantic_content_clear_eol = false; + self.semanticPromptSet(.input, .initial); }, .end_prompt_start_input_terminate_eol => { // End of prompt and start of user input, terminated by end-of-line. - self.semanticPromptSet(.input); + self.semanticPromptSet(.input, .initial); self.screens.active.cursor.semantic_content_clear_eol = true; }, .end_input_start_output => { // "End of input, and start of output." - self.screens.active.cursor.semantic_content = .output; - self.screens.active.cursor.semantic_content_clear_eol = false; + self.semanticPromptSet(.output, .initial); }, .end_command => { @@ -1142,8 +1138,7 @@ pub fn semanticPrompt( // anything. Other terminals appear to do nothing here. I think // its reasonable at this point to reset our semantic content // state but the spec doesn't really say what to do. - self.screens.active.cursor.semantic_content = .output; - self.screens.active.cursor.semantic_content_clear_eol = false; + self.semanticPromptSet(.output, .initial); }, } } @@ -1151,6 +1146,7 @@ pub fn semanticPrompt( fn semanticPromptSet( self: *Terminal, mode: pagepkg.Cell.SemanticContent, + kind: osc.semantic_prompt.PromptKind, ) void { // We always reset this when we mode change. The caller can set it // again after if they care. @@ -1158,6 +1154,20 @@ fn semanticPromptSet( // Update our mode self.screens.active.cursor.semantic_content = mode; + + // We only need to update our row marker for prompt types. We + // use a switch in case new modes are introduced so the compiler + // can force us to handle them. + switch (mode) { + .input, .output => return, + .prompt => {}, + } + + // Last prompt type wins + self.screens.active.cursor.page_row.semantic_prompt2 = switch (kind) { + .initial, .right => .prompt, + .continuation, .secondary => .prompt_continuation, + }; } // OSC 133;L @@ -1304,7 +1314,11 @@ pub fn index(self: *Terminal) !void { // Unset pending wrap state self.screens.active.cursor.pending_wrap = false; - // Always reset any semantic content clear-eol state + // Always reset any semantic content clear-eol state. + // + // The specification is not clear what "end-of-line" means. If we + // discover that there are more scenarios we should be unsetting + // this we should document and test it. if (self.screens.active.cursor.semantic_content_clear_eol) { @branchHint(.unlikely); self.screens.active.cursor.semantic_content = .output; @@ -11314,6 +11328,97 @@ test "Terminal: eraseDisplay complete preserves cursor" { try testing.expect(t.screens.active.cursor.style_id != style.default_id); } +test "Terminal: semantic prompt" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Prompt + try t.semanticPrompt(.init(.fresh_line_new_prompt)); + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.prompt, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.prompt, row.semantic_prompt2); + } + + // Start input but end it on EOL + try t.semanticPrompt(.init(.end_prompt_start_input_terminate_eol)); + t.carriageReturn(); + try t.linefeed(); + + // Write some output + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + for ("world") |c| try t.print(c); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.output, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.no_prompt, row.semantic_prompt2); + } +} + +test "Terminal: semantic prompt continuations" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Prompt + try t.semanticPrompt(.init(.fresh_line_new_prompt)); + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.prompt, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.prompt, row.semantic_prompt2); + } + + // Start input but end it on EOL + t.carriageReturn(); + try t.linefeed(); + try t.semanticPrompt(.{ + .action = .prompt_start, + .options_unvalidated = "k=c", + }); + + // Write some output + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + for ("world") |c| try t.print(c); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.prompt, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.prompt_continuation, row.semantic_prompt2); + } +} + test "Terminal: cursorIsAtPrompt" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 3, .rows = 2 }); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index b9061e2e9..a1386d14b 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -17,6 +17,7 @@ const parsers = @import("osc/parsers.zig"); const encoding = @import("osc/encoding.zig"); pub const color = parsers.color; +pub const semantic_prompt = parsers.semantic_prompt; const log = std.log.scoped(.osc); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 2f58bf49c..3747a8e6a 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1902,6 +1902,17 @@ pub const Row = packed struct(u64) { /// running program, or "unknown" if it was never set. semantic_prompt: SemanticPrompt = .unknown, + /// The semantic prompt state for this row. + /// + /// This is ONLY meant to note if there are ANY cells in this + /// row that are part of a prompt. This is an optimization for more + /// efficiently implementing jump-to-prompt operations. + /// + /// This may contain false positives but never false negatives. If + /// this is set, you should still check individual cells to see if they + /// have prompt semantics. + semantic_prompt2: SemanticPrompt2 = .no_prompt, + /// True if this row contains a virtual placeholder for the Kitty /// graphics protocol. (U+10EEEE) // Note: We keep this as memory-using even if the kitty graphics @@ -1922,7 +1933,18 @@ pub const Row = packed struct(u64) { /// screen. dirty: bool = false, - _padding: u22 = 0, + _padding: u20 = 0, + + /// The semantic prompt state of the row. See `semantic_prompt`. + pub const SemanticPrompt2 = enum(u2) { + /// No prompt cells in this row. + no_prompt = 0, + /// Prompt cells exist in this row. + prompt = 1, + /// Prompt cells exist in this row that had k=c set (continuation) + /// line. This is used as a way to + prompt_continuation = 2, + }; /// Semantic prompt type. pub const SemanticPrompt = enum(u3) { From 3c0fe022387ca4cf649e12b02d1a05682728125d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Jan 2026 13:54:53 -0800 Subject: [PATCH 073/108] terminal: PageList.promptIterator --- src/terminal/PageList.zig | 350 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 350 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 35826d97e..2d4585f60 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -12,6 +12,7 @@ const fastmem = @import("../fastmem.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); +const highlight = @import("highlight.zig"); const kitty = @import("kitty.zig"); const point = @import("point.zig"); const pagepkg = @import("page.zig"); @@ -4213,6 +4214,147 @@ pub fn diagram( /// Direction that iterators can move. pub const Direction = enum { left_up, right_down }; +pub const PromptIterator = struct { + /// The pin that we are currently at. Also the starting pin when + /// initializing. + current: ?Pin, + + /// The pin to end at or null if we end when we can't traverse + /// anymore. + limit: ?Pin, + + /// The direction to do the traversal. + direction: Direction, + + pub const empty: PromptIterator = .{ + .current = null, + .limit = null, + .direction = .left_up, + }; + + /// Return the next pin that represents the first row in a prompt. + /// From here, you can find the prompt input, command output, etc. + pub fn next(self: *PromptIterator) ?Pin { + switch (self.direction) { + .left_up => return self.nextLeftUp(), + .right_down => return self.nextRightDown(), + } + } + + pub fn nextRightDown(self: *PromptIterator) ?Pin { + // Start at our current pin. If we have no current it means + // we reached the end and we're done. + const start: Pin = self.current orelse return null; + + // We need to traverse downwards and look for prompts. + var current: ?Pin = start; + while (current) |p| { + const rac = p.rowAndCell(); + switch (rac.row.semantic_prompt2) { + // This row isn't a prompt. Keep looking. + .no_prompt => current = p.down(1), + + // This is a prompt line or continuation line. In either + // case we consider the first line the prompt, and then + // skip over any remaining prompt lines. This handles the + // case where scrollback pruned the prompt. + .prompt, .prompt_continuation => { + // Skip over any continuation lines that follow this prompt + var end_pin = p; + while (end_pin.down(1)) |next_pin| : (end_pin = next_pin) { + switch (next_pin.rowAndCell().row.semantic_prompt2) { + .prompt_continuation => {}, + .prompt, .no_prompt => { + self.current = next_pin; + return p.left(p.x); + }, + } + } else { + self.current = null; + return p.left(p.x); + } + }, + } + } + + self.current = null; + return null; + } + + pub fn nextLeftUp(self: *PromptIterator) ?Pin { + // Start at our current pin. If we have no current it means + // we reached the end and we're done. + const start: Pin = self.current orelse return null; + + // We need to traverse upwards and look for prompts. + var current: ?Pin = start; + while (current) |p| { + const rac = p.rowAndCell(); + switch (rac.row.semantic_prompt2) { + // This row isn't a prompt. Keep looking. + .no_prompt => current = p.up(1), + + // This is a prompt line. + .prompt => { + self.current = p.up(1); + // We want to make sure our x is 0 + return p.left(p.x); + }, + + // If this is a prompt continuation, then we continue + // looking for the start of the prompt OR a non-prompt + // line, whichever is first. The non-prompt line is to handle + // poorly behaved programs or scrollback that's been cut-off. + .prompt_continuation => while (current.?.up(1)) |prior| { + switch (prior.rowAndCell().row.semantic_prompt2) { + // No prompt. We know this line is bad, so we move + // our cursor to the NEXT line and then return the + // PREVIOUS line we looked at which we know was good. + .no_prompt => { + self.current = prior.up(1); + return current.?.left(current.?.x); + }, + + // Prompt continuation, keep looking. + .prompt_continuation => current = prior, + + // Prompt! Found it! + .prompt => { + self.current = prior.up(1); + return prior.left(prior.x); + }, + } + } else { + // No prior rows, trimmed scrollback probably. + self.current = null; + return p.left(p.x); + }, + } + } + + self.current = null; + return null; + } +}; + +pub fn promptIterator( + self: *const PageList, + direction: Direction, + tl_pt: point.Point, + bl_pt: ?point.Point, +) PromptIterator { + const tl_pin = self.pin(tl_pt).?; + const bl_pin = if (bl_pt) |pt| + self.pin(pt).? + else + self.getBottomRight(tl_pt) orelse return .empty; + + return switch (direction) { + .right_down => tl_pin.promptIterator(.right_down, bl_pin), + .left_up => bl_pin.promptIterator(.left_up, tl_pin), + }; +} + pub const CellIterator = struct { row_it: RowIterator, cell: ?Pin = null, @@ -4816,6 +4958,18 @@ pub const Pin = struct { return .{ .row_it = row_it, .cell = cell }; } + pub inline fn promptIterator( + self: Pin, + direction: Direction, + limit: ?Pin, + ) PromptIterator { + return .{ + .current = self, + .limit = limit, + .direction = direction, + }; + } + /// Returns true if this pin is between the top and bottom, inclusive. // // Note: this is primarily unit tested as part of the Kitty @@ -7565,6 +7719,202 @@ test "PageList cellIterator reverse" { try testing.expect(it.next() == null); } +test "PageList promptIterator left_up" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + // Normal prompt + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt2 = .prompt; + } + // Continuation + { + const rac = page.getRowAndCell(0, 6); + rac.row.semantic_prompt2 = .prompt; + } + { + const rac = page.getRowAndCell(0, 7); + rac.row.semantic_prompt2 = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 8); + rac.row.semantic_prompt2 = .prompt_continuation; + } + // Broken continuation that has non-prompts in between + { + const rac = page.getRowAndCell(0, 12); + rac.row.semantic_prompt2 = .prompt_continuation; + } + + var it = s.promptIterator(.left_up, .{ .screen = .{} }, null); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 12, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator right_down" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + // Normal prompt + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt2 = .prompt; + } + // Continuation (prompt on row 6, continuation on rows 7-8) + { + const rac = page.getRowAndCell(0, 6); + rac.row.semantic_prompt2 = .prompt; + } + { + const rac = page.getRowAndCell(0, 7); + rac.row.semantic_prompt2 = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 8); + rac.row.semantic_prompt2 = .prompt_continuation; + } + // Broken continuation that has non-prompts in between (orphaned continuation at row 12) + { + const rac = page.getRowAndCell(0, 12); + rac.row.semantic_prompt2 = .prompt_continuation; + } + + var it = s.promptIterator(.right_down, .{ .screen = .{} }, null); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 12, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator right_down continuation at start" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt continuation at row 0 (no prior rows - simulates trimmed scrollback) + { + const rac = page.getRowAndCell(0, 0); + rac.row.semantic_prompt2 = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 1); + rac.row.semantic_prompt2 = .prompt_continuation; + } + // Normal prompt later + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + } + + var it = s.promptIterator(.right_down, .{ .screen = .{} }, null); + { + // Should return the first continuation line since there's no prior prompt + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator right_down with prompt before continuation" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 2, continuation on rows 3-4 + // Starting iteration from row 3 should still find the prompt at row 2 + { + const rac = page.getRowAndCell(0, 2); + rac.row.semantic_prompt2 = .prompt; + } + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt2 = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 4); + rac.row.semantic_prompt2 = .prompt_continuation; + } + + // Start iteration from row 3 (middle of the continuation) + // Since we start on a continuation line, we treat it as the prompt start + // (handles case where scrollback pruned the actual prompt) + var it = s.promptIterator(.right_down, .{ .screen = .{ .y = 3 } }, null); + { + const p = it.next().?; + // Returns row 3 since that's the first prompt-related line we encounter + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + test "PageList erase" { const testing = std.testing; const alloc = testing.allocator; From 123e4ea3253f8203f2d8c3b89af0f26714da63f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Jan 2026 14:23:27 -0800 Subject: [PATCH 074/108] terminal: PageList delta_prompt scroll uses new promptIterator --- src/terminal/PageList.zig | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 2d4585f60..f8383bf8d 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2669,21 +2669,26 @@ fn scrollPrompt(self: *PageList, delta: isize) void { const delta_start: usize = @intCast(if (delta > 0) delta else -delta); var delta_rem: usize = delta_start; - // Iterate and count the number of prompts we see. - const viewport_pin = self.getTopLeft(.viewport); - var it = viewport_pin.rowIterator(if (delta > 0) .right_down else .left_up, null); - _ = it.next(); // skip our own row + // We start at the row before or after our viewport depending on the + // delta so that we don't land back on our current viewport. + const start_pin = start: { + const tl = self.getTopLeft(.viewport); + const adjusted: ?Pin = if (delta > 0) + tl.down(1) + else + tl.up(1); + break :start adjusted orelse return; + }; + + // Go through prompts delta times + var it = start_pin.promptIterator( + if (delta > 0) .right_down else .left_up, + null, + ); var prompt_pin: ?Pin = null; while (it.next()) |next| { - const row = next.rowAndCell().row; - switch (row.semantic_prompt) { - .command, .unknown => {}, - .prompt, .prompt_continuation, .input => { - delta_rem -= 1; - prompt_pin = next; - }, - } - + prompt_pin = next; + delta_rem -= 1; if (delta_rem == 0) break; } @@ -6595,11 +6600,11 @@ test "PageList: jump zero prompts" { const page = &s.pages.first.?.data; { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } s.scroll(.{ .delta_prompt = 0 }); @@ -6623,11 +6628,11 @@ test "Screen: jump back one prompt" { const page = &s.pages.first.?.data; { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } // Jump back From c74889124a8dea94d3abd5b6b3e8e839ae3b386c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Jan 2026 14:26:04 -0800 Subject: [PATCH 075/108] terminal: PageList uses new semantic_prompt2 --- src/terminal/PageList.zig | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index f8383bf8d..600cffcec 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1229,7 +1229,7 @@ const ReflowCursor = struct { // If the row has a semantic prompt then the blank row is meaningful // so we just consider pretend the first cell of the row isn't empty. - if (cols_len == 0 and src_row.semantic_prompt != .unknown) cols_len = 1; + if (cols_len == 0 and src_row.semantic_prompt2 != .no_prompt) cols_len = 1; } // Handle tracked pin adjustments. @@ -1973,13 +1973,13 @@ const ReflowCursor = struct { // If the row has a semantic prompt then the blank row is meaningful // so we always return all but one so that the row is drawn. - if (self.page_row.semantic_prompt != .unknown) return len - 1; + if (self.page_row.semantic_prompt2 != .no_prompt) return len - 1; return len; } fn copyRowMetadata(self: *ReflowCursor, other: *const Row) void { - self.page_row.semantic_prompt = other.semantic_prompt; + self.page_row.semantic_prompt2 = other.semantic_prompt2; } }; @@ -10254,7 +10254,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } // Resize @@ -10266,7 +10266,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 1); - try testing.expect(rac.row.semantic_prompt == .prompt); + try testing.expect(rac.row.semantic_prompt2 == .prompt); } } @@ -10829,7 +10829,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { const page = &s.pages.first.?.data; { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } for (0..s.cols) |x| { const rac = page.getRowAndCell(x, 1); @@ -10851,12 +10851,12 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { const p = s.pin(.{ .active = .{ .y = 1 } }).?; const rac = p.rowAndCell(); try testing.expect(rac.row.wrap); - try testing.expect(rac.row.semantic_prompt == .prompt); + try testing.expect(rac.row.semantic_prompt2 == .prompt); } { const p = s.pin(.{ .active = .{ .y = 2 } }).?; const rac = p.rowAndCell(); - try testing.expect(rac.row.semantic_prompt == .prompt); + try testing.expect(rac.row.semantic_prompt2 == .prompt); } } } @@ -10871,7 +10871,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } // Resize @@ -10883,7 +10883,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - try testing.expect(rac.row.semantic_prompt == .prompt); + try testing.expect(rac.row.semantic_prompt2 == .prompt); } } @@ -10897,7 +10897,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } // Resize @@ -10909,7 +10909,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - try testing.expect(rac.row.semantic_prompt == .prompt); + try testing.expect(rac.row.semantic_prompt2 == .prompt); } } From f9aa7597672d80c3811a9f2f6a324c84a2fd7a9c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Jan 2026 14:35:46 -0800 Subject: [PATCH 076/108] terminal: promptIterator needs to respect limits --- src/terminal/PageList.zig | 151 ++++++++++++++++++++++++++++++-------- 1 file changed, 121 insertions(+), 30 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 600cffcec..bff31fd3f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4253,31 +4253,44 @@ pub const PromptIterator = struct { // We need to traverse downwards and look for prompts. var current: ?Pin = start; - while (current) |p| { + while (current) |p| : (current = p.down(1)) { + // Check our limit. + const at_limit = if (self.limit) |limit| limit.eql(p) else false; + const rac = p.rowAndCell(); switch (rac.row.semantic_prompt2) { // This row isn't a prompt. Keep looking. - .no_prompt => current = p.down(1), + .no_prompt => if (at_limit) break, // This is a prompt line or continuation line. In either // case we consider the first line the prompt, and then // skip over any remaining prompt lines. This handles the // case where scrollback pruned the prompt. .prompt, .prompt_continuation => { - // Skip over any continuation lines that follow this prompt + // If we're at our limit just return this prompt. + if (at_limit) { + self.current = null; + return p.left(p.x); + } + + // Skip over any continuation lines that follow this prompt, + // up to our limit. var end_pin = p; while (end_pin.down(1)) |next_pin| : (end_pin = next_pin) { switch (next_pin.rowAndCell().row.semantic_prompt2) { - .prompt_continuation => {}, + .prompt_continuation => if (self.limit) |limit| { + if (limit.eql(next_pin)) break; + }, + .prompt, .no_prompt => { self.current = next_pin; return p.left(p.x); }, } - } else { - self.current = null; - return p.left(p.x); } + + self.current = null; + return p.left(p.x); }, } } @@ -4293,16 +4306,18 @@ pub const PromptIterator = struct { // We need to traverse upwards and look for prompts. var current: ?Pin = start; - while (current) |p| { + while (current) |p| : (current = p.up(1)) { + // Check our limit. + const at_limit = if (self.limit) |limit| limit.eql(p) else false; + const rac = p.rowAndCell(); switch (rac.row.semantic_prompt2) { // This row isn't a prompt. Keep looking. - .no_prompt => current = p.up(1), + .no_prompt => if (at_limit) break, // This is a prompt line. .prompt => { - self.current = p.up(1); - // We want to make sure our x is 0 + self.current = if (at_limit) null else p.up(1); return p.left(p.x); }, @@ -4310,26 +4325,37 @@ pub const PromptIterator = struct { // looking for the start of the prompt OR a non-prompt // line, whichever is first. The non-prompt line is to handle // poorly behaved programs or scrollback that's been cut-off. - .prompt_continuation => while (current.?.up(1)) |prior| { - switch (prior.rowAndCell().row.semantic_prompt2) { - // No prompt. We know this line is bad, so we move - // our cursor to the NEXT line and then return the - // PREVIOUS line we looked at which we know was good. - .no_prompt => { - self.current = prior.up(1); - return current.?.left(current.?.x); - }, - - // Prompt continuation, keep looking. - .prompt_continuation => current = prior, - - // Prompt! Found it! - .prompt => { - self.current = prior.up(1); - return prior.left(prior.x); - }, + .prompt_continuation => { + // If we're at our limit just return this continuation as prompt. + if (at_limit) { + self.current = null; + return p.left(p.x); } - } else { + + var end_pin = p; + while (end_pin.up(1)) |prior| : (end_pin = prior) { + if (self.limit) |limit| { + if (limit.eql(prior)) break; + } + + switch (prior.rowAndCell().row.semantic_prompt2) { + // No prompt. That means our last pin is good! + .no_prompt => { + self.current = prior; + return end_pin.left(end_pin.x); + }, + + // Prompt continuation, keep looking. + .prompt_continuation => {}, + + // Prompt! Found it! + .prompt => { + self.current = prior.up(1); + return prior.left(prior.x); + }, + } + } + // No prior rows, trimmed scrollback probably. self.current = null; return p.left(p.x); @@ -7920,6 +7946,71 @@ test "PageList promptIterator right_down with prompt before continuation" { try testing.expect(it.next() == null); } +test "PageList promptIterator right_down limit inclusive" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Iterate with limit at row 5 (the prompt row) - should include it + var it = s.promptIterator(.right_down, .{ .screen = .{} }, .{ .screen = .{ .y = 5 } }); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator left_up limit inclusive" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Iterate with limit at row 10 (the prompt row) - should include it + // tl_pt is the limit (upper bound), bl_pt is the start point for left_up + var it = s.promptIterator(.left_up, .{ .screen = .{ .y = 10 } }, .{ .screen = .{ .y = 15 } }); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + test "PageList erase" { const testing = std.testing; const alloc = testing.allocator; From 4dd5df6c05f6562c28f8e35bd78e687a92389fc6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Jan 2026 15:08:05 -0800 Subject: [PATCH 077/108] terminal: PageList.highlightSemanticContent --- src/terminal/PageList.zig | 1208 +++++++++++++++++++++++++++++++++++++ 1 file changed, 1208 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index bff31fd3f..c7ff0fc8d 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4216,6 +4216,151 @@ pub fn diagram( } } +/// Returns the boundaries of the given semantic content type for +/// the prompt at the given pin. The pin row MUST be the first row +/// of a prompt, otherwise the results may be nonsense. +/// +/// To get prompt pins, use promptIterator. Warning that if there are +/// no semantic prompts ever present, promptIterator will iterate the +/// entire PageList. Downstream callers should keep track of a flag if +/// they've ever seen semantic prompt operations to prevent this performance +/// case. +/// +/// Note that some semantic content type such as "input" is usually +/// nested within prompt boundaries, so the returned boundaries may include +/// prompt text. +pub fn highlightSemanticContent( + self: *const PageList, + at: Pin, + content: pagepkg.Cell.SemanticContent, +) ?highlight.Untracked { + // Performance note: we can do this more efficiently in a single + // forward-pass. Semantic content operations aren't usually fast path + // but if someone wants to optimize them someday that's great. + + const end: Pin = end: { + // Safety assertion, our starting point should be a prompt row. + // so the first returned prompt should be ourselves. + var it = at.promptIterator(.right_down, null); + assert(it.next().?.y == at.y); + + // Our end is the end of the line just before the next prompt + // line, which should exist since we verified we have at least + // two prompts here. + if (it.next()) |next| next: { + var prev = next.up(1) orelse break :next; + prev.x = prev.node.data.size.cols - 1; + break :end prev; + } + + // Didn't find any further prompt so the end of our zone is + // the end of the screen. + break :end self.getBottomRight(.screen).?; + }; + + switch (content) { + // For the prompt, we select all the way up to command output. + // We include all the input lines, too. + .prompt => { + var result: highlight.Untracked = .{ + .start = at.left(at.x), + .end = at, + }; + + var it = at.cellIterator(.right_down, end); + while (it.next()) |p| { + switch (p.rowAndCell().cell.semantic_content) { + .prompt, .input => result.end = p, + .output => break, + } + } + + return result; + }, + + // For input, we include the start of the input to the end of + // the input, which may include all the prompts in the middle, too. + .input => { + var result: highlight.Untracked = .{ + .start = undefined, + .end = undefined, + }; + + // Find the start + var it = at.cellIterator(.right_down, end); + while (it.next()) |p| { + switch (p.rowAndCell().cell.semantic_content) { + .prompt => {}, + .input => { + result.start = p; + result.end = p; + break; + }, + .output => return null, + } + } else { + // No input found + return null; + } + + // Find the end + while (it.next()) |p| { + switch (p.rowAndCell().cell.semantic_content) { + // Prompts can be nested in our input for continuation + .prompt => {}, + + // Output means we're done + .output => break, + + .input => result.end = p, + } + } + + return result; + }, + + .output => { + var result: highlight.Untracked = .{ + .start = undefined, + .end = undefined, + }; + + // Find the start + var it = at.cellIterator(.right_down, end); + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + switch (cell.semantic_content) { + .prompt, .input => {}, + .output => { + // Skip empty cells - they default to .output but aren't real output + if (!cell.hasText()) continue; + result.start = p; + result.end = p; + break; + }, + } + } else { + // No output found + return null; + } + + // Find the end + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + switch (cell.semantic_content) { + .prompt, .input => break, + .output => { + // Only extend to cells with actual text + if (cell.hasText()) result.end = p; + }, + } + } + + return result; + }, + } +} + /// Direction that iterators can move. pub const Direction = enum { left_up, right_down }; @@ -8011,6 +8156,1069 @@ test "PageList promptIterator left_up limit inclusive" { try testing.expect(it.next() == null); } +test "PageList highlightSemanticContent prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // Start the prompt for the first 5 cols + for (0..5) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + .semantic_content = .prompt, + }; + } + + // Next 3 let's make input + for (5..8) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'B' }, + .semantic_content = .input, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 2, .y = 5 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent prompt with output" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 4 are input + for (3..7) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + + // Rest is output (shouldn't be included in prompt highlight) + for (7..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting from prompt should include prompt and input, but stop at output + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 6, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent prompt multiline" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt starts on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // First row is all prompt + for (0..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + } + // Row 6 continues with input + { + for (0..5) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting should span both rows + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 2, .y = 5 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 6, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent prompt only" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 with only prompt content (no input) + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + for (0..5) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting should only include the prompt cells + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent prompt to end of screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Single prompt on row 15, no following prompt + { + const rac = page.getRowAndCell(0, 15); + rac.row.semantic_prompt2 = .prompt; + + for (0..3) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + for (3..8) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + + // Highlighting should include prompt and input up to column 7 + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 15 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 15, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 15, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input basic" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 5 are input + for (3..8) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting input should only include input cells + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input with output" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 3 are input + for (2..5) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + + // Rest is output + for (5..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting input should stop at output + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input multiline with continuation" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Rest is input + for (2..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + // Row 6 has continuation prompt then more input + { + // Continuation prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '>' }, + .semantic_content = .prompt, + }; + } + + // More input + for (2..6) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'd' }, + .semantic_content = .input, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting input should span both rows, skipping continuation prompts + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 6, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input no input returns null" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 with only prompt, then immediately output + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Rest is output (no input!) + for (3..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting input should return null when there's no input + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ); + try testing.expect(hl == null); +} + +test "PageList highlightSemanticContent input to end of screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Single prompt on row 15, no following prompt + { + const rac = page.getRowAndCell(0, 15); + rac.row.semantic_prompt2 = .prompt; + + for (0..2) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + for (2..7) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + + // Highlighting input with no following prompt + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 15 } }).?, + .input, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 15, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 6, + .y = 15, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input prompt only returns null" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 with only prompt content, no input or output + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // All cells are prompt + for (0..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + } + // Mark rows 6-9 as prompt to ensure no input before next prompt + { + for (6..10) |y| { + for (0..10) |x| { + const cell = page.getRowAndCell(x, y).cell; + cell.semantic_content = .prompt; + } + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting input should return null when there's only prompts + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ); + try testing.expect(hl == null); +} + +test "PageList highlightSemanticContent output basic" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 3 are input + for (2..5) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + + // Cols 5-7 are output + for (5..8) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + + // Mark remaining cells as prompt to bound the output + for (8..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.semantic_content = .prompt; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting output should only include output cells + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent output multiline" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 2 are input + for (2..4) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + + // Rest of row 5 is output + for (4..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Row 6 is all output + { + for (0..10) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Row 7 has partial output then input to bound it + { + for (0..5) |x| { + const cell = page.getRowAndCell(x, 7).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + for (5..10) |x| { + const cell = page.getRowAndCell(x, 7).cell; + cell.semantic_content = .input; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting output should span multiple rows + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 7, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent output stops at next prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 2 are input + for (2..4) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + + // Rest is output + for (4..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Row 6 has output then prompt starts + { + for (0..3) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + // Next prompt marker on same row + for (3..6) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting output should stop before prompt/input + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 6, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent output to end of screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Single prompt on row 15, no following prompt + { + const rac = page.getRowAndCell(0, 15); + rac.row.semantic_prompt2 = .prompt; + + for (0..2) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + for (2..4) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + + for (4..10) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Row 16 has output then prompt to bound it + { + for (0..8) |x| { + const cell = page.getRowAndCell(x, 16).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + for (8..10) |x| { + const cell = page.getRowAndCell(x, 16).cell; + cell.semantic_content = .prompt; + } + } + + // Highlighting output with no following prompt + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 15 } }).?, + .output, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 15, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 16, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent output no output returns null" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 with only prompt and input, no output + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Rest is input (must explicitly mark all cells to avoid default .output) + for (3..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + // Mark rows 6-9 as input to ensure no output between prompts + { + for (6..10) |y| { + for (0..10) |x| { + const cell = page.getRowAndCell(x, y).cell; + cell.semantic_content = .input; + } + } + } + // Prompt on row 10 (no output between prompts) + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting output should return null when there's no output + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ); + try testing.expect(hl == null); +} + +test "PageList highlightSemanticContent output skips empty cells" { + // Tests that empty cells with default .output semantic content are + // not selected as output. This can happen when a prompt/input line + // doesn't fill the entire row - trailing cells have default .output. + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 - only fills first 3 cells, rest are empty with default .output + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .prompt; + + // First 3 cols are prompt with text + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + // Cells 3-9 are empty (codepoint = 0) with default .output semantic content + // This simulates what happens when a short prompt is written + } + + // Row 6 has input (short, doesn't fill line) + { + for (0..4) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + // Cells 4-9 are empty with default .output + } + + // Row 7-8 have actual output with text + { + for (7..9) |y| { + for (0..5) |x| { + const cell = page.getRowAndCell(x, y).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + } + + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .prompt; + } + + // Highlighting output should skip empty cells on rows 5-6 and find + // the actual output starting at row 7 + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ).?; + // Output should start at row 7, not row 5 (where empty cells have default .output) + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 7, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 8, + } }, s.pointFromPin(.screen, hl.end).?); +} + test "PageList erase" { const testing = std.testing; const alloc = testing.allocator; From fd016fdb2a66d9490f491ba43220a055069f18d0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 09:22:06 -0800 Subject: [PATCH 078/108] terminal: move cursor semantic content functions into Screen --- src/terminal/Screen.zig | 34 ++++++++++++++++++++++++ src/terminal/Terminal.zig | 54 ++++++++++----------------------------- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index e92a81117..268ae934a 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -15,6 +15,7 @@ const Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); const StringMap = @import("StringMap.zig"); const ScreenFormatter = @import("formatter.zig").ScreenFormatter; +const osc = @import("osc.zig"); const pagepkg = @import("page.zig"); const point = @import("point.zig"); const size = @import("size.zig"); @@ -151,6 +152,39 @@ pub const Cursor = struct { alloc.destroy(link); } } + + /// Modify the semantic content type of the cursor. This should + /// be preferred over setting it manually since it handles all the + /// proper accounting. + pub fn setSemanticContent(self: *Cursor, t: union(enum) { + prompt: osc.semantic_prompt.PromptKind, + output, + input: enum { clear_explicit, clear_eol }, + }) void { + switch (t) { + .output => { + self.semantic_content = .output; + self.semantic_content_clear_eol = false; + }, + + .input => |clear| { + self.semantic_content = .input; + self.semantic_content_clear_eol = switch (clear) { + .clear_explicit => false, + .clear_eol => true, + }; + }, + + .prompt => |kind| { + self.semantic_content = .prompt; + self.semantic_content_clear_eol = false; + self.page_row.semantic_prompt2 = switch (kind) { + .initial, .right => .prompt, + .continuation, .secondary => .prompt_continuation, + }; + }, + } + } }; /// Saved cursor state. diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 063a240f5..dc649c5b1 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1077,10 +1077,9 @@ pub fn semanticPrompt( // "Subsequent text (until a OSC "133;B" or OSC "133;I" command) // is a prompt string (as if followed by OSC 133;P;k=i\007)." - self.semanticPromptSet( - .prompt, - cmd.readOption(.prompt_kind) orelse .initial, - ); + self.screens.active.cursor.setSemanticContent(.{ + .prompt = cmd.readOption(.prompt_kind) orelse .initial, + }); // This is a kitty-specific flag that notes that the shell // is capable of redraw. @@ -1110,27 +1109,29 @@ pub fn semanticPrompt( // The k (kind) option specifies the type of prompt: // regular primary prompt (k=i or default), // right-side prompts (k=r), or prompts for continuation lines (k=c or k=s). - self.semanticPromptSet( - .prompt, - cmd.readOption(.prompt_kind) orelse .initial, - ); + self.screens.active.cursor.setSemanticContent(.{ + .prompt = cmd.readOption(.prompt_kind) orelse .initial, + }); }, .end_prompt_start_input => { // End of prompt and start of user input, terminated by a OSC // "133;C" or another prompt (OSC "133;P"). - self.semanticPromptSet(.input, .initial); + self.screens.active.cursor.setSemanticContent(.{ + .input = .clear_explicit, + }); }, .end_prompt_start_input_terminate_eol => { // End of prompt and start of user input, terminated by end-of-line. - self.semanticPromptSet(.input, .initial); - self.screens.active.cursor.semantic_content_clear_eol = true; + self.screens.active.cursor.setSemanticContent(.{ + .input = .clear_eol, + }); }, .end_input_start_output => { // "End of input, and start of output." - self.semanticPromptSet(.output, .initial); + self.screens.active.cursor.setSemanticContent(.output); }, .end_command => { @@ -1138,38 +1139,11 @@ pub fn semanticPrompt( // anything. Other terminals appear to do nothing here. I think // its reasonable at this point to reset our semantic content // state but the spec doesn't really say what to do. - self.semanticPromptSet(.output, .initial); + self.screens.active.cursor.setSemanticContent(.output); }, } } -fn semanticPromptSet( - self: *Terminal, - mode: pagepkg.Cell.SemanticContent, - kind: osc.semantic_prompt.PromptKind, -) void { - // We always reset this when we mode change. The caller can set it - // again after if they care. - self.screens.active.cursor.semantic_content_clear_eol = false; - - // Update our mode - self.screens.active.cursor.semantic_content = mode; - - // We only need to update our row marker for prompt types. We - // use a switch in case new modes are introduced so the compiler - // can force us to handle them. - switch (mode) { - .input, .output => return, - .prompt => {}, - } - - // Last prompt type wins - self.screens.active.cursor.page_row.semantic_prompt2 = switch (kind) { - .initial, .right => .prompt, - .continuation, .secondary => .prompt_continuation, - }; -} - // OSC 133;L fn semanticPromptFreshLine(self: *Terminal) !void { const left_margin = if (self.screens.active.cursor.x < self.scrolling_region.left) From 07dce38cc533ca10df36f50c470afa283c0bcfb1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 09:39:44 -0800 Subject: [PATCH 079/108] terminal: Screen tracks semantic content seen --- src/terminal/Screen.zig | 86 +++++++++++++++++++++++---------------- src/terminal/Terminal.zig | 12 +++--- 2 files changed, 58 insertions(+), 40 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 268ae934a..09e5d19df 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -77,6 +77,9 @@ else /// Dirty flags for the renderer. dirty: Dirty = .{}, +/// Packed flags for the screen, internal state. +flags: Flags = .{}, + /// See Terminal.Dirty. This behaves the same way. pub const Dirty = packed struct { /// Set when the selection is set or unset, regardless of if the @@ -88,6 +91,17 @@ pub const Dirty = packed struct { hyperlink_hover: bool = false, }; +/// A set of internal state that we pack for memory size. +pub const Flags = packed struct { + /// This is flipped to true when any sort of semantic content is + /// seen. In particular, this is set to true only when a `prompt` type + /// is ever set on our cursor. + /// + /// This is used to optimize away semantic content operations if we know + /// we've never seen them. + semantic_content: bool = false, +}; + /// The cursor position and style. pub const Cursor = struct { // The x/y position within the active area. @@ -152,39 +166,6 @@ pub const Cursor = struct { alloc.destroy(link); } } - - /// Modify the semantic content type of the cursor. This should - /// be preferred over setting it manually since it handles all the - /// proper accounting. - pub fn setSemanticContent(self: *Cursor, t: union(enum) { - prompt: osc.semantic_prompt.PromptKind, - output, - input: enum { clear_explicit, clear_eol }, - }) void { - switch (t) { - .output => { - self.semantic_content = .output; - self.semantic_content_clear_eol = false; - }, - - .input => |clear| { - self.semantic_content = .input; - self.semantic_content_clear_eol = switch (clear) { - .clear_explicit => false, - .clear_eol => true, - }; - }, - - .prompt => |kind| { - self.semantic_content = .prompt; - self.semantic_content_clear_eol = false; - self.page_row.semantic_prompt2 = switch (kind) { - .initial, .right => .prompt, - .continuation, .secondary => .prompt_continuation, - }; - }, - } - } }; /// Saved cursor state. @@ -397,6 +378,7 @@ pub fn reset(self: *Screen) void { self.charset = .{}; self.kitty_keyboard = .{}; self.protected_mode = .off; + self.flags = .{}; self.clearSelection(); } @@ -2363,6 +2345,42 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { } } +/// Modify the semantic content type of the cursor. This should +/// be preferred over setting it manually since it handles all the +/// proper accounting. +pub fn cursorSetSemanticContent(self: *Screen, t: union(enum) { + prompt: osc.semantic_prompt.PromptKind, + output, + input: enum { clear_explicit, clear_eol }, +}) void { + const cursor = &self.cursor; + + switch (t) { + .output => { + cursor.semantic_content = .output; + cursor.semantic_content_clear_eol = false; + }, + + .input => |clear| { + cursor.semantic_content = .input; + cursor.semantic_content_clear_eol = switch (clear) { + .clear_explicit => false, + .clear_eol => true, + }; + }, + + .prompt => |kind| { + self.flags.semantic_content = true; + cursor.semantic_content = .prompt; + cursor.semantic_content_clear_eol = false; + cursor.page_row.semantic_prompt2 = switch (kind) { + .initial, .right => .prompt, + .continuation, .secondary => .prompt_continuation, + }; + }, + } +} + /// Set the selection to the given selection. If this is a tracked selection /// then the screen will take ownership of the selection. If this is untracked /// then the screen will convert it to tracked internally. This will automatically @@ -3874,7 +3892,7 @@ test "Screen eraseRows active partial" { } } -test "Screen: clearPrompt" { +test "Screen: clearPrompt single line prompt" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index dc649c5b1..2bbec67dc 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1077,7 +1077,7 @@ pub fn semanticPrompt( // "Subsequent text (until a OSC "133;B" or OSC "133;I" command) // is a prompt string (as if followed by OSC 133;P;k=i\007)." - self.screens.active.cursor.setSemanticContent(.{ + self.screens.active.cursorSetSemanticContent(.{ .prompt = cmd.readOption(.prompt_kind) orelse .initial, }); @@ -1109,7 +1109,7 @@ pub fn semanticPrompt( // The k (kind) option specifies the type of prompt: // regular primary prompt (k=i or default), // right-side prompts (k=r), or prompts for continuation lines (k=c or k=s). - self.screens.active.cursor.setSemanticContent(.{ + self.screens.active.cursorSetSemanticContent(.{ .prompt = cmd.readOption(.prompt_kind) orelse .initial, }); }, @@ -1117,21 +1117,21 @@ pub fn semanticPrompt( .end_prompt_start_input => { // End of prompt and start of user input, terminated by a OSC // "133;C" or another prompt (OSC "133;P"). - self.screens.active.cursor.setSemanticContent(.{ + self.screens.active.cursorSetSemanticContent(.{ .input = .clear_explicit, }); }, .end_prompt_start_input_terminate_eol => { // End of prompt and start of user input, terminated by end-of-line. - self.screens.active.cursor.setSemanticContent(.{ + self.screens.active.cursorSetSemanticContent(.{ .input = .clear_eol, }); }, .end_input_start_output => { // "End of input, and start of output." - self.screens.active.cursor.setSemanticContent(.output); + self.screens.active.cursorSetSemanticContent(.output); }, .end_command => { @@ -1139,7 +1139,7 @@ pub fn semanticPrompt( // anything. Other terminals appear to do nothing here. I think // its reasonable at this point to reset our semantic content // state but the spec doesn't really say what to do. - self.screens.active.cursor.setSemanticContent(.output); + self.screens.active.cursorSetSemanticContent(.output); }, } } From b62ac468dcc8999bd99d45da305323238390b3c6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 09:50:36 -0800 Subject: [PATCH 080/108] terminal: change Screen.resize to take an options struct --- src/terminal/Screen.zig | 147 +++++++++++++++++--------------------- src/terminal/Terminal.zig | 18 ++--- 2 files changed, 77 insertions(+), 88 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 09e5d19df..39b507ea4 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1651,12 +1651,21 @@ pub inline fn blankCell(self: *const Screen) Cell { return self.cursor.style.bgCell() orelse .{}; } +pub const Resize = struct { + /// The new size to resize to + cols: size.CellCountInt, + rows: size.CellCountInt, + + /// Whether to reflow soft-wrapped text. + /// + /// This will reflow soft-wrapped text. If the screen size is getting + /// smaller and the maximum scrollback size is exceeded, data will be + /// lost from the top of the scrollback. + reflow: bool = true, +}; + /// Resize the screen. The rows or cols can be bigger or smaller. /// -/// This will reflow soft-wrapped text. If the screen size is getting -/// smaller and the maximum scrollback size is exceeded, data will be -/// lost from the top of the scrollback. -/// /// If this returns an error, the screen is left in a likely garbage state. /// It is very hard to undo this operation without blowing up our memory /// usage. The only way to recover is to reset the screen. The only way @@ -1666,29 +1675,7 @@ pub inline fn blankCell(self: *const Screen) Cell { /// (resize) is difficult. pub inline fn resize( self: *Screen, - cols: size.CellCountInt, - rows: size.CellCountInt, -) !void { - try self.resizeInternal(cols, rows, true); -} - -/// Resize the screen without any reflow. In this mode, columns/rows will -/// be truncated as they are shrunk. If they are grown, the new space is filled -/// with zeros. -pub inline fn resizeWithoutReflow( - self: *Screen, - cols: size.CellCountInt, - rows: size.CellCountInt, -) !void { - try self.resizeInternal(cols, rows, false); -} - -/// Resize the screen. -fn resizeInternal( - self: *Screen, - cols: size.CellCountInt, - rows: size.CellCountInt, - reflow: bool, + opts: Resize, ) !void { defer self.assertIntegrity(); @@ -1744,9 +1731,9 @@ fn resizeInternal( // Perform the resize operation. try self.pages.resize(.{ - .rows = rows, - .cols = cols, - .reflow = reflow, + .rows = opts.rows, + .cols = opts.cols, + .reflow = opts.reflow, .cursor = .{ .x = self.cursor.x, .y = self.cursor.y }, }); @@ -1773,7 +1760,7 @@ fn resizeInternal( // If we had pending wrap set and we're no longer at the end of // the line, we unset the pending wrap and move the cursor to // reflect the correct next position. - if (sc.pending_wrap and sc.x != cols - 1) { + if (sc.pending_wrap and sc.x != opts.cols - 1) { sc.pending_wrap = false; sc.x += 1; } @@ -5787,7 +5774,7 @@ test "Screen: resize (no reflow) more rows" { try s.testWriteString(str); // Resize - try s.resizeWithoutReflow(10, 10); + try s.resize(.{ .cols = 10, .rows = 10, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -5805,7 +5792,7 @@ test "Screen: resize (no reflow) less rows" { try s.testWriteString(str); try testing.expectEqual(5, s.cursor.x); try testing.expectEqual(2, s.cursor.y); - try s.resizeWithoutReflow(10, 2); + try s.resize(.{ .cols = 10, .rows = 2, .reflow = false }); // Since we shrunk, we should adjust our cursor try testing.expectEqual(5, s.cursor.x); @@ -5840,7 +5827,7 @@ test "Screen: resize (no reflow) less rows trims blank lines" { } const cursor = s.cursor; - try s.resizeWithoutReflow(6, 2); + try s.resize(.{ .cols = 6, .rows = 2, .reflow = false }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -5875,7 +5862,7 @@ test "Screen: resize (no reflow) more rows trims blank lines" { } const cursor = s.cursor; - try s.resizeWithoutReflow(10, 7); + try s.resize(.{ .cols = 10, .rows = 7, .reflow = false }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -5896,7 +5883,7 @@ test "Screen: resize (no reflow) more cols" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - try s.resizeWithoutReflow(20, 3); + try s.resize(.{ .cols = 20, .rows = 3, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -5913,7 +5900,7 @@ test "Screen: resize (no reflow) less cols" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - try s.resizeWithoutReflow(4, 3); + try s.resize(.{ .cols = 4, .rows = 3, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -5931,7 +5918,7 @@ test "Screen: resize (no reflow) more rows with scrollback cursor end" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resizeWithoutReflow(7, 10); + try s.resize(.{ .cols = 7, .rows = 10, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -5948,7 +5935,7 @@ test "Screen: resize (no reflow) less rows with scrollback" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resizeWithoutReflow(7, 2); + try s.resize(.{ .cols = 7, .rows = 2, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -5972,7 +5959,7 @@ test "Screen: resize (no reflow) less rows with empty trailing" { try s.testWriteString("A\nB"); const cursor = s.cursor; - try s.resizeWithoutReflow(5, 2); + try s.resize(.{ .cols = 5, .rows = 2, .reflow = false }); try testing.expectEqual(cursor.x, s.cursor.x); try testing.expectEqual(cursor.y, s.cursor.y); @@ -6004,7 +5991,7 @@ test "Screen: resize (no reflow) more rows with soft wrapping" { } // Resize - try s.resizeWithoutReflow(2, 10); + try s.resize(.{ .cols = 2, .rows = 10, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6033,7 +6020,7 @@ test "Screen: resize more rows no scrollback" { const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); const cursor = s.cursor; - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6060,7 +6047,7 @@ test "Screen: resize more rows with empty scrollback" { const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); const cursor = s.cursor; - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6104,7 +6091,7 @@ test "Screen: resize more rows with populated scrollback" { } // Resize - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); // Cursor should still be on the "4" { @@ -6133,7 +6120,7 @@ test "Screen: resize more cols no reflow" { try s.testWriteString(str); const cursor = s.cursor; - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6160,7 +6147,7 @@ test "Screen: resize more cols perfect split" { defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -6190,7 +6177,7 @@ test "Screen: resize (no reflow) more cols with scrollback scrolled up" { try testing.expectEqualStrings("2\n3\n4", contents); } - try s.resize(8, 3); + try s.resize(.{ .cols = 8, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -6223,7 +6210,7 @@ test "Screen: resize (no reflow) less cols with scrollback scrolled up" { try testing.expectEqualStrings("2\n3\n4", contents); } - try s.resize(4, 3); + try s.resize(.{ .cols = 4, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -6259,7 +6246,7 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { try s.testWriteSemanticString("2EFGH\n", .prompt); try s.testWriteSemanticString("3IJKL", .unknown); - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3, .reflow = false }); const expected = "1ABCD\n2EFGH\n3IJKL"; { @@ -6316,7 +6303,7 @@ test "Screen: resize more cols with reflow that fits full width" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6356,7 +6343,7 @@ test "Screen: resize more cols with reflow that ends in newline" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6401,7 +6388,7 @@ test "Screen: resize more cols with reflow that forces more wrapping" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(7, 3); + try s.resize(.{ .cols = 7, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6442,7 +6429,7 @@ test "Screen: resize more cols with reflow that unwraps multiple times" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(15, 3); + try s.resize(.{ .cols = 15, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6481,7 +6468,7 @@ test "Screen: resize more cols with populated scrollback" { } // Resize - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6527,7 +6514,7 @@ test "Screen: resize more cols with reflow" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(7, 3); + try s.resize(.{ .cols = 7, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -6555,7 +6542,7 @@ test "Screen: resize more rows and cols with wrapping" { try testing.expectEqualStrings(expected, contents); } - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); // Cursor should move due to wrapping try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.x); @@ -6584,7 +6571,7 @@ test "Screen: resize less rows no scrollback" { s.cursorAbsolute(0, 0); const cursor = s.cursor; - try s.resize(5, 1); + try s.resize(.{ .cols = 5, .rows = 1 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6624,7 +6611,7 @@ test "Screen: resize less rows moving cursor" { } // Resize - try s.resize(5, 1); + try s.resize(.{ .cols = 5, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6652,7 +6639,7 @@ test "Screen: resize less rows with empty scrollback" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - try s.resize(5, 1); + try s.resize(.{ .cols = 5, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -6683,7 +6670,7 @@ test "Screen: resize less rows with populated scrollback" { } // Resize - try s.resize(5, 1); + try s.resize(.{ .cols = 5, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -6717,7 +6704,7 @@ test "Screen: resize less rows with full scrollback" { try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); // Resize - try s.resize(5, 2); + try s.resize(.{ .cols = 5, .rows = 2 }); // Cursor should stay in the same relative place (bottom of the // screen, same character). @@ -6749,7 +6736,7 @@ test "Screen: resize less cols no reflow" { s.cursorAbsolute(0, 0); const cursor = s.cursor; - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6786,7 +6773,7 @@ test "Screen: resize less cols with reflow but row space" { try testing.expectEqual(@as(u32, 'D'), list_cell.cell.content.codepoint); } - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6813,7 +6800,7 @@ test "Screen: resize less cols with reflow with trimmed rows" { defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6837,7 +6824,7 @@ test "Screen: resize less cols with reflow with trimmed rows and scrollback" { defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6870,7 +6857,7 @@ test "Screen: resize less cols with reflow previously wrapped" { try testing.expectEqualStrings(expected, contents); } - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); // { // const contents = try s.testString(alloc, .viewport); @@ -6905,7 +6892,7 @@ test "Screen: resize less cols with reflow and scrollback" { try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); } - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6946,7 +6933,7 @@ test "Screen: resize less cols with reflow previously wrapped and scrollback" { try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); } - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6988,7 +6975,7 @@ test "Screen: resize less cols with scrollback keeps cursor row" { // Move our cursor to the beginning s.cursorAbsolute(0, 0); - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -7024,7 +7011,7 @@ test "Screen: resize more rows, less cols with reflow with scrollback" { try testing.expectEqualStrings(expected, contents); } - try s.resize(2, 10); + try s.resize(.{ .cols = 2, .rows = 10 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -7053,7 +7040,7 @@ test "Screen: resize more rows then shrink again" { try s.testWriteString(str); // Grow - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -7066,7 +7053,7 @@ test "Screen: resize more rows then shrink again" { } // Shrink - try s.resize(5, 3); + try s.resize(.{ .cols = 5, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7079,7 +7066,7 @@ test "Screen: resize more rows then shrink again" { } // Grow again - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -7113,7 +7100,7 @@ test "Screen: resize less cols to eliminate wide char" { } // Resize to 1 column can't fit a wide char. So it should be deleted. - try s.resize(1, 1); + try s.resize(.{ .cols = 1, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7152,7 +7139,7 @@ test "Screen: resize less cols to wrap wide char" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - try s.resize(2, 3); + try s.resize(.{ .cols = 2, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7191,7 +7178,7 @@ test "Screen: resize less cols to eliminate wide char with row space" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - try s.resize(1, 2); + try s.resize(.{ .cols = 1, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7233,7 +7220,7 @@ test "Screen: resize more cols with wide spacer head" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - try s.resize(4, 2); + try s.resize(.{ .cols = 4, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7284,7 +7271,7 @@ test "Screen: resize more cols with wide spacer head multiple lines" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - try s.resize(8, 2); + try s.resize(.{ .cols = 8, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7330,7 +7317,7 @@ test "Screen: resize more cols requiring a wide spacer head" { // This resizes to 3 columns, which isn't enough space for our wide // char to enter row 1. But we need to mark the wide spacer head on the // end of the first row since we're wrapping to the next row. - try s.resize(3, 2); + try s.resize(.{ .cols = 3, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -9047,7 +9034,7 @@ test "Screen: hyperlink cursor state on resize" { } // Resize. Any column growth will trigger a page to be reallocated. - try s.resize(10, 10); + try s.resize(.{ .cols = 10, .rows = 10 }); try testing.expect(s.cursor.hyperlink_id != 0); { const page = &s.cursor.page_pin.node.data; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 2bbec67dc..83e54482b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2685,16 +2685,18 @@ pub fn resize( { primary.clearPrompt(); } - if (self.modes.get(.wraparound)) { - try primary.resize(cols, rows); - } else { - try primary.resizeWithoutReflow(cols, rows); - } + try primary.resize(.{ + .cols = cols, + .rows = rows, + .reflow = self.modes.get(.wraparound), + }); // Alternate screen, if it exists, doesn't reflow - if (self.screens.get(.alternate)) |alt| { - try alt.resizeWithoutReflow(cols, rows); - } + if (self.screens.get(.alternate)) |alt| try alt.resize(.{ + .cols = cols, + .rows = rows, + .reflow = false, + }); // Whenever we resize we just mark it as a screen clear self.flags.dirty.clear = true; From 142f8ca6dbbf6ff8037a0e3d8afd3860138cd0f1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 10:25:03 -0800 Subject: [PATCH 081/108] terminal: Screen.selectLine uses new semantic boundaries --- src/terminal/Screen.zig | 452 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 430 insertions(+), 22 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 39b507ea4..b32431db3 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2500,16 +2500,36 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { // only happen within the same prompt state. For example, if you triple // click output, but the shell uses spaces to soft-wrap to the prompt // then the selection will stop prior to the prompt. See issue #1329. - const semantic_prompt_state: ?bool = state: { + const semantic_prompt_state: ?Cell.SemanticContent = state: { if (!opts.semantic_prompt_boundary) break :state null; const rac = opts.pin.rowAndCell(); - break :state rac.row.semantic_prompt.promptOrInput(); + break :state rac.cell.semantic_content; }; // The real start of the row is the first row in the soft-wrap. const start_pin: Pin = start_pin: { var it = opts.pin.rowIterator(.left_up, null); var it_prev: Pin = it.next().?; // skip self + + // First, check the current row for semantic boundaries before the clicked position. + if (semantic_prompt_state) |v| { + const row = it_prev.rowAndCell().row; + const cells = it_prev.node.data.getCells(row); + // Scan backwards from clicked position to find where our content starts + for (0..opts.pin.x + 1) |i| { + const x_rev = opts.pin.x - i; + if (cells[x_rev].semantic_content != v) { + var copy = it_prev; + copy.x = @intCast(x_rev + 1); + break :start_pin copy; + } + } + + // No boundary found before clicked position on current row. + // If row doesn't wrap from above, start is at column 0. + // Otherwise, continue checking previous rows. + } + while (it.next()) |p| { const row = p.rowAndCell().row; @@ -2520,13 +2540,18 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { } if (semantic_prompt_state) |v| { - // See semantic_prompt_state comment for why - const current_prompt = row.semantic_prompt.promptOrInput(); - if (current_prompt != v) { - var copy = it_prev; - copy.x = 0; - break :start_pin copy; + // We need to check every cell in this row in reverse + // order since we're going up and back. + const cells = p.node.data.getCells(row); + for (0..cells.len) |x| { + const x_rev = cells.len - 1 - x; + const cell = cells[x_rev]; + if (cell.semantic_content != v) break :start_pin it_prev; + it_prev = p; + it_prev.x = @intCast(x_rev); } + + continue; } it_prev = p; @@ -2544,13 +2569,32 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { const row = p.rowAndCell().row; if (semantic_prompt_state) |v| { - // See semantic_prompt_state comment for why - const current_prompt = row.semantic_prompt.promptOrInput(); - if (current_prompt != v) { + // We need to check every cell in this row + const cells = p.node.data.getCells(row); + + // If this is our pin row we can start from our x because + // the start_pin logic already found the real start. + const start_offset = if (p.node == opts.pin.node and + p.y == opts.pin.y) opts.pin.x else 0; + + // Handle the zero case specially because if the first + // col doesn't match then we end at the end of the prior + // row. But if this is the first row, we can't go back, + // so we scan forward to find where our content ends. + if (start_offset == 0 and cells[0].semantic_content != v) { var prev = p.up(1).?; prev.x = p.node.data.size.cols - 1; break :end_pin prev; } + + // For every other case, we end at the prior cell. + for (start_offset.., cells[start_offset..]) |x, cell| { + if (cell.semantic_content != v) { + var copy = p; + copy.x = @intCast(x - 1); + break :end_pin copy; + } + } } if (!row.wrap) { @@ -3126,6 +3170,12 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { try self.cursorDownOrScroll(); self.cursorHorizontalAbsolute(0); self.cursor.pending_wrap = false; + if (self.cursor.semantic_content_clear_eol) { + self.cursorSetSemanticContent(.output); + } else switch (self.cursor.semantic_content) { + .input, .output => {}, + .prompt => self.cursor.page_row.semantic_prompt2 = .prompt_continuation, + } continue; } @@ -3168,6 +3218,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .content = .{ .codepoint = c }, .style_id = self.cursor.style_id, .protected = self.cursor.protected, + .semantic_content = self.cursor.semantic_content, }; // If we have a ref-counted style, increase. @@ -3189,6 +3240,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .content = .{ .codepoint = 0 }, .wide = .spacer_head, .protected = self.cursor.protected, + .semantic_content = self.cursor.semantic_content, }; // If we have a hyperlink, add it to the cell. @@ -3207,6 +3259,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .style_id = self.cursor.style_id, .wide = .wide, .protected = self.cursor.protected, + .semantic_content = self.cursor.semantic_content, }; // If we have a hyperlink, add it to the cell. @@ -3219,6 +3272,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .content = .{ .codepoint = 0 }, .wide = .spacer_tail, .protected = self.cursor.protected, + .semantic_content = self.cursor.semantic_content, }; // If we have a hyperlink, add it to the cell. @@ -7688,9 +7742,11 @@ test "Screen: selectLine semantic prompt boundary" { var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); - try s.testWriteSemanticString("ABCDE\n", .unknown); - try s.testWriteSemanticString("A ", .prompt); - try s.testWriteSemanticString("> ", .unknown); + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("A "); + s.cursorSetSemanticContent(.output); + try s.testWriteString("> "); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -7705,14 +7761,13 @@ test "Screen: selectLine semantic prompt boundary" { .y = 1, } }).? }).?; defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 1, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 1, - } }, s.pages.pointFromPin(.active, sel.end()).?); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + const expected = "A"; + try testing.expectEqualStrings(expected, contents); } { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ @@ -7731,6 +7786,359 @@ test "Screen: selectLine semantic prompt boundary" { } } +test "Screen: selectLine semantic prompt to input boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Write prompt followed by user input on same row: "$>command" + // Using non-whitespace to avoid whitespace trimming affecting the test + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$>"); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("command"); + + // Selecting from prompt should only select prompt + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + // Selecting from input should only select input + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 5, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 8, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } +} + +test "Screen: selectLine semantic input to output boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Row 0: user input + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("ls -la\n"); + // Row 1: command output + s.cursorSetSemanticContent(.output); + try s.testWriteString("file.txt"); + + // Selecting from input should only select input + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("ls -la", contents); + } + + // Selecting from output should only select output + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("file.txt", contents); + } +} + +test "Screen: selectLine semantic mid-row boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Single row with output then prompt then input: "out$>cmd" + // Using non-whitespace to avoid whitespace trimming affecting the test + s.cursorSetSemanticContent(.output); + try s.testWriteString("out"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$>"); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("cmd"); + + // Selecting from output should stop at prompt + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + // Selecting from prompt should only select prompt + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + // Selecting from input should only select input + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 6, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 5, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } +} + +test "Screen: selectLine semantic boundary soft-wrap with mid-row transition" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Row 0: prompt "$ " + input "cmd" (soft-wraps) + // Row 1: input continues "12" + output "out" + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$ "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("cmd12"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("out"); + + // Verify layout + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("$ cmd\n12out", contents); + } + + // Selecting from input on row 0 should get all input across soft-wrap + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("cmd12", contents); + } + + // Selecting from input on row 1 should get all input across soft-wrap + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("cmd12", contents); + } + + // Selecting from output should only get output + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 3, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("out", contents); + } +} + +test "Screen: selectLine semantic boundary disabled" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Write prompt followed by input + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$ "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("command"); + + // With semantic_prompt_boundary = false, should select entire line + { + var sel = s.selectLine(.{ + .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).?, + .semantic_prompt_boundary = false, + }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("$ command", contents); + } +} + +test "Screen: selectLine semantic boundary first cell of row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Row 0: input that soft-wraps + // Row 1: output starts at first cell + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("12345"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("ABCDE"); + + // Verify soft-wrap happened + { + const pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = pin.rowAndCell().row; + try testing.expect(row.wrap); + } + + // Selecting from input should stop before output on row 1 + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + // Selecting from output should only get output + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 4, + .y = 1, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } +} + +test "Screen: selectLine semantic all same content" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // All prompt content that soft-wraps + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("prompt text"); + + // Verify soft-wrap + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("promp\nt tex\nt", contents); + } + + // Should select all prompt content across soft-wraps + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("prompt text", contents); + } +} + test "Screen: selectWord" { const testing = std.testing; const alloc = testing.allocator; From ed0979cb0c21d3f2b167bdc1c9f2326bf92c2868 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 10:50:03 -0800 Subject: [PATCH 082/108] terminal: selectOutput uses new semantic prompt logic --- src/terminal/Screen.zig | 236 +++++++++++++++++----------------------- 1 file changed, 101 insertions(+), 135 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index b32431db3..7af157493 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2837,100 +2837,60 @@ pub fn selectWord( /// are determined by semantic prompt information provided by shell integration. /// A selection can span multiple physical lines if they are soft-wrapped. /// -/// This will return null if a selection is impossible. The only scenarios -/// this happens is if: +/// This will return null if a selection is impossible: /// - the point pt is outside of the written screen space. /// - the point pt is on a prompt / input line. pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { - _ = self; + // If our pin right now is not on output, then we return nothing. + if (pin.rowAndCell().cell.semantic_content != .output) return null; - switch (pin.rowAndCell().row.semantic_prompt) { - .input, .prompt_continuation, .prompt => { - // Cursor on a prompt line, selection impossible - return null; - }, + // Get the post prior prompt from this pin. This is the prompt whose + // output we'll be capturing. + const prompt_pin: Pin = prompt: { + // If we have a prompt above this point (including this point), + // then thats the prompt we want to capture output from. + var it = pin.promptIterator(.left_up, null); + if (it.next()) |p| break :prompt p; - else => {}, + // If we don't have a prompt, then we assume that we're + // capturing all the output up to the next prompt. + it = pin.promptIterator(.right_down, null); + const next = it.next() orelse return null; + + // We'll capture from the start of the screen to just above + // the prompt and will trim the trailing whitespace. + const start_pin = self.pages.getTopLeft(.screen); + var end_pin = next.up(1) orelse return null; + end_pin.x = end_pin.node.data.size.cols - 1; + var cell_it = end_pin.cellIterator(.left_up, start_pin); + while (cell_it.next()) |p| { + const cell = p.rowAndCell().cell; + end_pin = p; + if (cell.hasText()) break; + } + + return .init( + start_pin, + end_pin, + false, + ); + }; + + // Grab our content + var hl = self.pages.highlightSemanticContent( + prompt_pin, + .output, + ) orelse return null; + + // Trim our trailing whitespace + var cell_it = hl.end.cellIterator(.left_up, hl.start); + while (cell_it.next()) |p| { + const cell = p.rowAndCell().cell; + hl.end = p; + if (cell.hasText()) break; } - // Go forwards to find our end boundary - // We are looking for input start / prompt markers - const end: Pin = boundary: { - var it = pin.rowIterator(.right_down, null); - var it_prev = pin; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - .input, .prompt_continuation, .prompt => { - var copy = it_prev; - copy.x = it_prev.node.data.size.cols - 1; - break :boundary copy; - }, - else => {}, - } - - it_prev = p; - } - - // Find the last non-blank row - it = it_prev.rowIterator(.left_up, null); - while (it.next()) |p| { - const row = p.rowAndCell().row; - const cells = p.node.data.getCells(row); - if (Cell.hasTextAny(cells)) { - var copy = p; - copy.x = p.node.data.size.cols - 1; - break :boundary copy; - } - } - - // In this case it means that all our rows are blank. Let's - // just return no selection, this is a weird case. - return null; - }; - - // Go backwards to find our start boundary - // We are looking for output start markers - const start: Pin = boundary: { - var it = pin.rowIterator(.left_up, null); - var it_prev = pin; - - // First, iterate until we find the first line of command output - while (it.next()) |p| { - it_prev = p; - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - .command => break, - - .unknown, - .prompt, - .prompt_continuation, - .input, - => {}, - } - } - - // Because the first line of command output may span multiple visual rows we must now - // iterate until we find the first row of anything other than command output and then - // yield the previous row. - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - .command => {}, - - .unknown, - .prompt, - .prompt_continuation, - .input, - => break :boundary it_prev, - } - it_prev = p; - } - - break :boundary it_prev; - }; - - return .init(start, end, false); + return .init(hl.start, hl.end, false); } /// Returns the selection bounds for the prompt at the given point. If the @@ -8512,59 +8472,64 @@ test "Screen: selectOutput" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); - // zig fmt: off - { - // line number: - try s.testWriteSemanticString("output1\n", .command); // 0 - try s.testWriteSemanticString("output1\n", .command); // 1 - try s.testWriteSemanticString("prompt2\n", .prompt); // 2 - try s.testWriteSemanticString("input2\n", .input); // 3 - try s.testWriteSemanticString( // - "output2output2output2output2\n", // 4, 5, 6 due to overflow - .command, // - ); // - try s.testWriteSemanticString("output2\n", .command); // 7 - try s.testWriteSemanticString("$ ", .prompt); // 8 prompt - try s.testWriteSemanticString("input3\n", .input); // 8 input - try s.testWriteSemanticString("output3\n", .command); // 9 - try s.testWriteSemanticString("output3\n", .command); // 10 - try s.testWriteSemanticString("output3", .command); // 11 - } - // zig fmt: on + // Build content with cell-level semantic content: + // Row 0-1: output1 (output) + // Row 2: prompt2 (prompt) + // Row 3: input2 (input) + // Row 4-7: output2 (output, with overflow causing wrap) + // Row 8: "$ " (prompt) + "input3" (input) + // Row 9-11: output3 (output) + s.cursorSetSemanticContent(.output); + try s.testWriteString("output1\n"); + try s.testWriteString("output1\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("prompt2\n"); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("input2\n"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("output2output2output2output2\n"); + try s.testWriteString("output2\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$ "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("input3\n"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("output3\n"); + try s.testWriteString("output3\n"); + try s.testWriteString("output3"); - // No start marker, should select from the beginning + // First output block (rows 0-1), should select those rows { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, } }).?).?; defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 9, - .y = 1, - } }, s.pages.pointFromPin(.active, sel.end()).?); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("output1\noutput1", contents); } - // Both start and end markers, should select between them + // Second output block (rows 4-7) { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 3, .y = 7, } }).?).?; defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 4, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 9, - .y = 7, - } }, s.pages.pointFromPin(.active, sel.end()).?); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings( + "output2output2output2output2\noutput2", + contents, + ); } - // No end marker, should select till the end + // Third output block (rows 9-11) { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 2, @@ -8576,21 +8541,22 @@ test "Screen: selectOutput" { .y = 9, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ - .x = 9, + .x = 6, .y = 11, } }, s.pages.pointFromPin(.active, sel.end()).?); } - // input / prompt at y = 0, pt.y = 0 + // Click on prompt should return null { - s.deinit(); - s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); - try s.testWriteSemanticString("$ ", .prompt); - try s.testWriteSemanticString("input1\n", .input); - try s.testWriteSemanticString("output1\n", .command); - try s.testWriteSemanticString("prompt2\n", .prompt); try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ - .x = 2, - .y = 0, + .x = 1, + .y = 8, + } }).?) == null); + } + // Click on input should return null + { + try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ + .x = 5, + .y = 8, } }).?) == null); } } From 047914c7b51da4d0e36c65e570f04f3c56c61c71 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 11:40:38 -0800 Subject: [PATCH 083/108] terminal: promptPath uses new semantic_prompt logic --- src/terminal/Screen.zig | 337 +++++++++------------------------------- 1 file changed, 77 insertions(+), 260 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 7af157493..1a2d6ead5 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2893,87 +2893,6 @@ pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { return .init(hl.start, hl.end, false); } -/// Returns the selection bounds for the prompt at the given point. If the -/// point is not on a prompt line, this returns null. Note that due to -/// the underlying protocol, this will only return the y-coordinates of -/// the prompt. The x-coordinates of the start will always be zero and -/// the x-coordinates of the end will always be the last column. -/// -/// Note that this feature requires shell integration. If shell integration -/// is not enabled, this will always return null. -pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection { - _ = self; - - // Ensure that the line the point is on is a prompt. - const is_known = switch (pin.rowAndCell().row.semantic_prompt) { - .prompt, .prompt_continuation, .input => true, - .command => return null, - - // We allow unknown to continue because not all shells output any - // semantic prompt information for continuation lines. This has the - // possibility of making this function VERY slow (we look at all - // scrollback) so we should try to avoid this in the future by - // setting a flag or something if we have EVER seen a semantic - // prompt sequence. - .unknown => false, - }; - - // Find the start of the prompt. - var saw_semantic_prompt = is_known; - const start: Pin = start: { - var it = pin.rowIterator(.left_up, null); - var it_prev = it.next().?; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - // A prompt, we continue searching. - .prompt, .prompt_continuation, .input => saw_semantic_prompt = true, - - // See comment about "unknown" a few lines above. If we have - // previously seen a semantic prompt then if we see an unknown - // we treat it as a boundary. - .unknown => if (saw_semantic_prompt) break :start it_prev, - - // Command output or unknown, definitely not a prompt. - .command => break :start it_prev, - } - - it_prev = p; - } - - break :start it_prev; - }; - - // If we never saw a semantic prompt flag, then we can't trust our - // start value and we return null. This scenario usually means that - // semantic prompts aren't enabled via the shell. - if (!saw_semantic_prompt) return null; - - // Find the end of the prompt. - const end: Pin = end: { - var it = pin.rowIterator(.right_down, null); - var it_prev = it.next().?; - it_prev.x = it_prev.node.data.size.cols - 1; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - // A prompt, we continue searching. - .prompt, .prompt_continuation, .input => {}, - - // Command output or unknown, definitely not a prompt. - .command, .unknown => break :end it_prev, - } - - it_prev = p; - it_prev.x = it_prev.node.data.size.cols - 1; - } - - break :end it_prev; - }; - - return .init(start, end, false); -} - pub const LineIterator = struct { screen: *const Screen, current: ?Pin = null, @@ -3017,8 +2936,16 @@ pub fn promptPath( x: isize, y: isize, } { + // Verify "from" is on a prompt row before calling highlightSemanticContent. + // highlightSemanticContent asserts the starting point is a prompt. + switch (from.rowAndCell().row.semantic_prompt2) { + .prompt, .prompt_continuation => {}, + .no_prompt => return .{ .x = 0, .y = 0 }, + } + // Get our prompt bounds assuming "from" is at a prompt. - const bounds = self.selectPrompt(from) orelse return .{ .x = 0, .y = 0 }; + const hl = self.pages.highlightSemanticContent(from, .prompt) orelse return .{ .x = 0, .y = 0 }; + const bounds: Selection = .init(hl.start, hl.end, false); // Get our actual "to" point clamped to the bounds of the prompt. const to_clamped = if (bounds.contains(self, to)) @@ -8561,169 +8488,6 @@ test "Screen: selectOutput" { } } -test "Screen: selectPrompt basics" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); - defer s.deinit(); - - // zig fmt: off - { - // line number: - try s.testWriteSemanticString("output1\n", .command); // 0 - try s.testWriteSemanticString("output1\n", .command); // 1 - try s.testWriteSemanticString("prompt2\n", .prompt); // 2 - try s.testWriteSemanticString("input2\n", .input); // 3 - try s.testWriteSemanticString("output2\n", .command); // 4 - try s.testWriteSemanticString("output2\n", .command); // 5 - try s.testWriteSemanticString("$ ", .prompt); // 6 prompt - try s.testWriteSemanticString("input3\n", .input); // 6 input - try s.testWriteSemanticString("output3\n", .command); // 7 - try s.testWriteSemanticString("output3\n", .command); // 8 - try s.testWriteSemanticString("output3", .command); // 9 - } - // zig fmt: on - - // Not at a prompt - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 1, - } }).?); - try testing.expect(sel == null); - } - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 8, - } }).?); - try testing.expect(sel == null); - } - - // Single line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 6, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 6, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 6, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Multi line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 3, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectPrompt prompt at start" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); - defer s.deinit(); - - // zig fmt: off - { - // line number: - try s.testWriteSemanticString("prompt1\n", .prompt); // 0 - try s.testWriteSemanticString("input1\n", .input); // 1 - try s.testWriteSemanticString("output2\n", .command); // 2 - try s.testWriteSemanticString("output2\n", .command); // 3 - } - // zig fmt: on - - // Not at a prompt - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 3, - } }).?); - try testing.expect(sel == null); - } - - // Multi line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 1, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectPrompt prompt at end" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); - defer s.deinit(); - - // zig fmt: off - { - // line number: - try s.testWriteSemanticString("output2\n", .command); // 0 - try s.testWriteSemanticString("output2\n", .command); // 1 - try s.testWriteSemanticString("prompt1\n", .prompt); // 2 - try s.testWriteSemanticString("input1\n", .input); // 3 - } - // zig fmt: on - - // Not at a prompt - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 1, - } }).?); - try testing.expect(sel == null); - } - - // Multi line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 2, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - test "Screen: promptPath" { const testing = std.testing; const alloc = testing.allocator; @@ -8731,22 +8495,74 @@ test "Screen: promptPath" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); - // zig fmt: off + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const page = &s.pages.pages.first.?.data; + + // Set up: + // Row 0-1: output + // Row 2: prompt + // Row 3: input + // Row 4-5: output + // Row 6: prompt + input + // Row 7-9: output + + // Row 2: prompt (with prompt cells) and input { - // line number: - try s.testWriteSemanticString("output1\n", .command); // 0 - try s.testWriteSemanticString("output1\n", .command); // 1 - try s.testWriteSemanticString("prompt2\n", .prompt); // 2 - try s.testWriteSemanticString("input2\n", .input); // 3 - try s.testWriteSemanticString("output2\n", .command); // 4 - try s.testWriteSemanticString("output2\n", .command); // 5 - try s.testWriteSemanticString("$ ", .prompt); // 6 prompt - try s.testWriteSemanticString("input3\n", .input); // 6 input - try s.testWriteSemanticString("output3\n", .command); // 7 - try s.testWriteSemanticString("output3\n", .command); // 8 - try s.testWriteSemanticString("output3", .command); // 9 + const rac = page.getRowAndCell(0, 2); + rac.row.semantic_prompt2 = .prompt; + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 2).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'P' }, + .semantic_content = .prompt, + }; + } + // Next cols are input + for (3..10) |x| { + const cell = page.getRowAndCell(x, 2).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'I' }, + .semantic_content = .input, + }; + } + } + // Row 3: continuation line with input cells (same prompt block) + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt2 = .prompt_continuation; + for (0..6) |x| { + const cell = page.getRowAndCell(x, 3).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'I' }, + .semantic_content = .input, + }; + } + } + // Row 6: next prompt + input on same line + { + const rac = page.getRowAndCell(0, 6); + rac.row.semantic_prompt2 = .prompt; + for (0..2) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + for (2..8) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'i' }, + .semantic_content = .input, + }; + } } - // zig fmt: on // From is not in the prompt { @@ -8789,12 +8605,13 @@ test "Screen: promptPath" { } // To is out of bounds after + // Prompt ends at (5, 3) since that's the last input cell { const path = s.promptPath( s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, s.pages.pin(.{ .active = .{ .x = 3, .y = 9 } }).?, ); - try testing.expectEqual(@as(isize, 3), path.x); + try testing.expectEqual(@as(isize, -1), path.x); try testing.expectEqual(@as(isize, 1), path.y); } } From 3aaafa2ddabc8f2812e03b88bd43ce46c22265e2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 11:48:15 -0800 Subject: [PATCH 084/108] terminal: Screen testWriteString should set prompt_continuation for soft --- src/terminal/Screen.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 1a2d6ead5..c6b1a15b5 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -3095,6 +3095,10 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { try self.cursorDownOrScroll(); self.cursorHorizontalAbsolute(0); self.cursor.page_row.wrap_continuation = true; + switch (self.cursor.semantic_content) { + .input, .output => {}, + .prompt => self.cursor.page_row.semantic_prompt2 = .prompt_continuation, + } } assert(width == 1 or width == 2); From 0f05c2b71a7049fcd1ae5e5af01624b3602ec20f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 12:08:47 -0800 Subject: [PATCH 085/108] terminal: fix resize test to use new semantic prompt logic --- src/terminal/Screen.zig | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index c6b1a15b5..4f12f4b94 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6187,9 +6187,12 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { defer s.deinit(); // Set one of the rows to be a prompt - try s.testWriteSemanticString("1ABCD\n", .unknown); - try s.testWriteSemanticString("2EFGH\n", .prompt); - try s.testWriteSemanticString("3IJKL", .unknown); + s.cursorSetSemanticContent(.output); + try s.testWriteString("1ABCD\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("2EFGH"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("\n3IJKL"); try s.resize(.{ .cols = 10, .rows = 3, .reflow = false }); @@ -6208,15 +6211,15 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { // Our one row should still be a semantic prompt, the others should not. { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .unknown); + try testing.expect(list_cell.row.semantic_prompt2 == .no_prompt); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .prompt); + try testing.expect(list_cell.row.semantic_prompt2 == .prompt); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .unknown); + try testing.expect(list_cell.row.semantic_prompt2 == .no_prompt); } } From 112db8211debca11b3455bdf37be5592c768c2dd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 12:42:23 -0800 Subject: [PATCH 086/108] terminal: remove clearPrompt and integrate it into resize --- src/terminal/Screen.zig | 278 ++++++++++++++++---------------------- src/terminal/Terminal.zig | 18 +-- 2 files changed, 125 insertions(+), 171 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 4f12f4b94..76cf434ce 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1461,61 +1461,6 @@ pub fn clearUnprotectedCells( self.assertIntegrity(); } -/// Clears the prompt lines if the cursor is currently at a prompt. This -/// clears the entire line. This is used for resizing when the shell -/// handles reflow. -/// -/// The cleared cells are not colored with the current style background -/// color like other clear functions, because this is a special case used -/// for a specific purpose that does not want that behavior. -pub fn clearPrompt(self: *Screen) void { - var found: ?Pin = null; - - // From our cursor, move up and find all prompt lines. - var it = self.cursor.page_pin.rowIterator( - .left_up, - self.pages.pin(.{ .active = .{} }), - ); - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - // We are at a prompt but we're not at the start of the prompt. - // We mark our found value and continue because the prompt - // may be multi-line, unless this is the second time we've - // seen an .input marker, in which case we've run into an - // earlier prompt. - .input => { - if (found != null) break; - found = p; - }, - - // If we find the prompt then we're done. We are also done - // if we find any prompt continuation, because the shells - // that send this currently (zsh) cannot redraw every line. - .prompt, .prompt_continuation => { - found = p; - break; - }, - - // If we have command output, then we're most certainly not - // at a prompt. Break out of the loop. - .command => break, - - // If we don't know, we keep searching. - .unknown => {}, - } - } - - // If we found a prompt, we clear it. - if (found) |top| { - var clear_it = top.rowIterator(.right_down, null); - while (clear_it.next()) |p| { - const row = p.rowAndCell().row; - p.node.data.clearCells(row, 0, p.node.data.size.cols); - } - } -} - /// Clean up boundary conditions where a cell will become discontiguous with /// a neighboring cell because either one of them will be moved and/or cleared. /// @@ -1662,6 +1607,12 @@ pub const Resize = struct { /// smaller and the maximum scrollback size is exceeded, data will be /// lost from the top of the scrollback. reflow: bool = true, + + /// Set this to true to enable prompt redraw on resize. This signals + /// that the running program can redraw the prompt if the cursor is + /// currently at a prompt. This detects OSC133 prompts lines and clears + /// them. + prompt_redraw: bool = false, }; /// Resize the screen. The rows or cols can be bigger or smaller. @@ -1729,6 +1680,37 @@ pub inline fn resize( }; defer if (saved_cursor_pin) |p| self.pages.untrackPin(p); + // If our cursor is on a prompt line, then we clear the prompt so + // the shell can redraw it. This works with OSC133 semantic prompts. + if (opts.prompt_redraw and + self.cursor.page_row.semantic_prompt2 != .no_prompt) + prompt: { + const start = start: { + var it = self.cursor.page_pin.promptIterator( + .left_up, + null, + ); + break :start it.next() orelse { + // This should never happen because promptIterator should always + // find a prompt if we already verified our row is some kind of + // prompt. + log.warn("cursor on prompt line but promptIterator found no prompt", .{}); + break :prompt; + }; + }; + + // Clear cells from our start down. We replace it with spaces, + // and do not physically erase the rows (eraseRows) because the + // shell is going to expect this space to be available. + var it = start.rowIterator(.right_down, null); + while (it.next()) |pin| { + const page = &pin.node.data; + const row = pin.rowAndCell().row; + const cells = page.getCells(row); + self.clearCells(page, row, cells); + } + } + // Perform the resize operation. try self.pages.resize(.{ .rows = opts.rows, @@ -3189,29 +3171,6 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { } } -/// Write text that's marked as a semantic prompt. -fn testWriteSemanticString(self: *Screen, text: []const u8, semantic_prompt: Row.SemanticPrompt) !void { - // Determine the first row using the cursor position. If we know that our - // first write is going to start on the next line because of a pending - // wrap, we'll proactively start there. - const start_y = if (self.cursor.pending_wrap) self.cursor.y + 1 else self.cursor.y; - - try self.testWriteString(text); - - // Determine the last row that we actually wrote by inspecting the cursor's - // position. If we're in the first column, we haven't actually written any - // characters to it, so we end at the preceding row instead. - const end_y = if (self.cursor.x > 0) self.cursor.y else self.cursor.y - 1; - - // Mark the full range of written rows with our semantic prompt. - var y = start_y; - while (y <= end_y) { - const pin = self.pages.pin(.{ .active = .{ .y = y } }).?; - pin.rowAndCell().row.semantic_prompt = semantic_prompt; - y += 1; - } -} - test "Screen read and write" { const testing = std.testing; const alloc = testing.allocator; @@ -3824,88 +3783,6 @@ test "Screen eraseRows active partial" { } } -test "Screen: clearPrompt single line prompt" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer s.deinit(); - - // Set one of the rows to be a prompt - try s.testWriteSemanticString("1ABCD\n", .unknown); - try s.testWriteSemanticString("2EFGH\n", .prompt); - try s.testWriteSemanticString("3IJKL", .input); - - s.clearPrompt(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD", contents); - } -} - -test "Screen: clearPrompt continuation" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 0 }); - defer s.deinit(); - - // Set one of the rows to be a prompt followed by a continuation row - try s.testWriteSemanticString("1ABCD\n", .unknown); - try s.testWriteSemanticString("2EFGH\n", .prompt); - try s.testWriteSemanticString("3IJKL\n", .prompt_continuation); - try s.testWriteSemanticString("4MNOP", .input); - - s.clearPrompt(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); - } -} - -test "Screen: clearPrompt consecutive inputs" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer s.deinit(); - - // Set both rows to be inputs - try s.testWriteSemanticString("1ABCD\n", .unknown); - try s.testWriteSemanticString("2EFGH\n", .input); - try s.testWriteSemanticString("3IJKL", .input); - - s.clearPrompt(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); - } -} - -test "Screen: clearPrompt no prompt" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - - s.clearPrompt(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - test "Screen: cursorDown across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; @@ -7289,6 +7166,87 @@ test "Screen: resize more cols requiring a wide spacer head" { } } +test "Screen: resize more cols with cursor at prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); + defer s.deinit(); + + // zig fmt: off + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_eol }); + try s.testWriteString("echo"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> echo"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(.{ + .cols = 20, + .rows = 3, + .prompt_redraw = true, + }); + + // Cursor should not move + try testing.expectEqual(6, s.cursor.x); + try testing.expectEqual(1, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize more cols with cursor not at prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); + defer s.deinit(); + + // zig fmt: off + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_eol }); + try s.testWriteString("echo\n"); + try s.testWriteString("output"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> echo\noutput"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(.{ + .cols = 20, + .rows = 3, + .prompt_redraw = true, + }); + + // Cursor should not move + try testing.expectEqual(6, s.cursor.x); + try testing.expectEqual(2, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> echo\noutput"; + try testing.expectEqualStrings(expected, contents); + } +} + test "Screen: select untracked" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 83e54482b..4498fb798 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -80,11 +80,10 @@ mouse_shape: mouse_shape_pkg.MouseShape = .text, /// These are just a packed set of flags we may set on the terminal. flags: packed struct { - // This isn't a mode, this is set by OSC 133 using the "A" event. - // If this is true, it tells us that the shell supports redrawing - // the prompt and that when we resize, if the cursor is at a prompt, - // then we should clear the screen below and allow the shell to redraw. - shell_redraws_prompt: bool = false, + // This supports a Kitty extension where programs using semantic + // prompts (OSC133) can annotate their new prompts with `redraw=0` to + // disable clearing the prompt on resize. + shell_redraws_prompt: bool = true, // This is set via ESC[4;2m. Any other modify key mode just sets // this to false and we act in mode 1 by default. @@ -1082,7 +1081,8 @@ pub fn semanticPrompt( }); // This is a kitty-specific flag that notes that the shell - // is capable of redraw. + // is NOT capable of redraw. Redraw defaults to true so this + // usually just disables it, but either is possible. if (cmd.readOption(.redraw)) |v| { self.flags.shell_redraws_prompt = v; } @@ -2680,15 +2680,11 @@ pub fn resize( // Resize primary screen, which supports reflow const primary = self.screens.get(.primary).?; - if (self.screens.active_key == .primary and - self.flags.shell_redraws_prompt) - { - primary.clearPrompt(); - } try primary.resize(.{ .cols = cols, .rows = rows, .reflow = self.modes.get(.wraparound), + .prompt_redraw = self.flags.shell_redraws_prompt, }); // Alternate screen, if it exists, doesn't reflow From 917a42876ea44dd2b7ba69c4b0b328a0bf1bf015 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 13:02:58 -0800 Subject: [PATCH 087/108] terminal: cursorIsAtPrompt uses new APIs --- src/terminal/Terminal.zig | 50 +++++++++++++-------------------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 4498fb798..d33729be9 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1198,29 +1198,15 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool { // If we're on the secondary screen, we're never at a prompt. if (self.screens.active_key == .alternate) return false; - // Reverse through the active - const start_x, const start_y = .{ self.screens.active.cursor.x, self.screens.active.cursor.y }; - defer self.screens.active.cursorAbsolute(start_x, start_y); + // If our page row is a prompt then we're always at a prompt + const cursor: *const Screen.Cursor = &self.screens.active.cursor; + if (cursor.page_row.semantic_prompt2 != .no_prompt) return true; - for (0..start_y + 1) |i| { - if (i > 0) self.screens.active.cursorUp(1); - switch (self.screens.active.cursor.page_row.semantic_prompt) { - // If we're at a prompt or input area, then we are at a prompt. - .prompt, - .prompt_continuation, - .input, - => return true, - - // If we have command output, then we're most certainly not - // at a prompt. - .command => return false, - - // If we don't know, we keep searching. - .unknown => {}, - } - } - - return false; + // Otherwise, determine our cursor state + return switch (cursor.semantic_content) { + .input, .prompt => true, + .output => false, + }; } /// Horizontal tab moves the cursor to the next tabstop, clearing @@ -11397,27 +11383,23 @@ test "Terminal: cursorIsAtPrompt" { defer t.deinit(alloc); try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); + try t.semanticPrompt(.init(.prompt_start)); try testing.expect(t.cursorIsAtPrompt()); // Input is also a prompt - t.markSemanticPrompt(.input); - try testing.expect(t.cursorIsAtPrompt()); - - // Newline -- we expect we're still at a prompt if we received - // prompt stuff before. - try t.linefeed(); + try t.semanticPrompt(.init(.end_prompt_start_input)); try testing.expect(t.cursorIsAtPrompt()); // But once we say we're starting output, we're not a prompt - t.markSemanticPrompt(.command); - try testing.expect(!t.cursorIsAtPrompt()); + try t.semanticPrompt(.init(.end_input_start_output)); + // Still a prompt because this line has a prompt + try testing.expect(t.cursorIsAtPrompt()); try t.linefeed(); try testing.expect(!t.cursorIsAtPrompt()); // Until we know we're at a prompt again try t.linefeed(); - t.markSemanticPrompt(.prompt); + try t.semanticPrompt(.init(.prompt_start)); try testing.expect(t.cursorIsAtPrompt()); } @@ -11427,13 +11409,13 @@ test "Terminal: cursorIsAtPrompt alternate screen" { defer t.deinit(alloc); try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); + try t.semanticPrompt(.init(.prompt_start)); try testing.expect(t.cursorIsAtPrompt()); // Secondary screen is never a prompt try t.switchScreenMode(.@"1049", true); try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); + try t.semanticPrompt(.init(.prompt_start)); try testing.expect(!t.cursorIsAtPrompt()); } From 10bc88766bf541434980e4122d3f35d9ac1a2581 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 13:13:19 -0800 Subject: [PATCH 088/108] terminal: soft wrap preserves new semantic prompt state --- src/terminal/Terminal.zig | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d33729be9..a40022c33 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -754,22 +754,35 @@ fn printWrap(self: *Terminal) !void { // We only mark that we soft-wrapped if we're at the edge of our // full screen. We don't mark the row as wrapped if we're in the // middle due to a right margin. - const mark_wrap = self.screens.active.cursor.x == self.cols - 1; - if (mark_wrap) self.screens.active.cursor.page_row.wrap = true; + const cursor: *Screen.Cursor = &self.screens.active.cursor; + const mark_wrap = cursor.x == self.cols - 1; + if (mark_wrap) cursor.page_row.wrap = true; // Get the old semantic prompt so we can extend it to the next // line. We need to do this before we index() because we may // modify memory. - const old_prompt = self.screens.active.cursor.page_row.semantic_prompt; + const old_semantic = cursor.semantic_content; + const old_semantic_clear = cursor.semantic_content_clear_eol; // Move to the next line try self.index(); self.screens.active.cursorHorizontalAbsolute(self.scrolling_region.left); + // Our pointer should never move + assert(cursor == &self.screens.active.cursor); + + // We always reset our semantic prompt state + cursor.semantic_content = old_semantic; + cursor.semantic_content_clear_eol = old_semantic_clear; + switch (old_semantic) { + .output, .input => {}, + .prompt => cursor.page_row.semantic_prompt2 = .prompt_continuation, + } + if (mark_wrap) { - // New line must inherit semantic prompt of the old line - self.screens.active.cursor.page_row.semantic_prompt = old_prompt; - self.screens.active.cursor.page_row.wrap_continuation = true; + const row = self.screens.active.cursor.page_row; + // Always mark the row as a continuation + row.wrap_continuation = true; } // Assure that our screen is consistent @@ -4323,19 +4336,20 @@ test "Terminal: soft wrap with semantic prompt" { var t = try init(testing.allocator, .{ .cols = 3, .rows = 80 }); defer t.deinit(testing.allocator); - // Mark our prompt. Should not make anything dirty on its own. - t.markSemanticPrompt(.prompt); + // Mark our prompt. + try t.semanticPrompt(.init(.prompt_start)); + // Should not make anything dirty on its own. try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + // Write and wrap for ("hello") |c| try t.print(c); - { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt2); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; - try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt2); } } From 1b2376d3662caf36d350913ce9d0bdaac9f0a772 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 13:22:12 -0800 Subject: [PATCH 089/108] terminal: remove last semantic_prompt usage from Terminal --- src/terminal/Terminal.zig | 21 ++--------------- src/terminal/stream_readonly.zig | 40 +------------------------------- src/termio/Termio.zig | 5 ++-- src/termio/stream_handler.zig | 25 ++++---------------- 4 files changed, 10 insertions(+), 81 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a40022c33..c9eb6a912 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1189,19 +1189,6 @@ pub const SemanticPrompt = enum { command, }; -/// Mark the current semantic prompt information. Current escape sequences -/// (OSC 133) only allow setting this for wherever the current active cursor -/// is located. -pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { - //log.debug("semantic_prompt y={} p={}", .{ self.screens.active.cursor.y, p }); - self.screens.active.cursor.page_row.semantic_prompt = switch (p) { - .prompt => .prompt, - .prompt_continuation => .prompt_continuation, - .input => .input, - .command => .command, - }; -} - /// Returns true if the cursor is currently at a prompt. Another way to look /// at this is it returns false if the shell is currently outputting something. /// This requires shell integration (semantic prompt integration). @@ -2360,19 +2347,15 @@ pub fn eraseDisplay( ); while (it.next()) |p| { const row = p.rowAndCell().row; - switch (row.semantic_prompt) { + switch (row.semantic_prompt2) { // If we're at a prompt or input area, then we are at a prompt. .prompt, .prompt_continuation, - .input, => break, // If we have command output, then we're most certainly not // at a prompt. - .command => break :at_prompt, - - // If we don't know, we keep searching. - .unknown => {}, + .no_prompt => break :at_prompt, } } else break :at_prompt; diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 87b0d9788..91532c9d5 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -153,7 +153,7 @@ pub const Handler = struct { .full_reset => self.terminal.fullReset(), .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), .end_hyperlink => self.terminal.screens.active.endHyperlink(), - .semantic_prompt => try self.semanticPrompt(value), + .semantic_prompt => try self.terminal.semanticPrompt(value), .mouse_shape => self.terminal.mouse_shape = value, .color_operation => try self.colorOperation(value.op, &value.requests), .kitty_color_report => try self.kittyColorOperation(value), @@ -209,44 +209,6 @@ pub const Handler = struct { } } - fn semanticPrompt( - self: *Handler, - cmd: Action.SemanticPrompt, - ) !void { - switch (cmd.action) { - .fresh_line_new_prompt => { - const kind = cmd.readOption(.prompt_kind) orelse .initial; - switch (kind) { - .initial, .right => { - self.terminal.markSemanticPrompt(.prompt); - if (cmd.readOption(.redraw)) |redraw| { - self.terminal.flags.shell_redraws_prompt = redraw; - } - }, - .continuation, .secondary => { - self.terminal.markSemanticPrompt(.prompt_continuation); - }, - } - }, - - .end_prompt_start_input => self.terminal.markSemanticPrompt(.input), - .end_input_start_output => self.terminal.markSemanticPrompt(.command), - .end_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, - - // All of these commands weren't previously handled by our - // semantic prompt code. I am PR-ing the parser separate from the - // handling so we just ignore these like we did before, even - // though we should handle them eventually. - .end_prompt_start_input_terminate_eol, - .new_command, - .fresh_line, - .prompt_start, - => {}, - } - - try self.terminal.semanticPrompt(cmd); - } - fn setMode(self: *Handler, mode: modes.Mode, enabled: bool) !void { // Set the mode on the terminal self.terminal.modes.set(mode, enabled); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index f46e2ec05..89ea7407b 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -610,8 +610,9 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { // send a FF (0x0C) to the shell so that it can repaint the screen. // Mark the current row as a not a prompt so we can properly // clear the full screen in the next eraseDisplay call. - self.terminal.markSemanticPrompt(.command); - assert(!self.terminal.cursorIsAtPrompt()); + // TODO: fix this + // self.terminal.markSemanticPrompt(.command); + // assert(!self.terminal.cursorIsAtPrompt()); self.terminal.eraseDisplay(.complete, false); } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index b725649f1..bc3edd185 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1071,26 +1071,10 @@ pub const StreamHandler = struct { cmd: Stream.Action.SemanticPrompt, ) !void { switch (cmd.action) { - .fresh_line_new_prompt => { - const kind = cmd.readOption(.prompt_kind) orelse .initial; - switch (kind) { - .initial, .right => { - self.terminal.markSemanticPrompt(.prompt); - if (cmd.readOption(.redraw)) |redraw| { - self.terminal.flags.shell_redraws_prompt = redraw; - } - }, - .continuation, .secondary => { - self.terminal.markSemanticPrompt(.prompt_continuation); - }, - } - }, - - .end_prompt_start_input => self.terminal.markSemanticPrompt(.input), .end_input_start_output => { - self.terminal.markSemanticPrompt(.command); self.surfaceMessageWriter(.start_command); }, + .end_command => { // The specification seems to not specify the type but // other terminals accept 32-bits, but exit codes are really @@ -1103,12 +1087,11 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .stop_command = code }); }, - // All of these commands weren't previously handled by our - // semantic prompt code. I am PR-ing the parser separate from the - // handling so we just ignore these like we did before, even - // though we should handle them eventually. + // Handled by Terminal, no special handling by us + .end_prompt_start_input, .end_prompt_start_input_terminate_eol, .fresh_line, + .fresh_line_new_prompt, .new_command, .prompt_start, => {}, From 5f77b0ed98658c279e0fa37e8a500e7663afd9ce Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 13:26:33 -0800 Subject: [PATCH 090/108] terminal: remove old semantic_prompt --- src/renderer/row.zig | 6 +++--- src/terminal/page.zig | 31 ++----------------------------- 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/src/renderer/row.zig b/src/renderer/row.zig index 933bb338b..38b8540f9 100644 --- a/src/renderer/row.zig +++ b/src/renderer/row.zig @@ -15,9 +15,9 @@ pub fn neverExtendBg( // Any semantic prompts should not have their background extended // because prompts often contain special formatting (such as // powerline) that looks bad when extended. - switch (row.semantic_prompt) { - .prompt, .prompt_continuation, .input => return true, - .unknown, .command => {}, + switch (row.semantic_prompt2) { + .prompt, .prompt_continuation => return true, + .no_prompt => {}, } for (0.., cells) |x, *cell| { diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 3747a8e6a..31879aaf4 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1898,10 +1898,6 @@ pub const Row = packed struct(u64) { /// false negatives. This is used to optimize hyperlink operations. hyperlink: bool = false, - /// The semantic prompt type for this row as specified by the - /// running program, or "unknown" if it was never set. - semantic_prompt: SemanticPrompt = .unknown, - /// The semantic prompt state for this row. /// /// This is ONLY meant to note if there are ANY cells in this @@ -1933,9 +1929,9 @@ pub const Row = packed struct(u64) { /// screen. dirty: bool = false, - _padding: u20 = 0, + _padding: u23 = 0, - /// The semantic prompt state of the row. See `semantic_prompt`. + /// The semantic prompt state of the row. See `semantic_prompt2`. pub const SemanticPrompt2 = enum(u2) { /// No prompt cells in this row. no_prompt = 0, @@ -1946,29 +1942,6 @@ pub const Row = packed struct(u64) { prompt_continuation = 2, }; - /// Semantic prompt type. - pub const SemanticPrompt = enum(u3) { - /// Unknown, the running application didn't tell us for this line. - unknown = 0, - - /// This is a prompt line, meaning it only contains the shell prompt. - /// For poorly behaving shells, this may also be the input. - prompt = 1, - prompt_continuation = 2, - - /// This line contains the input area. We don't currently track - /// where this actually is in the line, so we just assume it is somewhere. - input = 3, - - /// This line is the start of command output. - command = 4, - - /// True if this is a prompt or input line. - pub fn promptOrInput(self: SemanticPrompt) bool { - return self == .prompt or self == .prompt_continuation or self == .input; - } - }; - /// Returns true if this row has any managed memory outside of the /// row structure (graphemes, styles, etc.) pub inline fn managedMemory(self: Row) bool { From c3e15a5cb6e5b0c7feb3f142f373fecb2f7c37e4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 13:27:44 -0800 Subject: [PATCH 091/108] terminal: rename semantic prompt --- src/renderer/row.zig | 2 +- src/terminal/PageList.zig | 142 +++++++++++++++++++------------------- src/terminal/Screen.zig | 22 +++--- src/terminal/Terminal.zig | 18 ++--- src/terminal/page.zig | 6 +- 5 files changed, 95 insertions(+), 95 deletions(-) diff --git a/src/renderer/row.zig b/src/renderer/row.zig index 38b8540f9..0f59359dc 100644 --- a/src/renderer/row.zig +++ b/src/renderer/row.zig @@ -15,7 +15,7 @@ pub fn neverExtendBg( // Any semantic prompts should not have their background extended // because prompts often contain special formatting (such as // powerline) that looks bad when extended. - switch (row.semantic_prompt2) { + switch (row.semantic_prompt) { .prompt, .prompt_continuation => return true, .no_prompt => {}, } diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index c7ff0fc8d..afd6eedf2 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1229,7 +1229,7 @@ const ReflowCursor = struct { // If the row has a semantic prompt then the blank row is meaningful // so we just consider pretend the first cell of the row isn't empty. - if (cols_len == 0 and src_row.semantic_prompt2 != .no_prompt) cols_len = 1; + if (cols_len == 0 and src_row.semantic_prompt != .no_prompt) cols_len = 1; } // Handle tracked pin adjustments. @@ -1973,13 +1973,13 @@ const ReflowCursor = struct { // If the row has a semantic prompt then the blank row is meaningful // so we always return all but one so that the row is drawn. - if (self.page_row.semantic_prompt2 != .no_prompt) return len - 1; + if (self.page_row.semantic_prompt != .no_prompt) return len - 1; return len; } fn copyRowMetadata(self: *ReflowCursor, other: *const Row) void { - self.page_row.semantic_prompt2 = other.semantic_prompt2; + self.page_row.semantic_prompt = other.semantic_prompt; } }; @@ -4403,7 +4403,7 @@ pub const PromptIterator = struct { const at_limit = if (self.limit) |limit| limit.eql(p) else false; const rac = p.rowAndCell(); - switch (rac.row.semantic_prompt2) { + switch (rac.row.semantic_prompt) { // This row isn't a prompt. Keep looking. .no_prompt => if (at_limit) break, @@ -4422,7 +4422,7 @@ pub const PromptIterator = struct { // up to our limit. var end_pin = p; while (end_pin.down(1)) |next_pin| : (end_pin = next_pin) { - switch (next_pin.rowAndCell().row.semantic_prompt2) { + switch (next_pin.rowAndCell().row.semantic_prompt) { .prompt_continuation => if (self.limit) |limit| { if (limit.eql(next_pin)) break; }, @@ -4456,7 +4456,7 @@ pub const PromptIterator = struct { const at_limit = if (self.limit) |limit| limit.eql(p) else false; const rac = p.rowAndCell(); - switch (rac.row.semantic_prompt2) { + switch (rac.row.semantic_prompt) { // This row isn't a prompt. Keep looking. .no_prompt => if (at_limit) break, @@ -4483,7 +4483,7 @@ pub const PromptIterator = struct { if (limit.eql(prior)) break; } - switch (prior.rowAndCell().row.semantic_prompt2) { + switch (prior.rowAndCell().row.semantic_prompt) { // No prompt. That means our last pin is good! .no_prompt => { self.current = prior; @@ -6771,11 +6771,11 @@ test "PageList: jump zero prompts" { const page = &s.pages.first.?.data; { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } s.scroll(.{ .delta_prompt = 0 }); @@ -6799,11 +6799,11 @@ test "Screen: jump back one prompt" { const page = &s.pages.first.?.data; { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Jump back @@ -7906,25 +7906,25 @@ test "PageList promptIterator left_up" { // Normal prompt { const rac = page.getRowAndCell(0, 3); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Continuation { const rac = page.getRowAndCell(0, 6); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } { const rac = page.getRowAndCell(0, 7); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } { const rac = page.getRowAndCell(0, 8); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } // Broken continuation that has non-prompts in between { const rac = page.getRowAndCell(0, 12); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } var it = s.promptIterator(.left_up, .{ .screen = .{} }, null); @@ -7963,25 +7963,25 @@ test "PageList promptIterator right_down" { // Normal prompt { const rac = page.getRowAndCell(0, 3); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Continuation (prompt on row 6, continuation on rows 7-8) { const rac = page.getRowAndCell(0, 6); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } { const rac = page.getRowAndCell(0, 7); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } { const rac = page.getRowAndCell(0, 8); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } // Broken continuation that has non-prompts in between (orphaned continuation at row 12) { const rac = page.getRowAndCell(0, 12); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } var it = s.promptIterator(.right_down, .{ .screen = .{} }, null); @@ -8021,16 +8021,16 @@ test "PageList promptIterator right_down continuation at start" { // Prompt continuation at row 0 (no prior rows - simulates trimmed scrollback) { const rac = page.getRowAndCell(0, 0); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } // Normal prompt later { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } var it = s.promptIterator(.right_down, .{ .screen = .{} }, null); @@ -8065,15 +8065,15 @@ test "PageList promptIterator right_down with prompt before continuation" { // Starting iteration from row 3 should still find the prompt at row 2 { const rac = page.getRowAndCell(0, 2); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } { const rac = page.getRowAndCell(0, 3); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } { const rac = page.getRowAndCell(0, 4); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } // Start iteration from row 3 (middle of the continuation) @@ -8103,12 +8103,12 @@ test "PageList promptIterator right_down limit inclusive" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Iterate with limit at row 5 (the prompt row) - should include it @@ -8135,12 +8135,12 @@ test "PageList promptIterator left_up limit inclusive" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Iterate with limit at row 10 (the prompt row) - should include it @@ -8168,7 +8168,7 @@ test "PageList highlightSemanticContent prompt" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // Start the prompt for the first 5 cols for (0..5) |x| { @@ -8193,7 +8193,7 @@ test "PageList highlightSemanticContent prompt" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } const hl = s.highlightSemanticContent( @@ -8222,7 +8222,7 @@ test "PageList highlightSemanticContent prompt with output" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 3 cols are prompt for (0..3) |x| { @@ -8257,7 +8257,7 @@ test "PageList highlightSemanticContent prompt with output" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting from prompt should include prompt and input, but stop at output @@ -8287,7 +8287,7 @@ test "PageList highlightSemanticContent prompt multiline" { // Prompt starts on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First row is all prompt for (0..10) |x| { @@ -8313,7 +8313,7 @@ test "PageList highlightSemanticContent prompt multiline" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting should span both rows @@ -8343,7 +8343,7 @@ test "PageList highlightSemanticContent prompt only" { // Prompt on row 5 with only prompt content (no input) { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; for (0..5) |x| { const cell = page.getRowAndCell(x, 5).cell; @@ -8357,7 +8357,7 @@ test "PageList highlightSemanticContent prompt only" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting should only include the prompt cells @@ -8387,7 +8387,7 @@ test "PageList highlightSemanticContent prompt to end of screen" { // Single prompt on row 15, no following prompt { const rac = page.getRowAndCell(0, 15); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; for (0..3) |x| { const cell = page.getRowAndCell(x, 15).cell; @@ -8435,7 +8435,7 @@ test "PageList highlightSemanticContent input basic" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 3 cols are prompt for (0..3) |x| { @@ -8460,7 +8460,7 @@ test "PageList highlightSemanticContent input basic" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting input should only include input cells @@ -8490,7 +8490,7 @@ test "PageList highlightSemanticContent input with output" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 2 cols are prompt for (0..2) |x| { @@ -8525,7 +8525,7 @@ test "PageList highlightSemanticContent input with output" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting input should stop at output @@ -8555,7 +8555,7 @@ test "PageList highlightSemanticContent input multiline with continuation" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 2 cols are prompt for (0..2) |x| { @@ -8602,7 +8602,7 @@ test "PageList highlightSemanticContent input multiline with continuation" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting input should span both rows, skipping continuation prompts @@ -8632,7 +8632,7 @@ test "PageList highlightSemanticContent input no input returns null" { // Prompt on row 5 with only prompt, then immediately output { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 3 cols are prompt for (0..3) |x| { @@ -8657,7 +8657,7 @@ test "PageList highlightSemanticContent input no input returns null" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting input should return null when there's no input @@ -8680,7 +8680,7 @@ test "PageList highlightSemanticContent input to end of screen" { // Single prompt on row 15, no following prompt { const rac = page.getRowAndCell(0, 15); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; for (0..2) |x| { const cell = page.getRowAndCell(x, 15).cell; @@ -8728,7 +8728,7 @@ test "PageList highlightSemanticContent input prompt only returns null" { // Prompt on row 5 with only prompt content, no input or output { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // All cells are prompt for (0..10) |x| { @@ -8752,7 +8752,7 @@ test "PageList highlightSemanticContent input prompt only returns null" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting input should return null when there's only prompts @@ -8775,7 +8775,7 @@ test "PageList highlightSemanticContent output basic" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 2 cols are prompt for (0..2) |x| { @@ -8816,7 +8816,7 @@ test "PageList highlightSemanticContent output basic" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting output should only include output cells @@ -8846,7 +8846,7 @@ test "PageList highlightSemanticContent output multiline" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 2 cols are prompt for (0..2) |x| { @@ -8907,7 +8907,7 @@ test "PageList highlightSemanticContent output multiline" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting output should span multiple rows @@ -8937,7 +8937,7 @@ test "PageList highlightSemanticContent output stops at next prompt" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 2 cols are prompt for (0..2) |x| { @@ -8992,7 +8992,7 @@ test "PageList highlightSemanticContent output stops at next prompt" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting output should stop before prompt/input @@ -9022,7 +9022,7 @@ test "PageList highlightSemanticContent output to end of screen" { // Single prompt on row 15, no following prompt { const rac = page.getRowAndCell(0, 15); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; for (0..2) |x| { const cell = page.getRowAndCell(x, 15).cell; @@ -9094,7 +9094,7 @@ test "PageList highlightSemanticContent output no output returns null" { // Prompt on row 5 with only prompt and input, no output { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 3 cols are prompt for (0..3) |x| { @@ -9128,7 +9128,7 @@ test "PageList highlightSemanticContent output no output returns null" { // Prompt on row 10 (no output between prompts) { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting output should return null when there's no output @@ -9154,7 +9154,7 @@ test "PageList highlightSemanticContent output skips empty cells" { // Prompt on row 5 - only fills first 3 cells, rest are empty with default .output { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 3 cols are prompt with text for (0..3) |x| { @@ -9199,7 +9199,7 @@ test "PageList highlightSemanticContent output skips empty cells" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting output should skip empty cells on rows 5-6 and find @@ -11553,7 +11553,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Resize @@ -11565,7 +11565,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 1); - try testing.expect(rac.row.semantic_prompt2 == .prompt); + try testing.expect(rac.row.semantic_prompt == .prompt); } } @@ -12128,7 +12128,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { const page = &s.pages.first.?.data; { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } for (0..s.cols) |x| { const rac = page.getRowAndCell(x, 1); @@ -12150,12 +12150,12 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { const p = s.pin(.{ .active = .{ .y = 1 } }).?; const rac = p.rowAndCell(); try testing.expect(rac.row.wrap); - try testing.expect(rac.row.semantic_prompt2 == .prompt); + try testing.expect(rac.row.semantic_prompt == .prompt); } { const p = s.pin(.{ .active = .{ .y = 2 } }).?; const rac = p.rowAndCell(); - try testing.expect(rac.row.semantic_prompt2 == .prompt); + try testing.expect(rac.row.semantic_prompt == .prompt); } } } @@ -12170,7 +12170,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Resize @@ -12182,7 +12182,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - try testing.expect(rac.row.semantic_prompt2 == .prompt); + try testing.expect(rac.row.semantic_prompt == .prompt); } } @@ -12196,7 +12196,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Resize @@ -12208,7 +12208,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - try testing.expect(rac.row.semantic_prompt2 == .prompt); + try testing.expect(rac.row.semantic_prompt == .prompt); } } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 76cf434ce..2fbfbafb9 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1683,7 +1683,7 @@ pub inline fn resize( // If our cursor is on a prompt line, then we clear the prompt so // the shell can redraw it. This works with OSC133 semantic prompts. if (opts.prompt_redraw and - self.cursor.page_row.semantic_prompt2 != .no_prompt) + self.cursor.page_row.semantic_prompt != .no_prompt) prompt: { const start = start: { var it = self.cursor.page_pin.promptIterator( @@ -2342,7 +2342,7 @@ pub fn cursorSetSemanticContent(self: *Screen, t: union(enum) { self.flags.semantic_content = true; cursor.semantic_content = .prompt; cursor.semantic_content_clear_eol = false; - cursor.page_row.semantic_prompt2 = switch (kind) { + cursor.page_row.semantic_prompt = switch (kind) { .initial, .right => .prompt, .continuation, .secondary => .prompt_continuation, }; @@ -2920,7 +2920,7 @@ pub fn promptPath( } { // Verify "from" is on a prompt row before calling highlightSemanticContent. // highlightSemanticContent asserts the starting point is a prompt. - switch (from.rowAndCell().row.semantic_prompt2) { + switch (from.rowAndCell().row.semantic_prompt) { .prompt, .prompt_continuation => {}, .no_prompt => return .{ .x = 0, .y = 0 }, } @@ -3043,7 +3043,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { self.cursorSetSemanticContent(.output); } else switch (self.cursor.semantic_content) { .input, .output => {}, - .prompt => self.cursor.page_row.semantic_prompt2 = .prompt_continuation, + .prompt => self.cursor.page_row.semantic_prompt = .prompt_continuation, } continue; } @@ -3079,7 +3079,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { self.cursor.page_row.wrap_continuation = true; switch (self.cursor.semantic_content) { .input, .output => {}, - .prompt => self.cursor.page_row.semantic_prompt2 = .prompt_continuation, + .prompt => self.cursor.page_row.semantic_prompt = .prompt_continuation, } } @@ -6088,15 +6088,15 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { // Our one row should still be a semantic prompt, the others should not. { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.row.semantic_prompt2 == .no_prompt); + try testing.expect(list_cell.row.semantic_prompt == .no_prompt); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; - try testing.expect(list_cell.row.semantic_prompt2 == .prompt); + try testing.expect(list_cell.row.semantic_prompt == .prompt); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; - try testing.expect(list_cell.row.semantic_prompt2 == .no_prompt); + try testing.expect(list_cell.row.semantic_prompt == .no_prompt); } } @@ -8474,7 +8474,7 @@ test "Screen: promptPath" { // Row 2: prompt (with prompt cells) and input { const rac = page.getRowAndCell(0, 2); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 3 cols are prompt for (0..3) |x| { const cell = page.getRowAndCell(x, 2).cell; @@ -8497,7 +8497,7 @@ test "Screen: promptPath" { // Row 3: continuation line with input cells (same prompt block) { const rac = page.getRowAndCell(0, 3); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; for (0..6) |x| { const cell = page.getRowAndCell(x, 3).cell; cell.* = .{ @@ -8510,7 +8510,7 @@ test "Screen: promptPath" { // Row 6: next prompt + input on same line { const rac = page.getRowAndCell(0, 6); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; for (0..2) |x| { const cell = page.getRowAndCell(x, 6).cell; cell.* = .{ diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index c9eb6a912..b8f83e781 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -776,7 +776,7 @@ fn printWrap(self: *Terminal) !void { cursor.semantic_content_clear_eol = old_semantic_clear; switch (old_semantic) { .output, .input => {}, - .prompt => cursor.page_row.semantic_prompt2 = .prompt_continuation, + .prompt => cursor.page_row.semantic_prompt = .prompt_continuation, } if (mark_wrap) { @@ -1200,7 +1200,7 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool { // If our page row is a prompt then we're always at a prompt const cursor: *const Screen.Cursor = &self.screens.active.cursor; - if (cursor.page_row.semantic_prompt2 != .no_prompt) return true; + if (cursor.page_row.semantic_prompt != .no_prompt) return true; // Otherwise, determine our cursor state return switch (cursor.semantic_content) { @@ -2347,7 +2347,7 @@ pub fn eraseDisplay( ); while (it.next()) |p| { const row = p.rowAndCell().row; - switch (row.semantic_prompt2) { + switch (row.semantic_prompt) { // If we're at a prompt or input area, then we are at a prompt. .prompt, .prompt_continuation, @@ -4328,11 +4328,11 @@ test "Terminal: soft wrap with semantic prompt" { for ("hello") |c| try t.print(c); { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(.prompt, list_cell.row.semantic_prompt2); + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; - try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt2); + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); } } @@ -11302,7 +11302,7 @@ test "Terminal: semantic prompt" { try testing.expectEqual(.prompt, cell.semantic_content); const row = list_cell.row; - try testing.expectEqual(.prompt, row.semantic_prompt2); + try testing.expectEqual(.prompt, row.semantic_prompt); } // Start input but end it on EOL @@ -11323,7 +11323,7 @@ test "Terminal: semantic prompt" { try testing.expectEqual(.output, cell.semantic_content); const row = list_cell.row; - try testing.expectEqual(.no_prompt, row.semantic_prompt2); + try testing.expectEqual(.no_prompt, row.semantic_prompt); } } @@ -11346,7 +11346,7 @@ test "Terminal: semantic prompt continuations" { try testing.expectEqual(.prompt, cell.semantic_content); const row = list_cell.row; - try testing.expectEqual(.prompt, row.semantic_prompt2); + try testing.expectEqual(.prompt, row.semantic_prompt); } // Start input but end it on EOL @@ -11370,7 +11370,7 @@ test "Terminal: semantic prompt continuations" { try testing.expectEqual(.prompt, cell.semantic_content); const row = list_cell.row; - try testing.expectEqual(.prompt_continuation, row.semantic_prompt2); + try testing.expectEqual(.prompt_continuation, row.semantic_prompt); } } diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 31879aaf4..83e42a4d9 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1907,7 +1907,7 @@ pub const Row = packed struct(u64) { /// This may contain false positives but never false negatives. If /// this is set, you should still check individual cells to see if they /// have prompt semantics. - semantic_prompt2: SemanticPrompt2 = .no_prompt, + semantic_prompt: SemanticPrompt = .no_prompt, /// True if this row contains a virtual placeholder for the Kitty /// graphics protocol. (U+10EEEE) @@ -1931,8 +1931,8 @@ pub const Row = packed struct(u64) { _padding: u23 = 0, - /// The semantic prompt state of the row. See `semantic_prompt2`. - pub const SemanticPrompt2 = enum(u2) { + /// The semantic prompt state of the row. See `semantic_prompt`. + pub const SemanticPrompt = enum(u2) { /// No prompt cells in this row. no_prompt = 0, /// Prompt cells exist in this row. From a4b7a766fe62628dd4cb77714a22a31864dff464 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 11:02:21 -0800 Subject: [PATCH 092/108] PR review --- src/renderer/row.zig | 2 +- src/terminal/PageList.zig | 12 ++++++------ src/terminal/Screen.zig | 8 ++++---- src/terminal/Terminal.zig | 6 +++--- src/terminal/page.zig | 13 +++++++++---- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/renderer/row.zig b/src/renderer/row.zig index 0f59359dc..74a641012 100644 --- a/src/renderer/row.zig +++ b/src/renderer/row.zig @@ -17,7 +17,7 @@ pub fn neverExtendBg( // powerline) that looks bad when extended. switch (row.semantic_prompt) { .prompt, .prompt_continuation => return true, - .no_prompt => {}, + .none => {}, } for (0.., cells) |x, *cell| { diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index afd6eedf2..71534d0aa 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1229,7 +1229,7 @@ const ReflowCursor = struct { // If the row has a semantic prompt then the blank row is meaningful // so we just consider pretend the first cell of the row isn't empty. - if (cols_len == 0 and src_row.semantic_prompt != .no_prompt) cols_len = 1; + if (cols_len == 0 and src_row.semantic_prompt != .none) cols_len = 1; } // Handle tracked pin adjustments. @@ -1973,7 +1973,7 @@ const ReflowCursor = struct { // If the row has a semantic prompt then the blank row is meaningful // so we always return all but one so that the row is drawn. - if (self.page_row.semantic_prompt != .no_prompt) return len - 1; + if (self.page_row.semantic_prompt != .none) return len - 1; return len; } @@ -4405,7 +4405,7 @@ pub const PromptIterator = struct { const rac = p.rowAndCell(); switch (rac.row.semantic_prompt) { // This row isn't a prompt. Keep looking. - .no_prompt => if (at_limit) break, + .none => if (at_limit) break, // This is a prompt line or continuation line. In either // case we consider the first line the prompt, and then @@ -4427,7 +4427,7 @@ pub const PromptIterator = struct { if (limit.eql(next_pin)) break; }, - .prompt, .no_prompt => { + .prompt, .none => { self.current = next_pin; return p.left(p.x); }, @@ -4458,7 +4458,7 @@ pub const PromptIterator = struct { const rac = p.rowAndCell(); switch (rac.row.semantic_prompt) { // This row isn't a prompt. Keep looking. - .no_prompt => if (at_limit) break, + .none => if (at_limit) break, // This is a prompt line. .prompt => { @@ -4485,7 +4485,7 @@ pub const PromptIterator = struct { switch (prior.rowAndCell().row.semantic_prompt) { // No prompt. That means our last pin is good! - .no_prompt => { + .none => { self.current = prior; return end_pin.left(end_pin.x); }, diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 2fbfbafb9..05b84d25f 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1683,7 +1683,7 @@ pub inline fn resize( // If our cursor is on a prompt line, then we clear the prompt so // the shell can redraw it. This works with OSC133 semantic prompts. if (opts.prompt_redraw and - self.cursor.page_row.semantic_prompt != .no_prompt) + self.cursor.page_row.semantic_prompt != .none) prompt: { const start = start: { var it = self.cursor.page_pin.promptIterator( @@ -2922,7 +2922,7 @@ pub fn promptPath( // highlightSemanticContent asserts the starting point is a prompt. switch (from.rowAndCell().row.semantic_prompt) { .prompt, .prompt_continuation => {}, - .no_prompt => return .{ .x = 0, .y = 0 }, + .none => return .{ .x = 0, .y = 0 }, } // Get our prompt bounds assuming "from" is at a prompt. @@ -6088,7 +6088,7 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { // Our one row should still be a semantic prompt, the others should not. { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .no_prompt); + try testing.expect(list_cell.row.semantic_prompt == .none); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; @@ -6096,7 +6096,7 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .no_prompt); + try testing.expect(list_cell.row.semantic_prompt == .none); } } diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index b8f83e781..4635b2a58 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1200,7 +1200,7 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool { // If our page row is a prompt then we're always at a prompt const cursor: *const Screen.Cursor = &self.screens.active.cursor; - if (cursor.page_row.semantic_prompt != .no_prompt) return true; + if (cursor.page_row.semantic_prompt != .none) return true; // Otherwise, determine our cursor state return switch (cursor.semantic_content) { @@ -2355,7 +2355,7 @@ pub fn eraseDisplay( // If we have command output, then we're most certainly not // at a prompt. - .no_prompt => break :at_prompt, + .none => break :at_prompt, } } else break :at_prompt; @@ -11323,7 +11323,7 @@ test "Terminal: semantic prompt" { try testing.expectEqual(.output, cell.semantic_content); const row = list_cell.row; - try testing.expectEqual(.no_prompt, row.semantic_prompt); + try testing.expectEqual(.none, row.semantic_prompt); } } diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 83e42a4d9..61507dc75 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1907,7 +1907,7 @@ pub const Row = packed struct(u64) { /// This may contain false positives but never false negatives. If /// this is set, you should still check individual cells to see if they /// have prompt semantics. - semantic_prompt: SemanticPrompt = .no_prompt, + semantic_prompt: SemanticPrompt = .none, /// True if this row contains a virtual placeholder for the Kitty /// graphics protocol. (U+10EEEE) @@ -1934,11 +1934,16 @@ pub const Row = packed struct(u64) { /// The semantic prompt state of the row. See `semantic_prompt`. pub const SemanticPrompt = enum(u2) { /// No prompt cells in this row. - no_prompt = 0, - /// Prompt cells exist in this row. + none = 0, + /// Prompt cells exist in this row and this is a primary prompt + /// line. A primary prompt line is one that is not a continuation + /// and is the beginning of a prompt. prompt = 1, /// Prompt cells exist in this row that had k=c set (continuation) - /// line. This is used as a way to + /// line. This is used as a way to detect when a line should + /// be considered part of some prior prompt. If no prior prompt + /// is found, the last (most historical) prompt continuation line is + /// considered the prompt. prompt_continuation = 2, }; From f14a1306cd226b3c99b802d4b5fbf0ab97fffd62 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 11:07:26 -0800 Subject: [PATCH 093/108] renderer: semantic prompt overlay --- src/inspector/widgets/renderer.zig | 84 +++++++++--- src/renderer/Overlay.zig | 204 +++++++++++++++++++++++++++-- 2 files changed, 263 insertions(+), 25 deletions(-) diff --git a/src/inspector/widgets/renderer.zig b/src/inspector/widgets/renderer.zig index 3c6492dfe..1003b02ce 100644 --- a/src/inspector/widgets/renderer.zig +++ b/src/inspector/widgets/renderer.zig @@ -48,24 +48,76 @@ pub const Info = struct { ) void { if (!open) return; - cimgui.c.ImGui_SeparatorText("Overlays"); + cimgui.c.ImGui_SetNextItemOpen(true, cimgui.c.ImGuiCond_Once); + if (!cimgui.c.ImGui_CollapsingHeader("Overlays", cimgui.c.ImGuiTreeNodeFlags_None)) return; - // 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."); + cimgui.c.ImGui_SeparatorText("Hyperlinks"); + self.overlayHyperlinks(alloc); + cimgui.c.ImGui_SeparatorText("Semantic Prompts"); + self.overlaySemanticPrompts(alloc); + } - if (!hyperlinks) { - _ = self.features.swapRemove(.highlight_hyperlinks); - } else { - self.features.put( - alloc, - .highlight_hyperlinks, - .highlight_hyperlinks, - ) catch log.warn("error enabling hyperlink overlay feature", .{}); - } + fn overlayHyperlinks(self: *Info, alloc: Allocator) void { + 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", .{}); } } + + fn overlaySemanticPrompts(self: *Info, alloc: Allocator) void { + var semantic_prompts: bool = self.features.contains(.semantic_prompts); + _ = cimgui.c.ImGui_Checkbox("Overlay Semantic Prompts", &semantic_prompts); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("When enabled, highlights OSC 133 semantic prompts."); + + // Handle the checkbox results + if (!semantic_prompts) { + _ = self.features.swapRemove(.semantic_prompts); + } else { + self.features.put( + alloc, + .semantic_prompts, + .semantic_prompts, + ) catch log.warn("error enabling semantic prompt overlay feature", .{}); + } + + // Help + cimgui.c.ImGui_Indent(); + defer cimgui.c.ImGui_Unindent(); + + cimgui.c.ImGui_TextDisabled("Colors:"); + + const prompt_rgb = renderer.Overlay.Color.semantic_prompt.rgb(); + const input_rgb = renderer.Overlay.Color.semantic_input.rgb(); + const prompt_col: cimgui.c.ImVec4 = .{ + .x = @as(f32, @floatFromInt(prompt_rgb.r)) / 255.0, + .y = @as(f32, @floatFromInt(prompt_rgb.g)) / 255.0, + .z = @as(f32, @floatFromInt(prompt_rgb.b)) / 255.0, + .w = 1.0, + }; + const input_col: cimgui.c.ImVec4 = .{ + .x = @as(f32, @floatFromInt(input_rgb.r)) / 255.0, + .y = @as(f32, @floatFromInt(input_rgb.g)) / 255.0, + .z = @as(f32, @floatFromInt(input_rgb.b)) / 255.0, + .w = 1.0, + }; + + _ = cimgui.c.ImGui_ColorButton("##prompt_color", prompt_col, cimgui.c.ImGuiColorEditFlags_NoTooltip); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("Prompt"); + + _ = cimgui.c.ImGui_ColorButton("##input_color", input_col, cimgui.c.ImGuiColorEditFlags_NoTooltip); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("Input"); + } }; diff --git a/src/renderer/Overlay.zig b/src/renderer/Overlay.zig index 7eb94acb5..62eb004ba 100644 --- a/src/renderer/Overlay.zig +++ b/src/renderer/Overlay.zig @@ -21,6 +21,44 @@ const Size = size.Size; const CellSize = size.CellSize; const Image = @import("image.zig").Image; +const log = std.log.scoped(.renderer_overlay); + +/// The colors we use for overlays. +pub const Color = enum { + hyperlink, // light blue + semantic_prompt, // orange/gold + semantic_input, // cyan + + pub fn rgb(self: Color) z2d.pixel.RGB { + return switch (self) { + .hyperlink => .{ .r = 180, .g = 180, .b = 255 }, + .semantic_prompt => .{ .r = 255, .g = 200, .b = 64 }, + .semantic_input => .{ .r = 64, .g = 200, .b = 255 }, + }; + } + + /// The fill color for rectangles. + pub fn rectFill(self: Color) z2d.Pixel { + return self.alphaPixel(96); + } + + /// The border color for rectangles. + pub fn rectBorder(self: Color) z2d.Pixel { + return self.alphaPixel(200); + } + + /// The raw RGB as a pixel. + pub fn pixel(self: Color) z2d.Pixel { + return self.rgb().asPixel(); + } + + fn alphaPixel(self: Color, alpha: u8) z2d.Pixel { + var rgba: z2d.pixel.RGBA = .fromPixel(self.pixel()); + rgba.a = alpha; + return rgba.multiply().asPixel(); + } +}; + /// The surface we're drawing our overlay to. surface: z2d.Surface, @@ -30,6 +68,7 @@ cell_size: CellSize, /// The set of available features and their configuration. pub const Feature = union(enum) { highlight_hyperlinks, + semantic_prompts, }; pub const InitError = Allocator.Error || error{ @@ -100,6 +139,10 @@ pub fn applyFeatures( alloc, state, ), + .semantic_prompts => self.highlightSemanticPrompts( + alloc, + state, + ), }; } @@ -113,13 +156,8 @@ fn highlightHyperlinks( alloc: Allocator, state: *const terminal.RenderState, ) void { - const border_fill_rgb: z2d.pixel.RGB = .{ .r = 180, .g = 180, .b = 255 }; - const border_color = border_fill_rgb.asPixel(); - const fill_color: z2d.Pixel = px: { - var rgba: z2d.pixel.RGBA = .fromPixel(border_color); - rgba.a = 128; - break :px rgba.multiply().asPixel(); - }; + const border_color = Color.hyperlink.rectBorder(); + const fill_color = Color.hyperlink.rectFill(); const row_slice = state.row_data.slice(); const row_raw = row_slice.items(.raw); @@ -145,7 +183,7 @@ fn highlightHyperlinks( while (x < raw_cells.len and raw_cells[x].hyperlink) x += 1; const end_x = x; - self.highlightRect( + self.highlightGridRect( alloc, start_x, y, @@ -160,9 +198,105 @@ fn highlightHyperlinks( } } +fn highlightSemanticPrompts( + self: *Overlay, + alloc: Allocator, + state: *const terminal.RenderState, +) void { + const row_slice = state.row_data.slice(); + const row_raw = row_slice.items(.raw); + const row_cells = row_slice.items(.cells); + + // Highlight the row-level semantic prompt bars. The prompts are easy + // because they're part of the row metadata. + { + const prompt_border = Color.semantic_prompt.rectBorder(); + const prompt_fill = Color.semantic_prompt.rectFill(); + + var y: usize = 0; + while (y < row_raw.len) { + // If its not a semantic prompt row, skip it. + if (row_raw[y].semantic_prompt == .none) { + y += 1; + continue; + } + + // Find the full length of the semantic prompt row by connecting + // all continuations. + const start_y = y; + y += 1; + while (y < row_raw.len and + row_raw[y].semantic_prompt == .prompt_continuation) + { + y += 1; + } + const end_y = y; // Exclusive + + const bar_width = @min(@as(usize, 5), self.cell_size.width); + self.highlightPixelRect( + alloc, + 0, + start_y, + bar_width, + end_y - start_y, + prompt_border, + prompt_fill, + ) catch |err| { + log.warn("Error drawing semantic prompt bar: {}", .{err}); + }; + } + } + + // Highlight contiguous semantic cells within rows. + for (row_cells, 0..) |cells, y| { + const cells_slice = cells.slice(); + const raw_cells = cells_slice.items(.raw); + + var x: usize = 0; + while (x < raw_cells.len) { + const cell = raw_cells[x]; + const content = cell.semantic_content; + const start_x = x; + + // We skip output because its just the rest of the non-prompt + // parts and it makes the overlay too noisy. + if (cell.semantic_content == .output) { + x += 1; + continue; + } + + // Find the end of this content. + x += 1; + while (x < raw_cells.len) { + const next = raw_cells[x]; + if (next.semantic_content != content) break; + x += 1; + } + + const color: Color = switch (content) { + .prompt => .semantic_prompt, + .input => .semantic_input, + .output => unreachable, + }; + + self.highlightGridRect( + alloc, + start_x, + y, + x - start_x, + 1, + color.rectBorder(), + color.rectFill(), + ) catch |err| { + log.warn("Error drawing semantic content highlight: {}", .{err}); + }; + } + } +} + /// Creates a rectangle for highlighting a grid region. x/y/width/height /// are all in grid cells. -fn highlightRect( +fn highlightGridRect( self: *Overlay, alloc: Allocator, x: usize, @@ -227,3 +361,55 @@ fn highlightRect( ctx.setSourceToPixel(border_color); try ctx.stroke(); } + +/// Creates a rectangle for highlighting a region. x/y are grid cells and +/// width/height are pixels. +fn highlightPixelRect( + self: *Overlay, + alloc: Allocator, + x: usize, + y: usize, + width_px: usize, + height: usize, + border_color: z2d.Pixel, + fill_color: z2d.Pixel, +) !void { + const px_width = std.math.cast(i32, width_px) orelse return error.Overflow; + const px_height = std.math.cast(i32, try std.math.mul( + usize, + height, + self.cell_size.height, + )) orelse return error.Overflow; + + const start_x: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + x, + self.cell_size.width, + )) orelse return error.Overflow); + const start_y: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + y, + self.cell_size.height, + )) orelse return error.Overflow); + const end_x: f64 = start_x + @as(f64, @floatFromInt(px_width)); + const end_y: f64 = start_y + @as(f64, @floatFromInt(px_height)); + + var ctx: z2d.Context = .init(alloc, &self.surface); + defer ctx.deinit(); + + ctx.setAntiAliasingMode(.none); + ctx.setHairline(true); + + try ctx.moveTo(start_x, start_y); + try ctx.lineTo(end_x, start_y); + try ctx.lineTo(end_x, end_y); + try ctx.lineTo(start_x, end_y); + try ctx.closePath(); + + ctx.setSourceToPixel(fill_color); + try ctx.fill(); + + ctx.setLineWidth(1); + ctx.setSourceToPixel(border_color); + try ctx.stroke(); +} From e7e3903151b5644c56d19939439399161085fcb1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 13:41:50 -0800 Subject: [PATCH 094/108] inspector: show if we've seen semantic content in screen --- src/inspector/widgets/screen.zig | 28 +++++++++++++++++++++------- src/inspector/widgets/surface.zig | 19 +++++++++++-------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/inspector/widgets/screen.zig b/src/inspector/widgets/screen.zig index 9365158a1..481413b4a 100644 --- a/src/inspector/widgets/screen.zig +++ b/src/inspector/widgets/screen.zig @@ -57,7 +57,7 @@ pub const Info = struct { if (cimgui.c.ImGui_CollapsingHeader( "Cursor", - cimgui.c.ImGuiTreeNodeFlags_None, + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) { cursorTable(&screen.cursor); cimgui.c.ImGui_Separator(); @@ -69,7 +69,7 @@ pub const Info = struct { if (cimgui.c.ImGui_CollapsingHeader( "Keyboard", - cimgui.c.ImGuiTreeNodeFlags_None, + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) keyboardTable( screen, data.modify_other_keys_2, @@ -77,13 +77,13 @@ pub const Info = struct { if (cimgui.c.ImGui_CollapsingHeader( "Kitty Graphics", - cimgui.c.ImGuiTreeNodeFlags_None, + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) kittyGraphicsTable(&screen.kitty_images); if (cimgui.c.ImGui_CollapsingHeader( - "Internal Terminal State", - cimgui.c.ImGuiTreeNodeFlags_None, - )) internalStateTable(&screen.pages); + "Other Screen State", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) internalStateTable(screen); } // Cell window @@ -327,8 +327,10 @@ pub fn kittyGraphicsTable( /// Render internal terminal state table. pub fn internalStateTable( - pages: *const terminal.PageList, + screen: *const terminal.Screen, ) void { + const pages = &screen.pages; + if (!cimgui.c.ImGui_BeginTable( "##terminal_state", 2, @@ -347,9 +349,21 @@ pub fn internalStateTable( 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); + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Semantic Content"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Whether semantic prompt markers (OSC 133) have been seen."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + var value: bool = screen.flags.semantic_content; + _ = cimgui.c.ImGui_Checkbox("##semantic_content", &value); + } } diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig index 3b69f214c..d73e784ce 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -25,6 +25,7 @@ pub const Inspector = struct { terminal_info: widgets.terminal.Info, vt_stream: widgets.termio.Stream, renderer_info: widgets.renderer.Info, + show_demo_window: bool, pub fn init(alloc: Allocator) !Inspector { return .{ @@ -33,6 +34,7 @@ pub const Inspector = struct { .terminal_info = .empty, .vt_stream = try .init(alloc), .renderer_info = .empty, + .show_demo_window = true, }; } @@ -52,13 +54,6 @@ pub const Inspector = struct { 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(); @@ -136,6 +131,14 @@ pub const Inspector = struct { } } + // In debug we show the ImGui demo window so we can easily view + // available widgets and such. + if (comptime builtin.mode == .Debug) { + if (self.show_demo_window) { + cimgui.c.ImGui_ShowDemoWindow(&self.show_demo_window); + } + } + 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 @@ -171,12 +174,12 @@ pub const Inspector = struct { // 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_imgui_demo, dock_id_main); 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); } From 4bee8202a8e3385bd50144252b8c86b1683b2a3a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 14:30:24 -0800 Subject: [PATCH 095/108] shell-integration/bash: mark each line in multiline prompts as secondary Insert OSC 133 A k=s marks after each newline in PS1, so that all lines following the first are marked as secondary prompts. This prevents ghostty from erasing leading lines during terminal resize. --- src/shell-integration/bash/ghostty.bash | 117 +++++++++++++----------- 1 file changed, 64 insertions(+), 53 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 799d0cff6..a5417f1b6 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -16,7 +16,7 @@ # along with this program. If not, see . # We need to be in interactive mode to proceed. -if [[ "$-" != *i* ]] ; then builtin return; fi +if [[ "$-" != *i* ]]; then builtin return; fi # When automatic shell integration is active, we were started in POSIX # mode and need to manually recreate the bash startup sequence. @@ -49,7 +49,10 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then if [[ $__ghostty_bash_flags != *"--noprofile"* ]]; then [ -r /etc/profile ] && builtin source "/etc/profile" for __ghostty_rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do - [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; } + [ -r "$__ghostty_rcfile" ] && { + builtin source "$__ghostty_rcfile" + break + } done fi else @@ -61,7 +64,10 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then # Void Linux uses /etc/bash/bashrc # Nixos uses /etc/bashrc for __ghostty_rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do - [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; } + [ -r "$__ghostty_rcfile" ] && { + builtin source "$__ghostty_rcfile" + break + } done if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" @@ -101,9 +107,9 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then fi done if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then - builtin command sudo "$@"; + builtin command sudo "$@" else - builtin command sudo --preserve-env=TERMINFO "$@"; + builtin command sudo --preserve-env=TERMINFO "$@" fi } fi @@ -127,8 +133,8 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then while IFS=' ' read -r ssh_key ssh_value; do case "$ssh_key" in - user) ssh_user="$ssh_value" ;; - hostname) ssh_hostname="$ssh_value" ;; + user) ssh_user="$ssh_value" ;; + hostname) ssh_hostname="$ssh_value" ;; esac [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break done < <(builtin command ssh -G "$@" 2>/dev/null) @@ -187,66 +193,71 @@ _ghostty_executing="" _ghostty_last_reported_cwd="" function __ghostty_precmd() { - local ret="$?" - if test "$_ghostty_executing" != "0"; then - _GHOSTTY_SAVE_PS1="$PS1" - _GHOSTTY_SAVE_PS2="$PS2" + local ret="$?" + if test "$_ghostty_executing" != "0"; then + _GHOSTTY_SAVE_PS1="$PS1" + _GHOSTTY_SAVE_PS2="$PS2" - # Marks - PS1=$PS1'\[\e]133;B\a\]' - PS2=$PS2'\[\e]133;B\a\]' + # Marks. We need to do fresh line (A) at the beginning of the prompt + # since if the cursor is not at the beginning of a line, the terminal + # will emit a newline. + PS1='\[\e]133;A\a\]'$PS1'\[\e]133;B\a\]' + PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]' - # bash doesn't redraw the leading lines in a multiline prompt so - # mark the last line as a secondary prompt (k=s) to prevent the - # preceding lines from being erased by ghostty after a resize. - if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then - PS1=$PS1'\[\e]133;A;k=s\a\]' - fi - - # Cursor - if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then - [[ "$PS1" != *'\[\e[5 q\]'* ]] && PS1=$PS1'\[\e[5 q\]' # input - [[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset - fi - - # Title (working directory) - if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then - PS1=$PS1'\[\e]2;\w\a\]' - fi + # Bash doesn't redraw the leading lines in a multiline prompt so + # we mark the start of each line (after each newline) as a secondary + # prompt. This correctly handles multiline prompts by setting the first + # to primary and the subsequent lines to secondary. + if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then + builtin local __ghostty_mark=$'\\[\\e]133;A;k=s\\a\\]' + PS1="${PS1//$'\n'/$'\n'$__ghostty_mark}" + PS1="${PS1//\\n/\\n$__ghostty_mark}" fi - if test "$_ghostty_executing" != ""; then - # End of current command. Report its status. - builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" + # Cursor + if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then + [[ "$PS1" != *'\[\e[5 q\]'* ]] && PS1=$PS1'\[\e[5 q\]' # input + [[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset fi - # unfortunately bash provides no hooks to detect cwd changes - # in particular this means cwd reporting will not happen for a - # command like cd /test && cat. PS0 is evaluated before cd is run. - if [[ "$_ghostty_last_reported_cwd" != "$PWD" ]]; then - _ghostty_last_reported_cwd="$PWD" - builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD" + # Title (working directory) + if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then + PS1=$PS1'\[\e]2;\w\a\]' fi + fi - # Fresh line and start of prompt. - builtin printf "\e]133;A;aid=%s\a" "$BASHPID" - _ghostty_executing=0 + if test "$_ghostty_executing" != ""; then + # End of current command. Report its status. + builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" + fi + + # unfortunately bash provides no hooks to detect cwd changes + # in particular this means cwd reporting will not happen for a + # command like cd /test && cat. PS0 is evaluated before cd is run. + if [[ "$_ghostty_last_reported_cwd" != "$PWD" ]]; then + _ghostty_last_reported_cwd="$PWD" + builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD" + fi + + # Fresh line and start of prompt. + builtin printf "\e]133;A;aid=%s\a" "$BASHPID" + _ghostty_executing=0 } function __ghostty_preexec() { - builtin local cmd="$1" + builtin local cmd="$1" - PS1="$_GHOSTTY_SAVE_PS1" - PS2="$_GHOSTTY_SAVE_PS2" + PS1="$_GHOSTTY_SAVE_PS1" + PS2="$_GHOSTTY_SAVE_PS2" - # Title (current command) - if [[ -n $cmd && "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then - builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]}" - fi + # Title (current command) + if [[ -n $cmd && "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then + builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]/}" + fi - # End of input, start of output. - builtin printf "\e]133;C;\a" - _ghostty_executing=1 + # End of input, start of output. + builtin printf "\e]133;C;\a" + _ghostty_executing=1 } preexec_functions+=(__ghostty_preexec) From 918c2934a36d275dde002e4e1bf757e46f3fa927 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 15:09:43 -0800 Subject: [PATCH 096/108] terminal: add redraw=last for bash for OSC133 --- src/Surface.zig | 2 +- src/shell-integration/bash/ghostty.bash | 4 +- src/terminal/Screen.zig | 153 +++++++++++++++---- src/terminal/Terminal.zig | 2 +- src/terminal/osc/parsers/semantic_prompt.zig | 50 ++++-- src/terminal/stream_readonly.zig | 2 +- 6 files changed, 170 insertions(+), 43 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index fa9b04685..44385fdae 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4295,7 +4295,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { // This flag is only set if we've seen at least one semantic prompt // OSC sequence. If we've never seen that sequence, we can't possibly // move the cursor so we can fast path out of here. - if (!t.flags.shell_redraws_prompt) return; + if (!t.screens.active.flags.semantic_content) return; // Get our path const from = t.screens.active.cursor.page_pin.*; diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index a5417f1b6..40fd71b19 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -201,7 +201,7 @@ function __ghostty_precmd() { # Marks. We need to do fresh line (A) at the beginning of the prompt # since if the cursor is not at the beginning of a line, the terminal # will emit a newline. - PS1='\[\e]133;A\a\]'$PS1'\[\e]133;B\a\]' + PS1='\[\e]133;A;redraw=last\a\]'$PS1'\[\e]133;B\a\]' PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]' # Bash doesn't redraw the leading lines in a multiline prompt so @@ -240,7 +240,7 @@ function __ghostty_precmd() { fi # Fresh line and start of prompt. - builtin printf "\e]133;A;aid=%s\a" "$BASHPID" + builtin printf "\e]133;A;redraw=last;aid=%s\a" "$BASHPID" _ghostty_executing=0 } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 05b84d25f..bf35b75df 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1608,11 +1608,11 @@ pub const Resize = struct { /// lost from the top of the scrollback. reflow: bool = true, - /// Set this to true to enable prompt redraw on resize. This signals + /// Set this to enable prompt redraw on resize. This signals /// that the running program can redraw the prompt if the cursor is /// currently at a prompt. This detects OSC133 prompts lines and clears - /// them. - prompt_redraw: bool = false, + /// them. If set to `.last`, only the most recent prompt line is cleared. + prompt_redraw: osc.semantic_prompt.Redraw = .false, }; /// Resize the screen. The rows or cols can be bigger or smaller. @@ -1682,32 +1682,47 @@ pub inline fn resize( // If our cursor is on a prompt line, then we clear the prompt so // the shell can redraw it. This works with OSC133 semantic prompts. - if (opts.prompt_redraw and + if (opts.prompt_redraw != .false and self.cursor.page_row.semantic_prompt != .none) prompt: { - const start = start: { - var it = self.cursor.page_pin.promptIterator( - .left_up, - null, - ); - break :start it.next() orelse { - // This should never happen because promptIterator should always - // find a prompt if we already verified our row is some kind of - // prompt. - log.warn("cursor on prompt line but promptIterator found no prompt", .{}); - break :prompt; - }; - }; + switch (opts.prompt_redraw) { + .false => unreachable, - // Clear cells from our start down. We replace it with spaces, - // and do not physically erase the rows (eraseRows) because the - // shell is going to expect this space to be available. - var it = start.rowIterator(.right_down, null); - while (it.next()) |pin| { - const page = &pin.node.data; - const row = pin.rowAndCell().row; - const cells = page.getCells(row); - self.clearCells(page, row, cells); + // For `.last`, only clear the current line where the cursor is. + // For `.true`, clear all prompt lines starting from the beginning. + .last => { + const page = &self.cursor.page_pin.node.data; + const row = self.cursor.page_row; + const cells = page.getCells(row); + self.clearCells(page, row, cells); + }, + + .true => { + const start = start: { + var it = self.cursor.page_pin.promptIterator( + .left_up, + null, + ); + break :start it.next() orelse { + // This should never happen because promptIterator should always + // find a prompt if we already verified our row is some kind of + // prompt. + log.warn("cursor on prompt line but promptIterator found no prompt", .{}); + break :prompt; + }; + }; + + // Clear cells from our start down. We replace it with spaces, + // and do not physically erase the rows (eraseRows) because the + // shell is going to expect this space to be available. + var it = start.rowIterator(.right_down, null); + while (it.next()) |pin| { + const page = &pin.node.data; + const row = pin.rowAndCell().row; + const cells = page.getCells(row); + self.clearCells(page, row, cells); + } + }, } } @@ -7191,7 +7206,7 @@ test "Screen: resize more cols with cursor at prompt" { try s.resize(.{ .cols = 20, .rows = 3, - .prompt_redraw = true, + .prompt_redraw = .true, }); // Cursor should not move @@ -7232,7 +7247,7 @@ test "Screen: resize more cols with cursor not at prompt" { try s.resize(.{ .cols = 20, .rows = 3, - .prompt_redraw = true, + .prompt_redraw = .true, }); // Cursor should not move @@ -7247,6 +7262,88 @@ test "Screen: resize more cols with cursor not at prompt" { } } +test "Screen: resize with prompt_redraw last clears only one line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 4, .max_scrollback = 5 }); + defer s.deinit(); + + // zig fmt: off + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_eol }); + try s.testWriteString("hello\n"); + try s.testWriteString("world"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> hello\nworld"; + try testing.expectEqualStrings(expected, contents); + } + + // Move cursor back to the prompt line (row 1) + s.cursorAbsolute(7, 1); + + try s.resize(.{ + .cols = 20, + .rows = 4, + .prompt_redraw = .last, + }); + + // With .last, only the first prompt line ("> ") should be cleared, + // but subsequent input lines ("hello", "world") remain + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n\nworld"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize with prompt_redraw last multiline prompt clears only last line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 5 }); + defer s.deinit(); + + // Create a 3-line prompt: 1 initial + 2 continuation lines + // zig fmt: off + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("line1\n"); + s.cursorSetSemanticContent(.{ .prompt = .continuation }); + try s.testWriteString("line2\n"); + s.cursorSetSemanticContent(.{ .prompt = .continuation }); + try s.testWriteString("line3"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "line1\nline2\nline3"; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor is at end of line3 (the last continuation line) + try s.resize(.{ + .cols = 30, + .rows = 5, + .prompt_redraw = .last, + }); + + // With .last, only line3 (where cursor is) should be cleared + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "line1\nline2"; + try testing.expectEqualStrings(expected, contents); + } +} + test "Screen: select untracked" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 4635b2a58..10e6f1630 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -83,7 +83,7 @@ flags: packed struct { // This supports a Kitty extension where programs using semantic // prompts (OSC133) can annotate their new prompts with `redraw=0` to // disable clearing the prompt on resize. - shell_redraws_prompt: bool = true, + shell_redraws_prompt: osc.semantic_prompt.Redraw = .true, // This is set via ESC[4;2m. Any other modify key mode just sets // this to false and we act in mode 1 by default. diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index 61aed4988..9014312f4 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -49,10 +49,8 @@ pub const Option = enum { err, // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - // Kitty supports a "redraw" option for prompt_start. I can't find - // this documented anywhere but can see in the code that this is used - // by shell environments to tell the terminal that the shell will NOT - // redraw the prompt so we should attempt to resize it. + // Kitty supports a "redraw" option for prompt_start. This is extended + // by Ghostty with the "last" option. See Redraw the type for more details. redraw, // Use a special key instead of arrow keys to move the cursor on @@ -82,7 +80,7 @@ pub const Option = enum { .cl => Click, .prompt_kind => PromptKind, .err => []const u8, - .redraw => bool, + .redraw => Redraw, .special_key => bool, .click_events => bool, .exit_code => i32, @@ -170,7 +168,15 @@ pub const Option = enum { .cl => std.meta.stringToEnum(Click, value), .prompt_kind => if (value.len == 1) PromptKind.init(value[0]) else null, .err => value, - .redraw, .special_key, .click_events => if (value.len == 1) switch (value[0]) { + .redraw => if (std.mem.eql(u8, value, "0")) + .false + else if (std.mem.eql(u8, value, "1")) + .true + else if (std.mem.eql(u8, value, "last")) + .last + else + null, + .special_key, .click_events => if (value.len == 1) switch (value[0]) { '0' => false, '1' => true, else => null, @@ -209,6 +215,29 @@ pub const PromptKind = enum { } }; +/// The values for the `redraw` extension to OSC133. This was +/// started by Kitty[1] and extended by Ghostty (the "last" option). +/// +/// [1]: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers +pub const Redraw = enum(u2) { + /// The shell supports redrawing the full prompt and all continuations. + /// This is the default value, it does not need to be explicitly set + /// unless it is to reset a prior other value. + true, + + /// The shell does NOT support redrawing. In this case, Ghostty will NOT + /// clear any prompt lines on resize. + false, + + /// The shell supports redrawing only the LAST line of the prompt. + /// Ghostty will only clear the last line of the prompt on resize. + /// + /// This is specifically introduced because Bash only redraws the last + /// line. It is literally the only shell that does this and it does this + /// because its bad and they should feel bad. Don't be like Bash. + last, +}; + /// Parse OSC 133, semantic prompts pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { const writer = parser.writer orelse { @@ -514,7 +543,7 @@ test "OSC 133: fresh_line_new_prompt with redraw=0" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == false); + try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == .false); } test "OSC 133: fresh_line_new_prompt with redraw=1" { @@ -528,7 +557,7 @@ test "OSC 133: fresh_line_new_prompt with redraw=1" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == true); + try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == .true); } test "OSC 133: fresh_line_new_prompt with invalid redraw" { @@ -871,8 +900,9 @@ test "Option.read err" { test "Option.read redraw" { const testing = std.testing; - try testing.expect(Option.redraw.read("redraw=1").? == true); - try testing.expect(Option.redraw.read("redraw=0").? == false); + try testing.expect(Option.redraw.read("redraw=1").? == .true); + try testing.expect(Option.redraw.read("redraw=0").? == .false); + try testing.expect(Option.redraw.read("redraw=last").? == .last); try testing.expect(Option.redraw.read("redraw=2") == null); try testing.expect(Option.redraw.read("redraw=10") == null); try testing.expect(Option.redraw.read("redraw=") == null); diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 91532c9d5..eca13bf06 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -904,7 +904,7 @@ test "semantic prompt fresh line new prompt" { // Test with redraw option try s.nextSlice("prompt$ "); try s.nextSlice("\x1b]133;A;redraw=1\x07"); - try testing.expect(t.flags.shell_redraws_prompt); + try testing.expect(t.flags.shell_redraws_prompt == .true); } test "semantic prompt end of input, then start output" { From ef2c90cd2ba18a40ac889bbb8abc59c5720fe148 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 1 Feb 2026 00:20:14 +0000 Subject: [PATCH 097/108] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index d5c06259a..05e440fcc 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz", - .hash = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260126-150817-02c580d.tgz", + .hash = "N-V-__8AAM5RAwB5jHC1P1uqrabiz_ieQvfrIYNzs4eAY__c", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index b12216bd9..debfb1689 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7": { + "N-V-__8AAM5RAwB5jHC1P1uqrabiz_ieQvfrIYNzs4eAY__c": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz", - "hash": "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260126-150817-02c580d.tgz", + "hash": "sha256-jWbZAS2GSIH8kGdafJua81OMJY8djEQUhvTtmnT90oI=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 430619e74..ce8f5b056 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -171,11 +171,11 @@ in }; } { - name = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7"; + name = "N-V-__8AAM5RAwB5jHC1P1uqrabiz_ieQvfrIYNzs4eAY__c"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz"; - hash = "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260126-150817-02c580d.tgz"; + hash = "sha256-jWbZAS2GSIH8kGdafJua81OMJY8djEQUhvTtmnT90oI="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 72597a650..264c49232 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260126-150817-02c580d.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 3e2b1e26d..df810aaa8 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz", - "dest": "vendor/p/N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7", - "sha256": "348a85d762aa5e122b3fe2f206d83a5ad907c4d51d58ecb388076af12e3946b5" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260126-150817-02c580d.tgz", + "dest": "vendor/p/N-V-__8AAM5RAwB5jHC1P1uqrabiz_ieQvfrIYNzs4eAY__c", + "sha256": "8d66d9012d864881fc90675a7c9b9af3538c258f1d8c441486f4ed9a74fdd282" }, { "type": "archive", From 92d6dde583d60ead1e4ca6761643637d0342bc2b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 19:31:06 -0800 Subject: [PATCH 098/108] shell-integration/zsh: set proper input and secondary prompt marks --- src/shell-integration/zsh/ghostty-integration | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 3fb3ec19b..ac609d6a0 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -132,6 +132,7 @@ _ghostty_deferred_init() { # asynchronously from a `zle -F` handler might still remove our # marks. Oh well. builtin local mark2=$'%{\e]133;A;k=s\a%}' + builtin local markB=$'%{\e]133;B\a%}' # Add marks conditionally to avoid a situation where we have # several marks in place. These conditions can have false # positives and false negatives though. @@ -139,8 +140,17 @@ _ghostty_deferred_init() { # - False positive (with prompt_percent): PS1="%(?.$mark1.)" # - False negative (with prompt_subst): PS1='$mark1' [[ $PS1 == *$mark1* ]] || PS1=${mark1}${PS1} + [[ $PS1 == *$markB* ]] || PS1=${PS1}${markB} + # Handle multiline prompts by marking continuation lines as + # secondary by replacing newlines with being prefixed + # with k=s + if [[ $PS1 == *$'\n'* ]]; then + PS1=${PS1//$'\n'/$'\n'${mark2}} + fi + # PS2 mark is needed when clearing the prompt on resize [[ $PS2 == *$mark2* ]] || PS2=${mark2}${PS2} + [[ $PS2 == *$markB* ]] || PS2=${PS2}${markB} (( _ghostty_state = 2 )) else # If our precmd hook is not the last, we cannot rely on prompt @@ -179,7 +189,10 @@ _ghostty_deferred_init() { # top. We cannot force prompt_subst on the user though, so we would # still need this code for the no_prompt_subst case. PS1=${PS1//$'%{\e]133;A\a%}'} + PS1=${PS1//$'%{\e]133;A;k=s\a%}'} + PS1=${PS1//$'%{\e]133;B\a%}'} PS2=${PS2//$'%{\e]133;A;k=s\a%}'} + PS2=${PS2//$'%{\e]133;B\a%}'} # This will work incorrectly in the presence of a preexec hook that # prints. For example, if MichaelAquilina/zsh-you-should-use installs From 853fee9496504c175c389ce487fba76c071a11b8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 20:31:49 -0800 Subject: [PATCH 099/108] terminal: when semantic cursor is prompt, assume newline is prompt This works around Fish (at least v4.2) having a non-compliant OSC133 implementation paired with not having the hooks to fix this via shell integration. We have to instead resort to heuristics in the terminal emulator. Womp, womp. The issue is that Fish does not emit OSC133 secondary prompt (`k=s`) markers at the beginning of continuation lines. And, since Fish doesn't provide a PS2-equivalent, we can't do this via shell integration. We fix this by assuming on newline (`\n`) that a cursor that is already painting prompt cells is continuing a prior prompt line, and pre-emptively mark it as a prompt line. But this has two further issues we have to work around: 1. Newline/index (`\n`) is one of the _hottest path_ functions in terminal emulation. It sucks to add any new conditional logic here. We do our best to gate this on unlikely conditions that the branch predictor can easily optimize away. 2. Fish also emits these for auto-complete hints that may be deleted later. So, we also have to handle the scenario where a prompt is continued, then replaced by command output, and fix up the prompt continuation flag to go back to output mode. Point 2 is ALMOST automatically handled, because Fish does emit a `CSI J` (erase display below) to erase the auto-complete hint. This resets all our rows back to output rows. **Unfortunately**, Fish emits `\n` before triggering the preexec hooks which set OSC133C. So we get the newline logic FIRST (sets the prompt line), THEN sets the output cursor. If they switched ordering here everything would just work (with the one heuristic). But now, we need two! To address this, I put some extra heuristic logic in the OSC133C (output starting) handler: if our row is marked as a prompt AND our cursor is at x=0, we assume that the prompt continuation was deleted and we unmark it. I put the heuristic logic dependent on OSC133C because that's way colder of a path than putting something in `printCell` (which is the actual hottest path in Ghostty). We could get more rigorous here by also checking if every cell is empty but that doesn't seem to be necessary at this time for any Fish version I've tested. I hope thats correct. I'd really love for Fish to improve their OSC133 implementation to conform more closely to the terminal-wg spec, but we're going to need these workarounds indefinitely to handle older Fish versions anyway. --- src/terminal/Terminal.zig | 326 ++++++++++++++++++++++++++++++++++---- 1 file changed, 293 insertions(+), 33 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 10e6f1630..4d4f312f2 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1145,6 +1145,21 @@ pub fn semanticPrompt( .end_input_start_output => { // "End of input, and start of output." self.screens.active.cursorSetSemanticContent(.output); + + // If our current row is marked as a prompt and we're + // at column zero then we assume we're un-prompting. This + // is a heuristic to deal with fish, mostly. The issue that + // fish brings up is that it has no PS2 equivalent and its + // builtin OSC133 marking doesn't output continuation lines + // as k=s. So, we assume when we get a newline with a prompt + // cursor that the new line is also a prompt. But fish changes + // to output on the newline. So if we're at col 0 we just assume + // we're overwriting the prompt. + if (self.screens.active.cursor.page_row.semantic_prompt != .none and + self.screens.active.cursor.x == 0) + { + self.screens.active.cursor.page_row.semantic_prompt = .none; + } }, .end_command => { @@ -1271,28 +1286,53 @@ pub fn tabReset(self: *Terminal) void { /// /// This unsets the pending wrap state without wrapping. pub fn index(self: *Terminal) !void { - // Unset pending wrap state - self.screens.active.cursor.pending_wrap = false; + const screen: *Screen = self.screens.active; - // Always reset any semantic content clear-eol state. - // - // The specification is not clear what "end-of-line" means. If we - // discover that there are more scenarios we should be unsetting - // this we should document and test it. - if (self.screens.active.cursor.semantic_content_clear_eol) { + // Unset pending wrap state + screen.cursor.pending_wrap = false; + + // We handle our cursor semantic prompt state AFTER doing the + // scrolling, because we may need to apply to new rows. + defer if (screen.cursor.semantic_content != .output) { @branchHint(.unlikely); - self.screens.active.cursor.semantic_content = .output; - self.screens.active.cursor.semantic_content_clear_eol = false; - } + + // If we're prompting and do a newline, immediately assume + // that the new row is a prompt continuation. This is to work + // around shells that don't send OSC 133 k=s sequences for + // continuations (fish as v4.3, which also doesn't have a way + // to do PS2-style prompts to fix this ourself!). + // + // This can be a false positive if the shell changes content + // type later and outputs something. We handle that in the + // semanticPrompt function. + if (screen.cursor.semantic_content == .prompt) { + screen.cursorSetSemanticContent(.{ + .prompt = .secondary, + }); + } + + // Always reset any semantic content clear-eol state. + // + // The specification is not clear what "end-of-line" means. If we + // discover that there are more scenarios we should be unsetting + // this we should document and test it. + if (screen.cursor.semantic_content_clear_eol) { + screen.cursor.semantic_content = .output; + screen.cursor.semantic_content_clear_eol = false; + } + } else { + // This should never be set in the output mode. + assert(!screen.cursor.semantic_content_clear_eol); + }; // Outside of the scroll region we move the cursor one line down. - if (self.screens.active.cursor.y < self.scrolling_region.top or - self.screens.active.cursor.y > self.scrolling_region.bottom) + if (screen.cursor.y < self.scrolling_region.top or + screen.cursor.y > self.scrolling_region.bottom) { // We only move down if we're not already at the bottom of // the screen. - if (self.screens.active.cursor.y < self.rows - 1) { - self.screens.active.cursorDown(1); + if (screen.cursor.y < self.rows - 1) { + screen.cursorDown(1); } return; @@ -1301,13 +1341,13 @@ pub fn index(self: *Terminal) !void { // If the cursor is inside the scrolling region and on the bottom-most // line, then we scroll up. If our scrolling region is the full screen // we create scrollback. - if (self.screens.active.cursor.y == self.scrolling_region.bottom and - self.screens.active.cursor.x >= self.scrolling_region.left and - self.screens.active.cursor.x <= self.scrolling_region.right) + if (screen.cursor.y == self.scrolling_region.bottom and + screen.cursor.x >= self.scrolling_region.left and + screen.cursor.x <= self.scrolling_region.right) { if (comptime build_options.kitty_graphics) { // Scrolling dirties the images because it updates their placements pins. - self.screens.active.kitty_images.dirty = true; + screen.kitty_images.dirty = true; } // If our scrolling region is at the top, we create scrollback. @@ -1315,7 +1355,7 @@ pub fn index(self: *Terminal) !void { self.scrolling_region.left == 0 and self.scrolling_region.right == self.cols - 1) { - try self.screens.active.cursorScrollAbove(); + try screen.cursorScrollAbove(); return; } @@ -1329,7 +1369,7 @@ pub fn index(self: *Terminal) !void { // However, scrollUp is WAY slower. We should optimize this // case to work in the eraseRowBounded codepath and remove // this check. - !self.screens.active.blankCell().isZero()) + !screen.blankCell().isZero()) { try self.scrollUp(1); return; @@ -1339,9 +1379,9 @@ pub fn index(self: *Terminal) !void { // scroll the contents of the scrolling region. // Preserve old cursor just for assertions - const old_cursor = self.screens.active.cursor; + const old_cursor = screen.cursor; - try self.screens.active.pages.eraseRowBounded( + try screen.pages.eraseRowBounded( .{ .active = .{ .y = self.scrolling_region.top } }, self.scrolling_region.bottom - self.scrolling_region.top, ); @@ -1350,26 +1390,26 @@ pub fn index(self: *Terminal) !void { // up by 1, so we need to move it back down. A `cursorReload` // would be better option but this is more efficient and this is // a super hot path so we do this instead. - assert(self.screens.active.cursor.x == old_cursor.x); - assert(self.screens.active.cursor.y == old_cursor.y); - self.screens.active.cursor.y -= 1; - self.screens.active.cursorDown(1); + assert(screen.cursor.x == old_cursor.x); + assert(screen.cursor.y == old_cursor.y); + screen.cursor.y -= 1; + screen.cursorDown(1); // The operations above can prune our cursor style so we need to // update. This should never fail because the above can only FREE // memory. - self.screens.active.manualStyleUpdate() catch |err| { + screen.manualStyleUpdate() catch |err| { std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); - self.screens.active.cursor.style = .{}; - self.screens.active.manualStyleUpdate() catch unreachable; + screen.cursor.style = .{}; + screen.manualStyleUpdate() catch unreachable; }; return; } // Increase cursor by 1, maximum to bottom of scroll region - if (self.screens.active.cursor.y < self.scrolling_region.bottom) { - self.screens.active.cursorDown(1); + if (screen.cursor.y < self.scrolling_region.bottom) { + screen.cursorDown(1); } } @@ -11374,20 +11414,240 @@ test "Terminal: semantic prompt continuations" { } } +test "Terminal: index in prompt mode marks new row as prompt continuation" { + // This tests the Fish shell workaround: when in prompt mode and we get + // a newline, assume the new row is a prompt continuation (since Fish + // doesn't emit OSC133 k=s markers for continuation lines). + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt + try t.semanticPrompt(.init(.prompt_start)); + for ("hello") |c| try t.print(c); + + // Verify first row is marked as prompt + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 0, + } }).?; + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt); + } + + // Now do a linefeed while still in prompt mode + t.carriageReturn(); + try t.linefeed(); + + // The new row should automatically be marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // The cursor semantic content should still be prompt + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); +} + +test "Terminal: index in input mode does not mark new row as prompt" { + // Input mode should NOT trigger prompt continuation on newline + // (only prompt mode does, not input mode) + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt then switch to input + try t.semanticPrompt(.init(.prompt_start)); + for ("$ ") |c| try t.print(c); + try t.semanticPrompt(.init(.end_prompt_start_input)); + for ("echo \\") |c| try t.print(c); + + // Linefeed while in input mode + t.carriageReturn(); + try t.linefeed(); + + // The new row should NOT be marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.none, list_cell.row.semantic_prompt); + } +} + +test "Terminal: index in output mode does not mark new row as prompt" { + // Output mode should NOT trigger prompt continuation + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Complete prompt cycle: prompt -> input -> output + try t.semanticPrompt(.init(.prompt_start)); + for ("$ ") |c| try t.print(c); + try t.semanticPrompt(.init(.end_prompt_start_input)); + for ("ls") |c| try t.print(c); + try t.semanticPrompt(.init(.end_input_start_output)); + + // Linefeed while in output mode + t.carriageReturn(); + try t.linefeed(); + + // The new row should NOT be marked as a prompt + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.none, list_cell.row.semantic_prompt); + } +} + +test "Terminal: OSC133C at x=0 on prompt row clears prompt mark" { + // This tests the second Fish heuristic: when Fish emits a newline + // then immediately sends OSC133C (start output) at column 0, we + // should clear the prompt continuation mark we just set. + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt + try t.semanticPrompt(.init(.prompt_start)); + for ("$ echo \\") |c| try t.print(c); + + // Simulate Fish behavior: newline first (which marks next row as prompt) + t.carriageReturn(); + try t.linefeed(); + + // Verify the new row is marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // Now Fish sends OSC133C at column 0 (cursor is still at x=0) + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try t.semanticPrompt(.init(.end_input_start_output)); + + // The prompt continuation should be cleared + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.none, list_cell.row.semantic_prompt); + } +} + +test "Terminal: OSC133C at x>0 on prompt row does not clear prompt mark" { + // If we're not at column 0, we shouldn't clear the prompt mark + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt on a row + try t.semanticPrompt(.init(.prompt_start)); + for ("$ ") |c| try t.print(c); + + // Move to a new line and mark it as prompt continuation manually + t.carriageReturn(); + try t.linefeed(); + try t.semanticPrompt(.{ + .action = .prompt_start, + .options_unvalidated = "k=c", + }); + for ("> ") |c| try t.print(c); + + // Verify the row is marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // Now send OSC133C but cursor is NOT at column 0 + try testing.expect(t.screens.active.cursor.x > 0); + try t.semanticPrompt(.init(.end_input_start_output)); + + // The prompt continuation should NOT be cleared (we're not at x=0) + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } +} + +test "Terminal: multiple newlines in prompt mode marks all rows" { + // Multiple newlines should each mark their row as prompt continuation + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt + try t.semanticPrompt(.init(.prompt_start)); + for ("line1") |c| try t.print(c); + + // Multiple newlines + t.carriageReturn(); + try t.linefeed(); + for ("line2") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("line3") |c| try t.print(c); + + // First row should be prompt + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 0, + } }).?; + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt); + } + + // Second and third rows should be prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 2, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } +} + test "Terminal: cursorIsAtPrompt" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 2 }); + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); defer t.deinit(alloc); try testing.expect(!t.cursorIsAtPrompt()); try t.semanticPrompt(.init(.prompt_start)); try testing.expect(t.cursorIsAtPrompt()); + for ("$ ") |c| try t.print(c); // Input is also a prompt try t.semanticPrompt(.init(.end_prompt_start_input)); try testing.expect(t.cursorIsAtPrompt()); + for ("ls") |c| try t.print(c); // But once we say we're starting output, we're not a prompt + // (cursor is not at x=0, so the Fish heuristic doesn't trigger) try t.semanticPrompt(.init(.end_input_start_output)); // Still a prompt because this line has a prompt try testing.expect(t.cursorIsAtPrompt()); From 8811d9b0553781be4d7ca3eaf89c6723f5d7fc33 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 20:51:42 -0800 Subject: [PATCH 100/108] terminal: for prompt redraw, assume at a prompt if at input line Nu properly marks input areas with OSC 133 B, but if it spans multiple lines it doesn't mark the continuation lines with the k=s sequence. Our prompt redraw logic before only cleared explicitly designated prompt lines. But if the input line is multi-line and the continuation lines are not marked, those lines would not be cleared, leading to visual issues on resize. To workaround this, we assume that if the current cursor semantic content is anything other than "command output" (default), then we're probably at a prompt line and should clear from there all the way up. --- src/terminal/Screen.zig | 75 +++++++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index bf35b75df..2d27eb2d6 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1680,10 +1680,18 @@ pub inline fn resize( }; defer if (saved_cursor_pin) |p| self.pages.untrackPin(p); - // If our cursor is on a prompt line, then we clear the prompt so - // the shell can redraw it. This works with OSC133 semantic prompts. + // If our cursor is on a prompt or input line, clear it so the shell can + // redraw it. This works with OSC 133 semantic prompts. + // + // We check cursor.semantic_content rather than page_row.semantic_prompt + // because some shells (e.g., Nu) mark input areas with OSC 133 B but don't + // mark continuation lines with k=s. If the input spans multiple lines and + // continuation lines are unmarked, checking only page_row.semantic_prompt + // would miss them. By checking semantic_content, we assume that if the + // cursor is on anything other than command output, we're at a prompt/input + // line and should clear from there. if (opts.prompt_redraw != .false and - self.cursor.page_row.semantic_prompt != .none) + self.cursor.semantic_content != .output) prompt: { switch (opts.prompt_redraw) { .false => unreachable, @@ -7273,7 +7281,7 @@ test "Screen: resize with prompt_redraw last clears only one line" { try s.testWriteString("ABCDE\n"); s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); - s.cursorSetSemanticContent(.{ .input = .clear_eol }); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello\n"); try s.testWriteString("world"); // zig fmt: on @@ -7285,21 +7293,18 @@ test "Screen: resize with prompt_redraw last clears only one line" { try testing.expectEqualStrings(expected, contents); } - // Move cursor back to the prompt line (row 1) - s.cursorAbsolute(7, 1); - + // Cursor is at end of "world" line with semantic_content = .input try s.resize(.{ .cols = 20, .rows = 4, .prompt_redraw = .last, }); - // With .last, only the first prompt line ("> ") should be cleared, - // but subsequent input lines ("hello", "world") remain + // With .last, only the current line where cursor is should be cleared { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "ABCDE\n\nworld"; + const expected = "ABCDE\n> hello"; try testing.expectEqualStrings(expected, contents); } } @@ -7344,6 +7349,56 @@ test "Screen: resize with prompt_redraw last multiline prompt clears only last l } } +test "Screen: resize with prompt_redraw clears input line without row semantic prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 5 }); + defer s.deinit(); + + // Simulate Nu shell behavior: marks input area with OSC 133 B but does not + // mark continuation lines with k=s sequence. This means: + // - cursor.semantic_content = .input + // - cursor.page_row.semantic_prompt = .none (not marked) + // The fix ensures we still clear based on semantic_content. + // zig fmt: off + try s.testWriteString("output\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("hello\n"); + // Continue typing on next line - no prompt marking, but still in input mode + try s.testWriteString("world"); + // zig fmt: on + + // Verify the row has no semantic prompt marking (simulating Nu behavior) + try testing.expectEqual(.none, s.cursor.page_row.semantic_prompt); + // But the cursor's semantic content is input + try testing.expectEqual(.input, s.cursor.semantic_content); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "output\n> hello\nworld"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(.{ + .cols = 30, + .rows = 5, + .prompt_redraw = .true, + }); + + // All prompt/input lines should be cleared even though the continuation + // row's semantic_prompt is .none + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "output"; + try testing.expectEqualStrings(expected, contents); + } +} + test "Screen: select untracked" { const testing = std.testing; const alloc = testing.allocator; From a909a1f120f058e7ad934698ac4c473ce8cd48d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 1 Feb 2026 13:01:01 -0800 Subject: [PATCH 101/108] terminal: mark newlines for input lines as prompt continuation rows --- src/terminal/Terminal.zig | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 4d4f312f2..31bc94d17 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1296,21 +1296,6 @@ pub fn index(self: *Terminal) !void { defer if (screen.cursor.semantic_content != .output) { @branchHint(.unlikely); - // If we're prompting and do a newline, immediately assume - // that the new row is a prompt continuation. This is to work - // around shells that don't send OSC 133 k=s sequences for - // continuations (fish as v4.3, which also doesn't have a way - // to do PS2-style prompts to fix this ourself!). - // - // This can be a false positive if the shell changes content - // type later and outputs something. We handle that in the - // semanticPrompt function. - if (screen.cursor.semantic_content == .prompt) { - screen.cursorSetSemanticContent(.{ - .prompt = .secondary, - }); - } - // Always reset any semantic content clear-eol state. // // The specification is not clear what "end-of-line" means. If we @@ -1319,6 +1304,16 @@ pub fn index(self: *Terminal) !void { if (screen.cursor.semantic_content_clear_eol) { screen.cursor.semantic_content = .output; screen.cursor.semantic_content_clear_eol = false; + } else { + // If we aren't clearing our state at EOL and we're not output, + // then we mark the new row as a prompt continuation. This is + // to work around shells that don't send OSC 133 k=s sequences + // for continuations. + // + // This can be a false positive if the shell changes content + // type later and outputs something. We handle that in the + // semanticPrompt function. + screen.cursor.page_row.semantic_prompt = .prompt_continuation; } } else { // This should never be set in the output mode. @@ -11469,14 +11464,17 @@ test "Terminal: index in input mode does not mark new row as prompt" { t.carriageReturn(); try t.linefeed(); - // The new row should NOT be marked as prompt continuation + // The new row should be marked as prompt continuation { const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 1, } }).?; - try testing.expectEqual(.none, list_cell.row.semantic_prompt); + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); } + + // Our cursor should still be in input + try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); } test "Terminal: index in output mode does not mark new row as prompt" { From ca1ee7d2c4eb18726d440bc82589339f48f5cc3e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 1 Feb 2026 13:18:31 -0800 Subject: [PATCH 102/108] renderer: don't draw overlay if it isn't needed This avoids loud log messages when no overlay is present. --- src/renderer/generic.zig | 70 +--------------------------------------- 1 file changed, 1 insertion(+), 69 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 3c77e4cdf..c8acebd0f 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1644,7 +1644,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Debug overlay. We do this before any custom shader state // because our debug overlay is aligned with the grid. - self.images.draw( + if (self.overlay != null) self.images.draw( &self.api, self.shaders.pipelines.image, &pass, @@ -1704,74 +1704,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.swap_chain.releaseFrame(); } - fn drawImagePlacements( - self: *Self, - pass: *RenderPass, - placements: []const imagepkg.Placement, - ) !void { - if (placements.len == 0) return; - - for (placements) |p| { - - // Look up the image - const image = self.images.get(p.image_id) orelse { - log.warn("image not found for placement image_id={}", .{p.image_id}); - continue; - }; - - // Get the texture - const texture = switch (image.image) { - .ready, - .unload_ready, - => |t| t, - else => { - log.warn("image not ready for placement image_id={}", .{p.image_id}); - continue; - }, - }; - - // Create our vertex buffer, which is always exactly one item. - // future(mitchellh): we can group rendering multiple instances of a single image - var buf = try Buffer(shaderpkg.Image).initFill( - self.api.imageBufferOptions(), - &.{.{ - .grid_pos = .{ - @as(f32, @floatFromInt(p.x)), - @as(f32, @floatFromInt(p.y)), - }, - - .cell_offset = .{ - @as(f32, @floatFromInt(p.cell_offset_x)), - @as(f32, @floatFromInt(p.cell_offset_y)), - }, - - .source_rect = .{ - @as(f32, @floatFromInt(p.source_x)), - @as(f32, @floatFromInt(p.source_y)), - @as(f32, @floatFromInt(p.source_width)), - @as(f32, @floatFromInt(p.source_height)), - }, - - .dest_size = .{ - @as(f32, @floatFromInt(p.width)), - @as(f32, @floatFromInt(p.height)), - }, - }}, - ); - defer buf.deinit(); - - pass.step(.{ - .pipeline = self.shaders.pipelines.image, - .buffers = &.{buf.buffer}, - .textures = &.{texture}, - .draw = .{ - .type = .triangle_strip, - .vertex_count = 4, - }, - }); - } - } - /// Call this any time the background image path changes. /// /// Caller must hold the draw mutex. From 446b26bb72c95956f6b5d54e4ec0365744838e00 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 1 Feb 2026 14:08:41 -0800 Subject: [PATCH 103/108] renderer: don't ever redraw the inspector. Not your job! --- src/renderer/Thread.zig | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index d651fed79..c6217fcd1 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -294,9 +294,6 @@ fn setQosClass(self: *const Thread) void { fn syncDrawTimer(self: *Thread) void { skip: { - // If we have an inspector, we always run the draw timer. - if (self.flags.has_inspector) break :skip; - // If our renderer supports animations and has them, then we // always have a draw timer. if (@hasDecl(rendererpkg.Renderer, "hasAnimations") and @@ -479,9 +476,6 @@ fn drainMailbox(self: *Thread) !void { .inspector => |v| { self.flags.has_inspector = v; - // Reset our draw timer state, which might change due - // to the inspector change. - self.syncDrawTimer(); }, .macos_display_id => |v| { @@ -614,11 +608,6 @@ fn renderCallback( return .disarm; }; - // If we have an inspector, let the app know we want to rerender that. - if (t.flags.has_inspector) { - _ = t.app_mailbox.push(.{ .redraw_inspector = t.surface }, .{ .instant = {} }); - } - // Update our frame data t.renderer.updateFrame( t.state, From 020fe35c48d5595f2301f278430484c4276d516f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 1 Feb 2026 14:22:07 -0800 Subject: [PATCH 104/108] macos: render inspectors on timed updates, pause when occluded --- .../Ghostty/Surface View/InspectorView.swift | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/InspectorView.swift b/macos/Sources/Ghostty/Surface View/InspectorView.swift index 0ca48371e..e8ad9e59d 100644 --- a/macos/Sources/Ghostty/Surface View/InspectorView.swift +++ b/macos/Sources/Ghostty/Surface View/InspectorView.swift @@ -120,9 +120,9 @@ extension Ghostty { self.commandQueue = commandQueue super.init(frame: frame, device: device) - // This makes it so renders only happen when we request - self.enableSetNeedsDisplay = true - self.isPaused = true + // Use timed updates mode. This is required for the inspector. + self.isPaused = false + self.preferredFramesPerSecond = 30 // After initializing the parent we can set our own properties self.device = MTLCreateSystemDefaultDevice() @@ -130,6 +130,13 @@ extension Ghostty { // Setup our tracking areas for mouse events updateTrackingAreas() + + // Observe occlusion state to pause rendering when not visible + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidChangeOcclusionState), + name: NSWindow.didChangeOcclusionStateNotification, + object: nil) } required init(coder: NSCoder) { @@ -141,27 +148,19 @@ extension Ghostty { NotificationCenter.default.removeObserver(self) } + @objc private func windowDidChangeOcclusionState(_ notification: NSNotification) { + guard let window = notification.object as? NSWindow, + window == self.window else { return } + // Pause rendering when our window isn't visible. + isPaused = !window.occlusionState.contains(.visible) + } + // MARK: Internal Inspector Funcs private func surfaceViewDidChange() { - let center = NotificationCenter.default - center.removeObserver(self) - - guard let surfaceView = self.surfaceView else { return } guard let inspector = self.inspector else { return } guard let device = self.device else { return } _ = inspector.metalInit(device: device) - - // Register an observer for render requests - center.addObserver( - self, - selector: #selector(didRequestRender), - name: Ghostty.Notification.inspectorNeedsDisplay, - object: surfaceView) - } - - @objc private func didRequestRender(notification: SwiftUI.Notification) { - self.needsDisplay = true } private func updateSize() { @@ -416,6 +415,8 @@ extension Ghostty { // MARK: MTKView override func draw(_ dirtyRect: NSRect) { + Ghostty.logger.warning("inspector draw at \(Date())") + guard let commandBuffer = self.commandQueue.makeCommandBuffer(), let descriptor = self.currentRenderPassDescriptor else { From 63f9d4aaf744c47fd38602ff4db30869093d901b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 1 Feb 2026 14:25:35 -0800 Subject: [PATCH 105/108] apprt/gtk: move imgui widget to frame timer redraw --- src/apprt/gtk/class/imgui_widget.zig | 55 +++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/imgui_widget.zig b/src/apprt/gtk/class/imgui_widget.zig index 8ad75f5d0..01b3f3e5c 100644 --- a/src/apprt/gtk/class/imgui_widget.zig +++ b/src/apprt/gtk/class/imgui_widget.zig @@ -63,6 +63,12 @@ pub const ImguiWidget = extern struct { /// Our previous instant used to calculate delta time for animations. instant: ?std.time.Instant = null, + /// Tick callback ID for timed updates. + tick_callback_id: c_uint = 0, + + /// Last render time for throttling to 30 FPS. + last_render_time: ?std.time.Instant = null, + pub var offset: c_int = 0; }; @@ -231,11 +237,26 @@ pub const ImguiWidget = extern struct { // Call the virtual method to setup the UI. self.setup(); + + // Add a tick callback to drive timed updates via the frame clock. + priv.tick_callback_id = self.as(gtk.Widget).addTickCallback( + tickCallback, + null, + null, + ); } /// Handle a request to unrealize the GLArea fn glAreaUnrealize(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { - assert(self.private().ig_context != null); + const priv = self.private(); + assert(priv.ig_context != null); + + // Remove the tick callback if it was registered. + if (priv.tick_callback_id != 0) { + self.as(gtk.Widget).removeTickCallback(priv.tick_callback_id); + priv.tick_callback_id = 0; + } + self.setCurrentContext() catch return; cimgui.ImGui_ImplOpenGL3_Shutdown(); } @@ -265,6 +286,10 @@ pub const ImguiWidget = extern struct { fn glAreaRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Self) callconv(.c) c_int { self.setCurrentContext() catch return @intFromBool(false); + // Update last render time for tick callback throttling. + const priv = self.private(); + priv.last_render_time = std.time.Instant.now() catch null; + // Setup our frame. We render twice because some ImGui behaviors // take multiple renders to process. I don't know how to make this // more efficient. @@ -411,6 +436,34 @@ pub const ImguiWidget = extern struct { cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes); } + /// Tick callback for timed updates. This drives periodic redraws. + /// Redraws are limited to 30 FPS max since our imgui widgets don't + /// usually need higher frame rates than that. + fn tickCallback( + widget: *gtk.Widget, + _: *gdk.FrameClock, + _: ?*anyopaque, + ) callconv(.c) c_int { + const self: *Self = gobject.ext.cast(Self, widget) orelse return 0; + const priv = self.private(); + + const now = std.time.Instant.now() catch { + self.queueRender(); + return 1; + }; + + // Throttle to 30 FPS (~33ms between frames) + const frame_time_ns: u64 = std.time.ns_per_s / 30; + const should_render = if (priv.last_render_time) |last| + now.since(last) >= frame_time_ns + else + true; + + if (should_render) self.queueRender(); + + return 1; // Continue the tick callback + } + //--------------------------------------------------------------- // Default virtual method handlers From 2d02e4bb546c7ba2dcabe912fc005abaa8d1071a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 1 Feb 2026 14:31:43 -0800 Subject: [PATCH 106/108] remove redraw_inspector message --- .../Sources/Ghostty/Surface View/InspectorView.swift | 2 -- src/App.zig | 12 +----------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/InspectorView.swift b/macos/Sources/Ghostty/Surface View/InspectorView.swift index e8ad9e59d..03be794e9 100644 --- a/macos/Sources/Ghostty/Surface View/InspectorView.swift +++ b/macos/Sources/Ghostty/Surface View/InspectorView.swift @@ -415,8 +415,6 @@ extension Ghostty { // MARK: MTKView override func draw(_ dirtyRect: NSRect) { - Ghostty.logger.warning("inspector draw at \(Date())") - guard let commandBuffer = self.commandQueue.makeCommandBuffer(), let descriptor = self.currentRenderPassDescriptor else { diff --git a/src/App.zig b/src/App.zig index 3e83e414d..33c8318db 100644 --- a/src/App.zig +++ b/src/App.zig @@ -240,7 +240,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void { if (comptime std.log.logEnabled(.debug, .app)) { switch (message) { // these tend to be way too verbose for normal debugging - .redraw_surface, .redraw_inspector => {}, + .redraw_surface => {}, else => log.debug("mailbox message={t}", .{message}), } } @@ -250,7 +250,6 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void { .close => |surface| self.closeSurface(surface), .surface_message => |msg| try self.surfaceMessage(msg.surface, msg.message), .redraw_surface => |surface| try self.redrawSurface(rt_app, surface), - .redraw_inspector => |surface| self.redrawInspector(rt_app, surface), // If we're quitting, then we set the quit flag and stop // draining the mailbox immediately. This lets us defer @@ -289,11 +288,6 @@ fn redrawSurface( ); } -fn redrawInspector(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) void { - if (!self.hasRtSurface(surface)) return; - rt_app.redrawInspector(surface); -} - /// Create a new window pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void { const target: apprt.Target = target: { @@ -565,10 +559,6 @@ pub const Message = union(enum) { /// message if it needs to. redraw_surface: *apprt.Surface, - /// Redraw the inspector. This is called whenever some non-OS event - /// causes the inspector to need to be redrawn. - redraw_inspector: *apprt.Surface, - const NewWindow = struct { /// The parent surface parent: ?*Surface = null, From 8714db8ea73f02d1a601bba54f1e0565f0fc673b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 00:22:00 +0000 Subject: [PATCH 107/108] build(deps): bump namespacelabs/nscloud-cache-action Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.2.21 to 1.3.0. - [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-cache-action/compare/446d8f390563cd54ca27e8de5bdb816f63c0b706...a4cc4697b9de3b562bec5a856e6f3c31f94506e2) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-cache-action dependency-version: 1.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 2 +- .github/workflows/snap.yml | 2 +- .github/workflows/test.yml | 48 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 90ce82989..876137517 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -41,7 +41,7 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 1342c4db6..4b46a83c2 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 82645c102..d9fe4e5a8 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -161,7 +161,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index 6fc7e0fb4..c625ea043 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -38,7 +38,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f253f249..dd4f506a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,7 +77,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -120,7 +120,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -153,7 +153,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -187,7 +187,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -231,7 +231,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -267,7 +267,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -296,7 +296,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -329,7 +329,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -375,7 +375,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -604,7 +604,7 @@ jobs: echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -646,7 +646,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -694,7 +694,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -729,7 +729,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -793,7 +793,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -822,7 +822,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -852,7 +852,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -881,7 +881,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -908,7 +908,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -935,7 +935,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -962,7 +962,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -994,7 +994,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -1021,7 +1021,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -1056,7 +1056,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -1118,7 +1118,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 9395f19e1..33c55bb42 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix From c87db66631b7e35a154bd7f31d8ef58922eeed83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 00:22:09 +0000 Subject: [PATCH 108/108] build(deps): bump namespacelabs/nscloud-setup-buildx-action Bumps [namespacelabs/nscloud-setup-buildx-action](https://github.com/namespacelabs/nscloud-setup-buildx-action) from 0.0.21 to 0.0.22. - [Release notes](https://github.com/namespacelabs/nscloud-setup-buildx-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-setup-buildx-action/compare/a7e525416136ee2842da3c800e7067b72a27200e...f5814dcf37a16cce0624d5bec2ab879654294aa0) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-setup-buildx-action dependency-version: 0.0.22 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f253f249..14cb083e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1085,7 +1085,7 @@ jobs: uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10 - name: Configure Namespace powered Buildx - uses: namespacelabs/nscloud-setup-buildx-action@a7e525416136ee2842da3c800e7067b72a27200e # v0.0.21 + uses: namespacelabs/nscloud-setup-buildx-action@f5814dcf37a16cce0624d5bec2ab879654294aa0 # v0.0.22 - name: Download Source Tarball Artifacts uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0