├── .github └── workflows │ └── rust.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── core ├── Cargo.toml └── src │ ├── data.rs │ ├── db.rs │ ├── db │ ├── directory.rs │ ├── log.rs │ └── note.rs │ ├── error.rs │ ├── event.rs │ ├── glues.rs │ ├── lib.rs │ ├── schema.rs │ ├── state.rs │ ├── state │ ├── entry.rs │ ├── notebook.rs │ └── notebook │ │ ├── consume.rs │ │ ├── consume │ │ ├── breadcrumb.rs │ │ ├── directory.rs │ │ ├── note.rs │ │ └── tabs.rs │ │ ├── directory_item.rs │ │ ├── inner_state.rs │ │ └── inner_state │ │ ├── editing_insert_mode.rs │ │ ├── editing_normal_mode.rs │ │ ├── editing_visual_mode.rs │ │ ├── note_tree.rs │ │ └── note_tree │ │ ├── directory_more_actions.rs │ │ ├── directory_selected.rs │ │ ├── gateway.rs │ │ ├── move_mode.rs │ │ ├── note_more_actions.rs │ │ ├── note_selected.rs │ │ └── numbering.rs │ ├── task.rs │ ├── transition.rs │ └── types.rs ├── deny.toml ├── rust-toolchain.toml └── tui ├── Cargo.toml └── src ├── action.rs ├── color.rs ├── config.rs ├── context.rs ├── context ├── entry.rs ├── notebook.rs └── notebook │ └── tree_item.rs ├── logger.rs ├── main.rs ├── transitions.rs ├── transitions ├── entry.rs ├── keymap.rs ├── notebook.rs └── notebook │ ├── editing_normal_mode.rs │ ├── editing_visual_mode.rs │ ├── note_tree.rs │ └── textarea.rs ├── views.rs └── views ├── body.rs ├── body ├── entry.rs ├── notebook.rs └── notebook │ ├── editor.rs │ └── note_tree.rs ├── dialog.rs ├── dialog ├── alert.rs ├── confirm.rs ├── directory_actions.rs ├── editor_keymap.rs ├── help.rs ├── keymap.rs ├── note_actions.rs ├── prompt.rs └── vim_keymap.rs └── statusbar.rs /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | paths-ignore: 7 | - "docs/**" 8 | pull_request: 9 | branches: [ "main" ] 10 | paths-ignore: 11 | - "docs/**" 12 | 13 | env: 14 | CARGO_TERM_COLOR: always 15 | 16 | jobs: 17 | clippy_workspace: 18 | name: Clippy 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: Swatinem/rust-cache@v2 23 | - run: cargo clippy --all-targets -- -D warnings 24 | 25 | rust_fmt: 26 | name: Rustfmt 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: Swatinem/rust-cache@v2 31 | - run: | 32 | cargo fmt -- --check 33 | 34 | rust_build: 35 | name: Build 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: Swatinem/rust-cache@v2 40 | - run: cargo build --all-features --verbose 41 | 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .glues 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["tui", "core"] 4 | default-members = ["tui", "core"] 5 | 6 | [workspace.package] 7 | authors = ["Taehoon Moon "] 8 | version = "0.6.2" 9 | edition = "2024" 10 | license = "Apache-2.0" 11 | repository = "https://github.com/gluesql/glues" 12 | 13 | [workspace.dependencies] 14 | glues-core = { path = "./core", version = "0.6.2" } 15 | async-recursion = "1.1.1" 16 | 17 | [workspace.dependencies.gluesql] 18 | version = "0.16.3" 19 | default-features = false 20 | features = [ 21 | "gluesql_memory_storage", 22 | "gluesql-csv-storage", 23 | "gluesql-json-storage", 24 | "gluesql-file-storage", 25 | "gluesql-git-storage", 26 | ] 27 | 28 | [profile.release] 29 | lto = true 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Taehoon Moon 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Glues 2 | 3 | [![crates.io](https://img.shields.io/crates/v/glues.svg)](https://crates.io/crates/glues) 4 | [![LICENSE](https://img.shields.io/crates/l/glues.svg)](https://github.com/gluesql/glues/blob/main/LICENSE) 5 | ![Rust](https://github.com/gluesql/glues/workflows/Rust/badge.svg) 6 | [![Chat](https://img.shields.io/discord/780298017940176946?logo=discord&logoColor=white)](https://discord.gg/C6TDEgzDzY) 7 | 8 | ## Vim-inspired, privacy-first TUI note-taking app with multiple storage options 9 | 10 | Glues is a Vim-inspired, terminal-based (TUI) note-taking application that offers flexible and secure storage options. You can store your notes locally, choose Git for distributed version control, or opt for MongoDB for centralized data management. This flexibility allows you to manage your notes in the way that best suits your workflow, whether you prefer the simplicity of local files, the collaboration capabilities of Git, or the scalability of MongoDB. For additional support, log file formats such as CSV and JSON are also available. 11 | 12 | Glues is designed with a core architecture that operates independently of the TUI, providing robust state management and action handling. Although the current frontend is TUI-based, the architecture allows for easy integration with other frontends such as GUI, iOS, Android, or even running headlessly without a UI. The TUI interface clearly displays the current state and available actions, making it intuitive and easy to use. 13 | 14 | With no reliance on third-party services, Glues ensures that your data remains private and fully under your control. Currently, it supports Git and MongoDB for storage, and we plan to integrate additional storage options through [GlueSQL](https://github.com/gluesql/gluesql), giving you even more flexibility in managing your data. The core concept behind Glues is to empower users to choose how their data is handled—whether through local files, Git, MongoDB, or future storage options—without any dependence on a central authority. This makes Glues a sync-enabled application that prioritizes user autonomy and privacy. 15 | 16 | image 17 | 18 | ## Installation 19 | 20 | First, ensure [Rust](https://www.rust-lang.org/tools/install) is installed. Then, install Glues by running: 21 | 22 | ```bash 23 | cargo install glues 24 | ``` 25 | 26 | For Arch Linux users, Glues is available [in the AUR](https://aur.archlinux.org/packages/glues/): 27 | 28 | ```bash 29 | paru -S glues # user your favorite AUR helper 30 | ``` 31 | 32 | We're working on making Glues available through more package managers soon. 33 | 34 | ## Usage 35 | 36 | Glues offers various storage options to suit your needs: 37 | 38 | * **Instant**: Data is stored in memory and only persists while the app is running. This option is useful for testing or temporary notes as it is entirely volatile. 39 | * **Local**: Notes are stored locally as separate files. This is the default option for users who prefer a simple, file-based approach without any remote synchronization. 40 | * **Git**: 41 | - Git storage requires three inputs: `path`, `remote`, and `branch`. 42 | - The `path` should point to an existing local Git repository. For example, you can clone a GitHub repository and use that path. 43 | - The `remote` and `branch` specify the target remote repository and branch for synchronization. 44 | - When you modify notes or directories, Glues will automatically sync changes with the specified remote repository, allowing for distributed note management. 45 | 46 | To see how notes and directories are stored using Git, you can refer to the [Glues sample repository](https://github.com/gluesql/glues-sample-note). 47 | * **MongoDB**: 48 | - MongoDB storage allows you to store your notes in a MongoDB database, providing a scalable and centralized solution for managing your notes. 49 | - You need to provide the MongoDB connection string and the database name. Glues will handle storing and retrieving notes from the specified database. 50 | - This option is ideal for users who need centralized data management or work in team environments where notes are shared. 51 | * **CSV or JSON**: 52 | - These formats store notes as simple log files, ideal for quick data exports or reading logs. 53 | - CSV saves data in comma-separated format, while JSON uses JSONL (JSON Lines) format. 54 | 55 | ## Roadmap 56 | 57 | Here is our plan for Glues and the features we aim to implement. Below is a list of upcoming improvements to make Glues more useful and versatile. If you have suggestions for new features, please feel free to open a GitHub issue. 58 | 59 | * **[In Progress] MCP Server Integration** 60 | - Integration with an MCP server is currently the top priority to enable secure interaction between Glues and external LLMs. 61 | - The setup will include **API token-based access control**, allowing permission scoping such as directory-level access and note read/write operations. 62 | - With this approach, LLMs will operate strictly within the boundaries defined by the user. 63 | - The MCP server runs locally, ensuring that LLM-based features are fully managed and authorized by the user. 64 | * **Enhanced Note Content Support:** Add support for richer note content, including tables and images, in addition to plain text. This will help users create more detailed and organized notes. 65 | * **Search and Tagging Improvements:** Improve search with tag support and advanced filtering to make it easier to find specific notes. 66 | * **Customizable Themes:** Allow users to personalize the TUI interface with customizable themes. 67 | * **Additional Package Manager Support:** Expand distribution beyond Cargo, making Glues available through more package managers like Homebrew, Snap, and APT for easier installation. 68 | * **Storage Migration:** Add a feature to migrate data between different storage options, such as from CSV to Git. 69 | * **More Vim Keybindings:** Integrate Vim keybindings for users who prefer Vim-like shortcuts. 70 | * **Additional Storage Backends:** Support more storage options like Redis and object storage for greater flexibility. 71 | 72 | ## License 73 | 74 | This project is licensed under the Apache License, Version 2.0 - see the [LICENSE](https://github.com/gluesql/glues/blob/main/LICENSE) file for details. 75 | -------------------------------------------------------------------------------- /core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "glues-core" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | description = "Headless state management module for Glues Notes" 9 | 10 | [dependencies] 11 | gluesql.workspace = true 12 | gluesql-mongo-storage = "0.16.3" 13 | async-recursion.workspace = true 14 | thiserror = "1.0.61" 15 | async-trait = "0.1" 16 | uuid = { version = "1.10", features = ["v7"] } 17 | strum_macros = "0.26.4" 18 | -------------------------------------------------------------------------------- /core/src/data.rs: -------------------------------------------------------------------------------- 1 | use crate::types::{DirectoryId, NoteId}; 2 | 3 | #[derive(Clone, Debug)] 4 | pub struct Note { 5 | pub id: NoteId, 6 | pub directory_id: DirectoryId, 7 | pub name: String, 8 | } 9 | 10 | #[derive(Clone, Debug)] 11 | pub struct Directory { 12 | pub id: DirectoryId, 13 | pub parent_id: DirectoryId, 14 | pub name: String, 15 | } 16 | -------------------------------------------------------------------------------- /core/src/db.rs: -------------------------------------------------------------------------------- 1 | mod directory; 2 | mod log; 3 | mod note; 4 | 5 | use { 6 | crate::{Result, schema::setup, task::Task, types::DirectoryId}, 7 | async_trait::async_trait, 8 | gluesql::{ 9 | core::ast_builder::Build, 10 | gluesql_git_storage::{GitStorage, StorageType}, 11 | prelude::{CsvStorage, FileStorage, Glue, JsonStorage, MemoryStorage, Payload}, 12 | }, 13 | gluesql_mongo_storage::MongoStorage, 14 | std::sync::mpsc::Sender, 15 | }; 16 | 17 | pub struct Db { 18 | pub storage: Storage, 19 | pub root_id: DirectoryId, 20 | pub task_tx: Sender, 21 | } 22 | 23 | pub enum Storage { 24 | Memory(Glue), 25 | Csv(Glue), 26 | Json(Glue), 27 | File(Glue), 28 | Git(Glue), 29 | Mongo(Glue), 30 | } 31 | 32 | impl Db { 33 | pub async fn memory(task_tx: Sender) -> Result { 34 | let glue = Glue::new(MemoryStorage::default()); 35 | let mut storage = Storage::Memory(glue); 36 | 37 | let root_id = setup(&mut storage).await?; 38 | 39 | Ok(Self { 40 | storage, 41 | root_id, 42 | task_tx, 43 | }) 44 | } 45 | 46 | pub async fn csv(task_tx: Sender, path: &str) -> Result { 47 | let mut storage = CsvStorage::new(path).map(Glue::new).map(Storage::Csv)?; 48 | 49 | let root_id = setup(&mut storage).await?; 50 | 51 | Ok(Self { 52 | storage, 53 | root_id, 54 | task_tx, 55 | }) 56 | } 57 | 58 | pub async fn json(task_tx: Sender, path: &str) -> Result { 59 | let mut storage = JsonStorage::new(path).map(Glue::new).map(Storage::Json)?; 60 | 61 | let root_id = setup(&mut storage).await?; 62 | 63 | Ok(Self { 64 | storage, 65 | root_id, 66 | task_tx, 67 | }) 68 | } 69 | 70 | pub async fn file(task_tx: Sender, path: &str) -> Result { 71 | let mut storage = FileStorage::new(path).map(Glue::new).map(Storage::File)?; 72 | 73 | let root_id = setup(&mut storage).await?; 74 | 75 | Ok(Self { 76 | storage, 77 | root_id, 78 | task_tx, 79 | }) 80 | } 81 | 82 | pub async fn git( 83 | task_tx: Sender, 84 | path: &str, 85 | remote: String, 86 | branch: String, 87 | ) -> Result { 88 | let mut storage = GitStorage::open(path, StorageType::File)?; 89 | storage.set_remote(remote); 90 | storage.set_branch(branch); 91 | 92 | let mut storage = Storage::Git(Glue::new(storage)); 93 | let root_id = setup(&mut storage).await?; 94 | 95 | Ok(Self { 96 | storage, 97 | root_id, 98 | task_tx, 99 | }) 100 | } 101 | 102 | pub async fn mongo(task_tx: Sender, conn_str: &str, db_name: &str) -> Result { 103 | let mut storage = MongoStorage::new(conn_str, db_name) 104 | .await 105 | .map(Glue::new) 106 | .map(Storage::Mongo)?; 107 | 108 | let root_id = setup(&mut storage).await?; 109 | 110 | Ok(Self { 111 | storage, 112 | root_id, 113 | task_tx, 114 | }) 115 | } 116 | 117 | pub async fn pull(&mut self) -> Result<()> { 118 | if let Storage::Git(glue) = &mut self.storage { 119 | glue.storage.pull()?; 120 | } 121 | 122 | Ok(()) 123 | } 124 | 125 | pub fn sync(&self) -> Result<()> { 126 | if let Storage::Git(glue) = &self.storage { 127 | let path = glue.storage.path.clone(); 128 | let remote = glue.storage.remote.clone(); 129 | let branch = glue.storage.branch.clone(); 130 | 131 | let task = Task::GitSync { 132 | path, 133 | remote, 134 | branch, 135 | }; 136 | 137 | self.task_tx.clone().send(task).unwrap(); 138 | } 139 | 140 | Ok(()) 141 | } 142 | } 143 | 144 | #[async_trait(?Send)] 145 | pub trait Execute 146 | where 147 | Self: Sized, 148 | { 149 | async fn execute(self, storage: &mut Storage) -> Result; 150 | } 151 | 152 | #[async_trait(?Send)] 153 | impl Execute for T 154 | where 155 | Self: Sized, 156 | { 157 | async fn execute(self, storage: &mut Storage) -> Result { 158 | let statement = self.build()?; 159 | 160 | match storage { 161 | Storage::Memory(glue) => glue.execute_stmt(&statement).await, 162 | Storage::Csv(glue) => glue.execute_stmt(&statement).await, 163 | Storage::Json(glue) => glue.execute_stmt(&statement).await, 164 | Storage::File(glue) => glue.execute_stmt(&statement).await, 165 | Storage::Git(glue) => glue.execute_stmt(&statement).await, 166 | Storage::Mongo(glue) => glue.execute_stmt(&statement).await, 167 | } 168 | .map_err(Into::into) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /core/src/db/directory.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{Db, Execute}, 3 | crate::{Result, data::Directory, types::DirectoryId}, 4 | async_recursion::async_recursion, 5 | gluesql::core::ast_builder::{col, function::now, table, text, uuid}, 6 | std::ops::Deref, 7 | uuid::Uuid, 8 | }; 9 | 10 | impl Db { 11 | pub async fn fetch_directory(&mut self, directory_id: DirectoryId) -> Result { 12 | let directory = table("Directory") 13 | .select() 14 | .filter(col("id").eq(uuid(directory_id))) 15 | .project(vec!["id", "parent_id", "name"]) 16 | .execute(&mut self.storage) 17 | .await? 18 | .select() 19 | .unwrap() 20 | .next() 21 | .map(|payload| Directory { 22 | id: payload.get("id").map(Deref::deref).unwrap().into(), 23 | parent_id: payload.get("parent_id").map(Deref::deref).unwrap().into(), 24 | name: payload.get("name").map(Deref::deref).unwrap().into(), 25 | }) 26 | .unwrap(); 27 | 28 | Ok(directory) 29 | } 30 | 31 | pub async fn fetch_directories(&mut self, parent_id: DirectoryId) -> Result> { 32 | let directories = table("Directory") 33 | .select() 34 | .filter(col("parent_id").eq(uuid(parent_id.clone()))) 35 | .project(vec!["id", "name"]) 36 | .execute(&mut self.storage) 37 | .await? 38 | .select() 39 | .unwrap() 40 | .map(|payload| Directory { 41 | id: payload.get("id").map(Deref::deref).unwrap().into(), 42 | parent_id: parent_id.clone(), 43 | name: payload.get("name").map(Deref::deref).unwrap().into(), 44 | }) 45 | .collect(); 46 | 47 | Ok(directories) 48 | } 49 | 50 | pub async fn add_directory( 51 | &mut self, 52 | parent_id: DirectoryId, 53 | name: String, 54 | ) -> Result { 55 | let id = Uuid::now_v7().to_string(); 56 | let directory = Directory { 57 | id: id.clone(), 58 | parent_id: parent_id.clone(), 59 | name: name.clone(), 60 | }; 61 | 62 | table("Directory") 63 | .insert() 64 | .columns(vec!["id", "parent_id", "name"]) 65 | .values(vec![vec![uuid(id.clone()), uuid(parent_id), text(name)]]) 66 | .execute(&mut self.storage) 67 | .await?; 68 | 69 | self.sync().map(|()| directory) 70 | } 71 | 72 | #[async_recursion(?Send)] 73 | pub async fn remove_directory(&mut self, directory_id: DirectoryId) -> Result<()> { 74 | table("Note") 75 | .delete() 76 | .filter(col("directory_id").eq(uuid(directory_id.clone()))) 77 | .execute(&mut self.storage) 78 | .await?; 79 | 80 | let directories = self.fetch_directories(directory_id.clone()).await?; 81 | for directory in directories { 82 | self.remove_directory(directory.id).await?; 83 | } 84 | 85 | table("Directory") 86 | .delete() 87 | .filter(col("id").eq(uuid(directory_id))) 88 | .execute(&mut self.storage) 89 | .await?; 90 | 91 | self.sync() 92 | } 93 | 94 | pub async fn move_directory( 95 | &mut self, 96 | directory_id: DirectoryId, 97 | parent_id: DirectoryId, 98 | ) -> Result<()> { 99 | table("Directory") 100 | .update() 101 | .filter(col("id").eq(uuid(directory_id))) 102 | .set("parent_id", uuid(parent_id)) 103 | .set("updated_at", now()) 104 | .execute(&mut self.storage) 105 | .await?; 106 | 107 | self.sync() 108 | } 109 | 110 | pub async fn rename_directory( 111 | &mut self, 112 | directory_id: DirectoryId, 113 | name: String, 114 | ) -> Result<()> { 115 | table("Directory") 116 | .update() 117 | .filter(col("id").eq(uuid(directory_id))) 118 | .set("name", text(name)) 119 | .set("updated_at", now()) 120 | .execute(&mut self.storage) 121 | .await?; 122 | 123 | self.sync() 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /core/src/db/log.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{Db, Execute}, 3 | crate::Result, 4 | gluesql::core::ast_builder::{table, text}, 5 | }; 6 | 7 | impl Db { 8 | pub async fn log(&mut self, category: String, message: String) -> Result<()> { 9 | table("Log") 10 | .insert() 11 | .columns(vec!["category", "message"]) 12 | .values(vec![vec![text(category), text(message)]]) 13 | .execute(&mut self.storage) 14 | .await?; 15 | 16 | Ok(()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /core/src/db/note.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{Db, Execute}, 3 | crate::{ 4 | Error, Result, 5 | data::Note, 6 | types::{DirectoryId, NoteId}, 7 | }, 8 | gluesql::core::ast_builder::{col, function::now, table, text, uuid}, 9 | std::ops::Deref, 10 | uuid::Uuid, 11 | }; 12 | 13 | impl Db { 14 | pub async fn fetch_note_content(&mut self, note_id: NoteId) -> Result { 15 | let content = table("Note") 16 | .select() 17 | .filter(col("id").eq(uuid(note_id))) 18 | .project(col("content")) 19 | .execute(&mut self.storage) 20 | .await? 21 | .select() 22 | .ok_or(Error::Wip("error case 2".to_owned()))? 23 | .next() 24 | .ok_or(Error::Wip("error case 3".to_owned()))? 25 | .get("content") 26 | .map(Deref::deref) 27 | .ok_or(Error::Wip("error case 4".to_owned()))? 28 | .into(); 29 | 30 | Ok(content) 31 | } 32 | 33 | pub async fn fetch_notes(&mut self, directory_id: DirectoryId) -> Result> { 34 | let notes = table("Note") 35 | .select() 36 | .filter(col("directory_id").eq(uuid(directory_id.clone()))) 37 | .project(vec!["id", "name"]) 38 | .execute(&mut self.storage) 39 | .await? 40 | .select() 41 | .unwrap() 42 | .map(|payload| Note { 43 | id: payload.get("id").map(Deref::deref).unwrap().into(), 44 | directory_id: directory_id.clone(), 45 | name: payload.get("name").map(Deref::deref).unwrap().into(), 46 | }) 47 | .collect(); 48 | 49 | Ok(notes) 50 | } 51 | 52 | pub async fn add_note(&mut self, directory_id: DirectoryId, name: String) -> Result { 53 | let id = Uuid::now_v7().to_string(); 54 | let note = Note { 55 | id: id.clone(), 56 | directory_id: directory_id.clone(), 57 | name: name.clone(), 58 | }; 59 | 60 | table("Note") 61 | .insert() 62 | .columns(vec!["id", "directory_id", "name"]) 63 | .values(vec![vec![uuid(id), uuid(directory_id), text(name)]]) 64 | .execute(&mut self.storage) 65 | .await?; 66 | 67 | self.sync().map(|()| note) 68 | } 69 | 70 | pub async fn remove_note(&mut self, note_id: NoteId) -> Result<()> { 71 | table("Note") 72 | .delete() 73 | .filter(col("id").eq(uuid(note_id))) 74 | .execute(&mut self.storage) 75 | .await?; 76 | 77 | self.sync() 78 | } 79 | 80 | pub async fn update_note_content(&mut self, note_id: NoteId, content: String) -> Result<()> { 81 | table("Note") 82 | .update() 83 | .filter(col("id").eq(uuid(note_id))) 84 | .set("content", text(content)) 85 | .set("updated_at", now()) 86 | .execute(&mut self.storage) 87 | .await?; 88 | 89 | self.sync() 90 | } 91 | 92 | pub async fn rename_note(&mut self, note_id: NoteId, name: String) -> Result<()> { 93 | table("Note") 94 | .update() 95 | .filter(col("id").eq(uuid(note_id))) 96 | .set("name", text(name)) 97 | .set("updated_at", now()) 98 | .execute(&mut self.storage) 99 | .await?; 100 | 101 | self.sync() 102 | } 103 | 104 | pub async fn move_note(&mut self, note_id: NoteId, directory_id: DirectoryId) -> Result<()> { 105 | table("Note") 106 | .update() 107 | .filter(col("id").eq(uuid(note_id))) 108 | .set("directory_id", uuid(directory_id)) 109 | .set("updated_at", now()) 110 | .execute(&mut self.storage) 111 | .await?; 112 | 113 | self.sync() 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /core/src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error as ThisError; 2 | 3 | #[derive(ThisError, Debug)] 4 | pub enum Error { 5 | #[error("gluesql: {0}")] 6 | GlueSql(#[from] gluesql::prelude::Error), 7 | 8 | #[error("wip: {0}")] 9 | Wip(String), 10 | } 11 | -------------------------------------------------------------------------------- /core/src/event.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | data::{Directory, Note}, 4 | types::{DirectoryId, NoteId}, 5 | }, 6 | strum_macros::Display, 7 | }; 8 | 9 | #[derive(Clone, Debug, Display)] 10 | pub enum Event { 11 | #[strum(to_string = "Key::{0}")] 12 | Key(KeyEvent), 13 | 14 | #[strum(to_string = "Entry::{0}")] 15 | Entry(EntryEvent), 16 | 17 | #[strum(to_string = "Notebook::{0}")] 18 | Notebook(NotebookEvent), 19 | 20 | Cancel, 21 | } 22 | 23 | #[derive(Clone, Debug, Display)] 24 | pub enum EntryEvent { 25 | OpenMemory, 26 | OpenCsv(String), 27 | OpenJson(String), 28 | OpenFile(String), 29 | OpenGit { 30 | path: String, 31 | remote: String, 32 | branch: String, 33 | }, 34 | OpenMongo { 35 | conn_str: String, 36 | db_name: String, 37 | }, 38 | } 39 | 40 | #[derive(Clone, Debug, Display)] 41 | pub enum NotebookEvent { 42 | OpenDirectory(DirectoryId), 43 | CloseDirectory(DirectoryId), 44 | 45 | SelectNote(Note), 46 | SelectDirectory(Directory), 47 | 48 | RenameNote(String), 49 | RenameDirectory(String), 50 | 51 | RemoveNote, 52 | RemoveDirectory, 53 | 54 | ShowNoteActionsDialog, 55 | CloseNoteActionsDialog, 56 | 57 | ShowDirectoryActionsDialog, 58 | CloseDirectoryActionsDialog, 59 | 60 | AddNote(String), 61 | AddDirectory(String), 62 | 63 | MoveNote(DirectoryId), 64 | MoveDirectory(DirectoryId), 65 | 66 | OpenNote, 67 | EditNote, 68 | ViewNote, 69 | 70 | UpdateNoteContent { note_id: NoteId, content: String }, 71 | 72 | CloseEntryDialog, 73 | } 74 | 75 | #[derive(Clone, Copy, Debug, Display)] 76 | pub enum KeyEvent { 77 | A, 78 | B, 79 | C, 80 | D, 81 | E, 82 | G, 83 | H, 84 | I, 85 | J, 86 | K, 87 | L, 88 | M, 89 | N, 90 | O, 91 | P, 92 | S, 93 | T, 94 | U, 95 | V, 96 | W, 97 | X, 98 | Y, 99 | Z, 100 | CapA, 101 | CapG, 102 | CapH, 103 | CapI, 104 | CapJ, 105 | CapK, 106 | CapL, 107 | CapO, 108 | CapS, 109 | CapU, 110 | CapX, 111 | CtrlH, 112 | CtrlR, 113 | DollarSign, 114 | Caret, 115 | QuestionMark, 116 | AngleBracketOpen, 117 | AngleBracketClose, 118 | Num(NumKey), 119 | Left, 120 | Right, 121 | Up, 122 | Down, 123 | Space, 124 | Enter, 125 | Tab, 126 | Tilde, 127 | Dot, 128 | Dash, 129 | Esc, 130 | } 131 | 132 | #[derive(Clone, Copy, Debug, Display)] 133 | pub enum NumKey { 134 | One, 135 | Two, 136 | Three, 137 | Four, 138 | Five, 139 | Six, 140 | Seven, 141 | Eight, 142 | Nine, 143 | Zero, 144 | } 145 | 146 | impl From for Event { 147 | fn from(event: EntryEvent) -> Self { 148 | Self::Entry(event) 149 | } 150 | } 151 | 152 | impl From for Event { 153 | fn from(event: NotebookEvent) -> Self { 154 | Self::Notebook(event) 155 | } 156 | } 157 | 158 | impl From for Event { 159 | fn from(event: KeyEvent) -> Self { 160 | Self::Key(event) 161 | } 162 | } 163 | 164 | impl From for KeyEvent { 165 | fn from(num_key: NumKey) -> Self { 166 | KeyEvent::Num(num_key) 167 | } 168 | } 169 | 170 | impl From for usize { 171 | fn from(num_key: NumKey) -> Self { 172 | match num_key { 173 | NumKey::One => 1, 174 | NumKey::Two => 2, 175 | NumKey::Three => 3, 176 | NumKey::Four => 4, 177 | NumKey::Five => 5, 178 | NumKey::Six => 6, 179 | NumKey::Seven => 7, 180 | NumKey::Eight => 8, 181 | NumKey::Nine => 9, 182 | NumKey::Zero => 0, 183 | } 184 | } 185 | } 186 | 187 | impl std::ops::Add for NumKey { 188 | type Output = usize; 189 | 190 | fn add(self, rhs: usize) -> Self::Output { 191 | let n = usize::from(self).saturating_add(rhs); 192 | 193 | if n > u16::MAX as usize { 194 | u16::MAX as usize 195 | } else { 196 | n 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /core/src/glues.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | Event, Result, Transition, 4 | db::Db, 5 | state::{EntryState, State}, 6 | task::{Task, handle_tasks}, 7 | }, 8 | std::{ 9 | collections::VecDeque, 10 | sync::{ 11 | Arc, Mutex, 12 | mpsc::{Sender, channel}, 13 | }, 14 | thread::JoinHandle, 15 | }, 16 | }; 17 | 18 | pub struct Glues { 19 | pub db: Option, 20 | pub state: State, 21 | 22 | pub task_tx: Sender, 23 | pub task_handle: JoinHandle<()>, 24 | pub transition_queue: Arc>>, 25 | } 26 | 27 | impl Glues { 28 | pub async fn new() -> Self { 29 | let transition_queue = Arc::new(Mutex::new(VecDeque::new())); 30 | let (task_tx, task_rx) = channel(); 31 | let task_handle = handle_tasks(task_rx, &transition_queue); 32 | 33 | Self { 34 | db: None, 35 | state: EntryState.into(), 36 | task_tx, 37 | task_handle, 38 | transition_queue, 39 | } 40 | } 41 | 42 | pub async fn dispatch(&mut self, event: Event) -> Result { 43 | State::consume(self, event).await 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /core/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod db; 2 | mod error; 3 | mod event; 4 | mod glues; 5 | mod schema; 6 | mod task; 7 | 8 | pub mod data; 9 | pub mod state; 10 | pub mod transition; 11 | pub mod types; 12 | 13 | pub use error::Error; 14 | pub use event::{EntryEvent, Event, KeyEvent, NotebookEvent, NumKey}; 15 | pub use glues::Glues; 16 | pub use transition::{EntryTransition, NotebookTransition, Transition}; 17 | 18 | type Result = std::result::Result; 19 | -------------------------------------------------------------------------------- /core/src/schema.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | Error, Result, 4 | db::{Execute, Storage}, 5 | types::DirectoryId, 6 | }, 7 | gluesql::core::ast_builder::{col, table, text}, 8 | std::ops::Deref, 9 | }; 10 | 11 | pub async fn setup(storage: &mut Storage) -> Result { 12 | table("Log") 13 | .create_table_if_not_exists() 14 | .add_column("category TEXT NULL") 15 | .add_column("message TEXT NOT NULL") 16 | .add_column("created_at TIMESTAMP NOT NULL DEFAULT NOW()") 17 | .execute(storage) 18 | .await?; 19 | 20 | table("Log").delete().execute(storage).await?; 21 | 22 | table("Meta") 23 | .create_table_if_not_exists() 24 | .add_column("key TEXT PRIMARY KEY") 25 | .add_column("value TEXT NOT NULL") 26 | .add_column("updated_at TIMESTAMP NOT NULL DEFAULT NOW()") 27 | .execute(storage) 28 | .await?; 29 | 30 | table("Directory") 31 | .create_table_if_not_exists() 32 | .add_column("id UUID PRIMARY KEY DEFAULT GENERATE_UUID()") 33 | .add_column("parent_id UUID NULL") 34 | .add_column("name TEXT NOT NULL") 35 | .add_column("created_at TIMESTAMP NOT NULL DEFAULT NOW()") 36 | .add_column("updated_at TIMESTAMP NOT NULL DEFAULT NOW()") 37 | .execute(storage) 38 | .await?; 39 | 40 | table("Note") 41 | .create_table_if_not_exists() 42 | .add_column("id UUID PRIMARY KEY") 43 | .add_column("name TEXT NOT NULL") 44 | .add_column("directory_id UUID NOT NULL") 45 | .add_column("created_at TIMESTAMP NOT NULL DEFAULT NOW()") 46 | .add_column("updated_at TIMESTAMP NOT NULL DEFAULT NOW()") 47 | .add_column("content TEXT NOT NULL DEFAULT ''") 48 | .execute(storage) 49 | .await?; 50 | 51 | let schema_version_not_exists = table("Meta") 52 | .select() 53 | .filter(col("key").eq(text("schema_version"))) 54 | .project("key") 55 | .execute(storage) 56 | .await? 57 | .select() 58 | .unwrap() 59 | .count() 60 | == 0; 61 | 62 | if schema_version_not_exists { 63 | table("Meta") 64 | .insert() 65 | .columns(vec!["key", "value"]) 66 | .values(vec![vec![text("schema_version"), text("1")]]) 67 | .execute(storage) 68 | .await?; 69 | } 70 | 71 | let root_not_exists = table("Directory") 72 | .select() 73 | .filter(col("parent_id").is_null()) 74 | .project("id") 75 | .execute(storage) 76 | .await? 77 | .select() 78 | .unwrap() 79 | .count() 80 | == 0; 81 | 82 | if root_not_exists { 83 | table("Directory") 84 | .insert() 85 | .columns("name") 86 | .values(vec![vec![text("Notes")]]) 87 | .execute(storage) 88 | .await?; 89 | } 90 | 91 | table("Directory") 92 | .select() 93 | .filter(col("parent_id").is_null()) 94 | .project("id") 95 | .execute(storage) 96 | .await? 97 | .select() 98 | .unwrap() 99 | .next() 100 | .unwrap() 101 | .get("id") 102 | .map(Deref::deref) 103 | .map(Into::into) 104 | .ok_or(Error::Wip("empty id".to_owned())) 105 | } 106 | -------------------------------------------------------------------------------- /core/src/state.rs: -------------------------------------------------------------------------------- 1 | mod entry; 2 | pub mod notebook; 3 | 4 | use crate::{Error, Event, Glues, KeyEvent, Result, Transition, transition::KeymapTransition}; 5 | 6 | pub use {entry::EntryState, notebook::NotebookState}; 7 | 8 | pub struct State { 9 | pub keymap: bool, 10 | inner: InnerState, 11 | } 12 | 13 | pub enum InnerState { 14 | EntryState(Box), 15 | NotebookState(Box), 16 | } 17 | 18 | impl State { 19 | pub async fn consume(glues: &mut Glues, event: Event) -> Result { 20 | match event { 21 | Event::Key(KeyEvent::QuestionMark) if glues.state.keymap => { 22 | glues.state.keymap = false; 23 | 24 | return Ok(KeymapTransition::Hide.into()); 25 | } 26 | Event::Key(KeyEvent::QuestionMark) => { 27 | glues.state.keymap = true; 28 | 29 | return Ok(KeymapTransition::Show.into()); 30 | } 31 | _ => {} 32 | }; 33 | 34 | match &glues.state.inner { 35 | InnerState::EntryState(_) => EntryState::consume(glues, event).await.map(Into::into), 36 | InnerState::NotebookState(_) => notebook::consume(glues, event).await.map(Into::into), 37 | } 38 | } 39 | 40 | pub fn describe(&self) -> Result { 41 | match &self.inner { 42 | InnerState::EntryState(state) => state.describe(), 43 | InnerState::NotebookState(state) => state.describe(), 44 | } 45 | } 46 | 47 | pub fn shortcuts(&self) -> Vec { 48 | match &self.inner { 49 | InnerState::EntryState(state) => state.shortcuts(), 50 | InnerState::NotebookState(state) => state.shortcuts(), 51 | } 52 | } 53 | } 54 | 55 | pub trait GetInner { 56 | fn get_inner(&self) -> Result<&T>; 57 | 58 | fn get_inner_mut(&mut self) -> Result<&mut T>; 59 | } 60 | 61 | macro_rules! impl_state_ext { 62 | ($State: ident) => { 63 | impl GetInner<$State> for State { 64 | fn get_inner(&self) -> Result<&$State> { 65 | match &self.inner { 66 | InnerState::$State(state) => Ok(&state), 67 | _ => Err(Error::Wip("State::get_inner for $State failed".to_owned())), 68 | } 69 | } 70 | 71 | fn get_inner_mut(&mut self) -> Result<&mut $State> { 72 | match &mut self.inner { 73 | InnerState::$State(state) => Ok(state), 74 | _ => Err(Error::Wip( 75 | "State::get_inner_mut for $State failed".to_owned(), 76 | )), 77 | } 78 | } 79 | } 80 | 81 | impl From<$State> for State { 82 | fn from(state: $State) -> Self { 83 | Self { 84 | keymap: false, 85 | inner: InnerState::$State(Box::new(state)), 86 | } 87 | } 88 | } 89 | }; 90 | } 91 | 92 | impl_state_ext!(EntryState); 93 | impl_state_ext!(NotebookState); 94 | -------------------------------------------------------------------------------- /core/src/state/entry.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | EntryEvent, EntryTransition, Error, Event, Glues, Result, db::Db, 3 | state::notebook::NotebookState, 4 | }; 5 | 6 | pub struct EntryState; 7 | 8 | impl EntryState { 9 | pub async fn consume(glues: &mut Glues, event: Event) -> Result { 10 | use EntryEvent::*; 11 | use Event::*; 12 | 13 | match event { 14 | Entry(OpenMemory) => { 15 | let mut db = Db::memory(glues.task_tx.clone()).await?; 16 | let root_id = db.root_id.clone(); 17 | let note_id = db.add_note(root_id, "Sample Note".to_owned()).await?.id; 18 | db.update_note_content(note_id, "Hi :D".to_owned()).await?; 19 | 20 | glues.db = Some(db); 21 | glues.state = NotebookState::new(glues).await?.into(); 22 | Ok(EntryTransition::OpenNotebook) 23 | } 24 | Entry(OpenCsv(path)) => { 25 | glues.db = Db::csv(glues.task_tx.clone(), &path).await.map(Some)?; 26 | glues.state = NotebookState::new(glues).await?.into(); 27 | 28 | Ok(EntryTransition::OpenNotebook) 29 | } 30 | Entry(OpenJson(path)) => { 31 | glues.db = Db::json(glues.task_tx.clone(), &path).await.map(Some)?; 32 | glues.state = NotebookState::new(glues).await?.into(); 33 | 34 | Ok(EntryTransition::OpenNotebook) 35 | } 36 | Entry(OpenFile(path)) => { 37 | glues.db = Db::file(glues.task_tx.clone(), &path).await.map(Some)?; 38 | glues.state = NotebookState::new(glues).await?.into(); 39 | 40 | Ok(EntryTransition::OpenNotebook) 41 | } 42 | Entry(OpenGit { 43 | path, 44 | remote, 45 | branch, 46 | }) => { 47 | glues.db = Db::git(glues.task_tx.clone(), &path, remote, branch) 48 | .await 49 | .map(Some)?; 50 | glues.state = NotebookState::new(glues).await?.into(); 51 | 52 | Ok(EntryTransition::OpenNotebook) 53 | } 54 | Entry(OpenMongo { conn_str, db_name }) => { 55 | glues.db = Db::mongo(glues.task_tx.clone(), &conn_str, &db_name) 56 | .await 57 | .map(Some)?; 58 | glues.state = NotebookState::new(glues).await?.into(); 59 | 60 | Ok(EntryTransition::OpenNotebook) 61 | } 62 | Key(_) => Ok(EntryTransition::Inedible(event)), 63 | Cancel => Ok(EntryTransition::None), 64 | _ => Err(Error::Wip("todo: EntryState::consume".to_owned())), 65 | } 66 | } 67 | 68 | pub fn describe(&self) -> Result { 69 | Ok( 70 | "Glues - TUI note-taking app offering complete data control and flexible storage options" 71 | .to_owned(), 72 | ) 73 | } 74 | 75 | pub fn shortcuts(&self) -> Vec { 76 | vec![ 77 | "[j] Select next".to_owned(), 78 | "[k] Select previous".to_owned(), 79 | "[Enter] Run selected item".to_owned(), 80 | "[q] Quit".to_owned(), 81 | ] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /core/src/state/notebook/consume.rs: -------------------------------------------------------------------------------- 1 | mod breadcrumb; 2 | pub mod directory; 3 | pub mod note; 4 | pub mod tabs; 5 | -------------------------------------------------------------------------------- /core/src/state/notebook/consume/breadcrumb.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::directory, 3 | crate::{Error, Result, db::Db, state::notebook::NotebookState}, 4 | }; 5 | 6 | pub(super) async fn update_breadcrumbs(db: &mut Db, state: &mut NotebookState) -> Result<()> { 7 | let directory_ids = state 8 | .tabs 9 | .iter() 10 | .map(|tab| tab.note.directory_id.clone()) 11 | .collect::>(); 12 | 13 | for directory_id in directory_ids { 14 | directory::open_all(db, state, directory_id).await?; 15 | } 16 | 17 | let tree_items = state.root.tree_items(0); 18 | 19 | for tab in state.tabs.iter_mut() { 20 | let (i, mut depth) = tree_items 21 | .iter() 22 | .enumerate() 23 | .find_map(|(i, item)| (item.id == &tab.note.id).then_some((i, item.depth))) 24 | .ok_or(Error::Wip(format!( 25 | "[breadcrumb::update_breadcrumbs] note not found: {}", 26 | tab.note.name 27 | )))?; 28 | 29 | let mut breadcrumb = vec![tab.note.name.clone()]; 30 | tree_items[0..i].iter().rev().for_each(|item| { 31 | if item.depth < depth { 32 | depth = item.depth; 33 | 34 | breadcrumb.push(item.name.to_owned()); 35 | } 36 | }); 37 | breadcrumb.reverse(); 38 | tab.breadcrumb = breadcrumb; 39 | } 40 | 41 | Ok(()) 42 | } 43 | -------------------------------------------------------------------------------- /core/src/state/notebook/consume/directory.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::breadcrumb, 3 | crate::{ 4 | Error, NotebookTransition, Result, 5 | data::Directory, 6 | db::Db, 7 | state::notebook::{ 8 | DirectoryItem, DirectoryItemChildren, InnerState, NoteTreeState, NotebookState, 9 | SelectedItem, 10 | }, 11 | transition::{MoveModeTransition, NoteTreeTransition}, 12 | types::DirectoryId, 13 | }, 14 | async_recursion::async_recursion, 15 | }; 16 | 17 | pub async fn open( 18 | db: &mut Db, 19 | state: &mut NotebookState, 20 | directory_id: DirectoryId, 21 | ) -> Result { 22 | let item = state 23 | .root 24 | .find_mut(&directory_id) 25 | .ok_or(Error::Wip(format!( 26 | "[directory::open] directory not found: {directory_id}" 27 | )))?; 28 | 29 | let notes = db.fetch_notes(directory_id.clone()).await?; 30 | let directories = db 31 | .fetch_directories(directory_id.clone()) 32 | .await? 33 | .into_iter() 34 | .map(|directory| DirectoryItem { 35 | directory, 36 | children: None, 37 | }) 38 | .collect::>(); 39 | 40 | item.children = Some(DirectoryItemChildren { 41 | notes: notes.clone(), 42 | directories: directories.clone(), 43 | }); 44 | 45 | Ok(NotebookTransition::NoteTree( 46 | NoteTreeTransition::OpenDirectory { 47 | id: directory_id, 48 | notes, 49 | directories, 50 | }, 51 | )) 52 | } 53 | 54 | #[async_recursion(?Send)] 55 | pub async fn open_all( 56 | db: &mut Db, 57 | state: &mut NotebookState, 58 | directory_id: DirectoryId, 59 | ) -> Result { 60 | if state.check_opened(&directory_id) { 61 | return Ok(NotebookTransition::None); 62 | } 63 | 64 | let directory = db.fetch_directory(directory_id).await?; 65 | 66 | if state.root.directory.id != directory.id { 67 | open_all(db, state, directory.parent_id).await?; 68 | } 69 | open(db, state, directory.id).await 70 | } 71 | 72 | pub fn close(state: &mut NotebookState, directory: Directory) -> Result { 73 | state 74 | .root 75 | .find_mut(&directory.id) 76 | .ok_or(Error::Wip(format!( 77 | "[directory::close] failed to find directory '{}'", 78 | directory.name 79 | )))? 80 | .children = None; 81 | 82 | let directory_id = directory.id.clone(); 83 | 84 | state.selected = SelectedItem::Directory(directory); 85 | state.inner_state = InnerState::NoteTree(NoteTreeState::DirectorySelected); 86 | 87 | Ok(NotebookTransition::NoteTree( 88 | NoteTreeTransition::CloseDirectory(directory_id), 89 | )) 90 | } 91 | 92 | pub fn show_actions_dialog( 93 | state: &mut NotebookState, 94 | directory: Directory, 95 | ) -> Result { 96 | state.selected = SelectedItem::Directory(directory.clone()); 97 | state.inner_state = InnerState::NoteTree(NoteTreeState::DirectoryMoreActions); 98 | 99 | Ok(NotebookTransition::NoteTree( 100 | NoteTreeTransition::ShowDirectoryActionsDialog(directory), 101 | )) 102 | } 103 | 104 | pub fn select(state: &mut NotebookState, directory: Directory) -> Result { 105 | state.selected = SelectedItem::Directory(directory); 106 | state.inner_state = InnerState::NoteTree(NoteTreeState::DirectorySelected); 107 | 108 | Ok(NotebookTransition::None) 109 | } 110 | 111 | pub async fn rename( 112 | db: &mut Db, 113 | state: &mut NotebookState, 114 | mut directory: Directory, 115 | new_name: String, 116 | ) -> Result { 117 | if state.root.directory.id == directory.id { 118 | return Ok(NotebookTransition::Alert( 119 | "Cannot rename the root directory".to_owned(), 120 | )); 121 | } 122 | 123 | db.rename_directory(directory.id.clone(), new_name.clone()) 124 | .await?; 125 | db.log( 126 | "directory::rename".to_owned(), 127 | format!( 128 | " id: {}\nfrom: {}\n to: {}", 129 | directory.id, directory.name, new_name 130 | ), 131 | ) 132 | .await?; 133 | 134 | directory.name = new_name; 135 | state.root.rename_directory(&directory).ok_or(Error::Wip( 136 | "[directory::rename] failed to find directory".to_owned(), 137 | ))?; 138 | state.inner_state = InnerState::NoteTree(NoteTreeState::DirectorySelected); 139 | 140 | breadcrumb::update_breadcrumbs(db, state).await?; 141 | 142 | Ok(NotebookTransition::NoteTree( 143 | NoteTreeTransition::RenameDirectory(directory), 144 | )) 145 | } 146 | 147 | pub async fn remove( 148 | db: &mut Db, 149 | state: &mut NotebookState, 150 | directory: Directory, 151 | ) -> Result { 152 | if state.root.directory.id == directory.id { 153 | return Ok(NotebookTransition::Alert( 154 | "Cannot remove the root directory".to_owned(), 155 | )); 156 | } 157 | 158 | db.remove_directory(directory.id.clone()).await?; 159 | 160 | let selected_directory = state 161 | .root 162 | .remove_directory(&directory) 163 | .ok_or(Error::Wip( 164 | "[directory::remove] failed to find parent directory".to_owned(), 165 | ))? 166 | .clone(); 167 | 168 | state.selected = SelectedItem::Directory(selected_directory.clone()); 169 | state.inner_state = InnerState::NoteTree(NoteTreeState::DirectorySelected); 170 | 171 | Ok(NotebookTransition::NoteTree( 172 | NoteTreeTransition::RemoveDirectory { 173 | directory, 174 | selected_directory, 175 | }, 176 | )) 177 | } 178 | 179 | pub async fn add( 180 | db: &mut Db, 181 | state: &mut NotebookState, 182 | directory: Directory, 183 | directory_name: String, 184 | ) -> Result { 185 | let parent_id = directory.id.clone(); 186 | let directory = db.add_directory(parent_id.clone(), directory_name).await?; 187 | 188 | let item = state 189 | .root 190 | .find_mut(&parent_id) 191 | .ok_or(Error::Wip("todo: failed to find {parent_id}".to_owned()))?; 192 | 193 | if let DirectoryItem { 194 | children: Some(children), 195 | .. 196 | } = item 197 | { 198 | let directories = db 199 | .fetch_directories(parent_id) 200 | .await? 201 | .into_iter() 202 | .map(|directory| DirectoryItem { 203 | directory, 204 | children: None, 205 | }) 206 | .collect(); 207 | 208 | children.directories = directories; 209 | } 210 | 211 | state.selected = SelectedItem::Directory(directory.clone()); 212 | state.inner_state = InnerState::NoteTree(NoteTreeState::DirectorySelected); 213 | 214 | Ok(NotebookTransition::NoteTree( 215 | NoteTreeTransition::AddDirectory(directory), 216 | )) 217 | } 218 | 219 | pub async fn move_directory( 220 | db: &mut Db, 221 | state: &mut NotebookState, 222 | target_directory_id: DirectoryId, 223 | ) -> Result { 224 | let directory = state.get_selected_directory()?.clone(); 225 | if directory.id == target_directory_id { 226 | state.inner_state = InnerState::NoteTree(NoteTreeState::DirectorySelected); 227 | 228 | return Ok(NotebookTransition::NoteTree(NoteTreeTransition::MoveMode( 229 | MoveModeTransition::Cancel, 230 | ))); 231 | } 232 | 233 | db.move_directory(directory.id.clone(), target_directory_id.clone()) 234 | .await?; 235 | close(state, state.root.directory.clone())?; 236 | open_all(db, state, target_directory_id).await?; 237 | 238 | state.selected = SelectedItem::Directory(directory); 239 | state.inner_state = InnerState::NoteTree(NoteTreeState::DirectorySelected); 240 | 241 | breadcrumb::update_breadcrumbs(db, state).await?; 242 | 243 | Ok(NotebookTransition::NoteTree(NoteTreeTransition::MoveMode( 244 | MoveModeTransition::Commit, 245 | ))) 246 | } 247 | -------------------------------------------------------------------------------- /core/src/state/notebook/consume/note.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{breadcrumb, directory}, 3 | crate::{ 4 | Error, NotebookTransition, Result, 5 | data::{Directory, Note}, 6 | db::Db, 7 | state::notebook::{ 8 | DirectoryItem, InnerState, NoteTreeState, NotebookState, SelectedItem, Tab, 9 | VimNormalState, 10 | }, 11 | transition::{MoveModeTransition, NoteTreeTransition}, 12 | types::{DirectoryId, NoteId}, 13 | }, 14 | }; 15 | 16 | pub fn show_actions_dialog(state: &mut NotebookState, note: Note) -> Result { 17 | state.inner_state = InnerState::NoteTree(NoteTreeState::NoteMoreActions); 18 | 19 | Ok(NotebookTransition::NoteTree( 20 | NoteTreeTransition::ShowNoteActionsDialog(note), 21 | )) 22 | } 23 | 24 | pub fn select(state: &mut NotebookState, note: Note) -> Result { 25 | state.selected = SelectedItem::Note(note); 26 | state.inner_state = InnerState::NoteTree(NoteTreeState::NoteSelected); 27 | 28 | Ok(NotebookTransition::None) 29 | } 30 | 31 | pub async fn rename( 32 | db: &mut Db, 33 | state: &mut NotebookState, 34 | mut note: Note, 35 | new_name: String, 36 | ) -> Result { 37 | db.rename_note(note.id.clone(), new_name.clone()).await?; 38 | db.log( 39 | "note::rename".to_owned(), 40 | format!(" id: {}\nfrom: {}\n to: {}", note.id, note.name, new_name), 41 | ) 42 | .await?; 43 | 44 | note.name = new_name; 45 | state.root.rename_note(¬e).ok_or(Error::Wip( 46 | "[note::rename] failed to find parent directory".to_owned(), 47 | ))?; 48 | 49 | for tab in state.tabs.iter_mut().filter(|tab| tab.note.id == note.id) { 50 | tab.note.name = note.name.clone(); 51 | } 52 | 53 | state.selected = SelectedItem::Note(note.clone()); 54 | state.inner_state = InnerState::NoteTree(NoteTreeState::NoteSelected); 55 | 56 | breadcrumb::update_breadcrumbs(db, state).await?; 57 | 58 | Ok(NotebookTransition::NoteTree( 59 | NoteTreeTransition::RenameNote(note), 60 | )) 61 | } 62 | 63 | pub async fn remove( 64 | db: &mut Db, 65 | state: &mut NotebookState, 66 | note: Note, 67 | ) -> Result { 68 | db.remove_note(note.id.clone()).await?; 69 | 70 | let directory = state.root.remove_note(¬e).ok_or(Error::Wip( 71 | "[note::remove] failed to find parent directory".to_owned(), 72 | ))?; 73 | 74 | state.selected = SelectedItem::Directory(directory.clone()); 75 | state.inner_state = InnerState::NoteTree(NoteTreeState::DirectorySelected); 76 | 77 | Ok(NotebookTransition::NoteTree( 78 | NoteTreeTransition::RemoveNote { 79 | note, 80 | selected_directory: directory.clone(), 81 | }, 82 | )) 83 | } 84 | 85 | pub async fn add( 86 | db: &mut Db, 87 | state: &mut NotebookState, 88 | directory: Directory, 89 | note_name: String, 90 | ) -> Result { 91 | let note = db.add_note(directory.id.clone(), note_name).await?; 92 | 93 | let item = state 94 | .root 95 | .find_mut(&directory.id) 96 | .ok_or(Error::Wip("todo: failed to find".to_owned()))?; 97 | 98 | if let DirectoryItem { 99 | children: Some(children), 100 | .. 101 | } = item 102 | { 103 | let notes = db.fetch_notes(directory.id.clone()).await?; 104 | children.notes = notes; 105 | } 106 | 107 | state.selected = SelectedItem::Note(note.clone()); 108 | state.inner_state = InnerState::NoteTree(NoteTreeState::NoteSelected); 109 | 110 | Ok(NotebookTransition::NoteTree(NoteTreeTransition::AddNote( 111 | note, 112 | ))) 113 | } 114 | 115 | pub async fn open( 116 | db: &mut Db, 117 | state: &mut NotebookState, 118 | note: Note, 119 | ) -> Result { 120 | let content = db.fetch_note_content(note.id.clone()).await?; 121 | 122 | let i = state.tabs.iter().enumerate().find_map(|(i, tab)| { 123 | if tab.note.id == note.id { 124 | Some(i) 125 | } else { 126 | None 127 | } 128 | }); 129 | 130 | if let Some(i) = i { 131 | state.tab_index = Some(i); 132 | } else { 133 | let tab = Tab { 134 | note: note.clone(), 135 | breadcrumb: vec![], 136 | }; 137 | state.tabs.push(tab.clone()); 138 | state.tab_index = Some(state.tabs.len() - 1); 139 | }; 140 | 141 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 142 | 143 | breadcrumb::update_breadcrumbs(db, state).await?; 144 | 145 | Ok(NotebookTransition::NoteTree(NoteTreeTransition::OpenNote { 146 | note, 147 | content, 148 | })) 149 | } 150 | 151 | pub async fn view(state: &mut NotebookState) -> Result { 152 | let note = state.get_editing()?.clone(); 153 | 154 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 155 | 156 | Ok(NotebookTransition::ViewMode(note)) 157 | } 158 | 159 | pub async fn update_content( 160 | db: &mut Db, 161 | note_id: NoteId, 162 | content: String, 163 | ) -> Result { 164 | let current = db.fetch_note_content(note_id.clone()).await?; 165 | let content = content.trim_end(); 166 | if current.trim_end() != content { 167 | db.update_note_content(note_id.clone(), content.to_owned()) 168 | .await?; 169 | } 170 | 171 | Ok(NotebookTransition::UpdateNoteContent(note_id)) 172 | } 173 | 174 | pub async fn move_note( 175 | db: &mut Db, 176 | state: &mut NotebookState, 177 | directory_id: DirectoryId, 178 | ) -> Result { 179 | let mut note = state.get_selected_note()?.clone(); 180 | note.directory_id = directory_id.clone(); 181 | 182 | state.tabs.iter_mut().for_each(|tab| { 183 | if tab.note.id == note.id { 184 | tab.note.directory_id = directory_id.clone(); 185 | } 186 | }); 187 | 188 | db.move_note(note.id.clone(), directory_id.clone()).await?; 189 | directory::close(state, state.root.directory.clone())?; 190 | directory::open_all(db, state, directory_id).await?; 191 | 192 | state.selected = SelectedItem::Note(note); 193 | state.inner_state = InnerState::NoteTree(NoteTreeState::NoteSelected); 194 | 195 | breadcrumb::update_breadcrumbs(db, state).await?; 196 | 197 | Ok(NotebookTransition::NoteTree(NoteTreeTransition::MoveMode( 198 | MoveModeTransition::Commit, 199 | ))) 200 | } 201 | -------------------------------------------------------------------------------- /core/src/state/notebook/consume/tabs.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | Error, NotebookTransition, Result, 4 | db::Db, 5 | state::notebook::{ 6 | InnerState, NoteTreeState, NotebookState, SelectedItem, VimNormalState, directory, 7 | }, 8 | transition::NormalModeTransition, 9 | }, 10 | std::cmp::min, 11 | }; 12 | 13 | pub async fn select_prev(db: &mut Db, state: &mut NotebookState) -> Result { 14 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 15 | 16 | let i = state 17 | .tab_index 18 | .ok_or(Error::Wip("opened note must exist".to_owned()))?; 19 | let i = if i + 1 >= state.tabs.len() { 0 } else { i + 1 }; 20 | state.tab_index = Some(i); 21 | 22 | let note = &state.tabs[i].note; 23 | state.selected = SelectedItem::Note(note.clone()); 24 | 25 | let note_id = note.id.clone(); 26 | let directory_id = note.directory_id.clone(); 27 | 28 | directory::open_all(db, state, directory_id).await?; 29 | Ok(NotebookTransition::EditingNormalMode( 30 | NormalModeTransition::NextTab(note_id), 31 | )) 32 | } 33 | 34 | pub async fn select_next(db: &mut Db, state: &mut NotebookState) -> Result { 35 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 36 | 37 | let i = state 38 | .tab_index 39 | .ok_or(Error::Wip("opened note must exist".to_owned()))?; 40 | let i = if i == 0 { state.tabs.len() - 1 } else { i - 1 }; 41 | state.tab_index = Some(i); 42 | 43 | let note = &state.tabs[i].note; 44 | state.selected = SelectedItem::Note(note.clone()); 45 | 46 | let note_id = note.id.clone(); 47 | let directory_id = note.directory_id.clone(); 48 | 49 | directory::open_all(db, state, directory_id).await?; 50 | Ok(NotebookTransition::EditingNormalMode( 51 | NormalModeTransition::PrevTab(note_id), 52 | )) 53 | } 54 | 55 | pub fn move_prev(state: &mut NotebookState) -> Result { 56 | let i = state 57 | .tab_index 58 | .ok_or(Error::Wip("opened note must exist".to_owned()))?; 59 | 60 | if i == 0 { 61 | return Ok(NotebookTransition::None); 62 | } 63 | 64 | let note = state.tabs.remove(i); 65 | state.tabs.insert(i - 1, note); 66 | state.tab_index = Some(i - 1); 67 | 68 | Ok(NotebookTransition::EditingNormalMode( 69 | NormalModeTransition::MoveTabPrev(i), 70 | )) 71 | } 72 | 73 | pub fn move_next(state: &mut NotebookState) -> Result { 74 | let i = state 75 | .tab_index 76 | .ok_or(Error::Wip("opened note must exist".to_owned()))?; 77 | 78 | if i >= state.tabs.len() - 1 { 79 | return Ok(NotebookTransition::None); 80 | } 81 | 82 | let note = state.tabs.remove(i); 83 | state.tabs.insert(i + 1, note); 84 | state.tab_index = Some(i + 1); 85 | 86 | Ok(NotebookTransition::EditingNormalMode( 87 | NormalModeTransition::MoveTabNext(i), 88 | )) 89 | } 90 | 91 | pub async fn close(db: &mut Db, state: &mut NotebookState) -> Result { 92 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 93 | let i = state 94 | .tab_index 95 | .ok_or(Error::Wip("opened note must exist".to_owned()))?; 96 | 97 | let note_id = state.tabs[i].note.id.clone(); 98 | state.tabs.retain(|tab| tab.note.id != note_id); 99 | 100 | if state.tabs.is_empty() { 101 | state.tab_index = None; 102 | state.inner_state = InnerState::NoteTree(NoteTreeState::NoteSelected); 103 | 104 | return Ok(NotebookTransition::EditingNormalMode( 105 | NormalModeTransition::CloseTab(note_id), 106 | )); 107 | } 108 | 109 | let i = min(i, state.tabs.len() - 1); 110 | state.tab_index = Some(i); 111 | 112 | let note = state.tabs[i].note.clone(); 113 | state.selected = SelectedItem::Note(note.clone()); 114 | 115 | directory::open_all(db, state, note.directory_id).await?; 116 | Ok(NotebookTransition::EditingNormalMode( 117 | NormalModeTransition::CloseTab(note_id), 118 | )) 119 | } 120 | 121 | pub async fn focus_editor(db: &mut Db, state: &mut NotebookState) -> Result { 122 | let note = state.get_editing()?.clone(); 123 | directory::open_all(db, state, note.directory_id.clone()).await?; 124 | 125 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 126 | state.selected = SelectedItem::Note(note); 127 | 128 | Ok(NotebookTransition::FocusEditor) 129 | } 130 | -------------------------------------------------------------------------------- /core/src/state/notebook/directory_item.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | data::{Directory, Note}, 3 | types::{DirectoryId, Id}, 4 | }; 5 | 6 | #[derive(Clone, Debug)] 7 | pub struct DirectoryItem { 8 | pub directory: Directory, 9 | pub children: Option, 10 | } 11 | 12 | #[derive(Clone, Debug)] 13 | pub struct DirectoryItemChildren { 14 | pub directories: Vec, 15 | pub notes: Vec, 16 | } 17 | 18 | impl DirectoryItem { 19 | pub fn find(&self, id: &DirectoryId) -> Option<&DirectoryItem> { 20 | if &self.directory.id == id { 21 | return Some(self); 22 | } 23 | 24 | self.children 25 | .as_ref()? 26 | .directories 27 | .iter() 28 | .filter_map(|item| item.find(id)) 29 | .next() 30 | } 31 | 32 | pub fn find_mut(&mut self, id: &DirectoryId) -> Option<&mut DirectoryItem> { 33 | if &self.directory.id == id { 34 | return Some(self); 35 | } 36 | 37 | self.children 38 | .as_mut()? 39 | .directories 40 | .iter_mut() 41 | .filter_map(|item| item.find_mut(id)) 42 | .next() 43 | } 44 | 45 | pub fn rename_directory(&mut self, target: &Directory) -> Option<()> { 46 | let directory_item = self.find_mut(&target.id)?; 47 | directory_item.directory.name = target.name.clone(); 48 | 49 | Some(()) 50 | } 51 | 52 | pub fn rename_note(&mut self, target: &Note) -> Option<()> { 53 | let directory_item = self.find_mut(&target.directory_id)?; 54 | for note in directory_item.children.as_mut()?.notes.iter_mut() { 55 | if note.id == target.id { 56 | note.name = target.name.clone(); 57 | break; 58 | } 59 | } 60 | 61 | Some(()) 62 | } 63 | 64 | pub fn remove_note(&mut self, target: &Note) -> Option<&Directory> { 65 | let directory_item = self.find_mut(&target.directory_id)?; 66 | directory_item 67 | .children 68 | .as_mut()? 69 | .notes 70 | .retain_mut(|note| note.id != target.id); 71 | 72 | Some(&directory_item.directory) 73 | } 74 | 75 | pub fn remove_directory(&mut self, target: &Directory) -> Option<&Directory> { 76 | let directory_item = self.find_mut(&target.parent_id)?; 77 | directory_item 78 | .children 79 | .as_mut()? 80 | .directories 81 | .retain_mut(|item| item.directory.id != target.id); 82 | 83 | Some(&directory_item.directory) 84 | } 85 | 86 | pub(crate) fn tree_items(&self, depth: usize) -> Vec { 87 | let mut items = vec![TreeItem { 88 | id: &self.directory.id, 89 | name: &self.directory.name, 90 | depth, 91 | }]; 92 | 93 | if let Some(children) = &self.children { 94 | for item in &children.directories { 95 | items.extend(item.tree_items(depth + 1)); 96 | } 97 | 98 | for note in &children.notes { 99 | items.push(TreeItem { 100 | id: ¬e.id, 101 | name: ¬e.name, 102 | depth: depth + 1, 103 | }); 104 | } 105 | } 106 | 107 | items 108 | } 109 | } 110 | 111 | pub struct TreeItem<'a> { 112 | pub id: &'a Id, 113 | pub name: &'a str, 114 | pub depth: usize, 115 | } 116 | -------------------------------------------------------------------------------- /core/src/state/notebook/inner_state.rs: -------------------------------------------------------------------------------- 1 | mod editing_insert_mode; 2 | mod editing_normal_mode; 3 | mod editing_visual_mode; 4 | mod note_tree; 5 | 6 | use crate::{ 7 | Event, NotebookEvent, NotebookTransition, Result, 8 | db::Db, 9 | state::notebook::{NotebookState, note}, 10 | }; 11 | pub use editing_normal_mode::VimNormalState; 12 | pub use editing_visual_mode::VimVisualState; 13 | pub use note_tree::NoteTreeState; 14 | 15 | #[derive(Clone, Copy)] 16 | pub enum InnerState { 17 | NoteTree(NoteTreeState), 18 | 19 | EditingNormalMode(VimNormalState), 20 | EditingVisualMode(VimVisualState), 21 | EditingInsertMode, 22 | } 23 | 24 | pub async fn consume( 25 | db: &mut Db, 26 | state: &mut NotebookState, 27 | event: Event, 28 | ) -> Result { 29 | use InnerState::*; 30 | 31 | if let Event::Notebook(NotebookEvent::UpdateNoteContent { note_id, content }) = event { 32 | return note::update_content(db, note_id, content).await; 33 | } 34 | 35 | match &state.inner_state { 36 | NoteTree(tree_state) => note_tree::consume(db, state, *tree_state, event).await, 37 | EditingNormalMode(vim_state) => { 38 | editing_normal_mode::consume(db, state, *vim_state, event).await 39 | } 40 | EditingVisualMode(vim_state) => { 41 | editing_visual_mode::consume(db, state, *vim_state, event).await 42 | } 43 | EditingInsertMode => editing_insert_mode::consume(db, state, event).await, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /core/src/state/notebook/inner_state/editing_insert_mode.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | Error, Event, KeyEvent, NotebookEvent, NotebookTransition, Result, 3 | db::Db, 4 | state::notebook::{NotebookState, note}, 5 | }; 6 | 7 | pub async fn consume( 8 | _db: &mut Db, 9 | state: &mut NotebookState, 10 | event: Event, 11 | ) -> Result { 12 | use Event::*; 13 | use NotebookEvent::*; 14 | 15 | match event { 16 | Key(KeyEvent::Esc) | Notebook(ViewNote) => note::view(state).await, 17 | event @ Key(_) => Ok(NotebookTransition::Inedible(event)), 18 | _ => Err(Error::Wip("todo: Notebook::consume".to_owned())), 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core/src/state/notebook/inner_state/editing_visual_mode.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | Error, Event, KeyEvent, NumKey, Result, 3 | db::Db, 4 | state::notebook::{InnerState, NotebookState, VimNormalState}, 5 | transition::{NormalModeTransition, NotebookTransition, VimKeymapKind, VisualModeTransition}, 6 | }; 7 | 8 | #[derive(Clone, Copy)] 9 | pub enum VimVisualState { 10 | Idle, 11 | Gateway, 12 | Numbering(usize), 13 | } 14 | 15 | pub async fn consume( 16 | db: &mut Db, 17 | state: &mut NotebookState, 18 | vim_state: VimVisualState, 19 | event: Event, 20 | ) -> Result { 21 | match vim_state { 22 | VimVisualState::Idle => consume_idle(db, state, event).await, 23 | VimVisualState::Gateway => consume_gateway(db, state, event).await, 24 | VimVisualState::Numbering(n) => consume_numbering(db, state, n, event).await, 25 | } 26 | } 27 | 28 | async fn consume_idle( 29 | _db: &mut Db, 30 | state: &mut NotebookState, 31 | event: Event, 32 | ) -> Result { 33 | use Event::*; 34 | use VisualModeTransition::*; 35 | 36 | match event { 37 | Key(KeyEvent::J | KeyEvent::Down) => MoveCursorDown(1).into(), 38 | Key(KeyEvent::K | KeyEvent::Up) => MoveCursorUp(1).into(), 39 | Key(KeyEvent::H | KeyEvent::Left) => MoveCursorBack(1).into(), 40 | Key(KeyEvent::L | KeyEvent::Right) => MoveCursorForward(1).into(), 41 | Key(KeyEvent::W) => MoveCursorWordForward(1).into(), 42 | Key(KeyEvent::E) => MoveCursorWordEnd(1).into(), 43 | Key(KeyEvent::B) => MoveCursorWordBack(1).into(), 44 | Key(KeyEvent::Num(NumKey::Zero)) => MoveCursorLineStart.into(), 45 | Key(KeyEvent::DollarSign) => MoveCursorLineEnd.into(), 46 | Key(KeyEvent::Caret) => MoveCursorLineNonEmptyStart.into(), 47 | Key(KeyEvent::CapG) => MoveCursorBottom.into(), 48 | Key(KeyEvent::Tilde) => { 49 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 50 | 51 | SwitchCase.into() 52 | } 53 | Key(KeyEvent::U) => { 54 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 55 | 56 | ToLowercase.into() 57 | } 58 | Key(KeyEvent::CapU) => { 59 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 60 | 61 | ToUppercase.into() 62 | } 63 | Key(KeyEvent::D | KeyEvent::X) => { 64 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 65 | 66 | DeleteSelection.into() 67 | } 68 | 69 | Key(KeyEvent::S | KeyEvent::CapS) => { 70 | state.inner_state = InnerState::EditingInsertMode; 71 | 72 | DeleteSelectionAndInsertMode.into() 73 | } 74 | Key(KeyEvent::Y) => { 75 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 76 | 77 | YankSelection.into() 78 | } 79 | Key(KeyEvent::G) => { 80 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Gateway); 81 | 82 | GatewayMode.into() 83 | } 84 | Key(KeyEvent::Esc) => { 85 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 86 | 87 | Ok(NotebookTransition::EditingNormalMode( 88 | NormalModeTransition::IdleMode, 89 | )) 90 | } 91 | Key(KeyEvent::Num(n)) => { 92 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Numbering(n.into())); 93 | 94 | NumberingMode.into() 95 | } 96 | Key(KeyEvent::CtrlH) => Ok(NotebookTransition::ShowVimKeymap(VimKeymapKind::VisualIdle)), 97 | event @ Key(_) => Ok(NotebookTransition::Inedible(event)), 98 | _ => Err(Error::Wip("todo: Notebook::consume".to_owned())), 99 | } 100 | } 101 | 102 | async fn consume_gateway( 103 | db: &mut Db, 104 | state: &mut NotebookState, 105 | event: Event, 106 | ) -> Result { 107 | use Event::*; 108 | use VisualModeTransition::*; 109 | 110 | match event { 111 | Key(KeyEvent::G) => { 112 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Idle); 113 | 114 | MoveCursorTop.into() 115 | } 116 | Key(KeyEvent::Esc) => { 117 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Idle); 118 | 119 | Ok(NotebookTransition::None) 120 | } 121 | event @ Key(_) => { 122 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 123 | 124 | consume_idle(db, state, event).await 125 | } 126 | _ => Err(Error::Wip("todo: Notebook::consume".to_owned())), 127 | } 128 | } 129 | 130 | async fn consume_numbering( 131 | db: &mut Db, 132 | state: &mut NotebookState, 133 | n: usize, 134 | event: Event, 135 | ) -> Result { 136 | use Event::*; 137 | use VisualModeTransition::*; 138 | 139 | match event { 140 | Key(KeyEvent::Num(n2)) => { 141 | let step = n2 + n.saturating_mul(10); 142 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Numbering(step)); 143 | 144 | Ok(NotebookTransition::None) 145 | } 146 | Key(KeyEvent::J | KeyEvent::Down) => { 147 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Idle); 148 | 149 | MoveCursorDown(n).into() 150 | } 151 | Key(KeyEvent::K | KeyEvent::Up) => { 152 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Idle); 153 | 154 | MoveCursorUp(n).into() 155 | } 156 | Key(KeyEvent::H | KeyEvent::Left) => { 157 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Idle); 158 | 159 | NormalModeTransition::MoveCursorBack(n).into() 160 | } 161 | Key(KeyEvent::L | KeyEvent::Right) => { 162 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Idle); 163 | 164 | MoveCursorForward(n).into() 165 | } 166 | Key(KeyEvent::W) => { 167 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Idle); 168 | 169 | MoveCursorWordForward(n).into() 170 | } 171 | Key(KeyEvent::E) => { 172 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Idle); 173 | 174 | MoveCursorWordEnd(n).into() 175 | } 176 | Key(KeyEvent::B) => { 177 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Idle); 178 | 179 | MoveCursorWordBack(n).into() 180 | } 181 | Key(KeyEvent::CapG) => { 182 | state.inner_state = InnerState::EditingVisualMode(VimVisualState::Idle); 183 | 184 | MoveCursorToLine(n).into() 185 | } 186 | Key(KeyEvent::Esc) => { 187 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 188 | 189 | Ok(NotebookTransition::EditingNormalMode( 190 | NormalModeTransition::IdleMode, 191 | )) 192 | } 193 | Key(KeyEvent::CtrlH) => Ok(NotebookTransition::ShowVimKeymap( 194 | VimKeymapKind::VisualNumbering, 195 | )), 196 | event @ Key(_) => { 197 | state.inner_state = InnerState::EditingNormalMode(VimNormalState::Idle); 198 | 199 | consume_idle(db, state, event).await 200 | } 201 | _ => Err(Error::Wip("todo: Notebook::consume".to_owned())), 202 | } 203 | } 204 | 205 | impl From for Result { 206 | fn from(transition: VisualModeTransition) -> Self { 207 | Ok(NotebookTransition::EditingVisualMode(transition)) 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /core/src/state/notebook/inner_state/note_tree.rs: -------------------------------------------------------------------------------- 1 | use crate::{Event, NotebookTransition, Result, db::Db, state::notebook::NotebookState}; 2 | 3 | mod directory_more_actions; 4 | mod directory_selected; 5 | mod gateway; 6 | mod move_mode; 7 | mod note_more_actions; 8 | mod note_selected; 9 | mod numbering; 10 | 11 | #[derive(Clone, Copy)] 12 | pub enum NoteTreeState { 13 | NoteSelected, 14 | NoteMoreActions, 15 | DirectorySelected, 16 | DirectoryMoreActions, 17 | Numbering(usize), 18 | GatewayMode, 19 | MoveMode, 20 | } 21 | 22 | pub async fn consume( 23 | db: &mut Db, 24 | state: &mut NotebookState, 25 | tree_state: NoteTreeState, 26 | event: Event, 27 | ) -> Result { 28 | use NoteTreeState::*; 29 | 30 | match tree_state { 31 | NoteSelected => note_selected::consume(db, state, event).await, 32 | DirectorySelected => directory_selected::consume(db, state, event).await, 33 | NoteMoreActions => note_more_actions::consume(db, state, event).await, 34 | DirectoryMoreActions => directory_more_actions::consume(db, state, event).await, 35 | Numbering(n) => numbering::consume(state, n, event).await, 36 | GatewayMode => gateway::consume(state, event).await, 37 | MoveMode => move_mode::consume(db, state, event).await, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /core/src/state/notebook/inner_state/note_tree/directory_more_actions.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | Error, Event, NotebookEvent, NotebookTransition, Result, 3 | db::Db, 4 | state::notebook::{NotebookState, directory, note}, 5 | }; 6 | 7 | pub async fn consume( 8 | db: &mut Db, 9 | state: &mut NotebookState, 10 | event: Event, 11 | ) -> Result { 12 | use Event::*; 13 | use NotebookEvent::*; 14 | 15 | match event { 16 | Notebook(CloseDirectoryActionsDialog) => { 17 | let directory = state.get_selected_directory()?.clone(); 18 | 19 | directory::select(state, directory) 20 | } 21 | Notebook(RenameDirectory(new_name)) => { 22 | let directory = state.get_selected_directory()?.clone(); 23 | 24 | directory::rename(db, state, directory, new_name).await 25 | } 26 | Notebook(RemoveDirectory) => { 27 | let directory = state.get_selected_directory()?.clone(); 28 | 29 | directory::remove(db, state, directory).await 30 | } 31 | Notebook(AddNote(note_name)) => { 32 | let directory = state.get_selected_directory()?.clone(); 33 | 34 | note::add(db, state, directory, note_name).await 35 | } 36 | Notebook(AddDirectory(directory_name)) => { 37 | let directory = state.get_selected_directory()?.clone(); 38 | 39 | directory::add(db, state, directory, directory_name).await 40 | } 41 | Cancel => { 42 | let directory = state.get_selected_directory()?.clone(); 43 | 44 | directory::select(state, directory) 45 | } 46 | event @ Key(_) => Ok(NotebookTransition::Inedible(event)), 47 | _ => Err(Error::Wip("todo: Notebook::consume".to_owned())), 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /core/src/state/notebook/inner_state/note_tree/directory_selected.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::NoteTreeState, 3 | crate::{ 4 | Error, Event, KeyEvent, NotebookEvent, NotebookTransition, Result, 5 | db::Db, 6 | state::notebook::{InnerState, NotebookState, directory, note, tabs}, 7 | transition::{MoveModeTransition, NoteTreeTransition}, 8 | }, 9 | }; 10 | 11 | pub async fn consume( 12 | db: &mut Db, 13 | state: &mut NotebookState, 14 | event: Event, 15 | ) -> Result { 16 | use Event::*; 17 | use NotebookEvent::*; 18 | 19 | match event { 20 | Notebook(OpenDirectory(directory_id)) => directory::open(db, state, directory_id).await, 21 | Key(KeyEvent::L | KeyEvent::Right | KeyEvent::Enter) => { 22 | let directory = state.get_selected_directory()?.clone(); 23 | let directory_item = state.root.find(&directory.id).ok_or(Error::Wip( 24 | "[Key::L] failed to find the target directory".to_owned(), 25 | ))?; 26 | 27 | if directory_item.children.is_none() { 28 | directory::open(db, state, directory.id.clone()).await 29 | } else { 30 | directory::close(state, directory) 31 | } 32 | } 33 | Notebook(CloseDirectory(directory_id)) => { 34 | let directory = state 35 | .root 36 | .find(&directory_id) 37 | .ok_or(Error::Wip( 38 | "[CloseDirectory] failed to find target directory".to_owned(), 39 | ))? 40 | .directory 41 | .clone(); 42 | 43 | directory::close(state, directory) 44 | } 45 | Key(KeyEvent::H) | Key(KeyEvent::Left) => { 46 | let directory = state.get_selected_directory()?; 47 | if state.root.directory.id == directory.id { 48 | return Ok(NotebookTransition::None); 49 | } 50 | 51 | let parent_item = state.root.find(&directory.parent_id).ok_or(Error::Wip( 52 | "[Key::H] failed to find parent directory".to_owned(), 53 | ))?; 54 | let parent = parent_item.directory.clone(); 55 | 56 | directory::close(state, parent) 57 | } 58 | Key(KeyEvent::J | KeyEvent::Down) => Ok(NotebookTransition::NoteTree( 59 | NoteTreeTransition::SelectNext(1), 60 | )), 61 | Key(KeyEvent::K | KeyEvent::Up) => Ok(NotebookTransition::NoteTree( 62 | NoteTreeTransition::SelectPrev(1), 63 | )), 64 | Key(KeyEvent::CapJ) => Ok(NotebookTransition::NoteTree( 65 | NoteTreeTransition::SelectNextDirectory, 66 | )), 67 | Key(KeyEvent::CapK) => Ok(NotebookTransition::NoteTree( 68 | NoteTreeTransition::SelectPrevDirectory, 69 | )), 70 | Key(KeyEvent::M) => { 71 | let directory = state.get_selected_directory()?.clone(); 72 | 73 | directory::show_actions_dialog(state, directory) 74 | } 75 | Key(KeyEvent::Space) => { 76 | state.inner_state = InnerState::NoteTree(NoteTreeState::MoveMode); 77 | 78 | Ok(NotebookTransition::NoteTree(NoteTreeTransition::MoveMode( 79 | MoveModeTransition::Enter, 80 | ))) 81 | } 82 | Notebook(SelectNote(note)) => note::select(state, note), 83 | Notebook(SelectDirectory(directory)) => directory::select(state, directory), 84 | Key(KeyEvent::Num(n)) => { 85 | state.inner_state = InnerState::NoteTree(NoteTreeState::Numbering(n.into())); 86 | 87 | Ok(NotebookTransition::None) 88 | } 89 | Key(KeyEvent::CapG) => Ok(NotebookTransition::NoteTree(NoteTreeTransition::SelectLast)), 90 | Key(KeyEvent::G) => { 91 | state.inner_state = InnerState::NoteTree(NoteTreeState::GatewayMode); 92 | 93 | Ok(NotebookTransition::NoteTree( 94 | NoteTreeTransition::GatewayMode, 95 | )) 96 | } 97 | Key(KeyEvent::AngleBracketOpen) => Ok(NotebookTransition::NoteTree( 98 | NoteTreeTransition::ShrinkWidth(1), 99 | )), 100 | Key(KeyEvent::AngleBracketClose) => Ok(NotebookTransition::NoteTree( 101 | NoteTreeTransition::ExpandWidth(1), 102 | )), 103 | Key(KeyEvent::Tab) if !state.tabs.is_empty() => tabs::focus_editor(db, state).await, 104 | event @ Key(_) => Ok(NotebookTransition::Inedible(event)), 105 | _ => Err(Error::Wip("todo: Notebook::consume".to_owned())), 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /core/src/state/notebook/inner_state/note_tree/gateway.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | Error, Event, KeyEvent, NotebookTransition, Result, 3 | state::notebook::{InnerState, NoteTreeState, NotebookState, SelectedItem}, 4 | transition::NoteTreeTransition, 5 | }; 6 | 7 | pub async fn consume(state: &mut NotebookState, event: Event) -> Result { 8 | use Event::*; 9 | 10 | match event { 11 | Key(KeyEvent::G) => { 12 | state.inner_state = leave_gateway_mode(&state.selected)?; 13 | 14 | Ok(NotebookTransition::NoteTree( 15 | NoteTreeTransition::SelectFirst, 16 | )) 17 | } 18 | Key(KeyEvent::Esc) => { 19 | state.inner_state = leave_gateway_mode(&state.selected)?; 20 | 21 | Ok(NotebookTransition::None) 22 | } 23 | event @ Key(_) => { 24 | state.inner_state = leave_gateway_mode(&state.selected)?; 25 | 26 | Ok(NotebookTransition::Inedible(event)) 27 | } 28 | _ => Err(Error::Wip( 29 | "todo: NoteTree::GatewayMode::consume".to_owned(), 30 | )), 31 | } 32 | } 33 | 34 | fn leave_gateway_mode(selected: &SelectedItem) -> Result { 35 | match selected { 36 | SelectedItem::Directory(_) => Ok(InnerState::NoteTree(NoteTreeState::DirectorySelected)), 37 | SelectedItem::Note(_) => Ok(InnerState::NoteTree(NoteTreeState::NoteSelected)), 38 | SelectedItem::None => Err(Error::Wip("todo: cannot leave gateway mode".to_owned())), 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /core/src/state/notebook/inner_state/note_tree/move_mode.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::NoteTreeState, 3 | crate::{ 4 | Error, Event, KeyEvent, NotebookEvent, NotebookTransition, Result, 5 | db::Db, 6 | state::notebook::{InnerState, NotebookState, SelectedItem, directory, note}, 7 | transition::{MoveModeTransition, NoteTreeTransition}, 8 | }, 9 | }; 10 | 11 | pub async fn consume( 12 | db: &mut Db, 13 | state: &mut NotebookState, 14 | event: Event, 15 | ) -> Result { 16 | use Event::*; 17 | 18 | match event { 19 | Key(KeyEvent::J | KeyEvent::Down) => MoveModeTransition::SelectNext.into(), 20 | Key(KeyEvent::K | KeyEvent::Up) => MoveModeTransition::SelectPrev.into(), 21 | Key(KeyEvent::CapG) => MoveModeTransition::SelectLast.into(), 22 | Key(KeyEvent::Esc) => { 23 | match state.selected { 24 | SelectedItem::Directory(_) => { 25 | state.inner_state = InnerState::NoteTree(NoteTreeState::DirectorySelected); 26 | } 27 | SelectedItem::Note(_) => { 28 | state.inner_state = InnerState::NoteTree(NoteTreeState::NoteSelected); 29 | } 30 | SelectedItem::None => {} 31 | }; 32 | 33 | MoveModeTransition::Cancel.into() 34 | } 35 | Key(KeyEvent::Enter) => MoveModeTransition::RequestCommit.into(), 36 | Notebook(NotebookEvent::MoveNote(directory_id)) => { 37 | note::move_note(db, state, directory_id).await 38 | } 39 | Notebook(NotebookEvent::MoveDirectory(target_directory_id)) => { 40 | directory::move_directory(db, state, target_directory_id).await 41 | } 42 | event @ Key(_) => Ok(NotebookTransition::Inedible(event)), 43 | _ => Err(Error::Wip("todo: Notebook::consume".to_owned())), 44 | } 45 | } 46 | 47 | impl From for Result { 48 | fn from(transition: MoveModeTransition) -> Self { 49 | Ok(NotebookTransition::NoteTree(NoteTreeTransition::MoveMode( 50 | transition, 51 | ))) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /core/src/state/notebook/inner_state/note_tree/note_more_actions.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | Error, Event, NotebookEvent, NotebookTransition, Result, 3 | db::Db, 4 | state::notebook::{NotebookState, note}, 5 | }; 6 | 7 | pub async fn consume( 8 | db: &mut Db, 9 | state: &mut NotebookState, 10 | event: Event, 11 | ) -> Result { 12 | use Event::*; 13 | use NotebookEvent::*; 14 | 15 | match event { 16 | Notebook(CloseNoteActionsDialog) => { 17 | let note = state.get_selected_note()?.clone(); 18 | 19 | note::select(state, note) 20 | } 21 | Notebook(RenameNote(new_name)) => { 22 | let note = state.get_selected_note()?.clone(); 23 | 24 | note::rename(db, state, note, new_name).await 25 | } 26 | Notebook(RemoveNote) => { 27 | let note = state.get_selected_note()?.clone(); 28 | 29 | note::remove(db, state, note).await 30 | } 31 | Cancel => { 32 | let note = state.get_selected_note()?.clone(); 33 | 34 | note::select(state, note.clone()) 35 | } 36 | event @ Key(_) => Ok(NotebookTransition::Inedible(event)), 37 | _ => Err(Error::Wip("todo: Notebook::consume".to_owned())), 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /core/src/state/notebook/inner_state/note_tree/note_selected.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::NoteTreeState, 3 | crate::{ 4 | Error, Event, KeyEvent, NotebookEvent, NotebookTransition, Result, 5 | db::Db, 6 | state::notebook::{InnerState, NotebookState, directory, note, tabs}, 7 | transition::{MoveModeTransition, NoteTreeTransition}, 8 | }, 9 | }; 10 | 11 | pub async fn consume( 12 | db: &mut Db, 13 | state: &mut NotebookState, 14 | event: Event, 15 | ) -> Result { 16 | use Event::*; 17 | use NotebookEvent::*; 18 | 19 | match event { 20 | Notebook(OpenDirectory(directory_id)) => directory::open(db, state, directory_id).await, 21 | Notebook(CloseDirectory(directory_id)) => { 22 | let directory = state 23 | .root 24 | .find(&directory_id) 25 | .ok_or(Error::Wip( 26 | "[CloseDirectory] failed to find target directory".to_owned(), 27 | ))? 28 | .directory 29 | .clone(); 30 | 31 | directory::close(state, directory) 32 | } 33 | Key(KeyEvent::H) | Key(KeyEvent::Left) => { 34 | let directory_id = &state.get_selected_note()?.directory_id; 35 | let directory_item = state.root.find(directory_id).ok_or(Error::Wip( 36 | "[Key::H] failed to find parent directory".to_owned(), 37 | ))?; 38 | let directory = directory_item.directory.clone(); 39 | 40 | directory::close(state, directory) 41 | } 42 | Key(KeyEvent::J | KeyEvent::Down) => Ok(NotebookTransition::NoteTree( 43 | NoteTreeTransition::SelectNext(1), 44 | )), 45 | Key(KeyEvent::K | KeyEvent::Up) => Ok(NotebookTransition::NoteTree( 46 | NoteTreeTransition::SelectPrev(1), 47 | )), 48 | Key(KeyEvent::CapJ) => Ok(NotebookTransition::NoteTree( 49 | NoteTreeTransition::SelectNextDirectory, 50 | )), 51 | Key(KeyEvent::CapK) => Ok(NotebookTransition::NoteTree( 52 | NoteTreeTransition::SelectPrevDirectory, 53 | )), 54 | Key(KeyEvent::M) => { 55 | let note = state.get_selected_note()?.clone(); 56 | 57 | note::show_actions_dialog(state, note) 58 | } 59 | Key(KeyEvent::Space) => { 60 | state.inner_state = InnerState::NoteTree(NoteTreeState::MoveMode); 61 | 62 | Ok(NotebookTransition::NoteTree(NoteTreeTransition::MoveMode( 63 | MoveModeTransition::Enter, 64 | ))) 65 | } 66 | Notebook(SelectNote(note)) => note::select(state, note), 67 | Notebook(SelectDirectory(directory)) => directory::select(state, directory), 68 | Key(KeyEvent::L | KeyEvent::Enter) | Notebook(OpenNote) => { 69 | let note = state.get_selected_note()?.clone(); 70 | 71 | note::open(db, state, note).await 72 | } 73 | Key(KeyEvent::Num(n)) => { 74 | state.inner_state = InnerState::NoteTree(NoteTreeState::Numbering(n.into())); 75 | 76 | Ok(NotebookTransition::None) 77 | } 78 | Key(KeyEvent::CapG) => Ok(NotebookTransition::NoteTree(NoteTreeTransition::SelectLast)), 79 | Key(KeyEvent::G) => { 80 | state.inner_state = InnerState::NoteTree(NoteTreeState::GatewayMode); 81 | 82 | Ok(NotebookTransition::NoteTree( 83 | NoteTreeTransition::GatewayMode, 84 | )) 85 | } 86 | Key(KeyEvent::AngleBracketOpen) => Ok(NotebookTransition::NoteTree( 87 | NoteTreeTransition::ShrinkWidth(1), 88 | )), 89 | Key(KeyEvent::AngleBracketClose) => Ok(NotebookTransition::NoteTree( 90 | NoteTreeTransition::ExpandWidth(1), 91 | )), 92 | Key(KeyEvent::Tab) if !state.tabs.is_empty() => tabs::focus_editor(db, state).await, 93 | event @ Key(_) => Ok(NotebookTransition::Inedible(event)), 94 | _ => Err(Error::Wip("todo: Notebook::consume".to_owned())), 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /core/src/state/notebook/inner_state/note_tree/numbering.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::NoteTreeState, 3 | crate::{ 4 | Error, Event, KeyEvent, NotebookEvent, NotebookTransition, Result, 5 | state::notebook::{InnerState, NotebookState, SelectedItem, directory, note}, 6 | transition::NoteTreeTransition, 7 | }, 8 | }; 9 | 10 | pub async fn consume( 11 | state: &mut NotebookState, 12 | n: usize, 13 | event: Event, 14 | ) -> Result { 15 | use Event::*; 16 | use NotebookEvent::*; 17 | 18 | let reset_state = |state: &mut NotebookState| { 19 | match state.selected { 20 | SelectedItem::Note { .. } => { 21 | state.inner_state = InnerState::NoteTree(NoteTreeState::NoteSelected); 22 | } 23 | SelectedItem::Directory { .. } => { 24 | state.inner_state = InnerState::NoteTree(NoteTreeState::DirectorySelected); 25 | } 26 | SelectedItem::None => {} 27 | }; 28 | }; 29 | 30 | match event { 31 | Notebook(SelectNote(note)) => note::select(state, note), 32 | Notebook(SelectDirectory(directory)) => directory::select(state, directory), 33 | Key(KeyEvent::Num(n2)) => { 34 | let step = n2 + n.saturating_mul(10); 35 | state.inner_state = InnerState::NoteTree(NoteTreeState::Numbering(step)); 36 | 37 | Ok(NotebookTransition::None) 38 | } 39 | Key(KeyEvent::Esc) => { 40 | reset_state(state); 41 | Ok(NotebookTransition::None) 42 | } 43 | Key(KeyEvent::J | KeyEvent::Down) => { 44 | reset_state(state); 45 | Ok(NotebookTransition::NoteTree( 46 | NoteTreeTransition::SelectNext(n), 47 | )) 48 | } 49 | Key(KeyEvent::K | KeyEvent::Up) => { 50 | reset_state(state); 51 | Ok(NotebookTransition::NoteTree( 52 | NoteTreeTransition::SelectPrev(n), 53 | )) 54 | } 55 | Key(KeyEvent::CapG) => { 56 | reset_state(state); 57 | Ok(NotebookTransition::NoteTree(NoteTreeTransition::SelectLast)) 58 | } 59 | Key(KeyEvent::AngleBracketOpen) => { 60 | reset_state(state); 61 | Ok(NotebookTransition::NoteTree( 62 | NoteTreeTransition::ShrinkWidth(n), 63 | )) 64 | } 65 | Key(KeyEvent::AngleBracketClose) => { 66 | reset_state(state); 67 | Ok(NotebookTransition::NoteTree( 68 | NoteTreeTransition::ExpandWidth(n), 69 | )) 70 | } 71 | event @ Key(_) => { 72 | reset_state(state); 73 | Ok(NotebookTransition::Inedible(event)) 74 | } 75 | _ => Err(Error::Wip("todo: Notebook::consume".to_owned())), 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /core/src/task.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{Result, Transition}, 3 | gluesql::gluesql_git_storage::{GitStorage, StorageType}, 4 | std::{ 5 | collections::VecDeque, 6 | path::PathBuf, 7 | sync::{Arc, Mutex, mpsc::Receiver}, 8 | thread::{JoinHandle, spawn}, 9 | }, 10 | }; 11 | 12 | #[derive(Clone, Debug)] 13 | pub enum Task { 14 | GitSync { 15 | path: PathBuf, 16 | remote: String, 17 | branch: String, 18 | }, 19 | } 20 | 21 | pub fn handle_tasks( 22 | task_rx: Receiver, 23 | transition_queue: &Arc>>, 24 | ) -> JoinHandle<()> { 25 | spawn({ 26 | let transition_queue = Arc::clone(transition_queue); 27 | 28 | move || { 29 | while let Ok(task) = task_rx.recv() { 30 | let transition = match handle_task(task) { 31 | Ok(transition) => transition, 32 | Err(error) => Transition::Error(error.to_string()), 33 | }; 34 | 35 | transition_queue 36 | .lock() 37 | .expect("failed to acquire transition queue") 38 | .push_back(transition); 39 | } 40 | } 41 | }) 42 | } 43 | 44 | fn handle_task(task: Task) -> Result { 45 | match task { 46 | Task::GitSync { 47 | path, 48 | remote, 49 | branch, 50 | } => { 51 | let mut storage = GitStorage::open(path, StorageType::File)?; 52 | storage.set_remote(remote); 53 | storage.set_branch(branch); 54 | storage.pull()?; 55 | storage.push()?; 56 | 57 | Ok(Transition::Log( 58 | "Sync complete. Your notes are up to date.".to_owned(), 59 | )) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /core/src/transition.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | Event, 4 | data::{Directory, Note}, 5 | state::notebook::DirectoryItem, 6 | types::{DirectoryId, NoteId}, 7 | }, 8 | strum_macros::Display, 9 | }; 10 | 11 | #[derive(Display)] 12 | pub enum Transition { 13 | #[strum(to_string = "Keymap::{0}")] 14 | Keymap(KeymapTransition), 15 | 16 | #[strum(to_string = "Entry::{0}")] 17 | Entry(EntryTransition), 18 | 19 | #[strum(to_string = "Notebook::{0}")] 20 | Notebook(NotebookTransition), 21 | 22 | Log(String), 23 | Error(String), 24 | } 25 | 26 | #[derive(Display)] 27 | pub enum KeymapTransition { 28 | Show, 29 | Hide, 30 | } 31 | 32 | #[derive(Display)] 33 | pub enum EntryTransition { 34 | OpenNotebook, 35 | 36 | #[strum(to_string = "Inedible::{0}")] 37 | Inedible(Event), 38 | 39 | None, 40 | } 41 | 42 | #[derive(Display)] 43 | pub enum NotebookTransition { 44 | ViewMode(Note), 45 | BrowseNoteTree, 46 | FocusEditor, 47 | 48 | UpdateNoteContent(NoteId), 49 | 50 | Alert(String), 51 | 52 | #[strum(to_string = "Inedible::{0}")] 53 | Inedible(Event), 54 | None, 55 | 56 | NoteTree(NoteTreeTransition), 57 | EditingNormalMode(NormalModeTransition), 58 | EditingVisualMode(VisualModeTransition), 59 | ShowVimKeymap(VimKeymapKind), 60 | } 61 | 62 | pub enum NoteTreeTransition { 63 | OpenDirectory { 64 | id: DirectoryId, 65 | notes: Vec, 66 | directories: Vec, 67 | }, 68 | CloseDirectory(DirectoryId), 69 | 70 | RenameNote(Note), 71 | RenameDirectory(Directory), 72 | 73 | RemoveNote { 74 | note: Note, 75 | selected_directory: Directory, 76 | }, 77 | RemoveDirectory { 78 | directory: Directory, 79 | selected_directory: Directory, 80 | }, 81 | 82 | AddNote(Note), 83 | AddDirectory(Directory), 84 | 85 | ShowNoteActionsDialog(Note), 86 | ShowDirectoryActionsDialog(Directory), 87 | 88 | MoveMode(MoveModeTransition), 89 | 90 | OpenNote { 91 | note: Note, 92 | content: String, 93 | }, 94 | 95 | SelectNext(usize), 96 | SelectPrev(usize), 97 | SelectFirst, 98 | SelectLast, 99 | 100 | SelectNextDirectory, 101 | SelectPrevDirectory, 102 | 103 | ExpandWidth(usize), 104 | ShrinkWidth(usize), 105 | GatewayMode, 106 | } 107 | 108 | pub enum MoveModeTransition { 109 | Enter, 110 | SelectNext, 111 | SelectPrev, 112 | SelectLast, 113 | RequestCommit, 114 | Commit, 115 | Cancel, 116 | } 117 | 118 | #[derive(Clone, Copy, Display)] 119 | pub enum VimKeymapKind { 120 | NormalIdle, 121 | NormalNumbering, 122 | NormalDelete, 123 | NormalDelete2, 124 | NormalChange, 125 | NormalChange2, 126 | VisualIdle, 127 | VisualNumbering, 128 | } 129 | 130 | #[derive(Display)] 131 | pub enum NormalModeTransition { 132 | IdleMode, 133 | ToggleMode, 134 | ToggleTabCloseMode, 135 | NumberingMode, 136 | GatewayMode, 137 | YankMode, 138 | DeleteMode, 139 | DeleteInsideMode, 140 | ChangeMode, 141 | ChangeInsideMode, 142 | ScrollMode, 143 | 144 | // toggle mode 145 | NextTab(NoteId), 146 | PrevTab(NoteId), 147 | CloseTab(NoteId), 148 | MoveTabNext(usize), 149 | MoveTabPrev(usize), 150 | ToggleLineNumbers, 151 | ToggleBrowser, 152 | 153 | // toggle tab close mode 154 | CloseRightTabs(usize), 155 | CloseLeftTabs(usize), 156 | 157 | MoveCursorDown(usize), 158 | MoveCursorUp(usize), 159 | MoveCursorBack(usize), 160 | MoveCursorForward(usize), 161 | MoveCursorWordForward(usize), 162 | MoveCursorWordEnd(usize), 163 | MoveCursorWordBack(usize), 164 | MoveCursorLineStart, 165 | MoveCursorLineEnd, 166 | MoveCursorLineNonEmptyStart, 167 | MoveCursorTop, 168 | MoveCursorBottom, 169 | MoveCursorToLine(usize), 170 | ScrollCenter, 171 | ScrollTop, 172 | ScrollBottom, 173 | InsertAtCursor, 174 | InsertAtLineStart, 175 | InsertAfterCursor, 176 | InsertAtLineEnd, 177 | InsertNewLineBelow, 178 | InsertNewLineAbove, 179 | DeleteChars(usize), 180 | DeleteCharsBack(usize), 181 | DeleteLines(usize), 182 | DeleteLinesAndInsert(usize), 183 | DeleteWordEnd(usize), 184 | DeleteWordBack(usize), 185 | DeleteLineStart, 186 | DeleteLineEnd(usize), 187 | Paste, 188 | Undo, 189 | Redo, 190 | YankLines(usize), 191 | DeleteInsideWord(usize), 192 | SwitchCase, 193 | } 194 | 195 | #[derive(Display)] 196 | pub enum VisualModeTransition { 197 | IdleMode, 198 | NumberingMode, 199 | GatewayMode, 200 | MoveCursorDown(usize), 201 | MoveCursorUp(usize), 202 | MoveCursorBack(usize), 203 | MoveCursorForward(usize), 204 | MoveCursorWordForward(usize), 205 | MoveCursorWordEnd(usize), 206 | MoveCursorWordBack(usize), 207 | MoveCursorLineStart, 208 | MoveCursorLineEnd, 209 | MoveCursorLineNonEmptyStart, 210 | MoveCursorTop, 211 | MoveCursorBottom, 212 | MoveCursorToLine(usize), 213 | YankSelection, 214 | DeleteSelection, 215 | DeleteSelectionAndInsertMode, 216 | SwitchCase, 217 | ToUppercase, 218 | ToLowercase, 219 | } 220 | 221 | impl From for Transition { 222 | fn from(t: KeymapTransition) -> Self { 223 | Self::Keymap(t) 224 | } 225 | } 226 | 227 | impl From for Transition { 228 | fn from(t: EntryTransition) -> Self { 229 | Self::Entry(t) 230 | } 231 | } 232 | 233 | impl From for Transition { 234 | fn from(t: NotebookTransition) -> Self { 235 | Self::Notebook(t) 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /core/src/types.rs: -------------------------------------------------------------------------------- 1 | pub type NoteId = String; // UUID 2 | pub type DirectoryId = String; // UUID 3 | pub type Id = String; // UUID 4 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # This template contains all of the possible sections and their default values 2 | 3 | # Note that all fields that take a lint level have these possible values: 4 | # * deny - An error will be produced and the check will fail 5 | # * warn - A warning will be produced, but the check will not fail 6 | # * allow - No warning or error will be produced, though in some cases a note 7 | # will be 8 | 9 | # The values provided in this template are the default values that will be used 10 | # when any section or field is not specified in your own configuration 11 | 12 | # Root options 13 | 14 | # The graph table configures how the dependency graph is constructed and thus 15 | # which crates the checks are performed against 16 | [graph] 17 | # If 1 or more target triples (and optionally, target_features) are specified, 18 | # only the specified targets will be checked when running `cargo deny check`. 19 | # This means, if a particular package is only ever used as a target specific 20 | # dependency, such as, for example, the `nix` crate only being used via the 21 | # `target_family = "unix"` configuration, that only having windows targets in 22 | # this list would mean the nix crate, as well as any of its exclusive 23 | # dependencies not shared by any other crates, would be ignored, as the target 24 | # list here is effectively saying which targets you are building for. 25 | targets = [ 26 | # The triple can be any string, but only the target triples built in to 27 | # rustc (as of 1.40) can be checked against actual config expressions 28 | #"x86_64-unknown-linux-musl", 29 | # You can also specify which target_features you promise are enabled for a 30 | # particular target. target_features are currently not validated against 31 | # the actual valid features supported by the target architecture. 32 | #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, 33 | ] 34 | # When creating the dependency graph used as the source of truth when checks are 35 | # executed, this field can be used to prune crates from the graph, removing them 36 | # from the view of cargo-deny. This is an extremely heavy hammer, as if a crate 37 | # is pruned from the graph, all of its dependencies will also be pruned unless 38 | # they are connected to another crate in the graph that hasn't been pruned, 39 | # so it should be used with care. The identifiers are [Package ID Specifications] 40 | # (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) 41 | #exclude = [] 42 | # If true, metadata will be collected with `--all-features`. Note that this can't 43 | # be toggled off if true, if you want to conditionally enable `--all-features` it 44 | # is recommended to pass `--all-features` on the cmd line instead 45 | all-features = false 46 | # If true, metadata will be collected with `--no-default-features`. The same 47 | # caveat with `all-features` applies 48 | no-default-features = false 49 | # If set, these feature will be enabled when collecting metadata. If `--features` 50 | # is specified on the cmd line they will take precedence over this option. 51 | #features = [] 52 | 53 | # The output table provides options for how/if diagnostics are outputted 54 | [output] 55 | # When outputting inclusion graphs in diagnostics that include features, this 56 | # option can be used to specify the depth at which feature edges will be added. 57 | # This option is included since the graphs can be quite large and the addition 58 | # of features from the crate(s) to all of the graph roots can be far too verbose. 59 | # This option can be overridden via `--feature-depth` on the cmd line 60 | feature-depth = 1 61 | 62 | # This section is considered when running `cargo deny check advisories` 63 | # More documentation for the advisories section can be found here: 64 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 65 | [advisories] 66 | # The path where the advisory databases are cloned/fetched into 67 | #db-path = "$CARGO_HOME/advisory-dbs" 68 | # The url(s) of the advisory databases to use 69 | #db-urls = ["https://github.com/rustsec/advisory-db"] 70 | # A list of advisory IDs to ignore. Note that ignored advisories will still 71 | # output a note when they are encountered. 72 | ignore = [ 73 | #"RUSTSEC-0000-0000", 74 | #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, 75 | #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish 76 | #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, 77 | ] 78 | # If this is true, then cargo deny will use the git executable to fetch advisory database. 79 | # If this is false, then it uses a built-in git library. 80 | # Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. 81 | # See Git Authentication for more information about setting up git authentication. 82 | #git-fetch-with-cli = true 83 | 84 | # This section is considered when running `cargo deny check licenses` 85 | # More documentation for the licenses section can be found here: 86 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 87 | [licenses] 88 | # List of explicitly allowed licenses 89 | # See https://spdx.org/licenses/ for list of possible licenses 90 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 91 | allow = [ 92 | "MIT", 93 | "Apache-2.0", 94 | "Unicode-DFS-2016", 95 | "MPL-2.0", 96 | ] 97 | # The confidence threshold for detecting a license from license text. 98 | # The higher the value, the more closely the license text must be to the 99 | # canonical license text of a valid SPDX license file. 100 | # [possible values: any between 0.0 and 1.0]. 101 | confidence-threshold = 0.8 102 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 103 | # aren't accepted for every possible crate as with the normal allow list 104 | exceptions = [ 105 | # Each entry is the crate and version constraint, and its specific allow 106 | # list 107 | #{ allow = ["Zlib"], crate = "adler32" }, 108 | ] 109 | 110 | # Some crates don't have (easily) machine readable licensing information, 111 | # adding a clarification entry for it allows you to manually specify the 112 | # licensing information 113 | #[[licenses.clarify]] 114 | # The package spec the clarification applies to 115 | #crate = "ring" 116 | # The SPDX expression for the license requirements of the crate 117 | #expression = "MIT AND ISC AND OpenSSL" 118 | # One or more files in the crate's source used as the "source of truth" for 119 | # the license expression. If the contents match, the clarification will be used 120 | # when running the license check, otherwise the clarification will be ignored 121 | # and the crate will be checked normally, which may produce warnings or errors 122 | # depending on the rest of your configuration 123 | #license-files = [ 124 | # Each entry is a crate relative path, and the (opaque) hash of its contents 125 | #{ path = "LICENSE", hash = 0xbd0eed23 } 126 | #] 127 | 128 | [licenses.private] 129 | # If true, ignores workspace crates that aren't published, or are only 130 | # published to private registries. 131 | # To see how to mark a crate as unpublished (to the official registry), 132 | # visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. 133 | ignore = false 134 | # One or more private registries that you might publish crates to, if a crate 135 | # is only published to private registries, and ignore is true, the crate will 136 | # not have its license(s) checked 137 | registries = [ 138 | #"https://sekretz.com/registry 139 | ] 140 | 141 | # This section is considered when running `cargo deny check bans`. 142 | # More documentation about the 'bans' section can be found here: 143 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 144 | [bans] 145 | # Lint level for when multiple versions of the same crate are detected 146 | multiple-versions = "warn" 147 | # Lint level for when a crate version requirement is `*` 148 | wildcards = "allow" 149 | # The graph highlighting used when creating dotgraphs for crates 150 | # with multiple versions 151 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 152 | # * simplest-path - The path to the version with the fewest edges is highlighted 153 | # * all - Both lowest-version and simplest-path are used 154 | highlight = "all" 155 | # The default lint level for `default` features for crates that are members of 156 | # the workspace that is being checked. This can be overridden by allowing/denying 157 | # `default` on a crate-by-crate basis if desired. 158 | workspace-default-features = "allow" 159 | # The default lint level for `default` features for external crates that are not 160 | # members of the workspace. This can be overridden by allowing/denying `default` 161 | # on a crate-by-crate basis if desired. 162 | external-default-features = "allow" 163 | # List of crates that are allowed. Use with care! 164 | allow = [ 165 | #"ansi_term@0.11.0", 166 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, 167 | ] 168 | # List of crates to deny 169 | deny = [ 170 | #"ansi_term@0.11.0", 171 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, 172 | # Wrapper crates can optionally be specified to allow the crate when it 173 | # is a direct dependency of the otherwise banned crate 174 | #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, 175 | ] 176 | 177 | # List of features to allow/deny 178 | # Each entry the name of a crate and a version range. If version is 179 | # not specified, all versions will be matched. 180 | #[[bans.features]] 181 | #crate = "reqwest" 182 | # Features to not allow 183 | #deny = ["json"] 184 | # Features to allow 185 | #allow = [ 186 | # "rustls", 187 | # "__rustls", 188 | # "__tls", 189 | # "hyper-rustls", 190 | # "rustls", 191 | # "rustls-pemfile", 192 | # "rustls-tls-webpki-roots", 193 | # "tokio-rustls", 194 | # "webpki-roots", 195 | #] 196 | # If true, the allowed features must exactly match the enabled feature set. If 197 | # this is set there is no point setting `deny` 198 | #exact = true 199 | 200 | # Certain crates/versions that will be skipped when doing duplicate detection. 201 | skip = [ 202 | #"ansi_term@0.11.0", 203 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" }, 204 | ] 205 | # Similarly to `skip` allows you to skip certain crates during duplicate 206 | # detection. Unlike skip, it also includes the entire tree of transitive 207 | # dependencies starting at the specified crate, up to a certain depth, which is 208 | # by default infinite. 209 | skip-tree = [ 210 | #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies 211 | #{ crate = "ansi_term@0.11.0", depth = 20 }, 212 | ] 213 | 214 | # This section is considered when running `cargo deny check sources`. 215 | # More documentation about the 'sources' section can be found here: 216 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 217 | [sources] 218 | # Lint level for what to happen when a crate from a crate registry that is not 219 | # in the allow list is encountered 220 | unknown-registry = "deny" 221 | # Lint level for what to happen when a crate from a git repository that is not 222 | # in the allow list is encountered 223 | unknown-git = "deny" 224 | # List of URLs for allowed crate registries. Defaults to the crates.io index 225 | # if not specified. If it is specified but empty, no registries are allowed. 226 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 227 | # List of URLs for allowed Git repositories 228 | allow-git = [] 229 | 230 | [sources.allow-org] 231 | # github.com organizations to allow git sources for 232 | github = [] 233 | # gitlab.com organizations to allow git sources for 234 | gitlab = [] 235 | # bitbucket.org organizations to allow git sources for 236 | bitbucket = [] 237 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.85" 3 | components = ["rustfmt", "clippy"] 4 | -------------------------------------------------------------------------------- /tui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "glues" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | description = "Vim-inspired TUI note-taking app with Git, MongoDB, CSV, and JSON support - privacy-focused and sync-enabled" 9 | readme = "../README.md" 10 | keywords = ["tui", "note-taking", "vim", "ratatui", "data-privacy"] 11 | default-run = "glues" 12 | 13 | [dependencies] 14 | glues-core.workspace = true 15 | gluesql.workspace = true 16 | async-recursion.workspace = true 17 | ratatui = "0.29.0" 18 | color-eyre = "0.6.3" 19 | tui-big-text = "0.7.0" 20 | tui-textarea = "0.7.0" 21 | home = "0.5.9" 22 | tokio = { version = "1.41.0", features = ["macros", "rt-multi-thread"] } 23 | arboard = "3.4.1" 24 | -------------------------------------------------------------------------------- /tui/src/color.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Color; 2 | 3 | // Primary 4 | pub const RED: Color = Color::Indexed(124); 5 | pub const GREEN: Color = Color::Indexed(77); 6 | pub const BLUE: Color = Color::Indexed(27); 7 | pub const YELLOW: Color = Color::Indexed(220); 8 | pub const MAGENTA: Color = Color::Indexed(128); 9 | pub const SKY_BLUE: Color = Color::Indexed(32); 10 | 11 | // Neutral 12 | pub const GRAY_BLACK: Color = Color::Indexed(234); 13 | pub const GRAY_DARK: Color = Color::Indexed(236); 14 | pub const GRAY_DIM: Color = Color::Indexed(240); 15 | pub const GRAY_MEDIUM: Color = Color::Indexed(243); 16 | pub const GRAY_LIGHT: Color = Color::Indexed(250); 17 | pub const GRAY_WHITE: Color = Color::Indexed(253); 18 | pub const WHITE: Color = Color::White; 19 | pub const BLACK: Color = Color::Black; 20 | 21 | pub const GRAY_A: Color = Color::Indexed(246); 22 | pub const GRAY_B: Color = Color::Indexed(248); 23 | -------------------------------------------------------------------------------- /tui/src/config.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::logger::*, 3 | gluesql::{ 4 | core::ast_builder::{Execute, col, table, text}, 5 | prelude::{CsvStorage, Glue}, 6 | }, 7 | home::home_dir, 8 | std::ops::Deref, 9 | }; 10 | 11 | pub const LAST_CSV_PATH: &str = "last_csv_path"; 12 | pub const LAST_JSON_PATH: &str = "last_json_path"; 13 | pub const LAST_FILE_PATH: &str = "last_file_path"; 14 | pub const LAST_GIT_PATH: &str = "last_git_path"; 15 | pub const LAST_GIT_REMOTE: &str = "last_git_remote"; 16 | pub const LAST_GIT_BRANCH: &str = "last_git_branch"; 17 | pub const LAST_MONGO_CONN_STR: &str = "last_mongo_conn_str"; 18 | pub const LAST_MONGO_DB_NAME: &str = "last_mongo_db_name"; 19 | 20 | const PATH: &str = ".glues/"; 21 | 22 | pub fn get_glue() -> Glue { 23 | let path = home_dir() 24 | .unwrap_or(std::env::current_dir().expect("failed to get current directory")) 25 | .join(PATH); 26 | let storage = CsvStorage::new(path).unwrap(); 27 | 28 | Glue::new(storage) 29 | } 30 | 31 | pub async fn init() { 32 | let mut glue = get_glue(); 33 | 34 | table("config") 35 | .create_table_if_not_exists() 36 | .add_column("key TEXT PRIMARY KEY") 37 | .add_column("value TEXT NOT NULL") 38 | .execute(&mut glue) 39 | .await 40 | .unwrap(); 41 | 42 | for (key, value) in [ 43 | (LAST_CSV_PATH, ""), 44 | (LAST_JSON_PATH, ""), 45 | (LAST_FILE_PATH, ""), 46 | (LAST_GIT_PATH, ""), 47 | (LAST_GIT_REMOTE, "origin"), 48 | (LAST_GIT_BRANCH, "main"), 49 | (LAST_MONGO_CONN_STR, ""), 50 | (LAST_MONGO_DB_NAME, ""), 51 | ] { 52 | let _ = table("config") 53 | .insert() 54 | .columns(vec!["key", "value"]) 55 | .values(vec![vec![text(key), text(value)]]) 56 | .execute(&mut glue) 57 | .await; 58 | } 59 | } 60 | 61 | pub async fn update(key: &str, value: &str) { 62 | let mut glue = get_glue(); 63 | 64 | table("config") 65 | .update() 66 | .filter(col("key").eq(text(key))) 67 | .set("value", text(value)) 68 | .execute(&mut glue) 69 | .await 70 | .unwrap(); 71 | } 72 | 73 | pub async fn get(key: &str) -> Option { 74 | let mut glue = get_glue(); 75 | 76 | let value = table("config") 77 | .select() 78 | .filter(col("key").eq(text(key))) 79 | .project(col("value")) 80 | .execute(&mut glue) 81 | .await 82 | .log_unwrap() 83 | .select() 84 | .log_expect("payload is not from select query") 85 | .next()? 86 | .get("value") 87 | .map(Deref::deref) 88 | .log_expect("value does not exist in row") 89 | .into(); 90 | 91 | Some(value) 92 | } 93 | -------------------------------------------------------------------------------- /tui/src/context.rs: -------------------------------------------------------------------------------- 1 | pub mod entry; 2 | pub mod notebook; 3 | 4 | use { 5 | crate::{Action, log, logger::*}, 6 | glues_core::transition::VimKeymapKind, 7 | ratatui::{ 8 | crossterm::event::{Event as Input, KeyCode, KeyEvent}, 9 | style::{Style, Stylize}, 10 | text::Line, 11 | widgets::{Block, Borders}, 12 | }, 13 | std::time::SystemTime, 14 | tui_textarea::TextArea, 15 | }; 16 | pub use {entry::EntryContext, notebook::NotebookContext}; 17 | 18 | pub enum ContextState { 19 | Entry, 20 | Notebook, 21 | } 22 | 23 | pub struct ContextPrompt { 24 | pub widget: TextArea<'static>, 25 | pub message: Vec>, 26 | pub action: Action, 27 | } 28 | 29 | impl ContextPrompt { 30 | pub fn new(message: Vec>, action: Action, default: Option) -> Self { 31 | let mut widget = TextArea::new(vec![default.unwrap_or_default()]); 32 | widget.set_cursor_style(Style::default().white().on_blue()); 33 | widget.set_block( 34 | Block::default() 35 | .border_style(Style::default()) 36 | .borders(Borders::ALL), 37 | ); 38 | Self { 39 | widget, 40 | message, 41 | action, 42 | } 43 | } 44 | } 45 | 46 | pub struct Context { 47 | pub entry: EntryContext, 48 | pub notebook: NotebookContext, 49 | 50 | pub state: ContextState, 51 | 52 | pub confirm: Option<(String, Action)>, 53 | pub alert: Option, 54 | pub prompt: Option, 55 | pub last_log: Option<(String, SystemTime)>, 56 | 57 | pub help: bool, 58 | pub editor_keymap: bool, 59 | pub vim_keymap: Option, 60 | 61 | pub keymap: bool, 62 | } 63 | 64 | impl Default for Context { 65 | fn default() -> Self { 66 | Self { 67 | entry: EntryContext::default(), 68 | notebook: NotebookContext::default(), 69 | 70 | state: ContextState::Entry, 71 | confirm: None, 72 | alert: None, 73 | prompt: None, 74 | last_log: None, 75 | 76 | help: false, 77 | editor_keymap: false, 78 | vim_keymap: None, 79 | 80 | keymap: false, 81 | } 82 | } 83 | } 84 | 85 | impl Context { 86 | pub fn take_prompt_input(&mut self) -> Option { 87 | self.prompt 88 | .take()? 89 | .widget 90 | .lines() 91 | .first() 92 | .map(ToOwned::to_owned) 93 | } 94 | 95 | pub async fn consume(&mut self, input: &Input) -> Action { 96 | if self.vim_keymap.is_some() { 97 | self.vim_keymap = None; 98 | return Action::None; 99 | } else if self.editor_keymap { 100 | self.editor_keymap = false; 101 | return Action::None; 102 | } else if self.help { 103 | self.help = false; 104 | return Action::None; 105 | } else if self.alert.is_some() { 106 | // any key pressed will close the alert 107 | self.alert = None; 108 | return Action::None; 109 | } else if self.confirm.is_some() { 110 | let code = match input { 111 | Input::Key(key) => key.code, 112 | _ => return Action::None, 113 | }; 114 | 115 | match code { 116 | KeyCode::Char('y') => { 117 | let (_, action) = self.confirm.take().log_expect("confirm must be some"); 118 | log!("Context::consume - remove note!!!"); 119 | return action; 120 | } 121 | KeyCode::Char('n') => { 122 | self.confirm = None; 123 | return Action::None; 124 | } 125 | _ => return Action::None, 126 | } 127 | } else if let Some(prompt) = self.prompt.as_ref() { 128 | match input { 129 | Input::Key(KeyEvent { 130 | code: KeyCode::Enter, 131 | .. 132 | }) => { 133 | return prompt.action.clone(); 134 | } 135 | Input::Key(KeyEvent { 136 | code: KeyCode::Esc, .. 137 | }) => { 138 | self.prompt = None; 139 | return Action::None; 140 | } 141 | _ => { 142 | self.prompt 143 | .as_mut() 144 | .log_expect("prompt must be some") 145 | .widget 146 | .input(input.clone()); 147 | 148 | return Action::None; 149 | } 150 | } 151 | } 152 | 153 | match self.state { 154 | ContextState::Entry => match input { 155 | Input::Key(key) => self.entry.consume(key.code).await, 156 | _ => Action::None, 157 | }, 158 | ContextState::Notebook => self.notebook.consume(input), 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /tui/src/context/entry.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | action::{Action, OpenGitStep, OpenMongoStep, TuiAction}, 4 | color::*, 5 | config::{ 6 | self, LAST_CSV_PATH, LAST_FILE_PATH, LAST_GIT_PATH, LAST_JSON_PATH, LAST_MONGO_CONN_STR, 7 | }, 8 | logger::*, 9 | }, 10 | glues_core::EntryEvent, 11 | ratatui::{crossterm::event::KeyCode, style::Stylize, text::Line, widgets::ListState}, 12 | }; 13 | 14 | pub const INSTANT: &str = "[1] Instant"; 15 | pub const FILE: &str = "[2] Local"; 16 | pub const GIT: &str = "[3] Git"; 17 | pub const MONGO: &str = "[4] MongoDB"; 18 | pub const CSV: &str = "[5] CSV"; 19 | pub const JSON: &str = "[6] JSON"; 20 | pub const HELP: &str = "[h] Help"; 21 | pub const QUIT: &str = "[q] Quit"; 22 | 23 | pub const MENU_ITEMS: [&str; 8] = [INSTANT, FILE, GIT, MONGO, CSV, JSON, HELP, QUIT]; 24 | 25 | pub struct EntryContext { 26 | pub list_state: ListState, 27 | } 28 | 29 | impl Default for EntryContext { 30 | fn default() -> Self { 31 | Self { 32 | list_state: ListState::default().with_selected(Some(0)), 33 | } 34 | } 35 | } 36 | 37 | impl EntryContext { 38 | pub async fn consume(&mut self, code: KeyCode) -> Action { 39 | let open = |key, action: TuiAction| async move { 40 | TuiAction::Prompt { 41 | message: vec![ 42 | Line::raw("Enter the path:"), 43 | Line::from("If path not exists, it will be created.".fg(GRAY_MEDIUM)), 44 | ], 45 | action: Box::new(action.into()), 46 | default: config::get(key).await, 47 | } 48 | .into() 49 | }; 50 | 51 | let open_git = || async move { 52 | TuiAction::Prompt { 53 | message: vec![ 54 | Line::raw("Enter the git repository path:"), 55 | Line::from( 56 | "The path must contain an existing .git repository.".fg(GRAY_MEDIUM), 57 | ), 58 | Line::from("otherwise, an error will occur.".fg(GRAY_MEDIUM)), 59 | ], 60 | action: Box::new(TuiAction::OpenGit(OpenGitStep::Path).into()), 61 | default: config::get(LAST_GIT_PATH).await, 62 | } 63 | .into() 64 | }; 65 | 66 | let open_mongo = || async move { 67 | TuiAction::Prompt { 68 | message: vec![ 69 | Line::raw("Enter the MongoDB connection string:"), 70 | Line::from("e.g. mongodb://localhost:27017".fg(GRAY_MEDIUM)), 71 | ], 72 | action: Box::new(TuiAction::OpenMongo(OpenMongoStep::ConnStr).into()), 73 | default: config::get(LAST_MONGO_CONN_STR).await, 74 | } 75 | .into() 76 | }; 77 | 78 | match code { 79 | KeyCode::Char('q') => TuiAction::Quit.into(), 80 | KeyCode::Char('j') | KeyCode::Down => { 81 | self.list_state.select_next(); 82 | Action::None 83 | } 84 | KeyCode::Char('k') | KeyCode::Up => { 85 | self.list_state.select_previous(); 86 | Action::None 87 | } 88 | KeyCode::Char('1') => EntryEvent::OpenMemory.into(), 89 | KeyCode::Char('2') => open(LAST_FILE_PATH, TuiAction::OpenFile).await, 90 | KeyCode::Char('3') => open_mongo().await, 91 | KeyCode::Char('4') => open_git().await, 92 | KeyCode::Char('5') => open(LAST_CSV_PATH, TuiAction::OpenCsv).await, 93 | KeyCode::Char('6') => open(LAST_JSON_PATH, TuiAction::OpenJson).await, 94 | KeyCode::Char('a') => TuiAction::Help.into(), 95 | 96 | KeyCode::Enter => { 97 | let i = self 98 | .list_state 99 | .selected() 100 | .log_expect("EntryContext::consume: selected is None. This should not happen."); 101 | match MENU_ITEMS[i] { 102 | INSTANT => EntryEvent::OpenMemory.into(), 103 | FILE => open(LAST_FILE_PATH, TuiAction::OpenFile).await, 104 | GIT => open_git().await, 105 | MONGO => open_mongo().await, 106 | CSV => open(LAST_CSV_PATH, TuiAction::OpenCsv).await, 107 | JSON => open(LAST_JSON_PATH, TuiAction::OpenJson).await, 108 | HELP => TuiAction::Help.into(), 109 | QUIT => TuiAction::Quit.into(), 110 | _ => Action::None, 111 | } 112 | } 113 | _ => Action::PassThrough, 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /tui/src/context/notebook/tree_item.rs: -------------------------------------------------------------------------------- 1 | use glues_core::{ 2 | data::{Directory, Note}, 3 | types::Id, 4 | }; 5 | 6 | #[derive(Clone)] 7 | pub struct TreeItem { 8 | pub depth: usize, 9 | pub target: bool, 10 | pub selectable: bool, 11 | pub kind: TreeItemKind, 12 | } 13 | 14 | #[derive(Clone)] 15 | pub enum TreeItemKind { 16 | Note { note: Note }, 17 | Directory { directory: Directory, opened: bool }, 18 | } 19 | 20 | impl TreeItem { 21 | pub fn is_directory(&self) -> bool { 22 | matches!(self.kind, TreeItemKind::Directory { .. }) 23 | } 24 | 25 | pub fn id(&self) -> &Id { 26 | match &self.kind { 27 | TreeItemKind::Note { note, .. } => ¬e.id, 28 | TreeItemKind::Directory { directory, .. } => &directory.id, 29 | } 30 | } 31 | 32 | pub fn name(&self) -> String { 33 | match &self.kind { 34 | TreeItemKind::Note { note, .. } => ¬e.name, 35 | TreeItemKind::Directory { directory, .. } => &directory.name, 36 | } 37 | .clone() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tui/src/logger.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::config::get_glue, 3 | gluesql::core::ast_builder::{Execute, table, text}, 4 | std::fmt::{Debug, Display}, 5 | }; 6 | 7 | pub async fn init() { 8 | let mut glue = get_glue(); 9 | 10 | table("logs") 11 | .drop_table_if_exists() 12 | .execute(&mut glue) 13 | .await 14 | .unwrap(); 15 | 16 | table("logs") 17 | .create_table_if_not_exists() 18 | .add_column("timestamp TIMESTAMP DEFAULT NOW()") 19 | .add_column("message TEXT") 20 | .execute(&mut glue) 21 | .await 22 | .unwrap(); 23 | } 24 | 25 | pub async fn log(message: &str) { 26 | let mut glue = get_glue(); 27 | 28 | table("logs") 29 | .insert() 30 | .columns("message") 31 | .values(vec![vec![text(message)]]) 32 | .execute(&mut glue) 33 | .await 34 | .unwrap(); 35 | } 36 | 37 | #[macro_export] 38 | macro_rules! log { 39 | ($($arg:tt)*) => { 40 | log(&format!($($arg)*)).await; 41 | }; 42 | } 43 | 44 | pub trait LogExpectExt { 45 | fn log_expect(self, message: &str) -> V; 46 | } 47 | 48 | impl LogExpectExt for Option { 49 | fn log_expect(self, message: &str) -> V { 50 | if let Some(v) = self { 51 | v 52 | } else { 53 | panic!("{message}"); 54 | } 55 | } 56 | } 57 | 58 | #[allow(dead_code)] 59 | pub trait LogUnwrapExt { 60 | fn log_unwrap(self) -> V; 61 | } 62 | 63 | impl LogUnwrapExt for Result 64 | where 65 | E: Debug + Display, 66 | { 67 | fn log_unwrap(self) -> V { 68 | match self { 69 | Ok(v) => v, 70 | Err(e) => { 71 | panic!("{e}"); 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tui/src/main.rs: -------------------------------------------------------------------------------- 1 | mod action; 2 | pub mod context; 3 | #[macro_use] 4 | mod logger; 5 | mod color; 6 | mod config; 7 | mod transitions; 8 | mod views; 9 | 10 | use { 11 | action::Action, 12 | color_eyre::Result, 13 | context::Context, 14 | glues_core::Glues, 15 | logger::*, 16 | ratatui::{ 17 | DefaultTerminal, Frame, 18 | crossterm::{ 19 | self, 20 | event::{Event as Input, KeyCode, KeyEvent as CKeyEvent, KeyEventKind, KeyModifiers}, 21 | }, 22 | layout::{ 23 | Constraint::{Length, Percentage}, 24 | Layout, 25 | }, 26 | }, 27 | std::time::Duration, 28 | }; 29 | 30 | #[tokio::main] 31 | async fn main() -> Result<()> { 32 | config::init().await; 33 | logger::init().await; 34 | color_eyre::install()?; 35 | 36 | log!("Hello"); 37 | 38 | let terminal = ratatui::init(); 39 | let app_result = App::new().await.run(terminal).await; 40 | ratatui::restore(); 41 | app_result 42 | } 43 | 44 | struct App { 45 | glues: Glues, 46 | context: Context, 47 | } 48 | 49 | impl App { 50 | async fn new() -> Self { 51 | let glues = Glues::new().await; 52 | let context = Context::default(); 53 | 54 | Self { glues, context } 55 | } 56 | 57 | async fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { 58 | loop { 59 | if let Some((_, created_at)) = self.context.last_log { 60 | if created_at.elapsed().log_unwrap().as_secs() > 5 { 61 | self.context.last_log = None; 62 | } 63 | } 64 | 65 | terminal.draw(|frame| self.draw(frame))?; 66 | 67 | if !crossterm::event::poll(Duration::from_millis(1500))? { 68 | let mut transitions = Vec::new(); 69 | { 70 | let mut queue = self.glues.transition_queue.lock().log_unwrap(); 71 | 72 | while let Some(transition) = queue.pop_front() { 73 | transitions.push(transition); 74 | } 75 | } 76 | 77 | for transition in transitions { 78 | self.handle_transition(transition).await; 79 | } 80 | 81 | self.save().await; 82 | continue; 83 | } 84 | 85 | let input = crossterm::event::read()?; 86 | if !matches!( 87 | input, 88 | Input::Key(CKeyEvent { 89 | kind: KeyEventKind::Press, 90 | .. 91 | }) 92 | ) { 93 | continue; 94 | } 95 | 96 | match input { 97 | Input::Key(CKeyEvent { 98 | code: KeyCode::Char('c'), 99 | modifiers: KeyModifiers::CONTROL, 100 | .. 101 | }) => { 102 | self.save().await; 103 | return Ok(()); 104 | } 105 | _ => { 106 | let action = self.context.consume(&input).await; 107 | let quit = self.handle_action(action, input).await; 108 | if quit { 109 | return Ok(()); 110 | } 111 | } 112 | } 113 | } 114 | } 115 | 116 | fn draw(&mut self, frame: &mut Frame) { 117 | let state = &self.glues.state; 118 | let context = &mut self.context; 119 | let vertical = Layout::vertical([Length(1), Percentage(100)]); 120 | let [statusbar, body] = vertical.areas(frame.area()); 121 | 122 | views::statusbar::draw(frame, statusbar, state, &context.notebook); 123 | views::body::draw(frame, body, context); 124 | views::dialog::draw(frame, state, context); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tui/src/transitions.rs: -------------------------------------------------------------------------------- 1 | mod entry; 2 | mod keymap; 3 | mod notebook; 4 | 5 | use { 6 | super::{App, logger::*}, 7 | async_recursion::async_recursion, 8 | glues_core::transition::Transition, 9 | std::time::SystemTime, 10 | }; 11 | 12 | impl App { 13 | #[async_recursion(?Send)] 14 | pub(super) async fn handle_transition(&mut self, transition: Transition) { 15 | match transition { 16 | Transition::Keymap(transition) => { 17 | self.handle_keymap_transition(transition).await; 18 | } 19 | Transition::Entry(transition) => { 20 | self.handle_entry_transition(transition).await; 21 | } 22 | Transition::Notebook(transition) => { 23 | self.handle_notebook_transition(transition).await; 24 | } 25 | Transition::Log(message) => { 26 | log!("{message}"); 27 | self.context.last_log = Some((message, SystemTime::now())); 28 | } 29 | Transition::Error(message) => { 30 | log!("[Err] {message}"); 31 | self.context.alert = Some(message); 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tui/src/transitions/entry.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{App, context::ContextState, logger::*}, 3 | glues_core::{ 4 | state::{GetInner, NotebookState}, 5 | transition::EntryTransition, 6 | }, 7 | }; 8 | 9 | impl App { 10 | pub(super) async fn handle_entry_transition(&mut self, transition: EntryTransition) { 11 | match transition { 12 | EntryTransition::OpenNotebook => { 13 | log!("Opening notebook"); 14 | 15 | let NotebookState { root, .. } = self.glues.state.get_inner().log_unwrap(); 16 | self.context.state = ContextState::Notebook; 17 | self.context.notebook.update_items(root); 18 | } 19 | EntryTransition::Inedible(event) => { 20 | log!("Inedible event: {event}"); 21 | } 22 | EntryTransition::None => {} 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tui/src/transitions/keymap.rs: -------------------------------------------------------------------------------- 1 | use {crate::App, glues_core::transition::KeymapTransition}; 2 | 3 | impl App { 4 | pub(super) async fn handle_keymap_transition(&mut self, transition: KeymapTransition) { 5 | match transition { 6 | KeymapTransition::Show => { 7 | self.context.keymap = true; 8 | } 9 | KeymapTransition::Hide => { 10 | self.context.keymap = false; 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tui/src/transitions/notebook.rs: -------------------------------------------------------------------------------- 1 | mod editing_normal_mode; 2 | mod editing_visual_mode; 3 | mod note_tree; 4 | mod textarea; 5 | 6 | use { 7 | crate::{ 8 | App, 9 | context::{self}, 10 | logger::*, 11 | }, 12 | glues_core::{ 13 | NotebookEvent, 14 | state::{ 15 | GetInner, NotebookState, 16 | notebook::{InnerState, NoteTreeState, VimNormalState}, 17 | }, 18 | transition::NotebookTransition, 19 | }, 20 | }; 21 | 22 | impl App { 23 | pub(super) async fn handle_notebook_transition(&mut self, transition: NotebookTransition) { 24 | use context::notebook::ContextState; 25 | 26 | let NotebookState { 27 | root, 28 | inner_state, 29 | tab_index, 30 | .. 31 | } = self.glues.state.get_inner().log_unwrap(); 32 | let new_state = match inner_state { 33 | InnerState::NoteTree( 34 | NoteTreeState::NoteSelected | NoteTreeState::DirectorySelected, 35 | ) => ContextState::NoteTreeBrowsing, 36 | InnerState::NoteTree(NoteTreeState::Numbering(_)) => ContextState::NoteTreeNumbering, 37 | InnerState::NoteTree(NoteTreeState::GatewayMode) => ContextState::NoteTreeGateway, 38 | InnerState::NoteTree(NoteTreeState::NoteMoreActions) => ContextState::NoteActionsDialog, 39 | InnerState::NoteTree(NoteTreeState::DirectoryMoreActions) => { 40 | ContextState::DirectoryActionsDialog 41 | } 42 | InnerState::NoteTree(NoteTreeState::MoveMode) => ContextState::MoveMode, 43 | InnerState::EditingNormalMode(VimNormalState::Idle) => { 44 | ContextState::EditorNormalMode { idle: true } 45 | } 46 | InnerState::EditingNormalMode(_) => ContextState::EditorNormalMode { idle: false }, 47 | InnerState::EditingVisualMode(_) => ContextState::EditorVisualMode, 48 | InnerState::EditingInsertMode => ContextState::EditorInsertMode, 49 | }; 50 | 51 | if self.context.notebook.state != new_state { 52 | self.context.notebook.state = new_state; 53 | } 54 | 55 | if &self.context.notebook.tab_index != tab_index { 56 | self.context.notebook.tab_index = *tab_index; 57 | } 58 | 59 | match transition { 60 | NotebookTransition::ShowVimKeymap(kind) => { 61 | self.context.vim_keymap = Some(kind); 62 | } 63 | NotebookTransition::ViewMode(_note) => { 64 | self.context.notebook.mark_dirty(); 65 | } 66 | NotebookTransition::UpdateNoteContent(note_id) => { 67 | self.context.notebook.mark_clean(¬e_id); 68 | } 69 | NotebookTransition::BrowseNoteTree => {} 70 | NotebookTransition::FocusEditor => { 71 | let note_id = self 72 | .context 73 | .notebook 74 | .get_opened_note() 75 | .log_expect("No note opened") 76 | .id 77 | .clone(); 78 | 79 | self.context.notebook.update_items(root); 80 | self.context.notebook.select_item(¬e_id); 81 | } 82 | NotebookTransition::NoteTree(transition) => { 83 | self.handle_note_tree_transition(transition).await; 84 | } 85 | NotebookTransition::EditingNormalMode(transition) => { 86 | self.handle_normal_mode_transition(transition).await; 87 | } 88 | NotebookTransition::EditingVisualMode(transition) => { 89 | self.handle_visual_mode_transition(transition).await; 90 | } 91 | NotebookTransition::Alert(message) => { 92 | log!("[Alert] {message}"); 93 | self.context.alert = Some(message); 94 | } 95 | NotebookTransition::Inedible(_) | NotebookTransition::None => {} 96 | } 97 | } 98 | 99 | pub(crate) async fn save(&mut self) { 100 | let mut transitions = vec![]; 101 | 102 | for (note_id, item) in self.context.notebook.editors.iter() { 103 | if !item.dirty { 104 | continue; 105 | } 106 | 107 | let event = NotebookEvent::UpdateNoteContent { 108 | note_id: note_id.clone(), 109 | content: item.editor.lines().join("\n"), 110 | } 111 | .into(); 112 | 113 | let transition = self.glues.dispatch(event).await.log_unwrap(); 114 | transitions.push(transition); 115 | } 116 | 117 | for transition in transitions { 118 | self.handle_transition(transition).await; 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /tui/src/transitions/notebook/editing_visual_mode.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::textarea::*, crate::App, glues_core::transition::VisualModeTransition, 3 | tui_textarea::CursorMove, 4 | }; 5 | 6 | impl App { 7 | pub(super) async fn handle_visual_mode_transition(&mut self, transition: VisualModeTransition) { 8 | use VisualModeTransition::*; 9 | 10 | match transition { 11 | IdleMode => { 12 | self.context.notebook.get_editor_mut().start_selection(); 13 | } 14 | NumberingMode | GatewayMode => {} 15 | MoveCursorDown(n) => { 16 | let editor = self.context.notebook.get_editor_mut(); 17 | let cursor_move = cursor_move_down(editor, n); 18 | 19 | editor.move_cursor(cursor_move); 20 | } 21 | MoveCursorUp(n) => { 22 | let editor = self.context.notebook.get_editor_mut(); 23 | let cursor_move = cursor_move_up(editor, n); 24 | 25 | editor.move_cursor(cursor_move); 26 | } 27 | MoveCursorBack(n) => { 28 | let editor = self.context.notebook.get_editor_mut(); 29 | let (row, col) = editor.cursor(); 30 | let cursor_move = if col < n { 31 | CursorMove::Head 32 | } else { 33 | CursorMove::Jump(row as u16, (col - n) as u16) 34 | }; 35 | 36 | editor.move_cursor(cursor_move); 37 | } 38 | MoveCursorForward(n) => { 39 | let editor = self.context.notebook.get_editor_mut(); 40 | let cursor_move = cursor_move_forward(editor, n); 41 | 42 | editor.move_cursor(cursor_move); 43 | } 44 | MoveCursorWordForward(n) => { 45 | let editor = self.context.notebook.get_editor_mut(); 46 | 47 | for _ in 0..n { 48 | editor.move_cursor(CursorMove::WordForward); 49 | } 50 | } 51 | MoveCursorWordEnd(n) => { 52 | let editor = self.context.notebook.get_editor_mut(); 53 | 54 | for _ in 0..n { 55 | editor.move_cursor(CursorMove::WordEnd); 56 | } 57 | } 58 | MoveCursorWordBack(n) => { 59 | let editor = self.context.notebook.get_editor_mut(); 60 | 61 | for _ in 0..n { 62 | editor.move_cursor(CursorMove::WordBack); 63 | } 64 | } 65 | MoveCursorLineStart => { 66 | self.context 67 | .notebook 68 | .get_editor_mut() 69 | .move_cursor(CursorMove::Head); 70 | } 71 | MoveCursorLineEnd => { 72 | self.context 73 | .notebook 74 | .get_editor_mut() 75 | .move_cursor(CursorMove::End); 76 | } 77 | MoveCursorLineNonEmptyStart => { 78 | move_cursor_to_line_non_empty_start(self.context.notebook.get_editor_mut()); 79 | } 80 | MoveCursorBottom => { 81 | self.context 82 | .notebook 83 | .get_editor_mut() 84 | .move_cursor(CursorMove::Bottom); 85 | } 86 | MoveCursorTop => { 87 | self.context 88 | .notebook 89 | .get_editor_mut() 90 | .move_cursor(CursorMove::Top); 91 | } 92 | MoveCursorToLine(n) => { 93 | let editor = self.context.notebook.get_editor_mut(); 94 | editor.move_cursor(CursorMove::Jump((n - 1) as u16, 0)); 95 | editor.move_cursor(CursorMove::WordForward); 96 | } 97 | YankSelection => { 98 | let editor = self.context.notebook.get_editor_mut(); 99 | reselect_for_yank(editor); 100 | editor.copy(); 101 | self.context.notebook.line_yanked = false; 102 | self.context.notebook.update_yank(); 103 | } 104 | DeleteSelection => { 105 | let editor = self.context.notebook.get_editor_mut(); 106 | reselect_for_yank(editor); 107 | editor.cut(); 108 | self.context.notebook.line_yanked = false; 109 | self.context.notebook.mark_dirty(); 110 | self.context.notebook.update_yank(); 111 | } 112 | DeleteSelectionAndInsertMode => { 113 | let editor = self.context.notebook.get_editor_mut(); 114 | reselect_for_yank(editor); 115 | editor.cut(); 116 | self.context.notebook.line_yanked = false; 117 | self.context.notebook.mark_dirty(); 118 | self.context.notebook.update_yank(); 119 | } 120 | SwitchCase => { 121 | let editor = self.context.notebook.get_editor_mut(); 122 | switch_case(editor); 123 | 124 | self.context.notebook.mark_dirty(); 125 | } 126 | ToLowercase => { 127 | let editor = self.context.notebook.get_editor_mut(); 128 | let yank = editor.yank_text(); 129 | reselect_for_yank(editor); 130 | editor.cut(); 131 | 132 | let changed = editor.yank_text().as_str().to_lowercase(); 133 | 134 | editor.insert_str(changed); 135 | editor.set_yank_text(yank); 136 | } 137 | ToUppercase => { 138 | let editor = self.context.notebook.get_editor_mut(); 139 | let yank = editor.yank_text(); 140 | reselect_for_yank(editor); 141 | editor.cut(); 142 | 143 | let changed = editor.yank_text().as_str().to_uppercase(); 144 | 145 | editor.insert_str(changed); 146 | editor.set_yank_text(yank); 147 | } 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /tui/src/transitions/notebook/note_tree.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | App, 4 | context::notebook::{TreeItem, TreeItemKind}, 5 | logger::*, 6 | }, 7 | glues_core::{ 8 | Event, NotebookEvent, 9 | data::{Directory, Note}, 10 | state::{GetInner, NotebookState}, 11 | transition::{MoveModeTransition, NoteTreeTransition}, 12 | }, 13 | }; 14 | 15 | impl App { 16 | pub(super) async fn handle_note_tree_transition(&mut self, transition: NoteTreeTransition) { 17 | let NotebookState { root, tabs, .. } = self.glues.state.get_inner().log_unwrap(); 18 | 19 | match transition { 20 | NoteTreeTransition::OpenDirectory { id, .. } => { 21 | log!("Opening directory {id}"); 22 | self.context.notebook.update_items(root); 23 | } 24 | NoteTreeTransition::CloseDirectory(id) => { 25 | log!("Closing directory {id}"); 26 | self.context.notebook.update_items(root); 27 | self.context.notebook.select_item(&id); 28 | } 29 | NoteTreeTransition::OpenNote { note, content, .. } => { 30 | self.context.notebook.open_note(note.id, content); 31 | self.context.notebook.tabs = tabs.clone(); 32 | self.context.notebook.apply_yank(); 33 | } 34 | NoteTreeTransition::RemoveNote { 35 | selected_directory, .. 36 | } 37 | | NoteTreeTransition::RemoveDirectory { 38 | selected_directory, .. 39 | } => { 40 | self.context.notebook.select_item(&selected_directory.id); 41 | self.context.notebook.update_items(root); 42 | } 43 | NoteTreeTransition::RenameDirectory(_) => { 44 | self.context.notebook.update_items(root); 45 | self.context.notebook.tabs = tabs.clone(); 46 | } 47 | NoteTreeTransition::RenameNote(_) => { 48 | self.context.notebook.update_items(root); 49 | self.context.notebook.tabs = tabs.clone(); 50 | } 51 | NoteTreeTransition::AddNote(Note { 52 | id, 53 | directory_id: parent_id, 54 | .. 55 | }) 56 | | NoteTreeTransition::AddDirectory(Directory { id, parent_id, .. }) => { 57 | self.glues 58 | .dispatch(NotebookEvent::OpenDirectory(parent_id.clone()).into()) 59 | .await 60 | .log_unwrap(); 61 | let NotebookState { root, .. } = self.glues.state.get_inner().log_unwrap(); 62 | 63 | self.context.notebook.update_items(root); 64 | self.context.notebook.select_item(&id); 65 | } 66 | NoteTreeTransition::MoveMode(transition) => { 67 | self.handle_move_mode_transition(transition).await; 68 | } 69 | NoteTreeTransition::SelectNext(n) => { 70 | self.context.notebook.select_next(n); 71 | 72 | let selected = self.context.notebook.selected(); 73 | let event = get_select_event(selected); 74 | self.glues.dispatch(event).await.log_unwrap(); 75 | } 76 | NoteTreeTransition::SelectPrev(n) => { 77 | self.context.notebook.select_prev(n); 78 | 79 | let selected = self.context.notebook.selected(); 80 | let event = get_select_event(selected); 81 | self.glues.dispatch(event).await.log_unwrap(); 82 | } 83 | NoteTreeTransition::SelectFirst => { 84 | self.context.notebook.select_first(); 85 | 86 | let selected = self.context.notebook.selected(); 87 | let event = get_select_event(selected); 88 | self.glues.dispatch(event).await.log_unwrap(); 89 | } 90 | NoteTreeTransition::SelectNextDirectory => { 91 | self.context.notebook.select_next_dir(); 92 | 93 | let selected = self.context.notebook.selected(); 94 | let event = get_select_event(selected); 95 | self.glues.dispatch(event).await.log_unwrap(); 96 | } 97 | NoteTreeTransition::SelectPrevDirectory => { 98 | self.context.notebook.select_prev_dir(); 99 | 100 | let selected = self.context.notebook.selected(); 101 | let event = get_select_event(selected); 102 | self.glues.dispatch(event).await.log_unwrap(); 103 | } 104 | NoteTreeTransition::SelectLast => { 105 | self.context.notebook.select_last(); 106 | 107 | let selected = self.context.notebook.selected(); 108 | let event = get_select_event(selected); 109 | self.glues.dispatch(event).await.log_unwrap(); 110 | } 111 | NoteTreeTransition::ExpandWidth(n) => { 112 | let n = n.try_into().unwrap_or_default(); 113 | let width = self.context.notebook.tree_width.saturating_add(n); 114 | 115 | self.context.notebook.tree_width = width; 116 | } 117 | NoteTreeTransition::ShrinkWidth(n) => { 118 | let n = n.try_into().unwrap_or_default(); 119 | let width = self.context.notebook.tree_width.saturating_sub(n); 120 | let width = if width < 11 { 11 } else { width }; 121 | 122 | self.context.notebook.tree_width = width; 123 | } 124 | NoteTreeTransition::GatewayMode 125 | | NoteTreeTransition::ShowNoteActionsDialog(_) 126 | | NoteTreeTransition::ShowDirectoryActionsDialog(_) => {} 127 | } 128 | 129 | fn get_select_event(selected: &TreeItem) -> Event { 130 | match selected { 131 | TreeItem { 132 | kind: TreeItemKind::Directory { directory, .. }, 133 | .. 134 | } => NotebookEvent::SelectDirectory(directory.clone()).into(), 135 | TreeItem { 136 | kind: TreeItemKind::Note { note }, 137 | .. 138 | } => NotebookEvent::SelectNote(note.clone()).into(), 139 | } 140 | } 141 | } 142 | 143 | async fn handle_move_mode_transition(&mut self, transition: MoveModeTransition) { 144 | use MoveModeTransition::*; 145 | 146 | match transition { 147 | Enter => { 148 | let state: &NotebookState = self.glues.state.get_inner().log_unwrap(); 149 | 150 | self.context.notebook.update_items(&state.root); 151 | self.context.notebook.select_prev(1); 152 | } 153 | SelectNext => { 154 | self.context.notebook.select_next(1); 155 | } 156 | SelectPrev => { 157 | self.context.notebook.select_prev(1); 158 | } 159 | SelectLast => { 160 | self.context.notebook.select_last(); 161 | } 162 | RequestCommit => { 163 | let is_directory = self 164 | .context 165 | .notebook 166 | .tree_items 167 | .iter() 168 | .find(|item| item.target) 169 | .log_expect("No target selected") 170 | .is_directory(); 171 | let event = match self.context.notebook.selected() { 172 | TreeItem { 173 | kind: TreeItemKind::Directory { directory, .. }, 174 | .. 175 | } => { 176 | if is_directory { 177 | NotebookEvent::MoveDirectory(directory.id.clone()).into() 178 | } else { 179 | NotebookEvent::MoveNote(directory.id.clone()).into() 180 | } 181 | } 182 | _ => { 183 | let message = format!( 184 | "Error - Cannot move {} to note", 185 | if is_directory { "directory" } else { "note" } 186 | ); 187 | log!("{message}"); 188 | self.context.alert = Some(message); 189 | 190 | return; 191 | } 192 | }; 193 | 194 | let transition = self.glues.dispatch(event).await.log_unwrap(); 195 | self.handle_transition(transition).await; 196 | } 197 | Commit => { 198 | let state: &NotebookState = self.glues.state.get_inner().log_unwrap(); 199 | let id = state.get_selected_id().log_unwrap(); 200 | 201 | self.context.notebook.update_items(&state.root); 202 | self.context.notebook.select_item(id); 203 | self.context.notebook.tabs = state.tabs.clone(); 204 | } 205 | Cancel => { 206 | let state: &NotebookState = self.glues.state.get_inner().log_unwrap(); 207 | let id = state.get_selected_id().log_unwrap(); 208 | 209 | self.context.notebook.update_items(&state.root); 210 | self.context.notebook.select_item(id); 211 | } 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /tui/src/transitions/notebook/textarea.rs: -------------------------------------------------------------------------------- 1 | use tui_textarea::{CursorMove, TextArea}; 2 | 3 | pub(super) fn cursor_move_forward(editor: &TextArea, n: usize) -> CursorMove { 4 | let (row, col) = editor.cursor(); 5 | if col + n >= editor.lines()[row].len() { 6 | CursorMove::End 7 | } else { 8 | CursorMove::Jump(row as u16, (col + n) as u16) 9 | } 10 | } 11 | 12 | pub(super) fn cursor_move_back(editor: &TextArea, n: usize) -> CursorMove { 13 | let (row, col) = editor.cursor(); 14 | if col < n { 15 | CursorMove::Head 16 | } else { 17 | CursorMove::Jump(row as u16, (col - n) as u16) 18 | } 19 | } 20 | 21 | pub(super) fn cursor_move_down(editor: &TextArea, n: usize) -> CursorMove { 22 | let num_lines = editor.lines().len(); 23 | let (row, col) = editor.cursor(); 24 | if row + n >= num_lines { 25 | CursorMove::Bottom 26 | } else { 27 | CursorMove::Jump((row + n) as u16, col as u16) 28 | } 29 | } 30 | 31 | pub(super) fn cursor_move_up(editor: &TextArea, n: usize) -> CursorMove { 32 | let (row, col) = editor.cursor(); 33 | if row < n { 34 | CursorMove::Top 35 | } else { 36 | CursorMove::Jump((row - n) as u16, col as u16) 37 | } 38 | } 39 | 40 | pub(super) fn move_cursor_to_line_non_empty_start(editor: &mut TextArea) { 41 | editor.move_cursor(CursorMove::Head); 42 | 43 | let (row, _) = editor.cursor(); 44 | let is_whitespace_at_first = editor.lines()[row] 45 | .chars() 46 | .next() 47 | .map(|c| c.is_whitespace()) 48 | .unwrap_or(false); 49 | if is_whitespace_at_first { 50 | editor.move_cursor(CursorMove::WordForward); 51 | } 52 | } 53 | 54 | pub(super) fn move_cursor_word_end(editor: &mut TextArea, n: usize) { 55 | for _ in 0..n { 56 | editor.move_cursor(CursorMove::WordEnd); 57 | } 58 | } 59 | 60 | pub(super) fn move_cursor_word_back(editor: &mut TextArea, n: usize) { 61 | for _ in 0..n { 62 | editor.move_cursor(CursorMove::WordBack); 63 | } 64 | } 65 | 66 | pub(super) fn reselect_for_yank(editor: &mut TextArea) { 67 | let (begin, end) = match editor.selection_range() { 68 | None => return, 69 | Some(range) => range, 70 | }; 71 | 72 | editor.cancel_selection(); 73 | editor.move_cursor(CursorMove::Jump(begin.0 as u16, begin.1 as u16)); 74 | editor.start_selection(); 75 | editor.move_cursor(CursorMove::Jump(end.0 as u16, end.1 as u16)); 76 | editor.move_cursor(CursorMove::Forward); 77 | } 78 | 79 | pub(super) fn switch_case(editor: &mut TextArea) { 80 | let yank = editor.yank_text(); 81 | reselect_for_yank(editor); 82 | editor.cut(); 83 | 84 | let changed = editor 85 | .yank_text() 86 | .chars() 87 | .map(|c| { 88 | if c.is_uppercase() { 89 | c.to_lowercase().to_string() 90 | } else { 91 | c.to_uppercase().to_string() 92 | } 93 | }) 94 | .collect::(); 95 | 96 | editor.insert_str(changed); 97 | editor.set_yank_text(yank); 98 | } 99 | -------------------------------------------------------------------------------- /tui/src/views.rs: -------------------------------------------------------------------------------- 1 | pub mod body; 2 | pub mod dialog; 3 | pub mod statusbar; 4 | -------------------------------------------------------------------------------- /tui/src/views/body.rs: -------------------------------------------------------------------------------- 1 | mod entry; 2 | mod notebook; 3 | 4 | use { 5 | crate::{Context, context::ContextState}, 6 | ratatui::{Frame, layout::Rect}, 7 | }; 8 | 9 | pub fn draw(frame: &mut Frame, area: Rect, context: &mut Context) { 10 | match context.state { 11 | ContextState::Entry => entry::draw(frame, area, &mut context.entry), 12 | ContextState::Notebook => notebook::draw(frame, area, context), 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tui/src/views/body/entry.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | color::*, 4 | context::{EntryContext, entry::MENU_ITEMS}, 5 | }, 6 | ratatui::{ 7 | Frame, 8 | layout::{Alignment, Constraint::Length, Flex, Layout, Rect}, 9 | style::{Style, Stylize}, 10 | widgets::{Block, HighlightSpacing, List, ListDirection, Padding}, 11 | }, 12 | tui_big_text::BigText, 13 | }; 14 | 15 | pub fn draw(frame: &mut Frame, area: Rect, context: &mut EntryContext) { 16 | let background = Block::default().bg(GRAY_BLACK); 17 | frame.render_widget(background, area); 18 | 19 | let [area] = Layout::horizontal([Length(38)]) 20 | .flex(Flex::Center) 21 | .areas(area); 22 | let [title_area, area] = Layout::vertical([Length(9), Length(12)]) 23 | .flex(Flex::Center) 24 | .areas(area); 25 | 26 | let title = BigText::builder() 27 | .lines(vec!["Glues".fg(YELLOW).into()]) 28 | .build(); 29 | let block = Block::bordered() 30 | .fg(WHITE) 31 | .padding(Padding::new(2, 2, 1, 1)) 32 | .title("Open Notes") 33 | .title_alignment(Alignment::Center); 34 | 35 | let items = MENU_ITEMS.into_iter().map(|name| { 36 | if name.ends_with("CSV") || name.ends_with("JSON") { 37 | name.fg(GRAY_DIM) 38 | } else { 39 | name.fg(GRAY_WHITE) 40 | } 41 | }); 42 | let list = List::new(items) 43 | .block(block) 44 | .highlight_style(Style::new().fg(WHITE).bg(BLUE)) 45 | .highlight_symbol(" ") 46 | .highlight_spacing(HighlightSpacing::Always) 47 | .direction(ListDirection::TopToBottom); 48 | 49 | frame.render_widget(title, title_area); 50 | frame.render_stateful_widget(list, area, &mut context.list_state); 51 | } 52 | -------------------------------------------------------------------------------- /tui/src/views/body/notebook.rs: -------------------------------------------------------------------------------- 1 | mod editor; 2 | mod note_tree; 3 | 4 | use { 5 | crate::context::Context, 6 | ratatui::{ 7 | Frame, 8 | layout::{ 9 | Constraint::{Length, Percentage}, 10 | Layout, Rect, 11 | }, 12 | }, 13 | }; 14 | 15 | pub fn draw(frame: &mut Frame, area: Rect, context: &mut Context) { 16 | if !context.notebook.show_browser { 17 | editor::draw(frame, area, context); 18 | 19 | return; 20 | } 21 | 22 | let horizontal = Layout::horizontal([Length(context.notebook.tree_width), Percentage(100)]); 23 | let [note_tree_area, editor_area] = horizontal.areas(area); 24 | 25 | note_tree::draw(frame, note_tree_area, &mut context.notebook); 26 | editor::draw(frame, editor_area, context); 27 | } 28 | -------------------------------------------------------------------------------- /tui/src/views/body/notebook/editor.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | color::*, 4 | context::{Context, notebook::ContextState}, 5 | }, 6 | ratatui::{ 7 | Frame, 8 | layout::Rect, 9 | style::{Style, Stylize}, 10 | text::{Line, Span}, 11 | widgets::{Block, Padding}, 12 | }, 13 | tui_textarea::TextArea, 14 | }; 15 | 16 | const NOTE_SYMBOL: &str = "󱇗 "; 17 | 18 | pub fn draw(frame: &mut Frame, area: Rect, context: &mut Context) { 19 | context.notebook.editor_height = area.height - 2; 20 | 21 | let (title, mut bottom_left) = if let Some(tab_index) = context.notebook.tab_index { 22 | let mut title = vec![]; 23 | for (i, tab) in context.notebook.tabs.iter().enumerate() { 24 | let name = format!(" {NOTE_SYMBOL}{} ", tab.note.name.clone()); 25 | let name = if i == tab_index { 26 | if context.notebook.state.is_editor() { 27 | name.fg(WHITE).bg(BLUE) 28 | } else { 29 | name.fg(WHITE).bg(GRAY_DIM) 30 | } 31 | } else { 32 | name.fg(GRAY_MEDIUM) 33 | }; 34 | 35 | title.push(name); 36 | } 37 | 38 | let title = Line::from(title); 39 | let mut breadcrumb = vec![]; 40 | let last_index = context.notebook.tabs[tab_index].breadcrumb.len() - 1; 41 | 42 | for (i, name) in context.notebook.tabs[tab_index] 43 | .breadcrumb 44 | .iter() 45 | .enumerate() 46 | { 47 | let (color_a, color_b) = if i % 2 == 0 { 48 | (GRAY_A, GRAY_B) 49 | } else { 50 | (GRAY_B, GRAY_A) 51 | }; 52 | 53 | let name = if i == 0 { 54 | breadcrumb.push(Span::raw(" 󰝰 ").fg(YELLOW).bg(color_a)); 55 | format!("{name} ") 56 | } else if i == last_index { 57 | format!(" 󱇗 {name} ") 58 | } else { 59 | breadcrumb.push(Span::raw(" 󰝰 ").fg(YELLOW).bg(color_a)); 60 | format!("{name} ") 61 | }; 62 | 63 | breadcrumb.push(Span::raw(name).fg(BLACK).bg(color_a)); 64 | 65 | if i < last_index { 66 | breadcrumb.push(Span::raw("").fg(color_a).bg(color_b)); 67 | } else { 68 | breadcrumb.push(Span::raw("").fg(color_a).bg(GRAY_BLACK)); 69 | } 70 | } 71 | 72 | (title, breadcrumb) 73 | } else { 74 | (Line::from("[Editor]".fg(GRAY_DIM)), vec![Span::default()]) 75 | }; 76 | 77 | let (mode, bg) = match context.notebook.state { 78 | ContextState::EditorNormalMode { .. } => (Span::raw(" NORMAL ").fg(WHITE).bg(BLACK), BLACK), 79 | ContextState::EditorInsertMode => (Span::raw(" INSERT ").fg(BLACK).bg(YELLOW), YELLOW), 80 | ContextState::EditorVisualMode => (Span::raw(" VISUAL ").fg(WHITE).bg(RED), RED), 81 | _ => (Span::raw(" ").bg(GRAY_DARK), GRAY_DARK), 82 | }; 83 | 84 | if context.notebook.tab_index.is_some() { 85 | bottom_left.insert(0, mode); 86 | bottom_left.insert(1, Span::raw("").fg(bg).bg(GRAY_A)); 87 | } 88 | 89 | let bottom_left = Line::from(bottom_left); 90 | let block = Block::new().title(title).title_bottom(bottom_left); 91 | let block = match ( 92 | context.last_log.as_ref(), 93 | context.notebook.editors.iter().any(|(_, item)| item.dirty), 94 | ) { 95 | (_, true) => block.title_bottom( 96 | Line::from(vec![ 97 | Span::raw("").fg(YELLOW).bg(GRAY_BLACK), 98 | Span::raw(" 󰔚 Saving... ").fg(BLACK).bg(YELLOW), 99 | ]) 100 | .right_aligned(), 101 | ), 102 | (Some((log, _)), false) => { 103 | block.title_bottom(Line::from(format!(" {} ", log).fg(BLACK).bg(GREEN)).right_aligned()) 104 | } 105 | (None, false) => block, 106 | } 107 | .fg(WHITE) 108 | .bg(GRAY_BLACK) 109 | .padding(if context.notebook.show_line_number { 110 | Padding::ZERO 111 | } else { 112 | Padding::left(1) 113 | }); 114 | 115 | let show_line_number = context.notebook.show_line_number; 116 | let state = context.notebook.state; 117 | let mut editor = TextArea::from("Welcome to Glues :D".lines()); 118 | let editor = if context.notebook.tab_index.is_some() { 119 | context.notebook.get_editor_mut() 120 | } else { 121 | &mut editor 122 | }; 123 | 124 | editor.set_block(block); 125 | 126 | let (cursor_style, cursor_line_style) = match state { 127 | ContextState::EditorNormalMode { .. } 128 | | ContextState::EditorInsertMode 129 | | ContextState::EditorVisualMode => ( 130 | Style::default().fg(WHITE).bg(BLUE), 131 | Style::default().underlined(), 132 | ), 133 | _ => (Style::default(), Style::default()), 134 | }; 135 | 136 | editor.set_cursor_style(cursor_style); 137 | editor.set_cursor_line_style(cursor_line_style); 138 | if show_line_number { 139 | editor.set_line_number_style(Style::default().fg(GRAY_DIM)); 140 | } else { 141 | editor.remove_line_number(); 142 | } 143 | 144 | frame.render_widget(&*editor, area); 145 | } 146 | -------------------------------------------------------------------------------- /tui/src/views/body/notebook/note_tree.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | color::*, 4 | context::{ 5 | NotebookContext, 6 | notebook::{ContextState, TreeItem, TreeItemKind}, 7 | }, 8 | }, 9 | ratatui::{ 10 | Frame, 11 | layout::Rect, 12 | style::{Style, Stylize}, 13 | text::{Line, Span}, 14 | widgets::{Block, BorderType, Borders, HighlightSpacing, List, ListDirection}, 15 | }, 16 | }; 17 | 18 | const CLOSED_SYMBOL: &str = "󰉋 "; 19 | const OPEN_SYMBOL: &str = "󰝰 "; 20 | const NOTE_SYMBOL: &str = "󱇗 "; 21 | 22 | pub fn draw(frame: &mut Frame, area: Rect, context: &mut NotebookContext) { 23 | let note_tree_focused = matches!( 24 | context.state, 25 | ContextState::NoteTreeBrowsing 26 | | ContextState::NoteTreeNumbering 27 | | ContextState::NoteTreeGateway 28 | | ContextState::MoveMode 29 | ); 30 | let title = "[Browser]"; 31 | let title = if note_tree_focused { 32 | title.fg(SKY_BLUE) 33 | } else { 34 | title.fg(GRAY_DIM) 35 | }; 36 | let block = Block::new() 37 | .borders(Borders::RIGHT) 38 | .border_type(BorderType::QuadrantOutside) 39 | .fg(GRAY_DARK) 40 | .title(title); 41 | let inner_area = block.inner(area); 42 | 43 | let tree_items = context.tree_items.iter().map( 44 | |TreeItem { 45 | depth, 46 | target, 47 | selectable, 48 | kind, 49 | }| { 50 | let line = match kind { 51 | TreeItemKind::Note { note } => { 52 | let pad = depth * 2; 53 | Line::from(vec![ 54 | format!("{:pad$}", "").into(), 55 | Span::raw(NOTE_SYMBOL).dim(), 56 | Span::raw(¬e.name), 57 | ]) 58 | } 59 | TreeItemKind::Directory { directory, opened } => { 60 | let pad = depth * 2; 61 | let symbol = if *opened { OPEN_SYMBOL } else { CLOSED_SYMBOL }; 62 | Line::from(vec![ 63 | format!("{:pad$}", "").into(), 64 | Span::raw(symbol).fg(YELLOW), 65 | Span::raw(&directory.name), 66 | ]) 67 | } 68 | }; 69 | 70 | match (selectable, target) { 71 | (true, _) => line.fg(WHITE), 72 | (false, true) => line.fg(MAGENTA), 73 | (false, false) => line.dim(), 74 | } 75 | }, 76 | ); 77 | 78 | let list = List::new(tree_items) 79 | .highlight_style(Style::new().fg(GRAY_WHITE).bg(if note_tree_focused { 80 | BLUE 81 | } else { 82 | GRAY_DARK 83 | })) 84 | .highlight_symbol(" ") 85 | .highlight_spacing(HighlightSpacing::Always) 86 | .direction(ListDirection::TopToBottom); 87 | 88 | frame.render_widget(block.bg(GRAY_BLACK), area); 89 | frame.render_stateful_widget(list, inner_area, &mut context.tree_state); 90 | } 91 | -------------------------------------------------------------------------------- /tui/src/views/dialog.rs: -------------------------------------------------------------------------------- 1 | mod alert; 2 | mod confirm; 3 | mod directory_actions; 4 | mod editor_keymap; 5 | mod help; 6 | mod keymap; 7 | mod note_actions; 8 | mod prompt; 9 | mod vim_keymap; 10 | 11 | use { 12 | crate::{ 13 | Context, 14 | context::{self}, 15 | }, 16 | glues_core::state::State, 17 | ratatui::Frame, 18 | }; 19 | 20 | pub fn draw(frame: &mut Frame, state: &State, context: &mut Context) { 21 | if context.keymap { 22 | keymap::draw(frame, state.shortcuts().as_slice()); 23 | } 24 | 25 | if let Some(kind) = context.vim_keymap { 26 | vim_keymap::draw(frame, kind); 27 | return; 28 | } else if context.editor_keymap { 29 | editor_keymap::draw(frame); 30 | return; 31 | } else if context.help { 32 | help::draw(frame); 33 | return; 34 | } else if context.alert.is_some() { 35 | alert::draw(frame, context); 36 | return; 37 | } else if context.confirm.is_some() { 38 | confirm::draw(frame, context); 39 | return; 40 | } else if context.prompt.is_some() { 41 | prompt::draw(frame, context); 42 | return; 43 | } 44 | 45 | match context.notebook.state { 46 | context::notebook::ContextState::NoteActionsDialog => { 47 | note_actions::draw(frame, &mut context.notebook); 48 | } 49 | context::notebook::ContextState::DirectoryActionsDialog => { 50 | directory_actions::draw(frame, &mut context.notebook); 51 | } 52 | _ => {} 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tui/src/views/dialog/alert.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{color::*, context::Context, logger::*}, 3 | ratatui::{ 4 | Frame, 5 | layout::{Alignment, Constraint::Length, Flex, Layout}, 6 | style::{Style, Stylize}, 7 | text::Line, 8 | widgets::{Block, Clear, Padding, Paragraph, Wrap}, 9 | }, 10 | }; 11 | 12 | pub fn draw(frame: &mut Frame, context: &mut Context) { 13 | let [area] = Layout::horizontal([Length(45)]) 14 | .flex(Flex::Center) 15 | .areas(frame.area()); 16 | let [area] = Layout::vertical([Length(8)]).flex(Flex::Center).areas(area); 17 | 18 | let block = Block::bordered() 19 | .fg(WHITE) 20 | .bg(GRAY_DARK) 21 | .padding(Padding::new(2, 2, 1, 1)) 22 | .title("Alert") 23 | .title_alignment(Alignment::Center); 24 | 25 | let inner_area = block.inner(area); 26 | let [message_area, control_area] = Layout::vertical([Length(3), Length(1)]) 27 | .flex(Flex::SpaceBetween) 28 | .areas(inner_area); 29 | 30 | let message: Line = context 31 | .alert 32 | .as_ref() 33 | .log_expect("alert message not found") 34 | .as_str() 35 | .into(); 36 | let paragraph = Paragraph::new(message) 37 | .wrap(Wrap { trim: true }) 38 | .style(Style::default()) 39 | .alignment(Alignment::Left); 40 | let control = Line::from("Press any key to close".fg(GRAY_LIGHT)).centered(); 41 | 42 | frame.render_widget(Clear, area); 43 | frame.render_widget(block, area); 44 | frame.render_widget(paragraph, message_area); 45 | frame.render_widget(control, control_area); 46 | } 47 | -------------------------------------------------------------------------------- /tui/src/views/dialog/confirm.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{color::*, context::Context, logger::*}, 3 | ratatui::{ 4 | Frame, 5 | layout::{Alignment, Constraint::Length, Flex, Layout}, 6 | style::{Style, Stylize}, 7 | widgets::{Block, Clear, Padding, Paragraph, Wrap}, 8 | }, 9 | }; 10 | 11 | pub fn draw(frame: &mut Frame, context: &mut Context) { 12 | let [area] = Layout::horizontal([Length(40)]) 13 | .flex(Flex::Center) 14 | .areas(frame.area()); 15 | let [area] = Layout::vertical([Length(9)]).flex(Flex::Center).areas(area); 16 | 17 | let block = Block::bordered() 18 | .bg(GRAY_DARK) 19 | .fg(WHITE) 20 | .padding(Padding::new(2, 2, 1, 1)) 21 | .title("Confirm") 22 | .title_alignment(Alignment::Center); 23 | let inner_area = block.inner(area); 24 | let [message_area, control_area] = Layout::vertical([Length(4), Length(2)]) 25 | .flex(Flex::SpaceBetween) 26 | .areas(inner_area); 27 | 28 | let (message, _) = context 29 | .confirm 30 | .as_ref() 31 | .log_expect("confirm message not found"); 32 | let message = Paragraph::new(message.as_str()) 33 | .wrap(Wrap { trim: true }) 34 | .style(Style::default()) 35 | .alignment(Alignment::Left); 36 | 37 | let lines = vec![ 38 | "[y] Confirm".fg(GRAY_LIGHT).into(), 39 | "[n] Cancel".fg(GRAY_LIGHT).into(), 40 | ]; 41 | let control = Paragraph::new(lines) 42 | .wrap(Wrap { trim: true }) 43 | .style(Style::default()) 44 | .alignment(Alignment::Left); 45 | 46 | frame.render_widget(Clear, area); 47 | frame.render_widget(block, area); 48 | frame.render_widget(message, message_area); 49 | frame.render_widget(control, control_area); 50 | } 51 | -------------------------------------------------------------------------------- /tui/src/views/dialog/directory_actions.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | color::*, 4 | context::{NotebookContext, notebook::DIRECTORY_ACTIONS}, 5 | }, 6 | ratatui::{ 7 | Frame, 8 | layout::{Alignment, Constraint::Length, Flex, Layout}, 9 | style::{Style, Stylize}, 10 | widgets::{Block, Clear, HighlightSpacing, List, ListDirection, Padding}, 11 | }, 12 | }; 13 | 14 | pub fn draw(frame: &mut Frame, context: &mut NotebookContext) { 15 | let [area] = Layout::horizontal([Length(28)]) 16 | .flex(Flex::Center) 17 | .areas(frame.area()); 18 | let [area] = Layout::vertical([Length(9)]).flex(Flex::Center).areas(area); 19 | 20 | let block = Block::bordered() 21 | .bg(GRAY_DARK) 22 | .fg(WHITE) 23 | .padding(Padding::new(2, 2, 1, 1)) 24 | .title("Directory Actions") 25 | .title_alignment(Alignment::Center); 26 | let list = List::new(DIRECTORY_ACTIONS) 27 | .block(block) 28 | .highlight_style(Style::new().fg(WHITE).bg(BLUE)) 29 | .highlight_symbol(" ") 30 | .highlight_spacing(HighlightSpacing::Always) 31 | .direction(ListDirection::TopToBottom); 32 | 33 | frame.render_widget(Clear, area); 34 | frame.render_stateful_widget(list, area, &mut context.directory_actions_state); 35 | } 36 | -------------------------------------------------------------------------------- /tui/src/views/dialog/editor_keymap.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::color::*, 3 | ratatui::{ 4 | Frame, 5 | layout::{Alignment, Constraint::Length, Flex, Layout}, 6 | style::{Style, Stylize}, 7 | text::Line, 8 | widgets::{Block, Clear, Padding, Paragraph, Wrap}, 9 | }, 10 | }; 11 | 12 | pub fn draw(frame: &mut Frame) { 13 | let [area] = Layout::horizontal([Length(100)]) 14 | .flex(Flex::Center) 15 | .areas(frame.area()); 16 | let [area] = Layout::vertical([Length(38)]) 17 | .flex(Flex::Center) 18 | .areas(area); 19 | 20 | let block = Block::bordered() 21 | .fg(WHITE) 22 | .bg(GRAY_DARK) 23 | .padding(Padding::new(2, 2, 1, 1)) 24 | .title("Editor Keymap") 25 | .title_alignment(Alignment::Center); 26 | 27 | let inner_area = block.inner(area); 28 | let [message_area, control_area] = Layout::vertical([Length(33), Length(1)]) 29 | .flex(Flex::SpaceBetween) 30 | .areas(inner_area); 31 | 32 | let message = " 33 | | Mappings | Description | 34 | |-----------------------------------------------|--------------------------------------------| 35 | | `Backspace` | Delete one character before cursor | 36 | | `Ctrl+D`, `Delete` | Delete one character next to cursor | 37 | | `Ctrl+M`, `Enter` | Insert newline | 38 | | `Ctrl+K` | Delete from cursor until the end of line | 39 | | `Ctrl+J` | Delete from cursor until the head of line | 40 | | `Ctrl+W`, `Alt+H`, `Alt+Backspace` | Delete one word before cursor | 41 | | `Alt+D`, `Alt+Delete` | Delete one word next to cursor | 42 | | `Ctrl+U` | Undo | 43 | | `Ctrl+R` | Redo | 44 | | `Ctrl+C`, `Copy` | Copy selected text | 45 | | `Ctrl+X`, `Cut` | Cut selected text | 46 | | `Ctrl+Y`, `Paste` | Paste yanked text | 47 | | `Ctrl+F`, `→` | Move cursor forward by one character | 48 | | `Ctrl+B`, `←` | Move cursor backward by one character | 49 | | `Ctrl+P`, `↑` | Move cursor up by one line | 50 | | `Ctrl+N`, `↓` | Move cursor down by one line | 51 | | `Alt+F`, `Ctrl+→` | Move cursor forward by word | 52 | | `Atl+B`, `Ctrl+←` | Move cursor backward by word | 53 | | `Alt+]`, `Alt+P`, `Ctrl+↑` | Move cursor up by paragraph | 54 | | `Alt+[`, `Alt+N`, `Ctrl+↓` | Move cursor down by paragraph | 55 | | `Ctrl+E`, `End`, `Ctrl+Alt+F`, `Ctrl+Alt+→` | Move cursor to the end of line | 56 | | `Ctrl+A`, `Home`, `Ctrl+Alt+B`, `Ctrl+Alt+←` | Move cursor to the head of line | 57 | | `Alt+<`, `Ctrl+Alt+P`, `Ctrl+Alt+↑` | Move cursor to top of lines | 58 | | `Alt+>`, `Ctrl+Alt+N`, `Ctrl+Alt+↓` | Move cursor to bottom of lines | 59 | | `Ctrl+V`, `PageDown` | Scroll down by page | 60 | | `Alt+V`, `PageUp` | Scroll up by page | 61 | 62 | Thanks to tui-textarea 63 | " 64 | .lines() 65 | .map(Line::from) 66 | .collect::>(); 67 | 68 | let paragraph = Paragraph::new(message) 69 | .wrap(Wrap { trim: true }) 70 | .style(Style::default()) 71 | .alignment(Alignment::Left); 72 | let control = Line::from("Press any key to close".fg(GRAY_MEDIUM)).centered(); 73 | 74 | frame.render_widget(Clear, area); 75 | frame.render_widget(block, area); 76 | frame.render_widget(paragraph, message_area); 77 | frame.render_widget(control, control_area); 78 | } 79 | -------------------------------------------------------------------------------- /tui/src/views/dialog/help.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::color::*, 3 | ratatui::{ 4 | Frame, 5 | layout::{Alignment, Constraint::Length, Flex, Layout}, 6 | style::{Style, Stylize}, 7 | text::Line, 8 | widgets::{Block, Clear, Padding, Paragraph, Wrap}, 9 | }, 10 | }; 11 | 12 | pub fn draw(frame: &mut Frame) { 13 | let [area] = Layout::horizontal([Length(120)]) 14 | .flex(Flex::Center) 15 | .areas(frame.area()); 16 | let [area] = Layout::vertical([Length(34)]) 17 | .flex(Flex::Center) 18 | .areas(area); 19 | 20 | let block = Block::bordered() 21 | .bg(GRAY_DARK) 22 | .fg(WHITE) 23 | .padding(Padding::new(2, 2, 1, 1)) 24 | .title("Help") 25 | .title_alignment(Alignment::Center); 26 | 27 | let inner_area = block.inner(area); 28 | let [message_area, control_area] = Layout::vertical([Length(27), Length(1)]) 29 | .flex(Flex::SpaceBetween) 30 | .areas(inner_area); 31 | 32 | let message = vec![ 33 | Line::from("Glues offers various storage options to suit your needs:"), 34 | Line::raw(""), 35 | Line::from("Instant".fg(BLACK).bg(YELLOW)), 36 | Line::raw("Data is stored in memory and only persists while the app is running."), 37 | Line::raw( 38 | "This option is useful for testing or temporary notes as it is entirely volatile.", 39 | ), 40 | Line::raw(""), 41 | Line::from("Local".fg(BLACK).bg(YELLOW)), 42 | Line::raw("Notes are stored locally as separate files."), 43 | Line::raw( 44 | "This is the default option for users who prefer a simple, file-based approach without any remote synchronization.", 45 | ), 46 | Line::raw(""), 47 | Line::from("Git".fg(BLACK).bg(YELLOW)), 48 | Line::raw("Git storage requires three inputs: `path`, `remote`, and `branch`."), 49 | Line::raw( 50 | "The `path` should point to an existing local Git repository, similar to the file storage path.", 51 | ), 52 | Line::raw("For example, you can clone a GitHub repository and use that path."), 53 | Line::raw( 54 | "The `remote` and `branch` specify the target remote repository and branch for synchronization.", 55 | ), 56 | Line::raw( 57 | "When you modify notes or directories, Glues will automatically sync changes with the specified remote repository.", 58 | ), 59 | Line::raw(""), 60 | Line::from("MongoDB".fg(BLACK).bg(YELLOW)), 61 | Line::raw( 62 | "MongoDB storage allows you to store your notes in a MongoDB database, providing a scalable and centralized solution for managing your notes.", 63 | ), 64 | Line::raw("You need to provide the MongoDB connection string and the database name."), 65 | Line::raw("Glues will handle storing and retrieving notes from the specified database."), 66 | Line::raw( 67 | "This option is ideal for users who prefer a centralized storage solution or need robust, reliable data storage.", 68 | ), 69 | Line::raw(""), 70 | Line::from(vec![ 71 | "CSV".fg(BLACK).bg(YELLOW), 72 | " or ".fg(GRAY_DIM), 73 | "JSON".fg(BLACK).bg(YELLOW), 74 | ]), 75 | Line::raw( 76 | "These formats store notes as simple log files, ideal for quick data exports or reading logs.", 77 | ), 78 | Line::raw( 79 | "CSV saves data in comma-separated format, while JSON uses JSONL (JSON Lines) format.", 80 | ), 81 | Line::raw(""), 82 | ]; 83 | let paragraph = Paragraph::new(message) 84 | .wrap(Wrap { trim: true }) 85 | .style(Style::default()) 86 | .alignment(Alignment::Left); 87 | let control = Line::from("Press any key to close".fg(GRAY_LIGHT)).centered(); 88 | 89 | frame.render_widget(Clear, area); 90 | frame.render_widget(block, area); 91 | frame.render_widget(paragraph, message_area); 92 | frame.render_widget(control, control_area); 93 | } 94 | -------------------------------------------------------------------------------- /tui/src/views/dialog/keymap.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::color::*, 3 | ratatui::{ 4 | Frame, 5 | layout::{ 6 | Alignment, 7 | Constraint::{Length, Percentage}, 8 | Flex, Layout, 9 | }, 10 | style::{Style, Stylize}, 11 | text::{Line, Span}, 12 | widgets::{Block, Clear, Padding, Paragraph, Wrap}, 13 | }, 14 | }; 15 | 16 | pub fn draw(frame: &mut Frame, keymap: &[String]) { 17 | let width = keymap.iter().map(|s| s.len()).max().unwrap_or_default() as u16; 18 | let width = width.max(20); 19 | let [area] = Layout::horizontal([Length(width + 5)]) 20 | .flex(Flex::End) 21 | .areas(frame.area()); 22 | let [area] = Layout::vertical([Percentage(100)]) 23 | .flex(Flex::Center) 24 | .areas(area); 25 | 26 | let block = Block::default() 27 | .fg(GRAY_DARK) 28 | .bg(GRAY_WHITE) 29 | .padding(Padding::new(2, 2, 1, 1)) 30 | .title( 31 | Line::from(vec![ 32 | Span::raw("").fg(GREEN).bg(GRAY_WHITE), 33 | Span::raw(" [?] Hide keymap ").fg(BLACK).bg(GREEN), 34 | ]) 35 | .right_aligned(), 36 | ); 37 | 38 | let inner_area = block.inner(area); 39 | let message: Vec = keymap.iter().map(|v| v.as_str().into()).collect(); 40 | let paragraph = Paragraph::new(message) 41 | .wrap(Wrap { trim: true }) 42 | .style(Style::default()) 43 | .alignment(Alignment::Left); 44 | 45 | frame.render_widget(Clear, area); 46 | frame.render_widget(block, area); 47 | frame.render_widget(paragraph, inner_area); 48 | } 49 | -------------------------------------------------------------------------------- /tui/src/views/dialog/note_actions.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | color::*, 4 | context::{NotebookContext, notebook::NOTE_ACTIONS}, 5 | }, 6 | ratatui::{ 7 | Frame, 8 | layout::{Alignment, Constraint::Length, Flex, Layout}, 9 | style::{Style, Stylize}, 10 | widgets::{Block, Clear, HighlightSpacing, List, ListDirection, Padding}, 11 | }, 12 | }; 13 | 14 | pub fn draw(frame: &mut Frame, context: &mut NotebookContext) { 15 | let [area] = Layout::horizontal([Length(28)]) 16 | .flex(Flex::Center) 17 | .areas(frame.area()); 18 | let [area] = Layout::vertical([Length(7)]).flex(Flex::Center).areas(area); 19 | 20 | let block = Block::bordered() 21 | .bg(GRAY_DARK) 22 | .fg(WHITE) 23 | .padding(Padding::new(2, 2, 1, 1)) 24 | .title("Note Actions") 25 | .title_alignment(Alignment::Center); 26 | let list = List::new(NOTE_ACTIONS) 27 | .block(block) 28 | .highlight_style(Style::new().fg(WHITE).bg(BLUE)) 29 | .highlight_symbol(" ") 30 | .highlight_spacing(HighlightSpacing::Always) 31 | .direction(ListDirection::TopToBottom); 32 | 33 | frame.render_widget(Clear, area); 34 | frame.render_stateful_widget(list, area, &mut context.note_actions_state); 35 | } 36 | -------------------------------------------------------------------------------- /tui/src/views/dialog/prompt.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | color::*, 4 | context::{Context, ContextPrompt}, 5 | logger::*, 6 | }, 7 | ratatui::{ 8 | Frame, 9 | layout::{Alignment, Constraint::Length, Flex, Layout}, 10 | style::{Style, Stylize}, 11 | widgets::{Block, Clear, Padding, Paragraph, Wrap}, 12 | }, 13 | }; 14 | 15 | pub fn draw(frame: &mut Frame, context: &mut Context) { 16 | let ContextPrompt { 17 | message, widget, .. 18 | } = context 19 | .prompt 20 | .as_ref() 21 | .log_expect("prompt message not found"); 22 | let num_lines = message.len() as u16; 23 | 24 | let [area] = Layout::horizontal([Length(61)]) 25 | .flex(Flex::Center) 26 | .areas(frame.area()); 27 | let [area] = Layout::vertical([Length(num_lines + 10)]) 28 | .flex(Flex::Center) 29 | .areas(area); 30 | 31 | let block = Block::bordered() 32 | .bg(GRAY_DARK) 33 | .fg(WHITE) 34 | .padding(Padding::new(2, 2, 1, 1)) 35 | .title("Prompt") 36 | .title_alignment(Alignment::Center); 37 | let inner_area = block.inner(area); 38 | let [message_area, input_area, _, control_area] = 39 | Layout::vertical([Length(num_lines), Length(3), Length(1), Length(2)]).areas(inner_area); 40 | let message = Paragraph::new(message.clone()) 41 | .wrap(Wrap { trim: true }) 42 | .style(Style::default()) 43 | .alignment(Alignment::Left); 44 | 45 | let lines = vec![ 46 | "[Enter] Submit".fg(GRAY_LIGHT).into(), 47 | "[Esc] Cancel".fg(GRAY_LIGHT).into(), 48 | ]; 49 | let control = Paragraph::new(lines) 50 | .wrap(Wrap { trim: true }) 51 | .style(Style::default()) 52 | .alignment(Alignment::Left); 53 | 54 | frame.render_widget(Clear, area); 55 | frame.render_widget(block, area); 56 | frame.render_widget(message, message_area); 57 | frame.render_widget(widget, input_area); 58 | frame.render_widget(control, control_area); 59 | } 60 | -------------------------------------------------------------------------------- /tui/src/views/dialog/vim_keymap.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::color::*, 3 | glues_core::transition::VimKeymapKind, 4 | ratatui::{ 5 | Frame, 6 | layout::{Alignment, Constraint::Length, Flex, Layout}, 7 | style::{Style, Stylize}, 8 | text::Line, 9 | widgets::{Block, Clear, Padding, Paragraph, Wrap}, 10 | }, 11 | }; 12 | 13 | pub fn draw(frame: &mut Frame, keymap_kind: VimKeymapKind) { 14 | let (title, message) = match keymap_kind { 15 | VimKeymapKind::NormalIdle => ( 16 | "VIM NORMAL MODE KEYMAP", 17 | vec![ 18 | Line::from("TO INSERT MODE".fg(BLACK).bg(YELLOW)), 19 | Line::raw("[i] Go to insert mode"), 20 | Line::raw("[I] Go to insert mode at the beginning of the line"), 21 | Line::raw("[o] Insert a new line below and go to insert mode"), 22 | Line::raw("[O] Insert a new line above and go to insert mode"), 23 | Line::raw("[a] Move cursor forward and go to insert mode"), 24 | Line::raw("[A] Move cursor to the end of the line and go to insert mode"), 25 | Line::raw("[s] Delete character and go to insert mode"), 26 | Line::raw("[S] Delete line and go to insert mode"), 27 | Line::raw(""), 28 | Line::from("TO OTHER MODES".fg(BLACK).bg(YELLOW)), 29 | Line::raw("[c] Go to change mode (prepare to edit text)"), 30 | Line::raw("[v] Go to visual mode (select text to edit or copy)"), 31 | Line::raw("[g] Go to gateway mode (access extended commands)"), 32 | Line::raw("[y] Go to yank mode (prepare to copy text)"), 33 | Line::raw("[d] Go to delete mode (prepare to delete text)"), 34 | Line::raw("[z] Go to scroll mode (adjust viewport)"), 35 | Line::raw("[1-9] Go to numbering mode (repeat or extend actions with numbers)"), 36 | Line::raw(""), 37 | Line::from("MOVE CURSOR".fg(BLACK).bg(YELLOW)), 38 | Line::raw("[h] Move cursor left"), 39 | Line::raw("[j] Move cursor down"), 40 | Line::raw("[k] Move cursor up"), 41 | Line::raw("[l] Move cursor right"), 42 | Line::raw("[w] Move cursor to the start of the next word"), 43 | Line::raw("[e] Move cursor to the end of the current word"), 44 | Line::raw("[b] Move cursor to the start of the previous word"), 45 | Line::raw("[0] Move cursor to the start of the line"), 46 | Line::raw("[$] Move cursor to the end of the line"), 47 | Line::raw("[^] Move cursor to the first non-blank character of the line"), 48 | Line::raw("[G] Move cursor to the end of the file"), 49 | Line::raw(""), 50 | Line::from("EDIT TEXT".fg(BLACK).bg(YELLOW)), 51 | Line::raw("[~] Toggle the case of the current character"), 52 | Line::raw("[x] Delete character under the cursor"), 53 | Line::raw("[u] Undo the last change"), 54 | Line::raw("[Ctrl+r] Redo the last undone change"), 55 | ], 56 | ), 57 | VimKeymapKind::NormalNumbering => ( 58 | "VIM NORMAL MODE KEYMAP - NUMBERING", 59 | vec![ 60 | Line::from("EXTENDING NUMBERING MODE".fg(BLACK).bg(YELLOW)), 61 | Line::raw("[0-9] Append additional digits to extend the current command"), 62 | Line::raw(""), 63 | Line::from("TO INSERT MODE".fg(BLACK).bg(YELLOW)), 64 | Line::raw("[s] Delete specified number of characters and go to insert mode"), 65 | Line::raw("[S] Delete the entire line and go to insert mode"), 66 | Line::raw(""), 67 | Line::from("TO OTHER MODES".fg(BLACK).bg(YELLOW)), 68 | Line::raw("[c] Go to change mode with repeat count (prepare to edit text)"), 69 | Line::raw("[y] Go to yank mode with repeat count (prepare to copy text)"), 70 | Line::raw("[d] Go to delete mode with repeat count (prepare to delete text)"), 71 | Line::raw(""), 72 | Line::from("MOVE CURSOR AND RETURN TO NORMAL MODE".fg(BLACK).bg(YELLOW)), 73 | Line::raw("[h] Move cursor left by the specified number of times"), 74 | Line::raw("[j] Move cursor down by the specified number of times"), 75 | Line::raw("[k] Move cursor up by the specified number of times"), 76 | Line::raw("[l] Move cursor right by the specified number of times"), 77 | Line::raw( 78 | "[w] Move cursor to the start of the next word, repeated by the specified number", 79 | ), 80 | Line::raw( 81 | "[e] Move cursor to the end of the next word, repeated by the specified number", 82 | ), 83 | Line::raw( 84 | "[b] Move cursor to the start of the previous word, repeated by the specified number", 85 | ), 86 | Line::raw("[G] Move cursor to the specified line number"), 87 | Line::raw(""), 88 | Line::from("EDIT TEXT AND RETURN TO NORMAL MODE".fg(BLACK).bg(YELLOW)), 89 | Line::raw("[x] Delete specified number of characters and return to normal mode"), 90 | ], 91 | ), 92 | VimKeymapKind::NormalDelete => ( 93 | "VIM NORMAL MODE KEYMAP - DELETE", 94 | vec![ 95 | Line::from("TO NUMBERING MODE".fg(BLACK).bg(YELLOW)), 96 | Line::raw("[1-9] Go to delete numbering mode"), 97 | Line::raw(""), 98 | Line::from("TO DELETE INSIDE MODE".fg(BLACK).bg(YELLOW)), 99 | Line::raw("[i] Go to delete inside mode"), 100 | Line::raw(""), 101 | Line::from("DELETE TEXT".fg(BLACK).bg(YELLOW)), 102 | Line::raw("[d] Delete the specified number of lines"), 103 | Line::raw("[e] Delete the word from the cursor to the end of the current word."), 104 | Line::raw("[b] Delete the word before the cursor."), 105 | Line::raw("[0] Delete to the beginning of the line"), 106 | Line::raw("[$] Delete to the end of the line, repeated by the specified number"), 107 | Line::raw("[h] Delete the specified number of characters to the left"), 108 | Line::raw("[l] Delete the specified number of characters to the right"), 109 | ], 110 | ), 111 | VimKeymapKind::NormalDelete2 => ( 112 | "VIM NORMAL MODE KEYMAP - DELETE NUMBERING", 113 | vec![ 114 | Line::from("EXTENDING NUMBERING MODE".fg(BLACK).bg(YELLOW)), 115 | Line::raw("[0-9] Append additional digits to extend the current command"), 116 | Line::raw(""), 117 | Line::from("TO DELETE INSIDE MODE".fg(BLACK).bg(YELLOW)), 118 | Line::raw("[i] Go to delete inside mode"), 119 | Line::raw(""), 120 | Line::from("DELETE TEXT".fg(BLACK).bg(YELLOW)), 121 | Line::raw("[d] Delete the specified number of lines"), 122 | Line::raw("[e] Delete the word from the cursor to the end of the current word."), 123 | Line::raw("[b] Delete the word before the cursor."), 124 | Line::raw("[$] Delete to the end of the line, repeated by the specified number"), 125 | Line::raw("[h] Delete the specified number of characters to the left"), 126 | Line::raw("[l] Delete the specified number of characters to the right"), 127 | ], 128 | ), 129 | VimKeymapKind::NormalChange => ( 130 | "VIM NORMAL MODE KEYMAP - CHANGE", 131 | vec![ 132 | Line::from("TO CHANGE INSIDE MODE".fg(BLACK).bg(YELLOW)), 133 | Line::raw("[i] Go to change inside mode"), 134 | Line::raw(""), 135 | Line::from("CHANGE TEXT AND GO TO INSERT MODE".fg(BLACK).bg(YELLOW)), 136 | Line::raw("[c] Delete the specified number of lines"), 137 | Line::from(vec![ 138 | "[e] ".into(), 139 | "or ".fg(GRAY_DIM), 140 | "[w] Delete to the end of the word by the specified number of times".into(), 141 | ]), 142 | Line::raw( 143 | "[b] Delete to the start of the previous word, repeated by the specified number", 144 | ), 145 | Line::raw("[0] Delete to the beginning of the line"), 146 | Line::raw("[$] Delete to the end of the line, repeated by the specified number"), 147 | ], 148 | ), 149 | VimKeymapKind::NormalChange2 => ( 150 | "VIM NORMAL MODE KEYMAP - CHANGE NUMBERING", 151 | vec![ 152 | Line::from("EXTENDING NUMBERING MODE".fg(BLACK).bg(YELLOW)), 153 | Line::raw("[0-9] Append additional digits to extend the current command"), 154 | Line::raw(""), 155 | Line::from("TO CHANGE INSIDE MODE".fg(BLACK).bg(YELLOW)), 156 | Line::raw("[i] Go to change inside mode"), 157 | Line::raw(""), 158 | Line::from("CHANGE TEXT AND GO TO INSERT MODE".fg(BLACK).bg(YELLOW)), 159 | Line::raw("[c] Delete the specified number of lines"), 160 | Line::from(vec![ 161 | "[e] ".into(), 162 | "or ".fg(GRAY_DIM), 163 | "[w] Delete to the end of the word by the specified number of times".into(), 164 | ]), 165 | Line::raw( 166 | "[b] Delete to the start of the previous word, repeated by the specified number", 167 | ), 168 | Line::raw("[$] Delete to the end of the line, repeated by the specified number"), 169 | ], 170 | ), 171 | VimKeymapKind::VisualIdle => ( 172 | "VIM VISUAL MODE KEYMAP", 173 | vec![ 174 | Line::from("MOVE CURSOR".fg(BLACK).bg(YELLOW)), 175 | Line::raw("[h] Move cursor left"), 176 | Line::raw("[j] Move cursor down"), 177 | Line::raw("[k] Move cursor up"), 178 | Line::raw("[l] Move cursor right"), 179 | Line::raw("[w] Move cursor to the start of the next word"), 180 | Line::raw("[e] Move cursor to the end of the next word"), 181 | Line::raw("[b] Move cursor to the start of the previous word"), 182 | Line::raw("[0] Move cursor to the start of the line"), 183 | Line::raw("[$] Move cursor to the end of the line"), 184 | Line::raw("[^] Move cursor to the first non-blank character of the line"), 185 | Line::raw("[G] Move cursor to the end of the file"), 186 | Line::raw(""), 187 | Line::from("TO INSERT MODE".fg(BLACK).bg(YELLOW)), 188 | Line::from(vec![ 189 | "[s] ".into(), 190 | "or ".fg(GRAY_DIM), 191 | "[S] Substitute selected text and go to insert mode".into(), 192 | ]), 193 | Line::raw(""), 194 | Line::from("TO EXTENDED MODES".fg(BLACK).bg(YELLOW)), 195 | Line::raw("[g] Go to gateway mode for additional commands"), 196 | Line::raw("[1-9] Specify repeat count for subsequent actions"), 197 | Line::raw(""), 198 | Line::from("EDIT TEXT AND RETURN TO NORMAL MODE".fg(BLACK).bg(YELLOW)), 199 | Line::from(vec![ 200 | "[d] ".into(), 201 | "or ".fg(GRAY_DIM), 202 | "[x] Delete selected text".into(), 203 | ]), 204 | Line::raw("[y] Yank (copy) selected text"), 205 | Line::raw("[~] Toggle the case of the select text"), 206 | ], 207 | ), 208 | VimKeymapKind::VisualNumbering => ( 209 | "VIM VISUAL MODE KEYMAP - NUMBERING", 210 | vec![ 211 | Line::from("EXTENDING NUMBERING MODE".fg(BLACK).bg(YELLOW)), 212 | Line::raw("[0-9] Append additional digits to extend the current command"), 213 | Line::raw(""), 214 | Line::from("MOVE CURSOR".fg(BLACK).bg(YELLOW)), 215 | Line::raw("[h] Move cursor left by the specified number of times"), 216 | Line::raw("[j] Move cursor down by the specified number of times"), 217 | Line::raw("[k] Move cursor up by the specified number of times"), 218 | Line::raw("[l] Move cursor right by the specified number of times"), 219 | Line::raw( 220 | "[w] Move cursor to the start of the next word, repeated by the specified number", 221 | ), 222 | Line::raw( 223 | "[e] Move cursor to the end of the next word, repeated by the specified number", 224 | ), 225 | Line::raw( 226 | "[b] Move cursor to the start of the previous word, repeated by the specified number", 227 | ), 228 | Line::raw("[G] Move cursor to the specified line number"), 229 | ], 230 | ), 231 | }; 232 | let height = message.len() as u16 + 7; 233 | 234 | let [area] = Layout::horizontal([Length(90)]) 235 | .flex(Flex::Center) 236 | .areas(frame.area()); 237 | let [area] = Layout::vertical([Length(height)]) 238 | .flex(Flex::Center) 239 | .areas(area); 240 | 241 | let block = Block::bordered() 242 | .fg(WHITE) 243 | .bg(GRAY_DARK) 244 | .padding(Padding::new(2, 2, 1, 1)) 245 | .title(title.fg(BLACK).bg(GREEN)) 246 | .title_alignment(Alignment::Center); 247 | 248 | let inner_area = block.inner(area); 249 | let [message_area, control_area] = Layout::vertical([Length(height - 5), Length(1)]) 250 | .flex(Flex::SpaceBetween) 251 | .areas(inner_area); 252 | let paragraph = Paragraph::new(message) 253 | .wrap(Wrap { trim: true }) 254 | .style(Style::default()) 255 | .alignment(Alignment::Left); 256 | let control = Line::from("Press any key to close".fg(GRAY_MEDIUM)).centered(); 257 | 258 | frame.render_widget(Clear, area); 259 | frame.render_widget(block, area); 260 | frame.render_widget(paragraph, message_area); 261 | frame.render_widget(control, control_area); 262 | } 263 | -------------------------------------------------------------------------------- /tui/src/views/statusbar.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | color::*, 4 | context::{NotebookContext, notebook::ContextState}, 5 | logger::*, 6 | }, 7 | glues_core::state::State, 8 | ratatui::{ 9 | Frame, 10 | layout::{ 11 | Constraint::{Length, Percentage}, 12 | Layout, Rect, 13 | }, 14 | style::Stylize, 15 | text::{Line, Span, Text}, 16 | }, 17 | }; 18 | 19 | pub fn draw(frame: &mut Frame, area: Rect, state: &State, context: &NotebookContext) { 20 | let description = format!(" {}", state.describe().log_unwrap()); 21 | let insert_mode = matches!(context.state, ContextState::EditorInsertMode); 22 | let [desc_area, keymap_area] = 23 | Layout::horizontal([Percentage(100), Length(if insert_mode { 23 } else { 18 })]) 24 | .areas(area); 25 | 26 | frame.render_widget( 27 | Text::raw(description).fg(GRAY_DARK).bg(GRAY_WHITE), 28 | desc_area, 29 | ); 30 | 31 | frame.render_widget( 32 | Line::from(vec![ 33 | Span::raw("").fg(GREEN).bg(GRAY_WHITE), 34 | Span::raw(if insert_mode { 35 | " [Ctrl+h] Show keymap " 36 | } else { 37 | " [?] Show keymap " 38 | }) 39 | .fg(BLACK) 40 | .bg(GREEN), 41 | ]), 42 | keymap_area, 43 | ); 44 | } 45 | --------------------------------------------------------------------------------