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)
This commit is contained in:
1 parent
b192fdb046
commit
d1a5e55198
25 files changed
+6693
No files matched your search
@@ -0,0 +1,408 @@
|
||||
//! The inspector window layout and surface information
|
||||
//! (ZIG/src/inspector/widgets/surface.zig).
|
||||
|
||||
use dear_imgui_rs::{DockNodeFlags, TableFlags, TreeNodeFlags, Ui, WindowFlags};
|
||||
use ghostty_renderer::size::{Coordinate, CoordinateTag, Size, SurfaceCoord};
|
||||
use ghostty_terminal::page_list::Pin;
|
||||
use ghostty_terminal::point::{Coordinate as PointCoordinate, Tag};
|
||||
use ghostty_terminal::terminal::Terminal;
|
||||
|
||||
use super::{key, renderer, terminal, termio};
|
||||
use crate::dock::begin_window;
|
||||
|
||||
/// This is discovered via the hardcoded string in the ImGui demo window.
|
||||
const WINDOW_IMGUI_DEMO: &str = "Dear ImGui Demo";
|
||||
const WINDOW_KEYBOARD: &str = "Keyboard";
|
||||
const WINDOW_TERMINAL: &str = "Terminal";
|
||||
const WINDOW_SURFACE: &str = "Surface";
|
||||
const WINDOW_TERMIO: &str = "Terminal IO";
|
||||
const WINDOW_RENDERER: &str = "Renderer";
|
||||
|
||||
/// The state of the surface that the inspector shows (the fields of the Zig
|
||||
/// `Surface` that `surface.zig` reads). The core surface fills this in.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SurfaceInfo {
|
||||
/// The size of the surface (`surface.size`).
|
||||
pub size: Size,
|
||||
|
||||
/// The font size in points and in pixels (`surface.font_size`).
|
||||
pub font_size_points: f32,
|
||||
pub font_size_pixels: f32,
|
||||
|
||||
/// Whether each mouse button (indexed by the button's integer value) is
|
||||
/// currently pressed (`surface.mouse.click_state`).
|
||||
pub click_pressed: [bool; 12],
|
||||
|
||||
/// The pin of the left click (`surface.mouse.left_click_pin`), already
|
||||
/// resolved against the active screen.
|
||||
pub left_click_pin: Option<Pin>,
|
||||
pub left_click_xpos: f64,
|
||||
pub left_click_ypos: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Inspector {
|
||||
/// Internal GUI state
|
||||
pub surface_info: Info,
|
||||
pub key_stream: key::Stream,
|
||||
pub terminal_info: terminal::Info,
|
||||
pub vt_stream: termio::Stream,
|
||||
pub renderer_info: renderer::Info,
|
||||
pub show_demo_window: bool,
|
||||
}
|
||||
|
||||
impl Default for Inspector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Inspector {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
surface_info: Info,
|
||||
key_stream: key::Stream::new(),
|
||||
terminal_info: terminal::Info::default(),
|
||||
vt_stream: termio::Stream::new(),
|
||||
renderer_info: renderer::Info::default(),
|
||||
show_demo_window: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, ui: &Ui, surface: &SurfaceInfo, mouse: &Mouse, t: &mut Terminal) {
|
||||
// Create our dockspace first. If we had to setup our dockspace,
|
||||
// then it is a first render.
|
||||
let first_render = Self::create_dock_space(ui);
|
||||
|
||||
// Draw everything that requires the terminal state mutex (the caller
|
||||
// holds it).
|
||||
{
|
||||
// Terminal info window
|
||||
{
|
||||
let (open, _scope) = begin_window(
|
||||
ui,
|
||||
WINDOW_TERMINAL,
|
||||
None,
|
||||
WindowFlags::NO_FOCUS_ON_APPEARING,
|
||||
);
|
||||
self.terminal_info.draw(ui, open, t);
|
||||
}
|
||||
|
||||
// Surface info window
|
||||
{
|
||||
let (open, _scope) =
|
||||
begin_window(ui, WINDOW_SURFACE, None, WindowFlags::NO_FOCUS_ON_APPEARING);
|
||||
self.surface_info.draw(ui, open, surface, mouse, t);
|
||||
}
|
||||
|
||||
// Keyboard info window
|
||||
{
|
||||
let (open, _scope) = begin_window(
|
||||
ui,
|
||||
WINDOW_KEYBOARD,
|
||||
None,
|
||||
WindowFlags::NO_FOCUS_ON_APPEARING,
|
||||
);
|
||||
self.key_stream.draw(ui, open);
|
||||
}
|
||||
|
||||
// Terminal IO window
|
||||
{
|
||||
let (open, _scope) =
|
||||
begin_window(ui, WINDOW_TERMIO, None, WindowFlags::NO_FOCUS_ON_APPEARING);
|
||||
if open {
|
||||
self.vt_stream.draw(ui, &t.colors.palette.current);
|
||||
}
|
||||
}
|
||||
|
||||
// Renderer info window
|
||||
{
|
||||
let (open, _scope) = begin_window(
|
||||
ui,
|
||||
WINDOW_RENDERER,
|
||||
None,
|
||||
WindowFlags::NO_FOCUS_ON_APPEARING,
|
||||
);
|
||||
self.renderer_info.draw(ui, open);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the dock splits requested by the pop-out headers.
|
||||
crate::dock::flush_splits(ui);
|
||||
|
||||
// In debug we show the ImGui demo window so we can easily view
|
||||
// available widgets and such.
|
||||
if cfg!(debug_assertions) && self.show_demo_window {
|
||||
ui.show_demo_window(&mut 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
|
||||
// focus afterward without it snapping back.
|
||||
ui.set_window_focus(Some(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 create_dock_space(ui: &Ui) -> bool {
|
||||
let dockspace_id = ui.get_id("Main Dockspace").raw();
|
||||
|
||||
// Initial Docking setup
|
||||
let setup = !crate::dock::dock_node_exists(ui, dockspace_id);
|
||||
if setup {
|
||||
// Register our dockspace node, make it the full size of the
|
||||
// viewport. We only initialize one central docking point now but
|
||||
// this is the point we'd pre-split and so on for the initial
|
||||
// layout.
|
||||
let size = ui.main_viewport().size();
|
||||
crate::dock::create_dock_space_node(
|
||||
ui,
|
||||
dockspace_id,
|
||||
Some(size),
|
||||
&[
|
||||
WINDOW_IMGUI_DEMO,
|
||||
WINDOW_TERMINAL,
|
||||
WINDOW_SURFACE,
|
||||
WINDOW_KEYBOARD,
|
||||
WINDOW_TERMIO,
|
||||
WINDOW_RENDERER,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Put the dockspace over the viewport.
|
||||
ui.dock_space_over_main_viewport_raw(
|
||||
dockspace_id.into(),
|
||||
DockNodeFlags::PASSTHRU_CENTRAL_NODE,
|
||||
);
|
||||
setup
|
||||
}
|
||||
}
|
||||
|
||||
/// Mouse state that we track in addition to normal mouse states that
|
||||
/// Ghostty always knows about.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Mouse {
|
||||
/// Last hovered x/y
|
||||
pub last_xpos: f64,
|
||||
pub last_ypos: f64,
|
||||
|
||||
// Last hovered screen point
|
||||
pub last_point: Option<Pin>,
|
||||
}
|
||||
|
||||
/// Surface information inspector widget.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Info;
|
||||
|
||||
impl Info {
|
||||
/// Draw the surface info window.
|
||||
pub fn draw(&self, ui: &Ui, open: bool, surface: &SurfaceInfo, mouse: &Mouse, t: &Terminal) {
|
||||
if !open {
|
||||
return;
|
||||
}
|
||||
|
||||
if ui.collapsing_header("Help", TreeNodeFlags::NONE) {
|
||||
ui.text_wrapped(
|
||||
"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.",
|
||||
);
|
||||
}
|
||||
|
||||
ui.separator_with_text("Dimensions");
|
||||
dimensions_table(ui, surface);
|
||||
|
||||
ui.separator_with_text("Font");
|
||||
font_table(ui, surface);
|
||||
|
||||
ui.separator_with_text("Mouse");
|
||||
mouse_table(ui, surface, mouse, t);
|
||||
}
|
||||
}
|
||||
|
||||
fn dimensions_table(ui: &Ui, surface: &SurfaceInfo) {
|
||||
let Some(_t) = ui.begin_table_with_flags("table_size", 2, TableFlags::NONE) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let row = |name: &str, value: String| {
|
||||
ui.table_next_row();
|
||||
ui.table_set_column_index(0);
|
||||
ui.text(name);
|
||||
ui.table_set_column_index(1);
|
||||
ui.text(value);
|
||||
};
|
||||
|
||||
let size = &surface.size;
|
||||
row(
|
||||
"Screen Size",
|
||||
format!("{}px x {}px", size.screen.width, size.screen.height),
|
||||
);
|
||||
let grid_size = size.grid();
|
||||
row(
|
||||
"Grid Size",
|
||||
format!("{}c x {}r", grid_size.columns, grid_size.rows),
|
||||
);
|
||||
row(
|
||||
"Cell Size",
|
||||
format!("{}px x {}px", size.cell.width, size.cell.height),
|
||||
);
|
||||
row(
|
||||
"Window Padding",
|
||||
format!(
|
||||
"T={} B={} L={} R={} px",
|
||||
size.padding.top, size.padding.bottom, size.padding.left, size.padding.right
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn font_table(ui: &Ui, surface: &SurfaceInfo) {
|
||||
let Some(_t) = ui.begin_table_with_flags("table_font", 2, TableFlags::NONE) else {
|
||||
return;
|
||||
};
|
||||
|
||||
{
|
||||
ui.table_next_row();
|
||||
ui.table_set_column_index(0);
|
||||
ui.text("Size (Points)");
|
||||
ui.table_set_column_index(1);
|
||||
ui.text(format!("{:.2} pt", surface.font_size_points));
|
||||
}
|
||||
|
||||
{
|
||||
ui.table_next_row();
|
||||
ui.table_set_column_index(0);
|
||||
ui.text("Size (Pixels)");
|
||||
ui.table_set_column_index(1);
|
||||
ui.text(format!("{:.2} px", surface.font_size_pixels));
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_table(ui: &Ui, surface: &SurfaceInfo, mouse: &Mouse, t: &Terminal) {
|
||||
let Some(_t) = ui.begin_table_with_flags("table_mouse", 2, TableFlags::NONE) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let pages = &t.screens.active().pages;
|
||||
|
||||
{
|
||||
let hover_point = mouse
|
||||
.last_point
|
||||
.and_then(|p| pages.point_from_pin(Tag::Active, p))
|
||||
.map_or_else(
|
||||
PointCoordinate::default,
|
||||
ghostty_terminal::point::Point::coord,
|
||||
);
|
||||
|
||||
ui.table_next_row();
|
||||
ui.table_set_column_index(0);
|
||||
ui.text("Hover Grid");
|
||||
ui.table_set_column_index(1);
|
||||
ui.text(format!("row={}, col={}", hover_point.y, hover_point.x));
|
||||
}
|
||||
|
||||
{
|
||||
let Coordinate::Terminal(coord) = Coordinate::Surface(SurfaceCoord {
|
||||
x: mouse.last_xpos,
|
||||
y: mouse.last_ypos,
|
||||
})
|
||||
.convert(CoordinateTag::Terminal, surface.size) else {
|
||||
unreachable!("converted to the terminal coordinate space");
|
||||
};
|
||||
|
||||
ui.table_next_row();
|
||||
ui.table_set_column_index(0);
|
||||
ui.text("Hover Point");
|
||||
ui.table_set_column_index(1);
|
||||
ui.text(format!("({}px, {}px)", coord.x as i64, coord.y as i64));
|
||||
}
|
||||
|
||||
let any_click = surface.click_pressed.iter().any(|p| *p);
|
||||
|
||||
{
|
||||
ui.table_next_row();
|
||||
ui.table_set_column_index(0);
|
||||
ui.text("Click State");
|
||||
ui.table_set_column_index(1);
|
||||
if any_click {
|
||||
for (i, pressed) in surface.click_pressed.iter().enumerate() {
|
||||
if !*pressed {
|
||||
continue;
|
||||
}
|
||||
ui.same_line();
|
||||
ui.text(mouse_button_label(i));
|
||||
}
|
||||
} else {
|
||||
ui.text("none");
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let left_click_point = surface
|
||||
.left_click_pin
|
||||
.and_then(|p| pages.point_from_pin(Tag::Active, p))
|
||||
.map_or_else(
|
||||
PointCoordinate::default,
|
||||
ghostty_terminal::point::Point::coord,
|
||||
);
|
||||
|
||||
ui.table_next_row();
|
||||
ui.table_set_column_index(0);
|
||||
ui.text("Click Grid");
|
||||
ui.table_set_column_index(1);
|
||||
ui.text(format!(
|
||||
"row={}, col={}",
|
||||
left_click_point.y, left_click_point.x
|
||||
));
|
||||
}
|
||||
|
||||
{
|
||||
ui.table_next_row();
|
||||
ui.table_set_column_index(0);
|
||||
ui.text("Click Point");
|
||||
ui.table_set_column_index(1);
|
||||
ui.text(format!(
|
||||
"({}px, {}px)",
|
||||
surface.left_click_xpos as u32, surface.left_click_ypos as u32
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// The label for a mouse button given its integer value (`input.MouseButton`).
|
||||
fn mouse_button_label(button: usize) -> &'static str {
|
||||
match button {
|
||||
1 => "L",
|
||||
2 => "R",
|
||||
3 => "M",
|
||||
4 => "{4}",
|
||||
5 => "{5}",
|
||||
6 => "{6}",
|
||||
7 => "{7}",
|
||||
8 => "{8}",
|
||||
9 => "{9}",
|
||||
10 => "{10}",
|
||||
11 => "{11}",
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn button_labels() {
|
||||
assert_eq!(mouse_button_label(0), "?");
|
||||
assert_eq!(mouse_button_label(1), "L");
|
||||
assert_eq!(mouse_button_label(2), "R");
|
||||
assert_eq!(mouse_button_label(3), "M");
|
||||
assert_eq!(mouse_button_label(11), "{11}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user