feat(02-03): rework app.rs with document display, scrolling, status bar, error screen

- Add DocumentState enum (Loaded/Missing/Error) for vault document state
- Extend App struct with document, scroll_offset, raw_content, filename, last_content_height
- Update App::new() signature to accept DocumentState and raw_content
- Replace Phase 1 placeholder draw() with content area + status bar layout
- Implement draw_status_bar() with reverse-video, filename left, hints right
- Implement draw_error_screen() BBS box-drawing error widget (red borders)
- Add j/k/Down/Up (1-line) and PgDn/PgUp (page) scroll key bindings
- Add handle_resize() for re-rendering markdown at new terminal width
- Preserve all Phase 1 quit behavior (double Ctrl+C, q key, login shell mode)
This commit is contained in:
2026-02-28 22:23:52 +01:00
parent b6069d90e5
commit 9cdfc6b2c4
+333 -81
View File
@@ -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<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.
@@ -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<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,
}
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<String>,
) -> 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()? {
match event::read()? {
Event::Key(key) => {
self.handle_key(key);
}
// Event::Resize is handled implicitly: ratatui redraws on the next
// loop iteration and fills the new terminal size automatically.
Event::Resize(w, _h) => {
self.handle_resize(w);
}
_ => {}
}
}
// 4. Check if we should quit
@@ -137,12 +195,33 @@ 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
/// - `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 {
@@ -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
/// ┌─────────────────────────────────────────┐
/// │ <markdown content, scrolled> │ ← 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)]
.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, .. } => {
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 {
vec![Constraint::Min(1), Constraint::Length(0)]
})
.split(inner);
" q:Quit j/k:Scroll PgUp/PgDn:Page ".to_string()
};
// 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),
))
},
];
// 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 content = Paragraph::new(body_text);
frame.render_widget(content, chunks[0]);
// 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...",
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),
)));
frame.render_widget(prompt, chunks[1]);
.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<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);
}
}