Skip to main content

gitkraft_tui/
layout.rs

1use ratatui::layout::{Constraint, Direction, Layout, Rect};
2use ratatui::style::Style;
3use ratatui::widgets::Block;
4use ratatui::Frame;
5
6use crate::app::{App, AppScreen};
7use crate::features;
8use crate::widgets;
9
10/// Main render entry point — called once per frame from the event loop.
11pub fn render(app: &mut App, frame: &mut Frame) {
12    // Paint the entire terminal area with the theme's background colour.
13    // Without this, light themes appear broken because ratatui defaults
14    // unstyled cells to `Color::Reset` (the terminal's own background).
15    let bg = app.theme().bg;
16    frame.render_widget(
17        Block::default().style(Style::default().bg(bg)),
18        frame.area(),
19    );
20
21    match app.screen {
22        AppScreen::Welcome => {
23            features::repo::view::render(&*app, frame, frame.area());
24        }
25        AppScreen::DirBrowser => {
26            features::repo::view::render_browser(app, frame, frame.area());
27        }
28        AppScreen::Main => {
29            render_main(app, frame);
30        }
31    }
32}
33
34/// Render the full Main screen layout with header, content columns, staging
35/// area, and status bar.
36fn render_main(app: &mut App, frame: &mut Frame) {
37    // Show the shimmering skeleton while the initial repo data is loading.
38    // Once commits arrive the real content takes over; background refreshes
39    // (watcher-triggered) use real content + the status-bar spinner instead.
40    let initial_load = app.tab().is_loading && app.tab().commits.is_empty();
41    if initial_load {
42        features::skeleton::render(app, frame);
43        return;
44    }
45    // -- Outer vertical split --
46    //  [0] Header bar          — 3 rows
47    //  [1] Main content area   — flexible
48    //  [2] Staging area        — 12 rows
49    //  [3] Status bar          — 1 row
50    let outer = Layout::default()
51        .direction(Direction::Vertical)
52        .constraints([
53            Constraint::Length(3),
54            Constraint::Percentage(60),
55            Constraint::Percentage(40),
56            Constraint::Length(1),
57        ])
58        .split(frame.area());
59
60    // Header
61    widgets::header::render(app, frame, outer[0]);
62
63    // -- Main content: three columns --
64    //  [0] Sidebar (branches + stashes + remotes) — dynamic width
65    //  [1] Commit log                             — 40 %
66    //  [2] Diff view                              — remainder
67
68    // Compute sidebar width from the longest branch name + padding for
69    // the indicator icon, highlight symbol, and borders (~6 chars overhead).
70    let longest_branch = app
71        .tab()
72        .branches
73        .iter()
74        .map(|b| b.name.chars().count())
75        .max()
76        .unwrap_or(10);
77    // +6 for: 2 (border) + 2 (highlight "▶ ") + 2 (prefix "* " or "⇄ ")
78    let ideal_sidebar = (longest_branch + 6) as u16;
79    let term_width = outer[1].width;
80    // Sidebar gets up to 30% of terminal width, clamped to [22, 50]
81    let max_sidebar = (term_width * 30 / 100).clamp(22, 50);
82    let sidebar_width = ideal_sidebar.min(max_sidebar).max(22);
83
84    let main_cols = Layout::default()
85        .direction(Direction::Horizontal)
86        .constraints([
87            Constraint::Length(sidebar_width),
88            Constraint::Percentage(40),
89            Constraint::Min(20),
90        ])
91        .split(outer[1]);
92
93    // The sidebar is itself split vertically: branches get the lion's share,
94    // with stashes and remotes at the bottom.
95    let sidebar = Layout::default()
96        .direction(Direction::Vertical)
97        .constraints([
98            Constraint::Min(6),    // branches
99            Constraint::Length(5), // stashes
100            Constraint::Length(5), // remotes
101        ])
102        .split(main_cols[0]);
103
104    features::branches::view::render(app, frame, sidebar[0]);
105    features::stash::view::render(app, frame, sidebar[1]);
106    features::remotes::view::render(app, frame, sidebar[2]);
107
108    // Commit log
109    features::commits::view::render(app, frame, main_cols[1]);
110
111    // Compute a full-height overlay rect spanning main_cols[2] down through
112    // the staging area (outer[2]) for theme/options panels.
113    let overlay_rect = Rect {
114        x: main_cols[2].x,
115        y: main_cols[2].y,
116        width: main_cols[2].width,
117        height: main_cols[2].height + outer[2].height,
118    };
119
120    // Diff view OR theme panel OR options panel (full-height overlay)
121    if app.show_theme_panel {
122        features::theme::view::render(app, frame, overlay_rect);
123    } else if app.show_options_panel {
124        features::options::view::render(app, frame, overlay_rect);
125    } else if app.show_editor_panel {
126        features::editor::view::render(app, frame, main_cols[2]);
127    } else if app.tab().file_history_path.is_some() {
128        features::diff::view::render_file_history(app, frame, main_cols[2]);
129    } else if app.tab().blame_path.is_some() {
130        features::diff::view::render_blame(app, frame, main_cols[2]);
131    } else {
132        features::diff::view::render(app, frame, main_cols[2]);
133    }
134
135    // Staging area (only when no overlay is active)
136    if !app.show_theme_panel && !app.show_options_panel {
137        features::staging::view::render(app, frame, outer[2]);
138    } else {
139        // Render staging only for the left 2/3 (unstaged + staged columns)
140        let staging_partial = Rect {
141            x: outer[2].x,
142            y: outer[2].y,
143            width: main_cols[2].x.saturating_sub(outer[2].x),
144            height: outer[2].height,
145        };
146        features::staging::view::render(app, frame, staging_partial);
147    }
148
149    // Status bar
150    widgets::status_bar::render(app, frame, outer[3]);
151}