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 #[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#[derive(Debug, Default)]
120pub struct LayoutDebugger {
121 enabled: bool,
122 records: Vec<LayoutRecord>,
123}
124
125impl LayoutDebugger {
126 pub fn new() -> Self {
128 Self {
129 enabled: false,
130 records: Vec::new(),
131 }
132 }
133
134 pub fn set_enabled(&mut self, enabled: bool) {
136 self.enabled = enabled;
137 }
138
139 pub fn enabled(&self) -> bool {
141 self.enabled
142 }
143
144 pub fn clear(&mut self) {
146 self.records.clear();
147 }
148
149 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 pub fn records(&self) -> &[LayoutRecord] {
176 &self.records
177 }
178
179 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 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 #[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)); assert!(c.width_overflow(11)); assert!(!c.width_overflow(5)); }
365
366 #[test]
367 fn constraints_width_overflow_unconstrained() {
368 let c = LayoutConstraints::new(0, 0, 0, 0); assert!(!c.width_overflow(9999)); }
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)); assert!(c.width_underflow(4)); assert!(!c.width_underflow(10)); }
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 #[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 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), );
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), );
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 #[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 #[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 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 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 assert!(dot.contains("n0 -> n1"));
580 assert!(dot.contains("n1 -> n2"));
581 }
582
583 #[test]
586 fn render_debug_disabled_noop() {
587 let dbg = LayoutDebugger::new(); 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), ));
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 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), ));
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 assert_eq!(cell.fg, PackedRgba::rgb(240, 200, 80));
626 }
627}