feat(inspector): port the terminal inspector
Ports ZIG/src/inspector (the Dear ImGui terminal inspector) as the ghostty-inspector crate using dear-imgui-rs + dear-imgui-glow (imgui-rs lacks a safe docking API): screen, modes, keyboard events, terminal IO / VT sequence log with filtering, cell inspector with picking, page list, renderer and size/font info, with Ghostty's docking layout. The crate denies unsafe except a single dock.rs module wrapping DockBuilder. GTK: imgui_widget, inspector_widget and inspector_window classes with their blueprints replace the former no-op; ctrl+shift+i toggles the inspector window. Core: activate/deactivate hooks, key event and mouse recording, and VT stream recording in termio while an inspector is active, as in Zig; overlay features reach the renderer through an InspectorHook on the render state. The debug input replay hook gained inspector-screenshot/ click/scroll steps used to verify all tabs in the real app. Deviations: font size scales with the display scale instead of Zig's fixed 16*2; pop-out dock splits apply one frame later; DCS/APC descriptions are approximated. Test Plan: - cargo test -p ghostty-inspector (15 incl. headless full-UI draw) - cargo test -p ghostty-core --test e2e_inspector (4)
This commit is contained in:
1 parent
b192fdb046
commit
d1a5e55198
25 files changed
+6693
No files matched your search
@@ -0,0 +1,51 @@
|
|||||||
|
using Gtk 4.0;
|
||||||
|
using Adw 1;
|
||||||
|
|
||||||
|
template $GhosttyImguiWidget: Adw.Bin {
|
||||||
|
styles [
|
||||||
|
"imgui",
|
||||||
|
]
|
||||||
|
|
||||||
|
Adw.Bin {
|
||||||
|
Gtk.GLArea gl_area {
|
||||||
|
auto-render: true;
|
||||||
|
// needs to be focusable so that we can receive events
|
||||||
|
focusable: true;
|
||||||
|
focus-on-click: true;
|
||||||
|
allowed-apis: gl;
|
||||||
|
realize => $realize();
|
||||||
|
unrealize => $unrealize();
|
||||||
|
resize => $resize();
|
||||||
|
render => $render();
|
||||||
|
|
||||||
|
EventControllerFocus {
|
||||||
|
enter => $focus_enter();
|
||||||
|
leave => $focus_leave();
|
||||||
|
}
|
||||||
|
|
||||||
|
EventControllerKey {
|
||||||
|
key-pressed => $key_pressed();
|
||||||
|
key-released => $key_released();
|
||||||
|
}
|
||||||
|
|
||||||
|
GestureClick {
|
||||||
|
pressed => $mouse_pressed();
|
||||||
|
released => $mouse_released();
|
||||||
|
button: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
EventControllerMotion {
|
||||||
|
motion => $mouse_motion();
|
||||||
|
}
|
||||||
|
|
||||||
|
EventControllerScroll {
|
||||||
|
scroll => $scroll();
|
||||||
|
flags: both_axes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IMMulticontext im_context {
|
||||||
|
commit => $im_commit();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Gtk 4.0;
|
||||||
|
|
||||||
|
template $GhosttyInspectorWidget: $GhosttyImguiWidget {
|
||||||
|
styles [
|
||||||
|
"inspector",
|
||||||
|
]
|
||||||
|
|
||||||
|
hexpand: true;
|
||||||
|
vexpand: true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Gtk 4.0;
|
||||||
|
using Adw 1;
|
||||||
|
|
||||||
|
template $GhosttyInspectorWindow: Adw.ApplicationWindow {
|
||||||
|
title: _("Ghostty: Terminal Inspector");
|
||||||
|
icon-name: "com.mitchellh.ghostty";
|
||||||
|
default-width: 1000;
|
||||||
|
default-height: 600;
|
||||||
|
|
||||||
|
styles [
|
||||||
|
"inspector",
|
||||||
|
]
|
||||||
|
|
||||||
|
content: Adw.ToolbarView {
|
||||||
|
[top]
|
||||||
|
Adw.HeaderBar {
|
||||||
|
title-widget: Adw.WindowTitle {
|
||||||
|
title: bind template.title;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Gtk.Box {
|
||||||
|
orientation: vertical;
|
||||||
|
spacing: 0;
|
||||||
|
hexpand: true;
|
||||||
|
vexpand: true;
|
||||||
|
|
||||||
|
$GhosttyDebugWarning {
|
||||||
|
visible: bind template.debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
$GhosttyInspectorWidget inspector_widget {
|
||||||
|
notify::surface => $notify_inspector_surface();
|
||||||
|
surface: bind template.surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
//! Port of apprt/gtk/class/imgui_widget.zig (`GhosttyImguiWidget`).
|
||||||
|
//!
|
||||||
|
//! A widget for embedding a Dear ImGui application: a `GtkGLArea` that owns
|
||||||
|
//! an ImGui context and a glow based renderer, feeds the GTK input events
|
||||||
|
//! into ImGui and drives the frames.
|
||||||
|
//!
|
||||||
|
//! The Zig widget exposes `setup` and `render` virtual methods that subclasses
|
||||||
|
//! implement. zig-gobject cannot define virtual methods, and neither can we
|
||||||
|
//! cheaply in gtk-rs without a lot of class struct boilerplate, so subclasses
|
||||||
|
//! install the two hooks with [`ImguiWidget::set_hooks`] instead.
|
||||||
|
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use adw::prelude::*;
|
||||||
|
use adw::subclass::prelude::*;
|
||||||
|
use dear_imgui_glow::GlowRenderer;
|
||||||
|
use ghostty_inspector::imgui::{Context, MouseButton, Ui};
|
||||||
|
use gtk::glib::translate::IntoGlib;
|
||||||
|
use gtk::{gdk, glib};
|
||||||
|
|
||||||
|
use crate::key;
|
||||||
|
|
||||||
|
/// Called once after the ImGui context is created, with the context current
|
||||||
|
/// (Zig virtual method `setup`).
|
||||||
|
pub type SetupHook = Box<dyn Fn(&mut Context, f64)>;
|
||||||
|
|
||||||
|
/// Called for every frame to draw the UI (Zig virtual method `render`).
|
||||||
|
pub type RenderHook = Box<dyn Fn(&Ui)>;
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct ImguiWidget(ObjectSubclass<imp::ImguiWidget>)
|
||||||
|
@extends adw::Bin, gtk::Widget,
|
||||||
|
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implemented by subclasses of [`ImguiWidget`].
|
||||||
|
pub trait ImguiWidgetImpl: BinImpl {}
|
||||||
|
|
||||||
|
#[allow(unsafe_code)]
|
||||||
|
// SAFETY: `ImguiWidget` adds no class/instance data that needs to be set up
|
||||||
|
// for subclasses; we forward to the parent (`adw::Bin`) class/instance
|
||||||
|
// initialization, which is valid for any `T: ImguiWidgetImpl`.
|
||||||
|
unsafe impl<T: ImguiWidgetImpl> IsSubclassable<T> for ImguiWidget {
|
||||||
|
fn class_init(class: &mut glib::Class<Self>) {
|
||||||
|
<adw::Bin as IsSubclassable<T>>::class_init(class.upcast_ref_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_init(instance: &mut glib::subclass::InitializingObject<T>) {
|
||||||
|
<adw::Bin as IsSubclassable<T>>::instance_init(instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImguiWidget {
|
||||||
|
/// This should be called anytime the underlying data for the UI changes
|
||||||
|
/// so that the UI can be refreshed.
|
||||||
|
pub fn queue_render(&self) {
|
||||||
|
self.imp().gl_area.queue_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the hooks (Zig: the `setup` and `render` virtual methods).
|
||||||
|
pub fn set_hooks(&self, setup: SetupHook, render: RenderHook) {
|
||||||
|
let imp = self.imp();
|
||||||
|
imp.setup_hook.replace(Some(setup));
|
||||||
|
imp.render_hook.replace(Some(render));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug aid: write the next rendered frame to a PNG file.
|
||||||
|
pub fn debug_screenshot(&self, path: &str) {
|
||||||
|
self.debug_render_now(3);
|
||||||
|
self.imp().pending_screenshot.replace(Some(path.to_owned()));
|
||||||
|
self.debug_render_now(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug aid: render `frames` frames right now. Compositors only send
|
||||||
|
/// frame callbacks to visible windows, so the GLArea of an inspector
|
||||||
|
/// window that is not on screen would otherwise lag behind the
|
||||||
|
/// synthesized input.
|
||||||
|
pub fn debug_render_now(&self, frames: usize) {
|
||||||
|
let imp = self.imp();
|
||||||
|
if !imp.gl_area.is_realized() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
imp.gl_area.make_current();
|
||||||
|
imp.gl_area.attach_buffers();
|
||||||
|
for _ in 0..frames {
|
||||||
|
imp.do_render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug aid: synthesize a left click at the given position (in
|
||||||
|
/// logical pixels, like the pointer position GTK reports). Frames are
|
||||||
|
/// rendered synchronously between the events.
|
||||||
|
pub fn debug_click(&self, x: f64, y: f64) {
|
||||||
|
let imp = self.imp();
|
||||||
|
let scale = imp.scale_factor();
|
||||||
|
let pos = [(x * scale) as f32, (y * scale) as f32];
|
||||||
|
imp.with_ctx(|ctx| ctx.io_mut().add_mouse_pos_event(pos));
|
||||||
|
self.debug_render_now(3);
|
||||||
|
imp.with_ctx(|ctx| {
|
||||||
|
ctx.io_mut().add_mouse_button_event(MouseButton::Left, true);
|
||||||
|
});
|
||||||
|
self.debug_render_now(3);
|
||||||
|
imp.with_ctx(|ctx| {
|
||||||
|
ctx.io_mut()
|
||||||
|
.add_mouse_button_event(MouseButton::Left, false);
|
||||||
|
});
|
||||||
|
self.debug_render_now(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug aid: synthesize a mouse wheel scroll.
|
||||||
|
pub fn debug_scroll(&self, x: f64, y: f64, dy: f64) {
|
||||||
|
let imp = self.imp();
|
||||||
|
let scale = imp.scale_factor();
|
||||||
|
imp.with_ctx(|ctx| {
|
||||||
|
let io = ctx.io_mut();
|
||||||
|
io.add_mouse_pos_event([(x * scale) as f32, (y * scale) as f32]);
|
||||||
|
io.add_mouse_wheel_event([0.0, dy as f32]);
|
||||||
|
});
|
||||||
|
self.debug_render_now(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
#[allow(clippy::wildcard_imports)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The state that only exists while the GLArea is realized.
|
||||||
|
struct Gui {
|
||||||
|
ctx: Context,
|
||||||
|
renderer: GlowRenderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(gtk::CompositeTemplate)]
|
||||||
|
#[template(resource = "/com/mitchellh/ghostty/ui/1.5/imgui-widget.ui")]
|
||||||
|
pub struct ImguiWidget {
|
||||||
|
/// GL area where we display the Dear ImGui application.
|
||||||
|
#[template_child]
|
||||||
|
pub gl_area: TemplateChild<gtk::GLArea>,
|
||||||
|
|
||||||
|
/// GTK input method context
|
||||||
|
#[template_child]
|
||||||
|
pub im_context: TemplateChild<gtk::IMMulticontext>,
|
||||||
|
|
||||||
|
pub setup_hook: RefCell<Option<SetupHook>>,
|
||||||
|
pub render_hook: RefCell<Option<RenderHook>>,
|
||||||
|
|
||||||
|
/// Dear ImGui context and renderer. We create a context per widget
|
||||||
|
/// so that we can have multiple active imgui views in the same
|
||||||
|
/// application.
|
||||||
|
gui: RefCell<Option<Gui>>,
|
||||||
|
|
||||||
|
/// Our previous instant used to calculate delta time for animations.
|
||||||
|
instant: Cell<Option<Instant>>,
|
||||||
|
|
||||||
|
/// Tick callback for timed updates.
|
||||||
|
tick_callback: RefCell<Option<gtk::TickCallbackId>>,
|
||||||
|
|
||||||
|
/// Last render time for throttling to 30 FPS.
|
||||||
|
last_render_time: Cell<Option<Instant>>,
|
||||||
|
|
||||||
|
/// A pending debug screenshot path.
|
||||||
|
pub pending_screenshot: RefCell<Option<String>>,
|
||||||
|
|
||||||
|
/// The scale factor the style was last scaled to.
|
||||||
|
style_scale: Cell<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ImguiWidget {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
gl_area: TemplateChild::default(),
|
||||||
|
im_context: TemplateChild::default(),
|
||||||
|
setup_hook: RefCell::new(None),
|
||||||
|
render_hook: RefCell::new(None),
|
||||||
|
gui: RefCell::new(None),
|
||||||
|
instant: Cell::new(None),
|
||||||
|
tick_callback: RefCell::new(None),
|
||||||
|
last_render_time: Cell::new(None),
|
||||||
|
pending_screenshot: RefCell::new(None),
|
||||||
|
style_scale: Cell::new(1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for ImguiWidget {
|
||||||
|
const NAME: &'static str = "GhosttyImguiWidget";
|
||||||
|
type Type = super::ImguiWidget;
|
||||||
|
type ParentType = adw::Bin;
|
||||||
|
|
||||||
|
fn class_init(klass: &mut Self::Class) {
|
||||||
|
klass.bind_template();
|
||||||
|
klass.bind_template_callbacks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||||
|
obj.init_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for ImguiWidget {
|
||||||
|
fn dispose(&self) {
|
||||||
|
self.dispose_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetImpl for ImguiWidget {}
|
||||||
|
impl BinImpl for ImguiWidget {}
|
||||||
|
|
||||||
|
impl ImguiWidget {
|
||||||
|
/// Run `f` with the ImGui context, if there is one.
|
||||||
|
pub(super) fn with_ctx(&self, f: impl FnOnce(&mut Context)) -> bool {
|
||||||
|
let Ok(mut gui) = self.gui.try_borrow_mut() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(gui) = gui.as_mut() else {
|
||||||
|
log::warn!("Dear ImGui context not initialized");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
f(&mut gui.ctx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn scale_factor(&self) -> f64 {
|
||||||
|
f64::from(self.gl_area.scale_factor())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle key press/release events.
|
||||||
|
fn key_event(
|
||||||
|
&self,
|
||||||
|
press: bool,
|
||||||
|
ec_key: >k::EventControllerKey,
|
||||||
|
keyval: gdk::Key,
|
||||||
|
gtk_mods: gdk::ModifierType,
|
||||||
|
) -> bool {
|
||||||
|
self.obj().queue_render();
|
||||||
|
|
||||||
|
let handled = self.with_ctx(|ctx| {
|
||||||
|
let io = ctx.io_mut();
|
||||||
|
let mods = key::translate_mods(gtk_mods);
|
||||||
|
io.add_key_event(ghostty_inspector::imgui::Key::LeftShift, mods.shift);
|
||||||
|
io.add_key_event(ghostty_inspector::imgui::Key::LeftCtrl, mods.ctrl);
|
||||||
|
io.add_key_event(ghostty_inspector::imgui::Key::LeftAlt, mods.alt);
|
||||||
|
io.add_key_event(ghostty_inspector::imgui::Key::LeftSuper, mods.super_);
|
||||||
|
|
||||||
|
// If our keyval has a key, then we send that key event
|
||||||
|
if let Some(imgui_key) = key::key_from_keyval(keyval.into_glib())
|
||||||
|
.and_then(ghostty_inspector::keys::imgui_key)
|
||||||
|
{
|
||||||
|
io.add_key_event(imgui_key, press);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !handled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to process the event as text
|
||||||
|
if let Some(event) = ec_key.current_event() {
|
||||||
|
self.im_context.filter_keypress(&event);
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a GTK mouse button to a Dear ImGui mouse button.
|
||||||
|
fn translate_mouse_button(button: u32) -> Option<MouseButton> {
|
||||||
|
match button {
|
||||||
|
1 => Some(MouseButton::Left),
|
||||||
|
2 => Some(MouseButton::Middle),
|
||||||
|
3 => Some(MouseButton::Right),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_button(&self, gesture: >k::GestureClick, down: bool) {
|
||||||
|
self.obj().queue_render();
|
||||||
|
let button = gesture.current_button();
|
||||||
|
self.with_ctx(|ctx| {
|
||||||
|
if let Some(button) = Self::translate_mouse_button(button) {
|
||||||
|
ctx.io_mut().add_mouse_button_event(button, down);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a frame, optionally rendering it.
|
||||||
|
fn frame(&self, gui: &mut Gui, render: bool) -> Result<(), String> {
|
||||||
|
// Determine our delta time
|
||||||
|
let now = Instant::now();
|
||||||
|
let delta = self.instant.get().map_or(1.0 / 60.0, |prev| {
|
||||||
|
now.duration_since(prev).as_secs_f32().max(0.00001)
|
||||||
|
});
|
||||||
|
self.instant.set(Some(now));
|
||||||
|
gui.ctx.io_mut().set_delta_time(delta);
|
||||||
|
|
||||||
|
{
|
||||||
|
let ui = gui.ctx.frame();
|
||||||
|
// Call the hook to draw the UI.
|
||||||
|
if let Some(hook) = self.render_hook.borrow().as_ref() {
|
||||||
|
hook(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !render {
|
||||||
|
gui.ctx.end_frame();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let consumer = gui
|
||||||
|
.renderer
|
||||||
|
.renderer_consumer()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let pending = gui.ctx.render(consumer);
|
||||||
|
gui.renderer.render(pending).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unsafe_code)]
|
||||||
|
fn clear(gui: &Gui) {
|
||||||
|
use glow::HasContext;
|
||||||
|
let Some(gl) = gui.renderer.gl_context() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// SAFETY: the GLArea context is current while rendering.
|
||||||
|
unsafe {
|
||||||
|
gl.clear_color(0.0, 0.0, 0.0, 1.0);
|
||||||
|
gl.clear(glow::COLOR_BUFFER_BIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unsafe_code)]
|
||||||
|
fn screenshot(&self, gui: &Gui, path: &str) {
|
||||||
|
use glow::HasContext;
|
||||||
|
let Some(gl) = gui.renderer.gl_context() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let w = self.gl_area.width() * self.gl_area.scale_factor();
|
||||||
|
let h = self.gl_area.height() * self.gl_area.scale_factor();
|
||||||
|
if w <= 0 || h <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (w, h) = (w as usize, h as usize);
|
||||||
|
let mut pixels = vec![0u8; w * h * 4];
|
||||||
|
// SAFETY: the GLArea context is current while rendering and the
|
||||||
|
// buffer is large enough for `w * h` RGBA8 pixels.
|
||||||
|
unsafe {
|
||||||
|
gl.read_pixels(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
w as i32,
|
||||||
|
h as i32,
|
||||||
|
glow::RGBA,
|
||||||
|
glow::UNSIGNED_BYTE,
|
||||||
|
glow::PixelPackData::Slice(Some(&mut pixels)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// GL's origin is the bottom-left.
|
||||||
|
let stride = w * 4;
|
||||||
|
let mut flipped = Vec::with_capacity(pixels.len());
|
||||||
|
for row in pixels.chunks_exact(stride).rev() {
|
||||||
|
flipped.extend_from_slice(row);
|
||||||
|
}
|
||||||
|
let texture = gdk::MemoryTexture::new(
|
||||||
|
w as i32,
|
||||||
|
h as i32,
|
||||||
|
gdk::MemoryFormat::R8g8b8a8,
|
||||||
|
&glib::Bytes::from_owned(flipped),
|
||||||
|
stride,
|
||||||
|
);
|
||||||
|
match texture.save_to_png(path) {
|
||||||
|
Ok(()) => log::warn!("inspector screenshot written to {path}"),
|
||||||
|
Err(err) => log::error!("inspector screenshot failed path={path} err={err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImguiWidget {
|
||||||
|
#[allow(unsafe_code)]
|
||||||
|
fn do_realize(&self, area: >k::GLArea) {
|
||||||
|
area.make_current();
|
||||||
|
if let Some(err) = area.error() {
|
||||||
|
log::warn!(
|
||||||
|
"GLArea for Dear ImGui widget failed to realize: {}",
|
||||||
|
err.message()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ctx = match Context::try_create() {
|
||||||
|
Ok(ctx) => ctx,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("unable to initialize Dear ImGui context: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup some basic config
|
||||||
|
if let Err(err) = ctx.set_platform_name(Some("ghostty_gtk")) {
|
||||||
|
log::warn!("unable to set the imgui platform name err={err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the hook to setup the UI. This happens before the
|
||||||
|
// renderer is created so the fonts exist when it uploads the
|
||||||
|
// atlas.
|
||||||
|
if let Some(hook) = self.setup_hook.borrow().as_ref() {
|
||||||
|
hook(&mut ctx, self.scale_factor());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Realize means that our OpenGL context is ready, so we can now
|
||||||
|
// initialize the ImGui OpenGL backend for our context.
|
||||||
|
//
|
||||||
|
// SAFETY: the GLArea's context is current and `proc_address`
|
||||||
|
// resolves GL functions for the current context.
|
||||||
|
let gl = unsafe { glow::Context::from_loader_function(crate::gl::proc_address) };
|
||||||
|
let renderer = match GlowRenderer::new(gl, &mut ctx) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("unable to initialize the Dear ImGui OpenGL renderer: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.style_scale.set(1.0);
|
||||||
|
self.gui.replace(Some(Gui { ctx, renderer }));
|
||||||
|
|
||||||
|
// Add a tick callback to drive timed updates via the frame clock.
|
||||||
|
// Redraws are limited to 30 FPS max since our imgui widgets don't
|
||||||
|
// usually need higher frame rates than that.
|
||||||
|
let obj = self.obj();
|
||||||
|
let id = obj.add_tick_callback(|widget, _| {
|
||||||
|
let imp = widget.imp();
|
||||||
|
let frame_time = Duration::from_secs(1) / 30;
|
||||||
|
let should_render = imp
|
||||||
|
.last_render_time
|
||||||
|
.get()
|
||||||
|
.is_none_or(|last| last.elapsed() >= frame_time);
|
||||||
|
if should_render {
|
||||||
|
widget.queue_render();
|
||||||
|
}
|
||||||
|
glib::ControlFlow::Continue
|
||||||
|
});
|
||||||
|
self.tick_callback.replace(Some(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_unrealize(&self, area: >k::GLArea) {
|
||||||
|
// Remove the tick callback if it was registered.
|
||||||
|
if let Some(id) = self.tick_callback.take() {
|
||||||
|
id.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unrealize is not guaranteed to be called with a current GL
|
||||||
|
// context, so we make it current for ImGui cleanup.
|
||||||
|
area.make_current();
|
||||||
|
if let Some(err) = area.error() {
|
||||||
|
log::warn!(
|
||||||
|
"GLArea for Dear ImGui widget failed to realize: {}",
|
||||||
|
err.message()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mut gui) = self.gui.take()
|
||||||
|
&& let Err(err) = gui.renderer.shutdown(&mut gui.ctx)
|
||||||
|
{
|
||||||
|
log::warn!("error shutting down the imgui renderer err={err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_resize(&self, width: i32, height: i32, area: >k::GLArea) {
|
||||||
|
let scale_factor = area.scale_factor();
|
||||||
|
self.with_ctx(|ctx| {
|
||||||
|
// Our display size is always unscaled. We'll do the scaling
|
||||||
|
// in the style instead. This creates crisper looking fonts.
|
||||||
|
let io = ctx.io_mut();
|
||||||
|
io.set_display_size([width as f32, height as f32]);
|
||||||
|
io.set_display_framebuffer_scale([1.0, 1.0]);
|
||||||
|
|
||||||
|
// Scale the style appropriately. Zig builds a fresh default
|
||||||
|
// style and scales it on every resize, the style of a
|
||||||
|
// Dear ImGui context cannot be rebuilt from Rust so we scale
|
||||||
|
// by the change in the factor instead.
|
||||||
|
let scale = scale_factor as f32;
|
||||||
|
let last = self.style_scale.replace(scale);
|
||||||
|
if (scale - last).abs() > f32::EPSILON {
|
||||||
|
ctx.style_mut().scale_all_sizes(scale / last);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn do_render(&self) -> bool {
|
||||||
|
// Update last render time for tick callback throttling.
|
||||||
|
self.last_render_time.set(Some(Instant::now()));
|
||||||
|
|
||||||
|
let Ok(mut guard) = self.gui.try_borrow_mut() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(gui) = guard.as_mut() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup our frame. We render twice because some ImGui behaviors
|
||||||
|
// take multiple renders to process. I don't know how to make this
|
||||||
|
// more efficient.
|
||||||
|
if let Err(err) = self.frame(gui, false) {
|
||||||
|
log::warn!("imgui frame failed err={err}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenGL final render
|
||||||
|
Self::clear(gui);
|
||||||
|
if let Err(err) = self.frame(gui, true) {
|
||||||
|
log::warn!("imgui render failed err={err}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path) = self.pending_screenshot.take() {
|
||||||
|
self.screenshot(gui, &path);
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_focus_enter(&self, _ec: >k::EventControllerFocus) {
|
||||||
|
self.with_ctx(|ctx| ctx.io_mut().add_focus_event(true));
|
||||||
|
self.obj().queue_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_focus_leave(&self, _ec: >k::EventControllerFocus) {
|
||||||
|
self.with_ctx(|ctx| ctx.io_mut().add_focus_event(false));
|
||||||
|
self.obj().queue_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_key_pressed(
|
||||||
|
&self,
|
||||||
|
keyval: gdk::Key,
|
||||||
|
_keycode: u32,
|
||||||
|
state: gdk::ModifierType,
|
||||||
|
ec: >k::EventControllerKey,
|
||||||
|
) -> glib::Propagation {
|
||||||
|
if self.key_event(true, ec, keyval, state) {
|
||||||
|
glib::Propagation::Stop
|
||||||
|
} else {
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_key_released(
|
||||||
|
&self,
|
||||||
|
keyval: gdk::Key,
|
||||||
|
_keycode: u32,
|
||||||
|
state: gdk::ModifierType,
|
||||||
|
ec: >k::EventControllerKey,
|
||||||
|
) {
|
||||||
|
self.key_event(false, ec, keyval, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_mouse_pressed(&self, _n: i32, _x: f64, _y: f64, gesture: >k::GestureClick) {
|
||||||
|
self.mouse_button(gesture, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_mouse_released(&self, _n: i32, _x: f64, _y: f64, gesture: >k::GestureClick) {
|
||||||
|
self.mouse_button(gesture, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_mouse_motion(&self, x: f64, y: f64, _ec: >k::EventControllerMotion) {
|
||||||
|
self.obj().queue_render();
|
||||||
|
let scale = self.scale_factor();
|
||||||
|
self.with_ctx(|ctx| {
|
||||||
|
ctx.io_mut()
|
||||||
|
.add_mouse_pos_event([(x * scale) as f32, (y * scale) as f32]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_scroll(&self, x: f64, y: f64, _ec: >k::EventControllerScroll) -> glib::Propagation {
|
||||||
|
self.obj().queue_render();
|
||||||
|
let handled = self.with_ctx(|ctx| {
|
||||||
|
ctx.io_mut().add_mouse_wheel_event([x as f32, -y as f32]);
|
||||||
|
});
|
||||||
|
if handled {
|
||||||
|
glib::Propagation::Stop
|
||||||
|
} else {
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_im_commit(&self, text: &str, _im: >k::IMMulticontext) {
|
||||||
|
self.obj().queue_render();
|
||||||
|
self.with_ctx(|ctx| ctx.io_mut().add_input_characters_utf8(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gtk::template_callbacks]
|
||||||
|
impl ImguiWidget {
|
||||||
|
#[template_callback]
|
||||||
|
fn realize(area: >k::GLArea, this: &super::ImguiWidget) {
|
||||||
|
this.imp().do_realize(area);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[template_callback]
|
||||||
|
fn unrealize(area: >k::GLArea, this: &super::ImguiWidget) {
|
||||||
|
this.imp().do_unrealize(area);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[template_callback]
|
||||||
|
fn resize(area: >k::GLArea, width: i32, height: i32, this: &super::ImguiWidget) {
|
||||||
|
this.imp().do_resize(width, height, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[template_callback]
|
||||||
|
fn render(area: >k::GLArea, context: &gdk::GLContext, this: &super::ImguiWidget) -> bool {
|
||||||
|
let _ = (area, context);
|
||||||
|
this.imp().do_render()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[template_callback]
|
||||||
|
fn focus_enter(ec: >k::EventControllerFocus, this: &super::ImguiWidget) {
|
||||||
|
this.imp().do_focus_enter(ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[template_callback]
|
||||||
|
fn focus_leave(ec: >k::EventControllerFocus, this: &super::ImguiWidget) {
|
||||||
|
this.imp().do_focus_leave(ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[template_callback]
|
||||||
|
fn key_pressed(
|
||||||
|
ec: >k::EventControllerKey,
|
||||||
|
keyval: gdk::Key,
|
||||||
|
keycode: u32,
|
||||||
|
state: gdk::ModifierType,
|
||||||
|
this: &super::ImguiWidget,
|
||||||
|
) -> glib::Propagation {
|
||||||
|
this.imp().do_key_pressed(keyval, keycode, state, ec)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[template_callback]
|
||||||
|
fn key_released(
|
||||||
|
ec: >k::EventControllerKey,
|
||||||
|
keyval: gdk::Key,
|
||||||
|
keycode: u32,
|
||||||
|
state: gdk::ModifierType,
|
||||||
|
this: &super::ImguiWidget,
|
||||||
|
) {
|
||||||
|
this.imp().do_key_released(keyval, keycode, state, ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[template_callback]
|
||||||
|
fn mouse_pressed(
|
||||||
|
gesture: >k::GestureClick,
|
||||||
|
n: i32,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
this: &super::ImguiWidget,
|
||||||
|
) {
|
||||||
|
this.imp().do_mouse_pressed(n, x, y, gesture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[template_callback]
|
||||||
|
fn mouse_released(
|
||||||
|
gesture: >k::GestureClick,
|
||||||
|
n: i32,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
this: &super::ImguiWidget,
|
||||||
|
) {
|
||||||
|
this.imp().do_mouse_released(n, x, y, gesture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[template_callback]
|
||||||
|
fn mouse_motion(
|
||||||
|
ec: >k::EventControllerMotion,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
this: &super::ImguiWidget,
|
||||||
|
) {
|
||||||
|
this.imp().do_mouse_motion(x, y, ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[template_callback]
|
||||||
|
fn scroll(
|
||||||
|
ec: >k::EventControllerScroll,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
this: &super::ImguiWidget,
|
||||||
|
) -> glib::Propagation {
|
||||||
|
this.imp().do_scroll(x, y, ec)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[template_callback]
|
||||||
|
fn im_commit(im: >k::IMMulticontext, text: &str, this: &super::ImguiWidget) {
|
||||||
|
this.imp().do_im_commit(text, im);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
//! Port of apprt/gtk/class/inspector_widget.zig (`GhosttyInspectorWidget`).
|
||||||
|
//!
|
||||||
|
//! Widget for displaying the Ghostty inspector. Setting its `surface`
|
||||||
|
//! property activates the inspector on that surface's core surface and
|
||||||
|
//! clearing it deactivates it again.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use adw::prelude::*;
|
||||||
|
use adw::subclass::prelude::*;
|
||||||
|
use ghostty_apprt::runtime_api::CoreSurface;
|
||||||
|
use gtk::glib;
|
||||||
|
|
||||||
|
use super::imgui_widget::{ImguiWidget, ImguiWidgetImpl};
|
||||||
|
use super::surface::Surface;
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct InspectorWidget(ObjectSubclass<imp::InspectorWidget>)
|
||||||
|
@extends ImguiWidget, adw::Bin, gtk::Widget,
|
||||||
|
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InspectorWidget {
|
||||||
|
/// Queue a render of the Dear ImGui widget.
|
||||||
|
pub fn queue_render(&self) {
|
||||||
|
self.upcast_ref::<ImguiWidget>().queue_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_surface(&self) -> Option<Surface> {
|
||||||
|
self.imp().surface.upgrade()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug aid: synthesize a left click.
|
||||||
|
pub fn debug_click(&self, x: f64, y: f64) {
|
||||||
|
self.upcast_ref::<ImguiWidget>().debug_click(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug aid: synthesize a mouse wheel scroll.
|
||||||
|
pub fn debug_scroll(&self, x: f64, y: f64, dy: f64) {
|
||||||
|
self.upcast_ref::<ImguiWidget>().debug_scroll(x, y, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug aid: write the next rendered frame to a PNG file.
|
||||||
|
pub fn debug_screenshot(&self, path: &str) {
|
||||||
|
self.upcast_ref::<ImguiWidget>().debug_screenshot(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImguiWidgetImpl for imp::InspectorWidget {}
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
#[allow(clippy::wildcard_imports)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(gtk::CompositeTemplate, glib::Properties, Default)]
|
||||||
|
#[properties(wrapper_type = super::InspectorWidget)]
|
||||||
|
#[template(resource = "/com/mitchellh/ghostty/ui/1.5/inspector-widget.ui")]
|
||||||
|
pub struct InspectorWidget {
|
||||||
|
/// The surface that we are attached to. This is NOT strongly
|
||||||
|
/// referenced (the GObject weak reference is cleared when the surface
|
||||||
|
/// is disposed, which closes our window).
|
||||||
|
pub surface: glib::WeakRef<Surface>,
|
||||||
|
|
||||||
|
/// The core surface of `surface`, kept so that we can deactivate the
|
||||||
|
/// inspector when the surface is going away (the GTK surface only
|
||||||
|
/// drops its core surface when finalized, after the window holding
|
||||||
|
/// this widget was destroyed in its dispose).
|
||||||
|
core: RefCell<Option<Rc<dyn CoreSurface>>>,
|
||||||
|
|
||||||
|
#[property(get = Self::get_surface, set = Self::set_surface, nullable, type = Option<Surface>, name = "surface")]
|
||||||
|
_surface_prop: (),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for InspectorWidget {
|
||||||
|
const NAME: &'static str = "GhosttyInspectorWidget";
|
||||||
|
type Type = super::InspectorWidget;
|
||||||
|
type ParentType = ImguiWidget;
|
||||||
|
|
||||||
|
fn class_init(klass: &mut Self::Class) {
|
||||||
|
ImguiWidget::ensure_type();
|
||||||
|
klass.bind_template();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||||
|
obj.init_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::derived_properties]
|
||||||
|
impl ObjectImpl for InspectorWidget {
|
||||||
|
fn constructed(&self) {
|
||||||
|
self.parent_constructed();
|
||||||
|
|
||||||
|
let obj = self.obj();
|
||||||
|
let weak = obj.downgrade();
|
||||||
|
obj.upcast_ref::<ImguiWidget>().set_hooks(
|
||||||
|
// Called to do initial setup of the UI.
|
||||||
|
Box::new(ghostty_inspector::setup),
|
||||||
|
// Called for every frame to draw the UI.
|
||||||
|
Box::new(move |ui| {
|
||||||
|
let Some(this) = weak.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(surface) = this.imp().surface.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(core) = surface.core() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
core.render_inspector(ui);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispose(&self) {
|
||||||
|
// Clear our surface so it deactivates the inspector.
|
||||||
|
self.set_surface(None);
|
||||||
|
self.dispose_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetImpl for InspectorWidget {}
|
||||||
|
impl BinImpl for InspectorWidget {}
|
||||||
|
|
||||||
|
impl InspectorWidget {
|
||||||
|
fn get_surface(&self) -> Option<Surface> {
|
||||||
|
self.surface.upgrade()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_surface(&self, surface: Option<Surface>) {
|
||||||
|
// Do nothing if we're not changing the value.
|
||||||
|
if surface == self.surface.upgrade() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup our notification to happen at the end because we're
|
||||||
|
// changing values no matter what.
|
||||||
|
let obj = self.obj();
|
||||||
|
let _guard = obj.freeze_notify();
|
||||||
|
obj.notify("surface");
|
||||||
|
|
||||||
|
// Deactivate the inspector on the old surface if it exists and
|
||||||
|
// set our value to null.
|
||||||
|
self.surface.set(None);
|
||||||
|
if let Some(core) = self.core.take() {
|
||||||
|
core.deactivate_inspector();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate the inspector on the new surface.
|
||||||
|
let Some(surface) = surface else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(core) = surface.core() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(err) = core.activate_inspector() {
|
||||||
|
log::warn!("failed to activate inspector err={err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.surface.set(Some(&surface));
|
||||||
|
self.core.replace(Some(core));
|
||||||
|
obj.queue_render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//! Port of apprt/gtk/class/inspector_window.zig (`GhosttyInspectorWindow`).
|
||||||
|
//!
|
||||||
|
//! Window for displaying the Ghostty inspector.
|
||||||
|
|
||||||
|
use adw::prelude::*;
|
||||||
|
use adw::subclass::prelude::*;
|
||||||
|
use gtk::glib;
|
||||||
|
|
||||||
|
use super::debug_warning::DebugWarning;
|
||||||
|
use super::inspector_widget::InspectorWidget;
|
||||||
|
use super::surface::Surface;
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct InspectorWindow(ObjectSubclass<imp::InspectorWindow>)
|
||||||
|
@extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget,
|
||||||
|
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable,
|
||||||
|
gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
use gtk::gio;
|
||||||
|
|
||||||
|
impl InspectorWindow {
|
||||||
|
pub fn new(surface: &Surface) -> Self {
|
||||||
|
crate::register_resources();
|
||||||
|
glib::Object::builder().property("surface", surface).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Present the window.
|
||||||
|
pub fn present_window(&self) {
|
||||||
|
self.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue a render of the embedded widget.
|
||||||
|
pub fn queue_render(&self) {
|
||||||
|
self.imp().inspector_widget.queue_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug aid: synthesize a left click in the inspector.
|
||||||
|
pub fn debug_click(&self, x: f64, y: f64) {
|
||||||
|
self.imp().inspector_widget.debug_click(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug aid: synthesize a mouse wheel scroll in the inspector.
|
||||||
|
pub fn debug_scroll(&self, x: f64, y: f64, dy: f64) {
|
||||||
|
self.imp().inspector_widget.debug_scroll(x, y, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug aid: write the next rendered inspector frame to a PNG file.
|
||||||
|
pub fn debug_screenshot(&self, path: &str) {
|
||||||
|
self.imp().inspector_widget.debug_screenshot(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
#[allow(clippy::wildcard_imports)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(gtk::CompositeTemplate, glib::Properties, Default)]
|
||||||
|
#[properties(wrapper_type = super::InspectorWindow)]
|
||||||
|
#[template(resource = "/com/mitchellh/ghostty/ui/1.5/inspector-window.ui")]
|
||||||
|
pub struct InspectorWindow {
|
||||||
|
/// The surface that we are attached to.
|
||||||
|
#[property(get, set = Self::set_surface, nullable)]
|
||||||
|
pub surface: glib::WeakRef<Surface>,
|
||||||
|
|
||||||
|
/// The embedded inspector widget.
|
||||||
|
#[template_child]
|
||||||
|
pub inspector_widget: TemplateChild<InspectorWidget>,
|
||||||
|
|
||||||
|
/// Whether this is a debug build (shows the debug warning).
|
||||||
|
#[property(get = Self::get_debug, type = bool, name = "debug")]
|
||||||
|
_debug_prop: (),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InspectorWindow {
|
||||||
|
#[allow(clippy::unused_self)]
|
||||||
|
fn get_debug(&self) -> bool {
|
||||||
|
cfg!(debug_assertions)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::needless_pass_by_value)] // property setter signature
|
||||||
|
fn set_surface(&self, surface: Option<Surface>) {
|
||||||
|
self.surface.set(surface.as_ref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for InspectorWindow {
|
||||||
|
const NAME: &'static str = "GhosttyInspectorWindow";
|
||||||
|
type Type = super::InspectorWindow;
|
||||||
|
type ParentType = adw::ApplicationWindow;
|
||||||
|
|
||||||
|
fn class_init(klass: &mut Self::Class) {
|
||||||
|
DebugWarning::ensure_type();
|
||||||
|
InspectorWidget::ensure_type();
|
||||||
|
klass.bind_template();
|
||||||
|
klass.bind_template_callbacks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||||
|
obj.init_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::derived_properties]
|
||||||
|
impl ObjectImpl for InspectorWindow {
|
||||||
|
fn constructed(&self) {
|
||||||
|
self.parent_constructed();
|
||||||
|
let obj = self.obj();
|
||||||
|
|
||||||
|
// Add our dev CSS class if we're in debug mode.
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
obj.add_css_class("devel");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set our window icon. We can't set this in the blueprint file
|
||||||
|
// because its dependent on the build config.
|
||||||
|
obj.set_icon_name(Some(crate::APPLICATION_ID));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispose(&self) {
|
||||||
|
// You MUST clear all weak refs in dispose, otherwise it causes
|
||||||
|
// memory corruption on dispose on the TARGET (weak referenced)
|
||||||
|
// object (Zig comment).
|
||||||
|
self.set_surface(None);
|
||||||
|
self.dispose_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetImpl for InspectorWindow {}
|
||||||
|
impl WindowImpl for InspectorWindow {}
|
||||||
|
impl ApplicationWindowImpl for InspectorWindow {}
|
||||||
|
impl AdwApplicationWindowImpl for InspectorWindow {}
|
||||||
|
|
||||||
|
#[gtk::template_callbacks]
|
||||||
|
impl InspectorWindow {
|
||||||
|
/// `notify::surface` of the inspector widget: if the inspector's
|
||||||
|
/// surface went away, we destroy the window.
|
||||||
|
#[template_callback]
|
||||||
|
fn notify_inspector_surface(
|
||||||
|
inspector: &InspectorWidget,
|
||||||
|
_pspec: &glib::ParamSpec,
|
||||||
|
this: &super::InspectorWindow,
|
||||||
|
) {
|
||||||
|
if inspector.get_surface().is_none() {
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
//! The terminal inspector hooks of the surface (`Surface.zig`:
|
||||||
|
//! `activateInspector`, `deactivateInspector` and the inspector parts of the
|
||||||
|
//! key and mouse callbacks).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use ghostty_apprt::runtime_api::CoreError;
|
||||||
|
use ghostty_inspector::Shared as Inspector;
|
||||||
|
use ghostty_inspector::imgui::Ui;
|
||||||
|
use ghostty_inspector::widgets::surface::SurfaceInfo;
|
||||||
|
use ghostty_renderer::message::Message as RendererMessage;
|
||||||
|
use ghostty_renderer::state::InspectorHook;
|
||||||
|
use ghostty_termio::message::Message as IoMessage;
|
||||||
|
|
||||||
|
use super::Surface;
|
||||||
|
|
||||||
|
impl Surface {
|
||||||
|
/// The active inspector, if any.
|
||||||
|
pub fn inspector(&self) -> Option<Arc<Inspector>> {
|
||||||
|
self.inspector.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate the inspector. This will begin collecting inspection data.
|
||||||
|
/// This will not affect the GUI. The GUI must use `perform_action` to
|
||||||
|
/// show/hide the inspector UI.
|
||||||
|
pub fn activate_inspector(&self) -> Result<(), CoreError> {
|
||||||
|
if self.inspector.borrow().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup the inspector
|
||||||
|
let insp = Arc::new(Inspector::new());
|
||||||
|
*self.inspector.borrow_mut() = Some(Arc::clone(&insp));
|
||||||
|
|
||||||
|
// Put the inspector onto the render state
|
||||||
|
{
|
||||||
|
let mut st = self.lock_state();
|
||||||
|
debug_assert!(st.inspector.is_none());
|
||||||
|
st.inspector = Some(insp as Arc<dyn InspectorHook>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify our components we have an inspector active
|
||||||
|
self.send_renderer(RendererMessage::Inspector(true));
|
||||||
|
self.queue_io(IoMessage::Inspector(true));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deactivate the inspector and stop collecting any information.
|
||||||
|
pub fn deactivate_inspector(&self) {
|
||||||
|
if self.inspector.borrow().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the inspector from the render state
|
||||||
|
{
|
||||||
|
let mut st = self.lock_state();
|
||||||
|
debug_assert!(st.inspector.is_some());
|
||||||
|
st.inspector = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify our components we have deactivated inspector
|
||||||
|
self.send_renderer(RendererMessage::Inspector(false));
|
||||||
|
self.queue_io(IoMessage::Inspector(false));
|
||||||
|
|
||||||
|
// Drop the inspector
|
||||||
|
*self.inspector.borrow_mut() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the inspector UI into the current Dear ImGui frame
|
||||||
|
/// (`Inspector.render`). Does nothing if the inspector isn't active.
|
||||||
|
pub fn render_inspector(&self, ui: &Ui) {
|
||||||
|
let Some(insp) = self.inspector() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The information about the surface that the widgets show.
|
||||||
|
let info = {
|
||||||
|
let font_size = self.font.borrow().size;
|
||||||
|
let m = self.mouse.borrow();
|
||||||
|
let mut click_pressed = [false; 12];
|
||||||
|
for (i, state) in m.click_state.iter().enumerate().take(click_pressed.len()) {
|
||||||
|
click_pressed[i] = *state == ghostty_input::MouseButtonState::Press;
|
||||||
|
}
|
||||||
|
(
|
||||||
|
self.size.get(),
|
||||||
|
font_size,
|
||||||
|
click_pressed,
|
||||||
|
m.left_click_pin,
|
||||||
|
m.left_click_screen,
|
||||||
|
m.left_click_xpos,
|
||||||
|
m.left_click_ypos,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
// The renderer mutex protects the terminal; lock order is the
|
||||||
|
// render state first, then the inspector.
|
||||||
|
let mut guard = self.lock_terminal();
|
||||||
|
let (size, font_size, click_pressed, pin, pin_screen, xpos, ypos) = info;
|
||||||
|
let left_click_pin = if pin_screen == guard.screens.active_key {
|
||||||
|
pin.and_then(|h| guard.screens.active().pages.tracked_pin(h))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let surface = SurfaceInfo {
|
||||||
|
size,
|
||||||
|
font_size_points: font_size.points,
|
||||||
|
font_size_pixels: font_size.pixels(),
|
||||||
|
click_pressed,
|
||||||
|
left_click_pin,
|
||||||
|
left_click_xpos: xpos,
|
||||||
|
left_click_ypos: ypos,
|
||||||
|
};
|
||||||
|
|
||||||
|
insp.lock().render(ui, &surface, &mut guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We always trigger a rebuild of the surface when the inspector
|
||||||
|
// is focused because modifying the inspector can change the terminal
|
||||||
|
// state. This is KIND OF expensive (wasted CPU if nothing was done)
|
||||||
|
// but the inspector is a development tool and it expressly costs
|
||||||
|
// more resources while open so its okay.
|
||||||
|
self.queue_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the mouse position for the inspector (the inspector block of
|
||||||
|
/// `cursorPosCallback`).
|
||||||
|
pub(crate) fn inspector_record_cursor_pos(&self, xpos: f64, ypos: f64) {
|
||||||
|
let Some(insp) = self.inspector() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pos_vp = self.pos_to_viewport(xpos, ypos);
|
||||||
|
let pin = self
|
||||||
|
.lock_terminal()
|
||||||
|
.screens
|
||||||
|
.active()
|
||||||
|
.pages
|
||||||
|
.pin(ghostty_terminal::point::Point::Viewport(pos_vp));
|
||||||
|
{
|
||||||
|
let mut guard = insp.lock();
|
||||||
|
guard.mouse.last_xpos = xpos;
|
||||||
|
guard.mouse.last_ypos = ypos;
|
||||||
|
guard.mouse.last_point = pin;
|
||||||
|
}
|
||||||
|
self.queue_render();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! The terminal inspector hooks of the surface: activation, key event
|
||||||
|
//! recording (with the pty encoding and triggered bindings) and VT event
|
||||||
|
//! recording while data is read from the pty.
|
||||||
|
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::assert_is_empty)]
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
use ghostty_input::Key;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspector_records_key_events_with_pty_data_and_bindings() {
|
||||||
|
let h = harness!(Harness::recorder(
|
||||||
|
"",
|
||||||
|
&["--keybind=ctrl+shift+c=scroll_page_up"]
|
||||||
|
));
|
||||||
|
h.surface.activate_inspector().unwrap();
|
||||||
|
let insp = h.surface.inspector().expect("active inspector");
|
||||||
|
|
||||||
|
h.key(Key::KeyA, none(), "a");
|
||||||
|
h.key(Key::KeyC, ctrl_shift(), "C");
|
||||||
|
h.expect_input(b"a");
|
||||||
|
|
||||||
|
let g = insp.lock();
|
||||||
|
let events = &g.gui.key_stream.events;
|
||||||
|
// A press for each key.
|
||||||
|
let press_a = events
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.event.key == Key::KeyA && e.event.action == ghostty_input::Action::Press)
|
||||||
|
.expect("press a");
|
||||||
|
assert_eq!(press_a.pty, b"a");
|
||||||
|
assert_eq!(press_a.event.utf8, "a");
|
||||||
|
assert!(press_a.binding.is_empty());
|
||||||
|
|
||||||
|
let press_c = events
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.event.key == Key::KeyC && e.event.action == ghostty_input::Action::Press)
|
||||||
|
.expect("press ctrl+shift+c");
|
||||||
|
assert!(
|
||||||
|
press_c
|
||||||
|
.binding
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.key().name() == "scroll_page_up"),
|
||||||
|
"binding recorded: {:?}",
|
||||||
|
press_c.binding
|
||||||
|
);
|
||||||
|
// The consumed binding was not encoded to the pty.
|
||||||
|
assert!(press_c.pty.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspector_records_vt_events_read_from_the_pty() {
|
||||||
|
let script = "stty raw -echo; printf READY; dd bs=1 count=1 >/dev/null 2>&1; \
|
||||||
|
printf '\\033[31mX\\033[0m\\033]0;title\\007'; printf DONE; exec cat >'{out}'";
|
||||||
|
let h = harness!(Harness::new(Opts::default().script(script)));
|
||||||
|
h.wait_screen("READY");
|
||||||
|
|
||||||
|
h.surface.activate_inspector().unwrap();
|
||||||
|
let insp = h.surface.inspector().expect("active inspector");
|
||||||
|
{
|
||||||
|
// The overlay features come from the inspector's renderer info.
|
||||||
|
let mut g = insp.lock();
|
||||||
|
assert!(g.renderer_info().features.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
h.type_text("a");
|
||||||
|
h.wait_screen("DONE");
|
||||||
|
|
||||||
|
let g = insp.lock();
|
||||||
|
let descs: Vec<String> = g
|
||||||
|
.gui
|
||||||
|
.vt_stream
|
||||||
|
.events()
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.raw_description.clone())
|
||||||
|
.collect();
|
||||||
|
assert!(descs.iter().any(|d| d == "31 m"), "events: {descs:?}");
|
||||||
|
assert!(descs.iter().any(|d| d == "0 m"), "events: {descs:?}");
|
||||||
|
assert!(
|
||||||
|
descs.iter().any(|d| d == "change_window_title "),
|
||||||
|
"events: {descs:?}"
|
||||||
|
);
|
||||||
|
drop(g);
|
||||||
|
|
||||||
|
// Deactivating drops the inspector and removes it from the render state.
|
||||||
|
h.surface.deactivate_inspector();
|
||||||
|
assert!(h.surface.inspector().is_none());
|
||||||
|
assert!(h.surface.lock_state().inspector.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspector_overlay_features_reach_the_render_state() {
|
||||||
|
use ghostty_renderer::overlay::Feature;
|
||||||
|
|
||||||
|
let h = harness!(Harness::recorder("", &[]));
|
||||||
|
h.surface.activate_inspector().unwrap();
|
||||||
|
let insp = h.surface.inspector().expect("active inspector");
|
||||||
|
insp.lock()
|
||||||
|
.renderer_info()
|
||||||
|
.features
|
||||||
|
.push(Feature::HighlightHyperlinks);
|
||||||
|
|
||||||
|
let st = h.surface.lock_state();
|
||||||
|
let hook = st.inspector.as_ref().expect("inspector in render state");
|
||||||
|
assert_eq!(hook.overlay_features(), vec![Feature::HighlightHyperlinks]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activating_twice_is_a_no_op() {
|
||||||
|
let h = harness!(Harness::recorder("", &[]));
|
||||||
|
h.surface.activate_inspector().unwrap();
|
||||||
|
let first = h.surface.inspector().unwrap();
|
||||||
|
h.surface.activate_inspector().unwrap();
|
||||||
|
assert!(std::sync::Arc::ptr_eq(
|
||||||
|
&first,
|
||||||
|
&h.surface.inspector().unwrap()
|
||||||
|
));
|
||||||
|
h.surface.deactivate_inspector();
|
||||||
|
// Deactivating when there is none is fine, too.
|
||||||
|
h.surface.deactivate_inspector();
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
[package]
|
||||||
|
name = "ghostty-inspector"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dear-imgui-rs = "0.19"
|
||||||
|
ghostty-font.workspace = true
|
||||||
|
ghostty-input.workspace = true
|
||||||
|
ghostty-renderer.workspace = true
|
||||||
|
ghostty-terminal.workspace = true
|
||||||
|
ghostty-vt.workspace = true
|
||||||
|
log = "0.4"
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "deny"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
|
todo = "warn"
|
||||||
|
unwrap_used = "warn"
|
||||||
|
# Porting a low-level terminal emulator involves a lot of intentional
|
||||||
|
# numeric casts and long state-machine functions; these pedantic lints are
|
||||||
|
# noise for this codebase.
|
||||||
|
cast_possible_truncation = "allow"
|
||||||
|
cast_possible_wrap = "allow"
|
||||||
|
cast_sign_loss = "allow"
|
||||||
|
cast_precision_loss = "allow"
|
||||||
|
cast_lossless = "allow"
|
||||||
|
module_name_repetitions = "allow"
|
||||||
|
too_many_lines = "allow"
|
||||||
|
missing_errors_doc = "allow"
|
||||||
|
missing_panics_doc = "allow"
|
||||||
|
similar_names = "allow"
|
||||||
|
struct_excessive_bools = "allow"
|
||||||
|
fn_params_excessive_bools = "allow"
|
||||||
|
match_same_arms = "allow"
|
||||||
|
unreadable_literal = "allow"
|
||||||
|
many_single_char_names = "allow"
|
||||||
|
doc_markdown = "allow"
|
||||||
|
must_use_candidate = "allow"
|
||||||
|
return_self_not_must_use = "allow"
|
||||||
|
used_underscore_binding = "allow"
|
||||||
|
large_stack_arrays = "allow"
|
||||||
|
inline_always = "allow"
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//! Unit helpers (ZIG/src/inspector/units.zig).
|
||||||
|
|
||||||
|
pub fn to_kibi_bytes(bytes: usize) -> usize {
|
||||||
|
bytes / 1024
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# ghostty-inspector (agent I1): terminal inspector
|
||||||
|
|
||||||
|
Ports `ZIG/src/inspector/*` (Inspector, widgets/{key,page,pagelist,renderer,screen,style,surface,terminal,termio}, units),
|
||||||
|
the GTK classes `imgui_widget`, `inspector_widget`, `inspector_window` (+ their `.blp`), and the core Surface hooks.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
`dear-imgui-rs` 0.19 (Dear ImGui 1.92.9b docking branch, builds fine with rustc 1.99) + `dear-imgui-glow` 0.19 (glow 0.18, the
|
||||||
|
same glow as the renderer). imgui-rs 0.12 was not used: it has no safe docking/DockBuilder and an old ImGui. The safe API of
|
||||||
|
`dear-imgui-rs` is strict (it panics on API misuse, e.g. table phases, dockspace ordering), which the headless test below exercises.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
* `crates/ghostty-inspector` (`unsafe_code = "deny"`)
|
||||||
|
* `inspector.rs`: `Inspector` (`mouse`, `gui`), `setup(ctx, scale)`, `Shared` (= `Mutex<Inspector>` newtype implementing
|
||||||
|
`ghostty_renderer::state::InspectorHook`).
|
||||||
|
* `widgets/*.rs`: 1:1 with the Zig widget files (`surface.rs` has the dockspace + Surface info + `SurfaceInfo` input struct).
|
||||||
|
* `keys.rs`: `tag_name` (Zig `@tagName` from `Debug`), `imgui_key` (Zig `Key.imguiKey`).
|
||||||
|
* `dock.rs`: the ONLY `unsafe` module (`#![allow(unsafe_code)]`, all SAFETY-commented): `DockBuilder` (node create/split/dock),
|
||||||
|
`SetNextWindowSize`, `SetNextItemAllowOverlap`, and a raw `Begin`/`End` scope (`begin_window`) so that widgets still run when
|
||||||
|
`Begin` returned false, like Zig's `Begin` + `defer End`.
|
||||||
|
* `ghostty-renderer::state`: `State.overlay_features` replaced by `State.inspector: Option<Arc<dyn InspectorHook>>` (Zig
|
||||||
|
`inspector: ?*Inspector`); `generic.rs` reads `overlay_features()` from it each frame. The trait breaks the crate cycle.
|
||||||
|
* `ghostty-termio`: `Output::process_output` goes into Zig's "slow mode" while an inspector is set (`record_pty_read` per byte, then
|
||||||
|
`process_byte`).
|
||||||
|
* `ghostty-core` (`surface/inspector.rs`): `activate_inspector` / `deactivate_inspector` (render-state pointer + renderer and IO
|
||||||
|
`Inspector(bool)` messages), `render_inspector(ui)` (locks terminal then inspector, fills `SurfaceInfo`, wakes the renderer),
|
||||||
|
key event recording in `keyboard.rs` (event created after key remapping, `binding` set in the consumed branch, `pty` copied in
|
||||||
|
`encode_key`, recorded + `queue_render` when done, only if the inspector is still active), mouse recording in
|
||||||
|
`mouse.rs` (cursor pos, `queue_render` on buttons). The `inspector` binding action already forwarded `RtAction::Inspector`.
|
||||||
|
* `ghostty-apprt` `CoreSurface`: new `render_inspector(&Ui)` next to `activate/deactivate_inspector`.
|
||||||
|
* `ghostty-apprt-gtk`: `class/{imgui_widget,inspector_widget,inspector_window}.rs`, `resources/ui/1.5/{imgui-widget,inspector-widget,
|
||||||
|
inspector-window}.blp` (verbatim from Zig), `Surface::control_inspector` (toggle/show/hide, weak ref to the window) and
|
||||||
|
`redraw_inspector`, `Window::toggle_inspector` (`win.toggle-inspector`, ctrl+shift+i). Unsafe only for `glow::Context::from_loader_function`
|
||||||
|
(epoxy loader) and the GL read-back of the debug screenshot, both `#[allow(unsafe_code)]` + SAFETY.
|
||||||
|
|
||||||
|
## GTK input/rendering (imgui_widget)
|
||||||
|
GLArea + `IMMulticontext` as in Zig: focus/key/click/motion/scroll/IM-commit controllers feed `Io` (modifier keys as LeftShift/Ctrl/Alt/Super
|
||||||
|
events, keyval -> `imgui_key`), 30 FPS tick callback, two ImGui frames per GL render (first one only `end_frame`), then clear + draw.
|
||||||
|
Zig clears with `0x28/0xFF` which is integer 0, so the background is black; kept.
|
||||||
|
|
||||||
|
## Debug / verification
|
||||||
|
`GHOSTTY_DEBUG_INPUT_SCRIPT` got `inspector-screenshot <png>`, `inspector-click <x> <y>`, `inspector-scroll <x> <y> <dy>`
|
||||||
|
(`debug_input.rs`). Inspector frames are rendered synchronously for these steps because the compositor does not send frame
|
||||||
|
callbacks to an off-screen window (without this the screenshot lags one event behind). Run:
|
||||||
|
`GSETTINGS_BACKEND=memory GHOSTTY_DEBUG_INPUT_SCRIPT=script.txt timeout 25 ./target/debug/ghostty -e sh` with
|
||||||
|
`key ctrl+shift+i`, `wait 2500`, `inspector-click 126 11`, `inspector-screenshot /abs/x.png`, ...
|
||||||
|
Verified in the real app on Wayland (screenshots viewed): all tabs (Terminal with screens/Misc/Layout/Mouse/Color/Modes tables, Surface,
|
||||||
|
Keyboard event table with pty encoding, Terminal IO VT log, Renderer overlays, ImGui demo), screen pop-out window with Info/Cell/PageList
|
||||||
|
docking (cell picking resolves the cell at (0,0)), detach of a header into a docked window, toggle open/close/reopen, closing a surface
|
||||||
|
and a tab while its inspector is open (no warnings, core deactivates in the surface's `dispose`).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
* ghostty-inspector: 15 unit tests (Zig has none for this package): label/pty/utf8 formatting, ring bound, hsv/key colour, text filter
|
||||||
|
(ImGuiTextFilter semantics), OSC/CSI/ESC/Execute encodings and event recording through a real `Stream`, features toggle, snake_case,
|
||||||
|
imgui key map, mouse button labels, plus `draws_frames_headless`: draws 8 frames of the whole inspector headlessly (all windows, pop-out
|
||||||
|
screen window, palette window, detached headers, selected VT event) to catch misuse of the strict imgui API.
|
||||||
|
* ghostty-core `tests/e2e_inspector.rs`: 4 tests (key events with pty data + binding, VT events from a real child, overlay features
|
||||||
|
reach the render state, activate/deactivate semantics).
|
||||||
|
* apprt-gtk `debug_input` parse test extended.
|
||||||
|
Skipped: none (Zig package has no tests).
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
* Font size is `16 * scale_factor` (Zig hardcodes `16 * 2` with a TODO). The imgui style is scaled by the change of the factor
|
||||||
|
(a `Style` cannot be re-created from Rust like Zig's `ImGuiStyle_ImGuiStyle`).
|
||||||
|
* `GhosttyImguiWidget` virtual methods (`setup`/`render`) are hooks installed with `ImguiWidget::set_hooks` (gtk-rs class-struct
|
||||||
|
boilerplate avoided); the widget hierarchy `InspectorWidget -> ImguiWidget -> AdwBin` is real.
|
||||||
|
* The inspector widget keeps an `Rc<dyn CoreSurface>` and the GTK Surface destroys its inspector window in `dispose` (Zig used a
|
||||||
|
GObject weak notify to call `deactivateInspector` while the core surface is alive).
|
||||||
|
* DockBuilder splits requested by the pop-out headers (`DetachableHeader`, `detachable_header`) are queued and applied after all windows
|
||||||
|
of the frame ended (`dock::flush_splits`): doing the split while the parent window is being built re-parents its dock node and trips
|
||||||
|
Dear ImGui's "Must call EndChild() and not End()" assertion. The popped-out window therefore docks one frame later.
|
||||||
|
* Popups: the VT filter popup uses a Rust port of `ImGuiTextFilter` (`TextFilter`, same Build/PassFilter semantics) because the safe wrapper's
|
||||||
|
type holds a raw pointer (not `Send`) and the inspector is shared with the IO thread.
|
||||||
|
* VT events: `Parser.Action.format` for DCS/APC is approximated (`parser.Parser.Action{ .dcs_hook = ... }`); the OSC metadata for
|
||||||
|
`color_operation.requests` / `kitty_color_protocol.list` print the Zig container type name; metadata keeps insertion order (Zig hash map order).
|
||||||
|
`vt_raw` only sees parser-driven actions (CSI/ESC/OSC/DCS/APC), exactly like Zig: printable text and C0 controls in the ground state
|
||||||
|
are dispatched straight to the handler, so they never appear in the log.
|
||||||
|
* The recorded cursor is a `CursorSnapshot` (displayed fields only) instead of a copy of the whole cursor struct.
|
||||||
|
* Added accessors in ghostty-terminal: `Page::string_used_bytes` / `string_capacity_bytes`, `PageList::viewport_pin_handle`.
|
||||||
|
* ImGui demo window is shown when `debug_assertions` (Zig: `builtin.mode == .Debug`); the workspace release profile keeps debug assertions,
|
||||||
|
so it is also shown there.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
* No unit tests can drive real GTK events; clicks/scroll are only covered by the debug replay.
|
||||||
|
* Fractional scale factors are rounded by GTK's integer `scale_factor`.
|
||||||
Reference in new issue
Block a user