//! 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)); }