Skip to main content

mermaid_cli/render/
layout.rs

1//! Vertical layout computation.
2//!
3//! Pure — given the `State` and the available `Rect`, returns the
4//! sub-rects for chat / attachments / input / status. Never mutates.
5//! Called once per render pass at the top of `render()`.
6
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use unicode_width::UnicodeWidthChar;
9
10use crate::domain::State;
11
12/// Layout zones, in top-to-bottom reading order.
13#[derive(Debug)]
14pub struct Zones {
15    pub chat: Rect,
16    pub attachments: Rect,
17    pub input: Rect,
18    pub status: Rect,
19}
20
21impl Zones {
22    /// Compute layout for `area`, factoring in input buffer length
23    /// (so the input box auto-grows), pending attachments, and
24    /// status line presence.
25    pub fn for_state(area: Rect, state: &State) -> Self {
26        let input_lines = estimate_input_lines(&state.ui.input_buffer, area.width);
27        let input_height = (input_lines + 2).min(7) as u16; // borders + cap
28
29        let attachment_height = if state.ui.attachments.is_empty() {
30            0
31        } else {
32            1
33        };
34        let status_height = if state.status.is_some() { 1 } else { 0 };
35
36        let chunks = Layout::default()
37            .direction(Direction::Vertical)
38            .constraints([
39                Constraint::Min(1),
40                Constraint::Length(attachment_height),
41                Constraint::Length(input_height),
42                Constraint::Length(status_height),
43            ])
44            .split(area);
45
46        Self {
47            chat: chunks[0],
48            attachments: chunks[1],
49            input: chunks[2],
50            status: chunks[3],
51        }
52    }
53}
54
55/// Estimate how many display rows the input buffer takes up inside a
56/// `width`-wide box (with 2 cells of border padding). CJK and emoji
57/// count as 2 cells; control chars count as 0; newlines are explicit
58/// wraps. Caps at 5 so the box can't eat the whole screen.
59fn estimate_input_lines(buffer: &str, width: u16) -> usize {
60    if buffer.is_empty() {
61        return 1;
62    }
63    let inner_width = width.saturating_sub(4) as usize;
64    let mut lines = 1usize;
65    let mut col = 0usize;
66    for ch in buffer.chars() {
67        let w = ch.width().unwrap_or(0);
68        if ch == '\n' || (inner_width > 0 && col + w > inner_width) {
69            lines += 1;
70            col = if ch == '\n' { 0 } else { w };
71        } else {
72            col += w;
73        }
74    }
75    lines.min(5)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::app::Config;
82    use std::path::PathBuf;
83
84    fn state() -> State {
85        State::new(
86            Config::default(),
87            PathBuf::from("/tmp/p"),
88            "ollama/test".to_string(),
89        )
90    }
91
92    #[test]
93    fn empty_input_single_line() {
94        assert_eq!(estimate_input_lines("", 80), 1);
95    }
96
97    #[test]
98    fn linebreaks_count() {
99        assert_eq!(estimate_input_lines("a\nb\nc", 80), 3);
100    }
101
102    #[test]
103    fn wraps_at_inner_width() {
104        // width 10 → inner width 6 → "abcdefg" should wrap at 6.
105        assert_eq!(estimate_input_lines("abcdefg", 10), 2);
106    }
107
108    #[test]
109    fn capped_at_five_lines() {
110        assert_eq!(estimate_input_lines("\n\n\n\n\n\n\n", 80), 5);
111    }
112
113    #[test]
114    fn zones_partition_area_without_overlap() {
115        let area = Rect::new(0, 0, 80, 24);
116        let zones = Zones::for_state(area, &state());
117        assert!(zones.chat.height > 0);
118        // Input box always visible.
119        assert!(zones.input.height >= 3);
120        // No attachment / status rows when empty.
121        assert_eq!(zones.attachments.height, 0);
122        assert_eq!(zones.status.height, 0);
123    }
124
125    #[test]
126    fn status_reserves_one_row_when_present() {
127        let mut s = state();
128        s.status = Some(crate::domain::StatusLine {
129            text: "hi".to_string(),
130            kind: crate::domain::StatusKind::Info,
131            shown_at: std::time::SystemTime::now(),
132        });
133        let area = Rect::new(0, 0, 80, 24);
134        let zones = Zones::for_state(area, &s);
135        assert_eq!(zones.status.height, 1);
136    }
137}