//! Scroll-diagnostics HUD — the in-pager "scroll playground". //! //! A compact top-right overlay painting a per-frame snapshot of the scroll //! state machine ([`MouseScrollState::debug_snapshot`]) plus the active //! scrollback's viewport facts, inside a REAL session with the REAL event //! loop. Recipe: `KIGI_FPS=1 KIGI_SCROLL_DEBUG=1 kigi --resume `, //! then flip `scroll_mode` / `scroll_lines` / `invert_scroll` / //! `scroll_speed` in `/settings` to compare variants live. For event-exact //! capture beyond this per-frame sampling, add `KIGI_SCROLL_LOG=1` — the //! JSONL flight recorder ([`crate::input::scroll_log`]). //! //! Invariant: the HUD must never affect scroll behavior. The snapshot is //! read-only (`&self`, caller-supplied `now`), taken in the draw path after //! all input/tick state updates for the frame, and rendering only paints //! buffer cells. Disabled cost is a single bool check per frame. //! //! Unlike the FPS overlay (`render::frame_metrics`, debug/dev builds only), this //! compiles into release builds behind its runtime gate (the hidden-command //! precedent, e.g. `/gboom`): dev instrumentation alters the frame pipeline //! (phase timings through `draw_frame`), so a dev-only HUD could not probe //! the production render path — defeating the zero-fidelity-gap goal — and //! the pty e2e suite runs against production-featured binaries. use ratatui::buffer::Buffer; use ratatui::layout::Rect; use crate::input::mouse::ScrollDebugSnapshot; /// Panel width in cells; each line is padded/truncated to this. const PANEL_WIDTH: u16 = 46; /// Runtime enablement for the HUD. Mirrors `FrameMetrics`' env machinery: /// `KIGI_SCROLL_DEBUG` (nonempty and not `"0"`) enables at startup, and the /// hidden `/scroll-debug` command toggles it live. Deliberately NOT a /// settings-registry entry: it is a diagnostic, not a preference to persist. pub struct ScrollDebugHud { enabled: bool, } impl Default for ScrollDebugHud { fn default() -> Self { Self::new() } } impl ScrollDebugHud { pub fn new() -> Self { let env_on = std::env::var("KIGI_SCROLL_DEBUG").is_ok_and(|v| !v.is_empty() && v != "0"); Self { enabled: env_on } } pub fn enabled(&self) -> bool { self.enabled } /// `/scroll-debug` runtime toggle. pub fn toggle(&mut self) { self.enabled = !self.enabled; } } /// Scrollback-side facts for the `view:` row (`None` off agent views). #[derive(Clone, Copy, Debug)] pub struct ViewportDebug { pub scroll_offset: usize, pub max_offset: usize, pub total_height: usize, pub follow_mode: bool, pub at_bottom: bool, } /// Owned per-frame render params, assembled by `AppView::draw` BEFORE the /// frame closure (after all scroll-state updates; borrow-splitting keeps the /// closure free of `self.scroll_state`). pub struct ScrollDebugPanel { pub snapshot: ScrollDebugSnapshot, pub view: Option, /// Rows left free for FPS overlays stacked above (the dev `KIGI_FPS` /// line and/or the release-safe `/debug fps` HUD). pub top_offset: u16, } impl ScrollDebugPanel { /// Paint the panel in the top-right corner of `area`. Per-frame /// formatting is fine for a debug tool; nothing here outlives the frame. pub fn render(&self, area: Rect, buf: &mut Buffer) { let s = &self.snapshot; let yn = |b: bool| if b { "y" } else { "n" }; let ctx = crate::terminal::terminal_context(); let mut lines: Vec = Vec::with_capacity(10); lines.push("scroll debug (/scroll-debug)".to_string()); lines.push(format!("term:{} mux:{}", ctx.brand, ctx.multiplexer)); lines.push(format!( "mode:{} inv:{} speed:x{:.2}", s.mode.label(), yn(s.invert), s.speed_multiplier )); lines.push(format!( "ept:{} lpt:{}/{} vp:{} cap:{} cad:{}ms", s.events_per_tick, s.wheel_lines_per_tick, s.trackpad_lines_per_tick, s.viewport_height, s.flush_cap, s.cadence_ms )); match &s.stream { Some(st) => { lines.push(format!( "stream:live kind:{}{} ev:{}", st.kind, if st.promoted { "*" } else { "" }, st.events )); lines.push(format!( "avg:{} accel:x{:.2} gap:{}ms", st.avg_interval_ms .map_or_else(|| "-".to_string(), |ms| format!("{ms:.1}ms")), st.accel, st.gap_remaining_ms )); lines.push(format!( "desired:{:+.1} applied:{:+} backlog:{:+}", st.desired_lines, st.applied_lines, st.backlog )); } None => { lines.push("stream:- kind:- ev:-".to_string()); lines.push("avg:- accel:- gap:-".to_string()); lines.push("desired:- applied:- backlog:-".to_string()); } } lines.push(format!( "carry:{:+.2} flush:{}ms clock:{}", s.carry_lines, s.ms_since_flush, s.next_deadline_ms .map_or_else(|| "-".to_string(), |ms| format!("{ms}ms")), )); lines.push(s.last_stream.as_ref().map_or_else( || "last:-".to_string(), |l| format!("last:{} ev:{} ln:{:+}", l.kind, l.events, l.applied_lines), )); if let Some(v) = &self.view { lines.push(format!( "view:{}/{} h:{} follow:{} bot:{}", v.scroll_offset, v.max_offset, v.total_height, yn(v.follow_mode), yn(v.at_bottom) )); } let line_refs: Vec<&str> = lines.iter().map(String::as_str).collect(); super::debug_style::render_panel(area, buf, self.top_offset, PANEL_WIDTH, &line_refs); } } #[cfg(test)] mod tests { use super::*; use ratatui::style::{Color, Modifier, Style}; fn snapshot() -> ScrollDebugSnapshot { ScrollDebugSnapshot { stream: None, last_stream: None, carry_lines: 0.0, ms_since_flush: 0, next_deadline_ms: None, mode: crate::input::mouse::ScrollInputMode::Auto, events_per_tick: 3, wheel_lines_per_tick: 3, trackpad_lines_per_tick: 1, invert: false, speed_multiplier: 1.0, viewport_height: 40, flush_cap: 120, cadence_ms: 16, } } /// The Oscura Midnight regression: the panel must paint the explicit /// debug chrome — bg black, white/yellow fg, no inherited modifiers — /// on EVERY cell of its rect, trailing padding included, regardless of /// the themed cells underneath. #[test] fn panel_paints_theme_agnostic_style_over_every_cell() { let area = Rect::new(0, 0, 60, 14); let mut buf = Buffer::empty(area); // Mimic a themed frame: near-black RGB bg (Oscura Midnight base is // #030304), tinted fg, and a modifier on every cell — everything // the overlay must override. let theme = Style::default() .fg(Color::Rgb(228, 228, 228)) .bg(Color::Rgb(3, 3, 4)) .add_modifier(Modifier::ITALIC); buf.set_style(area, theme); let panel = ScrollDebugPanel { snapshot: snapshot(), view: Some(ViewportDebug { scroll_offset: 5, max_offset: 10, total_height: 50, follow_mode: true, at_bottom: false, }), top_offset: 0, }; panel.render(area, &mut buf); // 10 lines with a `view:` row; the panel hugs the right edge. let x0 = area.width - PANEL_WIDTH; for y in 0..10u16 { for x in x0..area.width { let cell = &buf[(x, y)]; assert_eq!( cell.bg, Color::Black, "cell ({x},{y}) bg must be explicit black, got {:?}", cell.bg ); assert!( cell.fg == Color::White || cell.fg == Color::Yellow, "cell ({x},{y}) fg must be debug chrome, got {:?}", cell.fg ); assert_eq!( cell.modifier, Modifier::empty(), "cell ({x},{y}) must shed themed modifiers" ); } } // The overlay is rect-scoped: cells outside it keep the theme. assert_eq!(buf[(0, 0)].bg, Color::Rgb(3, 3, 4)); assert_eq!(buf[(x0 - 1, 3)].modifier, Modifier::ITALIC); } }