use ratatui::{
layout::{Constraint, Layout, Rect},
style::Style,
text::{Line, Span},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::theme;
use crate::app::App;
pub(crate) fn display_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
pub(crate) fn truncate_to_width(s: &str, max_width: usize) -> String {
let mut width = 0;
let mut result = String::new();
for c in s.chars() {
let w = UnicodeWidthChar::width(c).unwrap_or(0);
if width + w > max_width {
break;
}
result.push(c);
width += w;
}
result
}
pub(crate) fn skip_width(s: &str, skip: usize) -> String {
if skip == 0 {
return s.to_string();
}
let mut width = 0;
let mut byte_offset = 0;
for c in s.chars() {
let w = UnicodeWidthChar::width(c).unwrap_or(0);
if width + w > skip {
break;
}
width += w;
byte_offset += c.len_utf8();
if width >= skip {
break;
}
}
s[byte_offset..].to_string()
}
pub(crate) fn truncate_to_width_with_ellipsis(s: &str, max_width: usize) -> String {
let s_width = display_width(s);
if s_width <= max_width {
return s.to_string();
}
if max_width <= 3 {
return truncate_to_width(s, max_width);
}
let truncated = truncate_to_width(s, max_width.saturating_sub(3));
format!("{}...", truncated)
}
pub(crate) fn truncate_line_to_width(line: &Line, max_width: usize) -> Line<'static> {
if max_width == 0 {
return Line::from("");
}
let mut remaining = max_width;
let mut spans = Vec::new();
for span in &line.spans {
if remaining == 0 {
break;
}
let text = span.content.as_ref();
let text_width = display_width(text);
if text_width <= remaining {
spans.push(Span::styled(text.to_string(), span.style));
remaining = remaining.saturating_sub(text_width);
continue;
}
let mut out = String::new();
let mut used = 0usize;
for ch in text.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + w > remaining {
break;
}
out.push(ch);
used += w;
}
if !out.is_empty() {
spans.push(Span::styled(out, span.style));
}
break;
}
Line::from(spans)
}
pub(crate) fn fit_lines_to_inner_area(
lines: Vec<Line<'static>>,
inner_width: usize,
inner_height: usize,
) -> Vec<Line<'static>> {
lines
.into_iter()
.take(inner_height)
.map(|line| truncate_line_to_width(&line, inner_width))
.collect()
}
pub(crate) fn centered_overlay_rect(area: Rect, desired_width: u16, desired_height: u16) -> Rect {
let width = desired_width.max(1).min(area.width.max(1));
let height = desired_height.max(1).min(area.height.max(1));
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width, height)
}
pub(crate) const FOOTER_HEIGHT: u16 = 3;
pub(crate) const OVERLAY_BG_COLOR: ratatui::style::Color = theme::SURFACE0;
pub(crate) const OVERLAY_MIN_WIDTH: u16 = 24;
pub(crate) const OVERLAY_MARGIN: u16 = 2;
pub(crate) const DETAIL_OVERLAY_HEIGHT: u16 = 20;
pub(crate) const BRANCH_OVERLAY_MIN_WIDTH: u16 = 20;
pub(crate) const BRANCH_OVERLAY_PADDING: u16 = 6;
pub(crate) const STATUS_MIN_PATH_LEN: usize = 15;
pub(crate) const STATUS_OVERLAY_PADDING: u16 = 12;
pub(crate) const OVERLAY_BORDER_HEIGHT: u16 = 2;
pub(crate) const OVERLAY_MIN_HEIGHT: u16 = 4;
pub(crate) const OVERLAY_WIDTH_PERCENT_STANDARD: u16 = 80;
const OVERLAY_HEIGHT_PERCENT_STANDARD: u16 = 80;
const OVERLAY_BORDER_WIDTH: u16 = 2;
pub(crate) fn compute_overlay_size_standard(area: Rect) -> (u16, u16, u16, u16) {
let width = (area.width * OVERLAY_WIDTH_PERCENT_STANDARD / 100)
.max(OVERLAY_MIN_WIDTH)
.min(area.width.saturating_sub(4));
let height =
(area.height * OVERLAY_HEIGHT_PERCENT_STANDARD / 100).min(area.height.saturating_sub(4));
let x = (area.width.saturating_sub(width)) / 2;
let y = (area.height.saturating_sub(height)) / 2;
(x, y, width, height)
}
pub(crate) const BRANCH_MARKER_SELECTED: &str = "▶ ";
pub(crate) const BRANCH_MARKER_UNSELECTED: &str = " ";
pub(crate) const CURRENT_BRANCH_MARKER: &str = "● ";
pub(crate) const NOT_CURRENT_BRANCH_MARKER: &str = " ";
pub struct LayoutAreas {
pub footer: Rect,
pub sidebar_panels: [Rect; 5],
pub dashboard_main: Rect,
}
pub fn calculate_layout_areas(area: Rect, app: &App) -> LayoutAreas {
let [main_area, footer] =
Layout::vertical([Constraint::Min(0), Constraint::Length(FOOTER_HEIGHT)]).areas(area);
let sidebar_ratio = app.sidebar_width_ratio as u32;
let sidebar_width = (main_area.width as u32 * sidebar_ratio / 100) as u16;
let sidebar_width = sidebar_width.max(20).min(main_area.width / 2);
let main_width = main_area.width.saturating_sub(sidebar_width);
let [sidebar_area, dashboard_main_area] = Layout::horizontal([
Constraint::Length(sidebar_width),
Constraint::Length(main_width),
])
.areas(main_area);
let remaining_height = sidebar_area.height.saturating_sub(3);
let commits_h = (remaining_height as u32 * 40 / 100) as u16;
let branches_h = (remaining_height as u32 * 20 / 100) as u16;
let files_h = (remaining_height as u32 * 25 / 100) as u16;
let stash_h = remaining_height.saturating_sub(commits_h + branches_h + files_h);
let [status_area, commits_area, branches_area, files_area, stash_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(commits_h),
Constraint::Length(branches_h),
Constraint::Length(files_h),
Constraint::Length(stash_h),
])
.areas(sidebar_area);
LayoutAreas {
footer,
sidebar_panels: [
status_area,
commits_area,
branches_area,
files_area,
stash_area,
],
dashboard_main: dashboard_main_area,
}
}
pub(crate) struct OverlayContext {
pub area: Rect,
pub inner_width: usize,
}
impl OverlayContext {
pub fn standard(frame: &ratatui::Frame) -> Self {
let screen = frame.area();
let (x, y, width, height) = compute_overlay_size_standard(screen);
let area = Rect::new(x, y, width, height);
let inner_width = width.saturating_sub(OVERLAY_BORDER_WIDTH) as usize;
Self { area, inner_width }
}
pub fn custom(frame: &ratatui::Frame, width: u16, height: u16) -> Self {
let screen = frame.area();
let w = width
.max(OVERLAY_MIN_WIDTH)
.min(screen.width.saturating_sub(OVERLAY_MARGIN * 2));
let h = height.min(screen.height.saturating_sub(OVERLAY_MARGIN * 2));
let x = (screen.width.saturating_sub(w)) / 2;
let y = (screen.height.saturating_sub(h)) / 2;
let area = Rect::new(x, y, w, h);
let inner_width = w.saturating_sub(OVERLAY_BORDER_WIDTH) as usize;
Self { area, inner_width }
}
pub fn clear(&self, frame: &mut ratatui::Frame) -> Rect {
frame.render_widget(ratatui::widgets::Clear, self.area);
self.area
}
}
pub(crate) fn score_bar_spans(ratio: f64, width: usize) -> Vec<Span<'static>> {
let filled = (ratio * width as f64).round() as usize;
let empty = width.saturating_sub(filled);
let pct = (ratio * 100.0).round() as u8;
let color = match pct {
80..=100 => theme::GREEN,
60..=79 => theme::TEAL,
40..=59 => theme::YELLOW,
20..=39 => theme::PEACH,
_ => theme::RED,
};
vec![
Span::styled("\u{2588}".repeat(filled), Style::default().fg(color)),
Span::styled(
"\u{2591}".repeat(empty),
Style::default().fg(theme::OVERLAY0),
),
]
}
pub(crate) fn detail_separator_line(width: u16) -> Line<'static> {
Line::from(Span::styled(
"\u{2500}".repeat(width.saturating_sub(2) as usize),
Style::default().fg(theme::OVERLAY0),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_centered_overlay_rect_clamps_to_area() {
let area = Rect::new(0, 0, 20, 6);
let rect = centered_overlay_rect(area, 50, 20);
assert_eq!(rect.width, 20);
assert_eq!(rect.height, 6);
}
#[test]
fn test_fit_lines_to_inner_area_truncates_and_limits_height() {
let lines = vec![
Line::from("123456789"),
Line::from("abcdefghi"),
Line::from("zzzzzzzzz"),
];
let fitted = fit_lines_to_inner_area(lines, 5, 2);
assert_eq!(fitted.len(), 2);
assert_eq!(display_width(fitted[0].spans[0].content.as_ref()), 5);
assert_eq!(display_width(fitted[1].spans[0].content.as_ref()), 5);
}
#[test]
fn test_display_width_ascii() {
assert_eq!(display_width("hello"), 5);
assert_eq!(display_width("abc123"), 6);
}
#[test]
fn test_display_width_cjk() {
assert_eq!(display_width("日本語"), 6);
assert_eq!(display_width("こんにちは"), 10);
}
#[test]
fn test_display_width_mixed() {
assert_eq!(display_width("Hello世界"), 9); assert_eq!(display_width("abc日本語def"), 12); }
#[test]
fn test_truncate_to_width_ascii() {
assert_eq!(truncate_to_width("hello world", 5), "hello");
assert_eq!(truncate_to_width("hello", 10), "hello");
}
#[test]
fn test_truncate_to_width_cjk() {
assert_eq!(truncate_to_width("日本語テスト", 6), "日本語");
assert_eq!(truncate_to_width("こんにちは", 4), "こん");
}
#[test]
fn test_truncate_to_width_mixed() {
assert_eq!(truncate_to_width("Hello世界", 7), "Hello世"); assert_eq!(truncate_to_width("abc日本語", 5), "abc日"); }
#[test]
fn test_truncate_to_width_boundary() {
assert_eq!(truncate_to_width("日本語", 3), "日"); assert_eq!(truncate_to_width("日本語", 5), "日本"); }
#[test]
fn test_truncate_to_width_with_ellipsis_no_truncation() {
assert_eq!(truncate_to_width_with_ellipsis("short", 10), "short");
assert_eq!(truncate_to_width_with_ellipsis("日本語", 10), "日本語");
}
#[test]
fn test_truncate_to_width_with_ellipsis_ascii() {
assert_eq!(
truncate_to_width_with_ellipsis("hello world", 8),
"hello..."
);
}
#[test]
fn test_truncate_to_width_with_ellipsis_cjk() {
assert_eq!(
truncate_to_width_with_ellipsis("日本語テスト", 9),
"日本語..."
); assert_eq!(
truncate_to_width_with_ellipsis("こんにちは世界", 9),
"こんに..."
); }
#[test]
fn test_truncate_to_width_with_ellipsis_small_width() {
assert_eq!(truncate_to_width_with_ellipsis("hello", 3), "hel");
assert_eq!(truncate_to_width_with_ellipsis("日本語", 2), "日");
}
#[test]
fn test_display_width_empty() {
assert_eq!(display_width(""), 0);
}
#[test]
fn test_truncate_to_width_empty() {
assert_eq!(truncate_to_width("", 10), "");
assert_eq!(truncate_to_width("hello", 0), "");
}
#[test]
fn test_truncate_to_width_with_ellipsis_empty() {
assert_eq!(truncate_to_width_with_ellipsis("", 10), "");
assert_eq!(truncate_to_width_with_ellipsis("hello", 0), "");
}
#[test]
fn test_truncate_to_width_with_ellipsis_exact_fit() {
assert_eq!(truncate_to_width_with_ellipsis("hello", 5), "hello");
assert_eq!(truncate_to_width_with_ellipsis("日本語", 6), "日本語");
}
#[test]
fn test_calculate_layout_areas_dashboard() {
let app = App::new();
let area = Rect::new(0, 0, 150, 30);
let layout = calculate_layout_areas(area, &app);
assert_eq!(layout.sidebar_panels.len(), 5);
assert!(layout.dashboard_main.width > 0);
assert_eq!(layout.footer.height, FOOTER_HEIGHT);
}
#[test]
fn test_overlay_standard_size_centered() {
let area = Rect::new(0, 0, 100, 50);
let (x, y, w, h) = compute_overlay_size_standard(area);
assert_eq!(w, 80); assert_eq!(h, 40); assert_eq!(x, 10); assert_eq!(y, 5); }
#[test]
fn test_overlay_standard_size_small_terminal() {
let area = Rect::new(0, 0, 40, 20);
let (x, y, w, h) = compute_overlay_size_standard(area);
assert!(w >= OVERLAY_MIN_WIDTH);
assert!(w <= area.width);
assert!(h <= area.height);
assert_eq!(x, (area.width - w) / 2);
assert_eq!(y, (area.height - h) / 2);
}
#[test]
fn test_overlay_standard_min_width_enforced() {
let area = Rect::new(0, 0, 28, 20);
let (_x, _y, w, _h) = compute_overlay_size_standard(area);
assert_eq!(w, OVERLAY_MIN_WIDTH);
}
#[test]
fn test_overlay_standard_very_small_terminal() {
let area = Rect::new(0, 0, 20, 10);
let (x, y, w, h) = compute_overlay_size_standard(area);
assert!(w <= area.width);
assert!(h <= area.height);
assert!(x + w <= area.width);
assert!(y + h <= area.height);
}
}