├── .github └── workflows │ ├── publish.yml │ └── rust.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── docs ├── demo.gif └── screenshot.png └── src ├── banner.rs ├── client.rs ├── client ├── dictionary.rs └── suggester.rs ├── consts.rs ├── keys.rs ├── main.rs ├── models ├── app.rs ├── errors.rs ├── input_mode.rs ├── list.rs ├── mod.rs ├── thesaurus.rs └── word_suggestion.rs ├── tui.rs ├── ui.rs └── ui ├── banner_block.rs ├── definition_block.rs ├── example_block.rs ├── footer.rs ├── part_of_speech_block.rs ├── search_bar.rs └── synonym_block.rs /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | release: 10 | permissions: 11 | contents: write 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | 18 | - name: Set up Rust 19 | uses: actions-rs/toolchain@v1 20 | with: 21 | toolchain: stable 22 | override: true 23 | 24 | - name: Cache Cargo registry 25 | uses: actions/cache@v4 26 | with: 27 | path: ~/.cargo/registry 28 | key: ${{ runner.os }}-cargo-registry 29 | restore-keys: | 30 | ${{ runner.os }}-cargo-registry 31 | 32 | - name: Cache Cargo index 33 | uses: actions/cache@v4 34 | with: 35 | path: ~/.cargo/git 36 | key: ${{ runner.os }}-cargo-index 37 | restore-keys: | 38 | ${{ runner.os }}-cargo-index 39 | 40 | - name: Build the project 41 | run: cargo build --release 42 | 43 | - name: Publish to crates.io 44 | env: 45 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 46 | run: cargo publish 47 | 48 | - name: Release 49 | uses: softprops/action-gh-release@v2 50 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Machete (Dependencies) 20 | uses: bnjbvr/cargo-machete@main 21 | - name: Run cargo build 22 | run: cargo build --verbose 23 | - name: Linting 24 | run: RUSTFLAGS="-D warnings" cargo clippy --locked 25 | - name: Run tests 26 | run: cargo test --verbose 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | /src/.DS_Store 4 | 5 | .vs/ 6 | .vscode/ 7 | 8 | Cargo.lock 9 | .DS_Store 10 | .env 11 | 12 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cargo-thesaurust" 3 | version = "0.1.2" 4 | edition = "2021" 5 | authors = ["Moreen Ho "] 6 | homepage = "https://moreenh.me/pages/projects/cargo-thesaurust" 7 | description = "A terminal-based dictionary app." 8 | readme = "README.md" 9 | repository = "https://github.com/quietpigeon/cargo-thesaurust" 10 | license = "MIT" 11 | keywords = ["ratatui", "dictionary", "tui", "terminal", "thesaurus"] 12 | exclude = ["docs/*"] 13 | 14 | [[bin]] 15 | name = "thesaurust" 16 | path = "src/main.rs" 17 | 18 | [dependencies] 19 | reqwest = { version = "0.12.15", features = ["json"] } 20 | serde = { version = "1.0.187", features = ["derive"] } 21 | serde_json = "1.0.106" 22 | serde_derive = "1.0.187" 23 | tokio = { version = "1.42.0", features = ["macros", "rt-multi-thread"] } 24 | ratatui = "0.29.0" 25 | anyhow = "1.0.75" 26 | tui-input = "0.12.0" 27 | thiserror = "2.0.12" 28 | apply = "0.3.0" 29 | 30 | [dev-dependencies] 31 | pretty_assertions = "1.4.0" 32 | mockito = "1.7.0" 33 | 34 | [package.metadata.cargo-machete] 35 | ignored = ["serde"] 36 | 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2025 Moreen Ho 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Table of Contents 3 | 4 | - [thesaurust](#thesaurust) 5 | - [How it works](#how-it-works) 6 | - [Installation](#installation) 7 | - [Usage](#usage) 8 | - [Roadmap](#roadmap) 9 | 10 |
11 | 12 | # thesaurust 13 | 14 | A simple dictionary application built within the terminal, written in Rust. 15 | ![Demo](docs/demo.gif) 16 | 17 | ## How it works 18 | 19 | The data is fetched from the API provided by . Since words can contain more than one meanings, the user can toggle between different meanings based on the parts of speech the word has. 20 | 21 | `thesaurust` has built-in spellchecking. The inserted word will first be sent to . It will generate a list of words that has the closest spelling to the original. Of course, if the original is spelled correctly, the word with the highest score will be the same as the original. 22 | 23 | ## Installation 24 | 25 | > [!NOTE] 26 | > You need to install [Rust](https://www.rust-lang.org/tools/install) before you can proceed. 27 | 28 | ```zsh 29 | cargo install cargo-thesaurust --locked 30 | thesaurust 31 | ``` 32 | 33 | ## Usage 34 | 35 | - /: Insert the word you would like to look for. 36 | - Enter: Search. 37 | - j, k: Select the part of speech and press Enter. 38 | - l, h: Toggle between multiple definitions. 39 | - q: Exit the app. 40 | 41 | ## Roadmap 42 | 43 | - [x] Show an example with the definition (if available) 44 | - [x] Toggle between parts of speech 45 | - [x] Toggle between definitions with the same part of speech 46 | - [x] Use a spellchecking API to suggest correct spelling for words 47 | - [x] Show synonyms 48 | - [x] Publish crate 49 | - [ ] Loading screens 50 | - [ ] Allow users to select similarly spelled words 51 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quietpigeon/cargo-thesaurust/32b8149d8b5c167a975526723946614897ecdf42/docs/demo.gif -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quietpigeon/cargo-thesaurust/32b8149d8b5c167a975526723946614897ecdf42/docs/screenshot.png -------------------------------------------------------------------------------- /src/banner.rs: -------------------------------------------------------------------------------- 1 | /// Banner for `thesaurust`. 2 | pub const BANNER: &str = r" 3 | _____ _ _ 4 | |_ _| |__ ___ ___ __ _ _ _ _ __ _ _ ___| |_ 5 | | | | '_ \ / _ \/ __|/ _` | | | | '__| | | / __| __| 6 | | | | | | | __/\__ \ (_| | |_| | | | |_| \__ \ |_ 7 | |_| |_| |_|\___||___/\__,_|\__,_|_| \__,_|___/\__| 8 | "; 9 | 10 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | use crate::consts::{DICTATIONARY_DOMAIN, SUGGEST_DOMAIN}; 2 | use crate::models::errors::Error; 3 | use dictionary::{look_up_handler, WordDef}; 4 | use suggester::{suggest_handler, Suggestions}; 5 | 6 | mod dictionary; 7 | mod suggester; 8 | 9 | /// Sends word to dictionary API. 10 | pub(crate) fn look_up(word: &str) -> Result { 11 | look_up_handler(word, DICTATIONARY_DOMAIN) 12 | } 13 | 14 | /// Compares input with the list of suggested words. 15 | /// If the input is spelled correctly, it should match the first suggestion. 16 | /// Otherwise, the first suggestion is returned as it has the closest 17 | /// spelling to the input. 18 | pub(crate) fn spellcheck(word: &str) -> Result { 19 | let s = suggest(word)?.0; 20 | if s.is_empty() { 21 | return Err(Error::GetSuggestion); 22 | } 23 | match &s[0].word { 24 | Some(w) => Ok(w.to_string()), 25 | None => Err(Error::GetWord), 26 | } 27 | } 28 | 29 | fn suggest(word: &str) -> Result { 30 | suggest_handler(word, SUGGEST_DOMAIN) 31 | } 32 | 33 | #[cfg(test)] 34 | mod tests { 35 | use super::spellcheck; 36 | use pretty_assertions::assert_eq; 37 | 38 | #[test] 39 | fn spellcheck_word() { 40 | let mut server = mockito::Server::new(); 41 | let expected_response = 42 | r#"[{"word":"coffee","score":1500},{"word":"coffeemaker","score":180}]"#; 43 | let _ = server 44 | .mock("GET", "/sug?s=coffee") 45 | .with_status(200) 46 | .with_body(expected_response) 47 | .create(); 48 | 49 | assert_eq!(&spellcheck("coffee").unwrap(), "coffee"); 50 | assert_eq!(&spellcheck("coffeee").unwrap(), "coffee"); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/client/dictionary.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{errors::Error, thesaurus::Thesaurus}; 2 | 3 | pub(crate) struct WordDef(pub(crate) Vec); 4 | 5 | #[tokio::main] 6 | pub(crate) async fn look_up_handler(word: &str, url: &str) -> Result { 7 | let url = format!("{url}/api/v2/entries/en/{word}"); 8 | let response = reqwest::get(url).await?; 9 | if response.status().is_success() { 10 | let v: serde_json::Value = response.json().await?; 11 | let t: Vec = serde_json::from_value(v)?; 12 | Ok(WordDef(t)) 13 | } else { 14 | Err(Error::BadStatus(response.status())) 15 | } 16 | } 17 | 18 | #[cfg(test)] 19 | mod tests { 20 | use crate::client::dictionary::look_up_handler; 21 | use pretty_assertions::assert_eq; 22 | 23 | #[test] 24 | fn fetch_response_successful() { 25 | let mut server = mockito::Server::new(); 26 | let url = server.url(); 27 | let expected_response = r#"[{"word":"foo","phonetics":[],"meanings":[{"partOfSpeech":"noun","definitions":[{"definition":"bar","synonyms":[],"antonyms":[]}],"synonyms":[],"antonyms":[]}],"license":{"name":"CC BY-SA 3.0","url":"https://creativecommons.org/licenses/by-sa/3.0"},"sourceUrls":["https://en.wiktionary.org/wiki/foo"]}]"#; 28 | let mock = server 29 | .mock("GET", "/api/v2/entries/en/foo") 30 | .with_status(200) 31 | .with_body(expected_response) 32 | .create(); 33 | let result = look_up_handler("foo", &url).unwrap().0; 34 | let t = result[0].clone(); 35 | let m = &t.meanings.unwrap()[0]; 36 | let pos = m.partOfSpeech.as_ref().unwrap(); 37 | let d = m.definitions.as_ref().unwrap(); 38 | 39 | assert_eq!(&t.word.unwrap(), "foo"); 40 | assert_eq!(pos, "noun"); 41 | assert_eq!(d.len(), 1); 42 | assert_eq!(d[0].definition.as_ref().unwrap(), "bar"); 43 | 44 | mock.assert(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/client/suggester.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::models::errors::Error; 4 | 5 | pub(crate) struct Suggestions(pub(crate) Vec); 6 | 7 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 8 | pub(crate) struct Suggestion { 9 | pub(crate) word: Option, 10 | pub(crate) score: Option, 11 | } 12 | 13 | #[tokio::main] 14 | pub(crate) async fn suggest_handler(word: &str, url: &str) -> Result { 15 | let url = format!("{url}/sug?s={word}"); 16 | let response = reqwest::get(&url).await?; 17 | if response.status().is_success() { 18 | let results: serde_json::Value = response.json().await?; 19 | let s: Vec = serde_json::from_value(results)?; 20 | Ok(Suggestions(s)) 21 | } else { 22 | Err(Error::BadStatus(response.status())) 23 | } 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | use super::suggest_handler; 29 | 30 | #[test] 31 | fn fetch_response_successful() { 32 | let mut server = mockito::Server::new(); 33 | let url = server.url(); 34 | let expected_response = 35 | r#"[{"word":"coffee","score":1500},{"word":"coffeemaker","score":180}]"#; 36 | let mock = server 37 | .mock("GET", "/sug?s=coffee") 38 | .with_status(200) 39 | .with_body(expected_response) 40 | .create(); 41 | let result = suggest_handler("coffee", &url).unwrap().0; 42 | assert_eq!(result.len(), 2); 43 | assert_eq!(result[0].word.as_ref().unwrap(), "coffee"); 44 | 45 | let n = 180; 46 | assert_eq!(result[1].score.as_ref().unwrap(), &n); 47 | 48 | mock.assert(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/consts.rs: -------------------------------------------------------------------------------- 1 | pub(crate) static SUGGEST_DOMAIN: &str = "https://api.datamuse.com"; 2 | pub(crate) static DICTATIONARY_DOMAIN: &str = "https://api.dictionaryapi.dev"; 3 | -------------------------------------------------------------------------------- /src/keys.rs: -------------------------------------------------------------------------------- 1 | use crate::client::spellcheck; 2 | use crate::models::{app::App, errors::Error, input_mode::InputMode}; 3 | use apply::Apply; 4 | use ratatui::crossterm::event::{Event, KeyCode, KeyEvent}; 5 | use tui_input::backend::crossterm::EventHandler; 6 | 7 | pub(crate) fn key_handler(app: &mut App, key: KeyEvent) -> Result<(), Error> { 8 | match app.input_mode { 9 | InputMode::Normal => handle_normal(app, &key), 10 | InputMode::Insert => handle_editing(app, &key), 11 | InputMode::SelectPartOfSpeech => handle_select_pos(app, &key), 12 | InputMode::SelectDefinition => handle_select_definition(app, &key), 13 | } 14 | } 15 | 16 | fn handle_normal(app: &mut App, key: &KeyEvent) -> Result<(), Error> { 17 | match key.code { 18 | KeyCode::Char('q') | KeyCode::Esc => App::quit(app), 19 | KeyCode::Char('j') | KeyCode::Char('k') if !app.results.is_empty() => { 20 | app.input_mode = InputMode::SelectPartOfSpeech 21 | } 22 | KeyCode::Char('l') | KeyCode::Char('h') if app.part_of_speech_list.items.len() == 1 => { 23 | app.input_mode = InputMode::SelectDefinition; 24 | } 25 | KeyCode::Char('/') => { 26 | app.input_mode = InputMode::Insert; 27 | app.input.reset(); 28 | } 29 | _ => {} 30 | } 31 | 32 | Ok(()) 33 | } 34 | 35 | fn handle_editing(app: &mut App, key: &KeyEvent) -> Result<(), Error> { 36 | match key.code { 37 | KeyCode::Enter => { 38 | app.input_mode = InputMode::Normal; 39 | let word = app.input.to_string().apply_ref(|w| spellcheck(w))?; 40 | let results = crate::client::look_up(&word)?; 41 | // NOTE: This fixes the appearance of the word in the search bar if the word is 42 | // misspelled. I'm not sure if this would be the preferred approach for everyone, so 43 | // let's leave it as it is for now. An option that allows users to select other 44 | // variants would be nice. 45 | app.input = word.into(); 46 | app.results = results.0; 47 | App::update_all(app); 48 | } 49 | KeyCode::Esc => { 50 | app.input_mode = InputMode::Normal; 51 | } 52 | _ => { 53 | app.input.handle_event(&Event::Key(*key)); 54 | } 55 | } 56 | 57 | Ok(()) 58 | } 59 | 60 | fn handle_select_pos(app: &mut App, key: &KeyEvent) -> Result<(), Error> { 61 | match key.code { 62 | KeyCode::Char('j') | KeyCode::Down => { 63 | app.part_of_speech_list.down(); 64 | } 65 | KeyCode::Char('k') | KeyCode::Up => { 66 | app.part_of_speech_list.up(); 67 | } 68 | KeyCode::Char('q') | KeyCode::Esc => { 69 | app.input_mode = InputMode::Normal; 70 | } 71 | KeyCode::Enter => { 72 | app.input_mode = InputMode::SelectDefinition; 73 | App::update_definition_list(app); 74 | App::update_synonym_list(app); 75 | } 76 | _ => {} 77 | } 78 | 79 | Ok(()) 80 | } 81 | 82 | fn handle_select_definition(app: &mut App, key: &KeyEvent) -> Result<(), Error> { 83 | match key.code { 84 | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char('j') | KeyCode::Down => { 85 | app.definition_list.down(); 86 | App::update_synonym_list(app); 87 | } 88 | KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('k') | KeyCode::Up => { 89 | app.definition_list.up(); 90 | App::update_synonym_list(app); 91 | } 92 | KeyCode::Char('q') | KeyCode::Esc => { 93 | app.input_mode = InputMode::SelectPartOfSpeech; 94 | app.definition_list.state.select(Some(0)); 95 | App::update_synonym_list(app); 96 | } 97 | KeyCode::Char('/') => { 98 | app.input_mode = InputMode::Insert; 99 | app.input.reset(); 100 | } 101 | _ => {} 102 | } 103 | 104 | Ok(()) 105 | } 106 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use keys::key_handler; 2 | use models::{app::App, errors::Error}; 3 | use ratatui::{backend::CrosstermBackend, crossterm::event::Event, Terminal}; 4 | use tui::Tui; 5 | 6 | mod banner; 7 | mod client; 8 | mod consts; 9 | mod keys; 10 | mod models; 11 | mod tui; 12 | mod ui; 13 | 14 | fn main() -> Result<(), Error> { 15 | let mut app = App::new(); 16 | let backend = CrosstermBackend::new(std::io::stderr()); 17 | let terminal = Terminal::new(backend)?; 18 | let mut tui = Tui::new(terminal); 19 | tui.enter()?; 20 | 21 | while !app.should_quit { 22 | tui.draw(&mut app)?; 23 | if let Event::Key(key) = ratatui::crossterm::event::read()? { 24 | key_handler(&mut app, key)?; 25 | } 26 | } 27 | 28 | tui.exit()?; 29 | Ok(()) 30 | } 31 | -------------------------------------------------------------------------------- /src/models/app.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{input_mode::InputMode, list::StatefulList, thesaurus::Thesaurus}; 2 | use tui_input::Input; 3 | 4 | /// Application. 5 | #[derive(Clone, Debug, Default)] 6 | pub(crate) struct App { 7 | pub should_quit: bool, 8 | pub input: Input, 9 | pub input_mode: InputMode, 10 | pub results: Vec, 11 | pub part_of_speech_list: StatefulList, 12 | pub definition_list: StatefulList, 13 | pub synonym_list: StatefulList, 14 | } 15 | 16 | impl App { 17 | pub(crate) fn new() -> Self { 18 | Self::default() 19 | } 20 | 21 | pub(crate) fn quit(&mut self) { 22 | self.should_quit = true; 23 | } 24 | 25 | pub(crate) fn update_instructions(&mut self) -> String { 26 | match self.input_mode { 27 | InputMode::Normal if self.part_of_speech_list.items.len() == 1 => { 28 | String::from("l, h: Change definition /: Insert") 29 | } 30 | InputMode::Normal if !self.results.is_empty() => { 31 | String::from("j, k: Change part of speech /: Insert") 32 | } 33 | InputMode::Insert => String::from(": Search : Exit"), 34 | InputMode::SelectPartOfSpeech => String::from(": Select"), 35 | InputMode::SelectDefinition => String::from("l, h: Change definition /: Insert"), 36 | _ => String::from("/: Insert"), 37 | } 38 | } 39 | 40 | pub(crate) fn update_all(&mut self) { 41 | // NOTE: The order of the functions must not be changed. 42 | self.update_part_of_speech_list(); 43 | self.update_definition_list(); 44 | self.update_synonym_list(); 45 | } 46 | 47 | pub(crate) fn update_definition_list(&mut self) { 48 | if self.results.is_empty() { 49 | return; 50 | } 51 | if let Some(idx) = self.part_of_speech_list.state.selected() { 52 | let definitions = Thesaurus::unwrap_meanings_at(idx, &self.results[0]).1; 53 | let definitions: Vec = definitions 54 | .into_iter() 55 | .map(|i| i.definition.unwrap_or_default()) 56 | .collect(); 57 | self.definition_list = StatefulList::with_items(definitions); 58 | 59 | // Select the first item as default. 60 | self.definition_list.state.select(Some(0)) 61 | } 62 | } 63 | 64 | pub(crate) fn update_synonym_list(&mut self) { 65 | if self.results.is_empty() { 66 | return; 67 | } 68 | let pos_idx = self.part_of_speech_list.state.selected().unwrap_or(0); 69 | let definitions = Thesaurus::unwrap_meanings_at(pos_idx, &self.results[0]).1; 70 | let def_idx = self.definition_list.state.selected().unwrap_or(0); 71 | let definition = &definitions[def_idx]; 72 | let synonyms = definition.clone().synonyms; 73 | 74 | if let Some(s) = synonyms { 75 | self.synonym_list = StatefulList::with_items(s); 76 | } else { 77 | self.synonym_list = StatefulList::with_items(Vec::new()); 78 | } 79 | } 80 | 81 | fn update_part_of_speech_list(&mut self) { 82 | if self.results.is_empty() { 83 | return; 84 | } 85 | let meanings = self.results[0].meanings.clone(); 86 | if let Some(m) = meanings { 87 | let part_of_speech_list: Vec = m 88 | .into_iter() 89 | .map(|i| i.partOfSpeech.unwrap_or_default()) 90 | .collect(); 91 | self.part_of_speech_list = StatefulList::with_items(part_of_speech_list); 92 | 93 | // Select the first item as default. 94 | self.part_of_speech_list.state.select(Some(0)) 95 | } 96 | } 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use super::{App, InputMode}; 102 | use crate::models::thesaurus::{Definition, Meaning, Thesaurus}; 103 | use pretty_assertions::assert_eq; 104 | 105 | fn mock_app_in(input_mode: InputMode) -> App { 106 | let mut mock_app = App::new(); 107 | mock_app.input_mode = input_mode; 108 | 109 | mock_app 110 | } 111 | 112 | fn mock_part_of_speech() -> String { 113 | String::from("noun") 114 | } 115 | 116 | fn mock_meaning_with(p: Option, d: Option>) -> Meaning { 117 | Meaning { 118 | partOfSpeech: p, 119 | definitions: d, 120 | } 121 | } 122 | 123 | fn mock_definition_with(d: Option) -> Definition { 124 | Definition { 125 | definition: d, 126 | ..Default::default() 127 | } 128 | } 129 | 130 | fn mock_results_with(m: Vec) -> Vec { 131 | vec![Thesaurus { 132 | word: Some(String::from("mock")), 133 | meanings: Some(m), 134 | }] 135 | } 136 | 137 | #[test] 138 | fn test_update_part_of_speech_list() { 139 | let mut mock_app = mock_app_in(InputMode::default()); 140 | let mock_parts_of_speech = vec![ 141 | String::from("noun"), 142 | String::from("verb"), 143 | String::from("adjective"), 144 | ]; 145 | let mock_meanings = mock_parts_of_speech 146 | .clone() 147 | .iter() 148 | .map(|i| mock_meaning_with(Some(i.to_string()), None)) 149 | .collect(); 150 | mock_app.results = mock_results_with(mock_meanings); 151 | App::update_part_of_speech_list(&mut mock_app); 152 | assert_eq!( 153 | mock_parts_of_speech.len(), 154 | mock_app.part_of_speech_list.items.len() 155 | ); 156 | assert_eq!(Some(0), mock_app.part_of_speech_list.state.selected()) 157 | } 158 | 159 | #[test] 160 | fn test_update_all() { 161 | let mut mock_app = mock_app_in(InputMode::default()); 162 | let mock_definitions = vec![ 163 | mock_definition_with(Some(String::from("Definition 1"))), 164 | mock_definition_with(Some(String::from("Definition 2"))), 165 | mock_definition_with(Some(String::from("Definition 3"))), 166 | ]; 167 | let mock_meanings = vec![mock_meaning_with( 168 | Some(mock_part_of_speech()), 169 | Some(mock_definitions.clone()), 170 | )]; 171 | mock_app.results = mock_results_with(mock_meanings); 172 | App::update_all(&mut mock_app); 173 | 174 | assert_eq!(mock_definitions.len(), mock_app.definition_list.items.len()); 175 | assert_eq!(Some(0), mock_app.definition_list.state.selected()); 176 | } 177 | 178 | #[test] 179 | fn test_instructions_in_normal_mode() { 180 | let mut mock_app = mock_app_in(InputMode::Normal); 181 | assert_eq!(App::update_instructions(&mut mock_app), "/: Insert"); 182 | } 183 | 184 | #[test] 185 | fn test_instructions_for_word_with_single_part_of_speech() { 186 | let mut mock_app = mock_app_in(InputMode::default()); 187 | mock_app.results = 188 | mock_results_with(vec![mock_meaning_with(Some(mock_part_of_speech()), None)]); 189 | App::update_part_of_speech_list(&mut mock_app); 190 | assert_eq!( 191 | App::update_instructions(&mut mock_app), 192 | "l, h: Change definition /: Insert" 193 | ); 194 | } 195 | 196 | #[test] 197 | fn test_instructions_in_normal_mode_with_results() { 198 | let mut mock_app = mock_app_in(InputMode::Normal); 199 | mock_app.results = 200 | mock_results_with(vec![mock_meaning_with(Some(mock_part_of_speech()), None)]); 201 | assert_eq!(true, !mock_app.results.is_empty()); 202 | assert_eq!( 203 | App::update_instructions(&mut mock_app), 204 | "j, k: Change part of speech /: Insert" 205 | ); 206 | } 207 | 208 | #[test] 209 | fn test_instructions_in_editing_mode() { 210 | let mut mock_app = mock_app_in(InputMode::Insert); 211 | assert_eq!( 212 | App::update_instructions(&mut mock_app), 213 | ": Search : Exit" 214 | ); 215 | } 216 | 217 | #[test] 218 | fn test_instructions_in_part_of_speech_selection_mode() { 219 | let mut mock_app = mock_app_in(InputMode::SelectPartOfSpeech); 220 | assert_eq!(App::update_instructions(&mut mock_app), ": Select"); 221 | } 222 | 223 | #[test] 224 | fn test_instructions_in_definition_selection_mode() { 225 | let mut mock_app = mock_app_in(InputMode::SelectDefinition); 226 | assert_eq!( 227 | App::update_instructions(&mut mock_app), 228 | "l, h: Change definition /: Insert" 229 | ); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/models/errors.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use thiserror::Error; 3 | 4 | #[derive(Debug, Error)] 5 | pub(crate) enum Error { 6 | #[error("http request failed: {0}")] 7 | HttpRequest(#[from] reqwest::Error), 8 | 9 | #[error("failed to read json: {0}")] 10 | JsonRead(#[from] serde_json::Error), 11 | 12 | #[error("unexpected status code: {0}")] 13 | BadStatus(StatusCode), 14 | 15 | #[error(transparent)] 16 | Tui(#[from] anyhow::Error), 17 | 18 | #[error(transparent)] 19 | TerminalBackend(#[from] std::io::Error), 20 | 21 | #[error("no suggestions found")] 22 | GetSuggestion, 23 | 24 | #[error("word does not exist")] 25 | GetWord, 26 | } 27 | -------------------------------------------------------------------------------- /src/models/input_mode.rs: -------------------------------------------------------------------------------- 1 | #[derive(Default, Clone, Debug)] 2 | pub(crate) enum InputMode { 3 | #[default] 4 | Normal, 5 | Insert, 6 | SelectPartOfSpeech, 7 | SelectDefinition, 8 | } 9 | -------------------------------------------------------------------------------- /src/models/list.rs: -------------------------------------------------------------------------------- 1 | use ratatui::widgets::ListState; 2 | 3 | #[derive(Clone, Debug, Default)] 4 | pub(crate) struct StatefulList { 5 | pub(crate) state: ListState, 6 | pub(crate) items: Vec, 7 | } 8 | 9 | impl StatefulList { 10 | pub(crate) fn with_items(items: Vec) -> StatefulList { 11 | StatefulList { 12 | state: ListState::default(), 13 | items, 14 | } 15 | } 16 | 17 | pub(crate) fn down(&mut self) { 18 | let i = match self.state.selected() { 19 | Some(i) => { 20 | if i >= self.items.len() - 1 { 21 | 0 22 | } else { 23 | i + 1 24 | } 25 | } 26 | None => 0, 27 | }; 28 | self.state.select(Some(i)); 29 | } 30 | 31 | pub(crate) fn up(&mut self) { 32 | let i = match self.state.selected() { 33 | Some(i) => { 34 | if i == 0 { 35 | self.items.len() - 1 36 | } else { 37 | i - 1 38 | } 39 | } 40 | None => 0, 41 | }; 42 | self.state.select(Some(i)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod app; 2 | pub(crate) mod errors; 3 | pub(crate) mod input_mode; 4 | pub(crate) mod list; 5 | pub(crate) mod thesaurus; 6 | pub(crate) mod word_suggestion; 7 | -------------------------------------------------------------------------------- /src/models/thesaurus.rs: -------------------------------------------------------------------------------- 1 | use serde_derive::Deserialize; 2 | use std::fmt::Debug; 3 | 4 | /// Components of a response from the Free Dictionary API. 5 | #[derive(Default, Clone, Deserialize, Debug)] 6 | pub(crate) struct Thesaurus { 7 | #[allow(unused)] 8 | pub word: Option, 9 | 10 | // A word can have multiple meanings, hence it is represented as an array of meanings. 11 | pub meanings: Option>, 12 | } 13 | 14 | impl Thesaurus { 15 | /// A function that unwraps the contents inside `Meaning`. It returns a tuple that contains the `partOfSpeech` and `Vec`. 16 | pub(crate) fn unwrap_meanings_at( 17 | index: usize, 18 | thesaurus: &Thesaurus, 19 | ) -> (String, Vec) { 20 | //TODO: Create unit test to check index and array length. 21 | if let Some(meanings) = thesaurus.meanings.clone() { 22 | let meaning = meanings[index].clone(); 23 | match (meaning.partOfSpeech.clone(), meaning.definitions.clone()) { 24 | (Some(p), Some(d)) => (p, d), 25 | _ => (String::default(), Vec::::default()), 26 | } 27 | } else { 28 | (String::default(), Vec::::default()) 29 | } 30 | } 31 | } 32 | 33 | #[derive(Default, Clone, Deserialize, Debug)] 34 | #[allow(non_snake_case)] 35 | pub(crate) struct Meaning { 36 | pub partOfSpeech: Option, 37 | pub definitions: Option>, 38 | } 39 | 40 | #[derive(Default, Clone, Deserialize, Debug)] 41 | pub(crate) struct Definition { 42 | pub definition: Option, 43 | pub example: Option, 44 | pub synonyms: Option>, 45 | } 46 | -------------------------------------------------------------------------------- /src/models/word_suggestion.rs: -------------------------------------------------------------------------------- 1 | use serde_derive::{Deserialize, Serialize}; 2 | use std::fmt::Debug; 3 | 4 | #[derive(Clone, Serialize, Deserialize, Debug)] 5 | pub(crate) struct SearchResults { 6 | pub spelling_fix: String, 7 | } 8 | -------------------------------------------------------------------------------- /src/tui.rs: -------------------------------------------------------------------------------- 1 | use crate::{models::app::App, ui}; 2 | use anyhow::Result; 3 | use ratatui::crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 4 | use ratatui::crossterm::execute; 5 | use ratatui::crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; 6 | use std::{io, panic}; 7 | 8 | pub(crate) type Frame<'a> = ratatui::Frame<'a>; 9 | pub(crate) type CrosstermTerminal = 10 | ratatui::Terminal>; 11 | pub(crate) struct Tui { 12 | terminal: CrosstermTerminal, 13 | } 14 | 15 | impl Tui { 16 | /// Constructs a new instance of Tui. 17 | pub(crate) fn new(terminal: CrosstermTerminal) -> Self { 18 | Self { terminal } 19 | } 20 | 21 | /// Initializes the terminal interface. 22 | pub(crate) fn enter(&mut self) -> Result<()> { 23 | terminal::enable_raw_mode()?; 24 | execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?; 25 | let panic_hook = panic::take_hook(); 26 | panic::set_hook(Box::new(move |panic| { 27 | Self::reset().expect("failed to reset the terminal"); 28 | panic_hook(panic); 29 | })); 30 | let _ = self.terminal.hide_cursor(); 31 | self.terminal.clear()?; 32 | Ok(()) 33 | } 34 | 35 | /// Resets the terminal interface. 36 | fn reset() -> Result<()> { 37 | terminal::disable_raw_mode()?; 38 | execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?; 39 | Ok(()) 40 | } 41 | 42 | /// Exits the terminal interface. 43 | pub(crate) fn exit(&mut self) -> Result<()> { 44 | Self::reset()?; 45 | self.terminal.show_cursor()?; 46 | Ok(()) 47 | } 48 | 49 | pub(crate) fn draw(&mut self, app: &mut App) -> Result<()> { 50 | self.terminal.draw(|frame| ui::render(app, frame))?; 51 | Ok(()) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | use crate::models::app::App; 2 | use crate::models::thesaurus::Thesaurus; 3 | use crate::tui::Frame; 4 | use ratatui::layout::{Direction, Layout, Rect}; 5 | use ratatui::prelude::Constraint; 6 | use std::rc::Rc; 7 | 8 | mod banner_block; 9 | mod definition_block; 10 | mod example_block; 11 | mod footer; 12 | mod part_of_speech_block; 13 | mod search_bar; 14 | mod synonym_block; 15 | 16 | pub(crate) fn render(app: &mut App, f: &mut Frame) { 17 | // Main frame. 18 | let main_frame = Layout::default() 19 | .direction(Direction::Vertical) 20 | .margin(2) 21 | .constraints( 22 | [ 23 | Constraint::Length(3), 24 | Constraint::Length(9), 25 | Constraint::Length(9), 26 | Constraint::Min(1), 27 | ] 28 | .as_ref(), 29 | ) 30 | .split(f.area()); 31 | 32 | let upper_frame = create_upper_layout(main_frame[0]); 33 | let lower_frame = create_lower_layout(main_frame[1]); 34 | let banner_frame = create_banner_layout(main_frame[1]); 35 | let right_frame = create_right_layout(lower_frame[1]); 36 | let footer_frame = create_footer_layout(main_frame[2]); 37 | 38 | f.render_widget(search_bar::new(app), upper_frame[0]); 39 | if !app.results.is_empty() { 40 | render_part_of_speech_block(app, f, lower_frame[0]); 41 | render_right_frame_components(app, f, right_frame); 42 | render_synonym_block(app, f, lower_frame[2]); 43 | } else { 44 | f.render_widget(banner_block::new(), banner_frame[0]); 45 | } 46 | 47 | render_instructions(app, f, footer_frame); 48 | } 49 | 50 | fn create_banner_layout(area: Rect) -> Rc<[Rect]> { 51 | Layout::default() 52 | .constraints([Constraint::Percentage(100)]) 53 | .margin(1) 54 | .split(area) 55 | } 56 | 57 | fn create_right_layout(area: Rect) -> Rc<[Rect]> { 58 | Layout::default() 59 | .direction(Direction::Vertical) 60 | // Definitions(50%), Examples(50%) 61 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) 62 | .split(area) 63 | } 64 | 65 | fn create_upper_layout(area: Rect) -> Rc<[Rect]> { 66 | Layout::default() 67 | .direction(Direction::Horizontal) 68 | // Search bar (100%) 69 | .constraints([Constraint::Percentage(100)].as_ref()) 70 | .horizontal_margin(1) 71 | .split(area) 72 | } 73 | 74 | fn create_footer_layout(area: Rect) -> Rc<[Rect]> { 75 | Layout::default() 76 | .direction(Direction::Vertical) 77 | // Spacer(50%), Instructions(25%), Default instructions(25%) 78 | .constraints( 79 | [ 80 | Constraint::Percentage(80), 81 | Constraint::Percentage(10), 82 | Constraint::Percentage(10), 83 | ] 84 | .as_ref(), 85 | ) 86 | .horizontal_margin(1) 87 | .split(area) 88 | } 89 | 90 | fn create_lower_layout(area: Rect) -> Rc<[Rect]> { 91 | Layout::default() 92 | .direction(Direction::Horizontal) 93 | // Part of speech (20%), Definitions & Examples (60%), Synonyms (20%) 94 | .constraints( 95 | [ 96 | Constraint::Percentage(20), 97 | Constraint::Percentage(60), 98 | Constraint::Percentage(20), 99 | ] 100 | .as_ref(), 101 | ) 102 | .horizontal_margin(1) 103 | .split(area) 104 | } 105 | 106 | fn render_synonym_block(app: &mut App, f: &mut Frame, area: Rect) { 107 | let mut cloned_state = app.synonym_list.state.clone(); 108 | f.render_stateful_widget(synonym_block::new(app), area, &mut cloned_state); 109 | } 110 | 111 | fn render_right_frame_components(app: &mut App, f: &mut Frame, right_frame: Rc<[Rect]>) { 112 | let pos_list_idx = app.part_of_speech_list.state.selected().unwrap_or(0); 113 | let definition_list_idx = app.definition_list.state.selected().unwrap_or(0); 114 | let definitions = Thesaurus::unwrap_meanings_at(pos_list_idx, &app.results[0]).1; 115 | let d = definitions[definition_list_idx].clone(); 116 | let definition = d.definition.unwrap_or_default(); 117 | let example = d.example.unwrap_or_default(); 118 | f.render_widget( 119 | definition_block::new(app, &definitions, &definition), 120 | right_frame[0], 121 | ); 122 | f.render_widget(example_block::new(&example), right_frame[1]); 123 | } 124 | 125 | fn render_part_of_speech_block(app: &mut App, f: &mut Frame, area: Rect) { 126 | let meanings = app.results[0].meanings.clone(); 127 | if meanings.is_some() { 128 | let mut cloned_state = app.part_of_speech_list.state.clone(); 129 | f.render_stateful_widget(part_of_speech_block::new(app), area, &mut cloned_state); 130 | } 131 | } 132 | 133 | fn render_instructions(app: &mut App, f: &mut Frame, frame: Rc<[Rect]>) { 134 | let instructions = App::update_instructions(app); 135 | f.render_widget(footer::with(&instructions), frame[1]); 136 | f.render_widget(footer::with("default"), frame[2]); 137 | } 138 | -------------------------------------------------------------------------------- /src/ui/banner_block.rs: -------------------------------------------------------------------------------- 1 | use crate::banner::BANNER; 2 | use ratatui::{ 3 | layout::Alignment, 4 | style::{Color, Style}, 5 | widgets::Paragraph, 6 | }; 7 | 8 | pub(crate) fn new() -> Paragraph<'static> { 9 | Paragraph::new(BANNER) 10 | .style(Style::default().fg(Color::Green)) 11 | .alignment(Alignment::Center) 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/definition_block.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{app::App, input_mode::InputMode, thesaurus::Definition}; 2 | use ratatui::{ 3 | style::{Color, Style}, 4 | widgets::{Block, Borders, Paragraph, Wrap}, 5 | }; 6 | 7 | pub(crate) fn new<'a>( 8 | app: &'a mut App, 9 | definitions: &[Definition], 10 | definition: &'a String, 11 | ) -> Paragraph<'a> { 12 | Paragraph::new(definition.to_string()) 13 | .style(match app.input_mode { 14 | InputMode::SelectDefinition => Style::default().fg(Color::Yellow), 15 | _ => Style::default().fg(Color::Green), 16 | }) 17 | .wrap(Wrap { trim: true }) 18 | .block(Block::default().borders(Borders::ALL).title(format!( 19 | "Definition[{:}/{}]", 20 | app.definition_list.state.selected().unwrap() + 1, 21 | definitions.len() 22 | ))) 23 | } 24 | -------------------------------------------------------------------------------- /src/ui/example_block.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | style::{Color, Modifier, Style, Stylize}, 3 | widgets::{Block, Borders, Paragraph, Wrap}, 4 | }; 5 | 6 | pub(crate) fn new(example: &str) -> Paragraph<'static> { 7 | Paragraph::new(example.to_string()) 8 | .add_modifier(Modifier::ITALIC) 9 | .style(Style::default().fg(Color::Green)) 10 | .wrap(Wrap { trim: true }) 11 | .block(Block::default().borders(Borders::ALL).title("Example")) 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/footer.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::Alignment, 3 | style::{Color, Style}, 4 | widgets::{Block, Borders, Paragraph}, 5 | }; 6 | 7 | pub(crate) fn with(instructions: &str) -> Paragraph { 8 | match instructions { 9 | "default" => block_with("q: Quit"), 10 | _ => block_with(instructions), 11 | } 12 | } 13 | 14 | fn block_with(s: &str) -> Paragraph { 15 | Paragraph::new(s) 16 | .alignment(Alignment::Left) 17 | .style(Style::default().fg(Color::Green)) 18 | .block(Block::default().borders(Borders::NONE)) 19 | } 20 | -------------------------------------------------------------------------------- /src/ui/part_of_speech_block.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{app::App, input_mode::InputMode}; 2 | use ratatui::{ 3 | style::{Color, Style}, 4 | widgets::{Block, Borders, List, ListItem}, 5 | }; 6 | 7 | pub(crate) fn new(app: &mut App) -> List { 8 | let parts_of_speech: Vec = app 9 | .part_of_speech_list 10 | .items 11 | .iter() 12 | .map(|i| ListItem::new(i.clone())) 13 | .collect(); 14 | 15 | List::new(parts_of_speech) 16 | .block( 17 | Block::default() 18 | .borders(Borders::ALL) 19 | .title("Part Of Speech"), 20 | ) 21 | .style(match app.input_mode { 22 | InputMode::SelectPartOfSpeech => Style::default().fg(Color::Yellow), 23 | _ => Style::default().fg(Color::Green), 24 | }) 25 | .highlight_style(Style::default().fg(Color::Black).bg(Color::Cyan)) 26 | } 27 | -------------------------------------------------------------------------------- /src/ui/search_bar.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{app::App, input_mode::InputMode}; 2 | use ratatui::{ 3 | style::{Color, Style}, 4 | widgets::{Block, Borders, Paragraph, Wrap}, 5 | }; 6 | 7 | pub(crate) fn new(app: &mut App) -> Paragraph { 8 | Paragraph::new(app.input.value()) 9 | .style(match app.input_mode { 10 | InputMode::Insert => Style::default().fg(Color::Yellow), 11 | _ => Style::default().fg(Color::Green), 12 | }) 13 | .wrap(Wrap { trim: true }) 14 | .block(Block::default().borders(Borders::ALL).title("Search")) 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/synonym_block.rs: -------------------------------------------------------------------------------- 1 | use crate::models::app::App; 2 | use ratatui::{ 3 | style::{Color, Style}, 4 | widgets::{Block, Borders, List, ListItem}, 5 | }; 6 | 7 | pub(crate) fn new(app: &mut App) -> List { 8 | let cloned_list = app.synonym_list.clone(); 9 | let synonyms: Vec = cloned_list 10 | .items 11 | .iter() 12 | .map(|i| ListItem::new(i.clone())) 13 | .collect(); 14 | 15 | List::new(synonyms) 16 | .block(Block::default().borders(Borders::ALL).title("Synonyms")) 17 | .style(Style::default().fg(Color::Green)) 18 | .highlight_style(Style::default().fg(Color::Black).bg(Color::Cyan)) 19 | } 20 | --------------------------------------------------------------------------------