use crate::cli::screen;
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use scope::chains::ChainClient;
use scope::chains::dex::DexDataSource;
use scope::error::Result;
use std::io;
use std::time::{Duration, Instant};
use super::config::{MonitorConfig, OhlcCandle, TimePeriod};
use super::state::MonitorState;
use super::widgets::ui;
use scope::chains::dex::DexTokenData;
pub struct MonitorApp<B: ratatui::backend::Backend = ratatui::backend::CrosstermBackend<io::Stdout>>
{
pub(crate) terminal: ratatui::Terminal<B>,
pub(crate) state: MonitorState,
pub(crate) dex_client: Box<dyn DexDataSource>,
pub(crate) chain_client: Option<Box<dyn ChainClient>>,
pub(crate) exchange_client: Option<scope::market::ExchangeClient>,
pub(crate) should_exit: bool,
pub(crate) owns_terminal: bool,
}
impl MonitorApp {
pub fn new(
initial_data: DexTokenData,
chain: &str,
monitor_config: &MonitorConfig,
dex_client: Box<dyn DexDataSource>,
chain_client: Option<Box<dyn ChainClient>>,
exchange_client: Option<scope::market::ExchangeClient>,
) -> Result<Self> {
let terminal = screen::enter()?;
let mut state = MonitorState::new(&initial_data, chain);
state.apply_config(monitor_config);
if let Some(ref ex) = exchange_client {
let pair = ex.venue_symbol(&scope::market::Pair::parse(&initial_data.symbol));
state.venue_pair = Some(pair);
state.venue = Some(ex.venue_id().to_string());
}
Ok(Self {
terminal,
state,
dex_client,
chain_client,
exchange_client,
should_exit: false,
owns_terminal: true,
})
}
pub async fn run(&mut self) -> Result<()> {
use futures::StreamExt;
let mut event_stream = crossterm::event::EventStream::new();
loop {
self.terminal.draw(|f| {
ui(f, &mut self.state);
crate::cli::plain::finish_frame(f);
})?;
let refresh_delay = if self.state.paused {
Duration::from_millis(200) } else {
let elapsed = self.state.last_update.elapsed();
self.state.refresh_rate.saturating_sub(elapsed)
};
tokio::select! {
maybe_event = event_stream.next() => {
match maybe_event {
Some(Ok(Event::Key(key))) if key.kind != KeyEventKind::Release => {
self.handle_key_event(key);
}
Some(Ok(Event::Resize(_, _))) => {
}
_ => {}
}
}
_ = tokio::time::sleep(refresh_delay) => {
}
}
if self.should_exit {
break;
}
if self.state.should_refresh() {
self.fetch_data().await;
}
}
Ok(())
}
}
impl<B: ratatui::backend::Backend> Drop for MonitorApp<B> {
fn drop(&mut self) {
if self.owns_terminal {
screen::restore();
}
}
}
impl<B: ratatui::backend::Backend> MonitorApp<B> {
pub fn cleanup(&mut self) -> Result<()> {
self.state.save_cache();
if self.owns_terminal {
screen::restore();
}
Ok(())
}
pub(crate) fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
if handle_key_event_on_state(key, &mut self.state) {
self.should_exit = true;
}
}
pub(crate) async fn fetch_data(&mut self) {
if self.state.venue_only {
self.fetch_venue_ticker().await;
} else {
match self
.dex_client
.get_token_data(&self.state.chain, &self.state.token_address)
.await
{
Ok(data) => {
self.state.update(&data);
}
Err(e) => {
self.state.error_message = Some(format!("API Error: {}", e));
self.state.last_update = Instant::now(); }
}
}
if let (Some(ex), Some(pair)) = (&self.exchange_client, &self.state.venue_pair.clone())
&& ex.has_ohlc()
{
let interval = self.state.time_period.exchange_interval();
let limit = 100;
match ex.fetch_ohlc(pair, interval, limit).await {
Ok(candles) => {
self.state.exchange_ohlc = candles
.into_iter()
.map(|c| OhlcCandle {
timestamp: c.open_time as f64 / 1000.0,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
is_bullish: c.close >= c.open,
})
.collect();
}
Err(e) => {
tracing::debug!("Failed to fetch OHLC: {}", e);
}
}
}
self.fetch_venue_book_and_trades().await;
self.state.holder_fetch_counter += 1;
if self.state.holder_fetch_counter.is_multiple_of(12)
&& !self.state.venue_only
&& let Some(ref client) = self.chain_client
{
match client
.get_token_holder_count(&self.state.token_address)
.await
{
Ok(count) if count > 0 => {
self.state.holder_count = Some(count);
}
_ => {} }
}
}
}
impl<B: ratatui::backend::Backend> MonitorApp<B> {
async fn fetch_venue_book_and_trades(&mut self) {
let (Some(ex), Some(pair)) = (&self.exchange_client, self.state.venue_pair.clone()) else {
return;
};
let venue = ex.venue_id().to_string();
if ex.has_order_book() {
match ex.fetch_order_book(&pair).await {
Ok(book) => self.state.set_venue_book(&venue, book),
Err(e) => self.state.error_message = Some(format!("{venue} order book: {e}")),
}
}
if ex.has_trade_history() {
let limit = super::state::MAX_RECENT_TRADES as u32;
match ex.fetch_recent_trades(&pair, limit).await {
Ok(trades) => self.state.set_venue_trades(&venue, trades),
Err(e) => self.state.error_message = Some(format!("{venue} trades: {e}")),
}
}
}
async fn fetch_venue_ticker(&mut self) {
let (Some(ex), Some(pair)) = (&self.exchange_client, self.state.venue_pair.clone()) else {
self.state.error_message =
Some("Venue-only mode has no venue or pair to refresh from".to_string());
self.state.last_update = Instant::now(); return;
};
match ex.fetch_ticker(&pair).await {
Ok(ticker) => {
let data = super::build_exchange_token_data(&self.state.symbol, &pair, &ticker);
self.state.set_venue_ticker(&ticker);
self.state.update(&data);
}
Err(e) => {
self.state.error_message = Some(e.to_string());
self.state.last_update = Instant::now(); }
}
}
}
pub(crate) fn handle_key_event_on_state(
key: crossterm::event::KeyEvent,
state: &mut MonitorState,
) -> bool {
state.last_input_at = Instant::now();
if state.show_keys {
state.show_keys = false;
return false;
}
if key.code == KeyCode::Char('?') {
state.show_keys = true;
return false;
}
if state.widget_toggle_mode {
state.widget_toggle_mode = false;
if let KeyCode::Char(c @ '1'..='5') = key.code {
let idx = (c as u8 - b'0') as usize;
state.widgets.toggle_by_index(idx);
return false;
}
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
if state.export_active {
state.stop_export();
}
return true;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if state.export_active {
state.stop_export();
}
return true;
}
KeyCode::Char('r') => {
state.force_refresh();
state.log("Refresh requested".to_string());
}
KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::SHIFT) => {
state.auto_pause_on_input = !state.auto_pause_on_input;
state.log(format!(
"Auto-pause: {}",
if state.auto_pause_on_input {
"ON"
} else {
"OFF"
}
));
}
KeyCode::Char('p') | KeyCode::Char(' ') => {
state.toggle_pause();
}
KeyCode::Char('e') => {
state.toggle_export();
}
KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
state.slower_refresh();
}
KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
state.faster_refresh();
}
KeyCode::Char('1') => {
state.set_time_period(TimePeriod::Min1);
}
KeyCode::Char('2') => {
state.set_time_period(TimePeriod::Min5);
}
KeyCode::Char('3') => {
state.set_time_period(TimePeriod::Min15);
}
KeyCode::Char('4') => {
state.set_time_period(TimePeriod::Hour1);
}
KeyCode::Char('5') => {
state.set_time_period(TimePeriod::Hour4);
}
KeyCode::Char('6') => {
state.set_time_period(TimePeriod::Day1);
}
KeyCode::Char('t') | KeyCode::Tab => {
state.cycle_time_period();
}
KeyCode::Char('c') => {
state.toggle_chart_mode();
}
KeyCode::Char('s') => {
state.scale_mode = state.scale_mode.toggle();
state.log(format!("Scale: {}", state.scale_mode.label()));
}
KeyCode::Char('/') => {
state.color_scheme = state.color_scheme.next();
state.log(format!("Colors: {}", state.color_scheme.label()));
}
KeyCode::Char('j') | KeyCode::Down => {
state.scroll_log_down();
}
KeyCode::Char('k') | KeyCode::Up => {
state.scroll_log_up();
}
KeyCode::Char('l') => {
state.layout = state.layout.next();
state.auto_layout = false;
}
KeyCode::Char('h') => {
state.layout = state.layout.prev();
state.auto_layout = false;
}
KeyCode::Char('w') => {
state.widget_toggle_mode = true;
}
KeyCode::Char('a') => {
state.auto_layout = true;
}
_ => {}
}
false
}