magical markdown slides
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}