feat(01-03): implement App struct and event loop with exit behavior
- App struct with is_login_shell, double-press Ctrl+C state machine, show_quit_prompt - run_event_loop() polls SignalFlags first, then draws, then polls key events - handle_key() suppresses 'q' in login shell mode; double Ctrl+C within 2s quits - draw() renders Phase 1 placeholder TUI with title, welcome text, quit prompt - show_goodbye() prints BBS-style goodbye with 500ms delay after terminal restore - DOUBLE_PRESS_WINDOW const = 2 seconds - mod app added to main.rs
This commit is contained in:
+281
@@ -0,0 +1,281 @@
|
||||
//! Application event loop, key handling, and shutdown logic for bbs-md.
|
||||
//!
|
||||
//! # Design
|
||||
//!
|
||||
//! The event loop is the convergence point for all Phase 1 safety mechanisms:
|
||||
//! - 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
|
||||
//! - 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::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);
|
||||
|
||||
// ── 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,
|
||||
}
|
||||
|
||||
// ── App ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Application state for the Phase 1 event loop.
|
||||
pub struct App {
|
||||
/// 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).
|
||||
///
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Create a new `App` with default 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 {
|
||||
App {
|
||||
is_login_shell,
|
||||
ctrl_c_pressed_at: None,
|
||||
show_quit_prompt: false,
|
||||
should_quit: false,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
self.handle_key(key);
|
||||
}
|
||||
// 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
|
||||
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 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
|
||||
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;
|
||||
}
|
||||
_ => {
|
||||
// Any other key dismisses the quit prompt
|
||||
if self.show_quit_prompt {
|
||||
self.ctrl_c_pressed_at = None;
|
||||
self.show_quit_prompt = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the Phase 1 placeholder TUI.
|
||||
///
|
||||
/// 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) {
|
||||
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
|
||||
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);
|
||||
|
||||
// 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 = 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...",
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
frame.render_widget(prompt, chunks[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod signals;
|
||||
mod terminal;
|
||||
|
||||
Reference in New Issue
Block a user