├── .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 |

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 | [](https://crates.io/crates/ttypr)
16 | [](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 | 
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 | }
--------------------------------------------------------------------------------