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 let was_enabled = self.enabled;
137 self.enabled = enabled;
138 if was_enabled && !enabled {
139 self.clear();
140 }
141 }
142
143 pub fn enabled(&self) -> bool {
145 self.enabled
146 }
147
148 pub fn clear(&mut self) {
150 self.records.clear();
151 }
152
153 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 pub fn records(&self) -> &[LayoutRecord] {
180 &self.records
181 }
182
183 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 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 #[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)); assert!(c.width_overflow(11)); assert!(!c.width_overflow(5)); }
375
376 #[test]
377 fn constraints_width_overflow_unconstrained() {
378 let c = LayoutConstraints::new(0, 0, 0, 0); assert!(!c.width_overflow(9999)); }
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)); assert!(c.width_underflow(4)); assert!(!c.width_underflow(10)); }
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 #[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 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), );
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), );
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 #[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 #[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 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 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 assert!(dot.contains("n0 -> n1"));
608 assert!(dot.contains("n1 -> n2"));
609 }
610
611 #[test]
614 fn render_debug_disabled_noop() {
615 let dbg = LayoutDebugger::new(); 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), ));
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 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), ));
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 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}