├── .config └── config.toml ├── .envrc ├── .github ├── dependabot.yml └── workflows │ ├── bump.yml │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build.rs ├── desktop └── vault-tasks.desktop ├── dist-workspace.toml ├── examples ├── demo_calendar.tape ├── demo_calendar_view.png ├── demo_explorer.gif ├── demo_explorer.tape ├── demo_filter.tape ├── demo_filter_view.png ├── demo_readme.tape ├── demo_readme_explorer.png ├── demo_time.tape ├── demo_time_view.png └── justfile ├── flake.lock ├── flake.nix ├── misc └── ics_to_md.py ├── src ├── action.rs ├── app.rs ├── cli.rs ├── components.rs ├── components │ ├── calendar_tab.rs │ ├── explorer_tab.rs │ ├── explorer_tab │ │ ├── entry_list.rs │ │ └── utils.rs │ ├── filter_tab.rs │ ├── fps.rs │ ├── home.rs │ ├── snapshots │ │ └── vault_tasks__components__home__tests__render_home_component.snap │ └── time_management_tab.rs ├── config.rs ├── core.rs ├── core │ ├── filter.rs │ ├── parser.rs │ ├── parser │ │ ├── parser_file_entry.rs │ │ ├── snapshots │ │ │ ├── vault_tasks__core__parser__parser_file_entry__tests__code_block_task.snap │ │ │ ├── vault_tasks__core__parser__parser_file_entry__tests__commented_task.snap │ │ │ ├── vault_tasks__core__parser__parser_file_entry__tests__insert_global_tag.snap │ │ │ ├── vault_tasks__core__parser__parser_file_entry__tests__nested_tasks_desc.snap │ │ │ ├── vault_tasks_core__parser__parser_file_entry__tests__insert_global_tag.snap │ │ │ └── vault_tasks_core__parser__parser_file_entry__tests__nested_tasks_desc.snap │ │ ├── task.rs │ │ └── task │ │ │ ├── parse_completion.rs │ │ │ ├── parse_today.rs │ │ │ ├── parser_due_date.rs │ │ │ ├── parser_priorities.rs │ │ │ ├── parser_state.rs │ │ │ ├── parser_tags.rs │ │ │ ├── parser_time.rs │ │ │ └── token.rs │ ├── snapshots │ │ ├── vault_tasks__core__sorter__tests__task_sort_by_due_date.snap │ │ ├── vault_tasks__core__sorter__tests__task_sort_by_name.snap │ │ ├── vault_tasks__core__sorter__tests__task_sort_states.snap │ │ ├── vault_tasks_core__sorter__tests__task_sort_by_due_date.snap │ │ ├── vault_tasks_core__sorter__tests__task_sort_by_name.snap │ │ └── vault_tasks_core__sorter__tests__task_sort_states.snap │ ├── sorter.rs │ ├── task.rs │ ├── vault_data.rs │ └── vault_parser.rs ├── errors.rs ├── logging.rs ├── main.rs ├── time_management.rs ├── time_management │ ├── flow_time.rs │ ├── pomodoro.rs │ └── time_management_technique.rs ├── tui.rs ├── widgets.rs └── widgets │ ├── .task_list.rs.swp │ ├── help_menu.rs │ ├── input_bar.rs │ ├── snapshots │ ├── vault_tasks__widgets__input_bar__tests__render_search_bar.snap │ ├── vault_tasks__widgets__input_bar__tests__render_search_bar_line.snap │ ├── vault_tasks__widgets__task_list__tests__render_search_bar.snap │ ├── vault_tasks__widgets__task_list__tests__task_list.snap │ └── vault_tasks__widgets__task_list_item__tests__task_list_item.snap │ ├── styled_calendar.rs │ ├── task_list.rs │ ├── task_list_item.rs │ └── timer.rs └── test-vault ├── dir ├── subdir │ ├── test_1.md │ ├── test_2.md │ └── test_3.md └── test_0.md ├── example_physics_class.md ├── example_vault-tasks_project.md └── test.md /.config/config.toml: -------------------------------------------------------------------------------- 1 | [keybindings.Calendar] 2 | # App 3 | "" = "Quit" 4 | "" = "Quit" 5 | "" = "Suspend" 6 | "" = "Help" 7 | # Tabs 8 | "" = "TabRight" 9 | "" = "TabRight" 10 | "" = "TabLeft" 11 | "" = "TabLeft" 12 | # Scrolling 13 | "" = "ViewUp" 14 | "" = "ViewUp" 15 | "" = "ViewUp" 16 | "" = "ViewPageUp" 17 | "" = "ViewDown" 18 | "" = "ViewDown" 19 | "" = "ViewDown" 20 | "" = "ViewPageDown" 21 | # Navigation 22 | "" = "ReloadVault" 23 | "" = "GotoToday" 24 | "" = "Down" 25 | "" = "Down" 26 | "" = "Up" 27 | "" = "Up" 28 | "" = "Left" 29 | "" = "Left" 30 | "" = "Right" 31 | "" = "Right" 32 | "" = "NextMonth" 33 | "" = "NextMonth" 34 | "" = "PreviousMonth" 35 | "" = "PreviousMonth" 36 | "" = "NextYear" 37 | "" = "PreviousYear" 38 | 39 | [keybindings.Explorer] 40 | # App 41 | "" = "Quit" 42 | "" = "Quit" 43 | "" = "Suspend" 44 | "" = "Help" 45 | # Tabs 46 | "" = "TabRight" 47 | "" = "TabRight" 48 | "" = "TabLeft" 49 | "" = "TabLeft" 50 | # Navigation 51 | "" = "Down" 52 | "" = "Down" 53 | "" = "Down" 54 | "" = "Up" 55 | "" = "Up" 56 | "" = "Up" 57 | "" = "Left" 58 | "" = "Left" 59 | "" = "Right" 60 | "" = "Right" 61 | "" = "Enter" 62 | "" = "Cancel" 63 | "" = "Search" 64 | "" = "Escape" 65 | "" = "Open" 66 | "" = "Edit" 67 | "" = "MarkToDo" 68 | "" = "MarkDone" 69 | "" = "MarkCancel" 70 | "" = "MarkIncomplete" 71 | "" = "ReloadVault" 72 | "<+>" = "IncreaseCompletion" 73 | "<->" = "DecreaseCompletion" 74 | # Scrolling 75 | "" = "ViewUp" 76 | "" = "ViewUp" 77 | "" = "ViewUp" 78 | "" = "ViewPageUp" 79 | "" = "ViewDown" 80 | "" = "ViewDown" 81 | "" = "ViewDown" 82 | "" = "ViewPageDown" 83 | "" = "ViewRight" 84 | "" = "ViewRight" 85 | "" = "ViewLeft" 86 | "" = "ViewLeft" 87 | 88 | [keybindings.Filter] 89 | # App 90 | "" = "Quit" 91 | "" = "Quit" 92 | "" = "Suspend" 93 | "" = "Help" 94 | # Tabs 95 | "" = "TabRight" 96 | "" = "TabRight" 97 | "" = "TabLeft" 98 | "" = "TabLeft" 99 | # Navigation 100 | "" = "Enter" 101 | "" = "Search" 102 | "" = "SwitchSortingMode" 103 | "" = "Escape" 104 | "" = "ReloadVault" 105 | # Scrolling 106 | "" = "ViewUp" 107 | "" = "ViewUp" 108 | "" = "ViewUp" 109 | "" = "ViewPageUp" 110 | "" = "ViewDown" 111 | "" = "ViewDown" 112 | "" = "ViewDown" 113 | "" = "ViewPageDown" 114 | "" = "ViewRight" 115 | "" = "ViewRight" 116 | "" = "ViewLeft" 117 | "" = "ViewLeft" 118 | 119 | [keybindings.Home] 120 | # App 121 | "" = "Quit" 122 | "" = "Quit" 123 | "" = "Quit" 124 | "" = "Suspend" 125 | # Tabs 126 | "" = "TabRight" 127 | "" = "TabRight" 128 | "" = "TabLeft" 129 | "" = "TabLeft" 130 | 131 | [keybindings.TimeManagement] 132 | # App 133 | "" = "Quit" 134 | "" = "Quit" 135 | "" = "Suspend" 136 | "" = "Help" 137 | # Tabs 138 | "" = "TabRight" 139 | "" = "TabRight" 140 | "" = "TabLeft" 141 | "" = "TabLeft" 142 | # Navigation 143 | "" = "Edit" 144 | "" = "Escape" 145 | "" = "Enter" 146 | "" = "NextSegment" 147 | "

" = "Pause" 148 | "" = "NextMethod" 149 | "" = "PreviousMethod" 150 | "" = "Down" 151 | "" = "Down" 152 | "" = "Up" 153 | "" = "Up" 154 | "" = "Left" 155 | "" = "Left" 156 | "" = "Right" 157 | "" = "Right" 158 | 159 | [styles.Explorer] 160 | preview_headers = "bold rgb 255 153 000" 161 | 162 | [styles.Home] 163 | highlighted_style = "dark grey on rgb 255 153 000" 164 | highlighted_bar_style = "rgb 255 153 000" 165 | 166 | [tasks_config] 167 | use_american_format = true 168 | show_relative_due_dates = true 169 | indent_length = 2 170 | parse_dot_files = false 171 | file_tags_propagation = true 172 | ignored = [] 173 | # vault_path= "./test-vault" # default vault path when none is provided 174 | explorer_default_search_string = "- [ ] " 175 | filter_default_search_string = "" 176 | completion_bar_length = 5 177 | 178 | # Nice if your terminal font doesn't have emojis 179 | # pretty_symbols.task_done="[x]" 180 | # pretty_symbols.task_todo="[ ]" 181 | # pretty_symbols.task_incomplete="[/]" 182 | # pretty_symbols.task_canceled="[-]" 183 | # pretty_symbols.due_date="@" 184 | # pretty_symbols.priority="!" 185 | # pretty_symbols.today_tag="+" 186 | # pretty_symbols.progress_bar_true="=" 187 | # pretty_symbols.progress_bar_false=" " 188 | 189 | pretty_symbols.task_done = "✅" 190 | pretty_symbols.task_todo = "❌" 191 | pretty_symbols.task_incomplete = "⏳" 192 | pretty_symbols.task_canceled = "🚫" 193 | pretty_symbols.due_date = "📅" 194 | pretty_symbols.priority = "❗" 195 | pretty_symbols.today_tag = "☀️" 196 | pretty_symbols.progress_bar_true = "🟩" 197 | pretty_symbols.progress_bar_false = "⬜️" 198 | 199 | task_state_markers.todo = ' ' 200 | task_state_markers.done = 'x' 201 | task_state_markers.incomplete = '/' 202 | task_state_markers.canceled = '-' 203 | 204 | [[time_management_methods_settings.FlowTime]] 205 | name = "Break Factor" 206 | hint = "Break time is (focus time) / (break factor)" 207 | 208 | [time_management_methods_settings.FlowTime.value] 209 | Int = 5 210 | 211 | [[time_management_methods_settings.FlowTime]] 212 | name = "Auto Skip" 213 | hint = "Whether to wait for user input to enter new segments or not" 214 | 215 | [time_management_methods_settings.FlowTime.value] 216 | Bool = false 217 | 218 | [[time_management_methods_settings.Pomodoro]] 219 | name = "Focus Time" 220 | hint = "" 221 | 222 | [time_management_methods_settings.Pomodoro.value.Duration] 223 | secs = 1500 224 | nanos = 0 225 | 226 | [[time_management_methods_settings.Pomodoro]] 227 | name = "Short Break Time" 228 | hint = "" 229 | 230 | [time_management_methods_settings.Pomodoro.value.Duration] 231 | secs = 300 232 | nanos = 0 233 | 234 | [[time_management_methods_settings.Pomodoro]] 235 | name = "Auto Skip" 236 | hint = "Whether to wait for user input to enter new segments or not" 237 | 238 | [time_management_methods_settings.Pomodoro.value] 239 | Bool = false 240 | 241 | [[time_management_methods_settings.Pomodoro]] 242 | name = "Long Break Time" 243 | hint = "" 244 | 245 | [time_management_methods_settings.Pomodoro.value.Duration] 246 | secs = 900 247 | nanos = 0 248 | 249 | [[time_management_methods_settings.Pomodoro]] 250 | name = "Long Break Interval" 251 | hint = "Short breaks before a long break" 252 | 253 | [time_management_methods_settings.Pomodoro.value] 254 | Int = 4 255 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | export VAULT_TASKS_CONFIG=`pwd`/.config 2 | export VAULT_TASKS_DATA=`pwd`/.data 3 | export VAULT_TASKS_LOG_LEVEL=debug 4 | 5 | use flake 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | # Maintain dependencies for Cargo 9 | - package-ecosystem: "cargo" 10 | directory: "/" # Location of package manifests 11 | schedule: 12 | interval: "weekly" 13 | # Maintain dependencies for GitHub Actions 14 | - package-ecosystem: github-actions 15 | directory: "/" 16 | schedule: 17 | interval: weekly 18 | -------------------------------------------------------------------------------- /.github/workflows/bump.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | name: release-please 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | tag_name: ${{ steps.release.outputs.tag_name }} 17 | steps: 18 | - uses: googleapis/release-please-action@v4 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | skip-github-release: true 22 | release-type: rust 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI # Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | 11 | test: 12 | name: Test Suite 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | - name: Install Rust toolchain 18 | uses: dtolnay/rust-toolchain@stable 19 | - uses: Swatinem/rust-cache@v2 20 | - name: Run tests 21 | run: cargo test --all-features --workspace 22 | env: 23 | CI: true 24 | 25 | rustfmt: 26 | name: Rustfmt 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | - name: Install Rust toolchain 32 | uses: dtolnay/rust-toolchain@stable 33 | with: 34 | components: rustfmt 35 | - uses: Swatinem/rust-cache@v2 36 | - name: Check formatting 37 | run: cargo fmt --all --check 38 | 39 | clippy: 40 | name: Clippy 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v4 45 | - name: Install Rust toolchain 46 | uses: dtolnay/rust-toolchain@stable 47 | with: 48 | components: clippy 49 | - uses: Swatinem/rust-cache@v2 50 | - name: Clippy check 51 | run: cargo clippy --all-targets --all-features --workspace -- -D warnings 52 | 53 | docs: 54 | name: Docs 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Checkout repository 58 | uses: actions/checkout@v4 59 | - name: Install Rust toolchain 60 | uses: dtolnay/rust-toolchain@stable 61 | - uses: Swatinem/rust-cache@v2 62 | - name: Check documentation 63 | env: 64 | RUSTDOCFLAGS: -D warnings 65 | run: cargo doc --no-deps --document-private-items --all-features --workspace --examples 66 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by dist: https://opensource.axo.dev/cargo-dist/ 2 | # 3 | # Copyright 2022-2024, axodotdev 4 | # SPDX-License-Identifier: MIT or Apache-2.0 5 | # 6 | # CI that: 7 | # 8 | # * checks for a Git Tag that looks like a release 9 | # * builds artifacts with dist (archives, installers, hashes) 10 | # * uploads those artifacts to temporary workflow zip 11 | # * on success, uploads the artifacts to a GitHub Release 12 | # 13 | # Note that the GitHub Release will be created with a generated 14 | # title/body based on your changelogs. 15 | 16 | name: Release 17 | permissions: 18 | "contents": "write" 19 | 20 | # This task will run whenever you push a git tag that looks like a version 21 | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. 22 | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where 23 | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION 24 | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). 25 | # 26 | # If PACKAGE_NAME is specified, then the announcement will be for that 27 | # package (erroring out if it doesn't have the given version or isn't dist-able). 28 | # 29 | # If PACKAGE_NAME isn't specified, then the announcement will be for all 30 | # (dist-able) packages in the workspace with that version (this mode is 31 | # intended for workspaces with only one dist-able package, or with all dist-able 32 | # packages versioned/released in lockstep). 33 | # 34 | # If you push multiple tags at once, separate instances of this workflow will 35 | # spin up, creating an independent announcement for each one. However, GitHub 36 | # will hard limit this to 3 tags per commit, as it will assume more tags is a 37 | # mistake. 38 | # 39 | # If there's a prerelease-style suffix to the version, then the release(s) 40 | # will be marked as a prerelease. 41 | on: 42 | pull_request: 43 | push: 44 | tags: 45 | - '**[0-9]+.[0-9]+.[0-9]+*' 46 | 47 | jobs: 48 | # Run 'dist plan' (or host) to determine what tasks we need to do 49 | plan: 50 | runs-on: "ubuntu-latest" 51 | outputs: 52 | val: ${{ steps.plan.outputs.manifest }} 53 | tag: ${{ !github.event.pull_request && github.ref_name || '' }} 54 | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 55 | publishing: ${{ !github.event.pull_request }} 56 | env: 57 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | steps: 59 | - uses: actions/checkout@v4 60 | with: 61 | submodules: recursive 62 | - name: Install dist 63 | # we specify bash to get pipefail; it guards against the `curl` command 64 | # failing. otherwise `sh` won't catch that `curl` returned non-0 65 | shell: bash 66 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.28.0/cargo-dist-installer.sh | sh" 67 | - name: Cache dist 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: cargo-dist-cache 71 | path: ~/.cargo/bin/dist 72 | # sure would be cool if github gave us proper conditionals... 73 | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible 74 | # functionality based on whether this is a pull_request, and whether it's from a fork. 75 | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* 76 | # but also really annoying to build CI around when it needs secrets to work right.) 77 | - id: plan 78 | run: | 79 | dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 80 | echo "dist ran successfully" 81 | cat plan-dist-manifest.json 82 | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" 83 | - name: "Upload dist-manifest.json" 84 | uses: actions/upload-artifact@v4 85 | with: 86 | name: artifacts-plan-dist-manifest 87 | path: plan-dist-manifest.json 88 | 89 | # Build and packages all the platform-specific things 90 | build-local-artifacts: 91 | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) 92 | # Let the initial task tell us to not run (currently very blunt) 93 | needs: 94 | - plan 95 | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} 96 | strategy: 97 | fail-fast: false 98 | # Target platforms/runners are computed by dist in create-release. 99 | # Each member of the matrix has the following arguments: 100 | # 101 | # - runner: the github runner 102 | # - dist-args: cli flags to pass to dist 103 | # - install-dist: expression to run to install dist on the runner 104 | # 105 | # Typically there will be: 106 | # - 1 "global" task that builds universal installers 107 | # - N "local" tasks that build each platform's binaries and platform-specific installers 108 | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} 109 | runs-on: ${{ matrix.runner }} 110 | container: ${{ matrix.container && matrix.container.image || null }} 111 | env: 112 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 113 | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 114 | steps: 115 | - name: enable windows longpaths 116 | run: | 117 | git config --global core.longpaths true 118 | - uses: actions/checkout@v4 119 | with: 120 | submodules: recursive 121 | - name: Install Rust non-interactively if not already installed 122 | if: ${{ matrix.container }} 123 | run: | 124 | if ! command -v cargo > /dev/null 2>&1; then 125 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 126 | echo "$HOME/.cargo/bin" >> $GITHUB_PATH 127 | fi 128 | - name: Install dist 129 | run: ${{ matrix.install_dist.run }} 130 | # Get the dist-manifest 131 | - name: Fetch local artifacts 132 | uses: actions/download-artifact@v4 133 | with: 134 | pattern: artifacts-* 135 | path: target/distrib/ 136 | merge-multiple: true 137 | - name: Install dependencies 138 | run: | 139 | ${{ matrix.packages_install }} 140 | - name: Build artifacts 141 | run: | 142 | # Actually do builds and make zips and whatnot 143 | dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 144 | echo "dist ran successfully" 145 | - id: cargo-dist 146 | name: Post-build 147 | # We force bash here just because github makes it really hard to get values up 148 | # to "real" actions without writing to env-vars, and writing to env-vars has 149 | # inconsistent syntax between shell and powershell. 150 | shell: bash 151 | run: | 152 | # Parse out what we just built and upload it to scratch storage 153 | echo "paths<> "$GITHUB_OUTPUT" 154 | dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" 155 | echo "EOF" >> "$GITHUB_OUTPUT" 156 | 157 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 158 | - name: "Upload artifacts" 159 | uses: actions/upload-artifact@v4 160 | with: 161 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 162 | path: | 163 | ${{ steps.cargo-dist.outputs.paths }} 164 | ${{ env.BUILD_MANIFEST_NAME }} 165 | 166 | # Build and package all the platform-agnostic(ish) things 167 | build-global-artifacts: 168 | needs: 169 | - plan 170 | - build-local-artifacts 171 | runs-on: "ubuntu-latest" 172 | env: 173 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 174 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 175 | steps: 176 | - uses: actions/checkout@v4 177 | with: 178 | submodules: recursive 179 | - name: Install cached dist 180 | uses: actions/download-artifact@v4 181 | with: 182 | name: cargo-dist-cache 183 | path: ~/.cargo/bin/ 184 | - run: chmod +x ~/.cargo/bin/dist 185 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 186 | - name: Fetch local artifacts 187 | uses: actions/download-artifact@v4 188 | with: 189 | pattern: artifacts-* 190 | path: target/distrib/ 191 | merge-multiple: true 192 | - id: cargo-dist 193 | shell: bash 194 | run: | 195 | dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 196 | echo "dist ran successfully" 197 | 198 | # Parse out what we just built and upload it to scratch storage 199 | echo "paths<> "$GITHUB_OUTPUT" 200 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 201 | echo "EOF" >> "$GITHUB_OUTPUT" 202 | 203 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 204 | - name: "Upload artifacts" 205 | uses: actions/upload-artifact@v4 206 | with: 207 | name: artifacts-build-global 208 | path: | 209 | ${{ steps.cargo-dist.outputs.paths }} 210 | ${{ env.BUILD_MANIFEST_NAME }} 211 | # Determines if we should publish/announce 212 | host: 213 | needs: 214 | - plan 215 | - build-local-artifacts 216 | - build-global-artifacts 217 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 218 | if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} 219 | env: 220 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 221 | runs-on: "ubuntu-latest" 222 | outputs: 223 | val: ${{ steps.host.outputs.manifest }} 224 | steps: 225 | - uses: actions/checkout@v4 226 | with: 227 | submodules: recursive 228 | - name: Install cached dist 229 | uses: actions/download-artifact@v4 230 | with: 231 | name: cargo-dist-cache 232 | path: ~/.cargo/bin/ 233 | - run: chmod +x ~/.cargo/bin/dist 234 | # Fetch artifacts from scratch-storage 235 | - name: Fetch artifacts 236 | uses: actions/download-artifact@v4 237 | with: 238 | pattern: artifacts-* 239 | path: target/distrib/ 240 | merge-multiple: true 241 | - id: host 242 | shell: bash 243 | run: | 244 | dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 245 | echo "artifacts uploaded and released successfully" 246 | cat dist-manifest.json 247 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 248 | - name: "Upload dist-manifest.json" 249 | uses: actions/upload-artifact@v4 250 | with: 251 | # Overwrite the previous copy 252 | name: artifacts-dist-manifest 253 | path: dist-manifest.json 254 | # Create a GitHub Release while uploading all files to it 255 | - name: "Download GitHub Artifacts" 256 | uses: actions/download-artifact@v4 257 | with: 258 | pattern: artifacts-* 259 | path: artifacts 260 | merge-multiple: true 261 | - name: Cleanup 262 | run: | 263 | # Remove the granular manifests 264 | rm -f artifacts/*-dist-manifest.json 265 | - name: Create GitHub Release 266 | env: 267 | PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" 268 | ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" 269 | ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" 270 | RELEASE_COMMIT: "${{ github.sha }}" 271 | run: | 272 | # Write and read notes from a file to avoid quoting breaking things 273 | echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt 274 | 275 | gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* 276 | 277 | announce: 278 | needs: 279 | - plan 280 | - host 281 | # use "always() && ..." to allow us to wait for all publish jobs while 282 | # still allowing individual publish jobs to skip themselves (for prereleases). 283 | # "host" however must run to completion, no skipping allowed! 284 | if: ${{ always() && needs.host.result == 'success' }} 285 | runs-on: "ubuntu-latest" 286 | env: 287 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 288 | steps: 289 | - uses: actions/checkout@v4 290 | with: 291 | submodules: recursive 292 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | 12 | # RustRover 13 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 14 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 15 | # and can be added to the global gitignore or merged into this file. For a more nuclear 16 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 17 | #.idea/ 18 | 19 | # Nix 20 | .direnv/ 21 | 22 | **/.data/*.log 23 | 24 | tarpaulin-report.html 25 | 26 | out.gif 27 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [[bin]] 2 | name = "vault-tasks" 3 | path = "src/main.rs" 4 | 5 | [build-dependencies] 6 | anyhow = "1.0.98" 7 | vergen-gix = {version = "1.0.9", features = ["build", "cargo"]} 8 | 9 | [dependencies] 10 | better-panic = "0.3.0" 11 | clap = {version = "4.5.39", features = [ 12 | "derive", 13 | "cargo", 14 | "wrap_help", 15 | "unicode", 16 | "string", 17 | "unstable-styles" 18 | ]} 19 | config = "0.15.11" 20 | crossterm = {version = "0.28.1", features = ["serde", "event-stream"]} 21 | derive_deref = "1.1.1" 22 | directories = "6.0.0" 23 | futures = "0.3.31" 24 | human-panic = "2.0.2" 25 | lazy_static = "1.5.0" 26 | libc = "0.2.172" 27 | ratatui = {version = "0.29.0", features = ["serde", "macros", "widget-calendar"]} 28 | signal-hook = "0.3.18" 29 | strip-ansi-escapes = "0.2.1" 30 | tokio = {version = "1.45.1", features = ["full"]} 31 | tokio-util = "0.7.15" 32 | tracing-error = "0.2.1" 33 | tracing-subscriber = {version = "0.3.19", features = ["env-filter", "serde"]} 34 | chrono = {version="0.4.41"} 35 | tui-widget-list = "0.13.2" 36 | tui-input = "0.12.1" 37 | edit = "0.1.5" 38 | tui-scrollview = "=0.5.1" 39 | toml = "0.8.22" 40 | color-eyre = "0.6.5" 41 | serde = {version = "1.0.219", features = ["derive"]} 42 | tracing = "0.1.41" 43 | pretty_assertions = "1.4.1" 44 | strum = {version = "0.27.1", features = ["derive"]} 45 | strum_macros = "0.27.1" 46 | notify-rust = "4.11.7" 47 | lexical-sort = "0.3.1" 48 | winnow = "0.7.10" 49 | time = "0.3.41" 50 | ratskin = "0.2.0" 51 | 52 | [dev-dependencies] 53 | insta = {version = "1.43.1", features = ["yaml"]} 54 | 55 | [package] 56 | name = "vault-tasks" 57 | description = "TUI Markdown Task Manager" 58 | build = "build.rs" 59 | categories = ["command-line-utilities", "visualization"] 60 | keywords = ["markdown", "task-manager", "productivity", "tui", "obsidian"] 61 | version = "0.11.2" 62 | edition = "2021" 63 | repository = "https://github.com/louis-thevenet/vault-tasks" 64 | authors = ["Louis Thevenet "] 65 | license = "MIT" 66 | 67 | [profile.dev] 68 | 69 | [profile.dev.package] 70 | insta.opt-level = 3 71 | 72 | # The profile that 'cargo dist' will build with 73 | [profile.dist] 74 | inherits = "release" 75 | lto = "thin" 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vault-tasks 2 | 3 | `vault-tasks` is a TUI Markdown task manager. 4 | 5 | It will parse any Markdown file or vault and display the tasks it contains. 6 | 7 | ## Demo using `./test-vault` 8 | 9 | ``` 10 | ./test-vault 11 | ├── dir 12 | │ ├── subdir 13 | │ │ ├── test_1.md 14 | │ │ ├── test_2.md 15 | │ │ └── test_3.md 16 | │ └── test_0.md 17 | ├── example_physics_class.md 18 | ├── example_vault-tasks_project.md 19 | └── test.md 20 | ``` 21 | 22 | ![Demo explorer](./examples/demo_explorer.gif) 23 | ![Demo filter](./examples/demo_filter_view.png) 24 | ![Demo calendar](./examples/demo_calendar_view.png) 25 | ![Demo time](./examples/demo_time_view.png) 26 | 27 | ## Why 28 | 29 | I made this tool because I wanted to integrate my task system directly inside my Second Brain. 30 | 31 | Markdown tasks are very easy to integrate with knowledge/projects and are source-control-friendly. 32 | 33 | I wrote a [blog post](https://louis-thevenet.github.io/blog/pkms/2025/04/12/personal-knowledge-management-and-tasks.html) explaining my workflow and how I use vault-tasks if you're interested. 34 | 35 | I also spend most of my writing time in the terminal (Helix) and do not rely on heavy external software. 36 | 37 | ## Features 38 | 39 | - Task Parser (see [Usage](https://github.com/louis-thevenet/vault-tasks/tree/main?tab=readme-ov-file#usage)) 40 | - Subtasks 41 | - Fixed and relative dates 42 | - special _today_ tag and regular tags 43 | - descriptions, priority, completion percentage 44 | - Navigate vault 45 | - Edit tasks or open in default editor 46 | - Search through tasks (sort and filter) 47 | - Calendar view and timeline 48 | - Time Management tab (Pomodoro & Flowtime) 49 | 50 | ## Planned Features 51 | 52 | I'm planning a big refactor this summer. I'm currently gathering ideas and critics from my personal use to improve vault-tasks. If you're using it and want to contribute by requesting a feature or a change, don't hesitate to open an issue or contact me. 53 | 54 | ## Installation 55 | 56 | ### Cargo 57 | 58 | ``` 59 | cargo install vault-tasks 60 | ``` 61 | 62 | ### Nix 63 | 64 | You can get it from nixpkgs 24.11 or directly from this repo's flake: 65 | 66 | ```nix 67 | vault-tasks = { 68 | url = "github:louis-thevenet/vault-tasks"; 69 | inputs.nixpkgs.follows = "nixpkgs"; 70 | }; 71 | ``` 72 | 73 | And use the package in your configuration: `inputs.vault-tasks.packages.${pkgs.system}.default` 74 | 75 | ### Build From Source 76 | 77 | ``` 78 | git clone https://github.com/louis-thevenet/vault-tasks.git 79 | cd vault-tasks 80 | cargo build --release 81 | ``` 82 | 83 | ## Usage 84 | 85 | See `vault-tasks --help` for basic usage. 86 | 87 | ### Writing tasks 88 | 89 | ```md 90 | 91 | 92 | - [ ] An example task #tag tomorrow p1 93 | A description 94 | of this task 95 | - [x] A subtask today @today 96 | - [/] Another subtask 10/23 @today c50 97 | Partly done 98 | - [-] This one is canceled 99 | ``` 100 | 101 | | Token | Meaning | 102 | | ------------------------------------------ | ----------------------------------------------------------------- | 103 | | `- [ ]` (`- [x]`, ...) | declares a task and sets its state | 104 | | `p1` (`p10`, ...) | sets the priority | 105 | | `c50` (`c99`, `c150`, ...) | Sets the completion percentage | 106 | | `#tag` | is a tag, a task can have zero or more tags | 107 | | `@today` (`@tod`, `@t`) | is a special tag that will mark the task as part of today's tasks | 108 | | `23/10` (`2024/23/10`) | sets the due date with a literal date | 109 | | `today` (`tdy`) | sets the due date to today | 110 | | `tomorrow` (`tmr`) | sets the due date to tomorrow | 111 | | a day of the week (`monday` or `mon`, etc) | sets the due date to the next occurence of that day | 112 | | `3d` (`3m, 3w, 3y`, ...) | means "in 3 days" and will set the due date accordingly | 113 | 114 | - Task states are **Done** (`x`), **To-Do** (` `), **Incomplete** (`/`) and **Canceled** (`-`) 115 | 116 | - `@today` allows you mark a task for today while keeping a due date. It will show up with a ☀️ in `vault-tasks`. 117 | 118 | - Relative dates are always replaced by literal dates once `vault-tasks` is run. Thanks to this, `vault-tasks` does not store any data except its config file. 119 | 120 | - Other tokens will be part of the title of that task 121 | 122 | - Descriptions and subtasks are declared using indents (see configuration) 123 | 124 | This is what you will see in the preview of this `README.md` in `vault-tasks`: 125 | 126 | ![](./examples/demo_readme_explorer.png) 127 | 128 | 129 | 130 | 131 | ### Default Key Map 132 | 133 | Check the key map within the app with `?` 134 | 135 | #### General 136 | 137 | | Key | Alternate Key | Action | 138 | | ----------- | ------------- | ----------------------------------------- | 139 | | `shift-h` | `shift-←` | Previous tab | 140 | | `shift-l` | `shift-→` | Next tab | 141 | | `ctrl-k` | `ctrl-↓` | Scroll up | 142 | | `ctrl-j` | `ctrl-↑` | Scroll down | 143 | | `page_down` | | Scroll one page down | 144 | | `page_up` | | Scroll one page up | 145 | | `q` | `ctrl-c` | Quit the application | 146 | | `?` | | Open keybindings menu for the current tab | 147 | 148 | #### Explorer Tab 149 | 150 | ##### Navigation 151 | 152 | | Key | Alternate Key | Action | 153 | | --- | ----------------- | ------------------- | 154 | | `k` | `↑`, `shift-tab` | Previous entry | 155 | | `j` | `↓`, `tab` | Next entry | 156 | | `h` | `←`, `back_space` | Leave current entry | 157 | | `l` | `→`,`enter` | Enter current entry | 158 | 159 | ##### Commands 160 | 161 | | Key | Action | 162 | | --- | ---------------------------------------------- | 163 | | `s` | Focus search bar (`enter` or `esc` to unfocus) | 164 | | `o` | Open selection in default editor | 165 | | `e` | Quickly edit selection | 166 | | `r` | Reload vault | 167 | | `t` | Mark task **To-Do** | 168 | | `d` | Mark task **Done** | 169 | | `i` | Mark task **Incomplete** | 170 | | `c` | Mark task **Canceled** | 171 | 172 | ![](./examples/demo_explorer.gif) 173 | 174 | #### Filter Tab 175 | 176 | ##### Commands 177 | 178 | | Key | Action | 179 | | --------- | ------------------------ | 180 | | `enter` | Focus/Unfocus search bar | 181 | | `Shift-s` | Change sorting mode | 182 | 183 | ![](./examples/demo_filter_view.png) 184 | 185 | #### Calendar Tab 186 | 187 | ##### Navigation 188 | 189 | | Key | Alternate Key | Action | 190 | | --------- | ------------- | -------- | 191 | | `h` | `←` | +1 day | 192 | | `l` | `→` | -1 day | 193 | | `j` | `↓` | +7 days | 194 | | `k` | `↑` | -7 days | 195 | | `Shift-j` | `Shift-↓` | +1 month | 196 | | `Shift-k` | `Shift-↑` | -1 month | 197 | | `n` | | +1 year | 198 | | `Shift-n` | | -1 year | 199 | 200 | ##### Commands 201 | 202 | | Key | Action | 203 | | --- | ---------- | 204 | | `t` | Goto Today | 205 | 206 | ![](./examples/demo_calendar_view.png) 207 | 208 | #### Time Management Tab 209 | 210 | ##### Navigation 211 | 212 | | Key | Alternate Key | Action | 213 | | --- | ------------- | ---------------- | 214 | | `k` | `↑` | Previous setting | 215 | | `j` | `↓` | Next setting | 216 | 217 | ##### Commands 218 | 219 | | Key | Action | 220 | | ----------- | ---------------------------------- | 221 | | `space` | Next segment (skip current) | 222 | | `p` | Pause timer | 223 | | `e` | Edit selected setting | 224 | | `shift-tab` | Previous time management technique | 225 | | `tab` | Next time management technique | 226 | 227 | ![](./examples/demo_time_view.png) 228 | 229 | ### Modes 230 | 231 | You can start already focused on a tab by using one of the CLI subcommands: 232 | 233 | ```bash 234 | vault-tasks explorer # is the default 235 | # Or 236 | vault-tasks filter 237 | vault-tasks time 238 | vault-tasks calendar 239 | ``` 240 | 241 | You can also output the content of a vault in standard output using 242 | 243 | ```bash 244 | vault-tasks stdout 245 | ``` 246 | 247 | Example output: 248 | 249 | ``` 250 | vault-tasks -v ./README.md stdout 251 | ./README.md 252 | ‾‾‾‾‾‾‾‾‾‾‾ 253 | README.md 254 | ‾‾‾‾‾‾‾‾‾ 255 | Vault-tasks 256 | ‾‾‾‾‾‾‾‾‾‾‾ 257 | Usage 258 | ‾‾‾‾‾ 259 | Writing tasks 260 | ‾‾‾‾‾‾‾‾‾‾‾‾‾ 261 | ❌ An example task 262 | 📅 2025-03-31 (tomorrow) ❗1 263 | #tag 264 | A description 265 | of this task 266 | 267 | ✅ A subtask 268 | ☀️ 📅 2025-03-30 (today) 269 | 270 | 271 | ⏳ Another subtask 272 | ☀️ 📅 2025-10-23 (in 7 months) [🟩🟩⬜️⬜️⬜️ 50%] 273 | Partly done 274 | 275 | 276 | 🚫 This one is canceled 277 | ``` 278 | 279 | ## Configuration 280 | 281 | The [`config.toml`](./.config/config.toml) contains the default configuration which can be generated using `vault-tasks generate-config`. 282 | 283 | In `$HOME/.config/vault-tasks/config.toml`, you can override the default settings, keybindings and colorscheme. 284 | 285 | In particular, you can set a default vault path. 286 | 287 | ## Contributing 288 | 289 | Feel free to submit issues or pull requests. Contributions are welcome! 290 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | extern crate anyhow; 2 | extern crate vergen_gix; 3 | 4 | use anyhow::Result; 5 | use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder}; 6 | 7 | fn main() -> Result<()> { 8 | let build = BuildBuilder::all_build()?; 9 | let gix = GixBuilder::all_git()?; 10 | let cargo = CargoBuilder::all_cargo()?; 11 | Emitter::default() 12 | .add_instructions(&build)? 13 | .add_instructions(&gix)? 14 | .add_instructions(&cargo)? 15 | .emit() 16 | } 17 | -------------------------------------------------------------------------------- /desktop/vault-tasks.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=vault-tasks 3 | Version=0.11.2 4 | GenericName=Markdown Tasks Manager 5 | Exec=vault-tasks 6 | Terminal=true 7 | Type=Application 8 | Categories=Utility;Productivity;ConsoleOnly; 9 | StartupNotify=false 10 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | [dist.github-custom-runners] 5 | global = "ubuntu-latest" 6 | 7 | [dist.github-custom-runners.x86_64-unknown-linux-gnu] 8 | runner = "ubuntu-latest" 9 | 10 | # Config for 'dist' 11 | [dist] 12 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 13 | cargo-dist-version = "0.28.0" 14 | # CI backends to support 15 | ci = "github" 16 | # The installers to generate for each app 17 | installers = ["shell", "powershell"] 18 | # Target platforms to build apps for (Rust target-triple syntax) 19 | targets = [ 20 | "aarch64-apple-darwin", 21 | # "aarch64-unknown-linux-gnu", # fails due to Ubuntu 20.04 retirement 22 | "x86_64-apple-darwin", 23 | "x86_64-unknown-linux-gnu", 24 | "x86_64-pc-windows-msvc" 25 | ] 26 | # Path that installers should place binaries in 27 | install-path = "CARGO_HOME" 28 | # Whether to install an updater program 29 | install-updater = true 30 | 31 | -------------------------------------------------------------------------------- /examples/demo_calendar.tape: -------------------------------------------------------------------------------- 1 | # Output ./demo_calendar.gif 2 | # Output ./demo_calendar.mp4 3 | 4 | Set Shell fish 5 | Set Theme "Builtin Solarized Light" 6 | Set TypingSpeed 80ms 7 | Set FontSize 22 8 | Set Width 1800 9 | Set Height 1000 10 | 11 | Hide 12 | Type "vault-tasks -c ../.config/ -v ../test-vault calendar" 13 | Enter 14 | Show 15 | Screenshot demo_calendar_view.png 16 | Type "aaa" # so that the screenshot is actually produced 17 | Ctrl+c 18 | 19 | -------------------------------------------------------------------------------- /examples/demo_calendar_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louis-thevenet/vault-tasks/cde41d426da40558b17d790c98938ae47613d7db/examples/demo_calendar_view.png -------------------------------------------------------------------------------- /examples/demo_explorer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louis-thevenet/vault-tasks/cde41d426da40558b17d790c98938ae47613d7db/examples/demo_explorer.gif -------------------------------------------------------------------------------- /examples/demo_explorer.tape: -------------------------------------------------------------------------------- 1 | Output ./demo_explorer.gif 2 | # Output ./demo_explorer.mp4 3 | # Output ./demo_explorer.webm 4 | # Output ./frames/ 5 | 6 | Set Shell fish 7 | Set Theme "Builtin Solarized Light" 8 | Set TypingSpeed 150ms 9 | Set FontSize 22 10 | Set Width 1800 11 | Set Height 1000 12 | 13 | Hide 14 | Type "vault-tasks -c ../.config/ -v ../test-vault explorer" 15 | Enter 16 | Show 17 | 18 | # First entry is `dir` 19 | Down 20 | # Physics 21 | Sleep 3s 22 | 23 | Down 24 | # Vault-tasks 25 | Sleep 3s 26 | 27 | # Search bar 28 | Type "s" 29 | Backspace@0.5s 6 30 | Enter 31 | # Now we see every tasks 32 | Down 33 | Down # Back to #3 34 | 35 | # Scroll 36 | Ctrl+J 37 | Sleep 300ms 38 | Ctrl+J 39 | Sleep 300ms 40 | Ctrl+J 41 | Sleep 300ms 42 | Ctrl+J 43 | Sleep 300ms 44 | Ctrl+J 45 | Sleep 300ms 46 | Ctrl+J 47 | Sleep 300ms 48 | Ctrl+J 49 | Sleep 300ms 50 | Ctrl+J 51 | Sleep 300ms 52 | Ctrl+J 53 | Sleep 2s 54 | 55 | Up@0.5s 2 56 | Enter@0.5s 57 | Down 58 | Sleep 1.5s 59 | 60 | Up@0.5s 61 | Enter 62 | Down 63 | Sleep 1.5s 64 | Down 65 | Sleep 1.5s 66 | 67 | # open in editor and mark tasks done 68 | Type "o" 69 | Sleep 1s 70 | 71 | Type "/- \[ " 72 | Enter 73 | Type ";" 74 | Sleep 1s 75 | Type "rx" 76 | Sleep 600ms 77 | Type ":wq" 78 | Enter 79 | 80 | Sleep 2s 81 | 82 | Ctrl+c 83 | -------------------------------------------------------------------------------- /examples/demo_filter.tape: -------------------------------------------------------------------------------- 1 | # Output ./demo_filter.gif 2 | # Output ./demo_filter.mp4 3 | 4 | Set Shell fish 5 | Set Theme "Builtin Solarized Light" 6 | Set TypingSpeed 80ms 7 | Set FontSize 22 8 | Set Width 1800 9 | Set Height 1000 10 | 11 | Hide 12 | Type "vault-tasks -c ../.config/ -v ../test-vault filter" 13 | Enter 14 | Show 15 | 16 | Type "inc" 17 | Sleep 1 18 | 19 | Screenshot demo_filter_view.png 20 | Type "aaa" # so that the screenshot is actually produced 21 | 22 | # Stop vault-tasks 23 | Ctrl+c 24 | -------------------------------------------------------------------------------- /examples/demo_filter_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louis-thevenet/vault-tasks/cde41d426da40558b17d790c98938ae47613d7db/examples/demo_filter_view.png -------------------------------------------------------------------------------- /examples/demo_readme.tape: -------------------------------------------------------------------------------- 1 | Set Shell fish 2 | Set FontSize 22 3 | Set Width 1300 4 | Set Height 900 5 | Set Theme "Gruvbox Light" 6 | 7 | Type "vault-tasks -c ../.config/ -v ../README.md" 8 | Enter 9 | Sleep 2 10 | Screenshot demo_readme_explorer.png 11 | 12 | # Shift+L 13 | # Sleep 2 14 | # Screenshot demo_filter_readme_filter.png 15 | Type "aaa" # so that the screenshot is actually produced 16 | -------------------------------------------------------------------------------- /examples/demo_readme_explorer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louis-thevenet/vault-tasks/cde41d426da40558b17d790c98938ae47613d7db/examples/demo_readme_explorer.png -------------------------------------------------------------------------------- /examples/demo_time.tape: -------------------------------------------------------------------------------- 1 | # Output ./demo_time.gif 2 | # Output ./demo_time.mp4 3 | 4 | Set Shell fish 5 | Set Theme "Builtin Solarized Light" 6 | Set TypingSpeed 80ms 7 | Set FontSize 22 8 | Set Width 1800 9 | Set Height 1000 10 | 11 | Hide 12 | Type "vault-tasks -c ../.config/ -v ../test-vault time" 13 | Enter 14 | Show 15 | 16 | # Time Management tab 17 | Type "j" 18 | Type "e" 19 | Sleep 1s 20 | Backspace 21 | Backspace 22 | Backspace 23 | Backspace 24 | Backspace 25 | Type "0:5" 26 | Enter 1 27 | Space 2 28 | Sleep 2 29 | Screenshot demo_time_view.png 30 | Type "aaa" # so that the screenshot is actually produced 31 | 32 | # Stop vault-tasks 33 | Ctrl+c 34 | -------------------------------------------------------------------------------- /examples/demo_time_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louis-thevenet/vault-tasks/cde41d426da40558b17d790c98938ae47613d7db/examples/demo_time_view.png -------------------------------------------------------------------------------- /examples/justfile: -------------------------------------------------------------------------------- 1 | make-all-vhs: 2 | vhs < ./demo_explorer.tape 3 | vhs < ./demo_filter.tape 4 | vhs < ./demo_calendar.tape 5 | vhs < ./demo_time.tape 6 | vhs < ./demo_readme.tape 7 | git checkout HEAD -- ../test-vault # it is edited during explorer demo 8 | git checkout HEAD -- ../README.md # it is edited during explorer demo 9 | 10 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": "nixpkgs-lib" 6 | }, 7 | "locked": { 8 | "lastModified": 1741352980, 9 | "narHash": "sha256-+u2UunDA4Cl5Fci3m7S643HzKmIDAe+fiXrLqYsR2fs=", 10 | "owner": "hercules-ci", 11 | "repo": "flake-parts", 12 | "rev": "f4330d22f1c5d2ba72d3d22df5597d123fdb60a9", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "hercules-ci", 17 | "repo": "flake-parts", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1743095683, 24 | "narHash": "sha256-gWd4urRoLRe8GLVC/3rYRae1h+xfQzt09xOfb0PaHSk=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "5e5402ecbcb27af32284d4a62553c019a3a49ea6", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs-lib": { 38 | "locked": { 39 | "lastModified": 1740877520, 40 | "narHash": "sha256-oiwv/ZK/2FhGxrCkQkB83i7GnWXPPLzoqFHpDD3uYpk=", 41 | "owner": "nix-community", 42 | "repo": "nixpkgs.lib", 43 | "rev": "147dee35aab2193b174e4c0868bd80ead5ce755c", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "nix-community", 48 | "repo": "nixpkgs.lib", 49 | "type": "github" 50 | } 51 | }, 52 | "nixpkgs_2": { 53 | "locked": { 54 | "lastModified": 1735554305, 55 | "narHash": "sha256-zExSA1i/b+1NMRhGGLtNfFGXgLtgo+dcuzHzaWA6w3Q=", 56 | "owner": "nixos", 57 | "repo": "nixpkgs", 58 | "rev": "0e82ab234249d8eee3e8c91437802b32c74bb3fd", 59 | "type": "github" 60 | }, 61 | "original": { 62 | "owner": "nixos", 63 | "ref": "nixpkgs-unstable", 64 | "repo": "nixpkgs", 65 | "type": "github" 66 | } 67 | }, 68 | "root": { 69 | "inputs": { 70 | "flake-parts": "flake-parts", 71 | "nixpkgs": "nixpkgs", 72 | "systems": "systems", 73 | "treefmt-nix": "treefmt-nix" 74 | } 75 | }, 76 | "systems": { 77 | "locked": { 78 | "lastModified": 1681028828, 79 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 80 | "owner": "nix-systems", 81 | "repo": "default", 82 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 83 | "type": "github" 84 | }, 85 | "original": { 86 | "owner": "nix-systems", 87 | "repo": "default", 88 | "type": "github" 89 | } 90 | }, 91 | "treefmt-nix": { 92 | "inputs": { 93 | "nixpkgs": "nixpkgs_2" 94 | }, 95 | "locked": { 96 | "lastModified": 1743081648, 97 | "narHash": "sha256-WRAylyYptt6OX5eCEBWyTwOEqEtD6zt33rlUkr6u3cE=", 98 | "owner": "numtide", 99 | "repo": "treefmt-nix", 100 | "rev": "29a3d7b768c70addce17af0869f6e2bd8f5be4b7", 101 | "type": "github" 102 | }, 103 | "original": { 104 | "owner": "numtide", 105 | "repo": "treefmt-nix", 106 | "type": "github" 107 | } 108 | } 109 | }, 110 | "root": "root", 111 | "version": 7 112 | } 113 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 4 | flake-parts.url = "github:hercules-ci/flake-parts"; 5 | systems.url = "github:nix-systems/default"; 6 | 7 | # Dev tools 8 | treefmt-nix.url = "github:numtide/treefmt-nix"; 9 | }; 10 | 11 | outputs = 12 | inputs: 13 | inputs.flake-parts.lib.mkFlake { inherit inputs; } { 14 | systems = import inputs.systems; 15 | imports = [ 16 | inputs.treefmt-nix.flakeModule 17 | ]; 18 | perSystem = 19 | { 20 | config, 21 | self', 22 | pkgs, 23 | lib, 24 | system, 25 | ... 26 | }: 27 | let 28 | cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); 29 | rust-toolchain = pkgs.symlinkJoin { 30 | name = "rust-toolchain"; 31 | paths = with pkgs; [ 32 | rustc 33 | cargo 34 | cargo-watch 35 | rust-analyzer 36 | rustPlatform.rustcSrc 37 | cargo-dist 38 | cargo-tarpaulin 39 | cargo-insta 40 | cargo-machete 41 | cargo-edit 42 | ]; 43 | }; 44 | 45 | buildInputs = with pkgs; [ ]; 46 | nativeBuildInputs = with pkgs; [ ]; 47 | in 48 | { 49 | # Rust package 50 | packages.default = pkgs.rustPlatform.buildRustPackage { 51 | inherit (cargoToml.package) name version; 52 | src = ./.; 53 | cargoLock.lockFile = ./Cargo.lock; 54 | 55 | RUST_BACKTRACE = "full"; 56 | 57 | nativeBuildInputs = nativeBuildInputs; 58 | buildInputs = buildInputs; 59 | postInstall = "install -Dm444 desktop/vault-tasks.desktop -t $out/share/applications"; 60 | }; 61 | 62 | # Rust dev environment 63 | devShells.default = pkgs.mkShell { 64 | inputsFrom = [ 65 | config.treefmt.build.devShell 66 | ]; 67 | RUST_BACKTRACE = "full"; 68 | RUST_SRC_PATH = pkgs.rustPlatform.rustLibSrc; 69 | 70 | packages = 71 | nativeBuildInputs 72 | ++ buildInputs 73 | ++ [ 74 | rust-toolchain 75 | pkgs.clippy 76 | pkgs.just 77 | pkgs.vhs 78 | (pkgs.python3.withPackages (python-pkgs: [ 79 | python-pkgs.ics 80 | ])) 81 | ]; 82 | }; 83 | 84 | # Add your auto-formatters here. 85 | # cf. https://numtide.github.io/treefmt/ 86 | treefmt.config = { 87 | projectRootFile = "flake.nix"; 88 | programs = { 89 | nixpkgs-fmt.enable = true; 90 | rustfmt.enable = true; 91 | toml-sort.enable = true; 92 | }; 93 | }; 94 | }; 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /misc/ics_to_md.py: -------------------------------------------------------------------------------- 1 | from ics import Calendar 2 | from datetime import datetime, timezone 3 | import sys 4 | 5 | file = open(sys.argv[1], 'r') 6 | c = Calendar(file.read()) 7 | 8 | file.close() 9 | 10 | now = datetime.now(timezone.utc) 11 | 12 | for e in c.events: 13 | if e.begin.datetime > now: # Only add future events 14 | event_str = "- [ ]" 15 | event_str += " " + e.name 16 | event_str += " " + e.begin.datetime.strftime("%d/%m/%Y") 17 | print(event_str) 18 | -------------------------------------------------------------------------------- /src/action.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::KeyEvent; 2 | use serde::{Deserialize, Serialize}; 3 | use strum::Display; 4 | 5 | use crate::app::Mode; 6 | 7 | #[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize, Hash)] 8 | pub enum Action { 9 | Tick, 10 | Render, 11 | Resize(u16, u16), 12 | Suspend, 13 | Resume, 14 | Quit, 15 | ClearScreen, 16 | Error(String), 17 | Help, 18 | // Raw Key Events 19 | Key(KeyEvent), 20 | ReloadVault, 21 | // Movements 22 | GotoToday, 23 | NextMonth, 24 | PreviousMonth, 25 | NextYear, 26 | PreviousYear, 27 | PreviousMethod, 28 | NextMethod, 29 | NextSegment, 30 | Pause, 31 | Up, 32 | Down, 33 | Left, 34 | Right, 35 | Enter, 36 | Cancel, 37 | // View 38 | ViewPageUp, 39 | ViewUp, 40 | ViewPageDown, 41 | ViewDown, 42 | ViewLeft, 43 | ViewRight, 44 | // Menus 45 | SwitchSortingMode, 46 | Escape, 47 | Search, 48 | TabRight, 49 | TabLeft, 50 | Open, 51 | Edit, 52 | MarkToDo, 53 | MarkDone, 54 | MarkCancel, 55 | MarkIncomplete, 56 | IncreaseCompletion, 57 | DecreaseCompletion, 58 | Focus(Mode), 59 | } 60 | impl PartialOrd for Action { 61 | fn partial_cmp(&self, other: &Self) -> Option { 62 | Some(self.cmp(other)) 63 | } 64 | } 65 | impl Ord for Action { 66 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 67 | self.to_string().cmp(&other.to_string()) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use crossterm::event::KeyEvent; 3 | use ratatui::prelude::Rect; 4 | use serde::{Deserialize, Serialize}; 5 | use tokio::sync::mpsc; 6 | use tracing::{debug, error, info}; 7 | 8 | use crate::{ 9 | action::Action, 10 | cli::{Cli, Commands}, 11 | components::{ 12 | calendar_tab::CalendarTab, explorer_tab::ExplorerTab, filter_tab::FilterTab, 13 | fps::FpsCounter, home::Home, time_management_tab::TimeManagementTab, Component, 14 | }, 15 | config::Config, 16 | tui::{Event, Tui}, 17 | }; 18 | 19 | struct InitialState { 20 | tab: Action, 21 | } 22 | 23 | pub struct App { 24 | config: Config, 25 | initial_state: InitialState, 26 | tick_rate: f64, 27 | frame_rate: f64, 28 | components: Vec>, 29 | should_quit: bool, 30 | should_suspend: bool, 31 | mode: Mode, 32 | last_tick_key_events: Vec, 33 | action_tx: mpsc::UnboundedSender, 34 | action_rx: mpsc::UnboundedReceiver, 35 | } 36 | 37 | #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 38 | pub enum Mode { 39 | #[default] 40 | Home, 41 | Explorer, 42 | Filter, 43 | TimeManagement, 44 | Calendar, 45 | } 46 | 47 | impl App { 48 | pub fn new(args: &Cli) -> Result { 49 | let config = Config::new(args)?; 50 | let initial_state = Self::get_initial_state(args); 51 | let (action_tx, action_rx) = mpsc::unbounded_channel(); 52 | Ok(Self { 53 | tick_rate: args.tick_rate, 54 | frame_rate: args.frame_rate, 55 | components: vec![ 56 | Box::new(Home::new()), 57 | Box::::default(), 58 | Box::new(ExplorerTab::new()), 59 | Box::new(FilterTab::new()), 60 | Box::new(CalendarTab::new()), 61 | Box::new(TimeManagementTab::new()), 62 | ], 63 | should_quit: false, 64 | should_suspend: false, 65 | config, 66 | mode: Mode::Home, 67 | last_tick_key_events: Vec::new(), 68 | action_tx, 69 | action_rx, 70 | initial_state, 71 | }) 72 | } 73 | fn get_initial_state(args: &Cli) -> InitialState { 74 | let tab = match args.command { 75 | Some(Commands::Filter) => Action::Focus(Mode::Filter), 76 | Some(Commands::TimeManagement) => Action::Focus(Mode::TimeManagement), 77 | Some(Commands::Calendar) => Action::Focus(Mode::Calendar), 78 | Some(Commands::Explorer | Commands::GenerateConfig { path: _ }) | None => { 79 | Action::Focus(Mode::Explorer) 80 | } 81 | _ => { 82 | error!("Unhandled command: {:?}", args.command); 83 | Action::Focus(Mode::Explorer) 84 | } 85 | }; 86 | InitialState { tab } 87 | } 88 | pub async fn run(&mut self) -> Result<()> { 89 | let mut tui = Tui::new()? 90 | // .mouse(true) // uncomment this line to enable mouse support 91 | .tick_rate(self.tick_rate) 92 | .frame_rate(self.frame_rate); 93 | tui.enter()?; 94 | 95 | for component in &mut self.components { 96 | component.register_action_handler(self.action_tx.clone())?; 97 | } 98 | for component in &mut self.components { 99 | component.register_config_handler(self.config.clone())?; 100 | } 101 | for component in &mut self.components { 102 | component.init(tui.size()?)?; 103 | } 104 | 105 | let action_tx = self.action_tx.clone(); 106 | 107 | action_tx.send(self.initial_state.tab.clone())?; 108 | 109 | loop { 110 | self.handle_events(&mut tui).await?; 111 | self.handle_actions(&mut tui)?; 112 | if self.should_suspend { 113 | tui.suspend()?; 114 | action_tx.send(Action::Resume)?; 115 | action_tx.send(Action::ClearScreen)?; 116 | // tui.mouse(true); 117 | tui.enter()?; 118 | } else if self.should_quit { 119 | tui.stop()?; 120 | break; 121 | } 122 | } 123 | tui.exit()?; 124 | Ok(()) 125 | } 126 | 127 | async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> { 128 | let Some(event) = tui.next_event().await else { 129 | return Ok(()); 130 | }; 131 | let action_tx = self.action_tx.clone(); 132 | match event { 133 | Event::Quit => action_tx.send(Action::Quit)?, 134 | Event::Tick => action_tx.send(Action::Tick)?, 135 | Event::Render => action_tx.send(Action::Render)?, 136 | Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, 137 | Event::Key(key) => self.handle_key_event(key)?, 138 | _ => {} 139 | } 140 | for component in &mut self.components { 141 | if let Some(action) = component.handle_events(Some(event.clone()))? { 142 | action_tx.send(action)?; 143 | } 144 | } 145 | Ok(()) 146 | } 147 | 148 | fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { 149 | let action_tx = self.action_tx.clone(); 150 | let Some(keymap) = self.config.keybindings.get(&self.mode) else { 151 | return Ok(()); 152 | }; 153 | if let Some(action) = keymap.get(&vec![key]) { 154 | info!("Got action: {action:?}"); 155 | // Look for components in editing mode 156 | for component in &self.components { 157 | // Is it in editing mode and is the action in the escape list ? 158 | if component.blocking_mode() && !component.escape_blocking_mode().contains(action) { 159 | info!("Action was sent as raw key"); 160 | action_tx.send(Action::Key(key))?; 161 | return Ok(()); 162 | } 163 | } 164 | action_tx.send(action.clone())?; 165 | } else { 166 | // If there is a component in editing mode, send the raw key 167 | if self.components.iter().any(|c| c.blocking_mode()) { 168 | info!("Got raw key: {key:?}"); 169 | action_tx.send(Action::Key(key))?; 170 | return Ok(()); 171 | } 172 | 173 | // If the key was not handled as a single key action, 174 | // then consider it for multi-key combinations. 175 | self.last_tick_key_events.push(key); 176 | 177 | // Check for multi-key combinations 178 | if let Some(action) = keymap.get(&self.last_tick_key_events) { 179 | info!("Got action: {action:?}"); 180 | action_tx.send(action.clone())?; 181 | } 182 | } 183 | Ok(()) 184 | } 185 | 186 | fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> { 187 | while let Ok(action) = self.action_rx.try_recv() { 188 | if action != Action::Tick && action != Action::Render { 189 | debug!("Action: {action:?}"); 190 | } 191 | match action { 192 | Action::Focus(mode) => self.mode = mode, 193 | Action::Tick => { 194 | self.last_tick_key_events.drain(..); 195 | } 196 | Action::Quit => self.should_quit = true, 197 | Action::Suspend => self.should_suspend = true, 198 | Action::Resume => self.should_suspend = false, 199 | Action::ClearScreen => tui.terminal.clear()?, 200 | Action::Resize(w, h) => self.handle_resize(tui, w, h)?, 201 | Action::Render => self.render(tui)?, 202 | _ => {} 203 | } 204 | for component in &mut self.components { 205 | if let Some(action) = component.update(Some(tui), action.clone())? { 206 | self.action_tx.send(action)?; 207 | }; 208 | } 209 | } 210 | Ok(()) 211 | } 212 | 213 | fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> { 214 | tui.resize(Rect::new(0, 0, w, h))?; 215 | self.render(tui)?; 216 | Ok(()) 217 | } 218 | 219 | fn render(&mut self, tui: &mut Tui) -> Result<()> { 220 | tui.draw(|frame| { 221 | for component in &mut self.components { 222 | if let Err(err) = component.draw(frame, frame.area()) { 223 | let _ = self 224 | .action_tx 225 | .send(Action::Error(format!("Failed to draw: {err:?}"))); 226 | } 227 | } 228 | })?; 229 | Ok(()) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::{ArgAction, Parser, Subcommand}; 4 | 5 | use crate::config::{get_config_dir, get_data_dir}; 6 | 7 | #[derive(Parser, Debug)] 8 | #[command(author, version = version(), about)] 9 | pub struct Cli { 10 | /// Vault to open (can be a single file or a directory) 11 | #[arg(short, long, value_name = "PATH")] 12 | pub vault_path: Option, 13 | /// Show frame rate and tick rate 14 | #[arg(short, long, action = ArgAction::SetTrue)] 15 | pub show_fps: bool, 16 | /// Tick rate, i.e. number of ticks per second 17 | #[arg(short, long, value_name = "FLOAT", default_value_t = 4.0)] 18 | pub tick_rate: f64, 19 | /// Frame rate, i.e. number of frames per second 20 | #[arg(short, long, value_name = "FLOAT", default_value_t = 60.0)] 21 | pub frame_rate: f64, 22 | /// Use a custom config file 23 | #[arg(short, long, value_name = "PATH")] 24 | pub config_path: Option, 25 | /// Optional subcommand to run 26 | #[command(subcommand)] 27 | pub command: Option, 28 | } 29 | #[derive(Subcommand, Debug, Clone)] 30 | pub enum Commands { 31 | /// Open explorer view 32 | #[command(alias = "exp")] 33 | Explorer, 34 | /// Open filter view 35 | #[command(alias = "flt")] 36 | Filter, 37 | /// Open Time Management view 38 | #[command(alias = "time")] 39 | TimeManagement, 40 | /// Open Calendar view 41 | #[command(alias = "cld")] 42 | Calendar, 43 | /// Generates a new configuration file from the default one 44 | GenerateConfig { path: Option }, 45 | /// Write tasks to STDOUT 46 | Stdout, 47 | } 48 | 49 | const VERSION_MESSAGE: &str = env!("CARGO_PKG_VERSION"); 50 | 51 | pub fn version() -> String { 52 | let author = clap::crate_authors!(); 53 | 54 | // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string(); 55 | let config_dir_path = get_config_dir().display().to_string(); 56 | let data_dir_path = get_data_dir().display().to_string(); 57 | 58 | format!( 59 | "\ 60 | {VERSION_MESSAGE} 61 | 62 | Authors: {author} 63 | 64 | Config directory: {config_dir_path} 65 | Data directory: {data_dir_path}" 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /src/components.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use crossterm::event::{KeyEvent, MouseEvent}; 3 | use ratatui::{ 4 | layout::{Rect, Size}, 5 | Frame, 6 | }; 7 | use tokio::sync::mpsc::UnboundedSender; 8 | 9 | use crate::{ 10 | action::Action, 11 | config::Config, 12 | tui::{Event, Tui}, 13 | }; 14 | 15 | pub mod calendar_tab; 16 | pub mod explorer_tab; 17 | pub mod filter_tab; 18 | pub mod fps; 19 | pub mod home; 20 | pub mod time_management_tab; 21 | 22 | /// `Component` is a trait that represents a visual and interactive element of the user interface. 23 | /// 24 | /// Implementors of this trait can be registered with the main application loop and will be able to 25 | /// receive events, update state, and be rendered on the screen. 26 | pub trait Component { 27 | /// Register an action handler that can send actions for processing if necessary. 28 | /// 29 | /// # Arguments 30 | /// 31 | /// * `tx` - An unbounded sender that can send actions. 32 | /// 33 | /// # Returns 34 | /// 35 | /// * `Result<()>` - An Ok result or an error. 36 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 37 | let _ = tx; // to appease clippy 38 | Ok(()) 39 | } 40 | /// Register a configuration handler that provides configuration settings if necessary. 41 | /// 42 | /// # Arguments 43 | /// 44 | /// * `config` - Configuration settings. 45 | /// 46 | /// # Returns 47 | /// 48 | /// * `Result<()>` - An Ok result or an error. 49 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 50 | let _ = config; // to appease clippy 51 | Ok(()) 52 | } 53 | /// Initialize the component with a specified area if necessary. 54 | /// 55 | /// # Arguments 56 | /// 57 | /// * `area` - Rectangular area to initialize the component within. 58 | /// 59 | /// # Returns 60 | /// 61 | /// * `Result<()>` - An Ok result or an error. 62 | fn init(&mut self, area: Size) -> Result<()> { 63 | let _ = area; // to appease clippy 64 | Ok(()) 65 | } 66 | /// Handle incoming events and produce actions if necessary. 67 | /// 68 | /// # Arguments 69 | /// 70 | /// * `event` - An optional event to be processed. 71 | /// 72 | /// # Returns 73 | /// 74 | /// * `Result>` - An action to be processed or none. 75 | fn handle_events(&mut self, event: Option) -> Result> { 76 | let action = match event { 77 | Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, 78 | Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, 79 | _ => None, 80 | }; 81 | Ok(action) 82 | } 83 | /// Handle key events and produce actions if necessary. 84 | /// 85 | /// # Arguments 86 | /// 87 | /// * `key` - A key event to be processed. 88 | /// 89 | /// # Returns 90 | /// 91 | /// * `Result>` - An action to be processed or none. 92 | fn handle_key_event(&mut self, key: KeyEvent) -> Result> { 93 | let _ = key; // to appease clippy 94 | Ok(None) 95 | } 96 | /// Handle mouse events and produce actions if necessary. 97 | /// 98 | /// # Arguments 99 | /// 100 | /// * `mouse` - A mouse event to be processed. 101 | /// 102 | /// # Returns 103 | /// 104 | /// * `Result>` - An action to be processed or none. 105 | fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result> { 106 | let _ = mouse; // to appease clippy 107 | Ok(None) 108 | } 109 | /// Update the state of the component based on a received action. (REQUIRED) 110 | /// 111 | /// # Arguments 112 | /// 113 | /// * `action` - An action that may modify the state of the component. 114 | /// 115 | /// # Returns 116 | /// 117 | /// * `Result>` - An action to be processed or none. 118 | fn update(&mut self, tui: Option<&mut Tui>, action: Action) -> Result> { 119 | let _ = tui; // to appease clippy 120 | let _ = action; // to appease clippy 121 | Ok(None) 122 | } 123 | /// Render the component on the screen. (REQUIRED) 124 | /// 125 | /// # Arguments 126 | /// 127 | /// * `f` - A frame used for rendering. 128 | /// * `area` - The area in which the component should be drawn. 129 | /// 130 | /// # Returns 131 | /// 132 | /// * `Result<()>` - An Ok result or an error. 133 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>; 134 | /// Returns zero or more `Action` that should never be sent as `Action::RawKeyEvent` even if `Self::blocking_mode` returns `true`. 135 | /// 136 | /// A better way to do this would be to have a type `BlockingMode` and return a map (mode:actions). 137 | /// `Self::blocking_mode` returning a `BlockingMode` 138 | /// 139 | /// # Returns 140 | /// * `Vec` - A list of `Action` that should never be sent as `Action::RawKeyEvent`. 141 | fn escape_blocking_mode(&self) -> Vec { 142 | vec![] 143 | } 144 | /// Whether the app should send Actions or `Action::Key` 145 | /// 146 | /// # Returns 147 | /// 148 | /// * `bool` - Whether the component is in editing mode or not. 149 | fn blocking_mode(&self) -> bool { 150 | false 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/components/explorer_tab/entry_list.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use tracing::debug; 3 | 4 | use super::ExplorerTab; 5 | 6 | impl ExplorerTab<'_> { 7 | pub(super) fn leave_selected_entry(&mut self) -> Result<()> { 8 | if self.current_path.is_empty() { 9 | return Ok(()); 10 | } 11 | 12 | self.current_path.pop().unwrap_or_default(); 13 | // Update index of selected entry to previous selected entry 14 | self.state_center_view.select(self.state_left_view.selected); 15 | 16 | self.update_entries()?; 17 | 18 | // Find previously selected entry 19 | self.select_previous_left_entry(); 20 | Ok(()) 21 | } 22 | pub(super) fn enter_selected_entry(&mut self) -> Result<()> { 23 | // Update path with selected entry 24 | let entry = match self 25 | .entries_center_view 26 | .get(self.state_center_view.selected.unwrap_or_default()) 27 | { 28 | Some(i) => i, 29 | None => return Ok(()), // No selected entry (vault is empty) 30 | } 31 | .1 32 | .clone(); 33 | self.current_path.push(entry); 34 | 35 | // Can we enter ? 36 | if !self.task_mgr.can_enter(&self.current_path) { 37 | self.current_path.pop(); 38 | debug!("Coudln't enter: {:?}", self.current_path); 39 | return Ok(()); 40 | } 41 | 42 | // Update selections 43 | self.state_left_view 44 | .select(Some(self.state_center_view.selected.unwrap_or_default())); 45 | self.state_center_view.select(Some(0)); 46 | 47 | debug!("Entering: {:?}", self.current_path); 48 | 49 | // Update entries 50 | self.update_entries() 51 | } 52 | 53 | pub(super) fn select_previous_left_entry(&mut self) { 54 | if let Some(new_previous_entry) = self.current_path.last() { 55 | self.state_left_view.select(Some( 56 | self.entries_left_view 57 | .clone() 58 | .into_iter() 59 | .enumerate() 60 | .find(|(_, entry)| &entry.1 == new_previous_entry) 61 | .unwrap_or_default() 62 | .0, 63 | )); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/components/explorer_tab/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::core::task::Task; 2 | use crate::tui::Tui; 3 | use crate::{action::Action, core::vault_data::VaultData}; 4 | 5 | use super::{ExplorerTab, DIRECTORY_EMOJI, FILE_EMOJI}; 6 | use color_eyre::eyre::bail; 7 | use color_eyre::Result; 8 | use std::cmp::Ordering; 9 | use std::path::PathBuf; 10 | use tracing::{debug, error, info}; 11 | 12 | impl ExplorerTab<'_> { 13 | pub(super) fn apply_prefixes(entries: &[(String, String)]) -> Vec { 14 | entries 15 | .iter() 16 | .map(|item| format!("{} {}", item.0, item.1)) 17 | .collect() 18 | } 19 | 20 | fn vault_data_to_prefix_name(vd: &VaultData) -> (String, String) { 21 | match vd { 22 | VaultData::Directory(name, _) => (DIRECTORY_EMOJI.to_owned(), name.clone()), 23 | VaultData::Header(level, name, _) => ( 24 | if name.contains(".md") { 25 | FILE_EMOJI.to_owned() 26 | } else { 27 | "#".repeat(*level).clone() 28 | }, 29 | name.clone(), 30 | ), 31 | VaultData::Task(task) => (task.state.to_string(), task.name.clone()), 32 | } 33 | } 34 | 35 | pub(super) fn vault_data_to_entry_list(vd: &[VaultData]) -> Vec<(String, String)> { 36 | let mut res = vd 37 | .iter() 38 | .map(Self::vault_data_to_prefix_name) 39 | .collect::>(); 40 | 41 | if let Some(entry) = res.first() { 42 | if entry.0 == DIRECTORY_EMOJI || entry.0 == FILE_EMOJI { 43 | res.sort_by(|a, b| { 44 | if a.0 == DIRECTORY_EMOJI { 45 | if b.0 == DIRECTORY_EMOJI { 46 | a.1.cmp(&b.1) 47 | } else { 48 | Ordering::Less 49 | } 50 | } else if b.0 == DIRECTORY_EMOJI { 51 | Ordering::Greater 52 | } else { 53 | a.1.cmp(&b.1) 54 | } 55 | }); 56 | } 57 | } 58 | res 59 | } 60 | pub(super) fn get_preview_path(&self) -> Result> { 61 | let mut path_to_preview = self.current_path.clone(); 62 | if self.entries_center_view.is_empty() { 63 | bail!("Center view is empty for {:?}", self.current_path) 64 | } 65 | match self 66 | .entries_center_view 67 | .get(self.state_center_view.selected.unwrap_or_default()) 68 | { 69 | Some(res) => path_to_preview.push(res.clone().1), 70 | None => bail!( 71 | "Index ({:?}) of selected entry out of range {:?}", 72 | self.state_center_view.selected, 73 | self.entries_center_view 74 | ), 75 | } 76 | Ok(path_to_preview) 77 | } 78 | pub(super) fn open_current_file(&self, tui_opt: Option<&mut Tui>) -> Result<()> { 79 | let Some(tui) = tui_opt else { 80 | bail!("Could not open current entry, Tui was None") 81 | }; 82 | let path = self.get_current_path_to_file(); 83 | info!("Opening {:?} in default editor.", path); 84 | if let Some(tx) = &self.command_tx { 85 | tui.exit()?; 86 | edit::edit_file(path)?; 87 | tui.enter()?; 88 | tx.send(Action::ClearScreen)?; 89 | } else { 90 | bail!("Failed to open current path") 91 | } 92 | if let Some(tx) = self.command_tx.clone() { 93 | tx.send(Action::ReloadVault)?; 94 | } 95 | Ok(()) 96 | } 97 | pub(super) fn get_current_path_to_file(&self) -> PathBuf { 98 | let mut path = self.config.tasks_config.vault_path.clone(); 99 | for e in &self 100 | .get_preview_path() 101 | .unwrap_or_else(|_| self.current_path.clone()) 102 | { 103 | if path 104 | .extension() 105 | .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) 106 | { 107 | break; 108 | } 109 | path.push(e); 110 | } 111 | path 112 | } 113 | pub(super) fn get_selected_task(&self) -> Option { 114 | let path = match self.get_preview_path() { 115 | Ok(path) => path, 116 | Err(e) => { 117 | error!("Error while getting path for selected task: {}", e); 118 | return None; 119 | } 120 | }; 121 | debug!("Getting selected task from current path: {:?}", path); 122 | 123 | let Ok(entry) = self.task_mgr.get_vault_data_from_path(&path) else { 124 | error!("Error while collecting tasks from path"); 125 | return None; 126 | }; 127 | 128 | if let VaultData::Task(task) = entry { 129 | Some(task.clone()) 130 | } else { 131 | info!("Selected object is not a Task"); 132 | None 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/components/filter_tab.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use color_eyre::Result; 4 | use crossterm::event::Event; 5 | use ratatui::widgets::{List, Tabs}; 6 | use ratatui::{prelude::*, widgets::Block}; 7 | use strum::IntoEnumIterator; 8 | use tokio::sync::mpsc::UnboundedSender; 9 | use tracing::debug; 10 | use tui_scrollview::ScrollViewState; 11 | 12 | use super::Component; 13 | 14 | use crate::app::Mode; 15 | use crate::core::filter::{self, filter_to_vec, parse_search_input}; 16 | use crate::core::sorter::SortingMode; 17 | use crate::core::task::Task; 18 | use crate::core::vault_data::VaultData; 19 | use crate::core::TaskManager; 20 | use crate::tui::Tui; 21 | use crate::widgets::help_menu::HelpMenu; 22 | use crate::widgets::input_bar::InputBar; 23 | use crate::widgets::task_list::TaskList; 24 | use crate::{action::Action, config::Config}; 25 | use tui_input::backend::crossterm::EventHandler; 26 | 27 | /// Struct that helps with drawing the component 28 | struct FilterTabArea { 29 | search: Rect, 30 | sorting_modes_list: Rect, 31 | tag_list: Rect, 32 | task_list: Rect, 33 | footer: Rect, 34 | } 35 | 36 | #[derive(Default)] 37 | pub struct FilterTab<'a> { 38 | command_tx: Option>, 39 | config: Config, 40 | is_focused: bool, 41 | /// Tasks that match the current input in the filter bar 42 | matching_tasks: Vec, 43 | /// Tags that match the current input in the filter bar 44 | matching_tags: Vec, 45 | /// Input bar used to apply a filter 46 | input_bar_widget: InputBar<'a>, 47 | task_mgr: TaskManager, 48 | task_list_widget_state: ScrollViewState, 49 | /// Whether the help panel is open or not 50 | show_help: bool, 51 | help_menu_widget: HelpMenu<'a>, 52 | sorting_mode: SortingMode, 53 | } 54 | 55 | impl FilterTab<'_> { 56 | pub fn new() -> Self { 57 | Self::default() 58 | } 59 | /// Updates tasks and tags with the current filter string 60 | fn update_matching_entries(&mut self) { 61 | let filter_task = parse_search_input( 62 | self.input_bar_widget.input.value(), 63 | &self.config.tasks_config, 64 | ); 65 | 66 | // Filter tasks 67 | self.matching_tasks = filter_to_vec(&self.task_mgr.tasks, &filter_task); 68 | SortingMode::sort(&mut self.matching_tasks, self.sorting_mode); 69 | 70 | // Reset ScrollViewState 71 | self.task_list_widget_state.scroll_to_top(); 72 | 73 | // Filter tags 74 | if !self.matching_tasks.is_empty() { 75 | // We know that the vault will not be empty here 76 | 77 | let mut tags = HashSet::new(); 78 | TaskManager::collect_tags( 79 | &filter::filter(&self.task_mgr.tasks, &filter_task) 80 | .expect("Entry list was not empty but vault was."), 81 | &mut tags, 82 | ); 83 | self.matching_tags = tags.iter().cloned().collect::>(); 84 | self.matching_tags.sort(); 85 | } 86 | } 87 | fn split_frame(area: Rect) -> FilterTabArea { 88 | let vertical = Layout::vertical([ 89 | Constraint::Length(1), 90 | Constraint::Length(3), 91 | Constraint::Min(0), 92 | Constraint::Length(1), 93 | Constraint::Length(1), 94 | ]); 95 | let [_header, search, content, footer, _tab_footera] = vertical.areas(area); 96 | 97 | let [lateral_lists, task_list] = 98 | Layout::horizontal([Constraint::Length(16), Constraint::Min(0)]).areas(content); 99 | 100 | let [sorting_modes_list, tag_list] = 101 | Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(lateral_lists); 102 | FilterTabArea { 103 | search, 104 | sorting_modes_list, 105 | tag_list, 106 | task_list, 107 | footer, 108 | } 109 | } 110 | 111 | fn render_sorting_modes(&self, area: Rect, buf: &mut Buffer) { 112 | let titles = SortingMode::iter().map(|arg0: SortingMode| SortingMode::to_string(&arg0)); 113 | 114 | let highlight_style = *self 115 | .config 116 | .styles 117 | .get(&crate::app::Mode::Home) 118 | .unwrap() 119 | .get("highlighted_style") 120 | .unwrap(); 121 | 122 | let selected_tab_index = self.sorting_mode as usize; 123 | Tabs::new(titles) 124 | .select(selected_tab_index) 125 | .highlight_style(highlight_style) 126 | .padding("", "") 127 | .divider(" ") 128 | .block(Block::bordered().title("Sort By")) 129 | .render(area, buf); 130 | } 131 | pub fn render_footer(&self, area: Rect, frame: &mut Frame) { 132 | if self.input_bar_widget.is_focused { 133 | Line::raw("Stop Searching: ") 134 | } else { 135 | Line::raw("Search: | Cycle sorting modes: Shift-s") 136 | } 137 | .centered() 138 | .render(area, frame.buffer_mut()); 139 | } 140 | } 141 | impl Component for FilterTab<'_> { 142 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 143 | self.command_tx = Some(tx); 144 | Ok(()) 145 | } 146 | 147 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 148 | self.task_mgr = TaskManager::load_from_config(&config.tasks_config)?; 149 | self.config = config; 150 | self.input_bar_widget.is_focused = true; // Start with search bar focused 151 | self.input_bar_widget.input = self.input_bar_widget.input.clone().with_value( 152 | self.config 153 | .tasks_config 154 | .filter_default_search_string 155 | .clone(), 156 | ); 157 | self.help_menu_widget = HelpMenu::new(Mode::Filter, &self.config); 158 | self.update_matching_entries(); 159 | Ok(()) 160 | } 161 | 162 | fn blocking_mode(&self) -> bool { 163 | self.is_focused && (self.input_bar_widget.is_focused || self.show_help) 164 | } 165 | fn escape_blocking_mode(&self) -> Vec { 166 | vec![Action::Enter, Action::Cancel, Action::Escape] 167 | } 168 | fn update(&mut self, _tui: Option<&mut Tui>, action: Action) -> Result> { 169 | if !self.is_focused { 170 | match action { 171 | Action::ReloadVault => { 172 | self.task_mgr.reload(&self.config.tasks_config)?; 173 | self.update_matching_entries(); 174 | } 175 | Action::Focus(Mode::Filter) => self.is_focused = true, 176 | Action::Focus(mode) if mode != Mode::Filter => self.is_focused = false, 177 | _ => (), 178 | } 179 | } else if self.input_bar_widget.is_focused { 180 | match action { 181 | Action::Enter | Action::Escape => { 182 | self.input_bar_widget.is_focused = !self.input_bar_widget.is_focused; 183 | } 184 | Action::Key(key) => { 185 | self.input_bar_widget.input.handle_event(&Event::Key(key)); 186 | self.update_matching_entries(); 187 | } 188 | _ => (), 189 | } 190 | } else if self.show_help { 191 | match action { 192 | Action::ViewUp | Action::Up => self.help_menu_widget.scroll_up(), 193 | Action::ViewDown | Action::Down => self.help_menu_widget.scroll_down(), 194 | Action::Help | Action::Escape | Action::Enter => { 195 | self.show_help = !self.show_help; 196 | } 197 | _ => (), 198 | } 199 | } else { 200 | match action { 201 | Action::Focus(mode) if mode != Mode::Filter => self.is_focused = false, 202 | Action::Focus(Mode::Filter) => self.is_focused = true, 203 | Action::Enter | Action::Search | Action::Cancel | Action::Escape => { 204 | self.input_bar_widget.is_focused = !self.input_bar_widget.is_focused; 205 | } 206 | Action::SwitchSortingMode => { 207 | self.sorting_mode = self.sorting_mode.next(); 208 | self.update_matching_entries(); 209 | } 210 | Action::Help => self.show_help = !self.show_help, 211 | Action::ReloadVault => { 212 | self.task_mgr.reload(&self.config.tasks_config)?; 213 | self.update_matching_entries(); 214 | } 215 | Action::ViewUp => self.task_list_widget_state.scroll_up(), 216 | Action::ViewDown => self.task_list_widget_state.scroll_down(), 217 | Action::ViewPageUp => self.task_list_widget_state.scroll_page_up(), 218 | Action::ViewPageDown => self.task_list_widget_state.scroll_page_down(), 219 | Action::ViewRight => self.task_list_widget_state.scroll_right(), 220 | Action::ViewLeft => self.task_list_widget_state.scroll_left(), 221 | _ => (), 222 | } 223 | } 224 | 225 | Ok(None) 226 | } 227 | 228 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 229 | if !self.is_focused { 230 | return Ok(()); 231 | } 232 | 233 | let areas = Self::split_frame(area); 234 | self.render_footer(areas.footer, frame); 235 | 236 | if self.input_bar_widget.is_focused { 237 | let width = areas.search.width.max(3) - 3; // 2 for borders, 1 for cursor 238 | let scroll = self.input_bar_widget.input.visual_scroll(width as usize); 239 | 240 | // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering 241 | frame.set_cursor_position(( 242 | // Put cursor past the end of the input text 243 | areas.search.x.saturating_add( 244 | (self.input_bar_widget.input.visual_cursor().max(scroll) - scroll) as u16, 245 | ) + 1, 246 | // Move one line down, from the border to the input line 247 | areas.search.y + 1, 248 | )); 249 | } 250 | 251 | self.input_bar_widget.block = Some(Block::bordered().style( 252 | if self.input_bar_widget.is_focused { 253 | *self 254 | .config 255 | .styles 256 | .get(&crate::app::Mode::Home) 257 | .unwrap() 258 | .get("highlighted_bar_style") 259 | .unwrap() 260 | } else { 261 | Style::new() 262 | }, 263 | )); 264 | self.input_bar_widget 265 | .clone() 266 | .render(areas.search, frame.buffer_mut()); 267 | 268 | let tag_list = List::new(self.matching_tags.iter().map(std::string::String::as_str)) 269 | .block(Block::bordered().title("Found Tags")); 270 | 271 | let entries_list = TaskList::new( 272 | &self.config, 273 | &self 274 | .matching_tasks 275 | .clone() 276 | .iter() 277 | .map(|t| VaultData::Task(t.clone())) 278 | .collect::>(), 279 | areas.task_list.width, 280 | true, 281 | ); 282 | 283 | Widget::render(tag_list, areas.tag_list, frame.buffer_mut()); 284 | self.render_sorting_modes(areas.sorting_modes_list, frame.buffer_mut()); 285 | 286 | entries_list.render( 287 | areas.task_list, 288 | frame.buffer_mut(), 289 | &mut self.task_list_widget_state, 290 | ); 291 | if self.show_help { 292 | debug!("showing help"); 293 | self.help_menu_widget.clone().render( 294 | area, 295 | frame.buffer_mut(), 296 | &mut self.help_menu_widget.state, 297 | ); 298 | } 299 | Ok(()) 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /src/components/fps.rs: -------------------------------------------------------------------------------- 1 | use std::time::Instant; 2 | 3 | use color_eyre::Result; 4 | use ratatui::{ 5 | layout::{Constraint, Layout, Rect}, 6 | style::{Style, Stylize}, 7 | text::Span, 8 | widgets::Paragraph, 9 | Frame, 10 | }; 11 | 12 | use super::Component; 13 | 14 | use crate::{action::Action, tui::Tui}; 15 | 16 | #[derive(Debug, Clone, PartialEq)] 17 | pub struct FpsCounter { 18 | enabled: bool, 19 | last_tick_update: Instant, 20 | tick_count: u32, 21 | ticks_per_second: f64, 22 | 23 | last_frame_update: Instant, 24 | frame_count: u32, 25 | frames_per_second: f64, 26 | } 27 | 28 | impl Default for FpsCounter { 29 | fn default() -> Self { 30 | Self::new() 31 | } 32 | } 33 | 34 | impl FpsCounter { 35 | pub fn new() -> Self { 36 | Self { 37 | last_tick_update: Instant::now(), 38 | tick_count: 0, 39 | ticks_per_second: 0.0, 40 | last_frame_update: Instant::now(), 41 | frame_count: 0, 42 | frames_per_second: 0.0, 43 | enabled: false, 44 | } 45 | } 46 | 47 | fn app_tick(&mut self) -> Result<()> { 48 | self.tick_count += 1; 49 | let now = Instant::now(); 50 | let elapsed = (now - self.last_tick_update).as_secs_f64(); 51 | if elapsed >= 1.0 { 52 | self.ticks_per_second = f64::from(self.tick_count) / elapsed; 53 | self.last_tick_update = now; 54 | self.tick_count = 0; 55 | } 56 | Ok(()) 57 | } 58 | 59 | fn render_tick(&mut self) -> Result<()> { 60 | self.frame_count += 1; 61 | let now = Instant::now(); 62 | let elapsed = (now - self.last_frame_update).as_secs_f64(); 63 | if elapsed >= 1.0 { 64 | self.frames_per_second = f64::from(self.frame_count) / elapsed; 65 | self.last_frame_update = now; 66 | self.frame_count = 0; 67 | } 68 | Ok(()) 69 | } 70 | } 71 | 72 | impl Component for FpsCounter { 73 | fn update(&mut self, _tui: Option<&mut Tui>, action: Action) -> Result> { 74 | if self.enabled { 75 | match action { 76 | Action::Tick => self.app_tick()?, 77 | Action::Render => self.render_tick()?, 78 | _ => {} 79 | }; 80 | } 81 | Ok(None) 82 | } 83 | 84 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 85 | if self.enabled { 86 | let [top, _] = 87 | Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area); 88 | let message = format!( 89 | "{:.2} ticks/sec, {:.2} FPS", 90 | self.ticks_per_second, self.frames_per_second 91 | ); 92 | let span = Span::styled(message, Style::new().dim()); 93 | let paragraph = Paragraph::new(span).right_aligned(); 94 | frame.render_widget(paragraph, top); 95 | } 96 | Ok(()) 97 | } 98 | 99 | fn register_config_handler(&mut self, config: crate::config::Config) -> Result<()> { 100 | self.enabled = config.config.show_fps; 101 | Ok(()) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/components/home.rs: -------------------------------------------------------------------------------- 1 | use super::Component; 2 | use crate::{action::Action, app::Mode, config::Config, tui::Tui}; 3 | use color_eyre::Result; 4 | use ratatui::{prelude::*, widgets::Tabs}; 5 | use strum::{Display, EnumCount, EnumIter, FromRepr, IntoEnumIterator}; 6 | use tokio::sync::mpsc::UnboundedSender; 7 | use tracing::error; 8 | 9 | #[derive(Default)] 10 | pub struct Home { 11 | command_tx: Option>, 12 | config: Config, 13 | selected_tab: SelectedTab, 14 | } 15 | 16 | impl Home { 17 | pub fn new() -> Self { 18 | Self::default() 19 | } 20 | 21 | fn send_new_focused_tab_command(&self) { 22 | if let Some(tx) = &self.command_tx { 23 | if let Err(e) = tx.send(match self.selected_tab { 24 | SelectedTab::Explorer => Action::Focus(Mode::Explorer), 25 | SelectedTab::Filter => Action::Focus(Mode::Filter), 26 | SelectedTab::TimeManagement => Action::Focus(Mode::TimeManagement), 27 | SelectedTab::Calendar => Action::Focus(Mode::Calendar), 28 | }) { 29 | error!("Could not focus selected tab: {e}"); 30 | } 31 | } 32 | } 33 | pub fn next_tab(&mut self) { 34 | self.selected_tab = self.selected_tab.next(); 35 | self.send_new_focused_tab_command(); 36 | } 37 | 38 | pub fn previous_tab(&mut self) { 39 | self.selected_tab = self.selected_tab.previous(); 40 | self.send_new_focused_tab_command(); 41 | } 42 | fn render_tabs(&self, area: Rect, buf: &mut Buffer) { 43 | let titles = SelectedTab::iter().map(SelectedTab::title); 44 | 45 | let highlight_style = *self 46 | .config 47 | .styles 48 | .get(&crate::app::Mode::Home) 49 | .unwrap() 50 | .get("highlighted_style") 51 | .unwrap(); 52 | 53 | let selected_tab_index = self.selected_tab as usize; 54 | Tabs::new(titles) 55 | .select(selected_tab_index) 56 | .highlight_style(highlight_style) 57 | .padding("", "") 58 | .divider(" ") 59 | .render(area, buf); 60 | } 61 | 62 | pub fn render_footer(area: Rect, frame: &mut Frame) { 63 | Line::raw("Change tab: Shift+ | Quit: q | Help: ?") 64 | .centered() 65 | .render(area, frame.buffer_mut()); 66 | } 67 | } 68 | impl Component for Home { 69 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 70 | self.command_tx = Some(tx); 71 | self.send_new_focused_tab_command(); 72 | Ok(()) 73 | } 74 | 75 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 76 | self.config = config; 77 | Ok(()) 78 | } 79 | 80 | fn update(&mut self, _tui: Option<&mut Tui>, action: Action) -> Result> { 81 | match action { 82 | Action::TabRight => self.next_tab(), 83 | Action::TabLeft => self.previous_tab(), 84 | Action::Focus(Mode::Explorer) => self.selected_tab = SelectedTab::Explorer, 85 | Action::Focus(Mode::Filter) => self.selected_tab = SelectedTab::Filter, 86 | Action::Focus(Mode::TimeManagement) => self.selected_tab = SelectedTab::TimeManagement, 87 | Action::Focus(Mode::Calendar) => self.selected_tab = SelectedTab::Calendar, 88 | _ => (), 89 | } 90 | Ok(None) 91 | } 92 | 93 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 94 | use Constraint::{Length, Min}; 95 | let vertical = Layout::vertical([Length(1), Min(0), Length(1)]); 96 | let [header_area, _inner_area, footer_area] = vertical.areas(area); 97 | 98 | self.render_tabs(header_area, frame.buffer_mut()); 99 | Self::render_footer(footer_area, frame); 100 | Ok(()) 101 | } 102 | } 103 | 104 | #[derive(Default, Clone, Copy, Display, FromRepr, EnumIter, EnumCount)] 105 | enum SelectedTab { 106 | #[default] 107 | #[strum(to_string = "Explorer")] 108 | Explorer, 109 | #[strum(to_string = "Filter")] 110 | Filter, 111 | #[strum(to_string = "Calendar")] 112 | Calendar, 113 | #[strum(to_string = "Time Management")] 114 | TimeManagement, 115 | } 116 | 117 | impl SelectedTab { 118 | /// Get the previous tab, wrapping around if there is no previous tab. 119 | fn previous(self) -> Self { 120 | let current_index: usize = self as usize; 121 | let previous_index = current_index.wrapping_sub(1) % Self::COUNT; 122 | Self::from_repr(previous_index).unwrap_or(self) 123 | } 124 | 125 | /// Get the next tab, wrapping around if there is no next tab. 126 | fn next(self) -> Self { 127 | let current_index = self as usize; 128 | let next_index = current_index.wrapping_add(1) % Self::COUNT; 129 | Self::from_repr(next_index).unwrap_or(self) 130 | } 131 | fn title(self) -> Line<'static> { 132 | format!(" {self} ").into() 133 | } 134 | } 135 | 136 | #[cfg(test)] 137 | mod tests { 138 | use insta::assert_snapshot; 139 | use ratatui::{backend::TestBackend, Terminal}; 140 | use tokio::sync::mpsc::unbounded_channel; 141 | 142 | use crate::{ 143 | components::{home::Home, Component}, 144 | config::Config, 145 | }; 146 | 147 | #[test] 148 | fn test_render_home_component() { 149 | let mut home = Home::new(); 150 | let (tx, _rx) = unbounded_channel(); 151 | 152 | home.register_action_handler(tx).unwrap(); 153 | home.register_config_handler(Config::default()).unwrap(); 154 | 155 | let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); 156 | 157 | terminal 158 | .draw(|frame| home.draw(frame, frame.area()).unwrap()) 159 | .unwrap(); 160 | assert_snapshot!(terminal.backend()); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/components/snapshots/vault_tasks__components__home__tests__render_home_component.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/components/home.rs 3 | expression: terminal.backend() 4 | --- 5 | " Explorer Filter Calendar Time Management " 6 | " " 7 | " " 8 | " " 9 | " " 10 | " " 11 | " " 12 | " " 13 | " " 14 | " " 15 | " " 16 | " " 17 | " " 18 | " " 19 | " " 20 | " " 21 | " " 22 | " " 23 | " " 24 | " Change tab: Shift+ | Quit: q | Help: ? " 25 | -------------------------------------------------------------------------------- /src/core/parser.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_name_repetitions)] 2 | pub mod parser_file_entry; 3 | pub mod task; 4 | -------------------------------------------------------------------------------- /src/core/parser/snapshots/vault_tasks__core__parser__parser_file_entry__tests__code_block_task.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/core/parser/parser_file_entry.rs 3 | expression: res 4 | --- 5 | Test 6 | ‾‾‾‾ 7 | 1 Header 8 | ‾‾‾‾‾‾‾‾ 9 | ❌ This one is not 10 | -------------------------------------------------------------------------------- /src/core/parser/snapshots/vault_tasks__core__parser__parser_file_entry__tests__commented_task.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/core/parser/parser_file_entry.rs 3 | expression: res 4 | --- 5 | Test 6 | ‾‾‾‾ 7 | 1 Header 8 | ‾‾‾‾‾‾‾‾ 9 | ❌ This one is not 10 | -------------------------------------------------------------------------------- /src/core/parser/snapshots/vault_tasks__core__parser__parser_file_entry__tests__insert_global_tag.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/core/parser/parser_file_entry.rs 3 | expression: res 4 | --- 5 | Test 6 | ‾‾‾‾ 7 | 1 Header 8 | ‾‾‾‾‾‾‾‾ 9 | ❌ Task 10 | #test 11 | 12 | 2 Header 13 | ‾‾‾‾‾‾‾‾ 14 | 3 Header 15 | ‾‾‾‾‾‾‾‾ 16 | ❌ Task 17 | #test 18 | 19 | ❌ Task 2 20 | #test 21 | 22 | 2 Header 2 23 | ‾‾‾‾‾‾‾‾‾‾ 24 | ❌ Task 25 | #test 26 | Description 27 | -------------------------------------------------------------------------------- /src/core/parser/snapshots/vault_tasks__core__parser__parser_file_entry__tests__nested_tasks_desc.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/core/parser/parser_file_entry.rs 3 | expression: res 4 | --- 5 | Test 6 | ‾‾‾‾ 7 | 1 Header 8 | ‾‾‾‾‾‾‾‾ 9 | ❌ t1 10 | t1 11 | t1 12 | t1 13 | t1 14 | 15 | ❌ t2 16 | t2 17 | t2 18 | t2 19 | t2 20 | 21 | ❌ t3 22 | t3 23 | t3 24 | 25 | ❌ t4 26 | t4 27 | t4 28 | -------------------------------------------------------------------------------- /src/core/parser/snapshots/vault_tasks_core__parser__parser_file_entry__tests__insert_global_tag.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: vault-tasks-core/src/parser/parser_file_entry.rs 3 | expression: res 4 | --- 5 | Test 6 | ‾‾‾‾ 7 | 1 Header 8 | ‾‾‾‾‾‾‾‾ 9 | ❌ Task 10 | #test 11 | 12 | 2 Header 13 | ‾‾‾‾‾‾‾‾ 14 | 3 Header 15 | ‾‾‾‾‾‾‾‾ 16 | ❌ Task 17 | #test 18 | 19 | ❌ Task 2 20 | #test 21 | 22 | 2 Header 2 23 | ‾‾‾‾‾‾‾‾‾‾ 24 | ❌ Task 25 | #test 26 | Description 27 | -------------------------------------------------------------------------------- /src/core/parser/snapshots/vault_tasks_core__parser__parser_file_entry__tests__nested_tasks_desc.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: vault-tasks-core/src/parser/parser_file_entry.rs 3 | expression: res 4 | --- 5 | Test 6 | ‾‾‾‾ 7 | 1 Header 8 | ‾‾‾‾‾‾‾‾ 9 | ❌ t1 10 | t1 11 | t1 12 | t1 13 | t1 14 | 15 | ❌ t2 16 | t2 17 | t2 18 | t2 19 | t2 20 | 21 | ❌ t3 22 | t3 23 | t3 24 | 25 | ❌ t4 26 | t4 27 | t4 28 | -------------------------------------------------------------------------------- /src/core/parser/task.rs: -------------------------------------------------------------------------------- 1 | mod parse_completion; 2 | mod parse_today; 3 | mod parser_due_date; 4 | mod parser_priorities; 5 | mod parser_state; 6 | mod parser_tags; 7 | mod parser_time; 8 | mod token; 9 | 10 | use chrono::NaiveDateTime; 11 | use parse_completion::parse_completion; 12 | use parse_today::parse_today; 13 | use parser_due_date::parse_naive_date; 14 | use parser_priorities::parse_priority; 15 | use parser_state::parse_task_state; 16 | use parser_tags::parse_tag; 17 | use parser_time::parse_naive_time; 18 | use token::Token; 19 | use tracing::error; 20 | use winnow::{ 21 | combinator::{alt, fail, repeat}, 22 | token::any, 23 | Parser, Result, 24 | }; 25 | 26 | use crate::core::{ 27 | task::{DueDate, Task}, 28 | TasksConfig, 29 | }; 30 | 31 | /// Parses a `Token` from an input string.FileEntry 32 | fn parse_token(input: &mut &str, config: &TasksConfig) -> Result { 33 | alt(( 34 | |input: &mut &str| parse_naive_date(input, config.use_american_format), 35 | parse_naive_time, 36 | parse_tag, 37 | |input: &mut &str| parse_task_state(input, &config.task_state_markers), 38 | parse_priority, 39 | parse_completion, 40 | parse_today, 41 | |input: &mut &str| { 42 | let res = repeat(0.., any) 43 | .fold(String::new, |mut string, c| { 44 | string.push(c); 45 | string 46 | }) 47 | .parse_next(input)?; 48 | Ok(Token::Name(res)) 49 | }, 50 | )) 51 | .parse_next(input) 52 | } 53 | 54 | /// Parses a `Task` from an input string. Filename must be specified to be added to the task. 55 | /// 56 | /// # Errors 57 | /// 58 | /// Will return an error if the task can't be parsed. 59 | #[allow(clippy::module_name_repetitions)] 60 | pub fn parse_task(input: &mut &str, filename: String, config: &TasksConfig) -> Result { 61 | let task_state = match parse_task_state(input, &config.task_state_markers)? { 62 | Token::State(state) => Ok(state), 63 | _ => fail(input), 64 | }?; 65 | 66 | let mut token_parser = |input: &mut &str| parse_token(input, config); 67 | 68 | let tokens = input 69 | .split_ascii_whitespace() 70 | .map(|token| token_parser.parse(token)); 71 | 72 | let mut task = Task { 73 | state: task_state, 74 | filename, 75 | ..Default::default() 76 | }; 77 | 78 | // Placeholders for a date and a time 79 | let mut due_date_opt = None; 80 | let mut due_time_opt = None; 81 | let mut name_vec = vec![]; // collects words that aren't tokens from the input string 82 | 83 | for token_res in tokens { 84 | match token_res { 85 | Ok(Token::DueDate(date)) => due_date_opt = Some(date), 86 | Ok(Token::DueTime(time)) => due_time_opt = Some(time), 87 | Ok(Token::Name(name)) => name_vec.push(name), 88 | Ok(Token::Priority(p)) => task.priority = p, 89 | Ok(Token::State(state)) => task.state = state, 90 | Ok(Token::Tag(tag)) => { 91 | if let Some(ref mut tags) = task.tags { 92 | tags.push(tag); 93 | } else { 94 | task.tags = Some(vec![tag]); 95 | } 96 | } 97 | Ok(Token::TodayFlag) => task.is_today = true, 98 | Ok(Token::CompletionPercentage(c)) => task.completion = Some(c), 99 | Err(error) => error!("Error: {error:?}"), 100 | } 101 | } 102 | 103 | if !name_vec.is_empty() { 104 | task.name = name_vec.join(" "); 105 | } 106 | 107 | let now = chrono::Local::now(); 108 | let (due_date, has_date) = ( 109 | due_date_opt.unwrap_or_else(|| now.date_naive()), 110 | due_date_opt.is_some(), 111 | ); 112 | let (due_time, has_time) = ( 113 | due_time_opt.unwrap_or_else(|| now.time()), 114 | due_time_opt.is_some(), 115 | ); 116 | let due_date_time = if has_date { 117 | if has_time { 118 | DueDate::DayTime(NaiveDateTime::new(due_date, due_time)) 119 | } else { 120 | DueDate::Day(due_date) 121 | } 122 | } else if has_time { 123 | DueDate::DayTime(NaiveDateTime::new(now.date_naive(), due_time)) 124 | } else { 125 | DueDate::NoDate 126 | }; 127 | task.due_date = due_date_time; 128 | Ok(task) 129 | } 130 | #[cfg(test)] 131 | mod test { 132 | 133 | use chrono::{Datelike, Days, NaiveDate, NaiveDateTime, NaiveTime}; 134 | 135 | use crate::core::{ 136 | parser::task::parse_task, 137 | task::{DueDate, State, Task}, 138 | TasksConfig, 139 | }; 140 | #[test] 141 | fn test_parse_task_no_description() { 142 | let mut input = "- [x] 10/15 task_name #done"; 143 | let config = TasksConfig { 144 | use_american_format: true, 145 | ..Default::default() 146 | }; 147 | let res = parse_task(&mut input, String::new(), &config); 148 | assert!(res.is_ok()); 149 | let res = res.unwrap(); 150 | let year = chrono::Local::now().year(); 151 | let expected = Task { 152 | name: "task_name".to_string(), 153 | description: None, 154 | tags: Some(vec!["done".to_string()]), 155 | due_date: DueDate::Day(NaiveDate::from_ymd_opt(year, 10, 15).unwrap()), 156 | priority: 0, 157 | state: State::Done, 158 | line_number: 1, 159 | ..Default::default() 160 | }; 161 | assert_eq!(res, expected); 162 | } 163 | 164 | #[test] 165 | fn test_parse_task_only_state() { 166 | let mut input = "- [ ]"; 167 | let config = TasksConfig::default(); 168 | let res = parse_task(&mut input, String::new(), &config); 169 | assert!(res.is_ok()); 170 | let res = res.unwrap(); 171 | let expected = Task { 172 | subtasks: vec![], 173 | name: String::new(), 174 | description: None, 175 | tags: None, 176 | due_date: DueDate::NoDate, 177 | priority: 0, 178 | state: State::ToDo, 179 | line_number: 1, 180 | filename: String::new(), 181 | is_today: false, 182 | completion: None, 183 | }; 184 | assert_eq!(res, expected); 185 | } 186 | #[test] 187 | fn test_parse_task_with_due_date_words() { 188 | let mut input = "- [ ] today 15:30 task_name"; 189 | let config = TasksConfig::default(); 190 | let res = parse_task(&mut input, String::new(), &config); 191 | assert!(res.is_ok()); 192 | let res = res.unwrap(); 193 | let expected_date = chrono::Local::now().date_naive(); 194 | let expected_time = NaiveTime::from_hms_opt(15, 30, 0).unwrap(); 195 | let expected_due_date = DueDate::DayTime(NaiveDateTime::new(expected_date, expected_time)); 196 | assert_eq!(res.due_date, expected_due_date); 197 | } 198 | 199 | #[test] 200 | fn test_parse_task_with_weekday() { 201 | let mut input = "- [ ] monday 15:30 task_name"; 202 | let config = TasksConfig::default(); 203 | let res = parse_task(&mut input, String::new(), &config); 204 | assert!(res.is_ok()); 205 | let res = res.unwrap(); 206 | 207 | let now = chrono::Local::now(); 208 | let expected_date = now 209 | .date_naive() 210 | .checked_add_days(Days::new( 211 | 8 - u64::from(now.date_naive().weekday().number_from_monday()), 212 | )) 213 | .unwrap(); 214 | let expected_time = NaiveTime::from_hms_opt(15, 30, 0).unwrap(); 215 | let expected_due_date = DueDate::DayTime(NaiveDateTime::new(expected_date, expected_time)); 216 | assert_eq!(res.due_date, expected_due_date); 217 | } 218 | 219 | #[test] 220 | fn test_parse_task_with_weekday_this() { 221 | let mut input = "- [ ] this monday 15:30 task_name"; 222 | let config = TasksConfig::default(); 223 | let res = parse_task(&mut input, String::new(), &config); 224 | assert!(res.is_ok()); 225 | let res = res.unwrap(); 226 | let now = chrono::Local::now(); 227 | let expected_date = now 228 | .date_naive() 229 | .checked_add_days(Days::new( 230 | 8 - u64::from(now.date_naive().weekday().number_from_monday()), 231 | )) 232 | .unwrap(); 233 | let expected_time = NaiveTime::from_hms_opt(15, 30, 0).unwrap(); 234 | let expected_due_date = DueDate::DayTime(NaiveDateTime::new(expected_date, expected_time)); 235 | assert_eq!(res.due_date, expected_due_date); 236 | } 237 | 238 | #[test] 239 | fn test_parse_task_with_weekday_next() { 240 | let mut input = "- [ ] next monday 15:30 task_name"; 241 | let config = TasksConfig::default(); 242 | let res = parse_task(&mut input, String::new(), &config); 243 | assert!(res.is_ok()); 244 | let res = res.unwrap(); 245 | let now = chrono::Local::now(); 246 | let expected_date = now 247 | .date_naive() 248 | .checked_add_days(Days::new( 249 | 8 - u64::from(now.date_naive().weekday().number_from_monday()), 250 | )) 251 | .unwrap(); 252 | let expected_time = NaiveTime::from_hms_opt(15, 30, 0).unwrap(); 253 | let expected_due_date = DueDate::DayTime(NaiveDateTime::new(expected_date, expected_time)); 254 | assert_eq!(res.due_date, expected_due_date); 255 | } 256 | 257 | #[test] 258 | fn test_parse_task_without_due_date() { 259 | let mut input = "- [ ] task_name"; 260 | let config = TasksConfig::default(); 261 | let res = parse_task(&mut input, String::new(), &config); 262 | assert!(res.is_ok()); 263 | let res = res.unwrap(); 264 | let expected_due_date = DueDate::NoDate; 265 | assert_eq!(res.due_date, expected_due_date); 266 | } 267 | 268 | #[test] 269 | fn test_parse_task_with_invalid_state() { 270 | let mut input = "- [invalid] task_name"; 271 | let config = TasksConfig::default(); 272 | let res = parse_task(&mut input, String::new(), &config); 273 | assert!(res.is_err()); 274 | } 275 | 276 | #[test] 277 | fn test_parse_task_without_state() { 278 | let mut input = "task_name"; 279 | let config = TasksConfig::default(); 280 | let res = parse_task(&mut input, String::new(), &config); 281 | assert!(res.is_err()); 282 | } 283 | 284 | #[test] 285 | fn test_parse_task_with_invalid_priority() { 286 | let mut input = "- [ ] task_name p-9"; 287 | let config = TasksConfig::default(); 288 | let res = parse_task(&mut input, String::new(), &config); 289 | assert!(res.is_ok()); 290 | let res = res.unwrap(); 291 | assert_eq!(res.priority, 0); 292 | } 293 | 294 | #[test] 295 | fn test_parse_task_without_name() { 296 | let mut input = "- [ ]"; 297 | let config = TasksConfig::default(); 298 | let res = parse_task(&mut input, String::new(), &config); 299 | assert!(res.is_ok()); 300 | let res = res.unwrap(); 301 | assert_eq!(res.name, ""); // Default name is used when no name is provided 302 | } 303 | #[test] 304 | fn test_parse_task_with_today_flag() { 305 | let mut input = "- [ ] @t"; 306 | let config = TasksConfig::default(); 307 | let res = parse_task(&mut input, String::new(), &config); 308 | assert!(res.is_ok()); 309 | let res = res.unwrap(); 310 | assert!(res.is_today); 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /src/core/parser/task/parse_completion.rs: -------------------------------------------------------------------------------- 1 | use winnow::{combinator::preceded, token::take_while, Parser, Result}; 2 | 3 | use super::token::Token; 4 | 5 | /// Parses a completion percentage of the form `"%"`. 6 | pub fn parse_completion(input: &mut &str) -> Result { 7 | let res = preceded('c', take_while(1.., '0'..='9')) 8 | .parse_to() 9 | .parse_next(input)?; 10 | 11 | Ok(Token::CompletionPercentage(res)) 12 | } 13 | 14 | #[cfg(test)] 15 | mod tests { 16 | use crate::core::parser::task::{parse_completion::parse_completion, token::Token}; 17 | 18 | #[test] 19 | fn test_parse_completion_success() { 20 | let mut with_tag = "c99"; 21 | assert_eq!( 22 | parse_completion(&mut with_tag), 23 | Ok(Token::CompletionPercentage(99)) 24 | ); 25 | } 26 | #[test] 27 | fn test_parse_completion_fail() { 28 | let mut without_tag = "test"; 29 | assert!(parse_completion(&mut without_tag).is_err()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/core/parser/task/parse_today.rs: -------------------------------------------------------------------------------- 1 | use winnow::{ 2 | combinator::{alt, preceded}, 3 | Parser, Result, 4 | }; 5 | 6 | use super::token::Token; 7 | 8 | /// Parses a `Token::TodayFlag` of the form of the form "@t", @tdy", "@tod" or "@today". 9 | pub fn parse_today(input: &mut &str) -> Result { 10 | preceded('@', alt(("today", "tod", "tdy", "t"))).parse_next(input)?; 11 | Ok(Token::TodayFlag) 12 | } 13 | 14 | #[cfg(test)] 15 | mod tests { 16 | use crate::core::parser::task::{parse_today::parse_today, token::Token}; 17 | 18 | #[test] 19 | fn test_parse_today_tag() { 20 | let mut with_today = "@t"; 21 | assert_eq!(parse_today(&mut with_today), Ok(Token::TodayFlag)); 22 | let mut with_today = "@today"; 23 | assert_eq!(parse_today(&mut with_today), Ok(Token::TodayFlag)); 24 | let mut with_today = "@tdy"; 25 | assert_eq!(parse_today(&mut with_today), Ok(Token::TodayFlag)); 26 | } 27 | #[test] 28 | fn test_parse_today_tag_fail() { 29 | let mut should_fail = "today"; 30 | assert!(parse_today(&mut should_fail).is_err()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/core/parser/task/parser_priorities.rs: -------------------------------------------------------------------------------- 1 | use winnow::{combinator::preceded, token::take_while, Parser, Result}; 2 | 3 | use super::token::Token; 4 | 5 | /// Parses a priority value of the form `"p"`. 6 | pub fn parse_priority(input: &mut &str) -> Result { 7 | let res = preceded('p', take_while(1.., '0'..='9')) 8 | .parse_to() 9 | .parse_next(input)?; 10 | 11 | Ok(Token::Priority(res)) 12 | } 13 | 14 | #[cfg(test)] 15 | mod tests { 16 | use crate::core::parser::task::{parser_priorities::parse_priority, token::Token}; 17 | 18 | #[test] 19 | fn test_parse_priority_sucess() { 20 | let mut with_tag = "p5"; 21 | assert_eq!(parse_priority(&mut with_tag), Ok(Token::Priority(5))); 22 | } 23 | #[test] 24 | fn test_parse_priority_fail() { 25 | let mut without_tag = "test"; 26 | assert!(parse_priority(&mut without_tag).is_err()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/core/parser/task/parser_state.rs: -------------------------------------------------------------------------------- 1 | use winnow::{ 2 | combinator::{delimited, preceded}, 3 | token::any, 4 | Parser, Result, 5 | }; 6 | 7 | use crate::core::{task::State, TaskMarkerConfig}; 8 | 9 | use super::token::Token; 10 | 11 | /// Parses a `TaskState` from an input string. 12 | pub fn parse_task_state(input: &mut &str, task_marker_config: &TaskMarkerConfig) -> Result { 13 | match preceded("- ", delimited("[", any, "]")).parse_next(input) { 14 | Ok(c) => { 15 | if c == task_marker_config.todo { 16 | Ok(Token::State(State::ToDo)) 17 | } else if c == task_marker_config.incomplete { 18 | Ok(Token::State(State::Incomplete)) 19 | } else if c == task_marker_config.canceled { 20 | Ok(Token::State(State::Canceled)) 21 | } else { 22 | Ok(Token::State(State::Done)) 23 | } 24 | } 25 | 26 | Err(error) => Err(error), 27 | } 28 | 29 | // // This version only supports X to mark tasks done 30 | // match alt(("- [ ]", "- [X]")).parse_next(input) { 31 | // Err(error) => Err(error), 32 | // Ok("- [ ]") => Ok(Token::State(TaskState::ToDo)), 33 | // _ => Ok(Token::State(TaskState::Done)), 34 | // } 35 | } 36 | #[cfg(test)] 37 | mod test { 38 | use crate::core::{ 39 | parser::task::{parser_state::parse_task_state, token::Token}, 40 | task::State, 41 | TaskMarkerConfig, 42 | }; 43 | fn config() -> TaskMarkerConfig { 44 | TaskMarkerConfig { 45 | done: 'x', 46 | todo: ' ', 47 | incomplete: '/', 48 | canceled: '-', 49 | } 50 | } 51 | 52 | #[test] 53 | fn test_parse_task_state_todo() { 54 | let mut input = "- [ ]"; 55 | let expected = Ok(Token::State(State::ToDo)); 56 | let config = &config(); 57 | assert_eq!(parse_task_state(&mut input, config), expected); 58 | } 59 | #[test] 60 | fn test_parse_task_state_done() { 61 | let mut input = "- [X]"; 62 | let expected = Ok(Token::State(State::Done)); 63 | let config = &config(); 64 | assert_eq!(parse_task_state(&mut input, config), expected); 65 | } 66 | #[test] 67 | fn test_parse_task_state_done_alt() { 68 | let mut input = "- [o]"; 69 | let expected = Ok(Token::State(State::Done)); 70 | let config = &config(); 71 | assert_eq!(parse_task_state(&mut input, config), expected); 72 | } 73 | #[test] 74 | fn test_parse_task_state_canceled() { 75 | let mut input = "- [-]"; 76 | let expected = Ok(Token::State(State::Canceled)); 77 | let config = &config(); 78 | assert_eq!(parse_task_state(&mut input, config), expected); 79 | } 80 | #[test] 81 | fn test_parse_task_state_fail() { 82 | let mut input = "- o]"; 83 | let config = &config(); 84 | assert!(parse_task_state(&mut input, config).is_err()); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/core/parser/task/parser_tags.rs: -------------------------------------------------------------------------------- 1 | use winnow::{combinator::preceded, token::take_while, Parser, Result}; 2 | 3 | use super::token::Token; 4 | 5 | /// Parses tags of the form "#tag". 6 | pub fn parse_tag(input: &mut &str) -> Result { 7 | let tag = preceded( 8 | '#', 9 | take_while(1.., ('_', '0'..='9', 'A'..='Z', 'a'..='z', '0'..='9')), 10 | ) 11 | .parse_next(input)?; 12 | Ok(Token::Tag(tag.to_string())) 13 | } 14 | 15 | #[cfg(test)] 16 | mod tests { 17 | use crate::core::parser::task::{parser_tags::parse_tag, token::Token}; 18 | 19 | #[test] 20 | fn test_parse_tag_sucess() { 21 | let mut with_tag = "#test"; 22 | assert_eq!(parse_tag(&mut with_tag), Ok(Token::Tag("test".to_string()))); 23 | } 24 | #[test] 25 | fn test_parse_tag_symbols() { 26 | let mut with_tag = "#test_underscore123"; 27 | assert_eq!( 28 | parse_tag(&mut with_tag), 29 | Ok(Token::Tag("test_underscore123".to_string())) 30 | ); 31 | } 32 | #[test] 33 | fn test_parse_tag_fail() { 34 | let mut without_tag = "test"; 35 | assert!(parse_tag(&mut without_tag).is_err()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/core/parser/task/parser_time.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveTime; 2 | use winnow::{combinator::separated, token::take_while, Parser, Result}; 3 | 4 | use super::token::Token; 5 | 6 | /// Parses a `NaiveTime` from a `hh:mm:ss` or `hh:mm` string. 7 | pub fn parse_naive_time(input: &mut &str) -> Result { 8 | let tokens: Vec = 9 | separated(2..=3, take_while(1.., '0'..='9').parse_to::(), ':').parse_next(input)?; 10 | 11 | let h = tokens[0]; 12 | let m = tokens[1]; 13 | let s = if tokens.len() == 3 { tokens[2] } else { 0 }; 14 | 15 | Ok(Token::DueTime(NaiveTime::from_hms_opt(h, m, s).unwrap())) 16 | } 17 | 18 | #[cfg(test)] 19 | mod tests { 20 | use chrono::{NaiveTime, Timelike}; 21 | 22 | use crate::core::parser::task::{parser_time::parse_naive_time, token::Token}; 23 | 24 | #[test] 25 | fn test_parse_naive_time() { 26 | let now = chrono::Local::now().time(); 27 | let (h, m, s) = (now.hour(), now.minute(), now.second()); 28 | 29 | let input = format!("{h}:{m}:{s}"); 30 | let expected = NaiveTime::from_hms_opt(h, m, s).unwrap(); 31 | assert_eq!( 32 | parse_naive_time(&mut input.as_str()), 33 | Ok(Token::DueTime(expected)) 34 | ); 35 | 36 | let input = format!("{h}:{m}"); 37 | let expected = NaiveTime::from_hms_opt(h, m, 0).unwrap(); 38 | assert_eq!( 39 | parse_naive_time(&mut input.as_str()), 40 | Ok(Token::DueTime(expected)) 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/core/parser/task/token.rs: -------------------------------------------------------------------------------- 1 | use chrono::{NaiveDate, NaiveTime}; 2 | 3 | use crate::core::task::State; 4 | 5 | #[derive(Debug, PartialEq, Eq, Clone)] 6 | pub enum Token { 7 | DueDate(NaiveDate), 8 | DueTime(NaiveTime), 9 | Name(String), 10 | Priority(usize), 11 | Tag(String), 12 | State(State), 13 | TodayFlag, 14 | CompletionPercentage(usize), 15 | } 16 | -------------------------------------------------------------------------------- /src/core/snapshots/vault_tasks__core__sorter__tests__task_sort_by_due_date.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/core/sorter.rs 3 | description: "" 4 | expression: tasks 5 | info: 6 | - " test 2025/10/11" 7 | - " test 2025/10/9" 8 | - " test 2025/10/10 p5" 9 | - " test 2025/10/10 10:00" 10 | - " zèbre" 11 | - " zzz" 12 | - " zzz" 13 | - " test 2025/10/10 p2" 14 | - " test" 15 | - " test2" 16 | - " test 2025/10/10 5:00" 17 | - " abc" 18 | --- 19 | [ 20 | " - [ ] test 2025/10/09", 21 | " - [ ] test 2025/10/10 p2", 22 | " - [ ] test 2025/10/10 p5", 23 | " - [ ] test 2025/10/10 05:00:00", 24 | " - [ ] test 2025/10/10 10:00:00", 25 | " - [ ] test 2025/10/11", 26 | " - [ ] abc", 27 | " - [ ] test2", 28 | " - [ ] zzz", 29 | " - [x] test", 30 | " - [x] zèbre", 31 | " - [x] zzz", 32 | ] 33 | -------------------------------------------------------------------------------- /src/core/snapshots/vault_tasks__core__sorter__tests__task_sort_by_name.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/core/sorter.rs 3 | description: "" 4 | expression: tasks 5 | info: 6 | - " test 10/11" 7 | - " test 10/9" 8 | - " test 10/10 p5" 9 | - " test 10/10 10:00" 10 | - " zèbre" 11 | - " zzz" 12 | - " zzz" 13 | - " test 10/10 p2" 14 | - " test" 15 | - " test2" 16 | - " test 10/10 5:00" 17 | - " abc" 18 | --- 19 | [ 20 | " - [ ] abc", 21 | " - [ ] test 2025/10/09", 22 | " - [ ] test 2025/10/10 p2", 23 | " - [ ] test 2025/10/10 p5", 24 | " - [ ] test 2025/10/10 05:00:00", 25 | " - [ ] test 2025/10/10 10:00:00", 26 | " - [ ] test 2025/10/11", 27 | " - [x] test", 28 | " - [ ] test2", 29 | " - [x] zèbre", 30 | " - [ ] zzz", 31 | " - [x] zzz", 32 | ] 33 | -------------------------------------------------------------------------------- /src/core/snapshots/vault_tasks__core__sorter__tests__task_sort_states.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/core/sorter.rs 3 | description: "" 4 | expression: tasks 5 | info: 6 | - " test" 7 | - " test" 8 | - " test" 9 | - " test" 10 | --- 11 | [ 12 | " - [/] test", 13 | " - [ ] test", 14 | " - [-] test", 15 | " - [x] test", 16 | ] 17 | -------------------------------------------------------------------------------- /src/core/snapshots/vault_tasks_core__sorter__tests__task_sort_by_due_date.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: vault-tasks-core/src/sorter.rs 3 | description: "" 4 | expression: tasks 5 | info: 6 | - " test 10/11" 7 | - " test 10/9" 8 | - " test 10/10 p5" 9 | - " test 10/10 10:00" 10 | - " zèbre" 11 | - " zzz" 12 | - " zzz" 13 | - " test 10/10 p2" 14 | - " test" 15 | - " test2" 16 | - " test 10/10 5:00" 17 | - " abc" 18 | --- 19 | [ 20 | " - [ ] test 2024/10/09", 21 | " - [ ] test 2024/10/10 p2", 22 | " - [ ] test 2024/10/10 p5", 23 | " - [ ] test 2024/10/10 05:00:00", 24 | " - [ ] test 2024/10/10 10:00:00", 25 | " - [ ] test 2024/10/11", 26 | " - [ ] abc", 27 | " - [ ] test2", 28 | " - [ ] zzz", 29 | " - [x] test", 30 | " - [x] zèbre", 31 | " - [x] zzz", 32 | ] 33 | -------------------------------------------------------------------------------- /src/core/snapshots/vault_tasks_core__sorter__tests__task_sort_by_name.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: vault-tasks-core/src/sorter.rs 3 | description: "" 4 | expression: tasks 5 | info: 6 | - " test 10/11" 7 | - " test 10/9" 8 | - " test 10/10 p5" 9 | - " test 10/10 10:00" 10 | - " zèbre" 11 | - " zzz" 12 | - " zzz" 13 | - " test 10/10 p2" 14 | - " test" 15 | - " test2" 16 | - " test 10/10 5:00" 17 | - " abc" 18 | --- 19 | [ 20 | " - [ ] abc", 21 | " - [ ] test 2024/10/09", 22 | " - [ ] test 2024/10/10 p2", 23 | " - [ ] test 2024/10/10 p5", 24 | " - [ ] test 2024/10/10 05:00:00", 25 | " - [ ] test 2024/10/10 10:00:00", 26 | " - [ ] test 2024/10/11", 27 | " - [x] test", 28 | " - [ ] test2", 29 | " - [x] zèbre", 30 | " - [ ] zzz", 31 | " - [x] zzz", 32 | ] 33 | -------------------------------------------------------------------------------- /src/core/snapshots/vault_tasks_core__sorter__tests__task_sort_states.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: vault-tasks-core/src/sorter.rs 3 | description: "" 4 | expression: tasks 5 | info: 6 | - " test" 7 | - " test" 8 | - " test" 9 | - " test" 10 | --- 11 | [ 12 | " - [/] test", 13 | " - [ ] test", 14 | " - [-] test", 15 | " - [x] test", 16 | ] 17 | -------------------------------------------------------------------------------- /src/core/sorter.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use chrono::NaiveTime; 4 | use lexical_sort::lexical_cmp; 5 | use strum::EnumIter; 6 | use strum_macros::FromRepr; 7 | 8 | use super::task::{DueDate, Task}; 9 | 10 | #[derive(Default, Clone, Copy, FromRepr, EnumIter, strum_macros::Display)] 11 | pub enum SortingMode { 12 | #[default] 13 | #[strum(to_string = "Due Date")] 14 | ByDueDate, 15 | #[strum(to_string = "Title")] 16 | ByName, 17 | } 18 | 19 | impl SortingMode { 20 | #[must_use] 21 | pub fn next(self) -> Self { 22 | match self { 23 | Self::ByDueDate => Self::ByName, 24 | Self::ByName => Self::ByDueDate, 25 | } 26 | } 27 | pub fn sort(tasks: &mut [Task], sorter: Self) { 28 | tasks.sort_by(|t1, t2| Self::cmp(t1, t2, sorter)); 29 | } 30 | 31 | /// Compare two tasks by due date 32 | pub fn cmp_due_date(t1: &Task, t2: &Task) -> Ordering { 33 | match (&t1.due_date, &t2.due_date) { 34 | (DueDate::Day(d1), DueDate::Day(d2)) => d1.cmp(d2), 35 | (DueDate::DayTime(d1), DueDate::DayTime(d2)) => d1.cmp(d2), 36 | (DueDate::Day(d1), DueDate::DayTime(d2)) => d1.and_time(NaiveTime::default()).cmp(d2), 37 | (DueDate::DayTime(d1), DueDate::Day(d2)) => d1.cmp(&d2.and_time(NaiveTime::default())), 38 | (DueDate::NoDate, DueDate::Day(_) | DueDate::DayTime(_)) => Ordering::Greater, 39 | (DueDate::Day(_) | DueDate::DayTime(_), DueDate::NoDate) => Ordering::Less, 40 | _ => Ordering::Equal, 41 | } 42 | } 43 | /// Compares two tasks with the specified sorting mode 44 | /// Sorting mode is used first 45 | /// If equal, other attribues will be used: 46 | /// - State: `ToDo` < `Done` (in Ord impl of `State`) 47 | /// - The other sorting mode 48 | /// - Priority: usual number ordering 49 | /// - Tags: not used 50 | fn cmp(t1: &Task, t2: &Task, sorter: Self) -> Ordering { 51 | let res_initial_sort = match sorter { 52 | Self::ByDueDate => Self::cmp_due_date(t1, t2), 53 | Self::ByName => lexical_cmp(&t1.name, &t2.name), 54 | }; 55 | 56 | if !matches!(res_initial_sort, Ordering::Equal) { 57 | return res_initial_sort; 58 | } 59 | 60 | // Compare states 61 | let res = t1.state.cmp(&t2.state); 62 | if !matches!(res, Ordering::Equal) { 63 | return res; 64 | } 65 | 66 | // We do the other sorting methods 67 | let res = match sorter { 68 | Self::ByDueDate => lexical_cmp(&t1.name, &t2.name), 69 | Self::ByName => Self::cmp_due_date(t1, t2), 70 | }; 71 | if !matches!(res, Ordering::Equal) { 72 | return res; 73 | } 74 | 75 | t1.priority.cmp(&t2.priority) 76 | } 77 | } 78 | #[cfg(test)] 79 | mod tests { 80 | 81 | use insta::{assert_debug_snapshot, with_settings}; 82 | 83 | use super::SortingMode; 84 | use crate::core::{parser::task::parse_task, task::Task, TasksConfig}; 85 | #[test] 86 | fn task_sort_by_name() { 87 | let mut source = [ 88 | "- [ ] test 10/11", 89 | "- [ ] test 10/9", 90 | "- [ ] test 10/10 p5", 91 | "- [ ] test 10/10 10:00", 92 | "- [x] zèbre", 93 | "- [x] zzz", 94 | "- [ ] zzz", 95 | "- [ ] test 10/10 p2", 96 | "- [x] test", 97 | "- [ ] test2", 98 | "- [ ] test 10/10 5:00", 99 | "- [ ] abc", 100 | ]; 101 | let config = TasksConfig { 102 | use_american_format: true, 103 | ..Default::default() 104 | }; 105 | let mut tasks: Vec = source 106 | .iter_mut() 107 | .map(|input| parse_task(input, String::new(), &config).unwrap()) 108 | .collect(); 109 | 110 | let sorting_mode = SortingMode::ByName; 111 | SortingMode::sort(&mut tasks, sorting_mode); 112 | 113 | let tasks = tasks 114 | .iter() 115 | .map(|task| task.get_fixed_attributes(&config, 2)) 116 | .collect::>(); 117 | 118 | with_settings!({ 119 | info=>&source, 120 | description => "", // the template source code 121 | }, { 122 | assert_debug_snapshot!(tasks); 123 | }); 124 | } 125 | #[test] 126 | fn task_sort_by_due_date() { 127 | let mut source = [ 128 | "- [ ] test 2025/10/11", 129 | "- [ ] test 2025/10/9", 130 | "- [ ] test 2025/10/10 p5", 131 | "- [ ] test 2025/10/10 10:00", 132 | "- [x] zèbre", 133 | "- [x] zzz", 134 | "- [ ] zzz", 135 | "- [ ] test 2025/10/10 p2", 136 | "- [x] test", 137 | "- [ ] test2", 138 | "- [ ] test 2025/10/10 5:00", 139 | "- [ ] abc", 140 | ]; 141 | let config = TasksConfig { 142 | use_american_format: true, 143 | ..Default::default() 144 | }; 145 | let mut tasks: Vec = source 146 | .iter_mut() 147 | .map(|input| parse_task(input, String::new(), &config).unwrap()) 148 | .collect(); 149 | 150 | let sorting_mode = SortingMode::ByDueDate; 151 | SortingMode::sort(&mut tasks, sorting_mode); 152 | 153 | let tasks = tasks 154 | .iter() 155 | .map(|task| task.get_fixed_attributes(&config, 2)) 156 | .collect::>(); 157 | 158 | with_settings!({ 159 | info=>&source, 160 | description => "", // the template source code 161 | }, { 162 | assert_debug_snapshot!(tasks); 163 | }); 164 | } 165 | #[test] 166 | fn task_sort_states() { 167 | let mut source = ["- [ ] test", "- [x] test", "- [/] test", "- [-] test"]; 168 | let config = TasksConfig { 169 | use_american_format: true, 170 | ..Default::default() 171 | }; 172 | let mut tasks: Vec = source 173 | .iter_mut() 174 | .map(|input| parse_task(input, String::new(), &config).unwrap()) 175 | .collect(); 176 | 177 | let sorting_mode = SortingMode::ByDueDate; 178 | SortingMode::sort(&mut tasks, sorting_mode); 179 | 180 | let tasks = tasks 181 | .iter() 182 | .map(|task| task.get_fixed_attributes(&config, 2)) 183 | .collect::>(); 184 | 185 | with_settings!({ 186 | info=>&source, 187 | description => "", // the template source code 188 | }, { 189 | assert_debug_snapshot!(tasks); 190 | }); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/core/vault_data.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use super::task::Task; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq)] 6 | pub enum VaultData { 7 | /// Name, Content 8 | Directory(String, Vec), 9 | /// Name, Content 10 | Header(usize, String, Vec), 11 | /// Task, Subtasks 12 | Task(Task), 13 | } 14 | 15 | impl Display for VaultData { 16 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 17 | fn write_indent(indent_length: usize, f: &mut std::fmt::Formatter) -> std::fmt::Result { 18 | (1..=indent_length).try_for_each(|_| write!(f, "\t"))?; 19 | Ok(()) 20 | } 21 | fn write_underline_with_indent( 22 | text: &str, 23 | indent_length: usize, 24 | f: &mut std::fmt::Formatter, 25 | ) -> std::fmt::Result { 26 | write_indent(indent_length, f)?; 27 | writeln!(f, "{text}")?; 28 | write_indent(indent_length, f)?; 29 | for _i in 0..(text.len()) { 30 | write!(f, "‾")?; 31 | } 32 | writeln!(f)?; 33 | Ok(()) 34 | } 35 | fn fmt_aux( 36 | file_entry: &VaultData, 37 | f: &mut std::fmt::Formatter, 38 | depth: usize, 39 | ) -> std::fmt::Result { 40 | match file_entry { 41 | VaultData::Header(_, header, entries) => { 42 | write_underline_with_indent(&header.to_string(), depth, f)?; 43 | for entry in entries { 44 | fmt_aux(entry, f, depth + 1)?; 45 | } 46 | } 47 | VaultData::Directory(name, entries) => { 48 | write_underline_with_indent(&name.to_string(), depth, f)?; 49 | for entry in entries { 50 | fmt_aux(entry, f, depth + 1)?; 51 | } 52 | } 53 | VaultData::Task(task) => { 54 | for line in task.to_string().split('\n') { 55 | write_indent(depth, f)?; 56 | writeln!(f, "{line}")?; 57 | } 58 | 59 | for subtask in &task.subtasks { 60 | for line in VaultData::Task(subtask.clone()).to_string().split('\n') { 61 | write_indent(depth + 1, f)?; 62 | writeln!(f, "{line}")?; 63 | } 64 | } 65 | } 66 | } 67 | Ok(()) 68 | } 69 | fmt_aux(self, f, 0) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/core/vault_parser.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::{eyre::bail, Result}; 2 | use std::{ 3 | fs::{self, DirEntry}, 4 | path::Path, 5 | }; 6 | use tracing::{debug, info}; 7 | 8 | use crate::core::{parser::parser_file_entry::ParserFileEntry, TasksConfig}; 9 | 10 | use super::vault_data::VaultData; 11 | 12 | pub struct VaultParser { 13 | config: TasksConfig, 14 | } 15 | 16 | impl VaultParser { 17 | pub const fn new(config: TasksConfig) -> Self { 18 | Self { config } 19 | } 20 | pub fn scan_vault(&self) -> Result { 21 | let mut tasks = 22 | VaultData::Directory(self.config.vault_path.to_str().unwrap().to_owned(), vec![]); 23 | info!("Scanning {:?}", self.config.vault_path); 24 | self.scan(&self.config.vault_path, &mut tasks)?; 25 | Ok(tasks) 26 | } 27 | 28 | fn scan(&self, path: &Path, tasks: &mut VaultData) -> Result<()> { 29 | if self.config.ignored.contains(&path.to_owned()) { 30 | debug!("Ignoring {path:?} (ignored list)"); 31 | return Ok(()); 32 | } 33 | 34 | let entries = if path.is_dir() { 35 | path.read_dir()? 36 | .collect::>>() 37 | } else { 38 | path.parent() 39 | .unwrap() 40 | .read_dir()? 41 | .filter(|e| { 42 | let e = e.as_ref().unwrap(); 43 | e.file_name().eq(&path.file_name().unwrap()) 44 | }) 45 | .collect::>>() 46 | }; 47 | 48 | for entry_err in entries { 49 | let Ok(entry) = entry_err else { continue }; 50 | let name = entry.file_name().into_string().unwrap(); 51 | if !self.config.parse_dot_files && name.starts_with('.') { 52 | debug!("Ignoring {name:?} (dot file)"); 53 | continue; 54 | } 55 | if self.config.ignored.contains(&entry.path()) { 56 | debug!("Ignoring {name:?} (ignored list)"); 57 | continue; 58 | } 59 | 60 | if let VaultData::Directory(_, children) = tasks { 61 | if entry.path().is_dir() { 62 | // recursive call for this subdir 63 | let mut new_child = VaultData::Directory( 64 | entry.file_name().to_str().unwrap().to_owned(), 65 | vec![], 66 | ); 67 | 68 | self.scan(&entry.path(), &mut new_child)?; 69 | 70 | if let VaultData::Directory(_, c) = new_child.clone() { 71 | if !c.is_empty() { 72 | children.push(new_child); 73 | } 74 | } 75 | } else if !std::path::Path::new( 76 | &entry.file_name().into_string().unwrap_or_default(), 77 | ) 78 | .extension() 79 | .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) 80 | { 81 | debug!("Ignoring {name:?} (not a .md file)"); 82 | continue; 83 | } else if let Some(file_tasks) = self.parse_file(&entry) { 84 | children.push(file_tasks); 85 | } 86 | } else { 87 | bail!("Error while scanning directories, FileEntry was not a Directory"); 88 | } 89 | } 90 | Ok(()) 91 | } 92 | 93 | fn parse_file(&self, entry: &DirEntry) -> Option { 94 | debug!("Parsing {:?}", entry.file_name()); 95 | let content = fs::read_to_string(entry.path()).unwrap_or_default(); 96 | let mut parser = ParserFileEntry { 97 | config: &self.config, 98 | filename: String::new(), 99 | }; 100 | 101 | parser.parse_file(entry.file_name().to_str().unwrap(), &content.as_str()) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use color_eyre::Result; 4 | use tracing::error; 5 | 6 | pub fn init() -> Result<()> { 7 | let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() 8 | .panic_section(format!( 9 | "This is a bug. Consider reporting it at {}", 10 | env!("CARGO_PKG_REPOSITORY") 11 | )) 12 | .capture_span_trace_by_default(false) 13 | .display_location_section(false) 14 | .display_env_section(false) 15 | .into_hooks(); 16 | eyre_hook.install()?; 17 | std::panic::set_hook(Box::new(move |panic_info| { 18 | if let Ok(mut t) = crate::tui::Tui::new() { 19 | if let Err(r) = t.exit() { 20 | error!("Unable to exit Terminal: {:?}", r); 21 | } 22 | } 23 | 24 | #[cfg(not(debug_assertions))] 25 | { 26 | use human_panic::{handle_dump, metadata, print_msg}; 27 | let metadata = metadata!(); 28 | let file_path = handle_dump(&metadata, panic_info); 29 | // prints human-panic message 30 | print_msg(file_path, &metadata) 31 | .expect("human-panic: printing error message to console failed"); 32 | eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr 33 | } 34 | let msg = format!("{}", panic_hook.panic_report(panic_info)); 35 | error!("Error: {}", strip_ansi_escapes::strip_str(msg)); 36 | 37 | #[cfg(debug_assertions)] 38 | { 39 | // Better Panic stacktrace that is only enabled when debugging. 40 | better_panic::Settings::auto() 41 | .most_recent_first(false) 42 | .lineno_suffix(true) 43 | .verbosity(better_panic::Verbosity::Full) 44 | .create_panic_handler()(panic_info); 45 | } 46 | 47 | std::process::exit(libc::EXIT_FAILURE); 48 | })); 49 | Ok(()) 50 | } 51 | 52 | /// Similar to the `std::dbg!` macro, but generates `tracing` events rather 53 | /// than printing to stdout. 54 | /// 55 | /// By default, the verbosity level for the generated events is `DEBUG`, but 56 | /// this can be customized. 57 | #[macro_export] 58 | macro_rules! trace_dbg { 59 | (target: $target:expr, level: $level:expr, $ex:expr) => {{ 60 | match $ex { 61 | value => { 62 | tracing::event!(target: $target, $level, ?value, stringify!($ex)); 63 | value 64 | } 65 | } 66 | }}; 67 | (level: $level:expr, $ex:expr) => { 68 | trace_dbg!(target: module_path!(), level: $level, $ex) 69 | }; 70 | (target: $target:expr, $ex:expr) => { 71 | trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) 72 | }; 73 | ($ex:expr) => { 74 | trace_dbg!(level: tracing::Level::DEBUG, $ex) 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use tracing::debug; 3 | use tracing_error::ErrorLayer; 4 | use tracing_subscriber::{fmt, prelude::*, EnvFilter}; 5 | 6 | use crate::config; 7 | 8 | lazy_static::lazy_static! { 9 | pub static ref LOG_ENV: String = format!("{}_LOGLEVEL", config::PROJECT_NAME.clone()); 10 | pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); 11 | } 12 | 13 | pub fn init() -> Result<()> { 14 | let directory = config::get_data_dir(); 15 | std::fs::create_dir_all(directory.clone())?; 16 | let log_path = directory.join(LOG_FILE.clone()); 17 | let log_file = std::fs::File::create(log_path)?; 18 | let env_filter = EnvFilter::builder().with_default_directive(tracing::Level::INFO.into()); 19 | // If the `RUST_LOG` environment variable is set, use that as the default, otherwise use the 20 | // value of the `LOG_ENV` environment variable. If the `LOG_ENV` environment variable contains 21 | // errors, then this will return an error. 22 | debug!("test"); 23 | let env_filter = env_filter 24 | .try_from_env() 25 | .or_else(|_| env_filter.with_env_var(LOG_ENV.clone()).from_env())?; 26 | let file_subscriber = fmt::layer() 27 | .with_file(true) 28 | .with_line_number(true) 29 | .with_writer(log_file) 30 | .with_target(false) 31 | .with_ansi(false) 32 | .with_filter(env_filter); 33 | tracing_subscriber::registry() 34 | .with(file_subscriber) 35 | .with(ErrorLayer::default()) 36 | .try_init()?; 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use core::TaskManager; 2 | 3 | use clap::Parser; 4 | use cli::Cli; 5 | use color_eyre::Result; 6 | use config::Config; 7 | 8 | use crate::app::App; 9 | 10 | mod action; 11 | mod app; 12 | mod cli; 13 | mod components; 14 | mod config; 15 | mod errors; 16 | mod logging; 17 | 18 | mod core; 19 | mod time_management; 20 | mod tui; 21 | mod widgets; 22 | 23 | #[tokio::main] 24 | async fn main() -> Result<()> { 25 | crate::errors::init()?; 26 | crate::logging::init()?; 27 | 28 | let args = Cli::parse(); 29 | 30 | match args.command { 31 | Some(cli::Commands::GenerateConfig { path }) => Config::generate_config(path), 32 | Some(cli::Commands::Stdout) => { 33 | let config = Config::new(&args)?; 34 | let task_mgr = TaskManager::load_from_config(&config.tasks_config)?; 35 | println!("{}", task_mgr.tasks); 36 | Ok(()) 37 | } 38 | _ => { 39 | let mut app = App::new(&args)?; 40 | app.run().await 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/time_management.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use pomodoro::Pomodoro; 4 | use time_management_technique::TimeManagementTechnique; 5 | use tracing::debug; 6 | 7 | pub mod flow_time; 8 | pub mod pomodoro; 9 | pub mod time_management_technique; 10 | 11 | #[derive(Debug, PartialEq, Clone)] 12 | pub enum State { 13 | Frozen(Box), 14 | Focus(Option), 15 | Break(Option), 16 | } 17 | #[derive(Debug)] 18 | /// Provides tracking methods using a generic `TimeTrackingTechnique` 19 | pub struct TimeManagementEngine { 20 | pub mode: Box, 21 | pub state: Option, 22 | } 23 | impl Default for TimeManagementEngine { 24 | fn default() -> Self { 25 | Self { 26 | mode: Box::new(Pomodoro::classic_pomodoro()), 27 | state: None, 28 | } 29 | } 30 | } 31 | impl TimeManagementEngine { 32 | /// Creates a new [`TimeTrackingEngine`]. 33 | pub fn new(technique: Box) -> Self { 34 | Self { 35 | mode: technique, 36 | state: None, 37 | } 38 | } 39 | 40 | /// Returns the next state of the time tracking engine. 41 | /// # Argument 42 | /// - `from_clock`: bool : Whether the switch was triggered automatically or not. 43 | /// - `time_spent: Duration`: The duration of the previous session. 44 | /// # Returns 45 | /// - `Option`: Whether there is or not an explicit duration for the next session 46 | /// - `TimeManagementEngine`: The next state of the engine 47 | pub fn switch(&mut self, from_clock: bool, time_spent: Duration) -> State { 48 | let new_state = self.mode.switch(&self.state, from_clock, time_spent); 49 | debug!("{:?}", new_state); 50 | self.state = Some(new_state.clone()); 51 | new_state 52 | } 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use color_eyre::eyre::Result; 58 | 59 | use crate::time_management::{ 60 | flow_time::FlowTime, pomodoro::Pomodoro, State, TimeManagementEngine, 61 | }; 62 | 63 | use std::time::Duration; 64 | 65 | #[test] 66 | fn test_run_pomodoro() { 67 | let mut time_tracker = TimeManagementEngine::new(Box::new(Pomodoro::classic_pomodoro())); 68 | let focus_time = Duration::from_secs(60 * 25); 69 | let short_break_time = Duration::from_secs(60 * 5); 70 | assert!(time_tracker.state.is_none()); 71 | 72 | let to_spend_opt = time_tracker.switch(true, Duration::default()); 73 | assert!(time_tracker.state.is_some()); 74 | assert_eq!( 75 | time_tracker.state.clone().unwrap(), 76 | State::Focus(Some(focus_time)) 77 | ); 78 | assert_eq!(State::Focus(Some(focus_time)), to_spend_opt); 79 | 80 | let to_spend_opt = time_tracker.switch(true, Duration::default()); 81 | assert!(time_tracker.state.is_some()); 82 | assert_eq!( 83 | time_tracker.state.clone().unwrap(), 84 | State::Break(Some(short_break_time)) 85 | ); 86 | assert_eq!(State::Break(Some(short_break_time)), to_spend_opt); 87 | } 88 | #[test] 89 | fn test_full_run_pomodoro() { 90 | let mut time_tracker = TimeManagementEngine::new(Box::new(Pomodoro::classic_pomodoro())); 91 | assert!(time_tracker.state.is_none()); 92 | 93 | let mut to_spend_opt = State::Focus(None); 94 | 95 | for _i in 0..2 { 96 | // (Focus -> Break) 3 times 97 | for _j in 0..(3 * 2) { 98 | let to_spend_opt2 = time_tracker.switch(true, Duration::from_secs(0)); 99 | to_spend_opt = to_spend_opt2; 100 | } 101 | 102 | assert!(time_tracker.state.is_some()); 103 | assert_eq!(time_tracker.state.clone().unwrap(), to_spend_opt); 104 | } 105 | } 106 | #[test] 107 | fn test_full_run_pomodoro_manual_skip() { 108 | let technique = Pomodoro::new(false, Duration::ZERO, 4, Duration::ZERO, Duration::ZERO); 109 | 110 | let mut time_tracker = TimeManagementEngine::new(Box::new(technique)); 111 | assert!(time_tracker.state.is_none()); 112 | 113 | let mut to_spend_opt = State::Focus(None); 114 | 115 | for _i in 0..2 { 116 | // (Focus -> Break) 3 times 117 | for _j in 0..(3 * 2) { 118 | time_tracker.switch(true, Duration::from_secs(0)); 119 | let to_spend_opt2 = time_tracker.switch(true, Duration::ZERO); // actually skip 120 | to_spend_opt = to_spend_opt2; 121 | } 122 | 123 | assert!(time_tracker.state.is_some()); 124 | assert_eq!(time_tracker.state.clone().unwrap(), to_spend_opt); 125 | } 126 | } 127 | #[test] 128 | fn test_run_flowtime() -> Result<()> { 129 | let break_factor = 5; 130 | let mut time_tracker = 131 | TimeManagementEngine::new(Box::new(FlowTime::new(true, break_factor)?)); 132 | 133 | assert!(time_tracker.state.is_none()); 134 | 135 | let focus_time = Duration::from_secs(25); 136 | let break_time = focus_time / break_factor; 137 | 138 | let to_spend_opt = time_tracker.switch(true, Duration::from_secs(0)); 139 | 140 | assert_eq!(State::Focus(None), to_spend_opt); 141 | assert!(time_tracker.state.is_some()); 142 | assert_eq!(time_tracker.state.clone().unwrap(), State::Focus(None)); 143 | 144 | let to_spend_opt = time_tracker.switch(true, focus_time); 145 | assert!(time_tracker.state.is_some()); 146 | assert_eq!( 147 | time_tracker.state.clone().unwrap(), 148 | State::Break(Some(break_time)) 149 | ); 150 | assert_eq!(State::Break(Some(break_time)), to_spend_opt); 151 | Ok(()) 152 | } 153 | #[test] 154 | fn test_run_flowtime_excess_break_time() -> Result<()> { 155 | let break_factor = 5; 156 | let mut time_tracker = 157 | TimeManagementEngine::new(Box::new(FlowTime::new(true, break_factor)?)); 158 | 159 | assert!(time_tracker.state.is_none()); 160 | 161 | let focus_time = Duration::from_secs(25); 162 | let break_time = focus_time / break_factor; 163 | 164 | let to_spend_opt = time_tracker.switch(true, Duration::from_secs(0)); 165 | 166 | assert_eq!(State::Focus(None), to_spend_opt); 167 | assert!(time_tracker.state.is_some()); 168 | assert_eq!(time_tracker.state.clone().unwrap(), State::Focus(None)); 169 | 170 | let to_spend_opt = time_tracker.switch(true, focus_time); 171 | assert!(time_tracker.state.is_some()); 172 | assert_eq!( 173 | time_tracker.state.clone().unwrap(), 174 | State::Break(Some(break_time)) 175 | ); 176 | assert_eq!(State::Break(Some(break_time)), to_spend_opt); 177 | 178 | // Break time lasted 2s instead of 5s 179 | let break_time_skipped = Duration::from_secs(3); 180 | time_tracker.switch(true, break_time - break_time_skipped); 181 | let to_spend_opt = time_tracker.switch(true, focus_time); 182 | assert!(time_tracker.state.is_some()); 183 | assert_eq!( 184 | time_tracker.state.clone().unwrap(), 185 | State::Break(Some(break_time + break_time_skipped)) 186 | ); 187 | assert_eq!( 188 | State::Break(Some(break_time + break_time_skipped)), 189 | to_spend_opt 190 | ); 191 | 192 | // Ensures we return to normal cycle 193 | let to_spend_opt = time_tracker.switch(true, break_time + break_time_skipped); 194 | assert_eq!(State::Focus(None), to_spend_opt); 195 | assert!(time_tracker.state.is_some()); 196 | assert_eq!(time_tracker.state.clone().unwrap(), State::Focus(None)); 197 | 198 | // Break time is normal 199 | let to_spend_opt = time_tracker.switch(true, focus_time); 200 | assert!(time_tracker.state.is_some()); 201 | assert_eq!( 202 | time_tracker.state.clone().unwrap(), 203 | State::Break(Some(break_time)) 204 | ); 205 | assert_eq!(State::Break(Some(break_time)), to_spend_opt); 206 | Ok(()) 207 | } 208 | #[test] 209 | fn test_run_pomodoro_excess_break_time() { 210 | let mut time_tracker = TimeManagementEngine::new(Box::new(Pomodoro::classic_pomodoro())); 211 | 212 | assert!(time_tracker.state.is_none()); 213 | 214 | let focus_time = Duration::from_secs(1500); 215 | let break_time = Duration::from_secs(1500 / 5); 216 | 217 | // Init -> Focus 218 | let to_spend_opt = time_tracker.switch(true, Duration::from_secs(0)); 219 | 220 | assert_eq!(State::Focus(Some(focus_time)), to_spend_opt); 221 | assert!(time_tracker.state.is_some()); 222 | assert_eq!( 223 | time_tracker.state.clone().unwrap(), 224 | State::Focus(Some(focus_time)) 225 | ); 226 | 227 | // Focus -> Break 228 | let to_spend_opt = time_tracker.switch(true, focus_time); 229 | assert!(time_tracker.state.is_some()); 230 | assert_eq!( 231 | time_tracker.state.clone().unwrap(), 232 | State::Break(Some(break_time)) 233 | ); 234 | assert_eq!(State::Break(Some(break_time)), to_spend_opt); 235 | 236 | // Break skipped early -> Focus 237 | let break_time_skipped = Duration::from_secs(3); 238 | time_tracker.switch(true, break_time - break_time_skipped); 239 | 240 | // Focus skipped early -> Break 241 | let focus_time_skipped = Duration::from_secs(9); 242 | let to_spend_opt = time_tracker.switch(true, focus_time - focus_time_skipped); 243 | 244 | // Is break time extended ? 245 | assert!(time_tracker.state.is_some()); 246 | assert_eq!( 247 | time_tracker.state.clone().unwrap(), 248 | State::Break(Some(break_time + break_time_skipped)) 249 | ); 250 | assert_eq!( 251 | State::Break(Some(break_time + break_time_skipped)), 252 | to_spend_opt 253 | ); 254 | 255 | // Break -> Focus 256 | let to_spend_opt = time_tracker.switch(true, break_time + break_time_skipped); 257 | 258 | // Is focus time extended ? 259 | assert_eq!( 260 | State::Focus(Some(focus_time + focus_time_skipped)), 261 | to_spend_opt 262 | ); 263 | assert!(time_tracker.state.is_some()); 264 | assert_eq!( 265 | time_tracker.state.clone().unwrap(), 266 | State::Focus(Some(focus_time + focus_time_skipped)) 267 | ); 268 | 269 | // Break time is normal 270 | let to_spend_opt = time_tracker.switch(true, focus_time + focus_time_skipped); 271 | assert!(time_tracker.state.is_some()); 272 | assert_eq!( 273 | time_tracker.state.clone().unwrap(), 274 | State::Break(Some(break_time)) 275 | ); 276 | assert_eq!(State::Break(Some(break_time)), to_spend_opt); 277 | 278 | // Focus time is normal 279 | let to_spend_opt = time_tracker.switch(true, break_time); 280 | assert!(time_tracker.state.is_some()); 281 | assert_eq!( 282 | time_tracker.state.clone().unwrap(), 283 | State::Focus(Some(focus_time)) 284 | ); 285 | assert_eq!(State::Focus(Some(focus_time)), to_spend_opt); 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/time_management/flow_time.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::{eyre::bail, Result}; 2 | use std::time::Duration; 3 | 4 | use crate::time_management::{time_management_technique::TimeManagementTechnique, State}; 5 | 6 | #[derive(Debug)] 7 | pub struct FlowTime { 8 | auto_skip: bool, 9 | break_factor: u32, 10 | break_time_excess: f32, 11 | } 12 | 13 | impl FlowTime { 14 | /// Creates a new `FlowTime` object from a break time factor. 15 | /// After the first focus time (t), break time will be computed as t / `break_factor` 16 | /// # Errors 17 | /// Will return an error if `break_factor` <= 0 18 | pub fn new(auto_skip: bool, break_factor: u32) -> Result { 19 | if break_factor == 0 { 20 | bail!("Break Factor for FlowTime is negative") 21 | } 22 | Ok(Self { 23 | auto_skip, 24 | break_factor, 25 | break_time_excess: 0_f32, 26 | }) 27 | } 28 | } 29 | 30 | impl TimeManagementTechnique for FlowTime { 31 | fn switch(&mut self, state: &Option, from_clock: bool, time_spent: Duration) -> State { 32 | match state { 33 | Some(State::Frozen(next_state)) => *next_state.clone(), 34 | Some(State::Focus(_)) => self.change_state_or_freeze( 35 | self.auto_skip, 36 | from_clock, 37 | State::Break(Some( 38 | time_spent / self.break_factor 39 | + Duration::from_secs_f32(self.break_time_excess), 40 | )), 41 | ), 42 | 43 | Some(State::Break(Some(time_to_spend))) => { 44 | let delta = time_to_spend.as_secs_f32() - time_spent.as_secs_f32(); 45 | self.break_time_excess = 0_f32.max(delta); // save break time excess 46 | self.change_state_or_freeze(self.auto_skip, from_clock, State::Focus(None)) 47 | } 48 | Some(State::Break(None)) | None => State::Focus(None), 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/time_management/pomodoro.rs: -------------------------------------------------------------------------------- 1 | use core::panic; 2 | use std::time::Duration; 3 | 4 | use crate::time_management::{time_management_technique::TimeManagementTechnique, State}; 5 | 6 | #[derive(Debug, PartialEq, PartialOrd)] 7 | pub struct Pomodoro { 8 | auto_skip: bool, 9 | focus_duration: Duration, 10 | break_count: usize, 11 | short_breaks_before_long: usize, 12 | short_break_duration: Duration, 13 | long_break_duration: Duration, 14 | focus_time_excess: f32, 15 | break_time_excess: f32, 16 | } 17 | impl Pomodoro { 18 | pub fn new( 19 | auto_skip: bool, 20 | focus_duration: Duration, 21 | short_breaks_before_long: usize, 22 | short_break_duration: Duration, 23 | long_break_duration: Duration, 24 | ) -> Self { 25 | Self { 26 | auto_skip, 27 | focus_duration, 28 | break_count: 0, 29 | short_breaks_before_long, 30 | short_break_duration, 31 | long_break_duration, 32 | break_time_excess: 0_f32, 33 | focus_time_excess: 0_f32, 34 | } 35 | } 36 | 37 | pub fn classic_pomodoro() -> Self { 38 | Self { 39 | auto_skip: true, 40 | focus_duration: Duration::from_secs(25 * 60), 41 | short_break_duration: Duration::from_secs(5 * 60), 42 | long_break_duration: Duration::from_secs(15 * 60), 43 | break_count: 0, 44 | short_breaks_before_long: 3, 45 | break_time_excess: 0_f32, 46 | focus_time_excess: 0_f32, 47 | } 48 | } 49 | } 50 | impl TimeManagementTechnique for Pomodoro { 51 | fn switch(&mut self, state: &Option, from_clock: bool, time_spent: Duration) -> State { 52 | match state { 53 | Some(State::Frozen(next_state)) => *next_state.clone(), 54 | Some(State::Focus(None)) => panic!("invalid state"), 55 | 56 | Some(State::Focus(Some(time_to_spend))) => { 57 | let delta = time_to_spend.as_secs_f32() - time_spent.as_secs_f32(); 58 | self.focus_time_excess = 0_f32.max(delta); 59 | let res = if self.short_breaks_before_long == self.break_count { 60 | self.break_count = 0; 61 | State::Break(Some( 62 | self.long_break_duration + Duration::from_secs_f32(self.break_time_excess), 63 | )) 64 | } else { 65 | self.break_count += 1; 66 | State::Break(Some( 67 | self.short_break_duration + Duration::from_secs_f32(self.break_time_excess), 68 | )) 69 | }; 70 | self.change_state_or_freeze(self.auto_skip, from_clock, res) 71 | } 72 | 73 | Some(State::Break(Some(time_to_spend))) => { 74 | let delta = time_to_spend.as_secs_f32() - time_spent.as_secs_f32(); 75 | self.break_time_excess = 0_f32.max(delta); 76 | let res = State::Focus(Some( 77 | self.focus_duration + Duration::from_secs_f32(self.focus_time_excess), 78 | )); 79 | self.change_state_or_freeze(self.auto_skip, from_clock, res) 80 | } 81 | Some(State::Break(None)) | None => State::Focus(Some(self.focus_duration)), 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/time_management/time_management_technique.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Debug, time::Duration}; 2 | 3 | use super::State; 4 | 5 | pub trait TimeManagementTechnique: Debug { 6 | fn change_state_or_freeze( 7 | &self, 8 | auto_skip: bool, 9 | from_clock: bool, 10 | next_state: State, 11 | ) -> State { 12 | if auto_skip || !from_clock { 13 | next_state 14 | } else { 15 | State::Frozen(Box::new(next_state)) 16 | } 17 | } 18 | fn switch(&mut self, state: &Option, from_clock: bool, time_spent: Duration) -> State; 19 | } 20 | -------------------------------------------------------------------------------- /src/tui.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] // Remove this once you start using the code 2 | 3 | use std::{ 4 | io::{stdout, Stdout}, 5 | ops::{Deref, DerefMut}, 6 | time::Duration, 7 | }; 8 | 9 | use color_eyre::Result; 10 | use crossterm::{ 11 | cursor, 12 | event::{ 13 | DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, 14 | Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent, 15 | }, 16 | terminal::{EnterAlternateScreen, LeaveAlternateScreen}, 17 | }; 18 | use futures::{FutureExt, StreamExt}; 19 | use ratatui::backend::CrosstermBackend as Backend; 20 | use serde::{Deserialize, Serialize}; 21 | use tokio::{ 22 | sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, 23 | task::JoinHandle, 24 | time::interval, 25 | }; 26 | use tokio_util::sync::CancellationToken; 27 | use tracing::error; 28 | 29 | #[derive(Clone, Debug, Serialize, Deserialize)] 30 | pub enum Event { 31 | Init, 32 | Quit, 33 | Error, 34 | Closed, 35 | Tick, 36 | Render, 37 | FocusGained, 38 | FocusLost, 39 | Paste(String), 40 | Key(KeyEvent), 41 | Mouse(MouseEvent), 42 | Resize(u16, u16), 43 | } 44 | 45 | pub struct Tui { 46 | pub terminal: ratatui::Terminal>, 47 | pub task: JoinHandle<()>, 48 | pub cancellation_token: CancellationToken, 49 | pub event_rx: UnboundedReceiver, 50 | pub event_tx: UnboundedSender, 51 | pub frame_rate: f64, 52 | pub tick_rate: f64, 53 | pub mouse: bool, 54 | pub paste: bool, 55 | } 56 | 57 | impl Tui { 58 | pub fn new() -> Result { 59 | let (event_tx, event_rx) = mpsc::unbounded_channel(); 60 | Ok(Self { 61 | terminal: ratatui::Terminal::new(Backend::new(stdout()))?, 62 | task: tokio::spawn(async {}), 63 | cancellation_token: CancellationToken::new(), 64 | event_rx, 65 | event_tx, 66 | frame_rate: 60.0, 67 | tick_rate: 4.0, 68 | mouse: false, 69 | paste: false, 70 | }) 71 | } 72 | 73 | pub fn tick_rate(mut self, tick_rate: f64) -> Self { 74 | self.tick_rate = tick_rate; 75 | self 76 | } 77 | 78 | pub fn frame_rate(mut self, frame_rate: f64) -> Self { 79 | self.frame_rate = frame_rate; 80 | self 81 | } 82 | 83 | pub fn mouse(mut self, mouse: bool) -> Self { 84 | self.mouse = mouse; 85 | self 86 | } 87 | 88 | pub fn paste(mut self, paste: bool) -> Self { 89 | self.paste = paste; 90 | self 91 | } 92 | 93 | pub fn start(&mut self) { 94 | self.cancel(); // Cancel any existing task 95 | self.cancellation_token = CancellationToken::new(); 96 | let event_loop = Self::event_loop( 97 | self.event_tx.clone(), 98 | self.cancellation_token.clone(), 99 | self.tick_rate, 100 | self.frame_rate, 101 | ); 102 | self.task = tokio::spawn(async { 103 | event_loop.await; 104 | }); 105 | } 106 | 107 | async fn event_loop( 108 | event_tx: UnboundedSender, 109 | cancellation_token: CancellationToken, 110 | tick_rate: f64, 111 | frame_rate: f64, 112 | ) { 113 | let mut event_stream = EventStream::new(); 114 | let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); 115 | let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); 116 | 117 | // if this fails, then it's likely a bug in the calling code 118 | event_tx 119 | .send(Event::Init) 120 | .expect("failed to send init event"); 121 | loop { 122 | let event = tokio::select! { 123 | () = cancellation_token.cancelled() => { 124 | break; 125 | } 126 | _ = tick_interval.tick() => Event::Tick, 127 | _ = render_interval.tick() => Event::Render, 128 | crossterm_event = event_stream.next().fuse() => match crossterm_event { 129 | Some(Ok(event)) => match event { 130 | CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), 131 | CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), 132 | CrosstermEvent::Resize(x, y) => Event::Resize(x, y), 133 | CrosstermEvent::FocusLost => Event::FocusLost, 134 | CrosstermEvent::FocusGained => Event::FocusGained, 135 | CrosstermEvent::Paste(s) => Event::Paste(s), 136 | _ => continue, // ignore other events 137 | } 138 | Some(Err(_)) => Event::Error, 139 | None => break, // the event stream has stopped and will not produce any more events 140 | }, 141 | }; 142 | if event_tx.send(event).is_err() { 143 | // the receiver has been dropped, so there's no point in continuing the loop 144 | break; 145 | } 146 | } 147 | cancellation_token.cancel(); 148 | } 149 | 150 | pub fn stop(&self) -> Result<()> { 151 | self.cancel(); 152 | let mut counter = 0; 153 | while !self.task.is_finished() { 154 | std::thread::sleep(Duration::from_millis(1)); 155 | counter += 1; 156 | if counter > 50 { 157 | self.task.abort(); 158 | } 159 | if counter > 100 { 160 | error!("Failed to abort task in 100 milliseconds for unknown reason"); 161 | break; 162 | } 163 | } 164 | Ok(()) 165 | } 166 | 167 | pub fn enter(&mut self) -> Result<()> { 168 | crossterm::terminal::enable_raw_mode()?; 169 | crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; 170 | if self.mouse { 171 | crossterm::execute!(stdout(), EnableMouseCapture)?; 172 | } 173 | if self.paste { 174 | crossterm::execute!(stdout(), EnableBracketedPaste)?; 175 | } 176 | self.start(); 177 | Ok(()) 178 | } 179 | 180 | pub fn exit(&mut self) -> Result<()> { 181 | self.stop()?; 182 | if crossterm::terminal::is_raw_mode_enabled()? { 183 | self.flush()?; 184 | if self.paste { 185 | crossterm::execute!(stdout(), DisableBracketedPaste)?; 186 | } 187 | if self.mouse { 188 | crossterm::execute!(stdout(), DisableMouseCapture)?; 189 | } 190 | crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; 191 | crossterm::terminal::disable_raw_mode()?; 192 | } 193 | Ok(()) 194 | } 195 | 196 | pub fn cancel(&self) { 197 | self.cancellation_token.cancel(); 198 | } 199 | 200 | pub fn suspend(&mut self) -> Result<()> { 201 | self.exit()?; 202 | #[cfg(not(windows))] 203 | signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; 204 | Ok(()) 205 | } 206 | 207 | pub fn resume(&mut self) -> Result<()> { 208 | self.enter()?; 209 | Ok(()) 210 | } 211 | 212 | pub async fn next_event(&mut self) -> Option { 213 | self.event_rx.recv().await 214 | } 215 | } 216 | 217 | impl Deref for Tui { 218 | type Target = ratatui::Terminal>; 219 | 220 | fn deref(&self) -> &Self::Target { 221 | &self.terminal 222 | } 223 | } 224 | 225 | impl DerefMut for Tui { 226 | fn deref_mut(&mut self) -> &mut Self::Target { 227 | &mut self.terminal 228 | } 229 | } 230 | 231 | impl Drop for Tui { 232 | fn drop(&mut self) { 233 | self.exit().unwrap(); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/widgets.rs: -------------------------------------------------------------------------------- 1 | pub mod help_menu; 2 | pub mod input_bar; 3 | pub mod styled_calendar; 4 | pub mod task_list; 5 | pub mod task_list_item; 6 | pub mod timer; 7 | -------------------------------------------------------------------------------- /src/widgets/.task_list.rs.swp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louis-thevenet/vault-tasks/cde41d426da40558b17d790c98938ae47613d7db/src/widgets/.task_list.rs.swp -------------------------------------------------------------------------------- /src/widgets/help_menu.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use crossterm::event::KeyModifiers; 4 | use layout::Flex; 5 | use ratatui::{ 6 | prelude::*, 7 | widgets::{Block, Cell, Clear, Row, Table}, 8 | }; 9 | use tracing::debug; 10 | use tui_scrollview::{ScrollView, ScrollViewState}; 11 | 12 | use crate::{action::Action, app::Mode, config::Config}; 13 | 14 | #[derive(Default, Clone)] 15 | pub struct HelpMenu<'a> { 16 | content: Table<'a>, 17 | content_size: Size, 18 | pub state: ScrollViewState, 19 | } 20 | 21 | impl HelpMenu<'_> { 22 | fn get_keys_for_action(config: &Config, app_mode: Mode, action: &Action) -> String { 23 | config 24 | .keybindings 25 | .get(&app_mode) 26 | .unwrap() 27 | .iter() 28 | .filter_map(|(k, v)| { 29 | if *v == *action { 30 | let key = k.first().unwrap(); 31 | Some(if key.modifiers == KeyModifiers::NONE { 32 | format!("<{}>", key.code) 33 | } else { 34 | format!("<{}-{}>", key.modifiers, key.code) 35 | }) 36 | } else { 37 | None 38 | } 39 | }) 40 | .collect::>() 41 | .join(" | ") 42 | } 43 | pub fn new(app_mode: Mode, config: &Config) -> Self { 44 | let mut action_set = HashSet::::new(); 45 | for kb in config.keybindings.get(&app_mode).unwrap().values() { 46 | action_set.insert(kb.clone()); 47 | } 48 | let mut action_vec = action_set.iter().collect::>(); 49 | action_vec.sort(); 50 | 51 | let header_height = 1; 52 | let header = ["Action", "Keys"] 53 | .into_iter() 54 | .map(Cell::from) 55 | .collect::() 56 | .style(Style::new().bold()) 57 | .height(header_height); 58 | 59 | let rows = action_vec.iter().map(|action| { 60 | [ 61 | action.to_string(), 62 | Self::get_keys_for_action(config, app_mode, action), 63 | ] 64 | .into_iter() 65 | .map(Cell::from) 66 | .collect::() 67 | }); 68 | 69 | let lenghts = action_set.iter().map(|action| { 70 | ( 71 | action.to_string().len() as u16, 72 | Self::get_keys_for_action(config, app_mode, action).len() as u16, 73 | ) 74 | }); 75 | 76 | let longuest = ( 77 | lenghts 78 | .clone() 79 | .max_by(|a, b| a.0.cmp(&b.0)) 80 | .unwrap_or_default() 81 | .0, 82 | lenghts.max_by(|a, b| a.1.cmp(&b.1)).unwrap_or_default().1, 83 | ); 84 | 85 | let block = Block::bordered() 86 | .title("Help") 87 | .title_bottom(Line::from("Esc to close").right_aligned()); 88 | let column_spacing = 4; 89 | let table = Table::new( 90 | rows, 91 | [ 92 | Constraint::Length(longuest.0), 93 | Constraint::Length(longuest.1), 94 | ], 95 | ) 96 | .header(header) 97 | .column_spacing(column_spacing) 98 | .block(block); 99 | 100 | Self { 101 | state: ScrollViewState::new(), 102 | content: table, 103 | content_size: Size::new( 104 | longuest 105 | .0 106 | .saturating_add(longuest.1) 107 | .saturating_add(column_spacing) 108 | + 2, // +2 for block 109 | (action_vec.len() as u16).saturating_add(header_height) + 2, // +2 for block 110 | ), 111 | } 112 | } 113 | pub fn scroll_down(&mut self) { 114 | self.state.scroll_down(); 115 | } 116 | pub fn scroll_up(&mut self) { 117 | self.state.scroll_up(); 118 | } 119 | } 120 | 121 | impl StatefulWidget for HelpMenu<'_> { 122 | type State = ScrollViewState; 123 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) 124 | where 125 | Self: Sized, 126 | { 127 | let vertical = 128 | Layout::vertical([Constraint::Length(self.content_size.height)]).flex(Flex::End); 129 | let horizontal = Layout::horizontal([self.content_size.width]).flex(Flex::Start); 130 | let [area] = vertical.areas(area); 131 | let [area] = horizontal.areas(area); 132 | 133 | let mut scroll_view = ScrollView::new(self.content_size); 134 | debug!("{}", self.content_size); 135 | Widget::render(Clear, area, buf); 136 | scroll_view.render_widget(self.content, scroll_view.area()); 137 | scroll_view.render(area, buf, state); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/widgets/input_bar.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::Rect, 4 | style::Style, 5 | widgets::{Block, Clear, Paragraph, Widget}, 6 | }; 7 | use tui_input::Input; 8 | 9 | #[derive(Default, Clone)] 10 | pub struct InputBar<'a> { 11 | pub input: Input, 12 | pub is_focused: bool, 13 | pub block: Option>, 14 | } 15 | 16 | impl Widget for InputBar<'_> { 17 | fn render(self, area: Rect, buf: &mut Buffer) { 18 | let width = area.width.max(3) - 3; // 2 for borders, 1 for cursor 19 | let scroll = self.input.visual_scroll(width as usize); 20 | let res = Paragraph::new(self.input.value()) 21 | .style(Style::reset()) 22 | .scroll((0, scroll as u16)); 23 | 24 | Clear.render(area, buf); 25 | if let Some(block) = &self.block { 26 | res.block(block.clone()) 27 | } else { 28 | res 29 | } 30 | .render(area, buf); 31 | } 32 | } 33 | #[cfg(test)] 34 | mod tests { 35 | use insta::assert_snapshot; 36 | use ratatui::{ 37 | backend::TestBackend, 38 | layout::{Constraint, Layout}, 39 | widgets::Block, 40 | Terminal, 41 | }; 42 | use tui_input::Input; 43 | 44 | use crate::widgets::input_bar::InputBar; 45 | 46 | #[test] 47 | fn test_render_search_bar() { 48 | let bar = InputBar { 49 | input: Input::new("input".to_owned()), 50 | is_focused: true, 51 | block: Some(Block::bordered().title_top("test")), 52 | }; 53 | let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); 54 | terminal 55 | .draw(|frame| frame.render_widget(bar, frame.area())) 56 | .unwrap(); 57 | assert_snapshot!(terminal.backend()); 58 | } 59 | #[test] 60 | fn test_render_search_bar_line() { 61 | let input = Input::new("initial".to_owned()); 62 | let bar = InputBar { 63 | input, 64 | is_focused: true, 65 | block: Some(Block::bordered().title_top("test")), 66 | }; 67 | let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); 68 | terminal 69 | .draw(|frame| { 70 | let [_, inner, _] = Layout::vertical([ 71 | Constraint::Percentage(40), 72 | Constraint::Min(1), 73 | Constraint::Percentage(40), 74 | ]) 75 | .areas(frame.area()); 76 | let [_, inner, _] = Layout::horizontal([ 77 | Constraint::Percentage(20), 78 | Constraint::Min(10), 79 | Constraint::Percentage(20), 80 | ]) 81 | .areas(inner); 82 | 83 | frame.render_widget(bar, inner); 84 | }) 85 | .unwrap(); 86 | assert_snapshot!(terminal.backend()); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/widgets/snapshots/vault_tasks__widgets__input_bar__tests__render_search_bar.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: vault-tasks-tui/src/widgets/input_bar.rs 3 | expression: terminal.backend() 4 | --- 5 | "┌test──────────────────────────────────────────────────────────────────────────┐" 6 | "│input │" 7 | "│ │" 8 | "│ │" 9 | "│ │" 10 | "│ │" 11 | "│ │" 12 | "│ │" 13 | "│ │" 14 | "│ │" 15 | "│ │" 16 | "│ │" 17 | "│ │" 18 | "│ │" 19 | "│ │" 20 | "│ │" 21 | "│ │" 22 | "│ │" 23 | "│ │" 24 | "└──────────────────────────────────────────────────────────────────────────────┘" 25 | -------------------------------------------------------------------------------- /src/widgets/snapshots/vault_tasks__widgets__input_bar__tests__render_search_bar_line.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: vault-tasks-tui/src/widgets/input_bar.rs 3 | expression: terminal.backend() 4 | --- 5 | " " 6 | " " 7 | " " 8 | " " 9 | " " 10 | " " 11 | " " 12 | " " 13 | " ┌test──────────────────────────────────────────┐ " 14 | " │initial │ " 15 | " │ │ " 16 | " └──────────────────────────────────────────────┘ " 17 | " " 18 | " " 19 | " " 20 | " " 21 | " " 22 | " " 23 | " " 24 | " " 25 | -------------------------------------------------------------------------------- /src/widgets/snapshots/vault_tasks__widgets__task_list__tests__render_search_bar.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: vault-tasks-tui/src/widgets/task_list.rs 3 | expression: terminal.backend() 4 | --- 5 | "Test────────────────────────────────────" 6 | " 1──────────────────────────────────────" 7 | " ┌✅ task 1───────────────────────────┐" Hidden by multi-width symbols: [(4, " ")] 8 | " │📅 2016/07/08 09:10:11 ❗5 │" Hidden by multi-width symbols: [(4, " "), (27, " ")] 9 | " │#tag #tag2 │" 10 | " │┌❌ subtask test with desc─────────┐│" Hidden by multi-width symbols: [(5, " ")] 11 | " ││test ││" 12 | " ││desc ││" 13 | " │└──────────────────────────────────┘│" 14 | " │┌❌ subtask test with tags─────────┐│" Hidden by multi-width symbols: [(5, " ")] 15 | " ││#tag #tag2 ││" 16 | " │└──────────────────────────────────┘│" 17 | " │┌──────────────────────────────────┐│" 18 | " ││❌ subtask test ││" Hidden by multi-width symbols: [(5, " ")] 19 | " │└──────────────────────────────────┘│" 20 | " └────────────────────────────────────┘" 21 | " 1.1───────────────────────────────────" 22 | " 1.1.1────────────────────────────────" 23 | " ┌❌ test 1.1.1─────────────────────┐" Hidden by multi-width symbols: [(6, " ")] 24 | " │test │" 25 | " │desc │" 26 | " │🥃 │" Hidden by multi-width symbols: [(6, " ")] 27 | " └──────────────────────────────────┘" 28 | " 2──────────────────────────────────────" 29 | " 2.1───────────────────────────────────" 30 | " 2.2───────────────────────────────────" 31 | " ┌❌ test 2.2────────────────────────┐" Hidden by multi-width symbols: [(5, " ")] 32 | " │test │" 33 | " │desc │" 34 | " │┌❌ subtask 2.2───────────────────┐│" Hidden by multi-width symbols: [(6, " ")] 35 | " ││📅 2016/07/08 09:10:11 ││" Hidden by multi-width symbols: [(6, " ")] 36 | " ││#tag #tag2 ││" 37 | " ││test ││" 38 | " ││desc ││" 39 | " │└─────────────────────────────────┘│" 40 | " └───────────────────────────────────┘" 41 | " " 42 | " " 43 | " " 44 | " " 45 | -------------------------------------------------------------------------------- /src/widgets/snapshots/vault_tasks__widgets__task_list__tests__task_list.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/widgets/task_list.rs 3 | expression: terminal.backend() 4 | --- 5 | "Test────────────────────────────────────" 6 | " 1──────────────────────────────────────" 7 | " ┌✅ task 1───────────────────────────┐" Hidden by multi-width symbols: [(4, " ")] 8 | " │📅 2016/07/08 09:10:11 ❗5 │" Hidden by multi-width symbols: [(4, " "), (27, " ")] 9 | " │#tag #tag2 │" 10 | " │┌❌ subtask test with desc─────────┐│" Hidden by multi-width symbols: [(5, " ")] 11 | " ││test ││" 12 | " ││desc ││" 13 | " │└──────────────────────────────────┘│" 14 | " │┌❌ subtask test with tags─────────┐│" Hidden by multi-width symbols: [(5, " ")] 15 | " ││#tag #tag2 ││" 16 | " │└──────────────────────────────────┘│" 17 | " │┌──────────────────────────────────┐│" 18 | " ││❌ subtask test ││" Hidden by multi-width symbols: [(5, " ")] 19 | " │└──────────────────────────────────┘│" 20 | " └────────────────────────────────────┘" 21 | " 1.1───────────────────────────────────" 22 | " 1.1.1────────────────────────────────" 23 | " ┌❌ test 1.1.1─────────────────────┐" Hidden by multi-width symbols: [(6, " ")] 24 | " │test │" 25 | " │desc │" 26 | " │🥃 │" Hidden by multi-width symbols: [(6, " ")] 27 | " └──────────────────────────────────┘" 28 | " 2──────────────────────────────────────" 29 | " 2.1───────────────────────────────────" 30 | " 2.2───────────────────────────────────" 31 | " ┌❌ test 2.2────────────────────────┐" Hidden by multi-width symbols: [(5, " ")] 32 | " │test │" 33 | " │desc │" 34 | " │┌❌ subtask 2.2───────────────────┐│" Hidden by multi-width symbols: [(6, " ")] 35 | " ││📅 2016/07/08 09:10:11 ││" Hidden by multi-width symbols: [(6, " ")] 36 | " ││#tag #tag2 ││" 37 | " ││test ││" 38 | " ││desc ││" 39 | " │└─────────────────────────────────┘│" 40 | " └───────────────────────────────────┘" 41 | " " 42 | " " 43 | " " 44 | " " 45 | -------------------------------------------------------------------------------- /src/widgets/snapshots/vault_tasks__widgets__task_list_item__tests__task_list_item.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/widgets/task_list_item.rs 3 | expression: terminal.backend() 4 | --- 5 | "┌✅ task with a very long title that should wrap ┐" Hidden by multi-width symbols: [(2, " ")] 6 | "│the next line │" 7 | "│📅 2016/07/08 09:10:11 [🟩🟩🟩⬜️⬜️ 60%] ❗5 │" Hidden by multi-width symbols: [(2, " "), (26, " "), (28, " "), (30, " "), (32, " "), (34, " "), (42, " ")] 8 | "│#tag #tag2 │" 9 | "│┌❌ subtask with another long title that should┐│" Hidden by multi-width symbols: [(3, " ")] 10 | "││wrap around ││" 11 | "││test ││" 12 | "││desc ││" 13 | "│└──────────────────────────────────────────────┘│" 14 | "│┌❌ subtask test───────────────────────────────┐│" Hidden by multi-width symbols: [(3, " ")] 15 | "││#tag #tag2 ││" 16 | "│└──────────────────────────────────────────────┘│" 17 | "│┌❌ subtask test with a long title 123456789 1 ┐│" Hidden by multi-width symbols: [(3, " ")] 18 | "││3 ││" 19 | "││📅 2016/07/08 09:10:11 ❗5 ││" Hidden by multi-width symbols: [(3, " "), (26, " ")] 20 | "││test ││" 21 | "││desc ││" 22 | "│└──────────────────────────────────────────────┘│" 23 | "│ │" 24 | "│ │" 25 | "│ │" 26 | "│ │" 27 | "│ │" 28 | "│ │" 29 | "│ │" 30 | "│ │" 31 | "│ │" 32 | "│ │" 33 | "│ │" 34 | "│ │" 35 | "│ │" 36 | "│ │" 37 | "│ │" 38 | "│ │" 39 | "│ │" 40 | "│ │" 41 | "│ │" 42 | "│ │" 43 | "│ │" 44 | "└────────────────────────────────────────────────┘" 45 | -------------------------------------------------------------------------------- /src/widgets/styled_calendar.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Constraint, Layout, Margin, Rect}, 3 | style::{Style, Stylize}, 4 | widgets::calendar::{CalendarEventStore, Monthly}, 5 | Frame, 6 | }; 7 | use time::{Date, Month}; 8 | 9 | #[derive(Default, Clone, Copy)] 10 | pub struct StyledCalendar; 11 | 12 | impl StyledCalendar { 13 | // pub fn render_year(frame: &mut Frame, area: Rect, date: Date, events: &CalendarEventStore) { 14 | // let area = area.inner(Margin { 15 | // vertical: 1, 16 | // horizontal: 1, 17 | // }); 18 | // let rows = Layout::vertical([Constraint::Ratio(1, 3); 3]).split(area); 19 | // let areas = rows.iter().flat_map(|row| { 20 | // Layout::horizontal([Constraint::Ratio(1, 4); 4]) 21 | // .split(*row) 22 | // .to_vec() 23 | // }); 24 | // for (i, area) in areas.enumerate() { 25 | // let month = date 26 | // .replace_day(1) 27 | // .unwrap() 28 | // .replace_month(Month::try_from(i as u8 + 1).unwrap()) 29 | // .unwrap(); 30 | // StyledCalendar::render_month(frame, area, month, events); 31 | // } 32 | // } 33 | 34 | pub fn render_quarter(frame: &mut Frame, area: Rect, date: Date, events: &CalendarEventStore) { 35 | let area = area.inner(Margin { 36 | vertical: 1, 37 | horizontal: 1, 38 | }); 39 | let [pred, cur, next] = Layout::vertical([Constraint::Length(2 + 5 + 1); 3]).areas(area); 40 | 41 | let mut prev_date = date; 42 | if date.month() == Month::January { 43 | prev_date = prev_date.replace_year(date.year() - 1).unwrap(); 44 | } 45 | StyledCalendar::render_month( 46 | frame, 47 | pred, 48 | prev_date 49 | .replace_day(1) 50 | .unwrap() 51 | .replace_month(date.month().previous()) 52 | .unwrap(), 53 | events, 54 | ); 55 | StyledCalendar::render_month(frame, cur, date.replace_day(1).unwrap(), events); 56 | let mut next_date = date; 57 | if date.month() == Month::December { 58 | next_date = next_date.replace_year(date.year() + 1).unwrap(); 59 | } 60 | StyledCalendar::render_month( 61 | frame, 62 | next, 63 | next_date 64 | .replace_day(1) 65 | .unwrap() 66 | .replace_month(date.month().next()) 67 | .unwrap(), 68 | events, 69 | ); 70 | } 71 | 72 | fn render_month(frame: &mut Frame, area: Rect, date: Date, events: &CalendarEventStore) { 73 | let calendar = Monthly::new(date, events) 74 | .default_style(Style::new().bold()) 75 | .show_month_header(Style::default()) 76 | .show_surrounding(Style::new().dim()) 77 | .show_weekdays_header(Style::new().bold().green()); 78 | frame.render_widget(calendar, area); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/widgets/task_list.rs: -------------------------------------------------------------------------------- 1 | use crate::core::vault_data::VaultData; 2 | use ratatui::prelude::*; 3 | use tui_scrollview::{ScrollView, ScrollViewState}; 4 | 5 | use crate::config::Config; 6 | 7 | use super::task_list_item::TaskListItem; 8 | 9 | #[derive(Default, Clone)] 10 | pub struct TaskList { 11 | content: Vec, 12 | constraints: Vec, 13 | height: u16, 14 | } 15 | 16 | impl TaskList { 17 | pub fn new( 18 | config: &Config, 19 | file_content: &[VaultData], 20 | max_width: u16, 21 | display_filename: bool, 22 | ) -> Self { 23 | let content = file_content 24 | .iter() 25 | .map(|fc| { 26 | TaskListItem::new( 27 | fc.clone(), 28 | !config.tasks_config.use_american_format, 29 | config.tasks_config.pretty_symbols.clone(), 30 | max_width, 31 | display_filename, 32 | config.tasks_config.show_relative_due_dates, 33 | config.tasks_config.completion_bar_length, 34 | ) 35 | .header_style( 36 | *config 37 | .styles 38 | .get(&crate::app::Mode::Explorer) 39 | .unwrap() 40 | .get("preview_headers") 41 | .unwrap(), 42 | ) 43 | }) 44 | .collect::>(); 45 | let mut height = 0; 46 | let mut constraints = vec![]; 47 | for item in &content { 48 | height += item.height; 49 | constraints.push(Constraint::Length(item.height)); 50 | } 51 | Self { 52 | content, 53 | constraints, 54 | height, 55 | } 56 | } 57 | // pub fn height_of(&mut self, i: usize) -> u16 { 58 | // (0..i).map(|i| self.content[i].height).sum() 59 | // } 60 | } 61 | impl StatefulWidget for TaskList { 62 | type State = ScrollViewState; 63 | fn render( 64 | self, 65 | area: ratatui::prelude::Rect, 66 | buf: &mut ratatui::prelude::Buffer, 67 | state: &mut Self::State, 68 | ) where 69 | Self: Sized, 70 | { 71 | // If we need the vertical scrollbar 72 | // Then take into account that we need to draw it 73 | // 74 | // If we don't do this, the horizontal scrollbar 75 | // appears for only one character 76 | // It basically disables the horizontal scrollbar 77 | let width = if self.height > area.height { 78 | area.width - 1 79 | } else { 80 | area.width 81 | }; 82 | 83 | let size = Size::new(width, self.height); 84 | let mut scroll_view = ScrollView::new(size); 85 | 86 | let layout = Layout::vertical(self.constraints).split(scroll_view.area()); 87 | 88 | for (i, item) in self.content.into_iter().enumerate() { 89 | scroll_view.render_widget(item, layout[i]); 90 | } 91 | scroll_view.render(area, buf, state); 92 | } 93 | } 94 | 95 | #[cfg(test)] 96 | mod tests { 97 | use crate::core::{ 98 | task::{DueDate, State, Task}, 99 | vault_data::VaultData, 100 | }; 101 | use chrono::NaiveDate; 102 | use insta::assert_snapshot; 103 | use ratatui::{backend::TestBackend, Terminal}; 104 | use tui_scrollview::ScrollViewState; 105 | 106 | use crate::{config::Config, widgets::task_list::TaskList}; 107 | 108 | #[test] 109 | fn test_task_list() { 110 | let test_vault = VaultData::Header( 111 | 0, 112 | "Test".to_string(), 113 | vec![ 114 | VaultData::Header( 115 | 1, 116 | "1".to_string(), 117 | vec![ 118 | VaultData::Task(Task { 119 | name: "task 1".to_string(), 120 | state: State::Done, 121 | tags: Some(vec![String::from("tag"), String::from("tag2")]), 122 | priority: 5, 123 | due_date: DueDate::DayTime( 124 | NaiveDate::from_ymd_opt(2016, 7, 8) 125 | .unwrap() 126 | .and_hms_opt(9, 10, 11) 127 | .unwrap(), 128 | ), 129 | subtasks: vec![ 130 | Task { 131 | name: "subtask test with desc".to_string(), 132 | description: Some("test\ndesc".to_string()), 133 | ..Default::default() 134 | }, 135 | Task { 136 | name: "subtask test with tags".to_string(), 137 | tags: Some(vec![String::from("tag"), String::from("tag2")]), 138 | ..Default::default() 139 | }, 140 | Task { 141 | name: "subtask test".to_string(), 142 | ..Default::default() 143 | }, 144 | ], 145 | ..Default::default() 146 | }), 147 | VaultData::Header( 148 | 2, 149 | "1.1".to_string(), 150 | vec![VaultData::Header( 151 | 3, 152 | "1.1.1".to_string(), 153 | vec![VaultData::Task(Task { 154 | name: "test 1.1.1".to_string(), 155 | description: Some("test\ndesc\n🥃".to_string()), 156 | ..Default::default() 157 | })], 158 | )], 159 | ), 160 | ], 161 | ), 162 | VaultData::Header( 163 | 1, 164 | "2".to_string(), 165 | vec![ 166 | VaultData::Header(3, "2.1".to_string(), vec![]), 167 | VaultData::Header( 168 | 2, 169 | "2.2".to_string(), 170 | vec![VaultData::Task(Task { 171 | name: "test 2.2".to_string(), 172 | description: Some("test\ndesc".to_string()), 173 | subtasks: vec![Task { 174 | name: "subtask 2.2".to_string(), 175 | 176 | due_date: DueDate::DayTime( 177 | NaiveDate::from_ymd_opt(2016, 7, 8) 178 | .unwrap() 179 | .and_hms_opt(9, 10, 11) 180 | .unwrap(), 181 | ), 182 | description: Some("test\ndesc".to_string()), 183 | tags: Some(vec![String::from("tag"), String::from("tag2")]), 184 | ..Default::default() 185 | }], 186 | ..Default::default() 187 | })], 188 | ), 189 | ], 190 | ), 191 | ], 192 | ); 193 | 194 | let mut config = Config::default(); 195 | 196 | // We don't want tests to be time dependent 197 | config.tasks_config.show_relative_due_dates = false; 198 | 199 | let max_width = 40; 200 | let task_list = TaskList::new(&config, &[test_vault], max_width, true); 201 | let mut terminal = Terminal::new(TestBackend::new(max_width, 40)).unwrap(); 202 | terminal 203 | .draw(|frame| { 204 | frame.render_stateful_widget(task_list, frame.area(), &mut ScrollViewState::new()); 205 | }) 206 | .unwrap(); 207 | assert_snapshot!(terminal.backend()); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/widgets/timer.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use chrono::{NaiveTime, TimeDelta}; 4 | use ratatui::widgets::{Block, Gauge, StatefulWidget, Widget}; 5 | pub struct TimerWidget; 6 | 7 | #[derive(Default, Clone)] 8 | pub enum TimerState { 9 | Frozen, 10 | ClockDown { 11 | started_at: NaiveTime, 12 | stop_at: NaiveTime, 13 | paused_at: Option, 14 | }, 15 | ClockUp { 16 | started_at: NaiveTime, 17 | paused_at: Option, 18 | }, 19 | #[default] 20 | NotInitialized, 21 | } 22 | impl TimerState { 23 | pub fn new(new_duration: Option) -> Self { 24 | let now = chrono::Local::now().time(); 25 | match new_duration { 26 | Some(d) => Self::ClockDown { 27 | started_at: now, 28 | stop_at: now 29 | .overflowing_add_signed(TimeDelta::from_std(d).unwrap_or_default()) 30 | .0, 31 | paused_at: None, 32 | }, 33 | None => Self::ClockUp { 34 | started_at: now, 35 | paused_at: None, 36 | }, 37 | } 38 | } 39 | pub fn pause(self) -> Self { 40 | let now = chrono::Local::now().time(); 41 | match self { 42 | TimerState::ClockDown { 43 | started_at, 44 | stop_at, 45 | paused_at, 46 | } => { 47 | if let Some(paused_at) = paused_at { 48 | let delta = now - paused_at; 49 | Self::ClockDown { 50 | started_at: started_at + delta, 51 | stop_at: stop_at + delta, 52 | paused_at: None, 53 | } 54 | } else { 55 | Self::ClockDown { 56 | started_at, 57 | stop_at, 58 | paused_at: Some(now), 59 | } 60 | } 61 | } 62 | TimerState::ClockUp { 63 | started_at, 64 | paused_at, 65 | } => { 66 | if let Some(paused_at) = paused_at { 67 | let delta = now - paused_at; 68 | Self::ClockUp { 69 | paused_at: None, 70 | started_at: started_at + delta, 71 | } 72 | } else { 73 | TimerState::ClockUp { 74 | started_at, 75 | paused_at: Some(now), 76 | } 77 | } 78 | } 79 | TimerState::NotInitialized => TimerState::NotInitialized, 80 | TimerState::Frozen => TimerState::Frozen, 81 | } 82 | } 83 | pub fn get_time_spent(&self) -> Result { 84 | let now = chrono::Local::now().time(); 85 | match self { 86 | TimerState::ClockUp { 87 | started_at, 88 | paused_at: _, 89 | } 90 | | TimerState::ClockDown { 91 | started_at, 92 | stop_at: _, 93 | paused_at: _, 94 | } => (now - *started_at).to_std(), 95 | TimerState::NotInitialized | Self::Frozen => Ok(Duration::ZERO), 96 | } 97 | } 98 | /// Returns true if the current time finished 99 | pub fn tick(&self) -> bool { 100 | match self { 101 | TimerState::ClockDown { 102 | started_at: _, 103 | stop_at, 104 | paused_at: paused, 105 | } => chrono::Local::now().time() > *stop_at && paused.is_none(), 106 | TimerState::ClockUp { 107 | started_at: _, 108 | paused_at: _, 109 | } => false, 110 | TimerState::NotInitialized | Self::Frozen => false, 111 | } 112 | } 113 | } 114 | impl TimerWidget { 115 | pub fn format_time_delta(td: TimeDelta) -> String { 116 | let seconds = td.num_seconds() % 60; 117 | let minutes = (td.num_seconds() / 60) % 60; 118 | let hours = (td.num_seconds() / 60) / 60; 119 | 120 | let mut res = String::new(); 121 | if td.num_hours() > 0 { 122 | res.push_str(&format!("{hours:02}:")); 123 | } 124 | res.push_str(&format!("{minutes:02}:{seconds:02}")); 125 | res 126 | } 127 | } 128 | 129 | impl StatefulWidget for TimerWidget { 130 | type State = TimerState; 131 | #[allow(clippy::cast_precision_loss)] 132 | fn render( 133 | self, 134 | area: ratatui::prelude::Rect, 135 | buf: &mut ratatui::prelude::Buffer, 136 | state: &mut Self::State, 137 | ) { 138 | let now = chrono::Local::now().time(); 139 | let text = match state { 140 | TimerState::NotInitialized => "Not initialized".to_string(), 141 | TimerState::ClockUp { 142 | started_at, 143 | paused_at: paused, 144 | } => { 145 | if paused.is_some() { 146 | "Paused".to_string() 147 | } else { 148 | let current = now - *started_at; 149 | Self::format_time_delta(current) 150 | } 151 | } 152 | TimerState::ClockDown { 153 | stop_at, 154 | started_at: _, 155 | paused_at: paused, 156 | } => { 157 | if paused.is_some() { 158 | "Paused".to_string() 159 | } else { 160 | let remaining = *stop_at - now; 161 | Self::format_time_delta(remaining) 162 | } 163 | } 164 | TimerState::Frozen => "Waiting for input".to_string(), 165 | }; 166 | 167 | // let [area] = Layout::vertical([Constraint::Length(2 + 3)]).areas(area); 168 | 169 | let ratio = match state { 170 | TimerState::ClockDown { 171 | stop_at, 172 | started_at, 173 | paused_at, 174 | } => { 175 | let delta = if let Some(paused_at) = paused_at { 176 | now - *paused_at 177 | } else { 178 | TimeDelta::zero() 179 | }; 180 | let num = (now - *started_at - delta) 181 | .abs() 182 | .to_std() 183 | .unwrap() 184 | .as_nanos() as f64; 185 | let den = (*stop_at - *started_at).abs().to_std().unwrap().as_nanos() as f64; 186 | 1.0_f64.min(num / den) 187 | } 188 | TimerState::ClockUp { 189 | started_at: _, 190 | paused_at: _, 191 | } 192 | | TimerState::NotInitialized 193 | | TimerState::Frozen => 1.0, 194 | }; 195 | Gauge::default() 196 | .block(Block::bordered()) 197 | .ratio(ratio) 198 | .label(text) 199 | .render(area, buf); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /test-vault/dir/subdir/test_1.md: -------------------------------------------------------------------------------- 1 | # Test 1 2 | 3 | - [ ] Damn 4 | -------------------------------------------------------------------------------- /test-vault/dir/subdir/test_2.md: -------------------------------------------------------------------------------- 1 | # Test 2 2 | 3 | - [ ] Damn 2 4 | -------------------------------------------------------------------------------- /test-vault/dir/subdir/test_3.md: -------------------------------------------------------------------------------- 1 | # Test 3 2 | 3 | - [ ] Damn 3 4 | -------------------------------------------------------------------------------- /test-vault/dir/test_0.md: -------------------------------------------------------------------------------- 1 | # Test 0 2 | 3 | - [ ] `._.` 4 | -------------------------------------------------------------------------------- /test-vault/example_physics_class.md: -------------------------------------------------------------------------------- 1 | # 🧪 Physics 101 – Entropy 2 | 3 | **Date:** 2025-04-22 4 | **Topic:** Entropy – The Measure of Disorder 5 | 6 | Thanks chat GPT. 7 | 8 | --- 9 | 10 | ## 🔍 What is Entropy? 11 | 12 | Entropy is a fundamental concept in thermodynamics that quantifies the amount of disorder or randomness in a system. It reflects the number of microscopic configurations that correspond to a thermodynamic system's macroscopic state. 13 | 14 | - [x] Review the definition of entropy in the textbook (Chapter 1) #reading p2 15 | From Chapter 1, section 1.3. Statistical mechanics perspective 16 | 17 | - [x] Read section 1.3 on statistical interpretation 18 | - [x] Highlight key formulas 19 | - [x] Take notes in margin 20 | 21 | - [x] Summarize the key points about entropy in your own words #revision p3 22 | Turn notes into a summary paragraph 23 | - [x] Write a paragraph summary 24 | - [x] Compare entropy in gases vs solids 25 | - [x] Add summary to knowledge base 26 | 27 | --- 28 | 29 | ## 📈 The Second Law of Thermodynamics 30 | 31 | The second law states that the total entropy of an isolated system can never decrease over time. This implies that natural processes tend to move towards a state of maximum entropy. 32 | 33 | - [x] Find real-world examples illustrating the second law of thermodynamics #examples 34 | Three clear examples needed 35 | 36 | - [x] Melting ice cube 37 | - [x] Gas expansion in vacuum 38 | - [x] Heat transfer between bodies 39 | 40 | - [ ] Create a diagram showing entropy increase in a closed system #visual c60 p1 friday 41 | Use drawing software and add a caption 42 | - [ ] Sketch on paper 43 | - [/] Redraw neatly in drawing software c60 44 | Still needs arrows 45 | - [-] Add caption and explanation 46 | Will skip this part for now 47 | 48 | --- 49 | 50 | ## 🧪 Entropy in Practice 51 | 52 | Entropy has practical implications in various fields, including information theory, cosmology, and chemical reactions. 53 | 54 | - [ ] Research how entropy applies to information theory #research c20 p2 tomorrow 55 | Add at least one quote or diagram 56 | 57 | - [ ] Look up Shannon entropy 58 | - [ ] Note down example from data compression 59 | - [ ] Link to relevant paper 60 | 61 | - [ ] Write a brief explanation of entropy's role in chemical reactions #chemistry 62 | Focus on spontaneity 63 | - [ ] Include example of spontaneous reaction 64 | - [ ] Relate to Gibbs free energy 65 | 66 | --- 67 | 68 | ## 📝 Homework Tasks 69 | 70 | - [ ] Complete problem set 4 on entropy #homework p1 2025/04/25 71 | Includes multiple choice and theory questions 72 | 73 | - [ ] Do questions 1–3 74 | - [ ] Do questions 4–6 (challenging) 75 | - [ ] Review with classmate 76 | 77 | - [ ] Prepare a 5-minute presentation on entropy for next class #presentation p2 monday 78 | Slides + dry run + timer 79 | 80 | - [ ] Create slides 81 | - [ ] Practice once 82 | - [ ] Time the presentation 83 | 84 | - [ ] Quiz on entropy concepts scheduled #quiz 2025/04/29 85 | Closed book format 86 | - [ ] Review notes 87 | - [ ] Solve past quiz 88 | - [ ] Make a cheat sheet (if allowed) 89 | 90 | --- 91 | 92 | ## 🗂️ Tags 93 | 94 | #physics #entropy #thermodynamics #homework #chemistry #research #presentation #reading #revision #visual #examples 95 | -------------------------------------------------------------------------------- /test-vault/example_vault-tasks_project.md: -------------------------------------------------------------------------------- 1 | # Building `vault-tasks` 2 | 3 | #vaultTasks 4 | 5 | This was actually extracted from my `vault-tasks` project notes at some point. 6 | 7 | ## Releases Workflow 8 | 9 | 10 | 11 | - Update desktop entry 12 | - Merge PR (with nice commit message, change labels too) 13 | - Add tag to this commit 14 | 15 | 16 | 17 | - [ ] Prepare release x-y-z #vaultTasks 18 | 19 | > _Fake_ task `with` some **Markdown** ~~niceties~~ 2025/04/23 #vaultTasks 20 | 21 | ## Backlog (some real tasks) 22 | 23 | - [x] fix long names in tasks by wrapping p1 #vaultTasks 24 | - [ ] Add new time formats p1 #vaultTasks 25 | 26 | - `5pm` 27 | - `17h` 28 | 29 | - [x] fix long names in tasks by wrapping ? p3 #vaultTasks 30 | - [x] New states p1 #vaultTasks 31 | 32 | - [x] Canceled #vaultTasks 33 | - [x] Incomplete #vaultTasks 34 | - [x] Add actions to mark incomplete / cancel #vaultTasks 35 | 36 | - [x] Keep track of skipped time in pomodoro p2 #vaultTasks 37 | 38 | - [x] Time Managment tab #vaultTasks 39 | - [x] Fix break factor not ergonomic #vaultTasks 40 | - [x] Gauge takes too much place (doesn't flex right) #vaultTasks 41 | - [x] Export TUI related stuff from core in TUI #vaultTasks 42 | - [x] Better sorting system #vaultTasks 43 | Sorting rules: 44 | 45 | - always todo before done 46 | - we can choose due date or name to sort 47 | 48 | - [x] Rework filtering #vaultTasks 49 | - [x] Implement sorting #vaultTasks 50 | 51 | # Ideas to consider 52 | 53 | - [ ] If subtasks are partly done -> mark as incomplete ? p3 #vaultTasks 54 | -------------------------------------------------------------------------------- /test-vault/test.md: -------------------------------------------------------------------------------- 1 | # test 2 | 3 | This is mostly a test file. 4 | 5 | ## test2 6 | 7 | - [ ] task 2024/10/10 p5 #tag @today 8 | Markdown _description_ that looks ~~pretty~~ **good** 9 | 10 | - [ ] subtask with desc 11 | - desc 12 | - desc2 13 | - [ ] subtask with tags #tag1 #tag2 @today 14 | > What are we quoting ? 15 | - [ ] subtask 16 | 17 | ``` 18 | println!("What are we coding ?") 19 | ``` 20 | 21 | - [ ] Test a c10 22 | 23 | - [ ] Test b c40 24 | - [ ] Test c c10 25 | 26 | - [ ] Test to do c15 27 | - [x] Test done 28 | - [-] Test canceled 29 | - [/] Test incomplete 30 | 31 | - [ ] -10 c-10 32 | - [ ] 0 c0 33 | - [ ] 1 c10 34 | - [ ] 2 c20 35 | - [ ] 3 c30 36 | - [ ] 4 c40 37 | - [ ] 5 c50 38 | - [ ] 6 c60 39 | - [ ] 7 c70 40 | - [ ] 8 c80 41 | - [ ] 9 c90 42 | - [ ] 10 c100 43 | - [ ] 11 c110 44 | - [ ] 12 c120 45 | - [ ] 13 c130 46 | - [ ] 14 c140 47 | - [ ] 15 c150 48 | - [ ] 16 c160 49 | - [ ] 17 c170 50 | 51 | - [ ] fully 2025/04/12 c50 p50 #tag1 #tag2 @today 52 | description 53 | 54 | - [ ] subtask 55 | 56 | 60 | 61 | 62 | 63 | ```markdown 64 | - [ ] A task in a code block 65 | Shouldn't see this 66 | ``` 67 | 68 | 69 | 70 | ```markdown 71 | 75 | ``` 76 | 77 | - [ ] task123465 78 | 79 | - [ ] This task should have a code block in its description 80 | ``` 81 | Some code 82 | ``` 83 | --------------------------------------------------------------------------------