Skip to main content

gitkraft_gui/
layout.rs

1//! Responsive layout: pure functions mapping window size + stored pane sizes
2//! to the sizes actually rendered. Stored sizes are never modified.
3
4/// Below this window width the sidebar is hidden (or floated as an overlay).
5pub const NARROW_WIDTH: f32 = 720.0;
6/// Below this width toolbars switch to icon-only buttons.
7pub const COMPACT_WIDTH: f32 = 1000.0;
8/// Below this width secondary toolbar widgets (editor picker, version) go away.
9pub const MINIMAL_WIDTH: f32 = 800.0;
10/// Below this width even the theme picker is dropped.
11pub const TINY_WIDTH: f32 = 520.0;
12
13/// Max sidebar share of window width.
14pub const SIDEBAR_MAX_FRAC: f32 = 0.35;
15/// Max commit-log share of width remaining after the sidebar.
16pub const COMMIT_MAX_FRAC: f32 = 0.55;
17/// Max staging-area share of window height.
18pub const STAGING_MAX_FRAC: f32 = 0.5;
19/// Max staging share on short windows, so the commit log keeps room.
20pub const STAGING_MAX_FRAC_SHORT: f32 = 0.4;
21/// Windows shorter than this use [`STAGING_MAX_FRAC_SHORT`].
22pub const SHORT_HEIGHT: f32 = 640.0;
23/// Smallest useful staging height.
24pub const STAGING_MIN: f32 = 110.0;
25
26/// Width reserved for the diff viewer, however small the window.
27pub const DIFF_MIN_WIDTH: f32 = 280.0;
28/// Narrowest commit log we ever render.
29pub const COMMIT_MIN_WIDTH: f32 = 160.0;
30/// Width of a vertical divider.
31pub const DIVIDER_W: f32 = 6.0;
32/// Max share of the diff pane taken by its file list.
33pub const DIFF_LIST_MAX_FRAC: f32 = 0.4;
34
35/// Fixed width of the author sub-column in the commit log.
36pub const AUTHOR_COLUMN_PX: f32 = 110.0;
37/// Fixed width of the date sub-column in the commit log.
38pub const TIME_COLUMN_PX: f32 = 72.0;
39/// Commit-log width from which the author column is shown.
40pub const SHOW_AUTHOR_FROM: f32 = 380.0;
41/// Commit-log width from which the date column is shown.
42pub const SHOW_TIME_FROM: f32 = 480.0;
43
44/// Coarse window-width class.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Breakpoint {
47    /// < [`NARROW_WIDTH`]
48    Narrow,
49    /// < [`COMPACT_WIDTH`]
50    Medium,
51    /// everything wider
52    Wide,
53}
54
55impl Breakpoint {
56    pub fn of(window_w: f32) -> Self {
57        if window_w < NARROW_WIDTH {
58            Self::Narrow
59        } else if window_w < COMPACT_WIDTH {
60            Self::Medium
61        } else {
62            Self::Wide
63        }
64    }
65}
66
67/// Pane sizes to render.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct LayoutDims {
70    pub breakpoint: Breakpoint,
71    /// Sidebar docked beside the panes.
72    pub show_sidebar: bool,
73    /// Sidebar expanded but window narrow: float it over the panes.
74    pub overlay_sidebar: bool,
75    pub sidebar_width: f32,
76    pub commit_log_width: f32,
77    /// Width left for the diff viewer.
78    pub diff_width: f32,
79    /// Width of the file list inside the diff viewer.
80    pub diff_file_list_width: f32,
81    pub staging_height: f32,
82    /// Stack the commit box under the staged list instead of a third column.
83    pub staging_stacked: bool,
84    /// Toolbars show icons only.
85    pub compact_toolbar: bool,
86    /// Drop secondary toolbar widgets (editor picker, version label).
87    pub minimal_toolbar: bool,
88    /// Drop even the theme picker.
89    pub tiny_toolbar: bool,
90    /// Short window: tighten vertical padding.
91    pub compact_vertical: bool,
92}
93
94impl LayoutDims {
95    pub fn compute(
96        window_w: f32,
97        window_h: f32,
98        sidebar_expanded: bool,
99        sidebar_w: f32,
100        commit_w: f32,
101        staging_h: f32,
102        diff_list_w: f32,
103    ) -> Self {
104        let window_w = window_w.max(0.0);
105        let window_h = window_h.max(0.0);
106        let show_sidebar = sidebar_expanded && window_w >= NARROW_WIDTH;
107        let overlay_sidebar = sidebar_expanded && !show_sidebar;
108        let sidebar_width = sidebar_w.min(window_w * SIDEBAR_MAX_FRAC).max(0.0);
109        let used = if show_sidebar {
110            sidebar_width + DIVIDER_W
111        } else {
112            0.0
113        };
114        let avail = (window_w - used).max(0.0);
115
116        // Commit log: at most 55% of what is left, and always leave the diff
117        // viewer its minimum; never collapse below a readable minimum.
118        let cap = (avail * COMMIT_MAX_FRAC).min(avail - DIVIDER_W - DIFF_MIN_WIDTH);
119        let floor = COMMIT_MIN_WIDTH.min(avail * 0.6);
120        let commit_log_width = commit_w.min(cap).max(floor).min(avail);
121        let diff_width = (avail - commit_log_width - DIVIDER_W).max(0.0);
122
123        let staging_frac = if window_h < SHORT_HEIGHT {
124            STAGING_MAX_FRAC_SHORT
125        } else {
126            STAGING_MAX_FRAC
127        };
128        let staging_cap = window_h * staging_frac;
129        let staging_height = staging_h
130            .min(staging_cap)
131            .max(STAGING_MIN.min(staging_cap))
132            .max(0.0);
133
134        Self {
135            breakpoint: Breakpoint::of(window_w),
136            show_sidebar,
137            overlay_sidebar,
138            sidebar_width,
139            commit_log_width,
140            diff_width,
141            diff_file_list_width: diff_list_w.min(diff_width * DIFF_LIST_MAX_FRAC).max(0.0),
142            staging_height,
143            staging_stacked: window_w < NARROW_WIDTH,
144            compact_toolbar: window_w < COMPACT_WIDTH,
145            minimal_toolbar: window_w < MINIMAL_WIDTH,
146            tiny_toolbar: window_w < TINY_WIDTH,
147            compact_vertical: window_h < SHORT_HEIGHT,
148        }
149    }
150}
151
152/// Clamp a dialog dimension so it fits the window with a margin.
153pub fn clamp_dialog(want: f32, window: f32) -> f32 {
154    want.min((window - 32.0).max(120.0))
155}
156
157/// Commit-log column sizes for a given pane width.
158#[derive(Debug, Clone, Copy, PartialEq)]
159pub struct CommitColumns {
160    pub ref_w: f32,
161    pub graph_w: f32,
162    pub show_author: bool,
163    pub show_time: bool,
164    /// Pixels left for the commit summary text.
165    pub msg_px: f32,
166}
167
168impl CommitColumns {
169    pub fn compute(commit_w: f32, ref_stored: f32, graph_stored: f32) -> Self {
170        let commit_w = commit_w.max(0.0);
171        let show_author = commit_w >= SHOW_AUTHOR_FROM;
172        let show_time = commit_w >= SHOW_TIME_FROM;
173        let ref_w = ref_stored.min(commit_w * 0.28).max(0.0);
174        let graph_w = graph_stored.min(commit_w * 0.22).max(0.0);
175        let mut fixed = ref_w + DIVIDER_W + graph_w + DIVIDER_W + 30.0;
176        if show_author {
177            fixed += AUTHOR_COLUMN_PX + 6.0;
178        }
179        if show_time {
180            fixed += TIME_COLUMN_PX + 6.0;
181        }
182        Self {
183            ref_w,
184            graph_w,
185            show_author,
186            show_time,
187            msg_px: (commit_w - fixed).max(60.0),
188        }
189    }
190}
191
192impl crate::state::GitKraft {
193    /// Pane sizes to render for the current window and stored sizes.
194    pub fn layout(&self) -> LayoutDims {
195        LayoutDims::compute(
196            self.window_width,
197            self.window_height,
198            self.sidebar_expanded,
199            self.sidebar_width,
200            self.commit_log_width,
201            self.staging_height,
202            self.diff_file_list_width,
203        )
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    #[test]
210    fn short_window_is_vertically_compact() {
211        let d = |h| LayoutDims::compute(1200.0, h, true, 250.0, 600.0, 300.0, 200.0);
212        assert!(d(SHORT_HEIGHT - 1.0).compact_vertical);
213        assert!(!d(SHORT_HEIGHT).compact_vertical);
214    }
215
216    #[test]
217    fn dialog_fits_window() {
218        assert_eq!(super::clamp_dialog(1100.0, 2000.0), 1100.0);
219        assert_eq!(super::clamp_dialog(1100.0, 600.0), 568.0);
220        assert_eq!(super::clamp_dialog(700.0, 50.0), 120.0);
221    }
222
223    use super::*;
224
225    fn dims(w: f32, h: f32, exp: bool) -> LayoutDims {
226        LayoutDims::compute(w, h, exp, 250.0, 600.0, 300.0, 180.0)
227    }
228
229    #[test]
230    fn wide_window_keeps_stored_sizes() {
231        let d = dims(1920.0, 1080.0, true);
232        assert!(d.show_sidebar);
233        assert_eq!(d.sidebar_width, 250.0);
234        assert_eq!(d.commit_log_width, 600.0);
235        assert_eq!(d.staging_height, 300.0);
236        assert_eq!(d.diff_file_list_width, 180.0);
237        assert!(!d.compact_toolbar && !d.staging_stacked);
238    }
239
240    #[test]
241    fn narrow_window_hides_sidebar() {
242        assert!(!dims(NARROW_WIDTH - 1.0, 800.0, true).show_sidebar);
243        assert!(dims(NARROW_WIDTH, 800.0, true).show_sidebar);
244    }
245
246    #[test]
247    fn narrow_expanded_sidebar_overlays() {
248        let d = dims(600.0, 800.0, true);
249        assert!(d.overlay_sidebar && !d.show_sidebar);
250        assert!(!dims(1200.0, 800.0, true).overlay_sidebar);
251        assert!(!dims(600.0, 800.0, false).overlay_sidebar);
252    }
253
254    #[test]
255    fn collapsed_sidebar_stays_hidden() {
256        assert!(!dims(1920.0, 1080.0, false).show_sidebar);
257    }
258
259    #[test]
260    fn sidebar_and_commit_clamped() {
261        let d = LayoutDims::compute(800.0, 800.0, true, 400.0, 900.0, 300.0, 180.0);
262        assert_eq!(d.sidebar_width, 280.0); // capped: 35% of 800
263        assert!(d.commit_log_width <= (800.0 - d.sidebar_width) * COMMIT_MAX_FRAC);
264    }
265
266    #[test]
267    fn diff_always_keeps_minimum_width() {
268        for w in [721.0, 800.0, 1000.0, 1280.0, 1600.0] {
269            let d = dims(w, 900.0, true);
270            assert!(
271                d.diff_width >= DIFF_MIN_WIDTH - 0.01,
272                "w={w} diff={}",
273                d.diff_width
274            );
275        }
276        let d = dims(600.0, 800.0, true); // sidebar floats; full width shared
277        assert!(d.diff_width >= DIFF_MIN_WIDTH - 0.01);
278    }
279
280    #[test]
281    fn panes_add_up_to_window() {
282        for w in [400.0, 600.0, 900.0, 1400.0] {
283            let d = dims(w, 800.0, true);
284            let sb = if d.show_sidebar {
285                d.sidebar_width + DIVIDER_W
286            } else {
287                0.0
288            };
289            let total = sb + d.commit_log_width + DIVIDER_W + d.diff_width;
290            assert!(total <= w + 0.01, "w={w} total={total}");
291        }
292    }
293
294    #[test]
295    fn diff_file_list_limited_to_share_of_diff() {
296        let d = LayoutDims::compute(800.0, 800.0, false, 250.0, 300.0, 300.0, 400.0);
297        assert!(d.diff_file_list_width <= d.diff_width * DIFF_LIST_MAX_FRAC + 0.01);
298    }
299
300    #[test]
301    fn staging_clamped_with_floor() {
302        assert_eq!(dims(1200.0, 400.0, true).staging_height, 160.0); // short: 40%
303        assert_eq!(dims(1200.0, 800.0, true).staging_height, 300.0);
304        let d = LayoutDims::compute(1200.0, 1000.0, true, 250.0, 600.0, 20.0, 180.0);
305        assert_eq!(d.staging_height, STAGING_MIN);
306    }
307
308    #[test]
309    fn toolbar_modes_by_width() {
310        assert!(!dims(1200.0, 800.0, true).compact_toolbar);
311        let d = dims(900.0, 800.0, true);
312        assert!(d.compact_toolbar && !d.minimal_toolbar);
313        let d = dims(700.0, 800.0, true);
314        assert!(d.minimal_toolbar && d.staging_stacked && !d.tiny_toolbar);
315        assert!(dims(500.0, 800.0, true).tiny_toolbar);
316    }
317
318    #[test]
319    fn breakpoints() {
320        assert_eq!(Breakpoint::of(500.0), Breakpoint::Narrow);
321        assert_eq!(Breakpoint::of(900.0), Breakpoint::Medium);
322        assert_eq!(Breakpoint::of(1400.0), Breakpoint::Wide);
323    }
324
325    #[test]
326    fn tiny_window_never_negative() {
327        let d = dims(0.0, 0.0, true);
328        assert!(d.sidebar_width >= 0.0 && d.commit_log_width >= 0.0);
329        assert!(d.staging_height >= 0.0 && d.diff_width >= 0.0 && d.diff_file_list_width >= 0.0);
330    }
331
332    #[test]
333    fn commit_columns_drop_as_pane_shrinks() {
334        let wide = CommitColumns::compute(700.0, 120.0, 100.0);
335        assert!(wide.show_author && wide.show_time);
336        let mid = CommitColumns::compute(420.0, 120.0, 100.0);
337        assert!(mid.show_author && !mid.show_time);
338        let small = CommitColumns::compute(300.0, 120.0, 100.0);
339        assert!(!small.show_author && !small.show_time);
340        assert!(small.ref_w <= 300.0 * 0.28 + 0.01 && small.graph_w <= 300.0 * 0.22 + 0.01);
341        assert!(small.msg_px >= 60.0);
342    }
343
344    #[test]
345    fn commit_message_gets_room_when_wide() {
346        let c = CommitColumns::compute(900.0, 120.0, 100.0);
347        assert_eq!(c.ref_w, 120.0);
348        assert!(c.msg_px > 300.0);
349    }
350}