Skip to main content

tix/ui/
library.rs

1//! The `F11` Library page (tix #1593): one page over the definitions a user
2//! creates once and reuses — saved views and issue/form templates.
3//!
4//! # Why a page and not another modal
5//!
6//! The `v` modal and the `F12` Config surface each own one of these registries
7//! already, but both are *writers*: `v` saves the live board, Config edits
8//! `tix.toml`. The Library is the *reader* — the place to see what exists, what
9//! each record actually says, and which view the board is currently living in.
10//! That is why it is a top-level mode like `F4`/`F5`/`F6` rather than a modal
11//! over the board, and why it takes no key the board or the form already uses.
12//!
13//! # Read-only by contract
14//!
15//! Nothing on this page writes. The one action that changes anything at all is
16//! `Enter` on a **view**, which applies it to the board — and applying a view is
17//! read-only by C-005 of the saved-views mission (it writes neither an issue nor
18//! `tix.toml`). Templates are listed and inspected here; creating *from* one
19//! stays on its own surfaces (`tix create --template`, the MCP tool, and the F1
20//! form's template row), because the stable-ID path is a CLI/MCP seam today and
21//! wiring it into a page would be new plumbing rather than a reader.
22//!
23//! # Empty states are part of the page
24//!
25//! A key that silently does nothing is indistinguishable from a bug, so an empty
26//! tab draws the sentence that says how to fill it (`v` on the board; `tix
27//! template create`) instead of a blank pane.
28
29use 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/// Which registry the page is showing.
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
39pub enum LibraryTab {
40    /// Saved views (`[views.<id>]`, tix #172).
41    #[default]
42    Views,
43    /// Issue/form templates (`[issue_templates.<id>]`, tix #174).
44    Templates,
45}
46
47impl LibraryTab {
48    /// The tabs in display order.
49    pub const ALL: [Self; 2] = [Self::Views, Self::Templates];
50
51    /// The tab's label in the strip.
52    pub const fn label(self) -> &'static str {
53        match self {
54            Self::Views => "Views",
55            Self::Templates => "Templates",
56        }
57    }
58
59    /// The tab's registry name, as `tix.toml` spells it.
60    pub const fn section(self) -> &'static str {
61        match self {
62            Self::Views => "views",
63            Self::Templates => "issue_templates",
64        }
65    }
66
67    /// The noun for the list's title and for "no X yet" sentences.
68    pub const fn noun(self) -> &'static str {
69        match self {
70            Self::Views => "view",
71            Self::Templates => "template",
72        }
73    }
74
75    /// The next tab, wrapping. `Tab` and `l` walk forward.
76    pub const fn next(self) -> Self {
77        match self {
78            Self::Views => Self::Templates,
79            Self::Templates => Self::Views,
80        }
81    }
82
83    /// The previous tab, wrapping. `Shift+Tab` and `h` walk backward.
84    pub const fn prev(self) -> Self {
85        self.next()
86    }
87
88    /// What an empty tab says, including how to stop being empty.
89    ///
90    /// Kept here rather than in the app so the sentence is a property of the page
91    /// a test can assert, not a string literal inside a render function.
92    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    /// The keys this tab answers to, shown under the strip.
106    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/// The page's own state: which tab, and which row the cursor is on.
120///
121/// One cursor for both tabs, clamped to the active tab's row count when the tab
122/// changes — so switching tabs never leaves the cursor past the end of a shorter
123/// list.
124#[derive(Debug, Clone, Default, PartialEq, Eq)]
125pub struct LibraryState {
126    /// The active tab.
127    pub tab: LibraryTab,
128    /// The selected row inside the active tab, `0`-based.
129    pub selected: usize,
130}
131
132impl LibraryState {
133    /// A fresh page on the Views tab with the cursor on the first row.
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Move the cursor by `delta` rows, clamped to `rows` entries (which may be
139    /// zero). Returns `true` when the cursor moved.
140    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    /// Clamp the cursor into `rows` entries, for use after the registry changed
153    /// underneath the page (a view deleted from another surface, say).
154    pub fn clamp_selected(&mut self, rows: usize) {
155        self.selected = self.selected.min(rows.saturating_sub(1));
156    }
157}
158
159/// One row of the list, as display data: the widget never reads a registry.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct LibraryEntry {
162    /// The bold part of the row: a view's name, a template's id.
163    pub title: String,
164    /// The dimmed tail: scope, defaults count, `active`.
165    pub subtitle: String,
166    /// Whether this record is the board's live view (Views tab only).
167    pub active: bool,
168}
169
170/// The `key: value` lines of the detail pane, plus its heading.
171#[derive(Debug, Clone, Default, PartialEq, Eq)]
172pub struct LibraryDetail {
173    /// The heading line, e.g. `View` or `Template`.
174    pub heading: String,
175    /// The `key: value` lines, in a stable order.
176    pub lines: Vec<(String, String)>,
177}
178
179/// The style of a tab that is not active.
180fn dim() -> Style {
181    Style::default().fg(Color::DarkGray)
182}
183
184/// The tab strip: `F11 Library` on the left, then every tab, the active one cyan
185/// and bold.
186fn 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
207/// Draw the page: the strip and its hint on two lines, then the list beside the
208/// selected record's detail.
209pub 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        // The empty state is the page's content, not a missing pane: same block,
237        // sentence inside it, wrapped so a narrow terminal does not clip it.
238        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
305/// The detail pane's title: the record's own name, or the tab it belongs to.
306fn 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        // Two tabs, so forward and backward are the same swap — the point of the
324        // test is that neither direction can stick or panic.
325        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        // The two tabs must not describe the same registry.
351        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        // An empty registry keeps the cursor at zero rather than past the end.
365        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}