Skip to main content

cli_tutor/ui/
layout.rs

1use crate::app::{App, ContentView, DifficultyFilter};
2use crate::ui::{command_list, content_pane, help_overlay, progress_overlay};
3use ratatui::{
4    layout::{Constraint, Direction, Layout, Rect},
5    style::{Color, Modifier, Style},
6    text::{Line, Span},
7    widgets::{Block, Borders, Paragraph},
8    Frame,
9};
10
11pub fn render(app: &App, frame: &mut Frame) {
12    let area = frame.area();
13
14    if area.width < 80 || area.height < 24 {
15        render_resize_prompt(frame, area);
16        return;
17    }
18
19    let rows = Layout::default()
20        .direction(Direction::Vertical)
21        .constraints([
22            Constraint::Length(1),
23            Constraint::Min(0),
24            Constraint::Length(1),
25        ])
26        .split(area);
27
28    render_header(app, frame, rows[0]);
29    render_main(app, frame, rows[1]);
30    render_footer(app, frame, rows[2]);
31
32    if app.show_help {
33        help_overlay::render(app, frame, area);
34    }
35    // progress_summary.OVERLAY.1 — rendered on top of everything
36    if app.show_progress {
37        progress_overlay::render(app, frame, area);
38    }
39}
40
41fn render_header(app: &App, frame: &mut Frame, area: Rect) {
42    let nc = app.config.no_color;
43    let module = app.current_module();
44    let ex_count = app.exercise_count();
45    let progress_text = if ex_count > 0 {
46        let completed = app
47            .module_progress()
48            .map(|p| p.completed.len())
49            .unwrap_or(0);
50        format!(
51            "  {} — {}  ({}/{})",
52            module.module.name, module.module.description, completed, ex_count
53        )
54    } else {
55        format!("  {} — {}", module.module.name, module.module.description)
56    };
57
58    let view_label = match app.current_view {
59        ContentView::Intro => "[Intro]",
60        ContentView::Examples => "[Examples]",
61        ContentView::Exercise => "[Exercise]",
62        ContentView::FreePractice => "[Free Practice]",
63    };
64
65    // difficulty_filter.FILTER.3 — show active filter in header
66    let filter_label = match app.difficulty_filter {
67        DifficultyFilter::None => String::new(),
68        f => format!("  [{}]", f),
69    };
70
71    let title = Line::from(vec![
72        Span::styled(
73            " CLI Tutor ",
74            crate::ui::s(
75                Style::default()
76                    .fg(Color::Cyan)
77                    .add_modifier(Modifier::BOLD),
78                nc,
79            ),
80        ),
81        Span::styled(&progress_text, crate::ui::s(Style::default().fg(Color::White), nc)),
82        Span::styled("  ", Style::default()),
83        Span::styled(
84            view_label,
85            crate::ui::s(Style::default().fg(Color::Yellow), nc),
86        ),
87        Span::styled(
88            filter_label,
89            crate::ui::s(Style::default().fg(Color::Magenta), nc),
90        ),
91        Span::styled(
92            "  [?] Help  [P] Progress",
93            crate::ui::s(Style::default().fg(Color::DarkGray), nc),
94        ),
95    ]);
96
97    frame.render_widget(
98        Paragraph::new(title)
99            .style(crate::ui::s(Style::default().bg(Color::DarkGray), nc)),
100        area,
101    );
102}
103
104fn render_main(app: &App, frame: &mut Frame, area: Rect) {
105    let cols = Layout::default()
106        .direction(Direction::Horizontal)
107        .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
108        .split(area);
109
110    command_list::render(app, frame, cols[0]);
111    content_pane::render(app, frame, cols[1]);
112}
113
114fn render_footer(app: &App, frame: &mut Frame, area: Rect) {
115    let nc = app.config.no_color;
116    let hints = match app.current_view {
117        ContentView::Intro | ContentView::Examples => {
118            " ↑↓: Module  Tab: Next view  PgUp/PgDn: Scroll  /: Search  d: Filter  q: Quit"
119        }
120        ContentView::Exercise => {
121            " Enter: Submit  ↑↓: History  PgUp/PgDn: Scroll  ^T: Hint  ^S: Solution  ^N/^P: Next/Prev  ^R: Reset  Esc: Back"
122        }
123        ContentView::FreePractice => {
124            " Enter: Run  ↑↓: History  PgUp/PgDn: Scroll  ^L: Clear  Tab/Esc: Back"
125        }
126    };
127
128    frame.render_widget(
129        Paragraph::new(hints)
130            .style(crate::ui::s(Style::default().bg(Color::DarkGray).fg(Color::White), nc)),
131        area,
132    );
133}
134
135fn render_resize_prompt(frame: &mut Frame, area: Rect) {
136    let msg = Paragraph::new("Please resize your terminal (min 80×24)")
137        .style(Style::default().fg(Color::Yellow))
138        .block(Block::default().borders(Borders::ALL));
139    let center = centered_rect(50, 20, area);
140    frame.render_widget(msg, center);
141}
142
143fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
144    let layout = Layout::default()
145        .direction(Direction::Vertical)
146        .constraints([
147            Constraint::Percentage((100 - percent_y) / 2),
148            Constraint::Percentage(percent_y),
149            Constraint::Percentage((100 - percent_y) / 2),
150        ])
151        .split(r);
152    Layout::default()
153        .direction(Direction::Horizontal)
154        .constraints([
155            Constraint::Percentage((100 - percent_x) / 2),
156            Constraint::Percentage(percent_x),
157            Constraint::Percentage((100 - percent_x) / 2),
158        ])
159        .split(layout[1])[1]
160}