Skip to main content

ftui_widgets/
constraint_overlay.rs

1#![forbid(unsafe_code)]
2
3//! Constraint visualization overlay for layout debugging.
4//!
5//! Provides a visual overlay that shows layout constraint violations,
6//! requested vs received sizes, and constraint bounds at widget positions.
7//!
8//! # Example
9//!
10//! ```ignore
11//! use ftui_widgets::{ConstraintOverlay, LayoutDebugger, Widget};
12//!
13//! let mut debugger = LayoutDebugger::new();
14//! debugger.set_enabled(true);
15//!
16//! // Record constraint data during layout...
17//!
18//! // Later, render the overlay
19//! let overlay = ConstraintOverlay::new(&debugger);
20//! overlay.render(area, &mut frame);
21//! ```
22
23use crate::Widget;
24use crate::layout_debugger::{LayoutDebugger, LayoutRecord};
25use ftui_core::geometry::Rect;
26use ftui_render::buffer::Buffer;
27use ftui_render::cell::{Cell, PackedRgba};
28use ftui_render::drawing::{BorderChars, Draw};
29use ftui_render::frame::Frame;
30
31/// Visualization style for constraint overlay.
32#[derive(Debug, Clone)]
33pub struct ConstraintOverlayStyle {
34    /// Border color for widgets without constraint violations.
35    pub normal_color: PackedRgba,
36    /// Border color for widgets exceeding max constraints (overflow).
37    pub overflow_color: PackedRgba,
38    /// Border color for widgets below min constraints (underflow).
39    pub underflow_color: PackedRgba,
40    /// Color for the "requested" size outline.
41    pub requested_color: PackedRgba,
42    /// Label foreground color.
43    pub label_fg: PackedRgba,
44    /// Label background color.
45    pub label_bg: PackedRgba,
46    /// Whether to show requested vs received size difference.
47    pub show_size_diff: bool,
48    /// Whether to show constraint bounds in labels.
49    pub show_constraint_bounds: bool,
50    /// Whether to show border outlines.
51    pub show_borders: bool,
52    /// Whether to show labels.
53    pub show_labels: bool,
54    /// Border characters to use.
55    pub border_chars: BorderChars,
56}
57
58impl Default for ConstraintOverlayStyle {
59    fn default() -> Self {
60        Self {
61            normal_color: PackedRgba::rgb(100, 200, 100),
62            overflow_color: PackedRgba::rgb(240, 80, 80),
63            underflow_color: PackedRgba::rgb(240, 200, 80),
64            requested_color: PackedRgba::rgb(80, 150, 240),
65            label_fg: PackedRgba::rgb(255, 255, 255),
66            label_bg: PackedRgba::rgb(0, 0, 0),
67            show_size_diff: true,
68            show_constraint_bounds: true,
69            show_borders: true,
70            show_labels: true,
71            border_chars: BorderChars::ASCII,
72        }
73    }
74}
75
76/// Constraint visualization overlay widget.
77///
78/// Renders layout constraint information as a visual overlay:
79/// - Red borders for overflow violations (received > max)
80/// - Yellow borders for underflow violations (received < min)
81/// - Green borders for widgets within constraints
82/// - Blue dashed outline showing requested size vs received size
83/// - Labels showing widget name, sizes, and constraint bounds
84pub struct ConstraintOverlay<'a> {
85    debugger: &'a LayoutDebugger,
86    style: ConstraintOverlayStyle,
87}
88
89impl<'a> ConstraintOverlay<'a> {
90    /// Create a new constraint overlay for the given debugger.
91    pub fn new(debugger: &'a LayoutDebugger) -> Self {
92        Self {
93            debugger,
94            style: ConstraintOverlayStyle::default(),
95        }
96    }
97
98    /// Set custom styling.
99    #[must_use]
100    pub fn style(mut self, style: ConstraintOverlayStyle) -> Self {
101        self.style = style;
102        self
103    }
104
105    fn render_record(&self, record: &LayoutRecord, area: Rect, buf: &mut Buffer) {
106        // Only render if the received area intersects with our render area
107        let Some(clipped) = record.area_received.intersection_opt(&area) else {
108            return;
109        };
110        if clipped.is_empty() {
111            return;
112        }
113
114        // Determine constraint status
115        let constraints = &record.constraints;
116        let received = &record.area_received;
117
118        let is_overflow = (constraints.max_width != 0 && received.width > constraints.max_width)
119            || (constraints.max_height != 0 && received.height > constraints.max_height);
120        let is_underflow =
121            received.width < constraints.min_width || received.height < constraints.min_height;
122
123        let border_color = if is_overflow {
124            self.style.overflow_color
125        } else if is_underflow {
126            self.style.underflow_color
127        } else {
128            self.style.normal_color
129        };
130
131        // Draw received area border
132        if self.style.show_borders {
133            let border_cell = Cell::from_char('+').with_fg(border_color);
134            buf.draw_border(clipped, self.style.border_chars, border_cell);
135        }
136
137        // Draw requested area outline if different from received
138        if self.style.show_size_diff {
139            let requested = &record.area_requested;
140            if requested != received
141                && let Some(req_clipped) = requested.intersection_opt(&area)
142                && !req_clipped.is_empty()
143            {
144                // Draw dashed corners to indicate requested size
145                let req_cell = Cell::from_char('.').with_fg(self.style.requested_color);
146                self.draw_requested_outline(req_clipped, buf, req_cell);
147            }
148        }
149
150        // Draw label
151        if self.style.show_labels {
152            let label = self.format_label(record, is_overflow, is_underflow);
153            let label_x = clipped.x.saturating_add(1);
154            let label_y = clipped.y;
155            let max_x = clipped.right();
156
157            if label_x < max_x {
158                let label_cell = Cell::from_char(' ')
159                    .with_fg(self.style.label_fg)
160                    .with_bg(self.style.label_bg);
161                let _ = buf.print_text_clipped(label_x, label_y, &label, label_cell, max_x);
162            }
163        }
164
165        // Render children
166        for child in &record.children {
167            self.render_record(child, area, buf);
168        }
169    }
170
171    fn draw_requested_outline(&self, area: Rect, buf: &mut Buffer, cell: Cell) {
172        // Draw corner dots to indicate requested size boundary
173        if area.width >= 1 && area.height >= 1 {
174            buf.set_fast(area.x, area.y, cell);
175        }
176        if area.width >= 2 && area.height >= 1 {
177            buf.set_fast(area.right().saturating_sub(1), area.y, cell);
178        }
179        if area.width >= 1 && area.height >= 2 {
180            buf.set_fast(area.x, area.bottom().saturating_sub(1), cell);
181        }
182        if area.width >= 2 && area.height >= 2 {
183            buf.set_fast(
184                area.right().saturating_sub(1),
185                area.bottom().saturating_sub(1),
186                cell,
187            );
188        }
189    }
190
191    fn format_label(&self, record: &LayoutRecord, is_overflow: bool, is_underflow: bool) -> String {
192        let status = if is_overflow {
193            "!"
194        } else if is_underflow {
195            "?"
196        } else {
197            ""
198        };
199
200        let mut label = format!("{}{}", record.widget_name, status);
201
202        // Add size info
203        let req = &record.area_requested;
204        let got = &record.area_received;
205        if self.style.show_size_diff && (req.width != got.width || req.height != got.height) {
206            label.push_str(&format!(
207                " {}x{}\u{2192}{}x{}",
208                req.width, req.height, got.width, got.height
209            ));
210        } else {
211            label.push_str(&format!(" {}x{}", got.width, got.height));
212        }
213
214        // Add constraint bounds if requested
215        if self.style.show_constraint_bounds {
216            let c = &record.constraints;
217            if c.min_width != 0 || c.min_height != 0 || c.max_width != 0 || c.max_height != 0 {
218                label.push_str(&format!(
219                    " [{}..{} x {}..{}]",
220                    c.min_width,
221                    if c.max_width == 0 {
222                        "\u{221E}".to_string()
223                    } else {
224                        c.max_width.to_string()
225                    },
226                    c.min_height,
227                    if c.max_height == 0 {
228                        "\u{221E}".to_string()
229                    } else {
230                        c.max_height.to_string()
231                    }
232                ));
233            }
234        }
235
236        label
237    }
238}
239
240impl Widget for ConstraintOverlay<'_> {
241    fn render(&self, area: Rect, frame: &mut Frame) {
242        if !self.debugger.enabled() {
243            return;
244        }
245
246        for record in self.debugger.records() {
247            self.render_record(record, area, &mut frame.buffer);
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::layout_debugger::LayoutConstraints;
256    use ftui_render::grapheme_pool::GraphemePool;
257
258    #[test]
259    fn overlay_renders_nothing_when_disabled() {
260        let mut debugger = LayoutDebugger::new();
261        // Not enabled, so record is ignored
262        debugger.record(LayoutRecord::new(
263            "Root",
264            Rect::new(0, 0, 10, 4),
265            Rect::new(0, 0, 10, 4),
266            LayoutConstraints::unconstrained(),
267        ));
268
269        let overlay = ConstraintOverlay::new(&debugger);
270        let mut pool = GraphemePool::new();
271        let mut frame = Frame::new(20, 10, &mut pool);
272        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
273
274        // Buffer should be unchanged (all default cells)
275        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
276    }
277
278    #[test]
279    fn overlay_renders_border_for_valid_constraint() {
280        let mut debugger = LayoutDebugger::new();
281        debugger.set_enabled(true);
282        debugger.record(LayoutRecord::new(
283            "Root",
284            Rect::new(1, 1, 6, 4),
285            Rect::new(1, 1, 6, 4),
286            LayoutConstraints::new(4, 10, 2, 6),
287        ));
288
289        let overlay = ConstraintOverlay::new(&debugger);
290        let mut pool = GraphemePool::new();
291        let mut frame = Frame::new(20, 10, &mut pool);
292        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
293
294        // Should have border drawn
295        let cell = frame.buffer.get(1, 1).unwrap();
296        assert_eq!(cell.content.as_char(), Some('+'));
297    }
298
299    #[test]
300    fn overlay_uses_overflow_color_when_exceeds_max() {
301        let mut debugger = LayoutDebugger::new();
302        debugger.set_enabled(true);
303        // Received 10x4 but max is 8x3 (overflow)
304        debugger.record(LayoutRecord::new(
305            "Overflow",
306            Rect::new(0, 0, 10, 4),
307            Rect::new(0, 0, 10, 4),
308            LayoutConstraints::new(0, 8, 0, 3),
309        ));
310
311        let style = ConstraintOverlayStyle {
312            overflow_color: PackedRgba::rgb(255, 0, 0),
313            ..Default::default()
314        };
315
316        let overlay = ConstraintOverlay::new(&debugger).style(style);
317        let mut pool = GraphemePool::new();
318        let mut frame = Frame::new(20, 10, &mut pool);
319        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
320
321        let cell = frame.buffer.get(0, 0).unwrap();
322        assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
323    }
324
325    #[test]
326    fn overlay_uses_underflow_color_when_below_min() {
327        let mut debugger = LayoutDebugger::new();
328        debugger.set_enabled(true);
329        // Received 4x2 but min is 6x3 (underflow)
330        debugger.record(LayoutRecord::new(
331            "Underflow",
332            Rect::new(0, 0, 4, 2),
333            Rect::new(0, 0, 4, 2),
334            LayoutConstraints::new(6, 0, 3, 0),
335        ));
336
337        let style = ConstraintOverlayStyle {
338            underflow_color: PackedRgba::rgb(255, 255, 0),
339            ..Default::default()
340        };
341
342        let overlay = ConstraintOverlay::new(&debugger).style(style);
343        let mut pool = GraphemePool::new();
344        let mut frame = Frame::new(20, 10, &mut pool);
345        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
346
347        let cell = frame.buffer.get(0, 0).unwrap();
348        assert_eq!(cell.fg, PackedRgba::rgb(255, 255, 0));
349    }
350
351    #[test]
352    fn overlay_shows_requested_vs_received_diff() {
353        let mut debugger = LayoutDebugger::new();
354        debugger.set_enabled(true);
355        // Requested 10x5 but got 8x4
356        debugger.record(LayoutRecord::new(
357            "Diff",
358            Rect::new(0, 0, 10, 5),
359            Rect::new(0, 0, 8, 4),
360            LayoutConstraints::unconstrained(),
361        ));
362
363        let style = ConstraintOverlayStyle {
364            requested_color: PackedRgba::rgb(0, 0, 255),
365            ..Default::default()
366        };
367
368        let overlay = ConstraintOverlay::new(&debugger).style(style);
369        let mut pool = GraphemePool::new();
370        let mut frame = Frame::new(20, 10, &mut pool);
371        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
372
373        // Corner of requested area (10x5) should have dot marker
374        let cell = frame.buffer.get(9, 0).unwrap();
375        assert_eq!(cell.content.as_char(), Some('.'));
376        assert_eq!(cell.fg, PackedRgba::rgb(0, 0, 255));
377    }
378
379    #[test]
380    fn overlay_renders_children() {
381        let mut debugger = LayoutDebugger::new();
382        debugger.set_enabled(true);
383
384        let child = LayoutRecord::new(
385            "Child",
386            Rect::new(2, 2, 4, 2),
387            Rect::new(2, 2, 4, 2),
388            LayoutConstraints::unconstrained(),
389        );
390        let parent = LayoutRecord::new(
391            "Parent",
392            Rect::new(0, 0, 10, 6),
393            Rect::new(0, 0, 10, 6),
394            LayoutConstraints::unconstrained(),
395        )
396        .with_child(child);
397        debugger.record(parent);
398
399        let overlay = ConstraintOverlay::new(&debugger);
400        let mut pool = GraphemePool::new();
401        let mut frame = Frame::new(20, 10, &mut pool);
402        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
403
404        // Both parent and child should have borders
405        let parent_cell = frame.buffer.get(0, 0).unwrap();
406        assert_eq!(parent_cell.content.as_char(), Some('+'));
407
408        let child_cell = frame.buffer.get(2, 2).unwrap();
409        assert_eq!(child_cell.content.as_char(), Some('+'));
410    }
411
412    #[test]
413    fn overlay_clips_to_render_area() {
414        let mut debugger = LayoutDebugger::new();
415        debugger.set_enabled(true);
416        debugger.record(LayoutRecord::new(
417            "PartiallyVisible",
418            Rect::new(5, 5, 10, 10),
419            Rect::new(5, 5, 10, 10),
420            LayoutConstraints::unconstrained(),
421        ));
422
423        let overlay = ConstraintOverlay::new(&debugger);
424        let mut pool = GraphemePool::new();
425        let mut frame = Frame::new(10, 10, &mut pool);
426        // Render area is 0,0,10,10 but widget is at 5,5,10,10
427        overlay.render(Rect::new(0, 0, 10, 10), &mut frame);
428
429        // Should render the visible portion
430        let cell = frame.buffer.get(5, 5).unwrap();
431        assert_eq!(cell.content.as_char(), Some('+'));
432
433        // Outside render area should be empty
434        let outside = frame.buffer.get(0, 0).unwrap();
435        assert!(outside.is_empty());
436    }
437
438    #[test]
439    fn format_label_includes_status_marker() {
440        let debugger = LayoutDebugger::new();
441        let overlay = ConstraintOverlay::new(&debugger);
442
443        // Overflow case
444        let record = LayoutRecord::new(
445            "Widget",
446            Rect::new(0, 0, 10, 4),
447            Rect::new(0, 0, 10, 4),
448            LayoutConstraints::new(0, 8, 0, 0),
449        );
450        let label = overlay.format_label(&record, true, false);
451        assert!(label.starts_with("Widget!"));
452
453        // Underflow case
454        let label = overlay.format_label(&record, false, true);
455        assert!(label.starts_with("Widget?"));
456
457        // Normal case
458        let label = overlay.format_label(&record, false, false);
459        assert!(label.starts_with("Widget "));
460    }
461
462    #[test]
463    fn style_can_be_customized() {
464        let debugger = LayoutDebugger::new();
465        let style = ConstraintOverlayStyle {
466            show_borders: false,
467            show_labels: false,
468            show_size_diff: false,
469            ..Default::default()
470        };
471
472        let overlay = ConstraintOverlay::new(&debugger).style(style);
473        assert!(!overlay.style.show_borders);
474        assert!(!overlay.style.show_labels);
475    }
476
477    #[test]
478    fn default_style_values() {
479        let s = ConstraintOverlayStyle::default();
480        assert_eq!(s.normal_color, PackedRgba::rgb(100, 200, 100));
481        assert_eq!(s.overflow_color, PackedRgba::rgb(240, 80, 80));
482        assert_eq!(s.underflow_color, PackedRgba::rgb(240, 200, 80));
483        assert_eq!(s.requested_color, PackedRgba::rgb(80, 150, 240));
484        assert!(s.show_size_diff);
485        assert!(s.show_constraint_bounds);
486        assert!(s.show_borders);
487        assert!(s.show_labels);
488    }
489
490    #[test]
491    fn format_label_same_requested_and_received() {
492        let debugger = LayoutDebugger::new();
493        let overlay = ConstraintOverlay::new(&debugger);
494        let record = LayoutRecord::new(
495            "Box",
496            Rect::new(0, 0, 8, 4),
497            Rect::new(0, 0, 8, 4),
498            LayoutConstraints::unconstrained(),
499        );
500        let label = overlay.format_label(&record, false, false);
501        assert!(label.contains("8x4"));
502        // Should NOT contain arrow since sizes are equal.
503        assert!(!label.contains('\u{2192}'));
504    }
505
506    #[test]
507    fn format_label_different_sizes_shows_arrow() {
508        let debugger = LayoutDebugger::new();
509        let overlay = ConstraintOverlay::new(&debugger);
510        let record = LayoutRecord::new(
511            "Box",
512            Rect::new(0, 0, 10, 5),
513            Rect::new(0, 0, 8, 4),
514            LayoutConstraints::unconstrained(),
515        );
516        let label = overlay.format_label(&record, false, false);
517        // Should contain "10x5→8x4"
518        assert!(label.contains("10x5"));
519        assert!(label.contains('\u{2192}'));
520        assert!(label.contains("8x4"));
521    }
522
523    #[test]
524    fn format_label_hides_size_diff_when_disabled() {
525        let debugger = LayoutDebugger::new();
526        let style = ConstraintOverlayStyle {
527            show_size_diff: false,
528            ..Default::default()
529        };
530        let overlay = ConstraintOverlay::new(&debugger).style(style);
531        let record = LayoutRecord::new(
532            "Box",
533            Rect::new(0, 0, 10, 5),
534            Rect::new(0, 0, 8, 4),
535            LayoutConstraints::unconstrained(),
536        );
537        let label = overlay.format_label(&record, false, false);
538
539        assert!(!label.contains('\u{2192}'));
540        assert!(label.contains("8x4"));
541        assert!(!label.contains("10x5"));
542    }
543
544    #[test]
545    fn format_label_constraint_bounds_infinity() {
546        let debugger = LayoutDebugger::new();
547        let overlay = ConstraintOverlay::new(&debugger);
548        // min_width=5, max_width=0 (infinity), min_height=0, max_height=10
549        let record = LayoutRecord::new(
550            "W",
551            Rect::new(0, 0, 8, 4),
552            Rect::new(0, 0, 8, 4),
553            LayoutConstraints::new(5, 0, 0, 10),
554        );
555        let label = overlay.format_label(&record, false, false);
556        // max_width=0 should render as ∞
557        assert!(label.contains('\u{221E}'));
558        assert!(label.contains("5.."));
559    }
560
561    #[test]
562    fn format_label_no_bounds_when_all_zero() {
563        let debugger = LayoutDebugger::new();
564        let overlay = ConstraintOverlay::new(&debugger);
565        let record = LayoutRecord::new(
566            "W",
567            Rect::new(0, 0, 8, 4),
568            Rect::new(0, 0, 8, 4),
569            LayoutConstraints::new(0, 0, 0, 0),
570        );
571        let label = overlay.format_label(&record, false, false);
572        // All-zero constraints → no bounds shown.
573        assert!(!label.contains('['));
574    }
575
576    #[test]
577    fn format_label_no_bounds_when_disabled() {
578        let debugger = LayoutDebugger::new();
579        let style = ConstraintOverlayStyle {
580            show_constraint_bounds: false,
581            ..Default::default()
582        };
583        let overlay = ConstraintOverlay::new(&debugger).style(style);
584        let record = LayoutRecord::new(
585            "W",
586            Rect::new(0, 0, 8, 4),
587            Rect::new(0, 0, 8, 4),
588            LayoutConstraints::new(5, 10, 3, 8),
589        );
590        let label = overlay.format_label(&record, false, false);
591        assert!(!label.contains('['));
592    }
593
594    #[test]
595    fn enabled_debugger_with_no_records_renders_nothing() {
596        let mut debugger = LayoutDebugger::new();
597        debugger.set_enabled(true);
598        // No records added.
599        let overlay = ConstraintOverlay::new(&debugger);
600        let mut pool = GraphemePool::new();
601        let mut frame = Frame::new(20, 10, &mut pool);
602        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
603        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
604    }
605
606    #[test]
607    fn record_fully_outside_render_area_is_skipped() {
608        let mut debugger = LayoutDebugger::new();
609        debugger.set_enabled(true);
610        debugger.record(LayoutRecord::new(
611            "Offscreen",
612            Rect::new(50, 50, 10, 10),
613            Rect::new(50, 50, 10, 10),
614            LayoutConstraints::unconstrained(),
615        ));
616
617        let overlay = ConstraintOverlay::new(&debugger);
618        let mut pool = GraphemePool::new();
619        let mut frame = Frame::new(20, 10, &mut pool);
620        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
621
622        // Nothing should be drawn since record is fully outside render area.
623        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
624    }
625
626    // ─── Edge-case tests (bd-3szd1) ────────────────────────────────────
627
628    #[test]
629    fn zero_size_record_is_skipped() {
630        let mut debugger = LayoutDebugger::new();
631        debugger.set_enabled(true);
632        debugger.record(LayoutRecord::new(
633            "Empty",
634            Rect::new(0, 0, 0, 0),
635            Rect::new(0, 0, 0, 0),
636            LayoutConstraints::unconstrained(),
637        ));
638
639        let overlay = ConstraintOverlay::new(&debugger);
640        let mut pool = GraphemePool::new();
641        let mut frame = Frame::new(20, 10, &mut pool);
642        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
643        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
644    }
645
646    #[test]
647    fn one_by_one_record_renders_border() {
648        let mut debugger = LayoutDebugger::new();
649        debugger.set_enabled(true);
650        debugger.record(LayoutRecord::new(
651            "Tiny",
652            Rect::new(2, 2, 1, 1),
653            Rect::new(2, 2, 1, 1),
654            LayoutConstraints::unconstrained(),
655        ));
656
657        let overlay = ConstraintOverlay::new(&debugger);
658        let mut pool = GraphemePool::new();
659        let mut frame = Frame::new(20, 10, &mut pool);
660        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
661        // 1x1 area: border drawn at the single cell
662        let cell = frame.buffer.get(2, 2).unwrap();
663        assert!(!cell.is_empty());
664    }
665
666    #[test]
667    fn overflow_only_height() {
668        let mut debugger = LayoutDebugger::new();
669        debugger.set_enabled(true);
670        // width OK (5 <= 10), height overflow (8 > 6)
671        debugger.record(LayoutRecord::new(
672            "HOverflow",
673            Rect::new(0, 0, 5, 8),
674            Rect::new(0, 0, 5, 8),
675            LayoutConstraints::new(0, 10, 0, 6),
676        ));
677
678        let style = ConstraintOverlayStyle {
679            overflow_color: PackedRgba::rgb(255, 0, 0),
680            ..Default::default()
681        };
682        let overlay = ConstraintOverlay::new(&debugger).style(style);
683        let mut pool = GraphemePool::new();
684        let mut frame = Frame::new(20, 10, &mut pool);
685        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
686
687        let cell = frame.buffer.get(0, 0).unwrap();
688        assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0), "height overflow color");
689    }
690
691    #[test]
692    fn underflow_only_height() {
693        let mut debugger = LayoutDebugger::new();
694        debugger.set_enabled(true);
695        // width OK (6 >= 4), height underflow (2 < 3)
696        debugger.record(LayoutRecord::new(
697            "HUnderflow",
698            Rect::new(0, 0, 6, 2),
699            Rect::new(0, 0, 6, 2),
700            LayoutConstraints::new(4, 0, 3, 0),
701        ));
702
703        let style = ConstraintOverlayStyle {
704            underflow_color: PackedRgba::rgb(255, 255, 0),
705            ..Default::default()
706        };
707        let overlay = ConstraintOverlay::new(&debugger).style(style);
708        let mut pool = GraphemePool::new();
709        let mut frame = Frame::new(20, 10, &mut pool);
710        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
711
712        let cell = frame.buffer.get(0, 0).unwrap();
713        assert_eq!(
714            cell.fg,
715            PackedRgba::rgb(255, 255, 0),
716            "height underflow color"
717        );
718    }
719
720    #[test]
721    fn overflow_takes_priority_over_underflow() {
722        let mut debugger = LayoutDebugger::new();
723        debugger.set_enabled(true);
724        // width overflow (10 > 8), height underflow (2 < 3)
725        debugger.record(LayoutRecord::new(
726            "Both",
727            Rect::new(0, 0, 10, 2),
728            Rect::new(0, 0, 10, 2),
729            LayoutConstraints::new(0, 8, 3, 0),
730        ));
731
732        let style = ConstraintOverlayStyle {
733            overflow_color: PackedRgba::rgb(255, 0, 0),
734            underflow_color: PackedRgba::rgb(255, 255, 0),
735            ..Default::default()
736        };
737        let overlay = ConstraintOverlay::new(&debugger).style(style);
738        let mut pool = GraphemePool::new();
739        let mut frame = Frame::new(20, 10, &mut pool);
740        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
741
742        let cell = frame.buffer.get(0, 0).unwrap();
743        assert_eq!(
744            cell.fg,
745            PackedRgba::rgb(255, 0, 0),
746            "overflow wins over underflow"
747        );
748    }
749
750    #[test]
751    fn multiple_records_all_render() {
752        let mut debugger = LayoutDebugger::new();
753        debugger.set_enabled(true);
754        debugger.record(LayoutRecord::new(
755            "A",
756            Rect::new(0, 0, 5, 3),
757            Rect::new(0, 0, 5, 3),
758            LayoutConstraints::unconstrained(),
759        ));
760        debugger.record(LayoutRecord::new(
761            "B",
762            Rect::new(6, 0, 5, 3),
763            Rect::new(6, 0, 5, 3),
764            LayoutConstraints::unconstrained(),
765        ));
766
767        let overlay = ConstraintOverlay::new(&debugger);
768        let mut pool = GraphemePool::new();
769        let mut frame = Frame::new(20, 10, &mut pool);
770        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
771
772        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('+'));
773        assert_eq!(frame.buffer.get(6, 0).unwrap().content.as_char(), Some('+'));
774    }
775
776    #[test]
777    fn deeply_nested_children_render() {
778        let mut debugger = LayoutDebugger::new();
779        debugger.set_enabled(true);
780
781        let grandchild = LayoutRecord::new(
782            "GC",
783            Rect::new(4, 4, 3, 2),
784            Rect::new(4, 4, 3, 2),
785            LayoutConstraints::unconstrained(),
786        );
787        let child = LayoutRecord::new(
788            "Child",
789            Rect::new(2, 2, 8, 6),
790            Rect::new(2, 2, 8, 6),
791            LayoutConstraints::unconstrained(),
792        )
793        .with_child(grandchild);
794        let parent = LayoutRecord::new(
795            "Parent",
796            Rect::new(0, 0, 12, 10),
797            Rect::new(0, 0, 12, 10),
798            LayoutConstraints::unconstrained(),
799        )
800        .with_child(child);
801        debugger.record(parent);
802
803        let overlay = ConstraintOverlay::new(&debugger);
804        let mut pool = GraphemePool::new();
805        let mut frame = Frame::new(20, 12, &mut pool);
806        overlay.render(Rect::new(0, 0, 20, 12), &mut frame);
807
808        // All three levels should render borders
809        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('+'));
810        assert_eq!(frame.buffer.get(2, 2).unwrap().content.as_char(), Some('+'));
811        assert_eq!(frame.buffer.get(4, 4).unwrap().content.as_char(), Some('+'));
812    }
813
814    #[test]
815    fn format_label_empty_widget_name() {
816        let debugger = LayoutDebugger::new();
817        let overlay = ConstraintOverlay::new(&debugger);
818        let record = LayoutRecord::new(
819            "",
820            Rect::new(0, 0, 5, 3),
821            Rect::new(0, 0, 5, 3),
822            LayoutConstraints::unconstrained(),
823        );
824        let label = overlay.format_label(&record, false, false);
825        assert!(label.contains("5x3"), "size should still appear: {label}");
826    }
827
828    #[test]
829    fn format_label_both_bounds_finite() {
830        let debugger = LayoutDebugger::new();
831        let overlay = ConstraintOverlay::new(&debugger);
832        let record = LayoutRecord::new(
833            "W",
834            Rect::new(0, 0, 8, 4),
835            Rect::new(0, 0, 8, 4),
836            LayoutConstraints::new(4, 12, 2, 8),
837        );
838        let label = overlay.format_label(&record, false, false);
839        // Should show [4..12 x 2..8]
840        assert!(label.contains("[4..12 x 2..8]"), "label={label}");
841    }
842
843    #[test]
844    fn requested_outline_not_drawn_when_same_as_received() {
845        let mut debugger = LayoutDebugger::new();
846        debugger.set_enabled(true);
847        debugger.record(LayoutRecord::new(
848            "Same",
849            Rect::new(0, 0, 6, 4),
850            Rect::new(0, 0, 6, 4),
851            LayoutConstraints::unconstrained(),
852        ));
853
854        let style = ConstraintOverlayStyle {
855            requested_color: PackedRgba::rgb(0, 0, 255),
856            ..Default::default()
857        };
858        let overlay = ConstraintOverlay::new(&debugger).style(style);
859        let mut pool = GraphemePool::new();
860        let mut frame = Frame::new(20, 10, &mut pool);
861        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
862
863        // The '.' marker should not appear since areas are identical
864        // Check a corner that would have '+' from border but not '.'
865        let cell = frame.buffer.get(5, 3).unwrap(); // bottom-right corner
866        assert_ne!(
867            cell.content.as_char(),
868            Some('.'),
869            "dot should not appear when same size"
870        );
871    }
872
873    #[test]
874    fn style_clone_and_debug() {
875        let style = ConstraintOverlayStyle::default();
876        let cloned = style.clone();
877        let _ = format!("{cloned:?}");
878        assert_eq!(cloned.normal_color, style.normal_color);
879    }
880
881    #[test]
882    fn max_width_zero_means_unconstrained_no_overflow() {
883        let debugger = LayoutDebugger::new();
884        let overlay = ConstraintOverlay::new(&debugger);
885        // max_width=0 means no max constraint
886        let record = LayoutRecord::new(
887            "W",
888            Rect::new(0, 0, 100, 4),
889            Rect::new(0, 0, 100, 4),
890            LayoutConstraints::new(0, 0, 0, 0),
891        );
892        // is_overflow check: max_width!=0 && received.width>max_width
893        // With max_width=0, first condition is false, so not overflow
894        let label = overlay.format_label(&record, false, false);
895        assert!(!label.contains('!'), "should not be overflow: {label}");
896    }
897
898    // ─── End edge-case tests (bd-3szd1) ──────────────────────────────
899
900    #[test]
901    fn no_borders_when_show_borders_disabled() {
902        let mut debugger = LayoutDebugger::new();
903        debugger.set_enabled(true);
904        debugger.record(LayoutRecord::new(
905            "NoBorder",
906            Rect::new(0, 0, 6, 4),
907            Rect::new(0, 0, 6, 4),
908            LayoutConstraints::unconstrained(),
909        ));
910
911        let style = ConstraintOverlayStyle {
912            show_borders: false,
913            show_labels: false,
914            show_size_diff: false,
915            ..Default::default()
916        };
917        let overlay = ConstraintOverlay::new(&debugger).style(style);
918        let mut pool = GraphemePool::new();
919        let mut frame = Frame::new(20, 10, &mut pool);
920        overlay.render(Rect::new(0, 0, 20, 10), &mut frame);
921
922        // No border should be drawn.
923        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
924    }
925}