Skip to main content

ftui_widgets/
align.rs

1#![forbid(unsafe_code)]
2
3//! Alignment container widget.
4//!
5//! Positions a child widget within an available area according to horizontal
6//! and/or vertical alignment rules. The child is rendered into a sub-rect
7//! computed from the parent area and the child's known or fixed dimensions.
8
9use crate::block::Alignment;
10use crate::{StatefulWidget, Widget};
11use ftui_core::geometry::Rect;
12use ftui_render::frame::Frame;
13
14/// Vertical alignment method.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum VerticalAlignment {
17    /// Align content to the top (default).
18    #[default]
19    Top,
20    /// Center content vertically.
21    Middle,
22    /// Align content to the bottom.
23    Bottom,
24}
25
26/// A widget wrapper that aligns a child within the available area.
27///
28/// By default, uses the full width/height of the parent area. When explicit
29/// `child_width` or `child_height` are set, the child is positioned according
30/// to the chosen horizontal and vertical alignment.
31///
32/// # Example
33///
34/// ```ignore
35/// use ftui_widgets::align::{Align, VerticalAlignment};
36/// use ftui_widgets::block::Alignment;
37///
38/// let centered = Align::new(my_widget)
39///     .horizontal(Alignment::Center)
40///     .vertical(VerticalAlignment::Middle)
41///     .child_width(20)
42///     .child_height(5);
43/// ```
44#[derive(Debug, Clone)]
45pub struct Align<W> {
46    inner: W,
47    horizontal: Alignment,
48    vertical: VerticalAlignment,
49    child_width: Option<u16>,
50    child_height: Option<u16>,
51}
52
53impl<W> Align<W> {
54    /// Wrap a child widget with default alignment (top-left, full area).
55    pub fn new(inner: W) -> Self {
56        Self {
57            inner,
58            horizontal: Alignment::Left,
59            vertical: VerticalAlignment::Top,
60            child_width: None,
61            child_height: None,
62        }
63    }
64
65    /// Set horizontal alignment.
66    #[must_use]
67    pub fn horizontal(mut self, alignment: Alignment) -> Self {
68        self.horizontal = alignment;
69        self
70    }
71
72    /// Set vertical alignment.
73    #[must_use]
74    pub fn vertical(mut self, alignment: VerticalAlignment) -> Self {
75        self.vertical = alignment;
76        self
77    }
78
79    /// Set the child's width. If `None`, the child uses the full parent width.
80    #[must_use]
81    pub fn child_width(mut self, width: u16) -> Self {
82        self.child_width = Some(width);
83        self
84    }
85
86    /// Set the child's height. If `None`, the child uses the full parent height.
87    #[must_use]
88    pub fn child_height(mut self, height: u16) -> Self {
89        self.child_height = Some(height);
90        self
91    }
92
93    /// Compute the aligned child rect within the parent area.
94    pub fn aligned_area(&self, area: Rect) -> Rect {
95        let w = self.child_width.unwrap_or(area.width).min(area.width);
96        let h = self.child_height.unwrap_or(area.height).min(area.height);
97
98        let x = match self.horizontal {
99            Alignment::Left => area.x,
100            Alignment::Center => area.x.saturating_add((area.width.saturating_sub(w)) / 2),
101            Alignment::Right => area.x.saturating_add(area.width.saturating_sub(w)),
102        };
103
104        let y = match self.vertical {
105            VerticalAlignment::Top => area.y,
106            VerticalAlignment::Middle => area.y.saturating_add((area.height.saturating_sub(h)) / 2),
107            VerticalAlignment::Bottom => area.y.saturating_add(area.height.saturating_sub(h)),
108        };
109
110        Rect::new(x, y, w, h)
111    }
112
113    /// Get a shared reference to the inner widget.
114    pub const fn inner(&self) -> &W {
115        &self.inner
116    }
117
118    /// Get a mutable reference to the inner widget.
119    pub fn inner_mut(&mut self) -> &mut W {
120        &mut self.inner
121    }
122
123    /// Consume and return the inner widget.
124    pub fn into_inner(self) -> W {
125        self.inner
126    }
127}
128
129impl<W: Widget> Widget for Align<W> {
130    fn render(&self, area: Rect, frame: &mut Frame) {
131        if area.is_empty() {
132            return;
133        }
134
135        // Align owns the full wrapper rect. Clear stale child glyphs before
136        // rendering the current aligned child while preserving any existing
137        // parent-applied styling already present in the buffer.
138        for y in area.y..area.bottom() {
139            for x in area.x..area.right() {
140                if let Some(cell) = frame.buffer.get_mut(x, y) {
141                    cell.content = ftui_render::cell::CellContent::EMPTY;
142                }
143            }
144        }
145
146        let child_area = self.aligned_area(area);
147        if child_area.is_empty() {
148            return;
149        }
150
151        self.inner.render(child_area, frame);
152    }
153
154    fn is_essential(&self) -> bool {
155        self.inner.is_essential()
156    }
157}
158
159impl<W: StatefulWidget> StatefulWidget for Align<W> {
160    type State = W::State;
161
162    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
163        if area.is_empty() {
164            return;
165        }
166
167        // Align owns the full wrapper rect for stateful children too.
168        for y in area.y..area.bottom() {
169            for x in area.x..area.right() {
170                if let Some(cell) = frame.buffer.get_mut(x, y) {
171                    cell.content = ftui_render::cell::CellContent::EMPTY;
172                }
173            }
174        }
175
176        let child_area = self.aligned_area(area);
177        if child_area.is_empty() {
178            return;
179        }
180
181        self.inner.render(child_area, frame, state);
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use ftui_render::cell::Cell;
189    use ftui_render::grapheme_pool::GraphemePool;
190
191    fn buf_to_lines(buf: &ftui_render::buffer::Buffer) -> Vec<String> {
192        let mut lines = Vec::new();
193        for y in 0..buf.height() {
194            let mut row = String::with_capacity(buf.width() as usize);
195            for x in 0..buf.width() {
196                let ch = buf
197                    .get(x, y)
198                    .and_then(|c| c.content.as_char())
199                    .unwrap_or(' ');
200                row.push(ch);
201            }
202            lines.push(row);
203        }
204        lines
205    }
206
207    /// A small test widget that fills its area with a character.
208    #[derive(Debug, Clone, Copy)]
209    struct Fill(char);
210
211    impl Widget for Fill {
212        fn render(&self, area: Rect, frame: &mut Frame) {
213            for y in area.y..area.bottom() {
214                for x in area.x..area.right() {
215                    frame.buffer.set(x, y, Cell::from_char(self.0));
216                }
217            }
218        }
219    }
220
221    #[test]
222    fn default_alignment_uses_full_area() {
223        let align = Align::new(Fill('X'));
224        let area = Rect::new(0, 0, 5, 3);
225        let mut pool = GraphemePool::new();
226        let mut frame = Frame::new(5, 3, &mut pool);
227        align.render(area, &mut frame);
228
229        for line in buf_to_lines(&frame.buffer) {
230            assert_eq!(line, "XXXXX");
231        }
232    }
233
234    #[test]
235    fn center_horizontal() {
236        let align = Align::new(Fill('X'))
237            .horizontal(Alignment::Center)
238            .child_width(3);
239        let area = Rect::new(0, 0, 7, 1);
240        let mut pool = GraphemePool::new();
241        let mut frame = Frame::new(7, 1, &mut pool);
242        align.render(area, &mut frame);
243
244        assert_eq!(buf_to_lines(&frame.buffer), vec!["  XXX  "]);
245    }
246
247    #[test]
248    fn right_horizontal() {
249        let align = Align::new(Fill('X'))
250            .horizontal(Alignment::Right)
251            .child_width(3);
252        let area = Rect::new(0, 0, 7, 1);
253        let mut pool = GraphemePool::new();
254        let mut frame = Frame::new(7, 1, &mut pool);
255        align.render(area, &mut frame);
256
257        assert_eq!(buf_to_lines(&frame.buffer), vec!["    XXX"]);
258    }
259
260    #[test]
261    fn left_horizontal() {
262        let align = Align::new(Fill('X'))
263            .horizontal(Alignment::Left)
264            .child_width(3);
265        let area = Rect::new(0, 0, 7, 1);
266        let mut pool = GraphemePool::new();
267        let mut frame = Frame::new(7, 1, &mut pool);
268        align.render(area, &mut frame);
269
270        assert_eq!(buf_to_lines(&frame.buffer), vec!["XXX    "]);
271    }
272
273    #[test]
274    fn center_vertical() {
275        let align = Align::new(Fill('X'))
276            .vertical(VerticalAlignment::Middle)
277            .child_height(1);
278        let area = Rect::new(0, 0, 3, 5);
279        let mut pool = GraphemePool::new();
280        let mut frame = Frame::new(3, 5, &mut pool);
281        align.render(area, &mut frame);
282
283        assert_eq!(
284            buf_to_lines(&frame.buffer),
285            vec!["   ", "   ", "XXX", "   ", "   "]
286        );
287    }
288
289    #[test]
290    fn bottom_vertical() {
291        let align = Align::new(Fill('X'))
292            .vertical(VerticalAlignment::Bottom)
293            .child_height(2);
294        let area = Rect::new(0, 0, 3, 4);
295        let mut pool = GraphemePool::new();
296        let mut frame = Frame::new(3, 4, &mut pool);
297        align.render(area, &mut frame);
298
299        assert_eq!(
300            buf_to_lines(&frame.buffer),
301            vec!["   ", "   ", "XXX", "XXX"]
302        );
303    }
304
305    #[test]
306    fn center_both_axes() {
307        let align = Align::new(Fill('O'))
308            .horizontal(Alignment::Center)
309            .vertical(VerticalAlignment::Middle)
310            .child_width(1)
311            .child_height(1);
312        let area = Rect::new(0, 0, 5, 5);
313        let mut pool = GraphemePool::new();
314        let mut frame = Frame::new(5, 5, &mut pool);
315        align.render(area, &mut frame);
316
317        assert_eq!(
318            buf_to_lines(&frame.buffer),
319            vec!["     ", "     ", "  O  ", "     ", "     "]
320        );
321    }
322
323    #[test]
324    fn child_larger_than_area_is_clamped() {
325        let align = Align::new(Fill('X'))
326            .horizontal(Alignment::Center)
327            .child_width(20)
328            .child_height(10);
329        let area = Rect::new(0, 0, 5, 3);
330
331        let child_area = align.aligned_area(area);
332        assert_eq!(child_area.width, 5);
333        assert_eq!(child_area.height, 3);
334    }
335
336    #[test]
337    fn zero_size_area_is_noop() {
338        let align = Align::new(Fill('X'))
339            .horizontal(Alignment::Center)
340            .child_width(3);
341        let area = Rect::new(0, 0, 0, 0);
342        let mut pool = GraphemePool::new();
343        let mut frame = Frame::new(5, 5, &mut pool);
344        align.render(area, &mut frame);
345
346        // Nothing should have been drawn
347        for y in 0..5 {
348            for x in 0..5u16 {
349                assert!(frame.buffer.get(x, y).unwrap().is_empty());
350            }
351        }
352    }
353
354    #[test]
355    fn zero_child_size_is_noop() {
356        let align = Align::new(Fill('X')).child_width(0).child_height(0);
357        let area = Rect::new(0, 0, 5, 5);
358        let mut pool = GraphemePool::new();
359        let mut frame = Frame::new(5, 5, &mut pool);
360        align.render(area, &mut frame);
361
362        for y in 0..5 {
363            for x in 0..5u16 {
364                assert!(frame.buffer.get(x, y).unwrap().is_empty());
365            }
366        }
367    }
368
369    #[test]
370    fn smaller_second_render_clears_old_child_region() {
371        let mut pool = GraphemePool::new();
372        let mut frame = Frame::new(5, 1, &mut pool);
373        let area = Rect::new(0, 0, 5, 1);
374
375        Align::new(Fill('X')).render(area, &mut frame);
376        Align::new(Fill('O'))
377            .horizontal(Alignment::Center)
378            .child_width(1)
379            .render(area, &mut frame);
380
381        assert_eq!(buf_to_lines(&frame.buffer), vec!["  O  "]);
382    }
383
384    #[test]
385    fn zero_size_child_clears_previous_content() {
386        let mut pool = GraphemePool::new();
387        let mut frame = Frame::new(5, 1, &mut pool);
388        let area = Rect::new(0, 0, 5, 1);
389
390        Align::new(Fill('X')).render(area, &mut frame);
391        Align::new(Fill('O'))
392            .child_width(0)
393            .child_height(0)
394            .render(area, &mut frame);
395
396        for x in 0..5u16 {
397            assert!(frame.buffer.get(x, 0).unwrap().is_empty());
398        }
399    }
400
401    #[test]
402    fn area_with_offset() {
403        let align = Align::new(Fill('X'))
404            .horizontal(Alignment::Center)
405            .child_width(2);
406        let area = Rect::new(10, 5, 6, 1);
407
408        let child = align.aligned_area(area);
409        assert_eq!(child.x, 12);
410        assert_eq!(child.y, 5);
411        assert_eq!(child.width, 2);
412    }
413
414    #[test]
415    fn aligned_area_right_bottom() {
416        let align = Align::new(Fill('X'))
417            .horizontal(Alignment::Right)
418            .vertical(VerticalAlignment::Bottom)
419            .child_width(2)
420            .child_height(1);
421        let area = Rect::new(0, 0, 10, 5);
422
423        let child = align.aligned_area(area);
424        assert_eq!(child.x, 8);
425        assert_eq!(child.y, 4);
426        assert_eq!(child.width, 2);
427        assert_eq!(child.height, 1);
428    }
429
430    #[test]
431    fn vertical_alignment_default_is_top() {
432        assert_eq!(VerticalAlignment::default(), VerticalAlignment::Top);
433    }
434
435    #[test]
436    fn inner_accessors() {
437        let mut align = Align::new(Fill('A'));
438        assert_eq!(align.inner().0, 'A');
439        align.inner_mut().0 = 'B';
440        assert_eq!(align.inner().0, 'B');
441        let inner = align.into_inner();
442        assert_eq!(inner.0, 'B');
443    }
444
445    #[test]
446    fn stateful_widget_render() {
447        use std::cell::RefCell;
448        use std::rc::Rc;
449
450        #[derive(Debug, Clone)]
451        struct StatefulFill {
452            ch: char,
453        }
454
455        impl StatefulWidget for StatefulFill {
456            type State = Rc<RefCell<Rect>>;
457
458            fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
459                *state.borrow_mut() = area;
460                for y in area.y..area.bottom() {
461                    for x in area.x..area.right() {
462                        frame.buffer.set(x, y, Cell::from_char(self.ch));
463                    }
464                }
465            }
466        }
467
468        let align = Align::new(StatefulFill { ch: 'S' })
469            .horizontal(Alignment::Center)
470            .child_width(2)
471            .child_height(1);
472        let area = Rect::new(0, 0, 6, 3);
473        let mut pool = GraphemePool::new();
474        let mut frame = Frame::new(6, 3, &mut pool);
475        let mut state = Rc::new(RefCell::new(Rect::default()));
476        StatefulWidget::render(&align, area, &mut frame, &mut state);
477
478        let rendered_area = *state.borrow();
479        assert_eq!(rendered_area.x, 2);
480        assert_eq!(rendered_area.width, 2);
481    }
482
483    #[test]
484    fn stateful_smaller_second_render_clears_old_child_region() {
485        #[derive(Debug, Clone, Copy)]
486        struct StatefulFill(char);
487
488        impl StatefulWidget for StatefulFill {
489            type State = ();
490
491            fn render(&self, area: Rect, frame: &mut Frame, _state: &mut Self::State) {
492                for y in area.y..area.bottom() {
493                    for x in area.x..area.right() {
494                        frame.buffer.set(x, y, Cell::from_char(self.0));
495                    }
496                }
497            }
498        }
499
500        let mut pool = GraphemePool::new();
501        let mut frame = Frame::new(5, 1, &mut pool);
502        let area = Rect::new(0, 0, 5, 1);
503        let mut state = ();
504
505        StatefulWidget::render(&Align::new(StatefulFill('X')), area, &mut frame, &mut state);
506        StatefulWidget::render(
507            &Align::new(StatefulFill('O'))
508                .horizontal(Alignment::Center)
509                .child_width(1),
510            area,
511            &mut frame,
512            &mut state,
513        );
514
515        assert_eq!(buf_to_lines(&frame.buffer), vec!["  O  "]);
516    }
517
518    // ─── Edge-case tests (bd-2gp78) ────────────────────────────────────
519
520    #[test]
521    fn center_odd_remainder_floors_left() {
522        // width=6, child_width=3 → offset = (6-3)/2 = 1
523        let align = Align::new(Fill('X'))
524            .horizontal(Alignment::Center)
525            .child_width(3);
526        let area = Rect::new(0, 0, 6, 1);
527        let child = align.aligned_area(area);
528        assert_eq!(child.x, 1);
529        assert_eq!(child.width, 3);
530    }
531
532    #[test]
533    fn center_vertical_odd_remainder_floors_top() {
534        // height=6, child_height=3 → offset = (6-3)/2 = 1
535        let align = Align::new(Fill('X'))
536            .vertical(VerticalAlignment::Middle)
537            .child_height(3);
538        let area = Rect::new(0, 0, 1, 6);
539        let child = align.aligned_area(area);
540        assert_eq!(child.y, 1);
541        assert_eq!(child.height, 3);
542    }
543
544    #[test]
545    fn child_width_only_height_fills() {
546        let align = Align::new(Fill('X'))
547            .horizontal(Alignment::Center)
548            .child_width(2);
549        let area = Rect::new(0, 0, 8, 5);
550        let child = align.aligned_area(area);
551        assert_eq!(child.width, 2);
552        assert_eq!(child.height, 5, "height should be full parent height");
553    }
554
555    #[test]
556    fn child_height_only_width_fills() {
557        let align = Align::new(Fill('X'))
558            .vertical(VerticalAlignment::Bottom)
559            .child_height(2);
560        let area = Rect::new(0, 0, 8, 5);
561        let child = align.aligned_area(area);
562        assert_eq!(child.width, 8, "width should be full parent width");
563        assert_eq!(child.height, 2);
564        assert_eq!(child.y, 3);
565    }
566
567    #[test]
568    fn right_alignment_exact_fit() {
569        // child_width == area.width → x stays at area.x
570        let align = Align::new(Fill('X'))
571            .horizontal(Alignment::Right)
572            .child_width(10);
573        let area = Rect::new(5, 0, 10, 1);
574        let child = align.aligned_area(area);
575        assert_eq!(child.x, 5, "exact fit should not shift");
576        assert_eq!(child.width, 10);
577    }
578
579    #[test]
580    fn bottom_alignment_exact_fit() {
581        let align = Align::new(Fill('X'))
582            .vertical(VerticalAlignment::Bottom)
583            .child_height(5);
584        let area = Rect::new(0, 10, 1, 5);
585        let child = align.aligned_area(area);
586        assert_eq!(child.y, 10, "exact fit should not shift");
587    }
588
589    #[test]
590    fn center_1x1_in_large_area() {
591        let align = Align::new(Fill('O'))
592            .horizontal(Alignment::Center)
593            .vertical(VerticalAlignment::Middle)
594            .child_width(1)
595            .child_height(1);
596        let area = Rect::new(0, 0, 100, 100);
597        let child = align.aligned_area(area);
598        assert_eq!(child.x, 49); // (100-1)/2
599        assert_eq!(child.y, 49);
600        assert_eq!(child.width, 1);
601        assert_eq!(child.height, 1);
602    }
603
604    #[test]
605    fn vertical_alignment_copy_and_eq() {
606        let a = VerticalAlignment::Middle;
607        let b = a; // Copy
608        assert_eq!(a, b);
609        assert_ne!(a, VerticalAlignment::Top);
610        assert_ne!(a, VerticalAlignment::Bottom);
611    }
612
613    #[test]
614    fn align_clone_preserves_settings() {
615        let align = Align::new(Fill('X'))
616            .horizontal(Alignment::Right)
617            .vertical(VerticalAlignment::Bottom)
618            .child_width(5)
619            .child_height(3);
620        let cloned = align.clone();
621        let area = Rect::new(0, 0, 20, 20);
622        assert_eq!(align.aligned_area(area), cloned.aligned_area(area));
623    }
624
625    #[test]
626    fn debug_format() {
627        let align = Align::new(Fill('X'))
628            .horizontal(Alignment::Center)
629            .vertical(VerticalAlignment::Middle);
630        let dbg = format!("{align:?}");
631        assert!(dbg.contains("Align"));
632        assert!(dbg.contains("Center"));
633        assert!(dbg.contains("Middle"));
634    }
635
636    #[test]
637    fn stateful_zero_area_is_noop() {
638        use std::cell::RefCell;
639        use std::rc::Rc;
640
641        #[derive(Debug, Clone)]
642        struct StatefulFill;
643        impl StatefulWidget for StatefulFill {
644            type State = Rc<RefCell<bool>>;
645            fn render(&self, _: Rect, _: &mut Frame, state: &mut Self::State) {
646                *state.borrow_mut() = true;
647            }
648        }
649
650        let align = Align::new(StatefulFill)
651            .horizontal(Alignment::Center)
652            .child_width(3);
653        let mut pool = GraphemePool::new();
654        let mut frame = Frame::new(10, 10, &mut pool);
655        let mut rendered = Rc::new(RefCell::new(false));
656        StatefulWidget::render(&align, Rect::new(0, 0, 0, 0), &mut frame, &mut rendered);
657        assert!(!*rendered.borrow(), "should not render in zero area");
658    }
659
660    #[test]
661    fn stateful_zero_child_is_noop() {
662        use std::cell::RefCell;
663        use std::rc::Rc;
664
665        #[derive(Debug, Clone)]
666        struct StatefulFill;
667        impl StatefulWidget for StatefulFill {
668            type State = Rc<RefCell<bool>>;
669            fn render(&self, _: Rect, _: &mut Frame, state: &mut Self::State) {
670                *state.borrow_mut() = true;
671            }
672        }
673
674        let align = Align::new(StatefulFill).child_width(0).child_height(0);
675        let mut pool = GraphemePool::new();
676        let mut frame = Frame::new(10, 10, &mut pool);
677        let mut rendered = Rc::new(RefCell::new(false));
678        StatefulWidget::render(&align, Rect::new(0, 0, 10, 10), &mut frame, &mut rendered);
679        assert!(!*rendered.borrow(), "should not render zero-size child");
680    }
681
682    // ─── End edge-case tests (bd-2gp78) ──────────────────────────────
683
684    #[test]
685    fn is_essential_delegates() {
686        struct Essential;
687        impl Widget for Essential {
688            fn render(&self, _: Rect, _: &mut Frame) {}
689            fn is_essential(&self) -> bool {
690                true
691            }
692        }
693
694        assert!(Align::new(Essential).is_essential());
695        assert!(!Align::new(Fill('X')).is_essential());
696    }
697}