1#![forbid(unsafe_code)]
2
3use ftui_core::geometry::Rect;
32use ftui_render::cell::{Cell, PackedRgba};
33use ftui_render::frame::{Frame, HitCell, HitData, HitId, HitRegion};
34use ftui_text::display_width;
35
36use crate::{Widget, draw_text_span, set_style_area};
37use ftui_style::Style;
38use std::io::Write;
39use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
40use std::time::Instant;
41
42#[cfg(feature = "tracing")]
43use tracing::{info_span, trace};
44
45static INSPECTOR_DIAGNOSTICS_ENABLED: AtomicBool = AtomicBool::new(false);
51static INSPECTOR_EVENT_COUNTER: AtomicU64 = AtomicU64::new(0);
53
54pub fn init_diagnostics() {
56 let enabled = std::env::var("FTUI_INSPECTOR_DIAGNOSTICS")
57 .map(|v| v.eq_ignore_ascii_case("true"))
58 .unwrap_or(false);
59 INSPECTOR_DIAGNOSTICS_ENABLED.store(enabled, Ordering::Relaxed);
60}
61
62#[inline]
64pub fn diagnostics_enabled() -> bool {
65 INSPECTOR_DIAGNOSTICS_ENABLED.load(Ordering::Relaxed)
66}
67
68pub fn set_diagnostics_enabled(enabled: bool) {
70 INSPECTOR_DIAGNOSTICS_ENABLED.store(enabled, Ordering::Relaxed);
71}
72
73#[inline]
75fn next_event_seq() -> u64 {
76 INSPECTOR_EVENT_COUNTER.fetch_add(1, Ordering::Relaxed)
77}
78
79pub fn reset_event_counter() {
81 INSPECTOR_EVENT_COUNTER.store(0, Ordering::Relaxed);
82}
83
84pub fn is_deterministic_mode() -> bool {
86 std::env::var("FTUI_INSPECTOR_DETERMINISTIC")
87 .map(|v| v.eq_ignore_ascii_case("true"))
88 .unwrap_or(false)
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum DiagnosticEventKind {
94 InspectorToggled,
96 ModeChanged,
98 HoverChanged,
100 SelectionChanged,
102 DetailPanelToggled,
104 HitsToggled,
106 BoundsToggled,
108 NamesToggled,
110 TimesToggled,
112 WidgetsCleared,
114 WidgetRegistered,
116}
117
118impl DiagnosticEventKind {
119 pub const fn as_str(self) -> &'static str {
121 match self {
122 Self::InspectorToggled => "inspector_toggled",
123 Self::ModeChanged => "mode_changed",
124 Self::HoverChanged => "hover_changed",
125 Self::SelectionChanged => "selection_changed",
126 Self::DetailPanelToggled => "detail_panel_toggled",
127 Self::HitsToggled => "hits_toggled",
128 Self::BoundsToggled => "bounds_toggled",
129 Self::NamesToggled => "names_toggled",
130 Self::TimesToggled => "times_toggled",
131 Self::WidgetsCleared => "widgets_cleared",
132 Self::WidgetRegistered => "widget_registered",
133 }
134 }
135}
136
137#[derive(Debug, Clone)]
139pub struct DiagnosticEntry {
140 pub seq: u64,
142 pub timestamp_us: u64,
144 pub kind: DiagnosticEventKind,
146 pub mode: Option<InspectorMode>,
148 pub previous_mode: Option<InspectorMode>,
150 pub hover_pos: Option<(u16, u16)>,
152 pub selected: Option<HitId>,
154 pub widget_name: Option<String>,
156 pub widget_area: Option<Rect>,
158 pub widget_depth: Option<u8>,
160 pub widget_hit_id: Option<HitId>,
162 pub widget_count: Option<usize>,
164 pub flag: Option<String>,
166 pub enabled: Option<bool>,
168 pub context: Option<String>,
170 pub checksum: u64,
172}
173
174impl DiagnosticEntry {
175 pub fn new(kind: DiagnosticEventKind) -> Self {
177 let seq = next_event_seq();
178 let timestamp_us = if is_deterministic_mode() {
179 seq.saturating_mul(1_000)
180 } else {
181 static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
182 let start = START.get_or_init(Instant::now);
183 start.elapsed().as_micros() as u64
184 };
185
186 Self {
187 seq,
188 timestamp_us,
189 kind,
190 mode: None,
191 previous_mode: None,
192 hover_pos: None,
193 selected: None,
194 widget_name: None,
195 widget_area: None,
196 widget_depth: None,
197 widget_hit_id: None,
198 widget_count: None,
199 flag: None,
200 enabled: None,
201 context: None,
202 checksum: 0,
203 }
204 }
205
206 #[must_use]
208 pub fn with_mode(mut self, mode: InspectorMode) -> Self {
209 self.mode = Some(mode);
210 self
211 }
212
213 #[must_use]
215 pub fn with_previous_mode(mut self, mode: InspectorMode) -> Self {
216 self.previous_mode = Some(mode);
217 self
218 }
219
220 #[must_use]
222 pub fn with_hover_pos(mut self, pos: Option<(u16, u16)>) -> Self {
223 self.hover_pos = pos;
224 self
225 }
226
227 #[must_use]
229 pub fn with_selected(mut self, selected: Option<HitId>) -> Self {
230 self.selected = selected;
231 self
232 }
233
234 #[must_use]
236 pub fn with_widget(mut self, widget: &WidgetInfo) -> Self {
237 self.widget_name = Some(widget.name.clone());
238 self.widget_area = Some(widget.area);
239 self.widget_depth = Some(widget.depth);
240 self.widget_hit_id = widget.hit_id;
241 self
242 }
243
244 #[must_use]
246 pub fn with_widget_count(mut self, count: usize) -> Self {
247 self.widget_count = Some(count);
248 self
249 }
250
251 #[must_use]
253 pub fn with_flag(mut self, flag: impl Into<String>, enabled: bool) -> Self {
254 self.flag = Some(flag.into());
255 self.enabled = Some(enabled);
256 self
257 }
258
259 #[must_use]
261 pub fn with_context(mut self, context: impl Into<String>) -> Self {
262 self.context = Some(context.into());
263 self
264 }
265
266 #[must_use]
268 pub fn with_checksum(mut self) -> Self {
269 self.checksum = self.compute_checksum();
270 self
271 }
272
273 fn compute_checksum(&self) -> u64 {
275 let mut hash: u64 = 0xcbf29ce484222325;
276 let payload = format!(
277 "{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}",
278 self.kind,
279 self.mode,
280 self.previous_mode,
281 self.hover_pos,
282 self.selected.map(|id| id.id()),
283 self.widget_name.as_deref().unwrap_or(""),
284 self.widget_area
285 .map(|r| format!("{},{},{},{}", r.x, r.y, r.width, r.height))
286 .unwrap_or_default(),
287 self.widget_depth.unwrap_or(0),
288 self.widget_hit_id.map(|id| id.id()).unwrap_or(0),
289 self.widget_count.unwrap_or(0),
290 self.flag.as_deref().unwrap_or(""),
291 self.enabled.unwrap_or(false),
292 self.context.as_deref().unwrap_or("")
293 );
294 for &b in payload.as_bytes() {
295 hash ^= b as u64;
296 hash = hash.wrapping_mul(0x100000001b3);
297 }
298 hash
299 }
300
301 pub fn to_jsonl(&self) -> String {
303 let mut parts = vec![
304 format!("\"seq\":{}", self.seq),
305 format!("\"ts_us\":{}", self.timestamp_us),
306 format!("\"kind\":\"{}\"", self.kind.as_str()),
307 ];
308
309 if let Some(mode) = self.mode {
310 parts.push(format!("\"mode\":\"{}\"", mode.as_str()));
311 }
312 if let Some(mode) = self.previous_mode {
313 parts.push(format!("\"prev_mode\":\"{}\"", mode.as_str()));
314 }
315 if let Some((x, y)) = self.hover_pos {
316 parts.push(format!("\"hover_x\":{x}"));
317 parts.push(format!("\"hover_y\":{y}"));
318 }
319 if let Some(id) = self.selected {
320 parts.push(format!("\"selected_id\":{}", id.id()));
321 }
322 if let Some(ref name) = self.widget_name {
323 let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
324 parts.push(format!("\"widget\":\"{escaped}\""));
325 }
326 if let Some(area) = self.widget_area {
327 parts.push(format!("\"widget_x\":{}", area.x));
328 parts.push(format!("\"widget_y\":{}", area.y));
329 parts.push(format!("\"widget_w\":{}", area.width));
330 parts.push(format!("\"widget_h\":{}", area.height));
331 }
332 if let Some(depth) = self.widget_depth {
333 parts.push(format!("\"widget_depth\":{depth}"));
334 }
335 if let Some(id) = self.widget_hit_id {
336 parts.push(format!("\"widget_hit_id\":{}", id.id()));
337 }
338 if let Some(count) = self.widget_count {
339 parts.push(format!("\"widget_count\":{count}"));
340 }
341 if let Some(ref flag) = self.flag {
342 let escaped = flag.replace('\\', "\\\\").replace('"', "\\\"");
343 parts.push(format!("\"flag\":\"{escaped}\""));
344 }
345 if let Some(enabled) = self.enabled {
346 parts.push(format!("\"enabled\":{enabled}"));
347 }
348 if let Some(ref ctx) = self.context {
349 let escaped = ctx.replace('\\', "\\\\").replace('"', "\\\"");
350 parts.push(format!("\"context\":\"{escaped}\""));
351 }
352 parts.push(format!("\"checksum\":\"{:016x}\"", self.checksum));
353
354 format!("{{{}}}", parts.join(","))
355 }
356}
357
358#[derive(Debug, Default)]
360pub struct DiagnosticLog {
361 entries: Vec<DiagnosticEntry>,
362 max_entries: usize,
363 write_stderr: bool,
364}
365
366impl DiagnosticLog {
367 pub fn new() -> Self {
369 Self {
370 entries: Vec::new(),
371 max_entries: 5000,
372 write_stderr: false,
373 }
374 }
375
376 pub fn with_stderr(mut self) -> Self {
378 self.write_stderr = true;
379 self
380 }
381
382 pub fn with_max_entries(mut self, max: usize) -> Self {
384 self.max_entries = max;
385 self
386 }
387
388 pub fn record(&mut self, entry: DiagnosticEntry) {
390 if self.write_stderr {
391 let _ = writeln!(std::io::stderr(), "{}", entry.to_jsonl());
392 }
393 if self.max_entries > 0 && self.entries.len() >= self.max_entries {
394 self.entries.remove(0);
395 }
396 self.entries.push(entry);
397 }
398
399 pub fn entries(&self) -> &[DiagnosticEntry] {
401 &self.entries
402 }
403
404 pub fn entries_of_kind(&self, kind: DiagnosticEventKind) -> Vec<&DiagnosticEntry> {
406 self.entries.iter().filter(|e| e.kind == kind).collect()
407 }
408
409 pub fn clear(&mut self) {
411 self.entries.clear();
412 }
413
414 pub fn to_jsonl(&self) -> String {
416 self.entries
417 .iter()
418 .map(DiagnosticEntry::to_jsonl)
419 .collect::<Vec<_>>()
420 .join("\n")
421 }
422}
423
424pub type TelemetryCallback = Box<dyn Fn(&DiagnosticEntry) + Send + Sync>;
426
427#[derive(Default)]
429pub struct TelemetryHooks {
430 on_toggle: Option<TelemetryCallback>,
431 on_mode_change: Option<TelemetryCallback>,
432 on_hover_change: Option<TelemetryCallback>,
433 on_selection_change: Option<TelemetryCallback>,
434 on_any_event: Option<TelemetryCallback>,
435}
436
437impl std::fmt::Debug for TelemetryHooks {
438 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439 f.debug_struct("TelemetryHooks")
440 .field("on_toggle", &self.on_toggle.is_some())
441 .field("on_mode_change", &self.on_mode_change.is_some())
442 .field("on_hover_change", &self.on_hover_change.is_some())
443 .field("on_selection_change", &self.on_selection_change.is_some())
444 .field("on_any_event", &self.on_any_event.is_some())
445 .finish()
446 }
447}
448
449impl TelemetryHooks {
450 pub fn new() -> Self {
452 Self::default()
453 }
454
455 pub fn on_toggle(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
457 self.on_toggle = Some(Box::new(f));
458 self
459 }
460
461 pub fn on_mode_change(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
463 self.on_mode_change = Some(Box::new(f));
464 self
465 }
466
467 pub fn on_hover_change(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
469 self.on_hover_change = Some(Box::new(f));
470 self
471 }
472
473 pub fn on_selection_change(
475 mut self,
476 f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static,
477 ) -> Self {
478 self.on_selection_change = Some(Box::new(f));
479 self
480 }
481
482 pub fn on_any(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
484 self.on_any_event = Some(Box::new(f));
485 self
486 }
487
488 fn dispatch(&self, entry: &DiagnosticEntry) {
490 if let Some(ref cb) = self.on_any_event {
491 cb(entry);
492 }
493
494 match entry.kind {
495 DiagnosticEventKind::InspectorToggled => {
496 if let Some(ref cb) = self.on_toggle {
497 cb(entry);
498 }
499 }
500 DiagnosticEventKind::ModeChanged => {
501 if let Some(ref cb) = self.on_mode_change {
502 cb(entry);
503 }
504 }
505 DiagnosticEventKind::HoverChanged => {
506 if let Some(ref cb) = self.on_hover_change {
507 cb(entry);
508 }
509 }
510 DiagnosticEventKind::SelectionChanged => {
511 if let Some(ref cb) = self.on_selection_change {
512 cb(entry);
513 }
514 }
515 _ => {}
516 }
517 }
518}
519
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
522pub enum InspectorMode {
523 #[default]
525 Off,
526 HitRegions,
528 WidgetBounds,
530 Full,
532}
533
534impl InspectorMode {
535 #[must_use]
539 pub fn cycle(self) -> Self {
540 match self {
541 Self::Off => Self::HitRegions,
542 Self::HitRegions => Self::WidgetBounds,
543 Self::WidgetBounds => Self::Full,
544 Self::Full => Self::Off,
545 }
546 }
547
548 #[inline]
550 pub fn is_active(self) -> bool {
551 self != Self::Off
552 }
553
554 pub const fn as_str(self) -> &'static str {
556 match self {
557 Self::Off => "off",
558 Self::HitRegions => "hit_regions",
559 Self::WidgetBounds => "widget_bounds",
560 Self::Full => "full",
561 }
562 }
563
564 #[inline]
566 pub fn show_hit_regions(self) -> bool {
567 matches!(self, Self::HitRegions | Self::Full)
568 }
569
570 #[inline]
572 pub fn show_widget_bounds(self) -> bool {
573 matches!(self, Self::WidgetBounds | Self::Full)
574 }
575}
576
577#[derive(Debug, Clone)]
579pub struct WidgetInfo {
580 pub name: String,
582 pub area: Rect,
584 pub hit_id: Option<HitId>,
586 pub hit_regions: Vec<(Rect, HitRegion, HitData)>,
588 pub render_time_us: Option<u64>,
590 pub depth: u8,
592 pub children: Vec<WidgetInfo>,
594}
595
596impl WidgetInfo {
597 #[must_use]
599 pub fn new(name: impl Into<String>, area: Rect) -> Self {
600 Self {
601 name: name.into(),
602 area,
603 hit_id: None,
604 hit_regions: Vec::new(),
605 render_time_us: None,
606 depth: 0,
607 children: Vec::new(),
608 }
609 }
610
611 #[must_use]
613 pub fn with_hit_id(mut self, id: HitId) -> Self {
614 self.hit_id = Some(id);
615 self
616 }
617
618 pub fn add_hit_region(&mut self, rect: Rect, region: HitRegion, data: HitData) {
620 self.hit_regions.push((rect, region, data));
621 }
622
623 #[must_use]
625 pub fn with_depth(mut self, depth: u8) -> Self {
626 self.depth = depth;
627 self
628 }
629
630 pub fn add_child(&mut self, child: WidgetInfo) {
632 self.children.push(child);
633 }
634}
635
636#[derive(Debug, Clone)]
638pub struct InspectorStyle {
639 pub bound_colors: [PackedRgba; 6],
641 pub hit_overlay: PackedRgba,
643 pub hit_hover: PackedRgba,
645 pub selected_highlight: PackedRgba,
647 pub label_fg: PackedRgba,
649 pub label_bg: PackedRgba,
651}
652
653impl Default for InspectorStyle {
654 fn default() -> Self {
655 Self {
656 bound_colors: [
657 PackedRgba::rgb(255, 100, 100), PackedRgba::rgb(100, 255, 100), PackedRgba::rgb(100, 100, 255), PackedRgba::rgb(255, 255, 100), PackedRgba::rgb(255, 100, 255), PackedRgba::rgb(100, 255, 255), ],
664 hit_overlay: PackedRgba::rgba(255, 165, 0, 80), hit_hover: PackedRgba::rgba(255, 255, 0, 120), selected_highlight: PackedRgba::rgba(0, 200, 255, 150), label_fg: PackedRgba::WHITE,
668 label_bg: PackedRgba::rgba(0, 0, 0, 200),
669 }
670 }
671}
672
673impl InspectorStyle {
674 #[inline]
676 pub fn bound_color(&self, depth: u8) -> PackedRgba {
677 self.bound_colors[depth as usize % self.bound_colors.len()]
678 }
679
680 pub fn region_color(&self, region: HitRegion) -> PackedRgba {
682 match region {
683 HitRegion::None => PackedRgba::TRANSPARENT,
684 HitRegion::Content => PackedRgba::rgba(255, 165, 0, 60), HitRegion::Border => PackedRgba::rgba(128, 128, 128, 60), HitRegion::Scrollbar => PackedRgba::rgba(100, 100, 200, 60), HitRegion::Handle => PackedRgba::rgba(200, 100, 100, 60), HitRegion::Button => PackedRgba::rgba(0, 200, 255, 80), HitRegion::Link => PackedRgba::rgba(100, 200, 255, 80), HitRegion::Custom(_) => PackedRgba::rgba(200, 200, 200, 60), }
692 }
693}
694
695#[derive(Debug, Default)]
697pub struct InspectorState {
698 pub mode: InspectorMode,
700 pub hover_pos: Option<(u16, u16)>,
702 pub selected: Option<HitId>,
704 pub widgets: Vec<WidgetInfo>,
706 pub show_detail_panel: bool,
708 pub style: InspectorStyle,
710 pub show_hits: bool,
712 pub show_bounds: bool,
714 pub show_names: bool,
716 pub show_times: bool,
718 diagnostic_log: Option<DiagnosticLog>,
720 telemetry_hooks: Option<TelemetryHooks>,
722}
723
724impl InspectorState {
725 #[must_use]
727 pub fn new() -> Self {
728 let diagnostic_log = if diagnostics_enabled() {
729 Some(DiagnosticLog::new().with_stderr())
730 } else {
731 None
732 };
733 Self {
734 show_hits: true,
735 show_bounds: true,
736 show_names: true,
737 show_times: false,
738 diagnostic_log,
739 telemetry_hooks: None,
740 ..Default::default()
741 }
742 }
743
744 pub fn with_diagnostics(mut self) -> Self {
746 self.diagnostic_log = Some(DiagnosticLog::new());
747 self
748 }
749
750 pub fn with_telemetry_hooks(mut self, hooks: TelemetryHooks) -> Self {
752 self.telemetry_hooks = Some(hooks);
753 self
754 }
755
756 pub fn diagnostic_log(&self) -> Option<&DiagnosticLog> {
758 self.diagnostic_log.as_ref()
759 }
760
761 pub fn diagnostic_log_mut(&mut self) -> Option<&mut DiagnosticLog> {
763 self.diagnostic_log.as_mut()
764 }
765
766 pub fn toggle(&mut self) {
768 let prev = self.mode;
769 if self.mode.is_active() {
770 self.mode = InspectorMode::Off;
771 } else {
772 self.mode = InspectorMode::Full;
773 }
774 if self.mode != prev {
775 self.record_diagnostic(
776 DiagnosticEntry::new(DiagnosticEventKind::InspectorToggled)
777 .with_previous_mode(prev)
778 .with_mode(self.mode)
779 .with_flag("inspector", self.mode.is_active()),
780 );
781 }
782 }
783
784 #[inline]
786 pub fn is_active(&self) -> bool {
787 self.mode.is_active()
788 }
789
790 pub fn cycle_mode(&mut self) {
792 let prev = self.mode;
793 self.mode = self.mode.cycle();
794 if self.mode != prev {
795 self.record_diagnostic(
796 DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
797 .with_previous_mode(prev)
798 .with_mode(self.mode),
799 );
800 }
801 }
802
803 pub fn set_mode(&mut self, mode_num: u8) {
805 let prev = self.mode;
806 self.mode = match mode_num {
807 0 => InspectorMode::Off,
808 1 => InspectorMode::HitRegions,
809 2 => InspectorMode::WidgetBounds,
810 _ => InspectorMode::Full,
811 };
812 if self.mode != prev {
813 self.record_diagnostic(
814 DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
815 .with_previous_mode(prev)
816 .with_mode(self.mode),
817 );
818 }
819 }
820
821 pub fn set_hover(&mut self, pos: Option<(u16, u16)>) {
823 if self.hover_pos != pos {
824 self.hover_pos = pos;
825 self.record_diagnostic(
826 DiagnosticEntry::new(DiagnosticEventKind::HoverChanged).with_hover_pos(pos),
827 );
828 }
829 }
830
831 pub fn select(&mut self, id: Option<HitId>) {
833 if self.selected != id {
834 self.selected = id;
835 self.record_diagnostic(
836 DiagnosticEntry::new(DiagnosticEventKind::SelectionChanged).with_selected(id),
837 );
838 }
839 }
840
841 pub fn clear_selection(&mut self) {
843 self.select(None);
844 }
845
846 pub fn toggle_detail_panel(&mut self) {
848 self.show_detail_panel = !self.show_detail_panel;
849 self.record_diagnostic(
850 DiagnosticEntry::new(DiagnosticEventKind::DetailPanelToggled)
851 .with_flag("detail_panel", self.show_detail_panel),
852 );
853 }
854
855 pub fn toggle_hits(&mut self) {
857 self.show_hits = !self.show_hits;
858 self.record_diagnostic(
859 DiagnosticEntry::new(DiagnosticEventKind::HitsToggled)
860 .with_flag("hits", self.show_hits),
861 );
862 }
863
864 pub fn toggle_bounds(&mut self) {
866 self.show_bounds = !self.show_bounds;
867 self.record_diagnostic(
868 DiagnosticEntry::new(DiagnosticEventKind::BoundsToggled)
869 .with_flag("bounds", self.show_bounds),
870 );
871 }
872
873 pub fn toggle_names(&mut self) {
875 self.show_names = !self.show_names;
876 self.record_diagnostic(
877 DiagnosticEntry::new(DiagnosticEventKind::NamesToggled)
878 .with_flag("names", self.show_names),
879 );
880 }
881
882 pub fn toggle_times(&mut self) {
884 self.show_times = !self.show_times;
885 self.record_diagnostic(
886 DiagnosticEntry::new(DiagnosticEventKind::TimesToggled)
887 .with_flag("times", self.show_times),
888 );
889 }
890
891 pub fn clear_widgets(&mut self) {
893 let count = self.widgets.len();
894 self.widgets.clear();
895 if count > 0 {
896 self.record_diagnostic(
897 DiagnosticEntry::new(DiagnosticEventKind::WidgetsCleared).with_widget_count(count),
898 );
899 }
900 }
901
902 pub fn register_widget(&mut self, info: WidgetInfo) {
904 #[cfg(feature = "tracing")]
905 trace!(name = info.name, area = ?info.area, "Registered widget for inspection");
906 let widget_count = self.widgets.len() + 1;
907 self.record_diagnostic(
908 DiagnosticEntry::new(DiagnosticEventKind::WidgetRegistered)
909 .with_widget(&info)
910 .with_widget_count(widget_count),
911 );
912 self.widgets.push(info);
913 }
914
915 fn record_diagnostic(&mut self, entry: DiagnosticEntry) {
916 if self.diagnostic_log.is_none() && self.telemetry_hooks.is_none() {
917 return;
918 }
919 let entry = entry.with_checksum();
920
921 if let Some(ref hooks) = self.telemetry_hooks {
922 hooks.dispatch(&entry);
923 }
924
925 if let Some(ref mut log) = self.diagnostic_log {
926 log.record(entry);
927 }
928 }
929
930 #[inline]
932 pub fn should_show_hits(&self) -> bool {
933 self.show_hits && self.mode.show_hit_regions()
934 }
935
936 #[inline]
938 pub fn should_show_bounds(&self) -> bool {
939 self.show_bounds && self.mode.show_widget_bounds()
940 }
941}
942
943pub struct InspectorOverlay<'a> {
947 state: &'a InspectorState,
948}
949
950impl<'a> InspectorOverlay<'a> {
951 #[must_use]
953 pub fn new(state: &'a InspectorState) -> Self {
954 Self { state }
955 }
956
957 fn render_hit_regions(&self, area: Rect, frame: &mut Frame) {
959 #[cfg(feature = "tracing")]
960 let _span = info_span!("render_hit_regions").entered();
961
962 let Some(ref hit_grid) = frame.hit_grid else {
963 self.draw_warning(area, frame, "HitGrid not enabled");
965 return;
966 };
967
968 let style = &self.state.style;
969 let hover_pos = self.state.hover_pos;
970 let selected = self.state.selected;
971
972 for y in area.y..area.bottom() {
974 for x in area.x..area.right() {
975 if let Some(cell) = hit_grid.get(x, y) {
976 if cell.is_empty() {
977 continue;
978 }
979
980 let is_hovered = hover_pos == Some((x, y));
981 let is_selected = selected == cell.widget_id;
982
983 let overlay = if is_selected {
985 style.selected_highlight
986 } else if is_hovered {
987 style.hit_hover
988 } else {
989 style.region_color(cell.region)
990 };
991
992 if let Some(buf_cell) = frame.buffer.get_mut(x, y) {
994 buf_cell.bg = overlay.over(buf_cell.bg);
995 }
996 }
997 }
998 }
999 }
1000
1001 fn render_widget_bounds(&self, _area: Rect, frame: &mut Frame) {
1003 #[cfg(feature = "tracing")]
1004 let _span = info_span!(
1005 "render_widget_bounds",
1006 widget_count = self.state.widgets.len()
1007 )
1008 .entered();
1009
1010 let style = &self.state.style;
1011
1012 for widget in &self.state.widgets {
1013 self.render_widget_bound(widget, frame, style);
1014 }
1015 }
1016
1017 fn render_widget_bound(&self, widget: &WidgetInfo, frame: &mut Frame, style: &InspectorStyle) {
1019 let color = style.bound_color(widget.depth);
1020 let area = widget.area;
1021
1022 if area.is_empty() {
1024 return;
1025 }
1026
1027 self.draw_rect_outline(area, frame, color);
1029
1030 if self.state.show_names && !widget.name.is_empty() {
1032 self.draw_label(area, frame, &widget.name, style);
1033 }
1034
1035 for child in &widget.children {
1037 self.render_widget_bound(child, frame, style);
1038 }
1039 }
1040
1041 fn draw_rect_outline(&self, rect: Rect, frame: &mut Frame, color: PackedRgba) {
1043 if rect.width == 0 || rect.height == 0 {
1044 return;
1045 }
1046
1047 let x = rect.x;
1048 let y = rect.y;
1049 let right = rect.right().saturating_sub(1);
1050 let bottom = rect.bottom().saturating_sub(1);
1051
1052 for cx in x..=right {
1054 if let Some(cell) = frame.buffer.get_mut(cx, y) {
1055 cell.fg = color;
1056 }
1057 }
1058
1059 if bottom > y {
1061 for cx in x..=right {
1062 if let Some(cell) = frame.buffer.get_mut(cx, bottom) {
1063 cell.fg = color;
1064 }
1065 }
1066 }
1067
1068 for cy in y..=bottom {
1070 if let Some(cell) = frame.buffer.get_mut(x, cy) {
1071 cell.fg = color;
1072 }
1073 }
1074
1075 if right > x {
1077 for cy in y..=bottom {
1078 if let Some(cell) = frame.buffer.get_mut(right, cy) {
1079 cell.fg = color;
1080 }
1081 }
1082 }
1083 }
1084
1085 fn draw_label(&self, area: Rect, frame: &mut Frame, name: &str, style: &InspectorStyle) {
1087 let label = format!("[{name}]");
1088 let label_len = display_width(&label) as u16;
1089
1090 let x = area.x;
1092 let y = area.y;
1093
1094 let label_area = Rect::new(x, y, label_len.min(area.width), 1);
1096 set_style_area(
1097 &mut frame.buffer,
1098 label_area,
1099 Style::new().bg(style.label_bg),
1100 );
1101
1102 let label_style = Style::new().fg(style.label_fg).bg(style.label_bg);
1104 draw_text_span(frame, x, y, &label, label_style, area.x + area.width);
1105 }
1106
1107 fn draw_warning(&self, area: Rect, frame: &mut Frame, msg: &str) {
1109 let style = &self.state.style;
1110 let warning_style = Style::new()
1111 .fg(PackedRgba::rgb(255, 200, 0))
1112 .bg(style.label_bg);
1113
1114 let msg_len = display_width(msg) as u16;
1116 let x = area.x + area.width.saturating_sub(msg_len) / 2;
1117 let y = area.y;
1118
1119 set_style_area(
1120 &mut frame.buffer,
1121 Rect::new(x, y, msg_len, 1),
1122 warning_style,
1123 );
1124
1125 draw_text_span(frame, x, y, msg, warning_style, area.x + area.width);
1126 }
1127
1128 fn render_detail_panel(&self, area: Rect, frame: &mut Frame) {
1130 let style = &self.state.style;
1131
1132 let panel_width: u16 = 24;
1134 let panel_height = area.height.min(20);
1135
1136 let panel_x = area.right().saturating_sub(panel_width + 1);
1138 let panel_y = area.y + 1;
1139 let panel_area = Rect::new(panel_x, panel_y, panel_width, panel_height);
1140
1141 set_style_area(
1143 &mut frame.buffer,
1144 panel_area,
1145 Style::new().bg(style.label_bg),
1146 );
1147
1148 self.draw_rect_outline(panel_area, frame, style.label_fg);
1150
1151 let content_x = panel_x + 1;
1153 let mut y = panel_y + 1;
1154
1155 self.draw_panel_text(frame, content_x, y, "Inspector", style.label_fg);
1157 y += 2;
1158
1159 let mode_str = match self.state.mode {
1161 InspectorMode::Off => "Off",
1162 InspectorMode::HitRegions => "Hit Regions",
1163 InspectorMode::WidgetBounds => "Widget Bounds",
1164 InspectorMode::Full => "Full",
1165 };
1166 self.draw_panel_text(
1167 frame,
1168 content_x,
1169 y,
1170 &format!("Mode: {mode_str}"),
1171 style.label_fg,
1172 );
1173 y += 1;
1174
1175 if let Some((hx, hy)) = self.state.hover_pos {
1177 self.draw_panel_text(
1178 frame,
1179 content_x,
1180 y,
1181 &format!("Hover: ({hx},{hy})"),
1182 style.label_fg,
1183 );
1184 y += 1;
1185
1186 let hit_info = frame
1188 .hit_grid
1189 .as_ref()
1190 .and_then(|grid| grid.get(hx, hy).filter(|h| !h.is_empty()).map(|h| (*h,)));
1191
1192 if let Some((hit,)) = hit_info {
1194 let region_str = format!("{:?}", hit.region);
1195 self.draw_panel_text(
1196 frame,
1197 content_x,
1198 y,
1199 &format!("Region: {region_str}"),
1200 style.label_fg,
1201 );
1202 y += 1;
1203 if let Some(id) = hit.widget_id {
1204 self.draw_panel_text(
1205 frame,
1206 content_x,
1207 y,
1208 &format!("ID: {}", id.id()),
1209 style.label_fg,
1210 );
1211 y += 1;
1212 }
1213 if hit.data != 0 {
1214 self.draw_panel_text(
1215 frame,
1216 content_x,
1217 y,
1218 &format!("Data: {}", hit.data),
1219 style.label_fg,
1220 );
1221 #[allow(unused_assignments)]
1222 {
1223 y += 1;
1224 }
1225 }
1226 }
1227 }
1228 }
1229
1230 fn draw_panel_text(&self, frame: &mut Frame, x: u16, y: u16, text: &str, fg: PackedRgba) {
1232 for (i, ch) in text.chars().enumerate() {
1233 let cx = x + i as u16;
1234 if let Some(cell) = frame.buffer.get_mut(cx, y) {
1235 *cell = Cell::from_char(ch).with_fg(fg);
1236 }
1237 }
1238 }
1239}
1240
1241impl Widget for InspectorOverlay<'_> {
1242 fn render(&self, area: Rect, frame: &mut Frame) {
1243 #[cfg(feature = "tracing")]
1244 let _span = info_span!("inspector_overlay", ?area).entered();
1245
1246 if !self.state.is_active() {
1247 return;
1248 }
1249
1250 if self.state.should_show_hits() {
1252 self.render_hit_regions(area, frame);
1253 }
1254
1255 if self.state.should_show_bounds() {
1257 self.render_widget_bounds(area, frame);
1258 }
1259
1260 if self.state.show_detail_panel {
1262 self.render_detail_panel(area, frame);
1263 }
1264 }
1265
1266 fn is_essential(&self) -> bool {
1267 false
1269 }
1270}
1271
1272#[derive(Debug, Clone)]
1274pub struct HitInfo {
1275 pub widget_id: HitId,
1277 pub region: HitRegion,
1279 pub data: HitData,
1281 pub position: (u16, u16),
1283}
1284
1285impl HitInfo {
1286 #[must_use]
1288 pub fn from_cell(cell: &HitCell, x: u16, y: u16) -> Option<Self> {
1289 cell.widget_id.map(|id| Self {
1290 widget_id: id,
1291 region: cell.region,
1292 data: cell.data,
1293 position: (x, y),
1294 })
1295 }
1296}
1297
1298#[cfg(test)]
1299mod tests {
1300 use super::*;
1301 use ftui_render::grapheme_pool::GraphemePool;
1302
1303 #[test]
1304 fn inspector_mode_cycle() {
1305 let mut mode = InspectorMode::Off;
1306 mode = mode.cycle();
1307 assert_eq!(mode, InspectorMode::HitRegions);
1308 mode = mode.cycle();
1309 assert_eq!(mode, InspectorMode::WidgetBounds);
1310 mode = mode.cycle();
1311 assert_eq!(mode, InspectorMode::Full);
1312 mode = mode.cycle();
1313 assert_eq!(mode, InspectorMode::Off);
1314 }
1315
1316 #[test]
1317 fn inspector_mode_is_active() {
1318 assert!(!InspectorMode::Off.is_active());
1319 assert!(InspectorMode::HitRegions.is_active());
1320 assert!(InspectorMode::WidgetBounds.is_active());
1321 assert!(InspectorMode::Full.is_active());
1322 }
1323
1324 #[test]
1325 fn inspector_mode_show_flags() {
1326 assert!(!InspectorMode::Off.show_hit_regions());
1327 assert!(!InspectorMode::Off.show_widget_bounds());
1328
1329 assert!(InspectorMode::HitRegions.show_hit_regions());
1330 assert!(!InspectorMode::HitRegions.show_widget_bounds());
1331
1332 assert!(!InspectorMode::WidgetBounds.show_hit_regions());
1333 assert!(InspectorMode::WidgetBounds.show_widget_bounds());
1334
1335 assert!(InspectorMode::Full.show_hit_regions());
1336 assert!(InspectorMode::Full.show_widget_bounds());
1337 }
1338
1339 #[test]
1340 fn inspector_state_toggle() {
1341 let mut state = InspectorState::new();
1342 assert!(!state.is_active());
1343
1344 state.toggle();
1345 assert!(state.is_active());
1346 assert_eq!(state.mode, InspectorMode::Full);
1347
1348 state.toggle();
1349 assert!(!state.is_active());
1350 assert_eq!(state.mode, InspectorMode::Off);
1351 }
1352
1353 #[test]
1354 fn inspector_state_set_mode() {
1355 let mut state = InspectorState::new();
1356
1357 state.set_mode(1);
1358 assert_eq!(state.mode, InspectorMode::HitRegions);
1359
1360 state.set_mode(2);
1361 assert_eq!(state.mode, InspectorMode::WidgetBounds);
1362
1363 state.set_mode(3);
1364 assert_eq!(state.mode, InspectorMode::Full);
1365
1366 state.set_mode(0);
1367 assert_eq!(state.mode, InspectorMode::Off);
1368
1369 state.set_mode(99);
1371 assert_eq!(state.mode, InspectorMode::Full);
1372 }
1373
1374 #[test]
1375 fn inspector_style_default() {
1376 let style = InspectorStyle::default();
1377 assert_eq!(style.bound_colors.len(), 6);
1378 assert_eq!(style.hit_overlay, PackedRgba::rgba(255, 165, 0, 80));
1379 }
1380
1381 #[test]
1382 fn inspector_style_bound_color_cycles() {
1383 let style = InspectorStyle::default();
1384 assert_eq!(style.bound_color(0), style.bound_colors[0]);
1385 assert_eq!(style.bound_color(5), style.bound_colors[5]);
1386 assert_eq!(style.bound_color(6), style.bound_colors[0]); assert_eq!(style.bound_color(7), style.bound_colors[1]);
1388 }
1389
1390 #[test]
1391 fn widget_info_creation() {
1392 let info = WidgetInfo::new("Button", Rect::new(10, 5, 20, 3))
1393 .with_hit_id(HitId::new(42))
1394 .with_depth(2);
1395
1396 assert_eq!(info.name, "Button");
1397 assert_eq!(info.area, Rect::new(10, 5, 20, 3));
1398 assert_eq!(info.hit_id, Some(HitId::new(42)));
1399 assert_eq!(info.depth, 2);
1400 }
1401
1402 #[test]
1403 fn widget_info_add_hit_region() {
1404 let mut info = WidgetInfo::new("List", Rect::new(0, 0, 10, 10));
1405 info.add_hit_region(Rect::new(0, 0, 10, 1), HitRegion::Content, 0);
1406 info.add_hit_region(Rect::new(0, 1, 10, 1), HitRegion::Content, 1);
1407
1408 assert_eq!(info.hit_regions.len(), 2);
1409 assert_eq!(info.hit_regions[0].2, 0);
1410 assert_eq!(info.hit_regions[1].2, 1);
1411 }
1412
1413 #[test]
1414 fn widget_info_add_child() {
1415 let mut parent = WidgetInfo::new("Container", Rect::new(0, 0, 20, 20));
1416 let child = WidgetInfo::new("Button", Rect::new(5, 5, 10, 3));
1417 parent.add_child(child);
1418
1419 assert_eq!(parent.children.len(), 1);
1420 assert_eq!(parent.children[0].name, "Button");
1421 }
1422
1423 #[test]
1424 fn inspector_overlay_inactive_is_noop() {
1425 let state = InspectorState::new();
1426 let overlay = InspectorOverlay::new(&state);
1427
1428 let mut pool = GraphemePool::new();
1429 let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
1430 let area = Rect::new(0, 0, 10, 10);
1431
1432 overlay.render(area, &mut frame);
1434
1435 assert!(frame.buffer.get(0, 0).unwrap().is_empty());
1437 }
1438
1439 #[test]
1440 fn inspector_overlay_renders_when_active() {
1441 let mut state = InspectorState::new();
1442 state.mode = InspectorMode::Full;
1443 state.show_detail_panel = true;
1444
1445 let overlay = InspectorOverlay::new(&state);
1446
1447 let mut pool = GraphemePool::new();
1448 let mut frame = Frame::with_hit_grid(40, 20, &mut pool);
1449
1450 frame.register_hit(Rect::new(5, 5, 10, 3), HitId::new(1), HitRegion::Button, 42);
1452
1453 let area = Rect::new(0, 0, 40, 20);
1454 overlay.render(area, &mut frame);
1455
1456 }
1459
1460 #[test]
1461 fn hit_info_from_cell() {
1462 let cell = HitCell::new(HitId::new(5), HitRegion::Button, 99);
1463 let info = HitInfo::from_cell(&cell, 10, 20);
1464
1465 assert!(info.is_some());
1466 let info = info.unwrap();
1467 assert_eq!(info.widget_id, HitId::new(5));
1468 assert_eq!(info.region, HitRegion::Button);
1469 assert_eq!(info.data, 99);
1470 assert_eq!(info.position, (10, 20));
1471 }
1472
1473 #[test]
1474 fn hit_info_from_empty_cell() {
1475 let cell = HitCell::default();
1476 let info = HitInfo::from_cell(&cell, 0, 0);
1477 assert!(info.is_none());
1478 }
1479
1480 #[test]
1481 fn inspector_state_toggles() {
1482 let mut state = InspectorState::new();
1483
1484 assert!(state.show_hits);
1485 state.toggle_hits();
1486 assert!(!state.show_hits);
1487 state.toggle_hits();
1488 assert!(state.show_hits);
1489
1490 assert!(state.show_bounds);
1491 state.toggle_bounds();
1492 assert!(!state.show_bounds);
1493
1494 assert!(state.show_names);
1495 state.toggle_names();
1496 assert!(!state.show_names);
1497
1498 assert!(!state.show_times);
1499 state.toggle_times();
1500 assert!(state.show_times);
1501
1502 assert!(!state.show_detail_panel);
1503 state.toggle_detail_panel();
1504 assert!(state.show_detail_panel);
1505 }
1506
1507 #[test]
1508 fn inspector_state_selection() {
1509 let mut state = InspectorState::new();
1510
1511 assert!(state.selected.is_none());
1512 state.select(Some(HitId::new(42)));
1513 assert_eq!(state.selected, Some(HitId::new(42)));
1514 state.clear_selection();
1515 assert!(state.selected.is_none());
1516 }
1517
1518 #[test]
1519 fn inspector_state_hover() {
1520 let mut state = InspectorState::new();
1521
1522 assert!(state.hover_pos.is_none());
1523 state.set_hover(Some((10, 20)));
1524 assert_eq!(state.hover_pos, Some((10, 20)));
1525 state.set_hover(None);
1526 assert!(state.hover_pos.is_none());
1527 }
1528
1529 #[test]
1530 fn inspector_state_widget_registry() {
1531 let mut state = InspectorState::new();
1532
1533 let widget = WidgetInfo::new("Test", Rect::new(0, 0, 10, 10));
1534 state.register_widget(widget);
1535 assert_eq!(state.widgets.len(), 1);
1536
1537 state.clear_widgets();
1538 assert!(state.widgets.is_empty());
1539 }
1540
1541 #[test]
1542 fn inspector_overlay_is_not_essential() {
1543 let state = InspectorState::new();
1544 let overlay = InspectorOverlay::new(&state);
1545 assert!(!overlay.is_essential());
1546 }
1547
1548 #[test]
1553 fn edge_case_zero_area_widget() {
1554 let info = WidgetInfo::new("ZeroArea", Rect::new(0, 0, 0, 0));
1556 assert_eq!(info.area.width, 0);
1557 assert_eq!(info.area.height, 0);
1558 assert!(info.area.is_empty());
1559 }
1560
1561 #[test]
1562 fn edge_case_max_depth_widget() {
1563 let info = WidgetInfo::new("Deep", Rect::new(0, 0, 10, 10)).with_depth(u8::MAX);
1565 assert_eq!(info.depth, u8::MAX);
1566
1567 let style = InspectorStyle::default();
1569 let _color = style.bound_color(u8::MAX); }
1571
1572 #[test]
1573 fn edge_case_empty_widget_registry() {
1574 let mut state = InspectorState::new();
1575 assert!(state.widgets.is_empty());
1576
1577 state.clear_widgets();
1579 assert!(state.widgets.is_empty());
1580 }
1581
1582 #[test]
1583 fn edge_case_selection_without_widgets() {
1584 let mut state = InspectorState::new();
1585
1586 state.select(Some(HitId::new(42)));
1588 assert_eq!(state.selected, Some(HitId::new(42)));
1589
1590 state.clear_selection();
1592 assert!(state.selected.is_none());
1593 }
1594
1595 #[test]
1596 fn edge_case_hover_boundary_positions() {
1597 let mut state = InspectorState::new();
1598
1599 state.set_hover(Some((u16::MAX, u16::MAX)));
1601 assert_eq!(state.hover_pos, Some((u16::MAX, u16::MAX)));
1602
1603 state.set_hover(Some((0, 0)));
1605 assert_eq!(state.hover_pos, Some((0, 0)));
1606 }
1607
1608 #[test]
1609 fn edge_case_deeply_nested_widgets() {
1610 let mut deepest = WidgetInfo::new("L10", Rect::new(10, 10, 80, 80)).with_depth(10);
1612
1613 for i in (1..10).rev() {
1614 let mut parent =
1615 WidgetInfo::new(format!("L{i}"), Rect::new(i as u16, i as u16, 90, 90))
1616 .with_depth(i as u8);
1617 parent.add_child(deepest);
1618 deepest = parent;
1619 }
1620
1621 let mut root = WidgetInfo::new("Root", Rect::new(0, 0, 100, 100)).with_depth(0);
1622 root.add_child(deepest);
1623
1624 assert_eq!(root.children.len(), 1);
1626 assert_eq!(root.children[0].depth, 1);
1627 assert_eq!(root.children[0].children[0].depth, 2);
1628 }
1629
1630 #[test]
1631 fn edge_case_rapid_mode_cycling() {
1632 let mut state = InspectorState::new();
1633 assert_eq!(state.mode, InspectorMode::Off);
1634
1635 for _ in 0..1000 {
1637 state.mode = state.mode.cycle();
1638 }
1639 assert_eq!(state.mode, InspectorMode::Off);
1641 }
1642
1643 #[test]
1644 fn edge_case_many_hit_regions() {
1645 let mut info = WidgetInfo::new("ManyHits", Rect::new(0, 0, 100, 1000));
1646
1647 for i in 0..1000 {
1649 info.add_hit_region(
1650 Rect::new(0, i as u16, 100, 1),
1651 HitRegion::Content,
1652 i as HitData,
1653 );
1654 }
1655
1656 assert_eq!(info.hit_regions.len(), 1000);
1657 assert_eq!(info.hit_regions[0].2, 0);
1658 assert_eq!(info.hit_regions[999].2, 999);
1659 }
1660
1661 #[test]
1662 fn edge_case_mode_show_flags_consistency() {
1663 for mode in [
1665 InspectorMode::Off,
1666 InspectorMode::HitRegions,
1667 InspectorMode::WidgetBounds,
1668 InspectorMode::Full,
1669 ] {
1670 match mode {
1671 InspectorMode::Off => {
1672 assert!(!mode.show_hit_regions());
1673 assert!(!mode.show_widget_bounds());
1674 }
1675 InspectorMode::HitRegions => {
1676 assert!(mode.show_hit_regions());
1677 assert!(!mode.show_widget_bounds());
1678 }
1679 InspectorMode::WidgetBounds => {
1680 assert!(!mode.show_hit_regions());
1681 assert!(mode.show_widget_bounds());
1682 }
1683 InspectorMode::Full => {
1684 assert!(mode.show_hit_regions());
1685 assert!(mode.show_widget_bounds());
1686 }
1687 }
1688 }
1689 }
1690
1691 mod proptests {
1696 use super::*;
1697 use proptest::prelude::*;
1698
1699 proptest! {
1700 #[test]
1703 fn mode_cycle_is_periodic(start_cycle in 0u8..4) {
1704 let start_mode = match start_cycle {
1705 0 => InspectorMode::Off,
1706 1 => InspectorMode::HitRegions,
1707 2 => InspectorMode::WidgetBounds,
1708 _ => InspectorMode::Full,
1709 };
1710
1711 let mut mode = start_mode;
1712 for _ in 0..4 {
1713 mode = mode.cycle();
1714 }
1715 prop_assert_eq!(mode, start_mode);
1716 }
1717
1718 #[test]
1720 fn bound_color_cycle_is_periodic(depth in 0u8..200) {
1721 let style = InspectorStyle::default();
1722 let color_a = style.bound_color(depth);
1723 let color_b = style.bound_color(depth.wrapping_add(6));
1724 prop_assert_eq!(color_a, color_b);
1725 }
1726
1727 #[test]
1729 fn is_active_reflects_mode(mode_idx in 0u8..4) {
1730 let mode = match mode_idx {
1731 0 => InspectorMode::Off,
1732 1 => InspectorMode::HitRegions,
1733 2 => InspectorMode::WidgetBounds,
1734 _ => InspectorMode::Full,
1735 };
1736 let expected_active = mode_idx != 0;
1737 prop_assert_eq!(mode.is_active(), expected_active);
1738 }
1739
1740 #[test]
1742 fn double_toggle_is_identity(_seed in 0u32..1000) {
1743 let mut state = InspectorState::new();
1744 let initial_hits = state.show_hits;
1745 let initial_bounds = state.show_bounds;
1746 let initial_names = state.show_names;
1747 let initial_times = state.show_times;
1748 let initial_panel = state.show_detail_panel;
1749
1750 state.toggle_hits();
1752 state.toggle_hits();
1753 state.toggle_bounds();
1754 state.toggle_bounds();
1755 state.toggle_names();
1756 state.toggle_names();
1757 state.toggle_times();
1758 state.toggle_times();
1759 state.toggle_detail_panel();
1760 state.toggle_detail_panel();
1761
1762 prop_assert_eq!(state.show_hits, initial_hits);
1763 prop_assert_eq!(state.show_bounds, initial_bounds);
1764 prop_assert_eq!(state.show_names, initial_names);
1765 prop_assert_eq!(state.show_times, initial_times);
1766 prop_assert_eq!(state.show_detail_panel, initial_panel);
1767 }
1768
1769 #[test]
1771 fn widget_info_preserves_area(
1772 x in 0u16..1000,
1773 y in 0u16..1000,
1774 w in 1u16..500,
1775 h in 1u16..500,
1776 ) {
1777 let area = Rect::new(x, y, w, h);
1778 let info = WidgetInfo::new("Test", area);
1779 prop_assert_eq!(info.area, area);
1780 }
1781
1782 #[test]
1784 fn widget_depth_preserved(depth in 0u8..255) {
1785 let info = WidgetInfo::new("Test", Rect::new(0, 0, 10, 10))
1786 .with_depth(depth);
1787 prop_assert_eq!(info.depth, depth);
1788 }
1789
1790 #[test]
1792 fn widget_hit_id_preserved(id in 0u32..u32::MAX) {
1793 let hit_id = HitId::new(id);
1794 let info = WidgetInfo::new("Test", Rect::new(0, 0, 10, 10))
1795 .with_hit_id(hit_id);
1796 prop_assert_eq!(info.hit_id, Some(hit_id));
1797 }
1798
1799 #[test]
1801 fn add_child_increases_count(child_count in 0usize..50) {
1802 let mut parent = WidgetInfo::new("Parent", Rect::new(0, 0, 100, 100));
1803 for i in 0..child_count {
1804 parent.add_child(WidgetInfo::new(
1805 format!("Child{i}"),
1806 Rect::new(0, i as u16, 10, 1),
1807 ));
1808 }
1809 prop_assert_eq!(parent.children.len(), child_count);
1810 }
1811
1812 #[test]
1814 fn add_hit_regions_unbounded(region_count in 0usize..100) {
1815 let mut info = WidgetInfo::new("Test", Rect::new(0, 0, 100, 100));
1816 for i in 0..region_count {
1817 info.add_hit_region(
1818 Rect::new(0, i as u16, 10, 1),
1819 HitRegion::Content,
1820 i as HitData,
1821 );
1822 }
1823 prop_assert_eq!(info.hit_regions.len(), region_count);
1824 }
1825
1826 #[test]
1828 fn set_mode_maps_correctly(mode_idx in 0u8..10) {
1829 let mut state = InspectorState::new();
1830 state.set_mode(mode_idx);
1831 let expected = match mode_idx {
1832 0 => InspectorMode::Off,
1833 1 => InspectorMode::HitRegions,
1834 2 => InspectorMode::WidgetBounds,
1835 3 => InspectorMode::Full,
1836 _ => InspectorMode::Full, };
1838 prop_assert_eq!(state.mode, expected);
1839 }
1840
1841 #[test]
1843 fn should_show_hits_respects_both(mode_idx in 0u8..4, flag in proptest::bool::ANY) {
1844 let mut state = InspectorState::new();
1845 state.set_mode(mode_idx);
1846 state.show_hits = flag;
1847 let mode_allows = state.mode.show_hit_regions();
1848 prop_assert_eq!(state.should_show_hits(), flag && mode_allows);
1849 }
1850
1851 #[test]
1853 fn should_show_bounds_respects_both(mode_idx in 0u8..4, flag in proptest::bool::ANY) {
1854 let mut state = InspectorState::new();
1855 state.set_mode(mode_idx);
1856 state.show_bounds = flag;
1857 let mode_allows = state.mode.show_widget_bounds();
1858 prop_assert_eq!(state.should_show_bounds(), flag && mode_allows);
1859 }
1860 }
1861 }
1862
1863 #[test]
1868 fn region_color_all_variants() {
1869 let style = InspectorStyle::default();
1870
1871 let none_color = style.region_color(HitRegion::None);
1873 let content_color = style.region_color(HitRegion::Content);
1874 let border_color = style.region_color(HitRegion::Border);
1875 let scrollbar_color = style.region_color(HitRegion::Scrollbar);
1876 let handle_color = style.region_color(HitRegion::Handle);
1877 let button_color = style.region_color(HitRegion::Button);
1878 let link_color = style.region_color(HitRegion::Link);
1879 let custom_color = style.region_color(HitRegion::Custom(42));
1880
1881 assert_eq!(none_color, PackedRgba::TRANSPARENT);
1883
1884 assert_ne!(content_color.a(), 0);
1886 assert_ne!(border_color.a(), 0);
1887 assert_ne!(scrollbar_color.a(), 0);
1888 assert_ne!(handle_color.a(), 0);
1889 assert_ne!(button_color.a(), 0);
1890 assert_ne!(link_color.a(), 0);
1891 assert_ne!(custom_color.a(), 0);
1892
1893 assert!(content_color.a() < 255);
1895 assert!(button_color.a() < 255);
1896 }
1897
1898 #[test]
1899 fn region_color_custom_variants() {
1900 let style = InspectorStyle::default();
1901
1902 let c0 = style.region_color(HitRegion::Custom(0));
1904 let c1 = style.region_color(HitRegion::Custom(1));
1905 let c255 = style.region_color(HitRegion::Custom(255));
1906
1907 assert_eq!(c0, c1);
1908 assert_eq!(c1, c255);
1909 }
1910
1911 #[test]
1916 fn should_show_hits_requires_both_mode_and_flag() {
1917 let mut state = InspectorState::new();
1918
1919 state.mode = InspectorMode::Off;
1921 state.show_hits = true;
1922 assert!(!state.should_show_hits());
1923
1924 state.mode = InspectorMode::HitRegions;
1926 state.show_hits = true;
1927 assert!(state.should_show_hits());
1928
1929 state.show_hits = false;
1931 assert!(!state.should_show_hits());
1932
1933 state.mode = InspectorMode::WidgetBounds;
1935 state.show_hits = true;
1936 assert!(!state.should_show_hits());
1937
1938 state.mode = InspectorMode::Full;
1940 state.show_hits = true;
1941 assert!(state.should_show_hits());
1942 }
1943
1944 #[test]
1945 fn should_show_bounds_requires_both_mode_and_flag() {
1946 let mut state = InspectorState::new();
1947
1948 state.mode = InspectorMode::Off;
1950 state.show_bounds = true;
1951 assert!(!state.should_show_bounds());
1952
1953 state.mode = InspectorMode::WidgetBounds;
1955 state.show_bounds = true;
1956 assert!(state.should_show_bounds());
1957
1958 state.show_bounds = false;
1960 assert!(!state.should_show_bounds());
1961
1962 state.mode = InspectorMode::HitRegions;
1964 state.show_bounds = true;
1965 assert!(!state.should_show_bounds());
1966
1967 state.mode = InspectorMode::Full;
1969 state.show_bounds = true;
1970 assert!(state.should_show_bounds());
1971 }
1972
1973 #[test]
1978 fn overlay_respects_mode_hit_regions_only() {
1979 let mut state = InspectorState::new();
1980 state.mode = InspectorMode::HitRegions;
1981
1982 state.register_widget(WidgetInfo::new("TestWidget", Rect::new(5, 5, 10, 3)));
1984
1985 let overlay = InspectorOverlay::new(&state);
1986 let mut pool = GraphemePool::new();
1987 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
1988
1989 frame.register_hit(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Button, 0);
1991
1992 let area = Rect::new(0, 0, 20, 10);
1993 overlay.render(area, &mut frame);
1994
1995 assert!(state.should_show_hits());
1998 assert!(!state.should_show_bounds());
1999 }
2000
2001 #[test]
2002 fn overlay_respects_mode_widget_bounds_only() {
2003 let mut state = InspectorState::new();
2004 state.mode = InspectorMode::WidgetBounds;
2005 state.show_names = true;
2006
2007 state.register_widget(WidgetInfo::new("TestWidget", Rect::new(2, 2, 15, 5)));
2009
2010 let overlay = InspectorOverlay::new(&state);
2011 let mut pool = GraphemePool::new();
2012 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2013
2014 let area = Rect::new(0, 0, 20, 10);
2015 overlay.render(area, &mut frame);
2016
2017 assert!(!state.should_show_hits());
2019 assert!(state.should_show_bounds());
2020 }
2021
2022 #[test]
2023 fn overlay_full_mode_shows_both() {
2024 let mut state = InspectorState::new();
2025 state.mode = InspectorMode::Full;
2026
2027 state.register_widget(WidgetInfo::new("FullTest", Rect::new(0, 0, 10, 5)));
2029
2030 let overlay = InspectorOverlay::new(&state);
2031 let mut pool = GraphemePool::new();
2032 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2033
2034 frame.register_hit(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Content, 0);
2035
2036 let area = Rect::new(0, 0, 20, 10);
2037 overlay.render(area, &mut frame);
2038
2039 assert!(state.should_show_hits());
2040 assert!(state.should_show_bounds());
2041 }
2042
2043 #[test]
2044 fn overlay_detail_panel_renders_when_enabled() {
2045 let mut state = InspectorState::new();
2046 state.mode = InspectorMode::Full;
2047 state.show_detail_panel = true;
2048 state.set_hover(Some((5, 5)));
2049
2050 let overlay = InspectorOverlay::new(&state);
2051 let mut pool = GraphemePool::new();
2052 let mut frame = Frame::with_hit_grid(50, 25, &mut pool);
2053
2054 let area = Rect::new(0, 0, 50, 25);
2055 overlay.render(area, &mut frame);
2056
2057 let panel_x = 25;
2061 let panel_y = 1;
2062
2063 let cell = frame.buffer.get(panel_x + 1, panel_y + 1);
2065 assert!(cell.is_some());
2066 }
2067
2068 #[test]
2069 fn overlay_without_hit_grid_shows_warning() {
2070 let mut state = InspectorState::new();
2071 state.mode = InspectorMode::HitRegions;
2072
2073 let overlay = InspectorOverlay::new(&state);
2074 let mut pool = GraphemePool::new();
2075 let mut frame = Frame::new(40, 10, &mut pool);
2077
2078 let area = Rect::new(0, 0, 40, 10);
2079 overlay.render(area, &mut frame);
2080
2081 if let Some(cell) = frame.buffer.get(10, 0) {
2085 assert_eq!(cell.content.as_char(), Some('H'));
2086 }
2087 }
2088
2089 #[test]
2094 fn nested_widgets_render_with_depth_colors() {
2095 let mut state = InspectorState::new();
2096 state.mode = InspectorMode::WidgetBounds;
2097 state.show_names = false; let mut parent = WidgetInfo::new("Parent", Rect::new(0, 0, 30, 20)).with_depth(0);
2101 let child = WidgetInfo::new("Child", Rect::new(2, 2, 26, 16)).with_depth(1);
2102 parent.add_child(child);
2103
2104 state.register_widget(parent);
2105
2106 let overlay = InspectorOverlay::new(&state);
2107 let mut pool = GraphemePool::new();
2108 let mut frame = Frame::with_hit_grid(40, 25, &mut pool);
2109
2110 let area = Rect::new(0, 0, 40, 25);
2111 overlay.render(area, &mut frame);
2112
2113 let style = InspectorStyle::default();
2116 let parent_color = style.bound_color(0);
2117 let child_color = style.bound_color(1);
2118
2119 assert_ne!(parent_color, child_color);
2121 }
2122
2123 #[test]
2124 fn widget_with_empty_name_skips_label() {
2125 let mut state = InspectorState::new();
2126 state.mode = InspectorMode::WidgetBounds;
2127 state.show_names = true;
2128
2129 state.register_widget(WidgetInfo::new("", Rect::new(5, 5, 10, 5)));
2131
2132 let overlay = InspectorOverlay::new(&state);
2133 let mut pool = GraphemePool::new();
2134 let mut frame = Frame::with_hit_grid(20, 15, &mut pool);
2135
2136 let area = Rect::new(0, 0, 20, 15);
2137 overlay.render(area, &mut frame);
2138
2139 }
2141
2142 #[test]
2147 fn hit_info_all_region_types() {
2148 let regions = [
2149 HitRegion::None,
2150 HitRegion::Content,
2151 HitRegion::Border,
2152 HitRegion::Scrollbar,
2153 HitRegion::Handle,
2154 HitRegion::Button,
2155 HitRegion::Link,
2156 HitRegion::Custom(0),
2157 HitRegion::Custom(255),
2158 ];
2159
2160 for region in regions {
2161 let cell = HitCell::new(HitId::new(1), region, 42);
2162 let info = HitInfo::from_cell(&cell, 10, 20);
2163
2164 let info = info.expect("should create info");
2165 assert_eq!(info.region, region);
2166 assert_eq!(info.data, 42);
2167 }
2168 }
2169
2170 #[test]
2171 fn hit_cell_with_zero_data() {
2172 let cell = HitCell::new(HitId::new(5), HitRegion::Content, 0);
2173 let info = HitInfo::from_cell(&cell, 0, 0).unwrap();
2174 assert_eq!(info.data, 0);
2175 }
2176
2177 #[test]
2178 fn hit_cell_with_max_data() {
2179 let cell = HitCell::new(HitId::new(5), HitRegion::Content, u64::MAX);
2180 let info = HitInfo::from_cell(&cell, 0, 0).unwrap();
2181 assert_eq!(info.data, u64::MAX);
2182 }
2183
2184 #[test]
2189 fn inspector_state_new_defaults() {
2190 let state = InspectorState::new();
2191
2192 assert_eq!(state.mode, InspectorMode::Off);
2194 assert!(state.hover_pos.is_none());
2195 assert!(state.selected.is_none());
2196 assert!(state.widgets.is_empty());
2197 assert!(!state.show_detail_panel);
2198 assert!(state.show_hits);
2199 assert!(state.show_bounds);
2200 assert!(state.show_names);
2201 assert!(!state.show_times);
2202 }
2203
2204 #[test]
2205 fn inspector_state_default_matches_new() {
2206 let state_new = InspectorState::new();
2207 let state_default = InspectorState::default();
2208
2209 assert_eq!(state_new.mode, state_default.mode);
2211 assert_eq!(state_new.hover_pos, state_default.hover_pos);
2212 assert_eq!(state_new.selected, state_default.selected);
2213 }
2214
2215 #[test]
2216 fn inspector_style_colors_are_semi_transparent() {
2217 let style = InspectorStyle::default();
2218
2219 assert!(style.hit_overlay.a() > 0);
2221 assert!(style.hit_overlay.a() < 255);
2222
2223 assert!(style.hit_hover.a() > 0);
2225 assert!(style.hit_hover.a() < 255);
2226
2227 assert!(style.selected_highlight.a() > 0);
2229 assert!(style.selected_highlight.a() < 255);
2230
2231 assert!(style.label_bg.a() > 128);
2233 }
2234
2235 #[cfg(feature = "tracing")]
2236 #[test]
2237 fn telemetry_spans_and_events() {
2238 let mut state = InspectorState::new();
2241 state.toggle(); let overlay = InspectorOverlay::new(&state);
2244 let mut pool = GraphemePool::new();
2245 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2246
2247 let area = Rect::new(0, 0, 20, 10);
2248 overlay.render(area, &mut frame); }
2250
2251 #[test]
2252 fn diagnostic_entry_checksum_deterministic() {
2253 let entry1 = DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
2254 .with_previous_mode(InspectorMode::Off)
2255 .with_mode(InspectorMode::Full)
2256 .with_flag("hits", true)
2257 .with_context("test")
2258 .with_checksum();
2259 let entry2 = DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
2260 .with_previous_mode(InspectorMode::Off)
2261 .with_mode(InspectorMode::Full)
2262 .with_flag("hits", true)
2263 .with_context("test")
2264 .with_checksum();
2265 assert_eq!(entry1.checksum, entry2.checksum);
2266 assert_ne!(entry1.checksum, 0);
2267 }
2268
2269 #[test]
2270 fn diagnostic_log_records_mode_changes() {
2271 let mut state = InspectorState::new().with_diagnostics();
2272 state.set_mode(1);
2273 state.set_mode(2);
2274 let log = state.diagnostic_log().expect("diagnostic log should exist");
2275 assert!(!log.entries().is_empty());
2276 assert!(
2277 !log.entries_of_kind(DiagnosticEventKind::ModeChanged)
2278 .is_empty()
2279 );
2280 }
2281
2282 #[test]
2283 fn telemetry_hooks_on_mode_change_fires() {
2284 use std::sync::Arc;
2285 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
2286
2287 let counter = Arc::new(AtomicUsize::new(0));
2288 let counter_clone = Arc::clone(&counter);
2289 let hooks = TelemetryHooks::new().on_mode_change(move |_| {
2290 counter_clone.fetch_add(1, AtomicOrdering::Relaxed);
2291 });
2292
2293 let mut state = InspectorState::new().with_telemetry_hooks(hooks);
2294 state.set_mode(1);
2295 state.set_mode(2);
2296 assert!(counter.load(AtomicOrdering::Relaxed) >= 1);
2297 }
2298
2299 fn relative_luminance(rgba: PackedRgba) -> f64 {
2306 fn channel_luminance(c: u8) -> f64 {
2307 let c = c as f64 / 255.0;
2308 if c <= 0.03928 {
2309 c / 12.92
2310 } else {
2311 ((c + 0.055) / 1.055).powf(2.4)
2312 }
2313 }
2314 let r = channel_luminance(rgba.r());
2315 let g = channel_luminance(rgba.g());
2316 let b = channel_luminance(rgba.b());
2317 0.2126 * r + 0.7152 * g + 0.0722 * b
2318 }
2319
2320 fn contrast_ratio(fg: PackedRgba, bg: PackedRgba) -> f64 {
2323 let l1 = relative_luminance(fg);
2324 let l2 = relative_luminance(bg);
2325 let lighter = l1.max(l2);
2326 let darker = l1.min(l2);
2327 (lighter + 0.05) / (darker + 0.05)
2328 }
2329
2330 #[test]
2331 fn a11y_label_contrast_meets_wcag_aa() {
2332 let style = InspectorStyle::default();
2335 let ratio = contrast_ratio(style.label_fg, style.label_bg);
2336 assert!(
2337 ratio >= 3.0,
2338 "Label contrast ratio {:.2}:1 should be >= 3:1 (WCAG AA large text)",
2339 ratio
2340 );
2341 assert!(
2343 ratio >= 4.5,
2344 "Label contrast ratio {:.2}:1 should be >= 4.5:1 (WCAG AA normal text)",
2345 ratio
2346 );
2347 }
2348
2349 #[test]
2350 fn a11y_bound_colors_are_distinct() {
2351 let style = InspectorStyle::default();
2354 let colors = &style.bound_colors;
2355
2356 for (i, a) in colors.iter().enumerate() {
2358 for (j, b) in colors.iter().enumerate() {
2359 if i != j {
2360 let r_diff = (a.r() as i32 - b.r() as i32).abs();
2361 let g_diff = (a.g() as i32 - b.g() as i32).abs();
2362 let b_diff = (a.b() as i32 - b.b() as i32).abs();
2363 let max_diff = r_diff.max(g_diff).max(b_diff);
2364 assert!(
2365 max_diff >= 100,
2366 "Bound colors {} and {} should differ by at least 100 in one channel (max diff = {})",
2367 i,
2368 j,
2369 max_diff
2370 );
2371 }
2372 }
2373 }
2374 }
2375
2376 #[test]
2377 fn a11y_bound_colors_have_good_visibility() {
2378 let style = InspectorStyle::default();
2381 for (i, color) in style.bound_colors.iter().enumerate() {
2382 let max_channel = color.r().max(color.g()).max(color.b());
2383 assert!(
2384 max_channel >= 100,
2385 "Bound color {} should have at least one channel >= 100 for visibility (max = {})",
2386 i,
2387 max_channel
2388 );
2389 }
2390 }
2391
2392 #[test]
2393 fn a11y_hit_overlays_are_visible() {
2394 let style = InspectorStyle::default();
2397
2398 assert!(
2400 style.hit_overlay.a() >= 50,
2401 "hit_overlay alpha {} should be >= 50 for visibility",
2402 style.hit_overlay.a()
2403 );
2404
2405 assert!(
2407 style.hit_hover.a() >= 80,
2408 "hit_hover alpha {} should be >= 80 for clear hover indication",
2409 style.hit_hover.a()
2410 );
2411 assert!(
2412 style.hit_hover.a() > style.hit_overlay.a(),
2413 "hit_hover should be more visible than hit_overlay"
2414 );
2415
2416 assert!(
2418 style.selected_highlight.a() >= 100,
2419 "selected_highlight alpha {} should be >= 100 for clear selection",
2420 style.selected_highlight.a()
2421 );
2422 }
2423
2424 #[test]
2425 fn a11y_region_colors_cover_all_variants() {
2426 let style = InspectorStyle::default();
2428 let regions = [
2429 HitRegion::None,
2430 HitRegion::Content,
2431 HitRegion::Border,
2432 HitRegion::Scrollbar,
2433 HitRegion::Handle,
2434 HitRegion::Button,
2435 HitRegion::Link,
2436 HitRegion::Custom(0),
2437 ];
2438
2439 for region in regions {
2440 let color = style.region_color(region);
2441 match region {
2443 HitRegion::None => {
2444 assert_eq!(
2445 color,
2446 PackedRgba::TRANSPARENT,
2447 "HitRegion::None should be transparent"
2448 );
2449 }
2450 _ => {
2451 assert!(
2452 color.a() > 0,
2453 "HitRegion::{:?} should have non-zero alpha",
2454 region
2455 );
2456 }
2457 }
2458 }
2459 }
2460
2461 #[test]
2462 fn a11y_interactive_regions_are_distinct_from_passive() {
2463 let style = InspectorStyle::default();
2466
2467 let button_color = style.region_color(HitRegion::Button);
2468 let link_color = style.region_color(HitRegion::Link);
2469 let content_color = style.region_color(HitRegion::Content);
2470 let _border_color = style.region_color(HitRegion::Border);
2471
2472 assert!(
2474 button_color.a() >= content_color.a(),
2475 "Button overlay should be as visible or more visible than Content"
2476 );
2477 assert!(
2478 link_color.a() >= content_color.a(),
2479 "Link overlay should be as visible or more visible than Content"
2480 );
2481
2482 let button_content_diff = (button_color.r() as i32 - content_color.r() as i32).abs()
2484 + (button_color.g() as i32 - content_color.g() as i32).abs()
2485 + (button_color.b() as i32 - content_color.b() as i32).abs();
2486 assert!(
2487 button_content_diff >= 100,
2488 "Button color should differ significantly from Content (diff = {})",
2489 button_content_diff
2490 );
2491 }
2492
2493 #[test]
2494 fn a11y_keybinding_constants_documented() {
2495 }
2523
2524 use std::collections::hash_map::DefaultHasher;
2529 use std::hash::{Hash, Hasher};
2530 use std::time::Instant;
2531
2532 fn inspector_seed() -> u64 {
2533 std::env::var("INSPECTOR_SEED")
2534 .ok()
2535 .and_then(|s| s.parse().ok())
2536 .unwrap_or(42)
2537 }
2538
2539 fn next_u32(seed: &mut u64) -> u32 {
2540 let mut x = *seed;
2541 x ^= x << 13;
2542 x ^= x >> 7;
2543 x ^= x << 17;
2544 *seed = x;
2545 (x >> 32) as u32
2546 }
2547
2548 fn rand_range(seed: &mut u64, min: u16, max: u16) -> u16 {
2549 if min >= max {
2550 return min;
2551 }
2552 let span = (max - min) as u32 + 1;
2553 let n = next_u32(seed) % span;
2554 min + n as u16
2555 }
2556
2557 fn random_rect(seed: &mut u64, area: Rect) -> Rect {
2558 let max_w = area.width.max(1);
2559 let max_h = area.height.max(1);
2560 let w = rand_range(seed, 1, max_w);
2561 let h = rand_range(seed, 1, max_h);
2562 let max_x = area.x + area.width.saturating_sub(w);
2563 let max_y = area.y + area.height.saturating_sub(h);
2564 let x = rand_range(seed, area.x, max_x);
2565 let y = rand_range(seed, area.y, max_y);
2566 Rect::new(x, y, w, h)
2567 }
2568
2569 fn build_widget_tree(
2570 seed: &mut u64,
2571 depth: u8,
2572 max_depth: u8,
2573 breadth: u8,
2574 area: Rect,
2575 count: &mut usize,
2576 ) -> WidgetInfo {
2577 *count += 1;
2578 let name = format!("Widget_{depth}_{}", *count);
2579 let mut node = WidgetInfo::new(name, area).with_depth(depth);
2580
2581 if depth < max_depth {
2582 for _ in 0..breadth {
2583 let child_area = random_rect(seed, area);
2584 let child =
2585 build_widget_tree(seed, depth + 1, max_depth, breadth, child_area, count);
2586 node.add_child(child);
2587 }
2588 }
2589
2590 node
2591 }
2592
2593 fn build_stress_state(
2594 seed: &mut u64,
2595 roots: usize,
2596 max_depth: u8,
2597 breadth: u8,
2598 area: Rect,
2599 ) -> (InspectorState, usize) {
2600 let mut state = InspectorState {
2601 mode: InspectorMode::Full,
2602 show_hits: true,
2603 show_bounds: true,
2604 show_names: true,
2605 show_detail_panel: true,
2606 hover_pos: Some((area.x + 1, area.y + 1)),
2607 ..Default::default()
2608 };
2609
2610 let mut count = 0usize;
2611 for _ in 0..roots {
2612 let root_area = random_rect(seed, area);
2613 let widget = build_widget_tree(seed, 0, max_depth, breadth, root_area, &mut count);
2614 state.register_widget(widget);
2615 }
2616
2617 (state, count)
2618 }
2619
2620 fn populate_hit_grid(frame: &mut Frame, seed: &mut u64, count: usize, area: Rect) -> usize {
2621 for idx in 0..count {
2622 let region = match idx % 6 {
2623 0 => HitRegion::Content,
2624 1 => HitRegion::Border,
2625 2 => HitRegion::Scrollbar,
2626 3 => HitRegion::Handle,
2627 4 => HitRegion::Button,
2628 _ => HitRegion::Link,
2629 };
2630 let rect = random_rect(seed, area);
2631 frame.register_hit(rect, HitId::new((idx + 1) as u32), region, idx as HitData);
2632 }
2633 count
2634 }
2635
2636 fn buffer_checksum(frame: &Frame) -> u64 {
2637 let mut hasher = DefaultHasher::new();
2638 let mut scratch = String::new();
2639 for y in 0..frame.buffer.height() {
2640 for x in 0..frame.buffer.width() {
2641 if let Some(cell) = frame.buffer.get(x, y) {
2642 scratch.clear();
2643 use std::fmt::Write;
2644 let _ = write!(&mut scratch, "{cell:?}");
2645 scratch.hash(&mut hasher);
2646 }
2647 }
2648 }
2649 hasher.finish()
2650 }
2651
2652 fn log_jsonl(event: &str, fields: &[(&str, String)]) {
2653 let mut parts = Vec::with_capacity(fields.len() + 1);
2654 parts.push(format!(r#""event":"{event}""#));
2655 parts.extend(fields.iter().map(|(k, v)| format!(r#""{k}":{v}"#)));
2656 eprintln!("{{{}}}", parts.join(","));
2657 }
2658
2659 #[test]
2660 fn inspector_stress_large_tree_renders() {
2661 let mut seed = inspector_seed();
2662 let area = Rect::new(0, 0, 160, 48);
2663 let (state, widget_count) = build_stress_state(&mut seed, 6, 3, 3, area);
2664
2665 let mut pool = GraphemePool::new();
2666 let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2667 let hit_count = populate_hit_grid(&mut frame, &mut seed, 800, area);
2668
2669 let overlay = InspectorOverlay::new(&state);
2670 overlay.render(area, &mut frame);
2671
2672 let checksum = buffer_checksum(&frame);
2673 log_jsonl(
2674 "inspector_stress_render",
2675 &[
2676 ("seed", seed.to_string()),
2677 ("widgets", widget_count.to_string()),
2678 ("hit_regions", hit_count.to_string()),
2679 ("checksum", format!(r#""0x{checksum:016x}""#)),
2680 ],
2681 );
2682
2683 assert!(checksum != 0, "Rendered buffer checksum should be non-zero");
2684 }
2685
2686 #[test]
2687 fn inspector_stress_checksum_is_deterministic() {
2688 let seed = inspector_seed();
2689 let area = Rect::new(0, 0, 140, 40);
2690
2691 let checksum_a = {
2692 let mut seed = seed;
2693 let (state, _) = build_stress_state(&mut seed, 5, 3, 3, area);
2694 let mut pool = GraphemePool::new();
2695 let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2696 populate_hit_grid(&mut frame, &mut seed, 600, area);
2697 InspectorOverlay::new(&state).render(area, &mut frame);
2698 buffer_checksum(&frame)
2699 };
2700
2701 let checksum_b = {
2702 let mut seed = seed;
2703 let (state, _) = build_stress_state(&mut seed, 5, 3, 3, area);
2704 let mut pool = GraphemePool::new();
2705 let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2706 populate_hit_grid(&mut frame, &mut seed, 600, area);
2707 InspectorOverlay::new(&state).render(area, &mut frame);
2708 buffer_checksum(&frame)
2709 };
2710
2711 log_jsonl(
2712 "inspector_stress_determinism",
2713 &[
2714 ("seed", seed.to_string()),
2715 ("checksum_a", format!(r#""0x{checksum_a:016x}""#)),
2716 ("checksum_b", format!(r#""0x{checksum_b:016x}""#)),
2717 ],
2718 );
2719
2720 assert_eq!(
2721 checksum_a, checksum_b,
2722 "Stress render checksum should be deterministic"
2723 );
2724 }
2725
2726 #[test]
2727 fn inspector_perf_budget_overlay() {
2728 let seed = inspector_seed();
2729 let area = Rect::new(0, 0, 160, 48);
2730 let iterations = 40usize;
2731 let budget_p95_us = 15_000u64;
2732
2733 let mut timings = Vec::with_capacity(iterations);
2734 let mut checksums = Vec::with_capacity(iterations);
2735
2736 for i in 0..iterations {
2737 let mut seed = seed.wrapping_add(i as u64);
2738 let (state, widget_count) = build_stress_state(&mut seed, 6, 3, 3, area);
2739 let mut pool = GraphemePool::new();
2740 let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2741 let hit_count = populate_hit_grid(&mut frame, &mut seed, 800, area);
2742
2743 let start = Instant::now();
2744 InspectorOverlay::new(&state).render(area, &mut frame);
2745 let elapsed_us = start.elapsed().as_micros() as u64;
2746 timings.push(elapsed_us);
2747
2748 let checksum = buffer_checksum(&frame);
2749 checksums.push(checksum);
2750
2751 if i == 0 {
2752 log_jsonl(
2753 "inspector_perf_sample",
2754 &[
2755 ("seed", seed.to_string()),
2756 ("widgets", widget_count.to_string()),
2757 ("hit_regions", hit_count.to_string()),
2758 ("timing_us", elapsed_us.to_string()),
2759 ("checksum", format!(r#""0x{checksum:016x}""#)),
2760 ],
2761 );
2762 }
2763 }
2764
2765 let mut sorted = timings.clone();
2766 sorted.sort_unstable();
2767 let p95 = sorted[sorted.len() * 95 / 100];
2768 let p99 = sorted[sorted.len() * 99 / 100];
2769 let avg = timings.iter().sum::<u64>() as f64 / timings.len() as f64;
2770
2771 let mut seq_hasher = DefaultHasher::new();
2772 for checksum in &checksums {
2773 checksum.hash(&mut seq_hasher);
2774 }
2775 let seq_checksum = seq_hasher.finish();
2776
2777 log_jsonl(
2778 "inspector_perf_budget",
2779 &[
2780 ("seed", seed.to_string()),
2781 ("iterations", iterations.to_string()),
2782 ("avg_us", format!("{:.2}", avg)),
2783 ("p95_us", p95.to_string()),
2784 ("p99_us", p99.to_string()),
2785 ("budget_p95_us", budget_p95_us.to_string()),
2786 ("sequence_checksum", format!(r#""0x{seq_checksum:016x}""#)),
2787 ],
2788 );
2789
2790 assert!(
2791 p95 <= budget_p95_us,
2792 "Inspector overlay p95 {}µs exceeds budget {}µs",
2793 p95,
2794 budget_p95_us
2795 );
2796 }
2797}