├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── changelog-assembly.yml │ ├── cleanup_cache.yml │ ├── comment-changes.txt │ ├── homebrew.yml │ ├── main.yml │ └── release.yml ├── .gitignore ├── .version ├── CHANGELOG.ron ├── CITATION.cff ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── THEMES.md ├── assets ├── demo.gif └── fuzzy_find.gif ├── backend ├── src │ ├── json.rs │ ├── lib.rs │ └── sqlite │ │ ├── migrations │ │ ├── 20230509041613_init_entries.sql │ │ ├── 20230613041405_add_tags.sql │ │ └── 20240104160740_entries_priorities.sql │ │ ├── mod.rs │ │ └── sqlite_helper.rs └── tests │ ├── backend.rs │ ├── json │ ├── mod.rs │ └── temp_file.rs │ └── sqlite │ └── mod.rs ├── build.rs └── src ├── app ├── colored_tags.rs ├── external_editor.rs ├── filter │ ├── criterion.rs │ └── mod.rs ├── history.rs ├── keymap.rs ├── mod.rs ├── runner.rs ├── sorter.rs ├── state.rs ├── test │ ├── filter.rs │ ├── mock.rs │ ├── mod.rs │ └── undo_redo.rs └── ui │ ├── commands │ ├── editor_cmd.rs │ ├── entries_list_cmd.rs │ ├── global_cmd.rs │ ├── mod.rs │ └── multi_select_cmd.rs │ ├── editor │ └── mod.rs │ ├── entries_list │ └── mod.rs │ ├── entry_popup │ ├── mod.rs │ └── tags.rs │ ├── export_popup │ └── mod.rs │ ├── filter_popup │ └── mod.rs │ ├── footer.rs │ ├── fuzz_find │ └── mod.rs │ ├── help_popup │ ├── global_bindings.rs │ ├── keybindings_table.rs │ ├── mod.rs │ └── multi_select_bindings.rs │ ├── mod.rs │ ├── msg_box │ └── mod.rs │ ├── sort_popup │ └── mod.rs │ ├── themes │ ├── editor_styles.rs │ ├── general_styles.rs │ ├── journals_list_styles.rs │ ├── mod.rs │ ├── msgbox.rs │ └── style.rs │ └── ui_functions.rs ├── cli ├── commands.rs └── mod.rs ├── logging.rs ├── main.rs └── settings ├── export.rs ├── external_editor.rs ├── json_backend.rs ├── mod.rs └── sqlite_backend.rs /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @AmmarAbouZor 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: / 5 | schedule: 6 | interval: "monthly" 7 | assignees: 8 | - AmmarAbouZor 9 | commit-message: 10 | prefix: 'Chore: ' 11 | 12 | - package-ecosystem: github-actions 13 | directory: / 14 | schedule: 15 | interval: "monthly" 16 | assignees: 17 | - AmmarAbouZor 18 | commit-message: 19 | prefix: 'Chore: ' 20 | -------------------------------------------------------------------------------- /.github/workflows/changelog-assembly.yml: -------------------------------------------------------------------------------- 1 | name: changelog-assembly 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | release: 7 | description: Release type 8 | required: true 9 | default: patch 10 | type: choice 11 | options: 12 | - major 13 | - minor 14 | - patch 15 | 16 | permissions: 17 | contents: write 18 | pull-requests: write 19 | 20 | jobs: 21 | ronlog: 22 | if: contains(fromJson('["major", "minor", "patch"]'), inputs.release) 23 | name: ronlog 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | 30 | - uses: fregante/setup-git-user@v2.0.2 31 | - run: rustup update 32 | 33 | - uses: baptiste0928/cargo-install@v3.3.0 34 | with: 35 | crate: aeruginous 36 | 37 | - run: | 38 | aeruginous comment-changes \ 39 | -b -d : -f ron -k -o changelog.d/ -@ "v$(cat .version)" 40 | aeruginous increment-version \ 41 | -v "$(cat .version)" \ 42 | -r ${{ inputs.release }} \ 43 | -p tui-journal \ 44 | -e .version \ 45 | -e CITATION.cff \ 46 | -e Cargo.lock \ 47 | -e Cargo.toml 48 | aeruginous ronlog release -i changelog.d/ -v "$(cat .version)" 49 | 50 | - uses: peter-evans/create-pull-request@v7.0.8 51 | with: 52 | assignees: | 53 | AmmarAbouZor 54 | branch: documentation/aeruginous-ronlog 55 | branch-suffix: timestamp 56 | commit-message: '[Aeruginous] Assemble CHANGELOG' 57 | labels: | 58 | documentation 59 | title: '[Aeruginous] Assemble CHANGELOG' 60 | -------------------------------------------------------------------------------- /.github/workflows/cleanup_cache.yml: -------------------------------------------------------------------------------- 1 | name: cleanup caches by a branch 2 | on: 3 | pull_request: 4 | types: 5 | - closed 6 | 7 | jobs: 8 | cleanup: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out code 12 | uses: actions/checkout@v4 13 | 14 | - name: Cleanup 15 | run: | 16 | gh extension install actions/gh-actions-cache 17 | 18 | REPO=${{ github.repository }} 19 | BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge" 20 | 21 | echo "Fetching list of cache key" 22 | cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) 23 | 24 | ## Setting this to not fail the workflow while deleting cache keys. 25 | set +e 26 | echo "Deleting caches..." 27 | for cacheKey in $cacheKeysForPR 28 | do 29 | gh actions-cache delete "$cacheKey" -R $REPO -B $BRANCH --confirm 30 | done 31 | echo "Done" 32 | env: 33 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/comment-changes.txt: -------------------------------------------------------------------------------- 1 | name: comment-changes 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | aeruginous: 15 | if: | 16 | github.repository == 'AmmarAbouZor/tui-journal' && 17 | !contains(github.event.head_commit.message, '[Aeruginous]') && 18 | !contains(github.event.head_commit.message, 'Chore:') && 19 | !contains(github.event.head_commit.message, 'Docs:') 20 | name: aeruginous 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | 27 | - uses: fregante/setup-git-user@v2.0.1 28 | - run: rustup update 29 | 30 | - uses: baptiste0928/cargo-install@v2.2.0 31 | with: 32 | crate: aeruginous 33 | 34 | - run: | 35 | aeruginous comment-changes \ 36 | -b \ 37 | -C Changed \ 38 | -d : \ 39 | -f ron \ 40 | -k \ 41 | -n 1 \ 42 | -o changelog.d/ 43 | 44 | - uses: peter-evans/create-pull-request@v5.0.2 45 | with: 46 | assignees: | 47 | AmmarAbouZor 48 | branch: documentation/aeruginous-comment-changes 49 | branch-suffix: timestamp 50 | commit-message: '[Aeruginous] Create CHANGELOG Fragment' 51 | labels: | 52 | documentation 53 | title: '[Aeruginous] Create CHANGELOG Fragment' 54 | -------------------------------------------------------------------------------- /.github/workflows/homebrew.yml: -------------------------------------------------------------------------------- 1 | name: Homebrew Bump Formula 2 | on: 3 | workflow_dispatch: 4 | release: 5 | types: [released] 6 | jobs: 7 | homebrew: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout Repository 11 | uses: actions/checkout@v4 12 | - name: Get Latest Tag 13 | id: ltag 14 | run: | 15 | git fetch --tags 16 | echo "GIT_TAG=$(git describe --tags "$(git rev-list --tags --max-count=1)")" >> "$GITHUB_OUTPUT" 17 | - name: Debug tag 18 | run: | 19 | echo "-------------------------------------------------------------" 20 | echo ${{ steps.ltag.outputs.GIT_TAG }} 21 | echo "-------------------------------------------------------------" 22 | 23 | - name: Bump Homebrew Formula 24 | uses: dawidd6/action-homebrew-bump-formula@v4 25 | with: 26 | token: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} 27 | tap: AmmarAbouZor/homebrew-tui-journal 28 | formula: tui-journal 29 | tag: ${{ steps.ltag.outputs.GIT_TAG }} 30 | force: true 31 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "main" ] 7 | pull_request: 8 | branches: [ "main" ] 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | 13 | jobs: 14 | actionlint: 15 | name: actionlint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | persist-credentials: false 21 | 22 | - uses: docker://rhysd/actionlint:latest 23 | with: 24 | args: -color 25 | 26 | build: 27 | runs-on: ubuntu-latest 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | # Run on latest and minimal supported rust versions. 33 | rust-version: ["stable", "1.85.0"] 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | 38 | - name: Install Rust 39 | run: | 40 | echo "Installing Rust ${{ matrix.rust-version }}" 41 | rustup update --no-self-update ${{ matrix.rust-version }} 42 | rustup default ${{ matrix.rust-version }} 43 | 44 | - name: Rust Cache 45 | uses: Swatinem/rust-cache@v2.7.8 46 | 47 | - name: Check General 48 | run: cargo check --verbose 49 | 50 | - name: Check sqlite 51 | run: cargo check --no-default-features -F sqlite --verbose 52 | 53 | - name: Clippy 54 | if: ${{ matrix.rust-version == 'stable' }} 55 | run: cargo clippy --verbose 56 | 57 | - name: Build 58 | if: ${{ matrix.rust-version == 'stable' }} 59 | run: cargo build --verbose 60 | 61 | - name: Run tests 62 | if: ${{ matrix.rust-version == 'stable' }} 63 | run: cargo test --verbose 64 | 65 | cffconvert: 66 | runs-on: ubuntu-latest 67 | steps: 68 | - uses: actions/checkout@v4 69 | with: 70 | persist-credentials: false 71 | 72 | - uses: docker://citationcff/cffconvert:latest 73 | with: 74 | args: --validate 75 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - .version 10 | 11 | permissions: 12 | contents: write 13 | 14 | jobs: 15 | release: 16 | if: github.repository == 'AmmarAbouZor/tui-journal' 17 | name: build-release 18 | runs-on: ${{ matrix.os }} 19 | container: ${{ matrix.container }} 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | include: 24 | - os: ubuntu-latest 25 | container: rust 26 | dependencies: "libssl-dev" 27 | - os: macos-13 28 | arch: x86_64 29 | - os: macos-latest 30 | arch: arm64 31 | - os: windows-latest 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | 36 | - name: Install Latest Rust 37 | run: | 38 | rustup update --no-self-update ${{ env.RUST_CHANNEL }} 39 | rustup default ${{ env.RUST_CHANNEL }} 40 | 41 | - name: Install Linux dependencies 42 | if: matrix.dependencies 43 | run: apt update && apt install -y ${{ matrix.dependencies }} 44 | 45 | - name: Build Release Mac x86_64 46 | if: matrix.os == 'macos-13' && matrix.arch == 'x86_64' 47 | run: make release-mac-x86_64 48 | - name: Build Release Mac ARM64 49 | if: matrix.os == 'macos-latest' && matrix.arch == 'arm64' 50 | run: make release-mac-arm64 51 | - name: Build Release Linux 52 | if: matrix.os == 'ubuntu-latest' 53 | run: make release-linux 54 | - name: Build Release Win 55 | if: matrix.os == 'windows-latest' 56 | run: make release-win 57 | 58 | - name: tag_name 59 | id: tag_name 60 | shell: bash 61 | run: echo "version=$(cat .version)" >> "$GITHUB_OUTPUT" 62 | 63 | - name: release 64 | if: success() 65 | uses: softprops/action-gh-release@v2 66 | with: 67 | generate_release_notes: true 68 | tag_name: v${{ steps.tag_name.outputs.version }} 69 | files: | 70 | ./release/*.tar.gz 71 | ./release/*.zip 72 | 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.version: -------------------------------------------------------------------------------- 1 | 0.15.0 2 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | # Parser settings. 2 | cff-version: 1.2.0 3 | message: Please cite this software using these information. 4 | 5 | # Version information. 6 | date-released: 2025-03-28 7 | version: 0.15.0 8 | 9 | # Project information. 10 | abstract: Your journal app if you live in a terminl 11 | authors: 12 | - alias: AmmarAbouZor 13 | family-names: Abou Zor 14 | given-names: Ammar 15 | license: MIT 16 | repository-artifact: https://crates.io/crates/tui-journal 17 | repository-code: https://github.com/AmmarAbouZor/tui-journal 18 | title: TUI-Journal 19 | url: https://docs.rs/tui-journal 20 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tui-journal" 3 | version = "0.15.0" 4 | edition = "2024" 5 | authors = ["Ammar Abou Zor"] 6 | license = "MIT" 7 | description = "Tui app allows writing and managing journals/notes from within the terminal With different local back-ends" 8 | repository = "https://github.com/ammarabouzor/tui-journal" 9 | readme = "README.md" 10 | categories = ["command-line-utilities"] 11 | keywords = ["tui", "terminal-app", "journal", "cli", "ui"] 12 | rust-version = "1.85.0" 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | 15 | [dependencies] 16 | anyhow = "1" 17 | tokio = { version = "1", features = ["full"] } 18 | serde = { version = "1", features = ["derive"]} 19 | serde_json = "1" 20 | log = "0.4" 21 | chrono = { version = "0.4", features = ["serde"] } 22 | clap = { version = "4.5", features = ["derive"] } 23 | crossterm = {version = "0.28", features = ["event-stream"]} 24 | directories = "6" 25 | simplelog = "0.12" 26 | textwrap = "0.16" 27 | thiserror = "2" 28 | toml = "0.8" 29 | sqlx = {version = "0.8", features = ["runtime-tokio-native-tls", "sqlite", "chrono"], optional = true} 30 | futures-util = { version = "0.3", default-features = false } 31 | aho-corasick = "1.1" 32 | 33 | scopeguard = "1.2" 34 | git2 = { version = "0.20", default-features = false } 35 | rayon = "1.10" 36 | fuzzy-matcher = "0.3" 37 | path-absolutize = "3.1" 38 | tui-textarea = "0.7" 39 | ratatui = { version = "0.29", features = ["all-widgets", "serde"]} 40 | arboard = { version = "3.5", default-features = false, features = ["wayland-data-control"]} 41 | 42 | [features] 43 | default = ["json", "sqlite"] 44 | json =[] 45 | sqlite = ["dep:sqlx"] 46 | 47 | [[bin]] 48 | name = "tjournal" 49 | path = "src/main.rs" 50 | 51 | [lib] 52 | name = "backend" 53 | path = "backend/src/lib.rs" 54 | 55 | [[test]] 56 | name = "backend" 57 | path = "backend/tests/backend.rs" 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ammar Abou Zor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build-release 2 | 3 | cargo_check: 4 | cargo check 5 | cargo check --no-default-features -F json 6 | cargo check --no-default-features -F sqlite 7 | 8 | run_test: 9 | cargo test 10 | 11 | clippy: 12 | cargo clippy 13 | 14 | build-release: 15 | cargo build --release 16 | 17 | release-mac-x86_64: build-release 18 | cargo build --release --target=x86_64-apple-darwin 19 | strip target/x86_64-apple-darwin/release/tjournal 20 | otool -L target/x86_64-apple-darwin/release/tjournal 21 | mkdir -p release 22 | tar -C ./target/x86_64-apple-darwin/release/ -czvf ./release/tjournal-mac-x86_64.tar.gz ./tjournal 23 | ls -lisah ./release/tjournal-mac-x86_64.tar.gz 24 | 25 | release-mac-arm64: build-release 26 | cargo build --release --target=aarch64-apple-darwin 27 | strip target/aarch64-apple-darwin/release/tjournal 28 | otool -L target/aarch64-apple-darwin/release/tjournal 29 | mkdir -p release 30 | tar -C ./target/aarch64-apple-darwin/release/ -czvf ./release/tjournal-mac-arm64.tar.gz ./tjournal 31 | ls -lisah ./release/tjournal-mac-arm64.tar.gz 32 | 33 | release-win: build-release 34 | mkdir -p release 35 | 7z -y a ./release/tjournal-win.zip ./target/release/tjournal.exe 36 | 37 | release-linux: 38 | cargo build --release --target=x86_64-unknown-linux-gnu 39 | strip target/x86_64-unknown-linux-gnu/release/tjournal 40 | mkdir -p release 41 | tar -C ./target/x86_64-unknown-linux-gnu/release/ -czvf ./release/tjournal-linux-gnu.tar.gz ./tjournal 42 | 43 | install: 44 | cargo install --path "." 45 | 46 | install_sqlite: 47 | cargo install --path "." --no-default-features -F sqlite 48 | 49 | install_json: 50 | cargo install --path "." --no-default-features -F json 51 | 52 | -------------------------------------------------------------------------------- /THEMES.md: -------------------------------------------------------------------------------- 1 | # TUI-Journal Custom Themes: 2 | 3 | It's possible to override the styles used in the app or parts of them. Custom themes will be read from the file `theme.toml` in the configuration directory within the `tui-journal` directory. 4 | 5 | ## Table of Contents 6 | 7 | - [Getting Started](#getting-started) 8 | - [Themes structures](#themes-structures) 9 | - [Themes Groups](#themes-groups) 10 | - [Themes Types](#themes-types) 11 | - [Color Type](#color-type) 12 | - [Style Type](#style-type) 13 | - [Example](#example) 14 | 15 | ## Getting started: 16 | 17 | To get started, users can use the provided CLI subcommands under `tjournal theme`. These include specifying the path for the themes file, printing the default themes to be used as a base, or writing the themes directly into the themes file. 18 | 19 | ``` 20 | Provides commands regarding changing themes and styles of the app 21 | 22 | Usage: tjournal theme 23 | 24 | Commands: 25 | print-path Prints the path to the user themes file [aliases: path] 26 | print-default Dumps the styles with the default values to be used as a reference and base for user custom themes [aliases: default] 27 | write-defaults Creates user custom themes file if doesn't exist then writes the default styles to it [aliases: write] 28 | help Print this message or the help of the given subcommand(s) 29 | 30 | Options: 31 | -h, --help Print help 32 | ``` 33 | 34 | ## Themes structures: 35 | 36 | ### Themes Groups: 37 | 38 | Themes are divided into the following groups: 39 | - **general**: Styles for general controls in the app, including journal pop-ups, filter and sorting pop-ups, and more. 40 | - **journals_list**: Styles for the main list of journals. These styles are differentiated from the general ones since they are more important and contain more information than general list items. 41 | - **editor**: Styles for the built-in editor. 42 | - **msgbox**: Colors for message-box prompts (Questions, Errors, Warnings, etc.). 43 | 44 | ### Themes Types: 45 | 46 | The main types used to define themes are `Color` and `Style`. 47 | 48 | #### Color Type 49 | 50 | Represents the color value of an item or field. It can be defined as a terminal color, such as `Black` or `Reset`, or as an RGB value in hexadecimal format `#RRGGBB`. 51 | 52 | #### Style Type 53 | 54 | Represents a complete style definition with the following fields: 55 | - **fg**: Foreground color. 56 | - **bg**: Background color. 57 | - **modifiers**: Modifiers change the way a piece of text is displayed. They are bitflags, so they can be easily combined. 58 | The available modifiers are: `BOLD | DIM | ITALIC | UNDERLINED | SLOW_BLINK | RAPID_BLINK | REVERSED | HIDDEN | CROSSED_OUT`. 59 | - **underline_color**: The color of the underline parts if the `UNDERLINED` modifier is active. 60 | 61 | Here is an example of a style with all elements defined: 62 | 63 | ```toml 64 | # Example of a style with all possible elements 65 | [example_style] 66 | fg = "#0AFA96" # Foreground Color. Colors can be in hex-decimal format "#RRGGBB" 67 | bg = "Black" # Background Color. Also it can be one of terminal colors. 68 | # Modifiers with all available flags. Flags can be combined as in example. 69 | modifiers = "BOLD | DIM | ITALIC | UNDERLINED | SLOW_BLINK | RAPID_BLINK | REVERSED | HIDDEN | CROSSED_OUT" 70 | underline_color = "Magenta" # Color for underline element if activated 71 | ``` 72 | It's worth mentioning that not all fields must be defined. Missing parts will be filled with their default values. 73 | 74 | ## Example: 75 | 76 | Here is a small example of overriding some of the themes. For a full list of all available style fields, please use the CLI subcommands to print the default themes. 77 | 78 | ```toml 79 | [general] 80 | list_item_selected = { fg = "LightYellow", modifiers = "BOLD" } 81 | input_block_active = { fg = "Blue" } 82 | 83 | [general.input_block_invalid] 84 | fg = "Red" 85 | modifiers = "BOLD | SLOW_BLINK" 86 | 87 | [general.input_corsur_active] 88 | fg = "Black" 89 | bg = "LightYellow" 90 | modifiers = "RAPID_BLINK" 91 | 92 | [general.list_highlight_active] 93 | fg = "Black" 94 | bg = "LightGreen" 95 | modifiers = "UNDERLINED" 96 | underline_color = "Magenta" 97 | 98 | [journals_list.block_inactive] 99 | fg = "Grey" 100 | modifiers = "" 101 | 102 | [journals_list.highlight_active] 103 | fg = "Red" 104 | bg = "LightGreen" 105 | modifiers = "BOLD | ITALIC" 106 | 107 | [journals_list.highlight_inactive] 108 | fg = "Grey" 109 | bg = "LightBlue" 110 | modifiers = "BOLD" 111 | 112 | [journals_list.title_active] 113 | fg = "Reset" 114 | modifiers = "BOLD | UNDERLINED" 115 | 116 | [editor.block_insert] 117 | fg = "LightGreen" 118 | modifiers = "BOLD" 119 | 120 | [editor.cursor_insert] 121 | fg = "Green" 122 | bg = "LightGreen" 123 | modifiers = "RAPID_BLINK" 124 | 125 | [msgbox] 126 | error = "#105577" 127 | question = "Magenta" 128 | ``` 129 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmmarAbouZor/tui-journal/73e25b740ef92b607077d619d321e549522fc04e/assets/demo.gif -------------------------------------------------------------------------------- /assets/fuzzy_find.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmmarAbouZor/tui-journal/73e25b740ef92b607077d619d321e549522fc04e/assets/fuzzy_find.gif -------------------------------------------------------------------------------- /backend/src/json.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::{Context, anyhow}; 4 | 5 | use super::*; 6 | 7 | pub struct JsonDataProvide { 8 | file_path: PathBuf, 9 | } 10 | 11 | impl JsonDataProvide { 12 | pub fn new(file_path: PathBuf) -> Self { 13 | Self { file_path } 14 | } 15 | } 16 | 17 | impl DataProvider for JsonDataProvide { 18 | async fn load_all_entries(&self) -> anyhow::Result> { 19 | if !self.file_path.try_exists()? { 20 | return Ok(Vec::new()); 21 | } 22 | 23 | let json_content = tokio::fs::read_to_string(&self.file_path).await?; 24 | if json_content.is_empty() { 25 | return Ok(Vec::new()); 26 | } 27 | let entries = 28 | serde_json::from_str(&json_content).context("Error while parsing entries json data")?; 29 | 30 | Ok(entries) 31 | } 32 | 33 | async fn add_entry(&self, entry: EntryDraft) -> Result { 34 | if entry.title.is_empty() { 35 | return Err(ModifyEntryError::ValidationError( 36 | "Entry title can't be empty".into(), 37 | )); 38 | } 39 | 40 | let mut entries = self.load_all_entries().await?; 41 | 42 | entries.sort_by_key(|e| e.id); 43 | 44 | let id: u32 = entries.last().map(|entry| entry.id + 1).unwrap_or(0); 45 | 46 | let new_entry = Entry::from_draft(id, entry); 47 | 48 | entries.push(new_entry); 49 | 50 | self.write_entries_to_file(&entries) 51 | .await 52 | .map_err(|err| anyhow!(err))?; 53 | 54 | Ok(entries.into_iter().next_back().unwrap()) 55 | } 56 | 57 | async fn remove_entry(&self, entry_id: u32) -> anyhow::Result<()> { 58 | let mut entries = self.load_all_entries().await?; 59 | 60 | if let Some(pos) = entries.iter().position(|e| e.id == entry_id) { 61 | entries.remove(pos); 62 | 63 | self.write_entries_to_file(&entries) 64 | .await 65 | .map_err(|err| anyhow!(err))?; 66 | } 67 | 68 | Ok(()) 69 | } 70 | 71 | async fn update_entry(&self, entry: Entry) -> Result { 72 | if entry.title.is_empty() { 73 | return Err(ModifyEntryError::ValidationError( 74 | "Entry title can't be empty".into(), 75 | )); 76 | } 77 | 78 | let mut entries = self.load_all_entries().await?; 79 | 80 | if let Some(entry_to_modify) = entries.iter_mut().find(|e| e.id == entry.id) { 81 | *entry_to_modify = entry.clone(); 82 | 83 | self.write_entries_to_file(&entries) 84 | .await 85 | .map_err(|err| anyhow!(err))?; 86 | 87 | Ok(entry) 88 | } else { 89 | Err(ModifyEntryError::ValidationError( 90 | "Entry title can't be empty".into(), 91 | )) 92 | } 93 | } 94 | 95 | async fn get_export_object(&self, entries_ids: &[u32]) -> anyhow::Result { 96 | let entries: Vec = self 97 | .load_all_entries() 98 | .await? 99 | .into_iter() 100 | .filter(|entry| entries_ids.contains(&entry.id)) 101 | .map(EntryDraft::from_entry) 102 | .collect(); 103 | 104 | Ok(EntriesDTO::new(entries)) 105 | } 106 | 107 | async fn assign_priority_to_entries(&self, priority: u32) -> anyhow::Result<()> { 108 | let mut entries = self.load_all_entries().await?; 109 | 110 | entries 111 | .iter_mut() 112 | .filter(|entry| entry.priority.is_none()) 113 | .for_each(|entry| entry.priority = Some(priority)); 114 | 115 | self.write_entries_to_file(&entries).await?; 116 | 117 | Ok(()) 118 | } 119 | } 120 | 121 | impl JsonDataProvide { 122 | async fn write_entries_to_file(&self, entries: &Vec) -> anyhow::Result<()> { 123 | let entries_text = serde_json::to_vec(&entries)?; 124 | if !self.file_path.exists() { 125 | if let Some(parent) = self.file_path.parent() { 126 | tokio::fs::create_dir_all(parent).await?; 127 | } 128 | } 129 | tokio::fs::write(&self.file_path, entries_text).await?; 130 | 131 | Ok(()) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /backend/src/lib.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[cfg(feature = "json")] 6 | mod json; 7 | #[cfg(feature = "json")] 8 | pub use json::JsonDataProvide; 9 | 10 | #[cfg(feature = "sqlite")] 11 | mod sqlite; 12 | #[cfg(feature = "sqlite")] 13 | pub use sqlite::SqliteDataProvide; 14 | 15 | pub const TRANSFER_DATA_VERSION: u16 = 100; 16 | 17 | #[derive(Debug, thiserror::Error)] 18 | pub enum ModifyEntryError { 19 | #[error("{0}")] 20 | ValidationError(String), 21 | #[error("{0}")] 22 | DataError(#[from] anyhow::Error), 23 | } 24 | 25 | // The warning can be suppressed since this will be used with the code base of this app only 26 | #[allow(async_fn_in_trait)] 27 | pub trait DataProvider { 28 | async fn load_all_entries(&self) -> anyhow::Result>; 29 | async fn add_entry(&self, entry: EntryDraft) -> Result; 30 | async fn remove_entry(&self, entry_id: u32) -> anyhow::Result<()>; 31 | async fn update_entry(&self, entry: Entry) -> Result; 32 | async fn get_export_object(&self, entries_ids: &[u32]) -> anyhow::Result; 33 | async fn import_entries(&self, entries_dto: EntriesDTO) -> anyhow::Result<()> { 34 | debug_assert_eq!( 35 | TRANSFER_DATA_VERSION, entries_dto.version, 36 | "Version mismatches check if there is a need to do a converting to the data" 37 | ); 38 | 39 | for entry_draft in entries_dto.entries { 40 | self.add_entry(entry_draft).await?; 41 | } 42 | 43 | Ok(()) 44 | } 45 | /// Assigns priority to all entries that don't have a priority assigned to 46 | async fn assign_priority_to_entries(&self, priority: u32) -> anyhow::Result<()>; 47 | } 48 | 49 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 50 | pub struct Entry { 51 | pub id: u32, 52 | pub date: DateTime, 53 | pub title: String, 54 | pub content: String, 55 | #[serde(default)] 56 | pub tags: Vec, 57 | #[serde(default)] 58 | pub priority: Option, 59 | } 60 | 61 | impl Entry { 62 | #[allow(dead_code)] 63 | pub fn new( 64 | id: u32, 65 | date: DateTime, 66 | title: String, 67 | content: String, 68 | tags: Vec, 69 | priority: Option, 70 | ) -> Self { 71 | Self { 72 | id, 73 | date, 74 | title, 75 | content, 76 | tags, 77 | priority, 78 | } 79 | } 80 | 81 | pub fn from_draft(id: u32, draft: EntryDraft) -> Self { 82 | Self { 83 | id, 84 | date: draft.date, 85 | title: draft.title, 86 | content: draft.content, 87 | tags: draft.tags, 88 | priority: draft.priority, 89 | } 90 | } 91 | } 92 | 93 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 94 | pub struct EntryDraft { 95 | pub date: DateTime, 96 | pub title: String, 97 | pub content: String, 98 | pub tags: Vec, 99 | pub priority: Option, 100 | } 101 | 102 | impl EntryDraft { 103 | pub fn new( 104 | date: DateTime, 105 | title: String, 106 | tags: Vec, 107 | priority: Option, 108 | ) -> Self { 109 | let content = String::new(); 110 | Self { 111 | date, 112 | title, 113 | content, 114 | tags, 115 | priority, 116 | } 117 | } 118 | 119 | #[must_use] 120 | pub fn with_content(mut self, content: String) -> Self { 121 | self.content = content; 122 | self 123 | } 124 | 125 | pub fn from_entry(entry: Entry) -> Self { 126 | Self { 127 | date: entry.date, 128 | title: entry.title, 129 | content: entry.content, 130 | tags: entry.tags, 131 | priority: entry.priority, 132 | } 133 | } 134 | } 135 | 136 | /// Entries data transfer object 137 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 138 | pub struct EntriesDTO { 139 | pub version: u16, 140 | pub entries: Vec, 141 | } 142 | 143 | impl EntriesDTO { 144 | pub fn new(entries: Vec) -> Self { 145 | Self { 146 | version: TRANSFER_DATA_VERSION, 147 | entries, 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /backend/src/sqlite/migrations/20230509041613_init_entries.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE entries 2 | ( 3 | id INTEGER PRIMARY KEY NOT NULL, 4 | title TEXT NOT NULL, 5 | date DATE NOT NULL, 6 | content TEXT 7 | ) 8 | -------------------------------------------------------------------------------- /backend/src/sqlite/migrations/20230613041405_add_tags.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS tags ( 2 | entry_id INTEGER NOT NULL, 3 | tag TEXT NOT NULL, 4 | PRIMARY KEY (entry_id, tag) 5 | FOREIGN KEY (entry_id) REFERENCES entries (id) ON DELETE CASCADE 6 | ) 7 | 8 | -------------------------------------------------------------------------------- /backend/src/sqlite/migrations/20240104160740_entries_priorities.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE entries 2 | Add COLUMN priority INTEGER DEFAULT NULL; 3 | -------------------------------------------------------------------------------- /backend/src/sqlite/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, str::FromStr}; 2 | 3 | use self::sqlite_helper::EntryIntermediate; 4 | 5 | use super::*; 6 | use anyhow::anyhow; 7 | use path_absolutize::Absolutize; 8 | use sqlx::{ 9 | Row, Sqlite, SqlitePool, 10 | migrate::MigrateDatabase, 11 | sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, 12 | }; 13 | 14 | mod sqlite_helper; 15 | 16 | pub struct SqliteDataProvide { 17 | pool: SqlitePool, 18 | } 19 | 20 | impl SqliteDataProvide { 21 | pub async fn from_file(file_path: PathBuf) -> anyhow::Result { 22 | let file_full_path = file_path.absolutize()?; 23 | if !file_path.exists() { 24 | if let Some(parent) = file_path.parent() { 25 | tokio::fs::create_dir_all(parent).await?; 26 | } 27 | } 28 | 29 | let db_url = format!("sqlite://{}", file_full_path.to_string_lossy()); 30 | 31 | SqliteDataProvide::create(&db_url).await 32 | } 33 | 34 | pub async fn create(db_url: &str) -> anyhow::Result { 35 | if !Sqlite::database_exists(db_url).await? { 36 | log::trace!("Creating Database with the URL '{}'", db_url); 37 | Sqlite::create_database(db_url) 38 | .await 39 | .map_err(|err| anyhow!("Creating database failed. Error info: {err}"))?; 40 | } 41 | 42 | // We are using the database as a normal file for one user. 43 | // Journal mode will causes problems with the synchronisation in our case and it must be 44 | // turned off 45 | let options = SqliteConnectOptions::from_str(db_url)? 46 | .journal_mode(SqliteJournalMode::Off) 47 | .synchronous(SqliteSynchronous::Off); 48 | 49 | let pool = SqlitePoolOptions::new().connect_with(options).await?; 50 | 51 | sqlx::migrate!("backend/src/sqlite/migrations") 52 | .run(&pool) 53 | .await 54 | .map_err(|err| match err { 55 | sqlx::migrate::MigrateError::VersionMissing(id) => anyhow!("Database version mismatches. Error Info: migration {id} was previously applied but is missing in the resolved migrations"), 56 | err => anyhow!("Error while applying migrations on database: Error info {err}"), 57 | })?; 58 | 59 | Ok(Self { pool }) 60 | } 61 | } 62 | 63 | impl DataProvider for SqliteDataProvide { 64 | async fn load_all_entries(&self) -> anyhow::Result> { 65 | let entries: Vec = sqlx::query_as( 66 | r"SELECT entries.id, entries.title, entries.date, entries.content, entries.priority, GROUP_CONCAT(tags.tag) AS tags 67 | FROM entries 68 | LEFT JOIN tags ON entries.id = tags.entry_id 69 | GROUP BY entries.id 70 | ORDER BY date DESC", 71 | ) 72 | .fetch_all(&self.pool) 73 | .await 74 | .map_err(|err| { 75 | log::error!("Loading entries failed. Error Info {err}"); 76 | anyhow!(err) 77 | })?; 78 | 79 | let entries: Vec = entries.into_iter().map(Entry::from).collect(); 80 | 81 | Ok(entries) 82 | } 83 | 84 | async fn add_entry(&self, entry: EntryDraft) -> Result { 85 | let row = sqlx::query( 86 | r"INSERT INTO entries (title, date, content, priority) 87 | VALUES($1, $2, $3, $4) 88 | RETURNING id", 89 | ) 90 | .bind(&entry.title) 91 | .bind(entry.date) 92 | .bind(&entry.content) 93 | .bind(entry.priority) 94 | .fetch_one(&self.pool) 95 | .await 96 | .map_err(|err| { 97 | log::error!("Add entry failed. Error info: {}", err); 98 | anyhow!(err) 99 | })?; 100 | 101 | let id = row.get::(0); 102 | 103 | for tag in entry.tags.iter() { 104 | sqlx::query( 105 | r"INSERT INTO tags (entry_id, tag) 106 | VALUES($1, $2)", 107 | ) 108 | .bind(id) 109 | .bind(tag) 110 | .execute(&self.pool) 111 | .await 112 | .map_err(|err| { 113 | log::error!("Add entry tags failed. Error info:{}", err); 114 | anyhow!(err) 115 | })?; 116 | } 117 | 118 | Ok(Entry::from_draft(id, entry)) 119 | } 120 | 121 | async fn remove_entry(&self, entry_id: u32) -> anyhow::Result<()> { 122 | sqlx::query(r"DELETE FROM entries WHERE id=$1") 123 | .bind(entry_id) 124 | .execute(&self.pool) 125 | .await 126 | .map_err(|err| { 127 | log::error!("Delete entry failed. Error info: {err}"); 128 | anyhow!(err) 129 | })?; 130 | 131 | Ok(()) 132 | } 133 | 134 | async fn update_entry(&self, entry: Entry) -> Result { 135 | sqlx::query( 136 | r"UPDATE entries 137 | Set title = $1, 138 | date = $2, 139 | content = $3, 140 | priority = $4 141 | WHERE id = $5", 142 | ) 143 | .bind(&entry.title) 144 | .bind(entry.date) 145 | .bind(&entry.content) 146 | .bind(entry.priority) 147 | .bind(entry.id) 148 | .execute(&self.pool) 149 | .await 150 | .map_err(|err| { 151 | log::error!("Update entry failed. Error info {err}"); 152 | anyhow!(err) 153 | })?; 154 | 155 | let existing_tags: Vec = sqlx::query_scalar( 156 | r"SELECT tag FROM tags 157 | WHERE entry_id = $1", 158 | ) 159 | .bind(entry.id) 160 | .fetch_all(&self.pool) 161 | .await 162 | .map_err(|err| { 163 | log::error!("Update entry tags failed. Error info {err}"); 164 | anyhow!(err) 165 | })?; 166 | 167 | // Tags to remove 168 | for tag_to_remove in existing_tags.iter().filter(|tag| !entry.tags.contains(tag)) { 169 | sqlx::query(r"DELETE FROM tags Where entry_id = $1 AND tag = $2") 170 | .bind(entry.id) 171 | .bind(tag_to_remove) 172 | .execute(&self.pool) 173 | .await 174 | .map_err(|err| { 175 | log::error!("Update entry tags failed. Error info {err}"); 176 | anyhow!(err) 177 | })?; 178 | } 179 | 180 | // Tags to insert 181 | for tag_to_insert in entry.tags.iter().filter(|tag| !existing_tags.contains(tag)) { 182 | sqlx::query( 183 | r"INSERT INTO tags (entry_id, tag) 184 | VALUES ($1, $2)", 185 | ) 186 | .bind(entry.id) 187 | .bind(tag_to_insert) 188 | .execute(&self.pool) 189 | .await 190 | .map_err(|err| { 191 | log::error!("Update entry tags failed. Error info {err}"); 192 | anyhow!(err) 193 | })?; 194 | } 195 | 196 | Ok(entry) 197 | } 198 | 199 | async fn get_export_object(&self, entries_ids: &[u32]) -> anyhow::Result { 200 | let ids_text = entries_ids 201 | .iter() 202 | .map(|id| id.to_string()) 203 | .collect::>() 204 | .join(", "); 205 | 206 | let sql = format!( 207 | r"SELECT entries.id, entries.title, entries.date, entries.content, entries.priority, GROUP_CONCAT(tags.tag) AS tags 208 | FROM entries 209 | LEFT JOIN tags ON entries.id = tags.entry_id 210 | WHERE entries.id IN ({}) 211 | GROUP BY entries.id 212 | ORDER BY date DESC", 213 | ids_text 214 | ); 215 | 216 | let entries: Vec = sqlx::query_as(sql.as_str()) 217 | .fetch_all(&self.pool) 218 | .await 219 | .map_err(|err| { 220 | log::error!("Loading entries failed. Error Info {err}"); 221 | anyhow!(err) 222 | })?; 223 | 224 | let entry_drafts = entries 225 | .into_iter() 226 | .map(Entry::from) 227 | .map(EntryDraft::from_entry) 228 | .collect(); 229 | 230 | Ok(EntriesDTO::new(entry_drafts)) 231 | } 232 | 233 | async fn assign_priority_to_entries(&self, priority: u32) -> anyhow::Result<()> { 234 | let sql = format!( 235 | r"UPDATE entries 236 | SET priority = '{}' 237 | WHERE priority IS NULL;", 238 | priority 239 | ); 240 | 241 | sqlx::query(sql.as_str()) 242 | .execute(&self.pool) 243 | .await 244 | .map_err(|err| { 245 | log::error!("Assign priority to entries failed. Error info {err}"); 246 | 247 | anyhow!(err) 248 | })?; 249 | 250 | Ok(()) 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /backend/src/sqlite/sqlite_helper.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use sqlx::FromRow; 3 | 4 | use crate::Entry; 5 | 6 | /// Helper class to retrieve entries' data from database since FromRow can't handle arrays 7 | #[derive(FromRow)] 8 | pub(crate) struct EntryIntermediate { 9 | pub id: u32, 10 | pub date: DateTime, 11 | pub title: String, 12 | pub content: String, 13 | pub priority: Option, 14 | /// Tags as a string with commas as separator for the tags 15 | pub tags: Option, 16 | } 17 | 18 | impl From for Entry { 19 | fn from(value: EntryIntermediate) -> Self { 20 | Entry { 21 | id: value.id, 22 | date: value.date, 23 | title: value.title, 24 | content: value.content, 25 | priority: value.priority, 26 | tags: value 27 | .tags 28 | .map(|tags| tags.split_terminator(',').map(String::from).collect()) 29 | .unwrap_or_default(), 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /backend/tests/backend.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "json")] 2 | mod json; 3 | #[cfg(feature = "sqlite")] 4 | mod sqlite; 5 | -------------------------------------------------------------------------------- /backend/tests/json/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use backend::*; 4 | use chrono::{TimeZone, Utc}; 5 | 6 | use crate::json::temp_file::TempFile; 7 | 8 | mod temp_file; 9 | 10 | async fn create_provide_with_two_entries(path_file: PathBuf) -> JsonDataProvide { 11 | let json_provide = JsonDataProvide::new(path_file); 12 | let mut entry_draft_1 = EntryDraft::new( 13 | Utc::now(), 14 | String::from("Title 1"), 15 | vec![String::from("Tag_1"), String::from("Tag_2")], 16 | None, 17 | ); 18 | entry_draft_1.content.push_str("Content entry 1"); 19 | let mut entry_draft_2 = EntryDraft::new( 20 | Utc.with_ymd_and_hms(2023, 3, 23, 1, 1, 1).unwrap(), 21 | String::from("Title 2"), 22 | Vec::new(), 23 | Some(1), 24 | ); 25 | entry_draft_2.content.push_str("Content entry 2"); 26 | 27 | json_provide.add_entry(entry_draft_1).await.unwrap(); 28 | json_provide.add_entry(entry_draft_2).await.unwrap(); 29 | 30 | json_provide 31 | } 32 | 33 | #[tokio::test] 34 | async fn create_provider_with_default_entries() { 35 | let temp_file = TempFile::new("json_create_default"); 36 | let provider = create_provide_with_two_entries(temp_file.file_path.clone()).await; 37 | 38 | let entries = provider.load_all_entries().await.unwrap(); 39 | 40 | assert_eq!(entries.len(), 2); 41 | assert_eq!(entries[0].id, 0); 42 | assert_eq!(entries[1].id, 1); 43 | assert_eq!(entries[0].title, String::from("Title 1")); 44 | assert_eq!(entries[1].title, String::from("Title 2")); 45 | assert_eq!(entries[0].priority, None); 46 | assert_eq!(entries[1].priority, Some(1)); 47 | } 48 | 49 | #[tokio::test] 50 | async fn add_entry() { 51 | let temp_file = TempFile::new("json_add_entry"); 52 | let provider = create_provide_with_two_entries(temp_file.file_path.clone()).await; 53 | 54 | let mut entry_draft = EntryDraft::new( 55 | Utc.with_ymd_and_hms(2023, 3, 23, 1, 1, 1).unwrap(), 56 | String::from("Title added"), 57 | vec![String::from("Tag_1"), String::from("Tag_3")], 58 | Some(1), 59 | ); 60 | entry_draft.content.push_str("Content entry added"); 61 | 62 | provider.add_entry(entry_draft).await.unwrap(); 63 | 64 | let entries = provider.load_all_entries().await.unwrap(); 65 | 66 | assert_eq!(entries.len(), 3); 67 | assert_eq!(entries[2].id, 2); 68 | assert_eq!(entries[2].title, String::from("Title added")); 69 | assert_eq!(entries[2].content, String::from("Content entry added")); 70 | assert_eq!(entries[2].priority, Some(1)); 71 | assert_eq!( 72 | entries[2].tags, 73 | vec![String::from("Tag_1"), String::from("Tag_3")] 74 | ); 75 | } 76 | 77 | #[tokio::test] 78 | async fn remove_entry() { 79 | let temp_file = TempFile::new("json_remove_entry"); 80 | let provider = create_provide_with_two_entries(temp_file.file_path.clone()).await; 81 | 82 | provider.remove_entry(1).await.unwrap(); 83 | 84 | let entries = provider.load_all_entries().await.unwrap(); 85 | assert_eq!(entries.len(), 1); 86 | assert_eq!(entries[0].id, 0); 87 | } 88 | 89 | #[tokio::test] 90 | async fn update_entry() { 91 | let temp_file = TempFile::new("json_update_entry"); 92 | let provider = create_provide_with_two_entries(temp_file.file_path.clone()).await; 93 | 94 | let mut entries = provider.load_all_entries().await.unwrap(); 95 | 96 | entries[0].content = String::from("Updated Content"); 97 | entries[0].tags.pop().unwrap(); 98 | entries[0].priority = Some(2); 99 | entries[1].title = String::from("Updated Title"); 100 | entries[1].tags.push(String::from("Tag_4")); 101 | entries[1].priority = None; 102 | 103 | provider.update_entry(entries.pop().unwrap()).await.unwrap(); 104 | provider.update_entry(entries.pop().unwrap()).await.unwrap(); 105 | 106 | let entries = provider.load_all_entries().await.unwrap(); 107 | 108 | assert_eq!(entries.len(), 2); 109 | assert_eq!(entries[0].content, String::from("Updated Content")); 110 | assert_eq!(entries[0].tags.len(), 1); 111 | assert_eq!(entries[0].priority, Some(2)); 112 | assert_eq!(entries[1].title, String::from("Updated Title")); 113 | assert!(entries[1].tags.contains(&String::from("Tag_4"))); 114 | assert_eq!(entries[1].priority, None); 115 | } 116 | 117 | #[tokio::test] 118 | async fn export_import() { 119 | let temp_file_source = TempFile::new("json_export_source"); 120 | let provider_source = create_provide_with_two_entries(temp_file_source.file_path.clone()).await; 121 | 122 | let created_ids = [0, 1]; 123 | 124 | let dto_source = provider_source 125 | .get_export_object(&created_ids) 126 | .await 127 | .unwrap(); 128 | 129 | assert_eq!(dto_source.entries.len(), created_ids.len()); 130 | 131 | let temp_file_dist = TempFile::new("json_export_dist"); 132 | let provider_dist = JsonDataProvide::new(temp_file_dist.file_path.clone()); 133 | 134 | provider_dist 135 | .import_entries(dto_source.clone()) 136 | .await 137 | .unwrap(); 138 | 139 | let dto_dist = provider_dist.get_export_object(&created_ids).await.unwrap(); 140 | 141 | assert_eq!(dto_source, dto_dist); 142 | } 143 | 144 | #[tokio::test] 145 | async fn assign_priority() { 146 | let temp_file = TempFile::new("json_assign_priority"); 147 | let provider = create_provide_with_two_entries(temp_file.file_path.clone()).await; 148 | 149 | provider.assign_priority_to_entries(3).await.unwrap(); 150 | 151 | let entries = provider.load_all_entries().await.unwrap(); 152 | 153 | assert_eq!(entries[0].priority, Some(3)); 154 | assert_eq!(entries[1].priority, Some(1)); 155 | } 156 | -------------------------------------------------------------------------------- /backend/tests/json/temp_file.rs: -------------------------------------------------------------------------------- 1 | use std::{env, fs, path::PathBuf}; 2 | pub struct TempFile { 3 | pub file_path: PathBuf, 4 | } 5 | 6 | impl TempFile { 7 | pub fn new(file_name: &str) -> Self { 8 | let file_path = env::temp_dir().join(file_name); 9 | 10 | let temp_file = Self { file_path }; 11 | temp_file.clean_up(); 12 | 13 | temp_file 14 | } 15 | 16 | pub fn clean_up(&self) { 17 | if self 18 | .file_path 19 | .try_exists() 20 | .expect("Access to check the test file should be given") 21 | { 22 | fs::remove_file(&self.file_path) 23 | .expect("Access to delete the test file should be given"); 24 | } 25 | } 26 | } 27 | 28 | impl Drop for TempFile { 29 | fn drop(&mut self) { 30 | self.clean_up(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /backend/tests/sqlite/mod.rs: -------------------------------------------------------------------------------- 1 | use backend::*; 2 | use chrono::{TimeZone, Utc}; 3 | 4 | async fn create_provider_with_two_entries() -> SqliteDataProvide { 5 | let provider = create_provider().await; 6 | 7 | let mut entry_draft_1 = EntryDraft::new( 8 | Utc::now(), 9 | String::from("Title 1"), 10 | vec![String::from("Tag_1"), String::from("Tag_2")], 11 | None, 12 | ); 13 | entry_draft_1.content.push_str("Content entry 1"); 14 | let mut entry_draft_2 = EntryDraft::new( 15 | Utc.with_ymd_and_hms(2023, 3, 23, 1, 1, 1).unwrap(), 16 | String::from("Title 2"), 17 | Vec::new(), 18 | Some(1), 19 | ); 20 | entry_draft_2.content.push_str("Content entry 2"); 21 | 22 | provider.add_entry(entry_draft_1).await.unwrap(); 23 | provider.add_entry(entry_draft_2).await.unwrap(); 24 | 25 | provider 26 | } 27 | 28 | #[inline] 29 | async fn create_provider() -> SqliteDataProvide { 30 | SqliteDataProvide::create("sqlite::memory:").await.unwrap() 31 | } 32 | 33 | #[tokio::test] 34 | async fn create_provider_with_default_entries() { 35 | let provider = create_provider_with_two_entries().await; 36 | 37 | let entries = provider.load_all_entries().await.unwrap(); 38 | 39 | assert_eq!(entries.len(), 2); 40 | assert_eq!(entries[0].id, 1); 41 | assert_eq!(entries[1].id, 2); 42 | assert_eq!(entries[0].title, String::from("Title 1")); 43 | assert_eq!(entries[1].title, String::from("Title 2")); 44 | assert_eq!(entries[0].priority, None); 45 | assert_eq!(entries[1].priority, Some(1)); 46 | } 47 | 48 | #[tokio::test] 49 | async fn add_entry() { 50 | let provider = create_provider_with_two_entries().await; 51 | 52 | let mut entry_draft = EntryDraft::new( 53 | Utc.with_ymd_and_hms(2023, 3, 23, 1, 1, 1).unwrap(), 54 | String::from("Title added"), 55 | vec![String::from("Tag_1"), String::from("Tag_3")], 56 | Some(1), 57 | ); 58 | entry_draft.content.push_str("Content entry added"); 59 | 60 | provider.add_entry(entry_draft).await.unwrap(); 61 | 62 | let entries = provider.load_all_entries().await.unwrap(); 63 | 64 | assert_eq!(entries.len(), 3); 65 | assert_eq!(entries[2].id, 3); 66 | assert_eq!(entries[2].title, String::from("Title added")); 67 | assert_eq!(entries[2].content, String::from("Content entry added")); 68 | assert_eq!(entries[2].priority, Some(1)); 69 | assert_eq!( 70 | entries[2].tags, 71 | vec![String::from("Tag_1"), String::from("Tag_3")] 72 | ); 73 | } 74 | 75 | #[tokio::test] 76 | async fn remove_entry() { 77 | let provider = create_provider_with_two_entries().await; 78 | 79 | provider.remove_entry(1).await.unwrap(); 80 | 81 | let entries = provider.load_all_entries().await.unwrap(); 82 | assert_eq!(entries.len(), 1); 83 | assert_eq!(entries[0].id, 2); 84 | } 85 | 86 | #[tokio::test] 87 | async fn update_entry() { 88 | let provider = create_provider_with_two_entries().await; 89 | 90 | let mut entries = provider.load_all_entries().await.unwrap(); 91 | 92 | entries[0].content = String::from("Updated Content"); 93 | entries[0].tags.pop().unwrap(); 94 | entries[0].priority = Some(2); 95 | entries[1].title = String::from("Updated Title"); 96 | entries[1].tags.push(String::from("Tag_4")); 97 | entries[1].priority = None; 98 | 99 | provider.update_entry(entries.pop().unwrap()).await.unwrap(); 100 | provider.update_entry(entries.pop().unwrap()).await.unwrap(); 101 | 102 | let entries = provider.load_all_entries().await.unwrap(); 103 | 104 | assert_eq!(entries.len(), 2); 105 | assert_eq!(entries[0].content, String::from("Updated Content")); 106 | assert_eq!(entries[0].tags.len(), 1); 107 | assert_eq!(entries[0].priority, Some(2)); 108 | assert_eq!(entries[1].title, String::from("Updated Title")); 109 | assert!(entries[1].tags.contains(&String::from("Tag_4"))); 110 | assert_eq!(entries[1].priority, None); 111 | } 112 | 113 | #[tokio::test] 114 | async fn export_import() { 115 | let provider_source = create_provider_with_two_entries().await; 116 | 117 | let created_ids = [1, 2]; 118 | 119 | let dto_source = provider_source 120 | .get_export_object(&created_ids) 121 | .await 122 | .unwrap(); 123 | 124 | assert_eq!(dto_source.entries.len(), created_ids.len()); 125 | 126 | let provider_dist = create_provider().await; 127 | 128 | provider_dist 129 | .import_entries(dto_source.clone()) 130 | .await 131 | .unwrap(); 132 | 133 | let dto_dist = provider_dist.get_export_object(&created_ids).await.unwrap(); 134 | 135 | assert_eq!(dto_source, dto_dist); 136 | } 137 | 138 | #[tokio::test] 139 | async fn assign_priority() { 140 | let provider = create_provider_with_two_entries().await; 141 | 142 | provider.assign_priority_to_entries(3).await.unwrap(); 143 | 144 | let entries = provider.load_all_entries().await.unwrap(); 145 | 146 | assert_eq!(entries[0].priority, Some(3)); 147 | assert_eq!(entries[1].priority, Some(1)); 148 | } 149 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | // trigger recompilation when a new migration is added 3 | println!("cargo:rerun-if-changed=backend/src/sqlite/migrations"); 4 | 5 | // Make sure one feature at least is enabled 6 | #[cfg(all(not(feature = "json"), not(feature = "sqlite")))] 7 | compile_error!("One feature at least must be enabled"); 8 | } 9 | -------------------------------------------------------------------------------- /src/app/colored_tags.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use ratatui::style::Color; 4 | 5 | /// Hard coded colors for the tags. 6 | /// Note: the order to pick the colors is from bottom to top because we are popping the colors from 7 | /// the end of the stack. 8 | const TAG_COLORS: &[TagColors] = &[ 9 | TagColors::new(Color::Black, Color::LightMagenta), 10 | TagColors::new(Color::Red, Color::Cyan), 11 | TagColors::new(Color::Yellow, Color::Blue), 12 | TagColors::new(Color::Reset, Color::Red), 13 | TagColors::new(Color::Black, Color::LightYellow), 14 | TagColors::new(Color::Reset, Color::DarkGray), 15 | TagColors::new(Color::Black, Color::LightGreen), 16 | TagColors::new(Color::Black, Color::LightRed), 17 | TagColors::new(Color::Black, Color::LightCyan), 18 | ]; 19 | 20 | #[derive(Debug, Clone)] 21 | /// Manages assigning colors to the tags, keeping track on the assigned colors and providing 22 | /// functions to updating them. 23 | pub struct ColoredTagsManager { 24 | tag_colors_map: HashMap, 25 | available_colors: Vec, 26 | } 27 | 28 | impl ColoredTagsManager { 29 | pub fn new() -> Self { 30 | let available_colors = TAG_COLORS.to_vec(); 31 | 32 | Self { 33 | tag_colors_map: HashMap::new(), 34 | available_colors, 35 | } 36 | } 37 | 38 | /// Updates the tag_color map with the provided tags, removing the not existing tags and 39 | /// assigning colors to the newly added ones. 40 | pub fn update_tags(&mut self, current_tags: Vec) { 41 | // First: Clear the non-existing anymore tags. 42 | let tags_to_remove: Vec<_> = self 43 | .tag_colors_map 44 | .keys() 45 | .filter(|t| !current_tags.contains(t)) 46 | .cloned() 47 | .collect(); 48 | 49 | for tag in tags_to_remove { 50 | let color = self.tag_colors_map.remove(&tag).unwrap(); 51 | self.available_colors.push(color) 52 | } 53 | 54 | // Second: Add the new tags to the map 55 | for tag in current_tags { 56 | match self.tag_colors_map.entry(tag) { 57 | std::collections::hash_map::Entry::Occupied(_) => {} 58 | std::collections::hash_map::Entry::Vacant(vacant_entry) => { 59 | let color = self.available_colors.pop().unwrap_or_default(); 60 | vacant_entry.insert(color); 61 | } 62 | } 63 | } 64 | } 65 | 66 | /// Gets the matching color for the giving tag if tag exists. 67 | pub fn get_tag_color(&self, tag: &str) -> Option { 68 | self.tag_colors_map.get(tag).copied() 69 | } 70 | } 71 | 72 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 73 | /// Represents the needed colors for a colored tag 74 | pub struct TagColors { 75 | pub foreground: Color, 76 | pub background: Color, 77 | } 78 | 79 | impl TagColors { 80 | pub const fn new(foreground: Color, background: Color) -> Self { 81 | Self { 82 | foreground, 83 | background, 84 | } 85 | } 86 | } 87 | 88 | #[cfg(test)] 89 | mod tests { 90 | 91 | use super::*; 92 | 93 | #[test] 94 | fn test_colored_tags() { 95 | const TAG_ONE: &str = "Tag 1"; 96 | const TAG_TWO: &str = "Tag 2"; 97 | const ADDED_TAG: &str = "Added Tag"; 98 | 99 | let mut tags = vec![ 100 | String::from(TAG_ONE), 101 | String::from(TAG_TWO), 102 | String::from("Tag 3"), 103 | String::from("Tag 4"), 104 | ]; 105 | 106 | let mut manager = ColoredTagsManager::new(); 107 | manager.update_tags(tags.clone()); 108 | 109 | // Ensure all tags have colors. 110 | for tag in tags.iter() { 111 | assert!(manager.get_tag_color(tag).is_some()); 112 | } 113 | 114 | // Ensure non existing tags are none 115 | assert!(manager.get_tag_color("Non Existing Tag").is_none()); 116 | 117 | // Keep track on colors before updating. 118 | let tag_one_color = manager.get_tag_color(TAG_ONE).unwrap(); 119 | let tag_two_color = manager.get_tag_color(TAG_TWO).unwrap(); 120 | 121 | // Remove Tag one with changing the order of the tags. 122 | assert_eq!(tags.swap_remove(0), TAG_ONE); 123 | 124 | tags.push(ADDED_TAG.into()); 125 | 126 | manager.update_tags(tags.clone()); 127 | 128 | // Ensure all current tags have colors. 129 | for tag in tags.iter() { 130 | assert!(manager.get_tag_color(tag).is_some()); 131 | } 132 | 133 | // Tag one should have no color after remove. 134 | assert!(manager.get_tag_color(TAG_ONE).is_none()); 135 | 136 | // Tag two color must remain the same after update. 137 | assert_eq!(manager.get_tag_color(TAG_TWO).unwrap(), tag_two_color); 138 | 139 | // Added tag should take the color of tag one because we removed it then added the new tag. 140 | assert_eq!(manager.get_tag_color(ADDED_TAG).unwrap(), tag_one_color); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/app/external_editor.rs: -------------------------------------------------------------------------------- 1 | use std::{env, ffi::OsStr, io, path::Path}; 2 | 3 | use anyhow::{anyhow, bail}; 4 | 5 | use crossterm::{ 6 | ExecutableCommand, 7 | terminal::{EnterAlternateScreen, LeaveAlternateScreen}, 8 | }; 9 | use scopeguard::defer; 10 | use tokio::process::Command; 11 | 12 | use crate::settings::Settings; 13 | 14 | const ENV_EDITOR_OPTIONS: [&str; 2] = ["VISUAL", "EDITOR"]; 15 | 16 | pub async fn open_editor(file_path: &Path, settings: &Settings) -> anyhow::Result<()> { 17 | if !file_path.exists() { 18 | bail!("file doesn't exist: {}", file_path.display()); 19 | } 20 | 21 | let file_path = file_path.canonicalize()?; 22 | 23 | let editor_raw = settings 24 | .external_editor 25 | .command 26 | .as_ref() 27 | .cloned() 28 | .or_else(|| get_git_editor().ok()) 29 | .or_else(|| env::var(ENV_EDITOR_OPTIONS[0]).ok()) 30 | .or_else(|| env::var(ENV_EDITOR_OPTIONS[1]).ok()) 31 | .unwrap_or(String::from("vi")); 32 | 33 | if editor_raw.is_empty() { 34 | bail!( 35 | "The Editor in configuration and environmental variables is empty: {}", 36 | ENV_EDITOR_OPTIONS.join(" - ") 37 | ); 38 | } 39 | 40 | let mut editor_chars = editor_raw.chars().peekable(); 41 | 42 | let start_char = editor_chars 43 | .peek() 44 | .expect("Editor name can't be empty") 45 | .to_owned(); 46 | 47 | let editor_cmd: String = match start_char { 48 | '\"' => editor_chars 49 | .by_ref() 50 | .skip(1) 51 | .take_while(|&c| c != '\"') 52 | .collect(), 53 | _ => editor_chars.by_ref().take_while(|&c| c != ' ').collect(), 54 | }; 55 | 56 | let rest_args: String = editor_chars.collect(); 57 | let mut args: Vec<&OsStr> = rest_args.split_whitespace().map(OsStr::new).collect(); 58 | 59 | args.push(file_path.as_os_str()); 60 | 61 | io::stdout().execute(LeaveAlternateScreen)?; 62 | defer! { 63 | io::stdout().execute(EnterAlternateScreen).unwrap(); 64 | } 65 | 66 | Command::new(editor_cmd.clone()) 67 | .args(args) 68 | .status() 69 | .await 70 | .map_err(|err| { 71 | anyhow!( 72 | "Error while opening the editor. Editor command: '{}'. Error: {}", 73 | editor_cmd, 74 | err 75 | ) 76 | })?; 77 | 78 | Ok(()) 79 | } 80 | 81 | /// Tries to get the configured git editor from Git global config. 82 | fn get_git_editor() -> anyhow::Result { 83 | let config = git2::Config::open_default()?; 84 | let editor = config.get_string("core.editor").map_err(|err| { 85 | log::trace!("Failed to retrieve git editor, Err: {err}"); 86 | err 87 | })?; 88 | 89 | log::trace!("Git editor is: {}", editor); 90 | 91 | Ok(editor) 92 | } 93 | -------------------------------------------------------------------------------- /src/app/filter/criterion.rs: -------------------------------------------------------------------------------- 1 | use aho_corasick::AhoCorasick; 2 | use backend::Entry; 3 | 4 | #[derive(Debug, Clone, PartialEq, Eq)] 5 | pub enum FilterCriterion { 6 | Tag(TagFilterOption), 7 | Title(String), 8 | Content(String), 9 | Priority(u32), 10 | } 11 | 12 | #[derive(Debug, Clone, PartialEq, Eq)] 13 | pub enum TagFilterOption { 14 | Tag(String), 15 | NoTags, 16 | } 17 | 18 | impl FilterCriterion { 19 | /// Checks if the entry meets the criterion 20 | pub fn check_entry(&self, entry: &Entry) -> bool { 21 | match self { 22 | FilterCriterion::Tag(TagFilterOption::Tag(tag)) => entry.tags.contains(tag), 23 | FilterCriterion::Tag(TagFilterOption::NoTags) => entry.tags.is_empty(), 24 | FilterCriterion::Title(search) => { 25 | // Use simple smart-case search for title 26 | if search.chars().any(|c| c.is_uppercase()) { 27 | entry.title.contains(search) 28 | } else { 29 | entry.title.to_lowercase().contains(search) 30 | } 31 | } 32 | FilterCriterion::Content(search) => { 33 | if search.chars().any(|c| c.is_uppercase()) { 34 | // Use simple search when pattern already has uppercase 35 | entry.content.contains(search) 36 | } else { 37 | // Otherwise use case insensitive pattern matcher 38 | let ac = match AhoCorasick::builder() 39 | .ascii_case_insensitive(true) 40 | .build([&search]) 41 | { 42 | Ok(ac) => ac, 43 | Err(err) => { 44 | log::error!( 45 | "Build AhoCorasick with pattern {search} failed with error: {err}" 46 | ); 47 | return false; 48 | } 49 | }; 50 | 51 | ac.find(&entry.content).is_some() 52 | } 53 | } 54 | FilterCriterion::Priority(prio) => entry.priority.is_some_and(|pr| pr == *prio), 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/filter/mod.rs: -------------------------------------------------------------------------------- 1 | use backend::Entry; 2 | use rayon::prelude::*; 3 | 4 | pub mod criterion; 5 | 6 | pub use criterion::FilterCriterion; 7 | 8 | #[derive(Debug, Clone, Copy)] 9 | pub enum CriteriaRelation { 10 | And, 11 | Or, 12 | } 13 | 14 | #[derive(Debug, Clone)] 15 | pub struct Filter { 16 | pub relation: CriteriaRelation, 17 | pub criteria: Vec, 18 | } 19 | 20 | impl Default for Filter { 21 | fn default() -> Self { 22 | Filter { 23 | relation: CriteriaRelation::And, 24 | criteria: Vec::new(), 25 | } 26 | } 27 | } 28 | 29 | impl Filter { 30 | /// Checks if the entry meets the filter criteria 31 | pub fn check_entry(&self, entry: &Entry) -> bool { 32 | match self.relation { 33 | CriteriaRelation::And => self.criteria.par_iter().all(|cr| cr.check_entry(entry)), 34 | CriteriaRelation::Or => self.criteria.par_iter().any(|cr| cr.check_entry(entry)), 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/history.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | use backend::Entry; 4 | use chrono::{DateTime, Utc}; 5 | 6 | #[derive(Debug)] 7 | /// Keeps history of the changes on entries, enabling undo & redo operations 8 | pub struct HistoryManager { 9 | undo_stack: VecDeque, 10 | redo_stack: VecDeque, 11 | /// Sets the size limit of each stack 12 | stacks_limit: usize, 13 | } 14 | 15 | impl HistoryManager { 16 | pub fn new(stacks_limit: usize) -> Self { 17 | Self { 18 | undo_stack: VecDeque::new(), 19 | redo_stack: VecDeque::new(), 20 | stacks_limit, 21 | } 22 | } 23 | 24 | /// Adds the given history [`Change`] to the corresponding stack of the given [`HistoryStack`] 25 | /// and keeping the stack within its allowed limit by dropping changes from the bottom if 26 | /// needed. 27 | fn add_to_stack(&mut self, change: Change, target: HistoryStack) { 28 | let stack = match target { 29 | HistoryStack::Undo => &mut self.undo_stack, 30 | HistoryStack::Redo => &mut self.redo_stack, 31 | }; 32 | stack.push_front(change); 33 | if stack.len() > self.stacks_limit { 34 | _ = stack.pop_back(); 35 | } 36 | } 37 | 38 | /// Register Add Change on the corresponding stack of the [`HistoryStack`] 39 | pub fn register_add(&mut self, target: HistoryStack, entry: &Entry) { 40 | log::trace!("History Register Add: Entry: {entry:?}"); 41 | let change = Change::AddEntry { id: entry.id }; 42 | self.add_to_stack(change, target); 43 | } 44 | 45 | /// Register Remove Entry Change on the corresponding stack of the [`HistoryStack`] 46 | pub fn register_remove(&mut self, target: HistoryStack, deleted_entry: Entry) { 47 | log::trace!("History Register Remove: Deleted Entry: {deleted_entry:?}"); 48 | let change = Change::RemoveEntry(Box::new(deleted_entry)); 49 | self.add_to_stack(change, target); 50 | } 51 | 52 | /// Register changes on Entry attributes on the corresponding stack of the [`HistoryStack`] 53 | pub fn register_change_attributes( 54 | &mut self, 55 | target: HistoryStack, 56 | entry_before_change: &Entry, 57 | ) { 58 | log::trace!("History Register Change attribute: Entry before: {entry_before_change:?}"); 59 | let change = Change::EntryAttribute(Box::new(entry_before_change.into())); 60 | self.add_to_stack(change, target); 61 | } 62 | 63 | /// Register changes on Entry content on the corresponding stack of the [`HistoryStack`] 64 | pub fn register_change_content(&mut self, target: HistoryStack, entry_before_change: &Entry) { 65 | log::trace!( 66 | "History Register Change content: Entry ID: {}", 67 | entry_before_change.id 68 | ); 69 | let change = Change::EntryContent { 70 | id: entry_before_change.id, 71 | content: entry_before_change.content.to_owned(), 72 | }; 73 | 74 | self.add_to_stack(change, target); 75 | } 76 | 77 | /// Pops the latest undo Change from its stack if available 78 | pub fn pop_undo(&mut self) -> Option { 79 | self.undo_stack.pop_front() 80 | } 81 | 82 | /// Pops the latest redo Change from its stack if available 83 | pub fn pop_redo(&mut self) -> Option { 84 | self.redo_stack.pop_front() 85 | } 86 | } 87 | 88 | #[derive(Debug, Clone, Copy)] 89 | /// Represents the types of history targets within the [`HistoryManager`] 90 | pub enum HistoryStack { 91 | Undo, 92 | Redo, 93 | } 94 | 95 | #[derive(Debug, Clone)] 96 | /// Represents a change to the entries and infos about their previous states. 97 | pub enum Change { 98 | /// Entry added with the given id 99 | AddEntry { id: u32 }, 100 | /// Entry removed. It contains the removed entry. 101 | RemoveEntry(Box), 102 | /// Entry attributes changed. It contains the attribute before the change. 103 | EntryAttribute(Box), 104 | /// Entry content changed. It contains the content before the change. 105 | EntryContent { id: u32, content: String }, 106 | } 107 | 108 | #[derive(Debug, Clone)] 109 | /// Contains the changes of attributes on an [`Entry`] to be saved in the history stacks 110 | pub struct EntryAttributes { 111 | pub id: u32, 112 | pub date: DateTime, 113 | pub title: String, 114 | pub tags: Vec, 115 | pub priority: Option, 116 | } 117 | 118 | impl From<&Entry> for EntryAttributes { 119 | fn from(entry: &Entry) -> Self { 120 | Self { 121 | id: entry.id, 122 | date: entry.date, 123 | title: entry.title.to_owned(), 124 | tags: entry.tags.to_owned(), 125 | priority: entry.priority.to_owned(), 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/app/runner.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use crossterm::event::{Event, EventStream, KeyEventKind}; 3 | use ratatui::{Terminal, backend::Backend}; 4 | 5 | use crate::app::{App, UIComponents}; 6 | use crate::cli::PendingCliCommand; 7 | use crate::settings::{BackendType, Settings}; 8 | use futures_util::StreamExt; 9 | 10 | use backend::DataProvider; 11 | #[cfg(feature = "json")] 12 | use backend::JsonDataProvide; 13 | #[cfg(feature = "sqlite")] 14 | use backend::SqliteDataProvide; 15 | 16 | use super::keymap::Input; 17 | use super::ui::Styles; 18 | use super::ui::ui_functions::render_message_centered; 19 | 20 | #[derive(Debug, PartialEq, Eq)] 21 | pub enum HandleInputReturnType { 22 | Handled, 23 | NotFound, 24 | ExitApp, 25 | Ignore, 26 | } 27 | 28 | pub async fn run( 29 | terminal: &mut Terminal, 30 | settings: Settings, 31 | styles: Styles, 32 | pending_cmd: Option, 33 | ) -> Result<()> { 34 | match settings.backend_type.unwrap_or_default() { 35 | #[cfg(feature = "json")] 36 | BackendType::Json => { 37 | let path = if let Some(path) = &settings.json_backend.file_path { 38 | path.clone() 39 | } else { 40 | crate::settings::json_backend::get_default_json_path()? 41 | }; 42 | let data_provider = JsonDataProvide::new(path); 43 | run_intern(terminal, data_provider, settings, styles, pending_cmd).await 44 | } 45 | #[cfg(not(feature = "json"))] 46 | BackendType::Json => { 47 | anyhow::bail!( 48 | "Feature 'json' is not installed. Please check your configs and set your backend to an installed feature, or reinstall the program with 'json' feature" 49 | ) 50 | } 51 | #[cfg(feature = "sqlite")] 52 | BackendType::Sqlite => { 53 | let path = if let Some(path) = &settings.sqlite_backend.file_path { 54 | path.clone() 55 | } else { 56 | crate::settings::sqlite_backend::get_default_sqlite_path()? 57 | }; 58 | let data_provider = SqliteDataProvide::from_file(path).await?; 59 | run_intern(terminal, data_provider, settings, styles, pending_cmd).await 60 | } 61 | #[cfg(not(feature = "sqlite"))] 62 | BackendType::Sqlite => { 63 | anyhow::bail!( 64 | "Feature 'sqlite' is not installed. Please check your configs and set your backend to an installed feature, or reinstall the program with 'sqlite' feature" 65 | ) 66 | } 67 | } 68 | } 69 | 70 | async fn run_intern( 71 | terminal: &mut Terminal, 72 | data_provider: D, 73 | settings: Settings, 74 | styles: Styles, 75 | pending_cmd: Option, 76 | ) -> anyhow::Result<()> 77 | where 78 | B: Backend, 79 | D: DataProvider, 80 | { 81 | let mut ui_components = UIComponents::new(styles); 82 | let mut app = App::new(data_provider, settings); 83 | if let Some(cmd) = pending_cmd { 84 | if let Err(err) = exec_pending_cmd(terminal, &app, cmd).await { 85 | ui_components.show_err_msg(err.to_string()); 86 | } 87 | } 88 | 89 | app.load_state(&mut ui_components); 90 | 91 | if let Err(err) = app.load_entries().await { 92 | ui_components.show_err_msg(err.to_string()); 93 | } 94 | 95 | ui_components.set_current_entry(app.entries.first().map(|entry| entry.id), &mut app); 96 | 97 | draw_ui(terminal, &mut app, &mut ui_components)?; 98 | 99 | let mut input_stream = EventStream::new(); 100 | while let Some(event) = input_stream.next().await { 101 | let event = event.context("Error getting input stream")?; 102 | match handle_input(event, &mut app, &mut ui_components).await { 103 | Ok(result) => { 104 | match result { 105 | HandleInputReturnType::Handled => { 106 | ui_components.update_current_entry(&mut app); 107 | draw_ui(terminal, &mut app, &mut ui_components)?; 108 | } 109 | HandleInputReturnType::NotFound => { 110 | // UI should be drawn even if the input isn't handled in the app logic to 111 | // catch events like resize, Font resize, Mouse activation... 112 | draw_ui(terminal, &mut app, &mut ui_components)?; 113 | } 114 | HandleInputReturnType::ExitApp => { 115 | // Logging persisting errors by closing the app is enough 116 | if let Err(err) = app.persist_state() { 117 | log::error!("Persisting app state failed: Error info {err}"); 118 | } 119 | 120 | return Ok(()); 121 | } 122 | HandleInputReturnType::Ignore => {} 123 | }; 124 | } 125 | Err(err) => { 126 | ui_components.show_err_msg(err.to_string()); 127 | draw_ui(terminal, &mut app, &mut ui_components)?; 128 | } 129 | } 130 | } 131 | 132 | Ok(()) 133 | } 134 | 135 | async fn exec_pending_cmd( 136 | terminal: &mut Terminal, 137 | app: &App, 138 | pending_cmd: PendingCliCommand, 139 | ) -> anyhow::Result<()> { 140 | match pending_cmd { 141 | PendingCliCommand::ImportJournals(file_path) => { 142 | terminal.draw(|f| render_message_centered(f, "Importing journals..."))?; 143 | 144 | app.import_entries(file_path).await?; 145 | } 146 | PendingCliCommand::AssignPriority(priority) => { 147 | terminal.draw(|f| render_message_centered(f, "Assigning Priority to Journals..."))?; 148 | app.assign_priority_to_entries(priority).await?; 149 | } 150 | } 151 | 152 | Ok(()) 153 | } 154 | 155 | fn draw_ui( 156 | terminal: &mut Terminal, 157 | app: &mut App, 158 | ui_components: &mut UIComponents, 159 | ) -> anyhow::Result<()> { 160 | if app.redraw_after_restore { 161 | app.redraw_after_restore = false; 162 | // Apply hide cursor again after closing the external editor 163 | terminal.hide_cursor()?; 164 | // Clear the terminal and force a full redraw on the next draw call. 165 | terminal.clear()?; 166 | } 167 | 168 | terminal.draw(|f| ui_components.render_ui(f, app))?; 169 | 170 | Ok(()) 171 | } 172 | 173 | async fn handle_input( 174 | event: Event, 175 | app: &mut App, 176 | ui_components: &mut UIComponents<'_>, 177 | ) -> Result { 178 | if let Event::Key(key) = event { 179 | match key.kind { 180 | KeyEventKind::Press => { 181 | let input = Input::from(&key); 182 | ui_components.handle_input(&input, app).await 183 | } 184 | KeyEventKind::Repeat | KeyEventKind::Release => Ok(HandleInputReturnType::Ignore), 185 | } 186 | } else { 187 | Ok(HandleInputReturnType::NotFound) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/app/sorter.rs: -------------------------------------------------------------------------------- 1 | use backend::Entry; 2 | use serde::{Deserialize, Serialize}; 3 | use std::{cmp::Ordering, fmt::Display}; 4 | 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] 6 | pub enum SortCriteria { 7 | Date, 8 | Priority, 9 | Title, 10 | } 11 | 12 | impl Display for SortCriteria { 13 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 14 | match self { 15 | SortCriteria::Date => write!(f, "Date"), 16 | SortCriteria::Priority => write!(f, "Priority"), 17 | SortCriteria::Title => write!(f, "Title"), 18 | } 19 | } 20 | } 21 | 22 | impl SortCriteria { 23 | fn compare(&self, entry1: &Entry, entry2: &Entry, order: &SortOrder) -> Ordering { 24 | let ascending_ord = match self { 25 | SortCriteria::Date => entry1.date.cmp(&entry2.date), 26 | SortCriteria::Priority => entry1.priority.cmp(&entry2.priority), 27 | SortCriteria::Title => entry1.title.cmp(&entry2.title), 28 | }; 29 | 30 | match order { 31 | SortOrder::Ascending => ascending_ord, 32 | SortOrder::Descending => ascending_ord.reverse(), 33 | } 34 | } 35 | 36 | pub fn iterator() -> impl Iterator { 37 | use SortCriteria as S; 38 | 39 | // Static assertions to make sure all sort criteria are invloved in the iterator 40 | if cfg!(debug_assertions) { 41 | match S::Date { 42 | S::Date => (), 43 | S::Priority => (), 44 | S::Title => (), 45 | }; 46 | } 47 | 48 | [S::Date, S::Priority, S::Title].iter().copied() 49 | } 50 | } 51 | 52 | #[derive(Debug, Clone, Copy, Deserialize, Serialize)] 53 | pub enum SortOrder { 54 | Ascending, 55 | Descending, 56 | } 57 | 58 | impl Display for SortOrder { 59 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 60 | match self { 61 | SortOrder::Ascending => write!(f, "Ascending"), 62 | SortOrder::Descending => write!(f, "Descending"), 63 | } 64 | } 65 | } 66 | 67 | #[derive(Debug, Clone, Serialize, Deserialize)] 68 | pub struct Sorter { 69 | criteria: Vec, 70 | pub order: SortOrder, 71 | } 72 | 73 | impl Default for Sorter { 74 | fn default() -> Self { 75 | let criteria = vec![SortCriteria::Date, SortCriteria::Priority]; 76 | 77 | Self { 78 | criteria, 79 | order: SortOrder::Descending, 80 | } 81 | } 82 | } 83 | 84 | impl Sorter { 85 | pub fn set_criteria(&mut self, criteria: Vec) { 86 | self.criteria = criteria; 87 | } 88 | 89 | pub fn get_criteria(&self) -> &[SortCriteria] { 90 | &self.criteria 91 | } 92 | 93 | pub fn sort(&self, entry1: &Entry, entry2: &Entry) -> Ordering { 94 | self.criteria 95 | .iter() 96 | .map(|cr| cr.compare(entry1, entry2, &self.order)) 97 | .find(|cmp| matches!(cmp, Ordering::Less | Ordering::Greater)) 98 | .unwrap_or(Ordering::Equal) 99 | } 100 | } 101 | 102 | #[cfg(test)] 103 | mod test { 104 | use chrono::{TimeZone, Utc}; 105 | 106 | use super::*; 107 | 108 | fn get_default_entries() -> Vec { 109 | vec![ 110 | Entry::new( 111 | 0, 112 | Utc.with_ymd_and_hms(2023, 12, 2, 1, 2, 3).unwrap(), 113 | String::from("Title 2"), 114 | String::from("Content 2"), 115 | vec![], 116 | Some(1), 117 | ), 118 | Entry::new( 119 | 1, 120 | Utc.with_ymd_and_hms(2023, 10, 12, 11, 22, 33).unwrap(), 121 | String::from("Title 1"), 122 | String::from("Content 1"), 123 | vec![String::from("Tag 1"), String::from("Tag 2")], 124 | None, 125 | ), 126 | Entry::new( 127 | 2, 128 | Utc.with_ymd_and_hms(2024, 1, 2, 1, 2, 3).unwrap(), 129 | String::from("Title 2"), // This is intentionally 130 | String::from("Content 3"), 131 | vec![], 132 | Some(2), 133 | ), 134 | ] 135 | } 136 | 137 | fn get_ids(entries: &[Entry]) -> Vec { 138 | entries.iter().map(|e| e.id).collect() 139 | } 140 | 141 | #[test] 142 | fn sort_single_date() { 143 | let mut sorter = Sorter::default(); 144 | sorter.set_criteria(vec![SortCriteria::Date]); 145 | sorter.order = SortOrder::Ascending; 146 | 147 | let mut entries = get_default_entries(); 148 | entries.sort_by(|e1, e2| sorter.sort(e1, e2)); 149 | let ids = get_ids(&entries); 150 | assert_eq!(ids, vec![1, 0, 2], "Date Ascending"); 151 | 152 | sorter.order = SortOrder::Descending; 153 | entries.sort_by(|e1, e2| sorter.sort(e1, e2)); 154 | let ids = get_ids(&entries); 155 | assert_eq!(ids, vec![2, 0, 1], "Date Descending"); 156 | } 157 | 158 | #[test] 159 | fn sort_single_priority() { 160 | let mut sorter = Sorter::default(); 161 | sorter.set_criteria(vec![SortCriteria::Priority]); 162 | sorter.order = SortOrder::Ascending; 163 | 164 | let mut entries = get_default_entries(); 165 | entries.sort_by(|e1, e2| sorter.sort(e1, e2)); 166 | let ids = get_ids(&entries); 167 | assert_eq!(ids, vec![1, 0, 2], "Priority Ascending"); 168 | 169 | sorter.order = SortOrder::Descending; 170 | entries.sort_by(|e1, e2| sorter.sort(e1, e2)); 171 | let ids = get_ids(&entries); 172 | assert_eq!(ids, vec![2, 0, 1], "Priority Descending"); 173 | } 174 | 175 | #[test] 176 | fn sort_single_title() { 177 | let mut sorter = Sorter::default(); 178 | sorter.set_criteria(vec![SortCriteria::Title]); 179 | sorter.order = SortOrder::Ascending; 180 | 181 | let mut entries = get_default_entries(); 182 | entries.sort_by(|e1, e2| sorter.sort(e1, e2)); 183 | let ids = get_ids(&entries); 184 | assert_eq!(ids, vec![1, 0, 2], "Title Ascending"); 185 | 186 | sorter.order = SortOrder::Descending; 187 | entries.sort_by(|e1, e2| sorter.sort(e1, e2)); 188 | let ids = get_ids(&entries); 189 | assert_eq!(ids, vec![0, 2, 1], "Title Descending"); 190 | } 191 | 192 | #[test] 193 | fn sort_multi() { 194 | let mut sorter = Sorter::default(); 195 | sorter.set_criteria(vec![SortCriteria::Title, SortCriteria::Priority]); 196 | sorter.order = SortOrder::Ascending; 197 | 198 | let mut entries = get_default_entries(); 199 | let mut first_clone = entries[0].clone(); 200 | first_clone.id = 3; 201 | first_clone.priority = Some(3); 202 | entries.push(first_clone); 203 | 204 | entries.sort_by(|e1, e2| sorter.sort(e1, e2)); 205 | let ids = get_ids(&entries); 206 | assert_eq!(ids, vec![1, 0, 2, 3], "Multi Ascending"); 207 | 208 | sorter.order = SortOrder::Descending; 209 | entries.sort_by(|e1, e2| sorter.sort(e1, e2)); 210 | let ids = get_ids(&entries); 211 | assert_eq!(ids, vec![3, 2, 0, 1], "Multi Descending"); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/app/state.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, io::BufWriter}; 2 | 3 | use directories::BaseDirs; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use super::*; 7 | 8 | const STATE_FILE_NAME: &str = "state.json"; 9 | 10 | #[derive(Debug, Serialize, Deserialize, Default)] 11 | pub struct AppState { 12 | pub sorter: Sorter, 13 | pub full_screen: bool, 14 | } 15 | 16 | impl AppState { 17 | pub fn load(settings: &Settings) -> anyhow::Result { 18 | // Move state file from legacy data directory to the new state directory 19 | // to avoid breaking changes on users. 20 | // TODO: Remove this after three releases. 21 | if settings.app_state_dir.is_none() { 22 | Self::move_legacy_state(); 23 | } 24 | 25 | let state_path = Self::get_persist_path(settings)?; 26 | 27 | let state = if state_path.exists() { 28 | let state_file = File::open(state_path) 29 | .map_err(|err| anyhow!("Failed to load state file. Error info: {err}"))?; 30 | serde_json::from_reader(state_file) 31 | .map_err(|err| anyhow!("Failed to read state file. Error info: {err}"))? 32 | } else { 33 | AppState::default() 34 | }; 35 | 36 | Ok(state) 37 | } 38 | 39 | fn get_persist_path(settings: &Settings) -> anyhow::Result { 40 | if let Some(path) = settings.app_state_dir.as_ref() { 41 | Ok(path.join(STATE_FILE_NAME)) 42 | } else { 43 | Self::default_persist_dir().map(|dir| dir.join(STATE_FILE_NAME)) 44 | } 45 | } 46 | 47 | pub fn save(&self, settings: &Settings) -> anyhow::Result<()> { 48 | let state_path = Self::get_persist_path(settings)?; 49 | if let Some(parent) = state_path.parent() { 50 | fs::create_dir_all(parent)?; 51 | } 52 | 53 | let state_file = File::create(state_path)?; 54 | let state_writer = BufWriter::new(state_file); 55 | 56 | serde_json::to_writer_pretty(state_writer, self)?; 57 | 58 | Ok(()) 59 | } 60 | 61 | /// Return the default path of the directory used to persist the application state. 62 | /// It uses the state directories on supported platforms falling back to the data directory. 63 | pub fn default_persist_dir() -> anyhow::Result { 64 | BaseDirs::new() 65 | .map(|base_dirs| { 66 | base_dirs 67 | .state_dir() 68 | .unwrap_or_else(|| base_dirs.data_dir()) 69 | .join("tui-journal") 70 | }) 71 | .context("Config file path couldn't be retrieved") 72 | } 73 | 74 | /// Move app state from legacy path to the new one if the legacy exists and the new doesn't. 75 | fn move_legacy_state() { 76 | // Return early if operating system doesn't support `state_dir()` 77 | let state_path = match BaseDirs::new() 78 | .map(|base_dirs| base_dirs.state_dir().map(|state| state.join("tui-journal"))) 79 | { 80 | Some(Some(state)) => state, 81 | _ => return, 82 | }; 83 | 84 | // Gets legacy path which was used to store the state previously. 85 | let legacy_data_dir = 86 | match BaseDirs::new().map(|base_dirs| base_dirs.data_dir().join("tui-journal")) { 87 | Some(path) => path, 88 | None => return, 89 | }; 90 | // Legacy already removed -> Done 91 | if !legacy_data_dir.exists() { 92 | return; 93 | } 94 | 95 | let legacy_state_file = legacy_data_dir.join(STATE_FILE_NAME); 96 | if !legacy_state_file.exists() { 97 | // Legacy dir exists but it has no files -> remove it -> Done. 98 | if let Err(err) = std::fs::remove_dir_all(&legacy_data_dir) { 99 | log::error!( 100 | "Legacy State: Removing legacy directory failed. path: {}, Error {err}", 101 | legacy_data_dir.display() 102 | ); 103 | } 104 | return; 105 | } 106 | 107 | let new_state_file = state_path.join(STATE_FILE_NAME); 108 | if new_state_file.exists() { 109 | // New state file exists somehow -> Remove old state directory to avoid running this 110 | // again. 111 | if let Err(err) = std::fs::remove_dir_all(&legacy_data_dir) { 112 | log::error!( 113 | "Legacy State: Removing legacy directory failed. path: {}, Error {err}", 114 | legacy_data_dir.display() 115 | ); 116 | } 117 | 118 | return; 119 | } 120 | 121 | if !state_path.exists() { 122 | // Create new state directory if not exists. 123 | if let Err(err) = std::fs::create_dir_all(&state_path) { 124 | log::error!( 125 | "Legacy State: Creating state dir filed. Path: {}, Error {err}", 126 | state_path.display() 127 | ); 128 | return; 129 | } 130 | } 131 | 132 | // Move state file. 133 | if let Err(err) = std::fs::rename(legacy_state_file, new_state_file) { 134 | log::error!("Legacy State: Moving legacy state file failed. Error {err}"); 135 | return; 136 | } 137 | 138 | // Finally remove legacy state directory. 139 | if let Err(err) = std::fs::remove_dir_all(&legacy_data_dir) { 140 | log::error!( 141 | "Legacy State: Removing legacy directory failed. path: {}, Error {err}", 142 | legacy_data_dir.display() 143 | ); 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/app/test/filter.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::app::filter::CriteriaRelation; 3 | 4 | #[tokio::test] 5 | async fn test_filter() { 6 | let mut app = create_default_app(); 7 | app.load_entries().await.unwrap(); 8 | 9 | app.current_entry_id = Some(0); 10 | 11 | let mut filter = Filter::default(); 12 | filter 13 | .criteria 14 | .push(FilterCriterion::Title(String::from("Title 2"))); 15 | app.apply_filter(Some(filter)); 16 | 17 | assert_eq!(app.get_active_entries().count(), 1); 18 | assert!(app.get_current_entry().is_none()); 19 | let entry = app.get_active_entries().next().unwrap(); 20 | assert_eq!(entry.id, 1); 21 | assert_eq!(entry.title, String::from("Title 2")); 22 | assert!(app.get_entry(0).is_none()); 23 | 24 | app.apply_filter(None); 25 | assert_eq!(app.get_active_entries().count(), 2); 26 | } 27 | 28 | #[tokio::test] 29 | async fn test_title_smart_case() { 30 | let mut app = create_default_app(); 31 | app.load_entries().await.unwrap(); 32 | 33 | app.current_entry_id = Some(0); 34 | let mut filter = Filter::default(); 35 | filter 36 | .criteria 37 | .push(FilterCriterion::Title(String::from("title 2"))); 38 | app.apply_filter(Some(filter)); 39 | 40 | assert_eq!(app.get_active_entries().count(), 1); 41 | assert!(app.get_current_entry().is_none()); 42 | let entry = app.get_active_entries().next().unwrap(); 43 | assert_eq!(entry.id, 1); 44 | assert_eq!(entry.title, String::from("Title 2")); 45 | assert!(app.get_entry(0).is_none()); 46 | 47 | app.apply_filter(None); 48 | assert_eq!(app.get_active_entries().count(), 2); 49 | } 50 | 51 | #[tokio::test] 52 | async fn test_content_smart_case() { 53 | let mut app = create_default_app(); 54 | app.load_entries().await.unwrap(); 55 | 56 | app.current_entry_id = Some(0); 57 | let mut filter = Filter::default(); 58 | filter 59 | .criteria 60 | .push(FilterCriterion::Content(String::from("content 2"))); 61 | app.apply_filter(Some(filter)); 62 | 63 | assert_eq!(app.get_active_entries().count(), 1); 64 | assert!(app.get_current_entry().is_none()); 65 | let entry = app.get_active_entries().next().unwrap(); 66 | assert_eq!(entry.id, 1); 67 | assert_eq!(entry.content, String::from("Content 2")); 68 | assert!(app.get_entry(0).is_none()); 69 | 70 | app.apply_filter(None); 71 | assert_eq!(app.get_active_entries().count(), 2); 72 | } 73 | 74 | #[tokio::test] 75 | async fn test_filter_priority() { 76 | let mut app = create_default_app(); 77 | app.load_entries().await.unwrap(); 78 | 79 | app.current_entry_id = Some(0); 80 | 81 | let mut filter = Filter::default(); 82 | filter.criteria.push(FilterCriterion::Priority(1)); 83 | app.apply_filter(Some(filter)); 84 | 85 | assert_eq!(app.get_active_entries().count(), 1); 86 | assert!(app.get_current_entry().is_none()); 87 | let entry = app.get_active_entries().next().unwrap(); 88 | assert_eq!(entry.id, 1); 89 | assert_eq!(entry.priority, Some(1)); 90 | assert!(app.get_entry(0).is_none()); 91 | 92 | app.apply_filter(None); 93 | assert_eq!(app.get_active_entries().count(), 2); 94 | } 95 | 96 | #[tokio::test] 97 | async fn test_filter_relations() { 98 | let mut app = create_default_app(); 99 | app.load_entries().await.unwrap(); 100 | let criteria = vec![ 101 | FilterCriterion::Content("1".into()), 102 | FilterCriterion::Content("2".into()), 103 | ]; 104 | 105 | let mut filter = Filter { 106 | criteria, 107 | relation: CriteriaRelation::Or, 108 | }; 109 | 110 | app.apply_filter(Some(filter.clone())); 111 | 112 | assert_eq!(app.get_active_entries().count(), 2); 113 | 114 | filter.relation = CriteriaRelation::And; 115 | app.apply_filter(Some(filter)); 116 | 117 | assert_eq!(app.get_active_entries().count(), 0); 118 | } 119 | 120 | #[tokio::test] 121 | async fn cycle_tag_no_tags() { 122 | let mut app = App::new(MockDataProvider::default(), Settings::default()); 123 | app.load_entries().await.unwrap(); 124 | 125 | // Check empty app doesn't panic 126 | app.cycle_tags_in_filter(); 127 | 128 | app.add_entry("Title_1".into(), Utc::now(), Vec::new(), Some(1)) 129 | .await 130 | .unwrap(); 131 | app.add_entry("Title_2".into(), Utc::now(), Vec::new(), Some(2)) 132 | .await 133 | .unwrap(); 134 | 135 | // No panic on cycle with not tags 136 | app.cycle_tags_in_filter(); 137 | } 138 | 139 | #[tokio::test] 140 | async fn cycle_tag_no_existing_filter() { 141 | // default project has two tags 142 | let mut app = create_default_app(); 143 | app.load_entries().await.unwrap(); 144 | 145 | for _ in 0..3 { 146 | app.cycle_tags_in_filter(); 147 | 148 | // Filter exits and have one criteria 149 | assert_eq!(app.filter.as_ref().unwrap().criteria.len(), 1); 150 | 151 | // Check the criteria 152 | let criteria = app 153 | .filter 154 | .as_ref() 155 | .and_then(|f| f.criteria.first()) 156 | .unwrap(); 157 | assert!( 158 | matches!(criteria, FilterCriterion::Tag(_)), 159 | "Expected Tag criteria. found {criteria:?}" 160 | ); 161 | } 162 | } 163 | 164 | #[tokio::test] 165 | async fn cycle_tag_exact() { 166 | // default project has two tags "Tag 1" "Tag 2" 167 | let mut app = create_default_app(); 168 | app.load_entries().await.unwrap(); 169 | 170 | app.cycle_tags_in_filter(); 171 | 172 | // First iteration must be first tag 173 | match app 174 | .filter 175 | .as_ref() 176 | .and_then(|f| f.criteria.first()) 177 | .unwrap() 178 | { 179 | FilterCriterion::Tag(TagFilterOption::Tag(s)) => assert_eq!(s, "Tag 1"), 180 | invalid => panic!("Invalid criteria: {invalid:?}"), 181 | } 182 | 183 | app.cycle_tags_in_filter(); 184 | 185 | // Second iteration must be second tag 186 | match app 187 | .filter 188 | .as_ref() 189 | .and_then(|f| f.criteria.first()) 190 | .unwrap() 191 | { 192 | FilterCriterion::Tag(TagFilterOption::Tag(s)) => assert_eq!(s, "Tag 2"), 193 | invalid => panic!("Invalid criteria: {invalid:?}"), 194 | } 195 | 196 | // 3rd iteration is for untagged entries 197 | app.cycle_tags_in_filter(); 198 | 199 | match app 200 | .filter 201 | .as_ref() 202 | .and_then(|f| f.criteria.first()) 203 | .unwrap() 204 | { 205 | FilterCriterion::Tag(TagFilterOption::NoTags) => {} 206 | invalid => panic!("Invalid criteria: {invalid:?}"), 207 | } 208 | 209 | // 4th iteration must go back to first tag 210 | app.cycle_tags_in_filter(); 211 | match app 212 | .filter 213 | .as_ref() 214 | .and_then(|f| f.criteria.first()) 215 | .unwrap() 216 | { 217 | FilterCriterion::Tag(TagFilterOption::Tag(s)) => assert_eq!(s, "Tag 1"), 218 | invalid => panic!("Invalid criteria: {invalid:?}"), 219 | } 220 | } 221 | 222 | #[tokio::test] 223 | async fn cycle_tag_existing_filter() { 224 | // default project has two tags "Tag 1" "Tag 2" 225 | let mut app = create_default_app(); 226 | app.load_entries().await.unwrap(); 227 | app.add_entry( 228 | "Title_3".into(), 229 | Utc::now(), 230 | vec!["New".into(), "Other".into()], 231 | Some(55), 232 | ) 233 | .await 234 | .unwrap(); 235 | 236 | let mut filter = Filter::default(); 237 | filter.criteria.push(FilterCriterion::Title("Title".into())); 238 | app.apply_filter(Some(filter)); 239 | 240 | app.cycle_tags_in_filter(); 241 | 242 | // Filter exits and have two criteria 243 | assert_eq!(app.filter.as_ref().unwrap().criteria.len(), 2); 244 | 245 | // Criteria must be one tag 246 | assert_eq!( 247 | app.filter 248 | .as_ref() 249 | .unwrap() 250 | .criteria 251 | .iter() 252 | .filter(|c| matches!(c, FilterCriterion::Tag(_))) 253 | .count(), 254 | 1 255 | ); 256 | // Criteria must be one title 257 | assert_eq!( 258 | app.filter 259 | .as_ref() 260 | .unwrap() 261 | .criteria 262 | .iter() 263 | .filter(|c| matches!(c, FilterCriterion::Title(_))) 264 | .count(), 265 | 1 266 | ); 267 | } 268 | -------------------------------------------------------------------------------- /src/app/test/mock.rs: -------------------------------------------------------------------------------- 1 | use std::sync::RwLock; 2 | 3 | use backend::ModifyEntryError; 4 | 5 | use super::*; 6 | 7 | #[derive(Default)] 8 | pub struct MockDataProvider { 9 | entries: RwLock>, 10 | return_error: bool, 11 | } 12 | 13 | impl MockDataProvider { 14 | pub fn new_with_data() -> Self { 15 | let entries = RwLock::from(get_default_entries()); 16 | MockDataProvider { 17 | entries, 18 | return_error: false, 19 | } 20 | } 21 | 22 | pub fn set_return_err(&mut self, return_error: bool) { 23 | self.return_error = return_error 24 | } 25 | 26 | fn early_return(&self) -> anyhow::Result<()> { 27 | match self.return_error { 28 | true => bail!("Test Error"), 29 | false => Ok(()), 30 | } 31 | } 32 | } 33 | 34 | impl DataProvider for MockDataProvider { 35 | async fn load_all_entries(&self) -> anyhow::Result> { 36 | self.early_return()?; 37 | 38 | Ok(self.entries.read().unwrap().clone()) 39 | } 40 | 41 | async fn add_entry(&self, entry: EntryDraft) -> Result { 42 | self.early_return()?; 43 | let mut entries = self.entries.write().unwrap(); 44 | let new_id = entries.last().map_or(0, |entry| entry.id + 1); 45 | 46 | let entry = Entry::from_draft(new_id, entry); 47 | 48 | entries.push(entry.clone()); 49 | 50 | Ok(entry) 51 | } 52 | 53 | async fn remove_entry(&self, entry_id: u32) -> anyhow::Result<()> { 54 | self.early_return()?; 55 | 56 | let mut entries = self.entries.write().unwrap(); 57 | 58 | entries.retain(|entry| entry.id != entry_id); 59 | 60 | Ok(()) 61 | } 62 | 63 | async fn update_entry(&self, entry: Entry) -> Result { 64 | self.early_return()?; 65 | 66 | let mut entry_clone = entry.clone(); 67 | 68 | let mut entries = self.entries.write().unwrap(); 69 | 70 | let entry_to_change = entries 71 | .iter_mut() 72 | .find(|e| e.id == entry.id) 73 | .ok_or(anyhow!("No item found"))?; 74 | 75 | std::mem::swap(entry_to_change, &mut entry_clone); 76 | 77 | Ok(entry) 78 | } 79 | 80 | async fn get_export_object(&self, entries_ids: &[u32]) -> anyhow::Result { 81 | self.early_return()?; 82 | 83 | let entries = self.entries.read().unwrap(); 84 | 85 | Ok(EntriesDTO::new( 86 | entries 87 | .iter() 88 | .filter(|entry| entries_ids.contains(&entry.id)) 89 | .cloned() 90 | .map(EntryDraft::from_entry) 91 | .collect(), 92 | )) 93 | } 94 | 95 | async fn import_entries(&self, entries_dto: EntriesDTO) -> anyhow::Result<()> { 96 | self.early_return()?; 97 | 98 | for draft in entries_dto.entries { 99 | self.add_entry(draft).await?; 100 | } 101 | 102 | Ok(()) 103 | } 104 | 105 | async fn assign_priority_to_entries(&self, _priority: u32) -> anyhow::Result<()> { 106 | unimplemented!("There are not tests for assigning priority on the app level"); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/app/test/mod.rs: -------------------------------------------------------------------------------- 1 | use chrono::TimeZone; 2 | 3 | mod filter; 4 | 5 | use self::mock::MockDataProvider; 6 | 7 | use super::*; 8 | 9 | mod mock; 10 | mod undo_redo; 11 | 12 | fn get_default_entries() -> Vec { 13 | vec![ 14 | Entry::new( 15 | 0, 16 | Utc.with_ymd_and_hms(2023, 10, 12, 11, 22, 33).unwrap(), 17 | String::from("Title 1"), 18 | String::from("Content 1"), 19 | vec![String::from("Tag 1"), String::from("Tag 2")], 20 | None, 21 | ), 22 | Entry::new( 23 | 1, 24 | Utc.with_ymd_and_hms(2023, 12, 2, 1, 2, 3).unwrap(), 25 | String::from("Title 2"), 26 | String::from("Content 2"), 27 | vec![], 28 | Some(1), 29 | ), 30 | ] 31 | } 32 | 33 | fn create_default_app() -> App { 34 | let settings = Settings::default(); 35 | let data_provider = MockDataProvider::new_with_data(); 36 | 37 | App::new(data_provider, settings) 38 | } 39 | 40 | #[tokio::test] 41 | async fn test_load_items() { 42 | let mut app = create_default_app(); 43 | app.load_entries().await.unwrap(); 44 | 45 | let app_entries: Vec = app.get_active_entries().cloned().collect(); 46 | 47 | let mut default_entries = get_default_entries(); 48 | default_entries.reverse(); 49 | 50 | assert_eq!(app_entries, default_entries); 51 | } 52 | 53 | #[tokio::test] 54 | async fn test_data_provider_errors() { 55 | let settings = Settings::default(); 56 | let mut data_provider = MockDataProvider::new_with_data(); 57 | data_provider.set_return_err(true); 58 | 59 | let mut app = App::new(data_provider, settings); 60 | 61 | assert!(app.load_entries().await.is_err()); 62 | assert!(app.get_active_entries().next().is_none()); 63 | assert!(app.get_entry(0).is_none()); 64 | assert!(app.get_all_tags().is_empty()); 65 | assert!( 66 | app.add_entry("title".into(), Utc::now(), Vec::new(), Some(1)) 67 | .await 68 | .is_err() 69 | ); 70 | assert!(app.delete_entry(0).await.is_err()); 71 | assert!(app.get_current_entry().is_none()); 72 | assert!(app.export_entries(PathBuf::default()).await.is_err()); 73 | assert!(app.import_entries(PathBuf::default()).await.is_err()); 74 | } 75 | 76 | #[tokio::test] 77 | async fn test_get_tags() { 78 | let mut app = create_default_app(); 79 | app.load_entries().await.unwrap(); 80 | 81 | let tags = vec![String::from("Tag 1"), String::from("Tag 2")]; 82 | 83 | assert_eq!(app.get_all_tags(), tags); 84 | } 85 | 86 | #[tokio::test] 87 | async fn test_add_entry() { 88 | let mut app = create_default_app(); 89 | app.load_entries().await.unwrap(); 90 | 91 | let tag = String::from("Added Tag"); 92 | let title = String::from("Added Title"); 93 | let date = Utc::now(); 94 | 95 | app.add_entry(title.clone(), date.clone(), vec![tag.clone()], Some(1)) 96 | .await 97 | .unwrap(); 98 | 99 | assert_eq!(app.get_active_entries().count(), 3); 100 | let added_entry = app.get_active_entries().find(|e| e.id == 2).unwrap(); 101 | assert_eq!(added_entry.title, title); 102 | assert_eq!(added_entry.date, date); 103 | assert_eq!(added_entry.tags, vec![tag]); 104 | assert_eq!(added_entry.priority, Some(1)); 105 | assert_eq!(app.get_all_tags().len(), 3); 106 | } 107 | 108 | #[tokio::test] 109 | async fn test_remove_entry() { 110 | let mut app = create_default_app(); 111 | app.load_entries().await.unwrap(); 112 | 113 | app.delete_entry(0).await.unwrap(); 114 | 115 | assert_eq!(app.get_active_entries().count(), 1); 116 | let entry = app.get_active_entries().next().unwrap(); 117 | assert_eq!(entry.id, 1); 118 | assert!(app.get_all_tags().is_empty()); 119 | } 120 | 121 | #[tokio::test] 122 | async fn test_current_entry() { 123 | let mut app = create_default_app(); 124 | app.load_entries().await.unwrap(); 125 | 126 | app.current_entry_id = Some(0); 127 | 128 | let current_entry = app.get_current_entry().unwrap(); 129 | 130 | assert_eq!(current_entry.id, 0); 131 | assert_eq!(current_entry.tags.len(), 2); 132 | assert_eq!(current_entry.title, String::from("Title 1")); 133 | } 134 | 135 | async fn add_extra_entries_drafts(app: &mut App) { 136 | let drafts = [ 137 | EntryDraft::new( 138 | Utc.with_ymd_and_hms(2023, 11, 12, 11, 22, 33).unwrap(), 139 | String::from("Title 3"), 140 | vec![String::from("Tag 1"), String::from("Tag 2")], 141 | Some(2), 142 | ), 143 | EntryDraft::new( 144 | Utc.with_ymd_and_hms(2022, 12, 2, 1, 2, 3).unwrap(), 145 | String::from("Title 4"), 146 | vec![], 147 | Some(4), 148 | ), 149 | EntryDraft::new( 150 | Utc.with_ymd_and_hms(2023, 1, 2, 1, 2, 3).unwrap(), 151 | String::from("Title 5"), 152 | vec![String::from("Tag 1")], 153 | Some(3), 154 | ), 155 | ]; 156 | 157 | for draft in drafts { 158 | app.add_entry(draft.title, draft.date, draft.tags, draft.priority) 159 | .await 160 | .unwrap(); 161 | } 162 | } 163 | 164 | #[tokio::test] 165 | async fn test_sorter() { 166 | let mut app = create_default_app(); 167 | app.load_entries().await.unwrap(); 168 | 169 | add_extra_entries_drafts(&mut app).await; 170 | 171 | app.current_entry_id = Some(0); 172 | 173 | let mut sorter = Sorter::default(); 174 | sorter.set_criteria(vec![SortCriteria::Priority]); 175 | sorter.order = SortOrder::Ascending; 176 | 177 | app.apply_sort(vec![SortCriteria::Priority], SortOrder::Ascending); 178 | 179 | let ids: Vec = app.get_active_entries().map(|entry| entry.id).collect(); 180 | assert_eq!(ids, vec![0, 1, 2, 4, 3], "Priority Ascending"); 181 | 182 | app.apply_sort(vec![SortCriteria::Priority], SortOrder::Descending); 183 | 184 | let ids: Vec = app.get_active_entries().map(|entry| entry.id).collect(); 185 | assert_eq!(ids, vec![3, 4, 2, 1, 0], "Priority Descending"); 186 | } 187 | 188 | #[tokio::test] 189 | async fn test_sorter_with_filter() { 190 | let mut app = create_default_app(); 191 | app.load_entries().await.unwrap(); 192 | 193 | add_extra_entries_drafts(&mut app).await; 194 | 195 | app.current_entry_id = Some(0); 196 | 197 | // Apply filter then apply sorter 198 | let mut filter = Filter::default(); 199 | filter 200 | .criteria 201 | .push(FilterCriterion::Tag(TagFilterOption::Tag(String::from( 202 | "Tag 2", 203 | )))); 204 | app.apply_filter(Some(filter)); 205 | 206 | let mut sorter = Sorter::default(); 207 | sorter.set_criteria(vec![SortCriteria::Priority]); 208 | sorter.order = SortOrder::Ascending; 209 | 210 | app.apply_sort(vec![SortCriteria::Priority], SortOrder::Ascending); 211 | 212 | let ids: Vec = app.get_active_entries().map(|entry| entry.id).collect(); 213 | assert_eq!(ids, vec![0, 2], "Apply Filter Then Sorter Ascending"); 214 | 215 | app.apply_sort(vec![SortCriteria::Priority], SortOrder::Descending); 216 | 217 | let ids: Vec = app.get_active_entries().map(|entry| entry.id).collect(); 218 | assert_eq!(ids, vec![2, 0], "Apply Filter Then Sorter Descending"); 219 | 220 | // Apply Another filter on the already sorted items 221 | let mut filter = Filter::default(); 222 | filter 223 | .criteria 224 | .push(FilterCriterion::Tag(TagFilterOption::Tag(String::from( 225 | "Tag 1", 226 | )))); 227 | app.apply_filter(Some(filter)); 228 | 229 | let ids: Vec = app.get_active_entries().map(|entry| entry.id).collect(); 230 | assert_eq!(ids, vec![4, 2, 0], "Apply Filter Then Sorter Descending"); 231 | } 232 | -------------------------------------------------------------------------------- /src/app/test/undo_redo.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[tokio::test] 4 | /// Test for adding Entry 5 | async fn add() { 6 | let mut app = create_default_app(); 7 | app.load_entries().await.unwrap(); 8 | 9 | let original_count = app.get_active_entries().count(); 10 | 11 | let added_title = "Added"; 12 | 13 | let id = app 14 | .add_entry(added_title.into(), DateTime::default(), vec![], None) 15 | .await 16 | .unwrap(); 17 | 18 | assert!(app.get_entry(id).is_some()); 19 | 20 | app.undo().await.unwrap(); 21 | 22 | assert_eq!(app.get_active_entries().count(), original_count); 23 | assert!(app.get_entry(id).is_none()); 24 | 25 | let _id = app.redo().await.unwrap().unwrap(); 26 | 27 | assert_eq!(app.get_active_entries().count(), original_count + 1); 28 | assert!( 29 | app.get_entry(id) 30 | .is_some_and(|entry| entry.title == added_title) 31 | ) 32 | } 33 | 34 | #[tokio::test] 35 | /// Test for removing Entry 36 | async fn remove() { 37 | let mut app = create_default_app(); 38 | app.load_entries().await.unwrap(); 39 | 40 | let original_count = app.get_active_entries().count(); 41 | let id = 1; 42 | let title = app.get_entry(id).unwrap().title.to_owned(); 43 | 44 | app.delete_entry(id).await.unwrap(); 45 | 46 | assert!(app.get_active_entries().all(|e| e.title != title)); 47 | assert_eq!(app.get_active_entries().count(), original_count - 1); 48 | 49 | let _id = app.undo().await.unwrap().unwrap(); 50 | 51 | assert!(app.get_active_entries().any(|e| e.title == title)); 52 | assert_eq!(app.get_active_entries().count(), original_count); 53 | 54 | app.redo().await.unwrap(); 55 | 56 | assert!(app.get_active_entries().all(|e| e.title != title)); 57 | assert_eq!(app.get_active_entries().count(), original_count - 1); 58 | } 59 | 60 | #[tokio::test] 61 | /// Test for Updating entry attributes 62 | async fn update_attributes() { 63 | let mut app = create_default_app(); 64 | app.load_entries().await.unwrap(); 65 | 66 | app.current_entry_id = Some(1); 67 | 68 | let current = app.get_current_entry().unwrap(); 69 | 70 | let id = current.id; 71 | let original_title = current.title.to_owned(); 72 | let changed_title = "Changed_Title"; 73 | 74 | app.update_current_entry_attributes( 75 | changed_title.into(), 76 | current.date, 77 | current.tags.to_owned(), 78 | current.priority, 79 | ) 80 | .await 81 | .unwrap(); 82 | 83 | let update_entry = app.get_entry(id).unwrap(); 84 | assert_eq!(&update_entry.title, changed_title); 85 | 86 | let _id = app.undo().await.unwrap().unwrap(); 87 | 88 | let undo_entry = app.get_entry(id).unwrap(); 89 | assert_eq!(undo_entry.title, original_title); 90 | 91 | let _id = app.redo().await.unwrap().unwrap(); 92 | let redo_entry = app.get_entry(id).unwrap(); 93 | assert_eq!(redo_entry.title, changed_title); 94 | } 95 | 96 | #[tokio::test] 97 | /// Test for Updating Entry Content 98 | async fn update_content() { 99 | let mut app = create_default_app(); 100 | app.load_entries().await.unwrap(); 101 | 102 | app.current_entry_id = Some(1); 103 | 104 | let current = app.get_current_entry().unwrap(); 105 | 106 | let id = current.id; 107 | let original_content = current.content.to_owned(); 108 | let changed_content = "Changed_content"; 109 | 110 | app.update_current_entry_content(changed_content.into()) 111 | .await 112 | .unwrap(); 113 | 114 | let update_entry = app.get_entry(id).unwrap(); 115 | assert_eq!(&update_entry.content, changed_content); 116 | 117 | let _id = app.undo().await.unwrap().unwrap(); 118 | 119 | let undo_entry = app.get_entry(id).unwrap(); 120 | assert_eq!(undo_entry.content, original_content); 121 | 122 | let _id = app.redo().await.unwrap().unwrap(); 123 | let redo_entry = app.get_entry(id).unwrap(); 124 | assert_eq!(redo_entry.content, changed_content); 125 | } 126 | 127 | #[tokio::test] 128 | /// This test will run multiple delete calls, undo do them, then redo them 129 | async fn many() { 130 | let mut app = create_default_app(); 131 | app.load_entries().await.unwrap(); 132 | 133 | let original_count = app.get_active_entries().count(); 134 | let mut current_count = original_count; 135 | 136 | while current_count > 0 { 137 | let id = app.entries.first().unwrap().id; 138 | app.delete_entry(id).await.unwrap(); 139 | current_count -= 1; 140 | assert_eq!(app.entries.len(), current_count); 141 | } 142 | 143 | for _ in 0..original_count { 144 | app.undo().await.unwrap(); 145 | current_count += 1; 146 | assert_eq!(app.entries.len(), current_count); 147 | } 148 | 149 | for _ in 0..original_count { 150 | app.redo().await.unwrap(); 151 | current_count -= 1; 152 | assert_eq!(app.entries.len(), current_count); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/app/ui/commands/editor_cmd.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{App, HandleInputReturnType, UIComponents, ui::*}; 2 | 3 | use backend::DataProvider; 4 | 5 | use super::{ClipboardOperation, CmdResult}; 6 | 7 | pub fn exec_back_editor_to_normal_mode(ui_components: &mut UIComponents) -> CmdResult { 8 | if ui_components.active_control == ControlType::EntryContentTxt 9 | && ui_components.editor.is_prioritized() 10 | { 11 | ui_components.editor.set_editor_mode(EditorMode::Normal); 12 | } 13 | 14 | Ok(HandleInputReturnType::Handled) 15 | } 16 | 17 | pub async fn exec_save_entry_content( 18 | ui_components: &mut UIComponents<'_>, 19 | app: &mut App, 20 | ) -> CmdResult { 21 | let entry_content = ui_components.editor.get_content(); 22 | app.update_current_entry_content(entry_content).await?; 23 | 24 | ui_components.editor.refresh_has_unsaved(app); 25 | 26 | Ok(HandleInputReturnType::Handled) 27 | } 28 | 29 | pub fn exec_discard_content(ui_components: &mut UIComponents) -> CmdResult { 30 | if ui_components.has_unsaved() { 31 | let msg = MsgBoxType::Question("Do you want to discard all unsaved changes?".into()); 32 | let msg_actions = MsgBoxActions::YesNo; 33 | ui_components.show_msg_box( 34 | msg, 35 | msg_actions, 36 | Some(UICommand::DiscardChangesEntryContent), 37 | ); 38 | } 39 | Ok(HandleInputReturnType::Handled) 40 | } 41 | 42 | pub fn continue_discard_content( 43 | ui_components: &mut UIComponents, 44 | app: &mut App, 45 | msg_box_result: MsgBoxResult, 46 | ) -> CmdResult { 47 | match msg_box_result { 48 | MsgBoxResult::Yes => discard_current_content(ui_components, app), 49 | MsgBoxResult::No => {} 50 | _ => unreachable!("{:?} isn't implemented for discard content", msg_box_result), 51 | } 52 | 53 | Ok(HandleInputReturnType::Handled) 54 | } 55 | 56 | pub fn discard_current_content( 57 | ui_components: &mut UIComponents, 58 | app: &mut App, 59 | ) { 60 | ui_components 61 | .editor 62 | .set_current_entry(app.current_entry_id, app); 63 | } 64 | 65 | pub fn exec_toggle_editor_visual_mode(ui_components: &mut UIComponents) -> CmdResult { 66 | debug_assert!(ui_components.active_control == ControlType::EntryContentTxt); 67 | 68 | match ui_components.editor.get_editor_mode() { 69 | EditorMode::Normal => ui_components.editor.set_editor_mode(EditorMode::Visual), 70 | EditorMode::Visual => ui_components.editor.set_editor_mode(EditorMode::Normal), 71 | EditorMode::Insert => return Ok(HandleInputReturnType::NotFound), 72 | } 73 | 74 | Ok(HandleInputReturnType::Handled) 75 | } 76 | 77 | pub fn exec_copy_os_clipboard(ui_components: &mut UIComponents) -> CmdResult { 78 | ui_components 79 | .editor 80 | .exec_os_clipboard(ClipboardOperation::Copy) 81 | } 82 | 83 | pub fn exec_cut_os_clipboard(ui_components: &mut UIComponents) -> CmdResult { 84 | ui_components 85 | .editor 86 | .exec_os_clipboard(ClipboardOperation::Cut) 87 | } 88 | 89 | pub fn exec_paste_os_clipboard(ui_components: &mut UIComponents) -> CmdResult { 90 | ui_components 91 | .editor 92 | .exec_os_clipboard(ClipboardOperation::Paste) 93 | } 94 | -------------------------------------------------------------------------------- /src/app/ui/commands/global_cmd.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{ 2 | App, HandleInputReturnType, UIComponents, 3 | ui::{help_popup::KeybindingsTabs, *}, 4 | }; 5 | 6 | use backend::DataProvider; 7 | 8 | use super::{CmdResult, editor_cmd::exec_save_entry_content}; 9 | 10 | pub fn exec_quit(ui_components: &mut UIComponents) -> CmdResult { 11 | if ui_components.has_unsaved() { 12 | ui_components.show_unsaved_msg_box(Some(UICommand::Quit)); 13 | Ok(HandleInputReturnType::Handled) 14 | } else { 15 | Ok(HandleInputReturnType::ExitApp) 16 | } 17 | } 18 | 19 | pub async fn continue_quit( 20 | ui_components: &mut UIComponents<'_>, 21 | app: &mut App, 22 | msg_box_result: MsgBoxResult, 23 | ) -> CmdResult { 24 | match msg_box_result { 25 | MsgBoxResult::Ok | MsgBoxResult::Cancel => Ok(HandleInputReturnType::Handled), 26 | MsgBoxResult::Yes => { 27 | exec_save_entry_content(ui_components, app).await?; 28 | Ok(HandleInputReturnType::ExitApp) 29 | } 30 | MsgBoxResult::No => Ok(HandleInputReturnType::ExitApp), 31 | } 32 | } 33 | 34 | pub fn exec_show_help(ui_components: &mut UIComponents) -> CmdResult { 35 | let start_tab = match ( 36 | ui_components.active_control, 37 | ui_components.entries_list.multi_select_mode, 38 | ) { 39 | (ControlType::EntriesList, false) => KeybindingsTabs::Global, 40 | (ControlType::EntriesList, true) => KeybindingsTabs::MultiSelect, 41 | (ControlType::EntryContentTxt, _) => KeybindingsTabs::Editor, 42 | }; 43 | 44 | ui_components 45 | .popup_stack 46 | .push(Popup::Help(Box::new(HelpPopup::new(start_tab)))); 47 | 48 | Ok(HandleInputReturnType::Handled) 49 | } 50 | 51 | pub fn exec_cycle_forward(ui_components: &mut UIComponents) -> CmdResult { 52 | let next_control = match ui_components.active_control { 53 | ControlType::EntriesList => ControlType::EntryContentTxt, 54 | ControlType::EntryContentTxt => ControlType::EntriesList, 55 | }; 56 | 57 | ui_components.change_active_control(next_control); 58 | Ok(HandleInputReturnType::Handled) 59 | } 60 | 61 | pub fn exec_cycle_backward(ui_components: &mut UIComponents) -> CmdResult { 62 | let prev_control = match ui_components.active_control { 63 | ControlType::EntriesList => ControlType::EntryContentTxt, 64 | ControlType::EntryContentTxt => ControlType::EntriesList, 65 | }; 66 | 67 | ui_components.change_active_control(prev_control); 68 | 69 | Ok(HandleInputReturnType::Handled) 70 | } 71 | 72 | pub fn exec_start_edit_content(ui_components: &mut UIComponents) -> CmdResult { 73 | ui_components.start_edit_current_entry()?; 74 | 75 | Ok(HandleInputReturnType::Handled) 76 | } 77 | 78 | pub async fn exec_reload_all( 79 | ui_components: &mut UIComponents<'_>, 80 | app: &mut App, 81 | ) -> CmdResult { 82 | if ui_components.has_unsaved() { 83 | ui_components.show_unsaved_msg_box(Some(UICommand::ReloadAll)); 84 | } else { 85 | reload_all(ui_components, app).await?; 86 | } 87 | 88 | Ok(HandleInputReturnType::Handled) 89 | } 90 | 91 | async fn reload_all( 92 | ui_components: &mut UIComponents<'_>, 93 | app: &mut App, 94 | ) -> anyhow::Result<()> { 95 | app.load_entries().await?; 96 | ui_components.set_current_entry(app.current_entry_id, app); 97 | 98 | Ok(()) 99 | } 100 | 101 | pub async fn continue_reload_all( 102 | ui_components: &mut UIComponents<'_>, 103 | app: &mut App, 104 | msg_box_result: MsgBoxResult, 105 | ) -> CmdResult { 106 | match msg_box_result { 107 | MsgBoxResult::Ok | MsgBoxResult::Cancel => {} 108 | MsgBoxResult::Yes => { 109 | exec_save_entry_content(ui_components, app).await?; 110 | reload_all(ui_components, app).await?; 111 | } 112 | MsgBoxResult::No => reload_all(ui_components, app).await?, 113 | } 114 | 115 | Ok(HandleInputReturnType::Handled) 116 | } 117 | 118 | pub async fn exec_undo( 119 | ui_components: &mut UIComponents<'_>, 120 | app: &mut App, 121 | ) -> CmdResult { 122 | if ui_components.has_unsaved() { 123 | ui_components.show_unsaved_msg_box(Some(UICommand::Undo)); 124 | } else { 125 | undo(ui_components, app).await?; 126 | } 127 | 128 | Ok(HandleInputReturnType::Handled) 129 | } 130 | 131 | async fn undo( 132 | ui_components: &mut UIComponents<'_>, 133 | app: &mut App, 134 | ) -> anyhow::Result<()> { 135 | if let Some(id) = app.undo().await? { 136 | ui_components.set_current_entry(Some(id), app); 137 | } 138 | 139 | Ok(()) 140 | } 141 | 142 | pub async fn continue_undo( 143 | ui_components: &mut UIComponents<'_>, 144 | app: &mut App, 145 | msg_box_result: MsgBoxResult, 146 | ) -> CmdResult { 147 | match msg_box_result { 148 | MsgBoxResult::Ok | MsgBoxResult::Cancel => {} 149 | MsgBoxResult::Yes => { 150 | exec_save_entry_content(ui_components, app).await?; 151 | undo(ui_components, app).await?; 152 | } 153 | MsgBoxResult::No => undo(ui_components, app).await?, 154 | } 155 | 156 | Ok(HandleInputReturnType::Handled) 157 | } 158 | 159 | pub async fn exec_redo( 160 | ui_components: &mut UIComponents<'_>, 161 | app: &mut App, 162 | ) -> CmdResult { 163 | if ui_components.has_unsaved() { 164 | ui_components.show_unsaved_msg_box(Some(UICommand::Redo)); 165 | } else { 166 | redo(ui_components, app).await?; 167 | } 168 | 169 | Ok(HandleInputReturnType::Handled) 170 | } 171 | 172 | async fn redo( 173 | ui_components: &mut UIComponents<'_>, 174 | app: &mut App, 175 | ) -> anyhow::Result<()> { 176 | if let Some(id) = app.redo().await? { 177 | ui_components.set_current_entry(Some(id), app); 178 | } 179 | 180 | Ok(()) 181 | } 182 | 183 | pub async fn continue_redo( 184 | ui_components: &mut UIComponents<'_>, 185 | app: &mut App, 186 | msg_box_result: MsgBoxResult, 187 | ) -> CmdResult { 188 | match msg_box_result { 189 | MsgBoxResult::Ok | MsgBoxResult::Cancel => {} 190 | MsgBoxResult::Yes => { 191 | exec_save_entry_content(ui_components, app).await?; 192 | redo(ui_components, app).await?; 193 | } 194 | MsgBoxResult::No => redo(ui_components, app).await?, 195 | } 196 | 197 | Ok(HandleInputReturnType::Handled) 198 | } 199 | -------------------------------------------------------------------------------- /src/app/ui/commands/multi_select_cmd.rs: -------------------------------------------------------------------------------- 1 | use backend::DataProvider; 2 | 3 | use crate::app::{ 4 | App, HandleInputReturnType, UIComponents, 5 | ui::{ 6 | MsgBoxResult, Popup, 7 | export_popup::ExportPopup, 8 | msg_box::{MsgBoxActions, MsgBoxType}, 9 | }, 10 | }; 11 | 12 | use super::{ 13 | CmdResult, UICommand, 14 | editor_cmd::{discard_current_content, exec_save_entry_content}, 15 | }; 16 | 17 | pub fn exec_enter_select_mode(ui_components: &mut UIComponents) -> CmdResult { 18 | if ui_components.entries_list.multi_select_mode { 19 | return Ok(HandleInputReturnType::Handled); 20 | } 21 | 22 | if ui_components.has_unsaved() { 23 | ui_components.show_unsaved_msg_box(Some(UICommand::EnterMultiSelectMode)); 24 | } else { 25 | enter_select_mode(ui_components); 26 | } 27 | 28 | Ok(HandleInputReturnType::Handled) 29 | } 30 | 31 | #[inline] 32 | fn enter_select_mode(ui_components: &mut UIComponents) { 33 | ui_components.entries_list.multi_select_mode = true; 34 | } 35 | 36 | pub async fn continue_enter_select_mode( 37 | ui_components: &mut UIComponents<'_>, 38 | app: &mut App, 39 | msg_box_result: MsgBoxResult, 40 | ) -> CmdResult { 41 | match msg_box_result { 42 | MsgBoxResult::Ok | MsgBoxResult::Cancel => {} 43 | MsgBoxResult::Yes => { 44 | exec_save_entry_content(ui_components, app).await?; 45 | enter_select_mode(ui_components); 46 | } 47 | MsgBoxResult::No => { 48 | discard_current_content(ui_components, app); 49 | enter_select_mode(ui_components); 50 | } 51 | } 52 | 53 | Ok(HandleInputReturnType::Handled) 54 | } 55 | 56 | pub fn exec_leave_select_mode( 57 | ui_components: &mut UIComponents, 58 | app: &mut App, 59 | ) -> CmdResult { 60 | debug_assert!(ui_components.entries_list.multi_select_mode); 61 | debug_assert!(!ui_components.has_unsaved()); 62 | 63 | exec_select_none(app)?; 64 | ui_components.entries_list.multi_select_mode = false; 65 | 66 | Ok(HandleInputReturnType::Handled) 67 | } 68 | 69 | pub fn exec_toggle_selected(app: &mut App) -> CmdResult { 70 | if let Some(id) = app.get_current_entry().map(|entry| entry.id) { 71 | toggle_entry_selection(id, app); 72 | } 73 | 74 | Ok(HandleInputReturnType::Handled) 75 | } 76 | 77 | fn toggle_entry_selection(entry_id: u32, app: &mut App) { 78 | if !app.selected_entries.insert(entry_id) { 79 | // entry was selected, then remove it 80 | app.selected_entries.remove(&entry_id); 81 | } 82 | } 83 | 84 | pub fn exec_select_all(app: &mut App) -> CmdResult { 85 | let active_ids: Vec = app.get_active_entries().map(|entry| entry.id).collect(); 86 | 87 | for id in active_ids { 88 | app.selected_entries.insert(id); 89 | } 90 | 91 | Ok(HandleInputReturnType::Handled) 92 | } 93 | 94 | pub fn exec_select_none(app: &mut App) -> CmdResult { 95 | app.selected_entries.clear(); 96 | 97 | Ok(HandleInputReturnType::Handled) 98 | } 99 | 100 | pub fn exec_invert_selection(app: &mut App) -> CmdResult { 101 | let active_ids: Vec = app.get_active_entries().map(|entry| entry.id).collect(); 102 | 103 | active_ids.into_iter().for_each(|id| { 104 | toggle_entry_selection(id, app); 105 | }); 106 | 107 | Ok(HandleInputReturnType::Handled) 108 | } 109 | 110 | pub fn exec_delete_selected_entries( 111 | ui_components: &mut UIComponents, 112 | app: &mut App, 113 | ) -> CmdResult { 114 | debug_assert!(ui_components.entries_list.multi_select_mode); 115 | debug_assert!(!ui_components.has_unsaved()); 116 | 117 | if app.selected_entries.is_empty() { 118 | return Ok(HandleInputReturnType::Handled); 119 | } 120 | 121 | let msg = MsgBoxType::Question(format!( 122 | "Do you want to delete the selected {} entries", 123 | app.selected_entries.len() 124 | )); 125 | let msg_action = MsgBoxActions::YesNo; 126 | ui_components.show_msg_box(msg, msg_action, Some(UICommand::MulSelDeleteEntries)); 127 | 128 | Ok(HandleInputReturnType::Handled) 129 | } 130 | 131 | pub async fn continue_delete_selected_entries( 132 | app: &mut App, 133 | msg_box_result: MsgBoxResult, 134 | ) -> CmdResult { 135 | match msg_box_result { 136 | MsgBoxResult::Yes => { 137 | let delete_ids: Vec = app.selected_entries.iter().cloned().collect(); 138 | for entry_id in delete_ids { 139 | app.delete_entry(entry_id).await?; 140 | } 141 | app.selected_entries.clear(); 142 | } 143 | MsgBoxResult::No => {} 144 | _ => unreachable!( 145 | "{:?} not implemented for delete selected entries", 146 | msg_box_result 147 | ), 148 | } 149 | 150 | Ok(HandleInputReturnType::Handled) 151 | } 152 | 153 | pub fn exec_export_selected_entries( 154 | ui_components: &mut UIComponents, 155 | app: &mut App, 156 | ) -> CmdResult { 157 | debug_assert!(ui_components.entries_list.multi_select_mode); 158 | debug_assert!(!ui_components.has_unsaved()); 159 | 160 | if app.selected_entries.is_empty() { 161 | let msg = MsgBoxType::Info("No items have been selected".into()); 162 | let msg_action = MsgBoxActions::Ok; 163 | ui_components.show_msg_box(msg, msg_action, None); 164 | 165 | return Ok(HandleInputReturnType::Handled); 166 | } 167 | 168 | match ExportPopup::create_multi_select(app) { 169 | Ok(popup) => ui_components 170 | .popup_stack 171 | .push(Popup::Export(Box::new(popup))), 172 | Err(err) => ui_components.show_err_msg(format!( 173 | "Error while creating export dialog.\n Err: {}", 174 | err 175 | )), 176 | } 177 | 178 | Ok(HandleInputReturnType::Handled) 179 | } 180 | -------------------------------------------------------------------------------- /src/app/ui/entry_popup/tags.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeSet; 2 | 3 | use crossterm::event::{KeyCode, KeyModifiers}; 4 | use ratatui::{ 5 | Frame, 6 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 7 | style::Style, 8 | widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, 9 | }; 10 | 11 | use crate::app::{ 12 | keymap::Input, 13 | ui::{Styles, entry_popup::tags_to_text, ui_functions::centered_rect}, 14 | }; 15 | 16 | use super::text_to_tags; 17 | 18 | const FOOTER_TEXT: &str = 19 | r": Toggle Selected | Enter or : Confirm | Esc, q or : Cancel"; 20 | const FOOTER_MARGINE: u16 = 4; 21 | 22 | pub enum TagsPopupReturn { 23 | Keep, 24 | Cancel, 25 | Apply(String), 26 | } 27 | 28 | pub struct TagsPopup { 29 | state: ListState, 30 | tags: Vec, 31 | selected_tags: BTreeSet, 32 | } 33 | 34 | impl TagsPopup { 35 | pub fn new(tags_text: &str, mut tags: Vec) -> Self { 36 | let state = ListState::default(); 37 | 38 | let existing_tags = text_to_tags(tags_text); 39 | 40 | let unsaved_tags: Vec = existing_tags 41 | .iter() 42 | .filter(|tag| !tags.contains(tag)) 43 | .cloned() 44 | .collect(); 45 | 46 | unsaved_tags 47 | .into_iter() 48 | .rev() 49 | .for_each(|tag| tags.insert(0, tag)); 50 | 51 | let selected_tags = BTreeSet::from_iter(existing_tags); 52 | 53 | let mut tags_popup = Self { 54 | state, 55 | tags, 56 | selected_tags, 57 | }; 58 | 59 | tags_popup.cycle_next_tag(); 60 | 61 | tags_popup 62 | } 63 | 64 | pub fn render_widget(&mut self, frame: &mut Frame, area: Rect, styles: &Styles) { 65 | let mut area = centered_rect(70, 100, area); 66 | area.y += 1; 67 | area.height -= 2; 68 | 69 | let block = Block::default() 70 | .borders(Borders::ALL) 71 | .title("Tags") 72 | .border_type(BorderType::Rounded); 73 | 74 | frame.render_widget(Clear, area); 75 | frame.render_widget(block, area); 76 | 77 | let footer_height = if area.width < FOOTER_TEXT.len() as u16 + FOOTER_MARGINE { 78 | 3 79 | } else { 80 | 2 81 | }; 82 | 83 | let chunks = Layout::default() 84 | .direction(Direction::Vertical) 85 | .horizontal_margin(1) 86 | .vertical_margin(1) 87 | .constraints([Constraint::Min(3), Constraint::Length(footer_height)].as_ref()) 88 | .split(area); 89 | 90 | if self.tags.is_empty() { 91 | self.render_tags_place_holder(frame, chunks[0]); 92 | } else { 93 | self.render_tags_list(frame, chunks[0], styles); 94 | } 95 | 96 | let footer = Paragraph::new(FOOTER_TEXT) 97 | .alignment(Alignment::Center) 98 | .wrap(Wrap { trim: false }) 99 | .block( 100 | Block::default() 101 | .borders(Borders::TOP) 102 | .style(Style::default()), 103 | ); 104 | 105 | frame.render_widget(footer, chunks[1]); 106 | } 107 | 108 | fn render_tags_list(&mut self, frame: &mut Frame, area: Rect, styles: &Styles) { 109 | let gstyles = &styles.general; 110 | let selected_style = Style::from(gstyles.list_item_selected); 111 | let items: Vec = self 112 | .tags 113 | .iter() 114 | .map(|tag| { 115 | let is_selected = self.selected_tags.contains(tag); 116 | 117 | let (tag_text, style) = if is_selected { 118 | (format!("* {tag}"), selected_style) 119 | } else { 120 | (tag.to_owned(), Style::reset()) 121 | }; 122 | 123 | ListItem::new(tag_text).style(style) 124 | }) 125 | .collect(); 126 | 127 | let list = List::new(items) 128 | .highlight_style(gstyles.list_highlight_active) 129 | .highlight_symbol(">> "); 130 | 131 | frame.render_stateful_widget(list, area, &mut self.state); 132 | } 133 | 134 | fn render_tags_place_holder(&mut self, frame: &mut Frame, area: Rect) { 135 | let place_holder_text = String::from("\nNo journals with tags provided"); 136 | 137 | let place_holder = Paragraph::new(place_holder_text) 138 | .wrap(Wrap { trim: false }) 139 | .alignment(Alignment::Center) 140 | .block(Block::default().borders(Borders::NONE)); 141 | 142 | frame.render_widget(place_holder, area); 143 | } 144 | 145 | pub fn handle_input(&mut self, input: &Input) -> TagsPopupReturn { 146 | let has_control = input.modifiers.contains(KeyModifiers::CONTROL); 147 | match input.key_code { 148 | KeyCode::Char('j') | KeyCode::Down => self.cycle_next_tag(), 149 | KeyCode::Char('k') | KeyCode::Up => self.cycle_prev_tag(), 150 | KeyCode::Char(' ') => self.toggle_selected(), 151 | KeyCode::Esc | KeyCode::Char('q') => TagsPopupReturn::Cancel, 152 | KeyCode::Char('c') if has_control => TagsPopupReturn::Cancel, 153 | KeyCode::Enter => self.confirm(), 154 | KeyCode::Char('m') if has_control => self.confirm(), 155 | _ => TagsPopupReturn::Keep, 156 | } 157 | } 158 | 159 | fn cycle_next_tag(&mut self) -> TagsPopupReturn { 160 | if !self.tags.is_empty() { 161 | let last_index = self.tags.len() - 1; 162 | let new_index = self 163 | .state 164 | .selected() 165 | .map(|idx| if idx >= last_index { 0 } else { idx + 1 }) 166 | .unwrap_or(0); 167 | 168 | self.state.select(Some(new_index)); 169 | } 170 | 171 | TagsPopupReturn::Keep 172 | } 173 | 174 | fn cycle_prev_tag(&mut self) -> TagsPopupReturn { 175 | if !self.tags.is_empty() { 176 | let last_index = self.tags.len() - 1; 177 | let new_index = self 178 | .state 179 | .selected() 180 | .map(|idx| idx.checked_sub(1).unwrap_or(last_index)) 181 | .unwrap_or(last_index); 182 | 183 | self.state.select(Some(new_index)); 184 | } 185 | 186 | TagsPopupReturn::Keep 187 | } 188 | 189 | fn toggle_selected(&mut self) -> TagsPopupReturn { 190 | if let Some(idx) = self.state.selected() { 191 | let tag = self 192 | .tags 193 | .get(idx) 194 | .expect("tags has the index of the selected item in list"); 195 | 196 | if self.selected_tags.contains(tag) { 197 | self.selected_tags.remove(tag); 198 | } else { 199 | self.selected_tags.insert(tag.to_owned()); 200 | } 201 | } 202 | 203 | TagsPopupReturn::Keep 204 | } 205 | 206 | fn confirm(&self) -> TagsPopupReturn { 207 | // We must take the tags from the tags vector becuase it matches the order in the tags list 208 | let selected_tags: Vec = self 209 | .tags 210 | .iter() 211 | .filter(|tag| self.selected_tags.contains(*tag)) 212 | .cloned() 213 | .collect(); 214 | 215 | let tags_text = tags_to_text(&selected_tags); 216 | 217 | TagsPopupReturn::Apply(tags_text) 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/app/ui/export_popup/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{env, path::PathBuf}; 2 | 3 | use backend::{DataProvider, Entry}; 4 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 5 | use ratatui::{ 6 | Frame, 7 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 8 | style::Style, 9 | widgets::{Block, Borders, Clear, Paragraph, Wrap}, 10 | }; 11 | use tui_textarea::{CursorMove, TextArea}; 12 | 13 | use crate::app::{App, keymap::Input}; 14 | 15 | use super::{PopupReturn, Styles, ui_functions::centered_rect_exact_height}; 16 | 17 | type ExportPopupInputReturn = PopupReturn<(PathBuf, Option)>; 18 | 19 | const FOOTER_TEXT: &str = "Enter: confirm | Esc or : Cancel"; 20 | const FOOTER_MARGINE: u16 = 8; 21 | const DEFAULT_FILE_NAME: &str = "tjournal_export.json"; 22 | 23 | pub struct ExportPopup<'a> { 24 | path_txt: TextArea<'a>, 25 | path_err_msg: String, 26 | entry_id: Option, 27 | paragraph_text: String, 28 | } 29 | 30 | impl ExportPopup<'_> { 31 | pub fn create_entry_content( 32 | entry: &Entry, 33 | app: &App, 34 | ) -> anyhow::Result { 35 | let mut default_path = if let Some(path) = &app.settings.export.default_path { 36 | path.clone() 37 | } else { 38 | env::current_dir()? 39 | }; 40 | 41 | // Add filename if it's not already defined 42 | if default_path.extension().is_none() { 43 | default_path.push(format!("{}.txt", entry.title.as_str())); 44 | } 45 | 46 | let mut path_txt = TextArea::new(vec![default_path.to_string_lossy().to_string()]); 47 | path_txt.move_cursor(CursorMove::End); 48 | 49 | let paragraph_text = format!("Journal: {}", entry.title.to_owned()); 50 | 51 | let mut export_popup = ExportPopup { 52 | path_txt, 53 | path_err_msg: String::default(), 54 | entry_id: Some(entry.id), 55 | paragraph_text, 56 | }; 57 | 58 | export_popup.validate_path(); 59 | 60 | Ok(export_popup) 61 | } 62 | 63 | pub fn create_multi_select(app: &App) -> anyhow::Result { 64 | let mut default_path = if let Some(path) = &app.settings.export.default_path { 65 | path.clone() 66 | } else { 67 | env::current_dir()? 68 | }; 69 | 70 | // Add filename if it's not already defined 71 | if default_path.extension().is_none() { 72 | default_path.push(DEFAULT_FILE_NAME); 73 | } 74 | 75 | let mut path_txt = TextArea::new(vec![default_path.to_string_lossy().to_string()]); 76 | path_txt.move_cursor(CursorMove::End); 77 | 78 | let paragraph_text = format!( 79 | "Export the selected {} journals", 80 | app.selected_entries.len() 81 | ); 82 | 83 | let mut export_popup = ExportPopup { 84 | path_txt, 85 | path_err_msg: String::default(), 86 | entry_id: None, 87 | paragraph_text, 88 | }; 89 | 90 | export_popup.validate_path(); 91 | 92 | Ok(export_popup) 93 | } 94 | 95 | fn validate_path(&mut self) { 96 | let path = self 97 | .path_txt 98 | .lines() 99 | .first() 100 | .expect("Path Textbox should always have one line"); 101 | 102 | if path.is_empty() { 103 | self.path_err_msg = "Path can't be empty".into(); 104 | } else { 105 | self.path_err_msg.clear(); 106 | } 107 | } 108 | 109 | fn is_input_valid(&self) -> bool { 110 | self.path_err_msg.is_empty() 111 | } 112 | 113 | fn is_multi_select_mode(&self) -> bool { 114 | self.entry_id.is_none() 115 | } 116 | 117 | pub fn render_widget(&mut self, frame: &mut Frame, area: Rect, styles: &Styles) { 118 | let mut area = centered_rect_exact_height(70, 11, area); 119 | 120 | if area.width < FOOTER_TEXT.len() as u16 + FOOTER_MARGINE { 121 | area.height += 1; 122 | } 123 | 124 | let title = if self.is_multi_select_mode() { 125 | "Export journals" 126 | } else { 127 | "Export journal content" 128 | }; 129 | 130 | let block = Block::default().borders(Borders::ALL).title(title); 131 | 132 | frame.render_widget(Clear, area); 133 | frame.render_widget(block, area); 134 | 135 | let chunks = Layout::default() 136 | .direction(Direction::Vertical) 137 | .horizontal_margin(4) 138 | .vertical_margin(2) 139 | .constraints( 140 | [ 141 | Constraint::Length(2), 142 | Constraint::Length(3), 143 | Constraint::Length(1), 144 | Constraint::Min(1), 145 | ] 146 | .as_ref(), 147 | ) 148 | .split(area); 149 | 150 | let journal_paragraph = 151 | Paragraph::new(self.paragraph_text.as_str()).wrap(Wrap { trim: false }); 152 | frame.render_widget(journal_paragraph, chunks[0]); 153 | 154 | if self.path_err_msg.is_empty() { 155 | let block = Style::from(styles.general.input_block_active); 156 | let cursor = Style::from(styles.general.input_corsur_active); 157 | self.path_txt.set_style(block); 158 | self.path_txt.set_cursor_style(cursor); 159 | self.path_txt.set_block( 160 | Block::default() 161 | .borders(Borders::ALL) 162 | .style(block) 163 | .title("Path"), 164 | ); 165 | } else { 166 | let block = Style::from(styles.general.input_block_invalid); 167 | let cursor = Style::from(styles.general.input_corsur_invalid); 168 | self.path_txt.set_style(block); 169 | self.path_txt.set_cursor_style(cursor); 170 | self.path_txt.set_block( 171 | Block::default() 172 | .borders(Borders::ALL) 173 | .style(block) 174 | .title(format!("Path : {}", self.path_err_msg)), 175 | ); 176 | } 177 | 178 | self.path_txt.set_cursor_line_style(Style::default()); 179 | 180 | frame.render_widget(&self.path_txt, chunks[1]); 181 | 182 | let footer = Paragraph::new(FOOTER_TEXT) 183 | .alignment(Alignment::Center) 184 | .wrap(Wrap { trim: false }); 185 | 186 | frame.render_widget(footer, chunks[3]); 187 | } 188 | 189 | pub fn handle_input(&mut self, input: &Input) -> ExportPopupInputReturn { 190 | let has_ctrl = input.modifiers.contains(KeyModifiers::CONTROL); 191 | match input.key_code { 192 | KeyCode::Esc => ExportPopupInputReturn::Cancel, 193 | KeyCode::Char('c') if has_ctrl => ExportPopupInputReturn::Cancel, 194 | KeyCode::Enter => self.handle_confirm(), 195 | _ => { 196 | if self.path_txt.input(KeyEvent::from(input)) { 197 | self.validate_path(); 198 | } 199 | ExportPopupInputReturn::KeepPopup 200 | } 201 | } 202 | } 203 | 204 | fn handle_confirm(&mut self) -> ExportPopupInputReturn { 205 | self.validate_path(); 206 | if !self.is_input_valid() { 207 | return ExportPopupInputReturn::KeepPopup; 208 | } 209 | 210 | let path: PathBuf = self 211 | .path_txt 212 | .lines() 213 | .first() 214 | .expect("Path Textbox should always have one line") 215 | .parse() 216 | .expect("PathBuf from string should never fail"); 217 | 218 | ExportPopupInputReturn::Apply((path, self.entry_id)) 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/app/ui/footer.rs: -------------------------------------------------------------------------------- 1 | use backend::DataProvider; 2 | use ratatui::{ 3 | Frame, 4 | layout::{Alignment, Rect}, 5 | style::Style, 6 | widgets::{Block, Borders, Paragraph, Wrap}, 7 | }; 8 | 9 | use crate::app::{App, keymap::Keymap}; 10 | 11 | use super::{ControlType, UICommand, UIComponents}; 12 | 13 | const SEPARATOR: &str = " | "; 14 | 15 | pub fn get_footer_heigh( 16 | width: u16, 17 | ui_components: &UIComponents, 18 | app: &App, 19 | ) -> u16 { 20 | let footer_text = get_footer_text(ui_components, app); 21 | footer_text.len() as u16 / width + 1 22 | } 23 | 24 | pub fn render_footer( 25 | frame: &mut Frame, 26 | area: Rect, 27 | ui_components: &UIComponents, 28 | app: &App, 29 | ) { 30 | let footer_text = get_footer_text(ui_components, app); 31 | let footer = Paragraph::new(footer_text) 32 | .alignment(Alignment::Left) 33 | .wrap(Wrap { trim: false }) 34 | .block( 35 | Block::default() 36 | .borders(Borders::NONE) 37 | .style(Style::default()), 38 | ); 39 | 40 | frame.render_widget(footer, area); 41 | } 42 | 43 | fn get_footer_text(ui_components: &UIComponents, app: &App) -> String { 44 | let (edior_mode, multi_select_mode) = ( 45 | ui_components.editor.is_insert_mode(), 46 | ui_components.entries_list.multi_select_mode, 47 | ); 48 | match (edior_mode, multi_select_mode) { 49 | (true, false) => get_editor_mode_text(ui_components), 50 | (false, true) => get_multi_select_text(ui_components), 51 | _ => get_standard_text(ui_components, app), 52 | } 53 | } 54 | 55 | fn get_editor_mode_text(ui_components: &UIComponents) -> String { 56 | let exit_editor_mode_keymap: Vec<_> = ui_components 57 | .editor_keymaps 58 | .iter() 59 | .filter(|keymap| keymap.command == UICommand::BackEditorNormalMode) 60 | .collect(); 61 | 62 | format!( 63 | "{}{} Edit using Emacs motions", 64 | get_keymap_text(exit_editor_mode_keymap), 65 | SEPARATOR 66 | ) 67 | } 68 | 69 | fn get_standard_text(ui_components: &UIComponents, app: &App) -> String { 70 | let close_keymap: Vec<_> = ui_components 71 | .global_keymaps 72 | .iter() 73 | .filter(|keymap| keymap.command == UICommand::Quit) 74 | .collect(); 75 | 76 | let enter_editor_keymap: Vec<_> = ui_components 77 | .global_keymaps 78 | .iter() 79 | .filter(|keymap| keymap.command == UICommand::StartEditEntryContent) 80 | .collect(); 81 | 82 | let mut footer_parts = vec![ 83 | get_keymap_text(close_keymap), 84 | get_keymap_text(enter_editor_keymap), 85 | ]; 86 | 87 | if ui_components.active_control == ControlType::EntriesList { 88 | if app.filter.is_none() { 89 | let show_filter_keymap: Vec<_> = ui_components 90 | .entries_list_keymaps 91 | .iter() 92 | .filter(|keymap| keymap.command == UICommand::ShowFilter) 93 | .collect(); 94 | 95 | footer_parts.push(get_keymap_text(show_filter_keymap)); 96 | } else { 97 | let reset_filter_keymap: Vec<_> = ui_components 98 | .entries_list_keymaps 99 | .iter() 100 | .filter(|keymap| keymap.command == UICommand::ResetFilter) 101 | .collect(); 102 | 103 | footer_parts.push(get_keymap_text(reset_filter_keymap)); 104 | } 105 | 106 | let sort_keymap = ui_components 107 | .entries_list_keymaps 108 | .iter() 109 | .filter(|keymap| keymap.command == UICommand::ShowSortOptions) 110 | .collect(); 111 | 112 | footer_parts.push(get_keymap_text(sort_keymap)); 113 | } 114 | 115 | if app.state.full_screen { 116 | let full_screen_keymap: Vec<_> = ui_components 117 | .global_keymaps 118 | .iter() 119 | .filter(|keymap| keymap.command == UICommand::ToggleFullScreenMode) 120 | .collect(); 121 | footer_parts.push(get_keymap_text(full_screen_keymap)); 122 | } 123 | 124 | let help_keymap: Vec<_> = ui_components 125 | .global_keymaps 126 | .iter() 127 | .filter(|keymap| keymap.command == UICommand::ShowHelp) 128 | .collect(); 129 | 130 | footer_parts.push(get_keymap_text(help_keymap)); 131 | 132 | footer_parts.join(SEPARATOR) 133 | } 134 | 135 | fn get_multi_select_text(ui_components: &UIComponents) -> String { 136 | let leave_keymap: Vec<_> = ui_components 137 | .multi_select_keymaps 138 | .iter() 139 | .filter(|keymap| keymap.command == UICommand::LeaveMultiSelectMode) 140 | .collect(); 141 | 142 | let help_keymap: Vec<_> = ui_components 143 | .multi_select_keymaps 144 | .iter() 145 | .filter(|keymap| keymap.command == UICommand::ShowHelp) 146 | .collect(); 147 | 148 | let parts = [get_keymap_text(leave_keymap), get_keymap_text(help_keymap)]; 149 | 150 | parts.join(SEPARATOR) 151 | } 152 | 153 | fn get_keymap_text(keymaps: Vec<&Keymap>) -> String { 154 | let cmd_text = keymaps 155 | .first() 156 | .map(|keymap| keymap.command.get_info().name) 157 | .expect("Keymaps shouldn't be empty"); 158 | 159 | let keys: Vec = keymaps 160 | .iter() 161 | .map(|keymap| format!("'{}'", keymap.key)) 162 | .collect(); 163 | 164 | format!("{}: {}", cmd_text, keys.join(",")) 165 | } 166 | -------------------------------------------------------------------------------- /src/app/ui/fuzz_find/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 4 | use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; 5 | use ratatui::{ 6 | Frame, 7 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 8 | style::{Color, Modifier, Style}, 9 | text::{Line, Span}, 10 | widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, 11 | }; 12 | use tui_textarea::TextArea; 13 | 14 | use crate::app::keymap::Input; 15 | 16 | use super::{Styles, ui_functions::centered_rect}; 17 | 18 | const FOOTER_TEXT: &str = "Esc, Enter, , : Close | Up, Down, , : cycle through filtered list"; 19 | const FOOTER_MARGINE: usize = 8; 20 | 21 | pub struct FuzzFindPopup<'a> { 22 | query_text_box: TextArea<'a>, 23 | entries: HashMap, 24 | search_query: Option, 25 | filtered_entries: Vec, 26 | list_state: ListState, 27 | matcher: SkimMatcherV2, 28 | } 29 | 30 | pub enum FuzzFindReturn { 31 | Close, 32 | SelectEntry(Option), 33 | } 34 | 35 | struct FilteredEntry { 36 | id: u32, 37 | score: i64, 38 | indices: Vec, 39 | } 40 | 41 | impl FilteredEntry { 42 | fn new(id: u32, score: i64, indices: Vec) -> Self { 43 | Self { id, score, indices } 44 | } 45 | } 46 | 47 | impl FuzzFindPopup<'_> { 48 | pub fn new(entries: HashMap) -> Self { 49 | let mut query_text_box = TextArea::default(); 50 | let block = Block::default().title("Search Query").borders(Borders::ALL); 51 | query_text_box.set_cursor_line_style(Style::default()); 52 | query_text_box.set_block(block); 53 | 54 | Self { 55 | query_text_box, 56 | entries, 57 | search_query: None, 58 | filtered_entries: Vec::new(), 59 | list_state: ListState::default(), 60 | matcher: SkimMatcherV2::default().smart_case(), 61 | } 62 | } 63 | 64 | pub fn render_widget(&mut self, frame: &mut Frame, area: Rect, styles: &Styles) { 65 | let area = centered_rect(60, 60, area); 66 | 67 | let block = Block::default() 68 | .borders(Borders::ALL) 69 | .border_type(BorderType::Rounded) 70 | .title("Fuzzy Find"); 71 | 72 | frame.render_widget(Clear, area); 73 | frame.render_widget(block, area); 74 | 75 | let footer_height = textwrap::fill(FOOTER_TEXT, (area.width as usize) - FOOTER_MARGINE) 76 | .lines() 77 | .count(); 78 | 79 | let chunks = Layout::default() 80 | .direction(Direction::Vertical) 81 | .horizontal_margin(2) 82 | .vertical_margin(2) 83 | .constraints( 84 | [ 85 | Constraint::Length(3), 86 | Constraint::Min(4), 87 | Constraint::Length(footer_height.try_into().unwrap()), 88 | ] 89 | .as_ref(), 90 | ) 91 | .split(area); 92 | 93 | frame.render_widget(&self.query_text_box, chunks[0]); 94 | 95 | self.render_entries_list(frame, chunks[1], styles); 96 | 97 | self.render_footer(frame, chunks[2]); 98 | } 99 | 100 | fn render_entries_list(&mut self, frame: &mut Frame, area: Rect, styles: &Styles) { 101 | let items: Vec = self 102 | .filtered_entries 103 | .iter() 104 | .map(|entry| { 105 | let entry_title = self 106 | .entries 107 | .get(&entry.id) 108 | .expect("Entry must be in entries map"); 109 | 110 | let spans: Vec<_> = entry_title 111 | .chars() 112 | .enumerate() 113 | .map(|(idx, ch)| { 114 | Span::styled( 115 | ch.to_string(), 116 | if entry.indices.contains(&idx) { 117 | Style::default() 118 | .add_modifier(Modifier::BOLD) 119 | .fg(Color::LightBlue) 120 | } else { 121 | Style::default() 122 | }, 123 | ) 124 | }) 125 | .collect(); 126 | 127 | ListItem::new(Line::from(spans)) 128 | }) 129 | .collect(); 130 | 131 | let block_title = format!("Entries: {}", self.filtered_entries.len()); 132 | 133 | let block = Block::default().title(block_title).borders(Borders::ALL); 134 | 135 | let list = List::new(items) 136 | .block(block) 137 | .highlight_style(styles.general.list_highlight_active) 138 | .highlight_symbol(">> "); 139 | 140 | frame.render_stateful_widget(list, area, &mut self.list_state); 141 | } 142 | 143 | fn render_footer(&mut self, frame: &mut Frame, area: Rect) { 144 | let footer = Paragraph::new(FOOTER_TEXT) 145 | .alignment(Alignment::Center) 146 | .wrap(Wrap { trim: false }) 147 | .block( 148 | Block::default() 149 | .borders(Borders::NONE) 150 | .style(Style::default()), 151 | ); 152 | 153 | frame.render_widget(footer, area); 154 | } 155 | 156 | pub fn handle_input(&mut self, input: &Input) -> FuzzFindReturn { 157 | let has_control = input.modifiers.contains(KeyModifiers::CONTROL); 158 | 159 | match input.key_code { 160 | KeyCode::Esc | KeyCode::Enter => return FuzzFindReturn::Close, 161 | KeyCode::Char('c') | KeyCode::Char('m') if has_control => return FuzzFindReturn::Close, 162 | KeyCode::Up => self.cycle_prev_entry(), 163 | KeyCode::Char('p') if has_control => self.cycle_prev_entry(), 164 | KeyCode::Down => self.cycle_next_entry(), 165 | KeyCode::Char('n') if has_control => self.cycle_next_entry(), 166 | _ => { 167 | if self.query_text_box.input(KeyEvent::from(input)) { 168 | self.update_search_query(); 169 | } 170 | } 171 | } 172 | 173 | let selected_id = self.list_state.selected().map(|idx| { 174 | self.filtered_entries 175 | .get(idx) 176 | .expect("Index must be in the list boundaries") 177 | .id 178 | }); 179 | 180 | FuzzFindReturn::SelectEntry(selected_id) 181 | } 182 | 183 | pub fn cycle_next_entry(&mut self) { 184 | if self.filtered_entries.is_empty() { 185 | return; 186 | } 187 | 188 | let mut new_index = self.list_state.selected().map_or(0, |idx| idx + 1); 189 | 190 | new_index = new_index.clamp(0, self.filtered_entries.len() - 1); 191 | 192 | self.list_state.select(Some(new_index)); 193 | } 194 | 195 | pub fn cycle_prev_entry(&mut self) { 196 | if self.filtered_entries.is_empty() { 197 | return; 198 | } 199 | 200 | let new_index = self 201 | .list_state 202 | .selected() 203 | .map_or(0, |idx| idx.saturating_sub(1)); 204 | 205 | self.list_state.select(Some(new_index)); 206 | } 207 | 208 | fn update_search_query(&mut self) { 209 | self.filtered_entries.clear(); 210 | 211 | let query_text = self 212 | .query_text_box 213 | .lines() 214 | .first() 215 | .expect("Query text box has one line"); 216 | 217 | self.search_query = if query_text.is_empty() { 218 | None 219 | } else { 220 | Some(query_text.to_owned()) 221 | }; 222 | 223 | if let Some(query) = self.search_query.as_ref() { 224 | self.filtered_entries = self 225 | .entries 226 | .iter() 227 | .filter_map(|entry| { 228 | self.matcher 229 | .fuzzy_indices(entry.1, query) 230 | .map(|(score, indices)| FilteredEntry::new(*entry.0, score, indices)) 231 | }) 232 | .collect(); 233 | 234 | self.filtered_entries.sort_by(|a, b| b.score.cmp(&a.score)); 235 | } 236 | 237 | if self.filtered_entries.is_empty() { 238 | self.list_state.select(None); 239 | } else { 240 | // Select first item when search query is updated 241 | self.list_state.select(Some(0)); 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/app/ui/help_popup/global_bindings.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use ratatui::widgets::TableState; 4 | 5 | use crate::app::{ 6 | keymap::{ 7 | Input, Keymap, get_editor_mode_keymaps, get_entries_list_keymaps, get_global_keymaps, 8 | }, 9 | ui::UICommand, 10 | }; 11 | 12 | use super::keybindings_table::KeybindingsTable; 13 | 14 | #[derive(Debug)] 15 | pub struct GlobalBindings { 16 | state: TableState, 17 | bindings_map: BTreeMap>, 18 | } 19 | 20 | impl GlobalBindings { 21 | pub fn new() -> Self { 22 | let state = TableState::default(); 23 | 24 | let mut bindings_map: BTreeMap> = BTreeMap::new(); 25 | 26 | get_all_keymaps().for_each(|keymap| { 27 | bindings_map 28 | .entry(keymap.command) 29 | .and_modify(|keys| keys.push(keymap.key)) 30 | .or_insert(vec![keymap.key]); 31 | }); 32 | 33 | Self { 34 | state, 35 | bindings_map, 36 | } 37 | } 38 | } 39 | 40 | fn get_all_keymaps() -> impl Iterator { 41 | let global_maps = get_global_keymaps().into_iter(); 42 | let list_maps = get_entries_list_keymaps().into_iter(); 43 | let editor_maps = get_editor_mode_keymaps().into_iter(); 44 | 45 | global_maps.chain(list_maps).chain(editor_maps) 46 | } 47 | 48 | impl KeybindingsTable for GlobalBindings { 49 | fn get_state_mut(&mut self) -> &mut TableState { 50 | &mut self.state 51 | } 52 | 53 | fn get_bindings_map(&self) -> &BTreeMap> { 54 | &self.bindings_map 55 | } 56 | 57 | fn get_title(&self) -> &str { 58 | "Global Keybindings" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/ui/help_popup/keybindings_table.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use ratatui::widgets::TableState; 4 | 5 | use crate::app::{keymap::Input, ui::UICommand}; 6 | 7 | pub trait KeybindingsTable { 8 | fn get_state_mut(&mut self) -> &mut TableState; 9 | fn get_bindings_map(&self) -> &BTreeMap>; 10 | fn get_title(&self) -> &str; 11 | 12 | fn select_next(&mut self) { 13 | let last_index = self.get_bindings_map().len() - 1; 14 | let state = self.get_state_mut(); 15 | let new_row = state 16 | .selected() 17 | .map(|row| if row >= last_index { 0 } else { row + 1 }) 18 | .unwrap_or(0); 19 | state.select(Some(new_row)); 20 | } 21 | 22 | fn select_previous(&mut self) { 23 | let last_index = self.get_bindings_map().len() - 1; 24 | let state = self.get_state_mut(); 25 | let new_row = state 26 | .selected() 27 | .map(|row| row.checked_sub(1).unwrap_or(last_index)) 28 | .unwrap_or(last_index); 29 | 30 | state.select(Some(new_row)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/ui/help_popup/multi_select_bindings.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use ratatui::widgets::TableState; 4 | 5 | use crate::app::{ 6 | keymap::{Input, get_multi_select_keymaps}, 7 | ui::UICommand, 8 | }; 9 | 10 | use super::keybindings_table::KeybindingsTable; 11 | 12 | #[derive(Debug)] 13 | pub struct MultiSelectBindings { 14 | state: TableState, 15 | bingings_map: BTreeMap>, 16 | } 17 | 18 | impl MultiSelectBindings { 19 | pub fn new() -> Self { 20 | let state = TableState::default(); 21 | let mut bingings_map: BTreeMap> = BTreeMap::new(); 22 | 23 | get_multi_select_keymaps().into_iter().for_each(|keymap| { 24 | bingings_map 25 | .entry(keymap.command) 26 | .and_modify(|keys| keys.push(keymap.key)) 27 | .or_insert(vec![keymap.key]); 28 | }); 29 | Self { 30 | state, 31 | bingings_map, 32 | } 33 | } 34 | } 35 | 36 | impl KeybindingsTable for MultiSelectBindings { 37 | fn get_state_mut(&mut self) -> &mut TableState { 38 | &mut self.state 39 | } 40 | 41 | fn get_bindings_map(&self) -> &BTreeMap> { 42 | &self.bingings_map 43 | } 44 | 45 | fn get_title(&self) -> &str { 46 | "Multi-Select Mode Keybindings" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/app/ui/msg_box/mod.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::KeyCode; 2 | use ratatui::{ 3 | Frame, 4 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 5 | style::Style, 6 | text::Span, 7 | widgets::{Block, Borders, Clear, Paragraph, Wrap}, 8 | }; 9 | 10 | use crate::app::keymap::Input; 11 | 12 | use super::{Styles, ui_functions::centered_rect_exact_height}; 13 | 14 | // Not all enums are used in this app at this point 15 | #[allow(dead_code)] 16 | #[derive(Debug)] 17 | pub enum MsgBoxType { 18 | Error(String), 19 | Warning(String), 20 | Info(String), 21 | Question(String), 22 | } 23 | 24 | // Not all enums are used in this app at this point 25 | #[allow(dead_code)] 26 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 27 | pub enum MsgBoxActions { 28 | Ok, 29 | OkCancel, 30 | YesNo, 31 | YesNoCancel, 32 | } 33 | 34 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 35 | pub enum MsgBoxResult { 36 | Ok, 37 | Cancel, 38 | Yes, 39 | No, 40 | } 41 | 42 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 43 | pub enum MsgBoxInputResult { 44 | Keep, 45 | Close(MsgBoxResult), 46 | } 47 | 48 | #[derive(Debug)] 49 | pub struct MsgBox { 50 | msg_type: MsgBoxType, 51 | actions: MsgBoxActions, 52 | } 53 | 54 | impl MsgBox { 55 | pub fn new(msg_type: MsgBoxType, actions: MsgBoxActions) -> Self { 56 | Self { msg_type, actions } 57 | } 58 | 59 | pub fn render_widget(&mut self, frame: &mut Frame, area: Rect, styles: &Styles) { 60 | let area = centered_rect_exact_height(55, 8, area); 61 | 62 | let colors = &styles.msgbox; 63 | 64 | let (title, color, text) = match &self.msg_type { 65 | MsgBoxType::Error(text) => ("Error", colors.error, text), 66 | MsgBoxType::Warning(text) => ("Warning", colors.warning, text), 67 | MsgBoxType::Info(text) => ("Info", colors.info, text), 68 | MsgBoxType::Question(text) => ("", colors.question, text), 69 | }; 70 | 71 | let border = Block::default() 72 | .borders(Borders::ALL) 73 | .title(title) 74 | .style(Style::default().fg(color)); 75 | 76 | frame.render_widget(Clear, area); 77 | frame.render_widget(border, area); 78 | 79 | let chunks = Layout::default() 80 | .direction(Direction::Vertical) 81 | .horizontal_margin(3) 82 | .vertical_margin(2) 83 | .constraints([Constraint::Min(2), Constraint::Length(1)].as_ref()) 84 | .split(area); 85 | 86 | let text_paragraph = Paragraph::new(Span::raw(text)) 87 | .style(Style::default().fg(color)) 88 | .alignment(Alignment::Center) 89 | .wrap(Wrap { trim: false }); 90 | 91 | frame.render_widget(text_paragraph, chunks[0]); 92 | 93 | let actions_text = match self.actions { 94 | MsgBoxActions::Ok => "(O)k", 95 | MsgBoxActions::OkCancel => "(O)k , (C)ancel", 96 | MsgBoxActions::YesNo => "(Y)es , (N)o", 97 | MsgBoxActions::YesNoCancel => "(Y)es , (N)o , (C)ancel", 98 | }; 99 | 100 | let actions_paragraph = Paragraph::new(Span::raw(actions_text)) 101 | .alignment(Alignment::Center) 102 | .wrap(Wrap { trim: false }); 103 | 104 | frame.render_widget(actions_paragraph, chunks[1]); 105 | } 106 | 107 | pub fn handle_input(&self, input: &Input) -> MsgBoxInputResult { 108 | match self.actions { 109 | MsgBoxActions::Ok => match input.key_code { 110 | KeyCode::Enter | KeyCode::Esc | KeyCode::Char('o') => { 111 | MsgBoxInputResult::Close(MsgBoxResult::Ok) 112 | } 113 | _ => MsgBoxInputResult::Keep, 114 | }, 115 | MsgBoxActions::OkCancel => match input.key_code { 116 | KeyCode::Enter | KeyCode::Char('o') => MsgBoxInputResult::Close(MsgBoxResult::Ok), 117 | KeyCode::Esc | KeyCode::Char('c') => MsgBoxInputResult::Close(MsgBoxResult::Cancel), 118 | _ => MsgBoxInputResult::Keep, 119 | }, 120 | MsgBoxActions::YesNo => match input.key_code { 121 | KeyCode::Enter | KeyCode::Char('y') => MsgBoxInputResult::Close(MsgBoxResult::Yes), 122 | KeyCode::Esc | KeyCode::Char('n') => MsgBoxInputResult::Close(MsgBoxResult::No), 123 | _ => MsgBoxInputResult::Keep, 124 | }, 125 | MsgBoxActions::YesNoCancel => match input.key_code { 126 | KeyCode::Enter | KeyCode::Char('y') => MsgBoxInputResult::Close(MsgBoxResult::Yes), 127 | KeyCode::Char('n') => MsgBoxInputResult::Close(MsgBoxResult::No), 128 | KeyCode::Esc | KeyCode::Char('c') => MsgBoxInputResult::Close(MsgBoxResult::Cancel), 129 | _ => MsgBoxInputResult::Keep, 130 | }, 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/app/ui/themes/editor_styles.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Modifier; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::*; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 7 | pub struct EditorStyles { 8 | #[serde(default = "block_insert")] 9 | pub block_insert: Style, 10 | #[serde(default = "block_visual")] 11 | pub block_visual: Style, 12 | #[serde(default = "block_normal_active")] 13 | pub block_normal_active: Style, 14 | #[serde(default = "block_normal_inactive")] 15 | pub block_normal_inactive: Style, 16 | #[serde(default = "cursor_normal")] 17 | pub cursor_normal: Style, 18 | #[serde(default = "cursor_insert")] 19 | pub cursor_insert: Style, 20 | #[serde(default = "cursor_visual")] 21 | pub cursor_visual: Style, 22 | #[serde(default = "selection_style")] 23 | pub selection_style: Style, 24 | } 25 | 26 | impl Default for EditorStyles { 27 | fn default() -> Self { 28 | Self { 29 | block_insert: block_insert(), 30 | block_visual: block_visual(), 31 | block_normal_active: block_normal_active(), 32 | block_normal_inactive: block_normal_inactive(), 33 | cursor_normal: cursor_normal(), 34 | cursor_insert: cursor_insert(), 35 | cursor_visual: cursor_visual(), 36 | selection_style: selection_style(), 37 | } 38 | } 39 | } 40 | 41 | #[inline] 42 | fn block_insert() -> Style { 43 | Style { 44 | fg: Some(EDITOR_MODE_COLOR), 45 | modifiers: Modifier::BOLD, 46 | ..Default::default() 47 | } 48 | } 49 | 50 | #[inline] 51 | fn block_visual() -> Style { 52 | Style { 53 | fg: Some(VISUAL_MODE_COLOR), 54 | modifiers: Modifier::BOLD, 55 | ..Default::default() 56 | } 57 | } 58 | 59 | #[inline] 60 | fn block_normal_active() -> Style { 61 | Style { 62 | fg: Some(ACTIVE_CONTROL_COLOR), 63 | modifiers: Modifier::BOLD, 64 | ..Default::default() 65 | } 66 | } 67 | 68 | #[inline] 69 | fn block_normal_inactive() -> Style { 70 | Style { 71 | fg: Some(INACTIVE_CONTROL_COLOR), 72 | ..Default::default() 73 | } 74 | } 75 | 76 | #[inline] 77 | fn cursor_normal() -> Style { 78 | Style { 79 | fg: Some(Color::Black), 80 | bg: Some(Color::White), 81 | ..Default::default() 82 | } 83 | } 84 | 85 | #[inline] 86 | fn cursor_insert() -> Style { 87 | Style { 88 | fg: Some(Color::Black), 89 | bg: Some(EDITOR_MODE_COLOR), 90 | ..Default::default() 91 | } 92 | } 93 | 94 | #[inline] 95 | fn cursor_visual() -> Style { 96 | Style { 97 | fg: Some(Color::Black), 98 | bg: Some(VISUAL_MODE_COLOR), 99 | ..Default::default() 100 | } 101 | } 102 | 103 | #[inline] 104 | fn selection_style() -> Style { 105 | Style { 106 | fg: Some(Color::Black), 107 | bg: Some(Color::White), 108 | ..Default::default() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/app/ui/themes/general_styles.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Modifier; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::*; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 7 | pub struct GeneralStyles { 8 | // input text 9 | #[serde(default = "input_block_active")] 10 | pub input_block_active: Style, 11 | #[serde(default = "input_block_invalid")] 12 | pub input_block_invalid: Style, 13 | #[serde(default = "input_corsur_active")] 14 | pub input_corsur_active: Style, 15 | #[serde(default = "input_corsur_invalid")] 16 | pub input_corsur_invalid: Style, 17 | 18 | // General list items 19 | #[serde(default = "list_item_selected")] 20 | pub list_item_selected: Style, 21 | #[serde(default = "list_highlight_active")] 22 | pub list_highlight_active: Style, 23 | #[serde(default = "list_highlight_inactive")] 24 | pub list_highlight_inactive: Style, 25 | } 26 | 27 | impl Default for GeneralStyles { 28 | fn default() -> Self { 29 | Self { 30 | input_block_active: input_block_active(), 31 | input_block_invalid: input_block_invalid(), 32 | input_corsur_active: input_corsur_active(), 33 | input_corsur_invalid: input_corsur_invalid(), 34 | list_item_selected: list_item_selected(), 35 | list_highlight_active: list_highlight_active(), 36 | list_highlight_inactive: list_highlight_inactive(), 37 | } 38 | } 39 | } 40 | 41 | #[inline] 42 | fn input_block_invalid() -> Style { 43 | Style { 44 | fg: Some(INVALID_CONTROL_COLOR), 45 | ..Default::default() 46 | } 47 | } 48 | 49 | #[inline] 50 | fn input_block_active() -> Style { 51 | Style { 52 | fg: Some(ACTIVE_INPUT_BORDER_COLOR), 53 | ..Default::default() 54 | } 55 | } 56 | 57 | #[inline] 58 | fn input_corsur_active() -> Style { 59 | Style { 60 | bg: Some(ACTIVE_INPUT_BORDER_COLOR), 61 | fg: Some(Color::Black), 62 | ..Default::default() 63 | } 64 | } 65 | 66 | #[inline] 67 | fn input_corsur_invalid() -> Style { 68 | Style { 69 | bg: Some(INVALID_CONTROL_COLOR), 70 | fg: Some(Color::Black), 71 | ..Default::default() 72 | } 73 | } 74 | 75 | #[inline] 76 | fn list_item_selected() -> Style { 77 | Style { 78 | fg: Some(Color::LightYellow), 79 | modifiers: Modifier::BOLD, 80 | ..Default::default() 81 | } 82 | } 83 | fn list_highlight_active() -> Style { 84 | Style { 85 | fg: Some(Color::Black), 86 | bg: Some(Color::LightGreen), 87 | ..Default::default() 88 | } 89 | } 90 | 91 | fn list_highlight_inactive() -> Style { 92 | Style { 93 | fg: Some(Color::Black), 94 | bg: Some(Color::LightBlue), 95 | ..Default::default() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/app/ui/themes/journals_list_styles.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Modifier; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::*; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 7 | pub struct JournalsListStyles { 8 | #[serde(default = "block_active")] 9 | pub block_active: Style, 10 | #[serde(default = "block_inactive")] 11 | pub block_inactive: Style, 12 | #[serde(default = "block_multi_select")] 13 | pub block_multi_select: Style, 14 | #[serde(default = "highlight_active")] 15 | pub highlight_active: Style, 16 | #[serde(default = "highlight_inactive")] 17 | pub highlight_inactive: Style, 18 | #[serde(default = "title_active")] 19 | pub title_active: Style, 20 | #[serde(default = "title_inactive")] 21 | pub title_inactive: Style, 22 | /// Styles when item is marked as selected in select mode 23 | #[serde(default = "title_selected")] 24 | pub title_selected: Style, 25 | #[serde(default = "date_priority")] 26 | pub date_priority: Style, 27 | #[serde(default = "tags_default")] 28 | pub tags_default: Style, 29 | } 30 | 31 | impl Default for JournalsListStyles { 32 | fn default() -> Self { 33 | Self { 34 | block_active: block_active(), 35 | block_inactive: block_inactive(), 36 | block_multi_select: block_multi_select(), 37 | highlight_active: highlight_active(), 38 | highlight_inactive: highlight_inactive(), 39 | title_active: title_active(), 40 | title_inactive: title_inactive(), 41 | title_selected: title_selected(), 42 | date_priority: date_priority(), 43 | tags_default: tags_default(), 44 | } 45 | } 46 | } 47 | 48 | #[inline] 49 | fn block_active() -> Style { 50 | Style { 51 | fg: Some(ACTIVE_CONTROL_COLOR), 52 | modifiers: Modifier::BOLD, 53 | ..Default::default() 54 | } 55 | } 56 | 57 | #[inline] 58 | fn block_inactive() -> Style { 59 | Style { 60 | fg: Some(INACTIVE_CONTROL_COLOR), 61 | ..Default::default() 62 | } 63 | } 64 | 65 | #[inline] 66 | fn block_multi_select() -> Style { 67 | Style { 68 | fg: Some(SELECTED_FOREGROUND_COLOR), 69 | modifiers: Modifier::BOLD | Modifier::ITALIC, 70 | ..Default::default() 71 | } 72 | } 73 | 74 | #[inline] 75 | fn highlight_active() -> Style { 76 | Style { 77 | fg: Some(Color::Black), 78 | bg: Some(Color::LightGreen), 79 | modifiers: Modifier::BOLD, 80 | ..Default::default() 81 | } 82 | } 83 | 84 | #[inline] 85 | fn highlight_inactive() -> Style { 86 | Style { 87 | fg: Some(Color::Black), 88 | bg: Some(Color::LightBlue), 89 | modifiers: Modifier::BOLD, 90 | ..Default::default() 91 | } 92 | } 93 | 94 | #[inline] 95 | fn title_active() -> Style { 96 | Style { 97 | fg: Some(ACTIVE_CONTROL_COLOR), 98 | modifiers: Modifier::BOLD, 99 | ..Default::default() 100 | } 101 | } 102 | 103 | #[inline] 104 | fn title_inactive() -> Style { 105 | Style { 106 | fg: Some(INACTIVE_CONTROL_COLOR), 107 | modifiers: Modifier::BOLD, 108 | ..Default::default() 109 | } 110 | } 111 | 112 | #[inline] 113 | fn title_selected() -> Style { 114 | Style { 115 | fg: Some(SELECTED_FOREGROUND_COLOR), 116 | modifiers: Modifier::BOLD, 117 | ..Default::default() 118 | } 119 | } 120 | 121 | #[inline] 122 | fn date_priority() -> Style { 123 | Style { 124 | fg: Some(Color::LightBlue), 125 | ..Default::default() 126 | } 127 | } 128 | 129 | #[inline] 130 | fn tags_default() -> Style { 131 | Style { 132 | fg: Some(Color::LightCyan), 133 | modifiers: Modifier::DIM, 134 | ..Default::default() 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/app/ui/themes/mod.rs: -------------------------------------------------------------------------------- 1 | mod editor_styles; 2 | mod general_styles; 3 | mod journals_list_styles; 4 | mod msgbox; 5 | mod style; 6 | 7 | use std::{fs, path::PathBuf}; 8 | 9 | use anyhow::Context; 10 | use directories::BaseDirs; 11 | use ratatui::style::Color; 12 | use serde::{Deserialize, Serialize}; 13 | 14 | pub use editor_styles::EditorStyles; 15 | pub use general_styles::GeneralStyles; 16 | pub use journals_list_styles::JournalsListStyles; 17 | pub use msgbox::MsgBoxColors; 18 | pub use style::Style; 19 | 20 | const ACTIVE_CONTROL_COLOR: Color = Color::Reset; 21 | const INACTIVE_CONTROL_COLOR: Color = Color::Rgb(170, 170, 200); 22 | const EDITOR_MODE_COLOR: Color = Color::LightGreen; 23 | const VISUAL_MODE_COLOR: Color = Color::Blue; 24 | const SELECTED_FOREGROUND_COLOR: Color = Color::Yellow; 25 | const INVALID_CONTROL_COLOR: Color = Color::LightRed; 26 | const ACTIVE_INPUT_BORDER_COLOR: Color = Color::LightYellow; 27 | 28 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 29 | pub struct Styles { 30 | #[serde(default)] 31 | pub general: GeneralStyles, 32 | #[serde(default)] 33 | pub journals_list: JournalsListStyles, 34 | #[serde(default)] 35 | pub editor: EditorStyles, 36 | #[serde(default)] 37 | pub msgbox: MsgBoxColors, 38 | } 39 | 40 | impl Styles { 41 | pub fn file_path() -> anyhow::Result { 42 | BaseDirs::new() 43 | .map(|base_dirs| { 44 | base_dirs 45 | .config_dir() 46 | .join("tui-journal") 47 | .join("themes.toml") 48 | }) 49 | .context("Themes file path couldn't be retrieved") 50 | } 51 | 52 | /// Serialize default themes to `toml` format. 53 | pub fn serialize_default() -> anyhow::Result { 54 | let def_style = Self::default(); 55 | toml::to_string_pretty(&def_style) 56 | .context("Error while serializing default styles to toml format") 57 | } 58 | 59 | pub fn load() -> anyhow::Result { 60 | let file_path = Self::file_path()?; 61 | if !file_path.exists() { 62 | return Ok(Self::default()); 63 | } 64 | 65 | let file_content = fs::read_to_string(&file_path).with_context(|| { 66 | format!( 67 | "Loading themes file content failed. Path: {}", 68 | file_path.display() 69 | ) 70 | })?; 71 | 72 | Self::deserialize(&file_content).with_context(|| { 73 | format!( 74 | "Error while desrializing toml text to styles. File path: {}", 75 | file_path.display() 76 | ) 77 | }) 78 | } 79 | 80 | /// Deserialize [`Styles`] from the given text, filling the missing items from default 81 | /// implementation. 82 | fn deserialize(input: &str) -> anyhow::Result { 83 | toml::from_str(input).map_err(|err| err.into()) 84 | } 85 | } 86 | 87 | #[cfg(test)] 88 | mod tests { 89 | use ratatui::style::Modifier; 90 | 91 | use super::*; 92 | 93 | #[test] 94 | fn full_general_only() { 95 | let text = r##" 96 | [general.input_block_active] 97 | fg = "Yellow" 98 | modifiers = "DIM" 99 | 100 | [general.input_block_invalid] 101 | fg = "Red" 102 | modifiers = "" 103 | 104 | [general.input_corsur_active] 105 | fg = "Black" 106 | bg = "LightYellow" 107 | modifiers = "" 108 | 109 | [general.input_corsur_invalid] 110 | fg = "Black" 111 | bg = "LightRed" 112 | modifiers = "" 113 | 114 | [general.list_item_selected] 115 | fg = "LightYellow" 116 | modifiers = "BOLD" 117 | 118 | [general.list_highlight_active] 119 | fg = "Black" 120 | bg = "LightGreen" 121 | modifiers = "" 122 | 123 | [general.list_highlight_inactive] 124 | fg = "Black" 125 | bg = "LightBlue" 126 | modifiers = "" 127 | "##; 128 | 129 | let style = Styles::deserialize(text).unwrap(); 130 | assert_eq!(style.general.input_block_active.fg, Some(Color::Yellow)); 131 | assert_eq!(style.general.input_block_active.modifiers, Modifier::DIM); 132 | assert_eq!(style.general.input_block_invalid.fg, Some(Color::Red)); 133 | 134 | assert_eq!(style.journals_list, JournalsListStyles::default()); 135 | assert_eq!(style.editor, EditorStyles::default()); 136 | assert_eq!(style.msgbox, MsgBoxColors::default()); 137 | } 138 | 139 | #[test] 140 | fn part_general_only() { 141 | let text = r##" 142 | [general.input_block_active] 143 | fg = "Yellow" 144 | modifiers = "DIM" 145 | 146 | [general.input_block_invalid] 147 | fg = "Red" 148 | modifiers = "" 149 | "##; 150 | 151 | let style = Styles::deserialize(text).unwrap(); 152 | assert_eq!(style.general.input_block_active.fg, Some(Color::Yellow)); 153 | assert_eq!(style.general.input_block_active.modifiers, Modifier::DIM); 154 | assert_eq!(style.general.input_block_invalid.fg, Some(Color::Red)); 155 | 156 | let def_general = GeneralStyles::default(); 157 | assert_eq!( 158 | style.general.input_corsur_invalid, 159 | def_general.input_corsur_invalid 160 | ); 161 | 162 | assert_eq!(style.journals_list, JournalsListStyles::default()); 163 | assert_eq!(style.editor, EditorStyles::default()); 164 | assert_eq!(style.msgbox, MsgBoxColors::default()); 165 | } 166 | 167 | #[test] 168 | fn part_journals_list_only() { 169 | let text = r##" 170 | [journals_list.block_active] 171 | fg = "Red" 172 | modifiers = "ITALIC" 173 | 174 | [journals_list.block_inactive] 175 | fg = "Reset" 176 | "##; 177 | 178 | let style = Styles::deserialize(text).unwrap(); 179 | assert_eq!(style.journals_list.block_active.fg, Some(Color::Red)); 180 | assert_eq!(style.journals_list.block_active.modifiers, Modifier::ITALIC); 181 | assert_eq!(style.journals_list.block_inactive.fg, Some(Color::Reset)); 182 | 183 | let def_journals = JournalsListStyles::default(); 184 | assert_eq!( 185 | style.journals_list.highlight_active, 186 | def_journals.highlight_active 187 | ); 188 | assert_eq!( 189 | style.journals_list.highlight_inactive, 190 | def_journals.highlight_inactive 191 | ); 192 | 193 | assert_eq!(style.general, GeneralStyles::default()); 194 | assert_eq!(style.editor, EditorStyles::default()); 195 | assert_eq!(style.msgbox, MsgBoxColors::default()); 196 | } 197 | 198 | #[test] 199 | fn part_editor_only() { 200 | let text = r##" 201 | [editor.block_insert] 202 | fg = "Blue" 203 | modifiers = "" 204 | 205 | [editor.block_visual] 206 | fg = "Red" 207 | "##; 208 | 209 | let style = Styles::deserialize(text).unwrap(); 210 | assert_eq!(style.editor.block_insert.fg, Some(Color::Blue)); 211 | assert_eq!(style.editor.block_insert.modifiers, Modifier::empty()); 212 | assert_eq!(style.editor.block_visual.fg, Some(Color::Red)); 213 | 214 | let def_editor = EditorStyles::default(); 215 | assert_eq!(style.editor.cursor_visual, def_editor.cursor_visual); 216 | assert_eq!(style.editor.cursor_normal, def_editor.cursor_normal); 217 | 218 | assert_eq!(style.journals_list, JournalsListStyles::default()); 219 | assert_eq!(style.general, GeneralStyles::default()); 220 | assert_eq!(style.msgbox, MsgBoxColors::default()); 221 | } 222 | 223 | #[test] 224 | fn part_msg_only() { 225 | let text = r##" 226 | [msgbox] 227 | error = "Red" 228 | warning = "Blue" 229 | "##; 230 | 231 | let style = Styles::deserialize(text).unwrap(); 232 | assert_eq!(style.msgbox.error, Color::Red); 233 | assert_eq!(style.msgbox.warning, Color::Blue); 234 | 235 | let def_msg = MsgBoxColors::default(); 236 | assert_eq!(style.msgbox.info, def_msg.info); 237 | assert_eq!(style.msgbox.question, def_msg.question); 238 | 239 | assert_eq!(style.general, GeneralStyles::default()); 240 | assert_eq!(style.journals_list, JournalsListStyles::default()); 241 | assert_eq!(style.editor, EditorStyles::default()); 242 | } 243 | 244 | #[test] 245 | /// Tests input have a part of every style group 246 | fn part_from_all() { 247 | let text = r##" 248 | [general.input_block_active] 249 | fg = "Yellow" 250 | modifiers = "DIM" 251 | 252 | [general.input_block_invalid] 253 | fg = "Red" 254 | modifiers = "" 255 | 256 | [journals_list.block_active] 257 | fg = "Red" 258 | modifiers = "ITALIC" 259 | 260 | [journals_list.block_inactive] 261 | fg = "Reset" 262 | 263 | [editor.block_insert] 264 | fg = "Blue" 265 | modifiers = "" 266 | 267 | [editor.block_visual] 268 | fg = "Red" 269 | 270 | [msgbox] 271 | error = "Red" 272 | warning = "Blue" 273 | "##; 274 | 275 | // General 276 | let style = Styles::deserialize(text).unwrap(); 277 | assert_eq!(style.general.input_block_active.fg, Some(Color::Yellow)); 278 | assert_eq!(style.general.input_block_active.modifiers, Modifier::DIM); 279 | assert_eq!(style.general.input_block_invalid.fg, Some(Color::Red)); 280 | 281 | let def_general = GeneralStyles::default(); 282 | assert_eq!( 283 | style.general.input_corsur_invalid, 284 | def_general.input_corsur_invalid 285 | ); 286 | 287 | // Journals 288 | assert_eq!(style.journals_list.block_active.fg, Some(Color::Red)); 289 | assert_eq!(style.journals_list.block_active.modifiers, Modifier::ITALIC); 290 | assert_eq!(style.journals_list.block_inactive.fg, Some(Color::Reset)); 291 | 292 | let def_journals = JournalsListStyles::default(); 293 | assert_eq!( 294 | style.journals_list.highlight_active, 295 | def_journals.highlight_active 296 | ); 297 | assert_eq!( 298 | style.journals_list.highlight_inactive, 299 | def_journals.highlight_inactive 300 | ); 301 | 302 | // Editor 303 | assert_eq!(style.editor.block_insert.fg, Some(Color::Blue)); 304 | assert_eq!(style.editor.block_insert.modifiers, Modifier::empty()); 305 | assert_eq!(style.editor.block_visual.fg, Some(Color::Red)); 306 | 307 | let def_editor = EditorStyles::default(); 308 | assert_eq!(style.editor.cursor_visual, def_editor.cursor_visual); 309 | assert_eq!(style.editor.cursor_normal, def_editor.cursor_normal); 310 | 311 | // Colors 312 | let def_msg = MsgBoxColors::default(); 313 | assert_eq!(style.msgbox.error, Color::Red); 314 | assert_eq!(style.msgbox.warning, Color::Blue); 315 | 316 | assert_eq!(style.msgbox.info, def_msg.info); 317 | assert_eq!(style.msgbox.question, def_msg.question); 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/app/ui/themes/msgbox.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 4 | pub struct MsgBoxColors { 5 | #[serde(default = "def_error")] 6 | pub error: Color, 7 | #[serde(default = "def_warning")] 8 | pub warning: Color, 9 | #[serde(default = "def_info")] 10 | pub info: Color, 11 | #[serde(default = "def_question")] 12 | pub question: Color, 13 | } 14 | 15 | impl Default for MsgBoxColors { 16 | fn default() -> Self { 17 | Self { 18 | error: def_error(), 19 | warning: def_warning(), 20 | info: def_info(), 21 | question: def_question(), 22 | } 23 | } 24 | } 25 | 26 | #[inline] 27 | fn def_error() -> Color { 28 | Color::LightRed 29 | } 30 | 31 | #[inline] 32 | fn def_warning() -> Color { 33 | Color::Yellow 34 | } 35 | 36 | #[inline] 37 | fn def_info() -> Color { 38 | Color::LightGreen 39 | } 40 | 41 | #[inline] 42 | fn def_question() -> Color { 43 | Color::LightBlue 44 | } 45 | -------------------------------------------------------------------------------- /src/app/ui/themes/style.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::{Color, Modifier, Style as RataStyle}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)] 5 | /// Represents the style elements as they defined in [`ratatui`] lib but with more simple 6 | /// definitions for serialization so it's less confusing for users to define in custom themes. 7 | pub struct Style { 8 | pub fg: Option, 9 | pub bg: Option, 10 | #[serde(default)] 11 | pub modifiers: Modifier, 12 | pub underline_color: Option, 13 | } 14 | 15 | impl From