scope_cli/cli/monitor/
input.rs1use 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
17pub struct MonitorApp<B: ratatui::backend::Backend = ratatui::backend::CrosstermBackend<io::Stdout>>
22{
23 pub(crate) terminal: ratatui::Terminal<B>,
25
26 pub(crate) state: MonitorState,
28
29 pub(crate) dex_client: Box<dyn DexDataSource>,
31
32 pub(crate) chain_client: Option<Box<dyn ChainClient>>,
34
35 pub(crate) exchange_client: Option<scope::market::ExchangeClient>,
37
38 pub(crate) should_exit: bool,
40
41 pub(crate) owns_terminal: bool,
44}
45
46impl MonitorApp {
48 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 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 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 self.terminal.draw(|f| {
92 ui(f, &mut self.state);
93 crate::cli::plain::finish_frame(f);
94 })?;
95
96 let refresh_delay = if self.state.paused {
98 Duration::from_millis(200) } else {
100 let elapsed = self.state.last_update.elapsed();
101 self.state.refresh_rate.saturating_sub(elapsed)
102 };
103
104 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 }
114 _ => {}
115 }
116 }
117 _ = tokio::time::sleep(refresh_delay) => {
118 }
120 }
121
122 if self.should_exit {
123 break;
124 }
125
126 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
144impl<B: ratatui::backend::Backend> MonitorApp<B> {
147 pub fn cleanup(&mut self) -> Result<()> {
150 self.state.save_cache();
152
153 if self.owns_terminal {
154 screen::restore();
155 }
156 Ok(())
157 }
158
159 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 pub(crate) async fn fetch_data(&mut self) {
169 if self.state.venue_only {
170 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(); }
185 }
186 }
187
188 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 }
212 }
213 }
214
215 self.fetch_venue_book_and_trades().await;
216
217 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 _ => {} }
232 }
233 }
234}
235
236impl<B: ratatui::backend::Backend> MonitorApp<B> {
237 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 async fn fetch_venue_ticker(&mut self) {
262 let (Some(ex), Some(pair)) = (&self.exchange_client, self.state.venue_pair.clone()) else {
263 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(); 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(); }
280 }
281 }
282}
283
284pub(crate) fn handle_key_event_on_state(
288 key: crossterm::event::KeyEvent,
289 state: &mut MonitorState,
290) -> bool {
291 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 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 }
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 state.log("Refresh requested".to_string());
331 }
332 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 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}