Skip to main content

ftui_widgets/
inspector.rs

1#![forbid(unsafe_code)]
2
3//! UI Inspector overlay for debugging widget trees and hit-test regions.
4//!
5//! The inspector visualizes:
6//! - Hit regions with colored overlays
7//! - Widget boundaries with colored borders
8//! - Widget names and metadata
9//!
10//! # Usage
11//!
12//! ```ignore
13//! use ftui_widgets::inspector::{InspectorMode, InspectorState, InspectorOverlay};
14//!
15//! // In your app state
16//! let mut inspector = InspectorState::default();
17//!
18//! // Toggle with F12
19//! if key == KeyCode::F12 {
20//!     inspector.toggle();
21//! }
22//!
23//! // Render overlay after all widgets
24//! if inspector.is_active() {
25//!     InspectorOverlay::new(&inspector).render(area, frame);
26//! }
27//! ```
28//!
29//! See `docs/specs/ui-inspector.md` for the full specification.
30
31use ftui_core::geometry::Rect;
32use ftui_render::cell::{Cell, PackedRgba};
33use ftui_render::frame::{Frame, HitCell, HitData, HitId, HitRegion};
34use ftui_text::display_width;
35
36use crate::{Widget, draw_text_span, set_style_area};
37use ftui_style::Style;
38use std::io::Write;
39use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
40use web_time::Instant;
41
42#[cfg(feature = "tracing")]
43use tracing::{info_span, trace};
44
45// =============================================================================
46// Diagnostics + Telemetry (bd-17h9.8)
47// =============================================================================
48
49/// Global diagnostic enable flag (checked once at startup).
50static INSPECTOR_DIAGNOSTICS_ENABLED: AtomicBool = AtomicBool::new(false);
51/// Global monotonic event counter for deterministic ordering.
52static INSPECTOR_EVENT_COUNTER: AtomicU64 = AtomicU64::new(0);
53
54/// Initialize diagnostic settings from environment.
55pub fn init_diagnostics() {
56    let enabled = std::env::var("FTUI_INSPECTOR_DIAGNOSTICS")
57        .map(|v| v.eq_ignore_ascii_case("true"))
58        .unwrap_or(false);
59    INSPECTOR_DIAGNOSTICS_ENABLED.store(enabled, Ordering::Relaxed);
60}
61
62/// Check if diagnostics are enabled.
63#[inline]
64pub fn diagnostics_enabled() -> bool {
65    INSPECTOR_DIAGNOSTICS_ENABLED.load(Ordering::Relaxed)
66}
67
68/// Set diagnostics enabled state (for testing).
69pub fn set_diagnostics_enabled(enabled: bool) {
70    INSPECTOR_DIAGNOSTICS_ENABLED.store(enabled, Ordering::Relaxed);
71}
72
73/// Get next monotonic event sequence number.
74#[inline]
75fn next_event_seq() -> u64 {
76    INSPECTOR_EVENT_COUNTER.fetch_add(1, Ordering::Relaxed)
77}
78
79/// Reset event counter (for testing determinism).
80pub fn reset_event_counter() {
81    INSPECTOR_EVENT_COUNTER.store(0, Ordering::Relaxed);
82}
83
84/// Check if deterministic mode is enabled.
85pub fn is_deterministic_mode() -> bool {
86    std::env::var("FTUI_INSPECTOR_DETERMINISTIC")
87        .map(|v| v.eq_ignore_ascii_case("true"))
88        .unwrap_or(false)
89}
90
91/// Diagnostic event types for JSONL logging.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum DiagnosticEventKind {
94    /// Inspector toggled on/off.
95    InspectorToggled,
96    /// Inspector mode changed.
97    ModeChanged,
98    /// Hover position changed.
99    HoverChanged,
100    /// Selection changed.
101    SelectionChanged,
102    /// Detail panel toggled.
103    DetailPanelToggled,
104    /// Hit region visibility toggled.
105    HitsToggled,
106    /// Widget bounds visibility toggled.
107    BoundsToggled,
108    /// Widget name labels toggled.
109    NamesToggled,
110    /// Render time labels toggled.
111    TimesToggled,
112    /// Widgets cleared for a new frame.
113    WidgetsCleared,
114    /// Widget registered for inspection.
115    WidgetRegistered,
116}
117
118impl DiagnosticEventKind {
119    /// Get the JSONL event type string.
120    pub const fn as_str(self) -> &'static str {
121        match self {
122            Self::InspectorToggled => "inspector_toggled",
123            Self::ModeChanged => "mode_changed",
124            Self::HoverChanged => "hover_changed",
125            Self::SelectionChanged => "selection_changed",
126            Self::DetailPanelToggled => "detail_panel_toggled",
127            Self::HitsToggled => "hits_toggled",
128            Self::BoundsToggled => "bounds_toggled",
129            Self::NamesToggled => "names_toggled",
130            Self::TimesToggled => "times_toggled",
131            Self::WidgetsCleared => "widgets_cleared",
132            Self::WidgetRegistered => "widget_registered",
133        }
134    }
135}
136
137/// JSONL diagnostic log entry.
138#[derive(Debug, Clone)]
139pub struct DiagnosticEntry {
140    /// Monotonic sequence number.
141    pub seq: u64,
142    /// Timestamp in microseconds.
143    pub timestamp_us: u64,
144    /// Event kind.
145    pub kind: DiagnosticEventKind,
146    /// Current inspector mode.
147    pub mode: Option<InspectorMode>,
148    /// Previous inspector mode.
149    pub previous_mode: Option<InspectorMode>,
150    /// Hover position.
151    pub hover_pos: Option<(u16, u16)>,
152    /// Selected widget id.
153    pub selected: Option<HitId>,
154    /// Widget name (if applicable).
155    pub widget_name: Option<String>,
156    /// Widget area (if applicable).
157    pub widget_area: Option<Rect>,
158    /// Widget depth (if applicable).
159    pub widget_depth: Option<u8>,
160    /// Widget hit id (if applicable).
161    pub widget_hit_id: Option<HitId>,
162    /// Total widget count (if applicable).
163    pub widget_count: Option<usize>,
164    /// Flag name (for toggles).
165    pub flag: Option<String>,
166    /// Flag enabled state (for toggles).
167    pub enabled: Option<bool>,
168    /// Additional context string.
169    pub context: Option<String>,
170    /// Checksum for determinism verification.
171    pub checksum: u64,
172}
173
174impl DiagnosticEntry {
175    /// Create a new diagnostic entry with current timestamp.
176    pub fn new(kind: DiagnosticEventKind) -> Self {
177        let seq = next_event_seq();
178        let timestamp_us = if is_deterministic_mode() {
179            seq.saturating_mul(1_000)
180        } else {
181            static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
182            let start = START.get_or_init(Instant::now);
183            start.elapsed().as_micros() as u64
184        };
185
186        Self {
187            seq,
188            timestamp_us,
189            kind,
190            mode: None,
191            previous_mode: None,
192            hover_pos: None,
193            selected: None,
194            widget_name: None,
195            widget_area: None,
196            widget_depth: None,
197            widget_hit_id: None,
198            widget_count: None,
199            flag: None,
200            enabled: None,
201            context: None,
202            checksum: 0,
203        }
204    }
205
206    /// Set inspector mode.
207    #[must_use]
208    pub fn with_mode(mut self, mode: InspectorMode) -> Self {
209        self.mode = Some(mode);
210        self
211    }
212
213    /// Set previous inspector mode.
214    #[must_use]
215    pub fn with_previous_mode(mut self, mode: InspectorMode) -> Self {
216        self.previous_mode = Some(mode);
217        self
218    }
219
220    /// Set hover position.
221    #[must_use]
222    pub fn with_hover_pos(mut self, pos: Option<(u16, u16)>) -> Self {
223        self.hover_pos = pos;
224        self
225    }
226
227    /// Set selected widget id.
228    #[must_use]
229    pub fn with_selected(mut self, selected: Option<HitId>) -> Self {
230        self.selected = selected;
231        self
232    }
233
234    /// Set widget info.
235    #[must_use]
236    pub fn with_widget(mut self, widget: &WidgetInfo) -> Self {
237        self.widget_name = Some(widget.name.clone());
238        self.widget_area = Some(widget.area);
239        self.widget_depth = Some(widget.depth);
240        self.widget_hit_id = widget.hit_id;
241        self
242    }
243
244    /// Set widget count.
245    #[must_use]
246    pub fn with_widget_count(mut self, count: usize) -> Self {
247        self.widget_count = Some(count);
248        self
249    }
250
251    /// Set flag toggle details.
252    #[must_use]
253    pub fn with_flag(mut self, flag: impl Into<String>, enabled: bool) -> Self {
254        self.flag = Some(flag.into());
255        self.enabled = Some(enabled);
256        self
257    }
258
259    /// Set context string.
260    #[must_use]
261    pub fn with_context(mut self, context: impl Into<String>) -> Self {
262        self.context = Some(context.into());
263        self
264    }
265
266    /// Compute and set checksum.
267    #[must_use]
268    pub fn with_checksum(mut self) -> Self {
269        self.checksum = self.compute_checksum();
270        self
271    }
272
273    /// Compute FNV-1a hash of entry fields.
274    fn compute_checksum(&self) -> u64 {
275        let mut hash: u64 = 0xcbf29ce484222325;
276        let payload = format!(
277            "{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}",
278            self.kind,
279            self.mode,
280            self.previous_mode,
281            self.hover_pos,
282            self.selected.map(|id| id.id()),
283            self.widget_name.as_deref().unwrap_or(""),
284            self.widget_area
285                .map(|r| format!("{},{},{},{}", r.x, r.y, r.width, r.height))
286                .unwrap_or_default(),
287            self.widget_depth.unwrap_or(0),
288            self.widget_hit_id.map(|id| id.id()).unwrap_or(0),
289            self.widget_count.unwrap_or(0),
290            self.flag.as_deref().unwrap_or(""),
291            self.enabled.unwrap_or(false),
292            self.context.as_deref().unwrap_or("")
293        );
294        for &b in payload.as_bytes() {
295            hash ^= b as u64;
296            hash = hash.wrapping_mul(0x100000001b3);
297        }
298        hash
299    }
300
301    /// Format as JSONL string.
302    pub fn to_jsonl(&self) -> String {
303        let mut parts = vec![
304            format!("\"seq\":{}", self.seq),
305            format!("\"ts_us\":{}", self.timestamp_us),
306            format!("\"kind\":\"{}\"", self.kind.as_str()),
307        ];
308
309        if let Some(mode) = self.mode {
310            parts.push(format!("\"mode\":\"{}\"", mode.as_str()));
311        }
312        if let Some(mode) = self.previous_mode {
313            parts.push(format!("\"prev_mode\":\"{}\"", mode.as_str()));
314        }
315        if let Some((x, y)) = self.hover_pos {
316            parts.push(format!("\"hover_x\":{x}"));
317            parts.push(format!("\"hover_y\":{y}"));
318        }
319        if let Some(id) = self.selected {
320            parts.push(format!("\"selected_id\":{}", id.id()));
321        }
322        if let Some(ref name) = self.widget_name {
323            let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
324            parts.push(format!("\"widget\":\"{escaped}\""));
325        }
326        if let Some(area) = self.widget_area {
327            parts.push(format!("\"widget_x\":{}", area.x));
328            parts.push(format!("\"widget_y\":{}", area.y));
329            parts.push(format!("\"widget_w\":{}", area.width));
330            parts.push(format!("\"widget_h\":{}", area.height));
331        }
332        if let Some(depth) = self.widget_depth {
333            parts.push(format!("\"widget_depth\":{depth}"));
334        }
335        if let Some(id) = self.widget_hit_id {
336            parts.push(format!("\"widget_hit_id\":{}", id.id()));
337        }
338        if let Some(count) = self.widget_count {
339            parts.push(format!("\"widget_count\":{count}"));
340        }
341        if let Some(ref flag) = self.flag {
342            let escaped = flag.replace('\\', "\\\\").replace('"', "\\\"");
343            parts.push(format!("\"flag\":\"{escaped}\""));
344        }
345        if let Some(enabled) = self.enabled {
346            parts.push(format!("\"enabled\":{enabled}"));
347        }
348        if let Some(ref ctx) = self.context {
349            let escaped = ctx.replace('\\', "\\\\").replace('"', "\\\"");
350            parts.push(format!("\"context\":\"{escaped}\""));
351        }
352        parts.push(format!("\"checksum\":\"{:016x}\"", self.checksum));
353
354        format!("{{{}}}", parts.join(","))
355    }
356}
357
358/// Diagnostic log collector.
359#[derive(Debug, Default)]
360pub struct DiagnosticLog {
361    entries: Vec<DiagnosticEntry>,
362    max_entries: usize,
363    write_stderr: bool,
364}
365
366impl DiagnosticLog {
367    /// Create a new diagnostic log.
368    pub fn new() -> Self {
369        Self {
370            entries: Vec::new(),
371            max_entries: 5000,
372            write_stderr: false,
373        }
374    }
375
376    /// Create a log that writes to stderr.
377    #[must_use]
378    pub fn with_stderr(mut self) -> Self {
379        self.write_stderr = true;
380        self
381    }
382
383    /// Set maximum entries to keep.
384    #[must_use]
385    pub fn with_max_entries(mut self, max: usize) -> Self {
386        self.max_entries = max;
387        self
388    }
389
390    /// Record a diagnostic entry.
391    pub fn record(&mut self, entry: DiagnosticEntry) {
392        if self.write_stderr {
393            let _ = writeln!(std::io::stderr(), "{}", entry.to_jsonl());
394        }
395        if self.max_entries > 0 && self.entries.len() >= self.max_entries {
396            self.entries.remove(0);
397        }
398        self.entries.push(entry);
399    }
400
401    /// Get all entries.
402    pub fn entries(&self) -> &[DiagnosticEntry] {
403        &self.entries
404    }
405
406    /// Get entries of a specific kind.
407    pub fn entries_of_kind(&self, kind: DiagnosticEventKind) -> Vec<&DiagnosticEntry> {
408        self.entries.iter().filter(|e| e.kind == kind).collect()
409    }
410
411    /// Clear all entries.
412    pub fn clear(&mut self) {
413        self.entries.clear();
414    }
415
416    /// Export all entries as JSONL string.
417    pub fn to_jsonl(&self) -> String {
418        self.entries
419            .iter()
420            .map(DiagnosticEntry::to_jsonl)
421            .collect::<Vec<_>>()
422            .join("\n")
423    }
424}
425
426/// Callback type for telemetry hooks.
427pub type TelemetryCallback = Box<dyn Fn(&DiagnosticEntry) + Send + Sync>;
428
429/// Telemetry hooks for observing inspector events.
430#[derive(Default)]
431pub struct TelemetryHooks {
432    on_toggle: Option<TelemetryCallback>,
433    on_mode_change: Option<TelemetryCallback>,
434    on_hover_change: Option<TelemetryCallback>,
435    on_selection_change: Option<TelemetryCallback>,
436    on_any_event: Option<TelemetryCallback>,
437}
438
439impl std::fmt::Debug for TelemetryHooks {
440    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441        f.debug_struct("TelemetryHooks")
442            .field("on_toggle", &self.on_toggle.is_some())
443            .field("on_mode_change", &self.on_mode_change.is_some())
444            .field("on_hover_change", &self.on_hover_change.is_some())
445            .field("on_selection_change", &self.on_selection_change.is_some())
446            .field("on_any_event", &self.on_any_event.is_some())
447            .finish()
448    }
449}
450
451impl TelemetryHooks {
452    /// Create new empty hooks.
453    pub fn new() -> Self {
454        Self::default()
455    }
456
457    /// Set toggle callback.
458    #[must_use]
459    pub fn on_toggle(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
460        self.on_toggle = Some(Box::new(f));
461        self
462    }
463
464    /// Set mode change callback.
465    #[must_use]
466    pub fn on_mode_change(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
467        self.on_mode_change = Some(Box::new(f));
468        self
469    }
470
471    /// Set hover change callback.
472    #[must_use]
473    pub fn on_hover_change(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
474        self.on_hover_change = Some(Box::new(f));
475        self
476    }
477
478    /// Set selection change callback.
479    #[must_use]
480    pub fn on_selection_change(
481        mut self,
482        f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static,
483    ) -> Self {
484        self.on_selection_change = Some(Box::new(f));
485        self
486    }
487
488    /// Set catch-all callback.
489    #[must_use]
490    pub fn on_any(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
491        self.on_any_event = Some(Box::new(f));
492        self
493    }
494
495    /// Dispatch an entry to relevant hooks.
496    fn dispatch(&self, entry: &DiagnosticEntry) {
497        if let Some(ref cb) = self.on_any_event {
498            cb(entry);
499        }
500
501        match entry.kind {
502            DiagnosticEventKind::InspectorToggled => {
503                if let Some(ref cb) = self.on_toggle {
504                    cb(entry);
505                }
506            }
507            DiagnosticEventKind::ModeChanged => {
508                if let Some(ref cb) = self.on_mode_change {
509                    cb(entry);
510                }
511            }
512            DiagnosticEventKind::HoverChanged => {
513                if let Some(ref cb) = self.on_hover_change {
514                    cb(entry);
515                }
516            }
517            DiagnosticEventKind::SelectionChanged => {
518                if let Some(ref cb) = self.on_selection_change {
519                    cb(entry);
520                }
521            }
522            _ => {}
523        }
524    }
525}
526
527/// Inspector display mode.
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
529pub enum InspectorMode {
530    /// Inspector is disabled.
531    #[default]
532    Off,
533    /// Show hit regions with colored overlays.
534    HitRegions,
535    /// Show widget boundaries and names.
536    WidgetBounds,
537    /// Show both hit regions and widget bounds.
538    Full,
539}
540
541impl InspectorMode {
542    /// Cycle to the next mode.
543    ///
544    /// Off → HitRegions → WidgetBounds → Full → Off
545    #[must_use]
546    pub fn cycle(self) -> Self {
547        match self {
548            Self::Off => Self::HitRegions,
549            Self::HitRegions => Self::WidgetBounds,
550            Self::WidgetBounds => Self::Full,
551            Self::Full => Self::Off,
552        }
553    }
554
555    /// Check if inspector is active (any mode except Off).
556    #[inline]
557    pub fn is_active(self) -> bool {
558        self != Self::Off
559    }
560
561    /// Get a stable string representation for diagnostics.
562    pub const fn as_str(self) -> &'static str {
563        match self {
564            Self::Off => "off",
565            Self::HitRegions => "hit_regions",
566            Self::WidgetBounds => "widget_bounds",
567            Self::Full => "full",
568        }
569    }
570
571    /// Check if hit regions should be shown.
572    #[inline]
573    pub fn show_hit_regions(self) -> bool {
574        matches!(self, Self::HitRegions | Self::Full)
575    }
576
577    /// Check if widget bounds should be shown.
578    #[inline]
579    pub fn show_widget_bounds(self) -> bool {
580        matches!(self, Self::WidgetBounds | Self::Full)
581    }
582}
583
584/// Information about a widget for inspector display.
585#[derive(Debug, Clone)]
586pub struct WidgetInfo {
587    /// Human-readable widget name (e.g., "List", "Button").
588    pub name: String,
589    /// Allocated render area.
590    pub area: Rect,
591    /// Hit ID if widget is interactive.
592    pub hit_id: Option<HitId>,
593    /// Registered hit regions within this widget.
594    pub hit_regions: Vec<(Rect, HitRegion, HitData)>,
595    /// Render time in microseconds (if profiling enabled).
596    pub render_time_us: Option<u64>,
597    /// Nesting depth for color cycling.
598    pub depth: u8,
599    /// Child widgets (for tree view).
600    pub children: Vec<WidgetInfo>,
601}
602
603impl WidgetInfo {
604    /// Create a new widget info.
605    #[must_use]
606    pub fn new(name: impl Into<String>, area: Rect) -> Self {
607        Self {
608            name: name.into(),
609            area,
610            hit_id: None,
611            hit_regions: Vec::new(),
612            render_time_us: None,
613            depth: 0,
614            children: Vec::new(),
615        }
616    }
617
618    /// Set the hit ID.
619    #[must_use]
620    pub fn with_hit_id(mut self, id: HitId) -> Self {
621        self.hit_id = Some(id);
622        self
623    }
624
625    /// Add a hit region.
626    pub fn add_hit_region(&mut self, rect: Rect, region: HitRegion, data: HitData) {
627        self.hit_regions.push((rect, region, data));
628    }
629
630    /// Set nesting depth.
631    #[must_use]
632    pub fn with_depth(mut self, depth: u8) -> Self {
633        self.depth = depth;
634        self
635    }
636
637    /// Add a child widget.
638    pub fn add_child(&mut self, child: WidgetInfo) {
639        self.children.push(child);
640    }
641}
642
643/// Configuration for inspector appearance.
644#[derive(Debug, Clone)]
645pub struct InspectorStyle {
646    /// Border colors for widget bounds (cycles through for nesting).
647    pub bound_colors: [PackedRgba; 6],
648    /// Hit region overlay color (semi-transparent).
649    pub hit_overlay: PackedRgba,
650    /// Hovered hit region color.
651    pub hit_hover: PackedRgba,
652    /// Selected widget highlight.
653    pub selected_highlight: PackedRgba,
654    /// Label text color.
655    pub label_fg: PackedRgba,
656    /// Label background color.
657    pub label_bg: PackedRgba,
658}
659
660impl Default for InspectorStyle {
661    fn default() -> Self {
662        Self {
663            bound_colors: [
664                PackedRgba::rgb(255, 100, 100), // Red
665                PackedRgba::rgb(100, 255, 100), // Green
666                PackedRgba::rgb(100, 100, 255), // Blue
667                PackedRgba::rgb(255, 255, 100), // Yellow
668                PackedRgba::rgb(255, 100, 255), // Magenta
669                PackedRgba::rgb(100, 255, 255), // Cyan
670            ],
671            hit_overlay: PackedRgba::rgba(255, 165, 0, 80), // Orange 30%
672            hit_hover: PackedRgba::rgba(255, 255, 0, 120),  // Yellow 47%
673            selected_highlight: PackedRgba::rgba(0, 200, 255, 150), // Cyan 60%
674            label_fg: PackedRgba::WHITE,
675            label_bg: PackedRgba::rgba(0, 0, 0, 200),
676        }
677    }
678}
679
680impl InspectorStyle {
681    /// Get the bound color for a given nesting depth.
682    #[inline]
683    pub fn bound_color(&self, depth: u8) -> PackedRgba {
684        self.bound_colors[depth as usize % self.bound_colors.len()]
685    }
686
687    /// Get a region-specific overlay color.
688    pub fn region_color(&self, region: HitRegion) -> PackedRgba {
689        match region {
690            HitRegion::None => PackedRgba::TRANSPARENT,
691            HitRegion::Content => PackedRgba::rgba(255, 165, 0, 60), // Orange
692            HitRegion::Border => PackedRgba::rgba(128, 128, 128, 60), // Gray
693            HitRegion::Scrollbar => PackedRgba::rgba(100, 100, 200, 60), // Blue-ish
694            HitRegion::Handle => PackedRgba::rgba(200, 100, 100, 60), // Red-ish
695            HitRegion::Button => PackedRgba::rgba(0, 200, 255, 80),  // Cyan
696            HitRegion::Link => PackedRgba::rgba(100, 200, 255, 80),  // Light blue
697            HitRegion::Custom(_) => PackedRgba::rgba(200, 200, 200, 60), // Light gray
698        }
699    }
700}
701
702/// Inspector overlay state (shared across frames).
703#[derive(Debug, Default)]
704pub struct InspectorState {
705    /// Current display mode.
706    pub mode: InspectorMode,
707    /// Mouse position for hover detection.
708    pub hover_pos: Option<(u16, u16)>,
709    /// Selected widget (clicked).
710    pub selected: Option<HitId>,
711    /// Collected widget info for current frame.
712    pub widgets: Vec<WidgetInfo>,
713    /// Show detailed panel.
714    pub show_detail_panel: bool,
715    /// Visual style configuration.
716    pub style: InspectorStyle,
717    /// Toggle for hit regions visibility (within mode).
718    pub show_hits: bool,
719    /// Toggle for widget bounds visibility (within mode).
720    pub show_bounds: bool,
721    /// Toggle for widget name labels.
722    pub show_names: bool,
723    /// Toggle for render time display.
724    pub show_times: bool,
725    /// Diagnostic log for telemetry (bd-17h9.8).
726    diagnostic_log: Option<DiagnosticLog>,
727    /// Telemetry hooks for external observers (bd-17h9.8).
728    telemetry_hooks: Option<TelemetryHooks>,
729}
730
731impl InspectorState {
732    /// Create a new inspector state.
733    #[must_use]
734    pub fn new() -> Self {
735        let diagnostic_log = if diagnostics_enabled() {
736            Some(DiagnosticLog::new().with_stderr())
737        } else {
738            None
739        };
740        Self {
741            show_hits: true,
742            show_bounds: true,
743            show_names: true,
744            show_times: false,
745            diagnostic_log,
746            telemetry_hooks: None,
747            ..Default::default()
748        }
749    }
750
751    /// Create with diagnostic log enabled (for testing).
752    #[must_use]
753    pub fn with_diagnostics(mut self) -> Self {
754        self.diagnostic_log = Some(DiagnosticLog::new());
755        self
756    }
757
758    /// Create with telemetry hooks.
759    #[must_use]
760    pub fn with_telemetry_hooks(mut self, hooks: TelemetryHooks) -> Self {
761        self.telemetry_hooks = Some(hooks);
762        self
763    }
764
765    /// Get the diagnostic log (for testing).
766    #[must_use = "use the diagnostic log (if enabled)"]
767    pub fn diagnostic_log(&self) -> Option<&DiagnosticLog> {
768        self.diagnostic_log.as_ref()
769    }
770
771    /// Get mutable diagnostic log (for testing).
772    #[must_use = "use the diagnostic log (if enabled)"]
773    pub fn diagnostic_log_mut(&mut self) -> Option<&mut DiagnosticLog> {
774        self.diagnostic_log.as_mut()
775    }
776
777    #[inline]
778    fn diagnostics_active(&self) -> bool {
779        self.diagnostic_log.is_some() || self.telemetry_hooks.is_some()
780    }
781
782    /// Toggle the inspector on/off.
783    pub fn toggle(&mut self) {
784        let prev = self.mode;
785        if self.mode.is_active() {
786            self.mode = InspectorMode::Off;
787        } else {
788            self.mode = InspectorMode::Full;
789        }
790        if self.mode != prev && self.diagnostics_active() {
791            self.record_diagnostic(
792                DiagnosticEntry::new(DiagnosticEventKind::InspectorToggled)
793                    .with_previous_mode(prev)
794                    .with_mode(self.mode)
795                    .with_flag("inspector", self.mode.is_active()),
796            );
797        }
798    }
799
800    /// Check if the inspector is active.
801    #[inline]
802    pub fn is_active(&self) -> bool {
803        self.mode.is_active()
804    }
805
806    /// Cycle through display modes.
807    pub fn cycle_mode(&mut self) {
808        let prev = self.mode;
809        self.mode = self.mode.cycle();
810        if self.mode != prev && self.diagnostics_active() {
811            self.record_diagnostic(
812                DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
813                    .with_previous_mode(prev)
814                    .with_mode(self.mode),
815            );
816        }
817    }
818
819    /// Set mode directly (0=Off, 1=HitRegions, 2=WidgetBounds, 3=Full).
820    pub fn set_mode(&mut self, mode_num: u8) {
821        let prev = self.mode;
822        self.mode = match mode_num {
823            0 => InspectorMode::Off,
824            1 => InspectorMode::HitRegions,
825            2 => InspectorMode::WidgetBounds,
826            _ => InspectorMode::Full,
827        };
828        if self.mode != prev && self.diagnostics_active() {
829            self.record_diagnostic(
830                DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
831                    .with_previous_mode(prev)
832                    .with_mode(self.mode),
833            );
834        }
835    }
836
837    /// Update hover position from mouse event.
838    pub fn set_hover(&mut self, pos: Option<(u16, u16)>) {
839        if self.hover_pos != pos {
840            self.hover_pos = pos;
841            if self.diagnostics_active() {
842                self.record_diagnostic(
843                    DiagnosticEntry::new(DiagnosticEventKind::HoverChanged).with_hover_pos(pos),
844                );
845            }
846        }
847    }
848
849    /// Select a widget by hit ID.
850    pub fn select(&mut self, id: Option<HitId>) {
851        if self.selected != id {
852            self.selected = id;
853            if self.diagnostics_active() {
854                self.record_diagnostic(
855                    DiagnosticEntry::new(DiagnosticEventKind::SelectionChanged).with_selected(id),
856                );
857            }
858        }
859    }
860
861    /// Clear selection.
862    pub fn clear_selection(&mut self) {
863        self.select(None);
864    }
865
866    /// Toggle the detail panel.
867    pub fn toggle_detail_panel(&mut self) {
868        self.show_detail_panel = !self.show_detail_panel;
869        if self.diagnostics_active() {
870            self.record_diagnostic(
871                DiagnosticEntry::new(DiagnosticEventKind::DetailPanelToggled)
872                    .with_flag("detail_panel", self.show_detail_panel),
873            );
874        }
875    }
876
877    /// Toggle hit regions visibility.
878    pub fn toggle_hits(&mut self) {
879        self.show_hits = !self.show_hits;
880        if self.diagnostics_active() {
881            self.record_diagnostic(
882                DiagnosticEntry::new(DiagnosticEventKind::HitsToggled)
883                    .with_flag("hits", self.show_hits),
884            );
885        }
886    }
887
888    /// Toggle widget bounds visibility.
889    pub fn toggle_bounds(&mut self) {
890        self.show_bounds = !self.show_bounds;
891        if self.diagnostics_active() {
892            self.record_diagnostic(
893                DiagnosticEntry::new(DiagnosticEventKind::BoundsToggled)
894                    .with_flag("bounds", self.show_bounds),
895            );
896        }
897    }
898
899    /// Toggle name labels visibility.
900    pub fn toggle_names(&mut self) {
901        self.show_names = !self.show_names;
902        if self.diagnostics_active() {
903            self.record_diagnostic(
904                DiagnosticEntry::new(DiagnosticEventKind::NamesToggled)
905                    .with_flag("names", self.show_names),
906            );
907        }
908    }
909
910    /// Toggle render time visibility.
911    pub fn toggle_times(&mut self) {
912        self.show_times = !self.show_times;
913        if self.diagnostics_active() {
914            self.record_diagnostic(
915                DiagnosticEntry::new(DiagnosticEventKind::TimesToggled)
916                    .with_flag("times", self.show_times),
917            );
918        }
919    }
920
921    /// Clear widget info for new frame.
922    pub fn clear_widgets(&mut self) {
923        let count = self.widgets.len();
924        self.widgets.clear();
925        if count > 0 && self.diagnostics_active() {
926            self.record_diagnostic(
927                DiagnosticEntry::new(DiagnosticEventKind::WidgetsCleared).with_widget_count(count),
928            );
929        }
930    }
931
932    /// Register a widget for inspection.
933    pub fn register_widget(&mut self, info: WidgetInfo) {
934        #[cfg(feature = "tracing")]
935        trace!(name = info.name, area = ?info.area, "Registered widget for inspection");
936        if self.diagnostics_active() {
937            let widget_count = self.widgets.len() + 1;
938            self.record_diagnostic(
939                DiagnosticEntry::new(DiagnosticEventKind::WidgetRegistered)
940                    .with_widget(&info)
941                    .with_widget_count(widget_count),
942            );
943        }
944        self.widgets.push(info);
945    }
946
947    fn record_diagnostic(&mut self, entry: DiagnosticEntry) {
948        if self.diagnostic_log.is_none() && self.telemetry_hooks.is_none() {
949            return;
950        }
951        let entry = entry.with_checksum();
952
953        if let Some(ref hooks) = self.telemetry_hooks {
954            hooks.dispatch(&entry);
955        }
956
957        if let Some(ref mut log) = self.diagnostic_log {
958            log.record(entry);
959        }
960    }
961
962    /// Check if we should render hit regions.
963    #[inline]
964    pub fn should_show_hits(&self) -> bool {
965        self.show_hits && self.mode.show_hit_regions()
966    }
967
968    /// Check if we should render widget bounds.
969    #[inline]
970    pub fn should_show_bounds(&self) -> bool {
971        self.show_bounds && self.mode.show_widget_bounds()
972    }
973}
974
975/// Inspector overlay widget.
976///
977/// Renders hit region overlays and widget bounds on top of the UI.
978pub struct InspectorOverlay<'a> {
979    state: &'a InspectorState,
980}
981
982impl<'a> InspectorOverlay<'a> {
983    /// Create a new inspector overlay.
984    #[must_use]
985    pub fn new(state: &'a InspectorState) -> Self {
986        Self { state }
987    }
988
989    /// Render hit region overlays from the frame's HitGrid.
990    fn render_hit_regions(&self, area: Rect, frame: &mut Frame) {
991        #[cfg(feature = "tracing")]
992        let _span = info_span!("render_hit_regions").entered();
993
994        let Some(ref hit_grid) = frame.hit_grid else {
995            // No hit grid available - draw warning
996            self.draw_warning(area, frame, "HitGrid not enabled");
997            return;
998        };
999
1000        let style = &self.state.style;
1001        let hover_pos = self.state.hover_pos;
1002        let selected = self.state.selected;
1003
1004        // Iterate over visible cells and apply overlay colors
1005        for y in area.y..area.bottom() {
1006            for x in area.x..area.right() {
1007                if let Some(cell) = hit_grid.get(x, y) {
1008                    if cell.is_empty() {
1009                        continue;
1010                    }
1011
1012                    let is_hovered = hover_pos == Some((x, y));
1013                    let is_selected = selected == cell.widget_id;
1014
1015                    // Determine overlay color
1016                    let overlay = if is_selected {
1017                        style.selected_highlight
1018                    } else if is_hovered {
1019                        style.hit_hover
1020                    } else {
1021                        style.region_color(cell.region)
1022                    };
1023
1024                    // Apply overlay to buffer cell
1025                    if let Some(buf_cell) = frame.buffer.get_mut(x, y) {
1026                        buf_cell.bg = overlay.over(buf_cell.bg);
1027                    }
1028                }
1029            }
1030        }
1031    }
1032
1033    /// Render widget bounds from collected WidgetInfo.
1034    fn render_widget_bounds(&self, _area: Rect, frame: &mut Frame) {
1035        #[cfg(feature = "tracing")]
1036        let _span = info_span!(
1037            "render_widget_bounds",
1038            widget_count = self.state.widgets.len()
1039        )
1040        .entered();
1041
1042        let style = &self.state.style;
1043
1044        for widget in &self.state.widgets {
1045            self.render_widget_bound(widget, frame, style);
1046        }
1047    }
1048
1049    /// Render a single widget's bounds recursively.
1050    fn render_widget_bound(&self, widget: &WidgetInfo, frame: &mut Frame, style: &InspectorStyle) {
1051        let color = style.bound_color(widget.depth);
1052        let area = widget.area;
1053
1054        // Skip empty areas
1055        if area.is_empty() {
1056            return;
1057        }
1058
1059        // Draw border outline
1060        self.draw_rect_outline(area, frame, color);
1061
1062        // Draw label if names are enabled
1063        if self.state.show_names && !widget.name.is_empty() {
1064            self.draw_label(area, frame, &widget.name, style);
1065        }
1066
1067        // Recursively draw children
1068        for child in &widget.children {
1069            self.render_widget_bound(child, frame, style);
1070        }
1071    }
1072
1073    /// Draw a rectangle outline with the given color.
1074    fn draw_rect_outline(&self, rect: Rect, frame: &mut Frame, color: PackedRgba) {
1075        if rect.width == 0 || rect.height == 0 {
1076            return;
1077        }
1078
1079        let x = rect.x;
1080        let y = rect.y;
1081        let right = rect.right().saturating_sub(1);
1082        let bottom = rect.bottom().saturating_sub(1);
1083
1084        // Top edge
1085        for cx in x..=right {
1086            if let Some(cell) = frame.buffer.get_mut(cx, y) {
1087                cell.fg = color;
1088            }
1089        }
1090
1091        // Bottom edge
1092        if bottom > y {
1093            for cx in x..=right {
1094                if let Some(cell) = frame.buffer.get_mut(cx, bottom) {
1095                    cell.fg = color;
1096                }
1097            }
1098        }
1099
1100        // Left edge
1101        for cy in y..=bottom {
1102            if let Some(cell) = frame.buffer.get_mut(x, cy) {
1103                cell.fg = color;
1104            }
1105        }
1106
1107        // Right edge
1108        if right > x {
1109            for cy in y..=bottom {
1110                if let Some(cell) = frame.buffer.get_mut(right, cy) {
1111                    cell.fg = color;
1112                }
1113            }
1114        }
1115    }
1116
1117    /// Draw a widget name label at the top-left of its area.
1118    fn draw_label(&self, area: Rect, frame: &mut Frame, name: &str, style: &InspectorStyle) {
1119        let label = format!("[{name}]");
1120        let label_len = display_width(&label) as u16;
1121
1122        // Position label at top-left, clamped to area
1123        let x = area.x;
1124        let y = area.y;
1125
1126        // Draw label background
1127        let label_area = Rect::new(x, y, label_len.min(area.width), 1);
1128        set_style_area(
1129            &mut frame.buffer,
1130            label_area,
1131            Style::new().bg(style.label_bg),
1132        );
1133
1134        // Draw label text
1135        let label_style = Style::new().fg(style.label_fg).bg(style.label_bg);
1136        draw_text_span(frame, x, y, &label, label_style, area.x + area.width);
1137    }
1138
1139    /// Draw a warning message when something isn't available.
1140    fn draw_warning(&self, area: Rect, frame: &mut Frame, msg: &str) {
1141        let style = &self.state.style;
1142        let warning_style = Style::new()
1143            .fg(PackedRgba::rgb(255, 200, 0))
1144            .bg(style.label_bg);
1145
1146        // Center the message
1147        let msg_len = display_width(msg) as u16;
1148        let x = area.x + area.width.saturating_sub(msg_len) / 2;
1149        let y = area.y;
1150
1151        set_style_area(
1152            &mut frame.buffer,
1153            Rect::new(x, y, msg_len, 1),
1154            warning_style,
1155        );
1156
1157        draw_text_span(frame, x, y, msg, warning_style, area.x + area.width);
1158    }
1159
1160    /// Render the detail panel showing selected widget info.
1161    fn render_detail_panel(&self, area: Rect, frame: &mut Frame) {
1162        let style = &self.state.style;
1163
1164        // Panel dimensions
1165        let panel_width: u16 = 24;
1166        let panel_height = area.height.min(20);
1167
1168        // Position at right edge
1169        let panel_x = area.right().saturating_sub(panel_width + 1);
1170        let panel_y = area.y + 1;
1171        let panel_area = Rect::new(panel_x, panel_y, panel_width, panel_height);
1172
1173        // Draw panel background
1174        set_style_area(
1175            &mut frame.buffer,
1176            panel_area,
1177            Style::new().bg(style.label_bg),
1178        );
1179
1180        // Draw border
1181        self.draw_rect_outline(panel_area, frame, style.label_fg);
1182
1183        // Draw content
1184        let content_x = panel_x + 1;
1185        let mut y = panel_y + 1;
1186
1187        // Title
1188        self.draw_panel_text(frame, content_x, y, "Inspector", style.label_fg);
1189        y += 2;
1190
1191        // Mode info
1192        let mode_str = match self.state.mode {
1193            InspectorMode::Off => "Off",
1194            InspectorMode::HitRegions => "Hit Regions",
1195            InspectorMode::WidgetBounds => "Widget Bounds",
1196            InspectorMode::Full => "Full",
1197        };
1198        self.draw_panel_text(
1199            frame,
1200            content_x,
1201            y,
1202            &format!("Mode: {mode_str}"),
1203            style.label_fg,
1204        );
1205        y += 1;
1206
1207        // Hover info
1208        if let Some((hx, hy)) = self.state.hover_pos {
1209            self.draw_panel_text(
1210                frame,
1211                content_x,
1212                y,
1213                &format!("Hover: ({hx},{hy})"),
1214                style.label_fg,
1215            );
1216            y += 1;
1217
1218            // Extract hit info first to avoid borrow conflicts
1219            let hit_info = frame
1220                .hit_grid
1221                .as_ref()
1222                .and_then(|grid| grid.get(hx, hy).filter(|h| !h.is_empty()).map(|h| (*h,)));
1223
1224            // Show hit info at hover position
1225            if let Some((hit,)) = hit_info {
1226                let region_str = format!("{:?}", hit.region);
1227                self.draw_panel_text(
1228                    frame,
1229                    content_x,
1230                    y,
1231                    &format!("Region: {region_str}"),
1232                    style.label_fg,
1233                );
1234                y += 1;
1235                if let Some(id) = hit.widget_id {
1236                    self.draw_panel_text(
1237                        frame,
1238                        content_x,
1239                        y,
1240                        &format!("ID: {}", id.id()),
1241                        style.label_fg,
1242                    );
1243                    y += 1;
1244                }
1245                if hit.data != 0 {
1246                    self.draw_panel_text(
1247                        frame,
1248                        content_x,
1249                        y,
1250                        &format!("Data: {}", hit.data),
1251                        style.label_fg,
1252                    );
1253                    #[allow(unused_assignments)]
1254                    {
1255                        y += 1;
1256                    }
1257                }
1258            }
1259        }
1260    }
1261
1262    /// Draw text in the detail panel.
1263    fn draw_panel_text(&self, frame: &mut Frame, x: u16, y: u16, text: &str, fg: PackedRgba) {
1264        for (i, ch) in text.chars().enumerate() {
1265            let cx = x + i as u16;
1266            if let Some(cell) = frame.buffer.get_mut(cx, y) {
1267                *cell = Cell::from_char(ch).with_fg(fg);
1268            }
1269        }
1270    }
1271}
1272
1273impl Widget for InspectorOverlay<'_> {
1274    fn render(&self, area: Rect, frame: &mut Frame) {
1275        #[cfg(feature = "tracing")]
1276        let _span = info_span!("inspector_overlay", ?area).entered();
1277
1278        if !self.state.is_active() {
1279            return;
1280        }
1281
1282        // Render hit regions first (underneath widget bounds)
1283        if self.state.should_show_hits() {
1284            self.render_hit_regions(area, frame);
1285        }
1286
1287        // Render widget bounds on top
1288        if self.state.should_show_bounds() {
1289            self.render_widget_bounds(area, frame);
1290        }
1291
1292        // Render detail panel if enabled
1293        if self.state.show_detail_panel {
1294            self.render_detail_panel(area, frame);
1295        }
1296    }
1297
1298    fn is_essential(&self) -> bool {
1299        // Inspector is a debugging tool, not essential
1300        false
1301    }
1302}
1303
1304/// Helper to extract hit information from a HitCell for display.
1305#[derive(Debug, Clone)]
1306pub struct HitInfo {
1307    /// Widget ID.
1308    pub widget_id: HitId,
1309    /// Region type.
1310    pub region: HitRegion,
1311    /// Associated data.
1312    pub data: HitData,
1313    /// Screen position.
1314    pub position: (u16, u16),
1315}
1316
1317impl HitInfo {
1318    /// Create from a HitCell and position.
1319    #[must_use = "use the computed hit info (if any)"]
1320    pub fn from_cell(cell: &HitCell, x: u16, y: u16) -> Option<Self> {
1321        cell.widget_id.map(|id| Self {
1322            widget_id: id,
1323            region: cell.region,
1324            data: cell.data,
1325            position: (x, y),
1326        })
1327    }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332    use super::*;
1333    use ftui_render::grapheme_pool::GraphemePool;
1334
1335    #[test]
1336    fn inspector_mode_cycle() {
1337        let mut mode = InspectorMode::Off;
1338        mode = mode.cycle();
1339        assert_eq!(mode, InspectorMode::HitRegions);
1340        mode = mode.cycle();
1341        assert_eq!(mode, InspectorMode::WidgetBounds);
1342        mode = mode.cycle();
1343        assert_eq!(mode, InspectorMode::Full);
1344        mode = mode.cycle();
1345        assert_eq!(mode, InspectorMode::Off);
1346    }
1347
1348    #[test]
1349    fn inspector_mode_is_active() {
1350        assert!(!InspectorMode::Off.is_active());
1351        assert!(InspectorMode::HitRegions.is_active());
1352        assert!(InspectorMode::WidgetBounds.is_active());
1353        assert!(InspectorMode::Full.is_active());
1354    }
1355
1356    #[test]
1357    fn inspector_mode_show_flags() {
1358        assert!(!InspectorMode::Off.show_hit_regions());
1359        assert!(!InspectorMode::Off.show_widget_bounds());
1360
1361        assert!(InspectorMode::HitRegions.show_hit_regions());
1362        assert!(!InspectorMode::HitRegions.show_widget_bounds());
1363
1364        assert!(!InspectorMode::WidgetBounds.show_hit_regions());
1365        assert!(InspectorMode::WidgetBounds.show_widget_bounds());
1366
1367        assert!(InspectorMode::Full.show_hit_regions());
1368        assert!(InspectorMode::Full.show_widget_bounds());
1369    }
1370
1371    #[test]
1372    fn inspector_state_toggle() {
1373        let mut state = InspectorState::new();
1374        assert!(!state.is_active());
1375
1376        state.toggle();
1377        assert!(state.is_active());
1378        assert_eq!(state.mode, InspectorMode::Full);
1379
1380        state.toggle();
1381        assert!(!state.is_active());
1382        assert_eq!(state.mode, InspectorMode::Off);
1383    }
1384
1385    #[test]
1386    fn inspector_state_set_mode() {
1387        let mut state = InspectorState::new();
1388
1389        state.set_mode(1);
1390        assert_eq!(state.mode, InspectorMode::HitRegions);
1391
1392        state.set_mode(2);
1393        assert_eq!(state.mode, InspectorMode::WidgetBounds);
1394
1395        state.set_mode(3);
1396        assert_eq!(state.mode, InspectorMode::Full);
1397
1398        state.set_mode(0);
1399        assert_eq!(state.mode, InspectorMode::Off);
1400
1401        // Any value >= 3 maps to Full
1402        state.set_mode(99);
1403        assert_eq!(state.mode, InspectorMode::Full);
1404    }
1405
1406    #[test]
1407    fn inspector_style_default() {
1408        let style = InspectorStyle::default();
1409        assert_eq!(style.bound_colors.len(), 6);
1410        assert_eq!(style.hit_overlay, PackedRgba::rgba(255, 165, 0, 80));
1411    }
1412
1413    #[test]
1414    fn inspector_style_bound_color_cycles() {
1415        let style = InspectorStyle::default();
1416        assert_eq!(style.bound_color(0), style.bound_colors[0]);
1417        assert_eq!(style.bound_color(5), style.bound_colors[5]);
1418        assert_eq!(style.bound_color(6), style.bound_colors[0]); // Wraps
1419        assert_eq!(style.bound_color(7), style.bound_colors[1]);
1420    }
1421
1422    #[test]
1423    fn widget_info_creation() {
1424        let info = WidgetInfo::new("Button", Rect::new(10, 5, 20, 3))
1425            .with_hit_id(HitId::new(42))
1426            .with_depth(2);
1427
1428        assert_eq!(info.name, "Button");
1429        assert_eq!(info.area, Rect::new(10, 5, 20, 3));
1430        assert_eq!(info.hit_id, Some(HitId::new(42)));
1431        assert_eq!(info.depth, 2);
1432    }
1433
1434    #[test]
1435    fn widget_info_add_hit_region() {
1436        let mut info = WidgetInfo::new("List", Rect::new(0, 0, 10, 10));
1437        info.add_hit_region(Rect::new(0, 0, 10, 1), HitRegion::Content, 0);
1438        info.add_hit_region(Rect::new(0, 1, 10, 1), HitRegion::Content, 1);
1439
1440        assert_eq!(info.hit_regions.len(), 2);
1441        assert_eq!(info.hit_regions[0].2, 0);
1442        assert_eq!(info.hit_regions[1].2, 1);
1443    }
1444
1445    #[test]
1446    fn widget_info_add_child() {
1447        let mut parent = WidgetInfo::new("Container", Rect::new(0, 0, 20, 20));
1448        let child = WidgetInfo::new("Button", Rect::new(5, 5, 10, 3));
1449        parent.add_child(child);
1450
1451        assert_eq!(parent.children.len(), 1);
1452        assert_eq!(parent.children[0].name, "Button");
1453    }
1454
1455    #[test]
1456    fn inspector_overlay_inactive_is_noop() {
1457        let state = InspectorState::new();
1458        let overlay = InspectorOverlay::new(&state);
1459
1460        let mut pool = GraphemePool::new();
1461        let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
1462        let area = Rect::new(0, 0, 10, 10);
1463
1464        // Should do nothing since mode is Off
1465        overlay.render(area, &mut frame);
1466
1467        // Buffer should be empty
1468        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
1469    }
1470
1471    #[test]
1472    fn inspector_overlay_renders_when_active() {
1473        let mut state = InspectorState::new();
1474        state.mode = InspectorMode::Full;
1475        state.show_detail_panel = true;
1476
1477        let overlay = InspectorOverlay::new(&state);
1478
1479        let mut pool = GraphemePool::new();
1480        let mut frame = Frame::with_hit_grid(40, 20, &mut pool);
1481
1482        // Register a hit region
1483        frame.register_hit(Rect::new(5, 5, 10, 3), HitId::new(1), HitRegion::Button, 42);
1484
1485        let area = Rect::new(0, 0, 40, 20);
1486        overlay.render(area, &mut frame);
1487
1488        // The detail panel should be rendered at the right edge
1489        // This is a smoke test - actual content depends on implementation
1490    }
1491
1492    #[test]
1493    fn hit_info_from_cell() {
1494        let cell = HitCell::new(HitId::new(5), HitRegion::Button, 99);
1495        let info = HitInfo::from_cell(&cell, 10, 20);
1496
1497        assert!(info.is_some());
1498        let info = info.unwrap();
1499        assert_eq!(info.widget_id, HitId::new(5));
1500        assert_eq!(info.region, HitRegion::Button);
1501        assert_eq!(info.data, 99);
1502        assert_eq!(info.position, (10, 20));
1503    }
1504
1505    #[test]
1506    fn hit_info_from_empty_cell() {
1507        let cell = HitCell::default();
1508        let info = HitInfo::from_cell(&cell, 0, 0);
1509        assert!(info.is_none());
1510    }
1511
1512    #[test]
1513    fn inspector_state_toggles() {
1514        let mut state = InspectorState::new();
1515
1516        assert!(state.show_hits);
1517        state.toggle_hits();
1518        assert!(!state.show_hits);
1519        state.toggle_hits();
1520        assert!(state.show_hits);
1521
1522        assert!(state.show_bounds);
1523        state.toggle_bounds();
1524        assert!(!state.show_bounds);
1525
1526        assert!(state.show_names);
1527        state.toggle_names();
1528        assert!(!state.show_names);
1529
1530        assert!(!state.show_times);
1531        state.toggle_times();
1532        assert!(state.show_times);
1533
1534        assert!(!state.show_detail_panel);
1535        state.toggle_detail_panel();
1536        assert!(state.show_detail_panel);
1537    }
1538
1539    #[test]
1540    fn inspector_state_selection() {
1541        let mut state = InspectorState::new();
1542
1543        assert!(state.selected.is_none());
1544        state.select(Some(HitId::new(42)));
1545        assert_eq!(state.selected, Some(HitId::new(42)));
1546        state.clear_selection();
1547        assert!(state.selected.is_none());
1548    }
1549
1550    #[test]
1551    fn inspector_state_hover() {
1552        let mut state = InspectorState::new();
1553
1554        assert!(state.hover_pos.is_none());
1555        state.set_hover(Some((10, 20)));
1556        assert_eq!(state.hover_pos, Some((10, 20)));
1557        state.set_hover(None);
1558        assert!(state.hover_pos.is_none());
1559    }
1560
1561    #[test]
1562    fn inspector_state_widget_registry() {
1563        let mut state = InspectorState::new();
1564
1565        let widget = WidgetInfo::new("Test", Rect::new(0, 0, 10, 10));
1566        state.register_widget(widget);
1567        assert_eq!(state.widgets.len(), 1);
1568
1569        state.clear_widgets();
1570        assert!(state.widgets.is_empty());
1571    }
1572
1573    #[test]
1574    fn inspector_overlay_is_not_essential() {
1575        let state = InspectorState::new();
1576        let overlay = InspectorOverlay::new(&state);
1577        assert!(!overlay.is_essential());
1578    }
1579
1580    // =========================================================================
1581    // Edge Case Tests (bd-17h9.6)
1582    // =========================================================================
1583
1584    #[test]
1585    fn edge_case_zero_area_widget() {
1586        // Zero-sized areas should not panic
1587        let info = WidgetInfo::new("ZeroArea", Rect::new(0, 0, 0, 0));
1588        assert_eq!(info.area.width, 0);
1589        assert_eq!(info.area.height, 0);
1590        assert!(info.area.is_empty());
1591    }
1592
1593    #[test]
1594    fn edge_case_max_depth_widget() {
1595        // Maximum depth should work without overflow
1596        let info = WidgetInfo::new("Deep", Rect::new(0, 0, 10, 10)).with_depth(u8::MAX);
1597        assert_eq!(info.depth, u8::MAX);
1598
1599        // Bound color should still cycle correctly
1600        let style = InspectorStyle::default();
1601        let _color = style.bound_color(u8::MAX); // Should not panic
1602    }
1603
1604    #[test]
1605    fn edge_case_empty_widget_registry() {
1606        let mut state = InspectorState::new();
1607        assert!(state.widgets.is_empty());
1608
1609        // Clearing empty registry should not panic
1610        state.clear_widgets();
1611        assert!(state.widgets.is_empty());
1612    }
1613
1614    #[test]
1615    fn edge_case_selection_without_widgets() {
1616        let mut state = InspectorState::new();
1617
1618        // Selecting when no widgets are registered
1619        state.select(Some(HitId::new(42)));
1620        assert_eq!(state.selected, Some(HitId::new(42)));
1621
1622        // Clearing selection
1623        state.clear_selection();
1624        assert!(state.selected.is_none());
1625    }
1626
1627    #[test]
1628    fn edge_case_hover_boundary_positions() {
1629        let mut state = InspectorState::new();
1630
1631        // Maximum u16 coordinates
1632        state.set_hover(Some((u16::MAX, u16::MAX)));
1633        assert_eq!(state.hover_pos, Some((u16::MAX, u16::MAX)));
1634
1635        // Zero coordinates
1636        state.set_hover(Some((0, 0)));
1637        assert_eq!(state.hover_pos, Some((0, 0)));
1638    }
1639
1640    #[test]
1641    fn edge_case_deeply_nested_widgets() {
1642        // Build nested structure from inside out
1643        let mut deepest = WidgetInfo::new("L10", Rect::new(10, 10, 80, 80)).with_depth(10);
1644
1645        for i in (1..10).rev() {
1646            let mut parent =
1647                WidgetInfo::new(format!("L{i}"), Rect::new(i as u16, i as u16, 90, 90))
1648                    .with_depth(i as u8);
1649            parent.add_child(deepest);
1650            deepest = parent;
1651        }
1652
1653        let mut root = WidgetInfo::new("Root", Rect::new(0, 0, 100, 100)).with_depth(0);
1654        root.add_child(deepest);
1655
1656        // Verify nesting: root -> L1 -> L2 -> ... -> L10
1657        assert_eq!(root.children.len(), 1);
1658        assert_eq!(root.children[0].depth, 1);
1659        assert_eq!(root.children[0].children[0].depth, 2);
1660    }
1661
1662    #[test]
1663    fn edge_case_rapid_mode_cycling() {
1664        let mut state = InspectorState::new();
1665        assert_eq!(state.mode, InspectorMode::Off);
1666
1667        // Cycle 1000 times and verify we end at correct mode
1668        for _ in 0..1000 {
1669            state.mode = state.mode.cycle();
1670        }
1671        // 1000 % 4 = 0, so should be back at Off
1672        assert_eq!(state.mode, InspectorMode::Off);
1673    }
1674
1675    #[test]
1676    fn edge_case_many_hit_regions() {
1677        let mut info = WidgetInfo::new("ManyHits", Rect::new(0, 0, 100, 1000));
1678
1679        // Add 1000 hit regions
1680        for i in 0..1000 {
1681            info.add_hit_region(
1682                Rect::new(0, i as u16, 100, 1),
1683                HitRegion::Content,
1684                i as HitData,
1685            );
1686        }
1687
1688        assert_eq!(info.hit_regions.len(), 1000);
1689        assert_eq!(info.hit_regions[0].2, 0);
1690        assert_eq!(info.hit_regions[999].2, 999);
1691    }
1692
1693    #[test]
1694    fn edge_case_mode_show_flags_consistency() {
1695        // Verify show flags are consistent with mode
1696        for mode in [
1697            InspectorMode::Off,
1698            InspectorMode::HitRegions,
1699            InspectorMode::WidgetBounds,
1700            InspectorMode::Full,
1701        ] {
1702            match mode {
1703                InspectorMode::Off => {
1704                    assert!(!mode.show_hit_regions());
1705                    assert!(!mode.show_widget_bounds());
1706                }
1707                InspectorMode::HitRegions => {
1708                    assert!(mode.show_hit_regions());
1709                    assert!(!mode.show_widget_bounds());
1710                }
1711                InspectorMode::WidgetBounds => {
1712                    assert!(!mode.show_hit_regions());
1713                    assert!(mode.show_widget_bounds());
1714                }
1715                InspectorMode::Full => {
1716                    assert!(mode.show_hit_regions());
1717                    assert!(mode.show_widget_bounds());
1718                }
1719            }
1720        }
1721    }
1722
1723    // =========================================================================
1724    // Property-Based Tests (bd-17h9.6)
1725    // =========================================================================
1726
1727    mod proptests {
1728        use super::*;
1729        use proptest::prelude::*;
1730
1731        proptest! {
1732            /// Mode cycling is periodic with period 4.
1733            /// Cycling 4 times from any mode returns to the original mode.
1734            #[test]
1735            fn mode_cycle_is_periodic(start_cycle in 0u8..4) {
1736                let start_mode = match start_cycle {
1737                    0 => InspectorMode::Off,
1738                    1 => InspectorMode::HitRegions,
1739                    2 => InspectorMode::WidgetBounds,
1740                    _ => InspectorMode::Full,
1741                };
1742
1743                let mut mode = start_mode;
1744                for _ in 0..4 {
1745                    mode = mode.cycle();
1746                }
1747                prop_assert_eq!(mode, start_mode);
1748            }
1749
1750            /// Bound color cycling is periodic with period 6.
1751            #[test]
1752            fn bound_color_cycle_is_periodic(depth in 0u8..200) {
1753                let style = InspectorStyle::default();
1754                let color_a = style.bound_color(depth);
1755                let color_b = style.bound_color(depth.wrapping_add(6));
1756                prop_assert_eq!(color_a, color_b);
1757            }
1758
1759            /// is_active correctly reflects mode != Off.
1760            #[test]
1761            fn is_active_reflects_mode(mode_idx in 0u8..4) {
1762                let mode = match mode_idx {
1763                    0 => InspectorMode::Off,
1764                    1 => InspectorMode::HitRegions,
1765                    2 => InspectorMode::WidgetBounds,
1766                    _ => InspectorMode::Full,
1767                };
1768                let expected_active = mode_idx != 0;
1769                prop_assert_eq!(mode.is_active(), expected_active);
1770            }
1771
1772            /// Double toggle is identity for boolean flags.
1773            #[test]
1774            fn double_toggle_is_identity(_seed in 0u32..1000) {
1775                let mut state = InspectorState::new();
1776                let initial_hits = state.show_hits;
1777                let initial_bounds = state.show_bounds;
1778                let initial_names = state.show_names;
1779                let initial_times = state.show_times;
1780                let initial_panel = state.show_detail_panel;
1781
1782                // Toggle twice
1783                state.toggle_hits();
1784                state.toggle_hits();
1785                state.toggle_bounds();
1786                state.toggle_bounds();
1787                state.toggle_names();
1788                state.toggle_names();
1789                state.toggle_times();
1790                state.toggle_times();
1791                state.toggle_detail_panel();
1792                state.toggle_detail_panel();
1793
1794                prop_assert_eq!(state.show_hits, initial_hits);
1795                prop_assert_eq!(state.show_bounds, initial_bounds);
1796                prop_assert_eq!(state.show_names, initial_names);
1797                prop_assert_eq!(state.show_times, initial_times);
1798                prop_assert_eq!(state.show_detail_panel, initial_panel);
1799            }
1800
1801            /// Widget info preserves area dimensions.
1802            #[test]
1803            fn widget_info_preserves_area(
1804                x in 0u16..1000,
1805                y in 0u16..1000,
1806                w in 1u16..500,
1807                h in 1u16..500,
1808            ) {
1809                let area = Rect::new(x, y, w, h);
1810                let info = WidgetInfo::new("Test", area);
1811                prop_assert_eq!(info.area, area);
1812            }
1813
1814            /// Widget depth is preserved through builder pattern.
1815            #[test]
1816            fn widget_depth_preserved(depth in 0u8..255) {
1817                let info = WidgetInfo::new("Test", Rect::new(0, 0, 10, 10))
1818                    .with_depth(depth);
1819                prop_assert_eq!(info.depth, depth);
1820            }
1821
1822            /// Hit ID is preserved through builder pattern.
1823            #[test]
1824            fn widget_hit_id_preserved(id in 0u32..u32::MAX) {
1825                let hit_id = HitId::new(id);
1826                let info = WidgetInfo::new("Test", Rect::new(0, 0, 10, 10))
1827                    .with_hit_id(hit_id);
1828                prop_assert_eq!(info.hit_id, Some(hit_id));
1829            }
1830
1831            /// Adding children increases child count.
1832            #[test]
1833            fn add_child_increases_count(child_count in 0usize..50) {
1834                let mut parent = WidgetInfo::new("Parent", Rect::new(0, 0, 100, 100));
1835                for i in 0..child_count {
1836                    parent.add_child(WidgetInfo::new(
1837                        format!("Child{i}"),
1838                        Rect::new(0, i as u16, 10, 1),
1839                    ));
1840                }
1841                prop_assert_eq!(parent.children.len(), child_count);
1842            }
1843
1844            /// Hit regions can be added without bounds.
1845            #[test]
1846            fn add_hit_regions_unbounded(region_count in 0usize..100) {
1847                let mut info = WidgetInfo::new("Test", Rect::new(0, 0, 100, 100));
1848                for i in 0..region_count {
1849                    info.add_hit_region(
1850                        Rect::new(0, i as u16, 10, 1),
1851                        HitRegion::Content,
1852                        i as HitData,
1853                    );
1854                }
1855                prop_assert_eq!(info.hit_regions.len(), region_count);
1856            }
1857
1858            /// set_mode correctly maps index to mode.
1859            #[test]
1860            fn set_mode_maps_correctly(mode_idx in 0u8..10) {
1861                let mut state = InspectorState::new();
1862                state.set_mode(mode_idx);
1863                let expected = match mode_idx {
1864                    0 => InspectorMode::Off,
1865                    1 => InspectorMode::HitRegions,
1866                    2 => InspectorMode::WidgetBounds,
1867                    3 => InspectorMode::Full,
1868                    _ => InspectorMode::Full, // Saturates at max
1869                };
1870                prop_assert_eq!(state.mode, expected);
1871            }
1872
1873            /// should_show_hits respects both mode and toggle flag.
1874            #[test]
1875            fn should_show_hits_respects_both(mode_idx in 0u8..4, flag in proptest::bool::ANY) {
1876                let mut state = InspectorState::new();
1877                state.set_mode(mode_idx);
1878                state.show_hits = flag;
1879                let mode_allows = state.mode.show_hit_regions();
1880                prop_assert_eq!(state.should_show_hits(), flag && mode_allows);
1881            }
1882
1883            /// should_show_bounds respects both mode and toggle flag.
1884            #[test]
1885            fn should_show_bounds_respects_both(mode_idx in 0u8..4, flag in proptest::bool::ANY) {
1886                let mut state = InspectorState::new();
1887                state.set_mode(mode_idx);
1888                state.show_bounds = flag;
1889                let mode_allows = state.mode.show_widget_bounds();
1890                prop_assert_eq!(state.should_show_bounds(), flag && mode_allows);
1891            }
1892        }
1893    }
1894
1895    // =========================================================================
1896    // Region Color Coverage Tests (bd-17h9.6)
1897    // =========================================================================
1898
1899    #[test]
1900    fn region_color_all_variants() {
1901        let style = InspectorStyle::default();
1902
1903        // Each region type returns a distinct (or appropriate) color
1904        let none_color = style.region_color(HitRegion::None);
1905        let content_color = style.region_color(HitRegion::Content);
1906        let border_color = style.region_color(HitRegion::Border);
1907        let scrollbar_color = style.region_color(HitRegion::Scrollbar);
1908        let handle_color = style.region_color(HitRegion::Handle);
1909        let button_color = style.region_color(HitRegion::Button);
1910        let link_color = style.region_color(HitRegion::Link);
1911        let custom_color = style.region_color(HitRegion::Custom(42));
1912
1913        // None returns transparent
1914        assert_eq!(none_color, PackedRgba::TRANSPARENT);
1915
1916        // Other regions return non-transparent colors
1917        assert_ne!(content_color.a(), 0);
1918        assert_ne!(border_color.a(), 0);
1919        assert_ne!(scrollbar_color.a(), 0);
1920        assert_ne!(handle_color.a(), 0);
1921        assert_ne!(button_color.a(), 0);
1922        assert_ne!(link_color.a(), 0);
1923        assert_ne!(custom_color.a(), 0);
1924
1925        // Verify they are semi-transparent (not fully opaque)
1926        assert!(content_color.a() < 255);
1927        assert!(button_color.a() < 255);
1928    }
1929
1930    #[test]
1931    fn region_color_custom_variants() {
1932        let style = InspectorStyle::default();
1933
1934        // All Custom variants return the same color
1935        let c0 = style.region_color(HitRegion::Custom(0));
1936        let c1 = style.region_color(HitRegion::Custom(1));
1937        let c255 = style.region_color(HitRegion::Custom(255));
1938
1939        assert_eq!(c0, c1);
1940        assert_eq!(c1, c255);
1941    }
1942
1943    // =========================================================================
1944    // Should-Show Methods Tests (bd-17h9.6)
1945    // =========================================================================
1946
1947    #[test]
1948    fn should_show_hits_requires_both_mode_and_flag() {
1949        let mut state = InspectorState::new();
1950
1951        // Off mode: never show
1952        state.mode = InspectorMode::Off;
1953        state.show_hits = true;
1954        assert!(!state.should_show_hits());
1955
1956        // HitRegions mode with flag on: show
1957        state.mode = InspectorMode::HitRegions;
1958        state.show_hits = true;
1959        assert!(state.should_show_hits());
1960
1961        // HitRegions mode with flag off: don't show
1962        state.show_hits = false;
1963        assert!(!state.should_show_hits());
1964
1965        // WidgetBounds mode: doesn't show hits
1966        state.mode = InspectorMode::WidgetBounds;
1967        state.show_hits = true;
1968        assert!(!state.should_show_hits());
1969
1970        // Full mode with flag on: show
1971        state.mode = InspectorMode::Full;
1972        state.show_hits = true;
1973        assert!(state.should_show_hits());
1974    }
1975
1976    #[test]
1977    fn should_show_bounds_requires_both_mode_and_flag() {
1978        let mut state = InspectorState::new();
1979
1980        // Off mode: never show
1981        state.mode = InspectorMode::Off;
1982        state.show_bounds = true;
1983        assert!(!state.should_show_bounds());
1984
1985        // WidgetBounds mode with flag on: show
1986        state.mode = InspectorMode::WidgetBounds;
1987        state.show_bounds = true;
1988        assert!(state.should_show_bounds());
1989
1990        // WidgetBounds mode with flag off: don't show
1991        state.show_bounds = false;
1992        assert!(!state.should_show_bounds());
1993
1994        // HitRegions mode: doesn't show bounds
1995        state.mode = InspectorMode::HitRegions;
1996        state.show_bounds = true;
1997        assert!(!state.should_show_bounds());
1998
1999        // Full mode with flag on: show
2000        state.mode = InspectorMode::Full;
2001        state.show_bounds = true;
2002        assert!(state.should_show_bounds());
2003    }
2004
2005    // =========================================================================
2006    // Overlay Rendering Tests (bd-17h9.6)
2007    // =========================================================================
2008
2009    #[test]
2010    fn overlay_respects_mode_hit_regions_only() {
2011        let mut state = InspectorState::new();
2012        state.mode = InspectorMode::HitRegions;
2013
2014        // Register a widget for bounds drawing BEFORE creating overlay
2015        state.register_widget(WidgetInfo::new("TestWidget", Rect::new(5, 5, 10, 3)));
2016
2017        let overlay = InspectorOverlay::new(&state);
2018        let mut pool = GraphemePool::new();
2019        let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2020
2021        // Register a hit region
2022        frame.register_hit(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Button, 0);
2023
2024        let area = Rect::new(0, 0, 20, 10);
2025        overlay.render(area, &mut frame);
2026
2027        // In HitRegions mode, bounds should NOT be rendered
2028        // (We can verify by checking that widget info bounds area is not drawn)
2029        assert!(state.should_show_hits());
2030        assert!(!state.should_show_bounds());
2031    }
2032
2033    #[test]
2034    fn overlay_respects_mode_widget_bounds_only() {
2035        let mut state = InspectorState::new();
2036        state.mode = InspectorMode::WidgetBounds;
2037        state.show_names = true;
2038
2039        // Register widget
2040        state.register_widget(WidgetInfo::new("TestWidget", Rect::new(2, 2, 15, 5)));
2041
2042        let overlay = InspectorOverlay::new(&state);
2043        let mut pool = GraphemePool::new();
2044        let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2045
2046        let area = Rect::new(0, 0, 20, 10);
2047        overlay.render(area, &mut frame);
2048
2049        // In WidgetBounds mode, hits should NOT be shown
2050        assert!(!state.should_show_hits());
2051        assert!(state.should_show_bounds());
2052    }
2053
2054    #[test]
2055    fn overlay_full_mode_shows_both() {
2056        let mut state = InspectorState::new();
2057        state.mode = InspectorMode::Full;
2058
2059        // Register widget
2060        state.register_widget(WidgetInfo::new("FullTest", Rect::new(0, 0, 10, 5)));
2061
2062        let overlay = InspectorOverlay::new(&state);
2063        let mut pool = GraphemePool::new();
2064        let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2065
2066        frame.register_hit(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Content, 0);
2067
2068        let area = Rect::new(0, 0, 20, 10);
2069        overlay.render(area, &mut frame);
2070
2071        assert!(state.should_show_hits());
2072        assert!(state.should_show_bounds());
2073    }
2074
2075    #[test]
2076    fn overlay_detail_panel_renders_when_enabled() {
2077        let mut state = InspectorState::new();
2078        state.mode = InspectorMode::Full;
2079        state.show_detail_panel = true;
2080        state.set_hover(Some((5, 5)));
2081
2082        let overlay = InspectorOverlay::new(&state);
2083        let mut pool = GraphemePool::new();
2084        let mut frame = Frame::with_hit_grid(50, 25, &mut pool);
2085
2086        let area = Rect::new(0, 0, 50, 25);
2087        overlay.render(area, &mut frame);
2088
2089        // The detail panel is 24 chars wide, rendered at right edge
2090        // Panel should be at x = 50 - 24 - 1 = 25
2091        // Check that something is rendered in the panel area
2092        let panel_x = 25;
2093        let panel_y = 1;
2094
2095        // Panel background should be the label_bg color
2096        let cell = frame.buffer.get(panel_x + 1, panel_y + 1);
2097        assert!(cell.is_some());
2098    }
2099
2100    #[test]
2101    fn overlay_without_hit_grid_shows_warning() {
2102        let mut state = InspectorState::new();
2103        state.mode = InspectorMode::HitRegions;
2104
2105        let overlay = InspectorOverlay::new(&state);
2106        let mut pool = GraphemePool::new();
2107        // Frame without hit grid
2108        let mut frame = Frame::new(40, 10, &mut pool);
2109
2110        let area = Rect::new(0, 0, 40, 10);
2111        overlay.render(area, &mut frame);
2112
2113        // Warning message "HitGrid not enabled" should be centered
2114        // The message is 20 chars, centered in 40 char width = starts at x=10
2115        // Check first char is 'H' from "HitGrid"
2116        if let Some(cell) = frame.buffer.get(10, 0) {
2117            assert_eq!(cell.content.as_char(), Some('H'));
2118        }
2119    }
2120
2121    // =========================================================================
2122    // Widget Tree Rendering Tests (bd-17h9.6)
2123    // =========================================================================
2124
2125    #[test]
2126    fn nested_widgets_render_with_depth_colors() {
2127        let mut state = InspectorState::new();
2128        state.mode = InspectorMode::WidgetBounds;
2129        state.show_names = false; // Disable names for clearer test
2130
2131        // Create nested widget tree
2132        let mut parent = WidgetInfo::new("Parent", Rect::new(0, 0, 30, 20)).with_depth(0);
2133        let child = WidgetInfo::new("Child", Rect::new(2, 2, 26, 16)).with_depth(1);
2134        parent.add_child(child);
2135
2136        state.register_widget(parent);
2137
2138        let overlay = InspectorOverlay::new(&state);
2139        let mut pool = GraphemePool::new();
2140        let mut frame = Frame::with_hit_grid(40, 25, &mut pool);
2141
2142        let area = Rect::new(0, 0, 40, 25);
2143        overlay.render(area, &mut frame);
2144
2145        // Parent outline at depth 0 uses bound_colors[0]
2146        // Child outline at depth 1 uses bound_colors[1]
2147        let style = InspectorStyle::default();
2148        let parent_color = style.bound_color(0);
2149        let child_color = style.bound_color(1);
2150
2151        // Verify different colors are used
2152        assert_ne!(parent_color, child_color);
2153    }
2154
2155    #[test]
2156    fn widget_with_empty_name_skips_label() {
2157        let mut state = InspectorState::new();
2158        state.mode = InspectorMode::WidgetBounds;
2159        state.show_names = true;
2160
2161        // Widget with empty name
2162        state.register_widget(WidgetInfo::new("", Rect::new(5, 5, 10, 5)));
2163
2164        let overlay = InspectorOverlay::new(&state);
2165        let mut pool = GraphemePool::new();
2166        let mut frame = Frame::with_hit_grid(20, 15, &mut pool);
2167
2168        let area = Rect::new(0, 0, 20, 15);
2169        overlay.render(area, &mut frame);
2170
2171        // Should not panic; empty name is handled gracefully
2172    }
2173
2174    // =========================================================================
2175    // Hit Info Edge Cases (bd-17h9.6)
2176    // =========================================================================
2177
2178    #[test]
2179    fn hit_info_all_region_types() {
2180        let regions = [
2181            HitRegion::None,
2182            HitRegion::Content,
2183            HitRegion::Border,
2184            HitRegion::Scrollbar,
2185            HitRegion::Handle,
2186            HitRegion::Button,
2187            HitRegion::Link,
2188            HitRegion::Custom(0),
2189            HitRegion::Custom(255),
2190        ];
2191
2192        for region in regions {
2193            let cell = HitCell::new(HitId::new(1), region, 42);
2194            let info = HitInfo::from_cell(&cell, 10, 20);
2195
2196            let info = info.expect("should create info");
2197            assert_eq!(info.region, region);
2198            assert_eq!(info.data, 42);
2199        }
2200    }
2201
2202    #[test]
2203    fn hit_cell_with_zero_data() {
2204        let cell = HitCell::new(HitId::new(5), HitRegion::Content, 0);
2205        let info = HitInfo::from_cell(&cell, 0, 0).unwrap();
2206        assert_eq!(info.data, 0);
2207    }
2208
2209    #[test]
2210    fn hit_cell_with_max_data() {
2211        let cell = HitCell::new(HitId::new(5), HitRegion::Content, u64::MAX);
2212        let info = HitInfo::from_cell(&cell, 0, 0).unwrap();
2213        assert_eq!(info.data, u64::MAX);
2214    }
2215
2216    // =========================================================================
2217    // State Initialization Tests (bd-17h9.6)
2218    // =========================================================================
2219
2220    #[test]
2221    fn inspector_state_new_defaults() {
2222        let state = InspectorState::new();
2223
2224        // Verify all defaults
2225        assert_eq!(state.mode, InspectorMode::Off);
2226        assert!(state.hover_pos.is_none());
2227        assert!(state.selected.is_none());
2228        assert!(state.widgets.is_empty());
2229        assert!(!state.show_detail_panel);
2230        assert!(state.show_hits);
2231        assert!(state.show_bounds);
2232        assert!(state.show_names);
2233        assert!(!state.show_times);
2234    }
2235
2236    #[test]
2237    fn inspector_state_default_matches_new() {
2238        let state_new = InspectorState::new();
2239        let state_default = InspectorState::default();
2240
2241        // Most fields should match (but new() sets show_hits/bounds/names to true)
2242        assert_eq!(state_new.mode, state_default.mode);
2243        assert_eq!(state_new.hover_pos, state_default.hover_pos);
2244        assert_eq!(state_new.selected, state_default.selected);
2245    }
2246
2247    #[test]
2248    fn inspector_style_colors_are_semi_transparent() {
2249        let style = InspectorStyle::default();
2250
2251        // hit_overlay should be semi-transparent
2252        assert!(style.hit_overlay.a() > 0);
2253        assert!(style.hit_overlay.a() < 255);
2254
2255        // hit_hover should be semi-transparent
2256        assert!(style.hit_hover.a() > 0);
2257        assert!(style.hit_hover.a() < 255);
2258
2259        // selected_highlight should be semi-transparent
2260        assert!(style.selected_highlight.a() > 0);
2261        assert!(style.selected_highlight.a() < 255);
2262
2263        // label_bg should be nearly opaque
2264        assert!(style.label_bg.a() > 128);
2265    }
2266
2267    #[cfg(feature = "tracing")]
2268    #[test]
2269    fn telemetry_spans_and_events() {
2270        // This test mostly verifies that the code compiles with tracing macros.
2271        // Verifying actual output would require a custom subscriber which is overkill here.
2272        let mut state = InspectorState::new();
2273        state.toggle(); // Should log "Inspector toggled"
2274
2275        let overlay = InspectorOverlay::new(&state);
2276        let mut pool = GraphemePool::new();
2277        let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2278
2279        let area = Rect::new(0, 0, 20, 10);
2280        overlay.render(area, &mut frame); // Should enter "inspector_overlay" span
2281    }
2282
2283    #[test]
2284    fn diagnostic_entry_checksum_deterministic() {
2285        let entry1 = DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
2286            .with_previous_mode(InspectorMode::Off)
2287            .with_mode(InspectorMode::Full)
2288            .with_flag("hits", true)
2289            .with_context("test")
2290            .with_checksum();
2291        let entry2 = DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
2292            .with_previous_mode(InspectorMode::Off)
2293            .with_mode(InspectorMode::Full)
2294            .with_flag("hits", true)
2295            .with_context("test")
2296            .with_checksum();
2297        assert_eq!(entry1.checksum, entry2.checksum);
2298        assert_ne!(entry1.checksum, 0);
2299    }
2300
2301    #[test]
2302    fn diagnostic_log_records_mode_changes() {
2303        let mut state = InspectorState::new().with_diagnostics();
2304        state.set_mode(1);
2305        state.set_mode(2);
2306        let log = state.diagnostic_log().expect("diagnostic log should exist");
2307        assert!(!log.entries().is_empty());
2308        assert!(
2309            !log.entries_of_kind(DiagnosticEventKind::ModeChanged)
2310                .is_empty()
2311        );
2312    }
2313
2314    #[test]
2315    fn telemetry_hooks_on_mode_change_fires() {
2316        use std::sync::Arc;
2317        use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
2318
2319        let counter = Arc::new(AtomicUsize::new(0));
2320        let counter_clone = Arc::clone(&counter);
2321        let hooks = TelemetryHooks::new().on_mode_change(move |_| {
2322            counter_clone.fetch_add(1, AtomicOrdering::Relaxed);
2323        });
2324
2325        let mut state = InspectorState::new().with_telemetry_hooks(hooks);
2326        state.set_mode(1);
2327        state.set_mode(2);
2328        assert!(counter.load(AtomicOrdering::Relaxed) >= 1);
2329    }
2330
2331    // =========================================================================
2332    // Accessibility/UX Tests (bd-17h9.9)
2333    // =========================================================================
2334
2335    /// Calculate relative luminance for WCAG contrast calculation.
2336    /// Formula: https://www.w3.org/TR/WCAG20/#relativeluminancedef
2337    fn relative_luminance(rgba: PackedRgba) -> f64 {
2338        fn channel_luminance(c: u8) -> f64 {
2339            let c = c as f64 / 255.0;
2340            if c <= 0.03928 {
2341                c / 12.92
2342            } else {
2343                ((c + 0.055) / 1.055).powf(2.4)
2344            }
2345        }
2346        let r = channel_luminance(rgba.r());
2347        let g = channel_luminance(rgba.g());
2348        let b = channel_luminance(rgba.b());
2349        0.2126 * r + 0.7152 * g + 0.0722 * b
2350    }
2351
2352    /// Calculate WCAG contrast ratio between two colors.
2353    /// Returns ratio in range [1.0, 21.0].
2354    fn contrast_ratio(fg: PackedRgba, bg: PackedRgba) -> f64 {
2355        let l1 = relative_luminance(fg);
2356        let l2 = relative_luminance(bg);
2357        let lighter = l1.max(l2);
2358        let darker = l1.min(l2);
2359        (lighter + 0.05) / (darker + 0.05)
2360    }
2361
2362    #[test]
2363    fn a11y_label_contrast_meets_wcag_aa() {
2364        // WCAG AA requires 4.5:1 for normal text, 3:1 for large text
2365        // Labels in inspector are typically large (widget names), so 3:1 is sufficient
2366        let style = InspectorStyle::default();
2367        let ratio = contrast_ratio(style.label_fg, style.label_bg);
2368        assert!(
2369            ratio >= 3.0,
2370            "Label contrast ratio {:.2}:1 should be >= 3:1 (WCAG AA large text)",
2371            ratio
2372        );
2373        // Actually we exceed 4.5:1 (white on dark bg)
2374        assert!(
2375            ratio >= 4.5,
2376            "Label contrast ratio {:.2}:1 should be >= 4.5:1 (WCAG AA normal text)",
2377            ratio
2378        );
2379    }
2380
2381    #[test]
2382    fn a11y_bound_colors_are_distinct() {
2383        // Ensure bound colors are visually distinct from each other
2384        // by checking they have different hues
2385        let style = InspectorStyle::default();
2386        let colors = &style.bound_colors;
2387
2388        // All pairs should have at least one channel differing by 100+
2389        for (i, a) in colors.iter().enumerate() {
2390            for (j, b) in colors.iter().enumerate() {
2391                if i != j {
2392                    let r_diff = (a.r() as i32 - b.r() as i32).abs();
2393                    let g_diff = (a.g() as i32 - b.g() as i32).abs();
2394                    let b_diff = (a.b() as i32 - b.b() as i32).abs();
2395                    let max_diff = r_diff.max(g_diff).max(b_diff);
2396                    assert!(
2397                        max_diff >= 100,
2398                        "Bound colors {} and {} should differ by at least 100 in one channel (max diff = {})",
2399                        i,
2400                        j,
2401                        max_diff
2402                    );
2403                }
2404            }
2405        }
2406    }
2407
2408    #[test]
2409    fn a11y_bound_colors_have_good_visibility() {
2410        // All bound colors should be bright enough to be visible
2411        // At least one channel should be >= 100
2412        let style = InspectorStyle::default();
2413        for (i, color) in style.bound_colors.iter().enumerate() {
2414            let max_channel = color.r().max(color.g()).max(color.b());
2415            assert!(
2416                max_channel >= 100,
2417                "Bound color {} should have at least one channel >= 100 for visibility (max = {})",
2418                i,
2419                max_channel
2420            );
2421        }
2422    }
2423
2424    #[test]
2425    fn a11y_hit_overlays_are_visible() {
2426        // Hit overlays should have enough alpha to be visible
2427        // but not so much that they obscure content
2428        let style = InspectorStyle::default();
2429
2430        // hit_overlay (normal state) - should be visible but subtle
2431        assert!(
2432            style.hit_overlay.a() >= 50,
2433            "hit_overlay alpha {} should be >= 50 for visibility",
2434            style.hit_overlay.a()
2435        );
2436
2437        // hit_hover (hover state) - should be more prominent
2438        assert!(
2439            style.hit_hover.a() >= 80,
2440            "hit_hover alpha {} should be >= 80 for clear hover indication",
2441            style.hit_hover.a()
2442        );
2443        assert!(
2444            style.hit_hover.a() > style.hit_overlay.a(),
2445            "hit_hover should be more visible than hit_overlay"
2446        );
2447
2448        // selected_highlight - should be the most prominent
2449        assert!(
2450            style.selected_highlight.a() >= 100,
2451            "selected_highlight alpha {} should be >= 100 for clear selection",
2452            style.selected_highlight.a()
2453        );
2454    }
2455
2456    #[test]
2457    fn a11y_region_colors_cover_all_variants() {
2458        // Ensure all HitRegion variants have a defined color
2459        let style = InspectorStyle::default();
2460        let regions = [
2461            HitRegion::None,
2462            HitRegion::Content,
2463            HitRegion::Border,
2464            HitRegion::Scrollbar,
2465            HitRegion::Handle,
2466            HitRegion::Button,
2467            HitRegion::Link,
2468            HitRegion::Custom(0),
2469        ];
2470
2471        for region in regions {
2472            let color = style.region_color(region);
2473            // None should be transparent, others should be visible
2474            match region {
2475                HitRegion::None => {
2476                    assert_eq!(
2477                        color,
2478                        PackedRgba::TRANSPARENT,
2479                        "HitRegion::None should be transparent"
2480                    );
2481                }
2482                _ => {
2483                    assert!(
2484                        color.a() > 0,
2485                        "HitRegion::{:?} should have non-zero alpha",
2486                        region
2487                    );
2488                }
2489            }
2490        }
2491    }
2492
2493    #[test]
2494    fn a11y_interactive_regions_are_distinct_from_passive() {
2495        // Interactive regions (Button, Link) should be visually distinct
2496        // from passive regions (Content, Border)
2497        let style = InspectorStyle::default();
2498
2499        let button_color = style.region_color(HitRegion::Button);
2500        let link_color = style.region_color(HitRegion::Link);
2501        let content_color = style.region_color(HitRegion::Content);
2502        let _border_color = style.region_color(HitRegion::Border);
2503
2504        // Button and Link should be more visible (higher alpha) than passive regions
2505        assert!(
2506            button_color.a() >= content_color.a(),
2507            "Button overlay should be as visible or more visible than Content"
2508        );
2509        assert!(
2510            link_color.a() >= content_color.a(),
2511            "Link overlay should be as visible or more visible than Content"
2512        );
2513
2514        // Button and Link should differ from Content by color (not just alpha)
2515        let button_content_diff = (button_color.r() as i32 - content_color.r() as i32).abs()
2516            + (button_color.g() as i32 - content_color.g() as i32).abs()
2517            + (button_color.b() as i32 - content_color.b() as i32).abs();
2518        assert!(
2519            button_content_diff >= 100,
2520            "Button color should differ significantly from Content (diff = {})",
2521            button_content_diff
2522        );
2523    }
2524
2525    #[test]
2526    fn a11y_keybinding_constants_documented() {
2527        // This test documents the expected keybindings per spec.
2528        // It doesn't test runtime behavior, but serves as a reminder
2529        // of accessibility considerations for keybindings:
2530        //
2531        // Primary activations (accessible):
2532        //   - F12: Toggle inspector
2533        //   - Ctrl+Shift+I: Alternative toggle (browser devtools pattern)
2534        //
2535        // Mode selection (may conflict with text input):
2536        //   - i: Cycle modes
2537        //   - 0-3: Direct mode selection
2538        //
2539        // Navigation (accessible):
2540        //   - Tab/Shift+Tab: Widget cycling
2541        //   - Escape: Clear selection
2542        //   - Enter: Expand/collapse
2543        //
2544        // Toggles (may conflict with text input):
2545        //   - h: Toggle hits, b: bounds, n: names, t: times
2546        //   - d: Toggle detail panel
2547        //
2548        // Recommendation: When inspector is active and focused,
2549        // these single-letter keys should work. When a text input
2550        // has focus, pass through to the input.
2551
2552        // This test passes if it compiles - it's documentation-as-code
2553        // (Assertion removed as it was always true)
2554    }
2555
2556    // =========================================================================
2557    // Stress/Performance Regression Tests (bd-17h9.4)
2558    // =========================================================================
2559
2560    use std::collections::hash_map::DefaultHasher;
2561    use std::hash::{Hash, Hasher};
2562    use std::time::Instant;
2563
2564    fn inspector_seed() -> u64 {
2565        std::env::var("INSPECTOR_SEED")
2566            .ok()
2567            .and_then(|s| s.parse().ok())
2568            .unwrap_or(42)
2569    }
2570
2571    fn next_u32(seed: &mut u64) -> u32 {
2572        let mut x = *seed;
2573        x ^= x << 13;
2574        x ^= x >> 7;
2575        x ^= x << 17;
2576        *seed = x;
2577        (x >> 32) as u32
2578    }
2579
2580    fn rand_range(seed: &mut u64, min: u16, max: u16) -> u16 {
2581        if min >= max {
2582            return min;
2583        }
2584        let span = (max - min) as u32 + 1;
2585        let n = next_u32(seed) % span;
2586        min + n as u16
2587    }
2588
2589    fn random_rect(seed: &mut u64, area: Rect) -> Rect {
2590        let max_w = area.width.max(1);
2591        let max_h = area.height.max(1);
2592        let w = rand_range(seed, 1, max_w);
2593        let h = rand_range(seed, 1, max_h);
2594        let max_x = area.x + area.width.saturating_sub(w);
2595        let max_y = area.y + area.height.saturating_sub(h);
2596        let x = rand_range(seed, area.x, max_x);
2597        let y = rand_range(seed, area.y, max_y);
2598        Rect::new(x, y, w, h)
2599    }
2600
2601    fn build_widget_tree(
2602        seed: &mut u64,
2603        depth: u8,
2604        max_depth: u8,
2605        breadth: u8,
2606        area: Rect,
2607        count: &mut usize,
2608    ) -> WidgetInfo {
2609        *count += 1;
2610        let name = format!("Widget_{depth}_{}", *count);
2611        let mut node = WidgetInfo::new(name, area).with_depth(depth);
2612
2613        if depth < max_depth {
2614            for _ in 0..breadth {
2615                let child_area = random_rect(seed, area);
2616                let child =
2617                    build_widget_tree(seed, depth + 1, max_depth, breadth, child_area, count);
2618                node.add_child(child);
2619            }
2620        }
2621
2622        node
2623    }
2624
2625    fn build_stress_state(
2626        seed: &mut u64,
2627        roots: usize,
2628        max_depth: u8,
2629        breadth: u8,
2630        area: Rect,
2631    ) -> (InspectorState, usize) {
2632        let mut state = InspectorState {
2633            mode: InspectorMode::Full,
2634            show_hits: true,
2635            show_bounds: true,
2636            show_names: true,
2637            show_detail_panel: true,
2638            hover_pos: Some((area.x + 1, area.y + 1)),
2639            ..Default::default()
2640        };
2641
2642        let mut count = 0usize;
2643        for _ in 0..roots {
2644            let root_area = random_rect(seed, area);
2645            let widget = build_widget_tree(seed, 0, max_depth, breadth, root_area, &mut count);
2646            state.register_widget(widget);
2647        }
2648
2649        (state, count)
2650    }
2651
2652    fn populate_hit_grid(frame: &mut Frame, seed: &mut u64, count: usize, area: Rect) -> usize {
2653        for idx in 0..count {
2654            let region = match idx % 6 {
2655                0 => HitRegion::Content,
2656                1 => HitRegion::Border,
2657                2 => HitRegion::Scrollbar,
2658                3 => HitRegion::Handle,
2659                4 => HitRegion::Button,
2660                _ => HitRegion::Link,
2661            };
2662            let rect = random_rect(seed, area);
2663            frame.register_hit(rect, HitId::new((idx + 1) as u32), region, idx as HitData);
2664        }
2665        count
2666    }
2667
2668    fn buffer_checksum(frame: &Frame) -> u64 {
2669        let mut hasher = DefaultHasher::new();
2670        let mut scratch = String::new();
2671        for y in 0..frame.buffer.height() {
2672            for x in 0..frame.buffer.width() {
2673                if let Some(cell) = frame.buffer.get(x, y) {
2674                    scratch.clear();
2675                    use std::fmt::Write;
2676                    let _ = write!(&mut scratch, "{cell:?}");
2677                    scratch.hash(&mut hasher);
2678                }
2679            }
2680        }
2681        hasher.finish()
2682    }
2683
2684    fn log_jsonl(event: &str, fields: &[(&str, String)]) {
2685        let mut parts = Vec::with_capacity(fields.len() + 1);
2686        parts.push(format!(r#""event":"{event}""#));
2687        parts.extend(fields.iter().map(|(k, v)| format!(r#""{k}":{v}"#)));
2688        eprintln!("{{{}}}", parts.join(","));
2689    }
2690
2691    #[test]
2692    fn inspector_stress_large_tree_renders() {
2693        let mut seed = inspector_seed();
2694        let area = Rect::new(0, 0, 160, 48);
2695        let (state, widget_count) = build_stress_state(&mut seed, 6, 3, 3, area);
2696
2697        let mut pool = GraphemePool::new();
2698        let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2699        let hit_count = populate_hit_grid(&mut frame, &mut seed, 800, area);
2700
2701        let overlay = InspectorOverlay::new(&state);
2702        overlay.render(area, &mut frame);
2703
2704        let checksum = buffer_checksum(&frame);
2705        log_jsonl(
2706            "inspector_stress_render",
2707            &[
2708                ("seed", seed.to_string()),
2709                ("widgets", widget_count.to_string()),
2710                ("hit_regions", hit_count.to_string()),
2711                ("checksum", format!(r#""0x{checksum:016x}""#)),
2712            ],
2713        );
2714
2715        assert!(checksum != 0, "Rendered buffer checksum should be non-zero");
2716    }
2717
2718    #[test]
2719    fn inspector_stress_checksum_is_deterministic() {
2720        let seed = inspector_seed();
2721        let area = Rect::new(0, 0, 140, 40);
2722
2723        let checksum_a = {
2724            let mut seed = seed;
2725            let (state, _) = build_stress_state(&mut seed, 5, 3, 3, area);
2726            let mut pool = GraphemePool::new();
2727            let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2728            populate_hit_grid(&mut frame, &mut seed, 600, area);
2729            InspectorOverlay::new(&state).render(area, &mut frame);
2730            buffer_checksum(&frame)
2731        };
2732
2733        let checksum_b = {
2734            let mut seed = seed;
2735            let (state, _) = build_stress_state(&mut seed, 5, 3, 3, area);
2736            let mut pool = GraphemePool::new();
2737            let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2738            populate_hit_grid(&mut frame, &mut seed, 600, area);
2739            InspectorOverlay::new(&state).render(area, &mut frame);
2740            buffer_checksum(&frame)
2741        };
2742
2743        log_jsonl(
2744            "inspector_stress_determinism",
2745            &[
2746                ("seed", seed.to_string()),
2747                ("checksum_a", format!(r#""0x{checksum_a:016x}""#)),
2748                ("checksum_b", format!(r#""0x{checksum_b:016x}""#)),
2749            ],
2750        );
2751
2752        assert_eq!(
2753            checksum_a, checksum_b,
2754            "Stress render checksum should be deterministic"
2755        );
2756    }
2757
2758    #[test]
2759    fn inspector_perf_budget_overlay() {
2760        let seed = inspector_seed();
2761        let area = Rect::new(0, 0, 160, 48);
2762        let iterations = 40usize;
2763        let budget_p95_us = 15_000u64;
2764
2765        let mut timings = Vec::with_capacity(iterations);
2766        let mut checksums = Vec::with_capacity(iterations);
2767
2768        for i in 0..iterations {
2769            let mut seed = seed.wrapping_add(i as u64);
2770            let (state, widget_count) = build_stress_state(&mut seed, 6, 3, 3, area);
2771            let mut pool = GraphemePool::new();
2772            let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2773            let hit_count = populate_hit_grid(&mut frame, &mut seed, 800, area);
2774
2775            let start = Instant::now();
2776            InspectorOverlay::new(&state).render(area, &mut frame);
2777            let elapsed_us = start.elapsed().as_micros() as u64;
2778            timings.push(elapsed_us);
2779
2780            let checksum = buffer_checksum(&frame);
2781            checksums.push(checksum);
2782
2783            if i == 0 {
2784                log_jsonl(
2785                    "inspector_perf_sample",
2786                    &[
2787                        ("seed", seed.to_string()),
2788                        ("widgets", widget_count.to_string()),
2789                        ("hit_regions", hit_count.to_string()),
2790                        ("timing_us", elapsed_us.to_string()),
2791                        ("checksum", format!(r#""0x{checksum:016x}""#)),
2792                    ],
2793                );
2794            }
2795        }
2796
2797        let mut sorted = timings.clone();
2798        sorted.sort_unstable();
2799        let p95 = sorted[sorted.len() * 95 / 100];
2800        let p99 = sorted[sorted.len() * 99 / 100];
2801        let avg = timings.iter().sum::<u64>() as f64 / timings.len() as f64;
2802
2803        let mut seq_hasher = DefaultHasher::new();
2804        for checksum in &checksums {
2805            checksum.hash(&mut seq_hasher);
2806        }
2807        let seq_checksum = seq_hasher.finish();
2808
2809        log_jsonl(
2810            "inspector_perf_budget",
2811            &[
2812                ("seed", seed.to_string()),
2813                ("iterations", iterations.to_string()),
2814                ("avg_us", format!("{:.2}", avg)),
2815                ("p95_us", p95.to_string()),
2816                ("p99_us", p99.to_string()),
2817                ("budget_p95_us", budget_p95_us.to_string()),
2818                ("sequence_checksum", format!(r#""0x{seq_checksum:016x}""#)),
2819            ],
2820        );
2821
2822        assert!(
2823            p95 <= budget_p95_us,
2824            "Inspector overlay p95 {}µs exceeds budget {}µs",
2825            p95,
2826            budget_p95_us
2827        );
2828    }
2829}