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