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    pub fn horizontal(mut self, alignment: Alignment) -> Self {
67        self.horizontal = alignment;
68        self
69    }
70
71    /// Set vertical alignment.
72    pub fn vertical(mut self, alignment: VerticalAlignment) -> Self {
73        self.vertical = alignment;
74        self
75    }
76
77    /// Set the child's width. If `None`, the child uses the full parent width.
78    pub fn child_width(mut self, width: u16) -> Self {
79        self.child_width = Some(width);
80        self
81    }
82
83    /// Set the child's height. If `None`, the child uses the full parent height.
84    pub fn child_height(mut self, height: u16) -> Self {
85        self.child_height = Some(height);
86        self
87    }
88
89    /// Compute the aligned child rect within the parent area.
90    pub fn aligned_area(&self, area: Rect) -> Rect {
91        let w = self.child_width.unwrap_or(area.width).min(area.width);
92        let h = self.child_height.unwrap_or(area.height).min(area.height);
93
94        let x = match self.horizontal {
95            Alignment::Left => area.x,
96            Alignment::Center => area.x.saturating_add((area.width.saturating_sub(w)) / 2),
97            Alignment::Right => area.x.saturating_add(area.width.saturating_sub(w)),
98        };
99
100        let y = match self.vertical {
101            VerticalAlignment::Top => area.y,
102            VerticalAlignment::Middle => area.y.saturating_add((area.height.saturating_sub(h)) / 2),
103            VerticalAlignment::Bottom => area.y.saturating_add(area.height.saturating_sub(h)),
104        };
105
106        Rect::new(x, y, w, h)
107    }
108
109    /// Get a shared reference to the inner widget.
110    pub const fn inner(&self) -> &W {
111        &self.inner
112    }
113
114    /// Get a mutable reference to the inner widget.
115    pub fn inner_mut(&mut self) -> &mut W {
116        &mut self.inner
117    }
118
119    /// Consume and return the inner widget.
120    pub fn into_inner(self) -> W {
121        self.inner
122    }
123}
124
125impl<W: Widget> Widget for Align<W> {
126    fn render(&self, area: Rect, frame: &mut Frame) {
127        if area.is_empty() {
128            return;
129        }
130
131        let child_area = self.aligned_area(area);
132        if child_area.is_empty() {
133            return;
134        }
135
136        self.inner.render(child_area, frame);
137    }
138
139    fn is_essential(&self) -> bool {
140        self.inner.is_essential()
141    }
142}
143
144impl<W: StatefulWidget> StatefulWidget for Align<W> {
145    type State = W::State;
146
147    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
148        if area.is_empty() {
149            return;
150        }
151
152        let child_area = self.aligned_area(area);
153        if child_area.is_empty() {
154            return;
155        }
156
157        self.inner.render(child_area, frame, state);
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use ftui_render::cell::Cell;
165    use ftui_render::grapheme_pool::GraphemePool;
166
167    fn buf_to_lines(buf: &ftui_render::buffer::Buffer) -> Vec<String> {
168        let mut lines = Vec::new();
169        for y in 0..buf.height() {
170            let mut row = String::with_capacity(buf.width() as usize);
171            for x in 0..buf.width() {
172                let ch = buf
173                    .get(x, y)
174                    .and_then(|c| c.content.as_char())
175                    .unwrap_or(' ');
176                row.push(ch);
177            }
178            lines.push(row);
179        }
180        lines
181    }
182
183    /// A small test widget that fills its area with a character.
184    #[derive(Debug, Clone, Copy)]
185    struct Fill(char);
186
187    impl Widget for Fill {
188        fn render(&self, area: Rect, frame: &mut Frame) {
189            for y in area.y..area.bottom() {
190                for x in area.x..area.right() {
191                    frame.buffer.set(x, y, Cell::from_char(self.0));
192                }
193            }
194        }
195    }
196
197    #[test]
198    fn default_alignment_uses_full_area() {
199        let align = Align::new(Fill('X'));
200        let area = Rect::new(0, 0, 5, 3);
201        let mut pool = GraphemePool::new();
202        let mut frame = Frame::new(5, 3, &mut pool);
203        align.render(area, &mut frame);
204
205        for line in buf_to_lines(&frame.buffer) {
206            assert_eq!(line, "XXXXX");
207        }
208    }
209
210    #[test]
211    fn center_horizontal() {
212        let align = Align::new(Fill('X'))
213            .horizontal(Alignment::Center)
214            .child_width(3);
215        let area = Rect::new(0, 0, 7, 1);
216        let mut pool = GraphemePool::new();
217        let mut frame = Frame::new(7, 1, &mut pool);
218        align.render(area, &mut frame);
219
220        assert_eq!(buf_to_lines(&frame.buffer), vec!["  XXX  "]);
221    }
222
223    #[test]
224    fn right_horizontal() {
225        let align = Align::new(Fill('X'))
226            .horizontal(Alignment::Right)
227            .child_width(3);
228        let area = Rect::new(0, 0, 7, 1);
229        let mut pool = GraphemePool::new();
230        let mut frame = Frame::new(7, 1, &mut pool);
231        align.render(area, &mut frame);
232
233        assert_eq!(buf_to_lines(&frame.buffer), vec!["    XXX"]);
234    }
235
236    #[test]
237    fn left_horizontal() {
238        let align = Align::new(Fill('X'))
239            .horizontal(Alignment::Left)
240            .child_width(3);
241        let area = Rect::new(0, 0, 7, 1);
242        let mut pool = GraphemePool::new();
243        let mut frame = Frame::new(7, 1, &mut pool);
244        align.render(area, &mut frame);
245
246        assert_eq!(buf_to_lines(&frame.buffer), vec!["XXX    "]);
247    }
248
249    #[test]
250    fn center_vertical() {
251        let align = Align::new(Fill('X'))
252            .vertical(VerticalAlignment::Middle)
253            .child_height(1);
254        let area = Rect::new(0, 0, 3, 5);
255        let mut pool = GraphemePool::new();
256        let mut frame = Frame::new(3, 5, &mut pool);
257        align.render(area, &mut frame);
258
259        assert_eq!(
260            buf_to_lines(&frame.buffer),
261            vec!["   ", "   ", "XXX", "   ", "   "]
262        );
263    }
264
265    #[test]
266    fn bottom_vertical() {
267        let align = Align::new(Fill('X'))
268            .vertical(VerticalAlignment::Bottom)
269            .child_height(2);
270        let area = Rect::new(0, 0, 3, 4);
271        let mut pool = GraphemePool::new();
272        let mut frame = Frame::new(3, 4, &mut pool);
273        align.render(area, &mut frame);
274
275        assert_eq!(
276            buf_to_lines(&frame.buffer),
277            vec!["   ", "   ", "XXX", "XXX"]
278        );
279    }
280
281    #[test]
282    fn center_both_axes() {
283        let align = Align::new(Fill('O'))
284            .horizontal(Alignment::Center)
285            .vertical(VerticalAlignment::Middle)
286            .child_width(1)
287            .child_height(1);
288        let area = Rect::new(0, 0, 5, 5);
289        let mut pool = GraphemePool::new();
290        let mut frame = Frame::new(5, 5, &mut pool);
291        align.render(area, &mut frame);
292
293        assert_eq!(
294            buf_to_lines(&frame.buffer),
295            vec!["     ", "     ", "  O  ", "     ", "     "]
296        );
297    }
298
299    #[test]
300    fn child_larger_than_area_is_clamped() {
301        let align = Align::new(Fill('X'))
302            .horizontal(Alignment::Center)
303            .child_width(20)
304            .child_height(10);
305        let area = Rect::new(0, 0, 5, 3);
306
307        let child_area = align.aligned_area(area);
308        assert_eq!(child_area.width, 5);
309        assert_eq!(child_area.height, 3);
310    }
311
312    #[test]
313    fn zero_size_area_is_noop() {
314        let align = Align::new(Fill('X'))
315            .horizontal(Alignment::Center)
316            .child_width(3);
317        let area = Rect::new(0, 0, 0, 0);
318        let mut pool = GraphemePool::new();
319        let mut frame = Frame::new(5, 5, &mut pool);
320        align.render(area, &mut frame);
321
322        // Nothing should have been drawn
323        for y in 0..5 {
324            for x in 0..5u16 {
325                assert!(frame.buffer.get(x, y).unwrap().is_empty());
326            }
327        }
328    }
329
330    #[test]
331    fn zero_child_size_is_noop() {
332        let align = Align::new(Fill('X')).child_width(0).child_height(0);
333        let area = Rect::new(0, 0, 5, 5);
334        let mut pool = GraphemePool::new();
335        let mut frame = Frame::new(5, 5, &mut pool);
336        align.render(area, &mut frame);
337
338        for y in 0..5 {
339            for x in 0..5u16 {
340                assert!(frame.buffer.get(x, y).unwrap().is_empty());
341            }
342        }
343    }
344
345    #[test]
346    fn area_with_offset() {
347        let align = Align::new(Fill('X'))
348            .horizontal(Alignment::Center)
349            .child_width(2);
350        let area = Rect::new(10, 5, 6, 1);
351
352        let child = align.aligned_area(area);
353        assert_eq!(child.x, 12);
354        assert_eq!(child.y, 5);
355        assert_eq!(child.width, 2);
356    }
357
358    #[test]
359    fn aligned_area_right_bottom() {
360        let align = Align::new(Fill('X'))
361            .horizontal(Alignment::Right)
362            .vertical(VerticalAlignment::Bottom)
363            .child_width(2)
364            .child_height(1);
365        let area = Rect::new(0, 0, 10, 5);
366
367        let child = align.aligned_area(area);
368        assert_eq!(child.x, 8);
369        assert_eq!(child.y, 4);
370        assert_eq!(child.width, 2);
371        assert_eq!(child.height, 1);
372    }
373
374    #[test]
375    fn is_essential_delegates() {
376        struct Essential;
377        impl Widget for Essential {
378            fn render(&self, _: Rect, _: &mut Frame) {}
379            fn is_essential(&self) -> bool {
380                true
381            }
382        }
383
384        assert!(Align::new(Essential).is_essential());
385        assert!(!Align::new(Fill('X')).is_essential());
386    }
387}