Skip to main content

gm_lib/tui/
app.rs

1use std::{
2    io,
3    str::FromStr,
4    sync::{atomic::AtomicBool, mpsc, Arc, RwLock, RwLockWriteGuard},
5};
6
7use alloy::primitives::Address;
8use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
9use pages::{
10    config::ConfigPage,
11    dev_key_capture::DevKeyCapturePage,
12    main_menu::{MainMenuItem, MainMenuPage},
13    text::TextPage,
14    trade::TradePage,
15    Page,
16};
17use ratatui::{
18    buffer::Buffer,
19    layout::{Constraint, Layout, Rect},
20    widgets::{Block, Widget},
21    DefaultTerminal,
22};
23use widgets::{footer::Footer, form::Form, text_popup::TextPopup, title::Title};
24
25use super::{
26    events::{self, Event},
27    traits::{Component, HandleResult, RectUtil},
28};
29use crate::{
30    disk::{Config, DiskInterface},
31    error::FmtError,
32    tui::{app::widgets::invite_popup::InvitePopup, events::helios::helios_thread},
33    utils::assets::Asset,
34};
35use crate::{
36    tui::theme::{Theme, ThemeName},
37    utils::assets::AssetManager,
38};
39
40pub mod pages;
41pub mod widgets;
42
43// TODO update focus to have title bar, footer
44#[allow(dead_code)]
45#[derive(Debug, PartialEq)]
46pub enum Focus {
47    Main,
48    Sidebar,
49}
50
51pub struct SharedState {
52    pub online: Option<bool>,
53    pub asset_manager: Arc<RwLock<AssetManager>>,
54    pub recent_addresses: Option<Vec<Address>>,
55    pub testnet_mode: bool,
56    pub developer_mode: bool,
57    current_account: Option<Address>,
58    pub alchemy_api_key_available: bool,
59    pub eth_price: Option<String>,
60    pub theme: Theme,
61}
62
63impl SharedState {
64    pub fn assets_read(&self) -> crate::Result<Option<Vec<Asset>>> {
65        let Some(current_account) = self.current_account else {
66            return Ok(None);
67        };
68
69        Ok(self
70            .asset_manager
71            .read()
72            .map_err(|e| format!("poison error - please restart gm - {e}"))?
73            .get_assets(&current_account)
74            .cloned())
75    }
76
77    pub fn assets_mut(&mut self) -> crate::Result<RwLockWriteGuard<'_, AssetManager>> {
78        Ok(self
79            .asset_manager
80            .write()
81            .map_err(|e| format!("poison error - please restart gm - {e}"))?)
82    }
83
84    pub fn try_current_account(&self) -> crate::Result<Address> {
85        self.current_account
86            .ok_or_else(|| crate::Error::CurrentAccountNotSet)
87    }
88}
89
90pub struct App {
91    pub context: Vec<Page>,
92    pub preview_page: Option<Page>,
93
94    pub exit: bool,
95    pub fatal_error_popup: TextPopup,
96
97    pub shared_state: SharedState,
98
99    pub invite_popup: InvitePopup,
100
101    pub input_thread: Option<std::thread::JoinHandle<()>>,
102    pub eth_price_thread: Option<tokio::task::JoinHandle<()>>,
103    pub assets_thread: Option<tokio::task::JoinHandle<()>>,
104    pub recent_addresses_thread: Option<tokio::task::JoinHandle<()>>,
105    pub helios_thread: Option<tokio::task::JoinHandle<()>>,
106}
107
108impl App {
109    pub fn new() -> crate::Result<Self> {
110        let config = Config::load()?;
111        let theme_name = ThemeName::from_str(&config.theme_name)?;
112        let theme = Theme::new(theme_name);
113        Ok(Self {
114            context: vec![Page::MainMenu(MainMenuPage::new(config.developer_mode)?)],
115            preview_page: None,
116
117            exit: false,
118            // fatal_error: None,
119            fatal_error_popup: TextPopup::new("Fatal Error"),
120            shared_state: SharedState {
121                asset_manager: Arc::new(RwLock::new(AssetManager::default())),
122                recent_addresses: None,
123                current_account: config.current_account,
124                developer_mode: config.developer_mode,
125                alchemy_api_key_available: config.alchemy_api_key.is_some(),
126                online: None,
127                eth_price: None,
128                testnet_mode: config.testnet_mode,
129                theme,
130            },
131
132            invite_popup: InvitePopup::default(),
133
134            input_thread: None,
135            eth_price_thread: None,
136            assets_thread: None,
137            recent_addresses_thread: None,
138            helios_thread: None,
139        })
140    }
141
142    pub fn cli_args(&mut self, args: Vec<String>) -> crate::Result<()> {
143        if args.len() == 1 {
144            // TODO support for wallet connect URI
145            if args[0].ends_with("invite") {
146                self.invite_popup.set_invite_code(&args[0]);
147                self.invite_popup.open();
148            }
149        } else if args.len() > 1 {
150            self.fatal_error_popup.set_text(format!(
151                "Too many arguments provided to gm on the cli: {args:?}. Expected 0 or 1.",
152            ));
153        }
154
155        Ok(())
156    }
157}
158
159impl App {
160    pub fn draw(&self, terminal: &mut DefaultTerminal) -> io::Result<Rect> {
161        let completed_frame = terminal.draw(|frame| {
162            frame.render_widget(self, frame.area());
163        })?;
164        Ok(completed_frame.area)
165    }
166
167    pub fn init_threads(&mut self, tr: &mpsc::Sender<Event>, sd: &Arc<AtomicBool>) {
168        let tr_input = tr.clone();
169        let shutdown_signal = sd.clone();
170        self.input_thread = Some(std::thread::spawn(move || {
171            events::input::watch_input_events(tr_input, shutdown_signal);
172        }));
173
174        let tr_eth_price = tr.clone();
175        let shutdown_signal = sd.clone();
176        self.eth_price_thread = Some(tokio::spawn(async move {
177            events::eth_price::watch_eth_price_change(tr_eth_price, shutdown_signal).await
178        }));
179    }
180
181    fn start_other_threads(&mut self, tr: &mpsc::Sender<Event>, sd: &Arc<AtomicBool>) {
182        if self.assets_thread.is_none() {
183            let tr_assets = tr.clone();
184            let shutdown_signal = sd.clone();
185            self.assets_thread = Some(tokio::spawn(async move {
186                events::assets::watch_assets(tr_assets, shutdown_signal).await
187            }));
188        }
189
190        // TODO enable disabling helios through config
191        if self.helios_thread.is_none() {
192            let tr = tr.clone();
193            let assets_manager = Arc::clone(&self.shared_state.asset_manager);
194            self.helios_thread = Some(tokio::spawn(async move {
195                if let Err(e) = helios_thread(&tr, assets_manager).await {
196                    let _ = tr.send(Event::HeliosError(e.fmt_err("HeliosError")));
197                }
198            }));
199        }
200
201        if self.recent_addresses_thread.is_none() {
202            let tr_recent_addresses = tr.clone();
203            let shutdown_signal = sd.clone();
204            self.recent_addresses_thread = Some(tokio::spawn(async move {
205                events::recent_addresses::watch_recent_addresses(
206                    tr_recent_addresses,
207                    shutdown_signal,
208                )
209                .await
210            }));
211        }
212    }
213
214    async fn stop_other_threads(&mut self) {
215        if let Some(thread) = self.assets_thread.take() {
216            thread.abort();
217            let _ = thread.await;
218        }
219
220        if let Some(thread) = self.recent_addresses_thread.take() {
221            thread.abort();
222            let _ = thread.await;
223        }
224    }
225
226    fn set_online(&mut self, tr: &mpsc::Sender<Event>, sd: &Arc<AtomicBool>) {
227        self.start_other_threads(tr, sd);
228
229        self.shared_state.online = Some(true);
230    }
231
232    async fn set_offline(&mut self) {
233        self.shared_state.online = Some(false);
234
235        self.stop_other_threads().await;
236    }
237
238    pub async fn exit_threads(&mut self) {
239        if let Some(thread) = self.input_thread.take() {
240            thread.join().unwrap();
241        }
242        if let Some(thread) = self.eth_price_thread.take() {
243            thread.await.unwrap();
244        }
245        if let Some(thread) = self.assets_thread.take() {
246            thread.await.unwrap();
247        }
248
249        for page in &mut self.context {
250            page.exit_threads().await;
251        }
252    }
253
254    pub fn reload(&mut self) -> crate::Result<()> {
255        let config = Config::load()?;
256        self.shared_state.testnet_mode = config.testnet_mode;
257        self.shared_state.alchemy_api_key_available = config.alchemy_api_key.is_some();
258        self.shared_state.current_account = config.current_account;
259        self.shared_state.developer_mode = config.developer_mode;
260        let theme_name = ThemeName::from_str(&config.theme_name)?;
261        let theme = Theme::new(theme_name);
262        self.shared_state.theme = theme;
263        for page in &mut self.context {
264            page.reload(&self.shared_state)?;
265        }
266
267        Ok(())
268    }
269
270    async fn process_result(
271        &mut self,
272        result: crate::Result<HandleResult>,
273    ) -> crate::Result<usize> {
274        let result = match result {
275            Ok(res) => res,
276            Err(error) => {
277                self.fatal_error_popup.set_text(format!("{error:#?}"));
278                return Err(error);
279            }
280        };
281        for _ in 0..result.page_pops {
282            self.context.pop();
283        }
284        if result.reload {
285            self.reload()?;
286            if let Some(page) = self.context.last_mut() {
287                page.reload(&self.shared_state)?;
288            }
289        }
290        if result.refresh_assets {
291            // TODO restart the assets thread to avoid the delay
292            if let Some(account) = Config::current_account()? {
293                self.shared_state.assets_mut()?.clear_data_for(account);
294            }
295        }
296        self.context.extend(result.page_inserts);
297        Ok(result.esc_ignores)
298    }
299
300    pub async fn handle_event(
301        &mut self,
302        event: super::events::Event,
303        area: Rect,
304        tr: &mpsc::Sender<Event>,
305        sd: &Arc<AtomicBool>,
306    ) -> crate::Result<()> {
307        let [_, body_area, _] = self.get_areas(area);
308
309        let result = if self.fatal_error_popup.is_shown() {
310            self.fatal_error_popup.handle_event(&event, area)
311        } else if self.invite_popup.is_open() {
312            self.invite_popup.handle_event(&event, tr)
313        } else if self.context.last().is_some() {
314            let page = self.context.last_mut().unwrap();
315            page.handle_event(&event, body_area.block_inner(), tr, sd, &self.shared_state)
316        } else {
317            Ok(HandleResult::default())
318        };
319
320        let esc_ignores = self.process_result(result).await?;
321
322        if self.context.is_empty() {
323            self.exit = true;
324        }
325
326        match event {
327            Event::Input(key_event) => {
328                // check if we should exit on 'q' press
329                if key_event.kind == KeyEventKind::Press {
330                    #[allow(clippy::single_match)]
331                    match key_event.code {
332                        KeyCode::Char(char) => {
333                            // TODO can we quit using q as well?
334                            // if char == 'q' && self.navigation.text_input().is_none() {
335                            //     self.exit = true;
336                            // }
337                            if char == 'c' && key_event.modifiers == KeyModifiers::CONTROL {
338                                self.exit = true;
339                            }
340                            if char == 'r' && key_event.modifiers == KeyModifiers::CONTROL {
341                                self.fatal_error_popup.set_text("test error".to_string());
342                            }
343                            if char == 't' && key_event.modifiers == KeyModifiers::CONTROL {
344                                self.context.push(Page::Trade(TradePage::default()));
345                            }
346                        }
347                        KeyCode::Esc => {
348                            if self.fatal_error_popup.is_shown() {
349                                self.fatal_error_popup.clear();
350                            } else if esc_ignores == 0 {
351                                let page = self.context.pop();
352                                if let Some(mut page) = page {
353                                    page.exit_threads().await;
354                                }
355                                if self.context.is_empty() {
356                                    self.exit = true;
357                                }
358                            }
359                        }
360                        _ => {}
361                    }
362                }
363            }
364
365            Event::AccountChange(address) => {
366                self.shared_state.current_account = Some(address);
367            }
368
369            Event::ConfigUpdate => {
370                self.reload()?;
371            }
372
373            // ETH Price API
374            Event::EthPriceUpdate(eth_price) => {
375                self.shared_state.eth_price = Some(eth_price);
376                self.set_online(tr, sd);
377            }
378            Event::EthPriceError(error) => {
379                if error.is_connect() {
380                    // ETH Price is the main API for understanding if we are connected to internet
381                    self.set_offline().await;
382                } else {
383                    self.fatal_error_popup
384                        .set_text(error.fmt_err("EthPriceError"));
385                }
386            }
387
388            // Assets API
389            Event::AssetsUpdate(wallet_address, assets) => {
390                self.shared_state
391                    .assets_mut()?
392                    .update_assets(wallet_address, assets)?;
393            }
394            Event::AssetsUpdateError(error, silence_error) => {
395                if !silence_error {
396                    self.fatal_error_popup.set_text(error);
397                }
398            }
399
400            Event::HeliosUpdate {
401                account,
402                network,
403                token_address,
404                status,
405            } => {
406                self.shared_state
407                    .asset_manager
408                    .write()
409                    .map_err(|err| format!("poison error - please restart gm - {err}"))?
410                    .update_light_client_verification(account, network, token_address, status);
411            }
412            Event::HeliosError(error) => {
413                self.fatal_error_popup.set_text(error);
414            }
415
416            Event::RecentAddressesUpdate(addresses) => {
417                self.shared_state.recent_addresses = Some(addresses);
418            }
419            Event::RecentAddressesUpdateError(error) => {
420                self.fatal_error_popup.set_text(error);
421            }
422
423            // Candles API
424            Event::CandlesUpdateError(error) => {
425                self.fatal_error_popup
426                    .set_text(error.fmt_err("CandlesUpdateError"));
427            }
428
429            // Transaction API
430            Event::TxError(error) => self.fatal_error_popup.set_text(error),
431
432            Event::WalletConnectError(_, error) => {
433                self.fatal_error_popup.set_text(error);
434            }
435
436            _ => {}
437        };
438
439        Ok(())
440    }
441
442    fn current_page(&self) -> Option<&Page> {
443        self.context.last()
444    }
445
446    // TODO using this triggers rust borrow checks, as we are not able to do
447    // immutable borrows once self is borrowed mutably
448    #[allow(dead_code)]
449    fn current_page_mut(&mut self) -> Option<&mut Page> {
450        self.context.last_mut()
451    }
452
453    fn get_areas(&self, area: Rect) -> [Rect; 3] {
454        let [title_area, body_area, footer_area] = Layout::vertical([
455            Constraint::Length(1),
456            Constraint::Min(3),
457            Constraint::Length(1),
458        ])
459        .areas(area);
460        [title_area, body_area, footer_area]
461    }
462}
463
464impl Widget for &App {
465    fn render(self, area: Rect, buf: &mut Buffer)
466    where
467        Self: Sized,
468    {
469        let [title_area, body_area, footer_area] = self.get_areas(area);
470
471        Title.render_component(title_area, buf, &self.shared_state);
472
473        if let Some(page) = self.current_page() {
474            // Render Body
475            if page.is_main_menu() && page.main_menu_focused_item().is_some() {
476                let main_menu_item = page.main_menu_focused_item().unwrap();
477                let [left_area, gap_area, right_area] = Layout::horizontal([
478                    Constraint::Length(13),
479                    Constraint::Length(1),
480                    Constraint::Min(2),
481                ])
482                .areas(body_area.block_inner());
483
484                // Render border of the canvas
485                Block::bordered()
486                    .border_type(self.shared_state.theme.border_type)
487                    .render(body_area, buf);
488
489                // Render the Middle stick
490                let height_inner = gap_area.height;
491                let mut gap_area = gap_area.expand_vertical(1);
492                "┬".render(gap_area, buf);
493                for _ in 0..height_inner {
494                    gap_area = gap_area.consume_height(1).expect("should not fail");
495                    "│".render(gap_area, buf);
496                }
497                gap_area = gap_area.consume_height(1).expect("should not fail");
498                "┴".render(gap_area, buf);
499
500                // Render Main Menu on the Left side
501                page.render_component(left_area, buf, &self.shared_state);
502
503                // Render the preview of selection on the Right side
504                let dummy_page = match main_menu_item {
505                    MainMenuItem::Portfolio => {
506                        let mut preview_page = main_menu_item
507                            .get_page()
508                            .expect("main_menu_item.get_page() failed");
509                        preview_page.set_focus(false);
510                        preview_page
511                    }
512                    MainMenuItem::Config => Page::Config(ConfigPage {
513                        form: Form::init(|form| {
514                            form.show_everything_empty(true);
515                            Ok(())
516                        })
517                        .unwrap(),
518                    }),
519                    MainMenuItem::CompleteSetup => Page::Text(TextPage::new(
520                        "Setup some of the essential stuff to get the most out of gm".to_string(),
521                    )),
522                    MainMenuItem::Accounts => {
523                        Page::Text(TextPage::new("Create or load accounts".to_string()))
524                    }
525                    MainMenuItem::AddressBook => {
526                        Page::Text(TextPage::new("Manage familiar addresses".to_string()))
527                    }
528                    MainMenuItem::Networks => {
529                        Page::Text(TextPage::new("Manage networks and tokens".to_string()))
530                    }
531                    MainMenuItem::WalletConnect => {
532                        let mut preview_page = main_menu_item
533                            .get_page()
534                            .expect("main_menu_item.get_page() failed");
535                        preview_page.set_focus(false);
536                        preview_page
537                    }
538                    MainMenuItem::SignMessage => Page::Text(TextPage::new(
539                        "Sign a message and prove ownership to somebody".to_string(),
540                    )),
541                    MainMenuItem::SendMessage => {
542                        Page::Text(TextPage::new("Send onchain message to someone".to_string()))
543                    }
544                    MainMenuItem::DevKeyInput => Page::DevKeyCapture(DevKeyCapturePage::default()),
545                };
546
547                dummy_page.render_component(right_area, buf, &self.shared_state);
548            } else {
549                page.render_component_with_block(
550                    body_area,
551                    buf,
552                    Block::bordered().border_type(self.shared_state.theme.border_type),
553                    &self.shared_state,
554                );
555            }
556
557            Footer {
558                exit: &self.exit,
559                is_main_menu: &page.is_main_menu(),
560            }
561            .render(footer_area, buf, &self.shared_state.theme);
562        }
563
564        self.invite_popup.render(area, buf, &self.shared_state);
565
566        self.fatal_error_popup
567            .render(area, buf, &self.shared_state.theme.error_popup());
568    }
569}