Skip to main content

scope_cli/cli/monitor/
input.rs

1//! MonitorApp struct and event handling for the monitor TUI.
2
3use crossterm::{
4    event::{DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
5    execute,
6};
7use scope::chains::ChainClient;
8use scope::chains::dex::{DexClient, DexDataSource};
9use scope::error::{Result, ScopeError};
10use std::io;
11use std::time::{Duration, Instant};
12
13use super::config::{MonitorConfig, OhlcCandle, TimePeriod};
14use super::state::MonitorState;
15use super::widgets::ui;
16
17use scope::chains::dex::DexTokenData;
18
19/// Main monitor application, generic over terminal backend for testability.
20///
21/// The default type parameter (`CrosstermBackend<Stdout>`) is used in production.
22/// Tests use `TestBackend` to avoid real terminal I/O.
23pub struct MonitorApp<B: ratatui::backend::Backend = ratatui::backend::CrosstermBackend<io::Stdout>>
24{
25    /// Terminal backend.
26    pub(crate) terminal: ratatui::Terminal<B>,
27
28    /// Monitor state.
29    pub(crate) state: MonitorState,
30
31    /// DEX client for fetching data.
32    pub(crate) dex_client: Box<dyn DexDataSource>,
33
34    /// Optional chain client for on-chain data (holder count, etc.).
35    pub(crate) chain_client: Option<Box<dyn ChainClient>>,
36
37    /// Optional exchange client for real OHLC data.
38    pub(crate) exchange_client: Option<scope::market::ExchangeClient>,
39
40    /// Whether to exit the application.
41    pub(crate) should_exit: bool,
42
43    /// Whether this app owns the real terminal (and should restore it on drop).
44    /// False for test instances using `TestBackend`.
45    pub(crate) owns_terminal: bool,
46}
47
48/// Production constructor and terminal-specific methods.
49impl MonitorApp {
50    /// Creates a new monitor application with a real terminal and live DEX client.
51    pub fn new(
52        initial_data: DexTokenData,
53        chain: &str,
54        monitor_config: &MonitorConfig,
55        chain_client: Option<Box<dyn ChainClient>>,
56        exchange_client: Option<scope::market::ExchangeClient>,
57    ) -> Result<Self> {
58        // Setup terminal using ratatui's simplified init
59        let terminal = ratatui::init();
60        // Enable mouse capture (not handled by ratatui::init)
61        execute!(io::stdout(), EnableMouseCapture)
62            .map_err(|e| ScopeError::Chain(format!("Failed to enable mouse capture: {}", e)))?;
63
64        let mut state = MonitorState::new(&initial_data, chain);
65        state.apply_config(monitor_config);
66
67        // If we have an exchange client, set up the venue pair for OHLC queries
68        if let Some(ref ex) = exchange_client {
69            let pair = ex.format_pair(&initial_data.symbol);
70            state.venue_pair = Some(pair);
71        }
72
73        Ok(Self {
74            terminal,
75            state,
76            dex_client: Box::new(DexClient::new()),
77            chain_client,
78            exchange_client,
79            should_exit: false,
80            owns_terminal: true,
81        })
82    }
83
84    /// Runs the main event loop using async event stream.
85    pub async fn run(&mut self) -> Result<()> {
86        use futures::StreamExt;
87
88        let mut event_stream = crossterm::event::EventStream::new();
89
90        loop {
91            // Render UI
92            self.terminal.draw(|f| ui(f, &mut self.state))?;
93
94            // Calculate how long until next refresh
95            let refresh_delay = if self.state.paused {
96                Duration::from_millis(200) // Just check for events while paused
97            } else {
98                let elapsed = self.state.last_update.elapsed();
99                self.state.refresh_rate.saturating_sub(elapsed)
100            };
101
102            // Wait for either an event or the refresh timer
103            tokio::select! {
104                maybe_event = event_stream.next() => {
105                    match maybe_event {
106                        Some(Ok(Event::Key(key))) => {
107                            self.handle_key_event(key);
108                        }
109                        Some(Ok(Event::Resize(_, _))) => {
110                            // Terminal resized — ui() will pick up new size on next draw
111                        }
112                        _ => {}
113                    }
114                }
115                _ = tokio::time::sleep(refresh_delay) => {
116                    // Timer expired — check if refresh is needed
117                }
118            }
119
120            if self.should_exit {
121                break;
122            }
123
124            // Check if refresh needed
125            if self.state.should_refresh() {
126                self.fetch_data().await;
127            }
128        }
129
130        Ok(())
131    }
132}
133
134impl<B: ratatui::backend::Backend> Drop for MonitorApp<B> {
135    fn drop(&mut self) {
136        if self.owns_terminal {
137            let _ = execute!(io::stdout(), DisableMouseCapture);
138            ratatui::restore();
139        }
140    }
141}
142
143/// Methods that work with any terminal backend (production or test).
144/// Includes cleanup, key handling, and data fetching.
145impl<B: ratatui::backend::Backend> MonitorApp<B> {
146    /// Cleans up terminal state. Only performs real terminal restore when
147    /// this app owns the terminal (production mode).
148    pub fn cleanup(&mut self) -> Result<()> {
149        // Save cache before exiting
150        self.state.save_cache();
151
152        if self.owns_terminal {
153            // Disable mouse capture (not handled by ratatui::restore)
154            let _ = execute!(io::stdout(), DisableMouseCapture);
155            // Restore terminal using ratatui's simplified cleanup
156            ratatui::restore();
157        }
158        Ok(())
159    }
160
161    /// Handles a single key event, updating state accordingly.
162    /// Extracted from the event loop for testability.
163    pub(crate) fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
164        // Track last input time for auto-pause
165        self.state.last_input_at = Instant::now();
166
167        // Widget toggle mode: waiting for digit 1-5
168        if self.state.widget_toggle_mode {
169            self.state.widget_toggle_mode = false;
170            if let KeyCode::Char(c @ '1'..='5') = key.code {
171                let idx = (c as u8 - b'0') as usize;
172                self.state.widgets.toggle_by_index(idx);
173                return;
174            }
175            // Any other key cancels the mode and falls through
176        }
177
178        match key.code {
179            KeyCode::Char('q') | KeyCode::Esc => {
180                // Stop export before quitting if active
181                if self.state.export_active {
182                    self.state.stop_export();
183                }
184                self.should_exit = true;
185            }
186            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
187                if self.state.export_active {
188                    self.state.stop_export();
189                }
190                self.should_exit = true;
191            }
192            KeyCode::Char('r') => {
193                self.state.force_refresh();
194            }
195            // Shift+P toggles auto-pause on input
196            KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::SHIFT) => {
197                self.state.auto_pause_on_input = !self.state.auto_pause_on_input;
198                self.state.log(format!(
199                    "Auto-pause: {}",
200                    if self.state.auto_pause_on_input {
201                        "ON"
202                    } else {
203                        "OFF"
204                    }
205                ));
206            }
207            KeyCode::Char('p') | KeyCode::Char(' ') => {
208                self.state.toggle_pause();
209            }
210            // Toggle CSV export
211            KeyCode::Char('e') => {
212                self.state.toggle_export();
213            }
214            // Increase refresh interval (slower)
215            KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
216                self.state.slower_refresh();
217            }
218            // Decrease refresh interval (faster)
219            KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
220                self.state.faster_refresh();
221            }
222            // Time period selection (1=1m, 2=5m, 3=15m, 4=1h, 5=4h, 6=1d)
223            KeyCode::Char('1') => {
224                self.state.set_time_period(TimePeriod::Min1);
225            }
226            KeyCode::Char('2') => {
227                self.state.set_time_period(TimePeriod::Min5);
228            }
229            KeyCode::Char('3') => {
230                self.state.set_time_period(TimePeriod::Min15);
231            }
232            KeyCode::Char('4') => {
233                self.state.set_time_period(TimePeriod::Hour1);
234            }
235            KeyCode::Char('5') => {
236                self.state.set_time_period(TimePeriod::Hour4);
237            }
238            KeyCode::Char('6') => {
239                self.state.set_time_period(TimePeriod::Day1);
240            }
241            KeyCode::Char('t') | KeyCode::Tab => {
242                self.state.cycle_time_period();
243            }
244            // Toggle chart mode (line/candlestick/volume-profile)
245            KeyCode::Char('c') => {
246                self.state.toggle_chart_mode();
247            }
248            // Toggle scale mode (linear/log)
249            KeyCode::Char('s') => {
250                self.state.scale_mode = self.state.scale_mode.toggle();
251                self.state
252                    .log(format!("Scale: {}", self.state.scale_mode.label()));
253            }
254            // Cycle color scheme
255            KeyCode::Char('/') => {
256                self.state.color_scheme = self.state.color_scheme.next();
257                self.state
258                    .log(format!("Colors: {}", self.state.color_scheme.label()));
259            }
260            // Scroll activity log
261            KeyCode::Char('j') | KeyCode::Down => {
262                self.state.scroll_log_down();
263            }
264            KeyCode::Char('k') | KeyCode::Up => {
265                self.state.scroll_log_up();
266            }
267            // Layout cycling
268            KeyCode::Char('l') => {
269                self.state.layout = self.state.layout.next();
270                self.state.auto_layout = false;
271            }
272            KeyCode::Char('h') => {
273                self.state.layout = self.state.layout.prev();
274                self.state.auto_layout = false;
275            }
276            // Widget toggle mode
277            KeyCode::Char('w') => {
278                self.state.widget_toggle_mode = true;
279            }
280            // Re-enable auto layout
281            KeyCode::Char('a') => {
282                self.state.auto_layout = true;
283            }
284            _ => {}
285        }
286    }
287
288    /// Fetches new data from the API.
289    pub(crate) async fn fetch_data(&mut self) {
290        match self
291            .dex_client
292            .get_token_data(&self.state.chain, &self.state.token_address)
293            .await
294        {
295            Ok(data) => {
296                self.state.update(&data);
297            }
298            Err(e) => {
299                self.state.error_message = Some(format!("API Error: {}", e));
300                self.state.last_update = Instant::now(); // Prevent rapid retries
301            }
302        }
303
304        // Fetch OHLC candles from exchange venue (if configured)
305        if let (Some(ex), Some(pair)) = (&self.exchange_client, &self.state.venue_pair.clone())
306            && ex.has_ohlc()
307        {
308            let interval = self.state.time_period.exchange_interval();
309            let limit = 100;
310            match ex.fetch_ohlc(pair, interval, limit).await {
311                Ok(candles) => {
312                    self.state.exchange_ohlc = candles
313                        .into_iter()
314                        .map(|c| OhlcCandle {
315                            timestamp: c.open_time as f64 / 1000.0,
316                            open: c.open,
317                            high: c.high,
318                            low: c.low,
319                            close: c.close,
320                            is_bullish: c.close >= c.open,
321                        })
322                        .collect();
323                }
324                Err(e) => {
325                    tracing::debug!("Failed to fetch OHLC: {}", e);
326                    // Fall back to synthetic candles silently
327                }
328            }
329        }
330
331        // Periodically fetch holder count via chain client (~every 12th refresh ≈ 60s at 5s rate)
332        self.state.holder_fetch_counter += 1;
333        if self.state.holder_fetch_counter.is_multiple_of(12)
334            && let Some(ref client) = self.chain_client
335        {
336            match client
337                .get_token_holder_count(&self.state.token_address)
338                .await
339            {
340                Ok(count) if count > 0 => {
341                    self.state.holder_count = Some(count);
342                }
343                _ => {} // Keep previous value or None
344            }
345        }
346    }
347}
348
349/// Handles a key event by mutating state. Standalone version for testability.
350/// Returns true if the application should exit.
351#[cfg(test)]
352pub(crate) fn handle_key_event_on_state(
353    key: crossterm::event::KeyEvent,
354    state: &mut MonitorState,
355) -> bool {
356    // Track last input time for auto-pause
357    state.last_input_at = Instant::now();
358
359    // Widget toggle mode: waiting for digit 1-5
360    if state.widget_toggle_mode {
361        state.widget_toggle_mode = false;
362        if let KeyCode::Char(c @ '1'..='5') = key.code {
363            let idx = (c as u8 - b'0') as usize;
364            state.widgets.toggle_by_index(idx);
365            return false;
366        }
367        // Any other key cancels the mode and falls through
368    }
369
370    match key.code {
371        KeyCode::Char('q') | KeyCode::Esc => {
372            if state.export_active {
373                state.stop_export();
374            }
375            return true;
376        }
377        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
378            if state.export_active {
379                state.stop_export();
380            }
381            return true;
382        }
383        KeyCode::Char('r') => {
384            state.force_refresh();
385        }
386        // Shift+P toggles auto-pause
387        KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::SHIFT) => {
388            state.auto_pause_on_input = !state.auto_pause_on_input;
389            state.log(format!(
390                "Auto-pause: {}",
391                if state.auto_pause_on_input {
392                    "ON"
393                } else {
394                    "OFF"
395                }
396            ));
397        }
398        KeyCode::Char('p') | KeyCode::Char(' ') => {
399            state.toggle_pause();
400        }
401        // Toggle CSV export
402        KeyCode::Char('e') => {
403            state.toggle_export();
404        }
405        KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
406            state.slower_refresh();
407        }
408        KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
409            state.faster_refresh();
410        }
411        KeyCode::Char('1') => {
412            state.set_time_period(TimePeriod::Min1);
413        }
414        KeyCode::Char('2') => {
415            state.set_time_period(TimePeriod::Min5);
416        }
417        KeyCode::Char('3') => {
418            state.set_time_period(TimePeriod::Min15);
419        }
420        KeyCode::Char('4') => {
421            state.set_time_period(TimePeriod::Hour1);
422        }
423        KeyCode::Char('5') => {
424            state.set_time_period(TimePeriod::Hour4);
425        }
426        KeyCode::Char('6') => {
427            state.set_time_period(TimePeriod::Day1);
428        }
429        KeyCode::Char('t') | KeyCode::Tab => {
430            state.cycle_time_period();
431        }
432        KeyCode::Char('c') => {
433            state.toggle_chart_mode();
434        }
435        KeyCode::Char('s') => {
436            state.scale_mode = state.scale_mode.toggle();
437            state.log(format!("Scale: {}", state.scale_mode.label()));
438        }
439        KeyCode::Char('/') => {
440            state.color_scheme = state.color_scheme.next();
441            state.log(format!("Colors: {}", state.color_scheme.label()));
442        }
443        KeyCode::Char('j') | KeyCode::Down => {
444            state.scroll_log_down();
445        }
446        KeyCode::Char('k') | KeyCode::Up => {
447            state.scroll_log_up();
448        }
449        KeyCode::Char('l') => {
450            state.layout = state.layout.next();
451            state.auto_layout = false;
452        }
453        KeyCode::Char('h') => {
454            state.layout = state.layout.prev();
455            state.auto_layout = false;
456        }
457        KeyCode::Char('w') => {
458            state.widget_toggle_mode = true;
459        }
460        KeyCode::Char('a') => {
461            state.auto_layout = true;
462        }
463        _ => {}
464    }
465    false
466}