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