From c8e048f309ea39c44bd29ad60b3f12b732d4142c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Jan 2026 13:34:34 -0800 Subject: [PATCH] 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.