From d705313aae9ca34ccab7be17f5168bcb39c670d9 Mon Sep 17 00:00:00 2001 From: ruohki Date: Sat, 28 Feb 2026 23:10:23 +0100 Subject: [PATCH] feat(03-02): add navigation history, link cycling, and navigate_to to App - Add HistoryEntry struct with path, scroll_offset, selected_link fields - Add Phase 3 navigation fields to App: history, history_index, link_records, selected_link, current_path - Update App::new() to accept link_records and current_path parameters - Implement navigate_to() with browser-style forward history truncation - Implement navigate_back() and navigate_forward() with state restoration - Implement follow_selected_link() for wiki-link and standard link resolution - Implement select_next_link() and select_prev_link() with wrap-around - Implement scroll_to_selected_link() for auto-scroll centering - Add Tab, Shift-Tab, Enter, Backspace, Alt+Left, Alt+Right key bindings - Update handle_resize() to capture and validate link_records after re-render - Update main.rs to destructure link_records and pass to App::new() - Remove #[allow(dead_code)] on config field (now actively used) --- src/app.rs | 489 +++++++++++++++++++++++++++++++++++++++++++++++----- src/main.rs | 55 ++++-- 2 files changed, 481 insertions(+), 63 deletions(-) diff --git a/src/app.rs b/src/app.rs index a9d454f..58e3da3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,10 +3,12 @@ //! //! # Design //! -//! The event loop is the convergence point for all Phase 1 and Phase 2 behavior: +//! The event loop is the convergence point for all Phase 1, 2, and 3 behavior: //! - Signal polling (SIGHUP/SIGTERM) checked first each iteration //! - Double-press Ctrl+C state machine with a 2-second window //! - Login shell mode suppresses the 'q' key quit shortcut +//! - Tab/Shift-Tab cycle through links; Enter follows the selected link +//! - Backspace and Alt+Left/Right navigate the browser-style history stack //! - j/k and arrow keys scroll content one line; PgUp/PgDn scroll one page //! - Terminal resize triggers re-render of raw markdown at new width //! - Clean shutdown path restores terminal before displaying the goodbye message @@ -65,9 +67,21 @@ pub enum ShutdownReason { Signal, } +// ── HistoryEntry ────────────────────────────────────────────────────────────── + +/// A snapshot of navigation state for back/forward history. +struct HistoryEntry { + /// Vault-relative path (e.g. "guides/getting-started.md") + path: String, + /// Scroll offset at time of navigation away from this page + scroll_offset: u16, + /// Selected link index at time of navigation (None if no link was selected) + selected_link: Option, +} + // ── App ─────────────────────────────────────────────────────────────────────── -/// Application state for the Phase 2 event loop. +/// Application state for the bbs-md event loop. pub struct App { // ── Phase 1 fields (preserved exactly) ─────────────────────────────────── /// Whether the process was launched as a login shell (argv[0] starts with '-'). @@ -85,7 +99,6 @@ pub struct App { should_quit: bool, /// Loaded application configuration (vault_path, theme). - #[allow(dead_code)] config: Config, // ── Phase 2 additions ───────────────────────────────────────────────────── @@ -103,25 +116,47 @@ pub struct App { /// Height of the content area from the last draw, used for page scrolling. last_content_height: u16, + + // ── Phase 3 additions ───────────────────────────────────────────────────── + /// Browser-style navigation history. Vec of visited pages with state. + history: Vec, + /// Current position in history (index into history Vec). + history_index: usize, + /// Link records from the current rendered document. + link_records: Vec, + /// Index of the currently selected link (None = no link selected). + selected_link: Option, + /// Current document's vault-relative path (e.g. "index.md", "guides/page.md"). + current_path: String, } impl App { /// Create a new `App` with the given document state. /// /// `is_login_shell` controls whether the 'q' key is active. - /// `config` is stored for future use (vault_path for navigation in Phase 3+). + /// `config` is stored for vault_path access during navigation. /// `document` is the initial document to display. /// `raw_content` is the raw markdown string (Some if Loaded) for resize re-render. + /// `link_records` are the link metadata records from the initial render. + /// `current_path` is the vault-relative path of the initial document. pub fn new( is_login_shell: bool, config: Config, document: DocumentState, raw_content: Option, + link_records: Vec, + current_path: String, ) -> Self { let filename = match &document { DocumentState::Loaded { filename, .. } => filename.clone(), DocumentState::Missing { .. } | DocumentState::Error { .. } => "ERROR".to_string(), }; + // Initialize history with one entry for the initial page + let initial_history = vec![HistoryEntry { + path: current_path.clone(), + scroll_offset: 0, + selected_link: None, + }]; App { is_login_shell, ctrl_c_pressed_at: None, @@ -133,6 +168,11 @@ impl App { raw_content, filename, last_content_height: 24, + history: initial_history, + history_index: 0, + link_records, + selected_link: None, + current_path, } } @@ -201,8 +241,18 @@ impl App { /// We re-render so horizontal rules, code block borders, and table widths adapt. fn handle_resize(&mut self, new_width: u16) { if let Some(ref content) = self.raw_content.clone() { - // TODO(03-02): use link_records from render_markdown for Tab-cycling navigation - let (lines, _link_records) = crate::renderer::render_markdown(content, new_width, None); + let (lines, link_records) = crate::renderer::render_markdown( + content, + new_width, + Some(&self.config.vault_path), + ); + self.link_records = link_records; + // Preserve selected_link if still valid after re-render + if let Some(i) = self.selected_link { + if i >= self.link_records.len() { + self.selected_link = None; + } + } let filename = self.filename.clone(); self.document = DocumentState::Loaded { filename, lines }; // Clamp scroll to new max after re-render @@ -215,14 +265,20 @@ impl App { /// Handle a single key event and update app state accordingly. /// - /// # Key bindings + /// # Key bindings (in match order) /// - /// - `Ctrl+C` — first press opens double-press window; second press (within 2s) quits - /// - `q` — quits immediately (suppressed in login shell mode) + /// - `Ctrl+C` — first press opens double-press window; second press (within 2s) quits + /// - `q` — quits immediately (suppressed in login shell mode) + /// - `Tab` — select next link (wrap-around) + /// - `Shift+Tab` — select previous link (wrap-around) + /// - `Enter` — follow the selected link + /// - `Backspace` — navigate back in history + /// - `Alt+Left` — navigate back in history + /// - `Alt+Right` — navigate forward in history /// - `j` / `Down` — scroll down one line /// - `k` / `Up` — scroll up one line - /// - `PgDn` — scroll down one page - /// - `PgUp` — scroll up one page + /// - `PgDn` — scroll down one page + /// - `PgUp` — scroll up one page /// - Any other key — if the quit prompt is showing, dismisses it fn handle_key(&mut self, key: KeyEvent) { match key.code { @@ -247,6 +303,26 @@ impl App { // 'q' is suppressed in login shell mode — only double Ctrl+C exits self.should_quit = true; } + // ── Navigation keys ─────────────────────────────────────────────── + KeyCode::Tab => { + self.select_next_link(); + } + KeyCode::BackTab => { + self.select_prev_link(); + } + KeyCode::Enter => { + self.follow_selected_link(); + } + KeyCode::Backspace => { + self.navigate_back(); + } + // Alt+Left = back, Alt+Right = forward + KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => { + self.navigate_back(); + } + KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => { + self.navigate_forward(); + } // ── Scrolling keys — do NOT dismiss the quit prompt ─────────────── KeyCode::Char('j') | KeyCode::Down => { self.scroll_down(1); @@ -272,6 +348,256 @@ impl App { } } + // ── Navigation methods ───────────────────────────────────────────────────── + + /// Navigate to a new document by vault-relative path. + /// + /// Saves current state to history, loads the new document, renders it, + /// and updates all navigation state. If history_index is not at the end, + /// truncates forward history (browser-style fork). + fn navigate_to(&mut self, vault_relative: &str) { + let vault_path = self.config.vault_path.clone(); + + // 1. Save current state to history at current position + if let Some(entry) = self.history.get_mut(self.history_index) { + entry.scroll_offset = self.scroll_offset; + entry.selected_link = self.selected_link; + } + + // 2. Truncate forward history if we navigated back then follow a new link + self.history.truncate(self.history_index + 1); + + // 3. Load new document + match crate::vault::load_document(&vault_path, vault_relative) { + crate::vault::VaultDocument::Loaded { path, content } => { + let filename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| vault_relative.to_string()); + let width = ratatui::crossterm::terminal::size() + .map(|(w, _)| w) + .unwrap_or(80); + let (lines, link_records) = + crate::renderer::render_markdown(&content, width, Some(&vault_path)); + + self.document = DocumentState::Loaded { + filename: filename.clone(), + lines, + }; + self.raw_content = Some(content); + self.link_records = link_records; + self.selected_link = None; + self.scroll_offset = 0; + self.current_path = vault_relative.to_string(); + self.filename = filename; + + // 4. Push new history entry + self.history.push(HistoryEntry { + path: vault_relative.to_string(), + scroll_offset: 0, + selected_link: None, + }); + self.history_index = self.history.len() - 1; + } + crate::vault::VaultDocument::Missing { path } => { + // Show error screen for missing link target — do NOT push to history + self.document = DocumentState::Missing { path }; + self.raw_content = None; + self.link_records = Vec::new(); + self.selected_link = None; + } + crate::vault::VaultDocument::ReadError { path, reason } => { + self.document = DocumentState::Error { path, reason }; + self.raw_content = None; + self.link_records = Vec::new(); + self.selected_link = None; + } + } + } + + /// Navigate back one step in the history stack, restoring scroll and link selection. + fn navigate_back(&mut self) { + if self.history_index == 0 { + return; + } + + // Save current state + if let Some(entry) = self.history.get_mut(self.history_index) { + entry.scroll_offset = self.scroll_offset; + entry.selected_link = self.selected_link; + } + + self.history_index -= 1; + let target_path = self.history[self.history_index].path.clone(); + let target_scroll = self.history[self.history_index].scroll_offset; + let target_link = self.history[self.history_index].selected_link; + + // Re-load and re-render the document (per research: don't cache rendered output) + let vault_path = self.config.vault_path.clone(); + if let crate::vault::VaultDocument::Loaded { path, content } = + crate::vault::load_document(&vault_path, &target_path) + { + let filename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| target_path.clone()); + let width = ratatui::crossterm::terminal::size() + .map(|(w, _)| w) + .unwrap_or(80); + let (lines, link_records) = + crate::renderer::render_markdown(&content, width, Some(&vault_path)); + + self.document = DocumentState::Loaded { + filename: filename.clone(), + lines, + }; + self.raw_content = Some(content); + self.link_records = link_records; + self.selected_link = target_link; + self.scroll_offset = target_scroll; + self.current_path = target_path; + self.filename = filename; + } + // If file was deleted since last visit, leave current doc unchanged + } + + /// Navigate forward one step in the history stack, restoring scroll and link selection. + fn navigate_forward(&mut self) { + if self.history_index >= self.history.len().saturating_sub(1) { + return; + } + + // Save current state + if let Some(entry) = self.history.get_mut(self.history_index) { + entry.scroll_offset = self.scroll_offset; + entry.selected_link = self.selected_link; + } + + self.history_index += 1; + let target_path = self.history[self.history_index].path.clone(); + let target_scroll = self.history[self.history_index].scroll_offset; + let target_link = self.history[self.history_index].selected_link; + + let vault_path = self.config.vault_path.clone(); + if let crate::vault::VaultDocument::Loaded { path, content } = + crate::vault::load_document(&vault_path, &target_path) + { + let filename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| target_path.clone()); + let width = ratatui::crossterm::terminal::size() + .map(|(w, _)| w) + .unwrap_or(80); + let (lines, link_records) = + crate::renderer::render_markdown(&content, width, Some(&vault_path)); + + self.document = DocumentState::Loaded { + filename: filename.clone(), + lines, + }; + self.raw_content = Some(content); + self.link_records = link_records; + self.selected_link = target_link; + self.scroll_offset = target_scroll; + self.current_path = target_path; + self.filename = filename; + } + } + + /// Follow the currently selected link, resolving wiki-links or standard links. + fn follow_selected_link(&mut self) { + let link_index = match self.selected_link { + Some(i) if i < self.link_records.len() => i, + _ => return, // No link selected or index out of bounds + }; + + let dest = self.link_records[link_index].dest.clone(); + let is_wiki = self.link_records[link_index].is_wiki; + + let vault_path = self.config.vault_path.clone(); + + if is_wiki { + // Resolve wiki-link to vault-relative path + match crate::vault::resolve_wiki_link(&vault_path, &dest) { + Some(resolved) => { + let rel = resolved.to_string_lossy().to_string(); + self.navigate_to(&rel); + } + None => { + // Broken wiki-link — already shown as red/strikethrough in render. + // Do nothing on Enter for broken links. + } + } + } else { + // Standard markdown link — resolve relative to current document's directory + match crate::vault::resolve_standard_link(&vault_path, &self.current_path, &dest) { + Some(resolved) => { + let rel = resolved.to_string_lossy().to_string(); + self.navigate_to(&rel); + } + None => { + // Broken link — show error page + let full_path = vault_path.join(&dest); + self.document = DocumentState::Missing { path: full_path }; + self.link_records = Vec::new(); + self.selected_link = None; + } + } + } + } + + // ── Link cycling helpers ─────────────────────────────────────────────────── + + /// Select the next link (Tab key), wrapping from last to first. + fn select_next_link(&mut self) { + if self.link_records.is_empty() { + return; + } + let next = match self.selected_link { + Some(i) => (i + 1) % self.link_records.len(), // Wrap around + None => 0, // First Tab press selects the first link + }; + self.selected_link = Some(next); + self.scroll_to_selected_link(); + } + + /// Select the previous link (Shift+Tab key), wrapping from first to last. + fn select_prev_link(&mut self) { + if self.link_records.is_empty() { + return; + } + let prev = match self.selected_link { + Some(0) => self.link_records.len() - 1, // Wrap to last + Some(i) => i - 1, + None => self.link_records.len() - 1, // First Shift+Tab selects last link + }; + self.selected_link = Some(prev); + self.scroll_to_selected_link(); + } + + /// Auto-scroll to center the selected link on screen if it's off-screen. + fn scroll_to_selected_link(&mut self) { + if let Some(i) = self.selected_link { + if let Some(record) = self.link_records.get(i) { + let link_line = record.line_index as u16; + let viewport_start = self.scroll_offset; + let viewport_end = viewport_start + self.last_content_height; + + if link_line < viewport_start || link_line >= viewport_end { + // Center the link on screen + let half = self.last_content_height / 2; + self.scroll_offset = link_line.saturating_sub(half); + // Clamp to max scroll + let max = self.max_scroll(); + if self.scroll_offset > max { + self.scroll_offset = max; + } + } + } + } + } + // ── Scroll helpers ───────────────────────────────────────────────────────── fn scroll_down(&mut self, n: u16) { @@ -300,7 +626,7 @@ impl App { // ── Draw ────────────────────────────────────────────────────────────────── - /// Draw the Phase 2 TUI: content area + status bar. + /// Draw the TUI: content area + status bar. /// /// Layout: /// ```text @@ -309,7 +635,7 @@ impl App { /// │ │ /// │ ... │ /// ├─────────────────────────────────────────┤ - /// │ index.md q:Quit j/k:Scroll ... │ ← Length(1) status bar + /// │ guides > page Tab:Links q:Quit │ ← Length(1) status bar /// └─────────────────────────────────────────┘ /// ``` fn draw(&mut self, frame: &mut Frame) { @@ -330,8 +656,38 @@ impl App { // ── Content area ───────────────────────────────────────────────────── match &self.document { DocumentState::Loaded { lines, .. } => { - let para = Paragraph::new(lines.clone()) - .scroll((self.scroll_offset, 0)); + // Apply REVERSED modifier to the selected link at draw time + let display_lines = if let Some(selected_idx) = self.selected_link { + if let Some(record) = self.link_records.get(selected_idx) { + let mut cloned = lines.clone(); + if let Some(line) = cloned.get_mut(record.line_index) { + // Walk spans, summing character widths until we reach the link range + let mut col = 0usize; + for span in line.spans.iter_mut() { + let span_chars = span.content.chars().count(); + if col >= record.col_offset + && col < record.col_offset + record.span_len + { + span.style = span.style.add_modifier(Modifier::REVERSED); + } + // Also catch spans that overlap the link range + if col < record.col_offset + record.span_len + && col + span_chars > record.col_offset + { + span.style = span.style.add_modifier(Modifier::REVERSED); + } + col += span_chars; + } + } + cloned + } else { + lines.clone() + } + } else { + lines.clone() + }; + + let para = Paragraph::new(display_lines).scroll((self.scroll_offset, 0)); frame.render_widget(para, content_area); } DocumentState::Missing { path } => { @@ -349,50 +705,70 @@ impl App { self.draw_status_bar(frame, status_area); } - /// Render the one-line status bar with filename on the left and hints on the right. + /// Render the one-line status bar with breadcrumb on the left and navigation hints on the right. /// /// Uses `Modifier::REVERSED` for retro reverse-video BBS styling. /// When the quit prompt is active, replaces the hints with the warning text. fn draw_status_bar(&self, frame: &mut Frame, area: Rect) { let width = area.width as usize; - let left = format!(" {} ", self.filename); + let breadcrumb = build_breadcrumb(&self.current_path); + let left = format!(" {} ", breadcrumb); - let right = if self.show_quit_prompt { - " Press Ctrl+C again to disconnect... ".to_string() - } else if self.is_login_shell { - " Ctrl+C\u{00D7}2:Quit j/k:Scroll PgUp/PgDn:Page ".to_string() + if self.show_quit_prompt { + let right = " Press Ctrl+C again to disconnect... ".to_string(); + let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len()); + let padding = " ".repeat(pad_len); + let bar = Paragraph::new(Line::from(vec![Span::styled( + format!("{}{}{}", left, padding, right), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD | Modifier::REVERSED), + )])); + frame.render_widget(bar, area); + return; + } + + // Normal status bar: build right side from nav indicators + link counter + hints + let mut right_parts: Vec = Vec::new(); + + // Back indicator (shown only when history exists in that direction) + if self.history_index > 0 { + right_parts.push("< Back".to_string()); + } + + // Link counter (shown when a link is selected) + if let Some(i) = self.selected_link { + right_parts.push(format!("Link {}/{}", i + 1, self.link_records.len())); + } + + // Forward indicator + if self.history_index < self.history.len().saturating_sub(1) { + right_parts.push("Forward >".to_string()); + } + + // Keyboard hints + let hints = if self.is_login_shell { + "Tab:Links Enter:Go Bksp:Back Ctrl+C\u{00D7}2:Quit" } else { - " q:Quit j/k:Scroll PgUp/PgDn:Page ".to_string() + "Tab:Links Enter:Go Bksp:Back q:Quit" }; + right_parts.push(hints.to_string()); - // Calculate padding between left and right so the bar fills the full width - let pad_len = width - .saturating_sub(left.len()) - .saturating_sub(right.len()); + let right = format!(" {} ", right_parts.join(" ")); + + let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len()); let padding = " ".repeat(pad_len); - let status_text = if self.show_quit_prompt { - // Quit prompt: yellow bold reverse video - Line::from(vec![ - Span::styled( - format!("{}{}{}", left, padding, right), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD | Modifier::REVERSED), - ), - ]) - } else { - // Normal status bar: reverse video - Line::from(vec![Span::raw(format!("{}{}{}", left, padding, right))]) - }; - - let bar = Paragraph::new(status_text) - .style(Style::default().add_modifier(Modifier::REVERSED)); + let bar = Paragraph::new(Line::from(vec![Span::raw(format!( + "{}{}{}", + left, padding, right + ))])) + .style(Style::default().add_modifier(Modifier::REVERSED)); frame.render_widget(bar, area); } - /// Render the BBS-style error screen when index.md is missing or unreadable. + /// Render the BBS-style error screen when a document is missing or unreadable. /// /// Layout (centered in `area`): /// ```text @@ -512,6 +888,31 @@ impl App { } } +// ── Breadcrumb helper ───────────────────────────────────────────────────────── + +/// Build a breadcrumb trail from a vault-relative path. +/// +/// Converts path components to a human-readable trail, stripping `.md` extensions. +/// +/// # Examples +/// +/// ```text +/// build_breadcrumb("index.md") → "index" +/// build_breadcrumb("guides/getting-started.md") → "guides > getting-started" +/// ``` +fn build_breadcrumb(vault_relative: &str) -> String { + std::path::Path::new(vault_relative) + .components() + .map(|c| { + let s = c.as_os_str().to_string_lossy(); + s.strip_suffix(".md") + .unwrap_or(s.as_ref()) + .to_string() + }) + .collect::>() + .join(" > ") +} + // ── show_goodbye ───────────────────────────────────────────────────────────── /// Display a BBS-style goodbye message after terminal has been restored. diff --git a/src/main.rs b/src/main.rs index bc57a74..206ecad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,24 +38,32 @@ fn main() { .map(|(w, _)| w) .unwrap_or(80); - let (initial_doc, raw_content) = match vault::load_document(&app_config.vault_path, "index.md") { - vault::VaultDocument::Loaded { path, content } => { - let filename = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "index.md".to_string()); - // TODO(03-02): use link_records for Tab-cycling navigation - let (lines, _link_records) = renderer::render_markdown(&content, initial_width, None); - let doc = app::DocumentState::Loaded { filename, lines }; - (doc, Some(content)) - } - vault::VaultDocument::Missing { path } => { - (app::DocumentState::Missing { path }, None) - } - vault::VaultDocument::ReadError { path, reason } => { - (app::DocumentState::Error { path, reason }, None) - } - }; + let (initial_doc, raw_content, initial_link_records) = + match vault::load_document(&app_config.vault_path, "index.md") { + vault::VaultDocument::Loaded { path, content } => { + let filename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "index.md".to_string()); + let (lines, link_records) = renderer::render_markdown( + &content, + initial_width, + Some(&app_config.vault_path), + ); + let doc = app::DocumentState::Loaded { filename, lines }; + (doc, Some(content), link_records) + } + vault::VaultDocument::Missing { path } => { + (app::DocumentState::Missing { path }, None, Vec::new()) + } + vault::VaultDocument::ReadError { path, reason } => { + ( + app::DocumentState::Error { path, reason }, + None, + Vec::new(), + ) + } + }; // ── TERMINAL PHASE ──────────────────────────────────────────────────────── // Install safety envelope BEFORE terminal init so panics during init are caught. @@ -86,7 +94,16 @@ fn main() { // 7. Create app state and run the event loop. // raw_content is passed so the event loop can re-render on terminal resize. - let mut app_state = app::App::new(is_login_shell, app_config, initial_doc, raw_content); + // link_records enables Tab-cycling navigation. + // "index.md" is the initial vault-relative path for history and breadcrumb. + let mut app_state = app::App::new( + is_login_shell, + app_config, + initial_doc, + raw_content, + initial_link_records, + "index.md".to_string(), + ); let shutdown_reason = app_state.run_event_loop(&mut term, &signal_flags); // ── SHUTDOWN PHASE ────────────────────────────────────────────────────────