feat: TUI Phase 1 common widgets + scoring/path beads
This commit is contained in:
339
crates/lore-tui/src/navigation.rs
Normal file
339
crates/lore-tui/src/navigation.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user