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, KeyEventKind, 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()?;
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                        // Windows reports key releases too; acting on them
109                        // would apply every key twice.
110                        Some(Ok(Event::Key(key))) if key.kind != KeyEventKind::Release => {
111                            self.handle_key_event(key);
112                        }
113                        Some(Ok(Event::Resize(_, _))) => {
114                            // Terminal resized — ui() will pick up new size on next draw
115                        }
116                        _ => {}
117                    }
118                }
119                _ = tokio::time::sleep(refresh_delay) => {
120                    // Timer expired — check if refresh is needed
121                }
122            }
123
124            if self.should_exit {
125                break;
126            }
127
128            // Check if refresh needed
129            if self.state.should_refresh() {
130                self.fetch_data().await;
131            }
132        }
133
134        Ok(())
135    }
136}
137
138impl<B: ratatui::backend::Backend> Drop for MonitorApp<B> {
139    fn drop(&mut self) {
140        if self.owns_terminal {
141            screen::restore();
142        }
143    }
144}
145
146/// Methods that work with any terminal backend (production or test).
147/// Includes cleanup, key handling, and data fetching.
148impl<B: ratatui::backend::Backend> MonitorApp<B> {
149    /// Cleans up terminal state. Only performs real terminal restore when
150    /// this app owns the terminal (production mode).
151    pub fn cleanup(&mut self) -> Result<()> {
152        // Save cache before exiting
153        self.state.save_cache();
154
155        if self.owns_terminal {
156            screen::restore();
157        }
158        Ok(())
159    }
160
161    /// Handles a single key event with [`handle_key_event_on_state`], the
162    /// one key handler (production and tests).
163    pub(crate) fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
164        if handle_key_event_on_state(key, &mut self.state) {
165            self.should_exit = true;
166        }
167    }
168
169    /// Fetches new data from the API.
170    pub(crate) async fn fetch_data(&mut self) {
171        if self.state.venue_only {
172            // DexScreener does not know an `exchange:<pair>` token.
173            self.fetch_venue_ticker().await;
174        } else {
175            match self
176                .dex_client
177                .get_token_data(&self.state.chain, &self.state.token_address)
178                .await
179            {
180                Ok(data) => {
181                    self.state.update(&data);
182                }
183                Err(e) => {
184                    self.state.error_message = Some(format!("API Error: {}", e));
185                    self.state.last_update = Instant::now(); // Prevent rapid retries
186                }
187            }
188        }
189
190        // Fetch OHLC candles from exchange venue (if configured)
191        if let (Some(ex), Some(pair)) = (&self.exchange_client, &self.state.venue_pair.clone())
192            && ex.has_ohlc()
193        {
194            let interval = self.state.time_period.exchange_interval();
195            let limit = 100;
196            match ex.fetch_ohlc(pair, interval, limit).await {
197                Ok(candles) => {
198                    self.state.exchange_ohlc = candles
199                        .into_iter()
200                        .map(|c| OhlcCandle {
201                            timestamp: c.open_time as f64 / 1000.0,
202                            open: c.open,
203                            high: c.high,
204                            low: c.low,
205                            close: c.close,
206                            is_bullish: c.close >= c.open,
207                        })
208                        .collect();
209                }
210                Err(e) => {
211                    tracing::debug!("Failed to fetch OHLC: {}", e);
212                    // Fall back to synthetic candles silently
213                }
214            }
215        }
216
217        self.fetch_venue_book_and_trades().await;
218
219        // Periodically fetch holder count via chain client (~every 12th refresh ≈ 60s at 5s rate)
220        self.state.holder_fetch_counter += 1;
221        if self.state.holder_fetch_counter.is_multiple_of(12)
222            && !self.state.venue_only
223            && let Some(ref client) = self.chain_client
224        {
225            match client
226                .get_token_holder_count(&self.state.token_address)
227                .await
228            {
229                Ok(count) if count > 0 => {
230                    self.state.holder_count = Some(count);
231                }
232                _ => {} // Keep previous value or None
233            }
234        }
235    }
236}
237
238impl<B: ratatui::backend::Backend> MonitorApp<B> {
239    /// With a venue, its real order book and recent trades replace the ones
240    /// built from DEX data (#78). A failed fetch keeps the last real data
241    /// and shows the error.
242    async fn fetch_venue_book_and_trades(&mut self) {
243        let (Some(ex), Some(pair)) = (&self.exchange_client, self.state.venue_pair.clone()) else {
244            return;
245        };
246        let venue = ex.venue_id().to_string();
247        if ex.has_order_book() {
248            match ex.fetch_order_book(&pair).await {
249                Ok(book) => self.state.set_venue_book(&venue, book),
250                Err(e) => self.state.error_message = Some(format!("{venue} order book: {e}")),
251            }
252        }
253        if ex.has_trade_history() {
254            let limit = super::state::MAX_RECENT_TRADES as u32;
255            match ex.fetch_recent_trades(&pair, limit).await {
256                Ok(trades) => self.state.set_venue_trades(&venue, trades),
257                Err(e) => self.state.error_message = Some(format!("{venue} trades: {e}")),
258            }
259        }
260    }
261
262    /// Venue-only refresh: prices the token from the venue ticker.
263    async fn fetch_venue_ticker(&mut self) {
264        let (Some(ex), Some(pair)) = (&self.exchange_client, self.state.venue_pair.clone()) else {
265            // Venue-only mode is set only with a venue and a pair. Say so
266            // rather than freeze the price without a word.
267            self.state.error_message =
268                Some("Venue-only mode has no venue or pair to refresh from".to_string());
269            self.state.last_update = Instant::now(); // Prevent rapid retries
270            return;
271        };
272        match ex.fetch_ticker(&pair).await {
273            Ok(ticker) => {
274                let data = super::build_exchange_token_data(&self.state.symbol, &pair, &ticker);
275                self.state.set_venue_ticker(&ticker);
276                self.state.update(&data);
277            }
278            Err(e) => {
279                self.state.error_message = Some(e.to_string());
280                self.state.last_update = Instant::now(); // Prevent rapid retries
281            }
282        }
283    }
284}
285
286/// The one monitor key handler: mutates `state` and returns true when the
287/// monitor should exit. `?` shows the key overlay; with it open, any key
288/// closes it (#68).
289pub(crate) fn handle_key_event_on_state(
290    key: crossterm::event::KeyEvent,
291    state: &mut MonitorState,
292) -> bool {
293    // Track last input time for auto-pause
294    state.last_input_at = Instant::now();
295
296    if state.show_keys {
297        state.show_keys = false;
298        return false;
299    }
300    if key.code == KeyCode::Char('?') {
301        state.show_keys = true;
302        return false;
303    }
304
305    // Widget toggle mode: waiting for digit 1-5
306    if state.widget_toggle_mode {
307        state.widget_toggle_mode = false;
308        if let KeyCode::Char(c @ '1'..='5') = key.code {
309            let idx = (c as u8 - b'0') as usize;
310            state.widgets.toggle_by_index(idx);
311            return false;
312        }
313        // Any other key cancels the mode and falls through
314    }
315
316    match key.code {
317        KeyCode::Char('q') | KeyCode::Esc => {
318            if state.export_active {
319                state.stop_export();
320            }
321            return true;
322        }
323        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
324            if state.export_active {
325                state.stop_export();
326            }
327            return true;
328        }
329        KeyCode::Char('r') => {
330            state.force_refresh();
331            // The key says it was heard (#68).
332            state.log("Refresh requested".to_string());
333        }
334        // Shift+P toggles auto-pause
335        KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::SHIFT) => {
336            state.auto_pause_on_input = !state.auto_pause_on_input;
337            state.log(format!(
338                "Auto-pause: {}",
339                if state.auto_pause_on_input {
340                    "ON"
341                } else {
342                    "OFF"
343                }
344            ));
345        }
346        KeyCode::Char('p') | KeyCode::Char(' ') => {
347            state.toggle_pause();
348        }
349        // Toggle CSV export
350        KeyCode::Char('e') => {
351            state.toggle_export();
352        }
353        KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
354            state.slower_refresh();
355        }
356        KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
357            state.faster_refresh();
358        }
359        KeyCode::Char('1') => {
360            state.set_time_period(TimePeriod::Min1);
361        }
362        KeyCode::Char('2') => {
363            state.set_time_period(TimePeriod::Min5);
364        }
365        KeyCode::Char('3') => {
366            state.set_time_period(TimePeriod::Min15);
367        }
368        KeyCode::Char('4') => {
369            state.set_time_period(TimePeriod::Hour1);
370        }
371        KeyCode::Char('5') => {
372            state.set_time_period(TimePeriod::Hour4);
373        }
374        KeyCode::Char('6') => {
375            state.set_time_period(TimePeriod::Day1);
376        }
377        KeyCode::Char('t') | KeyCode::Tab => {
378            state.cycle_time_period();
379        }
380        KeyCode::Char('c') => {
381            state.toggle_chart_mode();
382        }
383        KeyCode::Char('s') => {
384            state.scale_mode = state.scale_mode.toggle();
385            state.log(format!("Scale: {}", state.scale_mode.label()));
386        }
387        KeyCode::Char('/') => {
388            state.color_scheme = state.color_scheme.next();
389            state.log(format!("Colors: {}", state.color_scheme.label()));
390        }
391        KeyCode::Char('j') | KeyCode::Down => {
392            state.scroll_log_down();
393        }
394        KeyCode::Char('k') | KeyCode::Up => {
395            state.scroll_log_up();
396        }
397        KeyCode::Char('l') => {
398            state.layout = state.layout.next();
399            state.auto_layout = false;
400        }
401        KeyCode::Char('h') => {
402            state.layout = state.layout.prev();
403            state.auto_layout = false;
404        }
405        KeyCode::Char('w') => {
406            state.widget_toggle_mode = true;
407        }
408        KeyCode::Char('a') => {
409            state.auto_layout = true;
410        }
411        _ => {}
412    }
413    false
414}