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,148 @@
|
||||
//! The terminal inspector hooks of the surface (`Surface.zig`:
|
||||
//! `activateInspector`, `deactivateInspector` and the inspector parts of the
|
||||
//! key and mouse callbacks).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ghostty_apprt::runtime_api::CoreError;
|
||||
use ghostty_inspector::Shared as Inspector;
|
||||
use ghostty_inspector::imgui::Ui;
|
||||
use ghostty_inspector::widgets::surface::SurfaceInfo;
|
||||
use ghostty_renderer::message::Message as RendererMessage;
|
||||
use ghostty_renderer::state::InspectorHook;
|
||||
use ghostty_termio::message::Message as IoMessage;
|
||||
|
||||
use super::Surface;
|
||||
|
||||
impl Surface {
|
||||
/// The active inspector, if any.
|
||||
pub fn inspector(&self) -> Option<Arc<Inspector>> {
|
||||
self.inspector.borrow().clone()
|
||||
}
|
||||
|
||||
/// Activate the inspector. This will begin collecting inspection data.
|
||||
/// This will not affect the GUI. The GUI must use `perform_action` to
|
||||
/// show/hide the inspector UI.
|
||||
pub fn activate_inspector(&self) -> Result<(), CoreError> {
|
||||
if self.inspector.borrow().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Setup the inspector
|
||||
let insp = Arc::new(Inspector::new());
|
||||
*self.inspector.borrow_mut() = Some(Arc::clone(&insp));
|
||||
|
||||
// Put the inspector onto the render state
|
||||
{
|
||||
let mut st = self.lock_state();
|
||||
debug_assert!(st.inspector.is_none());
|
||||
st.inspector = Some(insp as Arc<dyn InspectorHook>);
|
||||
}
|
||||
|
||||
// Notify our components we have an inspector active
|
||||
self.send_renderer(RendererMessage::Inspector(true));
|
||||
self.queue_io(IoMessage::Inspector(true));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deactivate the inspector and stop collecting any information.
|
||||
pub fn deactivate_inspector(&self) {
|
||||
if self.inspector.borrow().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the inspector from the render state
|
||||
{
|
||||
let mut st = self.lock_state();
|
||||
debug_assert!(st.inspector.is_some());
|
||||
st.inspector = None;
|
||||
}
|
||||
|
||||
// Notify our components we have deactivated inspector
|
||||
self.send_renderer(RendererMessage::Inspector(false));
|
||||
self.queue_io(IoMessage::Inspector(false));
|
||||
|
||||
// Drop the inspector
|
||||
*self.inspector.borrow_mut() = None;
|
||||
}
|
||||
|
||||
/// Draw the inspector UI into the current Dear ImGui frame
|
||||
/// (`Inspector.render`). Does nothing if the inspector isn't active.
|
||||
pub fn render_inspector(&self, ui: &Ui) {
|
||||
let Some(insp) = self.inspector() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// The information about the surface that the widgets show.
|
||||
let info = {
|
||||
let font_size = self.font.borrow().size;
|
||||
let m = self.mouse.borrow();
|
||||
let mut click_pressed = [false; 12];
|
||||
for (i, state) in m.click_state.iter().enumerate().take(click_pressed.len()) {
|
||||
click_pressed[i] = *state == ghostty_input::MouseButtonState::Press;
|
||||
}
|
||||
(
|
||||
self.size.get(),
|
||||
font_size,
|
||||
click_pressed,
|
||||
m.left_click_pin,
|
||||
m.left_click_screen,
|
||||
m.left_click_xpos,
|
||||
m.left_click_ypos,
|
||||
)
|
||||
};
|
||||
|
||||
{
|
||||
// The renderer mutex protects the terminal; lock order is the
|
||||
// render state first, then the inspector.
|
||||
let mut guard = self.lock_terminal();
|
||||
let (size, font_size, click_pressed, pin, pin_screen, xpos, ypos) = info;
|
||||
let left_click_pin = if pin_screen == guard.screens.active_key {
|
||||
pin.and_then(|h| guard.screens.active().pages.tracked_pin(h))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let surface = SurfaceInfo {
|
||||
size,
|
||||
font_size_points: font_size.points,
|
||||
font_size_pixels: font_size.pixels(),
|
||||
click_pressed,
|
||||
left_click_pin,
|
||||
left_click_xpos: xpos,
|
||||
left_click_ypos: ypos,
|
||||
};
|
||||
|
||||
insp.lock().render(ui, &surface, &mut guard);
|
||||
}
|
||||
|
||||
// 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.
|
||||
self.queue_render();
|
||||
}
|
||||
|
||||
/// Record the mouse position for the inspector (the inspector block of
|
||||
/// `cursorPosCallback`).
|
||||
pub(crate) fn inspector_record_cursor_pos(&self, xpos: f64, ypos: f64) {
|
||||
let Some(insp) = self.inspector() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let pos_vp = self.pos_to_viewport(xpos, ypos);
|
||||
let pin = self
|
||||
.lock_terminal()
|
||||
.screens
|
||||
.active()
|
||||
.pages
|
||||
.pin(ghostty_terminal::point::Point::Viewport(pos_vp));
|
||||
{
|
||||
let mut guard = insp.lock();
|
||||
guard.mouse.last_xpos = xpos;
|
||||
guard.mouse.last_ypos = ypos;
|
||||
guard.mouse.last_point = pin;
|
||||
}
|
||||
self.queue_render();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! The terminal inspector hooks of the surface: activation, key event
|
||||
//! recording (with the pty encoding and triggered bindings) and VT event
|
||||
//! recording while data is read from the pty.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::assert_is_empty)]
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use ghostty_input::Key;
|
||||
|
||||
#[test]
|
||||
fn inspector_records_key_events_with_pty_data_and_bindings() {
|
||||
let h = harness!(Harness::recorder(
|
||||
"",
|
||||
&["--keybind=ctrl+shift+c=scroll_page_up"]
|
||||
));
|
||||
h.surface.activate_inspector().unwrap();
|
||||
let insp = h.surface.inspector().expect("active inspector");
|
||||
|
||||
h.key(Key::KeyA, none(), "a");
|
||||
h.key(Key::KeyC, ctrl_shift(), "C");
|
||||
h.expect_input(b"a");
|
||||
|
||||
let g = insp.lock();
|
||||
let events = &g.gui.key_stream.events;
|
||||
// A press for each key.
|
||||
let press_a = events
|
||||
.iter()
|
||||
.find(|e| e.event.key == Key::KeyA && e.event.action == ghostty_input::Action::Press)
|
||||
.expect("press a");
|
||||
assert_eq!(press_a.pty, b"a");
|
||||
assert_eq!(press_a.event.utf8, "a");
|
||||
assert!(press_a.binding.is_empty());
|
||||
|
||||
let press_c = events
|
||||
.iter()
|
||||
.find(|e| e.event.key == Key::KeyC && e.event.action == ghostty_input::Action::Press)
|
||||
.expect("press ctrl+shift+c");
|
||||
assert!(
|
||||
press_c
|
||||
.binding
|
||||
.iter()
|
||||
.any(|a| a.key().name() == "scroll_page_up"),
|
||||
"binding recorded: {:?}",
|
||||
press_c.binding
|
||||
);
|
||||
// The consumed binding was not encoded to the pty.
|
||||
assert!(press_c.pty.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_records_vt_events_read_from_the_pty() {
|
||||
let script = "stty raw -echo; printf READY; dd bs=1 count=1 >/dev/null 2>&1; \
|
||||
printf '\\033[31mX\\033[0m\\033]0;title\\007'; printf DONE; exec cat >'{out}'";
|
||||
let h = harness!(Harness::new(Opts::default().script(script)));
|
||||
h.wait_screen("READY");
|
||||
|
||||
h.surface.activate_inspector().unwrap();
|
||||
let insp = h.surface.inspector().expect("active inspector");
|
||||
{
|
||||
// The overlay features come from the inspector's renderer info.
|
||||
let mut g = insp.lock();
|
||||
assert!(g.renderer_info().features.is_empty());
|
||||
}
|
||||
|
||||
h.type_text("a");
|
||||
h.wait_screen("DONE");
|
||||
|
||||
let g = insp.lock();
|
||||
let descs: Vec<String> = g
|
||||
.gui
|
||||
.vt_stream
|
||||
.events()
|
||||
.iter()
|
||||
.map(|e| e.raw_description.clone())
|
||||
.collect();
|
||||
assert!(descs.iter().any(|d| d == "31 m"), "events: {descs:?}");
|
||||
assert!(descs.iter().any(|d| d == "0 m"), "events: {descs:?}");
|
||||
assert!(
|
||||
descs.iter().any(|d| d == "change_window_title "),
|
||||
"events: {descs:?}"
|
||||
);
|
||||
drop(g);
|
||||
|
||||
// Deactivating drops the inspector and removes it from the render state.
|
||||
h.surface.deactivate_inspector();
|
||||
assert!(h.surface.inspector().is_none());
|
||||
assert!(h.surface.lock_state().inspector.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_overlay_features_reach_the_render_state() {
|
||||
use ghostty_renderer::overlay::Feature;
|
||||
|
||||
let h = harness!(Harness::recorder("", &[]));
|
||||
h.surface.activate_inspector().unwrap();
|
||||
let insp = h.surface.inspector().expect("active inspector");
|
||||
insp.lock()
|
||||
.renderer_info()
|
||||
.features
|
||||
.push(Feature::HighlightHyperlinks);
|
||||
|
||||
let st = h.surface.lock_state();
|
||||
let hook = st.inspector.as_ref().expect("inspector in render state");
|
||||
assert_eq!(hook.overlay_features(), vec![Feature::HighlightHyperlinks]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activating_twice_is_a_no_op() {
|
||||
let h = harness!(Harness::recorder("", &[]));
|
||||
h.surface.activate_inspector().unwrap();
|
||||
let first = h.surface.inspector().unwrap();
|
||||
h.surface.activate_inspector().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(
|
||||
&first,
|
||||
&h.surface.inspector().unwrap()
|
||||
));
|
||||
h.surface.deactivate_inspector();
|
||||
// Deactivating when there is none is fine, too.
|
||||
h.surface.deactivate_inspector();
|
||||
}
|
||||
Reference in new issue
Block a user