//! 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}; 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; /// 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>, }, /// 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, } // ── 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, /// 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, /// 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, /// Current position in history (index into history Vec). history_index: usize, /// Link records from the current rendered document. link_records: Vec, /// Index of the currently selected link (None = no link selected). selected_link: Option, /// Current document's vault-relative path (e.g. "index.md", "guides/page.md"). current_path: String, } impl App { /// Create a new `App` with the given document state. /// /// `is_login_shell` controls whether the 'q' key is active. /// `config` is stored for vault_path access during navigation. /// `document` is the initial document to display. /// `raw_content` is the raw markdown string (Some if Loaded) for resize re-render. /// `link_records` are the link metadata records from the initial render. /// `current_path` is the vault-relative path of the initial document. pub fn new( is_login_shell: bool, config: Config, document: DocumentState, raw_content: Option, link_records: Vec, current_path: String, ) -> Self { let filename = match &document { DocumentState::Loaded { filename, .. } => filename.clone(), DocumentState::Missing { .. } | DocumentState::Error { .. } => "ERROR".to_string(), }; // Initialize history with one entry for the initial page let initial_history = vec![HistoryEntry { path: current_path.clone(), scroll_offset: 0, selected_link: None, }]; App { is_login_shell, ctrl_c_pressed_at: None, 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, } } /// 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 { 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. fn handle_resize(&mut self, new_width: u16) { if let Some(ref content) = self.raw_content.clone() { 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 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 (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) { 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 /// ┌─────────────────────────────────────────┐ /// │ │ ← 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 = 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 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> = 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::()) .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::>() .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)); }