scope_cli/cli/monitor/
input.rs1use 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
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()?;
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))) if key.kind != KeyEventKind::Release => {
111 self.handle_key_event(key);
112 }
113 Some(Ok(Event::Resize(_, _))) => {
114 }
116 _ => {}
117 }
118 }
119 _ = tokio::time::sleep(refresh_delay) => {
120 }
122 }
123
124 if self.should_exit {
125 break;
126 }
127
128 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
146impl<B: ratatui::backend::Backend> MonitorApp<B> {
149 pub fn cleanup(&mut self) -> Result<()> {
152 self.state.save_cache();
154
155 if self.owns_terminal {
156 screen::restore();
157 }
158 Ok(())
159 }
160
161 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 pub(crate) async fn fetch_data(&mut self) {
171 if self.state.venue_only {
172 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(); }
187 }
188 }
189
190 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 }
214 }
215 }
216
217 self.fetch_venue_book_and_trades().await;
218
219 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 _ => {} }
234 }
235 }
236}
237
238impl<B: ratatui::backend::Backend> MonitorApp<B> {
239 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 async fn fetch_venue_ticker(&mut self) {
264 let (Some(ex), Some(pair)) = (&self.exchange_client, self.state.venue_pair.clone()) else {
265 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(); 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(); }
282 }
283 }
284}
285
286pub(crate) fn handle_key_event_on_state(
290 key: crossterm::event::KeyEvent,
291 state: &mut MonitorState,
292) -> bool {
293 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 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 }
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 state.log("Refresh requested".to_string());
333 }
334 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 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}