diff --git a/Cargo.lock b/Cargo.lock index 9f498f1..c916a3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1309,6 +1309,33 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "input-mapping-common" +version = "0.1.0" +dependencies = [ + "ratatui", +] + +[[package]] +name = "input-mapping-derive" +version = "0.1.0" +dependencies = [ + "input-mapping-common", + "itertools 0.13.0", + "proc-macro2", + "quote", + "syn 2.0.72", +] + +[[package]] +name = "input-mapping-tests" +version = "0.1.0" +dependencies = [ + "input-mapping-common", + "input-mapping-derive", + "ratatui", +] + [[package]] name = "is-terminal" version = "0.4.12" @@ -1443,6 +1470,8 @@ dependencies = [ "chrono", "copypasta", "futures", + "input-mapping-common", + "input-mapping-derive", "itertools 0.13.0", "ledger-lib", "ledger-proto", diff --git a/Cargo.toml b/Cargo.toml index 494e9a9..ed7f4d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ resolver = "2" -members = ["./app", "./app/api_proc_macro"] +members = ["./app", "./app/input_mapping/*"] [workspace.package] version = "0.1.0" @@ -11,6 +11,8 @@ authors = ["mertwole"] [workspace.dependencies] api-proc-macro = { path = "./app/api_proc_macro" } +input-mapping-derive = { path = "./app/input_mapping/derive" } +input-mapping-common = { path = "./app/input_mapping/common" } binance_spot_connector_rust = "1.1.0" bs58 = "0.5.1" diff --git a/app/Cargo.toml b/app/Cargo.toml index 35c675e..481c915 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -6,6 +6,8 @@ authors.workspace = true [dependencies] api-proc-macro.workspace = true +input-mapping-derive.workspace = true +input-mapping-common.workspace = true binance_spot_connector_rust.workspace = true bs58.workspace = true diff --git a/app/input_mapping/common/Cargo.toml b/app/input_mapping/common/Cargo.toml new file mode 100644 index 0000000..5ceaf70 --- /dev/null +++ b/app/input_mapping/common/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "input-mapping-common" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +ratatui.workspace = true diff --git a/app/input_mapping/common/src/lib.rs b/app/input_mapping/common/src/lib.rs new file mode 100644 index 0000000..38c0ec4 --- /dev/null +++ b/app/input_mapping/common/src/lib.rs @@ -0,0 +1,41 @@ +use ratatui::crossterm::event::{Event, KeyCode}; + +pub trait InputMappingT: Sized { + fn get_mapping() -> InputMapping; + + fn map_event(event: Event) -> Option; +} + +#[derive(Debug)] +pub struct InputMapping { + pub mapping: Vec, +} + +impl InputMapping { + pub fn merge(mut self, mut other: InputMapping) -> Self { + self.mapping.append(&mut other.mapping); + self + } +} + +#[derive(Debug)] +pub struct MappingEntry { + pub key: KeyCode, + pub description: String, +} + +pub trait KeyCodeConversions { + fn convert(self) -> KeyCode; +} + +impl KeyCodeConversions for char { + fn convert(self) -> KeyCode { + KeyCode::Char(self) + } +} + +impl KeyCodeConversions for KeyCode { + fn convert(self) -> KeyCode { + self + } +} diff --git a/app/input_mapping/derive/Cargo.toml b/app/input_mapping/derive/Cargo.toml new file mode 100644 index 0000000..e56d015 --- /dev/null +++ b/app/input_mapping/derive/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "input-mapping-derive" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[lib] +proc-macro = true + +[dependencies] +input-mapping-common.workspace = true + +itertools.workspace = true +proc-macro2.workspace = true +quote.workspace = true +syn.workspace = true diff --git a/app/input_mapping/derive/src/lib.rs b/app/input_mapping/derive/src/lib.rs new file mode 100644 index 0000000..b38b769 --- /dev/null +++ b/app/input_mapping/derive/src/lib.rs @@ -0,0 +1,183 @@ +use proc_macro2::{Literal, TokenStream}; +use quote::ToTokens; +use syn::{parse_macro_input, Expr, Fields, ItemEnum, Lit, Meta, MetaNameValue, Variant}; + +#[macro_use] +extern crate quote; +extern crate proc_macro; +extern crate syn; + +#[proc_macro_derive(InputMapping, attributes(key, description))] +pub fn derive_mapping(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let item_enum = parse_macro_input!(input as ItemEnum); + let trait_impl = generate_trait_impl(item_enum); + + proc_macro::TokenStream::from(trait_impl) +} + +// TODO: Add check that mappings don't overlap. +fn generate_trait_impl(item_enum: ItemEnum) -> TokenStream { + let (unit, to_be_flattened): (Vec<_>, Vec<_>) = item_enum + .variants + .into_iter() + .partition(|variant| matches!(variant.fields, Fields::Unit)); + + let mapping_entries = unit.into_iter().map(generate_mapping_entry); + + let mapping_constructors = mapping_entries.clone().map(|entry| { + let key = entry.key; + let description = entry.description; + + quote! { + ::input_mapping_common::MappingEntry { + key: #key.convert(), + description: (#description).to_string(), + } + } + }); + + let mapping_matchers = mapping_entries.map(|entry| { + let key = entry.key; + let event = entry.event; + + quote! { + ::ratatui::crossterm::event::Event::Key(::ratatui::crossterm::event::KeyEvent { + kind: ::ratatui::crossterm::event::KeyEventKind::Press, + code, + .. + }) if code == #key.convert() => ::std::option::Option::Some(Self:: #event) + } + }); + + let get_mapping_flattening = to_be_flattened.iter().map(|variant| { + let field_ty = match &variant.fields { + Fields::Unnamed(fields) => { + let fields = &fields.unnamed; + if fields.len() != 1 { + panic!("Multiple unnamed fields are not supported"); + } + &fields.first().expect("fields.len() checked to be = 1").ty + } + Fields::Unit => panic!("Unit fields have been filtered above"), + Fields::Named(_) => panic!("Named variant fields are not supported"), + }; + + quote! { + .merge(#field_ty ::get_mapping()) + } + }); + + let map_event_flattening = to_be_flattened.iter().map(|variant| { + let field_ty = match &variant.fields { + Fields::Unnamed(fields) => { + let fields = &fields.unnamed; + if fields.len() != 1 { + panic!("Multiple unnamed fields are not supported"); + } + &fields.first().expect("fields.len() checked to be = 1").ty + } + Fields::Unit => panic!("Unit fields have been filtered above"), + Fields::Named(_) => panic!("Named variant fields are not supported"), + }; + + let ident = &variant.ident; + + quote! { + .or_else(|| { + #field_ty ::map_event(event).map(Self:: #ident) + }) + } + }); + + let ident = item_enum.ident; + + quote! { + impl ::input_mapping_common::InputMappingT for #ident { + fn get_mapping() -> ::input_mapping_common::InputMapping { + use ::input_mapping_common::KeyCodeConversions; + + ::input_mapping_common::InputMapping { + mapping: vec![ + #(#mapping_constructors,)* + ] + } + + #(#get_mapping_flattening)* + } + + fn map_event(event: ::ratatui::crossterm::event::Event) -> ::std::option::Option { + use ::input_mapping_common::KeyCodeConversions; + + match event { + #(#mapping_matchers,)* + _ => None, + } + + #(#map_event_flattening)* + } + } + } +} + +struct MappingEntry { + key: TokenStream, + description: TokenStream, + event: TokenStream, +} + +fn generate_mapping_entry(variant: Variant) -> MappingEntry { + let mut key: Option = None; + let mut description: Option = None; + + for attr in &variant.attrs { + match &attr.meta { + Meta::NameValue(MetaNameValue { path, value, .. }) => { + if path.is_ident("key") { + if key.is_some() { + panic!("Duplicate definition for attribute: key"); + } + + key = Some(match value { + Expr::Lit(lit) => match &lit.lit { + Lit::Str(str) => str.value().parse().expect("Invalid expression"), + Lit::Char(char) => char.to_token_stream(), + _ => { + unimplemented!("Values other than string or char are not supported") + } + }, + _ => unimplemented!(), + }); + + //key = Some(value.into_token_stream()); + } else if path.is_ident("description") { + if description.is_some() { + panic!("Duplicate definition for attribute: description"); + } + + description = Some(value.into_token_stream()); + } + } + _ => continue, + } + } + + let key = key.unwrap_or_else(|| { + let key = variant + .ident + .to_string() + .chars() + .next() + .expect("Non-empty identifier expected") + .to_ascii_lowercase(); + + Literal::character(key).into_token_stream() + }); + + let description = description.unwrap_or_else(|| Literal::string("").into_token_stream()); + + MappingEntry { + key, + description, + event: variant.ident.to_token_stream(), + } +} diff --git a/app/input_mapping/tests/Cargo.toml b/app/input_mapping/tests/Cargo.toml new file mode 100644 index 0000000..8107899 --- /dev/null +++ b/app/input_mapping/tests/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "input-mapping-tests" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +input-mapping-derive.workspace = true +input-mapping-common.workspace = true + +ratatui.workspace = true diff --git a/app/input_mapping/tests/src/lib.rs b/app/input_mapping/tests/src/lib.rs new file mode 100644 index 0000000..11a6f13 --- /dev/null +++ b/app/input_mapping/tests/src/lib.rs @@ -0,0 +1,50 @@ +#![cfg(test)] + +use std::collections::HashMap; + +use input_mapping_common::InputMappingT; +use input_mapping_derive::InputMapping; +use ratatui::crossterm::event::KeyCode; + +#[derive(InputMapping)] +enum TestEnum { + #[key = 'a'] + One, + + #[description = "test"] + Two, + + #[allow(dead_code)] + Nested(Nested), +} + +#[derive(InputMapping)] +enum Nested { + #[description = "four_test"] + Four, + + #[key = "KeyCode::Up"] + #[description = "up"] + Five, + + Six, +} + +#[test] +fn test_input_mapping_generated_as_expected() { + let mapping = TestEnum::get_mapping(); + let mapping: HashMap<_, _> = mapping + .mapping + .into_iter() + .map(|map| (map.key, map.description)) + .collect(); + + assert_eq!(mapping.len(), 5); + assert_eq!( + mapping.get(&KeyCode::Char('f')), + Some(&"four_test".to_string()) + ); + assert_eq!(mapping.get(&KeyCode::Char('t')), Some(&"test".to_string())); + assert_eq!(mapping.get(&KeyCode::Char('s')), Some(&"".to_string())); + assert_eq!(mapping.get(&KeyCode::Up), Some(&"up".to_string())); +} diff --git a/app/src/screen/asset/controller.rs b/app/src/screen/asset/controller.rs index 5b8b908..51affc9 100644 --- a/app/src/screen/asset/controller.rs +++ b/app/src/screen/asset/controller.rs @@ -1,56 +1,77 @@ -use ratatui::crossterm::event::{Event, KeyCode}; +use input_mapping_common::InputMappingT; +use input_mapping_derive::InputMapping; +use ratatui::crossterm::event::Event; use crate::{ api::{ blockchain_monitoring::BlockchainMonitoringApiT, coin_price::CoinPriceApiT, ledger::LedgerApiT, }, - screen::{EventExt, OutgoingMessage, ScreenName}, + screen::{OutgoingMessage, ScreenName}, }; use super::{Model, TimePeriod}; -pub(super) fn process_input( - event: &Event, - model: &mut Model, -) -> Option { - if event.is_key_pressed(KeyCode::Char('q')) { - return Some(OutgoingMessage::Exit); - } +#[derive(InputMapping)] +pub enum InputEvent { + #[key = 'q'] + #[description = "Quit application"] + Quit, - if event.is_key_pressed(KeyCode::Char('b')) { - return Some(OutgoingMessage::Back); - } + #[key = 'b'] + #[description = "Return one screen back"] + Back, - if event.is_key_pressed(KeyCode::Char('s')) { - return Some(OutgoingMessage::SwitchScreen(ScreenName::Deposit)); - } + #[key = 's'] + #[description = "Open deposit screen"] + OpenDepositScreen, + + SelectTimeInterval(SelectTimeInterval), +} + +#[derive(InputMapping)] +pub enum SelectTimeInterval { + #[key = 'd'] + #[description = "Select time interval - day"] + Day, + + #[key = 'w'] + #[description = "Select time interval - week"] + Week, - process_time_interval_selection(event, model); + #[key = 'm'] + #[description = "Select time interval - month"] + Month, - None + #[key = 'y'] + #[description = "Select time interval - year"] + Year, + + #[key = 'a'] + #[description = "Select time interval - all time"] + All, } -fn process_time_interval_selection( +pub(super) fn process_input( event: &Event, model: &mut Model, -) { - match () { - () if event.is_key_pressed(KeyCode::Char('d')) => { - model.selected_time_period = TimePeriod::Day; - } - () if event.is_key_pressed(KeyCode::Char('w')) => { - model.selected_time_period = TimePeriod::Week; - } - () if event.is_key_pressed(KeyCode::Char('m')) => { - model.selected_time_period = TimePeriod::Month; - } - () if event.is_key_pressed(KeyCode::Char('y')) => { - model.selected_time_period = TimePeriod::Year; - } - () if event.is_key_pressed(KeyCode::Char('a')) => { - model.selected_time_period = TimePeriod::All; +) -> Option { + let event = InputEvent::map_event(event.clone())?; + + match event { + InputEvent::Quit => Some(OutgoingMessage::Exit), + InputEvent::Back => Some(OutgoingMessage::Back), + InputEvent::OpenDepositScreen => Some(OutgoingMessage::SwitchScreen(ScreenName::Deposit)), + InputEvent::SelectTimeInterval(event) => { + model.selected_time_period = match event { + SelectTimeInterval::Day => TimePeriod::Day, + SelectTimeInterval::Week => TimePeriod::Week, + SelectTimeInterval::Month => TimePeriod::Month, + SelectTimeInterval::Year => TimePeriod::Year, + SelectTimeInterval::All => TimePeriod::All, + }; + + None } - _ => {} - }; + } } diff --git a/app/src/screen/deposit/controller.rs b/app/src/screen/deposit/controller.rs index 85d7db9..412d207 100644 --- a/app/src/screen/deposit/controller.rs +++ b/app/src/screen/deposit/controller.rs @@ -1,7 +1,9 @@ use std::time::Instant; use copypasta::{ClipboardContext, ClipboardProvider}; -use ratatui::crossterm::event::{Event, KeyCode}; +use input_mapping_common::InputMappingT; +use input_mapping_derive::InputMapping; +use ratatui::crossterm::event::Event; use super::Model; use crate::{ @@ -9,38 +11,51 @@ use crate::{ blockchain_monitoring::BlockchainMonitoringApiT, coin_price::CoinPriceApiT, ledger::LedgerApiT, }, - screen::{EventExt, OutgoingMessage}, + screen::OutgoingMessage, }; +#[derive(InputMapping)] +pub enum InputEvent { + #[key = 'q'] + #[description = "Quit application"] + Quit, + + #[key = 'b'] + #[description = "Return one screen back"] + Back, + + #[key = 'c'] + #[description = "Copy address to a clipboard"] + CopyAddress, +} + pub(super) fn process_input( event: &Event, model: &mut Model, ) -> Option { - if event.is_key_pressed(KeyCode::Char('q')) { - return Some(OutgoingMessage::Exit); + let event = InputEvent::map_event(event.clone())?; + + match event { + InputEvent::Quit => Some(OutgoingMessage::Exit), + InputEvent::Back => Some(OutgoingMessage::Back), + InputEvent::CopyAddress => { + model.last_address_copy = Some(Instant::now()); + + let pubkey = model + .state + .selected_account + .as_ref() + .expect("Selected account should be present in state") // TODO: Enforce this rule at `app` level? + .1 + .get_info() + .pk; + + let mut ctx = ClipboardContext::new().unwrap(); + ctx.set_contents(pubkey).unwrap(); + // It's a bug in `copypasta`. Without calling `get_contents` after `set_contents` clipboard will contain nothing. + ctx.get_contents().unwrap(); + + None + } } - - if event.is_key_pressed(KeyCode::Char('b')) { - return Some(OutgoingMessage::Back); - } - - if event.is_key_pressed(KeyCode::Char('c')) { - model.last_address_copy = Some(Instant::now()); - - let pubkey = model - .state - .selected_account - .as_ref() - .expect("Selected account should be present in state") // TODO: Enforce this rule at `app` level? - .1 - .get_info() - .pk; - - let mut ctx = ClipboardContext::new().unwrap(); - ctx.set_contents(pubkey).unwrap(); - // It's a bug in `copypasta`. Without calling `get_contents` after `set_contents` clipboard will contain nothing. - ctx.get_contents().unwrap(); - } - - None } diff --git a/app/src/screen/device_selection/controller.rs b/app/src/screen/device_selection/controller.rs index 71cad93..ff5fee5 100644 --- a/app/src/screen/device_selection/controller.rs +++ b/app/src/screen/device_selection/controller.rs @@ -1,3 +1,5 @@ +use input_mapping_common::InputMappingT; +use input_mapping_derive::InputMapping; use ratatui::crossterm::event::{Event, KeyCode}; use crate::{ @@ -5,42 +7,64 @@ use crate::{ blockchain_monitoring::BlockchainMonitoringApiT, coin_price::CoinPriceApiT, ledger::LedgerApiT, }, - screen::{EventExt, OutgoingMessage}, + screen::OutgoingMessage, }; use super::Model; +#[derive(InputMapping)] +pub enum InputEvent { + #[key = 'q'] + #[description = "Quit application"] + Quit, + + #[key = "KeyCode::Down"] + #[description = "Navigate down in list"] + Down, + + #[key = "KeyCode::Up"] + #[description = "Navigate up in list"] + Up, + + #[key = "KeyCode::Enter"] + #[description = "Select device"] + Select, +} + pub(super) fn process_input( - model: &mut Model, event: &Event, + model: &mut Model, ) -> Option { - if event.is_key_pressed(KeyCode::Down) && !model.devices.is_empty() { - if let Some(selected) = model.selected_device.as_mut() { - *selected = (model.devices.len() - 1).min(*selected + 1); - } else { - model.selected_device = Some(0); - } - } + let event = InputEvent::map_event(event.clone())?; - if event.is_key_pressed(KeyCode::Up) && !model.devices.is_empty() { - if let Some(selected) = model.selected_device.as_mut() { - *selected = if *selected == 0 { 0 } else { *selected - 1 }; - } else { - model.selected_device = Some(model.devices.len() - 1); + match event { + InputEvent::Quit => return Some(OutgoingMessage::Exit), + InputEvent::Down => { + if !model.devices.is_empty() { + if let Some(selected) = model.selected_device.as_mut() { + *selected = (model.devices.len() - 1).min(*selected + 1); + } else { + model.selected_device = Some(0); + } + } } - } - - if event.is_key_pressed(KeyCode::Enter) { - if let Some(device_idx) = model.selected_device { - let (device, info) = model.devices[device_idx].clone(); - model.state.active_device = Some((device, info)); - - return Some(OutgoingMessage::Back); + InputEvent::Up => { + if !model.devices.is_empty() { + if let Some(selected) = model.selected_device.as_mut() { + *selected = if *selected == 0 { 0 } else { *selected - 1 }; + } else { + model.selected_device = Some(model.devices.len() - 1); + } + } } - } + InputEvent::Select => { + if let Some(device_idx) = model.selected_device { + let (device, info) = model.devices[device_idx].clone(); + model.state.active_device = Some((device, info)); - if event.is_key_pressed(KeyCode::Char('q')) { - return Some(OutgoingMessage::Exit); + return Some(OutgoingMessage::Back); + } + } } None diff --git a/app/src/screen/device_selection/mod.rs b/app/src/screen/device_selection/mod.rs index b31633c..6d7fb64 100644 --- a/app/src/screen/device_selection/mod.rs +++ b/app/src/screen/device_selection/mod.rs @@ -78,7 +78,7 @@ impl ScreenT) -> Option { self.tick_logic(); - controller::process_input(self, event.as_ref()?) + controller::process_input(event.as_ref()?, self) } fn deconstruct(self) -> (StateRegistry, ApiRegistry) { diff --git a/app/src/screen/mod.rs b/app/src/screen/mod.rs index a9784b5..c7bd19d 100644 --- a/app/src/screen/mod.rs +++ b/app/src/screen/mod.rs @@ -1,7 +1,4 @@ -use ratatui::{ - crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}, - Frame, -}; +use ratatui::{crossterm::event::Event, Frame}; use resources::Resources; use crate::{ @@ -96,22 +93,3 @@ pub enum ScreenName { Asset, Deposit, } - -trait EventExt { - fn is_key_pressed(&self, code: KeyCode) -> bool; -} - -impl EventExt for Event { - fn is_key_pressed(&self, code: KeyCode) -> bool { - let pressed_code = code; - - matches!( - self, - &Event::Key(KeyEvent { - kind: KeyEventKind::Press, - code, - .. - }) if code == pressed_code - ) - } -} diff --git a/app/src/screen/portfolio/controller.rs b/app/src/screen/portfolio/controller.rs index d20b0ab..d8f7f7f 100644 --- a/app/src/screen/portfolio/controller.rs +++ b/app/src/screen/portfolio/controller.rs @@ -1,5 +1,7 @@ use std::num::NonZeroUsize; +use input_mapping_common::InputMappingT; +use input_mapping_derive::InputMapping; use ratatui::crossterm::event::{Event, KeyCode}; use super::Model; @@ -8,15 +10,48 @@ use crate::{ blockchain_monitoring::BlockchainMonitoringApiT, coin_price::CoinPriceApiT, ledger::LedgerApiT, }, - screen::{EventExt, OutgoingMessage, ScreenName}, + screen::{OutgoingMessage, ScreenName}, }; +#[derive(InputMapping)] +pub enum InputEvent { + #[key = 'q'] + #[description = "Quit application"] + Quit, + + #[key = 'd'] + #[description = "Open device selection screen"] + OpenDeviceSelection, + + #[key = "KeyCode::Down"] + #[description = "Navigate down in list"] + Down, + + #[key = "KeyCode::Up"] + #[description = "Navigate up in list"] + Up, + + #[key = "KeyCode::Enter"] + #[description = "Select device"] + Select, +} + pub(super) fn process_input( - model: &mut Model, event: &Event, + model: &mut Model, ) -> Option { + let event = InputEvent::map_event(event.clone())?; + + match event { + InputEvent::Quit => return Some(OutgoingMessage::Exit), + InputEvent::OpenDeviceSelection => { + return Some(OutgoingMessage::SwitchScreen(ScreenName::DeviceSelection)) + } + _ => {} + }; + if let Some(accounts) = model.state.device_accounts.as_ref() { - if event.is_key_pressed(KeyCode::Enter) { + if matches!(event, InputEvent::Select) { if let Some((selected_network_idx, selected_account_idx)) = model.selected_account { let (selected_network, accounts) = &accounts[selected_network_idx]; let selected_account = accounts[selected_account_idx].clone(); @@ -33,15 +68,8 @@ pub(super) fn process_input( model: &mut Model, - event: &Event, + event: &InputEvent, accounts_per_network: &[NonZeroUsize], ) { - if event.is_key_pressed(KeyCode::Down) { + if matches!(event, InputEvent::Down) { if let Some((selected_network, selected_account)) = model.selected_account { if selected_account + 1 >= accounts_per_network[selected_network].into() { if selected_network >= accounts_per_network.len() - 1 { @@ -76,7 +104,7 @@ fn process_table_navigation ScreenT) -> Option { self.tick_logic(); - controller::process_input(self, event.as_ref()?) + controller::process_input(event.as_ref()?, self) } fn deconstruct(self) -> (StateRegistry, ApiRegistry) {