Files
ghostty-rs/crates/ghostty-inspector/src/widgets/pagelist.rs
T
ddidderr 90ad602969 feat(inspector): port the terminal inspector
Ports ZIG/src/inspector (the Dear ImGui terminal inspector) as the
ghostty-inspector crate using dear-imgui-rs + dear-imgui-glow (imgui-rs
lacks a safe docking API): screen, modes, keyboard events, terminal IO
/ VT sequence log with filtering, cell inspector with picking, page
list, renderer and size/font info, with Ghostty's docking layout. The
crate denies unsafe except a single dock.rs module wrapping DockBuilder.

GTK: imgui_widget, inspector_widget and inspector_window classes with
their blueprints replace the former no-op; ctrl+shift+i toggles the
inspector window.

Core: activate/deactivate hooks, key event and mouse recording, and VT
stream recording in termio while an inspector is active, as in Zig;
overlay features reach the renderer through an InspectorHook on the
render state. The debug input replay hook gained inspector-screenshot/
click/scroll steps used to verify all tabs in the real app.

Deviations: font size scales with the display scale instead of Zig's
fixed 16*2; pop-out dock splits apply one frame later; DCS/APC
descriptions are approximated.

Test Plan:
- cargo test -p ghostty-inspector (15 incl. headless full-UI draw)
- cargo test -p ghostty-core --test e2e_inspector (4)
2026-10-12 04:39:14 +02:00

803 lines
25 KiB
Rust

//! PageList inspector (ZIG/src/inspector/widgets/pagelist.zig).
use dear_imgui_rs::{
ComboBoxHeight, ComboBoxOptions, ItemHoveredFlags, MouseButton, StyleColor, TableFlags,
TreeNodeFlags, Ui,
};
use ghostty_terminal::page_list::{PageList, Scroll, Scrollbar};
use ghostty_terminal::point::{Coordinate, Point, Tag};
use ghostty_terminal::style::DEFAULT_ID;
use super::{help_marker, page, style};
use crate::units::to_kibi_bytes;
fn begin_table<'a>(ui: &'a Ui, id: &str, columns: usize) -> Option<dear_imgui_rs::TableToken<'a>> {
ui.begin_table_with_flags(
id,
columns,
dear_imgui_rs::TableOptions::default()
.flags(TableFlags::BORDERS_INNER_V | TableFlags::ROW_BG)
.sizing_policy(dear_imgui_rs::TableSizingPolicy::FixedFit),
)
}
const TAGS: [Tag; 4] = [Tag::Active, Tag::Viewport, Tag::Screen, Tag::History];
/// PageList inspector widget.
#[derive(Debug, Default)]
pub struct Inspector;
impl Inspector {
pub fn draw(&self, ui: &Ui, pages: &mut PageList) {
ui.text_wrapped(
"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 ui.collapsing_header("Overview", TreeNodeFlags::DEFAULT_OPEN) {
summary_table(ui, pages);
}
if ui.collapsing_header("Scrollbar & Regions", TreeNodeFlags::DEFAULT_OPEN) {
ui.separator_with_text("Scrollbar");
scrollbar_info(ui, pages);
ui.separator_with_text("Regions");
regions_table(ui, pages);
}
if ui.collapsing_header("Tracked Pins", TreeNodeFlags::DEFAULT_OPEN) {
tracked_pins_table(ui, pages);
}
if ui.collapsing_header("Pages", TreeNodeFlags::DEFAULT_OPEN) {
help_marker(
ui,
"Pages are shown most-recent first. Each page holds a grid of rows/cells \
plus metadata tables for styles, graphemes, strings, and hyperlinks.",
);
let active_pin = pages.get_top_left(Tag::Active);
let viewport_pin = pages.get_top_left(Tag::Viewport);
let mut row_offset = pages.total_rows;
let mut index = pages.total_pages();
let mut node = pages.last;
while let Some(node_id) = node {
let Some(page_node) = pages.node(node_id) else {
break;
};
let pg = &page_node.data;
node = page_node.prev;
row_offset -= usize::from(pg.size.rows);
index -= 1;
// We use our location as the ID so that even if reallocations
// happen we remain open if we're open already.
let _id = ui.push_id(index as i32);
// Open up the tree node.
let Some(_open) = page::tree_node(
ui,
&page::TreeNodeState {
page: pg,
index,
row_range: [
row_offset,
(row_offset + usize::from(pg.size.rows)).wrapping_sub(1),
],
active: active_pin.is_some_and(|p| p.node == node_id),
viewport: viewport_pin.is_some_and(|p| p.node == node_id),
},
) else {
continue;
};
page::inspector(ui, pg);
}
}
}
}
fn summary_table(ui: &Ui, pages: &PageList) {
let Some(_t) = begin_table(ui, "pagelist_summary", 3) else {
return;
};
let row = |name: &str, help: &str, value: String| {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text(name);
ui.table_set_column_index(1);
help_marker(ui, help);
ui.table_set_column_index(2);
ui.text(value);
};
row(
"Active Grid",
"Active viewport size in columns x rows.",
format!("{}c x {}r", pages.cols, pages.rows),
);
row(
"Pages",
"Total number of pages in the linked list.",
pages.total_pages().to_string(),
);
row(
"Total Rows",
"Total rows represented by scrollback + active area.",
pages.total_rows.to_string(),
);
row(
"Page Bytes",
"Total bytes allocated for active pages.",
format!("{} KiB", to_kibi_bytes(pages.page_size)),
);
row(
"Max Size",
"Maximum bytes before pages must be evicated. The total\n\
used bytes may be higher due to minimum individual page\n\
sizes but the next allocation that would exceed this limit\n\
will evict pages from the front of the list to free up space.",
format!("{} KiB", to_kibi_bytes(pages.max_size())),
);
row(
"Viewport",
"Current viewport anchoring mode.",
crate::keys::tag_name(&pages.viewport),
);
row(
"Tracked Pins",
"Number of pins tracked for automatic updates.",
pages.count_tracked_pins().to_string(),
);
}
fn scrollbar_info(ui: &Ui, pages: &mut PageList) {
let scrollbar = pages.scrollbar();
// If we have a scrollbar, show it.
if scrollbar.total > 0 {
let delta_row = scrollbar_widget(ui, &scrollbar);
if delta_row != 0 {
pages.scroll(Scroll::DeltaRow(delta_row));
}
}
let Some(_t) = begin_table(ui, "scrollbar_info", 3) else {
return;
};
let row = |name: &str, help: &str, value: usize| {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text(name);
ui.table_set_column_index(1);
help_marker(ui, help);
ui.table_set_column_index(2);
ui.text(value.to_string());
};
row(
"Total",
"Total number of scrollable rows including scrollback and active area.",
scrollbar.total,
);
row(
"Offset",
"Current scroll position as row offset from the top of scrollback.",
scrollbar.offset,
);
row(
"Length",
"Number of rows visible in the viewport.",
scrollbar.len,
);
}
fn regions_table(ui: &Ui, pages: &PageList) {
let Some(_t) = begin_table(ui, "pagelist_regions", 4) else {
return;
};
page::setup_fixed(ui, "Region");
page::setup_fixed(ui, "");
page::setup_fixed(ui, "Top-Left");
page::setup_stretch(ui, "Bottom-Right");
ui.table_headers_row();
for tag in TAGS {
region_row(ui, pages, tag);
}
}
fn region_row(ui: &Ui, pages: &PageList, tag: Tag) {
let tl_pin = pages.get_top_left(tag);
let br_pin = pages.get_bottom_right(tag);
ui.table_next_row();
ui.table_set_column_index(0);
ui.text(crate::keys::tag_name(&tag));
ui.table_set_column_index(1);
help_marker(ui, region_help_text(tag));
ui.table_set_column_index(2);
if let Some(pt) = tl_pin.and_then(|p| pages.point_from_pin(tag, p)) {
let coord = pt.coord();
ui.text(format!("({}, {})", coord.x, coord.y));
} else {
ui.text_disabled("(n/a)");
}
ui.table_set_column_index(3);
if let Some(br) = br_pin {
if let Some(pt) = pages.point_from_pin(tag, br) {
let coord = pt.coord();
ui.text(format!("({}, {})", coord.x, coord.y));
} else {
ui.text_disabled("(n/a)");
}
} else {
ui.text_disabled("(empty)");
}
}
fn region_help_text(tag: Tag) -> &'static str {
match tag {
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."
}
Tag::Viewport => {
"The visible viewport. If the user has scrolled, top-left changes. \
Bottom-right is the last written row from the top-left."
}
Tag::Screen => {
"Top-left is the furthest back in scrollback history. Bottom-right \
is the last written row. Unlike 'active', this only contains \
written rows."
}
Tag::History => {
"Same top-left as 'screen' but bottom-right is the line just before \
the top of 'active'. Contains only the scrollback history."
}
}
}
fn tracked_pins_table(ui: &Ui, pages: &PageList) {
let Some(_t) = ui.begin_table_with_flags(
"tracked_pins",
5,
dear_imgui_rs::TableOptions::default()
.flags(TableFlags::BORDERS | TableFlags::ROW_BG)
.sizing_policy(dear_imgui_rs::TableSizingPolicy::FixedFit),
) else {
return;
};
page::setup_fixed(ui, "Index");
page::setup_fixed(ui, "Pin");
page::setup_fixed(ui, "Context");
page::setup_fixed(ui, "Dirty");
page::setup_fixed(ui, "State");
ui.table_headers_row();
let active_pin = pages.get_top_left(Tag::Active);
let viewport_pin = pages.get_top_left(Tag::Viewport);
let viewport_handle = pages.viewport_pin_handle();
for (idx, (tracked, pin)) in pages.tracked_pins().enumerate() {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text(idx.to_string());
ui.table_set_column_index(1);
if pin.garbage {
ui.text_colored([1.0, 0.5, 0.3, 1.0], format!("({}, {})", pin.x, pin.y));
} else {
ui.text(format!("({}, {})", pin.x, pin.y));
}
ui.table_set_column_index(2);
if let Some(pt) = pages.point_from_pin(Tag::Screen, pin) {
let coord = pt.coord();
ui.text(format!("screen ({}, {})", coord.x, coord.y));
} else {
ui.text_disabled("screen (out of range)");
}
ui.table_set_column_index(3);
if pin.is_dirty(pages) {
ui.text_colored([1.0, 0.4, 0.4, 1.0], "dirty");
} else {
ui.text_disabled("clean");
}
ui.table_set_column_index(4);
if active_pin.is_some_and(|p| pin.eql(p)) {
ui.text_colored([0.4, 0.9, 0.4, 1.0], "active top");
} else if viewport_pin.is_some_and(|p| pin.eql(p)) {
ui.text_colored([0.4, 0.8, 1.0, 1.0], "viewport top");
} else if pin.garbage {
ui.text_colored([1.0, 0.5, 0.3, 1.0], "garbage");
} else if viewport_handle == Some(tracked) {
ui.text("viewport pin");
} else {
ui.text_disabled("tracked");
}
}
}
/// Draws the scrollbar; returns the number of rows the user dragged.
fn scrollbar_widget(ui: &Ui, scrollbar: &Scrollbar) -> isize {
let mut delta_row: isize = 0;
let avail_width = ui.content_region_avail()[0];
let bar_height = ui.frame_height();
let cursor_pos = ui.cursor_screen_pos();
let total_f = scrollbar.total as f32;
let offset_f = scrollbar.offset as f32;
let len_f = scrollbar.len as f32;
let grab_start = (offset_f / total_f) * avail_width;
let grab_width = ((len_f / total_f) * avail_width).max(4.0);
let draw_list = ui.get_window_draw_list();
let bg_color = ui.get_color_u32(StyleColor::ScrollbarBg);
let grab_color = ui.get_color_u32(StyleColor::ScrollbarGrab);
draw_list
.add_rect(
cursor_pos,
[cursor_pos[0] + avail_width, cursor_pos[1] + bar_height],
bg_color,
)
.filled(true)
.build();
draw_list
.add_rect(
[cursor_pos[0] + grab_start, cursor_pos[1]],
[
cursor_pos[0] + grab_start + grab_width,
cursor_pos[1] + bar_height,
],
grab_color,
)
.filled(true)
.build();
ui.invisible_button("scrollbar_drag", [avail_width, bar_height]);
if ui.is_item_active() {
let drag_delta = ui.mouse_drag_delta_with_threshold(MouseButton::Left, 0.0);
if drag_delta[0] != 0.0 {
let row_delta = (drag_delta[0] / avail_width) * total_f;
delta_row = row_delta as isize;
ui.reset_mouse_drag_delta(MouseButton::Left);
}
}
if ui.is_item_hovered_with_flags(ItemHoveredFlags::DELAY_SHORT) {
ui.set_tooltip(format!(
"offset={} len={} total={}",
scrollbar.offset, scrollbar.len, scrollbar.total
));
}
delta_row
}
/// Grid inspector widget for choosing and inspecting a specific cell.
#[derive(Debug)]
pub struct CellChooser {
pub lookup_region: Tag,
pub lookup_coord: Coordinate,
pub cell_info: CellInfo,
}
impl Default for CellChooser {
fn default() -> Self {
Self {
lookup_region: Tag::Viewport,
lookup_coord: Coordinate { x: 0, y: 0 },
cell_info: CellInfo,
}
}
}
impl CellChooser {
pub fn draw(&mut self, ui: &Ui, pages: &PageList) {
ui.text_wrapped(
"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.",
);
ui.separator_with_text("Cell Inspector");
let region_max = max_coord(pages, self.lookup_region);
if let Some(coord) = region_max {
self.lookup_coord.x = self.lookup_coord.x.min(coord.x);
self.lookup_coord.y = self.lookup_coord.y.min(coord.y);
} else {
self.lookup_coord = Coordinate { x: 0, y: 0 };
}
{
let disabled = region_max.is_none();
let _disabled = ui.begin_disabled_with_cond(disabled);
let preview = crate::keys::tag_name(&self.lookup_region);
let combo_width = TAGS
.iter()
.map(|t| crate::keys::tag_name(t).len())
.max()
.unwrap_or(0)
+ 4;
ui.set_next_item_width(ui.calc_text_size("X".repeat(combo_width))[0]);
if let Some(_combo) = ui.begin_combo_with_flags(
"##grid_region",
preview,
ComboBoxOptions::new().height(ComboBoxHeight::Small),
) {
for tag in TAGS {
let selected = tag == self.lookup_region;
if ui
.selectable_config(crate::keys::tag_name(&tag))
.selected(selected)
.build()
{
self.lookup_region = tag;
}
if selected {
ui.set_item_default_focus();
}
}
}
ui.same_line();
let width = ui.calc_text_size("00000")[0];
let mut x_value: u16 = self.lookup_coord.x;
let mut y_value: u32 = self.lookup_coord.y;
let mut changed = false;
ui.align_text_to_frame_padding();
ui.text("x:");
ui.same_line();
ui.set_next_item_width(width);
if ui.input_scalar("##grid_x", &mut x_value).build() {
changed = true;
}
ui.same_line();
ui.align_text_to_frame_padding();
ui.text("y:");
ui.same_line();
ui.set_next_item_width(width);
if ui.input_scalar("##grid_y", &mut y_value).build() {
changed = true;
}
ui.same_line();
help_marker(
ui,
"Choose the coordinate space and X/Y position (0-indexed).",
);
if changed && let Some(coord) = region_max {
self.lookup_coord.x = x_value.min(coord.x);
self.lookup_coord.y = y_value.min(coord.y);
}
}
if let Some(coord) = region_max {
ui.text_disabled(format!("Range: x 0..{}, y 0..{}", coord.x, coord.y));
} else {
ui.text_disabled("(region has no rows)");
return;
}
let pt = Point::new(self.lookup_region, self.lookup_coord);
let Some(cell) = lookup_cell(pages, pt) else {
ui.text_disabled("(cell out of range)");
return;
};
CellInfo::draw(ui, &cell, pt);
if cell.cell.style_id() != DEFAULT_ID {
ui.separator_with_text("Style");
let st = cell.page().style(cell.cell);
style::table(ui, &st, None);
}
if cell.cell.hyperlink() {
ui.separator_with_text("Hyperlink");
hyperlink_table(ui, &cell);
}
if cell.cell.has_grapheme() {
ui.separator_with_text("Grapheme");
grapheme_table(ui, &cell);
}
}
}
/// The resolved cell of a point (Zig: `PageList.Cell`).
struct CellRef<'a> {
page: &'a ghostty_terminal::page::Page,
row: ghostty_terminal::page::Row,
cell: ghostty_terminal::page::Cell,
row_idx: usize,
col_idx: usize,
}
impl CellRef<'_> {
fn page(&self) -> &ghostty_terminal::page::Page {
self.page
}
fn cell_index(&self) -> usize {
self.page
.cell_index(self.col_idx, self.row_idx)
.unwrap_or(0)
}
}
fn lookup_cell(pages: &PageList, pt: Point) -> Option<CellRef<'_>> {
let pin = pages.pin(pt)?;
let page = &pages.node(pin.node)?.data;
let (row, cell) = page.get_row_and_cell(usize::from(pin.x), usize::from(pin.y))?;
Some(CellRef {
page,
row: *row,
cell: *cell,
row_idx: usize::from(pin.y),
col_idx: usize::from(pin.x),
})
}
fn max_coord(pages: &PageList, tag: Tag) -> Option<Coordinate> {
let br_pin = pages.get_bottom_right(tag)?;
let br_point = pages.point_from_pin(tag, br_pin)?;
Some(br_point.coord())
}
fn hyperlink_table(ui: &Ui, cell: &CellRef<'_>) {
let Some(_t) = ui.begin_table_with_flags("cell_hyperlink", 2, TableFlags::NONE) else {
return;
};
let page = cell.page();
let Some(link_id) = page.lookup_hyperlink(cell.cell_index()) else {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Status");
ui.table_set_column_index(1);
ui.text_disabled("(missing link data)");
return;
};
let Some(entry) = page.hyperlink_set.get(link_id) else {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Status");
ui.table_set_column_index(1);
ui.text_disabled("(missing link data)");
return;
};
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("ID");
ui.table_set_column_index(1);
match &entry.id {
ghostty_terminal::hyperlink::HyperlinkId::Implicit(value) => {
ui.text(format!("implicit {value}"));
}
ghostty_terminal::hyperlink::HyperlinkId::Explicit(id) => {
if id.is_empty() {
ui.text_disabled("(empty)");
} else {
ui.text(String::from_utf8_lossy(id));
}
}
}
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("URI");
ui.table_set_column_index(1);
if entry.uri.is_empty() {
ui.text_disabled("(empty)");
} else {
ui.text(String::from_utf8_lossy(&entry.uri));
}
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Ref Count");
ui.table_set_column_index(1);
ui.text(page.hyperlink_set.ref_count(link_id).to_string());
}
fn grapheme_table(ui: &Ui, cell: &CellRef<'_>) {
let Some(_t) = ui.begin_table_with_flags("cell_grapheme", 2, TableFlags::NONE) else {
return;
};
let page = cell.page();
let Some(cps) = page.lookup_grapheme(cell.cell_index()) else {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Status");
ui.table_set_column_index(1);
ui.text_disabled("(missing grapheme data)");
return;
};
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Extra Codepoints");
ui.table_set_column_index(1);
if cps.is_empty() {
ui.text_disabled("(none)");
return;
}
if let Some(_list) = ui.list_box_config("##grapheme_list").begin(ui) {
for cp in cps {
ui.selectable(format!("U+{cp:X}"));
}
}
}
/// Cell inspector widget.
#[derive(Debug, Default)]
pub struct CellInfo;
impl CellInfo {
fn draw(ui: &Ui, cell: &CellRef<'_>, point: Point) {
let Some(_t) = begin_table(ui, "cell_info", 3) else {
return;
};
let row3 = |name: &str, help: &str, f: &dyn Fn()| {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text(name);
ui.table_set_column_index(1);
help_marker(ui, help);
ui.table_set_column_index(2);
f();
};
row3(
"Grid Position",
"The cell's X/Y coordinates in the selected region.",
&|| {
let coord = point.coord();
ui.text(format!("({}, {})", coord.x, coord.y));
},
);
row3(
"Page Location",
"Row and column indices within the backing page.",
&|| ui.text(format!("row={} col={}", cell.row_idx, cell.col_idx)),
);
row3(
"Content",
"Content tag describing how the cell data is stored.",
&|| ui.text(crate::keys::tag_name(&cell.cell.content_tag())),
);
row3(
"Codepoint",
"Primary Unicode codepoint for the cell.",
&|| {
let cp = cell.cell.codepoint();
if cp == 0 {
ui.text_disabled("(empty)");
} else {
ui.text(format!("U+{cp:04X}"));
}
},
);
if cell.cell.has_grapheme() {
row3(
"Grapheme",
"Extra codepoints that combine with the primary codepoint to form the grapheme cluster.",
&|| {
if let Some(_list) = ui.list_box_config("##cell_grapheme").begin(ui) {
if let Some(cps) = cell.page().lookup_grapheme(cell.cell_index()) {
for cp in cps {
ui.selectable(format!("U+{cp:X}"));
}
} else {
ui.selectable("(missing)");
}
}
},
);
}
row3(
"Width Property",
"Character width property (narrow, wide, spacer, etc.).",
&|| ui.text(crate::keys::tag_name(&cell.cell.wide())),
);
row3(
"Row Flags",
"Flags set on the row containing this cell.",
&|| {
let row = cell.row;
if row.wrap()
|| row.wrap_continuation()
|| row.grapheme()
|| row.styled()
|| row.hyperlink()
{
if row.wrap() {
ui.text_colored([0.4, 0.8, 1.0, 1.0], "wrap");
ui.same_line();
}
if row.wrap_continuation() {
ui.text_colored([0.4, 0.8, 1.0, 1.0], "cont");
ui.same_line();
}
if row.grapheme() {
ui.text_colored([0.9, 0.7, 0.3, 1.0], "grapheme");
ui.same_line();
}
if row.styled() {
ui.text_colored([0.7, 0.9, 0.5, 1.0], "styled");
ui.same_line();
}
if row.hyperlink() {
ui.text_colored([0.8, 0.6, 1.0, 1.0], "link");
ui.same_line();
}
} else {
ui.text_disabled("(none)");
}
},
);
row3(
"Style ID",
"Internal style reference ID for this cell.",
&|| ui.text(cell.cell.style_id().to_string()),
);
row3(
"Style",
"Resolved style for the cell (colors, attributes, etc.).",
&|| {
if cell.cell.style_id() == DEFAULT_ID {
ui.text_disabled("(default)");
} else {
ui.text_disabled("(see below)");
}
},
);
if cell.cell.hyperlink() {
row3(
"Hyperlink",
"OSC8 hyperlink ID associated with this cell.",
&|| {
let link_id = cell.page().lookup_hyperlink(cell.cell_index()).unwrap_or(0);
ui.text(format!("id={link_id}"));
},
);
}
}
}