Skip to main content

scope_cli/cli/monitor/
input.rs

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