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#[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(¤t_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_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 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 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 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 if key_event.kind == KeyEventKind::Press {
330 #[allow(clippy::single_match)]
331 match key_event.code {
332 KeyCode::Char(char) => {
333 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 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 self.set_offline().await;
382 } else {
383 self.fatal_error_popup
384 .set_text(error.fmt_err("EthPriceError"));
385 }
386 }
387
388 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 Event::CandlesUpdateError(error) => {
425 self.fatal_error_popup
426 .set_text(error.fmt_err("CandlesUpdateError"));
427 }
428
429 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 #[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 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 Block::bordered()
486 .border_type(self.shared_state.theme.border_type)
487 .render(body_area, buf);
488
489 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 page.render_component(left_area, buf, &self.shared_state);
502
503 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}