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:
+333
-81
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user