diff --git a/Cargo.toml b/Cargo.toml index 53ff195..68393d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ tokio-util = "0.7.12" tracing = "0.1.37" tracing-error = "0.2.0" tracing-subscriber = { version = "0.3.17", features = ["env-filter", "serde"] } +unicode-width = "0.2.0" [build-dependencies] anyhow = "1.0.91" diff --git a/src/action.rs b/src/action.rs index d58d5e9..4ec55ba 100644 --- a/src/action.rs +++ b/src/action.rs @@ -27,6 +27,7 @@ pub enum Action { UsedExplorerBlock(Option), UsedExplorerLog(Option), UsedAccount(AccountId32), + NewAccount(String), NewBestBlock(u32), NewBestHash(H256), diff --git a/src/components/explorer/block_explorer.rs b/src/components/explorer/block_explorer.rs index 8ebc897..690762a 100644 --- a/src/components/explorer/block_explorer.rs +++ b/src/components/explorer/block_explorer.rs @@ -212,8 +212,8 @@ impl Component for BlockExplorer { match key.code { KeyCode::Char('k') | KeyCode::Up if self.is_active => self.previous_row(), KeyCode::Char('j') | KeyCode::Down if self.is_active => self.next_row(), - KeyCode::Char('K') if self.is_active => self.first_row(), - KeyCode::Char('J') if self.is_active => self.last_row(), + KeyCode::Char('g') if self.is_active => self.first_row(), + KeyCode::Char('G') if self.is_active => self.last_row(), _ => {}, }; diff --git a/src/components/explorer/explorer_blocks.rs b/src/components/explorer/explorer_blocks.rs deleted file mode 100644 index 9ed8d52..0000000 --- a/src/components/explorer/explorer_blocks.rs +++ /dev/null @@ -1,510 +0,0 @@ -use std::collections::{HashMap, VecDeque}; -use color_eyre::Result; -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - layout::{Alignment, Rect}, - prelude::*, - text::Line, - widgets::{Block, BorderType, Paragraph}, - Frame -}; -use subxt::ext::sp_core::crypto::{Ss58Codec, Ss58AddressFormat}; -use subxt::utils::H256; -use codec::Decode; - -use super::Component; -use crate::{ - types::CasperExtrinsicDetails, CasperAccountId, - config::Config, action::Action, app::Mode, palette::StylePalette, -}; - -struct BlockInfo { - block_number: u32, - finalized: bool, -} - -#[derive(Default)] -pub struct ExplorerBlocks { - blocks: VecDeque, - block_headers: HashMap, - authors: HashMap, - extrinsics: HashMap>, - palette: StylePalette, - current_block_digit_length: u32, - - is_active: bool, - used_paragraph_index: usize, - used_block_number: Option, - used_ext_index: Option<(H256, usize)>, -} - -impl ExplorerBlocks { - const MAX_BLOCKS: usize = 50; - const LENGTH_OF_BLOCK_HASH: u16 = 13; - const LENGTH_OF_ADDRESS: u16 = 49; - const TOTAL_OFFSETS: u16 = 18; - - fn update_block_author( - &mut self, - hash: H256, - maybe_author: Option, - ) -> Result<()> { - if let Some(author) = maybe_author { - self.authors.insert(hash, author); - } - Ok(()) - } - - fn update_latest_block_info( - &mut self, - hash: H256, - block_number: u32, - extrinsics: Vec, - ) -> Result<()> { - let front_block_number = match self.blocks.front() { - Some(block_info) => block_info.block_number, - None => 0, - }; - - if front_block_number < block_number { - self.blocks.push_front(BlockInfo { - block_number, - finalized: false, - }); - - self.extrinsics.insert(hash, extrinsics); - self.block_headers.insert(block_number, hash); - - let block_length = block_number.checked_ilog10().unwrap_or(0) + 1; - if self.current_block_digit_length < block_length { - self.current_block_digit_length = block_length; - } - - if self.blocks.len() > Self::MAX_BLOCKS { - if let Some(block) = self.blocks.pop_back() { - if let Some(hash) = self.block_headers.remove(&block.block_number) { - self.extrinsics.remove(&hash); - self.authors.remove(&hash); - } - } - } - } - Ok(()) - } - - fn update_finalized_block_info( - &mut self, - _hash: H256, - block_number: u32, - _extrinsics: Vec, - ) -> Result<()> { - for idx in 0..self.blocks.len() { - if self.blocks[idx].finalized { break; } - else if self.blocks[idx].block_number > block_number { continue; } - else { self.blocks[idx].finalized = true; } - } - - Ok(()) - } - - fn prepare_block_line_info(&self, current_block: &BlockInfo, width: u16) -> Line { - let block_number_length = self - .current_block_digit_length - .max(current_block.block_number.checked_ilog10().unwrap_or(0) + 1) as usize; - - let free_space = width - .saturating_sub(block_number_length as u16) - .saturating_sub(Self::TOTAL_OFFSETS); - - let default_hash = H256::repeat_byte(69u8); - let hash = self - .block_headers - .get(¤t_block.block_number) - .unwrap_or(&default_hash); - - let author = self - .authors - .get(&hash) - .map_or(String::from("..."), |author| { - let extended_author = CasperAccountId::decode(&mut author.as_ref()) - .expect("author should be valid AccountId32; qed"); - let account_id = subxt::ext::sp_core::crypto::AccountId32::from(extended_author.0); - account_id.to_ss58check_with_version(Ss58AddressFormat::custom(1996)) - }); - - if free_space < Self::LENGTH_OF_BLOCK_HASH + Self::LENGTH_OF_ADDRESS { - let len_for_author = free_space * Self::LENGTH_OF_ADDRESS / (Self::LENGTH_OF_BLOCK_HASH + Self::LENGTH_OF_ADDRESS); - - if &author == "..." { - Line::raw(format!("{:^left$}| {} | {:^right$}", - current_block.block_number, - hash.to_string(), - author, - left=block_number_length, - right=(len_for_author + 2) as usize)) - } else { - Line::raw(format!("{} | {} | {}", - current_block.block_number, - hash.to_string(), - format!("{}...", &author[..(len_for_author) as usize]))) - } - } else { - let total_space_used = block_number_length as u16 + Self::LENGTH_OF_BLOCK_HASH + Self::LENGTH_OF_ADDRESS; - let str_length = width.saturating_sub(2).saturating_sub(total_space_used) as usize / 3; - Line::raw(format!("{:^length_block_number$}|{:^length_hash$}|{:^length_author$}", - current_block.block_number, hash.to_string(), author, - length_block_number=str_length+block_number_length, - length_hash=str_length+(Self::LENGTH_OF_BLOCK_HASH as usize), - length_author=str_length+(Self::LENGTH_OF_ADDRESS as usize))) - } - } - - fn prepare_block_lines(&mut self, rect: Rect) -> Vec { - let width = rect.as_size().width; - let total_length = rect.as_size().height as usize - 2; - let mut items = Vec::new(); - - let start_index = match self.used_block_number { - Some(used_block) if total_length < self.blocks.len() => { - self.blocks - .iter() - .position(|info| info.block_number == used_block) - .unwrap_or_default() - .saturating_add(1) - .saturating_sub(total_length) - }, - _ => 0, - }; - - let normal_style = self.palette.create_text_style(false); - let active_style = self.palette.create_text_style(true); - let finalized_style = self.palette.create_highlight_style(); - - for (idx, current_block_info) in self.blocks.iter().skip(start_index).enumerate() { - if idx == total_length { break; } - - let style = match self.used_block_number { - Some(used_block) if current_block_info.block_number == used_block => active_style, - _ => if current_block_info.finalized { finalized_style } else { normal_style } - }; - - items.push(self.prepare_block_line_info(¤t_block_info, width).style(style)); - } - - items - } - - fn prepare_ext_line_info( - &self, - index: usize, - width: u16, - pallet_name: &str, - variant_name: &str, - hash: &str, - ) -> Line { - let index_length = 4; // always 4, two digits and two spaces - let hash_length = Self::LENGTH_OF_BLOCK_HASH as usize + 2; - let pallet_name_length = pallet_name.len(); - let variant_name_length = variant_name.len(); - - let offset_variant = (width as usize) - .saturating_sub(index_length) - .saturating_sub(pallet_name_length) - .saturating_sub(variant_name_length) - .saturating_sub(hash_length) - .saturating_sub(2) / 2; - - let offset_pallet = if offset_variant % 2 == 0 { - offset_variant - } else { - offset_variant + 1 - }; - - Line::from(format!("{:^index_length$}{:>pallet_name_offset$}::{: Vec { - let width = rect.as_size().width; - let mut total_length = rect.as_size().height - 2; - let mut items = Vec::new(); - - if let Some(used_block_number) = self.used_block_number { - let default_hash = H256::repeat_byte(69u8); - let hash = self.block_headers - .get(&used_block_number) - .unwrap_or(&default_hash); - - let normal_style = self.palette.create_text_style(false); - let active_style = self.palette.create_text_style(true); - - if let Some(exts) = self.extrinsics.get(&hash) { - for (index, ext) in exts.iter().enumerate() { - if total_length == 0 { break; } - - let style = if let Some((_, used_ext_index)) = self.used_ext_index { - if index == used_ext_index { active_style } else { normal_style } - } else { normal_style }; - - items.push(self.prepare_ext_line_info( - index, - width.saturating_sub(2), - &ext.pallet_name, - &ext.variant_name, - &ext.hash.to_string()).style(style)); - total_length -= 1; - } - } - } - - items - } - - fn prepare_event_lines(&mut self, rect: Rect) -> Line { - let _width = rect.as_size().width; - - match self.used_ext_index { - Some((header, used_index)) if self.extrinsics.get(&header).is_some() => { - let exts = self.extrinsics - .get(&header) - .expect("extrinsics should exists, checked before"); - - let details = exts - .get(used_index) - .map_or(Vec::new(), |ext| ext.field_bytes.clone()); - - Line::from(format!("{}", hex::encode(&details))) - }, - _ => Line::from(""), - }.style(self.palette.create_text_style(false)) - } - - fn move_right(&mut self) { - let new_index = self.used_paragraph_index + 1; - if new_index < 2 { - self.used_paragraph_index = new_index; - } - } - - fn move_left(&mut self) { - self.used_paragraph_index = self - .used_paragraph_index - .saturating_sub(1); - self.used_ext_index = None; - } - - fn move_down(&mut self) { - if self.used_paragraph_index == 0 { - self.move_down_blocks(); - } else { - self.move_down_extrinsics(); - } - } - - fn move_up(&mut self) { - if self.used_paragraph_index == 0 { - self.move_up_blocks(); - } else { - self.move_up_extrinsics(); - } - } - - fn move_up_extrinsics(&mut self) { - match &self.used_ext_index { - Some((header, used_index)) => { - let new_index = used_index.saturating_sub(1); - if let Some(exts) = self.extrinsics.get(header) { - if exts.get(new_index).is_some() { - self.used_ext_index = Some((*header, new_index)); - } - } - }, - None => { - self.used_ext_index = self.used_block_number - .map(|block_number| { - let header = self.block_headers - .get(&block_number) - .expect("header exists for each block number; qed"); - self.extrinsics.get(&header).map(|_| (*header, 0usize)) - }) - .flatten() - } - } - } - - fn move_up_blocks(&mut self) { - self.used_block_number = match &self.used_block_number { - Some(block_number) => { - Some(self.blocks - .iter() - .find(|info| info.block_number == block_number + 1) - .map(|info| info.block_number) - .unwrap_or(*block_number)) - }, - None => self.blocks.front().map(|info| info.block_number), - } - } - - fn move_down_extrinsics(&mut self) { - match &self.used_ext_index { - Some((header, used_index)) => { - let new_index = used_index + 1; - if let Some(exts) = self.extrinsics.get(&header) { - if new_index < exts.len() && exts.get(new_index).is_some() { - self.used_ext_index = Some((*header, new_index)); - } - } - }, - None => { - self.used_ext_index = self.used_block_number - .map(|block_number| { - let header = self.block_headers - .get(&block_number) - .expect("header exists for each block number; qed"); - self.extrinsics.get(&header).map(|_| (*header, 0usize)) - }) - .flatten() - } - } - } - - fn move_down_blocks(&mut self) { - self.used_block_number = match &self.used_block_number { - Some(block_number) => { - Some(self.blocks - .iter() - .find(|info| info.block_number == block_number.saturating_sub(1)) - .map(|info| info.block_number) - .unwrap_or(*block_number)) - }, - None => { - self.blocks.front().map(|info| info.block_number) - } - } - } - - fn set_active(&mut self) -> Result<()> { - self.is_active = true; - Ok(()) - } - - fn unset_active(&mut self) -> Result<()> { - self.is_active = false; - Ok(()) - } - - fn prepare_blocks_paragraph( - &mut self, - place: Rect, - border_style: Color, - border_type: BorderType, - ) -> Paragraph { - let title_style = self - .palette - .create_title_style(self.is_active && self.used_paragraph_index == 0); - Paragraph::new(self.prepare_block_lines(place)) - .block(Block::bordered() - .border_style(border_style) - .border_type(border_type) - .title_alignment(Alignment::Right) - .title_style(title_style) - .title("Blocks")) - .alignment(Alignment::Center) - } - - fn prepare_extrinsics_paragraph( - &mut self, - place: Rect, - border_style: Color, - border_type: BorderType, - ) -> Paragraph { - let title_style = self - .palette - .create_title_style(self.is_active && self.used_paragraph_index == 1); - Paragraph::new(self.prepare_ext_lines(place)) - .block(Block::bordered() - .border_style(border_style) - .border_type(border_type) - .title_alignment(Alignment::Right) - .title_style(title_style) - .title("Transactions")) - .alignment(Alignment::Center) - } - - fn prepare_event_paragraph( - &mut self, - place: Rect, - border_style: Color, - border_type: BorderType, - ) -> Paragraph { - let title_style = self.palette.create_title_style(false); - Paragraph::new(self.prepare_event_lines(place)) - .block(Block::bordered() - .border_style(border_style) - .border_type(border_type) - .title_alignment(Alignment::Right) - .title_style(title_style) - .title("Events")) - .alignment(Alignment::Center) - } -} - -impl Component for ExplorerBlocks { - fn register_config_handler(&mut self, config: Config) -> Result<()> { - if let Some(style) = config.styles.get(&crate::app::Mode::Explorer) { - self.palette.with_normal_style(style.get("normal_style").copied()); - self.palette.with_hover_style(style.get("hover_style").copied()); - self.palette.with_normal_border_style(style.get("normal_border_style").copied()); - self.palette.with_hover_border_style(style.get("hover_border_style").copied()); - self.palette.with_normal_title_style(style.get("normal_title_style").copied()); - self.palette.with_hover_title_style(style.get("hover_title_style").copied()); - self.palette.with_highlight_style(style.get("highlight_style").copied()); - } - Ok(()) - } - - fn handle_key_event(&mut self, key: KeyEvent) -> Result> { - match key.code { - KeyCode::Char('k') | KeyCode::Up if self.is_active => self.move_up(), - KeyCode::Char('j') | KeyCode::Down if self.is_active => self.move_down(), - KeyCode::Char('l') | KeyCode::Right if self.is_active => self.move_right(), - KeyCode::Char('h') | KeyCode::Left if self.is_active => self.move_left(), - KeyCode::Esc => { - self.used_block_number = None; - self.used_ext_index = None; - self.used_paragraph_index = 0; - }, - _ => {}, - }; - - Ok(None) - } - - fn update(&mut self, action: Action) -> Result> { - match action { - Action::BestBlockInformation(hash, block_number, extrinsics) => self.update_latest_block_info(hash, block_number, extrinsics)?, - Action::FinalizedBlockInformation(hash, block_number, extrinsics) => self.update_finalized_block_info(hash, block_number, extrinsics)?, - Action::SetBlockAuthor(hash, maybe_author) => self.update_block_author(hash, maybe_author)?, - Action::SetMode(Mode::ExplorerActive) if !self.is_active => self.set_active()?, - Action::SetMode(_) if self.is_active => self.unset_active()?, - _ => {} - }; - Ok(None) - } - - fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { - let [blocks_place, ext_place] = super::explorer_scrollbars_layout(area); - let [_, _, event_place] = super::explorer_layout(area); - - let (border_style_block, border_type_block) = self.palette.create_border_style(self.is_active && self.used_paragraph_index == 0); - let (border_style_extrinsics, border_type_extrinsics) = self.palette.create_border_style(self.is_active && self.used_paragraph_index == 1); - let (border_style_event, border_type_event) = self.palette.create_border_style(self.is_active && self.used_paragraph_index == 2); - - frame.render_widget(self.prepare_blocks_paragraph(blocks_place, border_style_block, border_type_block), blocks_place); - frame.render_widget(self.prepare_extrinsics_paragraph(ext_place, border_style_extrinsics, border_type_extrinsics), ext_place); - frame.render_widget(self.prepare_event_paragraph(event_place, border_style_event, border_type_event), event_place); - Ok(()) - } -} diff --git a/src/components/explorer/extrinsic_explorer.rs b/src/components/explorer/extrinsic_explorer.rs index 5b6b225..06bfa7a 100644 --- a/src/components/explorer/extrinsic_explorer.rs +++ b/src/components/explorer/extrinsic_explorer.rs @@ -208,8 +208,8 @@ impl Component for ExtrinsicExplorer { match key.code { KeyCode::Char('k') | KeyCode::Up if self.is_active => self.previous_row(), KeyCode::Char('j') | KeyCode::Down if self.is_active => self.next_row(), - KeyCode::Char('K') if self.is_active => self.first_row(), - KeyCode::Char('J') if self.is_active => self.last_row(), + KeyCode::Char('g') if self.is_active => self.first_row(), + KeyCode::Char('G') if self.is_active => self.last_row(), _ => {}, }; diff --git a/src/components/wallet/accounts.rs b/src/components/wallet/accounts.rs index 341540c..f3451f8 100644 --- a/src/components/wallet/accounts.rs +++ b/src/components/wallet/accounts.rs @@ -34,11 +34,12 @@ use crate::{ pub struct Accounts { is_active: bool, + wallet_keys_file: PathBuf, action_tx: Option>, palette: StylePalette, scroll_state: ScrollbarState, table_state: TableState, - wallet_keys: Vec<(String, String, PairSigner)>, + wallet_keys: Vec<(String, String, String, PairSigner)>, } impl Default for Accounts { @@ -51,6 +52,7 @@ impl Accounts { pub fn new() -> Self { Self { is_active: false, + wallet_keys_file: Default::default(), action_tx: None, scroll_state: ScrollbarState::new(0), table_state: TableState::new(), @@ -59,6 +61,56 @@ impl Accounts { } } + fn create_new_account(&mut self, name: String) { + let (pair, seed) = Pair::generate(); + let secret_seed = hex::encode(seed); + let pair_signer = PairSigner::::new(pair); + let address = AccountId32::from(seed.clone()) + .to_ss58check_with_version(Ss58AddressFormat::custom(1996)); + + self.wallet_keys.push((name, address, secret_seed, pair_signer)); + self.save_to_file(); + } + + fn swap_up(&mut self) { + if let Some(src_index) = self.table_state.selected() { + let dst_index = src_index.saturating_sub(1); + if src_index > dst_index { + self.wallet_keys.swap(src_index, dst_index); + self.previous_row(); + self.save_to_file(); + } + } + } + + fn swap_down(&mut self) { + if let Some(src_index) = self.table_state.selected() { + let dst_index = src_index + 1; + if dst_index < self.wallet_keys.len() { + self.wallet_keys.swap(src_index, dst_index); + self.next_row(); + self.save_to_file(); + } + } + } + + fn delete_row(&mut self) { + if let Some(index) = self.table_state.selected() { + if self.wallet_keys.len() > 1 { + let _ = self.wallet_keys.remove(index); + self.previous_row(); + self.save_to_file(); + } + } + } + + fn save_to_file(&mut self) { + let mut file = File::create(&self.wallet_keys_file).unwrap(); + for wallet in self.wallet_keys.iter() { + writeln!(file, "{}:0x{}", wallet.0, &wallet.2).unwrap(); + } + } + fn read_or_create(&mut self, file_path: &PathBuf) -> Result<()> { assert!(self.wallet_keys.len() == 0, "wallet_keys already exists"); match File::open(file_path) { @@ -81,7 +133,7 @@ impl Accounts { let address = AccountId32::from(seed.clone()) .to_ss58check_with_version(Ss58AddressFormat::custom(1996)); - self.wallet_keys.push((wallet_name.to_string(), address, pair_signer)); + self.wallet_keys.push((wallet_name.to_string(), address, wallet_key.to_string(), pair_signer)); } }, Err(_) => { @@ -113,7 +165,7 @@ impl Accounts { let address = AccountId32::from(seed.clone()) .to_ss58check_with_version(Ss58AddressFormat::custom(1996)); - self.wallet_keys.push(("ghostie".to_string(), address, pair_signer)); + self.wallet_keys.push(("ghostie".to_string(), address, secret_seed, pair_signer)); } }; self.table_state.select(Some(0)); @@ -124,7 +176,7 @@ impl Accounts { fn send_wallet_change(&mut self, index: usize) { if let Some(action_tx) = &self.action_tx { - let (_, _, pair) = &self.wallet_keys[index]; + let (_, _, _, pair) = &self.wallet_keys[index]; let _ = action_tx.send(Action::UsedAccount(pair.account_id().clone())); } } @@ -210,11 +262,13 @@ impl Component for Accounts { let mut wallet_keys_file = config.config.data_dir; wallet_keys_file.push("wallet-keys"); self.read_or_create(&wallet_keys_file)?; + self.wallet_keys_file = wallet_keys_file; Ok(()) } fn update(&mut self, action: Action) -> Result> { match action { + Action::NewAccount(name) => self.create_new_account(name), _ => {} }; Ok(None) @@ -224,9 +278,11 @@ impl Component for Accounts { match key.code { KeyCode::Up | KeyCode::Char('k') if self.is_active => self.previous_row(), KeyCode::Down | KeyCode::Char('j') if self.is_active => self.next_row(), - KeyCode::Char('K') if self.is_active => self.first_row(), - KeyCode::Char('J') if self.is_active => self.last_row(), - // TODO: swap on alt+j or G/gg to bottom and up + KeyCode::Char('K') if self.is_active => self.swap_up(), + KeyCode::Char('J') if self.is_active => self.swap_down(), + KeyCode::Char('g') if self.is_active => self.first_row(), + KeyCode::Char('G') if self.is_active => self.last_row(), + KeyCode::Char('D') if self.is_active => self.delete_row(), _ => {}, }; Ok(None) diff --git a/src/components/wallet/add.rs b/src/components/wallet/add.rs deleted file mode 100644 index c312f01..0000000 --- a/src/components/wallet/add.rs +++ /dev/null @@ -1,82 +0,0 @@ -use crossterm::event::{KeyEvent, KeyCode}; -use color_eyre::Result; -use ratatui::{ - layout::{Constraint, Flex, Layout, Rect}, - widgets::{Block, Clear}, - Frame -}; - -use super::{Component, PartialComponent, CurrentTab}; -use crate::{ - action::Action, - config::Config, - palette::StylePalette, -}; - -#[derive(Debug)] -pub struct AddAddress { - is_shown: bool, - palette: StylePalette -} - -impl Default for AddAddress { - fn default() -> Self { - Self::new() - } -} - -impl AddAddress { - pub fn new() -> Self { - Self { - is_shown: false, - palette: StylePalette::default(), - } - } -} - -impl PartialComponent for AddAddress { - fn set_active(&mut self, _current_tab: CurrentTab) {} -} - -impl Component for AddAddress { - fn register_config_handler(&mut self, config: Config) -> Result<()> { - if let Some(style) = config.styles.get(&crate::app::Mode::Explorer) { - self.palette.with_normal_style(style.get("normal_style").copied()); - self.palette.with_hover_style(style.get("hover_style").copied()); - self.palette.with_normal_border_style(style.get("normal_border_style").copied()); - self.palette.with_hover_border_style(style.get("hover_border_style").copied()); - self.palette.with_normal_title_style(style.get("normal_title_style").copied()); - self.palette.with_hover_title_style(style.get("hover_title_style").copied()); - } - Ok(()) - } - - fn handle_key_event(&mut self, key: KeyEvent) -> Result> { - match key.code { - KeyCode::Char('a') => self.is_shown = true, - KeyCode::Char(' ') => self.is_shown = false, - _ => {}, - }; - Ok(None) - } - - fn update(&mut self, action: Action) -> Result> { - match action { - _ => {} - }; - Ok(None) - } - - fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { - if self.is_shown { - let block = Block::bordered().title("Transfer"); - let v = Layout::vertical([Constraint::Min(55)]).flex(Flex::Center); - let h = Layout::horizontal([Constraint::Min(10)]).flex(Flex::Center); - let [area] = v.areas(area); - let [area] = h.areas(area); - frame.render_widget(Clear, area); - frame.render_widget(block, area); - } - Ok(()) - } -} diff --git a/src/components/wallet/add_account.rs b/src/components/wallet/add_account.rs new file mode 100644 index 0000000..05f0d22 --- /dev/null +++ b/src/components/wallet/add_account.rs @@ -0,0 +1,126 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use color_eyre::Result; +use ratatui::{ + layout::{Position, Alignment, Constraint, Flex, Layout, Rect}, + widgets::{Block, Clear, Paragraph}, + Frame +}; +use tokio::sync::mpsc::UnboundedSender; + +use super::{Component, PartialComponent, CurrentTab}; +use crate::{ + widgets::{Input, InputRequest}, + action::Action, + config::Config, + palette::StylePalette, +}; + +#[derive(Debug)] +pub struct AddAccount { + is_active: bool, + action_tx: Option>, + name: Input, + palette: StylePalette +} + +impl Default for AddAccount { + fn default() -> Self { + Self::new() + } +} + +impl AddAccount { + pub fn new() -> Self { + Self { + is_active: false, + action_tx: None, + name: Input::new(String::new()), + palette: StylePalette::default(), + } + } +} + +impl AddAccount { + fn submit_message(&mut self) { + if let Some(action_tx) = &self.action_tx { + let _ = action_tx.send(Action::NewAccount(self.name.value().to_string())); + } + } + + fn enter_char(&mut self, new_char: char) { + let _ = self.name.handle(InputRequest::InsertChar(new_char)); + } + + fn delete_char(&mut self) { + let _ = self.name.handle(InputRequest::DeletePrevChar); + } + + fn move_cursor_right(&mut self) { + let _ = self.name.handle(InputRequest::GoToNextChar); + } + + fn move_cursor_left(&mut self) { + let _ = self.name.handle(InputRequest::GoToPrevChar); + } +} + +impl PartialComponent for AddAccount { + fn set_active(&mut self, current_tab: CurrentTab) { + match current_tab { + CurrentTab::AddAccount => self.is_active = true, + _ => self.is_active = false, + } + } +} + +impl Component for AddAccount { + 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::Explorer) { + 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()); + } + Ok(()) + } + + fn handle_key_event(&mut self, key: KeyEvent) -> Result> { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Enter => self.submit_message(), + 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.is_active = false, + _ => {}, + }; + } + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + if self.is_active { + let input = Paragraph::new(self.name.value()) + .block(Block::bordered() + .title_alignment(Alignment::Right) + .title("New wallet name")); + let v = Layout::vertical([Constraint::Max(3)]).flex(Flex::Center); + let h = Layout::horizontal([Constraint::Max(50)]).flex(Flex::Center); + let [area] = v.areas(area); + let [area] = h.areas(area); + + frame.render_widget(Clear, area); + frame.render_widget(input, area); + frame.set_cursor_position(Position::new( + area.x + self.name.cursor() as u16 + 1, + area.y + 1 + )); + } + Ok(()) + } +} diff --git a/src/components/wallet/address_book.rs b/src/components/wallet/address_book.rs index 0e532c8..e51f3d9 100644 --- a/src/components/wallet/address_book.rs +++ b/src/components/wallet/address_book.rs @@ -23,7 +23,8 @@ use crate::{ pub struct AddressBook { is_active: bool, - address_book: Vec<(String, String, AccountId32)>, + address_book_file: PathBuf, + address_book: Vec<(String, String, AccountId32, String)>, scroll_state: ScrollbarState, table_state: TableState, palette: StylePalette, @@ -39,6 +40,7 @@ impl AddressBook { pub fn new() -> Self { Self { is_active: false, + address_book_file: Default::default(), address_book: Vec::new(), scroll_state: ScrollbarState::new(0), table_state: TableState::new(), @@ -46,6 +48,13 @@ impl AddressBook { } } + fn save_to_file(&mut self) { + let mut file = File::create(&self.address_book_file).unwrap(); + for wallet in self.address_book.iter() { + writeln!(file, "{}:0x{}", wallet.0, &wallet.3).unwrap(); + } + } + fn read_or_create(&mut self, file_path: &PathBuf) -> Result<()> { assert!(self.address_book.len() == 0, "address_book already exists"); match File::open(file_path) { @@ -55,9 +64,9 @@ impl AddressBook { let line = line?.replace("\n", ""); let line_split_at = line.find(":").unwrap_or(line.len()); let (name, seed) = line.split_at(line_split_at); - let seed = &seed[3..]; + let seed_str = &seed[3..]; - let seed: [u8; 32] = hex::decode(seed) + let seed: [u8; 32] = hex::decode(seed_str) .expect("stored seed is valid hex string; qed") .as_slice() .try_into() @@ -66,7 +75,7 @@ impl AddressBook { let account_id = AccountId32::from(seed); let address = AccountId32::from(seed.clone()) .to_ss58check_with_version(Ss58AddressFormat::custom(1996)); - self.address_book.push((name.to_string(), address, account_id)); + self.address_book.push((name.to_string(), address, account_id, seed_str.to_string())); } }, Err(_) => { @@ -97,12 +106,11 @@ impl AddressBook { let chad_account_id = AccountId32::from(chad_account_id); let address = AccountId32::from(chad_account_id.clone()) .to_ss58check_with_version(Ss58AddressFormat::custom(1996)); - self.address_book.push((chad_info.0.to_string(), address, chad_account_id)); + self.address_book.push((chad_info.0.to_string(), address, chad_account_id, chad_info.1.to_string())); }); } }; self.scroll_state = self.scroll_state.content_length(self.address_book.len()); - Ok(()) } @@ -112,6 +120,7 @@ impl AddressBook { if src_index > dst_index { self.address_book.swap(src_index, dst_index); self.previous_row(); + self.save_to_file(); } } } @@ -122,6 +131,7 @@ impl AddressBook { if dst_index < self.address_book.len() { self.address_book.swap(src_index, dst_index); self.next_row(); + self.save_to_file(); } } } @@ -130,6 +140,7 @@ impl AddressBook { if let Some(index) = self.table_state.selected() { let _ = self.address_book.remove(index); self.previous_row(); + self.save_to_file(); } } @@ -208,6 +219,7 @@ impl Component for AddressBook { let mut address_book_file = config.config.data_dir; address_book_file.push("address-book"); self.read_or_create(&address_book_file)?; + self.address_book_file = address_book_file; Ok(()) } @@ -215,12 +227,11 @@ impl Component for AddressBook { match key.code { KeyCode::Up | KeyCode::Char('k') if self.is_active => self.previous_row(), KeyCode::Down | KeyCode::Char('j') if self.is_active => self.next_row(), - // TODO: swap on alt+j or G/gg to bottom and up KeyCode::Char('g') if self.is_active => self.first_row(), KeyCode::Char('G') if self.is_active => self.last_row(), KeyCode::Char('K') if self.is_active => self.swap_up(), KeyCode::Char('J') if self.is_active => self.swap_down(), - KeyCode::Char('d') if self.is_active => self.delete_row(), + KeyCode::Char('D') if self.is_active => self.delete_row(), _ => {}, }; Ok(None) diff --git a/src/components/wallet/event_logs.rs b/src/components/wallet/event_logs.rs index 6bd9598..46eb9ac 100644 --- a/src/components/wallet/event_logs.rs +++ b/src/components/wallet/event_logs.rs @@ -65,7 +65,7 @@ impl Component for EventLogs { .border_type(border_type) .title_alignment(Alignment::Right) .title_style(self.palette.create_title_style(false)) - .title("Logs")) + .title("Latest Logs")) .alignment(Alignment::Center) .wrap(Wrap { trim: true }); diff --git a/src/components/wallet/mod.rs b/src/components/wallet/mod.rs index ba3b521..c4a7325 100644 --- a/src/components/wallet/mod.rs +++ b/src/components/wallet/mod.rs @@ -8,7 +8,7 @@ use ratatui::{ mod balance; mod transfer; mod address_book; -mod add; +mod add_account; mod event_logs; mod accounts; mod overview; @@ -17,7 +17,7 @@ use balance::Balance; use tokio::sync::mpsc::UnboundedSender; use transfer::Transfer; use address_book::AddressBook; -use add::AddAddress; +use add_account::AddAccount; use event_logs::EventLogs; use accounts::Accounts; use overview::Overview; @@ -30,6 +30,7 @@ pub enum CurrentTab { Nothing, Accounts, AddressBook, + AddAccount, } pub trait PartialComponent: Component { @@ -54,7 +55,7 @@ impl Default for Wallet { Box::new(AddressBook::default()), Box::new(EventLogs::default()), Box::new(Transfer::default()), - Box::new(AddAddress::default()), + Box::new(AddAccount::default()), ], } } @@ -99,32 +100,54 @@ impl Component for Wallet { fn handle_key_event(&mut self, key: KeyEvent) -> Result> { if self.is_active { - match key.code { - KeyCode::Esc => { - self.is_active = false; - self.current_tab = CurrentTab::Nothing; - for component in self.components.iter_mut() { - component.set_active(self.current_tab.clone()); + if self.current_tab == CurrentTab::AddAccount { + match key.code { + KeyCode::Esc => { + self.current_tab = CurrentTab::Accounts; + for component in self.components.iter_mut() { + component.set_active(self.current_tab.clone()); + } + }, + _ => { + for component in self.components.iter_mut() { + component.handle_key_event(key)?; + } } - return Ok(Some(Action::SetActiveScreen(Mode::Menu))); - }, - KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { - self.move_right(); - for component in self.components.iter_mut() { - component.set_active(self.current_tab.clone()); - } - }, - KeyCode::Char('h') | KeyCode::Left => { - self.move_left(); - for component in self.components.iter_mut() { - component.set_active(self.current_tab.clone()); - } - }, - _ => { - for component in self.components.iter_mut() { - component.handle_key_event(key)?; - } - }, + } + } else { + match key.code { + KeyCode::Esc => { + self.is_active = false; + self.current_tab = CurrentTab::Nothing; + for component in self.components.iter_mut() { + component.set_active(self.current_tab.clone()); + } + return Ok(Some(Action::SetActiveScreen(Mode::Menu))); + }, + KeyCode::Char('w') => { + self.current_tab = CurrentTab::AddAccount; + for component in self.components.iter_mut() { + component.set_active(self.current_tab.clone()); + } + }, + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { + self.move_right(); + for component in self.components.iter_mut() { + component.set_active(self.current_tab.clone()); + } + }, + KeyCode::Char('h') | KeyCode::Left => { + self.move_left(); + for component in self.components.iter_mut() { + component.set_active(self.current_tab.clone()); + } + }, + _ => { + for component in self.components.iter_mut() { + component.handle_key_event(key)?; + } + }, + } } } Ok(None) @@ -138,6 +161,12 @@ impl Component for Wallet { component.set_active(self.current_tab.clone()); } } + if let Action::NewAccount(_) = action { + self.current_tab = CurrentTab::Accounts; + for component in self.components.iter_mut() { + component.set_active(self.current_tab.clone()); + } + } for component in self.components.iter_mut() { component.update(action.clone())?; } diff --git a/src/components/wallet/transfer.rs b/src/components/wallet/transfer.rs index e014f0b..7fd834b 100644 --- a/src/components/wallet/transfer.rs +++ b/src/components/wallet/transfer.rs @@ -1,5 +1,5 @@ use color_eyre::Result; -use crossterm::event::{KeyEvent, KeyCode}; +use crossterm::event::KeyEvent; use ratatui::{ layout::{Constraint, Flex, Layout, Rect}, widgets::{Block, Clear}, @@ -54,8 +54,6 @@ impl Component for Transfer { fn handle_key_event(&mut self, key: KeyEvent) -> Result> { match key.code { - KeyCode::Char('t') => self.is_shown = true, - KeyCode::Char(' ') => self.is_shown = false, _ => {}, }; Ok(None) diff --git a/src/widgets/input/backend.rs b/src/widgets/input/backend.rs new file mode 100644 index 0000000..bcc032f --- /dev/null +++ b/src/widgets/input/backend.rs @@ -0,0 +1,111 @@ +use super::{Input, InputRequest, StateChanged}; +use ratatui::crossterm::event::{ + Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, +}; +use ratatui::crossterm::{ + cursor::MoveTo, + queue, + style::{Attribute as CAttribute, Print, SetAttribute}, +}; +use std::io::{Result, Write}; + +pub fn to_input_request(evt: &CrosstermEvent) -> Option { + use InputRequest::*; + use KeyCode::*; + match evt { + CrosstermEvent::Key(KeyEvent { + code, + modifiers, + kind, + state: _, + }) if *kind == KeyEventKind::Press || *kind == KeyEventKind::Repeat => { + match (*code, *modifiers) { + (Backspace, KeyModifiers::NONE) | (Char('h'), KeyModifiers::CONTROL) => { + Some(DeletePrevChar) + } + (Delete, KeyModifiers::NONE) => Some(DeleteNextChar), + (Tab, KeyModifiers::NONE) => None, + (Left, KeyModifiers::NONE) | (Char('b'), KeyModifiers::CONTROL) => { + Some(GoToPrevChar) + } + (Left, KeyModifiers::CONTROL) | (Char('b'), KeyModifiers::META) => { + Some(GoToPrevWord) + } + (Right, KeyModifiers::NONE) | (Char('f'), KeyModifiers::CONTROL) => { + Some(GoToNextChar) + } + (Right, KeyModifiers::CONTROL) | (Char('f'), KeyModifiers::META) => { + Some(GoToNextWord) + } + (Char('u'), KeyModifiers::CONTROL) => Some(DeleteLine), + + (Char('w'), KeyModifiers::CONTROL) + | (Char('d'), KeyModifiers::META) + | (Backspace, KeyModifiers::META) + | (Backspace, KeyModifiers::ALT) => Some(DeletePrevWord), + + (Delete, KeyModifiers::CONTROL) => Some(DeleteNextWord), + (Char('k'), KeyModifiers::CONTROL) => Some(DeleteTillEnd), + (Char('a'), KeyModifiers::CONTROL) | (Home, KeyModifiers::NONE) => { + Some(GoToStart) + } + (Char('e'), KeyModifiers::CONTROL) | (End, KeyModifiers::NONE) => { + Some(GoToEnd) + } + (Char(c), KeyModifiers::NONE) => Some(InsertChar(c)), + (Char(c), KeyModifiers::SHIFT) => Some(InsertChar(c)), + (_, _) => None, + } + } + _ => None, + } +} + +pub fn write( + stdout: &mut W, + value: &str, + cursor: usize, + (x, y): (u16, u16), + width: u16, +) -> Result<()> { + queue!(stdout, MoveTo(x, y), SetAttribute(CAttribute::NoReverse))?; + + let val_width = width.max(1) as usize - 1; + let len = value.chars().count(); + let start = (len.max(val_width) - val_width).min(cursor); + let mut chars = value.chars().skip(start); + let mut i = start; + + while i < cursor { + i += 1; + let c = chars.next().unwrap_or(' '); + queue!(stdout, Print(c))?; + } + + i += 1; + let c = chars.next().unwrap_or(' '); + queue!( + stdout, + SetAttribute(CAttribute::Reverse), + Print(c), + SetAttribute(CAttribute::NoReverse) + )?; + + while i <= start + val_width { + i += 1; + let c = chars.next().unwrap_or(' '); + queue!(stdout, Print(c))?; + } + + Ok(()) +} + +pub trait EventHandler { + fn handle_event(&mut self, evt: &CrosstermEvent) -> Option; +} + +impl EventHandler for Input { + fn handle_event(&mut self, evt: &CrosstermEvent) -> Option { + to_input_request(evt).and_then(|req| self.handle(req)) + } +} diff --git a/src/widgets/input/input.rs b/src/widgets/input/input.rs new file mode 100644 index 0000000..3c183ba --- /dev/null +++ b/src/widgets/input/input.rs @@ -0,0 +1,355 @@ +#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] +#[derive(serde::Serialize, serde::Deserialize)] +pub enum InputRequest { + SetCursor(usize), + InsertChar(char), + GoToPrevChar, + GoToNextChar, + GoToPrevWord, + GoToNextWord, + GoToStart, + GoToEnd, + DeletePrevChar, + DeleteNextChar, + DeletePrevWord, + DeleteNextWord, + DeleteLine, + DeleteTillEnd, +} + +#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] +#[derive(serde::Serialize, serde::Deserialize)] +pub struct StateChanged { + pub value: bool, + pub cursor: bool, +} + +pub type InputResponse = Option; + +#[derive(Default, Debug, Clone)] +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Input { + value: String, + cursor: usize, +} + +impl Input { + pub fn new(value: String) -> Self { + let len = value.chars().count(); + Self { value, cursor: len } + } + + pub fn with_value(mut self, value: String) -> Self { + self.cursor = value.chars().count(); + self.value = value; + self + } + + pub fn with_cursor(mut self, cursor: usize) -> Self { + self.cursor = cursor.min(self.value.chars().count()); + self + } + + pub fn reset(&mut self) { + self.cursor = Default::default(); + self.value = Default::default(); + } + + pub fn handle(&mut self, req: InputRequest) -> InputResponse { + use InputRequest::*; + match req { + SetCursor(pos) => { + let pos = pos.min(self.value.chars().count()); + if self.cursor == pos { + None + } else { + self.cursor = pos; + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + InsertChar(c) => { + if self.cursor == self.value.chars().count() { + self.value.push(c); + } else { + self.value = self + .value + .chars() + .take(self.cursor) + .chain( + std::iter::once(c) + .chain(self.value.chars().skip(self.cursor)), + ) + .collect(); + } + self.cursor += 1; + Some(StateChanged { + value: true, + cursor: true, + }) + } + + DeletePrevChar => { + if self.cursor == 0 { + None + } else { + self.cursor -= 1; + self.value = self + .value + .chars() + .enumerate() + .filter(|(i, _)| i != &self.cursor) + .map(|(_, c)| c) + .collect(); + + Some(StateChanged { + value: true, + cursor: true, + }) + } + } + + DeleteNextChar => { + if self.cursor == self.value.chars().count() { + None + } else { + self.value = self + .value + .chars() + .enumerate() + .filter(|(i, _)| i != &self.cursor) + .map(|(_, c)| c) + .collect(); + Some(StateChanged { + value: true, + cursor: false, + }) + } + } + + GoToPrevChar => { + if self.cursor == 0 { + None + } else { + self.cursor -= 1; + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + + GoToPrevWord => { + if self.cursor == 0 { + None + } else { + self.cursor = self + .value + .chars() + .rev() + .skip(self.value.chars().count().max(self.cursor) - self.cursor) + .skip_while(|c| !c.is_alphanumeric()) + .skip_while(|c| c.is_alphanumeric()) + .count(); + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + + GoToNextChar => { + if self.cursor == self.value.chars().count() { + None + } else { + self.cursor += 1; + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + + GoToNextWord => { + if self.cursor == self.value.chars().count() { + None + } else { + self.cursor = self + .value + .chars() + .enumerate() + .skip(self.cursor) + .skip_while(|(_, c)| c.is_alphanumeric()) + .find(|(_, c)| c.is_alphanumeric()) + .map(|(i, _)| i) + .unwrap_or_else(|| self.value.chars().count()); + + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + + DeleteLine => { + if self.value.is_empty() { + None + } else { + let cursor = self.cursor; + self.value = "".into(); + self.cursor = 0; + Some(StateChanged { + value: true, + cursor: self.cursor == cursor, + }) + } + } + + DeletePrevWord => { + if self.cursor == 0 { + None + } else { + let remaining = self.value.chars().skip(self.cursor); + let rev = self + .value + .chars() + .rev() + .skip(self.value.chars().count().max(self.cursor) - self.cursor) + .skip_while(|c| !c.is_alphanumeric()) + .skip_while(|c| c.is_alphanumeric()) + .collect::>(); + let rev_len = rev.len(); + self.value = rev.into_iter().rev().chain(remaining).collect(); + self.cursor = rev_len; + Some(StateChanged { + value: true, + cursor: true, + }) + } + } + + DeleteNextWord => { + if self.cursor == self.value.chars().count() { + None + } else { + self.value = self + .value + .chars() + .take(self.cursor) + .chain( + self.value + .chars() + .skip(self.cursor) + .skip_while(|c| c.is_alphanumeric()) + .skip_while(|c| !c.is_alphanumeric()), + ) + .collect(); + + Some(StateChanged { + value: true, + cursor: false, + }) + } + } + + GoToStart => { + if self.cursor == 0 { + None + } else { + self.cursor = 0; + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + + GoToEnd => { + let count = self.value.chars().count(); + if self.cursor == count { + None + } else { + self.cursor = count; + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + + DeleteTillEnd => { + self.value = self.value.chars().take(self.cursor).collect(); + Some(StateChanged { + value: true, + cursor: false, + }) + } + } + } + + pub fn value(&self) -> &str { + self.value.as_str() + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + pub fn visual_cursor(&self) -> usize { + if self.cursor == 0 { + return 0; + } + + unicode_width::UnicodeWidthStr::width(unsafe { + self.value.get_unchecked( + 0..self + .value + .char_indices() + .nth(self.cursor) + .map_or_else(|| self.value.len(), |(index, _)| index), + ) + }) + } + + pub fn visual_scroll(&self, width: usize) -> usize { + let scroll = (self.visual_cursor()).max(width) - width; + let mut uscroll = 0; + let mut chars = self.value().chars(); + + while uscroll < scroll { + match chars.next() { + Some(c) => { + uscroll += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0); + } + None => break, + } + } + uscroll + } +} + +impl From for String { + fn from(input: Input) -> Self { + input.value + } +} + +impl From for Input { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From<&str> for Input { + fn from(value: &str) -> Self { + Self::new(value.into()) + } +} + +impl std::fmt::Display for Input { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.value.fmt(f) + } +} diff --git a/src/widgets/input/mod.rs b/src/widgets/input/mod.rs new file mode 100644 index 0000000..d4df390 --- /dev/null +++ b/src/widgets/input/mod.rs @@ -0,0 +1,4 @@ +mod input; + +//pub mod backend; +pub use input::{Input, InputRequest}; diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 1f99d9a..6aedfaf 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -2,11 +2,13 @@ mod dot_spinner; mod ogham; mod vertical_block; mod big_text; +mod input; pub use dot_spinner::DotSpinner; pub use vertical_block::VerticalBlocks; pub use ogham::OghamCenter; pub use big_text::BigText; pub use big_text::PixelSize; +pub use input::{Input, InputRequest}; const CYCLE: i64 = 1500;