Skip to main content

rpi_tui/
hstack.rs

1//! HStack - Horizontal stack layout component.
2//!
3//! Arranges children horizontally from left to right.
4
5use std::any::Any;
6use std::sync::{Arc, Mutex};
7
8use super::component::Component;
9use super::layout_node::{HStackNode, LayoutNode, LayoutNodeProvider, StackLayoutEntry};
10use super::vstack::{StackAlign, StackEntry, StackEntryOptions, StackOptions};
11use crate::ansi::{slice_by_column, visible_width};
12
13/// HStack - Horizontal stack layout.
14///
15/// Children are arranged from left to right.
16#[derive(Clone)]
17pub struct HStack {
18    children: Arc<Mutex<Vec<StackEntry>>>,
19    options: StackOptions,
20}
21
22impl HStack {
23    /// Create a new empty HStack.
24    pub fn new() -> Self {
25        Self {
26            children: Arc::new(Mutex::new(Vec::new())),
27            options: StackOptions::default(),
28        }
29    }
30
31    /// Create a new HStack with options.
32    pub fn with_options(options: StackOptions) -> Self {
33        Self {
34            children: Arc::new(Mutex::new(Vec::new())),
35            options,
36        }
37    }
38
39    /// Create an HStack from a vector of stack entries.
40    pub fn from_entries(children: Vec<StackEntry>) -> Self {
41        Self {
42            children: Arc::new(Mutex::new(children)),
43            options: StackOptions::default(),
44        }
45    }
46
47    /// Add a child with default options.
48    pub fn add_child(&self, component: Arc<dyn Component>) {
49        if let Ok(mut children) = self.children.lock() {
50            children.push(StackEntry::new(component));
51        }
52    }
53
54    /// Add a child with options.
55    pub fn add_child_with_options(
56        &self,
57        component: Arc<dyn Component>,
58        options: StackEntryOptions,
59    ) {
60        if let Ok(mut children) = self.children.lock() {
61            children.push(StackEntry::with_options(component, options));
62        }
63    }
64
65    /// Remove a child.
66    pub fn remove_child(&self, component: &Arc<dyn Component>) {
67        if let Ok(mut children) = self.children.lock() {
68            children.retain(|e| !Arc::ptr_eq(&e.component, component));
69        }
70    }
71
72    /// Clear all children.
73    pub fn clear(&self) {
74        if let Ok(mut children) = self.children.lock() {
75            children.clear();
76        }
77    }
78
79    /// Get the gap between children.
80    pub fn gap(&self) -> usize {
81        self.options.gap
82    }
83
84    /// Get the alignment.
85    pub fn align(&self) -> StackAlign {
86        self.options.align
87    }
88
89    /// Get a clone of the children list.
90    pub fn get_children(&self) -> Vec<StackEntry> {
91        self.children.lock().map(|c| c.clone()).unwrap_or_default()
92    }
93
94    /// Get layout entries for the layout system.
95    pub fn get_layout_entries(&self) -> Vec<StackLayoutEntry> {
96        self.children
97            .lock()
98            .map(|c| c.iter().map(|e| e.to_layout_entry()).collect())
99            .unwrap_or_default()
100    }
101}
102
103impl Default for HStack {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl Component for HStack {
110    fn render(&self, width: usize) -> Vec<String> {
111        let children = self.children.lock().map(|c| c.clone()).unwrap_or_default();
112        if children.is_empty() {
113            return vec![String::new()];
114        }
115
116        // First pass: calculate widths and render children
117        let mut child_data: Vec<(Vec<String>, usize)> = Vec::new();
118        let mut total_basis = 0usize;
119        let mut total_grow = 0usize;
120        let mut grow_indices = Vec::new();
121
122        for (i, entry) in children.iter().enumerate() {
123            // Render child to get intrinsic height
124            let lines = entry.component.render(width);
125            let max_line_width = lines.iter().map(|l| visible_width(l)).max().unwrap_or(0);
126
127            let basis = entry.options.basis.unwrap_or(max_line_width);
128            let min_size = entry.options.min_size;
129            let max_size = entry.options.max_size.unwrap_or(width);
130
131            let basis = basis.clamp(min_size, max_size);
132
133            child_data.push((lines, basis));
134            total_basis += basis;
135
136            if entry.options.grow > 0 {
137                total_grow += entry.options.grow;
138                grow_indices.push(i);
139            }
140        }
141
142        // Add gaps
143        let total_gap = if children.len() > 1 {
144            self.options.gap * (children.len() - 1)
145        } else {
146            0
147        };
148        total_basis += total_gap;
149
150        // Distribute remaining space
151        let available = width;
152        if total_basis < available && total_grow > 0 {
153            let remaining = available - total_basis;
154            for &i in &grow_indices {
155                let entry = &children[i];
156                let share = (remaining * entry.options.grow) / total_grow;
157                let max_size = entry.options.max_size.unwrap_or(width);
158                let current = child_data[i].1;
159                let new_width = (current + share).min(max_size);
160                child_data[i].1 = new_width;
161            }
162        }
163
164        // Find the max height
165        let max_height = child_data
166            .iter()
167            .map(|(lines, _)| lines.len())
168            .max()
169            .unwrap_or(1);
170
171        // Build output lines by compositing horizontally
172        let mut result: Vec<String> = vec![String::new(); max_height];
173
174        for (line_idx, result_line) in result.iter_mut().enumerate() {
175            let mut current_col = 0;
176
177            for (child_idx, (lines, child_width)) in child_data.iter().enumerate() {
178                // Add gap
179                if child_idx > 0 && self.options.gap > 0 {
180                    result_line.push_str(&" ".repeat(self.options.gap));
181                    current_col += self.options.gap;
182                }
183
184                // Get the line from this child (or empty string if too short)
185                let child_line = if line_idx < lines.len() {
186                    &lines[line_idx]
187                } else {
188                    ""
189                };
190
191                // Pad or truncate to allocated width
192                let line_width = visible_width(child_line);
193                if line_width < *child_width {
194                    // Pad right based on alignment
195                    match self.options.align {
196                        StackAlign::Stretch | StackAlign::Start => {
197                            result_line.push_str(child_line);
198                            result_line.push_str(&" ".repeat(child_width - line_width));
199                        }
200                        StackAlign::Center => {
201                            let left_pad = (child_width - line_width) / 2;
202                            let right_pad = child_width - line_width - left_pad;
203                            result_line.push_str(&" ".repeat(left_pad));
204                            result_line.push_str(child_line);
205                            result_line.push_str(&" ".repeat(right_pad));
206                        }
207                        StackAlign::End => {
208                            result_line.push_str(&" ".repeat(child_width - line_width));
209                            result_line.push_str(child_line);
210                        }
211                    }
212                } else if line_width > *child_width {
213                    // Truncate
214                    let truncated = slice_by_column(child_line, 0, *child_width, true);
215                    result_line.push_str(&truncated);
216                } else {
217                    result_line.push_str(child_line);
218                }
219
220                current_col += child_width;
221            }
222
223            // Ensure result doesn't exceed width
224            if visible_width(result_line) > width {
225                *result_line = slice_by_column(result_line, 0, width, true);
226            }
227        }
228
229        result
230    }
231
232    fn invalidate(&self) {
233        if let Ok(children) = self.children.lock() {
234            for entry in children.iter() {
235                entry.component.invalidate();
236            }
237        }
238    }
239
240    fn as_any(&self) -> &dyn Any {
241        self
242    }
243}
244
245impl LayoutNodeProvider for HStack {
246    fn layout_node(&self) -> Option<LayoutNode> {
247        Some(LayoutNode::HStack(HStackNode {
248            entries: self.get_layout_entries(),
249            gap: self.options.gap,
250            align: self.options.align,
251        }))
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::Text;
259
260    #[test]
261    fn test_hstack_horizontal_layout() {
262        let hstack = HStack::new();
263        hstack.add_child(Arc::new(Text::new("Hello", 0, 0)));
264        hstack.add_child(Arc::new(Text::new("World", 0, 0)));
265
266        let lines = hstack.render(20);
267        assert_eq!(lines.len(), 1);
268        assert!(lines[0].contains("Hello"));
269        assert!(lines[0].contains("World"));
270    }
271}