Skip to main content

netget/tui/render/
mod.rs

1//! Frame composition.
2//!
3//! Left column: the instance canvas. Right column: the stream, then the
4//! input box. One status line along the bottom; modals on top of everything.
5//! Narrower than [`TWO_COLUMN_WIDTH`] the two columns stack: the canvas on top,
6//! the stream and the input box under it.
7
8pub mod cards;
9pub mod chat;
10pub mod overlay;
11pub mod rail;
12pub mod status_bar;
13pub mod stream;
14
15use ratatui::layout::{Constraint, Direction, Layout, Rect};
16use ratatui::style::Style;
17use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
18use ratatui::Frame;
19
20use crate::tui::app::DashboardApp;
21use crate::tui::rail::Tone;
22
23/// Minimum terminal size the dashboard renders at. Below [`TWO_COLUMN_WIDTH`] it
24/// renders stacked, which is what a phone gets (the browser demo sizes its
25/// terminal to a readable font rather than to 80 columns of 6px text).
26pub const MIN_WIDTH: u16 = 40;
27pub const MIN_HEIGHT: u16 = 24;
28
29/// From this width the canvas and the stream sit side by side.
30pub const TWO_COLUMN_WIDTH: u16 = 80;
31
32/// Stacked, the canvas takes this share of the body's height; the stream and
33/// the input box get the rest, never fewer than [`STACKED_STREAM_MIN`] rows.
34const STACKED_CANVAS_PERCENT: u16 = 55;
35const STACKED_STREAM_MIN: u16 = 6;
36
37/// The management column: never narrower than this, never wider than what
38/// leaves the feed readable.
39const LEFT_MIN: u16 = 38;
40const LEFT_MAX: u16 = 76;
41const LEFT_PERCENT: u16 = 48;
42const RIGHT_MIN: u16 = 36;
43
44pub fn draw(frame: &mut Frame, app: &mut DashboardApp) {
45    app.hits.clear();
46    let area = frame.area();
47
48    if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
49        let notice = Paragraph::new(format!(
50            "Terminal too small: {}x{} (need {}x{}). Resize the window.",
51            area.width, area.height, MIN_WIDTH, MIN_HEIGHT
52        ))
53        .style(app.styles.warning);
54        frame.render_widget(notice, area);
55        return;
56    }
57
58    let rows = Layout::default()
59        .direction(Direction::Vertical)
60        .constraints([Constraint::Min(1), Constraint::Length(1)])
61        .split(area);
62    let body = rows[0];
63    let status = rows[1];
64
65    let (left, right) = if body.width >= TWO_COLUMN_WIDTH {
66        let left_width = ((body.width as u32 * LEFT_PERCENT as u32) / 100) as u16;
67        let left_width = left_width
68            .clamp(LEFT_MIN, LEFT_MAX)
69            .min(body.width.saturating_sub(RIGHT_MIN));
70        let columns = Layout::default()
71            .direction(Direction::Horizontal)
72            .constraints([Constraint::Length(left_width), Constraint::Min(RIGHT_MIN)])
73            .split(body);
74        (columns[0], columns[1])
75    } else {
76        let canvas_height = ((body.height as u32 * STACKED_CANVAS_PERCENT as u32) / 100) as u16;
77        let canvas_height = canvas_height.min(
78            body.height
79                .saturating_sub(STACKED_STREAM_MIN + chat::input_height(app)),
80        );
81        let stack = Layout::default()
82            .direction(Direction::Vertical)
83            .constraints([Constraint::Length(canvas_height), Constraint::Min(1)])
84            .split(body);
85        (stack[0], stack[1])
86    };
87
88    cards::draw(frame, app, left);
89
90    let input_height = chat::input_height(app);
91    let right_rows = Layout::default()
92        .direction(Direction::Vertical)
93        .constraints([Constraint::Min(4), Constraint::Length(input_height)])
94        .split(right);
95    stream::draw(frame, app, right_rows[0]);
96    chat::draw_input(frame, app, right_rows[1]);
97
98    status_bar::draw(frame, app, status);
99
100    if app.modal().is_some() {
101        overlay::draw(frame, app, area);
102    }
103}
104
105/// A pane frame: rounded, accent-coloured while focused.
106pub fn pane_block<'a>(app: &DashboardApp, focused: bool) -> Block<'a> {
107    Block::default()
108        .borders(Borders::ALL)
109        .border_type(BorderType::Rounded)
110        .border_style(if focused {
111            app.styles.accent
112        } else {
113            app.styles.separator
114        })
115}
116
117/// Resolve a semantic tone against the palette.
118pub fn tone_style(app: &DashboardApp, tone: Tone) -> Style {
119    match tone {
120        Tone::Normal => app.styles.normal,
121        Tone::Dim => app.styles.dimmed,
122        Tone::Good => app.styles.success,
123        Tone::Warn => app.styles.warning,
124        Tone::Bad => app.styles.error,
125        Tone::Accent => app.styles.info,
126        Tone::Server => app.styles.server,
127        Tone::Client => app.styles.client,
128        Tone::Title => app.styles.title,
129        Tone::Reasoning => app.styles.reasoning,
130    }
131}
132
133/// Centre a modal rect of the given percentage inside `area`.
134pub fn centered(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
135    centered_capped(area, percent_x, percent_y, u16::MAX, u16::MAX)
136}
137
138/// Centre a box sized by percentage but capped at what its content needs.
139pub fn centered_capped(
140    area: Rect,
141    percent_x: u16,
142    percent_y: u16,
143    max_cols: u16,
144    max_rows: u16,
145) -> Rect {
146    let width = (area.width * percent_x / 100)
147        .min(max_cols)
148        .max(24)
149        .min(area.width);
150    let height = (area.height * percent_y / 100)
151        .min(max_rows)
152        .max(5)
153        .min(area.height);
154    Rect {
155        x: area.x + (area.width.saturating_sub(width)) / 2,
156        y: area.y + (area.height.saturating_sub(height)) / 2,
157        width,
158        height,
159    }
160}