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
|
//! # 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
|
//! - Signal polling (SIGHUP/SIGTERM) checked first each iteration
|
||||||
//! - Double-press Ctrl+C state machine with a 2-second window
|
//! - Double-press Ctrl+C state machine with a 2-second window
|
||||||
//! - Login shell mode suppresses the 'q' key quit shortcut
|
//! - 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
|
//! - 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
|
//! - Terminal resize triggers re-render of raw markdown at new width
|
||||||
//! - Clean shutdown path restores terminal before displaying the goodbye message
|
//! - Clean shutdown path restores terminal before displaying the goodbye message
|
||||||
@@ -65,9 +67,21 @@ pub enum ShutdownReason {
|
|||||||
Signal,
|
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 ───────────────────────────────────────────────────────────────────────
|
// ── App ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Application state for the Phase 2 event loop.
|
/// Application state for the bbs-md event loop.
|
||||||
pub struct App {
|
pub struct App {
|
||||||
// ── Phase 1 fields (preserved exactly) ───────────────────────────────────
|
// ── Phase 1 fields (preserved exactly) ───────────────────────────────────
|
||||||
/// Whether the process was launched as a login shell (argv[0] starts with '-').
|
/// Whether the process was launched as a login shell (argv[0] starts with '-').
|
||||||
@@ -85,7 +99,6 @@ pub struct App {
|
|||||||
should_quit: bool,
|
should_quit: bool,
|
||||||
|
|
||||||
/// Loaded application configuration (vault_path, theme).
|
/// Loaded application configuration (vault_path, theme).
|
||||||
#[allow(dead_code)]
|
|
||||||
config: Config,
|
config: Config,
|
||||||
|
|
||||||
// ── Phase 2 additions ─────────────────────────────────────────────────────
|
// ── Phase 2 additions ─────────────────────────────────────────────────────
|
||||||
@@ -103,25 +116,47 @@ pub struct App {
|
|||||||
|
|
||||||
/// Height of the content area from the last draw, used for page scrolling.
|
/// Height of the content area from the last draw, used for page scrolling.
|
||||||
last_content_height: u16,
|
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 {
|
impl App {
|
||||||
/// Create a new `App` with the given document state.
|
/// Create a new `App` with the given document state.
|
||||||
///
|
///
|
||||||
/// `is_login_shell` controls whether the 'q' key is active.
|
/// `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.
|
/// `document` is the initial document to display.
|
||||||
/// `raw_content` is the raw markdown string (Some if Loaded) for resize re-render.
|
/// `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(
|
pub fn new(
|
||||||
is_login_shell: bool,
|
is_login_shell: bool,
|
||||||
config: Config,
|
config: Config,
|
||||||
document: DocumentState,
|
document: DocumentState,
|
||||||
raw_content: Option<String>,
|
raw_content: Option<String>,
|
||||||
|
link_records: Vec<crate::renderer::LinkRecord>,
|
||||||
|
current_path: String,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let filename = match &document {
|
let filename = match &document {
|
||||||
DocumentState::Loaded { filename, .. } => filename.clone(),
|
DocumentState::Loaded { filename, .. } => filename.clone(),
|
||||||
DocumentState::Missing { .. } | DocumentState::Error { .. } => "ERROR".to_string(),
|
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 {
|
App {
|
||||||
is_login_shell,
|
is_login_shell,
|
||||||
ctrl_c_pressed_at: None,
|
ctrl_c_pressed_at: None,
|
||||||
@@ -133,6 +168,11 @@ impl App {
|
|||||||
raw_content,
|
raw_content,
|
||||||
filename,
|
filename,
|
||||||
last_content_height: 24,
|
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.
|
/// We re-render so horizontal rules, code block borders, and table widths adapt.
|
||||||
fn handle_resize(&mut self, new_width: u16) {
|
fn handle_resize(&mut self, new_width: u16) {
|
||||||
if let Some(ref content) = self.raw_content.clone() {
|
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(
|
||||||
let (lines, _link_records) = crate::renderer::render_markdown(content, new_width, None);
|
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();
|
let filename = self.filename.clone();
|
||||||
self.document = DocumentState::Loaded { filename, lines };
|
self.document = DocumentState::Loaded { filename, lines };
|
||||||
// Clamp scroll to new max after re-render
|
// Clamp scroll to new max after re-render
|
||||||
@@ -215,10 +265,16 @@ impl App {
|
|||||||
|
|
||||||
/// Handle a single key event and update app state accordingly.
|
/// 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
|
/// - `Ctrl+C` — first press opens double-press window; second press (within 2s) quits
|
||||||
/// - `q` — quits immediately (suppressed in login shell mode)
|
/// - `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
|
/// - `j` / `Down` — scroll down one line
|
||||||
/// - `k` / `Up` — scroll up one line
|
/// - `k` / `Up` — scroll up one line
|
||||||
/// - `PgDn` — scroll down one page
|
/// - `PgDn` — scroll down one page
|
||||||
@@ -247,6 +303,26 @@ impl App {
|
|||||||
// 'q' is suppressed in login shell mode — only double Ctrl+C exits
|
// 'q' is suppressed in login shell mode — only double Ctrl+C exits
|
||||||
self.should_quit = true;
|
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 ───────────────
|
// ── Scrolling keys — do NOT dismiss the quit prompt ───────────────
|
||||||
KeyCode::Char('j') | KeyCode::Down => {
|
KeyCode::Char('j') | KeyCode::Down => {
|
||||||
self.scroll_down(1);
|
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 ─────────────────────────────────────────────────────────
|
// ── Scroll helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn scroll_down(&mut self, n: u16) {
|
fn scroll_down(&mut self, n: u16) {
|
||||||
@@ -300,7 +626,7 @@ impl App {
|
|||||||
|
|
||||||
// ── Draw ──────────────────────────────────────────────────────────────────
|
// ── Draw ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Draw the Phase 2 TUI: content area + status bar.
|
/// Draw the TUI: content area + status bar.
|
||||||
///
|
///
|
||||||
/// Layout:
|
/// Layout:
|
||||||
/// ```text
|
/// ```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) {
|
fn draw(&mut self, frame: &mut Frame) {
|
||||||
@@ -330,8 +656,38 @@ impl App {
|
|||||||
// ── Content area ─────────────────────────────────────────────────────
|
// ── Content area ─────────────────────────────────────────────────────
|
||||||
match &self.document {
|
match &self.document {
|
||||||
DocumentState::Loaded { lines, .. } => {
|
DocumentState::Loaded { lines, .. } => {
|
||||||
let para = Paragraph::new(lines.clone())
|
// Apply REVERSED modifier to the selected link at draw time
|
||||||
.scroll((self.scroll_offset, 0));
|
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);
|
frame.render_widget(para, content_area);
|
||||||
}
|
}
|
||||||
DocumentState::Missing { path } => {
|
DocumentState::Missing { path } => {
|
||||||
@@ -349,50 +705,70 @@ impl App {
|
|||||||
self.draw_status_bar(frame, status_area);
|
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.
|
/// Uses `Modifier::REVERSED` for retro reverse-video BBS styling.
|
||||||
/// When the quit prompt is active, replaces the hints with the warning text.
|
/// When the quit prompt is active, replaces the hints with the warning text.
|
||||||
fn draw_status_bar(&self, frame: &mut Frame, area: Rect) {
|
fn draw_status_bar(&self, frame: &mut Frame, area: Rect) {
|
||||||
let width = area.width as usize;
|
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 {
|
if self.show_quit_prompt {
|
||||||
" Press Ctrl+C again to disconnect... ".to_string()
|
let right = " Press Ctrl+C again to disconnect... ".to_string();
|
||||||
} else if self.is_login_shell {
|
let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len());
|
||||||
" 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());
|
|
||||||
let padding = " ".repeat(pad_len);
|
let padding = " ".repeat(pad_len);
|
||||||
|
let bar = Paragraph::new(Line::from(vec![Span::styled(
|
||||||
let status_text = if self.show_quit_prompt {
|
|
||||||
// Quit prompt: yellow bold reverse video
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
format!("{}{}{}", left, padding, right),
|
format!("{}{}{}", left, padding, right),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Yellow)
|
.fg(Color::Yellow)
|
||||||
.add_modifier(Modifier::BOLD | Modifier::REVERSED),
|
.add_modifier(Modifier::BOLD | Modifier::REVERSED),
|
||||||
),
|
)]));
|
||||||
])
|
frame.render_widget(bar, area);
|
||||||
} else {
|
return;
|
||||||
// Normal status bar: reverse video
|
}
|
||||||
Line::from(vec![Span::raw(format!("{}{}{}", left, padding, right))])
|
|
||||||
};
|
|
||||||
|
|
||||||
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));
|
.style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
frame.render_widget(bar, area);
|
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`):
|
/// Layout (centered in `area`):
|
||||||
/// ```text
|
/// ```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 ─────────────────────────────────────────────────────────────
|
// ── show_goodbye ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Display a BBS-style goodbye message after terminal has been restored.
|
/// Display a BBS-style goodbye message after terminal has been restored.
|
||||||
|
|||||||
+24
-7
@@ -38,22 +38,30 @@ fn main() {
|
|||||||
.map(|(w, _)| w)
|
.map(|(w, _)| w)
|
||||||
.unwrap_or(80);
|
.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 } => {
|
vault::VaultDocument::Loaded { path, content } => {
|
||||||
let filename = path
|
let filename = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| "index.md".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(
|
||||||
let (lines, _link_records) = renderer::render_markdown(&content, initial_width, None);
|
&content,
|
||||||
|
initial_width,
|
||||||
|
Some(&app_config.vault_path),
|
||||||
|
);
|
||||||
let doc = app::DocumentState::Loaded { filename, lines };
|
let doc = app::DocumentState::Loaded { filename, lines };
|
||||||
(doc, Some(content))
|
(doc, Some(content), link_records)
|
||||||
}
|
}
|
||||||
vault::VaultDocument::Missing { path } => {
|
vault::VaultDocument::Missing { path } => {
|
||||||
(app::DocumentState::Missing { path }, None)
|
(app::DocumentState::Missing { path }, None, Vec::new())
|
||||||
}
|
}
|
||||||
vault::VaultDocument::ReadError { path, reason } => {
|
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.
|
// 7. Create app state and run the event loop.
|
||||||
// raw_content is passed so the event loop can re-render on terminal resize.
|
// 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);
|
let shutdown_reason = app_state.run_event_loop(&mut term, &signal_flags);
|
||||||
|
|
||||||
// ── SHUTDOWN PHASE ────────────────────────────────────────────────────────
|
// ── SHUTDOWN PHASE ────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user