use ratatui::{Frame, prelude::*, text::Line, widgets::Paragraph};
use crate::keys::KeyHint;
use crate::ui::views::{BlameView, DiffView};
fn hint_to_span(hint: &KeyHint) -> Span<'static> {
Span::styled(
format!(" [{}] {} ", hint.key, hint.label),
Style::default().fg(Color::Black).bg(hint.color),
)
}
fn hint_width(hint: &KeyHint) -> usize {
hint.key.len() + hint.label.len() + 5
}
fn total_hints_width(hints: &[KeyHint]) -> usize {
hints.iter().enumerate().fold(0, |acc, (i, hint)| {
acc + hint_width(hint) + if i > 0 { 1 } else { 0 }
})
}
fn build_line(hints: &[KeyHint]) -> Line<'static> {
let mut spans = Vec::with_capacity(hints.len() * 2);
for (i, hint) in hints.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
spans.push(hint_to_span(hint));
}
Line::from(spans)
}
fn build_content(hints: &[KeyHint], width: u16) -> Vec<Line<'static>> {
let width = width as usize;
if total_hints_width(hints) <= width {
return vec![build_line(hints)];
}
let mut first_row_width = 0;
let mut split_index = hints.len();
for (i, hint) in hints.iter().enumerate() {
let w = hint_width(hint) + if i > 0 { 1 } else { 0 };
if first_row_width + w > width {
split_index = i;
break;
}
first_row_width += w;
}
let split_index = split_index.max(1);
let (first_hints, second_hints) = hints.split_at(split_index);
vec![
build_line(first_hints),
Line::from(""), build_line(second_hints),
]
}
pub fn build_status_bar_with_prefix(
prefix: Vec<Span<'static>>,
hints: &[KeyHint],
) -> Line<'static> {
let mut spans = prefix;
for hint in hints {
spans.push(Span::raw(" "));
spans.push(hint_to_span(hint));
}
Line::from(spans)
}
pub fn status_hints_height(hints: &[KeyHint], width: u16) -> u16 {
if total_hints_width(hints) > width as usize {
3 } else {
1
}
}
fn status_bar_area_h(frame: &Frame, height: u16) -> Option<Rect> {
let area = frame.area();
if area.height < 2 {
return None;
}
let actual_height = if area.height < height + 1 { 1 } else { height };
Some(Rect {
x: area.x,
y: area.y + area.height - actual_height,
width: area.width,
height: actual_height,
})
}
fn status_bar_area(frame: &Frame, hints: &[KeyHint]) -> Option<Rect> {
status_bar_area_h(frame, status_hints_height(hints, frame.area().width))
}
fn prefix_width(prefix: &[Span]) -> usize {
prefix
.iter()
.map(|s| crate::ui::text::display_width(&s.content))
.sum()
}
fn prefixed_hints_width(hints: &[KeyHint]) -> usize {
hints.iter().map(|h| 1 + hint_width(h)).sum()
}
fn prefixed_height(prefix_w: usize, hints: &[KeyHint], width: u16) -> u16 {
if prefix_w + prefixed_hints_width(hints) <= width as usize {
1
} else {
3
}
}
fn build_prefixed_content(
prefix: Vec<Span<'static>>,
prefix_w: usize,
hints: &[KeyHint],
width: u16,
) -> Vec<Line<'static>> {
let width = width as usize;
if prefix_w + prefixed_hints_width(hints) <= width {
return vec![build_status_bar_with_prefix(prefix, hints)];
}
let mut row_w = prefix_w;
let mut split = 0;
for (i, hint) in hints.iter().enumerate() {
let w = 1 + hint_width(hint);
if row_w + w > width {
break;
}
row_w += w;
split = i + 1;
}
let (first, second) = hints.split_at(split);
vec![
build_status_bar_with_prefix(prefix, first),
Line::from(""),
build_line(second),
]
}
pub fn render_status_hints(frame: &mut Frame, hints: &[KeyHint]) {
let Some(status_area) = status_bar_area(frame, hints) else {
return;
};
let content = if status_area.height >= 3 {
build_content(hints, status_area.width)
} else {
vec![build_line(hints)]
};
frame.render_widget(Paragraph::new(content), status_area);
}
pub fn render_diff_status_bar(frame: &mut Frame, diff_view: &DiffView) {
let hints = crate::keys::DIFF_VIEW_HINTS;
let prefix = diff_prefix(diff_view);
let prefix_w = prefix_width(&prefix);
let height = prefixed_height(prefix_w, hints, frame.area().width);
let Some(area) = status_bar_area_h(frame, height) else {
return;
};
let content = if area.height >= 3 {
build_prefixed_content(prefix, prefix_w, hints, area.width)
} else {
vec![build_status_bar_with_prefix(prefix, hints)]
};
frame.render_widget(Paragraph::new(content), area);
}
pub fn render_blame_status_bar(frame: &mut Frame, blame_view: &BlameView) {
let hints = crate::keys::BLAME_VIEW_HINTS;
let prefix = blame_prefix(blame_view);
let prefix_w = prefix_width(&prefix);
let height = prefixed_height(prefix_w, hints, frame.area().width);
let Some(area) = status_bar_area_h(frame, height) else {
return;
};
let content = if area.height >= 3 {
build_prefixed_content(prefix, prefix_w, hints, area.width)
} else {
vec![build_status_bar_with_prefix(prefix, hints)]
};
frame.render_widget(Paragraph::new(content), area);
}
fn diff_prefix(diff_view: &DiffView) -> Vec<Span<'static>> {
let context = diff_view.current_context();
vec![
Span::styled(
format!(" {} ", diff_view.revision),
Style::default().fg(Color::Black).bg(Color::Yellow),
),
Span::raw(" "),
Span::styled(format!(" {} ", context), Style::default().fg(Color::Cyan)),
]
}
fn blame_prefix(blame_view: &BlameView) -> Vec<Span<'static>> {
vec![
Span::styled(
format!(" {} ", blame_view.file_path()),
Style::default().fg(Color::Black).bg(Color::Yellow),
),
Span::raw(" "),
]
}
pub fn diff_status_height(diff_view: &DiffView, width: u16) -> u16 {
let prefix = diff_prefix(diff_view);
prefixed_height(prefix_width(&prefix), crate::keys::DIFF_VIEW_HINTS, width)
}
pub fn blame_status_height(blame_view: &BlameView, width: u16) -> u16 {
let prefix = blame_prefix(blame_view);
prefixed_height(prefix_width(&prefix), crate::keys::BLAME_VIEW_HINTS, width)
}
#[cfg(test)]
mod tests {
use super::*;
fn hints3() -> &'static [KeyHint] {
&[
KeyHint {
key: "j",
label: "Down",
color: Color::Cyan,
},
KeyHint {
key: "k",
label: "Up",
color: Color::Cyan,
},
KeyHint {
key: "q",
label: "Back",
color: Color::Red,
},
]
}
#[test]
fn prefixed_height_one_row_when_it_fits() {
assert_eq!(prefixed_height(10, hints3(), 200), 1);
}
#[test]
fn prefixed_height_three_rows_when_prefix_overflows() {
let hints = hints3();
let hints_w = prefixed_hints_width(hints);
let width = (hints_w + 5) as u16; assert_eq!(status_hints_height(hints, width), 1);
assert_eq!(prefixed_height(40, hints, width), 3);
}
#[test]
fn build_prefixed_content_wraps_to_three_lines() {
let hints = hints3();
let prefix = vec![Span::raw("X".repeat(40))];
let width = (prefixed_hints_width(hints) + 5) as u16;
let lines = build_prefixed_content(prefix, 40, hints, width);
assert_eq!(lines.len(), 3, "prefix + hints wrap: row, spacer, row");
assert_eq!(lines[1].width(), 0, "middle line is the spacer");
}
#[test]
fn build_prefixed_content_single_line_when_fits() {
let hints = hints3();
let prefix = vec![Span::raw(" rev ")];
let lines = build_prefixed_content(prefix, 5, hints, 200);
assert_eq!(lines.len(), 1);
}
#[test]
fn test_hint_to_span() {
let hint = KeyHint {
key: "q",
label: "Quit",
color: Color::Red,
};
let span = hint_to_span(&hint);
assert!(span.content.contains("[q]"));
assert!(span.content.contains("Quit"));
}
#[test]
fn test_hint_width() {
let hint = KeyHint {
key: "q",
label: "Quit",
color: Color::Red,
};
assert_eq!(hint_width(&hint), 10);
}
#[test]
fn test_build_line() {
let hints = &[
KeyHint {
key: "q",
label: "Quit",
color: Color::Red,
},
KeyHint {
key: "?",
label: "Help",
color: Color::Cyan,
},
];
let line = build_line(hints);
assert!(!line.spans.is_empty());
}
#[test]
fn test_build_content_single_line() {
let hints = &[KeyHint {
key: "q",
label: "Quit",
color: Color::Red,
}];
let content = build_content(hints, 80);
assert_eq!(content.len(), 1);
}
#[test]
fn test_build_content_two_lines() {
let hints = &[
KeyHint {
key: "a",
label: "AAAA",
color: Color::Red,
},
KeyHint {
key: "b",
label: "BBBB",
color: Color::Red,
},
];
let content = build_content(hints, 15);
assert_eq!(content.len(), 3); }
#[test]
fn test_build_status_bar_with_prefix() {
let prefix = vec![Span::raw("Test: ")];
let hints = &[KeyHint {
key: "q",
label: "Quit",
color: Color::Red,
}];
let line = build_status_bar_with_prefix(prefix, hints);
assert!(!line.spans.is_empty());
}
#[test]
fn test_status_hints_height_single() {
let hints = &[KeyHint {
key: "q",
label: "Quit",
color: Color::Red,
}];
assert_eq!(status_hints_height(hints, 80), 1);
}
#[test]
fn test_status_hints_height_multi() {
let hints = &[
KeyHint {
key: "a",
label: "AAAA",
color: Color::Red,
},
KeyHint {
key: "b",
label: "BBBB",
color: Color::Red,
},
];
assert_eq!(status_hints_height(hints, 15), 3);
}
#[test]
fn test_build_content_extremely_narrow() {
let hints = &[
KeyHint {
key: "a",
label: "AAAA",
color: Color::Red,
},
KeyHint {
key: "b",
label: "BBBB",
color: Color::Red,
},
];
let content = build_content(hints, 5);
assert_eq!(content.len(), 3);
assert!(!content[0].spans.is_empty());
}
}