1#![forbid(unsafe_code)]
2
3use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct LayoutConstraints {
20 pub min_width: u16,
22 pub max_width: u16,
24 pub min_height: u16,
26 pub max_height: u16,
28}
29
30impl LayoutConstraints {
31 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 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#[derive(Debug, Clone)]
70pub struct LayoutRecord {
71 pub widget_name: String,
73 pub area_requested: Rect,
75 pub area_received: Rect,
77 pub constraints: LayoutConstraints,
79 pub children: Vec<LayoutRecord>,
81}
82
83impl LayoutRecord {
84 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 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#[derive(Debug, Default)]
119pub struct LayoutDebugger {
120 enabled: bool,
121 records: Vec<LayoutRecord>,
122}
123
124impl LayoutDebugger {
125 pub fn new() -> Self {
127 Self {
128 enabled: false,
129 records: Vec::new(),
130 }
131 }
132
133 pub fn set_enabled(&mut self, enabled: bool) {
135 self.enabled = enabled;
136 }
137
138 pub fn enabled(&self) -> bool {
140 self.enabled
141 }
142
143 pub fn clear(&mut self) {
145 self.records.clear();
146 }
147
148 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 pub fn records(&self) -> &[LayoutRecord] {
175 &self.records
176 }
177
178 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 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}