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