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    pub fn with_child(mut self, child: LayoutRecord) -> Self {
102        self.children.push(child);
103        self
104    }
105
106    fn overflow(&self) -> bool {
107        self.constraints.width_overflow(self.area_received.width)
108            || self.constraints.height_overflow(self.area_received.height)
109    }
110
111    fn underflow(&self) -> bool {
112        self.constraints.width_underflow(self.area_received.width)
113            || self.constraints.height_underflow(self.area_received.height)
114    }
115}
116
117/// Layout debugger that records constraint data and renders diagnostics.
118#[derive(Debug, Default)]
119pub struct LayoutDebugger {
120    enabled: bool,
121    records: Vec<LayoutRecord>,
122}
123
124impl LayoutDebugger {
125    /// Create a new disabled layout debugger.
126    pub fn new() -> Self {
127        Self {
128            enabled: false,
129            records: Vec::new(),
130        }
131    }
132
133    /// Enable or disable the debugger.
134    pub fn set_enabled(&mut self, enabled: bool) {
135        self.enabled = enabled;
136    }
137
138    /// Returns whether the debugger is enabled.
139    pub fn enabled(&self) -> bool {
140        self.enabled
141    }
142
143    /// Clear all recorded layout data.
144    pub fn clear(&mut self) {
145        self.records.clear();
146    }
147
148    /// Record a layout computation result.
149    pub fn record(&mut self, record: LayoutRecord) {
150        if !self.enabled {
151            return;
152        }
153        #[cfg(feature = "tracing")]
154        {
155            if record.overflow() || record.underflow() {
156                warn!(
157                    widget = record.widget_name.as_str(),
158                    requested = ?record.area_requested,
159                    received = ?record.area_received,
160                    "Layout constraint violation"
161                );
162            }
163            debug!(
164                widget = record.widget_name.as_str(),
165                constraints = ?record.constraints,
166                result = ?record.area_received,
167                "Layout computed"
168            );
169        }
170        self.records.push(record);
171    }
172
173    /// Get the recorded layout data.
174    pub fn records(&self) -> &[LayoutRecord] {
175        &self.records
176    }
177
178    /// Render a simple tree view of layout records into the buffer.
179    pub fn render_debug(&self, area: Rect, buf: &mut Buffer) {
180        if !self.enabled {
181            return;
182        }
183        let mut y = area.y;
184        for record in &self.records {
185            y = self.render_record(record, 0, area, y, buf);
186            if y >= area.bottom() {
187                break;
188            }
189        }
190    }
191
192    /// Export recorded layout data as Graphviz DOT.
193    pub fn export_dot(&self) -> String {
194        let mut out = String::from("digraph Layout {\n  node [shape=box];\n");
195        let mut next_id = 0usize;
196        for record in &self.records {
197            next_id = write_dot_record(&mut out, record, next_id, None);
198        }
199        out.push_str("}\n");
200        out
201    }
202
203    fn render_record(
204        &self,
205        record: &LayoutRecord,
206        depth: usize,
207        area: Rect,
208        y: u16,
209        buf: &mut Buffer,
210    ) -> u16 {
211        if y >= area.bottom() {
212            return y;
213        }
214
215        let indent = " ".repeat(depth * 2);
216        let line = format!(
217            "{}{} req={}x{} got={}x{} min={}x{} max={}x{}",
218            indent,
219            record.widget_name,
220            record.area_requested.width,
221            record.area_requested.height,
222            record.area_received.width,
223            record.area_received.height,
224            record.constraints.min_width,
225            record.constraints.min_height,
226            record.constraints.max_width,
227            record.constraints.max_height,
228        );
229
230        let color = if record.overflow() {
231            PackedRgba::rgb(240, 80, 80)
232        } else if record.underflow() {
233            PackedRgba::rgb(240, 200, 80)
234        } else {
235            PackedRgba::rgb(200, 200, 200)
236        };
237
238        let cell = Cell::from_char(' ').with_fg(color);
239        let _ = buf.print_text_clipped(area.x, y, &line, cell, area.right());
240
241        let mut next_y = y.saturating_add(1);
242        for child in &record.children {
243            next_y = self.render_record(child, depth + 1, area, next_y, buf);
244            if next_y >= area.bottom() {
245                break;
246            }
247        }
248        next_y
249    }
250}
251
252fn write_dot_record(
253    out: &mut String,
254    record: &LayoutRecord,
255    id: usize,
256    parent: Option<usize>,
257) -> usize {
258    let safe_name = record.widget_name.replace('"', "'");
259    let label = format!(
260        "{}\\nreq={}x{} got={}x{}",
261        safe_name,
262        record.area_requested.width,
263        record.area_requested.height,
264        record.area_received.width,
265        record.area_received.height
266    );
267    out.push_str(&format!("  n{} [label=\"{}\"];\n", id, label));
268    if let Some(parent_id) = parent {
269        out.push_str(&format!("  n{} -> n{};\n", parent_id, id));
270    }
271
272    let mut next_id = id + 1;
273    for child in &record.children {
274        next_id = write_dot_record(out, child, next_id, Some(id));
275    }
276    next_id
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn export_dot_contains_nodes_and_edges() {
285        let mut dbg = LayoutDebugger::new();
286        dbg.set_enabled(true);
287        let record = LayoutRecord::new(
288            "Root",
289            Rect::new(0, 0, 10, 4),
290            Rect::new(0, 0, 8, 4),
291            LayoutConstraints::new(5, 12, 2, 6),
292        )
293        .with_child(LayoutRecord::new(
294            "Child",
295            Rect::new(0, 0, 5, 2),
296            Rect::new(0, 0, 5, 2),
297            LayoutConstraints::unconstrained(),
298        ));
299        dbg.record(record);
300
301        let dot = dbg.export_dot();
302        assert!(dot.contains("Root"));
303        assert!(dot.contains("Child"));
304        assert!(dot.contains("->"));
305    }
306
307    #[test]
308    fn render_debug_writes_lines() {
309        let mut dbg = LayoutDebugger::new();
310        dbg.set_enabled(true);
311        dbg.record(LayoutRecord::new(
312            "Root",
313            Rect::new(0, 0, 10, 4),
314            Rect::new(0, 0, 8, 4),
315            LayoutConstraints::new(9, 0, 0, 0),
316        ));
317
318        let mut buf = Buffer::new(30, 4);
319        dbg.render_debug(Rect::new(0, 0, 30, 4), &mut buf);
320
321        let cell = buf.get(0, 0).unwrap();
322        assert_eq!(cell.content.as_char(), Some('R'));
323    }
324
325    #[test]
326    fn disabled_debugger_is_noop() {
327        let mut dbg = LayoutDebugger::new();
328        dbg.record(LayoutRecord::new(
329            "Root",
330            Rect::new(0, 0, 10, 4),
331            Rect::new(0, 0, 8, 4),
332            LayoutConstraints::unconstrained(),
333        ));
334        assert!(dbg.records().is_empty());
335    }
336}