scope_cli/cli/monitor/
input.rs1use 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
19pub struct MonitorApp<B: ratatui::backend::Backend = ratatui::backend::CrosstermBackend<io::Stdout>>
24{
25 pub(crate) terminal: ratatui::Terminal<B>,
27
28 pub(crate) state: MonitorState,
30
31 pub(crate) dex_client: Box<dyn DexDataSource>,
33
34 pub(crate) chain_client: Option<Box<dyn ChainClient>>,
36
37 pub(crate) exchange_client: Option<scope::market::ExchangeClient>,
39
40 pub(crate) should_exit: bool,
42
43 pub(crate) owns_terminal: bool,
46}
47
48impl MonitorApp {
50 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 let terminal = ratatui::init();
60 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 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 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 self.terminal.draw(|f| ui(f, &mut self.state))?;
93
94 let refresh_delay = if self.state.paused {
96 Duration::from_millis(200) } else {
98 let elapsed = self.state.last_update.elapsed();
99 self.state.refresh_rate.saturating_sub(elapsed)
100 };
101
102 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 }
112 _ => {}
113 }
114 }
115 _ = tokio::time::sleep(refresh_delay) => {
116 }
118 }
119
120 if self.should_exit {
121 break;
122 }
123
124 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
143impl<B: ratatui::backend::Backend> MonitorApp<B> {
146 pub fn cleanup(&mut self) -> Result<()> {
149 self.state.save_cache();
151
152 if self.owns_terminal {
153 let _ = execute!(io::stdout(), DisableMouseCapture);
155 ratatui::restore();
157 }
158 Ok(())
159 }
160
161 pub(crate) fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
164 self.state.last_input_at = Instant::now();
166
167 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 }
177
178 match key.code {
179 KeyCode::Char('q') | KeyCode::Esc => {
180 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 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 KeyCode::Char('e') => {
212 self.state.toggle_export();
213 }
214 KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
216 self.state.slower_refresh();
217 }
218 KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
220 self.state.faster_refresh();
221 }
222 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 KeyCode::Char('c') => {
246 self.state.toggle_chart_mode();
247 }
248 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 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 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 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 KeyCode::Char('w') => {
278 self.state.widget_toggle_mode = true;
279 }
280 KeyCode::Char('a') => {
282 self.state.auto_layout = true;
283 }
284 _ => {}
285 }
286 }
287
288 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(); }
302 }
303
304 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 }
328 }
329 }
330
331 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 _ => {} }
345 }
346 }
347}
348
349#[cfg(test)]
352pub(crate) fn handle_key_event_on_state(
353 key: crossterm::event::KeyEvent,
354 state: &mut MonitorState,
355) -> bool {
356 state.last_input_at = Instant::now();
358
359 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 }
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 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 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}