magical markdown slides
at main 6.7 kB view raw
1use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; 2use std::{io, time::Duration}; 3 4#[cfg(not(test))] 5use crossterm::{ 6 execute, 7 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, 8}; 9 10/// Terminal manager that handles setup and cleanup 11/// 12/// Configures the terminal for TUI mode with alternate screen and raw mode. 13/// Automatically restores terminal state on drop to prevent terminal corruption. 14pub struct Terminal { 15 in_alternate_screen: bool, 16 in_raw_mode: bool, 17} 18 19impl Default for Terminal { 20 fn default() -> Self { 21 Self { in_alternate_screen: true, in_raw_mode: true } 22 } 23} 24 25impl Terminal { 26 /// Initialize terminal for TUI mode 27 /// 28 /// Enables alternate screen and raw mode for full terminal control. 29 pub fn setup() -> io::Result<Self> { 30 #[cfg(not(test))] 31 { 32 let mut stdout = io::stdout(); 33 execute!(stdout, EnterAlternateScreen)?; 34 enable_raw_mode()?; 35 } 36 37 Ok(Self::default()) 38 } 39 40 /// Restore terminal to normal mode by disabling raw mode and exits alternate screen. 41 /// 42 /// Called automatically on drop, but can be called manually for explicit cleanup. 43 pub fn restore(&mut self) -> io::Result<()> { 44 #[cfg(not(test))] 45 { 46 if self.in_raw_mode { 47 disable_raw_mode()?; 48 self.in_raw_mode = false; 49 } 50 51 if self.in_alternate_screen { 52 let mut stdout = io::stdout(); 53 execute!(stdout, LeaveAlternateScreen)?; 54 self.in_alternate_screen = false; 55 } 56 } 57 58 #[cfg(test)] 59 { 60 self.in_raw_mode = false; 61 self.in_alternate_screen = false; 62 } 63 64 Ok(()) 65 } 66} 67 68impl Drop for Terminal { 69 fn drop(&mut self) { 70 let _ = self.restore(); 71 } 72} 73 74/// Input event handler for slide navigation and control 75#[derive(Debug, Clone, PartialEq, Eq)] 76pub enum InputEvent { 77 /// Move to next slide 78 Next, 79 /// Move to previous slide 80 Previous, 81 /// Toggle speaker notes 82 ToggleNotes, 83 /// Toggle help display 84 ToggleHelp, 85 /// Search slides 86 /// TODO: Implement search functionality 87 Search, 88 /// Quit presentation 89 Quit, 90 /// Terminal was resized 91 /// NOTE: Terminal resize is handled automatically by ratatui 92 Resize { width: u16, height: u16 }, 93 /// Unknown/unhandled event 94 Other, 95} 96 97impl InputEvent { 98 /// Convert crossterm event to input event 99 /// 100 /// Maps keyboard and terminal events to presentation actions. 101 pub fn from_crossterm(event: Event) -> Self { 102 match event { 103 Event::Key(KeyEvent { code, modifiers, .. }) => Self::from_key(code, modifiers), 104 Event::Resize(width, height) => Self::Resize { width, height }, 105 _ => Self::Other, 106 } 107 } 108 109 /// Map key press to input event 110 fn from_key(code: KeyCode, modifiers: KeyModifiers) -> Self { 111 match (code, modifiers) { 112 (KeyCode::Right | KeyCode::Char('j') | KeyCode::Char(' '), _) => Self::Next, 113 (KeyCode::Char('n'), KeyModifiers::NONE) => Self::Next, 114 (KeyCode::Left | KeyCode::Char('k'), _) => Self::Previous, 115 (KeyCode::Char('p'), KeyModifiers::NONE) => Self::Previous, 116 (KeyCode::Char('q'), KeyModifiers::NONE) => Self::Quit, 117 (KeyCode::Char('c'), KeyModifiers::CONTROL) => Self::Quit, 118 (KeyCode::Esc, _) => Self::Quit, 119 (KeyCode::Char('n'), KeyModifiers::SHIFT) => Self::ToggleNotes, 120 (KeyCode::Char('?'), _) => Self::ToggleHelp, 121 (KeyCode::Char('f'), KeyModifiers::CONTROL) => Self::Search, 122 (KeyCode::Char('/'), KeyModifiers::NONE) => Self::Search, 123 _ => Self::Other, 124 } 125 } 126 127 /// Poll for next input event with timeout 128 pub fn poll(timeout: Duration) -> io::Result<Option<Self>> { 129 if event::poll(timeout)? { 130 let event = event::read()?; 131 Ok(Some(Self::from_crossterm(event))) 132 } else { 133 Ok(None) 134 } 135 } 136 137 /// Read next input event (blocking until an event is available) 138 pub fn read() -> io::Result<Self> { 139 let event = event::read()?; 140 Ok(Self::from_crossterm(event)) 141 } 142} 143 144#[cfg(test)] 145mod tests { 146 use super::*; 147 148 #[test] 149 fn input_event_navigation() { 150 let next = InputEvent::from_key(KeyCode::Right, KeyModifiers::NONE); 151 assert_eq!(next, InputEvent::Next); 152 153 let prev = InputEvent::from_key(KeyCode::Left, KeyModifiers::NONE); 154 assert_eq!(prev, InputEvent::Previous); 155 } 156 157 #[test] 158 fn input_event_quit() { 159 let quit_q = InputEvent::from_key(KeyCode::Char('q'), KeyModifiers::NONE); 160 assert_eq!(quit_q, InputEvent::Quit); 161 162 let quit_ctrl_c = InputEvent::from_key(KeyCode::Char('c'), KeyModifiers::CONTROL); 163 assert_eq!(quit_ctrl_c, InputEvent::Quit); 164 } 165 166 #[test] 167 fn input_event_search() { 168 let search_slash = InputEvent::from_key(KeyCode::Char('/'), KeyModifiers::NONE); 169 assert_eq!(search_slash, InputEvent::Search); 170 171 let search_ctrl_f = InputEvent::from_key(KeyCode::Char('f'), KeyModifiers::CONTROL); 172 assert_eq!(search_ctrl_f, InputEvent::Search); 173 } 174 175 #[test] 176 fn input_event_resize() { 177 let resize = InputEvent::from_crossterm(Event::Resize(80, 24)); 178 assert_eq!(resize, InputEvent::Resize { width: 80, height: 24 }); 179 } 180 181 #[test] 182 fn input_event_toggle_help() { 183 let help = InputEvent::from_key(KeyCode::Char('?'), KeyModifiers::NONE); 184 assert_eq!(help, InputEvent::ToggleHelp); 185 186 let help_shift = InputEvent::from_key(KeyCode::Char('?'), KeyModifiers::SHIFT); 187 assert_eq!(help_shift, InputEvent::ToggleHelp); 188 } 189 190 #[test] 191 fn terminal_default_state() { 192 let terminal = Terminal::default(); 193 assert!(terminal.in_alternate_screen); 194 assert!(terminal.in_raw_mode); 195 } 196 197 #[test] 198 fn terminal_restore_idempotent() { 199 let mut terminal = Terminal { in_alternate_screen: false, in_raw_mode: false }; 200 201 assert!(terminal.restore().is_ok()); 202 assert!(terminal.restore().is_ok()); 203 assert!(!terminal.in_alternate_screen); 204 assert!(!terminal.in_raw_mode); 205 } 206 207 #[test] 208 fn terminal_restore_clears_flags() { 209 let mut terminal = Terminal { in_alternate_screen: false, in_raw_mode: false }; 210 let _ = terminal.restore(); 211 assert!(!terminal.in_alternate_screen); 212 assert!(!terminal.in_raw_mode); 213 } 214}