diff --git a/src/app.rs b/src/app.rs index 244d19c..1e6911c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,14 @@ -//! Application event loop, key handling, and shutdown logic for bbs-md. +//! 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 safety mechanisms: +//! The event loop is the convergence point for all Phase 1 and Phase 2 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 +//! - 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 @@ -16,6 +19,7 @@ //! - `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::*; @@ -27,6 +31,26 @@ 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. @@ -43,8 +67,9 @@ pub enum ShutdownReason { // ── App ─────────────────────────────────────────────────────────────────────── -/// Application state for the Phase 1 event loop. +/// Application state for the Phase 2 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. @@ -60,25 +85,54 @@ pub struct App { should_quit: bool, /// Loaded application configuration (vault_path, theme). - /// - /// Stored here so Phase 2 can access it from the event loop without threading - /// it through every function. Phase 1 only validates it was loaded successfully. #[allow(dead_code)] 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, } impl App { - /// Create a new `App` with default state. + /// Create a new `App` with the given document state. /// /// `is_login_shell` controls whether the 'q' key is active. - /// `config` is stored for Phase 2+ use; Phase 1 only confirms it loaded. - pub fn new(is_login_shell: bool, config: Config) -> Self { + /// `config` is stored for future use (vault_path for navigation in Phase 3+). + /// `document` is the initial document to display. + /// `raw_content` is the raw markdown string (Some if Loaded) for resize re-render. + pub fn new( + is_login_shell: bool, + config: Config, + document: DocumentState, + raw_content: Option, + ) -> Self { + let filename = match &document { + DocumentState::Loaded { filename, .. } => filename.clone(), + DocumentState::Missing { .. } | DocumentState::Error { .. } => "ERROR".to_string(), + }; 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, } } @@ -115,11 +169,15 @@ impl App { // 3. Poll for events with a 250ms timeout if event::poll(Duration::from_millis(250))? { - if let Event::Key(key) = event::read()? { - self.handle_key(key); + match event::read()? { + Event::Key(key) => { + self.handle_key(key); + } + Event::Resize(w, _h) => { + self.handle_resize(w); + } + _ => {} } - // Event::Resize is handled implicitly: ratatui redraws on the next - // loop iteration and fills the new terminal size automatically. } // 4. Check if we should quit @@ -137,13 +195,34 @@ impl App { } } + /// 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 = crate::renderer::render_markdown(content, new_width); + 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 /// - /// - `Ctrl+C` — first press opens the double-press window; second press (within 2s) quits - /// - `q` — quits immediately (suppressed in login shell mode) - /// - Any other key — if the quit prompt is showing, dismisses it + /// - `Ctrl+C` — first press opens double-press window; second press (within 2s) quits + /// - `q` — quits immediately (suppressed in login shell mode) + /// - `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) => { @@ -167,6 +246,21 @@ impl App { // 'q' is suppressed in login shell mode — only double Ctrl+C exits self.should_quit = true; } + // ── 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 { @@ -177,85 +271,243 @@ impl App { } } - /// Draw the Phase 1 placeholder TUI. + // ── 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 Phase 2 TUI: content area + status bar. /// - /// Renders a centered block with the BBS-MD title and a welcome message. - /// If the quit prompt is active, shows a warning line at the bottom. - /// If running in login shell mode, shows a mode indicator in the block. - fn draw(&self, frame: &mut Frame) { + /// Layout: + /// ```text + /// ┌─────────────────────────────────────────┐ + /// │ │ ← Min(0) content area + /// │ │ + /// │ ... │ + /// ├─────────────────────────────────────────┤ + /// │ index.md q:Quit j/k:Scroll ... │ ← Length(1) status bar + /// └─────────────────────────────────────────┘ + /// ``` + fn draw(&mut self, frame: &mut Frame) { let area = frame.area(); - // Outer block with BBS-MD title - let mut title_spans = vec![Span::styled( - " BBS-MD ", - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), - )]; - - if self.is_login_shell { - title_spans.push(Span::styled( - " [Login Shell Mode] ", - Style::default().fg(Color::Yellow), - )); - } - - let block = Block::default() - .title(Line::from(title_spans)) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); - - // Body text inside the block - let inner = block.inner(area); - frame.render_widget(block, area); - - // Split inner area: content area + optional quit prompt line + // Split: content area (fills) + status bar (1 line) let chunks = Layout::default() .direction(Direction::Vertical) - .constraints(if self.show_quit_prompt { - vec![Constraint::Min(1), Constraint::Length(1)] - } else { - vec![Constraint::Min(1), Constraint::Length(0)] - }) - .split(inner); + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(area); - // Welcome message - let body_text = vec![ - Line::from(""), - Line::from(Span::styled( - " Welcome to BBS-MD.", - Style::default().fg(Color::White), - )), - Line::from(""), - Line::from(Span::styled( - " Content loading will be available in Phase 2.", - Style::default().fg(Color::DarkGray), - )), - Line::from(""), - if self.is_login_shell { - Line::from(Span::styled( - " Hint: Press Ctrl+C twice to disconnect.", - Style::default().fg(Color::DarkGray), - )) - } else { - Line::from(Span::styled( - " Press 'q' or Ctrl+C twice to exit.", - Style::default().fg(Color::DarkGray), - )) - }, - ]; + let content_area = chunks[0]; + let status_area = chunks[1]; - let content = Paragraph::new(body_text); - frame.render_widget(content, chunks[0]); + // Update content height for page scrolling + self.last_content_height = content_area.height; - // Quit prompt (shown only when double-press window is open) - if self.show_quit_prompt && chunks.len() > 1 && chunks[1].height > 0 { - let prompt = Paragraph::new(Line::from(Span::styled( - " Press Ctrl+C again to disconnect...", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ))); - frame.render_widget(prompt, chunks[1]); + // ── Content area ───────────────────────────────────────────────────── + match &self.document { + DocumentState::Loaded { lines, .. } => { + let para = Paragraph::new(lines.clone()) + .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 filename on the left and hints on the right. + /// + /// Uses `Modifier::REVERSED` for retro reverse-video BBS styling. + /// When the quit prompt is active, replaces the hints with the warning text. + fn draw_status_bar(&self, frame: &mut Frame, area: Rect) { + let width = area.width as usize; + + let left = format!(" {} ", self.filename); + + let right = if self.show_quit_prompt { + " Press Ctrl+C again to disconnect... ".to_string() + } else if self.is_login_shell { + " Ctrl+C\u{00D7}2:Quit j/k:Scroll PgUp/PgDn:Page ".to_string() + } else { + " q:Quit j/k:Scroll PgUp/PgDn:Page ".to_string() + }; + + // Calculate padding between left and right so the bar fills the full width + let pad_len = width + .saturating_sub(left.len()) + .saturating_sub(right.len()); + let padding = " ".repeat(pad_len); + + let status_text = if self.show_quit_prompt { + // Quit prompt: yellow bold reverse video + Line::from(vec![ + Span::styled( + format!("{}{}{}", left, padding, right), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD | Modifier::REVERSED), + ), + ]) + } else { + // Normal status bar: reverse video + Line::from(vec![Span::raw(format!("{}{}{}", left, padding, right))]) + }; + + let bar = Paragraph::new(status_text) + .style(Style::default().add_modifier(Modifier::REVERSED)); + frame.render_widget(bar, area); + } + + /// Render the BBS-style error screen when index.md 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); } }