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 web_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 #[must_use]
378 pub fn with_stderr(mut self) -> Self {
379 self.write_stderr = true;
380 self
381 }
382
383 #[must_use]
385 pub fn with_max_entries(mut self, max: usize) -> Self {
386 self.max_entries = max;
387 self
388 }
389
390 pub fn record(&mut self, entry: DiagnosticEntry) {
392 if self.write_stderr {
393 let _ = writeln!(std::io::stderr(), "{}", entry.to_jsonl());
394 }
395 if self.max_entries > 0 && self.entries.len() >= self.max_entries {
396 self.entries.remove(0);
397 }
398 self.entries.push(entry);
399 }
400
401 pub fn entries(&self) -> &[DiagnosticEntry] {
403 &self.entries
404 }
405
406 pub fn entries_of_kind(&self, kind: DiagnosticEventKind) -> Vec<&DiagnosticEntry> {
408 self.entries.iter().filter(|e| e.kind == kind).collect()
409 }
410
411 pub fn clear(&mut self) {
413 self.entries.clear();
414 }
415
416 pub fn to_jsonl(&self) -> String {
418 self.entries
419 .iter()
420 .map(DiagnosticEntry::to_jsonl)
421 .collect::<Vec<_>>()
422 .join("\n")
423 }
424}
425
426pub type TelemetryCallback = Box<dyn Fn(&DiagnosticEntry) + Send + Sync>;
428
429#[derive(Default)]
431pub struct TelemetryHooks {
432 on_toggle: Option<TelemetryCallback>,
433 on_mode_change: Option<TelemetryCallback>,
434 on_hover_change: Option<TelemetryCallback>,
435 on_selection_change: Option<TelemetryCallback>,
436 on_any_event: Option<TelemetryCallback>,
437}
438
439impl std::fmt::Debug for TelemetryHooks {
440 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441 f.debug_struct("TelemetryHooks")
442 .field("on_toggle", &self.on_toggle.is_some())
443 .field("on_mode_change", &self.on_mode_change.is_some())
444 .field("on_hover_change", &self.on_hover_change.is_some())
445 .field("on_selection_change", &self.on_selection_change.is_some())
446 .field("on_any_event", &self.on_any_event.is_some())
447 .finish()
448 }
449}
450
451impl TelemetryHooks {
452 pub fn new() -> Self {
454 Self::default()
455 }
456
457 #[must_use]
459 pub fn on_toggle(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
460 self.on_toggle = Some(Box::new(f));
461 self
462 }
463
464 #[must_use]
466 pub fn on_mode_change(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
467 self.on_mode_change = Some(Box::new(f));
468 self
469 }
470
471 #[must_use]
473 pub fn on_hover_change(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
474 self.on_hover_change = Some(Box::new(f));
475 self
476 }
477
478 #[must_use]
480 pub fn on_selection_change(
481 mut self,
482 f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static,
483 ) -> Self {
484 self.on_selection_change = Some(Box::new(f));
485 self
486 }
487
488 #[must_use]
490 pub fn on_any(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
491 self.on_any_event = Some(Box::new(f));
492 self
493 }
494
495 fn dispatch(&self, entry: &DiagnosticEntry) {
497 if let Some(ref cb) = self.on_any_event {
498 cb(entry);
499 }
500
501 match entry.kind {
502 DiagnosticEventKind::InspectorToggled => {
503 if let Some(ref cb) = self.on_toggle {
504 cb(entry);
505 }
506 }
507 DiagnosticEventKind::ModeChanged => {
508 if let Some(ref cb) = self.on_mode_change {
509 cb(entry);
510 }
511 }
512 DiagnosticEventKind::HoverChanged => {
513 if let Some(ref cb) = self.on_hover_change {
514 cb(entry);
515 }
516 }
517 DiagnosticEventKind::SelectionChanged => {
518 if let Some(ref cb) = self.on_selection_change {
519 cb(entry);
520 }
521 }
522 _ => {}
523 }
524 }
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
529pub enum InspectorMode {
530 #[default]
532 Off,
533 HitRegions,
535 WidgetBounds,
537 Full,
539}
540
541impl InspectorMode {
542 #[must_use]
546 pub fn cycle(self) -> Self {
547 match self {
548 Self::Off => Self::HitRegions,
549 Self::HitRegions => Self::WidgetBounds,
550 Self::WidgetBounds => Self::Full,
551 Self::Full => Self::Off,
552 }
553 }
554
555 #[inline]
557 pub fn is_active(self) -> bool {
558 self != Self::Off
559 }
560
561 pub const fn as_str(self) -> &'static str {
563 match self {
564 Self::Off => "off",
565 Self::HitRegions => "hit_regions",
566 Self::WidgetBounds => "widget_bounds",
567 Self::Full => "full",
568 }
569 }
570
571 #[inline]
573 pub fn show_hit_regions(self) -> bool {
574 matches!(self, Self::HitRegions | Self::Full)
575 }
576
577 #[inline]
579 pub fn show_widget_bounds(self) -> bool {
580 matches!(self, Self::WidgetBounds | Self::Full)
581 }
582}
583
584#[derive(Debug, Clone)]
586pub struct WidgetInfo {
587 pub name: String,
589 pub area: Rect,
591 pub hit_id: Option<HitId>,
593 pub hit_regions: Vec<(Rect, HitRegion, HitData)>,
595 pub render_time_us: Option<u64>,
597 pub depth: u8,
599 pub children: Vec<WidgetInfo>,
601}
602
603impl WidgetInfo {
604 #[must_use]
606 pub fn new(name: impl Into<String>, area: Rect) -> Self {
607 Self {
608 name: name.into(),
609 area,
610 hit_id: None,
611 hit_regions: Vec::new(),
612 render_time_us: None,
613 depth: 0,
614 children: Vec::new(),
615 }
616 }
617
618 #[must_use]
620 pub fn with_hit_id(mut self, id: HitId) -> Self {
621 self.hit_id = Some(id);
622 self
623 }
624
625 pub fn add_hit_region(&mut self, rect: Rect, region: HitRegion, data: HitData) {
627 self.hit_regions.push((rect, region, data));
628 }
629
630 #[must_use]
632 pub fn with_depth(mut self, depth: u8) -> Self {
633 self.depth = depth;
634 self
635 }
636
637 pub fn add_child(&mut self, child: WidgetInfo) {
639 self.children.push(child);
640 }
641}
642
643#[derive(Debug, Clone)]
645pub struct InspectorStyle {
646 pub bound_colors: [PackedRgba; 6],
648 pub hit_overlay: PackedRgba,
650 pub hit_hover: PackedRgba,
652 pub selected_highlight: PackedRgba,
654 pub label_fg: PackedRgba,
656 pub label_bg: PackedRgba,
658}
659
660impl Default for InspectorStyle {
661 fn default() -> Self {
662 Self {
663 bound_colors: [
664 PackedRgba::rgb(255, 100, 100), 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), ],
671 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,
675 label_bg: PackedRgba::rgba(0, 0, 0, 200),
676 }
677 }
678}
679
680impl InspectorStyle {
681 #[inline]
683 pub fn bound_color(&self, depth: u8) -> PackedRgba {
684 self.bound_colors[depth as usize % self.bound_colors.len()]
685 }
686
687 pub fn region_color(&self, region: HitRegion) -> PackedRgba {
689 match region {
690 HitRegion::None => PackedRgba::TRANSPARENT,
691 HitRegion::Content => PackedRgba::rgba(255, 165, 0, 60), 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), }
699 }
700}
701
702#[derive(Debug, Default)]
704pub struct InspectorState {
705 pub mode: InspectorMode,
707 pub hover_pos: Option<(u16, u16)>,
709 pub selected: Option<HitId>,
711 pub widgets: Vec<WidgetInfo>,
713 pub show_detail_panel: bool,
715 pub style: InspectorStyle,
717 pub show_hits: bool,
719 pub show_bounds: bool,
721 pub show_names: bool,
723 pub show_times: bool,
725 diagnostic_log: Option<DiagnosticLog>,
727 telemetry_hooks: Option<TelemetryHooks>,
729}
730
731impl InspectorState {
732 #[must_use]
734 pub fn new() -> Self {
735 let diagnostic_log = if diagnostics_enabled() {
736 Some(DiagnosticLog::new().with_stderr())
737 } else {
738 None
739 };
740 Self {
741 show_hits: true,
742 show_bounds: true,
743 show_names: true,
744 show_times: false,
745 diagnostic_log,
746 telemetry_hooks: None,
747 ..Default::default()
748 }
749 }
750
751 #[must_use]
753 pub fn with_diagnostics(mut self) -> Self {
754 self.diagnostic_log = Some(DiagnosticLog::new());
755 self
756 }
757
758 #[must_use]
760 pub fn with_telemetry_hooks(mut self, hooks: TelemetryHooks) -> Self {
761 self.telemetry_hooks = Some(hooks);
762 self
763 }
764
765 #[must_use = "use the diagnostic log (if enabled)"]
767 pub fn diagnostic_log(&self) -> Option<&DiagnosticLog> {
768 self.diagnostic_log.as_ref()
769 }
770
771 #[must_use = "use the diagnostic log (if enabled)"]
773 pub fn diagnostic_log_mut(&mut self) -> Option<&mut DiagnosticLog> {
774 self.diagnostic_log.as_mut()
775 }
776
777 #[inline]
778 fn diagnostics_active(&self) -> bool {
779 self.diagnostic_log.is_some() || self.telemetry_hooks.is_some()
780 }
781
782 pub fn toggle(&mut self) {
784 let prev = self.mode;
785 if self.mode.is_active() {
786 self.mode = InspectorMode::Off;
787 } else {
788 self.mode = InspectorMode::Full;
789 }
790 if self.mode != prev && self.diagnostics_active() {
791 self.record_diagnostic(
792 DiagnosticEntry::new(DiagnosticEventKind::InspectorToggled)
793 .with_previous_mode(prev)
794 .with_mode(self.mode)
795 .with_flag("inspector", self.mode.is_active()),
796 );
797 }
798 }
799
800 #[inline]
802 pub fn is_active(&self) -> bool {
803 self.mode.is_active()
804 }
805
806 pub fn cycle_mode(&mut self) {
808 let prev = self.mode;
809 self.mode = self.mode.cycle();
810 if self.mode != prev && self.diagnostics_active() {
811 self.record_diagnostic(
812 DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
813 .with_previous_mode(prev)
814 .with_mode(self.mode),
815 );
816 }
817 }
818
819 pub fn set_mode(&mut self, mode_num: u8) {
821 let prev = self.mode;
822 self.mode = match mode_num {
823 0 => InspectorMode::Off,
824 1 => InspectorMode::HitRegions,
825 2 => InspectorMode::WidgetBounds,
826 _ => InspectorMode::Full,
827 };
828 if self.mode != prev && self.diagnostics_active() {
829 self.record_diagnostic(
830 DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
831 .with_previous_mode(prev)
832 .with_mode(self.mode),
833 );
834 }
835 }
836
837 pub fn set_hover(&mut self, pos: Option<(u16, u16)>) {
839 if self.hover_pos != pos {
840 self.hover_pos = pos;
841 if self.diagnostics_active() {
842 self.record_diagnostic(
843 DiagnosticEntry::new(DiagnosticEventKind::HoverChanged).with_hover_pos(pos),
844 );
845 }
846 }
847 }
848
849 pub fn select(&mut self, id: Option<HitId>) {
851 if self.selected != id {
852 self.selected = id;
853 if self.diagnostics_active() {
854 self.record_diagnostic(
855 DiagnosticEntry::new(DiagnosticEventKind::SelectionChanged).with_selected(id),
856 );
857 }
858 }
859 }
860
861 pub fn clear_selection(&mut self) {
863 self.select(None);
864 }
865
866 pub fn toggle_detail_panel(&mut self) {
868 self.show_detail_panel = !self.show_detail_panel;
869 if self.diagnostics_active() {
870 self.record_diagnostic(
871 DiagnosticEntry::new(DiagnosticEventKind::DetailPanelToggled)
872 .with_flag("detail_panel", self.show_detail_panel),
873 );
874 }
875 }
876
877 pub fn toggle_hits(&mut self) {
879 self.show_hits = !self.show_hits;
880 if self.diagnostics_active() {
881 self.record_diagnostic(
882 DiagnosticEntry::new(DiagnosticEventKind::HitsToggled)
883 .with_flag("hits", self.show_hits),
884 );
885 }
886 }
887
888 pub fn toggle_bounds(&mut self) {
890 self.show_bounds = !self.show_bounds;
891 if self.diagnostics_active() {
892 self.record_diagnostic(
893 DiagnosticEntry::new(DiagnosticEventKind::BoundsToggled)
894 .with_flag("bounds", self.show_bounds),
895 );
896 }
897 }
898
899 pub fn toggle_names(&mut self) {
901 self.show_names = !self.show_names;
902 if self.diagnostics_active() {
903 self.record_diagnostic(
904 DiagnosticEntry::new(DiagnosticEventKind::NamesToggled)
905 .with_flag("names", self.show_names),
906 );
907 }
908 }
909
910 pub fn toggle_times(&mut self) {
912 self.show_times = !self.show_times;
913 if self.diagnostics_active() {
914 self.record_diagnostic(
915 DiagnosticEntry::new(DiagnosticEventKind::TimesToggled)
916 .with_flag("times", self.show_times),
917 );
918 }
919 }
920
921 pub fn clear_widgets(&mut self) {
923 let count = self.widgets.len();
924 self.widgets.clear();
925 if count > 0 && self.diagnostics_active() {
926 self.record_diagnostic(
927 DiagnosticEntry::new(DiagnosticEventKind::WidgetsCleared).with_widget_count(count),
928 );
929 }
930 }
931
932 pub fn register_widget(&mut self, info: WidgetInfo) {
934 #[cfg(feature = "tracing")]
935 trace!(name = info.name, area = ?info.area, "Registered widget for inspection");
936 if self.diagnostics_active() {
937 let widget_count = self.widgets.len() + 1;
938 self.record_diagnostic(
939 DiagnosticEntry::new(DiagnosticEventKind::WidgetRegistered)
940 .with_widget(&info)
941 .with_widget_count(widget_count),
942 );
943 }
944 self.widgets.push(info);
945 }
946
947 fn record_diagnostic(&mut self, entry: DiagnosticEntry) {
948 if self.diagnostic_log.is_none() && self.telemetry_hooks.is_none() {
949 return;
950 }
951 let entry = entry.with_checksum();
952
953 if let Some(ref hooks) = self.telemetry_hooks {
954 hooks.dispatch(&entry);
955 }
956
957 if let Some(ref mut log) = self.diagnostic_log {
958 log.record(entry);
959 }
960 }
961
962 #[inline]
964 pub fn should_show_hits(&self) -> bool {
965 self.show_hits && self.mode.show_hit_regions()
966 }
967
968 #[inline]
970 pub fn should_show_bounds(&self) -> bool {
971 self.show_bounds && self.mode.show_widget_bounds()
972 }
973}
974
975pub struct InspectorOverlay<'a> {
979 state: &'a InspectorState,
980}
981
982impl<'a> InspectorOverlay<'a> {
983 #[must_use]
985 pub fn new(state: &'a InspectorState) -> Self {
986 Self { state }
987 }
988
989 fn render_hit_regions(&self, area: Rect, frame: &mut Frame) {
991 #[cfg(feature = "tracing")]
992 let _span = info_span!("render_hit_regions").entered();
993
994 let Some(ref hit_grid) = frame.hit_grid else {
995 self.draw_warning(area, frame, "HitGrid not enabled");
997 return;
998 };
999
1000 let style = &self.state.style;
1001 let hover_pos = self.state.hover_pos;
1002 let selected = self.state.selected;
1003
1004 for y in area.y..area.bottom() {
1006 for x in area.x..area.right() {
1007 if let Some(cell) = hit_grid.get(x, y) {
1008 if cell.is_empty() {
1009 continue;
1010 }
1011
1012 let is_hovered = hover_pos == Some((x, y));
1013 let is_selected = selected == cell.widget_id;
1014
1015 let overlay = if is_selected {
1017 style.selected_highlight
1018 } else if is_hovered {
1019 style.hit_hover
1020 } else {
1021 style.region_color(cell.region)
1022 };
1023
1024 if let Some(buf_cell) = frame.buffer.get_mut(x, y) {
1026 buf_cell.bg = overlay.over(buf_cell.bg);
1027 }
1028 }
1029 }
1030 }
1031 }
1032
1033 fn render_widget_bounds(&self, _area: Rect, frame: &mut Frame) {
1035 #[cfg(feature = "tracing")]
1036 let _span = info_span!(
1037 "render_widget_bounds",
1038 widget_count = self.state.widgets.len()
1039 )
1040 .entered();
1041
1042 let style = &self.state.style;
1043
1044 for widget in &self.state.widgets {
1045 self.render_widget_bound(widget, frame, style);
1046 }
1047 }
1048
1049 fn render_widget_bound(&self, widget: &WidgetInfo, frame: &mut Frame, style: &InspectorStyle) {
1051 let color = style.bound_color(widget.depth);
1052 let area = widget.area;
1053
1054 if area.is_empty() {
1056 return;
1057 }
1058
1059 self.draw_rect_outline(area, frame, color);
1061
1062 if self.state.show_names && !widget.name.is_empty() {
1064 self.draw_label(area, frame, &widget.name, style);
1065 }
1066
1067 for child in &widget.children {
1069 self.render_widget_bound(child, frame, style);
1070 }
1071 }
1072
1073 fn draw_rect_outline(&self, rect: Rect, frame: &mut Frame, color: PackedRgba) {
1075 if rect.width == 0 || rect.height == 0 {
1076 return;
1077 }
1078
1079 let x = rect.x;
1080 let y = rect.y;
1081 let right = rect.right().saturating_sub(1);
1082 let bottom = rect.bottom().saturating_sub(1);
1083
1084 for cx in x..=right {
1086 if let Some(cell) = frame.buffer.get_mut(cx, y) {
1087 cell.fg = color;
1088 }
1089 }
1090
1091 if bottom > y {
1093 for cx in x..=right {
1094 if let Some(cell) = frame.buffer.get_mut(cx, bottom) {
1095 cell.fg = color;
1096 }
1097 }
1098 }
1099
1100 for cy in y..=bottom {
1102 if let Some(cell) = frame.buffer.get_mut(x, cy) {
1103 cell.fg = color;
1104 }
1105 }
1106
1107 if right > x {
1109 for cy in y..=bottom {
1110 if let Some(cell) = frame.buffer.get_mut(right, cy) {
1111 cell.fg = color;
1112 }
1113 }
1114 }
1115 }
1116
1117 fn draw_label(&self, area: Rect, frame: &mut Frame, name: &str, style: &InspectorStyle) {
1119 let label = format!("[{name}]");
1120 let label_len = display_width(&label) as u16;
1121
1122 let x = area.x;
1124 let y = area.y;
1125
1126 let label_area = Rect::new(x, y, label_len.min(area.width), 1);
1128 set_style_area(
1129 &mut frame.buffer,
1130 label_area,
1131 Style::new().bg(style.label_bg),
1132 );
1133
1134 let label_style = Style::new().fg(style.label_fg).bg(style.label_bg);
1136 draw_text_span(frame, x, y, &label, label_style, area.x + area.width);
1137 }
1138
1139 fn draw_warning(&self, area: Rect, frame: &mut Frame, msg: &str) {
1141 let style = &self.state.style;
1142 let warning_style = Style::new()
1143 .fg(PackedRgba::rgb(255, 200, 0))
1144 .bg(style.label_bg);
1145
1146 let msg_len = display_width(msg) as u16;
1148 let x = area.x + area.width.saturating_sub(msg_len) / 2;
1149 let y = area.y;
1150
1151 set_style_area(
1152 &mut frame.buffer,
1153 Rect::new(x, y, msg_len, 1),
1154 warning_style,
1155 );
1156
1157 draw_text_span(frame, x, y, msg, warning_style, area.x + area.width);
1158 }
1159
1160 fn render_detail_panel(&self, area: Rect, frame: &mut Frame) {
1162 let style = &self.state.style;
1163
1164 let panel_width: u16 = 24;
1166 let panel_height = area.height.min(20);
1167
1168 let panel_x = area.right().saturating_sub(panel_width + 1);
1170 let panel_y = area.y + 1;
1171 let panel_area = Rect::new(panel_x, panel_y, panel_width, panel_height);
1172
1173 set_style_area(
1175 &mut frame.buffer,
1176 panel_area,
1177 Style::new().bg(style.label_bg),
1178 );
1179
1180 self.draw_rect_outline(panel_area, frame, style.label_fg);
1182
1183 let content_x = panel_x + 1;
1185 let mut y = panel_y + 1;
1186
1187 self.draw_panel_text(frame, content_x, y, "Inspector", style.label_fg);
1189 y += 2;
1190
1191 let mode_str = match self.state.mode {
1193 InspectorMode::Off => "Off",
1194 InspectorMode::HitRegions => "Hit Regions",
1195 InspectorMode::WidgetBounds => "Widget Bounds",
1196 InspectorMode::Full => "Full",
1197 };
1198 self.draw_panel_text(
1199 frame,
1200 content_x,
1201 y,
1202 &format!("Mode: {mode_str}"),
1203 style.label_fg,
1204 );
1205 y += 1;
1206
1207 if let Some((hx, hy)) = self.state.hover_pos {
1209 self.draw_panel_text(
1210 frame,
1211 content_x,
1212 y,
1213 &format!("Hover: ({hx},{hy})"),
1214 style.label_fg,
1215 );
1216 y += 1;
1217
1218 let hit_info = frame
1220 .hit_grid
1221 .as_ref()
1222 .and_then(|grid| grid.get(hx, hy).filter(|h| !h.is_empty()).map(|h| (*h,)));
1223
1224 if let Some((hit,)) = hit_info {
1226 let region_str = format!("{:?}", hit.region);
1227 self.draw_panel_text(
1228 frame,
1229 content_x,
1230 y,
1231 &format!("Region: {region_str}"),
1232 style.label_fg,
1233 );
1234 y += 1;
1235 if let Some(id) = hit.widget_id {
1236 self.draw_panel_text(
1237 frame,
1238 content_x,
1239 y,
1240 &format!("ID: {}", id.id()),
1241 style.label_fg,
1242 );
1243 y += 1;
1244 }
1245 if hit.data != 0 {
1246 self.draw_panel_text(
1247 frame,
1248 content_x,
1249 y,
1250 &format!("Data: {}", hit.data),
1251 style.label_fg,
1252 );
1253 #[allow(unused_assignments)]
1254 {
1255 y += 1;
1256 }
1257 }
1258 }
1259 }
1260 }
1261
1262 fn draw_panel_text(&self, frame: &mut Frame, x: u16, y: u16, text: &str, fg: PackedRgba) {
1264 for (i, ch) in text.chars().enumerate() {
1265 let cx = x + i as u16;
1266 if let Some(cell) = frame.buffer.get_mut(cx, y) {
1267 *cell = Cell::from_char(ch).with_fg(fg);
1268 }
1269 }
1270 }
1271}
1272
1273impl Widget for InspectorOverlay<'_> {
1274 fn render(&self, area: Rect, frame: &mut Frame) {
1275 #[cfg(feature = "tracing")]
1276 let _span = info_span!("inspector_overlay", ?area).entered();
1277
1278 if !self.state.is_active() {
1279 return;
1280 }
1281
1282 if self.state.should_show_hits() {
1284 self.render_hit_regions(area, frame);
1285 }
1286
1287 if self.state.should_show_bounds() {
1289 self.render_widget_bounds(area, frame);
1290 }
1291
1292 if self.state.show_detail_panel {
1294 self.render_detail_panel(area, frame);
1295 }
1296 }
1297
1298 fn is_essential(&self) -> bool {
1299 false
1301 }
1302}
1303
1304#[derive(Debug, Clone)]
1306pub struct HitInfo {
1307 pub widget_id: HitId,
1309 pub region: HitRegion,
1311 pub data: HitData,
1313 pub position: (u16, u16),
1315}
1316
1317impl HitInfo {
1318 #[must_use = "use the computed hit info (if any)"]
1320 pub fn from_cell(cell: &HitCell, x: u16, y: u16) -> Option<Self> {
1321 cell.widget_id.map(|id| Self {
1322 widget_id: id,
1323 region: cell.region,
1324 data: cell.data,
1325 position: (x, y),
1326 })
1327 }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332 use super::*;
1333 use ftui_render::grapheme_pool::GraphemePool;
1334
1335 #[test]
1336 fn inspector_mode_cycle() {
1337 let mut mode = InspectorMode::Off;
1338 mode = mode.cycle();
1339 assert_eq!(mode, InspectorMode::HitRegions);
1340 mode = mode.cycle();
1341 assert_eq!(mode, InspectorMode::WidgetBounds);
1342 mode = mode.cycle();
1343 assert_eq!(mode, InspectorMode::Full);
1344 mode = mode.cycle();
1345 assert_eq!(mode, InspectorMode::Off);
1346 }
1347
1348 #[test]
1349 fn inspector_mode_is_active() {
1350 assert!(!InspectorMode::Off.is_active());
1351 assert!(InspectorMode::HitRegions.is_active());
1352 assert!(InspectorMode::WidgetBounds.is_active());
1353 assert!(InspectorMode::Full.is_active());
1354 }
1355
1356 #[test]
1357 fn inspector_mode_show_flags() {
1358 assert!(!InspectorMode::Off.show_hit_regions());
1359 assert!(!InspectorMode::Off.show_widget_bounds());
1360
1361 assert!(InspectorMode::HitRegions.show_hit_regions());
1362 assert!(!InspectorMode::HitRegions.show_widget_bounds());
1363
1364 assert!(!InspectorMode::WidgetBounds.show_hit_regions());
1365 assert!(InspectorMode::WidgetBounds.show_widget_bounds());
1366
1367 assert!(InspectorMode::Full.show_hit_regions());
1368 assert!(InspectorMode::Full.show_widget_bounds());
1369 }
1370
1371 #[test]
1372 fn inspector_state_toggle() {
1373 let mut state = InspectorState::new();
1374 assert!(!state.is_active());
1375
1376 state.toggle();
1377 assert!(state.is_active());
1378 assert_eq!(state.mode, InspectorMode::Full);
1379
1380 state.toggle();
1381 assert!(!state.is_active());
1382 assert_eq!(state.mode, InspectorMode::Off);
1383 }
1384
1385 #[test]
1386 fn inspector_state_set_mode() {
1387 let mut state = InspectorState::new();
1388
1389 state.set_mode(1);
1390 assert_eq!(state.mode, InspectorMode::HitRegions);
1391
1392 state.set_mode(2);
1393 assert_eq!(state.mode, InspectorMode::WidgetBounds);
1394
1395 state.set_mode(3);
1396 assert_eq!(state.mode, InspectorMode::Full);
1397
1398 state.set_mode(0);
1399 assert_eq!(state.mode, InspectorMode::Off);
1400
1401 state.set_mode(99);
1403 assert_eq!(state.mode, InspectorMode::Full);
1404 }
1405
1406 #[test]
1407 fn inspector_style_default() {
1408 let style = InspectorStyle::default();
1409 assert_eq!(style.bound_colors.len(), 6);
1410 assert_eq!(style.hit_overlay, PackedRgba::rgba(255, 165, 0, 80));
1411 }
1412
1413 #[test]
1414 fn inspector_style_bound_color_cycles() {
1415 let style = InspectorStyle::default();
1416 assert_eq!(style.bound_color(0), style.bound_colors[0]);
1417 assert_eq!(style.bound_color(5), style.bound_colors[5]);
1418 assert_eq!(style.bound_color(6), style.bound_colors[0]); assert_eq!(style.bound_color(7), style.bound_colors[1]);
1420 }
1421
1422 #[test]
1423 fn widget_info_creation() {
1424 let info = WidgetInfo::new("Button", Rect::new(10, 5, 20, 3))
1425 .with_hit_id(HitId::new(42))
1426 .with_depth(2);
1427
1428 assert_eq!(info.name, "Button");
1429 assert_eq!(info.area, Rect::new(10, 5, 20, 3));
1430 assert_eq!(info.hit_id, Some(HitId::new(42)));
1431 assert_eq!(info.depth, 2);
1432 }
1433
1434 #[test]
1435 fn widget_info_add_hit_region() {
1436 let mut info = WidgetInfo::new("List", Rect::new(0, 0, 10, 10));
1437 info.add_hit_region(Rect::new(0, 0, 10, 1), HitRegion::Content, 0);
1438 info.add_hit_region(Rect::new(0, 1, 10, 1), HitRegion::Content, 1);
1439
1440 assert_eq!(info.hit_regions.len(), 2);
1441 assert_eq!(info.hit_regions[0].2, 0);
1442 assert_eq!(info.hit_regions[1].2, 1);
1443 }
1444
1445 #[test]
1446 fn widget_info_add_child() {
1447 let mut parent = WidgetInfo::new("Container", Rect::new(0, 0, 20, 20));
1448 let child = WidgetInfo::new("Button", Rect::new(5, 5, 10, 3));
1449 parent.add_child(child);
1450
1451 assert_eq!(parent.children.len(), 1);
1452 assert_eq!(parent.children[0].name, "Button");
1453 }
1454
1455 #[test]
1456 fn inspector_overlay_inactive_is_noop() {
1457 let state = InspectorState::new();
1458 let overlay = InspectorOverlay::new(&state);
1459
1460 let mut pool = GraphemePool::new();
1461 let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
1462 let area = Rect::new(0, 0, 10, 10);
1463
1464 overlay.render(area, &mut frame);
1466
1467 assert!(frame.buffer.get(0, 0).unwrap().is_empty());
1469 }
1470
1471 #[test]
1472 fn inspector_overlay_renders_when_active() {
1473 let mut state = InspectorState::new();
1474 state.mode = InspectorMode::Full;
1475 state.show_detail_panel = true;
1476
1477 let overlay = InspectorOverlay::new(&state);
1478
1479 let mut pool = GraphemePool::new();
1480 let mut frame = Frame::with_hit_grid(40, 20, &mut pool);
1481
1482 frame.register_hit(Rect::new(5, 5, 10, 3), HitId::new(1), HitRegion::Button, 42);
1484
1485 let area = Rect::new(0, 0, 40, 20);
1486 overlay.render(area, &mut frame);
1487
1488 }
1491
1492 #[test]
1493 fn hit_info_from_cell() {
1494 let cell = HitCell::new(HitId::new(5), HitRegion::Button, 99);
1495 let info = HitInfo::from_cell(&cell, 10, 20);
1496
1497 assert!(info.is_some());
1498 let info = info.unwrap();
1499 assert_eq!(info.widget_id, HitId::new(5));
1500 assert_eq!(info.region, HitRegion::Button);
1501 assert_eq!(info.data, 99);
1502 assert_eq!(info.position, (10, 20));
1503 }
1504
1505 #[test]
1506 fn hit_info_from_empty_cell() {
1507 let cell = HitCell::default();
1508 let info = HitInfo::from_cell(&cell, 0, 0);
1509 assert!(info.is_none());
1510 }
1511
1512 #[test]
1513 fn inspector_state_toggles() {
1514 let mut state = InspectorState::new();
1515
1516 assert!(state.show_hits);
1517 state.toggle_hits();
1518 assert!(!state.show_hits);
1519 state.toggle_hits();
1520 assert!(state.show_hits);
1521
1522 assert!(state.show_bounds);
1523 state.toggle_bounds();
1524 assert!(!state.show_bounds);
1525
1526 assert!(state.show_names);
1527 state.toggle_names();
1528 assert!(!state.show_names);
1529
1530 assert!(!state.show_times);
1531 state.toggle_times();
1532 assert!(state.show_times);
1533
1534 assert!(!state.show_detail_panel);
1535 state.toggle_detail_panel();
1536 assert!(state.show_detail_panel);
1537 }
1538
1539 #[test]
1540 fn inspector_state_selection() {
1541 let mut state = InspectorState::new();
1542
1543 assert!(state.selected.is_none());
1544 state.select(Some(HitId::new(42)));
1545 assert_eq!(state.selected, Some(HitId::new(42)));
1546 state.clear_selection();
1547 assert!(state.selected.is_none());
1548 }
1549
1550 #[test]
1551 fn inspector_state_hover() {
1552 let mut state = InspectorState::new();
1553
1554 assert!(state.hover_pos.is_none());
1555 state.set_hover(Some((10, 20)));
1556 assert_eq!(state.hover_pos, Some((10, 20)));
1557 state.set_hover(None);
1558 assert!(state.hover_pos.is_none());
1559 }
1560
1561 #[test]
1562 fn inspector_state_widget_registry() {
1563 let mut state = InspectorState::new();
1564
1565 let widget = WidgetInfo::new("Test", Rect::new(0, 0, 10, 10));
1566 state.register_widget(widget);
1567 assert_eq!(state.widgets.len(), 1);
1568
1569 state.clear_widgets();
1570 assert!(state.widgets.is_empty());
1571 }
1572
1573 #[test]
1574 fn inspector_overlay_is_not_essential() {
1575 let state = InspectorState::new();
1576 let overlay = InspectorOverlay::new(&state);
1577 assert!(!overlay.is_essential());
1578 }
1579
1580 #[test]
1585 fn edge_case_zero_area_widget() {
1586 let info = WidgetInfo::new("ZeroArea", Rect::new(0, 0, 0, 0));
1588 assert_eq!(info.area.width, 0);
1589 assert_eq!(info.area.height, 0);
1590 assert!(info.area.is_empty());
1591 }
1592
1593 #[test]
1594 fn edge_case_max_depth_widget() {
1595 let info = WidgetInfo::new("Deep", Rect::new(0, 0, 10, 10)).with_depth(u8::MAX);
1597 assert_eq!(info.depth, u8::MAX);
1598
1599 let style = InspectorStyle::default();
1601 let _color = style.bound_color(u8::MAX); }
1603
1604 #[test]
1605 fn edge_case_empty_widget_registry() {
1606 let mut state = InspectorState::new();
1607 assert!(state.widgets.is_empty());
1608
1609 state.clear_widgets();
1611 assert!(state.widgets.is_empty());
1612 }
1613
1614 #[test]
1615 fn edge_case_selection_without_widgets() {
1616 let mut state = InspectorState::new();
1617
1618 state.select(Some(HitId::new(42)));
1620 assert_eq!(state.selected, Some(HitId::new(42)));
1621
1622 state.clear_selection();
1624 assert!(state.selected.is_none());
1625 }
1626
1627 #[test]
1628 fn edge_case_hover_boundary_positions() {
1629 let mut state = InspectorState::new();
1630
1631 state.set_hover(Some((u16::MAX, u16::MAX)));
1633 assert_eq!(state.hover_pos, Some((u16::MAX, u16::MAX)));
1634
1635 state.set_hover(Some((0, 0)));
1637 assert_eq!(state.hover_pos, Some((0, 0)));
1638 }
1639
1640 #[test]
1641 fn edge_case_deeply_nested_widgets() {
1642 let mut deepest = WidgetInfo::new("L10", Rect::new(10, 10, 80, 80)).with_depth(10);
1644
1645 for i in (1..10).rev() {
1646 let mut parent =
1647 WidgetInfo::new(format!("L{i}"), Rect::new(i as u16, i as u16, 90, 90))
1648 .with_depth(i as u8);
1649 parent.add_child(deepest);
1650 deepest = parent;
1651 }
1652
1653 let mut root = WidgetInfo::new("Root", Rect::new(0, 0, 100, 100)).with_depth(0);
1654 root.add_child(deepest);
1655
1656 assert_eq!(root.children.len(), 1);
1658 assert_eq!(root.children[0].depth, 1);
1659 assert_eq!(root.children[0].children[0].depth, 2);
1660 }
1661
1662 #[test]
1663 fn edge_case_rapid_mode_cycling() {
1664 let mut state = InspectorState::new();
1665 assert_eq!(state.mode, InspectorMode::Off);
1666
1667 for _ in 0..1000 {
1669 state.mode = state.mode.cycle();
1670 }
1671 assert_eq!(state.mode, InspectorMode::Off);
1673 }
1674
1675 #[test]
1676 fn edge_case_many_hit_regions() {
1677 let mut info = WidgetInfo::new("ManyHits", Rect::new(0, 0, 100, 1000));
1678
1679 for i in 0..1000 {
1681 info.add_hit_region(
1682 Rect::new(0, i as u16, 100, 1),
1683 HitRegion::Content,
1684 i as HitData,
1685 );
1686 }
1687
1688 assert_eq!(info.hit_regions.len(), 1000);
1689 assert_eq!(info.hit_regions[0].2, 0);
1690 assert_eq!(info.hit_regions[999].2, 999);
1691 }
1692
1693 #[test]
1694 fn edge_case_mode_show_flags_consistency() {
1695 for mode in [
1697 InspectorMode::Off,
1698 InspectorMode::HitRegions,
1699 InspectorMode::WidgetBounds,
1700 InspectorMode::Full,
1701 ] {
1702 match mode {
1703 InspectorMode::Off => {
1704 assert!(!mode.show_hit_regions());
1705 assert!(!mode.show_widget_bounds());
1706 }
1707 InspectorMode::HitRegions => {
1708 assert!(mode.show_hit_regions());
1709 assert!(!mode.show_widget_bounds());
1710 }
1711 InspectorMode::WidgetBounds => {
1712 assert!(!mode.show_hit_regions());
1713 assert!(mode.show_widget_bounds());
1714 }
1715 InspectorMode::Full => {
1716 assert!(mode.show_hit_regions());
1717 assert!(mode.show_widget_bounds());
1718 }
1719 }
1720 }
1721 }
1722
1723 mod proptests {
1728 use super::*;
1729 use proptest::prelude::*;
1730
1731 proptest! {
1732 #[test]
1735 fn mode_cycle_is_periodic(start_cycle in 0u8..4) {
1736 let start_mode = match start_cycle {
1737 0 => InspectorMode::Off,
1738 1 => InspectorMode::HitRegions,
1739 2 => InspectorMode::WidgetBounds,
1740 _ => InspectorMode::Full,
1741 };
1742
1743 let mut mode = start_mode;
1744 for _ in 0..4 {
1745 mode = mode.cycle();
1746 }
1747 prop_assert_eq!(mode, start_mode);
1748 }
1749
1750 #[test]
1752 fn bound_color_cycle_is_periodic(depth in 0u8..200) {
1753 let style = InspectorStyle::default();
1754 let color_a = style.bound_color(depth);
1755 let color_b = style.bound_color(depth.wrapping_add(6));
1756 prop_assert_eq!(color_a, color_b);
1757 }
1758
1759 #[test]
1761 fn is_active_reflects_mode(mode_idx in 0u8..4) {
1762 let mode = match mode_idx {
1763 0 => InspectorMode::Off,
1764 1 => InspectorMode::HitRegions,
1765 2 => InspectorMode::WidgetBounds,
1766 _ => InspectorMode::Full,
1767 };
1768 let expected_active = mode_idx != 0;
1769 prop_assert_eq!(mode.is_active(), expected_active);
1770 }
1771
1772 #[test]
1774 fn double_toggle_is_identity(_seed in 0u32..1000) {
1775 let mut state = InspectorState::new();
1776 let initial_hits = state.show_hits;
1777 let initial_bounds = state.show_bounds;
1778 let initial_names = state.show_names;
1779 let initial_times = state.show_times;
1780 let initial_panel = state.show_detail_panel;
1781
1782 state.toggle_hits();
1784 state.toggle_hits();
1785 state.toggle_bounds();
1786 state.toggle_bounds();
1787 state.toggle_names();
1788 state.toggle_names();
1789 state.toggle_times();
1790 state.toggle_times();
1791 state.toggle_detail_panel();
1792 state.toggle_detail_panel();
1793
1794 prop_assert_eq!(state.show_hits, initial_hits);
1795 prop_assert_eq!(state.show_bounds, initial_bounds);
1796 prop_assert_eq!(state.show_names, initial_names);
1797 prop_assert_eq!(state.show_times, initial_times);
1798 prop_assert_eq!(state.show_detail_panel, initial_panel);
1799 }
1800
1801 #[test]
1803 fn widget_info_preserves_area(
1804 x in 0u16..1000,
1805 y in 0u16..1000,
1806 w in 1u16..500,
1807 h in 1u16..500,
1808 ) {
1809 let area = Rect::new(x, y, w, h);
1810 let info = WidgetInfo::new("Test", area);
1811 prop_assert_eq!(info.area, area);
1812 }
1813
1814 #[test]
1816 fn widget_depth_preserved(depth in 0u8..255) {
1817 let info = WidgetInfo::new("Test", Rect::new(0, 0, 10, 10))
1818 .with_depth(depth);
1819 prop_assert_eq!(info.depth, depth);
1820 }
1821
1822 #[test]
1824 fn widget_hit_id_preserved(id in 0u32..u32::MAX) {
1825 let hit_id = HitId::new(id);
1826 let info = WidgetInfo::new("Test", Rect::new(0, 0, 10, 10))
1827 .with_hit_id(hit_id);
1828 prop_assert_eq!(info.hit_id, Some(hit_id));
1829 }
1830
1831 #[test]
1833 fn add_child_increases_count(child_count in 0usize..50) {
1834 let mut parent = WidgetInfo::new("Parent", Rect::new(0, 0, 100, 100));
1835 for i in 0..child_count {
1836 parent.add_child(WidgetInfo::new(
1837 format!("Child{i}"),
1838 Rect::new(0, i as u16, 10, 1),
1839 ));
1840 }
1841 prop_assert_eq!(parent.children.len(), child_count);
1842 }
1843
1844 #[test]
1846 fn add_hit_regions_unbounded(region_count in 0usize..100) {
1847 let mut info = WidgetInfo::new("Test", Rect::new(0, 0, 100, 100));
1848 for i in 0..region_count {
1849 info.add_hit_region(
1850 Rect::new(0, i as u16, 10, 1),
1851 HitRegion::Content,
1852 i as HitData,
1853 );
1854 }
1855 prop_assert_eq!(info.hit_regions.len(), region_count);
1856 }
1857
1858 #[test]
1860 fn set_mode_maps_correctly(mode_idx in 0u8..10) {
1861 let mut state = InspectorState::new();
1862 state.set_mode(mode_idx);
1863 let expected = match mode_idx {
1864 0 => InspectorMode::Off,
1865 1 => InspectorMode::HitRegions,
1866 2 => InspectorMode::WidgetBounds,
1867 3 => InspectorMode::Full,
1868 _ => InspectorMode::Full, };
1870 prop_assert_eq!(state.mode, expected);
1871 }
1872
1873 #[test]
1875 fn should_show_hits_respects_both(mode_idx in 0u8..4, flag in proptest::bool::ANY) {
1876 let mut state = InspectorState::new();
1877 state.set_mode(mode_idx);
1878 state.show_hits = flag;
1879 let mode_allows = state.mode.show_hit_regions();
1880 prop_assert_eq!(state.should_show_hits(), flag && mode_allows);
1881 }
1882
1883 #[test]
1885 fn should_show_bounds_respects_both(mode_idx in 0u8..4, flag in proptest::bool::ANY) {
1886 let mut state = InspectorState::new();
1887 state.set_mode(mode_idx);
1888 state.show_bounds = flag;
1889 let mode_allows = state.mode.show_widget_bounds();
1890 prop_assert_eq!(state.should_show_bounds(), flag && mode_allows);
1891 }
1892 }
1893 }
1894
1895 #[test]
1900 fn region_color_all_variants() {
1901 let style = InspectorStyle::default();
1902
1903 let none_color = style.region_color(HitRegion::None);
1905 let content_color = style.region_color(HitRegion::Content);
1906 let border_color = style.region_color(HitRegion::Border);
1907 let scrollbar_color = style.region_color(HitRegion::Scrollbar);
1908 let handle_color = style.region_color(HitRegion::Handle);
1909 let button_color = style.region_color(HitRegion::Button);
1910 let link_color = style.region_color(HitRegion::Link);
1911 let custom_color = style.region_color(HitRegion::Custom(42));
1912
1913 assert_eq!(none_color, PackedRgba::TRANSPARENT);
1915
1916 assert_ne!(content_color.a(), 0);
1918 assert_ne!(border_color.a(), 0);
1919 assert_ne!(scrollbar_color.a(), 0);
1920 assert_ne!(handle_color.a(), 0);
1921 assert_ne!(button_color.a(), 0);
1922 assert_ne!(link_color.a(), 0);
1923 assert_ne!(custom_color.a(), 0);
1924
1925 assert!(content_color.a() < 255);
1927 assert!(button_color.a() < 255);
1928 }
1929
1930 #[test]
1931 fn region_color_custom_variants() {
1932 let style = InspectorStyle::default();
1933
1934 let c0 = style.region_color(HitRegion::Custom(0));
1936 let c1 = style.region_color(HitRegion::Custom(1));
1937 let c255 = style.region_color(HitRegion::Custom(255));
1938
1939 assert_eq!(c0, c1);
1940 assert_eq!(c1, c255);
1941 }
1942
1943 #[test]
1948 fn should_show_hits_requires_both_mode_and_flag() {
1949 let mut state = InspectorState::new();
1950
1951 state.mode = InspectorMode::Off;
1953 state.show_hits = true;
1954 assert!(!state.should_show_hits());
1955
1956 state.mode = InspectorMode::HitRegions;
1958 state.show_hits = true;
1959 assert!(state.should_show_hits());
1960
1961 state.show_hits = false;
1963 assert!(!state.should_show_hits());
1964
1965 state.mode = InspectorMode::WidgetBounds;
1967 state.show_hits = true;
1968 assert!(!state.should_show_hits());
1969
1970 state.mode = InspectorMode::Full;
1972 state.show_hits = true;
1973 assert!(state.should_show_hits());
1974 }
1975
1976 #[test]
1977 fn should_show_bounds_requires_both_mode_and_flag() {
1978 let mut state = InspectorState::new();
1979
1980 state.mode = InspectorMode::Off;
1982 state.show_bounds = true;
1983 assert!(!state.should_show_bounds());
1984
1985 state.mode = InspectorMode::WidgetBounds;
1987 state.show_bounds = true;
1988 assert!(state.should_show_bounds());
1989
1990 state.show_bounds = false;
1992 assert!(!state.should_show_bounds());
1993
1994 state.mode = InspectorMode::HitRegions;
1996 state.show_bounds = true;
1997 assert!(!state.should_show_bounds());
1998
1999 state.mode = InspectorMode::Full;
2001 state.show_bounds = true;
2002 assert!(state.should_show_bounds());
2003 }
2004
2005 #[test]
2010 fn overlay_respects_mode_hit_regions_only() {
2011 let mut state = InspectorState::new();
2012 state.mode = InspectorMode::HitRegions;
2013
2014 state.register_widget(WidgetInfo::new("TestWidget", Rect::new(5, 5, 10, 3)));
2016
2017 let overlay = InspectorOverlay::new(&state);
2018 let mut pool = GraphemePool::new();
2019 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2020
2021 frame.register_hit(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Button, 0);
2023
2024 let area = Rect::new(0, 0, 20, 10);
2025 overlay.render(area, &mut frame);
2026
2027 assert!(state.should_show_hits());
2030 assert!(!state.should_show_bounds());
2031 }
2032
2033 #[test]
2034 fn overlay_respects_mode_widget_bounds_only() {
2035 let mut state = InspectorState::new();
2036 state.mode = InspectorMode::WidgetBounds;
2037 state.show_names = true;
2038
2039 state.register_widget(WidgetInfo::new("TestWidget", Rect::new(2, 2, 15, 5)));
2041
2042 let overlay = InspectorOverlay::new(&state);
2043 let mut pool = GraphemePool::new();
2044 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2045
2046 let area = Rect::new(0, 0, 20, 10);
2047 overlay.render(area, &mut frame);
2048
2049 assert!(!state.should_show_hits());
2051 assert!(state.should_show_bounds());
2052 }
2053
2054 #[test]
2055 fn overlay_full_mode_shows_both() {
2056 let mut state = InspectorState::new();
2057 state.mode = InspectorMode::Full;
2058
2059 state.register_widget(WidgetInfo::new("FullTest", Rect::new(0, 0, 10, 5)));
2061
2062 let overlay = InspectorOverlay::new(&state);
2063 let mut pool = GraphemePool::new();
2064 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2065
2066 frame.register_hit(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Content, 0);
2067
2068 let area = Rect::new(0, 0, 20, 10);
2069 overlay.render(area, &mut frame);
2070
2071 assert!(state.should_show_hits());
2072 assert!(state.should_show_bounds());
2073 }
2074
2075 #[test]
2076 fn overlay_detail_panel_renders_when_enabled() {
2077 let mut state = InspectorState::new();
2078 state.mode = InspectorMode::Full;
2079 state.show_detail_panel = true;
2080 state.set_hover(Some((5, 5)));
2081
2082 let overlay = InspectorOverlay::new(&state);
2083 let mut pool = GraphemePool::new();
2084 let mut frame = Frame::with_hit_grid(50, 25, &mut pool);
2085
2086 let area = Rect::new(0, 0, 50, 25);
2087 overlay.render(area, &mut frame);
2088
2089 let panel_x = 25;
2093 let panel_y = 1;
2094
2095 let cell = frame.buffer.get(panel_x + 1, panel_y + 1);
2097 assert!(cell.is_some());
2098 }
2099
2100 #[test]
2101 fn overlay_without_hit_grid_shows_warning() {
2102 let mut state = InspectorState::new();
2103 state.mode = InspectorMode::HitRegions;
2104
2105 let overlay = InspectorOverlay::new(&state);
2106 let mut pool = GraphemePool::new();
2107 let mut frame = Frame::new(40, 10, &mut pool);
2109
2110 let area = Rect::new(0, 0, 40, 10);
2111 overlay.render(area, &mut frame);
2112
2113 if let Some(cell) = frame.buffer.get(10, 0) {
2117 assert_eq!(cell.content.as_char(), Some('H'));
2118 }
2119 }
2120
2121 #[test]
2126 fn nested_widgets_render_with_depth_colors() {
2127 let mut state = InspectorState::new();
2128 state.mode = InspectorMode::WidgetBounds;
2129 state.show_names = false; let mut parent = WidgetInfo::new("Parent", Rect::new(0, 0, 30, 20)).with_depth(0);
2133 let child = WidgetInfo::new("Child", Rect::new(2, 2, 26, 16)).with_depth(1);
2134 parent.add_child(child);
2135
2136 state.register_widget(parent);
2137
2138 let overlay = InspectorOverlay::new(&state);
2139 let mut pool = GraphemePool::new();
2140 let mut frame = Frame::with_hit_grid(40, 25, &mut pool);
2141
2142 let area = Rect::new(0, 0, 40, 25);
2143 overlay.render(area, &mut frame);
2144
2145 let style = InspectorStyle::default();
2148 let parent_color = style.bound_color(0);
2149 let child_color = style.bound_color(1);
2150
2151 assert_ne!(parent_color, child_color);
2153 }
2154
2155 #[test]
2156 fn widget_with_empty_name_skips_label() {
2157 let mut state = InspectorState::new();
2158 state.mode = InspectorMode::WidgetBounds;
2159 state.show_names = true;
2160
2161 state.register_widget(WidgetInfo::new("", Rect::new(5, 5, 10, 5)));
2163
2164 let overlay = InspectorOverlay::new(&state);
2165 let mut pool = GraphemePool::new();
2166 let mut frame = Frame::with_hit_grid(20, 15, &mut pool);
2167
2168 let area = Rect::new(0, 0, 20, 15);
2169 overlay.render(area, &mut frame);
2170
2171 }
2173
2174 #[test]
2179 fn hit_info_all_region_types() {
2180 let regions = [
2181 HitRegion::None,
2182 HitRegion::Content,
2183 HitRegion::Border,
2184 HitRegion::Scrollbar,
2185 HitRegion::Handle,
2186 HitRegion::Button,
2187 HitRegion::Link,
2188 HitRegion::Custom(0),
2189 HitRegion::Custom(255),
2190 ];
2191
2192 for region in regions {
2193 let cell = HitCell::new(HitId::new(1), region, 42);
2194 let info = HitInfo::from_cell(&cell, 10, 20);
2195
2196 let info = info.expect("should create info");
2197 assert_eq!(info.region, region);
2198 assert_eq!(info.data, 42);
2199 }
2200 }
2201
2202 #[test]
2203 fn hit_cell_with_zero_data() {
2204 let cell = HitCell::new(HitId::new(5), HitRegion::Content, 0);
2205 let info = HitInfo::from_cell(&cell, 0, 0).unwrap();
2206 assert_eq!(info.data, 0);
2207 }
2208
2209 #[test]
2210 fn hit_cell_with_max_data() {
2211 let cell = HitCell::new(HitId::new(5), HitRegion::Content, u64::MAX);
2212 let info = HitInfo::from_cell(&cell, 0, 0).unwrap();
2213 assert_eq!(info.data, u64::MAX);
2214 }
2215
2216 #[test]
2221 fn inspector_state_new_defaults() {
2222 let state = InspectorState::new();
2223
2224 assert_eq!(state.mode, InspectorMode::Off);
2226 assert!(state.hover_pos.is_none());
2227 assert!(state.selected.is_none());
2228 assert!(state.widgets.is_empty());
2229 assert!(!state.show_detail_panel);
2230 assert!(state.show_hits);
2231 assert!(state.show_bounds);
2232 assert!(state.show_names);
2233 assert!(!state.show_times);
2234 }
2235
2236 #[test]
2237 fn inspector_state_default_matches_new() {
2238 let state_new = InspectorState::new();
2239 let state_default = InspectorState::default();
2240
2241 assert_eq!(state_new.mode, state_default.mode);
2243 assert_eq!(state_new.hover_pos, state_default.hover_pos);
2244 assert_eq!(state_new.selected, state_default.selected);
2245 }
2246
2247 #[test]
2248 fn inspector_style_colors_are_semi_transparent() {
2249 let style = InspectorStyle::default();
2250
2251 assert!(style.hit_overlay.a() > 0);
2253 assert!(style.hit_overlay.a() < 255);
2254
2255 assert!(style.hit_hover.a() > 0);
2257 assert!(style.hit_hover.a() < 255);
2258
2259 assert!(style.selected_highlight.a() > 0);
2261 assert!(style.selected_highlight.a() < 255);
2262
2263 assert!(style.label_bg.a() > 128);
2265 }
2266
2267 #[cfg(feature = "tracing")]
2268 #[test]
2269 fn telemetry_spans_and_events() {
2270 let mut state = InspectorState::new();
2273 state.toggle(); let overlay = InspectorOverlay::new(&state);
2276 let mut pool = GraphemePool::new();
2277 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2278
2279 let area = Rect::new(0, 0, 20, 10);
2280 overlay.render(area, &mut frame); }
2282
2283 #[test]
2284 fn diagnostic_entry_checksum_deterministic() {
2285 let entry1 = DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
2286 .with_previous_mode(InspectorMode::Off)
2287 .with_mode(InspectorMode::Full)
2288 .with_flag("hits", true)
2289 .with_context("test")
2290 .with_checksum();
2291 let entry2 = DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
2292 .with_previous_mode(InspectorMode::Off)
2293 .with_mode(InspectorMode::Full)
2294 .with_flag("hits", true)
2295 .with_context("test")
2296 .with_checksum();
2297 assert_eq!(entry1.checksum, entry2.checksum);
2298 assert_ne!(entry1.checksum, 0);
2299 }
2300
2301 #[test]
2302 fn diagnostic_log_records_mode_changes() {
2303 let mut state = InspectorState::new().with_diagnostics();
2304 state.set_mode(1);
2305 state.set_mode(2);
2306 let log = state.diagnostic_log().expect("diagnostic log should exist");
2307 assert!(!log.entries().is_empty());
2308 assert!(
2309 !log.entries_of_kind(DiagnosticEventKind::ModeChanged)
2310 .is_empty()
2311 );
2312 }
2313
2314 #[test]
2315 fn telemetry_hooks_on_mode_change_fires() {
2316 use std::sync::Arc;
2317 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
2318
2319 let counter = Arc::new(AtomicUsize::new(0));
2320 let counter_clone = Arc::clone(&counter);
2321 let hooks = TelemetryHooks::new().on_mode_change(move |_| {
2322 counter_clone.fetch_add(1, AtomicOrdering::Relaxed);
2323 });
2324
2325 let mut state = InspectorState::new().with_telemetry_hooks(hooks);
2326 state.set_mode(1);
2327 state.set_mode(2);
2328 assert!(counter.load(AtomicOrdering::Relaxed) >= 1);
2329 }
2330
2331 fn relative_luminance(rgba: PackedRgba) -> f64 {
2338 fn channel_luminance(c: u8) -> f64 {
2339 let c = c as f64 / 255.0;
2340 if c <= 0.03928 {
2341 c / 12.92
2342 } else {
2343 ((c + 0.055) / 1.055).powf(2.4)
2344 }
2345 }
2346 let r = channel_luminance(rgba.r());
2347 let g = channel_luminance(rgba.g());
2348 let b = channel_luminance(rgba.b());
2349 0.2126 * r + 0.7152 * g + 0.0722 * b
2350 }
2351
2352 fn contrast_ratio(fg: PackedRgba, bg: PackedRgba) -> f64 {
2355 let l1 = relative_luminance(fg);
2356 let l2 = relative_luminance(bg);
2357 let lighter = l1.max(l2);
2358 let darker = l1.min(l2);
2359 (lighter + 0.05) / (darker + 0.05)
2360 }
2361
2362 #[test]
2363 fn a11y_label_contrast_meets_wcag_aa() {
2364 let style = InspectorStyle::default();
2367 let ratio = contrast_ratio(style.label_fg, style.label_bg);
2368 assert!(
2369 ratio >= 3.0,
2370 "Label contrast ratio {:.2}:1 should be >= 3:1 (WCAG AA large text)",
2371 ratio
2372 );
2373 assert!(
2375 ratio >= 4.5,
2376 "Label contrast ratio {:.2}:1 should be >= 4.5:1 (WCAG AA normal text)",
2377 ratio
2378 );
2379 }
2380
2381 #[test]
2382 fn a11y_bound_colors_are_distinct() {
2383 let style = InspectorStyle::default();
2386 let colors = &style.bound_colors;
2387
2388 for (i, a) in colors.iter().enumerate() {
2390 for (j, b) in colors.iter().enumerate() {
2391 if i != j {
2392 let r_diff = (a.r() as i32 - b.r() as i32).abs();
2393 let g_diff = (a.g() as i32 - b.g() as i32).abs();
2394 let b_diff = (a.b() as i32 - b.b() as i32).abs();
2395 let max_diff = r_diff.max(g_diff).max(b_diff);
2396 assert!(
2397 max_diff >= 100,
2398 "Bound colors {} and {} should differ by at least 100 in one channel (max diff = {})",
2399 i,
2400 j,
2401 max_diff
2402 );
2403 }
2404 }
2405 }
2406 }
2407
2408 #[test]
2409 fn a11y_bound_colors_have_good_visibility() {
2410 let style = InspectorStyle::default();
2413 for (i, color) in style.bound_colors.iter().enumerate() {
2414 let max_channel = color.r().max(color.g()).max(color.b());
2415 assert!(
2416 max_channel >= 100,
2417 "Bound color {} should have at least one channel >= 100 for visibility (max = {})",
2418 i,
2419 max_channel
2420 );
2421 }
2422 }
2423
2424 #[test]
2425 fn a11y_hit_overlays_are_visible() {
2426 let style = InspectorStyle::default();
2429
2430 assert!(
2432 style.hit_overlay.a() >= 50,
2433 "hit_overlay alpha {} should be >= 50 for visibility",
2434 style.hit_overlay.a()
2435 );
2436
2437 assert!(
2439 style.hit_hover.a() >= 80,
2440 "hit_hover alpha {} should be >= 80 for clear hover indication",
2441 style.hit_hover.a()
2442 );
2443 assert!(
2444 style.hit_hover.a() > style.hit_overlay.a(),
2445 "hit_hover should be more visible than hit_overlay"
2446 );
2447
2448 assert!(
2450 style.selected_highlight.a() >= 100,
2451 "selected_highlight alpha {} should be >= 100 for clear selection",
2452 style.selected_highlight.a()
2453 );
2454 }
2455
2456 #[test]
2457 fn a11y_region_colors_cover_all_variants() {
2458 let style = InspectorStyle::default();
2460 let regions = [
2461 HitRegion::None,
2462 HitRegion::Content,
2463 HitRegion::Border,
2464 HitRegion::Scrollbar,
2465 HitRegion::Handle,
2466 HitRegion::Button,
2467 HitRegion::Link,
2468 HitRegion::Custom(0),
2469 ];
2470
2471 for region in regions {
2472 let color = style.region_color(region);
2473 match region {
2475 HitRegion::None => {
2476 assert_eq!(
2477 color,
2478 PackedRgba::TRANSPARENT,
2479 "HitRegion::None should be transparent"
2480 );
2481 }
2482 _ => {
2483 assert!(
2484 color.a() > 0,
2485 "HitRegion::{:?} should have non-zero alpha",
2486 region
2487 );
2488 }
2489 }
2490 }
2491 }
2492
2493 #[test]
2494 fn a11y_interactive_regions_are_distinct_from_passive() {
2495 let style = InspectorStyle::default();
2498
2499 let button_color = style.region_color(HitRegion::Button);
2500 let link_color = style.region_color(HitRegion::Link);
2501 let content_color = style.region_color(HitRegion::Content);
2502 let _border_color = style.region_color(HitRegion::Border);
2503
2504 assert!(
2506 button_color.a() >= content_color.a(),
2507 "Button overlay should be as visible or more visible than Content"
2508 );
2509 assert!(
2510 link_color.a() >= content_color.a(),
2511 "Link overlay should be as visible or more visible than Content"
2512 );
2513
2514 let button_content_diff = (button_color.r() as i32 - content_color.r() as i32).abs()
2516 + (button_color.g() as i32 - content_color.g() as i32).abs()
2517 + (button_color.b() as i32 - content_color.b() as i32).abs();
2518 assert!(
2519 button_content_diff >= 100,
2520 "Button color should differ significantly from Content (diff = {})",
2521 button_content_diff
2522 );
2523 }
2524
2525 #[test]
2526 fn a11y_keybinding_constants_documented() {
2527 }
2555
2556 use std::collections::hash_map::DefaultHasher;
2561 use std::hash::{Hash, Hasher};
2562 use std::time::Instant;
2563
2564 fn inspector_seed() -> u64 {
2565 std::env::var("INSPECTOR_SEED")
2566 .ok()
2567 .and_then(|s| s.parse().ok())
2568 .unwrap_or(42)
2569 }
2570
2571 fn next_u32(seed: &mut u64) -> u32 {
2572 let mut x = *seed;
2573 x ^= x << 13;
2574 x ^= x >> 7;
2575 x ^= x << 17;
2576 *seed = x;
2577 (x >> 32) as u32
2578 }
2579
2580 fn rand_range(seed: &mut u64, min: u16, max: u16) -> u16 {
2581 if min >= max {
2582 return min;
2583 }
2584 let span = (max - min) as u32 + 1;
2585 let n = next_u32(seed) % span;
2586 min + n as u16
2587 }
2588
2589 fn random_rect(seed: &mut u64, area: Rect) -> Rect {
2590 let max_w = area.width.max(1);
2591 let max_h = area.height.max(1);
2592 let w = rand_range(seed, 1, max_w);
2593 let h = rand_range(seed, 1, max_h);
2594 let max_x = area.x + area.width.saturating_sub(w);
2595 let max_y = area.y + area.height.saturating_sub(h);
2596 let x = rand_range(seed, area.x, max_x);
2597 let y = rand_range(seed, area.y, max_y);
2598 Rect::new(x, y, w, h)
2599 }
2600
2601 fn build_widget_tree(
2602 seed: &mut u64,
2603 depth: u8,
2604 max_depth: u8,
2605 breadth: u8,
2606 area: Rect,
2607 count: &mut usize,
2608 ) -> WidgetInfo {
2609 *count += 1;
2610 let name = format!("Widget_{depth}_{}", *count);
2611 let mut node = WidgetInfo::new(name, area).with_depth(depth);
2612
2613 if depth < max_depth {
2614 for _ in 0..breadth {
2615 let child_area = random_rect(seed, area);
2616 let child =
2617 build_widget_tree(seed, depth + 1, max_depth, breadth, child_area, count);
2618 node.add_child(child);
2619 }
2620 }
2621
2622 node
2623 }
2624
2625 fn build_stress_state(
2626 seed: &mut u64,
2627 roots: usize,
2628 max_depth: u8,
2629 breadth: u8,
2630 area: Rect,
2631 ) -> (InspectorState, usize) {
2632 let mut state = InspectorState {
2633 mode: InspectorMode::Full,
2634 show_hits: true,
2635 show_bounds: true,
2636 show_names: true,
2637 show_detail_panel: true,
2638 hover_pos: Some((area.x + 1, area.y + 1)),
2639 ..Default::default()
2640 };
2641
2642 let mut count = 0usize;
2643 for _ in 0..roots {
2644 let root_area = random_rect(seed, area);
2645 let widget = build_widget_tree(seed, 0, max_depth, breadth, root_area, &mut count);
2646 state.register_widget(widget);
2647 }
2648
2649 (state, count)
2650 }
2651
2652 fn populate_hit_grid(frame: &mut Frame, seed: &mut u64, count: usize, area: Rect) -> usize {
2653 for idx in 0..count {
2654 let region = match idx % 6 {
2655 0 => HitRegion::Content,
2656 1 => HitRegion::Border,
2657 2 => HitRegion::Scrollbar,
2658 3 => HitRegion::Handle,
2659 4 => HitRegion::Button,
2660 _ => HitRegion::Link,
2661 };
2662 let rect = random_rect(seed, area);
2663 frame.register_hit(rect, HitId::new((idx + 1) as u32), region, idx as HitData);
2664 }
2665 count
2666 }
2667
2668 fn buffer_checksum(frame: &Frame) -> u64 {
2669 let mut hasher = DefaultHasher::new();
2670 let mut scratch = String::new();
2671 for y in 0..frame.buffer.height() {
2672 for x in 0..frame.buffer.width() {
2673 if let Some(cell) = frame.buffer.get(x, y) {
2674 scratch.clear();
2675 use std::fmt::Write;
2676 let _ = write!(&mut scratch, "{cell:?}");
2677 scratch.hash(&mut hasher);
2678 }
2679 }
2680 }
2681 hasher.finish()
2682 }
2683
2684 fn log_jsonl(event: &str, fields: &[(&str, String)]) {
2685 let mut parts = Vec::with_capacity(fields.len() + 1);
2686 parts.push(format!(r#""event":"{event}""#));
2687 parts.extend(fields.iter().map(|(k, v)| format!(r#""{k}":{v}"#)));
2688 eprintln!("{{{}}}", parts.join(","));
2689 }
2690
2691 #[test]
2692 fn inspector_stress_large_tree_renders() {
2693 let mut seed = inspector_seed();
2694 let area = Rect::new(0, 0, 160, 48);
2695 let (state, widget_count) = build_stress_state(&mut seed, 6, 3, 3, area);
2696
2697 let mut pool = GraphemePool::new();
2698 let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2699 let hit_count = populate_hit_grid(&mut frame, &mut seed, 800, area);
2700
2701 let overlay = InspectorOverlay::new(&state);
2702 overlay.render(area, &mut frame);
2703
2704 let checksum = buffer_checksum(&frame);
2705 log_jsonl(
2706 "inspector_stress_render",
2707 &[
2708 ("seed", seed.to_string()),
2709 ("widgets", widget_count.to_string()),
2710 ("hit_regions", hit_count.to_string()),
2711 ("checksum", format!(r#""0x{checksum:016x}""#)),
2712 ],
2713 );
2714
2715 assert!(checksum != 0, "Rendered buffer checksum should be non-zero");
2716 }
2717
2718 #[test]
2719 fn inspector_stress_checksum_is_deterministic() {
2720 let seed = inspector_seed();
2721 let area = Rect::new(0, 0, 140, 40);
2722
2723 let checksum_a = {
2724 let mut seed = seed;
2725 let (state, _) = build_stress_state(&mut seed, 5, 3, 3, area);
2726 let mut pool = GraphemePool::new();
2727 let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2728 populate_hit_grid(&mut frame, &mut seed, 600, area);
2729 InspectorOverlay::new(&state).render(area, &mut frame);
2730 buffer_checksum(&frame)
2731 };
2732
2733 let checksum_b = {
2734 let mut seed = seed;
2735 let (state, _) = build_stress_state(&mut seed, 5, 3, 3, area);
2736 let mut pool = GraphemePool::new();
2737 let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2738 populate_hit_grid(&mut frame, &mut seed, 600, area);
2739 InspectorOverlay::new(&state).render(area, &mut frame);
2740 buffer_checksum(&frame)
2741 };
2742
2743 log_jsonl(
2744 "inspector_stress_determinism",
2745 &[
2746 ("seed", seed.to_string()),
2747 ("checksum_a", format!(r#""0x{checksum_a:016x}""#)),
2748 ("checksum_b", format!(r#""0x{checksum_b:016x}""#)),
2749 ],
2750 );
2751
2752 assert_eq!(
2753 checksum_a, checksum_b,
2754 "Stress render checksum should be deterministic"
2755 );
2756 }
2757
2758 #[test]
2759 fn inspector_perf_budget_overlay() {
2760 let seed = inspector_seed();
2761 let area = Rect::new(0, 0, 160, 48);
2762 let iterations = 40usize;
2763 let budget_p95_us = 15_000u64;
2764
2765 let mut timings = Vec::with_capacity(iterations);
2766 let mut checksums = Vec::with_capacity(iterations);
2767
2768 for i in 0..iterations {
2769 let mut seed = seed.wrapping_add(i as u64);
2770 let (state, widget_count) = build_stress_state(&mut seed, 6, 3, 3, area);
2771 let mut pool = GraphemePool::new();
2772 let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2773 let hit_count = populate_hit_grid(&mut frame, &mut seed, 800, area);
2774
2775 let start = Instant::now();
2776 InspectorOverlay::new(&state).render(area, &mut frame);
2777 let elapsed_us = start.elapsed().as_micros() as u64;
2778 timings.push(elapsed_us);
2779
2780 let checksum = buffer_checksum(&frame);
2781 checksums.push(checksum);
2782
2783 if i == 0 {
2784 log_jsonl(
2785 "inspector_perf_sample",
2786 &[
2787 ("seed", seed.to_string()),
2788 ("widgets", widget_count.to_string()),
2789 ("hit_regions", hit_count.to_string()),
2790 ("timing_us", elapsed_us.to_string()),
2791 ("checksum", format!(r#""0x{checksum:016x}""#)),
2792 ],
2793 );
2794 }
2795 }
2796
2797 let mut sorted = timings.clone();
2798 sorted.sort_unstable();
2799 let p95 = sorted[sorted.len() * 95 / 100];
2800 let p99 = sorted[sorted.len() * 99 / 100];
2801 let avg = timings.iter().sum::<u64>() as f64 / timings.len() as f64;
2802
2803 let mut seq_hasher = DefaultHasher::new();
2804 for checksum in &checksums {
2805 checksum.hash(&mut seq_hasher);
2806 }
2807 let seq_checksum = seq_hasher.finish();
2808
2809 log_jsonl(
2810 "inspector_perf_budget",
2811 &[
2812 ("seed", seed.to_string()),
2813 ("iterations", iterations.to_string()),
2814 ("avg_us", format!("{:.2}", avg)),
2815 ("p95_us", p95.to_string()),
2816 ("p99_us", p99.to_string()),
2817 ("budget_p95_us", budget_p95_us.to_string()),
2818 ("sequence_checksum", format!(r#""0x{seq_checksum:016x}""#)),
2819 ],
2820 );
2821
2822 assert!(
2823 p95 <= budget_p95_us,
2824 "Inspector overlay p95 {}µs exceeds budget {}µs",
2825 p95,
2826 budget_p95_us
2827 );
2828 }
2829}