//! Terminal engine boundary (`SMH-SPEC-SPEC0001`, Observer mode). use crossterm::{ event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, execute, terminal, }; use ratatui::{ Terminal, backend::CrosstermBackend, layout::Rect, widgets::{Block, Borders, Paragraph}, }; use smith_ui::Observer; use std::io::{self, Stdout}; use std::time::Duration; /// Smith-owned terminal input, independent of the terminal engine. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Input { /// A keyboard action. Key(Key), /// Terminal dimensions changed. Resize, } /// Smith-owned keyboard action. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Key { /// A printable character. Char(char), /// Delete the preceding search character. Backspace, /// Submit search input. Enter, /// Cancel search or leave observer mode. Escape, /// Navigate to earlier rows. PageUp, /// Navigate to later rows. PageDown, /// Abort observer mode. Interrupt, } /// RAII terminal ownership, including error and normal-exit restoration. pub struct Screen { terminal: Terminal>, restored: bool, } impl Screen { /// Enter raw mode and alternate screen. /// /// # Errors /// Returns terminal I/O error, restoring modes on partial initialization. pub fn open() -> io::Result { terminal::enable_raw_mode()?; if let Err(error) = execute!(io::stdout(), terminal::EnterAlternateScreen) { // Preserve the setup error; raw-mode cleanup is best-effort on this path. let _restore_result: io::Result<()> = terminal::disable_raw_mode(); return Err(error); } match Terminal::new(CrosstermBackend::new(io::stdout())) { Ok(terminal) => Ok(Self { terminal, restored: false, }), Err(error) => { // Preserve the initialization error; terminal cleanup is best-effort. let _restore_result: io::Result<()> = restore_terminal(); Err(error) } } } /// Draw only already prepared, immutable snapshot data. /// /// # Errors /// Returns terminal I/O error. pub fn draw(&mut self, id: &str, ui: &Observer, input: bool) -> io::Result<()> { self.terminal.draw(|frame| render(frame, id, ui, input))?; Ok(()) } /// Restore raw-mode and alternate-screen state on explicit shutdown. /// /// # Errors /// Returns a terminal restoration error after attempting both operations. pub fn close(mut self) -> io::Result<()> { self.restore() } fn restore(&mut self) -> io::Result<()> { if self.restored { return Ok(()); } let result = restore_terminal(); self.restored = result.is_ok(); result } } impl Drop for Screen { fn drop(&mut self) { // Drop cannot return terminal errors; explicit close reports them. let _restore_result: io::Result<()> = self.restore(); } } fn restore_terminal() -> io::Result<()> { let raw = terminal::disable_raw_mode(); let alternate = execute!(io::stdout(), terminal::LeaveAlternateScreen); raw.and(alternate) } /// Wait for keyboard or resize; no session I/O. /// /// # Errors /// Returns terminal I/O error. pub fn next_input(deadline: Duration) -> io::Result> { if !event::poll(deadline)? { return Ok(None); } Ok(match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => key_input(key).map(Input::Key), Event::Resize(..) => Some(Input::Resize), _ => None, }) } const fn key_input(key: KeyEvent) -> Option { Some(match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Key::Interrupt, KeyCode::Char(character) => Key::Char(character), KeyCode::Backspace => Key::Backspace, KeyCode::Enter => Key::Enter, KeyCode::Esc => Key::Escape, KeyCode::PageUp => Key::PageUp, KeyCode::PageDown => Key::PageDown, _ => return None, }) } fn render(frame: &mut ratatui::Frame<'_>, id: &str, ui: &Observer, input: bool) { let area = frame.area(); let status = if !ui.status_known { "checking writer" } else if ui.writer_active { "writer active" } else { "writer absent" }; let follow = if ui.follow { "FOLLOW LIVE" } else { "paused" }; let mut lines = vec![ format!("READ ONLY | session {id} | {status} | {follow}"), format!( "rows {}..{} / {} | b/n browse | f follow | r refresh | / search | e export | k fork | q quit", ui.start.saturating_add(1).min(ui.total), (ui.start + ui.rows.len()).min(ui.total), ui.total ), "Submission disabled: observer cannot write; no provider or tools started.".to_owned(), ]; if input { lines.push(format!("Search: {}", ui.search)); } else if !ui.search.is_empty() { lines.push(format!( "Search: {} | match: {}", ui.search, ui.found.as_deref().unwrap_or("none") )); } if !ui.notice.is_empty() { lines.push(ui.notice.clone()); } let width = usize::from(area.width.saturating_sub(4)); lines.extend(ui.rows.iter().map(|row| { let id = row.id.get(..8).unwrap_or(&row.id); let text = format!("{id} {}", row.text); if text.len() <= width { text } else { let mut end = width.saturating_sub(3).min(text.len()); while !text.is_char_boundary(end) { end -= 1; } format!("{}…", &text[..end]) } })); frame.render_widget( Paragraph::new(lines.join("\n")).block( Block::default() .title("Smith observer") .borders(Borders::ALL), ), Rect::new(area.x, area.y, area.width, area.height), ); } #[cfg(test)] mod tests { use super::*; use ratatui::backend::TestBackend; #[test] fn ctrl_c_is_an_engine_independent_interrupt() { assert_eq!( key_input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), Some(Key::Interrupt) ); } #[test] fn narrow_terminal_marks_clipped_visible_row_without_wrapping_into_other_rows() { let mut ui = Observer::default(); ui.publish( 2, false, vec![ smith_ui::Row { id: "12345678-a".into(), text: "x".repeat(200), }, smith_ui::Row { id: "12345678-b".into(), text: "second row".into(), }, ], ); let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap(); terminal .draw(|frame| render(frame, "session", &ui, false)) .unwrap(); let first: String = (0..40) .map(|x| terminal.backend().buffer()[(x, 4)].symbol()) .collect(); let second: String = (0..40) .map(|x| terminal.backend().buffer()[(x, 5)].symbol()) .collect(); assert!(first.contains('…')); assert!(second.contains("second row")); } #[test] fn observer_status_is_visible_and_submission_disabled() { let mut ui = Observer::default(); ui.publish( 1, true, vec![smith_ui::Row { id: "entry".into(), text: "hello".into(), }], ); let mut terminal = Terminal::new(TestBackend::new(100, 9)).unwrap(); terminal .draw(|frame| render(frame, "session", &ui, false)) .unwrap(); let rows: Vec = (0..9) .map(|y| { (0..100) .map(|x| terminal.backend().buffer()[(x, y)].symbol()) .collect::() .trim_end() .to_owned() }) .collect(); insta::assert_snapshot!(rows.join("\n")); } }