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        let child_area = self.aligned_area(area);
136        if child_area.is_empty() {
137            return;
138        }
139
140        self.inner.render(child_area, frame);
141    }
142
143    fn is_essential(&self) -> bool {
144        self.inner.is_essential()
145    }
146}
147
148impl<W: StatefulWidget> StatefulWidget for Align<W> {
149    type State = W::State;
150
151    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
152        if area.is_empty() {
153            return;
154        }
155
156        let child_area = self.aligned_area(area);
157        if child_area.is_empty() {
158            return;
159        }
160
161        self.inner.render(child_area, frame, state);
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use ftui_render::cell::Cell;
169    use ftui_render::grapheme_pool::GraphemePool;
170
171    fn buf_to_lines(buf: &ftui_render::buffer::Buffer) -> Vec<String> {
172        let mut lines = Vec::new();
173        for y in 0..buf.height() {
174            let mut row = String::with_capacity(buf.width() as usize);
175            for x in 0..buf.width() {
176                let ch = buf
177                    .get(x, y)
178                    .and_then(|c| c.content.as_char())
179                    .unwrap_or(' ');
180                row.push(ch);
181            }
182            lines.push(row);
183        }
184        lines
185    }
186
187    /// A small test widget that fills its area with a character.
188    #[derive(Debug, Clone, Copy)]
189    struct Fill(char);
190
191    impl Widget for Fill {
192        fn render(&self, area: Rect, frame: &mut Frame) {
193            for y in area.y..area.bottom() {
194                for x in area.x..area.right() {
195                    frame.buffer.set(x, y, Cell::from_char(self.0));
196                }
197            }
198        }
199    }
200
201    #[test]
202    fn default_alignment_uses_full_area() {
203        let align = Align::new(Fill('X'));
204        let area = Rect::new(0, 0, 5, 3);
205        let mut pool = GraphemePool::new();
206        let mut frame = Frame::new(5, 3, &mut pool);
207        align.render(area, &mut frame);
208
209        for line in buf_to_lines(&frame.buffer) {
210            assert_eq!(line, "XXXXX");
211        }
212    }
213
214    #[test]
215    fn center_horizontal() {
216        let align = Align::new(Fill('X'))
217            .horizontal(Alignment::Center)
218            .child_width(3);
219        let area = Rect::new(0, 0, 7, 1);
220        let mut pool = GraphemePool::new();
221        let mut frame = Frame::new(7, 1, &mut pool);
222        align.render(area, &mut frame);
223
224        assert_eq!(buf_to_lines(&frame.buffer), vec!["  XXX  "]);
225    }
226
227    #[test]
228    fn right_horizontal() {
229        let align = Align::new(Fill('X'))
230            .horizontal(Alignment::Right)
231            .child_width(3);
232        let area = Rect::new(0, 0, 7, 1);
233        let mut pool = GraphemePool::new();
234        let mut frame = Frame::new(7, 1, &mut pool);
235        align.render(area, &mut frame);
236
237        assert_eq!(buf_to_lines(&frame.buffer), vec!["    XXX"]);
238    }
239
240    #[test]
241    fn left_horizontal() {
242        let align = Align::new(Fill('X'))
243            .horizontal(Alignment::Left)
244            .child_width(3);
245        let area = Rect::new(0, 0, 7, 1);
246        let mut pool = GraphemePool::new();
247        let mut frame = Frame::new(7, 1, &mut pool);
248        align.render(area, &mut frame);
249
250        assert_eq!(buf_to_lines(&frame.buffer), vec!["XXX    "]);
251    }
252
253    #[test]
254    fn center_vertical() {
255        let align = Align::new(Fill('X'))
256            .vertical(VerticalAlignment::Middle)
257            .child_height(1);
258        let area = Rect::new(0, 0, 3, 5);
259        let mut pool = GraphemePool::new();
260        let mut frame = Frame::new(3, 5, &mut pool);
261        align.render(area, &mut frame);
262
263        assert_eq!(
264            buf_to_lines(&frame.buffer),
265            vec!["   ", "   ", "XXX", "   ", "   "]
266        );
267    }
268
269    #[test]
270    fn bottom_vertical() {
271        let align = Align::new(Fill('X'))
272            .vertical(VerticalAlignment::Bottom)
273            .child_height(2);
274        let area = Rect::new(0, 0, 3, 4);
275        let mut pool = GraphemePool::new();
276        let mut frame = Frame::new(3, 4, &mut pool);
277        align.render(area, &mut frame);
278
279        assert_eq!(
280            buf_to_lines(&frame.buffer),
281            vec!["   ", "   ", "XXX", "XXX"]
282        );
283    }
284
285    #[test]
286    fn center_both_axes() {
287        let align = Align::new(Fill('O'))
288            .horizontal(Alignment::Center)
289            .vertical(VerticalAlignment::Middle)
290            .child_width(1)
291            .child_height(1);
292        let area = Rect::new(0, 0, 5, 5);
293        let mut pool = GraphemePool::new();
294        let mut frame = Frame::new(5, 5, &mut pool);
295        align.render(area, &mut frame);
296
297        assert_eq!(
298            buf_to_lines(&frame.buffer),
299            vec!["     ", "     ", "  O  ", "     ", "     "]
300        );
301    }
302
303    #[test]
304    fn child_larger_than_area_is_clamped() {
305        let align = Align::new(Fill('X'))
306            .horizontal(Alignment::Center)
307            .child_width(20)
308            .child_height(10);
309        let area = Rect::new(0, 0, 5, 3);
310
311        let child_area = align.aligned_area(area);
312        assert_eq!(child_area.width, 5);
313        assert_eq!(child_area.height, 3);
314    }
315
316    #[test]
317    fn zero_size_area_is_noop() {
318        let align = Align::new(Fill('X'))
319            .horizontal(Alignment::Center)
320            .child_width(3);
321        let area = Rect::new(0, 0, 0, 0);
322        let mut pool = GraphemePool::new();
323        let mut frame = Frame::new(5, 5, &mut pool);
324        align.render(area, &mut frame);
325
326        // Nothing should have been drawn
327        for y in 0..5 {
328            for x in 0..5u16 {
329                assert!(frame.buffer.get(x, y).unwrap().is_empty());
330            }
331        }
332    }
333
334    #[test]
335    fn zero_child_size_is_noop() {
336        let align = Align::new(Fill('X')).child_width(0).child_height(0);
337        let area = Rect::new(0, 0, 5, 5);
338        let mut pool = GraphemePool::new();
339        let mut frame = Frame::new(5, 5, &mut pool);
340        align.render(area, &mut frame);
341
342        for y in 0..5 {
343            for x in 0..5u16 {
344                assert!(frame.buffer.get(x, y).unwrap().is_empty());
345            }
346        }
347    }
348
349    #[test]
350    fn area_with_offset() {
351        let align = Align::new(Fill('X'))
352            .horizontal(Alignment::Center)
353            .child_width(2);
354        let area = Rect::new(10, 5, 6, 1);
355
356        let child = align.aligned_area(area);
357        assert_eq!(child.x, 12);
358        assert_eq!(child.y, 5);
359        assert_eq!(child.width, 2);
360    }
361
362    #[test]
363    fn aligned_area_right_bottom() {
364        let align = Align::new(Fill('X'))
365            .horizontal(Alignment::Right)
366            .vertical(VerticalAlignment::Bottom)
367            .child_width(2)
368            .child_height(1);
369        let area = Rect::new(0, 0, 10, 5);
370
371        let child = align.aligned_area(area);
372        assert_eq!(child.x, 8);
373        assert_eq!(child.y, 4);
374        assert_eq!(child.width, 2);
375        assert_eq!(child.height, 1);
376    }
377
378    #[test]
379    fn vertical_alignment_default_is_top() {
380        assert_eq!(VerticalAlignment::default(), VerticalAlignment::Top);
381    }
382
383    #[test]
384    fn inner_accessors() {
385        let mut align = Align::new(Fill('A'));
386        assert_eq!(align.inner().0, 'A');
387        align.inner_mut().0 = 'B';
388        assert_eq!(align.inner().0, 'B');
389        let inner = align.into_inner();
390        assert_eq!(inner.0, 'B');
391    }
392
393    #[test]
394    fn stateful_widget_render() {
395        use std::cell::RefCell;
396        use std::rc::Rc;
397
398        #[derive(Debug, Clone)]
399        struct StatefulFill {
400            ch: char,
401        }
402
403        impl StatefulWidget for StatefulFill {
404            type State = Rc<RefCell<Rect>>;
405
406            fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
407                *state.borrow_mut() = area;
408                for y in area.y..area.bottom() {
409                    for x in area.x..area.right() {
410                        frame.buffer.set(x, y, Cell::from_char(self.ch));
411                    }
412                }
413            }
414        }
415
416        let align = Align::new(StatefulFill { ch: 'S' })
417            .horizontal(Alignment::Center)
418            .child_width(2)
419            .child_height(1);
420        let area = Rect::new(0, 0, 6, 3);
421        let mut pool = GraphemePool::new();
422        let mut frame = Frame::new(6, 3, &mut pool);
423        let mut state = Rc::new(RefCell::new(Rect::default()));
424        StatefulWidget::render(&align, area, &mut frame, &mut state);
425
426        let rendered_area = *state.borrow();
427        assert_eq!(rendered_area.x, 2);
428        assert_eq!(rendered_area.width, 2);
429    }
430
431    // ─── Edge-case tests (bd-2gp78) ────────────────────────────────────
432
433    #[test]
434    fn center_odd_remainder_floors_left() {
435        // width=6, child_width=3 → offset = (6-3)/2 = 1
436        let align = Align::new(Fill('X'))
437            .horizontal(Alignment::Center)
438            .child_width(3);
439        let area = Rect::new(0, 0, 6, 1);
440        let child = align.aligned_area(area);
441        assert_eq!(child.x, 1);
442        assert_eq!(child.width, 3);
443    }
444
445    #[test]
446    fn center_vertical_odd_remainder_floors_top() {
447        // height=6, child_height=3 → offset = (6-3)/2 = 1
448        let align = Align::new(Fill('X'))
449            .vertical(VerticalAlignment::Middle)
450            .child_height(3);
451        let area = Rect::new(0, 0, 1, 6);
452        let child = align.aligned_area(area);
453        assert_eq!(child.y, 1);
454        assert_eq!(child.height, 3);
455    }
456
457    #[test]
458    fn child_width_only_height_fills() {
459        let align = Align::new(Fill('X'))
460            .horizontal(Alignment::Center)
461            .child_width(2);
462        let area = Rect::new(0, 0, 8, 5);
463        let child = align.aligned_area(area);
464        assert_eq!(child.width, 2);
465        assert_eq!(child.height, 5, "height should be full parent height");
466    }
467
468    #[test]
469    fn child_height_only_width_fills() {
470        let align = Align::new(Fill('X'))
471            .vertical(VerticalAlignment::Bottom)
472            .child_height(2);
473        let area = Rect::new(0, 0, 8, 5);
474        let child = align.aligned_area(area);
475        assert_eq!(child.width, 8, "width should be full parent width");
476        assert_eq!(child.height, 2);
477        assert_eq!(child.y, 3);
478    }
479
480    #[test]
481    fn right_alignment_exact_fit() {
482        // child_width == area.width → x stays at area.x
483        let align = Align::new(Fill('X'))
484            .horizontal(Alignment::Right)
485            .child_width(10);
486        let area = Rect::new(5, 0, 10, 1);
487        let child = align.aligned_area(area);
488        assert_eq!(child.x, 5, "exact fit should not shift");
489        assert_eq!(child.width, 10);
490    }
491
492    #[test]
493    fn bottom_alignment_exact_fit() {
494        let align = Align::new(Fill('X'))
495            .vertical(VerticalAlignment::Bottom)
496            .child_height(5);
497        let area = Rect::new(0, 10, 1, 5);
498        let child = align.aligned_area(area);
499        assert_eq!(child.y, 10, "exact fit should not shift");
500    }
501
502    #[test]
503    fn center_1x1_in_large_area() {
504        let align = Align::new(Fill('O'))
505            .horizontal(Alignment::Center)
506            .vertical(VerticalAlignment::Middle)
507            .child_width(1)
508            .child_height(1);
509        let area = Rect::new(0, 0, 100, 100);
510        let child = align.aligned_area(area);
511        assert_eq!(child.x, 49); // (100-1)/2
512        assert_eq!(child.y, 49);
513        assert_eq!(child.width, 1);
514        assert_eq!(child.height, 1);
515    }
516
517    #[test]
518    fn vertical_alignment_copy_and_eq() {
519        let a = VerticalAlignment::Middle;
520        let b = a; // Copy
521        assert_eq!(a, b);
522        assert_ne!(a, VerticalAlignment::Top);
523        assert_ne!(a, VerticalAlignment::Bottom);
524    }
525
526    #[test]
527    fn align_clone_preserves_settings() {
528        let align = Align::new(Fill('X'))
529            .horizontal(Alignment::Right)
530            .vertical(VerticalAlignment::Bottom)
531            .child_width(5)
532            .child_height(3);
533        let cloned = align.clone();
534        let area = Rect::new(0, 0, 20, 20);
535        assert_eq!(align.aligned_area(area), cloned.aligned_area(area));
536    }
537
538    #[test]
539    fn debug_format() {
540        let align = Align::new(Fill('X'))
541            .horizontal(Alignment::Center)
542            .vertical(VerticalAlignment::Middle);
543        let dbg = format!("{align:?}");
544        assert!(dbg.contains("Align"));
545        assert!(dbg.contains("Center"));
546        assert!(dbg.contains("Middle"));
547    }
548
549    #[test]
550    fn stateful_zero_area_is_noop() {
551        use std::cell::RefCell;
552        use std::rc::Rc;
553
554        #[derive(Debug, Clone)]
555        struct StatefulFill;
556        impl StatefulWidget for StatefulFill {
557            type State = Rc<RefCell<bool>>;
558            fn render(&self, _: Rect, _: &mut Frame, state: &mut Self::State) {
559                *state.borrow_mut() = true;
560            }
561        }
562
563        let align = Align::new(StatefulFill)
564            .horizontal(Alignment::Center)
565            .child_width(3);
566        let mut pool = GraphemePool::new();
567        let mut frame = Frame::new(10, 10, &mut pool);
568        let mut rendered = Rc::new(RefCell::new(false));
569        StatefulWidget::render(&align, Rect::new(0, 0, 0, 0), &mut frame, &mut rendered);
570        assert!(!*rendered.borrow(), "should not render in zero area");
571    }
572
573    #[test]
574    fn stateful_zero_child_is_noop() {
575        use std::cell::RefCell;
576        use std::rc::Rc;
577
578        #[derive(Debug, Clone)]
579        struct StatefulFill;
580        impl StatefulWidget for StatefulFill {
581            type State = Rc<RefCell<bool>>;
582            fn render(&self, _: Rect, _: &mut Frame, state: &mut Self::State) {
583                *state.borrow_mut() = true;
584            }
585        }
586
587        let align = Align::new(StatefulFill).child_width(0).child_height(0);
588        let mut pool = GraphemePool::new();
589        let mut frame = Frame::new(10, 10, &mut pool);
590        let mut rendered = Rc::new(RefCell::new(false));
591        StatefulWidget::render(&align, Rect::new(0, 0, 10, 10), &mut frame, &mut rendered);
592        assert!(!*rendered.borrow(), "should not render zero-size child");
593    }
594
595    // ─── End edge-case tests (bd-2gp78) ──────────────────────────────
596
597    #[test]
598    fn is_essential_delegates() {
599        struct Essential;
600        impl Widget for Essential {
601            fn render(&self, _: Rect, _: &mut Frame) {}
602            fn is_essential(&self) -> bool {
603                true
604            }
605        }
606
607        assert!(Align::new(Essential).is_essential());
608        assert!(!Align::new(Fill('X')).is_essential());
609    }
610}