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        self.enabled = enabled;
137    }
138
139    /// Returns whether the debugger is enabled.
140    pub fn enabled(&self) -> bool {
141        self.enabled
142    }
143
144    /// Clear all recorded layout data.
145    pub fn clear(&mut self) {
146        self.records.clear();
147    }
148
149    /// Record a layout computation result.
150    pub fn record(&mut self, record: LayoutRecord) {
151        if !self.enabled {
152            return;
153        }
154        #[cfg(feature = "tracing")]
155        {
156            if record.overflow() || record.underflow() {
157                warn!(
158                    widget = record.widget_name.as_str(),
159                    requested = ?record.area_requested,
160                    received = ?record.area_received,
161                    "Layout constraint violation"
162                );
163            }
164            debug!(
165                widget = record.widget_name.as_str(),
166                constraints = ?record.constraints,
167                result = ?record.area_received,
168                "Layout computed"
169            );
170        }
171        self.records.push(record);
172    }
173
174    /// Get the recorded layout data.
175    pub fn records(&self) -> &[LayoutRecord] {
176        &self.records
177    }
178
179    /// Render a simple tree view of layout records into the buffer.
180    pub fn render_debug(&self, area: Rect, buf: &mut Buffer) {
181        if !self.enabled {
182            return;
183        }
184        let mut y = area.y;
185        for record in &self.records {
186            y = self.render_record(record, 0, area, y, buf);
187            if y >= area.bottom() {
188                break;
189            }
190        }
191    }
192
193    /// Export recorded layout data as Graphviz DOT.
194    pub fn export_dot(&self) -> String {
195        let mut out = String::from("digraph Layout {\n  node [shape=box];\n");
196        let mut next_id = 0usize;
197        for record in &self.records {
198            next_id = write_dot_record(&mut out, record, next_id, None);
199        }
200        out.push_str("}\n");
201        out
202    }
203
204    fn render_record(
205        &self,
206        record: &LayoutRecord,
207        depth: usize,
208        area: Rect,
209        y: u16,
210        buf: &mut Buffer,
211    ) -> u16 {
212        if y >= area.bottom() {
213            return y;
214        }
215
216        let indent = " ".repeat(depth * 2);
217        let line = format!(
218            "{}{} req={}x{} got={}x{} min={}x{} max={}x{}",
219            indent,
220            record.widget_name,
221            record.area_requested.width,
222            record.area_requested.height,
223            record.area_received.width,
224            record.area_received.height,
225            record.constraints.min_width,
226            record.constraints.min_height,
227            record.constraints.max_width,
228            record.constraints.max_height,
229        );
230
231        let color = if record.overflow() {
232            PackedRgba::rgb(240, 80, 80)
233        } else if record.underflow() {
234            PackedRgba::rgb(240, 200, 80)
235        } else {
236            PackedRgba::rgb(200, 200, 200)
237        };
238
239        let cell = Cell::from_char(' ').with_fg(color);
240        let _ = buf.print_text_clipped(area.x, y, &line, cell, area.right());
241
242        let mut next_y = y.saturating_add(1);
243        for child in &record.children {
244            next_y = self.render_record(child, depth + 1, area, next_y, buf);
245            if next_y >= area.bottom() {
246                break;
247            }
248        }
249        next_y
250    }
251}
252
253fn write_dot_record(
254    out: &mut String,
255    record: &LayoutRecord,
256    id: usize,
257    parent: Option<usize>,
258) -> usize {
259    let safe_name = record.widget_name.replace('"', "'");
260    let label = format!(
261        "{}\\nreq={}x{} got={}x{}",
262        safe_name,
263        record.area_requested.width,
264        record.area_requested.height,
265        record.area_received.width,
266        record.area_received.height
267    );
268    out.push_str(&format!("  n{} [label=\"{}\"];\n", id, label));
269    if let Some(parent_id) = parent {
270        out.push_str(&format!("  n{} -> n{};\n", parent_id, id));
271    }
272
273    let mut next_id = id + 1;
274    for child in &record.children {
275        next_id = write_dot_record(out, child, next_id, Some(id));
276    }
277    next_id
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn export_dot_contains_nodes_and_edges() {
286        let mut dbg = LayoutDebugger::new();
287        dbg.set_enabled(true);
288        let record = LayoutRecord::new(
289            "Root",
290            Rect::new(0, 0, 10, 4),
291            Rect::new(0, 0, 8, 4),
292            LayoutConstraints::new(5, 12, 2, 6),
293        )
294        .with_child(LayoutRecord::new(
295            "Child",
296            Rect::new(0, 0, 5, 2),
297            Rect::new(0, 0, 5, 2),
298            LayoutConstraints::unconstrained(),
299        ));
300        dbg.record(record);
301
302        let dot = dbg.export_dot();
303        assert!(dot.contains("Root"));
304        assert!(dot.contains("Child"));
305        assert!(dot.contains("->"));
306    }
307
308    #[test]
309    fn render_debug_writes_lines() {
310        let mut dbg = LayoutDebugger::new();
311        dbg.set_enabled(true);
312        dbg.record(LayoutRecord::new(
313            "Root",
314            Rect::new(0, 0, 10, 4),
315            Rect::new(0, 0, 8, 4),
316            LayoutConstraints::new(9, 0, 0, 0),
317        ));
318
319        let mut buf = Buffer::new(30, 4);
320        dbg.render_debug(Rect::new(0, 0, 30, 4), &mut buf);
321
322        let cell = buf.get(0, 0).unwrap();
323        assert_eq!(cell.content.as_char(), Some('R'));
324    }
325
326    #[test]
327    fn disabled_debugger_is_noop() {
328        let mut dbg = LayoutDebugger::new();
329        dbg.record(LayoutRecord::new(
330            "Root",
331            Rect::new(0, 0, 10, 4),
332            Rect::new(0, 0, 8, 4),
333            LayoutConstraints::unconstrained(),
334        ));
335        assert!(dbg.records().is_empty());
336    }
337
338    // --- LayoutConstraints ---
339
340    #[test]
341    fn constraints_new_and_fields() {
342        let c = LayoutConstraints::new(5, 80, 3, 24);
343        assert_eq!(c.min_width, 5);
344        assert_eq!(c.max_width, 80);
345        assert_eq!(c.min_height, 3);
346        assert_eq!(c.max_height, 24);
347    }
348
349    #[test]
350    fn constraints_unconstrained_all_zero() {
351        let c = LayoutConstraints::unconstrained();
352        assert_eq!(c.min_width, 0);
353        assert_eq!(c.max_width, 0);
354        assert_eq!(c.min_height, 0);
355        assert_eq!(c.max_height, 0);
356    }
357
358    #[test]
359    fn constraints_width_overflow() {
360        let c = LayoutConstraints::new(0, 10, 0, 0);
361        assert!(!c.width_overflow(10)); // at max = ok
362        assert!(c.width_overflow(11)); // over max = overflow
363        assert!(!c.width_overflow(5)); // under max = ok
364    }
365
366    #[test]
367    fn constraints_width_overflow_unconstrained() {
368        let c = LayoutConstraints::new(0, 0, 0, 0); // max_width=0 = unconstrained
369        assert!(!c.width_overflow(9999)); // never overflows
370    }
371
372    #[test]
373    fn constraints_height_overflow() {
374        let c = LayoutConstraints::new(0, 0, 0, 10);
375        assert!(!c.height_overflow(10));
376        assert!(c.height_overflow(11));
377    }
378
379    #[test]
380    fn constraints_width_underflow() {
381        let c = LayoutConstraints::new(5, 0, 0, 0);
382        assert!(!c.width_underflow(5)); // at min = ok
383        assert!(c.width_underflow(4)); // below min = underflow
384        assert!(!c.width_underflow(10)); // above min = ok
385    }
386
387    #[test]
388    fn constraints_height_underflow() {
389        let c = LayoutConstraints::new(0, 0, 3, 0);
390        assert!(!c.height_underflow(3));
391        assert!(c.height_underflow(2));
392    }
393
394    // --- LayoutRecord ---
395
396    #[test]
397    fn record_new_and_fields() {
398        let r = LayoutRecord::new(
399            "MyWidget",
400            Rect::new(0, 0, 20, 10),
401            Rect::new(0, 0, 15, 8),
402            LayoutConstraints::new(5, 25, 3, 12),
403        );
404        assert_eq!(r.widget_name, "MyWidget");
405        assert_eq!(r.area_requested.width, 20);
406        assert_eq!(r.area_received.width, 15);
407        assert!(r.children.is_empty());
408    }
409
410    #[test]
411    fn record_with_child_appends() {
412        let parent = LayoutRecord::new(
413            "Parent",
414            Rect::new(0, 0, 20, 10),
415            Rect::new(0, 0, 20, 10),
416            LayoutConstraints::unconstrained(),
417        )
418        .with_child(LayoutRecord::new(
419            "Child1",
420            Rect::new(0, 0, 10, 5),
421            Rect::new(0, 0, 10, 5),
422            LayoutConstraints::unconstrained(),
423        ))
424        .with_child(LayoutRecord::new(
425            "Child2",
426            Rect::new(10, 0, 10, 5),
427            Rect::new(10, 0, 10, 5),
428            LayoutConstraints::unconstrained(),
429        ));
430        assert_eq!(parent.children.len(), 2);
431        assert_eq!(parent.children[0].widget_name, "Child1");
432        assert_eq!(parent.children[1].widget_name, "Child2");
433    }
434
435    #[test]
436    fn record_overflow_detected() {
437        // width overflow
438        let r = LayoutRecord::new(
439            "Widget",
440            Rect::new(0, 0, 20, 10),
441            Rect::new(0, 0, 20, 10),
442            LayoutConstraints::new(0, 15, 0, 0), // max_width=15, received=20
443        );
444        assert!(r.overflow());
445    }
446
447    #[test]
448    fn record_underflow_detected() {
449        let r = LayoutRecord::new(
450            "Widget",
451            Rect::new(0, 0, 20, 10),
452            Rect::new(0, 0, 3, 10),
453            LayoutConstraints::new(5, 0, 0, 0), // min_width=5, received=3
454        );
455        assert!(r.underflow());
456    }
457
458    #[test]
459    fn record_no_violation() {
460        let r = LayoutRecord::new(
461            "Widget",
462            Rect::new(0, 0, 10, 5),
463            Rect::new(0, 0, 10, 5),
464            LayoutConstraints::new(5, 15, 3, 8),
465        );
466        assert!(!r.overflow());
467        assert!(!r.underflow());
468    }
469
470    // --- LayoutDebugger ---
471
472    #[test]
473    fn debugger_default_disabled() {
474        let dbg = LayoutDebugger::new();
475        assert!(!dbg.enabled());
476        assert!(dbg.records().is_empty());
477    }
478
479    #[test]
480    fn debugger_enable_disable() {
481        let mut dbg = LayoutDebugger::new();
482        dbg.set_enabled(true);
483        assert!(dbg.enabled());
484        dbg.set_enabled(false);
485        assert!(!dbg.enabled());
486    }
487
488    #[test]
489    fn debugger_clear() {
490        let mut dbg = LayoutDebugger::new();
491        dbg.set_enabled(true);
492        dbg.record(LayoutRecord::new(
493            "Widget",
494            Rect::new(0, 0, 10, 5),
495            Rect::new(0, 0, 10, 5),
496            LayoutConstraints::unconstrained(),
497        ));
498        assert_eq!(dbg.records().len(), 1);
499        dbg.clear();
500        assert!(dbg.records().is_empty());
501    }
502
503    #[test]
504    fn debugger_records_multiple() {
505        let mut dbg = LayoutDebugger::new();
506        dbg.set_enabled(true);
507        for i in 0..5 {
508            dbg.record(LayoutRecord::new(
509                format!("W{i}"),
510                Rect::new(0, 0, 10, 5),
511                Rect::new(0, 0, 10, 5),
512                LayoutConstraints::unconstrained(),
513            ));
514        }
515        assert_eq!(dbg.records().len(), 5);
516    }
517
518    // --- export_dot edge cases ---
519
520    #[test]
521    fn export_dot_empty() {
522        let dbg = LayoutDebugger::new();
523        let dot = dbg.export_dot();
524        assert!(dot.starts_with("digraph Layout"));
525        assert!(dot.ends_with(
526            "}
527"
528        ));
529        assert!(!dot.contains("n0"));
530    }
531
532    #[test]
533    fn export_dot_escapes_quotes() {
534        let mut dbg = LayoutDebugger::new();
535        dbg.set_enabled(true);
536        // Name containing a double-quote character
537        let name = String::from("Wid") + &String::from('"') + "get";
538        dbg.record(LayoutRecord::new(
539            &name,
540            Rect::new(0, 0, 10, 5),
541            Rect::new(0, 0, 10, 5),
542            LayoutConstraints::unconstrained(),
543        ));
544        let dot = dbg.export_dot();
545        // Double quotes should be replaced with single quotes
546        assert!(dot.contains("Wid'get"));
547    }
548
549    #[test]
550    fn export_dot_nested_children() {
551        let mut dbg = LayoutDebugger::new();
552        dbg.set_enabled(true);
553        let root = LayoutRecord::new(
554            "Root",
555            Rect::new(0, 0, 40, 20),
556            Rect::new(0, 0, 40, 20),
557            LayoutConstraints::unconstrained(),
558        )
559        .with_child(
560            LayoutRecord::new(
561                "Mid",
562                Rect::new(0, 0, 20, 10),
563                Rect::new(0, 0, 20, 10),
564                LayoutConstraints::unconstrained(),
565            )
566            .with_child(LayoutRecord::new(
567                "Leaf",
568                Rect::new(0, 0, 10, 5),
569                Rect::new(0, 0, 10, 5),
570                LayoutConstraints::unconstrained(),
571            )),
572        );
573        dbg.record(root);
574        let dot = dbg.export_dot();
575        assert!(dot.contains("Root"));
576        assert!(dot.contains("Mid"));
577        assert!(dot.contains("Leaf"));
578        // Should have edges: n0->n1, n1->n2
579        assert!(dot.contains("n0 -> n1"));
580        assert!(dot.contains("n1 -> n2"));
581    }
582
583    // --- render_debug edge cases ---
584
585    #[test]
586    fn render_debug_disabled_noop() {
587        let dbg = LayoutDebugger::new(); // disabled
588        let mut buf = Buffer::new(30, 4);
589        let blank_cell = *buf.get(0, 0).unwrap();
590        dbg.render_debug(Rect::new(0, 0, 30, 4), &mut buf);
591        assert_eq!(*buf.get(0, 0).unwrap(), blank_cell);
592    }
593
594    #[test]
595    fn render_debug_overflow_uses_red_color() {
596        let mut dbg = LayoutDebugger::new();
597        dbg.set_enabled(true);
598        dbg.record(LayoutRecord::new(
599            "Over",
600            Rect::new(0, 0, 20, 10),
601            Rect::new(0, 0, 20, 10),
602            LayoutConstraints::new(0, 10, 0, 0), // width overflows
603        ));
604        let mut buf = Buffer::new(60, 4);
605        dbg.render_debug(Rect::new(0, 0, 60, 4), &mut buf);
606        let cell = buf.get(0, 0).unwrap();
607        // Should be red-ish (240, 80, 80)
608        assert_eq!(cell.fg, PackedRgba::rgb(240, 80, 80));
609    }
610
611    #[test]
612    fn render_debug_underflow_uses_yellow_color() {
613        let mut dbg = LayoutDebugger::new();
614        dbg.set_enabled(true);
615        dbg.record(LayoutRecord::new(
616            "Under",
617            Rect::new(0, 0, 20, 10),
618            Rect::new(0, 0, 3, 10),
619            LayoutConstraints::new(5, 0, 0, 0), // width underflows
620        ));
621        let mut buf = Buffer::new(60, 4);
622        dbg.render_debug(Rect::new(0, 0, 60, 4), &mut buf);
623        let cell = buf.get(0, 0).unwrap();
624        // Should be yellow-ish (240, 200, 80)
625        assert_eq!(cell.fg, PackedRgba::rgb(240, 200, 80));
626    }
627}