1use ratatui::Frame;
30use ratatui::layout::{Constraint, Direction, Layout, Rect};
31use ratatui::style::{Color, Modifier, Style};
32use ratatui::text::{Line, Span};
33use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
34
35use crate::app::App;
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
39pub enum LibraryTab {
40 #[default]
42 Views,
43 Templates,
45}
46
47impl LibraryTab {
48 pub const ALL: [Self; 2] = [Self::Views, Self::Templates];
50
51 pub const fn label(self) -> &'static str {
53 match self {
54 Self::Views => "Views",
55 Self::Templates => "Templates",
56 }
57 }
58
59 pub const fn section(self) -> &'static str {
61 match self {
62 Self::Views => "views",
63 Self::Templates => "issue_templates",
64 }
65 }
66
67 pub const fn noun(self) -> &'static str {
69 match self {
70 Self::Views => "view",
71 Self::Templates => "template",
72 }
73 }
74
75 pub const fn next(self) -> Self {
77 match self {
78 Self::Views => Self::Templates,
79 Self::Templates => Self::Views,
80 }
81 }
82
83 pub const fn prev(self) -> Self {
85 self.next()
86 }
87
88 pub const fn empty_hint(self) -> &'static str {
93 match self {
94 Self::Views => {
95 "No saved views yet. Press `v` on the board to save the live filter, \
96 sort and visibility as a named view."
97 }
98 Self::Templates => {
99 "No templates yet. Create one with \
100 `tix template create --id <ID> --name <NAME> ...`."
101 }
102 }
103 }
104
105 pub const fn hint(self) -> &'static str {
107 match self {
108 Self::Views => {
109 "Tab: switch j/k: move Enter: apply to the board q: back to the board"
110 }
111 Self::Templates => {
112 "Tab: switch j/k: move q: back to the board \
113 (`tix create --template <ID>` creates from one)"
114 }
115 }
116 }
117}
118
119#[derive(Debug, Clone, Default, PartialEq, Eq)]
125pub struct LibraryState {
126 pub tab: LibraryTab,
128 pub selected: usize,
130}
131
132impl LibraryState {
133 pub fn new() -> Self {
135 Self::default()
136 }
137
138 pub fn move_selected(&mut self, delta: isize, rows: usize) -> bool {
141 let last = rows.saturating_sub(1);
142 let before = self.selected;
143 let next = if delta.is_negative() {
144 self.selected.saturating_sub(delta.unsigned_abs())
145 } else {
146 self.selected.saturating_add(delta as usize)
147 };
148 self.selected = next.min(last);
149 self.selected != before
150 }
151
152 pub fn clamp_selected(&mut self, rows: usize) {
155 self.selected = self.selected.min(rows.saturating_sub(1));
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct LibraryEntry {
162 pub title: String,
164 pub subtitle: String,
166 pub active: bool,
168}
169
170#[derive(Debug, Clone, Default, PartialEq, Eq)]
172pub struct LibraryDetail {
173 pub heading: String,
175 pub lines: Vec<(String, String)>,
177}
178
179fn dim() -> Style {
181 Style::default().fg(Color::DarkGray)
182}
183
184fn tab_line(active: LibraryTab) -> Line<'static> {
187 let mut spans = vec![
188 Span::styled("F11 Library", Style::default().add_modifier(Modifier::BOLD)),
189 Span::raw(" "),
190 ];
191 for (index, tab) in LibraryTab::ALL.iter().enumerate() {
192 if index > 0 {
193 spans.push(Span::raw(" "));
194 }
195 let style = if *tab == active {
196 Style::default()
197 .fg(Color::Cyan)
198 .add_modifier(Modifier::BOLD)
199 } else {
200 dim()
201 };
202 spans.push(Span::styled(format!("[{}]", tab.label()), style));
203 }
204 Line::from(spans)
205}
206
207pub fn render(app: &App, f: &mut Frame, area: Rect) {
210 let state = app.library_state();
211 let entries = app.library_entries(state.tab);
212 let detail = app.library_detail(state.tab, state.selected);
213
214 let rows = Layout::default()
215 .direction(Direction::Vertical)
216 .constraints([
217 Constraint::Length(1),
218 Constraint::Length(1),
219 Constraint::Min(1),
220 ])
221 .split(area);
222 f.render_widget(Paragraph::new(tab_line(state.tab)), rows[0]);
223 f.render_widget(
224 Paragraph::new(Span::styled(state.tab.hint(), dim())),
225 rows[1],
226 );
227
228 let body = Layout::default()
229 .direction(Direction::Horizontal)
230 .constraints([Constraint::Percentage(45), Constraint::Min(24)])
231 .split(rows[2]);
232
233 let title = format!("{} ({})", state.tab.label(), entries.len());
234 let list = Block::default().borders(Borders::ALL).title(title);
235 if entries.is_empty() {
236 f.render_widget(
239 Paragraph::new(state.tab.empty_hint())
240 .block(list)
241 .wrap(Wrap { trim: true })
242 .style(dim()),
243 body[0],
244 );
245 } else {
246 let mut lines: Vec<Line<'static>> = Vec::new();
247 for (index, entry) in entries.iter().enumerate() {
248 let selected = index == state.selected;
249 let mut spans = vec![Span::raw(if selected { "> " } else { " " })];
250 let title_style = if selected {
251 Style::default().add_modifier(Modifier::BOLD)
252 } else {
253 Style::default()
254 };
255 spans.push(Span::styled(entry.title.clone(), title_style));
256 if entry.active {
257 spans.push(Span::styled(" [active]", Style::default().fg(Color::Cyan)));
258 }
259 lines.push(Line::from(spans));
260 lines.push(Line::from(Span::styled(
261 format!(" {}", entry.subtitle),
262 dim(),
263 )));
264 }
265 f.render_widget(Paragraph::new(lines).block(list), body[0]);
266 }
267
268 let detail_block = Block::default()
269 .borders(Borders::ALL)
270 .title(detail_title(state.tab, detail.as_ref()));
271 match detail.as_ref() {
272 Some(detail) => {
273 let lines: Vec<Line<'static>> = detail
274 .lines
275 .iter()
276 .map(|(key, value)| {
277 Line::from(vec![
278 Span::styled(format!("{key}: "), dim()),
279 Span::raw(value.clone()),
280 ])
281 })
282 .collect();
283 f.render_widget(
284 Paragraph::new(lines)
285 .block(detail_block)
286 .wrap(Wrap { trim: false }),
287 body[1],
288 );
289 }
290 None => f.render_widget(
291 Paragraph::new(Span::styled(
292 format!(
293 "Nothing selected. A {} has to exist before the page can describe it.",
294 state.tab.noun()
295 ),
296 dim(),
297 ))
298 .block(detail_block)
299 .wrap(Wrap { trim: true }),
300 body[1],
301 ),
302 }
303}
304
305fn detail_title(tab: LibraryTab, detail: Option<&LibraryDetail>) -> String {
307 match detail {
308 Some(detail) => match detail.lines.first() {
309 Some((_, value)) if !value.is_empty() => format!("{} {value}", detail.heading),
310 _ => detail.heading.clone(),
311 },
312 None => format!("{} detail", tab.label()),
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 #[allow(clippy::wildcard_imports)]
319 use super::*;
320
321 #[test]
322 fn tabs_cycle_in_both_directions() {
323 assert_eq!(LibraryTab::Views.next(), LibraryTab::Templates);
326 assert_eq!(LibraryTab::Templates.next(), LibraryTab::Views);
327 assert_eq!(LibraryTab::Views.prev(), LibraryTab::Templates);
328 assert_eq!(LibraryTab::Templates.prev(), LibraryTab::Views);
329 assert_eq!(LibraryTab::default(), LibraryTab::Views);
330 }
331
332 #[test]
333 fn every_tab_has_a_label_section_noun_hint_and_empty_state() {
334 for tab in LibraryTab::ALL {
335 assert_ne!(tab.label(), "");
336 assert_ne!(tab.section(), "");
337 assert_ne!(tab.noun(), "");
338 assert!(
339 tab.hint().contains("Tab"),
340 "{} hint names the tab key",
341 tab.label()
342 );
343 assert!(
344 tab.empty_hint().starts_with("No "),
345 "{} empty state is a sentence: {}",
346 tab.label(),
347 tab.empty_hint()
348 );
349 }
350 assert_ne!(LibraryTab::Views.section(), LibraryTab::Templates.section());
352 }
353
354 #[test]
355 fn the_cursor_clamps_to_the_row_count() {
356 let mut state = LibraryState::default();
357 assert!(state.move_selected(1, 3));
358 assert_eq!(state.selected, 1);
359 assert!(state.move_selected(5, 3), "clamped to the last row");
360 assert_eq!(state.selected, 2);
361 assert!(!state.move_selected(1, 3), "already at the last row");
362 assert!(state.move_selected(-1, 3));
363 assert_eq!(state.selected, 1);
364 state.move_selected(10, 0);
366 assert_eq!(state.selected, 0);
367 state.move_selected(-1, 0);
368 assert_eq!(state.selected, 0);
369 }
370
371 #[test]
372 fn a_shrinking_registry_clamps_the_cursor() {
373 let mut state = LibraryState {
374 tab: LibraryTab::Views,
375 selected: 4,
376 };
377 state.clamp_selected(2);
378 assert_eq!(state.selected, 1);
379 state.clamp_selected(0);
380 assert_eq!(state.selected, 0);
381 }
382}