├── .gitignore ├── images ├── preview.gif └── social_preview.jpg ├── Cargo.toml ├── LICENSE ├── src ├── main.rs ├── input.rs ├── utils.rs ├── ui.rs └── app.rs ├── README.md └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /target -------------------------------------------------------------------------------- /images/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotellogical05/ttypr/HEAD/images/preview.gif -------------------------------------------------------------------------------- /images/social_preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotellogical05/ttypr/HEAD/images/social_preview.jpg -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ttypr" 3 | version = "0.3.5" 4 | edition = "2024" 5 | description = "terminal typing practice" 6 | license = "MIT" 7 | repository = "https://github.com/hotellogical05/ttypr" 8 | keywords = ["tui", "terminal", "ratatui", "typing", "practice"] 9 | 10 | [dependencies] 11 | ratatui = "0.29.0" 12 | crossterm = "0.28.1" 13 | color-eyre = "0.6.5" 14 | rand = "0.9.1" 15 | home = "0.5.9" 16 | serde = { version = "1.0.219", features = ["derive"] } 17 | toml = "0.8.23" 18 | sha2 = "0.10.9" 19 | 20 | [dev-dependencies] 21 | tempfile = "3.10.1" 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 hotellogical05 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/main.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use ratatui::DefaultTerminal; 3 | 4 | mod app; 5 | mod input; 6 | mod ui; 7 | mod utils; 8 | use crate::{ 9 | app::App, 10 | input::handle_events, 11 | ui::{draw_on_clear, render}, 12 | }; 13 | 14 | 15 | fn main() -> color_eyre::Result<()> { 16 | color_eyre::install()?; 17 | let terminal = ratatui::init(); 18 | let mut app = App::new(); 19 | let result = run(terminal, &mut app); 20 | 21 | app.on_exit(); 22 | 23 | // Restore the terminal and return the result from run() 24 | ratatui::restore(); 25 | result 26 | } 27 | 28 | fn run(mut terminal: DefaultTerminal, app: &mut App) -> Result<()> { 29 | app.setup()?; 30 | 31 | // Main application loop 32 | while app.running { 33 | app.on_tick(); 34 | 35 | // If the user typed 36 | if app.typed { 37 | app.update_id_field(); 38 | app.update_lines(); 39 | app.typed = false; 40 | } 41 | 42 | // Clear the entire area 43 | if app.needs_clear { 44 | terminal.draw(|frame| draw_on_clear(frame))?; 45 | app.needs_clear = false; 46 | app.needs_redraw = true; 47 | } 48 | 49 | // Draw/Redraw the ui 50 | if app.needs_redraw { 51 | terminal.draw(|frame| render(frame, app))?; 52 | app.needs_redraw = false; 53 | } 54 | 55 | // Read terminal events 56 | handle_events(app)?; 57 | } 58 | 59 | Ok(()) 60 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | ttypr - terminal typing practice 3 |
4 | 5 |
6 | 7 | **t**erminal **ty**ping **pr**actice 8 | 9 | _ttypr_ is a simple, lightweight typing practice application that runs in your terminal, built with [Rust](https://www.rust-lang.org/) and [Ratatui](https://ratatui.rs). 10 | 11 |
12 | 13 |
14 | 15 | [![Crates.io](https://img.shields.io/crates/v/ttypr?style=for-the-badge)](https://crates.io/crates/ttypr) 16 | [![GitHub repo](https://img.shields.io/badge/github-repo-blue?style=for-the-badge)](https://github.com/hotellogical05/ttypr) 17 | 18 |
19 | 20 | ## Features 21 | 22 | - **Multiple Typing Modes:** Practice with ASCII characters, random words, or your own text. 23 | - **Real-time Feedback:** Get immediate feedback on your accuracy and typing speed. 24 | - **Mistake Analysis:** Track your most commonly mistyped characters. 25 | - **Customizable:** Toggle notifications, character counting, and more. 26 | 27 | ## Preview 28 | 29 | ![](images/preview.gif) 30 | 31 | ## Installation 32 | 33 | ```shell 34 | cargo install ttypr 35 | ``` 36 | 37 | ## Usage 38 | 39 | > **Notes:** 40 | > 41 | > - The application starts in the **Menu mode**. 42 | > 43 | > - For larger font - increase the terminal font size. 44 | 45 | ### Menu mode: 46 | 47 | - **h** - display the help page 48 | - **q** - exit the application 49 | - **i** - switch to Typing mode 50 | - **o** - switch Typing option (ASCII, Words, Text) 51 | - **n** - toggle notifications 52 | - **c** - toggle counting mistyped characters 53 | - **w** - display top mistyped characters 54 | - **r** - clear mistyped characters count 55 | - **a** - toggle displaying WPM 56 | 57 | ### Typing mode: 58 | 59 | - **ESC** - switch to Menu mode 60 | - **Character keys** - Type the corresponding characters 61 | - **Backspace** - Remove characters 62 | 63 | ## Acknowledgements 64 | 65 | - [filipriec][FilipsGitLab] - creating a vector of styled Spans idea, if needs_redraw rendering concept 66 | - Concept taken from: [Monkeytype][MonkeytypeLink] 67 | 68 | ## License 69 | 70 | This project is licensed under the [MIT License][MITLicense]. 71 | 72 | [FilipsGitLab]: https://gitlab.com/filipriec 73 | [MonkeytypeLink]: https://monkeytype.com 74 | [MITLicense]: https://github.com/hotellogical05/ttypr/blob/main/LICENSE 75 | -------------------------------------------------------------------------------- /src/input.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{App, CurrentMode, CurrentTypingOption}; 2 | use crate::utils::{default_text, default_words}; 3 | use color_eyre::Result; 4 | use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; 5 | use std::collections::HashMap; 6 | 7 | /// Reads the terminal events. 8 | pub fn handle_events(app: &mut App) -> Result<()> { 9 | // Only wait for keyboard events for 50ms - otherwise continue the loop iteration 10 | if event::poll(std::time::Duration::from_millis(50))? { 11 | match event::read()? { 12 | Event::Key(key) if key.kind == KeyEventKind::Press => on_key_event(app, key), // Handle keyboard input 13 | Event::Mouse(_) => {} 14 | Event::Resize(_, _) => { 15 | app.needs_redraw = true; 16 | } // Re-render if terminal window resized 17 | _ => {} 18 | } 19 | } 20 | Ok(()) 21 | } 22 | 23 | /// Handles keyboard input. 24 | fn on_key_event(app: &mut App, key: KeyEvent) { 25 | // First boot page input (if toggled takes all input) 26 | // If Enter key is pressed sets first_boot to false in the config file 27 | if app.config.first_boot { 28 | match key.code { 29 | KeyCode::Enter => { 30 | app.config.first_boot = false; 31 | if let Ok(config_dir) = crate::utils::get_config_dir() { 32 | crate::utils::save_config(&app.config, &config_dir).unwrap_or_else(|err| { 33 | eprintln!("Failed to save config: {}", err); 34 | }); 35 | } 36 | app.needs_clear = true; 37 | app.needs_redraw = true; 38 | } 39 | _ => {} 40 | } 41 | return; 42 | } 43 | 44 | // Help page input (if toggled takes all input) 45 | if app.show_help { 46 | match key.code { 47 | KeyCode::Enter | KeyCode::Char('h') => { 48 | app.show_help = false; 49 | app.needs_clear = true; 50 | app.needs_redraw = true; 51 | } 52 | _ => {} 53 | } 54 | return; // Stop here 55 | } 56 | 57 | // Most mistyped page input (if toggled takes all input) 58 | if app.show_mistyped { 59 | match key.code { 60 | KeyCode::Enter | KeyCode::Char('w') => { 61 | app.show_mistyped = false; 62 | app.needs_clear = true; 63 | app.needs_redraw = true; 64 | } 65 | _ => {} 66 | } 67 | return; 68 | } 69 | 70 | match app.current_mode { 71 | // Menu mode input 72 | CurrentMode::Menu => { 73 | match key.code { 74 | // Exit the application 75 | KeyCode::Char('q') => app.quit(), 76 | 77 | // Toggle wpm notification 78 | KeyCode::Char('a') => { 79 | app.config.show_wpm_notification = !app.config.show_wpm_notification; 80 | app.notifications.show_display_wpm(); 81 | app.needs_redraw = true; 82 | } 83 | 84 | // Reset mistyped characters count 85 | KeyCode::Char('r') => { 86 | app.config.mistyped_chars = HashMap::new(); 87 | app.notifications.show_clear_mistyped(); 88 | app.needs_redraw = true; 89 | } 90 | 91 | // Show most mistyped page 92 | KeyCode::Char('w') => { 93 | app.show_mistyped = true; 94 | app.needs_clear = true; 95 | app.needs_redraw = true; 96 | } 97 | 98 | // Toggle counting mistyped characters 99 | KeyCode::Char('c') => { 100 | app.config.save_mistyped = !app.config.save_mistyped; 101 | app.notifications.show_mistyped(); 102 | app.needs_clear = true; 103 | app.needs_redraw = true; 104 | } 105 | 106 | // Toggle displaying notifications 107 | KeyCode::Char('n') => { 108 | app.config.show_notifications = !app.config.show_notifications; 109 | app.notifications.show_toggle(); 110 | app.needs_clear = true; 111 | app.needs_redraw = true; 112 | } 113 | 114 | // Show help page 115 | KeyCode::Char('h') => { 116 | app.show_help = true; 117 | app.needs_clear = true; 118 | app.needs_redraw = true; 119 | } 120 | 121 | // Typing option switch (ASCII, Words, Text) 122 | KeyCode::Char('o') => app.switch_typing_option(), 123 | 124 | // Switch to Typing mode 125 | KeyCode::Char('i') => { 126 | // Check for whether the words/text has anything 127 | // to prevent being able to switch to Typing mode 128 | // in info page if no words/text file was provided 129 | match app.current_typing_option { 130 | CurrentTypingOption::Words => { 131 | if app.words.len() == 0 { 132 | return; 133 | } 134 | } 135 | CurrentTypingOption::Text => { 136 | if app.text.len() == 0 { 137 | return; 138 | } 139 | } 140 | _ => {} 141 | } 142 | 143 | app.current_mode = CurrentMode::Typing; 144 | app.notifications.show_mode(); 145 | app.needs_redraw = true; 146 | } 147 | 148 | // If Enter is pressed in the Words/Text typing options, 149 | // with no words/text file provided - use the default set. 150 | KeyCode::Enter => { 151 | match app.current_typing_option { 152 | CurrentTypingOption::Words => { 153 | if app.words.is_empty() { 154 | // Get the default words set 155 | app.words = default_words(); 156 | 157 | // Generate three lines worth of words (characters) and ids. 158 | // Keep track of the length of those lines in characters. 159 | for _ in 0..3 { 160 | let one_line = app.gen_one_line_of_words(); 161 | app.populate_charset_from_line(one_line); 162 | } 163 | 164 | // Remember to use the default word set 165 | app.config.use_default_word_set = true; 166 | 167 | app.needs_redraw = true; 168 | } 169 | } 170 | CurrentTypingOption::Text => { 171 | // Only generate the lines if the text file was provided or the default text was chosen 172 | if app.text.is_empty() { 173 | // Get the default sentences 174 | app.text = default_text(); 175 | 176 | // Generate three lines worth of words (characters) and ids. 177 | // Keep track of the length of those lines in characters. 178 | for _ in 0..3 { 179 | let one_line = app.get_one_line_of_text(); 180 | 181 | // Count for how many "words" there were on the first three lines 182 | // to keep position on option switch and exit. 183 | // Otherwise would always skip 3 lines down. 184 | let first_text_gen_len: Vec = one_line 185 | .split_whitespace() 186 | .map(String::from) 187 | .collect(); 188 | app.first_text_gen_len += first_text_gen_len.len(); 189 | 190 | app.populate_charset_from_line(one_line); 191 | } 192 | 193 | // Remember to use the default text set 194 | app.config.use_default_text_set = true; 195 | 196 | app.needs_redraw = true; 197 | } 198 | } 199 | _ => {} 200 | } 201 | } 202 | _ => {} 203 | } 204 | } 205 | 206 | // Typing mode input 207 | CurrentMode::Typing => { 208 | match key.code { 209 | KeyCode::Esc => { 210 | // Switch to Menu mode if ESC pressed 211 | app.current_mode = CurrentMode::Menu; 212 | app.notifications.show_mode(); 213 | app.needs_redraw = true; 214 | } 215 | KeyCode::Char(c) => { 216 | // Add to input characters 217 | app.input_chars.push_back(c.to_string()); 218 | app.needs_redraw = true; 219 | app.typed = true; 220 | app.wpm.on_key_press(); 221 | } 222 | KeyCode::Backspace => { 223 | // Remove from input characters 224 | let position = app.input_chars.len(); 225 | if position > 0 { 226 | // If there are no input characters - don't do anything 227 | app.input_chars.pop_back(); 228 | app.ids[position - 1] = 0; 229 | app.needs_redraw = true; 230 | } 231 | } 232 | _ => {} 233 | } 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, fs, io, path::{Path, PathBuf}}; 2 | use serde::{ser::SerializeMap, Serialize, Deserialize, Serializer}; 3 | use sha2::{Sha256, Digest}; 4 | 5 | /// Config struct to store all config values, is a part of the App struct 6 | #[derive(Serialize, Deserialize)] 7 | pub struct Config { 8 | pub first_boot: bool, 9 | pub show_notifications: bool, 10 | pub show_wpm_notification: bool, 11 | #[serde(serialize_with = "serialize_sorted_by_value")] 12 | pub mistyped_chars: HashMap, 13 | pub save_mistyped: bool, 14 | pub skip_len: usize, 15 | pub use_default_word_set: bool, 16 | pub use_default_text_set: bool, 17 | pub last_text_txt_hash: Option>, 18 | } 19 | 20 | impl Default for Config { 21 | fn default() -> Self { 22 | Self { 23 | first_boot: true, 24 | show_notifications: true, 25 | show_wpm_notification: true, 26 | mistyped_chars: HashMap::new(), 27 | save_mistyped: true, 28 | skip_len: 0, // (For the text option) - To save position in the text 29 | use_default_word_set: false, 30 | use_default_text_set: false, 31 | last_text_txt_hash: None, 32 | } 33 | } 34 | } 35 | 36 | /// Takes a map of mistyped characters and returns them as a list 37 | /// sorted by count (descending) and then character (ascending). 38 | pub fn get_sorted_mistakes(map: &HashMap) -> Vec<(&String, &usize)> { 39 | let mut sorted: Vec<_> = map.iter().collect(); 40 | // Sort by value (count) descending, then by key (char) ascending for ties. 41 | sorted.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); 42 | sorted 43 | } 44 | 45 | /// Custom serializer that uses the reusable sorting logic 46 | fn serialize_sorted_by_value( 47 | map: &HashMap, 48 | serializer: S, 49 | ) -> Result 50 | where 51 | S: Serializer, 52 | { 53 | let sorted = get_sorted_mistakes(map); 54 | let mut map_serializer = serializer.serialize_map(Some(sorted.len()))?; 55 | for (key, value) in sorted { 56 | map_serializer.serialize_entry(key, value)?; 57 | } 58 | map_serializer.end() 59 | } 60 | 61 | /// Gets the application's configuration directory path. 62 | pub fn get_config_dir() -> io::Result { 63 | home::home_dir() 64 | .map(|path| path.join(".config/ttypr")) 65 | .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found")) 66 | } 67 | 68 | /// Loads config from a specified directory. 69 | /// If it doesn't exist, it creates a default config file. 70 | pub fn load_config(config_dir: &Path) -> Result> { 71 | let config_path = config_dir.join("config"); 72 | 73 | // Create the directory if it doesn't exist 74 | fs::create_dir_all(config_dir)?; 75 | 76 | // Check if file exists 77 | if !config_path.exists() { 78 | // If not, create it with default values 79 | let default_config = Config::default(); 80 | let toml_string = toml::to_string_pretty(&default_config)?; 81 | fs::write(&config_path, toml_string)?; 82 | return Ok(default_config); 83 | } 84 | 85 | // If it does exist, read, parse and return it 86 | let config_string = fs::read_to_string(config_path)?; 87 | let config: Config = toml::from_str(&config_string)?; 88 | Ok(config) 89 | } 90 | 91 | /// Saves the config to a specified directory. 92 | pub fn save_config(config: &Config, config_dir: &Path) -> Result<(), Box> { 93 | let config_path = config_dir.join("config"); 94 | let toml_string = toml::to_string_pretty(config)?; 95 | fs::write(config_path, toml_string)?; 96 | Ok(()) 97 | } 98 | 99 | /// Loads a list of items from a given file in a specified directory. 100 | fn load_items_from_file(dir: &Path, filename: &str) -> io::Result> { 101 | let file_path = dir.join(filename); 102 | let content = fs::read_to_string(file_path)?; 103 | let items = content 104 | .split_whitespace() 105 | .filter(|word| word.len() <= 50) 106 | .map(String::from) 107 | .collect(); 108 | Ok(items) 109 | } 110 | 111 | /// Reads the contents of words.txt from a specified directory. 112 | pub fn read_words_from_file(dir: &Path) -> io::Result> { 113 | load_items_from_file(dir, "words.txt") 114 | } 115 | 116 | /// Reads the contents of text.txt from a specified directory. 117 | pub fn read_text_from_file(dir: &Path) -> io::Result> { 118 | load_items_from_file(dir, "text.txt") 119 | } 120 | 121 | /// Just returns the default words set in a vector 122 | pub fn default_words() -> Vec { 123 | let default_words = vec!["the", "be", "to", "of", "and", "a", "in", "that", "have", "I", "it", "for", "not", "on", "with", "he", "as", "you", "do", "at", "this", "but", "his", "by", "from", "they", "we", "say", "her", "she", "or", "an", "will", "my", "one", "all", "would", "there", "their", "what", "so", "up", "out", "if", "about", "who", "get", "which", "go", "me", "when", "make", "can", "like", "time", "no", "just", "him", "know", "take", "people", "into", "year", "your", "good", "some", "could", "them", "see", "other", "than", "then", "now", "look", "only", "come", "over", "think", "also", "back", "after", "use", "two", "how", "our", "work", "first", "well", "way", "even", "new", "want", "because", "any", "these", "give", "day", "most", "us", "thing", "man", "find", "part", "eye", "place", "week", "case", "point", "government", "company", "number", "group", "problem", "fact", "leave", "while", "mean", "keep", "student", "great", "seem", "same", "tell", "begin", "help", "talk", "where", "turn", "start", "might", "show", "hear", "play", "run", "move", "live", "believe", "hold", "bring", "happen", "must", "write", "provide", "sit", "stand", "lose", "pay", "meet", "include", "continue", "set", "learn", "change", "lead", "understand", "watch", "follow", "stop", "create", "speak", "read", "allow", "add", "spend", "grow", "open", "walk", "win", "offer", "remember", "love", "consider", "appear", "buy", "wait", "serve", "die", "send", "expect", "build", "stay", "fall", "cut", "reach", "kill", "remain", "suggest", "raise", "pass", "sell", "require", "report", "decide", "pull", "return", "explain", "hope", "develop", "carry", "break", "receive", "agree", "support", "hit", "produce", "eat", "cover", "catch", "draw", "choose", "cause", "listen", "maybe", "until", "without", "probably", "around", "small", "green", "special", "difficult", "available", "likely", "short", "single", "medical", "current", "wrong", "private", "past", "foreign", "fine", "common", "poor", "natural", "significant", "similar", "hot", "dead", "central", "happy", "serious", "ready", "simple", "left", "physical", "general", "environmental", "financial", "blue", "democratic", "dark", "various", "entire", "close", "legal", "religious", "cold", "final", "main", "huge", "popular", "traditional", "cultural", "choice", "high", "big", "large", "particular", "tiny", "enormous"]; 124 | default_words.iter().map(|s| s.to_string()).collect() 125 | } 126 | 127 | /// Just returns the default sentences (a vector of words and punctuation) 128 | pub fn default_text() -> Vec { 129 | let default_text = vec!["The", "shimmering", "dragonfly", "hovered", "over", "the", "tranquil", "pond.", "Ancient", "mountains", "guard", "secrets", "of", "a", "time", "long", "forgotten.", "A", "melancholic", "melody", "drifted", "from", "the", "old,", "forgotten", "gramophone.", "The", "bustling", "city", "market", "was", "a", "kaleidoscope", "of", "colors,", "sounds,", "and", "smells.", "Through", "the", "fog,", "a", "lone", "lighthouse", "cast", "a", "guiding", "beam", "for", "lost", "sailors.", "The", "philosopher", "pondered", "the", "intricate", "dance", "between", "fate", "and", "free", "will.", "A", "child's", "laughter", "echoed", "in", "the", "empty", "playground,", "a", "ghost", "of", "happier", "times.", "The", "weathered", "fisherman", "mended", "his", "nets,", "his", "face", "a", "map", "of", "the", "sea.", "Cryptic", "symbols", "adorned", "the", "walls", "of", "the", "newly", "discovered", "tomb.", "The", "scent", "of", "rain", "on", "dry", "earth", "filled", "the", "air,", "a", "promise", "of", "renewal.", "A", "weary", "traveler", "sought", "refuge", "from", "the", "relentless", "storm", "in", "a", "deserted", "cabin.", "The", "artist's", "canvas", "held", "a", "chaotic", "explosion", "of", "emotions,", "rendered", "in", "oil", "and", "acrylic.", "Stars,", "like", "scattered", "diamonds,", "adorned", "the", "velvet", "canvas", "of", "the", "night", "sky.", "The", "old", "librarian", "cherished", "the", "silent", "companionship", "of", "his", "leather-bound", "books.", "A", "forgotten", "diary", "revealed", "the", "secret", "love", "story", "of", "a", "bygone", "era.", "The", "chef", "meticulously", "arranged", "the", "dish,", "transforming", "food", "into", "a", "work", "of", "art.", "In", "the", "heart", "of", "the", "forest,", "a", "hidden", "waterfall", "cascaded", "into", "a", "crystal-clear", "pool.", "The", "politician's", "speech", "was", "a", "carefully", "constructed", "fortress", "of", "half-truths", "and", "promises.", "A", "sudden", "gust", "of", "wind", "scattered", "the", "autumn", "leaves", "like", "a", "flurry", "of", "colorful", "confetti.", "The", "detective", "followed", "a", "labyrinthine", "trail", "of", "clues,", "each", "one", "more", "perplexing", "than", "the", "last.", "The", "scent", "of", "jasmine", "hung", "heavy", "in", "the", "humid", "evening", "air.", "Time", "seemed", "to", "slow", "down", "in", "the", "sleepy,", "sun-drenched", "village.", "The", "blacksmith's", "hammer", "rang", "out", "a", "rhythmic", "chorus", "against", "the", "glowing", "steel.", "A", "lone", "wolf", "howled", "at", "the", "full", "moon,", "its", "call", "a", "lament", "for", "its", "lost", "pack.", "The", "mathematician", "found", "elegance", "and", "beauty", "in", "the", "complex", "simplicity", "of", "equations.", "From", "the", "ashes", "of", "defeat,", "a", "spark", "of", "resilience", "began", "to", "glow.", "The", "antique", "clock", "ticked", "with", "a", "solemn,", "unhurried", "rhythm,", "marking", "the", "passage", "of", "time.", "A", "hummingbird,", "a", "jeweled", "marvel", "of", "nature,", "darted", "from", "flower", "to", "flower.", "The", "decrepit", "mansion", "on", "the", "hill", "was", "rumored", "to", "be", "haunted", "by", "a", "benevolent", "spirit.", "Sunlight", "streamed", "through", "the", "stained-glass", "windows,", "painting", "the", "cathedral", "floor", "in", "vibrant", "hues.", "The", "aroma", "of", "freshly", "baked", "bread", "wafted", "from", "the", "cozy", "little", "bakery.", "A", "complex", "network", "of", "roots", "anchored", "the", "ancient", "oak", "tree", "to", "the", "earth.", "The", "programmer", "stared", "at", "the", "screen,", "searching", "for", "the", "single,", "elusive", "bug", "in", "a", "million", "lines", "of", "code.", "The", "waves", "crashed", "against", "the", "rocky", "shore", "in", "a", "timeless,", "powerful", "rhythm.", "A", "flock", "of", "geese", "flew", "south", "in", "a", "perfect", "V-formation,", "a", "testament", "to", "their", "instinctual", "harmony.", "The", "historian", "pieced", "together", "the", "fragments", "of", "the", "past", "to", "tell", "a", "coherent", "story.", "In", "the", "quiet", "solitude", "of", "the", "desert,", "one", "could", "hear", "the", "whisper", "of", "the", "wind.", "The", "gardener", "tended", "to", "her", "roses", "with", "a", "gentle,", "nurturing", "touch.", "A", "crackling", "fireplace", "provided", "a", "warm", "and", "inviting", "centerpiece", "to", "the", "rustic", "living", "room.", "The", "mountaineer", "stood", "at", "the", "summit,", "humbled", "by", "the", "breathtaking", "vista", "below.", "A", "single,", "perfect", "snowflake", "landed", "on", "the", "child's", "outstretched", "mitten."]; 130 | default_text.iter().map(|s| s.to_string()).collect() 131 | } 132 | 133 | /// Calculates the hash of text.txt in a specified directory. 134 | pub fn calculate_text_txt_hash(dir: &Path) -> io::Result> { 135 | let path = dir.join("text.txt"); 136 | let file_bytes = fs::read(path)?; 137 | let mut hasher = Sha256::new(); 138 | hasher.update(file_bytes); 139 | Ok(hasher.finalize().to_vec()) 140 | } 141 | 142 | #[cfg(test)] 143 | mod tests { 144 | use super::*; 145 | use std::collections::HashMap; 146 | use tempfile::tempdir; 147 | 148 | #[test] 149 | fn test_save_and_load_config() { 150 | // Create a temporary directory to avoid interfering with actual config files. 151 | let dir = tempdir().unwrap(); 152 | let dir_path = dir.path(); 153 | 154 | // --- Test saving and loading an existing config --- 155 | let mut config_to_save = Config::default(); 156 | config_to_save.first_boot = false; 157 | config_to_save.save_mistyped = false; 158 | config_to_save.mistyped_chars.insert("a".to_string(), 100); 159 | 160 | // Save the custom config and assert it was successful. 161 | assert!(save_config(&config_to_save, dir_path).is_ok()); 162 | 163 | // Load the config back from the directory. 164 | let loaded_config = load_config(dir_path).unwrap(); 165 | 166 | // Check that the loaded values match what we saved. 167 | assert_eq!(loaded_config.first_boot, false); 168 | assert_eq!(loaded_config.save_mistyped, false); 169 | assert_eq!(*loaded_config.mistyped_chars.get("a").unwrap(), 100); 170 | 171 | // --- Test loading a config when none exists --- 172 | // `load_config` should create a default one automatically. 173 | let new_dir = tempdir().unwrap(); 174 | let new_dir_path = new_dir.path(); 175 | let default_config = load_config(new_dir_path).unwrap(); 176 | 177 | // Check that the created config has default values. 178 | assert_eq!(default_config.first_boot, true); 179 | assert!(default_config.mistyped_chars.is_empty()); 180 | } 181 | 182 | #[test] 183 | fn test_read_items_from_file() { 184 | // Create a temporary directory. 185 | let dir = tempdir().unwrap(); 186 | let dir_path = dir.path(); 187 | 188 | // --- Test reading a standard words.txt file --- 189 | let words_content = "hello world from ttypr"; 190 | fs::write(dir_path.join("words.txt"), words_content).unwrap(); 191 | 192 | let words = read_words_from_file(dir_path).unwrap(); 193 | assert_eq!(words, vec!["hello", "world", "from", "ttypr"]); 194 | 195 | // --- Test filtering based on length --- 196 | let long_word = "a".repeat(51); 197 | let valid_word = "b".repeat(50); 198 | let filter_content = format!("short {} another_short {}", long_word, valid_word); 199 | fs::write(dir_path.join("filter_test.txt"), filter_content).unwrap(); 200 | 201 | let filtered_items = load_items_from_file(dir_path, "filter_test.txt").unwrap(); 202 | assert_eq!(filtered_items, vec!["short", "another_short", &valid_word]); 203 | 204 | // --- Test reading a standard text.txt file --- 205 | let text_content = "this is a line of text"; 206 | fs::write(dir_path.join("text.txt"), text_content).unwrap(); 207 | 208 | let text = read_text_from_file(dir_path).unwrap(); 209 | assert_eq!(text, vec!["this", "is", "a", "line", "of", "text"]); 210 | 211 | // --- Test error handling for missing files --- 212 | assert!(read_words_from_file(dir.path().join("non_existent_dir").as_path()).is_err()); 213 | assert!(read_text_from_file(dir.path().join("another_fake_dir").as_path()).is_err()); 214 | } 215 | 216 | #[test] 217 | fn test_calculate_text_txt_hash() { 218 | // Create a temporary directory. 219 | let dir = tempdir().unwrap(); 220 | let dir_path = dir.path(); 221 | 222 | // --- Test hashing an existing file --- 223 | let content = "hello ttypr"; 224 | fs::write(dir_path.join("text.txt"), content).unwrap(); 225 | 226 | // Calculate the hash using our function. 227 | let file_hash = calculate_text_txt_hash(dir_path).unwrap(); 228 | 229 | // Calculate the hash manually to get the expected result. 230 | let mut hasher = Sha256::new(); 231 | hasher.update(content.as_bytes()); 232 | let expected_hash = hasher.finalize().to_vec(); 233 | 234 | assert_eq!(file_hash, expected_hash); 235 | 236 | // --- Test error handling for a missing file --- 237 | let new_dir = tempdir().unwrap(); 238 | assert!(calculate_text_txt_hash(new_dir.path()).is_err()); 239 | } 240 | 241 | #[test] 242 | fn test_get_sorted_mistakes() { 243 | // Create a sample map of mistyped characters 244 | let mut mistyped_chars = HashMap::new(); 245 | mistyped_chars.insert("a".to_string(), 5); 246 | mistyped_chars.insert("c".to_string(), 10); 247 | mistyped_chars.insert("b".to_string(), 10); // Same count as 'c' 248 | mistyped_chars.insert("d".to_string(), 2); 249 | 250 | // Get the sorted list of mistakes (which is a Vec of references) 251 | let sorted_result = get_sorted_mistakes(&mistyped_chars); 252 | 253 | // Convert the result from a Vec of references to a Vec of owned values for comparison 254 | let actual: Vec<(String, usize)> = sorted_result.iter().map(|(k, v)| ((*k).clone(), **v)).collect(); 255 | 256 | // Define the expected order directly with owned values 257 | let expected: Vec<(String, usize)> = vec![ 258 | ("b".to_string(), 10), 259 | ("c".to_string(), 10), 260 | ("a".to_string(), 5), 261 | ("d".to_string(), 2), 262 | ]; 263 | 264 | assert_eq!(actual, expected); 265 | 266 | // Test with an empty map 267 | let empty_map = HashMap::new(); 268 | let sorted_empty = get_sorted_mistakes(&empty_map); 269 | assert!(sorted_empty.is_empty()); 270 | } 271 | 272 | #[test] 273 | fn test_default_words() { 274 | let words = default_words(); 275 | // Check that it returns a non-empty list 276 | assert!(!words.is_empty(), "The default word list should not be empty."); 277 | // Check for a specific known value to guard against accidental changes 278 | assert_eq!(words[0], "the"); 279 | assert_eq!(words.last().unwrap(), "enormous"); 280 | } 281 | 282 | #[test] 283 | fn test_default_text() { 284 | let text = default_text(); 285 | // Check that it returns a non-empty list 286 | assert!(!text.is_empty(), "The default text list should not be empty."); 287 | // Check for specific known values to guard against accidental changes 288 | assert_eq!(text[0], "The"); 289 | assert_eq!(text.last().unwrap(), "mitten."); 290 | } 291 | } -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{App, CurrentMode, CurrentTypingOption}; 2 | use ratatui::{ 3 | layout::{Alignment, Direction, Flex}, 4 | prelude::{Constraint, Layout, Rect}, 5 | style::{Color, Style}, 6 | text::{Line, Span}, 7 | widgets::{Clear, List, ListItem}, 8 | Frame 9 | }; 10 | use crate::utils::{get_sorted_mistakes}; 11 | 12 | /// Renders the entire user interface based on the application's current state. 13 | /// 14 | /// This function acts as a dispatcher, determining which screen to render based on the app's 15 | /// state flags like `first_boot`, `show_help`, and `show_mistyped`. 16 | pub fn render(frame: &mut Frame, app: &App) { 17 | if app.config.first_boot || app.show_help { 18 | render_help_screen(frame); 19 | return; 20 | } 21 | 22 | if app.show_mistyped { 23 | render_mistakes_screen(frame, app); 24 | return; 25 | } 26 | 27 | render_main_ui(frame, app); 28 | } 29 | 30 | /// Renders the main user interface, including the typing area and notifications. 31 | fn render_main_ui(frame: &mut Frame, app: &App) { 32 | // Where to display the lines 33 | let area = center( 34 | frame.area(), // The area of the entire frame 35 | Constraint::Length(app.line_len as u16), // Width depending on set line length 36 | Constraint::Length(5), // Height, 5 - because spaces between them 37 | ); 38 | 39 | render_notifications(frame, app); 40 | render_typing_area(frame, app, area); 41 | } 42 | 43 | /// Renders the help screen, which displays keybindings and instructions. 44 | /// 45 | /// This screen is shown on the first boot or when the user explicitly requests it. 46 | fn render_help_screen(frame: &mut Frame) { 47 | let first_boot_message_area = center( 48 | frame.area(), 49 | Constraint::Length(65), 50 | Constraint::Length(32), 51 | ); 52 | 53 | let first_boot_message = vec![ 54 | Line::from("The application starts in the Menu mode.").alignment(Alignment::Center), 55 | Line::from(""), 56 | Line::from("For larger font - increase the terminal font size.").alignment(Alignment::Center), 57 | Line::from(""), 58 | Line::from(""), 59 | Line::from("Menu mode:").alignment(Alignment::Center), 60 | Line::from(""), 61 | Line::from(" h - access the help page"), 62 | Line::from(" q - exit the application"), 63 | Line::from(" i - switch to Typing mode"), 64 | Line::from(" o - switch Typing option (ASCII, Words, Text)"), 65 | Line::from(" n - toggle notifications"), 66 | Line::from(" c - toggle counting mistyped characters"), 67 | Line::from(" w - display top mistyped characters"), 68 | Line::from(" r - clear mistyped characters count"), 69 | Line::from(" a - toggle displaying WPM"), 70 | Line::from(""), 71 | Line::from(""), 72 | Line::from("Typing mode:").alignment(Alignment::Center), 73 | Line::from(""), 74 | Line::from(" ESC - switch to Menu mode"), 75 | Line::from(" Character keys - Type the corresponding characters"), 76 | Line::from(" Backspace - Remove characters"), 77 | Line::from(""), 78 | Line::from(""), 79 | Line::from(""), 80 | Line::from(Span::styled("", Style::new().bg(Color::White).fg(Color::Black))).alignment(Alignment::Center) 81 | ]; 82 | 83 | let first_boot_message: Vec<_> = first_boot_message 84 | .into_iter() 85 | .map(ListItem::new) 86 | .collect(); 87 | 88 | let first_boot_message = List::new(first_boot_message); 89 | frame.render_widget(first_boot_message, first_boot_message_area); 90 | } 91 | 92 | /// Renders the screen displaying the user's most frequently mistyped characters. 93 | fn render_mistakes_screen(frame: &mut Frame, app: &App) { 94 | let sorted_mistakes = get_sorted_mistakes(&app.config.mistyped_chars); 95 | // Limit the display to the top 15 most frequent mistakes. 96 | let sorted_mistakes: Vec<(String, usize)> = sorted_mistakes.iter().take(15).map(|(k, v)| (k.to_string(), **v)).collect(); 97 | 98 | let mut mistake_lines: Vec = vec![]; 99 | 100 | let mistyped_title = vec![ 101 | ListItem::new(Line::from("Most mistyped characters")), 102 | ListItem::new(Line::from("")), 103 | ListItem::new(Line::from("")), 104 | ]; 105 | for item in mistyped_title { mistake_lines.push(item) } 106 | 107 | for (mistake, count) in sorted_mistakes { 108 | let line = Line::from(format!("{}: {}", mistake, count)).alignment(Alignment::Center); 109 | mistake_lines.push(ListItem::new(line)); 110 | } 111 | 112 | let enter_button = vec![ 113 | ListItem::new(Line::from("")), 114 | ListItem::new(Line::from("")), 115 | ListItem::new(Line::from("")), 116 | ListItem::new(Line::from(Span::styled("", Style::new().bg(Color::White).fg(Color::Black))).alignment(Alignment::Center)), 117 | ]; 118 | for item in enter_button { mistake_lines.push(item) } 119 | 120 | let mistakes_area = center( 121 | frame.area(), 122 | Constraint::Length(25), 123 | Constraint::Length(25), 124 | ); 125 | 126 | let list = List::new(mistake_lines); 127 | frame.render_widget(list, mistakes_area); 128 | } 129 | 130 | /// Renders transient notifications at various positions on the screen. 131 | /// 132 | /// These notifications provide feedback for actions like toggling settings, changing modes, etc. 133 | fn render_notifications(frame: &mut Frame, app: &App) { 134 | // WPM display toggle notification 135 | if app.notifications.display_wpm && app.config.show_notifications { 136 | let display_wpm_notification_area = Layout::default() 137 | .direction(Direction::Vertical) 138 | .constraints(vec![ 139 | Constraint::Percentage(25), 140 | Constraint::Min(1), 141 | Constraint::Min(0), 142 | ]).split(frame.area()); 143 | let display_wpm_notification_area = Layout::default() 144 | .direction(Direction::Horizontal) 145 | .constraints(vec![ 146 | Constraint::Percentage(30), 147 | Constraint::Length(20), 148 | Constraint::Min(0), 149 | ]).split(display_wpm_notification_area[1]); 150 | 151 | let display_wpm_on = Line::from(vec![Span::from("Display wpm "), Span::styled("on", Style::new().fg(Color::Green))]).alignment(Alignment::Left); 152 | let display_wpm_off = Line::from(vec![Span::from("Display wpm "), Span::styled("off", Style::new().fg(Color::Red))]).alignment(Alignment::Left); 153 | 154 | if app.config.show_wpm_notification { 155 | frame.render_widget(display_wpm_on, display_wpm_notification_area[1]); 156 | } else { 157 | frame.render_widget(display_wpm_off, display_wpm_notification_area[1]); 158 | } 159 | } 160 | 161 | // WPM notification 162 | if app.notifications.wpm && app.config.show_wpm_notification { 163 | let wpm_notification_area = Layout::default() 164 | .direction(Direction::Vertical) 165 | .constraints(vec![ 166 | Constraint::Percentage(25), 167 | Constraint::Min(1), 168 | Constraint::Min(0), 169 | ]).split(frame.area()); 170 | let wpm_notification_area = Layout::default() 171 | .direction(Direction::Horizontal) 172 | .constraints(vec![ 173 | Constraint::Percentage(60), 174 | Constraint::Length(10), 175 | Constraint::Min(0), 176 | ]).split(wpm_notification_area[1]); 177 | 178 | frame.render_widget(Line::from(format!("{} wpm", app.wpm.wpm)), wpm_notification_area[1]); 179 | } 180 | 181 | // Cleared mistyped characters count display 182 | if app.notifications.clear_mistyped && app.config.show_notifications { 183 | let clear_mistyped_notification_area = Layout::default() 184 | .direction(Direction::Vertical) 185 | .constraints(vec![ 186 | Constraint::Percentage(65), 187 | Constraint::Percentage(10), 188 | Constraint::Percentage(25), 189 | ]).split(frame.area()); 190 | 191 | frame.render_widget(Line::from("Cleared mistyped characters count").alignment(Alignment::Center), clear_mistyped_notification_area[1]); 192 | } 193 | 194 | // Mistyped characters count toggle display 195 | if app.notifications.mistyped && app.config.show_notifications { 196 | let mistyped_chars_area = Layout::default() 197 | .direction(Direction::Vertical) 198 | .constraints(vec![ 199 | Constraint::Percentage(70), 200 | Constraint::Percentage(10), 201 | Constraint::Percentage(20), 202 | ]).split(frame.area()); 203 | 204 | let mistyped_chars_on = Line::from(vec![Span::from(" Counting mistyped characters "), Span::styled("on", Style::new().fg(Color::Green))]).alignment(Alignment::Center); 205 | let mistyped_chars_off = Line::from(vec![Span::from(" Counting mistyped characters "), Span::styled("off", Style::new().fg(Color::Red))]).alignment(Alignment::Center); 206 | 207 | if app.config.save_mistyped { 208 | frame.render_widget(mistyped_chars_on, mistyped_chars_area[1]); 209 | } else { 210 | frame.render_widget(mistyped_chars_off, mistyped_chars_area[1]); 211 | } 212 | } 213 | 214 | // Notification toggle display 215 | if app.notifications.toggle { 216 | let notification_toggle_area = Layout::default() 217 | .direction(Direction::Vertical) 218 | .constraints(vec![ 219 | Constraint::Length(1), 220 | Constraint::Length(1), 221 | Constraint::Min(0), 222 | ]).split(frame.area()); 223 | let notification_toggle_area = Layout::default() 224 | .direction(Direction::Horizontal) 225 | .constraints(vec![ 226 | Constraint::Length(25), 227 | Constraint::Min(0), 228 | ]).split(notification_toggle_area[1]); 229 | 230 | let notifications_on = Line::from(vec![Span::from(" Notifications "), Span::styled("on", Style::new().fg(Color::Green))]).alignment(Alignment::Left); 231 | let notifications_off = Line::from(vec![Span::from(" Notifications "), Span::styled("off", Style::new().fg(Color::Red))]).alignment(Alignment::Left); 232 | 233 | if app.config.show_notifications { 234 | frame.render_widget(notifications_on, notification_toggle_area[0]); 235 | } else { 236 | frame.render_widget(notifications_off, notification_toggle_area[0]); 237 | } 238 | } 239 | 240 | // Typing mode selection display (Menu, Typing) 241 | if app.notifications.mode && app.config.show_notifications { 242 | let mode_area = Layout::default() 243 | .direction(Direction::Vertical) 244 | .constraints(vec![ 245 | Constraint::Percentage(60), 246 | Constraint::Percentage(10), 247 | Constraint::Percentage(30), 248 | ]).split(frame.area()); 249 | 250 | match app.current_mode { 251 | CurrentMode::Menu => { 252 | frame.render_widget(Line::from("- Menu mode -").alignment(Alignment::Center), mode_area[1]); 253 | } 254 | CurrentMode::Typing => { 255 | frame.render_widget(Line::from("- Typing mode -").alignment(Alignment::Center), mode_area[1]); 256 | } 257 | } 258 | } 259 | 260 | // Typing option selection display (Ascii, Words, Text) 261 | if app.notifications.option && app.config.show_notifications { 262 | // Position the typing option selector in the top-right corner. 263 | let option_area = Layout::default() 264 | .direction(Direction::Vertical) 265 | .constraints(vec![ 266 | Constraint::Length(2), 267 | Constraint::Min(0), 268 | ]).split(frame.area()); 269 | let option_area = Layout::default() 270 | .direction(Direction::Horizontal) 271 | .constraints(vec![ 272 | Constraint::Min(0), 273 | Constraint::Length(5), 274 | ]).split(option_area[1]); 275 | 276 | let mut option_span: Vec = vec![]; 277 | 278 | match app.current_typing_option { 279 | CurrentTypingOption::Ascii => { 280 | option_span.push(ListItem::new(Span::styled("Ascii", Style::new().fg(Color::Black).bg(Color::White)))); 281 | option_span.push(ListItem::new(Span::styled("Words", Style::new().fg(Color::White)))); 282 | option_span.push(ListItem::new(Span::styled("Text", Style::new().fg(Color::White)))); 283 | } 284 | CurrentTypingOption::Words => { 285 | option_span.push(ListItem::new(Span::styled("Ascii", Style::new().fg(Color::White)))); 286 | option_span.push(ListItem::new(Span::styled("Words", Style::new().fg(Color::Black).bg(Color::White)))); 287 | option_span.push(ListItem::new(Span::styled("Text", Style::new().fg(Color::White)))); 288 | } 289 | CurrentTypingOption::Text => { 290 | option_span.push(ListItem::new(Span::styled("Ascii", Style::new().fg(Color::White)))); 291 | option_span.push(ListItem::new(Span::styled("Words", Style::new().fg(Color::White)))); 292 | option_span.push(ListItem::new(Span::styled("Text", Style::new().fg(Color::Black).bg(Color::White)))); 293 | } 294 | } 295 | 296 | frame.render_widget(List::new(option_span), option_area[1]); 297 | } 298 | } 299 | 300 | /// Renders the core typing area where the user practices. 301 | /// 302 | /// This function handles the display of the character set, user input, and messages for 303 | /// missing word/text files. 304 | fn render_typing_area(frame: &mut Frame, app: &App, area: Rect) { 305 | // A vector of colored characters 306 | let span: Vec = app.charset.iter().enumerate().map(|(i, c)| { 307 | match app.ids[i] { 308 | 1 => { // Correct 309 | Span::styled(c.to_string(), Style::new().fg(Color::Indexed(10))) 310 | } 311 | 2 => { // Incorrect 312 | // Render incorrect spaces as underscores for better visibility. 313 | let char_to_render = if app.input_chars[i] == " " || c == " " { 314 | "_" 315 | } else { 316 | c 317 | }; 318 | Span::styled(char_to_render.to_string(), Style::new().fg(Color::Indexed(9))) 319 | } 320 | _ => { // Untyped 321 | Span::styled(c.to_string(), Style::new().fg(Color::Indexed(8))) 322 | } 323 | } 324 | }).collect(); 325 | 326 | // Draw the typing area itself 327 | match app.current_typing_option { 328 | CurrentTypingOption::Ascii => { 329 | render_typing_lines(frame, app, area, span); 330 | } 331 | CurrentTypingOption::Words => { 332 | if app.words.is_empty() { 333 | render_file_not_found_message(frame, "Words", "~/.config/ttypr/words.txt", Some("The formatting is just words separated by spaces")); 334 | } else { 335 | render_typing_lines(frame, app, area, span); 336 | } 337 | } 338 | CurrentTypingOption::Text => { 339 | if app.text.is_empty() { 340 | render_file_not_found_message(frame, "Text", "~/.config/ttypr/text.txt", None); 341 | } else { 342 | render_typing_lines(frame, app, area, span); 343 | } 344 | } 345 | } 346 | } 347 | 348 | /// Renders a message indicating that a required file (e.g., for words or text) was not found. 349 | /// 350 | /// # Arguments 351 | /// 352 | /// * `frame` - The mutable frame to draw on. 353 | /// * `option_name` - The name of the typing option (e.g., "Words", "Text"). 354 | /// * `file_path` - The expected path of the missing file. 355 | /// * `extra_line` - An optional extra line of context, like formatting instructions. 356 | fn render_file_not_found_message(frame: &mut Frame, option_name: &str, file_path: &str, extra_line: Option<&str>) { 357 | let area = center( 358 | frame.area(), 359 | Constraint::Length(50), 360 | Constraint::Length(15), 361 | ); 362 | 363 | let mut message_lines = vec![ 364 | Line::from(format!("In order to use the {} typing option", option_name)).alignment(Alignment::Center), 365 | Line::from("you need to have a:").alignment(Alignment::Center), 366 | Line::from(""), // Push an empty space to separate lines 367 | Line::from(file_path).alignment(Alignment::Center), 368 | Line::from(""), 369 | ]; 370 | 371 | if let Some(line) = extra_line { 372 | message_lines.push(Line::from(line).alignment(Alignment::Center)); 373 | message_lines.push(Line::from("")); 374 | } 375 | 376 | message_lines.extend(vec![ 377 | Line::from("Or you can use the default one by pressing Enter").alignment(Alignment::Center), 378 | Line::from(""), 379 | Line::from(""), 380 | Line::from(Span::styled("", Style::new().bg(Color::White).fg(Color::Black))).alignment(Alignment::Center) 381 | ]); 382 | 383 | let list_items: Vec<_> = message_lines 384 | .into_iter() 385 | .map(ListItem::new) 386 | .collect(); 387 | 388 | let list = List::new(list_items); 389 | frame.render_widget(list, area); 390 | } 391 | 392 | /// Renders the lines of text for the user to type. 393 | /// 394 | /// This function takes the application state, a frame, a rendering area, and a vector of styled 395 | /// characters (`Span`s). It then splits the characters into three lines and displays them 396 | /// centered in the provided area. 397 | pub fn render_typing_lines(frame: &mut Frame, app: &App, area: Rect, span: Vec) { 398 | // Separating vector of all the colored characters into vector of 3 lines, each line_len long 399 | // and making them List items, to display as a List widget 400 | let mut three_lines = vec![]; 401 | let mut skip_len = 0; 402 | // The UI displays three lines of text at a time. 403 | for i in 0..3 { 404 | // Use `skip()` and `take()` to create a view into the full character buffer for each line. 405 | let line_span: Vec = span.iter().skip(skip_len).take(app.lines_len[i]).map(|c| { 406 | c.clone() 407 | }).collect(); 408 | let line = Line::from(line_span).alignment(Alignment::Center); 409 | let item = ListItem::new(line); 410 | three_lines.push(item); 411 | // Add an empty `ListItem` to create visual spacing between the lines. 412 | three_lines.push(ListItem::new("")); 413 | skip_len += app.lines_len[i]; 414 | } 415 | 416 | // Make a List widget out of list items and render it in the middle 417 | let list = List::new(three_lines); 418 | frame.render_widget(list, area); 419 | } 420 | 421 | /// Helper function to center a layout area 422 | pub fn center(area: Rect, horizontal: Constraint, vertical: Constraint) -> Rect { 423 | let [area] = Layout::horizontal([horizontal]).flex(Flex::Center).areas(area); 424 | let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area); 425 | area 426 | } 427 | 428 | /// Clear the entire area 429 | pub fn draw_on_clear(f: &mut Frame) { 430 | let area = f.area(); // The area of the entire frame 431 | f.render_widget(Clear, area); 432 | } -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 19 | 20 | [[package]] 21 | name = "allocator-api2" 22 | version = "0.2.21" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 25 | 26 | [[package]] 27 | name = "autocfg" 28 | version = "1.5.0" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 31 | 32 | [[package]] 33 | name = "backtrace" 34 | version = "0.3.75" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" 37 | dependencies = [ 38 | "addr2line", 39 | "cfg-if", 40 | "libc", 41 | "miniz_oxide", 42 | "object", 43 | "rustc-demangle", 44 | "windows-targets 0.52.6", 45 | ] 46 | 47 | [[package]] 48 | name = "bitflags" 49 | version = "2.9.1" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" 52 | 53 | [[package]] 54 | name = "block-buffer" 55 | version = "0.10.4" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 58 | dependencies = [ 59 | "generic-array", 60 | ] 61 | 62 | [[package]] 63 | name = "cassowary" 64 | version = "0.3.0" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 67 | 68 | [[package]] 69 | name = "castaway" 70 | version = "0.2.4" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" 73 | dependencies = [ 74 | "rustversion", 75 | ] 76 | 77 | [[package]] 78 | name = "cfg-if" 79 | version = "1.0.1" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" 82 | 83 | [[package]] 84 | name = "color-eyre" 85 | version = "0.6.5" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" 88 | dependencies = [ 89 | "backtrace", 90 | "color-spantrace", 91 | "eyre", 92 | "indenter", 93 | "once_cell", 94 | "owo-colors", 95 | "tracing-error", 96 | ] 97 | 98 | [[package]] 99 | name = "color-spantrace" 100 | version = "0.3.0" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" 103 | dependencies = [ 104 | "once_cell", 105 | "owo-colors", 106 | "tracing-core", 107 | "tracing-error", 108 | ] 109 | 110 | [[package]] 111 | name = "compact_str" 112 | version = "0.8.1" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 115 | dependencies = [ 116 | "castaway", 117 | "cfg-if", 118 | "itoa", 119 | "rustversion", 120 | "ryu", 121 | "static_assertions", 122 | ] 123 | 124 | [[package]] 125 | name = "cpufeatures" 126 | version = "0.2.17" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 129 | dependencies = [ 130 | "libc", 131 | ] 132 | 133 | [[package]] 134 | name = "crossterm" 135 | version = "0.28.1" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 138 | dependencies = [ 139 | "bitflags", 140 | "crossterm_winapi", 141 | "mio", 142 | "parking_lot", 143 | "rustix 0.38.44", 144 | "signal-hook", 145 | "signal-hook-mio", 146 | "winapi", 147 | ] 148 | 149 | [[package]] 150 | name = "crossterm_winapi" 151 | version = "0.9.1" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 154 | dependencies = [ 155 | "winapi", 156 | ] 157 | 158 | [[package]] 159 | name = "crypto-common" 160 | version = "0.1.6" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 163 | dependencies = [ 164 | "generic-array", 165 | "typenum", 166 | ] 167 | 168 | [[package]] 169 | name = "darling" 170 | version = "0.20.11" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 173 | dependencies = [ 174 | "darling_core", 175 | "darling_macro", 176 | ] 177 | 178 | [[package]] 179 | name = "darling_core" 180 | version = "0.20.11" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 183 | dependencies = [ 184 | "fnv", 185 | "ident_case", 186 | "proc-macro2", 187 | "quote", 188 | "strsim", 189 | "syn", 190 | ] 191 | 192 | [[package]] 193 | name = "darling_macro" 194 | version = "0.20.11" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 197 | dependencies = [ 198 | "darling_core", 199 | "quote", 200 | "syn", 201 | ] 202 | 203 | [[package]] 204 | name = "digest" 205 | version = "0.10.7" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 208 | dependencies = [ 209 | "block-buffer", 210 | "crypto-common", 211 | ] 212 | 213 | [[package]] 214 | name = "either" 215 | version = "1.15.0" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 218 | 219 | [[package]] 220 | name = "equivalent" 221 | version = "1.0.2" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 224 | 225 | [[package]] 226 | name = "errno" 227 | version = "0.3.13" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" 230 | dependencies = [ 231 | "libc", 232 | "windows-sys 0.60.2", 233 | ] 234 | 235 | [[package]] 236 | name = "eyre" 237 | version = "0.6.12" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" 240 | dependencies = [ 241 | "indenter", 242 | "once_cell", 243 | ] 244 | 245 | [[package]] 246 | name = "fastrand" 247 | version = "2.3.0" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 250 | 251 | [[package]] 252 | name = "fnv" 253 | version = "1.0.7" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 256 | 257 | [[package]] 258 | name = "foldhash" 259 | version = "0.1.5" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 262 | 263 | [[package]] 264 | name = "generic-array" 265 | version = "0.14.7" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 268 | dependencies = [ 269 | "typenum", 270 | "version_check", 271 | ] 272 | 273 | [[package]] 274 | name = "getrandom" 275 | version = "0.3.3" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 278 | dependencies = [ 279 | "cfg-if", 280 | "libc", 281 | "r-efi", 282 | "wasi 0.14.2+wasi-0.2.4", 283 | ] 284 | 285 | [[package]] 286 | name = "gimli" 287 | version = "0.31.1" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 290 | 291 | [[package]] 292 | name = "hashbrown" 293 | version = "0.15.4" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" 296 | dependencies = [ 297 | "allocator-api2", 298 | "equivalent", 299 | "foldhash", 300 | ] 301 | 302 | [[package]] 303 | name = "heck" 304 | version = "0.5.0" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 307 | 308 | [[package]] 309 | name = "home" 310 | version = "0.5.11" 311 | source = "registry+https://github.com/rust-lang/crates.io-index" 312 | checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" 313 | dependencies = [ 314 | "windows-sys 0.59.0", 315 | ] 316 | 317 | [[package]] 318 | name = "ident_case" 319 | version = "1.0.1" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 322 | 323 | [[package]] 324 | name = "indenter" 325 | version = "0.3.3" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" 328 | 329 | [[package]] 330 | name = "indexmap" 331 | version = "2.10.0" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" 334 | dependencies = [ 335 | "equivalent", 336 | "hashbrown", 337 | ] 338 | 339 | [[package]] 340 | name = "indoc" 341 | version = "2.0.6" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" 344 | 345 | [[package]] 346 | name = "instability" 347 | version = "0.3.9" 348 | source = "registry+https://github.com/rust-lang/crates.io-index" 349 | checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" 350 | dependencies = [ 351 | "darling", 352 | "indoc", 353 | "proc-macro2", 354 | "quote", 355 | "syn", 356 | ] 357 | 358 | [[package]] 359 | name = "itertools" 360 | version = "0.13.0" 361 | source = "registry+https://github.com/rust-lang/crates.io-index" 362 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 363 | dependencies = [ 364 | "either", 365 | ] 366 | 367 | [[package]] 368 | name = "itoa" 369 | version = "1.0.15" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 372 | 373 | [[package]] 374 | name = "lazy_static" 375 | version = "1.5.0" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 378 | 379 | [[package]] 380 | name = "libc" 381 | version = "0.2.174" 382 | source = "registry+https://github.com/rust-lang/crates.io-index" 383 | checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" 384 | 385 | [[package]] 386 | name = "linux-raw-sys" 387 | version = "0.4.15" 388 | source = "registry+https://github.com/rust-lang/crates.io-index" 389 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 390 | 391 | [[package]] 392 | name = "linux-raw-sys" 393 | version = "0.9.4" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 396 | 397 | [[package]] 398 | name = "lock_api" 399 | version = "0.4.13" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" 402 | dependencies = [ 403 | "autocfg", 404 | "scopeguard", 405 | ] 406 | 407 | [[package]] 408 | name = "log" 409 | version = "0.4.27" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 412 | 413 | [[package]] 414 | name = "lru" 415 | version = "0.12.5" 416 | source = "registry+https://github.com/rust-lang/crates.io-index" 417 | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 418 | dependencies = [ 419 | "hashbrown", 420 | ] 421 | 422 | [[package]] 423 | name = "memchr" 424 | version = "2.7.5" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" 427 | 428 | [[package]] 429 | name = "miniz_oxide" 430 | version = "0.8.9" 431 | source = "registry+https://github.com/rust-lang/crates.io-index" 432 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 433 | dependencies = [ 434 | "adler2", 435 | ] 436 | 437 | [[package]] 438 | name = "mio" 439 | version = "1.0.4" 440 | source = "registry+https://github.com/rust-lang/crates.io-index" 441 | checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" 442 | dependencies = [ 443 | "libc", 444 | "log", 445 | "wasi 0.11.1+wasi-snapshot-preview1", 446 | "windows-sys 0.59.0", 447 | ] 448 | 449 | [[package]] 450 | name = "object" 451 | version = "0.36.7" 452 | source = "registry+https://github.com/rust-lang/crates.io-index" 453 | checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 454 | dependencies = [ 455 | "memchr", 456 | ] 457 | 458 | [[package]] 459 | name = "once_cell" 460 | version = "1.21.3" 461 | source = "registry+https://github.com/rust-lang/crates.io-index" 462 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 463 | 464 | [[package]] 465 | name = "owo-colors" 466 | version = "4.2.2" 467 | source = "registry+https://github.com/rust-lang/crates.io-index" 468 | checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" 469 | 470 | [[package]] 471 | name = "parking_lot" 472 | version = "0.12.4" 473 | source = "registry+https://github.com/rust-lang/crates.io-index" 474 | checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" 475 | dependencies = [ 476 | "lock_api", 477 | "parking_lot_core", 478 | ] 479 | 480 | [[package]] 481 | name = "parking_lot_core" 482 | version = "0.9.11" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" 485 | dependencies = [ 486 | "cfg-if", 487 | "libc", 488 | "redox_syscall", 489 | "smallvec", 490 | "windows-targets 0.52.6", 491 | ] 492 | 493 | [[package]] 494 | name = "paste" 495 | version = "1.0.15" 496 | source = "registry+https://github.com/rust-lang/crates.io-index" 497 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 498 | 499 | [[package]] 500 | name = "pin-project-lite" 501 | version = "0.2.16" 502 | source = "registry+https://github.com/rust-lang/crates.io-index" 503 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 504 | 505 | [[package]] 506 | name = "ppv-lite86" 507 | version = "0.2.21" 508 | source = "registry+https://github.com/rust-lang/crates.io-index" 509 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 510 | dependencies = [ 511 | "zerocopy", 512 | ] 513 | 514 | [[package]] 515 | name = "proc-macro2" 516 | version = "1.0.95" 517 | source = "registry+https://github.com/rust-lang/crates.io-index" 518 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 519 | dependencies = [ 520 | "unicode-ident", 521 | ] 522 | 523 | [[package]] 524 | name = "quote" 525 | version = "1.0.40" 526 | source = "registry+https://github.com/rust-lang/crates.io-index" 527 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 528 | dependencies = [ 529 | "proc-macro2", 530 | ] 531 | 532 | [[package]] 533 | name = "r-efi" 534 | version = "5.3.0" 535 | source = "registry+https://github.com/rust-lang/crates.io-index" 536 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 537 | 538 | [[package]] 539 | name = "rand" 540 | version = "0.9.1" 541 | source = "registry+https://github.com/rust-lang/crates.io-index" 542 | checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" 543 | dependencies = [ 544 | "rand_chacha", 545 | "rand_core", 546 | ] 547 | 548 | [[package]] 549 | name = "rand_chacha" 550 | version = "0.9.0" 551 | source = "registry+https://github.com/rust-lang/crates.io-index" 552 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 553 | dependencies = [ 554 | "ppv-lite86", 555 | "rand_core", 556 | ] 557 | 558 | [[package]] 559 | name = "rand_core" 560 | version = "0.9.3" 561 | source = "registry+https://github.com/rust-lang/crates.io-index" 562 | checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 563 | dependencies = [ 564 | "getrandom", 565 | ] 566 | 567 | [[package]] 568 | name = "ratatui" 569 | version = "0.29.0" 570 | source = "registry+https://github.com/rust-lang/crates.io-index" 571 | checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" 572 | dependencies = [ 573 | "bitflags", 574 | "cassowary", 575 | "compact_str", 576 | "crossterm", 577 | "indoc", 578 | "instability", 579 | "itertools", 580 | "lru", 581 | "paste", 582 | "strum", 583 | "unicode-segmentation", 584 | "unicode-truncate", 585 | "unicode-width 0.2.0", 586 | ] 587 | 588 | [[package]] 589 | name = "redox_syscall" 590 | version = "0.5.13" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" 593 | dependencies = [ 594 | "bitflags", 595 | ] 596 | 597 | [[package]] 598 | name = "rustc-demangle" 599 | version = "0.1.25" 600 | source = "registry+https://github.com/rust-lang/crates.io-index" 601 | checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" 602 | 603 | [[package]] 604 | name = "rustix" 605 | version = "0.38.44" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 608 | dependencies = [ 609 | "bitflags", 610 | "errno", 611 | "libc", 612 | "linux-raw-sys 0.4.15", 613 | "windows-sys 0.59.0", 614 | ] 615 | 616 | [[package]] 617 | name = "rustix" 618 | version = "1.0.8" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" 621 | dependencies = [ 622 | "bitflags", 623 | "errno", 624 | "libc", 625 | "linux-raw-sys 0.9.4", 626 | "windows-sys 0.60.2", 627 | ] 628 | 629 | [[package]] 630 | name = "rustversion" 631 | version = "1.0.21" 632 | source = "registry+https://github.com/rust-lang/crates.io-index" 633 | checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" 634 | 635 | [[package]] 636 | name = "ryu" 637 | version = "1.0.20" 638 | source = "registry+https://github.com/rust-lang/crates.io-index" 639 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 640 | 641 | [[package]] 642 | name = "scopeguard" 643 | version = "1.2.0" 644 | source = "registry+https://github.com/rust-lang/crates.io-index" 645 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 646 | 647 | [[package]] 648 | name = "serde" 649 | version = "1.0.219" 650 | source = "registry+https://github.com/rust-lang/crates.io-index" 651 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 652 | dependencies = [ 653 | "serde_derive", 654 | ] 655 | 656 | [[package]] 657 | name = "serde_derive" 658 | version = "1.0.219" 659 | source = "registry+https://github.com/rust-lang/crates.io-index" 660 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 661 | dependencies = [ 662 | "proc-macro2", 663 | "quote", 664 | "syn", 665 | ] 666 | 667 | [[package]] 668 | name = "serde_spanned" 669 | version = "0.6.9" 670 | source = "registry+https://github.com/rust-lang/crates.io-index" 671 | checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" 672 | dependencies = [ 673 | "serde", 674 | ] 675 | 676 | [[package]] 677 | name = "sha2" 678 | version = "0.10.9" 679 | source = "registry+https://github.com/rust-lang/crates.io-index" 680 | checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 681 | dependencies = [ 682 | "cfg-if", 683 | "cpufeatures", 684 | "digest", 685 | ] 686 | 687 | [[package]] 688 | name = "sharded-slab" 689 | version = "0.1.7" 690 | source = "registry+https://github.com/rust-lang/crates.io-index" 691 | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 692 | dependencies = [ 693 | "lazy_static", 694 | ] 695 | 696 | [[package]] 697 | name = "signal-hook" 698 | version = "0.3.18" 699 | source = "registry+https://github.com/rust-lang/crates.io-index" 700 | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 701 | dependencies = [ 702 | "libc", 703 | "signal-hook-registry", 704 | ] 705 | 706 | [[package]] 707 | name = "signal-hook-mio" 708 | version = "0.2.4" 709 | source = "registry+https://github.com/rust-lang/crates.io-index" 710 | checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 711 | dependencies = [ 712 | "libc", 713 | "mio", 714 | "signal-hook", 715 | ] 716 | 717 | [[package]] 718 | name = "signal-hook-registry" 719 | version = "1.4.5" 720 | source = "registry+https://github.com/rust-lang/crates.io-index" 721 | checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" 722 | dependencies = [ 723 | "libc", 724 | ] 725 | 726 | [[package]] 727 | name = "smallvec" 728 | version = "1.15.1" 729 | source = "registry+https://github.com/rust-lang/crates.io-index" 730 | checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 731 | 732 | [[package]] 733 | name = "static_assertions" 734 | version = "1.1.0" 735 | source = "registry+https://github.com/rust-lang/crates.io-index" 736 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 737 | 738 | [[package]] 739 | name = "strsim" 740 | version = "0.11.1" 741 | source = "registry+https://github.com/rust-lang/crates.io-index" 742 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 743 | 744 | [[package]] 745 | name = "strum" 746 | version = "0.26.3" 747 | source = "registry+https://github.com/rust-lang/crates.io-index" 748 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 749 | dependencies = [ 750 | "strum_macros", 751 | ] 752 | 753 | [[package]] 754 | name = "strum_macros" 755 | version = "0.26.4" 756 | source = "registry+https://github.com/rust-lang/crates.io-index" 757 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 758 | dependencies = [ 759 | "heck", 760 | "proc-macro2", 761 | "quote", 762 | "rustversion", 763 | "syn", 764 | ] 765 | 766 | [[package]] 767 | name = "syn" 768 | version = "2.0.104" 769 | source = "registry+https://github.com/rust-lang/crates.io-index" 770 | checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" 771 | dependencies = [ 772 | "proc-macro2", 773 | "quote", 774 | "unicode-ident", 775 | ] 776 | 777 | [[package]] 778 | name = "tempfile" 779 | version = "3.20.0" 780 | source = "registry+https://github.com/rust-lang/crates.io-index" 781 | checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" 782 | dependencies = [ 783 | "fastrand", 784 | "getrandom", 785 | "once_cell", 786 | "rustix 1.0.8", 787 | "windows-sys 0.59.0", 788 | ] 789 | 790 | [[package]] 791 | name = "thread_local" 792 | version = "1.1.9" 793 | source = "registry+https://github.com/rust-lang/crates.io-index" 794 | checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 795 | dependencies = [ 796 | "cfg-if", 797 | ] 798 | 799 | [[package]] 800 | name = "toml" 801 | version = "0.8.23" 802 | source = "registry+https://github.com/rust-lang/crates.io-index" 803 | checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 804 | dependencies = [ 805 | "serde", 806 | "serde_spanned", 807 | "toml_datetime", 808 | "toml_edit", 809 | ] 810 | 811 | [[package]] 812 | name = "toml_datetime" 813 | version = "0.6.11" 814 | source = "registry+https://github.com/rust-lang/crates.io-index" 815 | checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 816 | dependencies = [ 817 | "serde", 818 | ] 819 | 820 | [[package]] 821 | name = "toml_edit" 822 | version = "0.22.27" 823 | source = "registry+https://github.com/rust-lang/crates.io-index" 824 | checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 825 | dependencies = [ 826 | "indexmap", 827 | "serde", 828 | "serde_spanned", 829 | "toml_datetime", 830 | "toml_write", 831 | "winnow", 832 | ] 833 | 834 | [[package]] 835 | name = "toml_write" 836 | version = "0.1.2" 837 | source = "registry+https://github.com/rust-lang/crates.io-index" 838 | checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 839 | 840 | [[package]] 841 | name = "tracing" 842 | version = "0.1.41" 843 | source = "registry+https://github.com/rust-lang/crates.io-index" 844 | checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 845 | dependencies = [ 846 | "pin-project-lite", 847 | "tracing-core", 848 | ] 849 | 850 | [[package]] 851 | name = "tracing-core" 852 | version = "0.1.34" 853 | source = "registry+https://github.com/rust-lang/crates.io-index" 854 | checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 855 | dependencies = [ 856 | "once_cell", 857 | "valuable", 858 | ] 859 | 860 | [[package]] 861 | name = "tracing-error" 862 | version = "0.2.1" 863 | source = "registry+https://github.com/rust-lang/crates.io-index" 864 | checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" 865 | dependencies = [ 866 | "tracing", 867 | "tracing-subscriber", 868 | ] 869 | 870 | [[package]] 871 | name = "tracing-subscriber" 872 | version = "0.3.19" 873 | source = "registry+https://github.com/rust-lang/crates.io-index" 874 | checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 875 | dependencies = [ 876 | "sharded-slab", 877 | "thread_local", 878 | "tracing-core", 879 | ] 880 | 881 | [[package]] 882 | name = "ttypr" 883 | version = "0.3.5" 884 | dependencies = [ 885 | "color-eyre", 886 | "crossterm", 887 | "home", 888 | "rand", 889 | "ratatui", 890 | "serde", 891 | "sha2", 892 | "tempfile", 893 | "toml", 894 | ] 895 | 896 | [[package]] 897 | name = "typenum" 898 | version = "1.18.0" 899 | source = "registry+https://github.com/rust-lang/crates.io-index" 900 | checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 901 | 902 | [[package]] 903 | name = "unicode-ident" 904 | version = "1.0.18" 905 | source = "registry+https://github.com/rust-lang/crates.io-index" 906 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 907 | 908 | [[package]] 909 | name = "unicode-segmentation" 910 | version = "1.12.0" 911 | source = "registry+https://github.com/rust-lang/crates.io-index" 912 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 913 | 914 | [[package]] 915 | name = "unicode-truncate" 916 | version = "1.1.0" 917 | source = "registry+https://github.com/rust-lang/crates.io-index" 918 | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 919 | dependencies = [ 920 | "itertools", 921 | "unicode-segmentation", 922 | "unicode-width 0.1.14", 923 | ] 924 | 925 | [[package]] 926 | name = "unicode-width" 927 | version = "0.1.14" 928 | source = "registry+https://github.com/rust-lang/crates.io-index" 929 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 930 | 931 | [[package]] 932 | name = "unicode-width" 933 | version = "0.2.0" 934 | source = "registry+https://github.com/rust-lang/crates.io-index" 935 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 936 | 937 | [[package]] 938 | name = "valuable" 939 | version = "0.1.1" 940 | source = "registry+https://github.com/rust-lang/crates.io-index" 941 | checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 942 | 943 | [[package]] 944 | name = "version_check" 945 | version = "0.9.5" 946 | source = "registry+https://github.com/rust-lang/crates.io-index" 947 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 948 | 949 | [[package]] 950 | name = "wasi" 951 | version = "0.11.1+wasi-snapshot-preview1" 952 | source = "registry+https://github.com/rust-lang/crates.io-index" 953 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 954 | 955 | [[package]] 956 | name = "wasi" 957 | version = "0.14.2+wasi-0.2.4" 958 | source = "registry+https://github.com/rust-lang/crates.io-index" 959 | checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 960 | dependencies = [ 961 | "wit-bindgen-rt", 962 | ] 963 | 964 | [[package]] 965 | name = "winapi" 966 | version = "0.3.9" 967 | source = "registry+https://github.com/rust-lang/crates.io-index" 968 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 969 | dependencies = [ 970 | "winapi-i686-pc-windows-gnu", 971 | "winapi-x86_64-pc-windows-gnu", 972 | ] 973 | 974 | [[package]] 975 | name = "winapi-i686-pc-windows-gnu" 976 | version = "0.4.0" 977 | source = "registry+https://github.com/rust-lang/crates.io-index" 978 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 979 | 980 | [[package]] 981 | name = "winapi-x86_64-pc-windows-gnu" 982 | version = "0.4.0" 983 | source = "registry+https://github.com/rust-lang/crates.io-index" 984 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 985 | 986 | [[package]] 987 | name = "windows-sys" 988 | version = "0.59.0" 989 | source = "registry+https://github.com/rust-lang/crates.io-index" 990 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 991 | dependencies = [ 992 | "windows-targets 0.52.6", 993 | ] 994 | 995 | [[package]] 996 | name = "windows-sys" 997 | version = "0.60.2" 998 | source = "registry+https://github.com/rust-lang/crates.io-index" 999 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 1000 | dependencies = [ 1001 | "windows-targets 0.53.2", 1002 | ] 1003 | 1004 | [[package]] 1005 | name = "windows-targets" 1006 | version = "0.52.6" 1007 | source = "registry+https://github.com/rust-lang/crates.io-index" 1008 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1009 | dependencies = [ 1010 | "windows_aarch64_gnullvm 0.52.6", 1011 | "windows_aarch64_msvc 0.52.6", 1012 | "windows_i686_gnu 0.52.6", 1013 | "windows_i686_gnullvm 0.52.6", 1014 | "windows_i686_msvc 0.52.6", 1015 | "windows_x86_64_gnu 0.52.6", 1016 | "windows_x86_64_gnullvm 0.52.6", 1017 | "windows_x86_64_msvc 0.52.6", 1018 | ] 1019 | 1020 | [[package]] 1021 | name = "windows-targets" 1022 | version = "0.53.2" 1023 | source = "registry+https://github.com/rust-lang/crates.io-index" 1024 | checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" 1025 | dependencies = [ 1026 | "windows_aarch64_gnullvm 0.53.0", 1027 | "windows_aarch64_msvc 0.53.0", 1028 | "windows_i686_gnu 0.53.0", 1029 | "windows_i686_gnullvm 0.53.0", 1030 | "windows_i686_msvc 0.53.0", 1031 | "windows_x86_64_gnu 0.53.0", 1032 | "windows_x86_64_gnullvm 0.53.0", 1033 | "windows_x86_64_msvc 0.53.0", 1034 | ] 1035 | 1036 | [[package]] 1037 | name = "windows_aarch64_gnullvm" 1038 | version = "0.52.6" 1039 | source = "registry+https://github.com/rust-lang/crates.io-index" 1040 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1041 | 1042 | [[package]] 1043 | name = "windows_aarch64_gnullvm" 1044 | version = "0.53.0" 1045 | source = "registry+https://github.com/rust-lang/crates.io-index" 1046 | checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 1047 | 1048 | [[package]] 1049 | name = "windows_aarch64_msvc" 1050 | version = "0.52.6" 1051 | source = "registry+https://github.com/rust-lang/crates.io-index" 1052 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1053 | 1054 | [[package]] 1055 | name = "windows_aarch64_msvc" 1056 | version = "0.53.0" 1057 | source = "registry+https://github.com/rust-lang/crates.io-index" 1058 | checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 1059 | 1060 | [[package]] 1061 | name = "windows_i686_gnu" 1062 | version = "0.52.6" 1063 | source = "registry+https://github.com/rust-lang/crates.io-index" 1064 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1065 | 1066 | [[package]] 1067 | name = "windows_i686_gnu" 1068 | version = "0.53.0" 1069 | source = "registry+https://github.com/rust-lang/crates.io-index" 1070 | checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 1071 | 1072 | [[package]] 1073 | name = "windows_i686_gnullvm" 1074 | version = "0.52.6" 1075 | source = "registry+https://github.com/rust-lang/crates.io-index" 1076 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1077 | 1078 | [[package]] 1079 | name = "windows_i686_gnullvm" 1080 | version = "0.53.0" 1081 | source = "registry+https://github.com/rust-lang/crates.io-index" 1082 | checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 1083 | 1084 | [[package]] 1085 | name = "windows_i686_msvc" 1086 | version = "0.52.6" 1087 | source = "registry+https://github.com/rust-lang/crates.io-index" 1088 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1089 | 1090 | [[package]] 1091 | name = "windows_i686_msvc" 1092 | version = "0.53.0" 1093 | source = "registry+https://github.com/rust-lang/crates.io-index" 1094 | checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 1095 | 1096 | [[package]] 1097 | name = "windows_x86_64_gnu" 1098 | version = "0.52.6" 1099 | source = "registry+https://github.com/rust-lang/crates.io-index" 1100 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1101 | 1102 | [[package]] 1103 | name = "windows_x86_64_gnu" 1104 | version = "0.53.0" 1105 | source = "registry+https://github.com/rust-lang/crates.io-index" 1106 | checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 1107 | 1108 | [[package]] 1109 | name = "windows_x86_64_gnullvm" 1110 | version = "0.52.6" 1111 | source = "registry+https://github.com/rust-lang/crates.io-index" 1112 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1113 | 1114 | [[package]] 1115 | name = "windows_x86_64_gnullvm" 1116 | version = "0.53.0" 1117 | source = "registry+https://github.com/rust-lang/crates.io-index" 1118 | checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 1119 | 1120 | [[package]] 1121 | name = "windows_x86_64_msvc" 1122 | version = "0.52.6" 1123 | source = "registry+https://github.com/rust-lang/crates.io-index" 1124 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1125 | 1126 | [[package]] 1127 | name = "windows_x86_64_msvc" 1128 | version = "0.53.0" 1129 | source = "registry+https://github.com/rust-lang/crates.io-index" 1130 | checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 1131 | 1132 | [[package]] 1133 | name = "winnow" 1134 | version = "0.7.12" 1135 | source = "registry+https://github.com/rust-lang/crates.io-index" 1136 | checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" 1137 | dependencies = [ 1138 | "memchr", 1139 | ] 1140 | 1141 | [[package]] 1142 | name = "wit-bindgen-rt" 1143 | version = "0.39.0" 1144 | source = "registry+https://github.com/rust-lang/crates.io-index" 1145 | checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 1146 | dependencies = [ 1147 | "bitflags", 1148 | ] 1149 | 1150 | [[package]] 1151 | name = "zerocopy" 1152 | version = "0.8.26" 1153 | source = "registry+https://github.com/rust-lang/crates.io-index" 1154 | checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" 1155 | dependencies = [ 1156 | "zerocopy-derive", 1157 | ] 1158 | 1159 | [[package]] 1160 | name = "zerocopy-derive" 1161 | version = "0.8.26" 1162 | source = "registry+https://github.com/rust-lang/crates.io-index" 1163 | checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" 1164 | dependencies = [ 1165 | "proc-macro2", 1166 | "quote", 1167 | "syn", 1168 | ] 1169 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::Config; 2 | use rand::Rng; 3 | use std::collections::VecDeque; 4 | use std::time::{Duration, Instant}; 5 | 6 | /// Calculates and stores words per minute (WPM) data. 7 | /// 8 | /// This struct tracks the user's key presses, the time elapsed, and calculates 9 | /// the typing speed. The WPM is calculated after a pause in typing. 10 | pub struct Wpm { 11 | pub timer: Option, 12 | pub time_since_last_key_pressed: Option, 13 | pub key_presses: usize, 14 | pub wpm: usize, 15 | } 16 | 17 | impl Wpm { 18 | /// Creates a new `Wpm` instance with default values. 19 | pub fn new() -> Wpm { 20 | Wpm { 21 | timer: None, 22 | time_since_last_key_pressed: None, 23 | key_presses: 0, 24 | wpm: 0, 25 | } 26 | } 27 | 28 | /// Handles the logic for each key press. 29 | /// 30 | /// This function starts the main timer on the first key press, resets the 31 | /// pause timer, and increments the key press count. 32 | pub fn on_key_press(&mut self) { 33 | if let None = self.timer { 34 | self.timer = Some(Instant::now()); 35 | } 36 | self.time_since_last_key_pressed = Some(Instant::now()); 37 | self.key_presses += 1; 38 | } 39 | 40 | /// Handles the logic for each application tick. 41 | /// 42 | /// This function checks if the user has paused typing (3 seconds). If so, 43 | /// it calculates the WPM based on the key presses and elapsed time. 44 | /// 45 | /// Returns `true` if the WPM was calculated, indicating the UI needs 46 | /// to be updated. 47 | pub fn on_tick(&mut self) -> bool { 48 | if let Some(time_since_last_key_pressed) = self.time_since_last_key_pressed { 49 | // If the user has paused for more than 3 seconds, calculate WPM 50 | if time_since_last_key_pressed.elapsed() > Duration::from_secs(3) { 51 | // Get the net typing time, excluding the 3-second pause 52 | let time = self.timer.unwrap().elapsed().as_secs_f64() - 3.0; 53 | 54 | // If the net time is non-positive or too few keys were pressed, just reset 55 | if time <= 0.0 || self.key_presses < 10 { 56 | self.timer = None; 57 | self.time_since_last_key_pressed = None; 58 | self.key_presses = 0; 59 | } else { 60 | // Calculate WPM: (total words) / (time in minutes) 61 | // A "word" is considered to be 5 characters (including spaces) 62 | self.wpm = ((self.key_presses as f64 / 5.0) / (time / 60.0)) as usize; 63 | 64 | // Reset timers and counters for the next measurement 65 | self.timer = None; 66 | self.time_since_last_key_pressed = None; 67 | self.key_presses = 0; 68 | 69 | // Indicate that WPM has been updated 70 | return true; 71 | } 72 | } 73 | } 74 | false 75 | } 76 | } 77 | 78 | /// Manages the state and display timer for transient notifications in the UI. 79 | pub struct Notifications { 80 | pub mode: bool, 81 | pub option: bool, 82 | pub toggle: bool, 83 | pub mistyped: bool, 84 | pub clear_mistyped: bool, 85 | pub wpm: bool, 86 | pub display_wpm: bool, 87 | pub time_count: Option, 88 | } 89 | 90 | impl Notifications { 91 | /// Creates a new `Notifications` instance with all flags turned off. 92 | pub fn new() -> Notifications { 93 | Notifications { 94 | mode: false, 95 | option: false, 96 | toggle: false, 97 | mistyped: false, 98 | clear_mistyped: false, 99 | wpm: false, 100 | display_wpm: false, 101 | time_count: None, 102 | } 103 | } 104 | 105 | /// Call this on each application tick to manage notification visibility. 106 | /// Returns true if the UI needs to be updated. 107 | pub fn on_tick(&mut self) -> bool { 108 | if let Some(shown_at) = self.time_count { 109 | if shown_at.elapsed() > Duration::from_secs(2) { 110 | self.hide_all(); 111 | return true; // Indicates an update is needed 112 | } 113 | } 114 | false 115 | } 116 | 117 | /// Hides all notifications and resets the timer. 118 | fn hide_all(&mut self) { 119 | self.mode = false; 120 | self.option = false; 121 | self.toggle = false; 122 | self.mistyped = false; 123 | self.clear_mistyped = false; 124 | self.wpm = false; 125 | self.display_wpm = false; 126 | self.time_count = None; 127 | } 128 | 129 | /// Starts the visibility timer for the currently active notification. 130 | fn trigger(&mut self) { 131 | self.time_count = Some(Instant::now()); 132 | } 133 | 134 | /// Shows a notification indicating displaying WPM has been toggled. 135 | pub fn show_display_wpm(&mut self) { 136 | self.display_wpm = true; 137 | self.trigger(); 138 | } 139 | 140 | /// Shows a notification indicating the WPM. 141 | pub fn show_wpm(&mut self) { 142 | self.wpm = true; 143 | self.trigger(); 144 | } 145 | 146 | /// Shows a notification indicating a mode change. 147 | pub fn show_mode(&mut self) { 148 | self.mode = true; 149 | self.trigger(); 150 | } 151 | 152 | /// Shows a notification indicating a typing option change. 153 | pub fn show_option(&mut self) { 154 | self.option = true; 155 | self.trigger(); 156 | } 157 | 158 | /// Shows a notification indicating that notifications have been toggled. 159 | pub fn show_toggle(&mut self) { 160 | self.toggle = true; 161 | self.trigger(); 162 | } 163 | 164 | /// Shows a notification indicating that counting mistyped characters has been toggled. 165 | pub fn show_mistyped(&mut self) { 166 | self.mistyped = true; 167 | self.trigger(); 168 | } 169 | 170 | /// Shows a notification that the mistyped characters count has been cleared. 171 | pub fn show_clear_mistyped(&mut self) { 172 | self.clear_mistyped = true; 173 | self.trigger(); 174 | } 175 | } 176 | 177 | /// Represents the main application state and logic. 178 | /// 179 | /// This struct holds all the data necessary for the application to run, including 180 | /// UI state, typing data, user input, and configuration settings. It is 181 | /// responsible for handling user input, updating the state, and managing the 182 | /// overall application lifecycle. 183 | pub struct App { 184 | pub running: bool, 185 | pub needs_redraw: bool, 186 | pub needs_clear: bool, 187 | pub typed: bool, 188 | pub charset: VecDeque, // The ASCII/Words/Text character set (all are set of characters: ["a", "b", "c"]) 189 | pub input_chars: VecDeque, // The characters user typed 190 | pub ids: VecDeque, // Identifiers to display colored characters (0 - untyped, 1 - correct, 2 - incorrect) 191 | pub line_len: usize, 192 | pub lines_len: VecDeque, // Current length of lines in characters 193 | pub current_mode: CurrentMode, 194 | pub current_typing_option: CurrentTypingOption, 195 | pub words: Vec, 196 | pub text: Vec, 197 | pub notifications: Notifications, 198 | pub config: Config, 199 | pub show_help: bool, 200 | pub show_mistyped: bool, 201 | pub first_text_gen_len: usize, 202 | pub wpm: Wpm, 203 | } 204 | 205 | /// Defines the major operational modes of the application. 206 | pub enum CurrentMode { 207 | /// The menu mode , is used for managing settings, switching typing options, 208 | /// viewing mistyped characters, and accessing the help page. 209 | Menu, 210 | /// The typing mode, where the user actively practices typing. 211 | Typing, 212 | } 213 | 214 | /// Defines the different types of content the user can practice typing. 215 | pub enum CurrentTypingOption { 216 | Ascii, 217 | Words, 218 | Text, 219 | } 220 | 221 | /// A constant array of ASCII characters used for generating lines of random ASCII characters. 222 | const ASCII_CHARSET: &[&str] = &["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "~", "`", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "-", "_", "+", "=", "{", "}", "[", "]", "|", "\\", ":", ";", "\"", "'", "<", ">", ",", ".", "?", "/"]; 223 | 224 | impl App { 225 | /// Construct a new instance of App 226 | pub fn new() -> App { 227 | App { 228 | running: true, 229 | needs_redraw: true, 230 | needs_clear: false, 231 | typed: false, 232 | charset: VecDeque::new(), 233 | input_chars: VecDeque::new(), 234 | ids: VecDeque::new(), 235 | line_len: 50, 236 | lines_len: VecDeque::new(), 237 | current_mode: CurrentMode::Menu, 238 | current_typing_option: CurrentTypingOption::Ascii, 239 | words: vec![], 240 | text: vec![], 241 | notifications: Notifications::new(), 242 | config: Config::default(), 243 | show_help: false, 244 | show_mistyped: false, 245 | first_text_gen_len: 0, 246 | wpm: Wpm::new(), 247 | } 248 | } 249 | 250 | /// Stop the application 251 | pub fn quit(&mut self) { 252 | self.running = false; 253 | } 254 | 255 | /// Handles cleanup and saving before the application exits. 256 | /// 257 | /// This function is called just before the application terminates. It's 258 | /// responsible for persisting the application's state, such as saving the 259 | /// current configuration and adjusting any other relevant settings. 260 | pub fn on_exit(&mut self) { 261 | use crate::utils::{get_config_dir, save_config}; 262 | 263 | // (If exited the application while being the Text option) 264 | // Subtract how many "words" there were on the first three lines 265 | match self.current_typing_option { 266 | CurrentTypingOption::Text => { 267 | if self.config.skip_len >= self.first_text_gen_len { 268 | self.config.skip_len -= self.first_text_gen_len; 269 | } else { 270 | self.config.skip_len = 0; 271 | } 272 | } 273 | _ => {} 274 | } 275 | 276 | // Save config (for mistyped characters) before exiting 277 | if let Ok(config_dir) = get_config_dir() { 278 | save_config(&self.config, &config_dir).unwrap_or_else(|err| { 279 | eprintln!("Failed to save config: {}", err); 280 | }); 281 | } 282 | } 283 | 284 | /// Handles tasks that run on every application tick. 285 | /// 286 | /// This function shows the WPM notification if a calculation is ready and also 287 | /// manages the lifecycle of notifications, clearing them after a timeout. 288 | pub fn on_tick(&mut self) { 289 | if self.wpm.on_tick() { 290 | self.notifications.show_wpm(); 291 | self.needs_redraw = true; 292 | } 293 | if self.notifications.on_tick() { 294 | self.needs_clear = true; 295 | self.needs_redraw = true; 296 | } 297 | } 298 | 299 | /// Initializes the application state at startup. 300 | /// 301 | /// This function is responsible for setting up the initial state of the 302 | /// application. It loads the configuration, populates the initial character 303 | /// sets for typing, and prepares the application to be run. 304 | pub fn setup(&mut self) -> color_eyre::Result<()> { 305 | use crate::utils::{ 306 | calculate_text_txt_hash, default_text, default_words, get_config_dir, load_config, 307 | read_text_from_file, read_words_from_file, 308 | }; 309 | 310 | // Get the config directory 311 | let config_dir = get_config_dir()?; 312 | 313 | // Load config file or create it 314 | self.config = load_config(&config_dir).unwrap_or_else(|_err| Config::default()); 315 | 316 | // (For the ASCII option) - Generate initial random charset and set all ids to 0 317 | // (This for block is here because the default typing option is Ascii) 318 | for _ in 0..3 { 319 | let one_line = self.gen_one_line_of_ascii(); 320 | 321 | let characters: Vec = one_line.chars().collect(); 322 | self.lines_len.push_back(characters.len()); 323 | for char in characters { 324 | self.charset.push_back(char.to_string()); 325 | self.ids.push_back(0); 326 | } 327 | } 328 | 329 | // (For the Words option) - Read the words from .config/ttypr/words.txt 330 | // If it doesn't exist, it will default to an empty vector. 331 | self.words = read_words_from_file(&config_dir).unwrap_or_default(); 332 | 333 | // (For the Text option) - Read the text from .config/ttypr/text.txt 334 | // If it doesn't exist, it will default to an empty vector. 335 | self.text = read_text_from_file(&config_dir).unwrap_or_default(); 336 | 337 | // If words file provided use that one instead of the default set 338 | if !self.words.is_empty() { 339 | self.config.use_default_word_set = false; 340 | } 341 | 342 | // Use the default word set if previously selected to use it 343 | if self.config.use_default_word_set { 344 | self.words = default_words(); 345 | } 346 | 347 | // This is for if user decided to switch between using the default text set 348 | // and a provided one. 349 | // If text file was provided, and default text set was previously selected - 350 | // use the provided file contents instead from now on, and reset the 351 | // Text option position. 352 | if !self.text.is_empty() && self.config.use_default_text_set { 353 | self.config.use_default_text_set = false; 354 | self.config.skip_len = 0; 355 | } 356 | 357 | // This is for if user decided to switch between using the default text set 358 | // and a provided one. 359 | // If file was not provided, and default text set is not selected - set the 360 | // Text option position to the beginning. 361 | // (This is here because the user can delete the provided text set, so this 362 | // if block resets the position in the Text option to the beginning) 363 | if self.text.is_empty() && !self.config.use_default_text_set { 364 | self.config.skip_len = 0; 365 | } 366 | 367 | // Use the default text set if previously selected to use it 368 | if self.config.use_default_text_set { 369 | self.text = default_text(); 370 | } 371 | 372 | // If the contents of the .config/ttypr/text.txt changed - 373 | // reset the position to the beginning 374 | if self.config.last_text_txt_hash != calculate_text_txt_hash(&config_dir).ok() { 375 | self.config.skip_len = 0; 376 | } 377 | 378 | // Calculate the hash of the .config/ttypr/text.txt to 379 | // compare to the previously generated one and determine 380 | // whether the file contents have changed 381 | self.config.last_text_txt_hash = calculate_text_txt_hash(&config_dir).ok(); 382 | 383 | Ok(()) 384 | } 385 | 386 | /// Constructs a line of random ASCII characters that fits within the configured line length. 387 | pub fn gen_one_line_of_ascii(&mut self) -> String { 388 | let mut line_of_ascii = vec![]; 389 | for _ in 0..self.line_len { 390 | let index = rand::rng().random_range(0..ASCII_CHARSET.len()); 391 | let character = ASCII_CHARSET[index]; 392 | line_of_ascii.push(character.to_string()) 393 | } 394 | line_of_ascii.join("") 395 | } 396 | 397 | /// Constructs a line of random words that fits within the configured line length. 398 | pub fn gen_one_line_of_words(&mut self) -> String { 399 | let mut line_of_words = vec![]; 400 | loop { 401 | let index = rand::rng().random_range(0..self.words.len()); 402 | let word = self.words[index].clone(); 403 | line_of_words.push(word); 404 | 405 | let current_line_len = line_of_words.join(" ").chars().count(); 406 | 407 | if current_line_len > self.line_len { 408 | line_of_words.pop(); 409 | let mut current_line = line_of_words.join(" "); 410 | if !current_line.is_empty() { 411 | current_line.push(' '); 412 | } 413 | return current_line; 414 | }; 415 | }; 416 | } 417 | 418 | /// Retrieves the next line of text from the source, respecting the configured line length. 419 | pub fn get_one_line_of_text(&mut self) -> String { 420 | let mut line_of_text = vec![]; 421 | loop { 422 | // If reached the end of the text - set position to 0 423 | if self.config.skip_len == self.text.len() { self.config.skip_len = 0 } 424 | 425 | line_of_text.push(self.text[self.config.skip_len].clone()); 426 | let current_line_len = line_of_text.join(" ").chars().count(); 427 | self.config.skip_len += 1; 428 | 429 | if current_line_len > self.line_len { 430 | line_of_text.pop(); 431 | self.config.skip_len -= 1; 432 | 433 | let mut current_line = line_of_text.join(" "); 434 | if !current_line.is_empty() { 435 | current_line.push(' '); 436 | } 437 | return current_line; 438 | } 439 | } 440 | } 441 | 442 | /// Set the ID for the last typed character to determine its color, 443 | /// and record it if it was a mistype. 444 | pub fn update_id_field(&mut self) { 445 | // Number of characters the user typed, to compare with the charset 446 | let pos = self.input_chars.len() - 1; 447 | 448 | // If the input character matches the characters in the 449 | // charset replace the 0 in ids with 1 (correct), 2 (incorrect) 450 | if self.input_chars[pos] == self.charset[pos] { 451 | self.ids[pos] = 1; 452 | } else { 453 | self.ids[pos] = 2; 454 | 455 | // Add the mistyped character to mistyped characters list 456 | if self.config.save_mistyped { 457 | let count = self.config.mistyped_chars.entry(self.charset[pos].to_string()).or_insert(0); 458 | *count += 1; 459 | } 460 | } 461 | } 462 | 463 | /// Manages the scrolling display by updating the character buffers. 464 | /// 465 | /// When the user finishes typing the second line, this function removes the 466 | /// first line's data from the buffers and appends a new line, creating a 467 | /// continuous scrolling effect. 468 | pub fn update_lines(&mut self) { 469 | // If reached the end of the second line 470 | if self.input_chars.len() == self.lines_len[0] + self.lines_len[1] { 471 | // Remove first line amount of characters from the character set, 472 | // the user inputted characters, and ids. 473 | for _ in 0..self.lines_len[0] { 474 | self.charset.pop_front(); 475 | self.input_chars.pop_front(); 476 | self.ids.pop_front(); 477 | } 478 | 479 | // One line of ascii characters/words/text 480 | let one_line = match self.current_typing_option { 481 | CurrentTypingOption::Ascii => { self.gen_one_line_of_ascii() }, 482 | CurrentTypingOption::Words => { self.gen_one_line_of_words() }, 483 | CurrentTypingOption::Text => { self.get_one_line_of_text() }, 484 | }; 485 | 486 | // Convert that line into characters 487 | let characters: Vec = one_line.chars().collect(); 488 | 489 | // Remove the length of the first line of characters from the front, 490 | // and push the new one to the back. 491 | self.lines_len.pop_front(); 492 | self.lines_len.push_back(characters.len()); 493 | 494 | // Push new amount of characters (words) to charset, and that amount of 0's to ids 495 | for char in characters { 496 | self.charset.push_back(char.to_string()); 497 | self.ids.push_back(0); 498 | } 499 | } 500 | } 501 | 502 | /// Empties the buffers that store the character set, user input, IDs and line lengths. 503 | /// 504 | /// This is called when the typing option is switched - to reset the buffers for 505 | /// the new content. 506 | pub fn clear_typing_buffers(&mut self) { 507 | self.charset.clear(); 508 | self.input_chars.clear(); 509 | self.ids.clear(); 510 | self.lines_len.clear(); 511 | } 512 | 513 | /// Switches to the next typing option and generates the text. 514 | /// 515 | /// This function cycles through the available typing options (ASCII, Words, Text) 516 | /// and prepares the application state for the new option. It clears the 517 | /// existing content in the buffers, generates new content, and signals to update the UI. 518 | pub(crate) fn switch_typing_option(&mut self) { 519 | self.needs_clear = true; 520 | self.notifications.show_option(); 521 | self.clear_typing_buffers(); 522 | 523 | // Switches current typing option 524 | match self.current_typing_option { 525 | // If ASCII - switch to Words 526 | CurrentTypingOption::Ascii => { 527 | self.current_typing_option = CurrentTypingOption::Words; 528 | 529 | // Only generate the lines if the words file was provided or the default set was chosen 530 | if !self.words.is_empty() { 531 | // Generate three lines of words 532 | for _ in 0..3 { 533 | let one_line = self.gen_one_line_of_words(); 534 | self.populate_charset_from_line(one_line); 535 | } 536 | } 537 | } 538 | // If Words - switch to Text 539 | CurrentTypingOption::Words => { 540 | self.current_typing_option = CurrentTypingOption::Text; 541 | 542 | // Only generate the lines if the text file was provided or the default text was chosen 543 | if !self.text.is_empty() { 544 | for _ in 0..3 { 545 | let one_line = self.get_one_line_of_text(); 546 | // Count for how many "words" there were on the first three lines 547 | // to keep position on option switch and exit. 548 | // Otherwise would always skip 3 lines down. 549 | let first_text_gen_len: Vec = 550 | one_line.split_whitespace().map(String::from).collect(); 551 | self.first_text_gen_len += first_text_gen_len.len(); 552 | 553 | self.populate_charset_from_line(one_line); 554 | } 555 | } 556 | } 557 | // If Text - switch to ASCII 558 | CurrentTypingOption::Text => { 559 | // Subtract how many "words" there were on the first three lines 560 | if self.config.skip_len >= self.first_text_gen_len { 561 | self.config.skip_len -= self.first_text_gen_len; 562 | } else { 563 | self.config.skip_len = 0; 564 | } 565 | self.first_text_gen_len = 0; 566 | 567 | self.current_typing_option = CurrentTypingOption::Ascii; 568 | 569 | // Generate three lines worth of characters and ids 570 | for _ in 0..3 { 571 | let one_line = self.gen_one_line_of_ascii(); 572 | self.populate_charset_from_line(one_line); 573 | } 574 | } 575 | } 576 | } 577 | 578 | /// Populates the character set and related fields from a single line of text. 579 | /// 580 | /// This helper function takes a string, splits it into characters, and updates 581 | /// the `charset`, `ids`, and `lines_len` fields of the `App` state. This is 582 | /// used to prepare the text that the user will be prompted to type. 583 | pub(crate) fn populate_charset_from_line(&mut self, one_line: String) { 584 | // Push a line of characters and ids 585 | let characters: Vec = one_line.chars().collect(); 586 | self.lines_len.push_back(characters.len()); 587 | for char in characters { 588 | self.charset.push_back(char.to_string()); 589 | self.ids.push_back(0); 590 | } 591 | } 592 | } 593 | 594 | #[cfg(test)] 595 | mod tests { 596 | use super::*; 597 | use std::thread; 598 | 599 | #[test] 600 | fn test_notifications_on_tick() { 601 | let mut notifications = Notifications::new(); 602 | 603 | // Should return false when no notification is active 604 | assert!(!notifications.on_tick()); 605 | 606 | // Show a notification to start the timer 607 | notifications.show_mode(); 608 | assert!(notifications.mode); 609 | assert!(notifications.time_count.is_some()); 610 | 611 | // Should still return false immediately after 612 | assert!(!notifications.on_tick()); 613 | 614 | // Wait for more than 2 seconds 615 | thread::sleep(Duration::from_secs(3)); 616 | 617 | // Now on_tick should return true and hide notifications 618 | assert!(notifications.on_tick()); 619 | assert!(!notifications.mode); 620 | assert!(notifications.time_count.is_none()); 621 | } 622 | 623 | #[test] 624 | fn test_notifications_hide_all() { 625 | let mut notifications = Notifications::new(); 626 | 627 | // Show some notifications 628 | notifications.show_mode(); 629 | notifications.show_option(); 630 | notifications.show_toggle(); 631 | notifications.show_mistyped(); 632 | notifications.show_clear_mistyped(); 633 | 634 | // Hide them 635 | notifications.hide_all(); 636 | 637 | // Check that all flags are false 638 | assert!(!notifications.mode); 639 | assert!(!notifications.option); 640 | assert!(!notifications.toggle); 641 | assert!(!notifications.mistyped); 642 | assert!(!notifications.clear_mistyped); 643 | assert!(notifications.time_count.is_none()); 644 | } 645 | 646 | #[test] 647 | fn test_notifications_trigger() { 648 | let mut notifications = Notifications::new(); 649 | 650 | // Timer should not be set initially 651 | assert!(notifications.time_count.is_none()); 652 | 653 | // Trigger the timer 654 | notifications.trigger(); 655 | 656 | // Timer should now be set 657 | assert!(notifications.time_count.is_some()); 658 | } 659 | 660 | #[test] 661 | fn test_notifications_show_methods() { 662 | let mut notifications = Notifications::new(); 663 | 664 | // Test show_mode 665 | notifications.show_mode(); 666 | assert!(notifications.mode); 667 | assert!(notifications.time_count.is_some()); 668 | notifications.hide_all(); // Reset for next test 669 | 670 | // Test show_option 671 | notifications.show_option(); 672 | assert!(notifications.option); 673 | assert!(notifications.time_count.is_some()); 674 | notifications.hide_all(); 675 | 676 | // Test show_toggle 677 | notifications.show_toggle(); 678 | assert!(notifications.toggle); 679 | assert!(notifications.time_count.is_some()); 680 | notifications.hide_all(); 681 | 682 | // Test show_mistyped 683 | notifications.show_mistyped(); 684 | assert!(notifications.mistyped); 685 | assert!(notifications.time_count.is_some()); 686 | notifications.hide_all(); 687 | 688 | // Test show_clear_mistyped 689 | notifications.show_clear_mistyped(); 690 | assert!(notifications.clear_mistyped); 691 | assert!(notifications.time_count.is_some()); 692 | notifications.hide_all(); 693 | 694 | // Test show_wpm 695 | notifications.show_wpm(); 696 | assert!(notifications.wpm); 697 | assert!(notifications.time_count.is_some()); 698 | notifications.hide_all(); 699 | 700 | // Test show_display_wpm 701 | notifications.show_display_wpm(); 702 | assert!(notifications.display_wpm); 703 | assert!(notifications.time_count.is_some()); 704 | } 705 | 706 | #[test] 707 | fn test_app_gen_one_line_of_ascii() { 708 | let mut app = App::new(); 709 | app.line_len = 50; 710 | let line = app.gen_one_line_of_ascii(); 711 | assert_eq!(line.chars().count(), 50); 712 | 713 | app.line_len = 10; 714 | let line = app.gen_one_line_of_ascii(); 715 | assert_eq!(line.chars().count(), 10); 716 | } 717 | 718 | #[test] 719 | fn test_app_gen_one_line_of_words() { 720 | let mut app = App::new(); 721 | app.line_len = 50; 722 | app.words = vec!["hello".to_string(), "world".to_string(), "this".to_string(), "is".to_string(), "a".to_string(), "test".to_string()]; 723 | 724 | let line = app.gen_one_line_of_words(); 725 | 726 | // Check that the line is not empty 727 | assert!(!line.is_empty()); 728 | // Check that it ends with a space 729 | assert!(line.ends_with(' ')); 730 | 731 | // Check that the line length is within the limit (or one over if the text part hit the limit exactly) 732 | assert!(line.chars().count() <= app.line_len + 1); 733 | 734 | // Check with a smaller line length 735 | app.line_len = 10; 736 | let line = app.gen_one_line_of_words(); 737 | assert!(!line.is_empty()); 738 | assert!(line.ends_with(' ')); 739 | assert!(line.chars().count() <= app.line_len + 1); 740 | 741 | // Test edge case where no words fit 742 | app.line_len = 2; 743 | let line = app.gen_one_line_of_words(); 744 | assert!(line.is_empty()); 745 | } 746 | 747 | #[test] 748 | fn test_app_get_one_line_of_text() { 749 | let mut app = App::new(); 750 | app.line_len = 20; 751 | app.text = "This is a sample text for testing purposes." 752 | .split_whitespace() 753 | .map(String::from) 754 | .collect(); 755 | app.config.skip_len = 0; 756 | 757 | // First line generation 758 | let line1 = app.get_one_line_of_text(); 759 | assert_eq!(line1, "This is a sample "); 760 | assert_eq!(app.config.skip_len, 4); // Should have processed 4 words 761 | 762 | // Second line generation 763 | let line2 = app.get_one_line_of_text(); 764 | assert_eq!(line2, "text for testing "); 765 | assert_eq!(app.config.skip_len, 7); 766 | 767 | // Third line generation, testing wrap-around 768 | let line3 = app.get_one_line_of_text(); 769 | assert_eq!(line3, "purposes. This is a "); 770 | assert_eq!(app.config.skip_len, 3); // Wrapped around and used 3 words 771 | } 772 | 773 | #[test] 774 | fn test_app_update_id_field() { 775 | let mut app = App::new(); 776 | app.charset = VecDeque::from(vec!["a".to_string(), "b".to_string(), "c".to_string()]); 777 | app.ids = VecDeque::from(vec![0, 0, 0]); 778 | 779 | // --- Test 1: Correct character --- 780 | app.input_chars.push_back("a".to_string()); 781 | app.update_id_field(); 782 | assert_eq!(app.ids[0], 1); 783 | 784 | // --- Test 2: Incorrect character, without saving mistypes --- 785 | app.config.save_mistyped = false; 786 | app.input_chars.push_back("x".to_string()); // Correct char is "b" 787 | app.update_id_field(); 788 | assert_eq!(app.ids[1], 2); 789 | assert!(app.config.mistyped_chars.is_empty()); // Should not record 790 | 791 | // --- Test 3: Incorrect character, with saving mistypes --- 792 | app.config.save_mistyped = true; 793 | app.input_chars.push_back("y".to_string()); // Correct char is "c" 794 | app.update_id_field(); 795 | assert_eq!(app.ids[2], 2); 796 | assert_eq!(*app.config.mistyped_chars.get("c").unwrap(), 1); // "c" was mistyped once 797 | } 798 | 799 | #[test] 800 | fn test_app_update_lines() { 801 | let mut app = App::new(); 802 | app.line_len = 5; // Use a short line length for easier testing 803 | 804 | // --- Setup initial state with 3 lines of content --- 805 | app.current_typing_option = CurrentTypingOption::Ascii; 806 | 807 | // Line 1: "aaaaa" 808 | app.charset.extend(vec!["a".to_string(); 5]); 809 | app.ids.extend(vec![1; 5]); // Simulate typed 810 | app.input_chars.extend(vec!["a".to_string(); 5]); 811 | app.lines_len.push_back(5); 812 | 813 | // Line 2: "bbbbb" 814 | app.charset.extend(vec!["b".to_string(); 5]); 815 | app.ids.extend(vec![1; 5]); // Simulate typed 816 | app.input_chars.extend(vec!["b".to_string(); 5]); 817 | app.lines_len.push_back(5); 818 | 819 | // Line 3: "ccccc" (not yet typed) 820 | app.charset.extend(vec!["c".to_string(); 5]); 821 | app.ids.extend(vec![0; 5]); 822 | app.lines_len.push_back(5); 823 | 824 | // At this point, input_chars length is 10, which equals lines_len[0] + lines_len[1] 825 | assert_eq!(app.input_chars.len(), app.lines_len[0] + app.lines_len[1]); 826 | 827 | // --- Call the function to test --- 828 | app.update_lines(); 829 | 830 | // --- Assert the results --- 831 | // 1. First line's data should be removed from buffers 832 | assert_eq!(app.input_chars.len(), 5); 833 | assert_eq!(app.input_chars.front().unwrap(), "b"); 834 | 835 | // 2. A new line should be generated and added 836 | assert_eq!(app.lines_len.len(), 3); // Still 3 lines 837 | assert_eq!(app.lines_len[0], 5); // Old line 2 is now line 1 838 | assert_eq!(app.lines_len[1], 5); // Old line 3 is now line 2 839 | assert_eq!(app.lines_len[2], 5); // New line 3 has been added 840 | 841 | assert_eq!(app.charset.len(), 15); // Total chars should be back to 15 842 | assert_eq!(app.ids.len(), 15); // Total ids should be back to 15 843 | 844 | // 3. The newly added ids should be 0 (untyped) 845 | // (Check the last 5 elements of the ids VecDeque) 846 | assert!(app.ids.iter().skip(10).all(|&id| id == 0)); 847 | } 848 | 849 | #[test] 850 | fn test_app_clear_typing_buffers() { 851 | let mut app = App::new(); 852 | 853 | // Populate buffers with some data 854 | app.charset.push_back("a".to_string()); 855 | app.input_chars.push_back("a".to_string()); 856 | app.ids.push_back(1); 857 | app.lines_len.push_back(1); 858 | 859 | // Ensure they are not empty before clearing 860 | assert!(!app.charset.is_empty()); 861 | assert!(!app.input_chars.is_empty()); 862 | assert!(!app.ids.is_empty()); 863 | assert!(!app.lines_len.is_empty()); 864 | 865 | // Call the function 866 | app.clear_typing_buffers(); 867 | 868 | // Assert that all buffers are empty 869 | assert!(app.charset.is_empty()); 870 | assert!(app.input_chars.is_empty()); 871 | assert!(app.ids.is_empty()); 872 | assert!(app.lines_len.is_empty()); 873 | } 874 | 875 | #[test] 876 | fn test_app_switch_typing_option() { 877 | let mut app = App::new(); 878 | // Provide some data for words and text modes 879 | app.words = vec!["word1".to_string(), "word2".to_string()]; 880 | app.text = vec!["text1".to_string(), "text2".to_string()]; 881 | app.line_len = 10; 882 | 883 | // --- 1. Switch from ASCII (default) to Words --- 884 | assert!(matches!(app.current_typing_option, CurrentTypingOption::Ascii)); 885 | app.switch_typing_option(); 886 | assert!(matches!(app.current_typing_option, CurrentTypingOption::Words)); 887 | assert!(!app.charset.is_empty()); // Should be populated with words 888 | assert!(!app.lines_len.is_empty()); 889 | 890 | // --- 2. Switch from Words to Text --- 891 | app.switch_typing_option(); 892 | assert!(matches!(app.current_typing_option, CurrentTypingOption::Text)); 893 | assert!(!app.charset.is_empty()); // Should be populated with text 894 | assert_ne!(app.first_text_gen_len, 0); // Should be tracking generated text length 895 | 896 | // --- 3. Switch from Text back to ASCII --- 897 | app.switch_typing_option(); 898 | assert!(matches!(app.current_typing_option, CurrentTypingOption::Ascii)); 899 | assert!(!app.charset.is_empty()); // Should be populated with ASCII 900 | assert_eq!(app.first_text_gen_len, 0); // Should be reset 901 | } 902 | 903 | #[test] 904 | fn test_app_populate_charset_from_line() { 905 | let mut app = App::new(); 906 | let line = "hello".to_string(); 907 | 908 | app.populate_charset_from_line(line); 909 | 910 | // Check lines_len 911 | assert_eq!(app.lines_len.len(), 1); 912 | assert_eq!(app.lines_len[0], 5); 913 | 914 | // Check charset 915 | let expected_charset = VecDeque::from(vec!["h".to_string(), "e".to_string(), "l".to_string(), "l".to_string(), "o".to_string()]); 916 | assert_eq!(app.charset, expected_charset); 917 | 918 | // Check ids 919 | assert_eq!(app.ids.len(), 5); 920 | assert!(app.ids.iter().all(|&id| id == 0)); // All ids should be 0 921 | } 922 | 923 | #[test] 924 | fn test_wpm_logic() { 925 | let mut wpm = Wpm::new(); 926 | 927 | // 1. Initial state check 928 | assert!(wpm.timer.is_none()); 929 | assert!(wpm.time_since_last_key_pressed.is_none()); 930 | assert_eq!(wpm.key_presses, 0); 931 | assert_eq!(wpm.wpm, 0); 932 | 933 | // 2. First key press 934 | wpm.on_key_press(); 935 | assert!(wpm.timer.is_some()); 936 | assert!(wpm.time_since_last_key_pressed.is_some()); 937 | assert_eq!(wpm.key_presses, 1); 938 | 939 | // 3. Subsequent key presses 940 | for _ in 0..19 { 941 | wpm.on_key_press(); 942 | } 943 | assert_eq!(wpm.key_presses, 20); 944 | 945 | // 4. Tick before pause timeout 946 | assert!(!wpm.on_tick()); 947 | assert_eq!(wpm.wpm, 0); // WPM should not be calculated yet 948 | 949 | // 5. Simulate pause and test WPM calculation 950 | thread::sleep(Duration::from_secs(4)); // Wait for longer than the 3s pause 951 | let wpm_updated = wpm.on_tick(); 952 | 953 | assert!(wpm_updated); // Should return true as WPM was calculated 954 | assert_ne!(wpm.wpm, 0); // WPM should be a non-zero value 955 | 956 | // Check if state is reset 957 | assert!(wpm.timer.is_none()); 958 | assert!(wpm.time_since_last_key_pressed.is_none()); 959 | assert_eq!(wpm.key_presses, 0); 960 | } 961 | 962 | #[test] 963 | fn test_app_on_tick() { 964 | let mut app = App::new(); 965 | 966 | // --- Scenario 1: WPM update triggers notification --- 967 | // Manually set up the Wpm state to simulate a completed typing session 968 | app.wpm.key_presses = 15; // A realistic number of key presses 969 | app.wpm.timer = Some(Instant::now() - Duration::from_secs(10)); // Timer started 10s ago 970 | app.wpm.time_since_last_key_pressed = Some(Instant::now() - Duration::from_secs(4)); // Paused for 4s 971 | 972 | app.on_tick(); 973 | 974 | // Check that a WPM update occurred and triggered a notification 975 | assert!(app.notifications.wpm); 976 | assert!(app.notifications.time_count.is_some()); 977 | assert!(app.needs_redraw); 978 | 979 | // Reset flags for the next scenario 980 | app.needs_redraw = false; 981 | app.notifications.hide_all(); 982 | 983 | // --- Scenario 2: Notification timeout clears flags --- 984 | app.notifications.show_mode(); // Show a notification to start its timer 985 | assert!(app.notifications.mode); 986 | 987 | // Wait for the notification to time out 988 | thread::sleep(Duration::from_secs(3)); 989 | 990 | app.on_tick(); 991 | 992 | // Check that the notification timeout has set the appropriate flags 993 | assert!(app.needs_clear); 994 | assert!(app.needs_redraw); 995 | // The notification's own on_tick should have hidden it 996 | assert!(!app.notifications.mode); 997 | } 998 | } --------------------------------------------------------------------------------