Skip to main content

opencode_voice/input/
keyboard.rs

1//! Terminal keyboard input handler using crossterm raw mode.
2
3use anyhow::Result;
4use crossterm::{
5    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
6    terminal,
7};
8use std::io;
9use std::time::{Duration, Instant};
10use tokio_util::sync::CancellationToken;
11
12use crate::state::InputEvent;
13
14/// Returns true if stdin is a TTY (interactive terminal).
15pub fn is_tty() -> bool {
16    use std::io::IsTerminal;
17    io::stdin().is_terminal()
18}
19
20/// Terminal keyboard input reader using crossterm raw mode.
21pub struct KeyboardInput {
22    toggle_key: char,
23    sender: tokio::sync::mpsc::UnboundedSender<InputEvent>,
24    cancel: CancellationToken,
25}
26
27impl KeyboardInput {
28    pub fn new(
29        toggle_key: char,
30        sender: tokio::sync::mpsc::UnboundedSender<InputEvent>,
31        cancel: CancellationToken,
32    ) -> Self {
33        KeyboardInput {
34            toggle_key,
35            sender,
36            cancel,
37        }
38    }
39
40    /// Runs the keyboard input loop. Blocks until cancel token is fired or Quit received.
41    ///
42    /// Enables raw mode on entry, ALWAYS disables on exit (including errors/panics via Drop).
43    pub fn run(&self) -> Result<()> {
44        // Enable raw mode
45        terminal::enable_raw_mode()?;
46
47        // Ensure raw mode is disabled when this function exits (via Drop guard)
48        let _guard = RawModeGuard;
49
50        let mut last_toggle = Instant::now()
51            .checked_sub(Duration::from_millis(500))
52            .unwrap_or(Instant::now());
53
54        loop {
55            // Check cancellation
56            if self.cancel.is_cancelled() {
57                break;
58            }
59
60            // Poll for events with 100ms timeout
61            if !event::poll(Duration::from_millis(100))? {
62                continue;
63            }
64
65            let ev = event::read()?;
66
67            match ev {
68                Event::Key(KeyEvent {
69                    code, modifiers, ..
70                }) => match code {
71                    // Ctrl+C → Quit
72                    KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
73                        let _ = self.sender.send(InputEvent::Quit);
74                        break;
75                    }
76                    // 'q' → Quit
77                    KeyCode::Char('q') => {
78                        let _ = self.sender.send(InputEvent::Quit);
79                        break;
80                    }
81                    // Toggle key (e.g., space)
82                    KeyCode::Char(c) if c == self.toggle_key => {
83                        let now = Instant::now();
84                        if now.duration_since(last_toggle) >= Duration::from_millis(200) {
85                            last_toggle = now;
86                            let _ = self.sender.send(InputEvent::Toggle);
87                        }
88                    }
89                    _ => {}
90                },
91                _ => {}
92            }
93        }
94
95        Ok(())
96    }
97}
98
99/// RAII guard that disables raw mode on drop.
100struct RawModeGuard;
101
102impl Drop for RawModeGuard {
103    fn drop(&mut self) {
104        let _ = terminal::disable_raw_mode();
105    }
106}