Skip to main content

graphrag_cli/
tui.rs

1//! Terminal User Interface management
2//!
3//! Handles terminal initialization, cleanup, and event streaming.
4
5use color_eyre::eyre::Result;
6use crossterm::{
7    event::{DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream},
8    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
9    ExecutableCommand,
10};
11use futures::StreamExt;
12use ratatui::{backend::CrosstermBackend, Terminal};
13use std::io::{self, Stdout};
14use tokio::{
15    sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
16    task::JoinHandle,
17    time::{self, Duration},
18};
19use tokio_util::sync::CancellationToken;
20
21/// Event types from the terminal
22#[derive(Debug, Clone)]
23pub enum Event {
24    /// Keyboard or mouse event from crossterm
25    Crossterm(CrosstermEvent),
26    /// Periodic tick for animations/updates
27    Tick,
28    /// Render frame
29    Render,
30    /// Terminal was resized
31    Resize(u16, u16),
32}
33
34/// Terminal User Interface
35pub struct Tui {
36    /// The terminal instance
37    pub terminal: Terminal<CrosstermBackend<Stdout>>,
38    /// Background task handle
39    task: JoinHandle<()>,
40    /// Cancellation token for cleanup
41    cancellation_token: CancellationToken,
42    /// Event receiver
43    event_rx: UnboundedReceiver<Event>,
44    /// Event sender (for external use if needed)
45    _event_tx: UnboundedSender<Event>,
46    /// Frame rate (FPS)
47    #[allow(dead_code)]
48    frame_rate: f64,
49    /// Tick rate (events per second)
50    #[allow(dead_code)]
51    tick_rate: f64,
52}
53
54impl Tui {
55    /// Create a new TUI instance
56    pub fn new() -> Result<Self> {
57        let frame_rate = 60.0; // 60 FPS
58        let tick_rate = 4.0; // 4 ticks per second
59
60        let (event_tx, event_rx) = mpsc::unbounded_channel();
61        let cancellation_token = CancellationToken::new();
62
63        // Spawn event handler task
64        let task = {
65            let event_tx = event_tx.clone();
66            let cancellation_token = cancellation_token.clone();
67            let tick_duration = Duration::from_secs_f64(1.0 / tick_rate);
68            let render_duration = Duration::from_secs_f64(1.0 / frame_rate);
69
70            tokio::spawn(async move {
71                let mut reader = EventStream::new();
72                let mut tick_interval = time::interval(tick_duration);
73                let mut render_interval = time::interval(render_duration);
74
75                loop {
76                    tokio::select! {
77                        biased;
78
79                        _ = cancellation_token.cancelled() => {
80                            break;
81                        }
82                        maybe_event = reader.next() => {
83                            match maybe_event {
84                                Some(Ok(evt)) => {
85                                    // Handle resize events specially
86                                    if let CrosstermEvent::Resize(w, h) = evt {
87                                        let _ = event_tx.send(Event::Resize(w, h));
88                                    }
89                                    let _ = event_tx.send(Event::Crossterm(evt));
90                                }
91                                Some(Err(_)) => {}
92                                None => break,
93                            }
94                        }
95                        _ = tick_interval.tick() => {
96                            let _ = event_tx.send(Event::Tick);
97                        }
98                        _ = render_interval.tick() => {
99                            let _ = event_tx.send(Event::Render);
100                        }
101                    }
102                }
103            })
104        };
105
106        // Initialize terminal
107        let terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
108
109        Ok(Self {
110            terminal,
111            task,
112            cancellation_token,
113            event_rx,
114            _event_tx: event_tx,
115            frame_rate,
116            tick_rate,
117        })
118    }
119
120    /// Enter the alternate screen and enable raw mode
121    pub fn enter(&mut self) -> Result<()> {
122        enable_raw_mode()?;
123        io::stdout().execute(EnterAlternateScreen)?;
124        io::stdout().execute(EnableMouseCapture)?;
125        self.terminal.hide_cursor()?;
126        self.terminal.clear()?;
127        Ok(())
128    }
129
130    /// Leave the alternate screen and disable raw mode
131    pub fn exit(&mut self) -> Result<()> {
132        self.terminal.show_cursor()?;
133        io::stdout().execute(DisableMouseCapture)?;
134        io::stdout().execute(LeaveAlternateScreen)?;
135        disable_raw_mode()?;
136        Ok(())
137    }
138
139    /// Cancel the background task
140    pub fn cancel(&self) {
141        self.cancellation_token.cancel();
142    }
143
144    /// Get the next event
145    pub async fn next(&mut self) -> Option<Event> {
146        self.event_rx.recv().await
147    }
148
149    /// Get frame rate
150    #[allow(dead_code)]
151    pub fn frame_rate(&self) -> f64 {
152        self.frame_rate
153    }
154
155    /// Get tick rate
156    #[allow(dead_code)]
157    pub fn tick_rate(&self) -> f64 {
158        self.tick_rate
159    }
160}
161
162impl Drop for Tui {
163    fn drop(&mut self) {
164        self.cancel();
165        let _ = self.exit();
166        self.task.abort();
167    }
168}