├── README.md ├── src ├── action.rs ├── main.rs ├── cli.rs ├── components │ ├── home.rs │ └── fps.rs ├── logging.rs ├── errors.rs ├── components.rs ├── app.rs ├── tui.rs └── config.rs ├── LICENSE └── Cargo.toml /README.md: -------------------------------------------------------------------------------- 1 | # paintty 2 | 3 | [![CI](https://github.com//paintty/workflows/CI/badge.svg)](https://github.com/preprocessor/paintty/actions) 4 | 5 | Draw text art directly in your terminal 6 | -------------------------------------------------------------------------------- /src/action.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use strum::Display; 3 | 4 | #[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)] 5 | pub enum Action { 6 | Tick, 7 | Render, 8 | Resize(u16, u16), 9 | Suspend, 10 | Resume, 11 | Quit, 12 | ClearScreen, 13 | Error(String), 14 | Help, 15 | } 16 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use cli::Cli; 3 | use color_eyre::Result; 4 | 5 | use crate::app::App; 6 | 7 | mod action; 8 | mod app; 9 | mod cli; 10 | mod components; 11 | mod config; 12 | mod errors; 13 | mod logging; 14 | mod tui; 15 | 16 | #[tokio::main] 17 | async fn main() -> Result<()> { 18 | crate::errors::init()?; 19 | crate::logging::init()?; 20 | 21 | let args = Cli::parse(); 22 | let mut app = App::new(args.tick_rate, args.frame_rate)?; 23 | app.run().await?; 24 | Ok(()) 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 wyspr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | use crate::config::{get_config_dir, get_data_dir}; 4 | 5 | #[derive(Parser, Debug)] 6 | #[command(author, version = version(), about)] 7 | pub struct Cli { 8 | /// Tick rate, i.e. number of ticks per second 9 | #[arg(short, long, value_name = "FLOAT", default_value_t = 4.0)] 10 | pub tick_rate: f64, 11 | 12 | /// Frame rate, i.e. number of frames per second 13 | #[arg(short, long, value_name = "FLOAT", default_value_t = 60.0)] 14 | pub frame_rate: f64, 15 | } 16 | 17 | const VERSION_MESSAGE: &str = concat!( 18 | env!("CARGO_PKG_VERSION"), 19 | "-", 20 | env!("VERGEN_GIT_DESCRIBE"), 21 | " (", 22 | env!("VERGEN_BUILD_DATE"), 23 | ")" 24 | ); 25 | 26 | pub fn version() -> String { 27 | let author = clap::crate_authors!(); 28 | 29 | // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string(); 30 | let config_dir_path = get_config_dir().display().to_string(); 31 | let data_dir_path = get_data_dir().display().to_string(); 32 | 33 | format!( 34 | "\ 35 | {VERSION_MESSAGE} 36 | 37 | Authors: {author} 38 | 39 | Config directory: {config_dir_path} 40 | Data directory: {data_dir_path}" 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/components/home.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use ratatui::{prelude::*, widgets::*}; 3 | use tokio::sync::mpsc::UnboundedSender; 4 | 5 | use super::Component; 6 | use crate::{action::Action, config::Config}; 7 | 8 | #[derive(Default)] 9 | pub struct Home { 10 | command_tx: Option>, 11 | config: Config, 12 | } 13 | 14 | impl Home { 15 | pub fn new() -> Self { 16 | Self::default() 17 | } 18 | } 19 | 20 | impl Component for Home { 21 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 22 | self.command_tx = Some(tx); 23 | Ok(()) 24 | } 25 | 26 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 27 | self.config = config; 28 | Ok(()) 29 | } 30 | 31 | fn update(&mut self, action: Action) -> Result> { 32 | match action { 33 | Action::Tick => { 34 | // add any logic here that should run on every tick 35 | } 36 | Action::Render => { 37 | // add any logic here that should run on every render 38 | } 39 | _ => {} 40 | } 41 | Ok(None) 42 | } 43 | 44 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 45 | frame.render_widget(Paragraph::new("hello world"), area); 46 | Ok(()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "paintty" 3 | version = "0.1.0" 4 | edition = "2021" 5 | description = "Draw text art directly in your terminal" 6 | authors = ["wyspr"] 7 | build = "build.rs" 8 | repository = "https://github.com/preprocessor/paintty" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [dependencies] 13 | better-panic = "0.3.0" 14 | clap = { version = "4.5.20", features = [ 15 | "derive", 16 | "cargo", 17 | "wrap_help", 18 | "unicode", 19 | "string", 20 | "unstable-styles", 21 | ] } 22 | color-eyre = "0.6.3" 23 | config = "0.14.0" 24 | crossterm = { version = "0.28.1", features = ["serde", "event-stream"] } 25 | derive_deref = "1.1.1" 26 | directories = "5.0.1" 27 | futures = "0.3.31" 28 | human-panic = "2.0.2" 29 | json5 = "0.4.1" 30 | lazy_static = "1.5.0" 31 | libc = "0.2.161" 32 | pretty_assertions = "1.4.1" 33 | ratatui = { version = "0.29.0", features = ["serde", "macros"] } 34 | serde = { version = "1.0.211", features = ["derive"] } 35 | serde_json = "1.0.132" 36 | signal-hook = "0.3.17" 37 | strip-ansi-escapes = "0.2.0" 38 | strum = { version = "0.26.3", features = ["derive"] } 39 | tokio = { version = "1.40.0", features = ["full"] } 40 | tokio-util = "0.7.12" 41 | tracing = "0.1.40" 42 | tracing-error = "0.2.0" 43 | tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } 44 | 45 | [build-dependencies] 46 | anyhow = "1.0.90" 47 | vergen-gix = { version = "1.0.2", features = ["build", "cargo"] } 48 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use tracing_error::ErrorLayer; 3 | use tracing_subscriber::{fmt, prelude::*, EnvFilter}; 4 | 5 | use crate::config; 6 | 7 | lazy_static::lazy_static! { 8 | pub static ref LOG_ENV: String = format!("{}_LOG_LEVEL", config::PROJECT_NAME.clone()); 9 | pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); 10 | } 11 | 12 | pub fn init() -> Result<()> { 13 | let directory = config::get_data_dir(); 14 | std::fs::create_dir_all(directory.clone())?; 15 | let log_path = directory.join(LOG_FILE.clone()); 16 | let log_file = std::fs::File::create(log_path)?; 17 | let env_filter = EnvFilter::builder().with_default_directive(tracing::Level::INFO.into()); 18 | // If the `RUST_LOG` environment variable is set, use that as the default, otherwise use the 19 | // value of the `LOG_ENV` environment variable. If the `LOG_ENV` environment variable contains 20 | // errors, then this will return an error. 21 | let env_filter = env_filter 22 | .try_from_env() 23 | .or_else(|_| env_filter.with_env_var(LOG_ENV.clone()).from_env())?; 24 | let file_subscriber = fmt::layer() 25 | .with_file(true) 26 | .with_line_number(true) 27 | .with_writer(log_file) 28 | .with_target(false) 29 | .with_ansi(false) 30 | .with_filter(env_filter); 31 | tracing_subscriber::registry() 32 | .with(file_subscriber) 33 | .with(ErrorLayer::default()) 34 | .try_init()?; 35 | Ok(()) 36 | } 37 | -------------------------------------------------------------------------------- /src/components/fps.rs: -------------------------------------------------------------------------------- 1 | use std::time::Instant; 2 | 3 | use color_eyre::Result; 4 | use ratatui::{ 5 | layout::{Constraint, Layout, Rect}, 6 | style::{Style, Stylize}, 7 | text::Span, 8 | widgets::Paragraph, 9 | Frame, 10 | }; 11 | 12 | use super::Component; 13 | 14 | use crate::action::Action; 15 | 16 | #[derive(Debug, Clone, PartialEq)] 17 | pub struct FpsCounter { 18 | last_tick_update: Instant, 19 | tick_count: u32, 20 | ticks_per_second: f64, 21 | 22 | last_frame_update: Instant, 23 | frame_count: u32, 24 | frames_per_second: f64, 25 | } 26 | 27 | impl Default for FpsCounter { 28 | fn default() -> Self { 29 | Self::new() 30 | } 31 | } 32 | 33 | impl FpsCounter { 34 | pub fn new() -> Self { 35 | Self { 36 | last_tick_update: Instant::now(), 37 | tick_count: 0, 38 | ticks_per_second: 0.0, 39 | last_frame_update: Instant::now(), 40 | frame_count: 0, 41 | frames_per_second: 0.0, 42 | } 43 | } 44 | 45 | fn app_tick(&mut self) -> Result<()> { 46 | self.tick_count += 1; 47 | let now = Instant::now(); 48 | let elapsed = (now - self.last_tick_update).as_secs_f64(); 49 | if elapsed >= 1.0 { 50 | self.ticks_per_second = self.tick_count as f64 / elapsed; 51 | self.last_tick_update = now; 52 | self.tick_count = 0; 53 | } 54 | Ok(()) 55 | } 56 | 57 | fn render_tick(&mut self) -> Result<()> { 58 | self.frame_count += 1; 59 | let now = Instant::now(); 60 | let elapsed = (now - self.last_frame_update).as_secs_f64(); 61 | if elapsed >= 1.0 { 62 | self.frames_per_second = self.frame_count as f64 / elapsed; 63 | self.last_frame_update = now; 64 | self.frame_count = 0; 65 | } 66 | Ok(()) 67 | } 68 | } 69 | 70 | impl Component for FpsCounter { 71 | fn update(&mut self, action: Action) -> Result> { 72 | match action { 73 | Action::Tick => self.app_tick()?, 74 | Action::Render => self.render_tick()?, 75 | _ => {} 76 | }; 77 | Ok(None) 78 | } 79 | 80 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 81 | let [top, _] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area); 82 | let message = format!( 83 | "{:.2} ticks/sec, {:.2} FPS", 84 | self.ticks_per_second, self.frames_per_second 85 | ); 86 | let span = Span::styled(message, Style::new().dim()); 87 | let paragraph = Paragraph::new(span).right_aligned(); 88 | frame.render_widget(paragraph, top); 89 | Ok(()) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use color_eyre::Result; 4 | use tracing::error; 5 | 6 | pub fn init() -> Result<()> { 7 | let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() 8 | .panic_section(format!( 9 | "This is a bug. Consider reporting it at {}", 10 | env!("CARGO_PKG_REPOSITORY") 11 | )) 12 | .capture_span_trace_by_default(false) 13 | .display_location_section(false) 14 | .display_env_section(false) 15 | .into_hooks(); 16 | eyre_hook.install()?; 17 | std::panic::set_hook(Box::new(move |panic_info| { 18 | if let Ok(mut t) = crate::tui::Tui::new() { 19 | if let Err(r) = t.exit() { 20 | error!("Unable to exit Terminal: {:?}", r); 21 | } 22 | } 23 | 24 | #[cfg(not(debug_assertions))] 25 | { 26 | use human_panic::{handle_dump, metadata, print_msg}; 27 | let metadata = metadata!(); 28 | let file_path = handle_dump(&metadata, panic_info); 29 | // prints human-panic message 30 | print_msg(file_path, &metadata) 31 | .expect("human-panic: printing error message to console failed"); 32 | eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr 33 | } 34 | let msg = format!("{}", panic_hook.panic_report(panic_info)); 35 | error!("Error: {}", strip_ansi_escapes::strip_str(msg)); 36 | 37 | #[cfg(debug_assertions)] 38 | { 39 | // Better Panic stacktrace that is only enabled when debugging. 40 | better_panic::Settings::auto() 41 | .most_recent_first(false) 42 | .lineno_suffix(true) 43 | .verbosity(better_panic::Verbosity::Full) 44 | .create_panic_handler()(panic_info); 45 | } 46 | 47 | std::process::exit(libc::EXIT_FAILURE); 48 | })); 49 | Ok(()) 50 | } 51 | 52 | /// Similar to the `std::dbg!` macro, but generates `tracing` events rather 53 | /// than printing to stdout. 54 | /// 55 | /// By default, the verbosity level for the generated events is `DEBUG`, but 56 | /// this can be customized. 57 | #[macro_export] 58 | macro_rules! trace_dbg { 59 | (target: $target:expr, level: $level:expr, $ex:expr) => {{ 60 | match $ex { 61 | value => { 62 | tracing::event!(target: $target, $level, ?value, stringify!($ex)); 63 | value 64 | } 65 | } 66 | }}; 67 | (level: $level:expr, $ex:expr) => { 68 | trace_dbg!(target: module_path!(), level: $level, $ex) 69 | }; 70 | (target: $target:expr, $ex:expr) => { 71 | trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) 72 | }; 73 | ($ex:expr) => { 74 | trace_dbg!(level: tracing::Level::DEBUG, $ex) 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /src/components.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use crossterm::event::{KeyEvent, MouseEvent}; 3 | use ratatui::{ 4 | layout::{Rect, Size}, 5 | Frame, 6 | }; 7 | use tokio::sync::mpsc::UnboundedSender; 8 | 9 | use crate::{action::Action, config::Config, tui::Event}; 10 | 11 | pub mod fps; 12 | pub mod home; 13 | 14 | /// `Component` is a trait that represents a visual and interactive element of the user interface. 15 | /// 16 | /// Implementors of this trait can be registered with the main application loop and will be able to 17 | /// receive events, update state, and be rendered on the screen. 18 | pub trait Component { 19 | /// Register an action handler that can send actions for processing if necessary. 20 | /// 21 | /// # Arguments 22 | /// 23 | /// * `tx` - An unbounded sender that can send actions. 24 | /// 25 | /// # Returns 26 | /// 27 | /// * `Result<()>` - An Ok result or an error. 28 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 29 | let _ = tx; // to appease clippy 30 | Ok(()) 31 | } 32 | /// Register a configuration handler that provides configuration settings if necessary. 33 | /// 34 | /// # Arguments 35 | /// 36 | /// * `config` - Configuration settings. 37 | /// 38 | /// # Returns 39 | /// 40 | /// * `Result<()>` - An Ok result or an error. 41 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 42 | let _ = config; // to appease clippy 43 | Ok(()) 44 | } 45 | /// Initialize the component with a specified area if necessary. 46 | /// 47 | /// # Arguments 48 | /// 49 | /// * `area` - Rectangular area to initialize the component within. 50 | /// 51 | /// # Returns 52 | /// 53 | /// * `Result<()>` - An Ok result or an error. 54 | fn init(&mut self, area: Size) -> Result<()> { 55 | let _ = area; // to appease clippy 56 | Ok(()) 57 | } 58 | /// Handle incoming events and produce actions if necessary. 59 | /// 60 | /// # Arguments 61 | /// 62 | /// * `event` - An optional event to be processed. 63 | /// 64 | /// # Returns 65 | /// 66 | /// * `Result>` - An action to be processed or none. 67 | fn handle_events(&mut self, event: Option) -> Result> { 68 | let action = match event { 69 | Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, 70 | Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, 71 | _ => None, 72 | }; 73 | Ok(action) 74 | } 75 | /// Handle key events and produce actions if necessary. 76 | /// 77 | /// # Arguments 78 | /// 79 | /// * `key` - A key event to be processed. 80 | /// 81 | /// # Returns 82 | /// 83 | /// * `Result>` - An action to be processed or none. 84 | fn handle_key_event(&mut self, key: KeyEvent) -> Result> { 85 | let _ = key; // to appease clippy 86 | Ok(None) 87 | } 88 | /// Handle mouse events and produce actions if necessary. 89 | /// 90 | /// # Arguments 91 | /// 92 | /// * `mouse` - A mouse event to be processed. 93 | /// 94 | /// # Returns 95 | /// 96 | /// * `Result>` - An action to be processed or none. 97 | fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result> { 98 | let _ = mouse; // to appease clippy 99 | Ok(None) 100 | } 101 | /// Update the state of the component based on a received action. (REQUIRED) 102 | /// 103 | /// # Arguments 104 | /// 105 | /// * `action` - An action that may modify the state of the component. 106 | /// 107 | /// # Returns 108 | /// 109 | /// * `Result>` - An action to be processed or none. 110 | fn update(&mut self, action: Action) -> Result> { 111 | let _ = action; // to appease clippy 112 | Ok(None) 113 | } 114 | /// Render the component on the screen. (REQUIRED) 115 | /// 116 | /// # Arguments 117 | /// 118 | /// * `f` - A frame used for rendering. 119 | /// * `area` - The area in which the component should be drawn. 120 | /// 121 | /// # Returns 122 | /// 123 | /// * `Result<()>` - An Ok result or an error. 124 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>; 125 | } 126 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use crossterm::event::KeyEvent; 3 | use ratatui::prelude::Rect; 4 | use serde::{Deserialize, Serialize}; 5 | use tokio::sync::mpsc; 6 | use tracing::{debug, info}; 7 | 8 | use crate::{ 9 | action::Action, 10 | components::{fps::FpsCounter, home::Home, Component}, 11 | config::Config, 12 | tui::{Event, Tui}, 13 | }; 14 | 15 | pub struct App { 16 | config: Config, 17 | tick_rate: f64, 18 | frame_rate: f64, 19 | components: Vec>, 20 | should_quit: bool, 21 | should_suspend: bool, 22 | mode: Mode, 23 | last_tick_key_events: Vec, 24 | action_tx: mpsc::UnboundedSender, 25 | action_rx: mpsc::UnboundedReceiver, 26 | } 27 | 28 | #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 29 | pub enum Mode { 30 | #[default] 31 | Home, 32 | } 33 | 34 | impl App { 35 | pub fn new(tick_rate: f64, frame_rate: f64) -> Result { 36 | let (action_tx, action_rx) = mpsc::unbounded_channel(); 37 | Ok(Self { 38 | tick_rate, 39 | frame_rate, 40 | components: vec![Box::new(Home::new()), Box::new(FpsCounter::default())], 41 | should_quit: false, 42 | should_suspend: false, 43 | config: Config::new()?, 44 | mode: Mode::Home, 45 | last_tick_key_events: Vec::new(), 46 | action_tx, 47 | action_rx, 48 | }) 49 | } 50 | 51 | pub async fn run(&mut self) -> Result<()> { 52 | let mut tui = Tui::new()? 53 | // .mouse(true) // uncomment this line to enable mouse support 54 | .tick_rate(self.tick_rate) 55 | .frame_rate(self.frame_rate); 56 | tui.enter()?; 57 | 58 | for component in self.components.iter_mut() { 59 | component.register_action_handler(self.action_tx.clone())?; 60 | } 61 | for component in self.components.iter_mut() { 62 | component.register_config_handler(self.config.clone())?; 63 | } 64 | for component in self.components.iter_mut() { 65 | component.init(tui.size()?)?; 66 | } 67 | 68 | let action_tx = self.action_tx.clone(); 69 | loop { 70 | self.handle_events(&mut tui).await?; 71 | self.handle_actions(&mut tui)?; 72 | if self.should_suspend { 73 | tui.suspend()?; 74 | action_tx.send(Action::Resume)?; 75 | action_tx.send(Action::ClearScreen)?; 76 | // tui.mouse(true); 77 | tui.enter()?; 78 | } else if self.should_quit { 79 | tui.stop()?; 80 | break; 81 | } 82 | } 83 | tui.exit()?; 84 | Ok(()) 85 | } 86 | 87 | async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> { 88 | let Some(event) = tui.next_event().await else { 89 | return Ok(()); 90 | }; 91 | let action_tx = self.action_tx.clone(); 92 | match event { 93 | Event::Quit => action_tx.send(Action::Quit)?, 94 | Event::Tick => action_tx.send(Action::Tick)?, 95 | Event::Render => action_tx.send(Action::Render)?, 96 | Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, 97 | Event::Key(key) => self.handle_key_event(key)?, 98 | _ => {} 99 | } 100 | for component in self.components.iter_mut() { 101 | if let Some(action) = component.handle_events(Some(event.clone()))? { 102 | action_tx.send(action)?; 103 | } 104 | } 105 | Ok(()) 106 | } 107 | 108 | fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { 109 | let action_tx = self.action_tx.clone(); 110 | let Some(keymap) = self.config.keybindings.get(&self.mode) else { 111 | return Ok(()); 112 | }; 113 | match keymap.get(&vec![key]) { 114 | Some(action) => { 115 | info!("Got action: {action:?}"); 116 | action_tx.send(action.clone())?; 117 | } 118 | _ => { 119 | // If the key was not handled as a single key action, 120 | // then consider it for multi-key combinations. 121 | self.last_tick_key_events.push(key); 122 | 123 | // Check for multi-key combinations 124 | if let Some(action) = keymap.get(&self.last_tick_key_events) { 125 | info!("Got action: {action:?}"); 126 | action_tx.send(action.clone())?; 127 | } 128 | } 129 | } 130 | Ok(()) 131 | } 132 | 133 | fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> { 134 | while let Ok(action) = self.action_rx.try_recv() { 135 | if action != Action::Tick && action != Action::Render { 136 | debug!("{action:?}"); 137 | } 138 | match action { 139 | Action::Tick => { 140 | self.last_tick_key_events.drain(..); 141 | } 142 | Action::Quit => self.should_quit = true, 143 | Action::Suspend => self.should_suspend = true, 144 | Action::Resume => self.should_suspend = false, 145 | Action::ClearScreen => tui.terminal.clear()?, 146 | Action::Resize(w, h) => self.handle_resize(tui, w, h)?, 147 | Action::Render => self.render(tui)?, 148 | _ => {} 149 | } 150 | for component in self.components.iter_mut() { 151 | if let Some(action) = component.update(action.clone())? { 152 | self.action_tx.send(action)? 153 | }; 154 | } 155 | } 156 | Ok(()) 157 | } 158 | 159 | fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> { 160 | tui.resize(Rect::new(0, 0, w, h))?; 161 | self.render(tui)?; 162 | Ok(()) 163 | } 164 | 165 | fn render(&mut self, tui: &mut Tui) -> Result<()> { 166 | tui.draw(|frame| { 167 | for component in self.components.iter_mut() { 168 | if let Err(err) = component.draw(frame, frame.area()) { 169 | let _ = self 170 | .action_tx 171 | .send(Action::Error(format!("Failed to draw: {:?}", err))); 172 | } 173 | } 174 | })?; 175 | Ok(()) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/tui.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] // Remove this once you start using the code 2 | 3 | use std::{ 4 | io::{stdout, Stdout}, 5 | ops::{Deref, DerefMut}, 6 | time::Duration, 7 | }; 8 | 9 | use color_eyre::Result; 10 | use crossterm::{ 11 | cursor, 12 | event::{ 13 | DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, 14 | Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent, 15 | }, 16 | terminal::{EnterAlternateScreen, LeaveAlternateScreen}, 17 | }; 18 | use futures::{FutureExt, StreamExt}; 19 | use ratatui::backend::CrosstermBackend as Backend; 20 | use serde::{Deserialize, Serialize}; 21 | use tokio::{ 22 | sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, 23 | task::JoinHandle, 24 | time::interval, 25 | }; 26 | use tokio_util::sync::CancellationToken; 27 | use tracing::error; 28 | 29 | #[derive(Clone, Debug, Serialize, Deserialize)] 30 | pub enum Event { 31 | Init, 32 | Quit, 33 | Error, 34 | Closed, 35 | Tick, 36 | Render, 37 | FocusGained, 38 | FocusLost, 39 | Paste(String), 40 | Key(KeyEvent), 41 | Mouse(MouseEvent), 42 | Resize(u16, u16), 43 | } 44 | 45 | pub struct Tui { 46 | pub terminal: ratatui::Terminal>, 47 | pub task: JoinHandle<()>, 48 | pub cancellation_token: CancellationToken, 49 | pub event_rx: UnboundedReceiver, 50 | pub event_tx: UnboundedSender, 51 | pub frame_rate: f64, 52 | pub tick_rate: f64, 53 | pub mouse: bool, 54 | pub paste: bool, 55 | } 56 | 57 | impl Tui { 58 | pub fn new() -> Result { 59 | let (event_tx, event_rx) = mpsc::unbounded_channel(); 60 | Ok(Self { 61 | terminal: ratatui::Terminal::new(Backend::new(stdout()))?, 62 | task: tokio::spawn(async {}), 63 | cancellation_token: CancellationToken::new(), 64 | event_rx, 65 | event_tx, 66 | frame_rate: 60.0, 67 | tick_rate: 4.0, 68 | mouse: false, 69 | paste: false, 70 | }) 71 | } 72 | 73 | pub fn tick_rate(mut self, tick_rate: f64) -> Self { 74 | self.tick_rate = tick_rate; 75 | self 76 | } 77 | 78 | pub fn frame_rate(mut self, frame_rate: f64) -> Self { 79 | self.frame_rate = frame_rate; 80 | self 81 | } 82 | 83 | pub fn mouse(mut self, mouse: bool) -> Self { 84 | self.mouse = mouse; 85 | self 86 | } 87 | 88 | pub fn paste(mut self, paste: bool) -> Self { 89 | self.paste = paste; 90 | self 91 | } 92 | 93 | pub fn start(&mut self) { 94 | self.cancel(); // Cancel any existing task 95 | self.cancellation_token = CancellationToken::new(); 96 | let event_loop = Self::event_loop( 97 | self.event_tx.clone(), 98 | self.cancellation_token.clone(), 99 | self.tick_rate, 100 | self.frame_rate, 101 | ); 102 | self.task = tokio::spawn(async { 103 | event_loop.await; 104 | }); 105 | } 106 | 107 | async fn event_loop( 108 | event_tx: UnboundedSender, 109 | cancellation_token: CancellationToken, 110 | tick_rate: f64, 111 | frame_rate: f64, 112 | ) { 113 | let mut event_stream = EventStream::new(); 114 | let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); 115 | let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); 116 | 117 | // if this fails, then it's likely a bug in the calling code 118 | event_tx 119 | .send(Event::Init) 120 | .expect("failed to send init event"); 121 | loop { 122 | let event = tokio::select! { 123 | _ = cancellation_token.cancelled() => { 124 | break; 125 | } 126 | _ = tick_interval.tick() => Event::Tick, 127 | _ = render_interval.tick() => Event::Render, 128 | crossterm_event = event_stream.next().fuse() => match crossterm_event { 129 | Some(Ok(event)) => match event { 130 | CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), 131 | CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), 132 | CrosstermEvent::Resize(x, y) => Event::Resize(x, y), 133 | CrosstermEvent::FocusLost => Event::FocusLost, 134 | CrosstermEvent::FocusGained => Event::FocusGained, 135 | CrosstermEvent::Paste(s) => Event::Paste(s), 136 | _ => continue, // ignore other events 137 | } 138 | Some(Err(_)) => Event::Error, 139 | None => break, // the event stream has stopped and will not produce any more events 140 | }, 141 | }; 142 | if event_tx.send(event).is_err() { 143 | // the receiver has been dropped, so there's no point in continuing the loop 144 | break; 145 | } 146 | } 147 | cancellation_token.cancel(); 148 | } 149 | 150 | pub fn stop(&self) -> Result<()> { 151 | self.cancel(); 152 | let mut counter = 0; 153 | while !self.task.is_finished() { 154 | std::thread::sleep(Duration::from_millis(1)); 155 | counter += 1; 156 | if counter > 50 { 157 | self.task.abort(); 158 | } 159 | if counter > 100 { 160 | error!("Failed to abort task in 100 milliseconds for unknown reason"); 161 | break; 162 | } 163 | } 164 | Ok(()) 165 | } 166 | 167 | pub fn enter(&mut self) -> Result<()> { 168 | crossterm::terminal::enable_raw_mode()?; 169 | crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; 170 | if self.mouse { 171 | crossterm::execute!(stdout(), EnableMouseCapture)?; 172 | } 173 | if self.paste { 174 | crossterm::execute!(stdout(), EnableBracketedPaste)?; 175 | } 176 | self.start(); 177 | Ok(()) 178 | } 179 | 180 | pub fn exit(&mut self) -> Result<()> { 181 | self.stop()?; 182 | if crossterm::terminal::is_raw_mode_enabled()? { 183 | self.flush()?; 184 | if self.paste { 185 | crossterm::execute!(stdout(), DisableBracketedPaste)?; 186 | } 187 | if self.mouse { 188 | crossterm::execute!(stdout(), DisableMouseCapture)?; 189 | } 190 | crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; 191 | crossterm::terminal::disable_raw_mode()?; 192 | } 193 | Ok(()) 194 | } 195 | 196 | pub fn cancel(&self) { 197 | self.cancellation_token.cancel(); 198 | } 199 | 200 | pub fn suspend(&mut self) -> Result<()> { 201 | self.exit()?; 202 | #[cfg(not(windows))] 203 | signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; 204 | Ok(()) 205 | } 206 | 207 | pub fn resume(&mut self) -> Result<()> { 208 | self.enter()?; 209 | Ok(()) 210 | } 211 | 212 | pub async fn next_event(&mut self) -> Option { 213 | self.event_rx.recv().await 214 | } 215 | } 216 | 217 | impl Deref for Tui { 218 | type Target = ratatui::Terminal>; 219 | 220 | fn deref(&self) -> &Self::Target { 221 | &self.terminal 222 | } 223 | } 224 | 225 | impl DerefMut for Tui { 226 | fn deref_mut(&mut self) -> &mut Self::Target { 227 | &mut self.terminal 228 | } 229 | } 230 | 231 | impl Drop for Tui { 232 | fn drop(&mut self) { 233 | self.exit().unwrap(); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] // Remove this once you start using the code 2 | 3 | use std::{collections::HashMap, env, path::PathBuf}; 4 | 5 | use color_eyre::Result; 6 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 7 | use derive_deref::{Deref, DerefMut}; 8 | use directories::ProjectDirs; 9 | use lazy_static::lazy_static; 10 | use ratatui::style::{Color, Modifier, Style}; 11 | use serde::{de::Deserializer, Deserialize}; 12 | use tracing::error; 13 | 14 | use crate::{action::Action, app::Mode}; 15 | 16 | const CONFIG: &str = include_str!("../.config/config.json5"); 17 | 18 | #[derive(Clone, Debug, Deserialize, Default)] 19 | pub struct AppConfig { 20 | #[serde(default)] 21 | pub data_dir: PathBuf, 22 | #[serde(default)] 23 | pub config_dir: PathBuf, 24 | } 25 | 26 | #[derive(Clone, Debug, Default, Deserialize)] 27 | pub struct Config { 28 | #[serde(default, flatten)] 29 | pub config: AppConfig, 30 | #[serde(default)] 31 | pub keybindings: KeyBindings, 32 | #[serde(default)] 33 | pub styles: Styles, 34 | } 35 | 36 | lazy_static! { 37 | pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); 38 | pub static ref DATA_FOLDER: Option = 39 | env::var(format!("{}_DATA", PROJECT_NAME.clone())) 40 | .ok() 41 | .map(PathBuf::from); 42 | pub static ref CONFIG_FOLDER: Option = 43 | env::var(format!("{}_CONFIG", PROJECT_NAME.clone())) 44 | .ok() 45 | .map(PathBuf::from); 46 | } 47 | 48 | impl Config { 49 | pub fn new() -> Result { 50 | let default_config: Config = json5::from_str(CONFIG).unwrap(); 51 | let data_dir = get_data_dir(); 52 | let config_dir = get_config_dir(); 53 | let mut builder = config::Config::builder() 54 | .set_default("data_dir", data_dir.to_str().unwrap())? 55 | .set_default("config_dir", config_dir.to_str().unwrap())?; 56 | 57 | let config_files = [ 58 | ("config.json5", config::FileFormat::Json5), 59 | ("config.json", config::FileFormat::Json), 60 | ("config.yaml", config::FileFormat::Yaml), 61 | ("config.toml", config::FileFormat::Toml), 62 | ("config.ini", config::FileFormat::Ini), 63 | ]; 64 | let mut found_config = false; 65 | for (file, format) in &config_files { 66 | let source = config::File::from(config_dir.join(file)) 67 | .format(*format) 68 | .required(false); 69 | builder = builder.add_source(source); 70 | if config_dir.join(file).exists() { 71 | found_config = true 72 | } 73 | } 74 | if !found_config { 75 | error!("No configuration file found. Application may not behave as expected"); 76 | } 77 | 78 | let mut cfg: Self = builder.build()?.try_deserialize()?; 79 | 80 | for (mode, default_bindings) in default_config.keybindings.iter() { 81 | let user_bindings = cfg.keybindings.entry(*mode).or_default(); 82 | for (key, cmd) in default_bindings.iter() { 83 | user_bindings 84 | .entry(key.clone()) 85 | .or_insert_with(|| cmd.clone()); 86 | } 87 | } 88 | for (mode, default_styles) in default_config.styles.iter() { 89 | let user_styles = cfg.styles.entry(*mode).or_default(); 90 | for (style_key, style) in default_styles.iter() { 91 | user_styles.entry(style_key.clone()).or_insert(*style); 92 | } 93 | } 94 | 95 | Ok(cfg) 96 | } 97 | } 98 | 99 | pub fn get_data_dir() -> PathBuf { 100 | let directory = if let Some(s) = DATA_FOLDER.clone() { 101 | s 102 | } else if let Some(proj_dirs) = project_directory() { 103 | proj_dirs.data_local_dir().to_path_buf() 104 | } else { 105 | PathBuf::from(".").join(".data") 106 | }; 107 | directory 108 | } 109 | 110 | pub fn get_config_dir() -> PathBuf { 111 | let directory = if let Some(s) = CONFIG_FOLDER.clone() { 112 | s 113 | } else if let Some(proj_dirs) = project_directory() { 114 | proj_dirs.config_local_dir().to_path_buf() 115 | } else { 116 | PathBuf::from(".").join(".config") 117 | }; 118 | directory 119 | } 120 | 121 | fn project_directory() -> Option { 122 | ProjectDirs::from("com", "kdheepak", env!("CARGO_PKG_NAME")) 123 | } 124 | 125 | #[derive(Clone, Debug, Default, Deref, DerefMut)] 126 | pub struct KeyBindings(pub HashMap, Action>>); 127 | 128 | impl<'de> Deserialize<'de> for KeyBindings { 129 | fn deserialize(deserializer: D) -> Result 130 | where 131 | D: Deserializer<'de>, 132 | { 133 | let parsed_map = HashMap::>::deserialize(deserializer)?; 134 | 135 | let keybindings = parsed_map 136 | .into_iter() 137 | .map(|(mode, inner_map)| { 138 | let converted_inner_map = inner_map 139 | .into_iter() 140 | .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)) 141 | .collect(); 142 | (mode, converted_inner_map) 143 | }) 144 | .collect(); 145 | 146 | Ok(KeyBindings(keybindings)) 147 | } 148 | } 149 | 150 | fn parse_key_event(raw: &str) -> Result { 151 | let raw_lower = raw.to_ascii_lowercase(); 152 | let (remaining, modifiers) = extract_modifiers(&raw_lower); 153 | parse_key_code_with_modifiers(remaining, modifiers) 154 | } 155 | 156 | fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) { 157 | let mut modifiers = KeyModifiers::empty(); 158 | let mut current = raw; 159 | 160 | loop { 161 | match current { 162 | rest if rest.starts_with("ctrl-") => { 163 | modifiers.insert(KeyModifiers::CONTROL); 164 | current = &rest[5..]; 165 | } 166 | rest if rest.starts_with("alt-") => { 167 | modifiers.insert(KeyModifiers::ALT); 168 | current = &rest[4..]; 169 | } 170 | rest if rest.starts_with("shift-") => { 171 | modifiers.insert(KeyModifiers::SHIFT); 172 | current = &rest[6..]; 173 | } 174 | _ => break, // break out of the loop if no known prefix is detected 175 | }; 176 | } 177 | 178 | (current, modifiers) 179 | } 180 | 181 | fn parse_key_code_with_modifiers( 182 | raw: &str, 183 | mut modifiers: KeyModifiers, 184 | ) -> Result { 185 | let c = match raw { 186 | "esc" => KeyCode::Esc, 187 | "enter" => KeyCode::Enter, 188 | "left" => KeyCode::Left, 189 | "right" => KeyCode::Right, 190 | "up" => KeyCode::Up, 191 | "down" => KeyCode::Down, 192 | "home" => KeyCode::Home, 193 | "end" => KeyCode::End, 194 | "pageup" => KeyCode::PageUp, 195 | "pagedown" => KeyCode::PageDown, 196 | "backtab" => { 197 | modifiers.insert(KeyModifiers::SHIFT); 198 | KeyCode::BackTab 199 | } 200 | "backspace" => KeyCode::Backspace, 201 | "delete" => KeyCode::Delete, 202 | "insert" => KeyCode::Insert, 203 | "f1" => KeyCode::F(1), 204 | "f2" => KeyCode::F(2), 205 | "f3" => KeyCode::F(3), 206 | "f4" => KeyCode::F(4), 207 | "f5" => KeyCode::F(5), 208 | "f6" => KeyCode::F(6), 209 | "f7" => KeyCode::F(7), 210 | "f8" => KeyCode::F(8), 211 | "f9" => KeyCode::F(9), 212 | "f10" => KeyCode::F(10), 213 | "f11" => KeyCode::F(11), 214 | "f12" => KeyCode::F(12), 215 | "space" => KeyCode::Char(' '), 216 | "hyphen" => KeyCode::Char('-'), 217 | "minus" => KeyCode::Char('-'), 218 | "tab" => KeyCode::Tab, 219 | c if c.len() == 1 => { 220 | let mut c = c.chars().next().unwrap(); 221 | if modifiers.contains(KeyModifiers::SHIFT) { 222 | c = c.to_ascii_uppercase(); 223 | } 224 | KeyCode::Char(c) 225 | } 226 | _ => return Err(format!("Unable to parse {raw}")), 227 | }; 228 | Ok(KeyEvent::new(c, modifiers)) 229 | } 230 | 231 | pub fn key_event_to_string(key_event: &KeyEvent) -> String { 232 | let char; 233 | let key_code = match key_event.code { 234 | KeyCode::Backspace => "backspace", 235 | KeyCode::Enter => "enter", 236 | KeyCode::Left => "left", 237 | KeyCode::Right => "right", 238 | KeyCode::Up => "up", 239 | KeyCode::Down => "down", 240 | KeyCode::Home => "home", 241 | KeyCode::End => "end", 242 | KeyCode::PageUp => "pageup", 243 | KeyCode::PageDown => "pagedown", 244 | KeyCode::Tab => "tab", 245 | KeyCode::BackTab => "backtab", 246 | KeyCode::Delete => "delete", 247 | KeyCode::Insert => "insert", 248 | KeyCode::F(c) => { 249 | char = format!("f({c})"); 250 | &char 251 | } 252 | KeyCode::Char(' ') => "space", 253 | KeyCode::Char(c) => { 254 | char = c.to_string(); 255 | &char 256 | } 257 | KeyCode::Esc => "esc", 258 | KeyCode::Null => "", 259 | KeyCode::CapsLock => "", 260 | KeyCode::Menu => "", 261 | KeyCode::ScrollLock => "", 262 | KeyCode::Media(_) => "", 263 | KeyCode::NumLock => "", 264 | KeyCode::PrintScreen => "", 265 | KeyCode::Pause => "", 266 | KeyCode::KeypadBegin => "", 267 | KeyCode::Modifier(_) => "", 268 | }; 269 | 270 | let mut modifiers = Vec::with_capacity(3); 271 | 272 | if key_event.modifiers.intersects(KeyModifiers::CONTROL) { 273 | modifiers.push("ctrl"); 274 | } 275 | 276 | if key_event.modifiers.intersects(KeyModifiers::SHIFT) { 277 | modifiers.push("shift"); 278 | } 279 | 280 | if key_event.modifiers.intersects(KeyModifiers::ALT) { 281 | modifiers.push("alt"); 282 | } 283 | 284 | let mut key = modifiers.join("-"); 285 | 286 | if !key.is_empty() { 287 | key.push('-'); 288 | } 289 | key.push_str(key_code); 290 | 291 | key 292 | } 293 | 294 | pub fn parse_key_sequence(raw: &str) -> Result, String> { 295 | if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { 296 | return Err(format!("Unable to parse `{}`", raw)); 297 | } 298 | let raw = if !raw.contains("><") { 299 | let raw = raw.strip_prefix('<').unwrap_or(raw); 300 | let raw = raw.strip_prefix('>').unwrap_or(raw); 301 | raw 302 | } else { 303 | raw 304 | }; 305 | let sequences = raw 306 | .split("><") 307 | .map(|seq| { 308 | if let Some(s) = seq.strip_prefix('<') { 309 | s 310 | } else if let Some(s) = seq.strip_suffix('>') { 311 | s 312 | } else { 313 | seq 314 | } 315 | }) 316 | .collect::>(); 317 | 318 | sequences.into_iter().map(parse_key_event).collect() 319 | } 320 | 321 | #[derive(Clone, Debug, Default, Deref, DerefMut)] 322 | pub struct Styles(pub HashMap>); 323 | 324 | impl<'de> Deserialize<'de> for Styles { 325 | fn deserialize(deserializer: D) -> Result 326 | where 327 | D: Deserializer<'de>, 328 | { 329 | let parsed_map = HashMap::>::deserialize(deserializer)?; 330 | 331 | let styles = parsed_map 332 | .into_iter() 333 | .map(|(mode, inner_map)| { 334 | let converted_inner_map = inner_map 335 | .into_iter() 336 | .map(|(str, style)| (str, parse_style(&style))) 337 | .collect(); 338 | (mode, converted_inner_map) 339 | }) 340 | .collect(); 341 | 342 | Ok(Styles(styles)) 343 | } 344 | } 345 | 346 | pub fn parse_style(line: &str) -> Style { 347 | let (foreground, background) = 348 | line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len())); 349 | let foreground = process_color_string(foreground); 350 | let background = process_color_string(&background.replace("on ", "")); 351 | 352 | let mut style = Style::default(); 353 | if let Some(fg) = parse_color(&foreground.0) { 354 | style = style.fg(fg); 355 | } 356 | if let Some(bg) = parse_color(&background.0) { 357 | style = style.bg(bg); 358 | } 359 | style = style.add_modifier(foreground.1 | background.1); 360 | style 361 | } 362 | 363 | fn process_color_string(color_str: &str) -> (String, Modifier) { 364 | let color = color_str 365 | .replace("grey", "gray") 366 | .replace("bright ", "") 367 | .replace("bold ", "") 368 | .replace("underline ", "") 369 | .replace("inverse ", ""); 370 | 371 | let mut modifiers = Modifier::empty(); 372 | if color_str.contains("underline") { 373 | modifiers |= Modifier::UNDERLINED; 374 | } 375 | if color_str.contains("bold") { 376 | modifiers |= Modifier::BOLD; 377 | } 378 | if color_str.contains("inverse") { 379 | modifiers |= Modifier::REVERSED; 380 | } 381 | 382 | (color, modifiers) 383 | } 384 | 385 | fn parse_color(s: &str) -> Option { 386 | let s = s.trim_start(); 387 | let s = s.trim_end(); 388 | if s.contains("bright color") { 389 | let s = s.trim_start_matches("bright "); 390 | let c = s 391 | .trim_start_matches("color") 392 | .parse::() 393 | .unwrap_or_default(); 394 | Some(Color::Indexed(c.wrapping_shl(8))) 395 | } else if s.contains("color") { 396 | let c = s 397 | .trim_start_matches("color") 398 | .parse::() 399 | .unwrap_or_default(); 400 | Some(Color::Indexed(c)) 401 | } else if s.contains("gray") { 402 | let c = 232 403 | + s.trim_start_matches("gray") 404 | .parse::() 405 | .unwrap_or_default(); 406 | Some(Color::Indexed(c)) 407 | } else if s.contains("rgb") { 408 | let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8; 409 | let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8; 410 | let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8; 411 | let c = 16 + red * 36 + green * 6 + blue; 412 | Some(Color::Indexed(c)) 413 | } else if s == "bold black" { 414 | Some(Color::Indexed(8)) 415 | } else if s == "bold red" { 416 | Some(Color::Indexed(9)) 417 | } else if s == "bold green" { 418 | Some(Color::Indexed(10)) 419 | } else if s == "bold yellow" { 420 | Some(Color::Indexed(11)) 421 | } else if s == "bold blue" { 422 | Some(Color::Indexed(12)) 423 | } else if s == "bold magenta" { 424 | Some(Color::Indexed(13)) 425 | } else if s == "bold cyan" { 426 | Some(Color::Indexed(14)) 427 | } else if s == "bold white" { 428 | Some(Color::Indexed(15)) 429 | } else if s == "black" { 430 | Some(Color::Indexed(0)) 431 | } else if s == "red" { 432 | Some(Color::Indexed(1)) 433 | } else if s == "green" { 434 | Some(Color::Indexed(2)) 435 | } else if s == "yellow" { 436 | Some(Color::Indexed(3)) 437 | } else if s == "blue" { 438 | Some(Color::Indexed(4)) 439 | } else if s == "magenta" { 440 | Some(Color::Indexed(5)) 441 | } else if s == "cyan" { 442 | Some(Color::Indexed(6)) 443 | } else if s == "white" { 444 | Some(Color::Indexed(7)) 445 | } else { 446 | None 447 | } 448 | } 449 | 450 | #[cfg(test)] 451 | mod tests { 452 | use pretty_assertions::assert_eq; 453 | 454 | use super::*; 455 | 456 | #[test] 457 | fn test_parse_style_default() { 458 | let style = parse_style(""); 459 | assert_eq!(style, Style::default()); 460 | } 461 | 462 | #[test] 463 | fn test_parse_style_foreground() { 464 | let style = parse_style("red"); 465 | assert_eq!(style.fg, Some(Color::Indexed(1))); 466 | } 467 | 468 | #[test] 469 | fn test_parse_style_background() { 470 | let style = parse_style("on blue"); 471 | assert_eq!(style.bg, Some(Color::Indexed(4))); 472 | } 473 | 474 | #[test] 475 | fn test_parse_style_modifiers() { 476 | let style = parse_style("underline red on blue"); 477 | assert_eq!(style.fg, Some(Color::Indexed(1))); 478 | assert_eq!(style.bg, Some(Color::Indexed(4))); 479 | } 480 | 481 | #[test] 482 | fn test_process_color_string() { 483 | let (color, modifiers) = process_color_string("underline bold inverse gray"); 484 | assert_eq!(color, "gray"); 485 | assert!(modifiers.contains(Modifier::UNDERLINED)); 486 | assert!(modifiers.contains(Modifier::BOLD)); 487 | assert!(modifiers.contains(Modifier::REVERSED)); 488 | } 489 | 490 | #[test] 491 | fn test_parse_color_rgb() { 492 | let color = parse_color("rgb123"); 493 | let expected = 16 + 36 + 2 * 6 + 3; 494 | assert_eq!(color, Some(Color::Indexed(expected))); 495 | } 496 | 497 | #[test] 498 | fn test_parse_color_unknown() { 499 | let color = parse_color("unknown"); 500 | assert_eq!(color, None); 501 | } 502 | 503 | #[test] 504 | fn test_config() -> Result<()> { 505 | let c = Config::new()?; 506 | assert_eq!( 507 | c.keybindings 508 | .get(&Mode::Home) 509 | .unwrap() 510 | .get(&parse_key_sequence("").unwrap_or_default()) 511 | .unwrap(), 512 | &Action::Quit 513 | ); 514 | Ok(()) 515 | } 516 | 517 | #[test] 518 | fn test_simple_keys() { 519 | assert_eq!( 520 | parse_key_event("a").unwrap(), 521 | KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()) 522 | ); 523 | 524 | assert_eq!( 525 | parse_key_event("enter").unwrap(), 526 | KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()) 527 | ); 528 | 529 | assert_eq!( 530 | parse_key_event("esc").unwrap(), 531 | KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()) 532 | ); 533 | } 534 | 535 | #[test] 536 | fn test_with_modifiers() { 537 | assert_eq!( 538 | parse_key_event("ctrl-a").unwrap(), 539 | KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) 540 | ); 541 | 542 | assert_eq!( 543 | parse_key_event("alt-enter").unwrap(), 544 | KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) 545 | ); 546 | 547 | assert_eq!( 548 | parse_key_event("shift-esc").unwrap(), 549 | KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT) 550 | ); 551 | } 552 | 553 | #[test] 554 | fn test_multiple_modifiers() { 555 | assert_eq!( 556 | parse_key_event("ctrl-alt-a").unwrap(), 557 | KeyEvent::new( 558 | KeyCode::Char('a'), 559 | KeyModifiers::CONTROL | KeyModifiers::ALT 560 | ) 561 | ); 562 | 563 | assert_eq!( 564 | parse_key_event("ctrl-shift-enter").unwrap(), 565 | KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) 566 | ); 567 | } 568 | 569 | #[test] 570 | fn test_reverse_multiple_modifiers() { 571 | assert_eq!( 572 | key_event_to_string(&KeyEvent::new( 573 | KeyCode::Char('a'), 574 | KeyModifiers::CONTROL | KeyModifiers::ALT 575 | )), 576 | "ctrl-alt-a".to_string() 577 | ); 578 | } 579 | 580 | #[test] 581 | fn test_invalid_keys() { 582 | assert!(parse_key_event("invalid-key").is_err()); 583 | assert!(parse_key_event("ctrl-invalid-key").is_err()); 584 | } 585 | 586 | #[test] 587 | fn test_case_insensitivity() { 588 | assert_eq!( 589 | parse_key_event("CTRL-a").unwrap(), 590 | KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) 591 | ); 592 | 593 | assert_eq!( 594 | parse_key_event("AlT-eNtEr").unwrap(), 595 | KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) 596 | ); 597 | } 598 | } 599 | --------------------------------------------------------------------------------