use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use crate::app::App;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum LibraryTab {
#[default]
Views,
Templates,
}
impl LibraryTab {
pub const ALL: [Self; 2] = [Self::Views, Self::Templates];
pub const fn label(self) -> &'static str {
match self {
Self::Views => "Views",
Self::Templates => "Templates",
}
}
pub const fn section(self) -> &'static str {
match self {
Self::Views => "views",
Self::Templates => "issue_templates",
}
}
pub const fn noun(self) -> &'static str {
match self {
Self::Views => "view",
Self::Templates => "template",
}
}
pub const fn next(self) -> Self {
match self {
Self::Views => Self::Templates,
Self::Templates => Self::Views,
}
}
pub const fn prev(self) -> Self {
self.next()
}
pub const fn empty_hint(self) -> &'static str {
match self {
Self::Views => {
"No saved views yet. Press `v` on the board to save the live filter, \
sort and visibility as a named view."
}
Self::Templates => {
"No templates yet. Create one with \
`tix template create --id <ID> --name <NAME> ...`."
}
}
}
pub const fn hint(self) -> &'static str {
match self {
Self::Views => {
"Tab: switch j/k: move Enter: apply to the board q: back to the board"
}
Self::Templates => {
"Tab: switch j/k: move q: back to the board \
(`tix create --template <ID>` creates from one)"
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LibraryState {
pub tab: LibraryTab,
pub selected: usize,
}
impl LibraryState {
pub fn new() -> Self {
Self::default()
}
pub fn move_selected(&mut self, delta: isize, rows: usize) -> bool {
let last = rows.saturating_sub(1);
let before = self.selected;
let next = if delta.is_negative() {
self.selected.saturating_sub(delta.unsigned_abs())
} else {
self.selected.saturating_add(delta as usize)
};
self.selected = next.min(last);
self.selected != before
}
pub fn clamp_selected(&mut self, rows: usize) {
self.selected = self.selected.min(rows.saturating_sub(1));
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LibraryEntry {
pub title: String,
pub subtitle: String,
pub active: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LibraryDetail {
pub heading: String,
pub lines: Vec<(String, String)>,
}
fn dim() -> Style {
Style::default().fg(Color::DarkGray)
}
fn tab_line(active: LibraryTab) -> Line<'static> {
let mut spans = vec![
Span::styled("F11 Library", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" "),
];
for (index, tab) in LibraryTab::ALL.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(" "));
}
let style = if *tab == active {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
dim()
};
spans.push(Span::styled(format!("[{}]", tab.label()), style));
}
Line::from(spans)
}
pub fn render(app: &App, f: &mut Frame, area: Rect) {
let state = app.library_state();
let entries = app.library_entries(state.tab);
let detail = app.library_detail(state.tab, state.selected);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(area);
f.render_widget(Paragraph::new(tab_line(state.tab)), rows[0]);
f.render_widget(
Paragraph::new(Span::styled(state.tab.hint(), dim())),
rows[1],
);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(45), Constraint::Min(24)])
.split(rows[2]);
let title = format!("{} ({})", state.tab.label(), entries.len());
let list = Block::default().borders(Borders::ALL).title(title);
if entries.is_empty() {
f.render_widget(
Paragraph::new(state.tab.empty_hint())
.block(list)
.wrap(Wrap { trim: true })
.style(dim()),
body[0],
);
} else {
let mut lines: Vec<Line<'static>> = Vec::new();
for (index, entry) in entries.iter().enumerate() {
let selected = index == state.selected;
let mut spans = vec![Span::raw(if selected { "> " } else { " " })];
let title_style = if selected {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
spans.push(Span::styled(entry.title.clone(), title_style));
if entry.active {
spans.push(Span::styled(" [active]", Style::default().fg(Color::Cyan)));
}
lines.push(Line::from(spans));
lines.push(Line::from(Span::styled(
format!(" {}", entry.subtitle),
dim(),
)));
}
f.render_widget(Paragraph::new(lines).block(list), body[0]);
}
let detail_block = Block::default()
.borders(Borders::ALL)
.title(detail_title(state.tab, detail.as_ref()));
match detail.as_ref() {
Some(detail) => {
let lines: Vec<Line<'static>> = detail
.lines
.iter()
.map(|(key, value)| {
Line::from(vec![
Span::styled(format!("{key}: "), dim()),
Span::raw(value.clone()),
])
})
.collect();
f.render_widget(
Paragraph::new(lines)
.block(detail_block)
.wrap(Wrap { trim: false }),
body[1],
);
}
None => f.render_widget(
Paragraph::new(Span::styled(
format!(
"Nothing selected. A {} has to exist before the page can describe it.",
state.tab.noun()
),
dim(),
))
.block(detail_block)
.wrap(Wrap { trim: true }),
body[1],
),
}
}
fn detail_title(tab: LibraryTab, detail: Option<&LibraryDetail>) -> String {
match detail {
Some(detail) => match detail.lines.first() {
Some((_, value)) if !value.is_empty() => format!("{} {value}", detail.heading),
_ => detail.heading.clone(),
},
None => format!("{} detail", tab.label()),
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[test]
fn tabs_cycle_in_both_directions() {
assert_eq!(LibraryTab::Views.next(), LibraryTab::Templates);
assert_eq!(LibraryTab::Templates.next(), LibraryTab::Views);
assert_eq!(LibraryTab::Views.prev(), LibraryTab::Templates);
assert_eq!(LibraryTab::Templates.prev(), LibraryTab::Views);
assert_eq!(LibraryTab::default(), LibraryTab::Views);
}
#[test]
fn every_tab_has_a_label_section_noun_hint_and_empty_state() {
for tab in LibraryTab::ALL {
assert_ne!(tab.label(), "");
assert_ne!(tab.section(), "");
assert_ne!(tab.noun(), "");
assert!(
tab.hint().contains("Tab"),
"{} hint names the tab key",
tab.label()
);
assert!(
tab.empty_hint().starts_with("No "),
"{} empty state is a sentence: {}",
tab.label(),
tab.empty_hint()
);
}
assert_ne!(LibraryTab::Views.section(), LibraryTab::Templates.section());
}
#[test]
fn the_cursor_clamps_to_the_row_count() {
let mut state = LibraryState::default();
assert!(state.move_selected(1, 3));
assert_eq!(state.selected, 1);
assert!(state.move_selected(5, 3), "clamped to the last row");
assert_eq!(state.selected, 2);
assert!(!state.move_selected(1, 3), "already at the last row");
assert!(state.move_selected(-1, 3));
assert_eq!(state.selected, 1);
state.move_selected(10, 0);
assert_eq!(state.selected, 0);
state.move_selected(-1, 0);
assert_eq!(state.selected, 0);
}
#[test]
fn a_shrinking_registry_clamps_the_cursor() {
let mut state = LibraryState {
tab: LibraryTab::Views,
selected: 4,
};
state.clamp_selected(2);
assert_eq!(state.selected, 1);
state.clamp_selected(0);
assert_eq!(state.selected, 0);
}
}