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)
This commit is contained in:
+439
-38
@@ -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<usize>,
|
||||
}
|
||||
|
||||
// ── 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<HistoryEntry>,
|
||||
/// Current position in history (index into history Vec).
|
||||
history_index: usize,
|
||||
/// Link records from the current rendered document.
|
||||
link_records: Vec<crate::renderer::LinkRecord>,
|
||||
/// Index of the currently selected link (None = no link selected).
|
||||
selected_link: Option<usize>,
|
||||
/// 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<String>,
|
||||
link_records: Vec<crate::renderer::LinkRecord>,
|
||||
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,10 +265,16 @@ 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)
|
||||
/// - `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
|
||||
@@ -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()
|
||||
} else {
|
||||
" q:Quit j/k:Scroll PgUp/PgDn:Page ".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());
|
||||
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 status_text = if self.show_quit_prompt {
|
||||
// Quit prompt: yellow bold reverse video
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
let bar = Paragraph::new(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))])
|
||||
};
|
||||
)]));
|
||||
frame.render_widget(bar, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let bar = Paragraph::new(status_text)
|
||||
// Normal status bar: build right side from nav indicators + link counter + hints
|
||||
let mut right_parts: Vec<String> = 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 {
|
||||
"Tab:Links Enter:Go Bksp:Back q:Quit"
|
||||
};
|
||||
right_parts.push(hints.to_string());
|
||||
|
||||
let right = format!(" {} ", right_parts.join(" "));
|
||||
|
||||
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::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::<Vec<_>>()
|
||||
.join(" > ")
|
||||
}
|
||||
|
||||
// ── show_goodbye ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Display a BBS-style goodbye message after terminal has been restored.
|
||||
|
||||
+24
-7
@@ -38,22 +38,30 @@ fn main() {
|
||||
.map(|(w, _)| w)
|
||||
.unwrap_or(80);
|
||||
|
||||
let (initial_doc, raw_content) = match vault::load_document(&app_config.vault_path, "index.md") {
|
||||
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());
|
||||
// TODO(03-02): use link_records for Tab-cycling navigation
|
||||
let (lines, _link_records) = renderer::render_markdown(&content, initial_width, None);
|
||||
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))
|
||||
(doc, Some(content), link_records)
|
||||
}
|
||||
vault::VaultDocument::Missing { path } => {
|
||||
(app::DocumentState::Missing { path }, None)
|
||||
(app::DocumentState::Missing { path }, None, Vec::new())
|
||||
}
|
||||
vault::VaultDocument::ReadError { path, reason } => {
|
||||
(app::DocumentState::Error { path, reason }, None)
|
||||
(
|
||||
app::DocumentState::Error { path, reason },
|
||||
None,
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user