use std::cell::RefCell;
use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::text::Line;
use ratatui::widgets::{Block, Clear, Padding, Paragraph};
use crate::theme::Skin;
pub const BRAND: &str = "hop";
const TAB_NAMES: [(&str, &str); 2] =
[("1", "Git Repos"), ("2", "Files and Folders")];
const ARCHIVE_SUFFIX: &str = " \u{00b7} Archiv";
#[derive(Debug, Clone, Copy)]
pub struct TabBar {
pub active: usize,
pub archived: bool,
}
fn tab_labels(bar: TabBar) -> Vec<(&'static str, String)> {
TAB_NAMES
.iter()
.enumerate()
.map(|(index, (key, name))| {
let label = if index == bar.active && bar.archived {
format!("{name}{ARCHIVE_SUFFIX}")
} else {
(*name).to_string()
};
(*key, label)
})
.collect()
}
fn borrow_pairs<'a>(
labels: &'a [(&'static str, String)],
) -> Vec<(&'static str, &'a str)> {
labels
.iter()
.map(|(key, label)| (*key, label.as_str()))
.collect()
}
const SCRIM_FACTOR: f32 = 0.4;
const POPUP_LIGHTEN: f32 = 0.08;
thread_local! {
static LAST_FRAME: RefCell<Option<Buffer>> = const { RefCell::new(None) };
}
pub fn snapshot_frame(frame: &mut Frame<'_>) {
let snapshot = frame.buffer_mut().clone();
LAST_FRAME.with(|last| *last.borrow_mut() = Some(snapshot));
}
pub fn blit_backdrop(frame: &mut Frame<'_>) {
LAST_FRAME.with(|last| {
let borrow = last.borrow();
if let Some(buf) = borrow.as_ref() {
let dst = frame.buffer_mut();
if buf.area == dst.area {
dst.content.clone_from(&buf.content);
}
}
});
}
pub fn dim_backdrop(frame: &mut Frame<'_>) {
blit_backdrop(frame);
ratada::overlay::dim(frame, SCRIM_FACTOR);
}
pub fn popup_backdrop(frame: &mut Frame<'_>, skin: &Skin, area: Rect) {
dim_backdrop(frame);
frame.render_widget(Clear, area);
let bg = skin.palette.surface.lighten(POPUP_LIGHTEN);
frame.render_widget(Block::default().style(ratada::style::bg(bg)), area);
}
const PROGRESS_HEIGHT: u16 = 1;
pub struct FrameAreas {
pub content: Rect,
pub progress: Option<Rect>,
}
pub fn render_frame(
frame: &mut Frame<'_>,
skin: &Skin,
tabs: TabBar,
status: Vec<Line>,
hints: &[(String, Vec<(String, String)>)],
show_progress: bool,
) -> FrameAreas {
let area = frame.area();
frame.render_widget(
Block::default().style(ratada::style::base(
skin.palette.foreground,
skin.palette.background,
)),
area,
);
let width = area.width.saturating_sub(2) as usize;
let labels = tab_labels(tabs);
let pairs = borrow_pairs(&labels);
let header_h = header_height(&pairs, width);
let status_h = status.len() as u16;
let progress_h = if show_progress { PROGRESS_HEIGHT } else { 0 };
let hints_h = if hints.is_empty() {
0
} else {
hints_height(hints, width)
};
let chunks = Layout::vertical([
Constraint::Length(header_h),
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(progress_h),
Constraint::Length(status_h),
Constraint::Length(hints_h),
])
.split(area);
fill(frame, chunks[1], skin.palette.surface);
fill(frame, chunks[2], skin.palette.surface);
render_header(frame, chunks[0], skin, &pairs, tabs.active);
if status_h > 0 {
fill(frame, chunks[4], skin.palette.footer);
frame.render_widget(
Paragraph::new(status)
.block(Block::default().padding(Padding::horizontal(1))),
chunks[4],
);
}
if hints_h > 0 {
render_hints(frame, chunks[5], skin, hints);
}
let content = Block::default()
.padding(Padding::new(1, 1, 0, 0))
.inner(chunks[2]);
let progress = (progress_h > 0).then_some(chunks[3]);
FrameAreas { content, progress }
}
fn header_height(pairs: &[(&str, &str)], width: usize) -> u16 {
ratada::tabs::height(BRAND, pairs, width) + 2
}
fn render_header(
frame: &mut Frame<'_>,
area: Rect,
skin: &Skin,
pairs: &[(&str, &str)],
active: usize,
) {
let block = Block::default()
.style(ratada::style::bg(skin.palette.header))
.padding(Padding::uniform(1));
let inner = block.inner(area);
frame.render_widget(block, area);
let tab_height = ratada::tabs::height(BRAND, pairs, inner.width as usize);
let tab_area = Rect {
height: tab_height.min(inner.height),
..inner
};
ratada::tabs::render(frame, tab_area, skin, BRAND, pairs, active);
}
fn fill(frame: &mut Frame<'_>, area: Rect, color: crate::theme::Color) {
frame.render_widget(Block::default().style(ratada::style::bg(color)), area);
}
fn hint_style(skin: &Skin) -> ratada::shortcut_hints::HintStyle {
ratada::shortcut_hints::HintStyle {
key: ratada::style::fg(skin.palette.accent)
.add_modifier(Modifier::BOLD),
label: ratada::style::dim(),
description: ratada::style::secondary(&skin.palette),
top_margin: 1,
background: None,
}
}
fn to_hint_groups(
groups: &[(String, Vec<(String, String)>)],
) -> Vec<ratada::shortcut_hints::HintGroup<'_, String>> {
groups
.iter()
.map(|(label, pairs)| ratada::shortcut_hints::HintGroup {
label: label.as_str(),
hints: pairs.as_slice(),
})
.collect()
}
fn hints_height(
hints: &[(String, Vec<(String, String)>)],
width: usize,
) -> u16 {
ratada::shortcut_hints::height(&to_hint_groups(hints), width, 1)
}
fn render_hints(
frame: &mut Frame<'_>,
area: Rect,
skin: &Skin,
hints: &[(String, Vec<(String, String)>)],
) {
let opts = hint_style(skin);
let hint_area = Rect {
x: area.x + 1,
width: area.width.saturating_sub(2),
..area
};
ratada::shortcut_hints::render(
frame,
hint_area,
&to_hint_groups(hints),
&opts,
);
}
#[cfg(test)]
mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::*;
fn skin() -> Skin {
crate::config::Config::default().skin()
}
fn areas(show_progress: bool) -> FrameAreas {
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
let mut out = None;
terminal
.draw(|frame| {
out = Some(render_frame(
frame,
&skin(),
TabBar {
active: 0,
archived: false,
},
vec![Line::raw("info")],
&[(
"App".to_string(),
vec![("q".to_string(), "quit".to_string())],
)],
show_progress,
));
})
.unwrap();
out.unwrap()
}
#[test]
fn reserves_a_progress_region_when_shown() {
let with = areas(true);
let progress = with.progress.expect("progress region when shown");
assert_eq!(progress.height, PROGRESS_HEIGHT);
let without = areas(false);
assert!(without.progress.is_none());
assert_eq!(
without.content.height - with.content.height,
PROGRESS_HEIGHT,
);
}
}