Skip to main content

rich_rs/
layout.rs

1//! Layout: split a terminal area into regions and render children.
2//!
3//! Port of Python Rich's `rich/layout.py` (subset).
4
5use std::collections::HashMap;
6use std::sync::{Arc, Mutex, Weak};
7
8use crate::region::Region;
9use crate::screen_buffer::ScreenBuffer;
10use crate::segment::{Segment, Segments};
11use crate::{Console, ConsoleOptions, Renderable};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SplitterKind {
15    Row,
16    Column,
17}
18
19#[derive(Debug, Clone)]
20pub struct LayoutRender {
21    pub region: Region,
22    pub lines: Vec<Vec<Segment>>,
23}
24
25struct LayoutState {
26    name: Option<String>,
27    size: Option<usize>,
28    minimum_size: usize,
29    ratio: usize,
30    visible: bool,
31    splitter: SplitterKind,
32    renderable: Arc<dyn Renderable>,
33    children: Vec<Layout>,
34    render_map: HashMap<usize, LayoutRender>,
35}
36
37/// A renderable that divides a fixed height in to rows or columns.
38#[derive(Clone)]
39pub struct Layout {
40    id: usize,
41    state: Arc<Mutex<LayoutState>>,
42}
43
44impl std::fmt::Debug for Layout {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        let state = self.state.lock().expect("layout mutex poisoned");
47        f.debug_struct("Layout")
48            .field("name", &state.name)
49            .field("size", &state.size)
50            .field("minimum_size", &state.minimum_size)
51            .field("ratio", &state.ratio)
52            .field("visible", &state.visible)
53            .field("splitter", &state.splitter)
54            .field("children", &state.children.len())
55            .finish_non_exhaustive()
56    }
57}
58
59fn next_layout_id() -> usize {
60    use std::sync::atomic::{AtomicUsize, Ordering};
61    static NEXT: AtomicUsize = AtomicUsize::new(1);
62    NEXT.fetch_add(1, Ordering::Relaxed)
63}
64
65#[derive(Clone)]
66struct Placeholder {
67    layout: Weak<Mutex<LayoutState>>,
68}
69
70impl Renderable for Placeholder {
71    fn render(&self, console: &Console, options: &ConsoleOptions) -> Segments {
72        use crate::{Align, Panel, Pretty, Style, VerticalAlignMethod};
73
74        let Some(layout) = self.layout.upgrade() else {
75            return Segments::new();
76        };
77        let state = layout.lock().expect("layout mutex poisoned");
78        let width = options.max_width;
79        let height = options.height.unwrap_or(options.size.1);
80        let title = if let Some(name) = &state.name {
81            format!("{name:?} ({width} x {height})")
82        } else {
83            format!("({width} x {height})")
84        };
85
86        #[derive(Debug)]
87        #[allow(dead_code)]
88        struct LayoutInfo {
89            name: Option<String>,
90            size: Option<usize>,
91            minimum_size: usize,
92            ratio: usize,
93            visible: bool,
94            splitter: SplitterKind,
95            children: usize,
96        }
97
98        let info = LayoutInfo {
99            name: state.name.clone(),
100            size: state.size,
101            minimum_size: state.minimum_size,
102            ratio: state.ratio,
103            visible: state.visible,
104            splitter: state.splitter,
105            children: state.children.len(),
106        };
107
108        let content =
109            Align::center(Box::new(Pretty::new(&info))).with_vertical(VerticalAlignMethod::Middle);
110
111        let panel = Panel::new(Box::new(content))
112            .with_title(title)
113            .with_border_style(Style::parse("blue").unwrap_or_else(Style::new))
114            .with_height(height);
115
116        panel.render(console, options)
117    }
118}
119
120impl Layout {
121    /// Create a new Layout with a placeholder renderable.
122    pub fn new() -> Self {
123        let id = next_layout_id();
124        let state = Arc::new(Mutex::new(LayoutState {
125            name: None,
126            size: None,
127            minimum_size: 1,
128            ratio: 1,
129            visible: true,
130            splitter: SplitterKind::Column,
131            renderable: Arc::new(String::new()),
132            children: Vec::new(),
133            render_map: HashMap::new(),
134        }));
135        let placeholder = Placeholder {
136            layout: Arc::downgrade(&state),
137        };
138        {
139            let mut st = state.lock().expect("layout mutex poisoned");
140            st.renderable = Arc::new(placeholder);
141        }
142
143        Self { id, state }
144    }
145
146    /// Create a new leaf layout with a renderable.
147    pub fn with_renderable(renderable: impl Renderable + 'static) -> Self {
148        let layout = Self::new();
149        layout.update(renderable);
150        layout
151    }
152
153    pub fn with_name(self, name: impl Into<String>) -> Self {
154        self.state.lock().expect("layout mutex poisoned").name = Some(name.into());
155        self
156    }
157
158    pub fn with_size(self, size: usize) -> Self {
159        self.state.lock().expect("layout mutex poisoned").size = Some(size);
160        self
161    }
162
163    pub fn with_minimum_size(self, minimum_size: usize) -> Self {
164        self.state
165            .lock()
166            .expect("layout mutex poisoned")
167            .minimum_size = minimum_size.max(1);
168        self
169    }
170
171    pub fn with_ratio(self, ratio: usize) -> Self {
172        self.state.lock().expect("layout mutex poisoned").ratio = ratio.max(1);
173        self
174    }
175
176    pub fn with_visible(self, visible: bool) -> Self {
177        self.state.lock().expect("layout mutex poisoned").visible = visible;
178        self
179    }
180
181    pub fn id(&self) -> usize {
182        self.id
183    }
184
185    pub fn name(&self) -> Option<String> {
186        self.state
187            .lock()
188            .expect("layout mutex poisoned")
189            .name
190            .clone()
191    }
192
193    pub fn children(&self) -> Vec<Layout> {
194        let state = self.state.lock().expect("layout mutex poisoned");
195        state
196            .children
197            .iter()
198            .cloned()
199            .filter(|c| c.state.lock().expect("layout mutex poisoned").visible)
200            .collect()
201    }
202
203    pub fn get(&self, name: &str) -> Option<Layout> {
204        let state = self.state.lock().expect("layout mutex poisoned");
205        if state.name.as_deref() == Some(name) {
206            return Some(self.clone());
207        }
208        for child in &state.children {
209            if let Some(found) = child.get(name) {
210                return Some(found);
211            }
212        }
213        None
214    }
215
216    pub fn update(&self, renderable: impl Renderable + 'static) {
217        self.state.lock().expect("layout mutex poisoned").renderable = Arc::new(renderable);
218    }
219
220    pub fn split(&self, splitter: SplitterKind, layouts: Vec<Layout>) {
221        let mut state = self.state.lock().expect("layout mutex poisoned");
222        state.splitter = splitter;
223        state.children = layouts;
224    }
225
226    pub fn split_row(&self, layouts: Vec<Layout>) {
227        self.split(SplitterKind::Row, layouts)
228    }
229
230    pub fn split_column(&self, layouts: Vec<Layout>) {
231        self.split(SplitterKind::Column, layouts)
232    }
233
234    pub fn add_split(&self, layouts: Vec<Layout>) {
235        self.state
236            .lock()
237            .expect("layout mutex poisoned")
238            .children
239            .extend(layouts);
240    }
241
242    pub fn unsplit(&self) {
243        self.state
244            .lock()
245            .expect("layout mutex poisoned")
246            .children
247            .clear();
248    }
249
250    fn visible_children(state: &LayoutState) -> Vec<Layout> {
251        state
252            .children
253            .iter()
254            .cloned()
255            .filter(|c| c.state.lock().expect("layout mutex poisoned").visible)
256            .collect()
257    }
258
259    fn divide_region(
260        children: &[Layout],
261        region: Region,
262        splitter: SplitterKind,
263    ) -> Vec<(Layout, Region)> {
264        let x = region.x;
265        let y = region.y;
266        let width = region.width as usize;
267        let height = region.height as usize;
268        match splitter {
269            SplitterKind::Row => {
270                let widths = ratio_resolve(width as i64, children);
271                let mut offset: i32 = 0;
272                children
273                    .iter()
274                    .cloned()
275                    .zip(widths.into_iter())
276                    .map(|(child, w)| {
277                        let r = Region::new(x + offset, y, w as u32, region.height);
278                        offset += w as i32;
279                        (child, r)
280                    })
281                    .collect()
282            }
283            SplitterKind::Column => {
284                let heights = ratio_resolve(height as i64, children);
285                let mut offset: i32 = 0;
286                children
287                    .iter()
288                    .cloned()
289                    .zip(heights.into_iter())
290                    .map(|(child, h)| {
291                        let r = Region::new(x, y + offset, region.width, h as u32);
292                        offset += h as i32;
293                        (child, r)
294                    })
295                    .collect()
296            }
297        }
298    }
299
300    fn make_region_map(&self, width: usize, height: usize) -> Vec<(Layout, Region)> {
301        let mut stack: Vec<(Layout, Region)> =
302            vec![(self.clone(), Region::new(0, 0, width as u32, height as u32))];
303        let mut layout_regions: Vec<(Layout, Region)> = Vec::new();
304        while let Some((layout, region)) = stack.pop() {
305            layout_regions.push((layout.clone(), region));
306
307            let state = layout.state.lock().expect("layout mutex poisoned");
308            let children = Self::visible_children(&state);
309            if !children.is_empty() {
310                let divided = Self::divide_region(&children, region, state.splitter);
311                for item in divided {
312                    stack.push(item);
313                }
314            }
315        }
316
317        layout_regions.sort_by(|a, b| {
318            // Python sorts by Region tuple (x, y, width, height)
319            let ra = a.1;
320            let rb = b.1;
321            (ra.x, ra.y, ra.width, ra.height).cmp(&(rb.x, rb.y, rb.width, rb.height))
322        });
323
324        layout_regions
325    }
326
327    fn render_map(
328        &self,
329        console: &Console,
330        options: &ConsoleOptions,
331    ) -> HashMap<usize, LayoutRender> {
332        let width = options.max_width.max(1);
333        let height = options.height.unwrap_or_else(|| console.height()).max(1);
334        let regions = self.make_region_map(width, height);
335        let leaves: Vec<(Layout, Region)> = regions
336            .into_iter()
337            .filter(|(layout, _)| layout.children().is_empty())
338            .collect();
339
340        let mut render_map: HashMap<usize, LayoutRender> = HashMap::new();
341
342        for (layout, region) in leaves {
343            let mut child_opts = options.clone();
344            let w = region.width as usize;
345            let h = region.height as usize;
346            child_opts.size = (w.max(1), h.max(1));
347            child_opts.min_width = w.max(1);
348            child_opts.max_width = w.max(1);
349            child_opts.max_height = h.max(1);
350            child_opts.height = Some(h.max(1));
351
352            let renderable = {
353                let state = layout.state.lock().expect("layout mutex poisoned");
354                state.renderable.clone()
355            };
356
357            let lines =
358                console.render_lines(renderable.as_ref(), Some(&child_opts), None, true, false);
359            render_map.insert(layout.id, LayoutRender { region, lines });
360        }
361
362        render_map
363    }
364
365    /// Refresh a sub-layout in an alternate screen.
366    ///
367    /// This matches Rich's `Layout.refresh_screen` behavior and requires alt-screen mode.
368    pub fn refresh_screen(
369        &self,
370        console: &mut crate::Console<std::io::Stdout>,
371        layout_name: &str,
372    ) -> std::io::Result<()> {
373        let Some(layout) = self.get(layout_name) else {
374            return Ok(());
375        };
376
377        let region = {
378            let state = self.state.lock().expect("layout mutex poisoned");
379            let Some(render) = state.render_map.get(&layout.id) else {
380                return Ok(());
381            };
382            render.region
383        };
384
385        let mut child_opts = console.options().clone();
386        let w = region.width as usize;
387        let h = region.height as usize;
388        child_opts.size = (w.max(1), h.max(1));
389        child_opts.min_width = w.max(1);
390        child_opts.max_width = w.max(1);
391        child_opts.max_height = h.max(1);
392        child_opts.height = Some(h.max(1));
393
394        let lines = console.render_lines(&layout, Some(&child_opts), None, true, false);
395
396        // Store updated lines in the root render_map.
397        self.state
398            .lock()
399            .expect("layout mutex poisoned")
400            .render_map
401            .insert(
402                layout.id,
403                LayoutRender {
404                    region,
405                    lines: lines.clone(),
406                },
407            );
408
409        console.update_screen_lines(&lines, region.x.max(0) as u16, region.y.max(0) as u16)?;
410        Ok(())
411    }
412}
413
414impl Layout {
415    /// Build a Tree renderable that visualises the layout hierarchy.
416    ///
417    /// This matches Python Rich's `Layout.tree` property: each node shows the
418    /// layout name (or "<unnamed>"), its splitter direction, and size/ratio info.
419    pub fn to_tree(&self) -> crate::tree::Tree {
420        use crate::text::Text;
421        use crate::tree::Tree;
422
423        fn build_label(state: &LayoutState) -> Text {
424            let name = state.name.as_deref().unwrap_or("<unnamed>");
425            let kind = match state.splitter {
426                SplitterKind::Row => "row",
427                SplitterKind::Column => "column",
428            };
429            let size_info = if let Some(s) = state.size {
430                format!(" size={s}")
431            } else {
432                format!(" ratio={}", state.ratio)
433            };
434            Text::plain(format!("{name} ({kind}{size_info})"))
435        }
436
437        fn recurse(layout: &Layout) -> Tree {
438            let state = layout.state.lock().expect("layout mutex poisoned");
439            let label = build_label(&state);
440            let mut node = Tree::new(Box::new(label));
441            for child in &state.children {
442                node.children_mut().push(recurse(child));
443            }
444            node
445        }
446
447        recurse(self)
448    }
449}
450
451impl Renderable for Layout {
452    fn render(&self, console: &Console, options: &ConsoleOptions) -> Segments {
453        // Leaf layouts render their stored renderable.
454        if self.children().is_empty() {
455            let renderable = self
456                .state
457                .lock()
458                .expect("layout mutex poisoned")
459                .renderable
460                .clone();
461            return renderable.render(console, options);
462        }
463
464        let width = options.max_width.max(1);
465        let height = options.height.unwrap_or_else(|| console.height()).max(1);
466
467        let render_map = self.render_map(console, options);
468
469        // Store last render_map on root.
470        self.state.lock().expect("layout mutex poisoned").render_map = render_map.clone();
471
472        let mut buffer = ScreenBuffer::new(width, height, None);
473        // Insert in region order: sort by region tuple.
474        let mut items: Vec<_> = render_map.into_values().collect();
475        items.sort_by(|a, b| {
476            let ra = a.region;
477            let rb = b.region;
478            (ra.x, ra.y, ra.width, ra.height).cmp(&(rb.x, rb.y, rb.width, rb.height))
479        });
480
481        for item in items {
482            let x = item.region.x.max(0) as usize;
483            let y = item.region.y.max(0) as usize;
484            let w = item.region.width as usize;
485            buffer.blit_lines(x, y, w, &item.lines);
486        }
487
488        let lines = buffer.to_styled_lines();
489        let mut out = Segments::new();
490        let new_line = Segment::line();
491        for line in lines {
492            for seg in line {
493                out.push(seg);
494            }
495            out.push(new_line.clone());
496        }
497        out
498    }
499}
500
501// =============================================================================
502// Ratio resolve (copied from Rich _ratio.py)
503// =============================================================================
504
505fn ratio_resolve(total: i64, layouts: &[Layout]) -> Vec<i64> {
506    let mut sizes: Vec<Option<i64>> = layouts
507        .iter()
508        .map(|layout| layout.state.lock().unwrap().size.map(|s| s as i64))
509        .collect();
510
511    while sizes.iter().any(|s| s.is_none()) {
512        let flexible: Vec<(usize, &Layout)> = sizes
513            .iter()
514            .zip(layouts.iter())
515            .enumerate()
516            .filter_map(|(i, (size, edge))| size.is_none().then_some((i, edge)))
517            .collect();
518
519        let used: i64 = sizes.iter().map(|s| s.unwrap_or(0)).sum();
520        let remaining = total - used;
521        if remaining <= 0 {
522            return sizes
523                .into_iter()
524                .zip(layouts.iter())
525                .map(|(size, edge)| match size {
526                    Some(v) => v,
527                    None => edge.state.lock().unwrap().minimum_size.max(1) as i64,
528                })
529                .collect();
530        }
531
532        let total_ratio: i64 = flexible
533            .iter()
534            .map(|(_, edge)| edge.state.lock().unwrap().ratio.max(1) as i64)
535            .sum();
536        let portion_num = remaining;
537        let portion_den = total_ratio.max(1);
538
539        // Ensure minimum sizes first.
540        let mut fixed_any = false;
541        for (index, edge) in &flexible {
542            let st = edge.state.lock().unwrap();
543            let min = st.minimum_size.max(1) as i64;
544            let ratio = st.ratio.max(1) as i64;
545            // portion * ratio <= minimum_size
546            if portion_num * ratio <= min * portion_den {
547                sizes[*index] = Some(min);
548                fixed_any = true;
549                break;
550            }
551        }
552        if fixed_any {
553            continue;
554        }
555
556        // Distribute with remainder.
557        let mut remainder_num: i64 = 0;
558        for (index, edge) in flexible {
559            let ratio = edge.state.lock().unwrap().ratio.max(1) as i64;
560            let num = portion_num * ratio + remainder_num;
561            let size = num / portion_den;
562            remainder_num = num % portion_den;
563            sizes[index] = Some(size);
564        }
565        break;
566    }
567
568    sizes.into_iter().map(|s| s.unwrap_or(0)).collect()
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use crate::Text;
575
576    #[test]
577    fn test_ratio_resolve_respects_fixed_size() {
578        let a = Layout::new().with_size(3);
579        let b = Layout::new().with_ratio(1);
580        let widths = ratio_resolve(10, &[a, b]);
581        assert_eq!(widths, vec![3, 7]);
582    }
583
584    #[test]
585    fn test_layout_split_row_renders_side_by_side() {
586        let console = Console::new();
587        let mut options = console.options().clone();
588        options.max_width = 6;
589        options.size = (6, 2);
590        options.height = Some(2);
591
592        let left = Layout::with_renderable(Text::plain("L")).with_name("left");
593        let right = Layout::with_renderable(Text::plain("R")).with_name("right");
594        let root = Layout::new();
595        root.split_row(vec![left, right]);
596
597        let output: String = root
598            .render(&console, &options)
599            .iter()
600            .map(|s| s.text.to_string())
601            .collect();
602        let lines: Vec<&str> = output.split('\n').collect();
603        assert!(lines[0].contains('L'));
604        assert!(lines[0].contains('R'));
605    }
606
607    #[test]
608    fn test_layout_get_by_name() {
609        let child = Layout::new().with_name("child");
610        let root = Layout::new();
611        root.split_column(vec![child.clone()]);
612        assert!(root.get("child").is_some());
613    }
614
615    #[test]
616    fn test_layout_split_column_stacks() {
617        let console = Console::new();
618        let mut options = console.options().clone();
619        options.max_width = 4;
620        options.size = (4, 2);
621        options.height = Some(2);
622
623        let top = Layout::with_renderable(Text::plain("A")).with_size(1);
624        let bottom = Layout::with_renderable(Text::plain("B"));
625        let root = Layout::new();
626        root.split_column(vec![top, bottom]);
627
628        let output: String = root
629            .render(&console, &options)
630            .iter()
631            .map(|s| s.text.to_string())
632            .collect();
633        let lines: Vec<&str> = output.split('\n').collect();
634        assert!(lines[0].contains('A'));
635        assert!(lines[1].contains('B'));
636    }
637
638    #[test]
639    fn test_layout_to_tree() {
640        let root = Layout::new().with_name("root");
641        let left = Layout::new().with_name("left").with_size(3);
642        let right = Layout::new().with_name("right");
643        root.split_row(vec![left, right]);
644
645        let tree = root.to_tree();
646        assert_eq!(tree.children().len(), 2);
647    }
648
649    #[test]
650    fn test_layout_to_tree_leaf() {
651        let leaf = Layout::new().with_name("leaf");
652        let tree = leaf.to_tree();
653        assert!(tree.children().is_empty());
654    }
655
656    #[test]
657    fn test_layout_nested_regions() {
658        let console = Console::new();
659        let mut options = console.options().clone();
660        options.max_width = 6;
661        options.size = (6, 3);
662        options.height = Some(3);
663
664        let header = Layout::with_renderable(Text::plain("H"))
665            .with_size(1)
666            .with_name("header");
667        let body = Layout::new().with_name("body");
668        let root = Layout::new();
669        root.split_column(vec![header, body.clone()]);
670
671        let left = Layout::with_renderable(Text::plain("L")).with_size(2);
672        let right = Layout::with_renderable(Text::plain("R"));
673        body.split_row(vec![left, right]);
674
675        let output: String = root
676            .render(&console, &options)
677            .iter()
678            .map(|s| s.text.to_string())
679            .collect();
680        let lines: Vec<&str> = output.split('\n').collect();
681        // Header on first row.
682        assert!(lines[0].contains('H'));
683        // Body on second row: L should appear before R.
684        let lpos = lines[1].find('L').unwrap();
685        let rpos = lines[1].find('R').unwrap();
686        assert!(lpos < rpos);
687    }
688}