From 90ad6029695c8f2689d94d1593aadd59292e5cb0 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 04:39:14 +0200 Subject: [PATCH] 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) --- .../resources/ui/1.5/imgui-widget.blp | 51 + .../resources/ui/1.5/inspector-widget.blp | 10 + .../resources/ui/1.5/inspector-window.blp | 38 + .../src/class/imgui_widget.rs | 693 ++++++++++++ .../src/class/inspector_widget.rs | 168 +++ .../src/class/inspector_window.rs | 150 +++ crates/ghostty-core/src/surface/inspector.rs | 148 +++ crates/ghostty-core/tests/e2e_inspector.rs | 121 +++ crates/ghostty-inspector/Cargo.toml | 46 + crates/ghostty-inspector/src/dock.rs | 180 ++++ crates/ghostty-inspector/src/inspector.rs | 197 ++++ crates/ghostty-inspector/src/keys.rs | 185 ++++ crates/ghostty-inspector/src/lib.rs | 18 + crates/ghostty-inspector/src/units.rs | 5 + crates/ghostty-inspector/src/widgets/key.rs | 539 ++++++++++ crates/ghostty-inspector/src/widgets/mod.rs | 194 ++++ crates/ghostty-inspector/src/widgets/page.rs | 384 +++++++ .../ghostty-inspector/src/widgets/pagelist.rs | 802 ++++++++++++++ .../ghostty-inspector/src/widgets/renderer.rs | 116 ++ .../ghostty-inspector/src/widgets/screen.rs | 400 +++++++ crates/ghostty-inspector/src/widgets/style.rs | 132 +++ .../ghostty-inspector/src/widgets/surface.rs | 408 ++++++++ .../ghostty-inspector/src/widgets/terminal.rs | 637 +++++++++++ .../ghostty-inspector/src/widgets/termio.rs | 987 ++++++++++++++++++ docs/rewrite/notes/ghostty-inspector.md | 84 ++ 25 files changed, 6693 insertions(+) create mode 100644 crates/ghostty-apprt-gtk/resources/ui/1.5/imgui-widget.blp create mode 100644 crates/ghostty-apprt-gtk/resources/ui/1.5/inspector-widget.blp create mode 100644 crates/ghostty-apprt-gtk/resources/ui/1.5/inspector-window.blp create mode 100644 crates/ghostty-apprt-gtk/src/class/imgui_widget.rs create mode 100644 crates/ghostty-apprt-gtk/src/class/inspector_widget.rs create mode 100644 crates/ghostty-apprt-gtk/src/class/inspector_window.rs create mode 100644 crates/ghostty-core/src/surface/inspector.rs create mode 100644 crates/ghostty-core/tests/e2e_inspector.rs create mode 100644 crates/ghostty-inspector/Cargo.toml create mode 100644 crates/ghostty-inspector/src/dock.rs create mode 100644 crates/ghostty-inspector/src/inspector.rs create mode 100644 crates/ghostty-inspector/src/keys.rs create mode 100644 crates/ghostty-inspector/src/lib.rs create mode 100644 crates/ghostty-inspector/src/units.rs create mode 100644 crates/ghostty-inspector/src/widgets/key.rs create mode 100644 crates/ghostty-inspector/src/widgets/mod.rs create mode 100644 crates/ghostty-inspector/src/widgets/page.rs create mode 100644 crates/ghostty-inspector/src/widgets/pagelist.rs create mode 100644 crates/ghostty-inspector/src/widgets/renderer.rs create mode 100644 crates/ghostty-inspector/src/widgets/screen.rs create mode 100644 crates/ghostty-inspector/src/widgets/style.rs create mode 100644 crates/ghostty-inspector/src/widgets/surface.rs create mode 100644 crates/ghostty-inspector/src/widgets/terminal.rs create mode 100644 crates/ghostty-inspector/src/widgets/termio.rs create mode 100644 docs/rewrite/notes/ghostty-inspector.md diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.5/imgui-widget.blp b/crates/ghostty-apprt-gtk/resources/ui/1.5/imgui-widget.blp new file mode 100644 index 0000000..d5b973a --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.5/imgui-widget.blp @@ -0,0 +1,51 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyImguiWidget: Adw.Bin { + styles [ + "imgui", + ] + + Adw.Bin { + Gtk.GLArea gl_area { + auto-render: true; + // needs to be focusable so that we can receive events + focusable: true; + focus-on-click: true; + allowed-apis: gl; + realize => $realize(); + unrealize => $unrealize(); + resize => $resize(); + render => $render(); + + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + + GestureClick { + pressed => $mouse_pressed(); + released => $mouse_released(); + button: 0; + } + + EventControllerMotion { + motion => $mouse_motion(); + } + + EventControllerScroll { + scroll => $scroll(); + flags: both_axes; + } + } + } +} + +IMMulticontext im_context { + commit => $im_commit(); +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.5/inspector-widget.blp b/crates/ghostty-apprt-gtk/resources/ui/1.5/inspector-widget.blp new file mode 100644 index 0000000..ac15ab4 --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.5/inspector-widget.blp @@ -0,0 +1,10 @@ +using Gtk 4.0; + +template $GhosttyInspectorWidget: $GhosttyImguiWidget { + styles [ + "inspector", + ] + + hexpand: true; + vexpand: true; +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.5/inspector-window.blp b/crates/ghostty-apprt-gtk/resources/ui/1.5/inspector-window.blp new file mode 100644 index 0000000..a7625bc --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.5/inspector-window.blp @@ -0,0 +1,38 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyInspectorWindow: Adw.ApplicationWindow { + title: _("Ghostty: Terminal Inspector"); + icon-name: "com.mitchellh.ghostty"; + default-width: 1000; + default-height: 600; + + styles [ + "inspector", + ] + + content: Adw.ToolbarView { + [top] + Adw.HeaderBar { + title-widget: Adw.WindowTitle { + title: bind template.title; + }; + } + + Gtk.Box { + orientation: vertical; + spacing: 0; + hexpand: true; + vexpand: true; + + $GhosttyDebugWarning { + visible: bind template.debug; + } + + $GhosttyInspectorWidget inspector_widget { + notify::surface => $notify_inspector_surface(); + surface: bind template.surface; + } + } + }; +} diff --git a/crates/ghostty-apprt-gtk/src/class/imgui_widget.rs b/crates/ghostty-apprt-gtk/src/class/imgui_widget.rs new file mode 100644 index 0000000..68f9576 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/imgui_widget.rs @@ -0,0 +1,693 @@ +//! Port of apprt/gtk/class/imgui_widget.zig (`GhosttyImguiWidget`). +//! +//! A widget for embedding a Dear ImGui application: a `GtkGLArea` that owns +//! an ImGui context and a glow based renderer, feeds the GTK input events +//! into ImGui and drives the frames. +//! +//! The Zig widget exposes `setup` and `render` virtual methods that subclasses +//! implement. zig-gobject cannot define virtual methods, and neither can we +//! cheaply in gtk-rs without a lot of class struct boilerplate, so subclasses +//! install the two hooks with [`ImguiWidget::set_hooks`] instead. + +use std::cell::{Cell, RefCell}; +use std::time::{Duration, Instant}; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use dear_imgui_glow::GlowRenderer; +use ghostty_inspector::imgui::{Context, MouseButton, Ui}; +use gtk::glib::translate::IntoGlib; +use gtk::{gdk, glib}; + +use crate::key; + +/// Called once after the ImGui context is created, with the context current +/// (Zig virtual method `setup`). +pub type SetupHook = Box; + +/// Called for every frame to draw the UI (Zig virtual method `render`). +pub type RenderHook = Box; + +glib::wrapper! { + pub struct ImguiWidget(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +/// Implemented by subclasses of [`ImguiWidget`]. +pub trait ImguiWidgetImpl: BinImpl {} + +#[allow(unsafe_code)] +// SAFETY: `ImguiWidget` adds no class/instance data that needs to be set up +// for subclasses; we forward to the parent (`adw::Bin`) class/instance +// initialization, which is valid for any `T: ImguiWidgetImpl`. +unsafe impl IsSubclassable for ImguiWidget { + fn class_init(class: &mut glib::Class) { + >::class_init(class.upcast_ref_mut()); + } + + fn instance_init(instance: &mut glib::subclass::InitializingObject) { + >::instance_init(instance); + } +} + +impl ImguiWidget { + /// This should be called anytime the underlying data for the UI changes + /// so that the UI can be refreshed. + pub fn queue_render(&self) { + self.imp().gl_area.queue_render(); + } + + /// Install the hooks (Zig: the `setup` and `render` virtual methods). + pub fn set_hooks(&self, setup: SetupHook, render: RenderHook) { + let imp = self.imp(); + imp.setup_hook.replace(Some(setup)); + imp.render_hook.replace(Some(render)); + } + + /// Debug aid: write the next rendered frame to a PNG file. + pub fn debug_screenshot(&self, path: &str) { + self.debug_render_now(3); + self.imp().pending_screenshot.replace(Some(path.to_owned())); + self.debug_render_now(1); + } + + /// Debug aid: render `frames` frames right now. Compositors only send + /// frame callbacks to visible windows, so the GLArea of an inspector + /// window that is not on screen would otherwise lag behind the + /// synthesized input. + pub fn debug_render_now(&self, frames: usize) { + let imp = self.imp(); + if !imp.gl_area.is_realized() { + return; + } + imp.gl_area.make_current(); + imp.gl_area.attach_buffers(); + for _ in 0..frames { + imp.do_render(); + } + } + + /// Debug aid: synthesize a left click at the given position (in + /// logical pixels, like the pointer position GTK reports). Frames are + /// rendered synchronously between the events. + pub fn debug_click(&self, x: f64, y: f64) { + let imp = self.imp(); + let scale = imp.scale_factor(); + let pos = [(x * scale) as f32, (y * scale) as f32]; + imp.with_ctx(|ctx| ctx.io_mut().add_mouse_pos_event(pos)); + self.debug_render_now(3); + imp.with_ctx(|ctx| { + ctx.io_mut().add_mouse_button_event(MouseButton::Left, true); + }); + self.debug_render_now(3); + imp.with_ctx(|ctx| { + ctx.io_mut() + .add_mouse_button_event(MouseButton::Left, false); + }); + self.debug_render_now(3); + } + + /// Debug aid: synthesize a mouse wheel scroll. + pub fn debug_scroll(&self, x: f64, y: f64, dy: f64) { + let imp = self.imp(); + let scale = imp.scale_factor(); + imp.with_ctx(|ctx| { + let io = ctx.io_mut(); + io.add_mouse_pos_event([(x * scale) as f32, (y * scale) as f32]); + io.add_mouse_wheel_event([0.0, dy as f32]); + }); + self.debug_render_now(3); + } +} + +mod imp { + #[allow(clippy::wildcard_imports)] + use super::*; + + /// The state that only exists while the GLArea is realized. + struct Gui { + ctx: Context, + renderer: GlowRenderer, + } + + #[derive(gtk::CompositeTemplate)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.5/imgui-widget.ui")] + pub struct ImguiWidget { + /// GL area where we display the Dear ImGui application. + #[template_child] + pub gl_area: TemplateChild, + + /// GTK input method context + #[template_child] + pub im_context: TemplateChild, + + pub setup_hook: RefCell>, + pub render_hook: RefCell>, + + /// Dear ImGui context and renderer. We create a context per widget + /// so that we can have multiple active imgui views in the same + /// application. + gui: RefCell>, + + /// Our previous instant used to calculate delta time for animations. + instant: Cell>, + + /// Tick callback for timed updates. + tick_callback: RefCell>, + + /// Last render time for throttling to 30 FPS. + last_render_time: Cell>, + + /// A pending debug screenshot path. + pub pending_screenshot: RefCell>, + + /// The scale factor the style was last scaled to. + style_scale: Cell, + } + + impl Default for ImguiWidget { + fn default() -> Self { + Self { + gl_area: TemplateChild::default(), + im_context: TemplateChild::default(), + setup_hook: RefCell::new(None), + render_hook: RefCell::new(None), + gui: RefCell::new(None), + instant: Cell::new(None), + tick_callback: RefCell::new(None), + last_render_time: Cell::new(None), + pending_screenshot: RefCell::new(None), + style_scale: Cell::new(1.0), + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for ImguiWidget { + const NAME: &'static str = "GhosttyImguiWidget"; + type Type = super::ImguiWidget; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ImguiWidget { + fn dispose(&self) { + self.dispose_template(); + } + } + + impl WidgetImpl for ImguiWidget {} + impl BinImpl for ImguiWidget {} + + impl ImguiWidget { + /// Run `f` with the ImGui context, if there is one. + pub(super) fn with_ctx(&self, f: impl FnOnce(&mut Context)) -> bool { + let Ok(mut gui) = self.gui.try_borrow_mut() else { + return false; + }; + let Some(gui) = gui.as_mut() else { + log::warn!("Dear ImGui context not initialized"); + return false; + }; + f(&mut gui.ctx); + true + } + + pub(super) fn scale_factor(&self) -> f64 { + f64::from(self.gl_area.scale_factor()) + } + + /// Handle key press/release events. + fn key_event( + &self, + press: bool, + ec_key: >k::EventControllerKey, + keyval: gdk::Key, + gtk_mods: gdk::ModifierType, + ) -> bool { + self.obj().queue_render(); + + let handled = self.with_ctx(|ctx| { + let io = ctx.io_mut(); + let mods = key::translate_mods(gtk_mods); + io.add_key_event(ghostty_inspector::imgui::Key::LeftShift, mods.shift); + io.add_key_event(ghostty_inspector::imgui::Key::LeftCtrl, mods.ctrl); + io.add_key_event(ghostty_inspector::imgui::Key::LeftAlt, mods.alt); + io.add_key_event(ghostty_inspector::imgui::Key::LeftSuper, mods.super_); + + // If our keyval has a key, then we send that key event + if let Some(imgui_key) = key::key_from_keyval(keyval.into_glib()) + .and_then(ghostty_inspector::keys::imgui_key) + { + io.add_key_event(imgui_key, press); + } + }); + if !handled { + return false; + } + + // Try to process the event as text + if let Some(event) = ec_key.current_event() { + self.im_context.filter_keypress(&event); + } + + true + } + + /// Translate a GTK mouse button to a Dear ImGui mouse button. + fn translate_mouse_button(button: u32) -> Option { + match button { + 1 => Some(MouseButton::Left), + 2 => Some(MouseButton::Middle), + 3 => Some(MouseButton::Right), + _ => None, + } + } + + fn mouse_button(&self, gesture: >k::GestureClick, down: bool) { + self.obj().queue_render(); + let button = gesture.current_button(); + self.with_ctx(|ctx| { + if let Some(button) = Self::translate_mouse_button(button) { + ctx.io_mut().add_mouse_button_event(button, down); + } + }); + } + + /// Draw a frame, optionally rendering it. + fn frame(&self, gui: &mut Gui, render: bool) -> Result<(), String> { + // Determine our delta time + let now = Instant::now(); + let delta = self.instant.get().map_or(1.0 / 60.0, |prev| { + now.duration_since(prev).as_secs_f32().max(0.00001) + }); + self.instant.set(Some(now)); + gui.ctx.io_mut().set_delta_time(delta); + + { + let ui = gui.ctx.frame(); + // Call the hook to draw the UI. + if let Some(hook) = self.render_hook.borrow().as_ref() { + hook(ui); + } + } + + if !render { + gui.ctx.end_frame(); + return Ok(()); + } + + let consumer = gui + .renderer + .renderer_consumer() + .map_err(|e| e.to_string())?; + let pending = gui.ctx.render(consumer); + gui.renderer.render(pending).map_err(|e| e.to_string()) + } + + #[allow(unsafe_code)] + fn clear(gui: &Gui) { + use glow::HasContext; + let Some(gl) = gui.renderer.gl_context() else { + return; + }; + // SAFETY: the GLArea context is current while rendering. + unsafe { + gl.clear_color(0.0, 0.0, 0.0, 1.0); + gl.clear(glow::COLOR_BUFFER_BIT); + } + } + + #[allow(unsafe_code)] + fn screenshot(&self, gui: &Gui, path: &str) { + use glow::HasContext; + let Some(gl) = gui.renderer.gl_context() else { + return; + }; + let w = self.gl_area.width() * self.gl_area.scale_factor(); + let h = self.gl_area.height() * self.gl_area.scale_factor(); + if w <= 0 || h <= 0 { + return; + } + let (w, h) = (w as usize, h as usize); + let mut pixels = vec![0u8; w * h * 4]; + // SAFETY: the GLArea context is current while rendering and the + // buffer is large enough for `w * h` RGBA8 pixels. + unsafe { + gl.read_pixels( + 0, + 0, + w as i32, + h as i32, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelPackData::Slice(Some(&mut pixels)), + ); + } + // GL's origin is the bottom-left. + let stride = w * 4; + let mut flipped = Vec::with_capacity(pixels.len()); + for row in pixels.chunks_exact(stride).rev() { + flipped.extend_from_slice(row); + } + let texture = gdk::MemoryTexture::new( + w as i32, + h as i32, + gdk::MemoryFormat::R8g8b8a8, + &glib::Bytes::from_owned(flipped), + stride, + ); + match texture.save_to_png(path) { + Ok(()) => log::warn!("inspector screenshot written to {path}"), + Err(err) => log::error!("inspector screenshot failed path={path} err={err}"), + } + } + } + + impl ImguiWidget { + #[allow(unsafe_code)] + fn do_realize(&self, area: >k::GLArea) { + area.make_current(); + if let Some(err) = area.error() { + log::warn!( + "GLArea for Dear ImGui widget failed to realize: {}", + err.message() + ); + return; + } + + let mut ctx = match Context::try_create() { + Ok(ctx) => ctx, + Err(err) => { + log::warn!("unable to initialize Dear ImGui context: {err}"); + return; + } + }; + + // Setup some basic config + if let Err(err) = ctx.set_platform_name(Some("ghostty_gtk")) { + log::warn!("unable to set the imgui platform name err={err}"); + } + + // Call the hook to setup the UI. This happens before the + // renderer is created so the fonts exist when it uploads the + // atlas. + if let Some(hook) = self.setup_hook.borrow().as_ref() { + hook(&mut ctx, self.scale_factor()); + } + + // Realize means that our OpenGL context is ready, so we can now + // initialize the ImGui OpenGL backend for our context. + // + // SAFETY: the GLArea's context is current and `proc_address` + // resolves GL functions for the current context. + let gl = unsafe { glow::Context::from_loader_function(crate::gl::proc_address) }; + let renderer = match GlowRenderer::new(gl, &mut ctx) { + Ok(r) => r, + Err(err) => { + log::warn!("unable to initialize the Dear ImGui OpenGL renderer: {err}"); + return; + } + }; + + self.style_scale.set(1.0); + self.gui.replace(Some(Gui { ctx, renderer })); + + // Add a tick callback to drive timed updates via the frame clock. + // Redraws are limited to 30 FPS max since our imgui widgets don't + // usually need higher frame rates than that. + let obj = self.obj(); + let id = obj.add_tick_callback(|widget, _| { + let imp = widget.imp(); + let frame_time = Duration::from_secs(1) / 30; + let should_render = imp + .last_render_time + .get() + .is_none_or(|last| last.elapsed() >= frame_time); + if should_render { + widget.queue_render(); + } + glib::ControlFlow::Continue + }); + self.tick_callback.replace(Some(id)); + } + + fn do_unrealize(&self, area: >k::GLArea) { + // Remove the tick callback if it was registered. + if let Some(id) = self.tick_callback.take() { + id.remove(); + } + + // Unrealize is not guaranteed to be called with a current GL + // context, so we make it current for ImGui cleanup. + area.make_current(); + if let Some(err) = area.error() { + log::warn!( + "GLArea for Dear ImGui widget failed to realize: {}", + err.message() + ); + return; + } + + if let Some(mut gui) = self.gui.take() + && let Err(err) = gui.renderer.shutdown(&mut gui.ctx) + { + log::warn!("error shutting down the imgui renderer err={err}"); + } + } + + fn do_resize(&self, width: i32, height: i32, area: >k::GLArea) { + let scale_factor = area.scale_factor(); + self.with_ctx(|ctx| { + // Our display size is always unscaled. We'll do the scaling + // in the style instead. This creates crisper looking fonts. + let io = ctx.io_mut(); + io.set_display_size([width as f32, height as f32]); + io.set_display_framebuffer_scale([1.0, 1.0]); + + // Scale the style appropriately. Zig builds a fresh default + // style and scales it on every resize, the style of a + // Dear ImGui context cannot be rebuilt from Rust so we scale + // by the change in the factor instead. + let scale = scale_factor as f32; + let last = self.style_scale.replace(scale); + if (scale - last).abs() > f32::EPSILON { + ctx.style_mut().scale_all_sizes(scale / last); + } + }); + } + + pub(super) fn do_render(&self) -> bool { + // Update last render time for tick callback throttling. + self.last_render_time.set(Some(Instant::now())); + + let Ok(mut guard) = self.gui.try_borrow_mut() else { + return false; + }; + let Some(gui) = guard.as_mut() else { + return false; + }; + + // Setup our frame. We render twice because some ImGui behaviors + // take multiple renders to process. I don't know how to make this + // more efficient. + if let Err(err) = self.frame(gui, false) { + log::warn!("imgui frame failed err={err}"); + return false; + } + + // OpenGL final render + Self::clear(gui); + if let Err(err) = self.frame(gui, true) { + log::warn!("imgui render failed err={err}"); + return false; + } + + if let Some(path) = self.pending_screenshot.take() { + self.screenshot(gui, &path); + } + + true + } + + fn do_focus_enter(&self, _ec: >k::EventControllerFocus) { + self.with_ctx(|ctx| ctx.io_mut().add_focus_event(true)); + self.obj().queue_render(); + } + + fn do_focus_leave(&self, _ec: >k::EventControllerFocus) { + self.with_ctx(|ctx| ctx.io_mut().add_focus_event(false)); + self.obj().queue_render(); + } + + fn do_key_pressed( + &self, + keyval: gdk::Key, + _keycode: u32, + state: gdk::ModifierType, + ec: >k::EventControllerKey, + ) -> glib::Propagation { + if self.key_event(true, ec, keyval, state) { + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + } + + fn do_key_released( + &self, + keyval: gdk::Key, + _keycode: u32, + state: gdk::ModifierType, + ec: >k::EventControllerKey, + ) { + self.key_event(false, ec, keyval, state); + } + + fn do_mouse_pressed(&self, _n: i32, _x: f64, _y: f64, gesture: >k::GestureClick) { + self.mouse_button(gesture, true); + } + + fn do_mouse_released(&self, _n: i32, _x: f64, _y: f64, gesture: >k::GestureClick) { + self.mouse_button(gesture, false); + } + + fn do_mouse_motion(&self, x: f64, y: f64, _ec: >k::EventControllerMotion) { + self.obj().queue_render(); + let scale = self.scale_factor(); + self.with_ctx(|ctx| { + ctx.io_mut() + .add_mouse_pos_event([(x * scale) as f32, (y * scale) as f32]); + }); + } + + fn do_scroll(&self, x: f64, y: f64, _ec: >k::EventControllerScroll) -> glib::Propagation { + self.obj().queue_render(); + let handled = self.with_ctx(|ctx| { + ctx.io_mut().add_mouse_wheel_event([x as f32, -y as f32]); + }); + if handled { + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + } + + fn do_im_commit(&self, text: &str, _im: >k::IMMulticontext) { + self.obj().queue_render(); + self.with_ctx(|ctx| ctx.io_mut().add_input_characters_utf8(text)); + } + } + + #[gtk::template_callbacks] + impl ImguiWidget { + #[template_callback] + fn realize(area: >k::GLArea, this: &super::ImguiWidget) { + this.imp().do_realize(area); + } + + #[template_callback] + fn unrealize(area: >k::GLArea, this: &super::ImguiWidget) { + this.imp().do_unrealize(area); + } + + #[template_callback] + fn resize(area: >k::GLArea, width: i32, height: i32, this: &super::ImguiWidget) { + this.imp().do_resize(width, height, area); + } + + #[template_callback] + fn render(area: >k::GLArea, context: &gdk::GLContext, this: &super::ImguiWidget) -> bool { + let _ = (area, context); + this.imp().do_render() + } + + #[template_callback] + fn focus_enter(ec: >k::EventControllerFocus, this: &super::ImguiWidget) { + this.imp().do_focus_enter(ec); + } + + #[template_callback] + fn focus_leave(ec: >k::EventControllerFocus, this: &super::ImguiWidget) { + this.imp().do_focus_leave(ec); + } + + #[template_callback] + fn key_pressed( + ec: >k::EventControllerKey, + keyval: gdk::Key, + keycode: u32, + state: gdk::ModifierType, + this: &super::ImguiWidget, + ) -> glib::Propagation { + this.imp().do_key_pressed(keyval, keycode, state, ec) + } + + #[template_callback] + fn key_released( + ec: >k::EventControllerKey, + keyval: gdk::Key, + keycode: u32, + state: gdk::ModifierType, + this: &super::ImguiWidget, + ) { + this.imp().do_key_released(keyval, keycode, state, ec); + } + + #[template_callback] + fn mouse_pressed( + gesture: >k::GestureClick, + n: i32, + x: f64, + y: f64, + this: &super::ImguiWidget, + ) { + this.imp().do_mouse_pressed(n, x, y, gesture); + } + + #[template_callback] + fn mouse_released( + gesture: >k::GestureClick, + n: i32, + x: f64, + y: f64, + this: &super::ImguiWidget, + ) { + this.imp().do_mouse_released(n, x, y, gesture); + } + + #[template_callback] + fn mouse_motion( + ec: >k::EventControllerMotion, + x: f64, + y: f64, + this: &super::ImguiWidget, + ) { + this.imp().do_mouse_motion(x, y, ec); + } + + #[template_callback] + fn scroll( + ec: >k::EventControllerScroll, + x: f64, + y: f64, + this: &super::ImguiWidget, + ) -> glib::Propagation { + this.imp().do_scroll(x, y, ec) + } + + #[template_callback] + fn im_commit(im: >k::IMMulticontext, text: &str, this: &super::ImguiWidget) { + this.imp().do_im_commit(text, im); + } + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/inspector_widget.rs b/crates/ghostty-apprt-gtk/src/class/inspector_widget.rs new file mode 100644 index 0000000..18b2ae7 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/inspector_widget.rs @@ -0,0 +1,168 @@ +//! Port of apprt/gtk/class/inspector_widget.zig (`GhosttyInspectorWidget`). +//! +//! Widget for displaying the Ghostty inspector. Setting its `surface` +//! property activates the inspector on that surface's core surface and +//! clearing it deactivates it again. + +use std::cell::RefCell; +use std::rc::Rc; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use ghostty_apprt::runtime_api::CoreSurface; +use gtk::glib; + +use super::imgui_widget::{ImguiWidget, ImguiWidgetImpl}; +use super::surface::Surface; + +glib::wrapper! { + pub struct InspectorWidget(ObjectSubclass) + @extends ImguiWidget, adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl InspectorWidget { + /// Queue a render of the Dear ImGui widget. + pub fn queue_render(&self) { + self.upcast_ref::().queue_render(); + } + + pub fn get_surface(&self) -> Option { + self.imp().surface.upgrade() + } + + /// Debug aid: synthesize a left click. + pub fn debug_click(&self, x: f64, y: f64) { + self.upcast_ref::().debug_click(x, y); + } + + /// Debug aid: synthesize a mouse wheel scroll. + pub fn debug_scroll(&self, x: f64, y: f64, dy: f64) { + self.upcast_ref::().debug_scroll(x, y, dy); + } + + /// Debug aid: write the next rendered frame to a PNG file. + pub fn debug_screenshot(&self, path: &str) { + self.upcast_ref::().debug_screenshot(path); + } +} + +impl ImguiWidgetImpl for imp::InspectorWidget {} + +mod imp { + #[allow(clippy::wildcard_imports)] + use super::*; + + #[derive(gtk::CompositeTemplate, glib::Properties, Default)] + #[properties(wrapper_type = super::InspectorWidget)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.5/inspector-widget.ui")] + pub struct InspectorWidget { + /// The surface that we are attached to. This is NOT strongly + /// referenced (the GObject weak reference is cleared when the surface + /// is disposed, which closes our window). + pub surface: glib::WeakRef, + + /// The core surface of `surface`, kept so that we can deactivate the + /// inspector when the surface is going away (the GTK surface only + /// drops its core surface when finalized, after the window holding + /// this widget was destroyed in its dispose). + core: RefCell>>, + + #[property(get = Self::get_surface, set = Self::set_surface, nullable, type = Option, name = "surface")] + _surface_prop: (), + } + + #[glib::object_subclass] + impl ObjectSubclass for InspectorWidget { + const NAME: &'static str = "GhosttyInspectorWidget"; + type Type = super::InspectorWidget; + type ParentType = ImguiWidget; + + fn class_init(klass: &mut Self::Class) { + ImguiWidget::ensure_type(); + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for InspectorWidget { + fn constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + let weak = obj.downgrade(); + obj.upcast_ref::().set_hooks( + // Called to do initial setup of the UI. + Box::new(ghostty_inspector::setup), + // Called for every frame to draw the UI. + Box::new(move |ui| { + let Some(this) = weak.upgrade() else { + return; + }; + let Some(surface) = this.imp().surface.upgrade() else { + return; + }; + let Some(core) = surface.core() else { + return; + }; + core.render_inspector(ui); + }), + ); + } + + fn dispose(&self) { + // Clear our surface so it deactivates the inspector. + self.set_surface(None); + self.dispose_template(); + } + } + + impl WidgetImpl for InspectorWidget {} + impl BinImpl for InspectorWidget {} + + impl InspectorWidget { + fn get_surface(&self) -> Option { + self.surface.upgrade() + } + + fn set_surface(&self, surface: Option) { + // Do nothing if we're not changing the value. + if surface == self.surface.upgrade() { + return; + } + + // Setup our notification to happen at the end because we're + // changing values no matter what. + let obj = self.obj(); + let _guard = obj.freeze_notify(); + obj.notify("surface"); + + // Deactivate the inspector on the old surface if it exists and + // set our value to null. + self.surface.set(None); + if let Some(core) = self.core.take() { + core.deactivate_inspector(); + } + + // Activate the inspector on the new surface. + let Some(surface) = surface else { + return; + }; + let Some(core) = surface.core() else { + return; + }; + if let Err(err) = core.activate_inspector() { + log::warn!("failed to activate inspector err={err}"); + return; + } + + self.surface.set(Some(&surface)); + self.core.replace(Some(core)); + obj.queue_render(); + } + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/inspector_window.rs b/crates/ghostty-apprt-gtk/src/class/inspector_window.rs new file mode 100644 index 0000000..bb706f0 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/inspector_window.rs @@ -0,0 +1,150 @@ +//! Port of apprt/gtk/class/inspector_window.zig (`GhosttyInspectorWindow`). +//! +//! Window for displaying the Ghostty inspector. + +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::glib; + +use super::debug_warning::DebugWarning; +use super::inspector_widget::InspectorWidget; +use super::surface::Surface; + +glib::wrapper! { + pub struct InspectorWindow(ObjectSubclass) + @extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget, + @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable, + gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager; +} + +use gtk::gio; + +impl InspectorWindow { + pub fn new(surface: &Surface) -> Self { + crate::register_resources(); + glib::Object::builder().property("surface", surface).build() + } + + /// Present the window. + pub fn present_window(&self) { + self.present(); + } + + /// Queue a render of the embedded widget. + pub fn queue_render(&self) { + self.imp().inspector_widget.queue_render(); + } + + /// Debug aid: synthesize a left click in the inspector. + pub fn debug_click(&self, x: f64, y: f64) { + self.imp().inspector_widget.debug_click(x, y); + } + + /// Debug aid: synthesize a mouse wheel scroll in the inspector. + pub fn debug_scroll(&self, x: f64, y: f64, dy: f64) { + self.imp().inspector_widget.debug_scroll(x, y, dy); + } + + /// Debug aid: write the next rendered inspector frame to a PNG file. + pub fn debug_screenshot(&self, path: &str) { + self.imp().inspector_widget.debug_screenshot(path); + } +} + +mod imp { + #[allow(clippy::wildcard_imports)] + use super::*; + + #[derive(gtk::CompositeTemplate, glib::Properties, Default)] + #[properties(wrapper_type = super::InspectorWindow)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.5/inspector-window.ui")] + pub struct InspectorWindow { + /// The surface that we are attached to. + #[property(get, set = Self::set_surface, nullable)] + pub surface: glib::WeakRef, + + /// The embedded inspector widget. + #[template_child] + pub inspector_widget: TemplateChild, + + /// Whether this is a debug build (shows the debug warning). + #[property(get = Self::get_debug, type = bool, name = "debug")] + _debug_prop: (), + } + + impl InspectorWindow { + #[allow(clippy::unused_self)] + fn get_debug(&self) -> bool { + cfg!(debug_assertions) + } + + #[allow(clippy::needless_pass_by_value)] // property setter signature + fn set_surface(&self, surface: Option) { + self.surface.set(surface.as_ref()); + } + } + + #[glib::object_subclass] + impl ObjectSubclass for InspectorWindow { + const NAME: &'static str = "GhosttyInspectorWindow"; + type Type = super::InspectorWindow; + type ParentType = adw::ApplicationWindow; + + fn class_init(klass: &mut Self::Class) { + DebugWarning::ensure_type(); + InspectorWidget::ensure_type(); + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for InspectorWindow { + fn constructed(&self) { + self.parent_constructed(); + let obj = self.obj(); + + // Add our dev CSS class if we're in debug mode. + if cfg!(debug_assertions) { + obj.add_css_class("devel"); + } + + // Set our window icon. We can't set this in the blueprint file + // because its dependent on the build config. + obj.set_icon_name(Some(crate::APPLICATION_ID)); + } + + fn dispose(&self) { + // You MUST clear all weak refs in dispose, otherwise it causes + // memory corruption on dispose on the TARGET (weak referenced) + // object (Zig comment). + self.set_surface(None); + self.dispose_template(); + } + } + + impl WidgetImpl for InspectorWindow {} + impl WindowImpl for InspectorWindow {} + impl ApplicationWindowImpl for InspectorWindow {} + impl AdwApplicationWindowImpl for InspectorWindow {} + + #[gtk::template_callbacks] + impl InspectorWindow { + /// `notify::surface` of the inspector widget: if the inspector's + /// surface went away, we destroy the window. + #[template_callback] + fn notify_inspector_surface( + inspector: &InspectorWidget, + _pspec: &glib::ParamSpec, + this: &super::InspectorWindow, + ) { + if inspector.get_surface().is_none() { + this.destroy(); + } + } + } +} diff --git a/crates/ghostty-core/src/surface/inspector.rs b/crates/ghostty-core/src/surface/inspector.rs new file mode 100644 index 0000000..40a4b1d --- /dev/null +++ b/crates/ghostty-core/src/surface/inspector.rs @@ -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> { + 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); + } + + // 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(); + } +} diff --git a/crates/ghostty-core/tests/e2e_inspector.rs b/crates/ghostty-core/tests/e2e_inspector.rs new file mode 100644 index 0000000..5bd283d --- /dev/null +++ b/crates/ghostty-core/tests/e2e_inspector.rs @@ -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 = 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(); +} diff --git a/crates/ghostty-inspector/Cargo.toml b/crates/ghostty-inspector/Cargo.toml new file mode 100644 index 0000000..01c0625 --- /dev/null +++ b/crates/ghostty-inspector/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "ghostty-inspector" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +dear-imgui-rs = "0.19" +ghostty-font.workspace = true +ghostty-input.workspace = true +ghostty-renderer.workspace = true +ghostty-terminal.workspace = true +ghostty-vt.workspace = true +log = "0.4" + +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +todo = "warn" +unwrap_used = "warn" +# Porting a low-level terminal emulator involves a lot of intentional +# numeric casts and long state-machine functions; these pedantic lints are +# noise for this codebase. +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_sign_loss = "allow" +cast_precision_loss = "allow" +cast_lossless = "allow" +module_name_repetitions = "allow" +too_many_lines = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" +fn_params_excessive_bools = "allow" +match_same_arms = "allow" +unreadable_literal = "allow" +many_single_char_names = "allow" +doc_markdown = "allow" +must_use_candidate = "allow" +return_self_not_must_use = "allow" +used_underscore_binding = "allow" +large_stack_arrays = "allow" +inline_always = "allow" diff --git a/crates/ghostty-inspector/src/dock.rs b/crates/ghostty-inspector/src/dock.rs new file mode 100644 index 0000000..f5b1c7a --- /dev/null +++ b/crates/ghostty-inspector/src/dock.rs @@ -0,0 +1,180 @@ +//! 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> = + 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 = 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, + ); + } + }); +} diff --git a/crates/ghostty-inspector/src/inspector.rs b/crates/ghostty-inspector/src/inspector.rs new file mode 100644 index 0000000..48f72e2 --- /dev/null +++ b/crates/ghostty-inspector/src/inspector.rs @@ -0,0 +1,197 @@ +//! The Inspector (ZIG/src/inspector/Inspector.zig). + +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use dear_imgui_rs::{ConfigFlags, Context, FontSource, StbTrueTypeFontData, Ui}; +use ghostty_renderer::overlay::Feature; +use ghostty_renderer::state::InspectorHook; +use ghostty_terminal::terminal::Terminal; + +use crate::widgets; + +/// Setup the ImGui state. This requires an ImGui context. +/// +/// `scale_factor` is the content scale of the display; Zig hardcodes a 2x +/// scale here (with a TODO) and sizes the font as `16 * 2`, this uses +/// `16 * scale_factor` instead. +pub fn setup(ctx: &mut Context, scale_factor: f64) { + let io = ctx.io_mut(); + + // Enable docking, which we use heavily for the UI. Our colorspace is + // sRGB. + io.set_config_flags(io.config_flags() | ConfigFlags::DOCKING_ENABLE | ConfigFlags::IS_SRGB); + + // Disable the ini file to save layout + if let Err(err) = ctx.set_ini_filename::(None) { + log::warn!("failed to disable the imgui ini file err={err}"); + } + if let Err(err) = ctx.set_log_filename::(None) { + log::warn!("failed to disable the imgui log file err={err}"); + } + + // Use our own embedded font + let font_size = (16.0 * scale_factor.max(1.0)) as f32; + match StbTrueTypeFontData::from_slice(ghostty_font::embedded::REGULAR) { + Ok(data) => { + ctx.font_atlas() + .add_font(&[FontSource::stb_truetype_with_size(data, font_size)]); + } + Err(err) => log::warn!("failed to load the embedded inspector font err={err}"), + } +} + +/// The Inspector is a development tool to debug the terminal. This is +/// useful for terminal application developers as well as people potentially +/// debugging issues in Ghostty itself. +#[derive(Debug, Default)] +pub struct Inspector { + /// Mouse state that we track in addition to normal mouse states that + /// Ghostty always knows about. + pub mouse: widgets::surface::Mouse, + + // ImGui state + pub gui: widgets::surface::Inspector, +} + +impl Inspector { + pub fn new() -> Self { + Self::default() + } + + /// Returns the renderer info panel. This is a convenience function + /// to access and find this state to read and modify. + pub fn renderer_info(&mut self) -> &mut widgets::renderer::Info { + &mut self.gui.renderer_info + } + + /// Record a keyboard event. + pub fn record_key_event(&mut self, ev: widgets::key::Event) { + self.gui.key_stream.record(ev); + } + + /// Record data read from the pty. + pub fn record_pty_read(&mut self, t: &Terminal, data: &[u8]) { + self.gui.vt_stream.record_pty_read(t, data); + } + + /// Render the frame. The caller must hold the terminal lock and provide + /// the surface information. + pub fn render(&mut self, ui: &Ui, surface: &widgets::surface::SurfaceInfo, t: &mut Terminal) { + // Draw the UI + self.gui.draw(ui, surface, &self.mouse, t); + } +} + +/// The inspector as shared between the surface, the IO thread (VT events) +/// and the renderer (overlay features). Zig shares a pointer guarded by the +/// renderer mutex; the lock order is: renderer state, then this. +#[derive(Debug, Default)] +pub struct Shared(Mutex); + +impl Shared { + pub fn new() -> Self { + Self::default() + } + + /// Lock the inspector (poisoning is ignored: the data is display-only). + pub fn lock(&self) -> MutexGuard<'_, Inspector> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl InspectorHook for Shared { + fn overlay_features(&self) -> Vec { + self.lock().gui.renderer_info.overlay_features() + } + + fn record_pty_read(&self, t: &Terminal, data: &[u8]) { + self.lock().record_pty_read(t, data); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::widgets::surface::SurfaceInfo; + use ghostty_input::{Action, Key, KeyEvent}; + use ghostty_renderer::size::{CellSize, Padding, ScreenSize, Size}; + use ghostty_terminal::terminal::Options; + use ghostty_vt::stream::Stream; + + fn surface_info() -> SurfaceInfo { + SurfaceInfo { + size: Size { + screen: ScreenSize { + width: 800, + height: 600, + }, + cell: CellSize { + width: 10, + height: 20, + }, + padding: Padding::default(), + }, + font_size_points: 12.0, + font_size_pixels: 16.0, + click_pressed: [false; 12], + left_click_pin: None, + left_click_xpos: 0.0, + left_click_ypos: 0.0, + } + } + + /// Draw a few full frames headlessly (no renderer) with every window + /// open and the detached/pop-out state enabled, to catch Dear ImGui + /// misuse (the safe wrapper panics on invalid API use). + #[test] + fn draws_frames_headless() { + let mut ctx = Context::create(); + setup(&mut ctx, 1.0); + ctx.io_mut().set_display_size([1000.0, 700.0]); + ctx.io_mut().set_delta_time(1.0 / 60.0); + ctx.font_atlas() + .try_claim_legacy_renderer() + .expect("legacy font atlas") + .build(); + + let mut t = Terminal::new(Options::new(80, 24)).expect("terminal"); + let mut insp = Inspector::new(); + + // Feed some pty data through both the inspector and the terminal. + let data = + b"hello\x07\x1b[31mred\x1b[0m\r\n\x1b]8;;http://x\x1b\\link\x1b]8;;\x1b\\\x1b]0;t\x07"; + insp.record_pty_read(&t, data); + { + let mut term_stream = + Stream::new(ghostty_terminal::stream_readonly::Handler::new(&mut t)); + term_stream.next_slice(data).expect("process"); + } + + insp.record_key_event(crate::KeyEvent::new(&KeyEvent { + action: Action::Press, + key: Key::KeyA, + utf8: "a", + ..KeyEvent::default() + })); + + for frame in 0..8 { + // Open the pop-out state after the layout has settled, like a + // user clicking the buttons. + if frame == 3 { + insp.gui.terminal_info.misc_header.detached = true; + insp.gui.terminal_info.layout_header.detached = true; + } + if frame == 2 { + insp.gui.vt_stream.selected_event_seq = Some(1); + } + if frame == 4 { + insp.gui.terminal_info.screens[0] = Some(crate::widgets::screen::Info::default()); + insp.gui.terminal_info.show_palette = true; + } + let ui = ctx.frame(); + insp.render(ui, &surface_info(), &mut t); + let _ = ctx.render_legacy(); + } + assert!(!insp.gui.vt_stream.events().is_empty()); + } +} diff --git a/crates/ghostty-inspector/src/keys.rs b/crates/ghostty-inspector/src/keys.rs new file mode 100644 index 0000000..3073681 --- /dev/null +++ b/crates/ghostty-inspector/src/keys.rs @@ -0,0 +1,185 @@ +//! Helpers to name things like Zig's `@tagName`. + +use dear_imgui_rs::Key as ImKey; + +/// The `snake_case` form of the `Debug` output of a field-less enum value, +/// which is what Zig's `@tagName` returns for the equivalent enum. +pub fn tag_name(v: &dyn std::fmt::Debug) -> String { + let dbg = format!("{v:?}"); + // Only take the variant name (drop payloads for data-carrying enums). + let name = dbg + .split(|c: char| !c.is_alphanumeric() && c != '_') + .next() + .unwrap_or(""); + snake_case(name) +} + +/// Convert `CamelCase` to `snake_case` (`SgrPixels` -> `sgr_pixels`, +/// `X10` -> `x10`). +pub fn snake_case(name: &str) -> String { + let mut out = String::with_capacity(name.len() + 4); + let mut prev_lower = false; + for ch in name.chars() { + if ch.is_uppercase() { + if prev_lower { + out.push('_'); + } + out.extend(ch.to_lowercase()); + prev_lower = false; + } else { + out.push(ch); + prev_lower = ch.is_lowercase(); + } + } + out +} + +/// Returns the Dear ImGui key for a Ghostty key (Zig: `Key.imguiKey`), or +/// `None` for keys that have no ImGui equivalent. +pub fn imgui_key(key: ghostty_input::Key) -> Option { + use dear_imgui_rs::Key as I; + use ghostty_input::Key as K; + Some(match key { + K::KeyA => I::A, + K::KeyB => I::B, + K::KeyC => I::C, + K::KeyD => I::D, + K::KeyE => I::E, + K::KeyF => I::F, + K::KeyG => I::G, + K::KeyH => I::H, + K::KeyI => I::I, + K::KeyJ => I::J, + K::KeyK => I::K, + K::KeyL => I::L, + K::KeyM => I::M, + K::KeyN => I::N, + K::KeyO => I::O, + K::KeyP => I::P, + K::KeyQ => I::Q, + K::KeyR => I::R, + K::KeyS => I::S, + K::KeyT => I::T, + K::KeyU => I::U, + K::KeyV => I::V, + K::KeyW => I::W, + K::KeyX => I::X, + K::KeyY => I::Y, + K::KeyZ => I::Z, + K::Digit0 => I::Key0, + K::Numpad0 => I::Keypad0, + K::Digit1 => I::Key1, + K::Numpad1 => I::Keypad1, + K::Digit2 => I::Key2, + K::Numpad2 => I::Keypad2, + K::Digit3 => I::Key3, + K::Numpad3 => I::Keypad3, + K::Digit4 => I::Key4, + K::Numpad4 => I::Keypad4, + K::Digit5 => I::Key5, + K::Numpad5 => I::Keypad5, + K::Digit6 => I::Key6, + K::Numpad6 => I::Keypad6, + K::Digit7 => I::Key7, + K::Numpad7 => I::Keypad7, + K::Digit8 => I::Key8, + K::Numpad8 => I::Keypad8, + K::Digit9 => I::Key9, + K::Numpad9 => I::Keypad9, + K::F1 => I::F1, + K::F2 => I::F2, + K::F3 => I::F3, + K::F4 => I::F4, + K::F5 => I::F5, + K::F6 => I::F6, + K::F7 => I::F7, + K::F8 => I::F8, + K::F9 => I::F9, + K::F10 => I::F10, + K::F11 => I::F11, + K::F12 => I::F12, + K::Semicolon => I::Semicolon, + K::Space => I::Space, + K::Quote => I::Apostrophe, + K::Comma => I::Comma, + K::Backquote => I::GraveAccent, + K::Period => I::Period, + K::Slash => I::Slash, + K::Minus => I::Minus, + K::Equal => I::Equal, + K::BracketLeft => I::LeftBracket, + K::BracketRight => I::RightBracket, + K::Backslash => I::Backslash, + K::ArrowUp => I::UpArrow, + K::ArrowDown => I::DownArrow, + K::ArrowLeft => I::LeftArrow, + K::ArrowRight => I::RightArrow, + K::Home => I::Home, + K::End => I::End, + K::Insert => I::Insert, + K::Delete => I::Delete, + K::CapsLock => I::CapsLock, + K::ScrollLock => I::ScrollLock, + K::NumLock => I::NumLock, + K::PageUp => I::PageUp, + K::PageDown => I::PageDown, + K::Escape => I::Escape, + K::Enter => I::Enter, + K::Tab => I::Tab, + K::Backspace => I::Backspace, + K::PrintScreen => I::PrintScreen, + K::Pause => I::Pause, + K::ContextMenu => I::Menu, + K::NumpadDecimal => I::KeypadDecimal, + K::NumpadDivide => I::KeypadDivide, + K::NumpadMultiply => I::KeypadMultiply, + K::NumpadSubtract => I::KeypadSubtract, + K::NumpadAdd => I::KeypadAdd, + K::NumpadEnter => I::KeypadEnter, + K::NumpadEqual => I::KeypadEqual, + K::NumpadLeft => I::LeftArrow, + K::NumpadRight => I::RightArrow, + K::NumpadUp => I::UpArrow, + K::NumpadDown => I::DownArrow, + K::NumpadPageUp => I::PageUp, + K::NumpadPageDown => I::PageUp, + K::NumpadHome => I::Home, + K::NumpadEnd => I::End, + K::NumpadInsert => I::Insert, + K::NumpadDelete => I::Delete, + K::NumpadBegin => I::Tab, + K::ShiftLeft => I::LeftShift, + K::ControlLeft => I::LeftCtrl, + K::AltLeft => I::LeftAlt, + K::MetaLeft => I::LeftSuper, + K::ShiftRight => I::RightShift, + K::ControlRight => I::RightCtrl, + K::AltRight => I::RightAlt, + K::MetaRight => I::RightSuper, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snake_case_names() { + assert_eq!(snake_case("SgrPixels"), "sgr_pixels"); + assert_eq!(snake_case("X10"), "x10"); + assert_eq!(snake_case("Utf8"), "utf8"); + assert_eq!(snake_case("Active"), "active"); + assert_eq!(tag_name(&Some(3)), "some"); + } + + #[test] + fn imgui_key_mapping() { + use ghostty_input::Key; + assert_eq!(imgui_key(Key::KeyA), Some(ImKey::A)); + assert_eq!(imgui_key(Key::Digit5), Some(ImKey::Key5)); + assert_eq!(imgui_key(Key::ArrowUp), Some(ImKey::UpArrow)); + assert_eq!(imgui_key(Key::F13), None); + assert_eq!(imgui_key(Key::NumpadPageDown), Some(ImKey::PageUp)); + } +} diff --git a/crates/ghostty-inspector/src/lib.rs b/crates/ghostty-inspector/src/lib.rs new file mode 100644 index 0000000..4ad6c85 --- /dev/null +++ b/crates/ghostty-inspector/src/lib.rs @@ -0,0 +1,18 @@ +//! The terminal inspector (ZIG/src/inspector/*). +//! +//! The inspector is a development tool to debug the terminal, similar to a +//! browser's developer tools. The UI is built with Dear ImGui +//! ([`dear_imgui_rs`]); the apprt owns the ImGui context, feeds input events +//! and renders the draw data. This crate only draws the widgets and records +//! the events (keyboard, VT stream) that the widgets display. + +pub use dear_imgui_rs as imgui; + +mod dock; +mod inspector; +pub mod keys; +pub mod units; +pub mod widgets; + +pub use inspector::{Inspector, Shared, setup}; +pub use widgets::key::Event as KeyEvent; diff --git a/crates/ghostty-inspector/src/units.rs b/crates/ghostty-inspector/src/units.rs new file mode 100644 index 0000000..41cb93e --- /dev/null +++ b/crates/ghostty-inspector/src/units.rs @@ -0,0 +1,5 @@ +//! Unit helpers (ZIG/src/inspector/units.zig). + +pub fn to_kibi_bytes(bytes: usize) -> usize { + bytes / 1024 +} diff --git a/crates/ghostty-inspector/src/widgets/key.rs b/crates/ghostty-inspector/src/widgets/key.rs new file mode 100644 index 0000000..beb585c --- /dev/null +++ b/crates/ghostty-inspector/src/widgets/key.rs @@ -0,0 +1,539 @@ +//! Keyboard event inspector (ZIG/src/inspector/widgets/key.zig). + +use std::collections::VecDeque; + +use dear_imgui_rs::{InputTextFlags, TableColumnFlags, TableColumnWidth, TableFlags, Ui}; +use ghostty_input::binding::Action as BindingAction; +use ghostty_input::{Action, Key, KeyEvent, Mods}; + +/// The maximum number of recorded events (Zig: the ring grows by doubling up +/// to this capacity, after which the oldest event is dropped). +pub const MAX_EVENTS: usize = 50; + +/// The recorded copy of the input event (Zig keeps an `input.KeyEvent` whose +/// `utf8` is owned by the inspector). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InputEvent { + pub action: Action, + pub key: Key, + pub mods: Mods, + pub consumed_mods: Mods, + pub composing: bool, + pub utf8: String, + pub unshifted_codepoint: u32, +} + +impl InputEvent { + pub fn from_key_event(ev: &KeyEvent<'_>) -> Self { + Self { + action: ev.action, + key: ev.key, + mods: ev.mods, + consumed_mods: ev.consumed_mods, + composing: ev.composing, + utf8: ev.utf8.to_owned(), + unshifted_codepoint: ev.unshifted_codepoint, + } + } +} + +/// Represents a recorded keyboard event. +#[derive(Debug, Clone)] +pub struct Event { + /// The input event. + pub event: InputEvent, + + /// The binding that was triggered as a result of this event. + /// Multiple bindings are possible if they are chained. + pub binding: Vec, + + /// The data sent to the pty as a result of this keyboard event. + pub pty: Vec, + + /// State for the inspector GUI. Do not set this unless you're the inspector. + pub imgui_selected: bool, + + /// Unique ID used for the ImGui ID stack (Zig used the event pointer). + id: u64, +} + +fn next_event_id() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(1); + NEXT.fetch_add(1, Ordering::Relaxed) +} + +fn action_name(a: Action) -> &'static str { + match a { + Action::Press => "press", + Action::Release => "release", + Action::Repeat => "repeat", + } +} + +/// The name of the key, or its UTF-8 for unidentified keys. +fn key_display(ev: &InputEvent) -> String { + match ev.key { + Key::Unidentified if !ev.utf8.is_empty() => ev.utf8.clone(), + k => k.name().to_owned(), + } +} + +/// Formats pty data like the inspector shows it. +fn format_pty(data: &[u8]) -> String { + use std::fmt::Write; + let mut out = String::new(); + for &byte in data { + // Print ESC special because its so common + if byte == 0x1B { + out.push_str("ESC "); + continue; + } + + // Print ASCII as-is + if byte > 0x20 && byte < 0x7F { + out.push(char::from(byte)); + continue; + } + + // Everything else as a hex byte + let _ = write!(out, "0x{byte:X} "); + } + out +} + +/// Formats a UTF-8 string as a sequence of codepoints. +fn format_utf8(utf8: &str) -> String { + use std::fmt::Write; + let mut out = String::new(); + for ch in utf8.chars() { + let _ = write!(out, "U+{:X} ", u32::from(ch)); + } + out +} + +impl Event { + pub fn new(ev: &KeyEvent<'_>) -> Self { + Self { + event: InputEvent::from_key_event(ev), + binding: Vec::new(), + pty: Vec::new(), + imgui_selected: false, + id: next_event_id(), + } + } + + /// Returns a label that can be used for this event. + pub fn label(&self) -> String { + let mut out = String::new(); + match self.event.action { + Action::Press => out.push_str("Press: "), + Action::Release => out.push_str("Release: "), + Action::Repeat => out.push_str("Repeat: "), + } + + if self.event.mods.shift { + out.push_str("Shift+"); + } + if self.event.mods.ctrl { + out.push_str("Ctrl+"); + } + if self.event.mods.alt { + out.push_str("Alt+"); + } + if self.event.mods.super_ { + out.push_str("Super+"); + } + + // Write our key. If we have an invalid key we attempt to write + // the utf8 associated with it if we have it to handle non-ascii. + out.push_str(&key_display(&self.event)); + + // Deadkey + if self.event.composing { + out.push_str(" (composing)"); + } + + out + } + + /// Render this event in the inspector GUI. + pub fn render(&self, ui: &Ui) { + let Some(_t) = ui.begin_table_with_flags("##event", 2, TableFlags::NONE) else { + return; + }; + + if !self.binding.is_empty() { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Triggered Binding"); + ui.table_set_column_index(1); + + let height = { + let item_count = self.binding.len().min(5) as f32; + let padding = ui.clone_style().frame_padding()[1] * 2.0; + ui.text_line_height_with_spacing() * item_count + padding + }; + if let Some(_list) = ui + .list_box_config("##bindings") + .size([0.0, height]) + .begin(ui) + { + for action in &self.binding { + ui.selectable(action.key().name()); + } + } + } + + { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Encoding to Pty"); + ui.table_set_column_index(1); + if self.pty.is_empty() { + ui.text_disabled("(no data)"); + } else { + let mut buf = format_pty(&self.pty); + ui.input_text("##pty", &mut buf) + .flags(InputTextFlags::READ_ONLY) + .build(); + } + } + + { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Action"); + ui.table_set_column_index(1); + ui.text(action_name(self.event.action)); + } + { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Key"); + ui.table_set_column_index(1); + ui.text(self.event.key.name()); + } + if !self.event.mods.empty() { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Mods"); + ui.table_set_column_index(1); + if self.event.mods.shift { + ui.text("shift "); + } + if self.event.mods.ctrl { + ui.text("ctrl "); + } + if self.event.mods.alt { + ui.text("alt "); + } + if self.event.mods.super_ { + ui.text("super "); + } + } + if self.event.composing { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Composing"); + ui.table_set_column_index(1); + ui.text("true"); + } + { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("UTF-8"); + ui.table_set_column_index(1); + if self.event.utf8.is_empty() { + ui.text_disabled("(empty)"); + } else { + let mut buf = format_utf8(&self.event.utf8); + ui.input_text("##utf8", &mut buf) + .flags(InputTextFlags::READ_ONLY) + .build(); + } + } + } +} + +fn mods_tooltip(mods: &Mods) -> String { + let mut parts = Vec::new(); + if mods.shift { + parts.push("Shift"); + } + if mods.ctrl { + parts.push("Ctrl"); + } + if mods.alt { + parts.push("Alt"); + } + if mods.super_ { + parts.push("Super"); + } + parts.join("+") +} + +/// Keyboard event stream inspector widget. +#[derive(Debug, Default)] +pub struct Stream { + pub events: VecDeque, +} + +impl Stream { + pub fn new() -> Self { + Self { + events: VecDeque::with_capacity(MAX_EVENTS), + } + } + + /// Append an event, dropping the oldest one when full. + pub fn record(&mut self, ev: Event) { + if self.events.len() >= MAX_EVENTS { + self.events.pop_front(); + } + self.events.push_back(ev); + } + + pub fn draw(&mut self, ui: &Ui, open: bool) { + if !open { + return; + } + + if self.events.is_empty() { + ui.text("No recorded key events. Press a key with the terminal focused to record it."); + return; + } + + if ui.button("Clear") { + self.events.clear(); + } + + ui.separator(); + + let table_flags = TableFlags::BORDERS | TableFlags::RESIZABLE | TableFlags::SCROLL_Y; + + let Some(_t) = ui.begin_table_with_flags( + "table_key_events", + 6, + dear_imgui_rs::TableOptions::default() + .flags(table_flags) + .sizing_policy(dear_imgui_rs::TableSizingPolicy::FixedFit), + ) else { + return; + }; + + ui.table_setup_scroll_freeze(0, 1); + let fixed = |name: &str, w: f32| { + ui.table_setup_column( + name, + TableColumnFlags::NONE, + Some(TableColumnWidth::Fixed(w)), + ); + }; + fixed("Action", 80.0); + fixed("Key", 160.0); + fixed("Mods", 150.0); + fixed("UTF-8", 80.0); + ui.table_setup_column( + "PTY Encoding", + TableColumnFlags::NONE, + Some(TableColumnWidth::Stretch(0.0)), + ); + ui.table_setup_column( + "Binding", + TableColumnFlags::NONE, + Some(TableColumnWidth::Stretch(0.0)), + ); + ui.table_headers_row(); + + for ev in self.events.iter().rev() { + let _id = ui.push_id(ev.id as i32); + + ui.table_next_row(); + let row_min_y = ui.cursor_screen_pos()[1]; + + // Set row background color based on action + ui.table_set_row_bg0_color_u32(action_color(ev.event.action)); + + // Action column with colored text + ui.table_set_column_index(0); + let action_text_color = match ev.event.action { + Action::Press => [0.4, 1.0, 0.4, 1.0], // Green + Action::Release => [0.6, 0.6, 1.0, 1.0], // Blue + Action::Repeat => [1.0, 1.0, 0.4, 1.0], // Yellow + }; + ui.text_colored(action_text_color, action_name(ev.event.action)); + + // Key column with consistent key coloring + ui.table_set_column_index(1); + let key_name = key_display(&ev.event); + let key_rgba = key_color(ev.event.key); + let key_color = [ + (key_rgba & 0xFF) as f32 / 255.0, + ((key_rgba >> 8) & 0xFF) as f32 / 255.0, + ((key_rgba >> 16) & 0xFF) as f32 / 255.0, + 1.0, + ]; + ui.text_colored(key_color, key_name); + + // Composing indicator + if ev.event.composing { + ui.same_line(); + ui.text_colored([1.0, 0.6, 0.0, 1.0], "*"); + if ui.is_item_hovered() { + ui.set_tooltip("Composing (dead key)"); + } + } + + // Mods + ui.table_set_column_index(2); + if ev.event.mods.empty() { + ui.text_disabled("-"); + } else { + let mut any_hovered = false; + for (set, label) in [ + (ev.event.mods.shift, "S"), + (ev.event.mods.ctrl, "C"), + (ev.event.mods.alt, "A"), + (ev.event.mods.super_, "M"), + ] { + if set { + ui.small_button(label); + any_hovered = any_hovered || ui.is_item_hovered(); + ui.same_line(); + } + } + ui.new_line(); + + if any_hovered { + ui.set_tooltip(mods_tooltip(&ev.event.mods)); + } + } + + // UTF-8 + ui.table_set_column_index(3); + if ev.event.utf8.is_empty() { + ui.text_disabled("-"); + } else { + ui.text(format_utf8(&ev.event.utf8)); + } + + // PTY + ui.table_set_column_index(4); + if ev.pty.is_empty() { + ui.text_disabled("-"); + } else { + ui.text(format_pty(&ev.pty)); + } + + // Binding + ui.table_set_column_index(5); + if ev.binding.is_empty() { + ui.text_disabled("-"); + } else { + let names: Vec<&str> = ev.binding.iter().map(|a| a.key().name()).collect(); + ui.text(names.join(", ")); + } + + // Row hover highlight + let row_max_y = ui.cursor_screen_pos()[1]; + let mouse_pos = ui.mouse_pos(); + if mouse_pos[1] >= row_min_y && mouse_pos[1] < row_max_y { + ui.table_set_row_bg1_color_u32(0x1AFFFFFF); + } + } + } +} + +/// Returns row background color for an action (ABGR format for ImGui) +fn action_color(action: Action) -> u32 { + match action { + Action::Press => 0x1A4A6F4A, // Muted sage green + Action::Release => 0x1A6A5A5A, // Muted slate gray + Action::Repeat => 0x1A4A5A6F, // Muted warm brown + } +} + +/// Generate a consistent color for a key based on its enum value. +/// Uses HSV color space with fixed saturation and value for pleasing colors. +fn key_color(key: Key) -> u32 { + let key_int = key as u32; + let hue = key_int.wrapping_mul(47) as f32 / 256.0; + hsv_to_rgba(hue, 0.5, 0.9, 1.0) +} + +/// Convert HSV (hue 0-1, saturation 0-1, value 0-1) to RGBA u32. +fn hsv_to_rgba(h: f32, s: f32, v: f32, a: f32) -> u32 { + let i = (h * 6.0) as u32; + let f = h * 6.0 - i as f32; + let p = v * (1.0 - s); + let q = v * (1.0 - f * s); + let t = v * (1.0 - (1.0 - f) * s); + + let (r, g, b) = match i % 6 { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + }; + + let ri = (r * 255.0) as u32; + let gi = (g * 255.0) as u32; + let bi = (b * 255.0) as u32; + let ai = (a * 255.0) as u32; + + (ai << 24) | (bi << 16) | (gi << 8) | ri +} + +#[cfg(test)] +mod tests { + use super::*; + + fn press(key: Key, utf8: &str) -> Event { + Event::new(&KeyEvent { + action: Action::Press, + key, + utf8, + ..KeyEvent::default() + }) + } + + #[test] + fn event_label() { + let mut ev = press(Key::KeyA, "a"); + ev.event.mods.ctrl = true; + ev.event.mods.shift = true; + assert_eq!(ev.label(), "Press: Shift+Ctrl+key_a"); + + let ev = press(Key::Unidentified, "é"); + assert_eq!(ev.label(), "Press: é"); + } + + #[test] + fn pty_formatting() { + assert_eq!(format_pty(b"\x1b[A"), "ESC [A"); + assert_eq!(format_pty(&[0x0d, b'a', 0x20]), "0xD a0x20 "); + } + + #[test] + fn utf8_formatting() { + assert_eq!(format_utf8("aé"), "U+61 U+E9 "); + } + + #[test] + fn ring_drops_oldest() { + let mut s = Stream::new(); + for _ in 0..(MAX_EVENTS + 5) { + s.record(press(Key::KeyA, "a")); + } + assert_eq!(s.events.len(), MAX_EVENTS); + } + + #[test] + fn hsv_matches_zig() { + // pure red, value 1 + assert_eq!(hsv_to_rgba(0.0, 1.0, 1.0, 1.0), 0xFF00_00FF); + } +} diff --git a/crates/ghostty-inspector/src/widgets/mod.rs b/crates/ghostty-inspector/src/widgets/mod.rs new file mode 100644 index 0000000..6550f9a --- /dev/null +++ b/crates/ghostty-inspector/src/widgets/mod.rs @@ -0,0 +1,194 @@ +//! Widgets of the inspector (ZIG/src/inspector/widgets.zig and widgets/*). + +use dear_imgui_rs::{ItemHoveredFlags, TreeNodeFlags, Ui, WindowFlags}; + +pub mod key; +pub mod page; +pub mod pagelist; +pub mod renderer; +pub mod screen; +pub mod style; +pub mod surface; +pub mod terminal; +pub mod termio; + +/// Draws a "(?)" disabled text marker that shows some help text +/// on hover. +pub fn help_marker(ui: &Ui, text: &str) { + ui.text_disabled("(?)"); + // `BeginItemTooltip` + if !ui.is_item_hovered_with_flags(ItemHoveredFlags::FOR_TOOLTIP) { + return; + } + let Some(_tooltip) = ui.begin_tooltip() else { + return; + }; + + let _wrap = ui.push_text_wrap_pos(ui.current_font_size() * 35.0); + ui.text(text); +} + +/// `DetachableHeader` allows rendering a collapsing header that can be +/// detached into its own window. +#[derive(Debug, Clone)] +pub struct DetachableHeader { + /// Set whether the window is detached. + pub detached: bool, + + /// If true, detaching will move the item into a docking position + /// to the right. + pub dock: bool, + + // Internal state do not touch. + window_first: bool, +} + +impl Default for DetachableHeader { + fn default() -> Self { + Self { + detached: false, + dock: true, + window_first: true, + } + } +} + +impl DetachableHeader { + /// Draws the detached window if this header is detached; `content` is + /// only called when the window is open (the `Begin` return value) and + /// `End` is always called. Does nothing if not detached. + pub fn window(&mut self, ui: &Ui, label: &str, content: impl FnOnce()) { + // If we're not detached, we don't create a window. + if !self.detached { + self.window_first = true; + return; + } + + // If this is our first time showing the window then we need to + // setup docking. We only do this on the first time because we + // don't want to reset a user's docking behavior later. + if self.window_first { + self.window_first = false; + if self.dock { + let dock_id = crate::dock::window_dock_id(ui); + if dock_id != 0 { + crate::dock::split_right_and_dock(dock_id, 0.4, label); + } + } + } + + let mut detached = self.detached; + ui.window(label) + .flags(WindowFlags::NO_FOCUS_ON_APPEARING) + .opened(&mut detached) + .build(content); + self.detached = detached; + } + + pub fn header(&mut self, ui: &Ui, label: &str) -> bool { + // If we're detached, create a separate window. + if self.detached { + return false; + } + + // Make sure all headers have a unique ID in the stack. We only + // need to do this for the header side because creating a window + // automatically creates an ID. + let _id = ui.push_id(label); + + // Create the collapsing header with the pop out button overlaid. + crate::dock::set_next_item_allow_overlap(ui); + let is_open = ui.collapsing_header(label, TreeNodeFlags::NONE); + + // Place pop-out button inside the header bar + let (header_min, header_max) = ui.item_rect(); + let frame_height = ui.frame_height(); + let button_size = frame_height - 4.0; + let padding = 4.0; + + ui.same_line(); + ui.set_cursor_screen_pos([header_max[0] - button_size - padding, header_min[1] + 2.0]); + { + let _style = ui.push_style_var(dear_imgui_rs::StyleVar::FramePadding([0.0, 0.0])); + if ui.button_with_size(">>##detach", [button_size, button_size]) { + self.detached = true; + } + } + + if ui.is_item_hovered_with_flags(ItemHoveredFlags::DELAY_SHORT) { + ui.set_tooltip("Detach into separate window"); + } + + is_open + } +} + +#[derive(Debug, Clone, Default)] +pub struct DetachableHeaderState { + pub show_window: bool, + + /// Internal state. Don't touch. + first_show: bool, +} + +/// Render a collapsing header that can be detached into its own window. +/// When detached, renders as a separate window with a close button. +/// When attached, renders as a collapsing header with a pop-out button. +pub fn detachable_header( + ui: &Ui, + label: &str, + state: &mut DetachableHeaderState, + content: impl FnOnce(), +) { + let _id = ui.push_id(label); + + if state.show_window { + // On first show, dock this window to the right of the parent + // window's dock. We only do this once so the user can freely + // reposition the window afterward without it snapping back to the + // right on every frame. + if !state.first_show { + state.first_show = true; + let current_dock_id = crate::dock::window_dock_id(ui); + if current_dock_id != 0 { + crate::dock::split_right_and_dock(current_dock_id, 0.3, label); + } + } + + let mut show = state.show_window; + ui.window(label) + .flags(WindowFlags::NO_FOCUS_ON_APPEARING) + .opened(&mut show) + .build(content); + state.show_window = show; + return; + } + + // Reset first_show when window is closed so next open docks again + state.first_show = false; + + crate::dock::set_next_item_allow_overlap(ui); + let is_open = ui.collapsing_header(label, TreeNodeFlags::NONE); + + // Place pop-out button inside the header bar + let (header_min, header_max) = ui.item_rect(); + let frame_height = ui.frame_height(); + let button_size = frame_height - 4.0; + let padding = 4.0; + + ui.same_line(); + ui.set_cursor_screen_pos([header_max[0] - button_size - padding, header_min[1] + 2.0]); + { + let _style = ui.push_style_var(dear_imgui_rs::StyleVar::FramePadding([0.0, 0.0])); + if ui.button_with_size(">>##detach", [button_size, button_size]) { + state.show_window = true; + } + } + if ui.is_item_hovered_with_flags(ItemHoveredFlags::DELAY_SHORT) { + ui.set_tooltip("Pop out into separate window"); + } + + if is_open { + content(); + } +} diff --git a/crates/ghostty-inspector/src/widgets/page.rs b/crates/ghostty-inspector/src/widgets/page.rs new file mode 100644 index 0000000..83b3dc4 --- /dev/null +++ b/crates/ghostty-inspector/src/widgets/page.rs @@ -0,0 +1,384 @@ +//! Page inspector (ZIG/src/inspector/widgets/page.zig). + +use dear_imgui_rs::{ChildFlags, TableColumnFlags, TableColumnWidth, TableFlags, Ui}; +use ghostty_terminal::hyperlink::HyperlinkId; +use ghostty_terminal::page::Page; + +use super::{help_marker, style}; +use crate::units::to_kibi_bytes; + +pub(crate) fn setup_fixed(ui: &Ui, label: &str) { + ui.table_setup_column( + label, + TableColumnFlags::NONE, + Some(TableColumnWidth::Fixed(0.0)), + ); +} + +pub(crate) fn setup_stretch(ui: &Ui, label: &str) { + ui.table_setup_column( + label, + TableColumnFlags::NONE, + Some(TableColumnWidth::Stretch(0.0)), + ); +} + +/// The flags used by most of the page tables. +fn page_table_flags() -> TableFlags { + TableFlags::BORDERS_INNER_V | TableFlags::ROW_BG +} + +/// Sizing policy used by most of the page tables (`SizingFixedFit`). +fn begin_page_table<'a>( + ui: &'a Ui, + id: &str, + columns: usize, +) -> Option> { + ui.begin_table_with_flags( + id, + columns, + dear_imgui_rs::TableOptions::default() + .flags(page_table_flags()) + .sizing_policy(dear_imgui_rs::TableSizingPolicy::FixedFit), + ) +} + +pub fn inspector(ui: &Ui, page: &Page) { + ui.separator_with_text("Managed Memory"); + managed_memory(ui, page); + + ui.separator_with_text("Styles"); + styles_list(ui, page); + + ui.separator_with_text("Hyperlinks"); + hyperlinks_list(ui, page); + + ui.separator_with_text("Rows"); + rows_table(ui, page); +} + +/// Arguments for [`tree_node`]. +pub struct TreeNodeState<'a> { + /// The page + pub page: &'a Page, + /// The index of the page in a page list, used for headers. + pub index: usize, + /// The range of rows this page covers, inclusive. + pub row_range: [usize; 2], + /// Whether this page is the active or viewport node. + pub active: bool, + pub viewport: bool, +} + +/// Draw a tree node header with metadata about this page. Returns the tree +/// node token if the tree node is open; dropping it closes the node. +pub fn tree_node<'ui>( + ui: &'ui Ui, + state: &TreeNodeState<'_>, +) -> Option> { + // Setup our node. + let label = format!("Page {}", state.index); + let token = ui + .tree_node_config(label.as_str()) + .allow_item_overlap(true) + .span_full_width(true) + .frame_padding(true) + .push(); + + // Move our cursor into the tree header so we can add extra info. + let (header_min, header_max) = ui.item_rect(); + let header_height = header_max[1] - header_min[1]; + let text_line = ui.text_line_height(); + let y_center = header_min[1] + (header_height - text_line) * 0.5; + ui.set_cursor_screen_pos([header_min[0] + 170.0, y_center]); + + // Metadata + ui.text_disabled(format!( + "{}c x {}r", + state.page.size.cols, state.page.size.rows + )); + ui.same_line(); + ui.text(format!( + "rows {}..{}", + state.row_range[0], state.row_range[1] + )); + + // Labels + if state.active { + ui.same_line(); + ui.text_colored([0.4, 0.9, 0.4, 1.0], "active"); + } + if state.viewport { + ui.same_line(); + ui.text_colored([0.4, 0.8, 1.0, 1.0], "viewport"); + } + if state.page.is_dirty() { + ui.same_line(); + ui.text_colored([1.0, 0.4, 0.4, 1.0], "dirty"); + } + + token +} + +pub fn managed_memory(ui: &Ui, page: &Page) { + let layout = Page::layout(page.capacity); + + if let Some(_t) = begin_page_table(ui, "##overview", 3) { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Memory Size"); + ui.table_set_column_index(1); + help_marker( + ui, + "Memory allocated for this page. Note the backing memory \ + may be a larger allocation from which this page \ + uses a portion.", + ); + ui.table_set_column_index(2); + ui.text(format!("{} KiB", to_kibi_bytes(layout.total_size))); + } + + if let Some(_t) = begin_page_table(ui, "##managed", 4) { + setup_fixed(ui, "Resource"); + setup_fixed(ui, ""); + setup_fixed(ui, "Used"); + setup_fixed(ui, "Capacity"); + ui.table_headers_row(); + + let size = page.size; + let cap = page.capacity; + let row = |name: &str, help: &str, used: String, capacity: 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(used); + ui.table_set_column_index(3); + ui.text(capacity); + }; + + row( + "Columns", + "Number of columns in the terminal grid.", + size.cols.to_string(), + cap.cols.to_string(), + ); + row( + "Rows", + "Number of rows in this page.", + size.rows.to_string(), + cap.rows.to_string(), + ); + row( + "Styles", + "Unique text styles (colors, attributes) currently in use.", + page.styles.count().to_string(), + layout.styles_layout.cap.to_string(), + ); + row( + "Graphemes", + "Extended grapheme clusters for multi-codepoint characters.", + page.grapheme_count().to_string(), + page.grapheme_capacity().to_string(), + ); + row( + "Strings (bytes)", + "String storage for hyperlink URIs and other text data.", + page.string_used_bytes().to_string(), + page.string_capacity_bytes().to_string(), + ); + row( + "Hyperlink Map", + "Maps cell positions to hyperlink IDs.", + page.hyperlink_count().to_string(), + page.hyperlink_capacity().to_string(), + ); + row( + "Hyperlink IDs", + "Unique hyperlink definitions (URI + optional ID).", + page.hyperlink_set.count().to_string(), + layout.hyperlink_set_layout.cap.to_string(), + ); + } +} + +fn rows_table(ui: &Ui, page: &Page) { + let visible_rows = usize::from(page.size.rows).min(12); + let row_height = ui.text_line_height_with_spacing(); + let child_height = row_height * (visible_rows as f32 + 2.0); + + // Child window so scrolling is separate. + ui.child_window("##page_rows") + .size([0.0, child_height]) + .child_flags(ChildFlags::BORDERS) + .build(ui, || { + let Some(_t) = begin_page_table(ui, "##page_rows_table", 10) else { + return; + }; + + ui.table_setup_scroll_freeze(0, 1); + for name in [ + "Row", "Text", "Dirty", "Wrap", "Cont", "Styled", "Grapheme", "Link", "Prompt", + "Kitty", + ] { + setup_fixed(ui, name); + } + ui.table_headers_row(); + + for row_index in 0..usize::from(page.size.rows) { + let (Some(row), Some(cells)) = (page.get_row(row_index), page.get_cells(row_index)) + else { + continue; + }; + let text_cells = cells.iter().filter(|c| c.has_text()).count(); + + ui.table_next_row(); + + ui.table_set_column_index(0); + ui.text(row_index.to_string()); + + ui.table_set_column_index(1); + if text_cells == 0 { + ui.text_disabled("0"); + } else { + ui.text(text_cells.to_string()); + } + + ui.table_set_column_index(2); + flag_cell(ui, row.dirty()); + + ui.table_set_column_index(3); + flag_cell(ui, row.wrap()); + + ui.table_set_column_index(4); + flag_cell(ui, row.wrap_continuation()); + + ui.table_set_column_index(5); + flag_cell(ui, row.styled()); + + ui.table_set_column_index(6); + flag_cell(ui, row.grapheme()); + + ui.table_set_column_index(7); + flag_cell(ui, row.hyperlink()); + + ui.table_set_column_index(8); + ui.text(crate::keys::tag_name(&row.semantic_prompt())); + + ui.table_set_column_index(9); + flag_cell(ui, row.kitty_virtual_placeholder()); + } + }); +} + +fn styles_list(ui: &Ui, page: &Page) { + let items: Vec<_> = page + .styles + .entries() + .filter(|(id, _, _)| *id != 0) + .collect(); + let count = items.len(); + + if count == 0 { + ui.text_disabled("(no styles in use)"); + return; + } + + let visible_rows = count.min(8); + let row_height = ui.text_line_height_with_spacing(); + let child_height = row_height * (visible_rows as f32 + 2.0); + + ui.child_window("##page_styles") + .size([0.0, child_height]) + .child_flags(ChildFlags::BORDERS) + .build(ui, || { + let Some(_t) = begin_page_table(ui, "##page_styles_table", 3) else { + return; + }; + + ui.table_setup_scroll_freeze(0, 1); + setup_fixed(ui, "ID"); + setup_fixed(ui, "Refs"); + setup_stretch(ui, "Style"); + ui.table_headers_row(); + + for (index, st, refs) in items { + ui.table_next_row(); + let _id = ui.push_id(i32::from(index)); + + ui.table_set_column_index(0); + ui.text(index.to_string()); + + ui.table_set_column_index(1); + ui.text(refs.to_string()); + + ui.table_set_column_index(2); + if let Some(_node) = ui.tree_node("Details") { + style::table(ui, st, None); + } + } + }); +} + +fn hyperlinks_list(ui: &Ui, page: &Page) { + let items: Vec<_> = page + .hyperlink_set + .entries() + .filter(|(id, _, _)| *id != 0) + .collect(); + let count = items.len(); + + if count == 0 { + ui.text_disabled("(no hyperlinks in use)"); + return; + } + + let visible_rows = count.min(8); + let row_height = ui.text_line_height_with_spacing(); + let child_height = row_height * (visible_rows as f32 + 2.0); + + ui.child_window("##page_hyperlinks") + .size([0.0, child_height]) + .child_flags(ChildFlags::BORDERS) + .build(ui, || { + let Some(_t) = begin_page_table(ui, "##page_hyperlinks_table", 4) else { + return; + }; + + ui.table_setup_scroll_freeze(0, 1); + setup_fixed(ui, "ID"); + setup_fixed(ui, "Refs"); + setup_fixed(ui, "Explicit ID"); + setup_stretch(ui, "URI"); + ui.table_headers_row(); + + for (index, link, refs) in items { + ui.table_next_row(); + + ui.table_set_column_index(0); + ui.text(index.to_string()); + + ui.table_set_column_index(1); + ui.text(refs.to_string()); + + ui.table_set_column_index(2); + match &link.id { + HyperlinkId::Explicit(id) => ui.text(String::from_utf8_lossy(id)), + HyperlinkId::Implicit(_) => ui.text_disabled("-"), + } + + ui.table_set_column_index(3); + ui.text(String::from_utf8_lossy(&link.uri)); + } + }); +} + +fn flag_cell(ui: &Ui, value: bool) { + if value { + ui.text_colored([0.4, 0.9, 0.4, 1.0], "yes"); + } else { + ui.text_disabled("-"); + } +} diff --git a/crates/ghostty-inspector/src/widgets/pagelist.rs b/crates/ghostty-inspector/src/widgets/pagelist.rs new file mode 100644 index 0000000..846be15 --- /dev/null +++ b/crates/ghostty-inspector/src/widgets/pagelist.rs @@ -0,0 +1,802 @@ +//! 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> { + 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> { + 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 { + 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}")); + }, + ); + } + } +} diff --git a/crates/ghostty-inspector/src/widgets/renderer.rs b/crates/ghostty-inspector/src/widgets/renderer.rs new file mode 100644 index 0000000..7ef8ef9 --- /dev/null +++ b/crates/ghostty-inspector/src/widgets/renderer.rs @@ -0,0 +1,116 @@ +//! Renderer info window (ZIG/src/inspector/widgets/renderer.zig). + +use dear_imgui_rs::{ColorButtonFlags, Condition, TreeNodeFlags, Ui}; +use ghostty_renderer::overlay::{Color, Feature}; + +use super::help_marker; + +/// Renderer information inspector widget. +#[derive(Debug, Default)] +pub struct Info { + /// The enabled overlay features, in the order they were enabled (Zig: + /// an array hash map keyed by feature tag). + pub features: Vec, +} + +impl Info { + /// Grab the features into a new allocated vec. + pub fn overlay_features(&self) -> Vec { + self.features.clone() + } + + fn set_feature(&mut self, feature: Feature, enabled: bool) { + let pos = self.features.iter().position(|f| *f == feature); + match (enabled, pos) { + (false, Some(i)) => { + self.features.remove(i); + } + (true, None) => self.features.push(feature), + _ => {} + } + } + + /// Draw the renderer info window. + pub fn draw(&mut self, ui: &Ui, open: bool) { + if !open { + return; + } + + ui.set_next_item_open_with_cond(true, Condition::Once); + if !ui.collapsing_header("Overlays", TreeNodeFlags::NONE) { + return; + } + + ui.separator_with_text("Hyperlinks"); + self.overlay_hyperlinks(ui); + ui.separator_with_text("Semantic Prompts"); + self.overlay_semantic_prompts(ui); + } + + fn overlay_hyperlinks(&mut self, ui: &Ui) { + let mut hyperlinks = self.features.contains(&Feature::HighlightHyperlinks); + ui.checkbox("Overlay Hyperlinks", &mut hyperlinks); + ui.same_line(); + help_marker(ui, "When enabled, highlights OSC8 hyperlinks."); + + self.set_feature(Feature::HighlightHyperlinks, hyperlinks); + } + + fn overlay_semantic_prompts(&mut self, ui: &Ui) { + let mut semantic_prompts = self.features.contains(&Feature::SemanticPrompts); + ui.checkbox("Overlay Semantic Prompts", &mut semantic_prompts); + ui.same_line(); + help_marker(ui, "When enabled, highlights OSC 133 semantic prompts."); + + // Handle the checkbox results + self.set_feature(Feature::SemanticPrompts, semantic_prompts); + + // Help + ui.indent(); + + ui.text_disabled("Colors:"); + + let to_col = |c: Color| { + let rgb = c.rgb(); + [ + f32::from(rgb[0]) / 255.0, + f32::from(rgb[1]) / 255.0, + f32::from(rgb[2]) / 255.0, + 1.0, + ] + }; + + ui.color_button_config("##prompt_color", to_col(Color::SemanticPrompt)) + .flags(ColorButtonFlags::NO_TOOLTIP) + .build(); + ui.same_line(); + ui.text("Prompt"); + + ui.color_button_config("##input_color", to_col(Color::SemanticInput)) + .flags(ColorButtonFlags::NO_TOOLTIP) + .build(); + ui.same_line(); + ui.text("Input"); + + ui.unindent(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn features_toggle() { + let mut info = Info::default(); + info.set_feature(Feature::HighlightHyperlinks, true); + info.set_feature(Feature::HighlightHyperlinks, true); + info.set_feature(Feature::SemanticPrompts, true); + assert_eq!( + info.overlay_features(), + vec![Feature::HighlightHyperlinks, Feature::SemanticPrompts] + ); + info.set_feature(Feature::HighlightHyperlinks, false); + assert_eq!(info.overlay_features(), vec![Feature::SemanticPrompts]); + } +} diff --git a/crates/ghostty-inspector/src/widgets/screen.rs b/crates/ghostty-inspector/src/widgets/screen.rs new file mode 100644 index 0000000..18c2e54 --- /dev/null +++ b/crates/ghostty-inspector/src/widgets/screen.rs @@ -0,0 +1,400 @@ +//! Screen information (ZIG/src/inspector/widgets/screen.zig). + +use dear_imgui_rs::{DockNodeFlags, TableFlags, TreeNodeFlags, Ui, WindowFlags}; +use ghostty_terminal::kitty::graphics_storage::ImageStorage; +use ghostty_terminal::screen::{Cursor, Screen, SemanticClick, SemanticPrompt}; +use ghostty_terminal::screen_set::Key as ScreenKey; +use ghostty_terminal::style::Style; +use ghostty_vt::color::{DynamicPalette, Palette}; + +use super::{help_marker, pagelist, style as style_widget}; +use crate::units::to_kibi_bytes; + +/// Window names for the screen dockspace. +const WINDOW_INFO: &str = "Info"; +const WINDOW_CELL: &str = "Cell"; +const WINDOW_PAGELIST: &str = "PageList"; + +/// A copy of the parts of a [`Cursor`] that the inspector shows. Zig copies +/// the whole cursor struct (including pointers) into recorded VT events; in +/// Rust the cursor owns a heap hyperlink and a tracked pin so we snapshot the +/// displayed fields instead. +#[derive(Debug, Clone)] +pub struct CursorSnapshot { + pub x: u16, + pub y: u16, + pub pending_wrap: bool, + pub protected: bool, + pub style: Style, + /// The URI of the active hyperlink, if any. + pub hyperlink_uri: Option, +} + +impl CursorSnapshot { + pub fn new(cursor: &Cursor) -> Self { + Self { + x: cursor.x, + y: cursor.y, + pending_wrap: cursor.pending_wrap, + protected: cursor.protected, + style: cursor.style, + hyperlink_uri: cursor + .hyperlink + .as_ref() + .map(|h| String::from_utf8_lossy(&h.uri).into_owned()), + } + } +} + +/// Screen information inspector widget. +#[derive(Debug, Default)] +pub struct Info { + pub pagelist: pagelist::Inspector, + pub cell_chooser: pagelist::CellChooser, +} + +/// The data needed to draw a screen. +pub struct Data<'a> { + /// The screen that we're inspecting. + pub screen: &'a mut Screen, + + /// Which screen key we're viewing. + pub key: ScreenKey, + + /// Which screen is active (primary or alternate). + pub active_key: ScreenKey, + + /// Whether xterm modify other keys mode 2 is enabled. + pub modify_other_keys_2: bool, + + /// Color palette for cursor color resolution. + pub color_palette: &'a DynamicPalette, +} + +impl Info { + /// Draw the screen info contents. + pub fn draw(&mut self, ui: &Ui, open: bool, data: Data<'_>) { + // Create the dockspace for this screen + Self::create_dock_space(ui); + + let screen = data.screen; + + // Info window + ui.window(WINDOW_INFO) + .flags(WindowFlags::NO_FOCUS_ON_APPEARING) + .build(|| { + if ui.collapsing_header("Cursor", TreeNodeFlags::DEFAULT_OPEN) { + let snapshot = CursorSnapshot::new(&screen.cursor); + cursor_table(ui, &snapshot); + ui.separator(); + cursor_style(ui, &snapshot, Some(&data.color_palette.current)); + } + + if ui.collapsing_header("Keyboard", TreeNodeFlags::DEFAULT_OPEN) { + keyboard_table(ui, screen, data.modify_other_keys_2); + } + + if ui.collapsing_header("Semantic Prompt", TreeNodeFlags::DEFAULT_OPEN) { + semantic_prompt_table(ui, &screen.semantic_prompt); + } + + if ui.collapsing_header("Kitty Graphics", TreeNodeFlags::DEFAULT_OPEN) { + kitty_graphics_table(ui, &screen.kitty_images); + } + + if ui.collapsing_header("Other Screen State", TreeNodeFlags::DEFAULT_OPEN) { + internal_state_table(ui, screen); + } + }); + + // Cell window + ui.window(WINDOW_CELL) + .flags(WindowFlags::NO_FOCUS_ON_APPEARING) + .build(|| { + self.cell_chooser.draw(ui, &screen.pages); + }); + + // PageList window + ui.window(WINDOW_PAGELIST) + .flags(WindowFlags::NO_FOCUS_ON_APPEARING) + .build(|| { + self.pagelist.draw(ui, &mut screen.pages); + }); + + // The remainder is the open state + if !open { + return; + } + + // Show warning if viewing an inactive screen + if data.key != data.active_key { + ui.text_colored([1.0, 0.8, 0.0, 1.0], "\u{26a0} Viewing inactive screen"); + ui.separator(); + } + } + + /// Create the dock space for the screen inspector. This creates + /// a dedicated dock space for the screen inspector windows. But they + /// can of course be undocked and moved around as desired. + fn create_dock_space(ui: &Ui) { + let dockspace_id = ui.get_id("Screen Dockspace").raw(); + + // Check if we need to set up the dockspace + if !crate::dock::dock_node_exists(ui, dockspace_id) { + // Register our dockspace node and dock windows into the space + crate::dock::create_dock_space_node( + ui, + dockspace_id, + None, + &[WINDOW_INFO, WINDOW_CELL, WINDOW_PAGELIST], + ); + } + + // Create the dockspace + let avail = ui.content_region_avail(); + ui.dock_space_raw( + dockspace_id.into(), + [avail[0].max(1.0), avail[1].max(1.0)], + DockNodeFlags::NONE, + None, + ); + } +} + +/// Render cursor state with a table of cursor-specific fields. +pub fn cursor_table(ui: &Ui, cursor: &CursorSnapshot) { + let Some(_t) = ui.begin_table_with_flags("table_cursor", 2, TableFlags::NONE) else { + return; + }; + + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Position (x, y)"); + ui.same_line(); + help_marker( + ui, + "The current cursor position in the terminal grid (0-indexed).", + ); + ui.table_set_column_index(1); + ui.text(format!("({}, {})", cursor.x, cursor.y)); + + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Hyperlink"); + ui.same_line(); + help_marker( + ui, + "The active OSC8 hyperlink for newly printed characters.", + ); + ui.table_set_column_index(1); + if let Some(uri) = &cursor.hyperlink_uri { + ui.text(uri); + } else { + ui.text_disabled("(none)"); + } + + { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Pending Wrap"); + ui.same_line(); + help_marker( + ui, + "The 'last column flag' (LCF). If set, the next character will force a soft-wrap to the next line.", + ); + ui.table_set_column_index(1); + let mut value = cursor.pending_wrap; + ui.checkbox("##pending_wrap", &mut value); + } + + { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Protected"); + ui.same_line(); + help_marker( + ui, + "If enabled, new characters will have the protected attribute set, preventing erasure by certain sequences.", + ); + ui.table_set_column_index(1); + let mut value = cursor.protected; + ui.checkbox("##protected", &mut value); + } +} + +/// Render cursor style information using the shared style table. +pub fn cursor_style(ui: &Ui, cursor: &CursorSnapshot, palette: Option<&Palette>) { + style_widget::table(ui, &cursor.style, palette); +} + +/// Render keyboard information with a table. +fn keyboard_table(ui: &Ui, screen: &Screen, modify_other_keys_2: bool) { + let Some(_t) = ui.begin_table_with_flags("table_keyboard", 2, TableFlags::NONE) else { + return; + }; + + let kitty_flags = screen.kitty_keyboard.current(); + + { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Mode"); + ui.table_set_column_index(1); + let mode = if kitty_flags.int() != 0 { + "kitty" + } else { + "legacy" + }; + ui.text(mode); + } + + if kitty_flags.int() != 0 { + let fields: [(&str, bool); 5] = [ + ("disambiguate", kitty_flags.disambiguate), + ("report_events", kitty_flags.report_events), + ("report_alternates", kitty_flags.report_alternates), + ("report_all", kitty_flags.report_all), + ("report_associated", kitty_flags.report_associated), + ]; + for (name, value) in fields { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text(name); + ui.table_set_column_index(1); + ui.text(if value { "true" } else { "false" }); + } + } else { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Xterm modify keys"); + ui.table_set_column_index(1); + ui.text(if modify_other_keys_2 { "true" } else { "false" }); + } +} + +/// Render kitty graphics information table. +pub fn kitty_graphics_table(ui: &Ui, kitty_images: &ImageStorage) { + if !kitty_images.enabled() { + ui.text_disabled("(Kitty graphics are disabled)"); + return; + } + + let Some(_t) = ui.begin_table_with_flags("##kitty_graphics", 2, TableFlags::NONE) else { + return; + }; + + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Memory Usage"); + ui.table_set_column_index(1); + ui.text(format!( + "{} bytes ({} KiB)", + kitty_images.total_bytes, + to_kibi_bytes(kitty_images.total_bytes) + )); + + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Memory Limit"); + ui.table_set_column_index(1); + ui.text(format!( + "{} bytes ({} KiB)", + kitty_images.total_limit, + to_kibi_bytes(kitty_images.total_limit) + )); + + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Image Count"); + ui.table_set_column_index(1); + ui.text(format!("{}", kitty_images.images.len())); + + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Placement Count"); + ui.table_set_column_index(1); + ui.text(format!("{}", kitty_images.placements.len())); + + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Image Loading"); + ui.table_set_column_index(1); + ui.text(if kitty_images.loading.is_some() { + "true" + } else { + "false" + }); +} + +/// Render internal terminal state table. +pub fn internal_state_table(ui: &Ui, screen: &Screen) { + let pages = &screen.pages; + + let Some(_t) = ui.begin_table_with_flags("##terminal_state", 2, TableFlags::NONE) else { + return; + }; + + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Memory Usage"); + ui.table_set_column_index(1); + ui.text(format!( + "{} bytes ({} KiB)", + pages.page_size, + to_kibi_bytes(pages.page_size) + )); + + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Memory Limit"); + ui.table_set_column_index(1); + ui.text(format!( + "{} bytes ({} KiB)", + pages.max_size(), + to_kibi_bytes(pages.max_size()) + )); + + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Viewport Location"); + ui.table_set_column_index(1); + ui.text(crate::keys::tag_name(&pages.viewport)); +} + +/// Render semantic prompt state table. +pub fn semantic_prompt_table(ui: &Ui, semantic_prompt: &SemanticPrompt) { + let Some(_t) = ui.begin_table_with_flags("##semantic_prompt", 2, TableFlags::NONE) else { + return; + }; + + { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Seen"); + ui.same_line(); + help_marker( + ui, + "Whether any semantic prompt markers (OSC 133) have been seen in this screen.", + ); + ui.table_set_column_index(1); + let mut value = semantic_prompt.seen; + ui.checkbox("##seen", &mut value); + } + + { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Click Handling"); + ui.same_line(); + help_marker( + ui, + "How click events are handled in prompts. Set via 'cl' or 'click_events' options in OSC 133.", + ); + ui.table_set_column_index(1); + match semantic_prompt.click { + SemanticClick::None => ui.text_disabled("(none)"), + SemanticClick::ClickEvents => ui.text("click_events"), + SemanticClick::Cl(cl) => ui.text(format!("cl={}", crate::keys::tag_name(&cl))), + } + } +} diff --git a/crates/ghostty-inspector/src/widgets/style.rs b/crates/ghostty-inspector/src/widgets/style.rs new file mode 100644 index 0000000..457c650 --- /dev/null +++ b/crates/ghostty-inspector/src/widgets/style.rs @@ -0,0 +1,132 @@ +//! Style table (ZIG/src/inspector/widgets/style.zig). + +use dear_imgui_rs::{ColorDisplayMode, ColorEditFlags, ColorEditOptions, TableFlags, Ui}; +use ghostty_terminal::style::{Color, Style}; +use ghostty_vt::color::{Palette, Rgb}; + +use super::help_marker; + +pub(crate) fn rgb_f32(rgb: Rgb) -> [f32; 3] { + [ + f32::from(rgb.r) / 255.0, + f32::from(rgb.g) / 255.0, + f32::from(rgb.b) / 255.0, + ] +} + +/// Render a style as a table. +pub fn table(ui: &Ui, st: &Style, palette: Option<&Palette>) { + if let Some(_t) = ui.begin_table_with_flags("style", 2, TableFlags::NONE) { + { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Foreground"); + ui.same_line(); + help_marker(ui, "The foreground (text) color"); + ui.table_set_column_index(1); + color(ui, "fg", st.fg_color, palette); + } + + { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Background"); + ui.same_line(); + help_marker(ui, "The background (cell) color"); + ui.table_set_column_index(1); + color(ui, "bg", st.bg_color, palette); + } + + { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("Underline"); + ui.same_line(); + help_marker(ui, "The underline color, if underlines are enabled."); + ui.table_set_column_index(1); + color(ui, "underline", st.underline_color, palette); + } + + let style_flags: [(&str, bool, &str); 7] = [ + ( + "bold", + st.flags.bold, + "Text will be rendered with bold weight.", + ), + ( + "italic", + st.flags.italic, + "Text will be rendered in italic style.", + ), + ( + "faint", + st.flags.faint, + "Text will be rendered with reduced intensity.", + ), + ("blink", st.flags.blink, "Text will blink."), + ( + "inverse", + st.flags.inverse, + "Foreground and background colors are swapped.", + ), + ( + "invisible", + st.flags.invisible, + "Text will be invisible (hidden).", + ), + ( + "strikethrough", + st.flags.strikethrough, + "Text will have a line through it.", + ), + ]; + for (name, set, help) in style_flags { + if !set { + continue; + } + + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text(name); + ui.same_line(); + help_marker(ui, help); + } + { + ui.table_set_column_index(1); + ui.text("true"); + } + } + } + + ui.text_disabled("(Any styles not shown are not currently set)"); +} + +fn hex_color_edit(ui: &Ui, rgb: Rgb) { + let mut data = rgb_f32(rgb); + ui.color_edit3_config("color_fg", &mut data) + .flags( + ColorEditOptions::new() + .flags(ColorEditFlags::NO_PICKER | ColorEditFlags::NO_LABEL) + .display_mode(ColorDisplayMode::Hex), + ) + .build(); +} + +/// Render a style color. +pub fn color(ui: &Ui, id: &str, c: Color, palette: Option<&Palette>) { + let _id = ui.push_id(id); + + match c { + Color::None => ui.text("default"), + + Color::Palette(idx) => { + ui.text(format!("Palette {idx}")); + if let Some(p) = palette { + hex_color_edit(ui, p[usize::from(idx)]); + } + } + + Color::Rgb(rgb) => hex_color_edit(ui, rgb), + } +} diff --git a/crates/ghostty-inspector/src/widgets/surface.rs b/crates/ghostty-inspector/src/widgets/surface.rs new file mode 100644 index 0000000..e27cef9 --- /dev/null +++ b/crates/ghostty-inspector/src/widgets/surface.rs @@ -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, + 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, +} + +/// 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}"); + } +} diff --git a/crates/ghostty-inspector/src/widgets/terminal.rs b/crates/ghostty-inspector/src/widgets/terminal.rs new file mode 100644 index 0000000..ef3a121 --- /dev/null +++ b/crates/ghostty-inspector/src/widgets/terminal.rs @@ -0,0 +1,637 @@ +//! Terminal information (ZIG/src/inspector/widgets/terminal.zig). + +use dear_imgui_rs::{ + ColorEditFlags, Condition, ItemHoveredFlags, StyleVar, TableColumnFlags, TableColumnWidth, + TableFlags, TableRowFlags, Ui, WindowFlags, +}; +use ghostty_terminal::screen_set::Key as ScreenKey; +use ghostty_terminal::terminal::{MouseShiftCapture, Terminal}; +use ghostty_vt::color::{DynamicRgb, Palette}; +use ghostty_vt::modes::ENTRIES as MODE_ENTRIES; + +use super::{DetachableHeader, help_marker, screen, style::rgb_f32}; +use crate::keys::tag_name; + +const SCREEN_KEYS: [ScreenKey; 2] = [ScreenKey::Primary, ScreenKey::Alternate]; + +fn screen_index(key: ScreenKey) -> usize { + match key { + ScreenKey::Primary => 0, + ScreenKey::Alternate => 1, + } +} + +/// Terminal information inspector widget. +#[derive(Debug, Default)] +pub struct Info { + /// True if we're showing the 256-color palette window. + pub show_palette: bool, + + /// The various detachable headers. + pub misc_header: DetachableHeader, + pub layout_header: DetachableHeader, + pub mouse_header: DetachableHeader, + pub color_header: DetachableHeader, + pub modes_header: DetachableHeader, + + /// Screen detail windows for each screen key. + pub screens: [Option; 2], +} + +impl Info { + /// Draw the terminal info window. + pub fn draw(&mut self, ui: &Ui, open: bool, t: &mut Terminal) { + // Draw our open state if we're open. + if open { + self.draw_open(ui, t); + } + + // Draw our detached state that draws regardless of if + // we're open or not. + self.misc_header + .window(ui, "Terminal Misc", || misc_table(ui, t)); + self.layout_header + .window(ui, "Terminal Layout", || layout_table(ui, t)); + self.mouse_header + .window(ui, "Terminal Mouse", || mouse_table(ui, t)); + { + let show_palette = &mut self.show_palette; + self.color_header + .window(ui, "Terminal Color", || color_table(ui, t, show_palette)); + } + self.modes_header + .window(ui, "Terminal Modes", || modes_table(ui, t)); + + // Palette pop-out window + if self.show_palette { + let mut show = self.show_palette; + ui.window("256-Color Palette") + .flags(WindowFlags::NO_FOCUS_ON_APPEARING) + .opened(&mut show) + .build(|| palette(ui, "palette", &t.colors.palette.current)); + self.show_palette = show; + } + + // Screen pop-out windows + for key in SCREEN_KEYS { + let idx = screen_index(key); + if self.screens[idx].is_none() { + continue; + } + + let modify_other_keys_2 = t.flags.modify_other_keys_2; + let active_key = t.screens.active_key; + let palette = &t.colors.palette; + let Some(screen_ref) = t.screens.get_mut(key) else { + // Could happen if we opened up a window for a screen + // and that screen was subsequently deinitialized. In + // this case, hide the window. + self.screens[idx] = None; + continue; + }; + + let title = format!("Screen: {}", tag_name(&key)); + + // Setup our next window so it has some size to it. + let viewport_size = ui.main_viewport().size(); + crate::dock::set_next_window_size( + ui, + [ + 400.0_f32.min(viewport_size[0]), + 300.0_f32.min(viewport_size[1]), + ], + Condition::FirstUseEver, + ); + + let mut screen_open = true; + let (screen_draw, _scope) = crate::dock::begin_window( + ui, + &title, + Some(&mut screen_open), + WindowFlags::NO_FOCUS_ON_APPEARING, + ); + if let Some(info) = self.screens[idx].as_mut() { + info.draw( + ui, + screen_draw, + screen::Data { + screen: screen_ref, + key, + active_key, + modify_other_keys_2, + color_palette: palette, + }, + ); + } + + // If the window was closed, remove it from our map so future + // renders don't draw it. + if !screen_open { + self.screens[idx] = None; + } + } + } + + fn draw_open(&mut self, ui: &Ui, t: &mut Terminal) { + // Show our screens up top. + screens_table(ui, t, &mut self.screens); + + if self.misc_header.header(ui, "Misc") { + misc_table(ui, t); + } + if self.layout_header.header(ui, "Layout") { + layout_table(ui, t); + } + if self.mouse_header.header(ui, "Mouse") { + mouse_table(ui, t); + } + if self.color_header.header(ui, "Color") { + color_table(ui, t, &mut self.show_palette); + } + if self.modes_header.header(ui, "Modes") { + modes_table(ui, t); + } + } +} + +/// Render the table of possible screens with various actions. +fn screens_table(ui: &Ui, t: &Terminal, map: &mut [Option; 2]) { + let Some(_t) = ui.begin_table_with_flags( + "screens", + 3, + dear_imgui_rs::TableOptions::default() + .flags(TableFlags::BORDERS | TableFlags::ROW_BG) + .sizing_policy(dear_imgui_rs::TableSizingPolicy::FixedFit), + ) else { + return; + }; + + let fixed = |name: &str| { + ui.table_setup_column( + name, + TableColumnFlags::NONE, + Some(TableColumnWidth::Fixed(0.0)), + ); + }; + fixed("Screen"); + fixed("Status"); + fixed(""); + + // Custom header row to include help marker before "Screen" + { + ui.table_next_row_with_flags(TableRowFlags::HEADERS, 0.0); + { + ui.table_set_column_index(0); + { + let _style = ui.push_style_var(StyleVar::FramePadding([0.0, 0.0])); + help_marker( + ui, + "A terminal can have multiple screens, only one of which is active at \ + a time. Each screen has its own grid, contents, and other state. \ + This section allows you to inspect the different screens managed by \ + the terminal.", + ); + } + ui.same_line_with_spacing(0.0, ui.clone_style().item_inner_spacing()[0]); + ui.table_header("Screen"); + } + { + ui.table_set_column_index(1); + ui.table_header("Status"); + } + { + ui.table_set_column_index(2); + ui.table_header(""); + } + } + + for key in SCREEN_KEYS { + let is_initialized = t.screens.get(key).is_some(); + let is_active = t.screens.active_key == key; + + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text(tag_name(&key)); + } + { + ui.table_set_column_index(1); + if is_active { + ui.text_colored([0.4, 1.0, 0.4, 1.0], "active"); + } else if is_initialized { + ui.text_colored([0.6, 0.6, 0.6, 1.0], "initialized"); + } else { + ui.text_colored([0.4, 0.4, 0.4, 1.0], "(not initialized)"); + } + } + { + ui.table_set_column_index(2); + let _id = ui.push_id(screen_index(key) as i32); + let _disabled = ui.begin_disabled_with_cond(!is_initialized); + if ui.button("View") { + let slot = &mut map[screen_index(key)]; + if slot.is_none() { + *slot = Some(screen::Info::default()); + } + } + } + } +} + +/// Table of miscellaneous terminal information. +fn misc_table(ui: &Ui, t: &Terminal) { + let Some(_t) = ui.begin_table_with_flags("table_misc", 2, TableFlags::NONE) else { + return; + }; + + { + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text("Working Directory"); + ui.same_line(); + help_marker(ui, "The current working directory reported by the shell."); + } + { + ui.table_set_column_index(1); + if t.pwd.is_empty() { + ui.text_disabled("(none)"); + } else { + ui.text(String::from_utf8_lossy(&t.pwd)); + } + } + } + + { + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text("Focused"); + ui.same_line(); + help_marker(ui, "Whether the terminal itself is currently focused."); + } + { + ui.table_set_column_index(1); + let mut value = t.flags.focused; + ui.checkbox("##focused", &mut value); + } + } + + { + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text("Previous Char"); + ui.same_line(); + help_marker( + ui, + "The previously printed character, used only for the REP sequence.", + ); + } + { + ui.table_set_column_index(1); + if let Some(c) = t.previous_char { + ui.text(format!("U+{c:04X}")); + } else { + ui.text_disabled("(none)"); + } + } + } +} + +/// Table of terminal layout information. +fn layout_table(ui: &Ui, t: &mut Terminal) { + let Some(_t) = ui.begin_table_with_flags("table_layout", 2, TableFlags::NONE) else { + return; + }; + + { + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text("Grid"); + ui.same_line(); + help_marker(ui, "The size of the terminal grid in columns and rows."); + } + { + ui.table_set_column_index(1); + ui.text(format!("{}c x {}r", t.cols, t.rows)); + } + } + + { + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text("Pixels"); + ui.same_line(); + help_marker(ui, "The size of the terminal grid in pixels."); + } + { + ui.table_set_column_index(1); + ui.text(format!("{}w x {}h", t.width_px, t.height_px)); + } + } + + { + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text("Scroll Region"); + ui.same_line(); + help_marker( + ui, + "The scrolling region boundaries (top, bottom, left, right).", + ); + } + { + ui.table_set_column_index(1); + let _width = ui.push_item_width(ui.calc_text_size("00000")[0]); + + let mut over = t.scrolling_region; + let mut changed = false; + let rows_max = t.rows.saturating_sub(1); + let cols_max = t.cols.saturating_sub(1); + + ui.align_text_to_frame_padding(); + ui.text("T:"); + ui.same_line(); + if ui.input_scalar("##scroll_top", &mut over.top).build() { + over.top = over.top.min(rows_max); + changed = true; + } + + ui.same_line(); + ui.text("B:"); + ui.same_line(); + if ui.input_scalar("##scroll_bottom", &mut over.bottom).build() { + over.bottom = over.bottom.min(rows_max); + changed = true; + } + + ui.same_line(); + ui.text("L:"); + ui.same_line(); + if ui.input_scalar("##scroll_left", &mut over.left).build() { + over.left = over.left.min(cols_max); + changed = true; + } + + ui.same_line(); + ui.text("R:"); + ui.same_line(); + if ui.input_scalar("##scroll_right", &mut over.right).build() { + over.right = over.right.min(cols_max); + changed = true; + } + + if changed && over.top < over.bottom && over.left < over.right { + t.scrolling_region = over; + } + } + } +} + +/// Table of mouse-related terminal information. +fn mouse_table(ui: &Ui, t: &Terminal) { + let Some(_t) = ui.begin_table_with_flags("table_mouse", 2, TableFlags::NONE) else { + return; + }; + + let row = |name: &str, help: &str, value: &dyn Fn()| { + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text(name); + ui.same_line(); + help_marker(ui, help); + } + { + ui.table_set_column_index(1); + value(); + } + }; + + row( + "Event Mode", + "The mouse event reporting mode set by the application.", + &|| ui.text(tag_name(&t.flags.mouse_event)), + ); + row("Format", "The mouse event encoding format.", &|| { + ui.text(tag_name(&t.flags.mouse_format)); + }); + row( + "Shape", + "The current mouse cursor shape set by the application.", + &|| ui.text(tag_name(&t.mouse_shape)), + ); + row( + "Shift Capture", + "XTSHIFTESCAPE state for capturing shift in mouse protocol.", + &|| { + if t.flags.mouse_shift_capture == MouseShiftCapture::Null { + ui.text_disabled("(unset)"); + } else { + ui.text(tag_name(&t.flags.mouse_shift_capture)); + } + }, + ); +} + +/// Table of color-related terminal information. +fn color_table(ui: &Ui, t: &Terminal, show_palette: &mut bool) { + ui.text_wrapped( + "Color state for the terminal. Note these colors only apply \ + to the palette and unstyled colors. Many modern terminal \ + applications use direct RGB colors which are not reflected here.", + ); + ui.separator(); + + let Some(_t) = ui.begin_table_with_flags("table_color", 2, TableFlags::NONE) else { + return; + }; + + { + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text("Background"); + ui.same_line(); + help_marker(ui, "Unstyled cell background color."); + } + { + ui.table_set_column_index(1); + dynamic_rgb(ui, "bg_color", t.colors.background); + } + } + + { + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text("Foreground"); + ui.same_line(); + help_marker(ui, "Unstyled cell foreground color."); + } + { + ui.table_set_column_index(1); + dynamic_rgb(ui, "fg_color", t.colors.foreground); + } + } + + { + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text("Cursor"); + ui.same_line(); + help_marker(ui, "Cursor coloring set by escape sequences."); + } + { + ui.table_set_column_index(1); + dynamic_rgb(ui, "cursor_color", t.colors.cursor); + } + } + + { + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text("Palette"); + ui.same_line(); + help_marker(ui, "The 256-color palette."); + } + { + ui.table_set_column_index(1); + if ui.button("View") { + *show_palette = true; + } + } + } +} + +/// Table of terminal modes. +fn modes_table(ui: &Ui, t: &Terminal) { + let Some(_t) = ui.begin_table_with_flags( + "table_modes", + 3, + dear_imgui_rs::TableOptions::default() + .flags(TableFlags::ROW_BG) + .sizing_policy(dear_imgui_rs::TableSizingPolicy::FixedFit), + ) else { + return; + }; + + { + ui.table_setup_column("", TableColumnFlags::NO_RESIZE, None); + ui.table_setup_column("Number", TableColumnFlags::PREFER_SORT_ASCENDING, None); + ui.table_setup_column( + "Name", + TableColumnFlags::NONE, + Some(TableColumnWidth::Stretch(0.0)), + ); + ui.table_headers_row(); + } + + for entry in MODE_ENTRIES { + ui.table_next_row(); + let _id = ui.push_id(i32::from(entry.value)); + { + ui.table_set_column_index(0); + let mut value = t.modes.get(entry.mode); + ui.checkbox("##checkbox", &mut value); + } + { + ui.table_set_column_index(1); + ui.text(format!( + "{}{}", + if entry.ansi { "" } else { "?" }, + entry.value + )); + } + { + ui.table_set_column_index(2); + ui.text(entry.name); + } + } +} + +/// Render a DynamicRGB color. +fn dynamic_rgb(ui: &Ui, label: &str, rgb: DynamicRgb) { + let columns = if rgb.override_.is_some() { 2 } else { 1 }; + let Some(_t) = ui.begin_table_with_flags( + label, + columns, + dear_imgui_rs::TableOptions::default() + .sizing_policy(dear_imgui_rs::TableSizingPolicy::FixedFit), + ) else { + return; + }; + + if rgb.override_.is_some() { + ui.table_setup_column( + "##label", + TableColumnFlags::NONE, + Some(TableColumnWidth::Fixed(0.0)), + ); + } + ui.table_setup_column( + "##value", + TableColumnFlags::NONE, + Some(TableColumnWidth::Stretch(0.0)), + ); + + if let Some(c) = rgb.override_ { + ui.table_next_row(); + ui.table_set_column_index(0); + ui.text("override:"); + ui.same_line(); + help_marker(ui, "Overridden color set by escape sequences."); + + ui.table_set_column_index(1); + let mut col = rgb_f32(c); + ui.color_edit3("##override", &mut col); + } + + ui.table_next_row(); + ui.table_set_column_index(0); + if let Some(c) = rgb.default { + if rgb.override_.is_some() { + ui.text("default:"); + ui.same_line(); + help_marker(ui, "Default color from configuration."); + + ui.table_set_column_index(1); + } + + let mut col = rgb_f32(c); + ui.color_edit3("##default", &mut col); + } else { + ui.text_disabled("(unset)"); + } +} + +/// Render a color palette as a 16x16 grid of color buttons. +fn palette(ui: &Ui, label: &str, pal: &Palette) { + let _id = ui.push_id(label); + + for row in 0..16_usize { + for col in 0..16_usize { + let idx = row * 16 + col; + let rgb = pal[idx]; + let mut col_arr = rgb_f32(rgb); + + if col > 0 { + ui.same_line(); + } + + let _idx_id = ui.push_id(idx as i32); + ui.color_edit3_config("##color", &mut col_arr) + .flags(ColorEditFlags::NO_INPUTS) + .build(); + if ui.is_item_hovered_with_flags(ItemHoveredFlags::DELAY_SHORT) { + ui.set_tooltip(format!("{}: #{:02X}{:02X}{:02X}", idx, rgb.r, rgb.g, rgb.b)); + } + } + } +} diff --git a/crates/ghostty-inspector/src/widgets/termio.rs b/crates/ghostty-inspector/src/widgets/termio.rs new file mode 100644 index 0000000..ec6aaf4 --- /dev/null +++ b/crates/ghostty-inspector/src/widgets/termio.rs @@ -0,0 +1,987 @@ +//! VT event stream inspector (ZIG/src/inspector/widgets/termio.zig). + +use std::collections::VecDeque; +use std::convert::Infallible; +use std::fmt::Write as _; + +use dear_imgui_rs::{ + FocusedFlags, Key, SelectableFlags, TableColumnFlags, TableColumnWidth, TableFlags, Ui, + WindowFlags, +}; +use ghostty_terminal::terminal::{ScrollingRegion, Terminal}; +use ghostty_vt::color::Palette; +use ghostty_vt::osc::{self, Command as OscCommand}; +use ghostty_vt::parser::Action as ParserAction; +use ghostty_vt::stream::{Action as StreamAction, Handler, Stream as VtStream}; + +use super::screen::{CursorSnapshot, cursor_style, cursor_table}; + +/// The maximum number of recorded events (see the key event ring). +pub const MAX_EVENTS: usize = 100; + +/// The tags of `Parser.Action` (used for filtering). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionTag { + Print, + Execute, + CsiDispatch, + EscDispatch, + OscDispatch, + DcsHook, + DcsPut, + DcsUnhook, + ApcStart, + ApcPut, + ApcEnd, +} + +impl ActionTag { + pub const ALL: [ActionTag; 11] = [ + ActionTag::Print, + ActionTag::Execute, + ActionTag::CsiDispatch, + ActionTag::EscDispatch, + ActionTag::OscDispatch, + ActionTag::DcsHook, + ActionTag::DcsPut, + ActionTag::DcsUnhook, + ActionTag::ApcStart, + ActionTag::ApcPut, + ActionTag::ApcEnd, + ]; + + pub fn of(action: &ParserAction) -> ActionTag { + match action { + ParserAction::Print(_) => ActionTag::Print, + ParserAction::Execute(_) => ActionTag::Execute, + ParserAction::CsiDispatch(_) => ActionTag::CsiDispatch, + ParserAction::EscDispatch(_) => ActionTag::EscDispatch, + ParserAction::OscDispatch(_) => ActionTag::OscDispatch, + ParserAction::DcsHook(_) => ActionTag::DcsHook, + ParserAction::DcsPut(_) => ActionTag::DcsPut, + ParserAction::DcsUnhook => ActionTag::DcsUnhook, + ParserAction::ApcStart => ActionTag::ApcStart, + ParserAction::ApcPut(_) => ActionTag::ApcPut, + ParserAction::ApcEnd => ActionTag::ApcEnd, + } + } + + pub fn name(self) -> String { + crate::keys::tag_name(&self) + } +} + +/// A simple set of excluded action tags (Zig: `std.EnumSet`). +#[derive(Debug, Clone, Default)] +pub struct ActionTagSet(Vec); + +impl ActionTagSet { + pub fn contains(&self, tag: ActionTag) -> bool { + self.0.contains(&tag) + } + + pub fn insert(&mut self, tag: ActionTag) { + if !self.contains(tag) { + self.0.push(tag); + } + } + + pub fn remove(&mut self, tag: ActionTag) { + self.0.retain(|t| *t != tag); + } +} + +/// Port of `ImGuiTextFilter` (build + pass filter): comma separated terms, +/// a term starting with `-` excludes, matching is ASCII case insensitive. +#[derive(Debug, Clone, Default)] +pub struct TextFilter { + pub input: String, + /// `(exclude, lowercase term)` in order. + filters: Vec<(bool, String)>, + count_grep: usize, +} + +impl TextFilter { + /// Re-parse the filter expression (`ImGuiTextFilter::Build`). + pub fn build(&mut self) { + self.filters.clear(); + self.count_grep = 0; + for part in self.input.split(',') { + let part = part.trim_matches(|c| c == ' ' || c == '\t'); + if part.is_empty() { + continue; + } + self.filters + .push((part.starts_with('-'), part.to_ascii_lowercase())); + // `-` alone is a no-op term, like in ImGui. + if !part.starts_with('-') { + self.count_grep += 1; + } + } + } + + /// `ImGuiTextFilter::PassFilter`. + pub fn pass_filter(&self, text: &str) -> bool { + if self.filters.is_empty() { + return true; + } + + let lower = text.to_ascii_lowercase(); + for (exclude, term) in &self.filters { + if *exclude { + // Subtract hyphen + let needle = &term[1..]; + if !needle.is_empty() && lower.contains(needle) { + return false; + } + } else if lower.contains(term.as_str()) { + return true; + } + } + + // Implicit * grep + self.count_grep == 0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Print, + Execute, + Csi, + Esc, + Osc, + Dcs, + Apc, +} + +impl Kind { + fn name(self) -> &'static str { + match self { + Kind::Print => "print", + Kind::Execute => "execute", + Kind::Csi => "csi", + Kind::Esc => "esc", + Kind::Osc => "osc", + Kind::Dcs => "dcs", + Kind::Apc => "apc", + } + } +} + +/// VT event. This is just how we store internal events. +#[derive(Debug, Clone)] +pub struct VtEvent { + /// Sequence number, just monotonically increasing and wrapping if + /// it ever overflows. It gives us a nice way to visualize progress. + pub seq: usize, + + /// Kind of event, for filtering + pub kind: Kind, + + /// The description of the raw event in a more human-friendly format. + /// For example for control sequences this is the full sequence but + /// control characters are replaced with human-readable names, e.g. + /// 0x07 (bell) becomes BEL. + pub raw_description: String, + + /// Various metadata at the time of the event (before processing). + pub cursor: CursorSnapshot, + pub scrolling_region: ScrollingRegion, + pub metadata: Vec<(String, String)>, + + /// imgui selection state + pub imgui_selected: bool, +} + +impl VtEvent { + /// Initialize the event information for the given parser action. + pub fn new( + cursor: CursorSnapshot, + scrolling_region: ScrollingRegion, + action: &ParserAction, + ) -> Self { + let mut md = Vec::new(); + let desc = encode_action(&mut md, action); + + let kind = match action { + ParserAction::Print(_) => Kind::Print, + ParserAction::Execute(_) => Kind::Execute, + ParserAction::CsiDispatch(_) => Kind::Csi, + ParserAction::EscDispatch(_) => Kind::Esc, + ParserAction::OscDispatch(_) => Kind::Osc, + ParserAction::DcsHook(_) | ParserAction::DcsPut(_) | ParserAction::DcsUnhook => { + Kind::Dcs + } + ParserAction::ApcStart | ParserAction::ApcPut(_) | ParserAction::ApcEnd => Kind::Apc, + }; + + VtEvent { + seq: 1, + kind, + raw_description: desc, + cursor, + scrolling_region, + metadata: md, + imgui_selected: false, + } + } + + /// Returns true if the event passes the given filter. + pub fn pass_filter(&self, filter: &TextFilter) -> bool { + // Check our main string + if filter.pass_filter(&self.raw_description) { + return true; + } + + // We also check all metadata keys and values + self.metadata + .iter() + .any(|(k, v)| filter.pass_filter(k) || filter.pass_filter(v)) + } +} + +/// Encode a parser action as a string that we show in the logs. +fn encode_action(md: &mut Vec<(String, String)>, action: &ParserAction) -> String { + match action { + ParserAction::Print(ch) => encode_print(*ch), + ParserAction::Execute(ch) => encode_execute(*ch), + ParserAction::CsiDispatch(v) => encode_csi(v), + ParserAction::EscDispatch(v) => encode_esc(*v), + ParserAction::OscDispatch(v) => encode_osc(md, v), + other => format_action(other), + } +} + +/// Mirrors `Parser.Action.format` for the actions that are not specially +/// encoded. +fn format_action(action: &ParserAction) -> String { + let (tag, value) = match action { + ParserAction::DcsHook(d) => ( + "dcs_hook", + format!( + "{{ intermediates = {:?}, params = {:?}, final = {} }}", + d.intermediates(), + d.params(), + d.final_ + ), + ), + ParserAction::DcsPut(b) => ("dcs_put", format!("0x{b:x}")), + ParserAction::DcsUnhook => ("dcs_unhook", "void".to_owned()), + ParserAction::ApcStart => ("apc_start", "void".to_owned()), + ParserAction::ApcPut(b) => ("apc_put", format!("0x{b:x}")), + ParserAction::ApcEnd => ("apc_end", "void".to_owned()), + // Handled by the specific encoders. + _ => ("unknown", String::new()), + }; + format!("parser.Parser.Action{{ .{tag} = {value} }}") +} + +fn encode_print(ch: u32) -> String { + let c = char::from_u32(ch).unwrap_or(char::REPLACEMENT_CHARACTER); + format!("'{c}' (U+{ch:X})") +} + +fn encode_execute(ch: u8) -> String { + let name = match ch { + 0x00 => "NUL", + 0x01 => "SOH", + 0x02 => "STX", + 0x03 => "ETX", + 0x04 => "EOT", + 0x05 => "ENQ", + 0x06 => "ACK", + 0x07 => "BEL", + 0x08 => "BS", + 0x09 => "HT", + 0x0A => "LF", + 0x0B => "VT", + 0x0C => "FF", + 0x0D => "CR", + 0x0E => "SO", + 0x0F => "SI", + _ => "?", + }; + format!("{name} (0x{ch:X})") +} + +fn encode_csi(csi: &ghostty_vt::parser::Csi) -> String { + let mut out = String::new(); + for v in csi.intermediates() { + let _ = write!(out, "{} ", char::from(*v)); + } + for (i, v) in csi.params().iter().enumerate() { + if i != 0 { + out.push(';'); + } + let _ = write!(out, "{v}"); + } + if !csi.intermediates().is_empty() || !csi.params().is_empty() { + out.push(' '); + } + out.push(char::from(csi.final_)); + out +} + +fn encode_esc(esc: ghostty_vt::parser::Esc) -> String { + let mut out = String::new(); + for v in esc.intermediates() { + let _ = write!(out, "{} ", char::from(*v)); + } + out.push(char::from(esc.final_)); + out +} + +fn bytes(v: &[u8]) -> String { + String::from_utf8_lossy(v).into_owned() +} + +fn terminator_name(t: osc::Terminator) -> &'static str { + match t { + osc::Terminator::St => "st", + osc::Terminator::Bel => "bel", + } +} + +fn optional_bool(v: Option) -> String { + match v { + Some(true) => "true".to_owned(), + Some(false) => "false".to_owned(), + None => "(unset)".to_owned(), + } +} + +fn encode_osc(md: &mut Vec<(String, String)>, cmd: &OscCommand) -> String { + // The description is just the tag + let out = format!("{} ", crate::keys::tag_name(&cmd.key())); + encode_osc_metadata(md, cmd); + out +} + +/// Add the OSC fields to the metadata (Zig: `encodeMetadata`, which walks +/// the fields of the command union with comptime reflection). +fn encode_osc_metadata(md: &mut Vec<(String, String)>, cmd: &OscCommand) { + let mut put = |k: &str, v: String| md.push((k.to_owned(), v)); + match cmd { + OscCommand::Invalid | OscCommand::HyperlinkEnd | OscCommand::ConemuWaitInput => {} + OscCommand::ChangeWindowTitle(v) + | OscCommand::ChangeWindowIcon(v) + | OscCommand::ConemuShowMessageBox(v) + | OscCommand::ConemuGuimacro(v) + | OscCommand::ConemuRunProcess(v) + | OscCommand::ConemuOutputEnvironmentVariable(v) + | OscCommand::ConemuComment(v) => put("data", bytes(v)), + OscCommand::SemanticPrompt(v) => { + put("action", crate::keys::tag_name(&v.action)); + put("options_unvalidated", bytes(&v.options_unvalidated)); + } + OscCommand::ClipboardContents { kind, data } => { + put("kind", kind.to_string()); + put("data", bytes(data)); + } + OscCommand::ReportPwd { value } | OscCommand::MouseShape { value } => { + put("value", bytes(value)); + } + OscCommand::ColorOperation { + op, + requests: _, + terminator, + } => { + // `Operation` tags are named `osc_4`, `osc_104`, ... + let dbg = format!("{op:?}"); + let name = dbg + .strip_prefix("Osc") + .map_or_else(|| crate::keys::snake_case(&dbg), |n| format!("osc_{n}")); + put("op", name); + put( + "requests", + "array_list.Aligned(terminal.osc.parsers.color.Request,null)".to_owned(), + ); + put("terminator", terminator_name(*terminator).to_owned()); + } + OscCommand::KittyColorProtocol(v) => { + put( + "list", + "array_list.Aligned(terminal.kitty.color.OSC.Request,null)".to_owned(), + ); + put("terminator", terminator_name(v.terminator).to_owned()); + } + OscCommand::ShowDesktopNotification { title, body } => { + put("title", bytes(title)); + put("body", bytes(body)); + } + OscCommand::HyperlinkStart { id, uri } => { + put( + "id", + id.as_deref().map_or_else(|| "(unset)".to_owned(), bytes), + ); + put("uri", bytes(uri)); + } + OscCommand::ConemuSleep { duration_ms } => put("duration_ms", duration_ms.to_string()), + OscCommand::ConemuChangeTabTitle(v) => match v { + osc::ConemuChangeTabTitle::Reset => put("data", "reset".to_owned()), + osc::ConemuChangeTabTitle::Value(s) => put("value", bytes(s)), + }, + OscCommand::ConemuProgressReport(v) => { + put("state", crate::keys::tag_name(&v.state)); + put( + "progress", + v.progress + .map_or_else(|| "(unset)".to_owned(), |p| p.to_string()), + ); + } + OscCommand::ConemuXtermEmulation { keyboard, output } => { + put("keyboard", optional_bool(*keyboard)); + put("output", optional_bool(*output)); + } + OscCommand::KittyTextSizing(v) => { + put("scale", v.scale.to_string()); + put("width", v.width.to_string()); + put("numerator", v.numerator.to_string()); + put("denominator", v.denominator.to_string()); + put("valign", crate::keys::tag_name(&v.valign)); + put("halign", crate::keys::tag_name(&v.halign)); + put("text", bytes(&v.text)); + } + OscCommand::KittyClipboardProtocol(v) => { + put("metadata", bytes(&v.metadata)); + put( + "payload", + v.payload + .as_deref() + .map_or_else(|| "(unset)".to_owned(), bytes), + ); + put("terminator", terminator_name(v.terminator).to_owned()); + } + OscCommand::ContextSignal(v) => { + put("action", crate::keys::tag_name(&v.action)); + put("id", bytes(&v.id)); + put("metadata", bytes(&v.metadata)); + } + } +} + +/// The capture state of the VT handler: the terminal state at the time of +/// the events. +#[derive(Debug, Clone)] +struct CaptureState { + cursor: CursorSnapshot, + scrolling_region: ScrollingRegion, +} + +/// Our VT stream handler for the Stream widget. +#[derive(Debug)] +pub struct VtHandler { + /// The capture state, must be set before use. If `None`, then + /// events are dropped. + state: Option, + + /// The event ring to write events to. + pub events: VecDeque, + + /// True to pause this artificially. + pub paused: bool, + + /// Current sequence number + pub current_seq: usize, + + /// Exclude certain actions by tag. + pub filter_exclude: ActionTagSet, + pub filter_text: TextFilter, +} + +impl VtHandler { + fn new() -> Self { + let mut filter_exclude = ActionTagSet::default(); + filter_exclude.insert(ActionTag::Print); + Self { + state: None, + events: VecDeque::with_capacity(MAX_EVENTS), + paused: false, + current_seq: 1, + filter_exclude, + filter_text: TextFilter::default(), + } + } +} + +impl Handler for VtHandler { + type Error = Infallible; + + fn vt(&mut self, _action: StreamAction<'_>) -> Result<(), Self::Error> { + Ok(()) + } + + /// This is called with every single terminal action. + fn vt_raw(&mut self, action: &ParserAction) -> Result { + let Some(state) = self.state.clone() else { + return Ok(true); + }; + + // We always increment the sequence number, even if we're paused or + // filter out the event. This helps show the user that there is a gap + // between events and roughly how large that gap was. + let seq = self.current_seq; + self.current_seq = self.current_seq.wrapping_add(1); + + // If we're manually paused, we ignore all events. + if self.paused { + return Ok(true); + } + + // We ignore certain action types that are too noisy. + if matches!(action, ParserAction::DcsPut(_) | ParserAction::ApcPut(_)) { + return Ok(true); + } + + // If we requested a specific type to be ignored, ignore it. + // We return true because we did "handle" it by ignoring it. + if self.filter_exclude.contains(ActionTag::of(action)) { + return Ok(true); + } + + // Build our event + let mut ev = VtEvent::new(state.cursor, state.scrolling_region, action); + ev.seq = seq; + + // Check if the event passes the filter + if !ev.pass_filter(&self.filter_text) { + return Ok(true); + } + + if self.events.len() >= MAX_EVENTS { + self.events.pop_front(); + } + self.events.push_back(ev); + + // Do NOT skip it, because we want to record more information + // about this event. + Ok(false) + } +} + +/// Enum representing keyboard navigation actions +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyAction { + Down, + None, + Up, +} + +/// Helper function to check keyboard state and determine navigation action. +fn get_key_action(ui: &Ui) -> KeyAction { + let keys = [ + (Key::J, KeyAction::Down), + (Key::DownArrow, KeyAction::Down), + (Key::K, KeyAction::Up), + (Key::UpArrow, KeyAction::Up), + ]; + + for (key, action) in keys { + if ui.is_key_pressed(key) { + return action; + } + } + KeyAction::None +} + +/// VT event stream inspector widget. +pub struct Stream { + pub parser_stream: VtStream, + + /// The currently selected event sequence number for keyboard navigation + pub selected_event_seq: Option, + + /// Flag indicating whether we need to scroll to the selected item + pub need_scroll_to_selected: bool, + + /// Flag indicating whether the selection was made by keyboard + pub is_keyboard_selection: bool, +} + +impl std::fmt::Debug for Stream { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Stream") + .field("events", &self.parser_stream.handler.events.len()) + .field("selected_event_seq", &self.selected_event_seq) + .finish_non_exhaustive() + } +} + +impl Default for Stream { + fn default() -> Self { + Self::new() + } +} + +impl Stream { + pub fn new() -> Self { + Self { + parser_stream: VtStream::new_alloc(VtHandler::new()), + selected_event_seq: None, + need_scroll_to_selected: false, + is_keyboard_selection: false, + } + } + + pub fn events(&self) -> &VecDeque { + &self.parser_stream.handler.events + } + + pub fn record_pty_read(&mut self, t: &Terminal, data: &[u8]) { + self.parser_stream.handler.state = Some(CaptureState { + cursor: CursorSnapshot::new(&t.screens.active().cursor), + scrolling_region: t.scrolling_region, + }); + let res = self.parser_stream.next_slice(data); + self.parser_stream.handler.state = None; + if let Err(err) = res { + log::warn!("error recording pty read in inspector err={err:?}"); + } + } + + pub fn draw(&mut self, ui: &Ui, palette: &Palette) { + let popup_filter = "Filter"; + let handler = &mut self.parser_stream.handler; + + // Controls + { + let pause_play = if handler.paused { + "Resume##pause_play" + } else { + "Pause##pause_play" + }; + if ui.button(pause_play) { + handler.paused = !handler.paused; + } + + let inner = ui.clone_style().item_inner_spacing()[0]; + ui.same_line_with_spacing(0.0, inner); + if ui.button("Filter") { + ui.open_popup(popup_filter); + } + + if !handler.events.is_empty() { + ui.same_line_with_spacing(0.0, inner); + if ui.button("Clear") { + handler.events.clear(); + handler.current_seq = 1; + } + } + } + + // Events Table + if handler.events.is_empty() { + ui.text("Waiting for events..."); + } + + { + ui.separator(); + + let table = ui.begin_table_with_flags( + "table_vt_events", + 3, + TableFlags::ROW_BG | TableFlags::BORDERS, + ); + if let Some(_table) = table { + ui.table_setup_column( + "Seq", + TableColumnFlags::NONE, + Some(TableColumnWidth::Fixed(0.0)), + ); + ui.table_setup_column( + "Kind", + TableColumnFlags::NONE, + Some(TableColumnWidth::Fixed(0.0)), + ); + ui.table_setup_column( + "Description", + TableColumnFlags::NONE, + Some(TableColumnWidth::Stretch(0.0)), + ); + + // Handle keyboard navigation when window is focused + if ui.is_window_focused_with_flags(FocusedFlags::ROOT_AND_CHILD_WINDOWS) { + let key_pressed = get_key_action(ui); + let events = &handler.events; + + match key_pressed { + KeyAction::None => {} + KeyAction::Up | KeyAction::Down => { + // If no event is selected, select the first/last event based on direction + if let Some(selected) = self.selected_event_seq { + // Find next/previous event based on current selection + // (iterating newest to oldest). + match key_pressed { + KeyAction::Down => { + let mut found = false; + for ev in events.iter().rev() { + if found { + self.selected_event_seq = Some(ev.seq as u32); + break; + } + if ev.seq as u32 == selected { + found = true; + } + } + } + KeyAction::Up => { + let mut prev_ev: Option<&VtEvent> = None; + for ev in events.iter().rev() { + if ev.seq as u32 == selected + && let Some(prev) = prev_ev + { + self.selected_event_seq = Some(prev.seq as u32); + break; + } + prev_ev = Some(ev); + } + } + KeyAction::None => {} + } + } else if !events.is_empty() { + // `.forward` iterator for up, `.reverse` for down. + let first = if key_pressed == KeyAction::Up { + events.front() + } else { + events.back() + }; + if let Some(ev) = first { + self.selected_event_seq = Some(ev.seq as u32); + } + } + + // Mark that we need to scroll to the newly selected item + self.need_scroll_to_selected = true; + self.is_keyboard_selection = true; + } + } + } + + for (i, ev) in handler.events.iter_mut().enumerate().rev() { + // Need to push an ID so that our selectable is unique. + let _id = ui.push_id(i as i32); + + ui.table_next_row(); + ui.table_next_column(); + + // Store the previous selection state to detect changes + let was_selected = ev.imgui_selected; + + // Update selection state based on keyboard navigation + if let Some(seq) = self.selected_event_seq { + ev.imgui_selected = ev.seq as u32 == seq; + } + + // Handle selectable widget + if ui + .selectable_config("##select") + .selected(ev.imgui_selected) + .flags(SelectableFlags::SPAN_ALL_COLUMNS) + .build() + { + ev.imgui_selected = !ev.imgui_selected; + // If selection state changed, update keyboard navigation state + if ev.imgui_selected != was_selected { + self.selected_event_seq = if ev.imgui_selected { + Some(ev.seq as u32) + } else { + None + }; + self.is_keyboard_selection = false; + } + } + + ui.same_line(); + ui.text(ev.seq.to_string()); + ui.table_next_column(); + ui.text(ev.kind.name()); + ui.table_next_column(); + ui.text(&ev.raw_description); + + // If the event is selected, we render info about it. For now + // we put this in the last column because that's the widest and + // imgui has no way to make a column span. + if ev.imgui_selected { + { + cursor_table(ui, &ev.cursor); + cursor_style(ui, &ev.cursor, Some(palette)); + + if let Some(_details) = + ui.begin_table_with_flags("details", 2, TableFlags::NONE) + { + ui.table_next_row(); + { + ui.table_set_column_index(0); + ui.text("Scroll Region"); + } + { + ui.table_set_column_index(1); + ui.text(format!( + "T={} B={} L={} R={}", + ev.scrolling_region.top, + ev.scrolling_region.bottom, + ev.scrolling_region.left, + ev.scrolling_region.right, + )); + } + + for (key, value) in &ev.metadata { + ui.table_next_row(); + ui.table_next_column(); + ui.text(key); + ui.table_next_column(); + ui.text(value); + } + } + } + + // If this is the selected event and scrolling is needed, scroll to it + if self.need_scroll_to_selected && self.is_keyboard_selection { + ui.set_scroll_here_y(0.5); + self.need_scroll_to_selected = false; + } + } + } + } + } // table + + if let Some(_popup) = ui + .begin_modal_popup_config(popup_filter) + .flags(WindowFlags::ALWAYS_AUTO_RESIZE) + .begin() + { + ui.text("Changed filter settings will only affect future events."); + + ui.separator(); + + if let Some(_t) = ui.begin_table_with_flags("table_filter_kind", 3, TableFlags::NONE) { + for tag in ActionTag::ALL { + if matches!(tag, ActionTag::ApcPut | ActionTag::DcsPut) { + continue; + } + + ui.table_next_column(); + let mut value = !handler.filter_exclude.contains(tag); + if ui.checkbox(tag.name(), &mut value) { + if value { + handler.filter_exclude.remove(tag); + } else { + handler.filter_exclude.insert(tag); + } + } + } + } // Filter kind table + + ui.separator(); + + ui.text( + "Filter by string. Empty displays all, \"abc\" finds lines\n\ + containing \"abc\", \"abc,xyz\" finds lines containing \"abc\"\n\ + or \"xyz\", \"-abc\" excludes lines containing \"abc\".", + ); + if ui + .input_text("##filter_text", &mut handler.filter_text.input) + .build() + { + handler.filter_text.build(); + } + + ui.separator(); + if ui.button("Close") { + ui.close_current_popup(); + } + } // filter popup + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn filter(s: &str) -> TextFilter { + let mut f = TextFilter { + input: s.to_owned(), + ..TextFilter::default() + }; + f.build(); + f + } + + #[test] + fn text_filter() { + assert!(filter("").pass_filter("anything")); + assert!(filter("abc").pass_filter("xxABCxx")); + assert!(!filter("abc").pass_filter("xyz")); + assert!(filter("abc,xyz").pass_filter("xyz")); + assert!(!filter("-abc").pass_filter("zabcz")); + assert!(filter("-abc").pass_filter("zzz")); + assert!(filter("abc,-abc").pass_filter("abc")); + assert!(!filter("-abc,abc").pass_filter("abc")); + assert!(filter("-").pass_filter("abc")); + } + + #[test] + fn encode_execute_names() { + assert_eq!(encode_execute(0x07), "BEL (0x7)"); + assert_eq!(encode_execute(0x0A), "LF (0xA)"); + assert_eq!(encode_execute(0x1f), "? (0x1F)"); + } + + #[test] + fn encode_print_format() { + assert_eq!(encode_print(u32::from('a')), "'a' (U+61)"); + } + + #[test] + fn encode_csi_and_esc() { + let mut stream = VtStream::new_alloc(VtHandler::new()); + stream.handler.state = Some(CaptureState { + cursor: CursorSnapshot { + x: 0, + y: 0, + pending_wrap: false, + protected: false, + style: ghostty_terminal::style::Style::default(), + hyperlink_uri: None, + }, + scrolling_region: ScrollingRegion { + top: 0, + bottom: 23, + left: 0, + right: 79, + }, + }); + stream.handler.filter_exclude = ActionTagSet::default(); + // Like Zig, `vt_raw` only sees the parser actions of escape + // sequences: printable text and C0 controls in the ground state are + // dispatched directly to the handler. + stream + .next_slice(b"a\x07\x1b[1;2H\x1bM\x1b]0;title\x07") + .unwrap(); + let descs: Vec<_> = stream + .handler + .events + .iter() + .map(|e| (e.seq, e.kind.name(), e.raw_description.clone())) + .collect(); + assert_eq!(descs[0], (1, "csi", "1;2 H".to_owned())); + assert_eq!(descs[1], (2, "esc", "M".to_owned())); + assert_eq!(descs[2].1, "osc"); + assert_eq!(descs[2].2, "change_window_title "); + assert_eq!( + stream.handler.events[2].metadata, + vec![("data".to_owned(), "title".to_owned())] + ); + } + + #[test] + fn paused_still_increments_seq_and_drops() { + let mut stream = VtStream::new_alloc(VtHandler::new()); + stream.handler.state = None; + stream.next_slice(b"\x07").unwrap(); + // No capture state: events dropped and the sequence is untouched. + assert!(stream.handler.events.is_empty()); + assert_eq!(stream.handler.current_seq, 1); + } +} diff --git a/docs/rewrite/notes/ghostty-inspector.md b/docs/rewrite/notes/ghostty-inspector.md new file mode 100644 index 0000000..9cb7271 --- /dev/null +++ b/docs/rewrite/notes/ghostty-inspector.md @@ -0,0 +1,84 @@ +# ghostty-inspector (agent I1): terminal inspector + +Ports `ZIG/src/inspector/*` (Inspector, widgets/{key,page,pagelist,renderer,screen,style,surface,terminal,termio}, units), +the GTK classes `imgui_widget`, `inspector_widget`, `inspector_window` (+ their `.blp`), and the core Surface hooks. + +## Stack +`dear-imgui-rs` 0.19 (Dear ImGui 1.92.9b docking branch, builds fine with rustc 1.99) + `dear-imgui-glow` 0.19 (glow 0.18, the +same glow as the renderer). imgui-rs 0.12 was not used: it has no safe docking/DockBuilder and an old ImGui. The safe API of +`dear-imgui-rs` is strict (it panics on API misuse, e.g. table phases, dockspace ordering), which the headless test below exercises. + +## Layout +* `crates/ghostty-inspector` (`unsafe_code = "deny"`) + * `inspector.rs`: `Inspector` (`mouse`, `gui`), `setup(ctx, scale)`, `Shared` (= `Mutex` newtype implementing + `ghostty_renderer::state::InspectorHook`). + * `widgets/*.rs`: 1:1 with the Zig widget files (`surface.rs` has the dockspace + Surface info + `SurfaceInfo` input struct). + * `keys.rs`: `tag_name` (Zig `@tagName` from `Debug`), `imgui_key` (Zig `Key.imguiKey`). + * `dock.rs`: the ONLY `unsafe` module (`#![allow(unsafe_code)]`, all SAFETY-commented): `DockBuilder` (node create/split/dock), + `SetNextWindowSize`, `SetNextItemAllowOverlap`, and a raw `Begin`/`End` scope (`begin_window`) so that widgets still run when + `Begin` returned false, like Zig's `Begin` + `defer End`. +* `ghostty-renderer::state`: `State.overlay_features` replaced by `State.inspector: Option>` (Zig + `inspector: ?*Inspector`); `generic.rs` reads `overlay_features()` from it each frame. The trait breaks the crate cycle. +* `ghostty-termio`: `Output::process_output` goes into Zig's "slow mode" while an inspector is set (`record_pty_read` per byte, then + `process_byte`). +* `ghostty-core` (`surface/inspector.rs`): `activate_inspector` / `deactivate_inspector` (render-state pointer + renderer and IO + `Inspector(bool)` messages), `render_inspector(ui)` (locks terminal then inspector, fills `SurfaceInfo`, wakes the renderer), + key event recording in `keyboard.rs` (event created after key remapping, `binding` set in the consumed branch, `pty` copied in + `encode_key`, recorded + `queue_render` when done, only if the inspector is still active), mouse recording in + `mouse.rs` (cursor pos, `queue_render` on buttons). The `inspector` binding action already forwarded `RtAction::Inspector`. +* `ghostty-apprt` `CoreSurface`: new `render_inspector(&Ui)` next to `activate/deactivate_inspector`. +* `ghostty-apprt-gtk`: `class/{imgui_widget,inspector_widget,inspector_window}.rs`, `resources/ui/1.5/{imgui-widget,inspector-widget, + inspector-window}.blp` (verbatim from Zig), `Surface::control_inspector` (toggle/show/hide, weak ref to the window) and + `redraw_inspector`, `Window::toggle_inspector` (`win.toggle-inspector`, ctrl+shift+i). Unsafe only for `glow::Context::from_loader_function` + (epoxy loader) and the GL read-back of the debug screenshot, both `#[allow(unsafe_code)]` + SAFETY. + +## GTK input/rendering (imgui_widget) +GLArea + `IMMulticontext` as in Zig: focus/key/click/motion/scroll/IM-commit controllers feed `Io` (modifier keys as LeftShift/Ctrl/Alt/Super +events, keyval -> `imgui_key`), 30 FPS tick callback, two ImGui frames per GL render (first one only `end_frame`), then clear + draw. +Zig clears with `0x28/0xFF` which is integer 0, so the background is black; kept. + +## Debug / verification +`GHOSTTY_DEBUG_INPUT_SCRIPT` got `inspector-screenshot `, `inspector-click `, `inspector-scroll ` +(`debug_input.rs`). Inspector frames are rendered synchronously for these steps because the compositor does not send frame +callbacks to an off-screen window (without this the screenshot lags one event behind). Run: +`GSETTINGS_BACKEND=memory GHOSTTY_DEBUG_INPUT_SCRIPT=script.txt timeout 25 ./target/debug/ghostty -e sh` with +`key ctrl+shift+i`, `wait 2500`, `inspector-click 126 11`, `inspector-screenshot /abs/x.png`, ... +Verified in the real app on Wayland (screenshots viewed): all tabs (Terminal with screens/Misc/Layout/Mouse/Color/Modes tables, Surface, +Keyboard event table with pty encoding, Terminal IO VT log, Renderer overlays, ImGui demo), screen pop-out window with Info/Cell/PageList +docking (cell picking resolves the cell at (0,0)), detach of a header into a docked window, toggle open/close/reopen, closing a surface +and a tab while its inspector is open (no warnings, core deactivates in the surface's `dispose`). + +## Tests +* ghostty-inspector: 15 unit tests (Zig has none for this package): label/pty/utf8 formatting, ring bound, hsv/key colour, text filter + (ImGuiTextFilter semantics), OSC/CSI/ESC/Execute encodings and event recording through a real `Stream`, features toggle, snake_case, + imgui key map, mouse button labels, plus `draws_frames_headless`: draws 8 frames of the whole inspector headlessly (all windows, pop-out + screen window, palette window, detached headers, selected VT event) to catch misuse of the strict imgui API. +* ghostty-core `tests/e2e_inspector.rs`: 4 tests (key events with pty data + binding, VT events from a real child, overlay features + reach the render state, activate/deactivate semantics). +* apprt-gtk `debug_input` parse test extended. +Skipped: none (Zig package has no tests). + +## Deviations +* Font size is `16 * scale_factor` (Zig hardcodes `16 * 2` with a TODO). The imgui style is scaled by the change of the factor + (a `Style` cannot be re-created from Rust like Zig's `ImGuiStyle_ImGuiStyle`). +* `GhosttyImguiWidget` virtual methods (`setup`/`render`) are hooks installed with `ImguiWidget::set_hooks` (gtk-rs class-struct + boilerplate avoided); the widget hierarchy `InspectorWidget -> ImguiWidget -> AdwBin` is real. +* The inspector widget keeps an `Rc` and the GTK Surface destroys its inspector window in `dispose` (Zig used a + GObject weak notify to call `deactivateInspector` while the core surface is alive). +* DockBuilder splits requested by the pop-out headers (`DetachableHeader`, `detachable_header`) are queued and applied after all windows + of the frame ended (`dock::flush_splits`): doing the split while the parent window is being built re-parents its dock node and trips + Dear ImGui's "Must call EndChild() and not End()" assertion. The popped-out window therefore docks one frame later. +* Popups: the VT filter popup uses a Rust port of `ImGuiTextFilter` (`TextFilter`, same Build/PassFilter semantics) because the safe wrapper's + type holds a raw pointer (not `Send`) and the inspector is shared with the IO thread. +* VT events: `Parser.Action.format` for DCS/APC is approximated (`parser.Parser.Action{ .dcs_hook = ... }`); the OSC metadata for + `color_operation.requests` / `kitty_color_protocol.list` print the Zig container type name; metadata keeps insertion order (Zig hash map order). + `vt_raw` only sees parser-driven actions (CSI/ESC/OSC/DCS/APC), exactly like Zig: printable text and C0 controls in the ground state + are dispatched straight to the handler, so they never appear in the log. +* The recorded cursor is a `CursorSnapshot` (displayed fields only) instead of a copy of the whole cursor struct. +* Added accessors in ghostty-terminal: `Page::string_used_bytes` / `string_capacity_bytes`, `PageList::viewport_pin_handle`. +* ImGui demo window is shown when `debug_assertions` (Zig: `builtin.mode == .Debug`); the workspace release profile keeps debug assertions, + so it is also shown there. + +## Known gaps +* No unit tests can drive real GTK events; clicks/scroll are only covered by the debug replay. +* Fractional scale factors are rounded by GTK's integer `scale_factor`.