tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
//! The vertical top-level mode navigation (tui-mode-navigation).
//!
//! One column listing every top-level mode with its effective switch chord, drawn
//! to the left of the active mode's content, in ascending key order. Each entry
//! records its [`Rect`] through
//! [`App::record_region`], so a click is resolved by the same hit-region mechanism
//! every other widget uses and the geometry cannot drift from what is drawn.

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};

use crate::app::{Action, App};
use crate::keybindings::{InputContext, TuiAction};

/// A top-level mode the shell hosts. [`TopMode::ALL`] is the full list, in
/// ascending key order.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum TopMode {
    /// The new-issue form (F1).
    NewIssue,
    /// The triage inbox (F2).
    Triage,
    /// The board (F3). Active on launch.
    #[default]
    Board,
    /// Cycle management (F4).
    Cycle,
    /// The milestone registry (F5).
    Milestones,
    /// The child-repository list (F6, tix #1529).
    Repos,
    /// The Stats page (F7), whose Health tab ships with mission
    /// `project-issue-health-status-01M3C8HX`: every scope's health, with its
    /// contributors and diagnostics. Read-only; the actions live in the detail
    /// view's form.
    Stats,
    /// The Schedule page (F8, tix #1593): the recurring series and the keys that
    /// act on them. Its Series tab ships with the recurring-issues mission; the
    /// cycle-schedule and capacity tabs arrive with their own missions.
    Schedule,
    /// The Portfolio page (F10, WP06): the project registry this mission introduces,
    /// listed with each project's derived member count. Its **Projects** tab ships
    /// now; further portfolio tabs arrive with their own missions. Read-only — the
    /// mutations live in `tix project …` and the issue form.
    Portfolio,
    /// The Library page (F11, tix #1593): the saved views and issue/form
    /// templates that already exist, listed and inspected, with `Enter` applying
    /// a view to the board.
    Library,
    /// Settings/configuration (F12).
    Config,
}

impl TopMode {
    /// The modes in navigation order — **ascending function key**.
    ///
    /// The rail is a list of keys, so it reads as one: `F1` at the top down to
    /// `F12`, and the order of this array is the order of the rows. A page whose
    /// key has no page yet simply has no row — `F9` is reserved (tix #1593's
    /// **Data** page), so the sequence skips from `F8` to `F10` rather than
    /// renumbering anything. `KeyBindings` can rebind a switch chord, but the row
    /// **order** stays this one: a stable list is what a user learns, and a rail
    /// that reshuffled itself on a rebind would move a row the moment it was
    /// bound. `modes_are_ordered_by_their_default_chord` pins the invariant
    /// against the default binding table, so a future page inserted in the wrong
    /// slot fails a test rather than shipping.
    pub const ALL: [Self; 11] = [
        Self::NewIssue,
        Self::Triage,
        Self::Board,
        Self::Cycle,
        Self::Milestones,
        Self::Repos,
        Self::Stats,
        Self::Schedule,
        Self::Portfolio,
        Self::Library,
        Self::Config,
    ];

    /// The singular display name shown in the `Default` navigation state.
    pub const fn name(self) -> &'static str {
        match self {
            Self::NewIssue => "New Issue",
            Self::Triage => "Triage",
            Self::Board => "Board",
            Self::Cycle => "Cycle",
            Self::Milestones => "Milestone",
            // The issue names the entry "Repos"; the noun is already the plural.
            Self::Repos => "Repos",
            Self::Stats => "Stats",
            Self::Schedule => "Schedule",
            Self::Library => "Library",
            Self::Portfolio => "Portfolio",
            Self::Config => "Config",
        }
    }

    /// The trunk label with `plural` applied where the mode owns a countable
    /// set — boards, cycles, milestones. The chord is untouched: only the noun
    /// moves, so `F3 Board` becomes `F3 Boards` with more than one board
    /// configured and back again with one. The other modes have no plural.
    pub const fn label(self, plural: bool) -> &'static str {
        match (self, plural) {
            (Self::Board, true) => "Boards",
            (Self::Cycle, true) => "Cycles",
            (Self::Milestones, true) => "Milestones",
            _ => self.name(),
        }
    }

    /// The shell action that switches to this mode.
    pub const fn action(self) -> TuiAction {
        match self {
            Self::NewIssue => TuiAction::SwitchToNewIssue,
            Self::Triage => TuiAction::SwitchToTriage,
            Self::Board => TuiAction::SwitchToBoard,
            Self::Cycle => TuiAction::SwitchToCycle,
            Self::Milestones => TuiAction::SwitchToMilestones,
            Self::Repos => TuiAction::SwitchToRepos,
            Self::Stats => TuiAction::SwitchToStats,
            Self::Schedule => TuiAction::SwitchToSchedule,
            Self::Library => TuiAction::SwitchToLibrary,
            Self::Portfolio => TuiAction::SwitchToPortfolio,
            Self::Config => TuiAction::SwitchToConfig,
        }
    }
}

/// How much horizontal space the vertical navigation takes.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum NavDisplay {
    /// Hidden; mode content uses the full width.
    Off,
    /// Compact: only the `F<n>` chord labels.
    Small,
    /// Full: `F<n> <Mode Name>`.
    #[default]
    Default,
}

impl NavDisplay {
    /// The next state in the toggle cycle (`Off` → `Small` → `Default` → `Off`).
    pub const fn next(self) -> Self {
        match self {
            Self::Off => Self::Small,
            Self::Small => Self::Default,
            Self::Default => Self::Off,
        }
    }

    /// The *minimum* column width this state needs, `0` when hidden.
    ///
    /// [`App::nav_width`] grows this to fit the widest line actually drawn, so a
    /// long branch name is shown whole in `Default` rather than clipped.
    pub const fn width(self) -> u16 {
        match self {
            Self::Off => 0,
            // The branch form `"  Xx"` (indent + two letters) plus one space of
            // padding, which also fits the widest `Small` trunk chord (`F12`).
            Self::Small => 5,
            // `F12 New Issue` at the widest, plus padding.
            Self::Default => 14,
        }
    }
}

/// What kind of quick-access limb a branch row is.
///
/// The [`NavBranch::action`] alone does not say whether a row names a board or a
/// template, so the kind is carried explicitly — it is what the rail's tests and
/// any future branch-specific affordance read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchKind {
    /// A board under the Board trunk (every configured board; the active one is
    /// bold).
    Board,
    /// A cycle under the Cycles trunk.
    Cycle,
    /// A milestone under the Milestones trunk.
    Milestone,
    /// A `[templates.<name>]` entry under the New Issue trunk.
    Template,
    /// A `[repos]` entry (or the parent) under the Repos trunk (tix #1529).
    Repo,
    /// One of the Portfolio page's tabs under the Portfolio trunk (WP06). The
    /// limb names what the page shows rather than a stored record: the project
    /// rows themselves are the page's body, and the rail draws a limb without
    /// reading the issue set.
    Project,
}

/// One indented limb row drawn under a trunk mode.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavBranch {
    /// The full name shown in `Default` (and abbreviated in `Small`).
    pub label: String,
    /// What activating the row does.
    pub action: Action,
    /// Which kind of limb this is.
    pub kind: BranchKind,
    /// Whether this row is the currently active target (e.g. the active board);
    /// drawn in bold.
    pub active: bool,
}

/// One trunk mode and the limbs drawn under it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavNode {
    /// The trunk mode.
    pub mode: TopMode,
    /// The limb rows; empty when the mode owns none.
    pub branches: Vec<NavBranch>,
}

/// The hover/focus highlight: one style for both, so a rail row under the
/// keyboard cursor reads exactly like the row under the pointer.
fn highlight_style() -> Style {
    Style::default()
        .fg(Color::White)
        .bg(Color::DarkGray)
        .add_modifier(Modifier::BOLD)
}

/// The style of a trunk row: a hovered or keyboard-focused row is highlighted —
/// and that highlight outranks the active-mode colour, so entering the rail with
/// `h`/`Left` on the active trunk is always visible. Otherwise the active mode is
/// highlighted and every other row is gray.
fn trunk_style(app: &App, mode: TopMode, index: usize) -> Style {
    if app.is_hovered(&Action::SwitchMode(mode)) || app.nav_row_focused(index) {
        // Every mode draws this rail, so the cursor/hover highlight is available
        // everywhere; the cursor wins over the active-mode cyan.
        highlight_style()
    } else if mode == app.top_mode {
        Style::default()
            .fg(Color::Black)
            .bg(Color::Cyan)
            .add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(Color::Gray)
    }
}

/// The style of a branch row: the active target is bold, a hovered or
/// keyboard-focused row gets a lighter background, every other row is dimmed.
fn branch_style(app: &App, branch: &NavBranch, index: usize) -> Style {
    if app.is_hovered(&branch.action) || app.nav_row_focused(index) {
        highlight_style()
    } else if branch.active {
        Style::default()
            .fg(Color::Gray)
            .add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(Color::DarkGray)
    }
}

/// The abbreviation `NavDisplay::Small` shows for a branch: the first two
/// characters (Unicode-aware, case preserved) of its label, or the label whole
/// when it is shorter. Duplicates are allowed — the adjacent trunk and the hover
/// highlight disambiguate.
fn branch_abbrev(label: &str) -> String {
    label.chars().take(2).collect()
}

/// Draw the navigation tree into `area`, recording one click region per painted
/// row: trunk rows carry `Action::SwitchMode`, branch rows their own action.
pub fn render(app: &App, f: &mut Frame, area: Rect) {
    if app.nav_display == NavDisplay::Off || area.width == 0 {
        return;
    }
    let nodes = app.nav_tree();
    let mut lines: Vec<Line<'static>> = Vec::new();
    // One target per painted row, in order; the row index is the line index.
    let mut targets: Vec<Action> = Vec::new();
    for node in &nodes {
        if lines.len() as u16 >= area.height {
            break;
        }
        let chord = app.bindings.label(InputContext::Shell, node.mode.action());
        let trunk = match app.nav_display {
            NavDisplay::Small => chord,
            _ => format!("{chord} {}", app.mode_label(node.mode)),
        };
        lines.push(Line::from(Span::styled(
            trunk,
            trunk_style(app, node.mode, lines.len()),
        )));
        targets.push(Action::SwitchMode(node.mode));
        for branch in &node.branches {
            if lines.len() as u16 >= area.height {
                break;
            }
            let text = match app.nav_display {
                NavDisplay::Small => format!("  {}", branch_abbrev(&branch.label)),
                _ => format!("  {}", branch.label),
            };
            lines.push(Line::from(Span::styled(
                text,
                branch_style(app, branch, lines.len()),
            )));
            targets.push(branch.action.clone());
        }
    }
    // When the rail owns the keyboard, mark it as focused with a right-edge
    // accent (the same yellow the Config surface uses for its focused pane), so
    // the keyboard cursor is never lost among the rows.
    let block = if app.nav_focus_index().is_some() {
        Block::default()
            .borders(Borders::RIGHT)
            .border_style(Style::default().fg(Color::Yellow))
    } else {
        Block::default()
    };
    f.render_widget(Paragraph::new(lines).block(block), area);

    // Record the regions *after* drawing, one row per painted line, clipped to the
    // area — a short terminal must not record a target that was never painted.
    for (index, action) in targets.into_iter().enumerate() {
        let rect = Rect::new(area.x, area.y + index as u16, area.width, 1);
        app.record_region(rect, action);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn display_states_cycle_and_shrink() {
        assert_eq!(NavDisplay::Off.next(), NavDisplay::Small);
        assert_eq!(NavDisplay::Small.next(), NavDisplay::Default);
        assert_eq!(NavDisplay::Default.next(), NavDisplay::Off);
        assert_eq!(NavDisplay::Off.width(), 0);
        assert!(NavDisplay::Small.width() < NavDisplay::Default.width());
        assert_eq!(NavDisplay::Default, NavDisplay::default());
    }

    #[test]
    fn every_mode_maps_to_a_distinct_shell_action() {
        let actions: Vec<TuiAction> = TopMode::ALL.iter().map(|mode| mode.action()).collect();
        for (index, action) in actions.iter().enumerate() {
            assert!(
                !actions[index + 1..].contains(action),
                "two modes share the switch action {action:?}"
            );
        }
    }

    /// The mode order the shell presents (tui-mode-navigation): `F1` New Issue
    /// down to `F12` Config, in key order — with **no row** for `F9`, which is
    /// reserved for the Data page (tix #1593) and therefore has no page to list.
    #[test]
    fn modes_are_ordered_new_issue_to_config() {
        assert_eq!(
            TopMode::ALL,
            [
                TopMode::NewIssue,
                TopMode::Triage,
                TopMode::Board,
                TopMode::Cycle,
                TopMode::Milestones,
                TopMode::Repos,
                TopMode::Stats,
                TopMode::Schedule,
                TopMode::Portfolio,
                TopMode::Library,
                TopMode::Config,
            ]
        );
    }

    /// The rail is a list of keys, so it must read in key order — and that has to
    /// be checked against the **binding table**, not against the array above, or
    /// the assertion is only ever a copy of the thing it tests.
    ///
    /// This is the test that fails when a page is appended in arrival order
    /// instead of key order: `F11` Library once sat above `F10` Portfolio because
    /// it was built first, which is invisible in a unit test that only compares
    /// `ALL` with itself.
    #[test]
    fn modes_are_ordered_by_their_default_chord() {
        let bindings = crate::config::Config::default()
            .effective_keybindings()
            .expect("the shipped key bindings must resolve");

        let keys: Vec<(u16, TopMode)> = TopMode::ALL
            .iter()
            .map(|mode| {
                let chord = bindings.label(InputContext::Shell, mode.action());
                let number = chord
                    .strip_prefix('F')
                    .and_then(|rest| rest.parse::<u16>().ok())
                    .unwrap_or_else(|| {
                        panic!("{mode:?} advertises the shell chord `{chord}`, not an `F<n>` key")
                    });
                (number, *mode)
            })
            .collect();

        assert!(
            keys.windows(2).all(|pair| pair[0].0 < pair[1].0),
            "the navigation rail lists modes in ascending key order, got {keys:?}"
        );
    }

    /// `Small` abbreviates a branch to its first two characters, case preserved
    /// and Unicode-aware; a shorter label is shown whole.
    #[test]
    fn small_branch_abbreviation_is_two_characters() {
        assert_eq!(branch_abbrev("Default"), "De");
        assert_eq!(branch_abbrev("October 2026"), "Oc");
        assert_eq!(branch_abbrev("Backlog"), "Ba");
        assert_eq!(branch_abbrev("X"), "X");
        assert_eq!(branch_abbrev(""), "");
        // Byte-count would split this; `chars` does not.
        assert_eq!(branch_abbrev("Ünïcode"), "Ün");
    }

    /// `Small` must fit the `"  Xx"` branch form (indent + two letters).
    #[test]
    fn small_width_fits_the_branch_form() {
        assert!(NavDisplay::Small.width() >= "  Xx".chars().count() as u16);
    }

    /// Only the countable modes have a plural; the chord is never part of the
    /// label, and `name()` is the singular default.
    #[test]
    fn label_pluralizes_only_the_countable_modes() {
        assert_eq!(TopMode::Board.label(false), "Board");
        assert_eq!(TopMode::Board.label(true), "Boards");
        assert_eq!(TopMode::Cycle.label(false), "Cycle");
        assert_eq!(TopMode::Cycle.label(true), "Cycles");
        assert_eq!(TopMode::Milestones.label(false), "Milestone");
        assert_eq!(TopMode::Milestones.label(true), "Milestones");
        assert_eq!(TopMode::NewIssue.label(true), "New Issue");
        assert_eq!(TopMode::Triage.label(true), "Triage");
        assert_eq!(TopMode::Config.label(true), "Config");
        assert_eq!(TopMode::Cycle.name(), "Cycle");
    }
}