feat: TUI Phase 1 common widgets + scoring/path beads

This commit is contained in:
teernisse
2026-02-12 15:28:53 -05:00
parent 4664e0cfe3
commit eb5b464d03
24 changed files with 4410 additions and 823 deletions

View File

@@ -0,0 +1,339 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Browser-like navigation stack with vim-style jump list.
//!
//! Supports back/forward (browser), jump back/forward (vim Ctrl+O/Ctrl+I),
//! and breadcrumb generation. State is preserved when navigating away —
//! screens are never cleared on pop.
use crate::message::Screen;
// ---------------------------------------------------------------------------
// NavigationStack
// ---------------------------------------------------------------------------
/// Browser-like navigation with back/forward stacks and a vim jump list.
///
/// The jump list only records "significant" hops — detail views and
/// cross-references — skipping list/dashboard screens that users
/// visit briefly during drilling.
pub struct NavigationStack {
back_stack: Vec<Screen>,
current: Screen,
forward_stack: Vec<Screen>,
jump_list: Vec<Screen>,
jump_index: usize,
}
impl NavigationStack {
/// Create a new stack starting at the Dashboard.
#[must_use]
pub fn new() -> Self {
Self {
back_stack: Vec::new(),
current: Screen::Dashboard,
forward_stack: Vec::new(),
jump_list: Vec::new(),
jump_index: 0,
}
}
/// The currently displayed screen.
#[must_use]
pub fn current(&self) -> &Screen {
&self.current
}
/// Whether the current screen matches the given screen.
#[must_use]
pub fn is_at(&self, screen: &Screen) -> bool {
&self.current == screen
}
/// Navigate to a new screen.
///
/// Pushes current to back_stack, clears forward_stack (browser behavior),
/// and records detail hops in the jump list.
pub fn push(&mut self, screen: Screen) {
let old = std::mem::replace(&mut self.current, screen);
self.back_stack.push(old);
self.forward_stack.clear();
// Record significant hops in jump list (vim behavior):
// truncate any forward entries beyond jump_index, then append.
if self.current.is_detail_or_entity() {
self.jump_list.truncate(self.jump_index);
self.jump_list.push(self.current.clone());
self.jump_index = self.jump_list.len();
}
}
/// Go back to the previous screen.
///
/// Returns `None` at root (can't pop past the initial screen).
pub fn pop(&mut self) -> Option<&Screen> {
let prev = self.back_stack.pop()?;
let old = std::mem::replace(&mut self.current, prev);
self.forward_stack.push(old);
Some(&self.current)
}
/// Go forward (redo a pop).
///
/// Returns `None` if there's nothing to go forward to.
pub fn go_forward(&mut self) -> Option<&Screen> {
let next = self.forward_stack.pop()?;
let old = std::mem::replace(&mut self.current, next);
self.back_stack.push(old);
Some(&self.current)
}
/// Jump backward through the jump list (vim Ctrl+O).
///
/// Only visits detail/entity screens.
pub fn jump_back(&mut self) -> Option<&Screen> {
if self.jump_index == 0 {
return None;
}
self.jump_index -= 1;
self.jump_list.get(self.jump_index)
}
/// Jump forward through the jump list (vim Ctrl+I).
pub fn jump_forward(&mut self) -> Option<&Screen> {
if self.jump_index >= self.jump_list.len() {
return None;
}
let screen = self.jump_list.get(self.jump_index)?;
self.jump_index += 1;
Some(screen)
}
/// Reset to a single screen, clearing all history.
pub fn reset_to(&mut self, screen: Screen) {
self.current = screen;
self.back_stack.clear();
self.forward_stack.clear();
self.jump_list.clear();
self.jump_index = 0;
}
/// Breadcrumb labels for the current navigation path.
///
/// Returns the back stack labels plus the current screen label.
#[must_use]
pub fn breadcrumbs(&self) -> Vec<&str> {
self.back_stack
.iter()
.chain(std::iter::once(&self.current))
.map(Screen::label)
.collect()
}
/// Navigation depth (1 = at root, 2 = one push deep, etc.).
#[must_use]
pub fn depth(&self) -> usize {
self.back_stack.len() + 1
}
/// Whether there's anything to go back to.
#[must_use]
pub fn can_go_back(&self) -> bool {
!self.back_stack.is_empty()
}
/// Whether there's anything to go forward to.
#[must_use]
pub fn can_go_forward(&self) -> bool {
!self.forward_stack.is_empty()
}
}
impl Default for NavigationStack {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::message::EntityKey;
#[test]
fn test_new_starts_at_dashboard() {
let nav = NavigationStack::new();
assert!(nav.is_at(&Screen::Dashboard));
assert_eq!(nav.depth(), 1);
}
#[test]
fn test_push_pop_preserves_order() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::IssueDetail(EntityKey::issue(1, 42)));
assert!(nav.is_at(&Screen::IssueDetail(EntityKey::issue(1, 42))));
assert_eq!(nav.depth(), 3);
nav.pop();
assert!(nav.is_at(&Screen::IssueList));
nav.pop();
assert!(nav.is_at(&Screen::Dashboard));
}
#[test]
fn test_pop_at_root_returns_none() {
let mut nav = NavigationStack::new();
assert!(nav.pop().is_none());
assert!(nav.is_at(&Screen::Dashboard));
}
#[test]
fn test_forward_stack_cleared_on_new_push() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::Search);
nav.pop(); // back to IssueList, Search in forward
assert!(nav.can_go_forward());
nav.push(Screen::Timeline); // new push clears forward
assert!(!nav.can_go_forward());
}
#[test]
fn test_go_forward_restores() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::Search);
nav.pop(); // back to IssueList
let screen = nav.go_forward();
assert!(screen.is_some());
assert!(nav.is_at(&Screen::Search));
}
#[test]
fn test_go_forward_returns_none_when_empty() {
let mut nav = NavigationStack::new();
assert!(nav.go_forward().is_none());
}
#[test]
fn test_jump_list_skips_list_screens() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList); // not a detail — skip
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1))); // detail — record
nav.push(Screen::MrList); // not a detail — skip
nav.push(Screen::MrDetail(EntityKey::mr(1, 2))); // detail — record
assert_eq!(nav.jump_list.len(), 2);
}
#[test]
fn test_jump_back_and_forward() {
let mut nav = NavigationStack::new();
let issue = Screen::IssueDetail(EntityKey::issue(1, 1));
let mr = Screen::MrDetail(EntityKey::mr(1, 2));
nav.push(Screen::IssueList);
nav.push(issue.clone());
nav.push(Screen::MrList);
nav.push(mr.clone());
// jump_index is at 2 (past the end of 2 items)
let prev = nav.jump_back();
assert_eq!(prev, Some(&mr));
let prev = nav.jump_back();
assert_eq!(prev, Some(&issue));
// at beginning
assert!(nav.jump_back().is_none());
// forward
let next = nav.jump_forward();
assert_eq!(next, Some(&issue));
let next = nav.jump_forward();
assert_eq!(next, Some(&mr));
// at end
assert!(nav.jump_forward().is_none());
}
#[test]
fn test_jump_list_truncates_on_new_push() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1)));
nav.push(Screen::IssueDetail(EntityKey::issue(1, 2)));
nav.push(Screen::IssueDetail(EntityKey::issue(1, 3)));
// jump back twice
nav.jump_back();
nav.jump_back();
// jump_index = 1, pointing at issue 2
// new detail push truncates forward entries
nav.push(Screen::MrDetail(EntityKey::mr(1, 99)));
// should have issue(1,1) and mr(1,99), not issue(1,2) or issue(1,3)
assert_eq!(nav.jump_list.len(), 2);
assert_eq!(nav.jump_list[1], Screen::MrDetail(EntityKey::mr(1, 99)));
}
#[test]
fn test_reset_clears_all_history() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::Search);
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1)));
nav.reset_to(Screen::Dashboard);
assert!(nav.is_at(&Screen::Dashboard));
assert_eq!(nav.depth(), 1);
assert!(!nav.can_go_back());
assert!(!nav.can_go_forward());
assert!(nav.jump_list.is_empty());
}
#[test]
fn test_breadcrumbs_reflect_stack() {
let mut nav = NavigationStack::new();
assert_eq!(nav.breadcrumbs(), vec!["Dashboard"]);
nav.push(Screen::IssueList);
assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues"]);
nav.push(Screen::IssueDetail(EntityKey::issue(1, 42)));
assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues", "Issue"]);
}
#[test]
fn test_default_is_new() {
let nav = NavigationStack::default();
assert!(nav.is_at(&Screen::Dashboard));
assert_eq!(nav.depth(), 1);
}
#[test]
fn test_can_go_back_and_forward() {
let mut nav = NavigationStack::new();
assert!(!nav.can_go_back());
assert!(!nav.can_go_forward());
nav.push(Screen::IssueList);
assert!(nav.can_go_back());
assert!(!nav.can_go_forward());
nav.pop();
assert!(!nav.can_go_back());
assert!(nav.can_go_forward());
}
}