27139cf7cd
- Add PageMeta struct and read_page_meta() to app.rs with Gregorian date math - App::new() computes initial page_meta from vault path + current_path - navigate_to/back/forward all prepend splash lines and offset link_records for index.md - handle_resize re-prepends splash for index.md after re-render - Initial index.md load in main.rs prepends splash and adjusts link_records - Status bar right side now shows 'Last modified: Mon DD, YYYY | X.X KB' - Graceful truncation loop drops metadata fields when status bar is too narrow
1101 lines
46 KiB
Rust
1101 lines
46 KiB
Rust
//! Application event loop, key handling, document display, scrolling, and
|
|
//! shutdown logic for bbs-md.
|
|
//!
|
|
//! # Design
|
|
//!
|
|
//! 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
|
|
//!
|
|
//! # Exit Paths
|
|
//!
|
|
//! - `ShutdownReason::UserQuit` — 'q' key (non-login) or double Ctrl+C
|
|
//! - `ShutdownReason::Signal` — SIGHUP/SIGTERM from OS
|
|
//! - `Err(BrokenPipe)` — SSH connection closed mid-write (silent exit)
|
|
//! - `Err(_)` — unexpected I/O error (logged to stderr after terminal restore)
|
|
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, Instant, UNIX_EPOCH};
|
|
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
|
use ratatui::prelude::*;
|
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
|
use crate::config::Config;
|
|
use crate::terminal::Term;
|
|
use crate::signals::SignalFlags;
|
|
|
|
// ── PageMeta ──────────────────────────────────────────────────────────────────
|
|
|
|
/// File metadata for status bar display.
|
|
struct PageMeta {
|
|
/// Human-readable last-modified date, e.g. "Feb 25, 2026".
|
|
modified: String,
|
|
/// Human-readable file size, e.g. "2.4 KB".
|
|
size: String,
|
|
}
|
|
|
|
/// Read file metadata (mtime and size) for a given path.
|
|
///
|
|
/// Returns `None` if the file cannot be stat'd.
|
|
fn read_page_meta(full_path: &Path) -> Option<PageMeta> {
|
|
let meta = std::fs::metadata(full_path).ok()?;
|
|
let secs = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_secs();
|
|
let (y, m, d) = unix_secs_to_ymd(secs);
|
|
let months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
|
Some(PageMeta {
|
|
modified: format!("{} {}, {}", months[(m - 1) as usize], d, y),
|
|
size: format_file_size(meta.len()),
|
|
})
|
|
}
|
|
|
|
/// Convert unix seconds to (year, month, day) using pure Gregorian arithmetic.
|
|
fn unix_secs_to_ymd(secs: u64) -> (u32, u32, u32) {
|
|
let mut days = (secs / 86400) as u32;
|
|
let mut year = 1970u32;
|
|
loop {
|
|
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
|
if days < days_in_year { break; }
|
|
days -= days_in_year;
|
|
year += 1;
|
|
}
|
|
let leap = is_leap(year);
|
|
let month_days: [u32; 12] = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
let mut month = 0u32;
|
|
for (i, &d) in month_days.iter().enumerate() {
|
|
if days < d { month = i as u32 + 1; break; }
|
|
days -= d;
|
|
}
|
|
(year, month, days + 1)
|
|
}
|
|
|
|
fn is_leap(year: u32) -> bool {
|
|
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
|
}
|
|
|
|
fn format_file_size(bytes: u64) -> String {
|
|
match bytes {
|
|
0..=1023 => format!("{} B", bytes),
|
|
1024..=1_048_575 => format!("{:.1} KB", bytes as f64 / 1024.0),
|
|
_ => format!("{:.1} MB", bytes as f64 / 1_048_576.0),
|
|
}
|
|
}
|
|
|
|
/// How long the double-Ctrl+C window stays open before resetting.
|
|
const DOUBLE_PRESS_WINDOW: Duration = Duration::from_secs(2);
|
|
|
|
// ── DocumentState ─────────────────────────────────────────────────────────────
|
|
|
|
/// The display state of the document loaded from the vault.
|
|
pub enum DocumentState {
|
|
/// File was read and rendered successfully.
|
|
Loaded {
|
|
filename: String,
|
|
lines: Vec<Line<'static>>,
|
|
},
|
|
/// File does not exist in the vault.
|
|
Missing {
|
|
path: PathBuf,
|
|
},
|
|
/// File exists but could not be read.
|
|
Error {
|
|
path: PathBuf,
|
|
reason: String,
|
|
},
|
|
}
|
|
|
|
// ── ShutdownReason ────────────────────────────────────────────────────────────
|
|
|
|
/// The reason the application is shutting down.
|
|
///
|
|
/// This determines what happens after `restore_terminal()` is called:
|
|
/// - `UserQuit` → display the BBS goodbye message and sleep 500ms
|
|
/// - `Signal` → exit silently (SSH disconnect, nobody to show the message to)
|
|
pub enum ShutdownReason {
|
|
/// The user pressed 'q' (in non-login-shell mode) or completed double Ctrl+C.
|
|
UserQuit,
|
|
/// A SIGHUP or SIGTERM was received.
|
|
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 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 '-').
|
|
///
|
|
/// When true, the 'q' key shortcut is suppressed — the only exit is double Ctrl+C.
|
|
is_login_shell: bool,
|
|
|
|
/// Timestamp of the first Ctrl+C press, if the double-press window is open.
|
|
ctrl_c_pressed_at: Option<Instant>,
|
|
|
|
/// Whether to show the "Press Ctrl+C again to disconnect" prompt in the TUI.
|
|
show_quit_prompt: bool,
|
|
|
|
/// Set to true when the user has confirmed they want to quit.
|
|
should_quit: bool,
|
|
|
|
/// Loaded application configuration (vault_path, theme).
|
|
config: Config,
|
|
|
|
// ── Phase 2 additions ─────────────────────────────────────────────────────
|
|
/// The current document state: loaded content, missing file, or I/O error.
|
|
document: DocumentState,
|
|
|
|
/// Vertical scroll offset in lines (0 = top).
|
|
scroll_offset: u16,
|
|
|
|
/// Raw markdown content, kept for re-rendering on terminal resize.
|
|
raw_content: Option<String>,
|
|
|
|
/// Filename to display in the status bar (cached from DocumentState).
|
|
filename: String,
|
|
|
|
/// 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,
|
|
|
|
// ── Phase 4 additions ─────────────────────────────────────────────────────
|
|
/// File metadata (mtime, size) for the currently displayed document.
|
|
/// None for error screens or when metadata cannot be read.
|
|
page_meta: Option<PageMeta>,
|
|
}
|
|
|
|
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 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.
|
|
///
|
|
/// `page_meta` is computed automatically from `current_path` and `config.vault_path`.
|
|
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(),
|
|
};
|
|
// Compute initial page metadata from the loaded document's path
|
|
let page_meta = match &document {
|
|
DocumentState::Loaded { .. } => {
|
|
let full_path = config.vault_path.join(¤t_path);
|
|
read_page_meta(&full_path)
|
|
}
|
|
_ => None,
|
|
};
|
|
// 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,
|
|
show_quit_prompt: false,
|
|
should_quit: false,
|
|
config,
|
|
document,
|
|
scroll_offset: 0,
|
|
raw_content,
|
|
filename,
|
|
last_content_height: 24,
|
|
history: initial_history,
|
|
history_index: 0,
|
|
link_records,
|
|
selected_link: None,
|
|
current_path,
|
|
page_meta,
|
|
}
|
|
}
|
|
|
|
/// Run the main event loop until the application should shut down.
|
|
///
|
|
/// # Loop structure
|
|
///
|
|
/// Each iteration:
|
|
/// 1. Poll signal flags — fast path for SSH disconnect
|
|
/// 2. Draw the UI via ratatui
|
|
/// 3. Poll for crossterm events with a 250ms timeout
|
|
/// 4. Check if should_quit was set by key handling
|
|
/// 5. Expire the double-press window if it has elapsed
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Propagates `std::io::Error` from crossterm. `main.rs` catches
|
|
/// `ErrorKind::BrokenPipe` for silent exit (LIFE-04).
|
|
pub fn run_event_loop(
|
|
&mut self,
|
|
terminal: &mut Term,
|
|
signals: &SignalFlags,
|
|
) -> io::Result<ShutdownReason> {
|
|
loop {
|
|
// 1. Check signal flags FIRST — fast path for SSH disconnect
|
|
if signals.should_terminate() {
|
|
return Ok(ShutdownReason::Signal);
|
|
}
|
|
|
|
// 2. Draw the UI
|
|
// If draw() returns BrokenPipe, the ? propagates it up.
|
|
// main.rs catches BrokenPipe and exits cleanly (LIFE-04).
|
|
terminal.draw(|frame| self.draw(frame))?;
|
|
|
|
// 3. Poll for events with a 250ms timeout
|
|
if event::poll(Duration::from_millis(250))? {
|
|
match event::read()? {
|
|
Event::Key(key) => {
|
|
self.handle_key(key);
|
|
}
|
|
Event::Resize(w, _h) => {
|
|
self.handle_resize(w);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// 4. Check if we should quit
|
|
if self.should_quit {
|
|
return Ok(ShutdownReason::UserQuit);
|
|
}
|
|
|
|
// 5. Clear quit prompt if double-press window has expired
|
|
if let Some(pressed_at) = self.ctrl_c_pressed_at {
|
|
if pressed_at.elapsed() >= DOUBLE_PRESS_WINDOW {
|
|
self.ctrl_c_pressed_at = None;
|
|
self.show_quit_prompt = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle a terminal resize event by re-rendering the markdown at the new width.
|
|
///
|
|
/// ratatui handles the buffer resize automatically for `Viewport::Fullscreen`.
|
|
/// We re-render so horizontal rules, code block borders, and table widths adapt.
|
|
/// On index.md, splash lines are re-prepended after the re-render.
|
|
fn handle_resize(&mut self, new_width: u16) {
|
|
if let Some(ref content) = self.raw_content.clone() {
|
|
let vault_path = self.config.vault_path.clone();
|
|
let (mut lines, mut link_records) = crate::renderer::render_markdown(
|
|
content,
|
|
new_width,
|
|
Some(&vault_path),
|
|
);
|
|
|
|
// Prepend splash lines for index.md (same logic as navigate_to)
|
|
if self.current_path == "index.md" {
|
|
if let Some(mut splash_lines) = crate::splash::load_splash(&vault_path) {
|
|
let splash_count = splash_lines.len() + 1; // +1 for blank separator
|
|
splash_lines.push(Line::default());
|
|
splash_lines.extend(lines);
|
|
lines = splash_lines;
|
|
// Offset link_records by the number of splash lines added
|
|
for record in &mut link_records {
|
|
record.line_index += splash_count;
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
let max = self.max_scroll();
|
|
if self.scroll_offset > max {
|
|
self.scroll_offset = max;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle a single key event and update app state accordingly.
|
|
///
|
|
/// # 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
|
|
/// - `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 {
|
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
// Double Ctrl+C state machine
|
|
if let Some(pressed_at) = self.ctrl_c_pressed_at {
|
|
if pressed_at.elapsed() < DOUBLE_PRESS_WINDOW {
|
|
// Second press within the window — quit
|
|
self.should_quit = true;
|
|
} else {
|
|
// Window expired; treat as a first press
|
|
self.ctrl_c_pressed_at = Some(Instant::now());
|
|
self.show_quit_prompt = true;
|
|
}
|
|
} else {
|
|
// First press — open the confirmation window
|
|
self.ctrl_c_pressed_at = Some(Instant::now());
|
|
self.show_quit_prompt = true;
|
|
}
|
|
}
|
|
KeyCode::Char('q') if !self.is_login_shell => {
|
|
// '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);
|
|
}
|
|
KeyCode::Char('k') | KeyCode::Up => {
|
|
self.scroll_up(1);
|
|
}
|
|
KeyCode::PageDown => {
|
|
let h = self.page_height();
|
|
self.scroll_down(h);
|
|
}
|
|
KeyCode::PageUp => {
|
|
let h = self.page_height();
|
|
self.scroll_up(h);
|
|
}
|
|
_ => {
|
|
// Any other key dismisses the quit prompt
|
|
if self.show_quit_prompt {
|
|
self.ctrl_c_pressed_at = None;
|
|
self.show_quit_prompt = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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 (mut lines, mut link_records) =
|
|
crate::renderer::render_markdown(&content, width, Some(&vault_path));
|
|
|
|
// Read file metadata for status bar
|
|
let full_path = vault_path.join(vault_relative);
|
|
self.page_meta = read_page_meta(&full_path);
|
|
|
|
// Prepend splash art for index.md
|
|
if vault_relative == "index.md" {
|
|
if let Some(mut splash_lines) = crate::splash::load_splash(&vault_path) {
|
|
let splash_count = splash_lines.len() + 1; // +1 for blank separator
|
|
splash_lines.push(Line::default());
|
|
splash_lines.extend(lines);
|
|
lines = splash_lines;
|
|
// Offset link_records so Tab-cycling still targets correct lines
|
|
for record in &mut link_records {
|
|
record.line_index += splash_count;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
self.page_meta = 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;
|
|
self.page_meta = 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 (mut lines, mut link_records) =
|
|
crate::renderer::render_markdown(&content, width, Some(&vault_path));
|
|
|
|
// Read file metadata for status bar
|
|
let full_path = vault_path.join(&target_path);
|
|
self.page_meta = read_page_meta(&full_path);
|
|
|
|
// Prepend splash art for index.md
|
|
if target_path == "index.md" {
|
|
if let Some(mut splash_lines) = crate::splash::load_splash(&vault_path) {
|
|
let splash_count = splash_lines.len() + 1;
|
|
splash_lines.push(Line::default());
|
|
splash_lines.extend(lines);
|
|
lines = splash_lines;
|
|
for record in &mut link_records {
|
|
record.line_index += splash_count;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (mut lines, mut link_records) =
|
|
crate::renderer::render_markdown(&content, width, Some(&vault_path));
|
|
|
|
// Read file metadata for status bar
|
|
let full_path = vault_path.join(&target_path);
|
|
self.page_meta = read_page_meta(&full_path);
|
|
|
|
// Prepend splash art for index.md
|
|
if target_path == "index.md" {
|
|
if let Some(mut splash_lines) = crate::splash::load_splash(&vault_path) {
|
|
let splash_count = splash_lines.len() + 1;
|
|
splash_lines.push(Line::default());
|
|
splash_lines.extend(lines);
|
|
lines = splash_lines;
|
|
for record in &mut link_records {
|
|
record.line_index += splash_count;
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
let max = self.max_scroll();
|
|
self.scroll_offset = self.scroll_offset.saturating_add(n).min(max);
|
|
}
|
|
|
|
fn scroll_up(&mut self, n: u16) {
|
|
self.scroll_offset = self.scroll_offset.saturating_sub(n);
|
|
}
|
|
|
|
/// Maximum valid scroll offset: number of lines beyond what fits on screen.
|
|
fn max_scroll(&self) -> u16 {
|
|
match &self.document {
|
|
DocumentState::Loaded { lines, .. } => {
|
|
(lines.len() as u16).saturating_sub(self.last_content_height)
|
|
}
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
/// Height of the content area (one page for PgUp/PgDn).
|
|
fn page_height(&self) -> u16 {
|
|
self.last_content_height.max(1)
|
|
}
|
|
|
|
// ── Draw ──────────────────────────────────────────────────────────────────
|
|
|
|
/// Draw the TUI: content area + status bar.
|
|
///
|
|
/// Layout:
|
|
/// ```text
|
|
/// ┌─────────────────────────────────────────┐
|
|
/// │ <markdown content, scrolled> │ ← Min(0) content area
|
|
/// │ │
|
|
/// │ ... │
|
|
/// ├─────────────────────────────────────────┤
|
|
/// │ guides > page Tab:Links q:Quit │ ← Length(1) status bar
|
|
/// └─────────────────────────────────────────┘
|
|
/// ```
|
|
fn draw(&mut self, frame: &mut Frame) {
|
|
let area = frame.area();
|
|
|
|
// Split: content area (fills) + status bar (1 line)
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Min(0), Constraint::Length(1)])
|
|
.split(area);
|
|
|
|
let content_area = chunks[0];
|
|
let status_area = chunks[1];
|
|
|
|
// Update content height for page scrolling
|
|
self.last_content_height = content_area.height;
|
|
|
|
// ── Content area ─────────────────────────────────────────────────────
|
|
match &self.document {
|
|
DocumentState::Loaded { lines, .. } => {
|
|
// 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 } => {
|
|
let path = path.clone();
|
|
self.draw_error_screen(frame, content_area, &path, None);
|
|
}
|
|
DocumentState::Error { path, reason } => {
|
|
let path = path.clone();
|
|
let reason = reason.clone();
|
|
self.draw_error_screen(frame, content_area, &path, Some(&reason));
|
|
}
|
|
}
|
|
|
|
// ── Status bar ───────────────────────────────────────────────────────
|
|
self.draw_status_bar(frame, status_area);
|
|
}
|
|
|
|
/// 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 breadcrumb = build_breadcrumb(&self.current_path);
|
|
let left = format!(" {} ", breadcrumb);
|
|
|
|
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<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());
|
|
}
|
|
|
|
// File metadata: "Last modified: Feb 25, 2026 | 2.4 KB"
|
|
if let Some(ref meta) = self.page_meta {
|
|
right_parts.push(format!("Last modified: {} | {}", meta.modified, meta.size));
|
|
}
|
|
|
|
// 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());
|
|
|
|
// Gracefully truncate right side if it overflows the status bar width.
|
|
// Strategy: progressively drop rightmost metadata items (size, then full meta, then hints)
|
|
// until the bar fits within the terminal width.
|
|
let right = loop {
|
|
let candidate = format!(" {} ", right_parts.join(" "));
|
|
if left.len() + candidate.len() <= width || right_parts.len() <= 1 {
|
|
break candidate;
|
|
}
|
|
// Drop the hints-adjacent item (metadata is between nav parts and hints)
|
|
// Hints is always last, metadata is second-to-last when present.
|
|
// If we can't fit, remove the item before hints.
|
|
let hints_idx = right_parts.len() - 1;
|
|
if hints_idx > 0 {
|
|
right_parts.remove(hints_idx - 1);
|
|
} else {
|
|
break 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 a document is missing or unreadable.
|
|
///
|
|
/// Layout (centered in `area`):
|
|
/// ```text
|
|
/// ┌─────────────────────────────────────────┐
|
|
/// │ *** SYSTEM ERROR *** │
|
|
/// │ │
|
|
/// │ No index.md found in vault: │ (when reason is None)
|
|
/// │ /path/to/vault │
|
|
/// │ │
|
|
/// │ Create index.md to begin. │
|
|
/// └─────────────────────────────────────────┘
|
|
/// ```
|
|
///
|
|
/// When `reason` is `Some(msg)`, shows the I/O error message instead.
|
|
fn draw_error_screen(
|
|
&self,
|
|
frame: &mut Frame,
|
|
area: Rect,
|
|
path: &Path,
|
|
reason: Option<&str>,
|
|
) {
|
|
// Build the content lines
|
|
let path_str = path.display().to_string();
|
|
|
|
let body: Vec<Line<'static>> = if let Some(err) = reason {
|
|
// ReadError: show the I/O error message
|
|
vec![
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" *** SYSTEM ERROR ***".to_string(),
|
|
Style::default()
|
|
.fg(Color::LightRed)
|
|
.add_modifier(Modifier::BOLD),
|
|
)),
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" Could not read file:".to_string(),
|
|
Style::default().fg(Color::White),
|
|
)),
|
|
Line::from(Span::styled(
|
|
format!(" {}", path_str),
|
|
Style::default().fg(Color::Yellow),
|
|
)),
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
format!(" Error: {}", err),
|
|
Style::default().fg(Color::LightRed),
|
|
)),
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" Contact the SysOp if this persists.".to_string(),
|
|
Style::default().fg(Color::DarkGray),
|
|
)),
|
|
]
|
|
} else {
|
|
// Missing: show the "create index.md" hint
|
|
let vault_dir = path
|
|
.parent()
|
|
.map(|p| p.display().to_string())
|
|
.unwrap_or_else(|| path_str.clone());
|
|
vec![
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" *** SYSTEM ERROR ***".to_string(),
|
|
Style::default()
|
|
.fg(Color::LightRed)
|
|
.add_modifier(Modifier::BOLD),
|
|
)),
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" No index.md found in vault:".to_string(),
|
|
Style::default().fg(Color::White),
|
|
)),
|
|
Line::from(Span::styled(
|
|
format!(" {}", vault_dir),
|
|
Style::default().fg(Color::Yellow),
|
|
)),
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" Create index.md to begin.".to_string(),
|
|
Style::default().fg(Color::DarkGray),
|
|
)),
|
|
]
|
|
};
|
|
|
|
// Calculate a centered Rect for the error block
|
|
let box_height = (body.len() as u16 + 2).min(area.height); // +2 for borders
|
|
let box_width = body
|
|
.iter()
|
|
.map(|l| l.spans.iter().map(|s| s.content.len()).sum::<usize>())
|
|
.max()
|
|
.unwrap_or(40) as u16
|
|
+ 4; // +4 for border padding
|
|
let box_width = box_width.min(area.width);
|
|
|
|
let v_offset = area.y + area.height.saturating_sub(box_height) / 2;
|
|
let h_offset = area.x + area.width.saturating_sub(box_width) / 2;
|
|
|
|
let centered = Rect {
|
|
x: h_offset,
|
|
y: v_offset,
|
|
width: box_width,
|
|
height: box_height,
|
|
};
|
|
|
|
// Draw the block border
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Red));
|
|
|
|
let inner = block.inner(centered);
|
|
frame.render_widget(block, centered);
|
|
|
|
// Draw the body text inside the block
|
|
let para = Paragraph::new(body);
|
|
frame.render_widget(para, inner);
|
|
}
|
|
}
|
|
|
|
// ── 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.
|
|
///
|
|
/// Called ONLY on user-initiated quit (`ShutdownReason::UserQuit`), NOT on
|
|
/// signal-driven shutdown (nobody is there to read it after an SSH disconnect).
|
|
///
|
|
/// # Precondition
|
|
///
|
|
/// `restore_terminal()` must have been called before this function. The terminal
|
|
/// is in normal (cooked) mode when this runs, so `println!()` is safe.
|
|
pub fn show_goodbye() {
|
|
println!("\r");
|
|
println!(" +------------------------------------------+");
|
|
println!(" | Thank you for calling the BBS! |");
|
|
println!(" | *** CARRIER LOST *** |");
|
|
println!(" +------------------------------------------+");
|
|
println!("\r");
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
}
|