feat(inspector): port the terminal inspector

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

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

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

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

Test Plan:
- cargo test -p ghostty-inspector (15 incl. headless full-UI draw)
- cargo test -p ghostty-core --test e2e_inspector (4)
This commit is contained in:
ddidderr committed 2026-10-12 04:39:14 +02:00
1 parent b192fdb046
commit d1a5e55198
25 files changed
+6693

No files matched your search

+180
View File
@@ -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<Vec<SplitRequest>> =
const { std::cell::RefCell::new(Vec::new()) };
}
/// Request that the node `dock_id` is split to the right (`ratio` is the
/// fraction given to the new right node) and the window named `label` docked
/// into the right node. Zig performs this immediately while the parent window
/// is still being built (`DockBuilderSplitNode`, `DockBuilderDockWindow`,
/// `DockBuilderFinish`), but that re-parents the dock node of the window
/// being built, which trips an upstream Dear ImGui sanity check when
/// assertions are enabled. The request is applied by [`flush_splits`] once
/// all windows of the frame have ended, so the new window gets docked one
/// frame later.
pub fn split_right_and_dock(dock_id: u32, ratio: f32, label: &str) {
PENDING_SPLITS.with(|p| {
p.borrow_mut().push(SplitRequest {
dock_id,
ratio,
label: label.to_owned(),
});
});
}
/// Apply the requests made by [`split_right_and_dock`]. Must be called with
/// no window being built.
pub fn flush_splits(ui: &Ui) {
let requests = PENDING_SPLITS.with(|p| std::mem::take(&mut *p.borrow_mut()));
for req in requests {
let Ok(c_label) = std::ffi::CString::new(req.label) else {
continue;
};
ui.with_bound_context(|| {
let mut right: sys::ImGuiID = 0;
let mut left: sys::ImGuiID = 0;
// SAFETY: the Dear ImGui context of `ui` is bound (and current)
// for the duration of this closure, all pointers are valid for
// the call, and `c_label` outlives the calls that borrow it.
unsafe {
sys::igDockBuilderSplitNode(
req.dock_id,
sys::ImGuiDir_Right,
req.ratio,
&raw mut right,
&raw mut left,
);
sys::igDockBuilderDockWindow(c_label.as_ptr(), right);
sys::igDockBuilderFinish(req.dock_id);
}
});
}
}
/// `SetNextItemAllowOverlap`: the next item can be overlapped by items
/// submitted after it (used to put a button inside a header).
pub fn set_next_item_allow_overlap(ui: &Ui) {
ui.with_bound_context(|| {
// SAFETY: the context of `ui` is bound for the duration of the call.
unsafe { sys::igSetNextItemAllowOverlap() };
});
}
/// An open `Begin` scope; `End` is called when dropped. Unlike
/// [`Ui::window`], the scope stays open (as in Zig's `Begin` + `defer End`)
/// even when `Begin` returned false, which matters for widgets that must run
/// their per-frame logic regardless of whether the window content is visible.
pub struct WindowScope<'ui> {
ui: &'ui Ui,
}
impl Drop for WindowScope<'_> {
fn drop(&mut self) {
self.ui.with_bound_context(|| {
// SAFETY: balanced with the `igBegin` in `begin_window`.
unsafe { sys::igEnd() };
});
}
}
/// `Begin(name, p_open, flags)`. Returns whether the window content is
/// visible together with the scope that ends the window.
pub fn begin_window<'ui>(
ui: &'ui Ui,
name: &str,
open: Option<&mut bool>,
flags: dear_imgui_rs::WindowFlags,
) -> (bool, WindowScope<'ui>) {
let c_name = std::ffi::CString::new(name.replace('\0', "")).unwrap_or_default();
let open_ptr: *mut bool = match open {
Some(b) => b,
None => std::ptr::null_mut(),
};
let visible = ui.with_bound_context(|| {
// SAFETY: the context is bound, `c_name` is a valid NUL-terminated
// string for the call and `open_ptr` is null or a valid exclusive
// pointer derived from a `&mut bool`.
unsafe { sys::igBegin(c_name.as_ptr(), open_ptr, flags.bits()) }
});
(visible, WindowScope { ui })
}
/// Whether a dock node with the given ID exists (`DockBuilderGetNode`).
pub fn dock_node_exists(ui: &Ui, id: u32) -> bool {
ui.with_bound_context(|| {
// SAFETY: the context is bound; the returned pointer is only compared
// against null.
!unsafe { sys::igDockBuilderGetNode(id) }.is_null()
})
}
/// Create a dockspace node with the given ID and dock all `windows` into it
/// (`DockBuilderAddNode` with the `DockSpace` flag, optionally
/// `DockBuilderSetNodeSize`, `DockBuilderDockWindow`, `DockBuilderFinish`).
pub fn create_dock_space_node(ui: &Ui, id: u32, size: Option<[f32; 2]>, windows: &[&str]) {
let names: Vec<std::ffi::CString> = windows
.iter()
.filter_map(|w| std::ffi::CString::new(*w).ok())
.collect();
ui.with_bound_context(|| {
// SAFETY: the context is bound, and every `CString` outlives the
// calls that borrow it.
unsafe {
let added = sys::igDockBuilderAddNode(id, sys::ImGuiDockNodeFlags_DockSpace);
debug_assert_eq!(added, id);
if let Some(size) = size {
sys::igDockBuilderSetNodeSize(
id,
sys::ImVec2_c {
x: size[0],
y: size[1],
},
);
}
for name in &names {
sys::igDockBuilderDockWindow(name.as_ptr(), id);
}
sys::igDockBuilderFinish(id);
}
});
}
/// `SetNextWindowSize`.
pub fn set_next_window_size(ui: &Ui, size: [f32; 2], cond: dear_imgui_rs::Condition) {
ui.with_bound_context(|| {
// SAFETY: the context is bound for the duration of the call.
unsafe {
sys::igSetNextWindowSize(
sys::ImVec2 {
x: size[0],
y: size[1],
},
cond as i32,
);
}
});
}
+197
View File
@@ -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::<std::path::PathBuf>(None) {
log::warn!("failed to disable the imgui ini file err={err}");
}
if let Err(err) = ctx.set_log_filename::<std::path::PathBuf>(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<Inspector>);
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<Feature> {
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());
}
}
+185
View File
@@ -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<ImKey> {
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));
}
}
+18
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
//! Unit helpers (ZIG/src/inspector/units.zig).
pub fn to_kibi_bytes(bytes: usize) -> usize {
bytes / 1024
}
+539
View File
@@ -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<BindingAction>,
/// The data sent to the pty as a result of this keyboard event.
pub pty: Vec<u8>,
/// 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<Event>,
}
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);
}
}
+194
View File
@@ -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();
}
}
@@ -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<dear_imgui_rs::TableToken<'a>> {
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<dear_imgui_rs::TreeNodeToken<'ui>> {
// 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("-");
}
}
@@ -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<dear_imgui_rs::TableToken<'a>> {
ui.begin_table_with_flags(
id,
columns,
dear_imgui_rs::TableOptions::default()
.flags(TableFlags::BORDERS_INNER_V | TableFlags::ROW_BG)
.sizing_policy(dear_imgui_rs::TableSizingPolicy::FixedFit),
)
}
const TAGS: [Tag; 4] = [Tag::Active, Tag::Viewport, Tag::Screen, Tag::History];
/// PageList inspector widget.
#[derive(Debug, Default)]
pub struct Inspector;
impl Inspector {
pub fn draw(&self, ui: &Ui, pages: &mut PageList) {
ui.text_wrapped(
"PageList manages the backing pages that hold scrollback and the active \
terminal grid. Each page is a contiguous memory buffer with its \
own rows, cells, style set, grapheme map, and hyperlink storage.",
);
if ui.collapsing_header("Overview", TreeNodeFlags::DEFAULT_OPEN) {
summary_table(ui, pages);
}
if ui.collapsing_header("Scrollbar & Regions", TreeNodeFlags::DEFAULT_OPEN) {
ui.separator_with_text("Scrollbar");
scrollbar_info(ui, pages);
ui.separator_with_text("Regions");
regions_table(ui, pages);
}
if ui.collapsing_header("Tracked Pins", TreeNodeFlags::DEFAULT_OPEN) {
tracked_pins_table(ui, pages);
}
if ui.collapsing_header("Pages", TreeNodeFlags::DEFAULT_OPEN) {
help_marker(
ui,
"Pages are shown most-recent first. Each page holds a grid of rows/cells \
plus metadata tables for styles, graphemes, strings, and hyperlinks.",
);
let active_pin = pages.get_top_left(Tag::Active);
let viewport_pin = pages.get_top_left(Tag::Viewport);
let mut row_offset = pages.total_rows;
let mut index = pages.total_pages();
let mut node = pages.last;
while let Some(node_id) = node {
let Some(page_node) = pages.node(node_id) else {
break;
};
let pg = &page_node.data;
node = page_node.prev;
row_offset -= usize::from(pg.size.rows);
index -= 1;
// We use our location as the ID so that even if reallocations
// happen we remain open if we're open already.
let _id = ui.push_id(index as i32);
// Open up the tree node.
let Some(_open) = page::tree_node(
ui,
&page::TreeNodeState {
page: pg,
index,
row_range: [
row_offset,
(row_offset + usize::from(pg.size.rows)).wrapping_sub(1),
],
active: active_pin.is_some_and(|p| p.node == node_id),
viewport: viewport_pin.is_some_and(|p| p.node == node_id),
},
) else {
continue;
};
page::inspector(ui, pg);
}
}
}
}
fn summary_table(ui: &Ui, pages: &PageList) {
let Some(_t) = begin_table(ui, "pagelist_summary", 3) else {
return;
};
let row = |name: &str, help: &str, value: String| {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text(name);
ui.table_set_column_index(1);
help_marker(ui, help);
ui.table_set_column_index(2);
ui.text(value);
};
row(
"Active Grid",
"Active viewport size in columns x rows.",
format!("{}c x {}r", pages.cols, pages.rows),
);
row(
"Pages",
"Total number of pages in the linked list.",
pages.total_pages().to_string(),
);
row(
"Total Rows",
"Total rows represented by scrollback + active area.",
pages.total_rows.to_string(),
);
row(
"Page Bytes",
"Total bytes allocated for active pages.",
format!("{} KiB", to_kibi_bytes(pages.page_size)),
);
row(
"Max Size",
"Maximum bytes before pages must be evicated. The total\n\
used bytes may be higher due to minimum individual page\n\
sizes but the next allocation that would exceed this limit\n\
will evict pages from the front of the list to free up space.",
format!("{} KiB", to_kibi_bytes(pages.max_size())),
);
row(
"Viewport",
"Current viewport anchoring mode.",
crate::keys::tag_name(&pages.viewport),
);
row(
"Tracked Pins",
"Number of pins tracked for automatic updates.",
pages.count_tracked_pins().to_string(),
);
}
fn scrollbar_info(ui: &Ui, pages: &mut PageList) {
let scrollbar = pages.scrollbar();
// If we have a scrollbar, show it.
if scrollbar.total > 0 {
let delta_row = scrollbar_widget(ui, &scrollbar);
if delta_row != 0 {
pages.scroll(Scroll::DeltaRow(delta_row));
}
}
let Some(_t) = begin_table(ui, "scrollbar_info", 3) else {
return;
};
let row = |name: &str, help: &str, value: usize| {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text(name);
ui.table_set_column_index(1);
help_marker(ui, help);
ui.table_set_column_index(2);
ui.text(value.to_string());
};
row(
"Total",
"Total number of scrollable rows including scrollback and active area.",
scrollbar.total,
);
row(
"Offset",
"Current scroll position as row offset from the top of scrollback.",
scrollbar.offset,
);
row(
"Length",
"Number of rows visible in the viewport.",
scrollbar.len,
);
}
fn regions_table(ui: &Ui, pages: &PageList) {
let Some(_t) = begin_table(ui, "pagelist_regions", 4) else {
return;
};
page::setup_fixed(ui, "Region");
page::setup_fixed(ui, "");
page::setup_fixed(ui, "Top-Left");
page::setup_stretch(ui, "Bottom-Right");
ui.table_headers_row();
for tag in TAGS {
region_row(ui, pages, tag);
}
}
fn region_row(ui: &Ui, pages: &PageList, tag: Tag) {
let tl_pin = pages.get_top_left(tag);
let br_pin = pages.get_bottom_right(tag);
ui.table_next_row();
ui.table_set_column_index(0);
ui.text(crate::keys::tag_name(&tag));
ui.table_set_column_index(1);
help_marker(ui, region_help_text(tag));
ui.table_set_column_index(2);
if let Some(pt) = tl_pin.and_then(|p| pages.point_from_pin(tag, p)) {
let coord = pt.coord();
ui.text(format!("({}, {})", coord.x, coord.y));
} else {
ui.text_disabled("(n/a)");
}
ui.table_set_column_index(3);
if let Some(br) = br_pin {
if let Some(pt) = pages.point_from_pin(tag, br) {
let coord = pt.coord();
ui.text(format!("({}, {})", coord.x, coord.y));
} else {
ui.text_disabled("(n/a)");
}
} else {
ui.text_disabled("(empty)");
}
}
fn region_help_text(tag: Tag) -> &'static str {
match tag {
Tag::Active => {
"The active area where a running program can jump the cursor \
and make changes. This is the 'editable' part of the screen. \
Bottom-right includes the full height of the screen, including \
rows that may not be written yet."
}
Tag::Viewport => {
"The visible viewport. If the user has scrolled, top-left changes. \
Bottom-right is the last written row from the top-left."
}
Tag::Screen => {
"Top-left is the furthest back in scrollback history. Bottom-right \
is the last written row. Unlike 'active', this only contains \
written rows."
}
Tag::History => {
"Same top-left as 'screen' but bottom-right is the line just before \
the top of 'active'. Contains only the scrollback history."
}
}
}
fn tracked_pins_table(ui: &Ui, pages: &PageList) {
let Some(_t) = ui.begin_table_with_flags(
"tracked_pins",
5,
dear_imgui_rs::TableOptions::default()
.flags(TableFlags::BORDERS | TableFlags::ROW_BG)
.sizing_policy(dear_imgui_rs::TableSizingPolicy::FixedFit),
) else {
return;
};
page::setup_fixed(ui, "Index");
page::setup_fixed(ui, "Pin");
page::setup_fixed(ui, "Context");
page::setup_fixed(ui, "Dirty");
page::setup_fixed(ui, "State");
ui.table_headers_row();
let active_pin = pages.get_top_left(Tag::Active);
let viewport_pin = pages.get_top_left(Tag::Viewport);
let viewport_handle = pages.viewport_pin_handle();
for (idx, (tracked, pin)) in pages.tracked_pins().enumerate() {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text(idx.to_string());
ui.table_set_column_index(1);
if pin.garbage {
ui.text_colored([1.0, 0.5, 0.3, 1.0], format!("({}, {})", pin.x, pin.y));
} else {
ui.text(format!("({}, {})", pin.x, pin.y));
}
ui.table_set_column_index(2);
if let Some(pt) = pages.point_from_pin(Tag::Screen, pin) {
let coord = pt.coord();
ui.text(format!("screen ({}, {})", coord.x, coord.y));
} else {
ui.text_disabled("screen (out of range)");
}
ui.table_set_column_index(3);
if pin.is_dirty(pages) {
ui.text_colored([1.0, 0.4, 0.4, 1.0], "dirty");
} else {
ui.text_disabled("clean");
}
ui.table_set_column_index(4);
if active_pin.is_some_and(|p| pin.eql(p)) {
ui.text_colored([0.4, 0.9, 0.4, 1.0], "active top");
} else if viewport_pin.is_some_and(|p| pin.eql(p)) {
ui.text_colored([0.4, 0.8, 1.0, 1.0], "viewport top");
} else if pin.garbage {
ui.text_colored([1.0, 0.5, 0.3, 1.0], "garbage");
} else if viewport_handle == Some(tracked) {
ui.text("viewport pin");
} else {
ui.text_disabled("tracked");
}
}
}
/// Draws the scrollbar; returns the number of rows the user dragged.
fn scrollbar_widget(ui: &Ui, scrollbar: &Scrollbar) -> isize {
let mut delta_row: isize = 0;
let avail_width = ui.content_region_avail()[0];
let bar_height = ui.frame_height();
let cursor_pos = ui.cursor_screen_pos();
let total_f = scrollbar.total as f32;
let offset_f = scrollbar.offset as f32;
let len_f = scrollbar.len as f32;
let grab_start = (offset_f / total_f) * avail_width;
let grab_width = ((len_f / total_f) * avail_width).max(4.0);
let draw_list = ui.get_window_draw_list();
let bg_color = ui.get_color_u32(StyleColor::ScrollbarBg);
let grab_color = ui.get_color_u32(StyleColor::ScrollbarGrab);
draw_list
.add_rect(
cursor_pos,
[cursor_pos[0] + avail_width, cursor_pos[1] + bar_height],
bg_color,
)
.filled(true)
.build();
draw_list
.add_rect(
[cursor_pos[0] + grab_start, cursor_pos[1]],
[
cursor_pos[0] + grab_start + grab_width,
cursor_pos[1] + bar_height,
],
grab_color,
)
.filled(true)
.build();
ui.invisible_button("scrollbar_drag", [avail_width, bar_height]);
if ui.is_item_active() {
let drag_delta = ui.mouse_drag_delta_with_threshold(MouseButton::Left, 0.0);
if drag_delta[0] != 0.0 {
let row_delta = (drag_delta[0] / avail_width) * total_f;
delta_row = row_delta as isize;
ui.reset_mouse_drag_delta(MouseButton::Left);
}
}
if ui.is_item_hovered_with_flags(ItemHoveredFlags::DELAY_SHORT) {
ui.set_tooltip(format!(
"offset={} len={} total={}",
scrollbar.offset, scrollbar.len, scrollbar.total
));
}
delta_row
}
/// Grid inspector widget for choosing and inspecting a specific cell.
#[derive(Debug)]
pub struct CellChooser {
pub lookup_region: Tag,
pub lookup_coord: Coordinate,
pub cell_info: CellInfo,
}
impl Default for CellChooser {
fn default() -> Self {
Self {
lookup_region: Tag::Viewport,
lookup_coord: Coordinate { x: 0, y: 0 },
cell_info: CellInfo,
}
}
}
impl CellChooser {
pub fn draw(&mut self, ui: &Ui, pages: &PageList) {
ui.text_wrapped(
"Inspect a cell by choosing a coordinate space and entering the X/Y position. \
The inspector resolves the point into the page list and displays the cell contents.",
);
ui.separator_with_text("Cell Inspector");
let region_max = max_coord(pages, self.lookup_region);
if let Some(coord) = region_max {
self.lookup_coord.x = self.lookup_coord.x.min(coord.x);
self.lookup_coord.y = self.lookup_coord.y.min(coord.y);
} else {
self.lookup_coord = Coordinate { x: 0, y: 0 };
}
{
let disabled = region_max.is_none();
let _disabled = ui.begin_disabled_with_cond(disabled);
let preview = crate::keys::tag_name(&self.lookup_region);
let combo_width = TAGS
.iter()
.map(|t| crate::keys::tag_name(t).len())
.max()
.unwrap_or(0)
+ 4;
ui.set_next_item_width(ui.calc_text_size("X".repeat(combo_width))[0]);
if let Some(_combo) = ui.begin_combo_with_flags(
"##grid_region",
preview,
ComboBoxOptions::new().height(ComboBoxHeight::Small),
) {
for tag in TAGS {
let selected = tag == self.lookup_region;
if ui
.selectable_config(crate::keys::tag_name(&tag))
.selected(selected)
.build()
{
self.lookup_region = tag;
}
if selected {
ui.set_item_default_focus();
}
}
}
ui.same_line();
let width = ui.calc_text_size("00000")[0];
let mut x_value: u16 = self.lookup_coord.x;
let mut y_value: u32 = self.lookup_coord.y;
let mut changed = false;
ui.align_text_to_frame_padding();
ui.text("x:");
ui.same_line();
ui.set_next_item_width(width);
if ui.input_scalar("##grid_x", &mut x_value).build() {
changed = true;
}
ui.same_line();
ui.align_text_to_frame_padding();
ui.text("y:");
ui.same_line();
ui.set_next_item_width(width);
if ui.input_scalar("##grid_y", &mut y_value).build() {
changed = true;
}
ui.same_line();
help_marker(
ui,
"Choose the coordinate space and X/Y position (0-indexed).",
);
if changed && let Some(coord) = region_max {
self.lookup_coord.x = x_value.min(coord.x);
self.lookup_coord.y = y_value.min(coord.y);
}
}
if let Some(coord) = region_max {
ui.text_disabled(format!("Range: x 0..{}, y 0..{}", coord.x, coord.y));
} else {
ui.text_disabled("(region has no rows)");
return;
}
let pt = Point::new(self.lookup_region, self.lookup_coord);
let Some(cell) = lookup_cell(pages, pt) else {
ui.text_disabled("(cell out of range)");
return;
};
CellInfo::draw(ui, &cell, pt);
if cell.cell.style_id() != DEFAULT_ID {
ui.separator_with_text("Style");
let st = cell.page().style(cell.cell);
style::table(ui, &st, None);
}
if cell.cell.hyperlink() {
ui.separator_with_text("Hyperlink");
hyperlink_table(ui, &cell);
}
if cell.cell.has_grapheme() {
ui.separator_with_text("Grapheme");
grapheme_table(ui, &cell);
}
}
}
/// The resolved cell of a point (Zig: `PageList.Cell`).
struct CellRef<'a> {
page: &'a ghostty_terminal::page::Page,
row: ghostty_terminal::page::Row,
cell: ghostty_terminal::page::Cell,
row_idx: usize,
col_idx: usize,
}
impl CellRef<'_> {
fn page(&self) -> &ghostty_terminal::page::Page {
self.page
}
fn cell_index(&self) -> usize {
self.page
.cell_index(self.col_idx, self.row_idx)
.unwrap_or(0)
}
}
fn lookup_cell(pages: &PageList, pt: Point) -> Option<CellRef<'_>> {
let pin = pages.pin(pt)?;
let page = &pages.node(pin.node)?.data;
let (row, cell) = page.get_row_and_cell(usize::from(pin.x), usize::from(pin.y))?;
Some(CellRef {
page,
row: *row,
cell: *cell,
row_idx: usize::from(pin.y),
col_idx: usize::from(pin.x),
})
}
fn max_coord(pages: &PageList, tag: Tag) -> Option<Coordinate> {
let br_pin = pages.get_bottom_right(tag)?;
let br_point = pages.point_from_pin(tag, br_pin)?;
Some(br_point.coord())
}
fn hyperlink_table(ui: &Ui, cell: &CellRef<'_>) {
let Some(_t) = ui.begin_table_with_flags("cell_hyperlink", 2, TableFlags::NONE) else {
return;
};
let page = cell.page();
let Some(link_id) = page.lookup_hyperlink(cell.cell_index()) else {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Status");
ui.table_set_column_index(1);
ui.text_disabled("(missing link data)");
return;
};
let Some(entry) = page.hyperlink_set.get(link_id) else {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Status");
ui.table_set_column_index(1);
ui.text_disabled("(missing link data)");
return;
};
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("ID");
ui.table_set_column_index(1);
match &entry.id {
ghostty_terminal::hyperlink::HyperlinkId::Implicit(value) => {
ui.text(format!("implicit {value}"));
}
ghostty_terminal::hyperlink::HyperlinkId::Explicit(id) => {
if id.is_empty() {
ui.text_disabled("(empty)");
} else {
ui.text(String::from_utf8_lossy(id));
}
}
}
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("URI");
ui.table_set_column_index(1);
if entry.uri.is_empty() {
ui.text_disabled("(empty)");
} else {
ui.text(String::from_utf8_lossy(&entry.uri));
}
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Ref Count");
ui.table_set_column_index(1);
ui.text(page.hyperlink_set.ref_count(link_id).to_string());
}
fn grapheme_table(ui: &Ui, cell: &CellRef<'_>) {
let Some(_t) = ui.begin_table_with_flags("cell_grapheme", 2, TableFlags::NONE) else {
return;
};
let page = cell.page();
let Some(cps) = page.lookup_grapheme(cell.cell_index()) else {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Status");
ui.table_set_column_index(1);
ui.text_disabled("(missing grapheme data)");
return;
};
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Extra Codepoints");
ui.table_set_column_index(1);
if cps.is_empty() {
ui.text_disabled("(none)");
return;
}
if let Some(_list) = ui.list_box_config("##grapheme_list").begin(ui) {
for cp in cps {
ui.selectable(format!("U+{cp:X}"));
}
}
}
/// Cell inspector widget.
#[derive(Debug, Default)]
pub struct CellInfo;
impl CellInfo {
fn draw(ui: &Ui, cell: &CellRef<'_>, point: Point) {
let Some(_t) = begin_table(ui, "cell_info", 3) else {
return;
};
let row3 = |name: &str, help: &str, f: &dyn Fn()| {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text(name);
ui.table_set_column_index(1);
help_marker(ui, help);
ui.table_set_column_index(2);
f();
};
row3(
"Grid Position",
"The cell's X/Y coordinates in the selected region.",
&|| {
let coord = point.coord();
ui.text(format!("({}, {})", coord.x, coord.y));
},
);
row3(
"Page Location",
"Row and column indices within the backing page.",
&|| ui.text(format!("row={} col={}", cell.row_idx, cell.col_idx)),
);
row3(
"Content",
"Content tag describing how the cell data is stored.",
&|| ui.text(crate::keys::tag_name(&cell.cell.content_tag())),
);
row3(
"Codepoint",
"Primary Unicode codepoint for the cell.",
&|| {
let cp = cell.cell.codepoint();
if cp == 0 {
ui.text_disabled("(empty)");
} else {
ui.text(format!("U+{cp:04X}"));
}
},
);
if cell.cell.has_grapheme() {
row3(
"Grapheme",
"Extra codepoints that combine with the primary codepoint to form the grapheme cluster.",
&|| {
if let Some(_list) = ui.list_box_config("##cell_grapheme").begin(ui) {
if let Some(cps) = cell.page().lookup_grapheme(cell.cell_index()) {
for cp in cps {
ui.selectable(format!("U+{cp:X}"));
}
} else {
ui.selectable("(missing)");
}
}
},
);
}
row3(
"Width Property",
"Character width property (narrow, wide, spacer, etc.).",
&|| ui.text(crate::keys::tag_name(&cell.cell.wide())),
);
row3(
"Row Flags",
"Flags set on the row containing this cell.",
&|| {
let row = cell.row;
if row.wrap()
|| row.wrap_continuation()
|| row.grapheme()
|| row.styled()
|| row.hyperlink()
{
if row.wrap() {
ui.text_colored([0.4, 0.8, 1.0, 1.0], "wrap");
ui.same_line();
}
if row.wrap_continuation() {
ui.text_colored([0.4, 0.8, 1.0, 1.0], "cont");
ui.same_line();
}
if row.grapheme() {
ui.text_colored([0.9, 0.7, 0.3, 1.0], "grapheme");
ui.same_line();
}
if row.styled() {
ui.text_colored([0.7, 0.9, 0.5, 1.0], "styled");
ui.same_line();
}
if row.hyperlink() {
ui.text_colored([0.8, 0.6, 1.0, 1.0], "link");
ui.same_line();
}
} else {
ui.text_disabled("(none)");
}
},
);
row3(
"Style ID",
"Internal style reference ID for this cell.",
&|| ui.text(cell.cell.style_id().to_string()),
);
row3(
"Style",
"Resolved style for the cell (colors, attributes, etc.).",
&|| {
if cell.cell.style_id() == DEFAULT_ID {
ui.text_disabled("(default)");
} else {
ui.text_disabled("(see below)");
}
},
);
if cell.cell.hyperlink() {
row3(
"Hyperlink",
"OSC8 hyperlink ID associated with this cell.",
&|| {
let link_id = cell.page().lookup_hyperlink(cell.cell_index()).unwrap_or(0);
ui.text(format!("id={link_id}"));
},
);
}
}
}
@@ -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<Feature>,
}
impl Info {
/// Grab the features into a new allocated vec.
pub fn overlay_features(&self) -> Vec<Feature> {
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]);
}
}
@@ -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<String>,
}
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))),
}
}
}
@@ -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),
}
}
@@ -0,0 +1,408 @@
//! The inspector window layout and surface information
//! (ZIG/src/inspector/widgets/surface.zig).
use dear_imgui_rs::{DockNodeFlags, TableFlags, TreeNodeFlags, Ui, WindowFlags};
use ghostty_renderer::size::{Coordinate, CoordinateTag, Size, SurfaceCoord};
use ghostty_terminal::page_list::Pin;
use ghostty_terminal::point::{Coordinate as PointCoordinate, Tag};
use ghostty_terminal::terminal::Terminal;
use super::{key, renderer, terminal, termio};
use crate::dock::begin_window;
/// This is discovered via the hardcoded string in the ImGui demo window.
const WINDOW_IMGUI_DEMO: &str = "Dear ImGui Demo";
const WINDOW_KEYBOARD: &str = "Keyboard";
const WINDOW_TERMINAL: &str = "Terminal";
const WINDOW_SURFACE: &str = "Surface";
const WINDOW_TERMIO: &str = "Terminal IO";
const WINDOW_RENDERER: &str = "Renderer";
/// The state of the surface that the inspector shows (the fields of the Zig
/// `Surface` that `surface.zig` reads). The core surface fills this in.
#[derive(Debug, Clone)]
pub struct SurfaceInfo {
/// The size of the surface (`surface.size`).
pub size: Size,
/// The font size in points and in pixels (`surface.font_size`).
pub font_size_points: f32,
pub font_size_pixels: f32,
/// Whether each mouse button (indexed by the button's integer value) is
/// currently pressed (`surface.mouse.click_state`).
pub click_pressed: [bool; 12],
/// The pin of the left click (`surface.mouse.left_click_pin`), already
/// resolved against the active screen.
pub left_click_pin: Option<Pin>,
pub left_click_xpos: f64,
pub left_click_ypos: f64,
}
#[derive(Debug)]
pub struct Inspector {
/// Internal GUI state
pub surface_info: Info,
pub key_stream: key::Stream,
pub terminal_info: terminal::Info,
pub vt_stream: termio::Stream,
pub renderer_info: renderer::Info,
pub show_demo_window: bool,
}
impl Default for Inspector {
fn default() -> Self {
Self::new()
}
}
impl Inspector {
pub fn new() -> Self {
Self {
surface_info: Info,
key_stream: key::Stream::new(),
terminal_info: terminal::Info::default(),
vt_stream: termio::Stream::new(),
renderer_info: renderer::Info::default(),
show_demo_window: true,
}
}
pub fn draw(&mut self, ui: &Ui, surface: &SurfaceInfo, mouse: &Mouse, t: &mut Terminal) {
// Create our dockspace first. If we had to setup our dockspace,
// then it is a first render.
let first_render = Self::create_dock_space(ui);
// Draw everything that requires the terminal state mutex (the caller
// holds it).
{
// Terminal info window
{
let (open, _scope) = begin_window(
ui,
WINDOW_TERMINAL,
None,
WindowFlags::NO_FOCUS_ON_APPEARING,
);
self.terminal_info.draw(ui, open, t);
}
// Surface info window
{
let (open, _scope) =
begin_window(ui, WINDOW_SURFACE, None, WindowFlags::NO_FOCUS_ON_APPEARING);
self.surface_info.draw(ui, open, surface, mouse, t);
}
// Keyboard info window
{
let (open, _scope) = begin_window(
ui,
WINDOW_KEYBOARD,
None,
WindowFlags::NO_FOCUS_ON_APPEARING,
);
self.key_stream.draw(ui, open);
}
// Terminal IO window
{
let (open, _scope) =
begin_window(ui, WINDOW_TERMIO, None, WindowFlags::NO_FOCUS_ON_APPEARING);
if open {
self.vt_stream.draw(ui, &t.colors.palette.current);
}
}
// Renderer info window
{
let (open, _scope) = begin_window(
ui,
WINDOW_RENDERER,
None,
WindowFlags::NO_FOCUS_ON_APPEARING,
);
self.renderer_info.draw(ui, open);
}
}
// Apply the dock splits requested by the pop-out headers.
crate::dock::flush_splits(ui);
// In debug we show the ImGui demo window so we can easily view
// available widgets and such.
if cfg!(debug_assertions) && self.show_demo_window {
ui.show_demo_window(&mut self.show_demo_window);
}
if first_render {
// On first render, setup our initial focus state. We only
// do this on first render so that we can let the user change
// focus afterward without it snapping back.
ui.set_window_focus(Some(WINDOW_TERMINAL));
}
}
/// Create the global dock space for the inspector. A dock space
/// is a special area where windows can be docked into. The global
/// dock space fills the entire main viewport.
///
/// Returns true if this was the first time the dock space was created.
fn create_dock_space(ui: &Ui) -> bool {
let dockspace_id = ui.get_id("Main Dockspace").raw();
// Initial Docking setup
let setup = !crate::dock::dock_node_exists(ui, dockspace_id);
if setup {
// Register our dockspace node, make it the full size of the
// viewport. We only initialize one central docking point now but
// this is the point we'd pre-split and so on for the initial
// layout.
let size = ui.main_viewport().size();
crate::dock::create_dock_space_node(
ui,
dockspace_id,
Some(size),
&[
WINDOW_IMGUI_DEMO,
WINDOW_TERMINAL,
WINDOW_SURFACE,
WINDOW_KEYBOARD,
WINDOW_TERMIO,
WINDOW_RENDERER,
],
);
}
// Put the dockspace over the viewport.
ui.dock_space_over_main_viewport_raw(
dockspace_id.into(),
DockNodeFlags::PASSTHRU_CENTRAL_NODE,
);
setup
}
}
/// Mouse state that we track in addition to normal mouse states that
/// Ghostty always knows about.
#[derive(Debug, Clone, Copy, Default)]
pub struct Mouse {
/// Last hovered x/y
pub last_xpos: f64,
pub last_ypos: f64,
// Last hovered screen point
pub last_point: Option<Pin>,
}
/// Surface information inspector widget.
#[derive(Debug, Default)]
pub struct Info;
impl Info {
/// Draw the surface info window.
pub fn draw(&self, ui: &Ui, open: bool, surface: &SurfaceInfo, mouse: &Mouse, t: &Terminal) {
if !open {
return;
}
if ui.collapsing_header("Help", TreeNodeFlags::NONE) {
ui.text_wrapped(
"This window displays information about the surface (window). \
A surface is the graphical area that displays the terminal \
content. It includes dimensions, font sizing, and mouse state \
information specific to this window instance.",
);
}
ui.separator_with_text("Dimensions");
dimensions_table(ui, surface);
ui.separator_with_text("Font");
font_table(ui, surface);
ui.separator_with_text("Mouse");
mouse_table(ui, surface, mouse, t);
}
}
fn dimensions_table(ui: &Ui, surface: &SurfaceInfo) {
let Some(_t) = ui.begin_table_with_flags("table_size", 2, TableFlags::NONE) else {
return;
};
let row = |name: &str, value: String| {
ui.table_next_row();
ui.table_set_column_index(0);
ui.text(name);
ui.table_set_column_index(1);
ui.text(value);
};
let size = &surface.size;
row(
"Screen Size",
format!("{}px x {}px", size.screen.width, size.screen.height),
);
let grid_size = size.grid();
row(
"Grid Size",
format!("{}c x {}r", grid_size.columns, grid_size.rows),
);
row(
"Cell Size",
format!("{}px x {}px", size.cell.width, size.cell.height),
);
row(
"Window Padding",
format!(
"T={} B={} L={} R={} px",
size.padding.top, size.padding.bottom, size.padding.left, size.padding.right
),
);
}
fn font_table(ui: &Ui, surface: &SurfaceInfo) {
let Some(_t) = ui.begin_table_with_flags("table_font", 2, TableFlags::NONE) else {
return;
};
{
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Size (Points)");
ui.table_set_column_index(1);
ui.text(format!("{:.2} pt", surface.font_size_points));
}
{
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Size (Pixels)");
ui.table_set_column_index(1);
ui.text(format!("{:.2} px", surface.font_size_pixels));
}
}
fn mouse_table(ui: &Ui, surface: &SurfaceInfo, mouse: &Mouse, t: &Terminal) {
let Some(_t) = ui.begin_table_with_flags("table_mouse", 2, TableFlags::NONE) else {
return;
};
let pages = &t.screens.active().pages;
{
let hover_point = mouse
.last_point
.and_then(|p| pages.point_from_pin(Tag::Active, p))
.map_or_else(
PointCoordinate::default,
ghostty_terminal::point::Point::coord,
);
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Hover Grid");
ui.table_set_column_index(1);
ui.text(format!("row={}, col={}", hover_point.y, hover_point.x));
}
{
let Coordinate::Terminal(coord) = Coordinate::Surface(SurfaceCoord {
x: mouse.last_xpos,
y: mouse.last_ypos,
})
.convert(CoordinateTag::Terminal, surface.size) else {
unreachable!("converted to the terminal coordinate space");
};
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Hover Point");
ui.table_set_column_index(1);
ui.text(format!("({}px, {}px)", coord.x as i64, coord.y as i64));
}
let any_click = surface.click_pressed.iter().any(|p| *p);
{
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Click State");
ui.table_set_column_index(1);
if any_click {
for (i, pressed) in surface.click_pressed.iter().enumerate() {
if !*pressed {
continue;
}
ui.same_line();
ui.text(mouse_button_label(i));
}
} else {
ui.text("none");
}
}
{
let left_click_point = surface
.left_click_pin
.and_then(|p| pages.point_from_pin(Tag::Active, p))
.map_or_else(
PointCoordinate::default,
ghostty_terminal::point::Point::coord,
);
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Click Grid");
ui.table_set_column_index(1);
ui.text(format!(
"row={}, col={}",
left_click_point.y, left_click_point.x
));
}
{
ui.table_next_row();
ui.table_set_column_index(0);
ui.text("Click Point");
ui.table_set_column_index(1);
ui.text(format!(
"({}px, {}px)",
surface.left_click_xpos as u32, surface.left_click_ypos as u32
));
}
}
/// The label for a mouse button given its integer value (`input.MouseButton`).
fn mouse_button_label(button: usize) -> &'static str {
match button {
1 => "L",
2 => "R",
3 => "M",
4 => "{4}",
5 => "{5}",
6 => "{6}",
7 => "{7}",
8 => "{8}",
9 => "{9}",
10 => "{10}",
11 => "{11}",
_ => "?",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn button_labels() {
assert_eq!(mouse_button_label(0), "?");
assert_eq!(mouse_button_label(1), "L");
assert_eq!(mouse_button_label(2), "R");
assert_eq!(mouse_button_label(3), "M");
assert_eq!(mouse_button_label(11), "{11}");
}
}
@@ -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<screen::Info>; 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<screen::Info>; 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));
}
}
}
}
@@ -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<ActionTag>);
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<bool>) -> 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<CaptureState>,
/// The event ring to write events to.
pub events: VecDeque<VtEvent>,
/// 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<bool, Self::Error> {
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<VtHandler>,
/// The currently selected event sequence number for keyboard navigation
pub selected_event_seq: Option<u32>,
/// 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<VtEvent> {
&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);
}
}