Files
gitlore/crates/lore-tui/src/layout.rs
teernisse 45a989637c feat(tui): add per-screen responsive layout helpers
Introduce breakpoint-aware helper functions in layout.rs that
centralize per-screen responsive decisions. Each function maps a
Breakpoint to a screen-specific value, replacing scattered
hardcoded checks across view modules:

- detail_side_panel: show discussions side panel at Lg+
- info_screen_columns: 1 column on Xs/Sm, 2 on Md+
- search_show_project: hide project path column on narrow terminals
- timeline_time_width: compact time on Xs (5), full on Md+ (12)
- who_abbreviated_tabs: shorten tab labels on Xs/Sm
- sync_progress_bar_width: scale progress bar 15→50 with width

All functions are const fn with exhaustive match arms.
Includes 6 unit tests covering every breakpoint variant.
2026-02-18 23:59:04 -05:00

237 lines
7.8 KiB
Rust

#![allow(clippy::module_name_repetitions)]
//! Responsive layout helpers for the Lore TUI.
//!
//! Wraps [`ftui::layout::Breakpoint`] and [`ftui::layout::Breakpoints`] with
//! Lore-specific configuration: breakpoint thresholds, column counts per
//! breakpoint, and preview-pane visibility rules.
use ftui::layout::{Breakpoint, Breakpoints};
/// Lore-specific breakpoint thresholds.
///
/// Uses the ftui defaults: Sm=60, Md=90, Lg=120, Xl=160 columns.
pub const LORE_BREAKPOINTS: Breakpoints = Breakpoints::DEFAULT;
/// Classify a terminal width into a [`Breakpoint`].
#[inline]
pub fn classify_width(width: u16) -> Breakpoint {
LORE_BREAKPOINTS.classify_width(width)
}
/// Number of dashboard columns for a given breakpoint.
///
/// - `Xs` / `Sm`: 1 column (narrow terminals)
/// - `Md`: 2 columns (standard width)
/// - `Lg` / `Xl`: 3 columns (wide terminals)
#[inline]
pub const fn dashboard_columns(bp: Breakpoint) -> u16 {
match bp {
Breakpoint::Xs | Breakpoint::Sm => 1,
Breakpoint::Md => 2,
Breakpoint::Lg | Breakpoint::Xl => 3,
}
}
/// Whether the preview pane should be visible at a given breakpoint.
///
/// Preview requires at least `Md` width to avoid cramping the main list.
#[inline]
pub const fn show_preview_pane(bp: Breakpoint) -> bool {
match bp {
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => true,
Breakpoint::Xs | Breakpoint::Sm => false,
}
}
// ---------------------------------------------------------------------------
// Per-screen responsive helpers
// ---------------------------------------------------------------------------
/// Whether detail views (issue/MR) should show a side panel for discussions.
///
/// At `Lg`+ widths, enough room exists for a 60/40 or 50/50 split with
/// description on the left and discussions/cross-refs on the right.
#[inline]
pub const fn detail_side_panel(bp: Breakpoint) -> bool {
match bp {
Breakpoint::Lg | Breakpoint::Xl => true,
Breakpoint::Xs | Breakpoint::Sm | Breakpoint::Md => false,
}
}
/// Number of stat columns for the Stats/Doctor screens.
///
/// - `Xs` / `Sm`: 1 column (full-width stacked)
/// - `Md`+: 2 columns (side-by-side sections)
#[inline]
pub const fn info_screen_columns(bp: Breakpoint) -> u16 {
match bp {
Breakpoint::Xs | Breakpoint::Sm => 1,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 2,
}
}
/// Whether to show the project path column in search results.
///
/// On narrow terminals, the project path is dropped to give the title
/// more room.
#[inline]
pub const fn search_show_project(bp: Breakpoint) -> bool {
match bp {
Breakpoint::Xs | Breakpoint::Sm => false,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => true,
}
}
/// Width allocated for the relative-time column in timeline events.
///
/// Narrow terminals get a compact time (e.g., "2h"), wider terminals
/// get the full relative time (e.g., "2 hours ago").
#[inline]
pub const fn timeline_time_width(bp: Breakpoint) -> u16 {
match bp {
Breakpoint::Xs => 5,
Breakpoint::Sm => 8,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 12,
}
}
/// Whether to use abbreviated mode-tab labels in the Who screen.
///
/// On narrow terminals, tabs are shortened to 3-char abbreviations
/// (e.g., "Exp" instead of "Expert") to fit all 5 modes.
#[inline]
pub const fn who_abbreviated_tabs(bp: Breakpoint) -> bool {
match bp {
Breakpoint::Xs | Breakpoint::Sm => true,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => false,
}
}
/// Width of the progress bar in the Sync screen.
///
/// Scales with terminal width to use available space effectively.
#[inline]
pub const fn sync_progress_bar_width(bp: Breakpoint) -> u16 {
match bp {
Breakpoint::Xs => 15,
Breakpoint::Sm => 25,
Breakpoint::Md => 35,
Breakpoint::Lg | Breakpoint::Xl => 50,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_classify_width_boundaries() {
// Xs: 0..59
assert_eq!(classify_width(59), Breakpoint::Xs);
// Sm: 60..89
assert_eq!(classify_width(60), Breakpoint::Sm);
assert_eq!(classify_width(89), Breakpoint::Sm);
// Md: 90..119
assert_eq!(classify_width(90), Breakpoint::Md);
assert_eq!(classify_width(119), Breakpoint::Md);
// Lg: 120..159
assert_eq!(classify_width(120), Breakpoint::Lg);
assert_eq!(classify_width(159), Breakpoint::Lg);
// Xl: 160+
assert_eq!(classify_width(160), Breakpoint::Xl);
}
#[test]
fn test_dashboard_columns_per_breakpoint() {
assert_eq!(dashboard_columns(Breakpoint::Xs), 1);
assert_eq!(dashboard_columns(Breakpoint::Sm), 1);
assert_eq!(dashboard_columns(Breakpoint::Md), 2);
assert_eq!(dashboard_columns(Breakpoint::Lg), 3);
assert_eq!(dashboard_columns(Breakpoint::Xl), 3);
}
#[test]
fn test_show_preview_pane_per_breakpoint() {
assert!(!show_preview_pane(Breakpoint::Xs));
assert!(!show_preview_pane(Breakpoint::Sm));
assert!(show_preview_pane(Breakpoint::Md));
assert!(show_preview_pane(Breakpoint::Lg));
assert!(show_preview_pane(Breakpoint::Xl));
}
#[test]
fn test_edge_cases() {
// Width 0 must not panic, should classify as Xs
assert_eq!(classify_width(0), Breakpoint::Xs);
// Very wide terminal
assert_eq!(classify_width(300), Breakpoint::Xl);
}
#[test]
fn test_lore_breakpoints_matches_defaults() {
assert_eq!(LORE_BREAKPOINTS, Breakpoints::DEFAULT);
}
// -- Per-screen responsive helpers ----------------------------------------
#[test]
fn test_detail_side_panel() {
assert!(!detail_side_panel(Breakpoint::Xs));
assert!(!detail_side_panel(Breakpoint::Sm));
assert!(!detail_side_panel(Breakpoint::Md));
assert!(detail_side_panel(Breakpoint::Lg));
assert!(detail_side_panel(Breakpoint::Xl));
}
#[test]
fn test_info_screen_columns() {
assert_eq!(info_screen_columns(Breakpoint::Xs), 1);
assert_eq!(info_screen_columns(Breakpoint::Sm), 1);
assert_eq!(info_screen_columns(Breakpoint::Md), 2);
assert_eq!(info_screen_columns(Breakpoint::Lg), 2);
assert_eq!(info_screen_columns(Breakpoint::Xl), 2);
}
#[test]
fn test_search_show_project() {
assert!(!search_show_project(Breakpoint::Xs));
assert!(!search_show_project(Breakpoint::Sm));
assert!(search_show_project(Breakpoint::Md));
assert!(search_show_project(Breakpoint::Lg));
assert!(search_show_project(Breakpoint::Xl));
}
#[test]
fn test_timeline_time_width() {
assert_eq!(timeline_time_width(Breakpoint::Xs), 5);
assert_eq!(timeline_time_width(Breakpoint::Sm), 8);
assert_eq!(timeline_time_width(Breakpoint::Md), 12);
assert_eq!(timeline_time_width(Breakpoint::Lg), 12);
assert_eq!(timeline_time_width(Breakpoint::Xl), 12);
}
#[test]
fn test_who_abbreviated_tabs() {
assert!(who_abbreviated_tabs(Breakpoint::Xs));
assert!(who_abbreviated_tabs(Breakpoint::Sm));
assert!(!who_abbreviated_tabs(Breakpoint::Md));
assert!(!who_abbreviated_tabs(Breakpoint::Lg));
assert!(!who_abbreviated_tabs(Breakpoint::Xl));
}
#[test]
fn test_sync_progress_bar_width() {
assert_eq!(sync_progress_bar_width(Breakpoint::Xs), 15);
assert_eq!(sync_progress_bar_width(Breakpoint::Sm), 25);
assert_eq!(sync_progress_bar_width(Breakpoint::Md), 35);
assert_eq!(sync_progress_bar_width(Breakpoint::Lg), 50);
assert_eq!(sync_progress_bar_width(Breakpoint::Xl), 50);
}
}