use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; use color_eyre::Result; use ratatui::{ layout::{Alignment, Constraint, Flex, Layout, Position, Rect}, text::Text, widgets::{Block, Cell, Clear, Paragraph, Row, Table, TableState}, Frame, }; use subxt::ext::sp_core::crypto::{ ByteArray, Ss58Codec, Ss58AddressFormat, AccountId32, }; use tokio::sync::mpsc::UnboundedSender; use std::sync::mpsc::Sender; use super::{Component, PartialComponent, CurrentTab}; use crate::{ action::Action, config::Config, palette::StylePalette, types::{ActionLevel, ActionTarget, RewardDestination}, widgets::{Input, InputRequest}, }; #[derive(Debug)] pub struct PayeePopup { is_active: bool, action_tx: Option>, network_tx: Option>, table_state: TableState, account_secret_seed: [u8; 32], account_id: [u8; 32], proposed_account_id: Option<[u8; 32]>, is_bonded: bool, is_account_chosen: bool, is_input_active: bool, address: Input, possible_payee_options: &'static [(&'static str, &'static str)], current_reward_destination: RewardDestination, palette: StylePalette } impl Default for PayeePopup { fn default() -> Self { Self::new() } } impl PayeePopup { pub fn new() -> Self { Self { is_active: false, account_secret_seed: [0u8; 32], account_id: [0u8; 32], proposed_account_id: None, action_tx: None, network_tx: None, table_state: TableState::new(), is_bonded: false, is_account_chosen: false, is_input_active: false, address: Input::new(String::new()), current_reward_destination: Default::default(), possible_payee_options: &[ ("Re-stake", "(pay into the stash account, increasing the amount at stake accordingly)"), ("Stake", "(pay into the stash account, not increasing the amount at stake)"), ("Account", "(pay into a specified account different from stash)"), ("None", "(refuse to receive all rewards from staking)"), ], palette: StylePalette::default(), } } fn close_popup(&mut self) { self.is_active = false; if let Some(action_tx) = &self.action_tx { let _ = action_tx.send(Action::ClosePopup); } } fn parse_index_from_destination(&mut self) -> usize { let (index, address) = match self.current_reward_destination { RewardDestination::Staked => (0, Default::default()), RewardDestination::Stash => (1, Default::default()), RewardDestination::Account(account_id) => { let address = AccountId32::from(account_id) .to_ss58check_with_version(Ss58AddressFormat::custom(1996)); (2, address) }, RewardDestination::None => (3, Default::default()), _ => (0, Default::default()), }; self.address = Input::new(address); index } fn move_to_row(&mut self, index: usize) { self.table_state.select(Some(index)); self.is_account_chosen = index == 2; } fn next_row(&mut self) { let i = match self.table_state.selected() { Some(i) => { if i >= self.possible_payee_options.len() - 1 { i } else { i + 1 } }, None => 0, }; self.move_to_row(i); } fn previous_row(&mut self) { let i = match self.table_state.selected() { Some(i) => { if i == 0 { 0 } else { i - 1 } }, None => 0 }; self.move_to_row(i); } fn log_event(&mut self, message: String, level: ActionLevel) { if let Some(action_tx) = &self.action_tx { let _ = action_tx.send(Action::EventLog( message, level, ActionTarget::WalletLog)); } } fn update_used_account(&mut self, account_id: [u8; 32], secret_seed_str: String) { let secret_seed: [u8; 32] = hex::decode(secret_seed_str) .expect("stored seed is valid hex string; qed") .as_slice() .try_into() .expect("stored seed is valid length; qed"); self.account_id = account_id; self.account_secret_seed = secret_seed; } fn trigger_address_input(&mut self) { self.is_input_active = !self.is_input_active; } fn submit_new_input(&mut self) { match AccountId32::from_ss58check_with_version(self.address.value()) { Ok((account_id, format)) => { if format != Ss58AddressFormat::custom(1996) { self.log_event( format!("provided public address for {} is not part of Casper/Ghost ecosystem", self.address.value()), ActionLevel::Error); } let seed_vec = account_id.to_raw_vec(); let mut account_id = [0u8; 32]; account_id.copy_from_slice(&seed_vec); self.proposed_account_id = Some(account_id); self.submit_new_payee(); }, _ => { self.log_event( format!("could not create valid account id from {}", self.address.value()), ActionLevel::Error); self.proposed_account_id = None; } }; } fn submit_new_payee(&mut self) { if let Some(index) = self.table_state.selected() { let new_destination = match index { 0 => RewardDestination::Staked, 1 => RewardDestination::Stash, 2 => { let account_id = self.proposed_account_id .expect("checked before in submit_new_input; qed"); RewardDestination::Account(account_id) } 3 => RewardDestination::None, _ => RewardDestination::Staked, }; if !self.is_bonded { self.log_event( "no bond detected, stake minimum bond amount first".to_string(), ActionLevel::Warn); } else if new_destination == self.current_reward_destination { self.log_event( "same destination choosen, no need for transaction".to_string(), ActionLevel::Warn); } else { if let Some(network_tx) = &self.network_tx { let _ = network_tx.send(Action::SetPayee( self.account_secret_seed, new_destination, ActionTarget::WalletLog)); } } if let Some(action_tx) = &self.action_tx { let _ = action_tx.send(Action::ClosePopup); } } } fn enter_char(&mut self, new_char: char) { let _ = self.address.handle(InputRequest::InsertChar(new_char)); } fn delete_char(&mut self) { let _ = self.address.handle(InputRequest::DeletePrevChar); } fn move_cursor_right(&mut self) { let _ = self.address.handle(InputRequest::GoToNextChar); } fn move_cursor_left(&mut self) { let _ = self.address.handle(InputRequest::GoToPrevChar); } } impl PartialComponent for PayeePopup { fn set_active(&mut self, current_tab: CurrentTab) { match current_tab { CurrentTab::PayeePopup => self.is_active = true, _ => { self.is_active = false; self.is_account_chosen = false; self.is_input_active = false; let index = self.parse_index_from_destination(); self.move_to_row(index); } }; } } impl Component for PayeePopup { fn register_network_handler(&mut self, tx: Sender) -> Result<()> { self.network_tx = Some(tx); Ok(()) } fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { self.action_tx = Some(tx); Ok(()) } fn register_config_handler(&mut self, config: Config) -> Result<()> { if let Some(style) = config.styles.get(&crate::app::Mode::Wallet) { self.palette.with_normal_style(style.get("normal_style").copied()); self.palette.with_normal_border_style(style.get("normal_border_style").copied()); self.palette.with_normal_title_style(style.get("normal_title_style").copied()); self.palette.with_popup_style(style.get("popup_style").copied()); self.palette.with_popup_title_style(style.get("popup_title_style").copied()); self.palette.with_highlight_style(style.get("highlight_style").copied()); } Ok(()) } fn handle_key_event(&mut self, key: KeyEvent) -> Result> { if self.is_active && key.kind == KeyEventKind::Press { if self.is_input_active { match key.code { KeyCode::Enter => self.submit_new_input(), KeyCode::Char(to_insert) => self.enter_char(to_insert), KeyCode::Backspace => self.delete_char(), KeyCode::Left => self.move_cursor_left(), KeyCode::Right => self.move_cursor_right(), KeyCode::Esc => self.trigger_address_input(), _ => {}, }; } else { match key.code { KeyCode::Enter if !self.is_account_chosen => self.submit_new_payee(), KeyCode::Enter if self.is_account_chosen => self.trigger_address_input(), KeyCode::Up | KeyCode::Char('k') => self.previous_row(), KeyCode::Down | KeyCode::Char('j') => self.next_row(), KeyCode::Esc => self.close_popup(), _ => {}, }; } } Ok(None) } fn update(&mut self, action: Action) -> Result> { match action { Action::UsedAccount(account_id, secret_seed) => self.update_used_account(account_id, secret_seed), Action::SetIsBonded(is_bonded, account_id) if self.account_id == account_id => self.is_bonded = is_bonded, Action::SetStakingPayee(reward_destination, account_id) if self.account_id == account_id => { let destination_changed = self.current_reward_destination != reward_destination; self.current_reward_destination = reward_destination; if destination_changed || self.table_state.selected().is_none() { let index = self.parse_index_from_destination(); self.move_to_row(index); } } _ => {} }; Ok(None) } fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { if self.is_active { let (border_style, border_type) = self.palette.create_popup_style(); let size = area.as_size(); let input_area = Rect::new(size.width / 2, size.height / 2, 51, 3); let table = Table::new( self.possible_payee_options .iter() .map(|data| { Row::new(vec![ Cell::from(Text::from(data.0.to_string()).alignment(Alignment::Left)), Cell::from(Text::from(data.1.to_string()).alignment(Alignment::Left)), ]) }) .collect::>(), [Constraint::Length(8), Constraint::Min(0)] ) .highlight_style(self.palette.create_highlight_style()) .column_spacing(1) .block(Block::bordered() .border_style(border_style) .border_type(border_type) .title_style(self.palette.create_popup_title_style()) .title_alignment(Alignment::Right) .title("Select reward destination")); let v = Layout::vertical([Constraint::Max(6)]).flex(Flex::Center); let h = Layout::horizontal([Constraint::Max(83)]).flex(Flex::Center); let [area] = v.areas(area); let [area] = h.areas(area); frame.render_widget(Clear, area); frame.render_stateful_widget(table, area, &mut self.table_state); if self.is_input_active { let input_amount = Paragraph::new(self.address.value()) .block(Block::bordered() .border_style(border_style) .border_type(border_type) .title_style(self.palette.create_popup_title_style()) .title_alignment(Alignment::Right) .title("Destination account")); let v = Layout::vertical([Constraint::Max(8)]).flex(Flex::Center); let h = Layout::horizontal([Constraint::Max(51)]).flex(Flex::Center); let [input_area] = v.areas(input_area); let [input_area] = h.areas(input_area); frame.render_widget(Clear, input_area); frame.render_widget(input_amount, input_area); frame.set_cursor_position(Position::new( input_area.x + self.address.cursor() as u16 + 1, input_area.y + 1 )); } } Ok(()) } }