Skip to main content

ftui_widgets/
layout_debugger.rs

1#![forbid(unsafe_code)]
2
3//! Layout constraint debugger utilities.
4//!
5//! Provides a lightweight recorder and renderer for layout constraint
6//! diagnostics. This is intended for developer tooling and can be kept
7//! disabled in production to avoid overhead.
8
9use ftui_core::geometry::Rect;
10use ftui_render::buffer::Buffer;
11use ftui_render::cell::{Cell, PackedRgba};
12use ftui_render::drawing::Draw;
13
14#[cfg(feature = "tracing")]
15use tracing::{debug, warn};
16
17/// Constraint bounds for a widget's layout.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct LayoutConstraints {
20    /// Minimum allowed width.
21    pub min_width: u16,
22    /// Maximum allowed width (0 = unconstrained).
23    pub max_width: u16,
24    /// Minimum allowed height.
25    pub min_height: u16,
26    /// Maximum allowed height (0 = unconstrained).
27    pub max_height: u16,
28}
29
30impl LayoutConstraints {
31    /// Create constraints with the given bounds.
32    pub fn new(min_width: u16, max_width: u16, min_height: u16, max_height: u16) -> Self {
33        Self {
34            min_width,
35            max_width,
36            min_height,
37            max_height,
38        }
39    }
40
41    /// Create unconstrained (all-zero) bounds.
42    pub fn unconstrained() -> Self {
43        Self {
44            min_width: 0,
45            max_width: 0,
46            min_height: 0,
47            max_height: 0,
48        }
49    }
50
51    fn width_overflow(&self, width: u16) -> bool {
52        self.max_width != 0 && width > self.max_width
53    }
54
55    fn height_overflow(&self, height: u16) -> bool {
56        self.max_height != 0 && height > self.max_height
57    }
58
59    fn width_underflow(&self, width: u16) -> bool {
60        width < self.min_width
61    }
62
63    fn height_underflow(&self, height: u16) -> bool {
64        height < self.min_height
65    }
66}
67
68/// Layout record for a single widget.
69#[derive(Debug, Clone)]
70pub struct LayoutRecord {
71    /// Name of the widget this record describes.
72    pub widget_name: String,
73    /// Area originally requested by the widget.
74    pub area_requested: Rect,
75    /// Area actually received after layout.
76    pub area_received: Rect,
77    /// Constraint bounds applied during layout.
78    pub constraints: LayoutConstraints,
79    /// Child layout records for nested widgets.
80    pub children: Vec<LayoutRecord>,
81}
82
83impl LayoutRecord {
84    /// Create a new layout record for the given widget.
85    pub fn new(
86        name: impl Into<String>,
87        area_requested: Rect,
88        area_received: Rect,
89        constraints: LayoutConstraints,
90    ) -> Self {
91        Self {
92            widget_name: name.into(),
93            area_requested,
94            area_received,
95            constraints,
96            children: Vec::new(),
97        }
98    }
99
100    /// Add a child record to this layout record.
101    #[must_use]
102    pub fn with_child(mut self, child: LayoutRecord) -> Self {
103        self.children.push(child);
104        self
105    }
106
107    fn overflow(&self) -> bool {
108        self.constraints.width_overflow(self.area_received.width)
109            || self.constraints.height_overflow(self.area_received.height)
110    }
111
112    fn underflow(&self) -> bool {
113        self.constraints.width_underflow(self.area_received.width)
114            || self.constraints.height_underflow(self.area_received.height)
115    }
116}
117
118/// Layout debugger that records constraint data and renders diagnostics.
119#[derive(Debug, Default)]
120pub struct LayoutDebugger {
121    enabled: bool,
122    records: Vec<LayoutRecord>,
123}
124
125impl LayoutDebugger {
126    /// Create a new disabled layout debugger.
127    pub fn new() -> Self {
128        Self {
129            enabled: false,
130            records: Vec::new(),
131        }
132    }
133
134    /// Enable or disable the debugger.
135    pub fn set_enabled(&mut self, enabled: bool) {
136        let was_enabled = self.enabled;
137        self.enabled = enabled;
138        if was_enabled && !enabled {
139            self.clear();
140        }
141    }
142
143    /// Returns whether the debugger is enabled.
144    pub fn enabled(&self) -> bool {
145        self.enabled
146    }
147
148    /// Clear all recorded layout data.
149    pub fn clear(&mut self) {
150        self.records.clear();
151    }
152
153    /// Record a layout computation result.
154    pub fn record(&mut self, record: LayoutRecord) {
155        if !self.enabled {
156            return;
157        }
158        #[cfg(feature = "tracing")]
159        {
160            if record.overflow() || record.underflow() {
161                warn!(
162                    widget = record.widget_name.as_str(),
163                    requested = ?record.area_requested,
164                    received = ?record.area_received,
165                    "Layout constraint violation"
166                );
167            }
168            debug!(
169                widget = record.widget_name.as_str(),
170                constraints = ?record.constraints,
171                result = ?record.area_received,
172                "Layout computed"
173            );
174        }
175        self.records.push(record);
176    }
177
178    /// Get the recorded layout data.
179    pub fn records(&self) -> &[LayoutRecord] {
180        &self.records
181    }
182
183    /// Render a simple tree view of layout records into the buffer.
184    pub fn render_debug(&self, area: Rect, buf: &mut Buffer) {
185        if area.is_empty() {
186            return;
187        }
188
189        buf.fill(area, Cell::from_char(' '));
190
191        if !self.enabled {
192            return;
193        }
194        let mut y = area.y;
195        for record in &self.records {
196            y = self.render_record(record, 0, area, y, buf);
197            if y >= area.bottom() {
198                break;
199            }
200        }
201    }
202
203    /// Export recorded layout data as Graphviz DOT.
204    pub fn export_dot(&self) -> String {
205        let mut out = String::from("digraph Layout {\n  node [shape=box];\n");
206        let mut next_id = 0usize;
207        for record in &self.records {
208            next_id = write_dot_record(&mut out, record, next_id, None);
209        }
210        out.push_str("}\n");
211        out
212    }
213
214    fn render_record(
215        &self,
216        record: &LayoutRecord,
217        depth: usize,
218        area: Rect,
219        y: u16,
220        buf: &mut Buffer,
221    ) -> u16 {
222        if y >= area.bottom() {
223            return y;
224        }
225
226        let indent = " ".repeat(depth * 2);
227        let line = format!(
228            "{}{} req={}x{} got={}x{} min={}x{} max={}x{}",
229            indent,
230            record.widget_name,
231            record.area_requested.width,
232            record.area_requested.height,
233            record.area_received.width,
234            record.area_received.height,
235            record.constraints.min_width,
236            record.constraints.min_height,
237            record.constraints.max_width,
238            record.constraints.max_height,
239        );
240
241        let color = if record.overflow() {
242            PackedRgba::rgb(240, 80, 80)
243        } else if record.underflow() {
244            PackedRgba::rgb(240, 200, 80)
245        } else {
246            PackedRgba::rgb(200, 200, 200)
247        };
248
249        let cell = Cell::from_char(' ').with_fg(color);
250        let _ = buf.print_text_clipped(area.x, y, &line, cell, area.right());
251
252        let mut next_y = y.saturating_add(1);
253        for child in &record.children {
254            next_y = self.render_record(child, depth + 1, area, next_y, buf);
255            if next_y >= area.bottom() {
256                break;
257            }
258        }
259        next_y
260    }
261}
262
263fn write_dot_record(
264    out: &mut String,
265    record: &LayoutRecord,
266    id: usize,
267    parent: Option<usize>,
268) -> usize {
269    let safe_name = record.widget_name.replace('"', "'");
270    let label = format!(
271        "{}\\nreq={}x{} got={}x{}",
272        safe_name,
273        record.area_requested.width,
274        record.area_requested.height,
275        record.area_received.width,
276        record.area_received.height
277    );
278    out.push_str(&format!("  n{} [label=\"{}\"];\n", id, label));
279    if let Some(parent_id) = parent {
280        out.push_str(&format!("  n{} -> n{};\n", parent_id, id));
281    }
282
283    let mut next_id = id + 1;
284    for child in &record.children {
285        next_id = write_dot_record(out, child, next_id, Some(id));
286    }
287    next_id
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn export_dot_contains_nodes_and_edges() {
296        let mut dbg = LayoutDebugger::new();
297        dbg.set_enabled(true);
298        let record = LayoutRecord::new(
299            "Root",
300            Rect::new(0, 0, 10, 4),
301            Rect::new(0, 0, 8, 4),
302            LayoutConstraints::new(5, 12, 2, 6),
303        )
304        .with_child(LayoutRecord::new(
305            "Child",
306            Rect::new(0, 0, 5, 2),
307            Rect::new(0, 0, 5, 2),
308            LayoutConstraints::unconstrained(),
309        ));
310        dbg.record(record);
311
312        let dot = dbg.export_dot();
313        assert!(dot.contains("Root"));
314        assert!(dot.contains("Child"));
315        assert!(dot.contains("->"));
316    }
317
318    #[test]
319    fn render_debug_writes_lines() {
320        let mut dbg = LayoutDebugger::new();
321        dbg.set_enabled(true);
322        dbg.record(LayoutRecord::new(
323            "Root",
324            Rect::new(0, 0, 10, 4),
325            Rect::new(0, 0, 8, 4),
326            LayoutConstraints::new(9, 0, 0, 0),
327        ));
328
329        let mut buf = Buffer::new(30, 4);
330        dbg.render_debug(Rect::new(0, 0, 30, 4), &mut buf);
331
332        let cell = buf.get(0, 0).unwrap();
333        assert_eq!(cell.content.as_char(), Some('R'));
334    }
335
336    #[test]
337    fn disabled_debugger_is_noop() {
338        let mut dbg = LayoutDebugger::new();
339        dbg.record(LayoutRecord::new(
340            "Root",
341            Rect::new(0, 0, 10, 4),
342            Rect::new(0, 0, 8, 4),
343            LayoutConstraints::unconstrained(),
344        ));
345        assert!(dbg.records().is_empty());
346    }
347
348    // --- LayoutConstraints ---
349
350    #[test]
351    fn constraints_new_and_fields() {
352        let c = LayoutConstraints::new(5, 80, 3, 24);
353        assert_eq!(c.min_width, 5);
354        assert_eq!(c.max_width, 80);
355        assert_eq!(c.min_height, 3);
356        assert_eq!(c.max_height, 24);
357    }
358
359    #[test]
360    fn constraints_unconstrained_all_zero() {
361        let c = LayoutConstraints::unconstrained();
362        assert_eq!(c.min_width, 0);
363        assert_eq!(c.max_width, 0);
364        assert_eq!(c.min_height, 0);
365        assert_eq!(c.max_height, 0);
366    }
367
368    #[test]
369    fn constraints_width_overflow() {
370        let c = LayoutConstraints::new(0, 10, 0, 0);
371        assert!(!c.width_overflow(10)); // at max = ok
372        assert!(c.width_overflow(11)); // over max = overflow
373        assert!(!c.width_overflow(5)); // under max = ok
374    }
375
376    #[test]
377    fn constraints_width_overflow_unconstrained() {
378        let c = LayoutConstraints::new(0, 0, 0, 0); // max_width=0 = unconstrained
379        assert!(!c.width_overflow(9999)); // never overflows
380    }
381
382    #[test]
383    fn constraints_height_overflow() {
384        let c = LayoutConstraints::new(0, 0, 0, 10);
385        assert!(!c.height_overflow(10));
386        assert!(c.height_overflow(11));
387    }
388
389    #[test]
390    fn constraints_width_underflow() {
391        let c = LayoutConstraints::new(5, 0, 0, 0);
392        assert!(!c.width_underflow(5)); // at min = ok
393        assert!(c.width_underflow(4)); // below min = underflow
394        assert!(!c.width_underflow(10)); // above min = ok
395    }
396
397    #[test]
398    fn constraints_height_underflow() {
399        let c = LayoutConstraints::new(0, 0, 3, 0);
400        assert!(!c.height_underflow(3));
401        assert!(c.height_underflow(2));
402    }
403
404    // --- LayoutRecord ---
405
406    #[test]
407    fn record_new_and_fields() {
408        let r = LayoutRecord::new(
409            "MyWidget",
410            Rect::new(0, 0, 20, 10),
411            Rect::new(0, 0, 15, 8),
412            LayoutConstraints::new(5, 25, 3, 12),
413        );
414        assert_eq!(r.widget_name, "MyWidget");
415        assert_eq!(r.area_requested.width, 20);
416        assert_eq!(r.area_received.width, 15);
417        assert!(r.children.is_empty());
418    }
419
420    #[test]
421    fn record_with_child_appends() {
422        let parent = LayoutRecord::new(
423            "Parent",
424            Rect::new(0, 0, 20, 10),
425            Rect::new(0, 0, 20, 10),
426            LayoutConstraints::unconstrained(),
427        )
428        .with_child(LayoutRecord::new(
429            "Child1",
430            Rect::new(0, 0, 10, 5),
431            Rect::new(0, 0, 10, 5),
432            LayoutConstraints::unconstrained(),
433        ))
434        .with_child(LayoutRecord::new(
435            "Child2",
436            Rect::new(10, 0, 10, 5),
437            Rect::new(10, 0, 10, 5),
438            LayoutConstraints::unconstrained(),
439        ));
440        assert_eq!(parent.children.len(), 2);
441        assert_eq!(parent.children[0].widget_name, "Child1");
442        assert_eq!(parent.children[1].widget_name, "Child2");
443    }
444
445    #[test]
446    fn record_overflow_detected() {
447        // width overflow
448        let r = LayoutRecord::new(
449            "Widget",
450            Rect::new(0, 0, 20, 10),
451            Rect::new(0, 0, 20, 10),
452            LayoutConstraints::new(0, 15, 0, 0), // max_width=15, received=20
453        );
454        assert!(r.overflow());
455    }
456
457    #[test]
458    fn record_underflow_detected() {
459        let r = LayoutRecord::new(
460            "Widget",
461            Rect::new(0, 0, 20, 10),
462            Rect::new(0, 0, 3, 10),
463            LayoutConstraints::new(5, 0, 0, 0), // min_width=5, received=3
464        );
465        assert!(r.underflow());
466    }
467
468    #[test]
469    fn record_no_violation() {
470        let r = LayoutRecord::new(
471            "Widget",
472            Rect::new(0, 0, 10, 5),
473            Rect::new(0, 0, 10, 5),
474            LayoutConstraints::new(5, 15, 3, 8),
475        );
476        assert!(!r.overflow());
477        assert!(!r.underflow());
478    }
479
480    // --- LayoutDebugger ---
481
482    #[test]
483    fn debugger_default_disabled() {
484        let dbg = LayoutDebugger::new();
485        assert!(!dbg.enabled());
486        assert!(dbg.records().is_empty());
487    }
488
489    #[test]
490    fn debugger_enable_disable() {
491        let mut dbg = LayoutDebugger::new();
492        dbg.set_enabled(true);
493        assert!(dbg.enabled());
494        dbg.set_enabled(false);
495        assert!(!dbg.enabled());
496        assert!(dbg.records().is_empty());
497    }
498
499    #[test]
500    fn debugger_disable_clears_stale_records() {
501        let mut dbg = LayoutDebugger::new();
502        dbg.set_enabled(true);
503        dbg.record(LayoutRecord::new(
504            "Widget",
505            Rect::new(0, 0, 10, 5),
506            Rect::new(0, 0, 10, 5),
507            LayoutConstraints::unconstrained(),
508        ));
509        assert_eq!(dbg.records().len(), 1);
510
511        dbg.set_enabled(false);
512
513        assert!(dbg.records().is_empty());
514    }
515
516    #[test]
517    fn debugger_clear() {
518        let mut dbg = LayoutDebugger::new();
519        dbg.set_enabled(true);
520        dbg.record(LayoutRecord::new(
521            "Widget",
522            Rect::new(0, 0, 10, 5),
523            Rect::new(0, 0, 10, 5),
524            LayoutConstraints::unconstrained(),
525        ));
526        assert_eq!(dbg.records().len(), 1);
527        dbg.clear();
528        assert!(dbg.records().is_empty());
529    }
530
531    #[test]
532    fn debugger_records_multiple() {
533        let mut dbg = LayoutDebugger::new();
534        dbg.set_enabled(true);
535        for i in 0..5 {
536            dbg.record(LayoutRecord::new(
537                format!("W{i}"),
538                Rect::new(0, 0, 10, 5),
539                Rect::new(0, 0, 10, 5),
540                LayoutConstraints::unconstrained(),
541            ));
542        }
543        assert_eq!(dbg.records().len(), 5);
544    }
545
546    // --- export_dot edge cases ---
547
548    #[test]
549    fn export_dot_empty() {
550        let dbg = LayoutDebugger::new();
551        let dot = dbg.export_dot();
552        assert!(dot.starts_with("digraph Layout"));
553        assert!(dot.ends_with(
554            "}
555"
556        ));
557        assert!(!dot.contains("n0"));
558    }
559
560    #[test]
561    fn export_dot_escapes_quotes() {
562        let mut dbg = LayoutDebugger::new();
563        dbg.set_enabled(true);
564        // Name containing a double-quote character
565        let name = String::from("Wid") + &String::from('"') + "get";
566        dbg.record(LayoutRecord::new(
567            &name,
568            Rect::new(0, 0, 10, 5),
569            Rect::new(0, 0, 10, 5),
570            LayoutConstraints::unconstrained(),
571        ));
572        let dot = dbg.export_dot();
573        // Double quotes should be replaced with single quotes
574        assert!(dot.contains("Wid'get"));
575    }
576
577    #[test]
578    fn export_dot_nested_children() {
579        let mut dbg = LayoutDebugger::new();
580        dbg.set_enabled(true);
581        let root = LayoutRecord::new(
582            "Root",
583            Rect::new(0, 0, 40, 20),
584            Rect::new(0, 0, 40, 20),
585            LayoutConstraints::unconstrained(),
586        )
587        .with_child(
588            LayoutRecord::new(
589                "Mid",
590                Rect::new(0, 0, 20, 10),
591                Rect::new(0, 0, 20, 10),
592                LayoutConstraints::unconstrained(),
593            )
594            .with_child(LayoutRecord::new(
595                "Leaf",
596                Rect::new(0, 0, 10, 5),
597                Rect::new(0, 0, 10, 5),
598                LayoutConstraints::unconstrained(),
599            )),
600        );
601        dbg.record(root);
602        let dot = dbg.export_dot();
603        assert!(dot.contains("Root"));
604        assert!(dot.contains("Mid"));
605        assert!(dot.contains("Leaf"));
606        // Should have edges: n0->n1, n1->n2
607        assert!(dot.contains("n0 -> n1"));
608        assert!(dot.contains("n1 -> n2"));
609    }
610
611    // --- render_debug edge cases ---
612
613    #[test]
614    fn render_debug_disabled_noop() {
615        let dbg = LayoutDebugger::new(); // disabled
616        let mut buf = Buffer::new(30, 4);
617        let sentinel = Cell::from_char('X').with_fg(PackedRgba::rgb(1, 2, 3));
618        buf.fill(Rect::new(0, 0, 30, 4), sentinel);
619        dbg.render_debug(Rect::new(0, 0, 30, 4), &mut buf);
620        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some(' '));
621        assert_eq!(buf.get(29, 3).unwrap().content.as_char(), Some(' '));
622    }
623
624    #[test]
625    fn render_debug_overflow_uses_red_color() {
626        let mut dbg = LayoutDebugger::new();
627        dbg.set_enabled(true);
628        dbg.record(LayoutRecord::new(
629            "Over",
630            Rect::new(0, 0, 20, 10),
631            Rect::new(0, 0, 20, 10),
632            LayoutConstraints::new(0, 10, 0, 0), // width overflows
633        ));
634        let mut buf = Buffer::new(60, 4);
635        dbg.render_debug(Rect::new(0, 0, 60, 4), &mut buf);
636        let cell = buf.get(0, 0).unwrap();
637        // Should be red-ish (240, 80, 80)
638        assert_eq!(cell.fg, PackedRgba::rgb(240, 80, 80));
639    }
640
641    #[test]
642    fn render_debug_underflow_uses_yellow_color() {
643        let mut dbg = LayoutDebugger::new();
644        dbg.set_enabled(true);
645        dbg.record(LayoutRecord::new(
646            "Under",
647            Rect::new(0, 0, 20, 10),
648            Rect::new(0, 0, 3, 10),
649            LayoutConstraints::new(5, 0, 0, 0), // width underflows
650        ));
651        let mut buf = Buffer::new(60, 4);
652        dbg.render_debug(Rect::new(0, 0, 60, 4), &mut buf);
653        let cell = buf.get(0, 0).unwrap();
654        // Should be yellow-ish (240, 200, 80)
655        assert_eq!(cell.fg, PackedRgba::rgb(240, 200, 80));
656    }
657
658    #[test]
659    fn render_debug_shorter_second_render_clears_stale_suffix_and_rows() {
660        let mut dbg = LayoutDebugger::new();
661        dbg.set_enabled(true);
662        let area = Rect::new(0, 0, 40, 4);
663        let mut buf = Buffer::new(40, 4);
664
665        dbg.record(
666            LayoutRecord::new(
667                "LongWidgetName",
668                Rect::new(0, 0, 20, 10),
669                Rect::new(0, 0, 18, 8),
670                LayoutConstraints::new(5, 25, 3, 12),
671            )
672            .with_child(LayoutRecord::new(
673                "Child",
674                Rect::new(0, 0, 10, 4),
675                Rect::new(0, 0, 10, 4),
676                LayoutConstraints::unconstrained(),
677            )),
678        );
679        dbg.render_debug(area, &mut buf);
680
681        dbg.clear();
682        dbg.record(LayoutRecord::new(
683            "Short",
684            Rect::new(0, 0, 8, 3),
685            Rect::new(0, 0, 8, 3),
686            LayoutConstraints::unconstrained(),
687        ));
688        dbg.render_debug(area, &mut buf);
689
690        let row0: String = (0..area.width)
691            .map(|x| buf.get(x, 0).unwrap().content.as_char().unwrap_or(' '))
692            .collect();
693        let row1: String = (0..area.width)
694            .map(|x| buf.get(x, 1).unwrap().content.as_char().unwrap_or(' '))
695            .collect();
696        assert!(row0.starts_with("Short req=8x3 got=8x3"));
697        assert_eq!(row1, " ".repeat(area.width as usize));
698    }
699}