From bad8fba5aacdbac464b4465c18063b3bef44d440 Mon Sep 17 00:00:00 2001 From: ruohki Date: Sat, 28 Feb 2026 21:17:10 +0100 Subject: [PATCH] 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 --- src/app.rs | 281 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 2 files changed, 282 insertions(+) create mode 100644 src/app.rs diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..244d19c --- /dev/null +++ b/src/app.rs @@ -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, + + /// 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 { + 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)); +} diff --git a/src/main.rs b/src/main.rs index a786d70..acd1c8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod app; mod config; mod signals; mod terminal;