Skip to main content

rpi_tui/
layout_node.rs

1//! Layout node system for exposing component layout structure.
2//!
3//! This allows VStack, HStack, and ScrollView to expose their internal structure
4//! to the layout system for proper constrained layout.
5
6use std::sync::Arc;
7
8use super::component::Component;
9use super::vstack::StackAlign;
10
11/// A viewport for layout calculations.
12#[derive(Debug, Clone, Copy)]
13pub struct LayoutViewport {
14    pub width: usize,
15    pub height: usize,
16}
17
18impl Default for LayoutViewport {
19    fn default() -> Self {
20        Self {
21            width: 80,
22            height: 24,
23        }
24    }
25}
26
27/// Layout node types.
28#[derive(Clone)]
29pub enum LayoutNode {
30    /// A vertical stack.
31    VStack(VStackNode),
32    /// A horizontal stack.
33    HStack(HStackNode),
34    /// A scroll view.
35    Scroll(ScrollNode),
36}
37
38/// VStack layout node.
39#[derive(Clone)]
40pub struct VStackNode {
41    pub entries: Vec<StackLayoutEntry>,
42    pub gap: usize,
43    pub align: StackAlign,
44}
45
46/// HStack layout node.
47#[derive(Clone)]
48pub struct HStackNode {
49    pub entries: Vec<StackLayoutEntry>,
50    pub gap: usize,
51    pub align: StackAlign,
52}
53
54/// Scroll view layout node.
55#[derive(Clone)]
56pub struct ScrollNode {
57    pub component: Arc<dyn Component>,
58    /// A callback to get the scroll state during layout
59    pub get_scroll_top: fn() -> usize,
60}
61
62impl ScrollNode {
63    /// Create a new scroll node with a component.
64    pub fn new(component: Arc<dyn Component>) -> Self {
65        Self {
66            component,
67            get_scroll_top: || 0,
68        }
69    }
70
71    /// Create a scroll node with a scroll top getter.
72    pub fn with_scroll_top(component: Arc<dyn Component>, get_scroll_top: fn() -> usize) -> Self {
73        Self {
74            component,
75            get_scroll_top,
76        }
77    }
78}
79
80/// A layout entry in a stack.
81#[derive(Clone)]
82pub struct StackLayoutEntry {
83    pub component: Arc<dyn Component>,
84    pub basis: Option<usize>,
85    pub grow: usize,
86    pub shrink: usize,
87    pub min_size: usize,
88    pub max_size: usize,
89    pub visible: Option<fn(LayoutViewport) -> bool>,
90}
91
92impl StackLayoutEntry {
93    /// Create a new stack layout entry.
94    pub fn new(component: Arc<dyn Component>) -> Self {
95        Self {
96            component,
97            basis: None,
98            grow: 0,
99            shrink: 1,
100            min_size: 0,
101            max_size: usize::MAX,
102            visible: None,
103        }
104    }
105
106    /// Create from a component with specific options.
107    pub fn with_options(
108        component: Arc<dyn Component>,
109        basis: Option<usize>,
110        grow: usize,
111        shrink: usize,
112        min_size: usize,
113        max_size: usize,
114    ) -> Self {
115        Self {
116            component,
117            basis,
118            grow,
119            shrink,
120            min_size,
121            max_size,
122            visible: None,
123        }
124    }
125}
126
127/// Trait for components that expose a layout node.
128pub trait LayoutNodeProvider: Component {
129    /// Get the layout node if this component has one.
130    fn layout_node(&self) -> Option<LayoutNode>;
131}
132
133/// Calculate sizes for stack entries given available space.
134pub fn allocate_stack_sizes(
135    entries: &[StackLayoutEntry],
136    intrinsic_sizes: &[usize],
137    available_size: Option<usize>,
138    gap: usize,
139) -> Vec<usize> {
140    let n = entries.len();
141    if n == 0 {
142        return Vec::new();
143    }
144
145    let total_gap = if n > 1 { gap * (n - 1) } else { 0 };
146
147    // First pass: calculate basis sizes
148    let mut sizes: Vec<usize> = entries
149        .iter()
150        .enumerate()
151        .map(|(i, entry)| {
152            let basis = entry
153                .basis
154                .unwrap_or_else(|| intrinsic_sizes.get(i).copied().unwrap_or(0));
155            basis.clamp(entry.min_size, entry.max_size)
156        })
157        .collect();
158
159    if let Some(available) = available_size {
160        let content_available = available.saturating_sub(total_gap);
161        let total_basis: usize = sizes.iter().sum();
162
163        if total_basis < content_available {
164            // Distribute remaining space to growing entries
165            let remaining = content_available - total_basis;
166            let total_grow: usize = entries.iter().map(|e| e.grow).sum();
167
168            if total_grow > 0 {
169                let mut to_distribute = remaining;
170
171                // First pass: distribute proportionally
172                for (i, entry) in entries.iter().enumerate() {
173                    if entry.grow > 0 && to_distribute > 0 {
174                        let share = (remaining * entry.grow / total_grow).min(to_distribute);
175                        let new_size = (sizes[i] + share).min(entry.max_size);
176                        let added = new_size - sizes[i];
177                        sizes[i] = new_size;
178                        to_distribute -= added;
179                    }
180                }
181
182                // Second pass: distribute leftover
183                for (i, entry) in entries.iter().enumerate() {
184                    if to_distribute == 0 {
185                        break;
186                    }
187                    if entry.grow > 0 && sizes[i] < entry.max_size {
188                        sizes[i] += 1;
189                        to_distribute -= 1;
190                    }
191                }
192            }
193        } else if total_basis > content_available {
194            // Need to shrink
195            let overflow = total_basis - content_available;
196            let mut to_shrink = overflow;
197
198            // Find shrinkable entries
199            let shrinkable: Vec<(usize, usize)> = entries
200                .iter()
201                .enumerate()
202                .filter(|(i, e)| e.shrink > 0 && sizes[*i] > e.min_size)
203                .map(|(i, e)| (i, e.shrink * sizes[i].max(1)))
204                .collect();
205
206            let total_shrink: usize = shrinkable.iter().map(|(_, s)| *s).sum();
207
208            if total_shrink > 0 {
209                for (i, weight) in &shrinkable {
210                    if to_shrink == 0 {
211                        break;
212                    }
213                    let share = (overflow * weight / total_shrink).min(to_shrink);
214                    let entry = &entries[*i];
215                    let new_size = sizes[*i].saturating_sub(share).max(entry.min_size);
216                    let removed = sizes[*i] - new_size;
217                    sizes[*i] = new_size;
218                    to_shrink -= removed;
219                }
220            }
221        }
222    }
223
224    sizes
225}
226
227/// Filter visible stack entries based on viewport.
228pub fn visible_stack_entries(
229    entries: &[StackLayoutEntry],
230    viewport: LayoutViewport,
231) -> Vec<StackLayoutEntry> {
232    entries
233        .iter()
234        .filter(|entry| entry.visible.map(|v| v(viewport)).unwrap_or(true))
235        .cloned()
236        .collect()
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::Text;
243
244    #[test]
245    fn test_allocate_stack_sizes_auto() {
246        let entries = vec![
247            StackLayoutEntry::new(Arc::new(Text::new("A", 0, 0))),
248            StackLayoutEntry::new(Arc::new(Text::new("B", 0, 0))),
249        ];
250
251        let intrinsic = vec![3, 3]; // Both have 3 lines
252        let sizes = allocate_stack_sizes(&entries, &intrinsic, None, 0);
253        assert_eq!(sizes, vec![3, 3]);
254    }
255
256    #[test]
257    fn test_allocate_stack_sizes_grow() {
258        let entries = vec![
259            StackLayoutEntry::with_options(
260                Arc::new(Text::new("A", 0, 0)),
261                Some(2),
262                1,
263                1,
264                0,
265                usize::MAX,
266            ),
267            StackLayoutEntry::with_options(
268                Arc::new(Text::new("B", 0, 0)),
269                Some(2),
270                1,
271                1,
272                0,
273                usize::MAX,
274            ),
275        ];
276
277        let intrinsic = vec![2, 2];
278        let sizes = allocate_stack_sizes(&entries, &intrinsic, Some(10), 0);
279        assert_eq!(sizes.iter().sum::<usize>(), 10);
280    }
281
282    #[test]
283    fn test_allocate_stack_sizes_shrink() {
284        let entries = vec![
285            StackLayoutEntry::with_options(
286                Arc::new(Text::new("A", 0, 0)),
287                Some(10),
288                0,
289                1,
290                2,
291                usize::MAX,
292            ),
293            StackLayoutEntry::with_options(
294                Arc::new(Text::new("B", 0, 0)),
295                Some(10),
296                0,
297                1,
298                2,
299                usize::MAX,
300            ),
301        ];
302
303        let intrinsic = vec![10, 10];
304        let sizes = allocate_stack_sizes(&entries, &intrinsic, Some(10), 0);
305        // Total should be 10, each gets at least min_size
306        assert!(sizes[0] >= 2);
307        assert!(sizes[1] >= 2);
308        assert_eq!(sizes.iter().sum::<usize>(), 10);
309    }
310}