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