diff --git a/.config/config.kdl b/.config/config.kdl index 9991a9c..4a81bfa 100644 --- a/.config/config.kdl +++ b/.config/config.kdl @@ -1,50 +1,8 @@ -scroll_offset 4 -keybindings { - Explorer { - "" Quit // Quit the application - "" Quit // Another way to quit - "" Quit // Yet another way to quit - "" Suspend // Suspend the application - "<2>" switch-to="TodoList" - "<3>" switch-to="Inspector" - f ToggleShowFinished - x Delete - t NewTask - g NewSubGroup - "" NewGroup - j MoveDown - k MoveUp - l MoveInto - h MoveOutOf - } - - TodoList { - "" Quit // Quit the application - "" Quit // Another way to quit - "" Quit // Yet another way to quit - "" Suspend // Suspend the application - "<1>" switch-to="Explorer" - "<3>" switch-to="Inspector" - j MoveDown - k MoveUp - } - - Inspector { - "" Quit // Quit the application - "" Quit // Another way to quit - "" Quit // Yet another way to quit - "" Suspend // Suspend the application - "<1>" switch-to="Explorer" - "<2>" switch-to="TodoList" - r RandomColor - n EditName - c EditColor - p EditPriority - u EditDue - d EditDescription - f ToggleFinishTask - t NewTask - g NewSubGroup +keymap { + Home { + Quit // Quit the application + Quit // Another way to quit + Suspend // Suspend the application } } diff --git a/.config/config_old.kdl b/.config/config_old.kdl new file mode 100644 index 0000000..9991a9c --- /dev/null +++ b/.config/config_old.kdl @@ -0,0 +1,50 @@ +scroll_offset 4 + +keybindings { + Explorer { + "" Quit // Quit the application + "" Quit // Another way to quit + "" Quit // Yet another way to quit + "" Suspend // Suspend the application + "<2>" switch-to="TodoList" + "<3>" switch-to="Inspector" + f ToggleShowFinished + x Delete + t NewTask + g NewSubGroup + "" NewGroup + j MoveDown + k MoveUp + l MoveInto + h MoveOutOf + } + + TodoList { + "" Quit // Quit the application + "" Quit // Another way to quit + "" Quit // Yet another way to quit + "" Suspend // Suspend the application + "<1>" switch-to="Explorer" + "<3>" switch-to="Inspector" + j MoveDown + k MoveUp + } + + Inspector { + "" Quit // Quit the application + "" Quit // Another way to quit + "" Quit // Yet another way to quit + "" Suspend // Suspend the application + "<1>" switch-to="Explorer" + "<2>" switch-to="TodoList" + r RandomColor + n EditName + c EditColor + p EditPriority + u EditDue + d EditDescription + f ToggleFinishTask + t NewTask + g NewSubGroup + } +} diff --git a/Cargo.lock b/Cargo.lock index 501a4a8..f7b96d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,7 +290,7 @@ dependencies = [ "strsim", "terminal_size", "unicase", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -751,6 +751,7 @@ dependencies = [ "directories", "futures", "human-panic", + "kdl", "ratatui", "serde", "signal-hook 0.4.3", @@ -1017,7 +1018,7 @@ dependencies = [ "gix-utils", "itoa", "thiserror 2.0.18", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -1098,7 +1099,7 @@ dependencies = [ "smallvec", "thiserror 2.0.18", "unicode-bom", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -1345,7 +1346,7 @@ dependencies = [ "itoa", "smallvec", "thiserror 2.0.18", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -1442,7 +1443,7 @@ dependencies = [ "gix-utils", "maybe-async", "thiserror 2.0.18", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -1474,7 +1475,7 @@ dependencies = [ "gix-validate", "memmap2", "thiserror 2.0.18", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -1911,6 +1912,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "kdl" +version = "6.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e" +dependencies = [ + "miette", + "num", + "winnow 0.6.24", +] + [[package]] name = "kstring" version = "2.0.2" @@ -2061,6 +2073,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "unicode-width 0.1.14", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2129,6 +2151,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2146,6 +2201,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2477,7 +2563,7 @@ dependencies = [ "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -2528,7 +2614,7 @@ dependencies = [ "strum 0.27.2", "time", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -3293,9 +3379,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -3877,6 +3969,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index 094b55a..800ead9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ directories = "6.0.0" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } tracing-error = "0.2.1" clap = { version = "4.5.60", features = ["derive", "cargo", "wrap_help", "unicode", "string", "unstable-styles"] } +kdl = "6.5.0" [build-dependencies] anyhow = "1.0.102" diff --git a/src/app.rs b/src/app.rs index 6b01d65..3cc093c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,8 +2,9 @@ use color_eyre::eyre::Result; use crossterm::event::KeyEvent; use ratatui::layout::Rect; use serde::{Deserialize, Serialize}; +use strum::{Display, EnumIter}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; -use tracing::{debug, info}; +use tracing::debug; use crate::{ components::Component, @@ -29,7 +30,9 @@ pub struct App { /// The different regions of the application that the user can /// be interacting with. Think of these kind of like the highest class of /// components. -#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive( + Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter, Display, +)] pub enum Region { #[default] Home, @@ -75,8 +78,7 @@ impl App { loop { self.handle_events(&mut tui).await?; - - self.handle_signals(&mut tui).await?; + self.handle_signals(&mut tui)?; if self.should_suspend { tui.suspend()?; @@ -101,6 +103,8 @@ impl App { return Ok(()); }; + debug!("received event: {event:?}"); + let signal_tx = self.signal_tx.clone(); match event { @@ -122,24 +126,32 @@ impl App { Ok(()) } - // We are okay with this because we know that this is the function signature, - // we just haven't implemented the keyboard parsing logic just yet, revisit - // this later. - // - // DO NOT LET THIS MERGE INTO MAIN WITH THIS CLIPPY IGNORES - #[allow(clippy::needless_pass_by_ref_mut, clippy::unnecessary_wraps)] fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { - let _signal_tx = self.signal_tx.clone(); + debug!("key received: {key:#?}"); - info!("key received: {key:#?}"); + let signal_tx = self.signal_tx.clone(); + + let Some(region_keymap) = self.config.keymap.get(&self.region) else { + return Ok(()); + }; + + if let Some(signal) = region_keymap.get(&vec![key]) { + signal_tx.send(signal.clone())?; + } else { + self.last_tick_key_events.push(key); + if let Some(signal) = region_keymap.get(&self.last_tick_key_events) { + debug!("Got signal: {signal:?}"); + signal_tx.send(signal.clone())?; + } + } Ok(()) } - async fn handle_signals(&mut self, tui: &mut Tui) -> Result<()> { - while let Some(signal) = self.signal_rx.recv().await { + fn handle_signals(&mut self, tui: &mut Tui) -> Result<()> { + while let Ok(signal) = self.signal_rx.try_recv() { if signal != Signal::Tick && signal != Signal::Render { - debug!("App: handling signal: {signal:?}"); + debug!("handling signal: {signal:?}"); } match signal { diff --git a/src/config.rs b/src/config.rs index 64bae95..f72bae2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,10 @@ use directories::ProjectDirs; +use kdl::KdlDocument; use serde::Deserialize; use std::{env, path::PathBuf, sync::LazyLock}; +use crate::keymap::KeyMap; + /// Project Name: Filaments pub static PROJECT_NAME: LazyLock = LazyLock::new(|| env!("CARGO_CRATE_NAME").to_uppercase()); @@ -20,10 +23,12 @@ pub static CONFIG_DIRECTORY: LazyLock> = LazyLock::new(|| { .map(PathBuf::from) }); +const DEFAULT_CONFIG: &str = include_str!("../.config/config.kdl"); + /// The App Config and Data locations. #[derive(Clone, Debug, Deserialize, Default)] #[expect(dead_code)] -pub struct AppDirs { +pub struct AppConfig { #[serde(default)] pub data_dir: PathBuf, #[serde(default)] @@ -34,19 +39,30 @@ pub struct AppDirs { #[expect(dead_code)] #[derive(Debug, Clone)] pub struct Config { - pub app_dirs: AppDirs, // pub data_dir: PathBuf, - // pub keybindings: KeyBindings, - - // pub styles: Styles, + pub app_config: AppConfig, + pub keymap: KeyMap, + // pub styles: Styles, } impl Config { pub fn new() -> Self { + let default_config: KdlDocument = DEFAULT_CONFIG + .parse() + .expect("Default config should always be a valid KDL document."); + + let keymap_node = default_config + .get("keymap") + .expect("Config::new Keymap must exist in default config."); + + let keymap = + KeyMap::try_from(keymap_node).expect("default config should always be a valid keymap"); + Self { - app_dirs: AppDirs { + app_config: AppConfig { data_dir: get_data_dir(), config_dir: get_config_dir(), }, + keymap, } } } diff --git a/src/keymap.rs b/src/keymap.rs new file mode 100644 index 0000000..3d2aabb --- /dev/null +++ b/src/keymap.rs @@ -0,0 +1,209 @@ +use std::{ + collections::HashMap, + ops::{Deref, DerefMut}, +}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use kdl::KdlNode; +use strum::IntoEnumIterator; + +use crate::{app::Region, signal::Signal}; + +#[derive(Debug, Clone)] +pub struct KeyMap(pub HashMap, Signal>>); + +impl TryFrom<&KdlNode> for KeyMap { + type Error = color_eyre::Report; + + fn try_from(value: &KdlNode) -> std::result::Result { + let mut all_binds = HashMap::new(); + + for region in Region::iter() { + let mut region_binds = HashMap::new(); + let Some(binds) = value + .children() + .expect("Keymap must have children.") + .get(®ion.to_string()) + else { + continue; + }; + + // now we iter through the things children + for child in binds.iter_children() { + let key_combo_str = child.name().to_string(); + let key_combo_str = key_combo_str.trim(); + + let signal_str = child + .entries() + .first() + .expect("A bind must map to an entry") + .to_string(); + let signal_str = signal_str.trim(); + + let signal: Signal = signal_str.parse().expect("Must be a \"bindable\" Signal"); + let key_combo = parse_key_sequence(key_combo_str).unwrap(); + + let _ = region_binds.insert(key_combo, signal); + } + + let _ = all_binds.insert(region, region_binds); + } + + Ok(Self(all_binds)) + } +} + +impl Deref for KeyMap { + type Target = HashMap, Signal>>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for KeyMap { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +pub fn parse_key_sequence(raw: &str) -> color_eyre::Result, String> { + if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { + return Err(format!("Unable to parse `{raw}`")); + } + let raw = if raw.contains("><") { + raw + } else { + let raw = raw.strip_prefix('<').unwrap_or(raw); + + raw.strip_prefix('>').unwrap_or(raw) + }; + + raw.split("><") + .map(|seq| { + seq.strip_prefix('<') + .unwrap_or_else(|| seq.strip_suffix('>').map_or(seq, |s| s)) + }) + .map(parse_key_event) + .collect() +} + +fn parse_key_event(raw: &str) -> color_eyre::Result { + let raw_lower = raw.to_ascii_lowercase(); + let (remaining, modifiers) = extract_modifiers(&raw_lower); + parse_key_code_with_modifiers(remaining, modifiers) +} + +fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) { + let mut modifiers = KeyModifiers::empty(); + let mut current = raw; + + loop { + match current { + rest if rest.starts_with("ctrl-") => { + modifiers.insert(KeyModifiers::CONTROL); + current = &rest[5..]; + } + rest if rest.starts_with("alt-") => { + modifiers.insert(KeyModifiers::ALT); + current = &rest[4..]; + } + rest if rest.starts_with("shift-") => { + modifiers.insert(KeyModifiers::SHIFT); + current = &rest[6..]; + } + _ => break, // break out of the loop if no known prefix is detected + } + } + + (current, modifiers) +} + +fn parse_key_code_with_modifiers( + raw: &str, + mut modifiers: KeyModifiers, +) -> color_eyre::Result { + let c = match raw { + "esc" => KeyCode::Esc, + "enter" => KeyCode::Enter, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "backtab" => { + modifiers.insert(KeyModifiers::SHIFT); + KeyCode::BackTab + } + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "insert" => KeyCode::Insert, + "f1" => KeyCode::F(1), + "f2" => KeyCode::F(2), + "f3" => KeyCode::F(3), + "f4" => KeyCode::F(4), + "f5" => KeyCode::F(5), + "f6" => KeyCode::F(6), + "f7" => KeyCode::F(7), + "f8" => KeyCode::F(8), + "f9" => KeyCode::F(9), + "f10" => KeyCode::F(10), + "f11" => KeyCode::F(11), + "f12" => KeyCode::F(12), + "space" => KeyCode::Char(' '), + "hyphen" | "minuc" => KeyCode::Char('-'), + "tab" => KeyCode::Tab, + c if c.len() == 1 => { + let mut c = c.chars().next().unwrap(); + if modifiers.contains(KeyModifiers::SHIFT) { + c = c.to_ascii_uppercase(); + } + KeyCode::Char(c) + } + _ => return Err(format!("Unable to parse {raw}")), + }; + Ok(KeyEvent::new(c, modifiers)) +} + +#[cfg(test)] +mod test { + use crossterm::event::{KeyEvent, KeyModifiers}; + use kdl::KdlNode; + + use crate::{keymap::KeyMap, signal::Signal}; + + #[test] + fn test_quit_in_home_region() { + let keymap_str = " + keymap { + Home { + q Quit + Quit + } + } + "; + + let kdl: &KdlNode = &keymap_str + .parse() + .expect("Keymap_str should be a valid KDL document"); + + let keymap: KeyMap = kdl.try_into().expect("Must be a valid keymap"); + + let map = keymap + .get(&crate::app::Region::Home) + .expect("Home region must exist in keymap"); + + let signal = map + .get(&vec![KeyEvent::new_with_kind( + crossterm::event::KeyCode::Char('q'), + KeyModifiers::empty(), + crossterm::event::KeyEventKind::Press, + )]) + .expect("Must resolve to a signal"); + + assert_eq!(*signal, Signal::Quit); + } +} diff --git a/src/main.rs b/src/main.rs index 8eef232..f7de8c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,6 @@ //! use clap::Parser; - use crate::{app::App, cli::Cli}; mod app; @@ -11,6 +10,7 @@ mod cli; mod components; mod config; mod errors; +mod keymap; mod logging; mod signal; mod tui; diff --git a/src/signal.rs b/src/signal.rs index bcaef38..58491e4 100644 --- a/src/signal.rs +++ b/src/signal.rs @@ -1,3 +1,6 @@ +use std::str::FromStr; + +use color_eyre::eyre::eyre; use strum::Display; use serde::{Deserialize, Serialize}; @@ -15,3 +18,20 @@ pub enum Signal { Error(String), Help, } + +impl FromStr for Signal { + type Err = color_eyre::Report; + + fn from_str(s: &str) -> Result { + Ok(match s.to_lowercase().as_str() { + "suspend" => Self::Suspend, + "resume" => Self::Resume, + "quit" => Self::Quit, + _ => { + return Err(eyre!(format!( + "Attempt to construct a non-user Signal from str: {s}" + ))); + } + }) + } +} diff --git a/src/tui.rs b/src/tui.rs index 17ec4fd..23648ac 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -165,7 +165,6 @@ impl Tui { // not doing anything related to up / down keypresses CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), CrosstermEvent::Key(_) => continue, - CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), CrosstermEvent::Resize(x, y) => Event::Resize(x, y), CrosstermEvent::FocusLost => {Event::FocusLost }, @@ -177,8 +176,8 @@ impl Tui { None => break, } }; + if event_tx.send(event).is_err() { - // no more receiver break; } }