Files
ghostty-rs/crates/ghostty-inspector/src/dock.rs
T
ddidderr d1a5e55198 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

181 lines
6.5 KiB
Rust

//! Thin wrappers over Dear ImGui's imperative `DockBuilder` API.
//!
//! The safe `dear-imgui-rs` API only exposes declarative dock layouts; the
//! detachable headers need to split the node of the window that is currently
//! being built (Zig: `DockBuilderSplitNode`), so those few calls go through
//! the raw `sys` bindings. This is the only module in the crate that is
//! allowed to use `unsafe`.
#![allow(unsafe_code)]
use dear_imgui_rs::{Ui, sys};
/// The ID of the dock node of the current window (0 when undocked).
pub fn window_dock_id(ui: &Ui) -> u32 {
ui.get_window_dock_id().raw()
}
/// A deferred "split the node to the right and dock this window there"
/// request.
struct SplitRequest {
dock_id: u32,
ratio: f32,
label: String,
}
thread_local! {
static PENDING_SPLITS: std::cell::RefCell<Vec<SplitRequest>> =
const { std::cell::RefCell::new(Vec::new()) };
}
/// Request that the node `dock_id` is split to the right (`ratio` is the
/// fraction given to the new right node) and the window named `label` docked
/// into the right node. Zig performs this immediately while the parent window
/// is still being built (`DockBuilderSplitNode`, `DockBuilderDockWindow`,
/// `DockBuilderFinish`), but that re-parents the dock node of the window
/// being built, which trips an upstream Dear ImGui sanity check when
/// assertions are enabled. The request is applied by [`flush_splits`] once
/// all windows of the frame have ended, so the new window gets docked one
/// frame later.
pub fn split_right_and_dock(dock_id: u32, ratio: f32, label: &str) {
PENDING_SPLITS.with(|p| {
p.borrow_mut().push(SplitRequest {
dock_id,
ratio,
label: label.to_owned(),
});
});
}
/// Apply the requests made by [`split_right_and_dock`]. Must be called with
/// no window being built.
pub fn flush_splits(ui: &Ui) {
let requests = PENDING_SPLITS.with(|p| std::mem::take(&mut *p.borrow_mut()));
for req in requests {
let Ok(c_label) = std::ffi::CString::new(req.label) else {
continue;
};
ui.with_bound_context(|| {
let mut right: sys::ImGuiID = 0;
let mut left: sys::ImGuiID = 0;
// SAFETY: the Dear ImGui context of `ui` is bound (and current)
// for the duration of this closure, all pointers are valid for
// the call, and `c_label` outlives the calls that borrow it.
unsafe {
sys::igDockBuilderSplitNode(
req.dock_id,
sys::ImGuiDir_Right,
req.ratio,
&raw mut right,
&raw mut left,
);
sys::igDockBuilderDockWindow(c_label.as_ptr(), right);
sys::igDockBuilderFinish(req.dock_id);
}
});
}
}
/// `SetNextItemAllowOverlap`: the next item can be overlapped by items
/// submitted after it (used to put a button inside a header).
pub fn set_next_item_allow_overlap(ui: &Ui) {
ui.with_bound_context(|| {
// SAFETY: the context of `ui` is bound for the duration of the call.
unsafe { sys::igSetNextItemAllowOverlap() };
});
}
/// An open `Begin` scope; `End` is called when dropped. Unlike
/// [`Ui::window`], the scope stays open (as in Zig's `Begin` + `defer End`)
/// even when `Begin` returned false, which matters for widgets that must run
/// their per-frame logic regardless of whether the window content is visible.
pub struct WindowScope<'ui> {
ui: &'ui Ui,
}
impl Drop for WindowScope<'_> {
fn drop(&mut self) {
self.ui.with_bound_context(|| {
// SAFETY: balanced with the `igBegin` in `begin_window`.
unsafe { sys::igEnd() };
});
}
}
/// `Begin(name, p_open, flags)`. Returns whether the window content is
/// visible together with the scope that ends the window.
pub fn begin_window<'ui>(
ui: &'ui Ui,
name: &str,
open: Option<&mut bool>,
flags: dear_imgui_rs::WindowFlags,
) -> (bool, WindowScope<'ui>) {
let c_name = std::ffi::CString::new(name.replace('\0', "")).unwrap_or_default();
let open_ptr: *mut bool = match open {
Some(b) => b,
None => std::ptr::null_mut(),
};
let visible = ui.with_bound_context(|| {
// SAFETY: the context is bound, `c_name` is a valid NUL-terminated
// string for the call and `open_ptr` is null or a valid exclusive
// pointer derived from a `&mut bool`.
unsafe { sys::igBegin(c_name.as_ptr(), open_ptr, flags.bits()) }
});
(visible, WindowScope { ui })
}
/// Whether a dock node with the given ID exists (`DockBuilderGetNode`).
pub fn dock_node_exists(ui: &Ui, id: u32) -> bool {
ui.with_bound_context(|| {
// SAFETY: the context is bound; the returned pointer is only compared
// against null.
!unsafe { sys::igDockBuilderGetNode(id) }.is_null()
})
}
/// Create a dockspace node with the given ID and dock all `windows` into it
/// (`DockBuilderAddNode` with the `DockSpace` flag, optionally
/// `DockBuilderSetNodeSize`, `DockBuilderDockWindow`, `DockBuilderFinish`).
pub fn create_dock_space_node(ui: &Ui, id: u32, size: Option<[f32; 2]>, windows: &[&str]) {
let names: Vec<std::ffi::CString> = windows
.iter()
.filter_map(|w| std::ffi::CString::new(*w).ok())
.collect();
ui.with_bound_context(|| {
// SAFETY: the context is bound, and every `CString` outlives the
// calls that borrow it.
unsafe {
let added = sys::igDockBuilderAddNode(id, sys::ImGuiDockNodeFlags_DockSpace);
debug_assert_eq!(added, id);
if let Some(size) = size {
sys::igDockBuilderSetNodeSize(
id,
sys::ImVec2_c {
x: size[0],
y: size[1],
},
);
}
for name in &names {
sys::igDockBuilderDockWindow(name.as_ptr(), id);
}
sys::igDockBuilderFinish(id);
}
});
}
/// `SetNextWindowSize`.
pub fn set_next_window_size(ui: &Ui, size: [f32; 2], cond: dear_imgui_rs::Condition) {
ui.with_bound_context(|| {
// SAFETY: the context is bound for the duration of the call.
unsafe {
sys::igSetNextWindowSize(
sys::ImVec2 {
x: size[0],
y: size[1],
},
cond as i32,
);
}
});
}