diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 100755 index 0000000..0ba11e8 --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# +# After a commit that bumps the crate version, create the matching +# vX.Y.Z tag if it does not exist yet. Pushing that tag is what +# triggers the GitHub Release pipeline (.github/workflows/release.yml). +# +# Enable the repo hooks once per clone: +# git config core.hooksPath .githooks (or ./scripts/install-hooks.sh) +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +# Only act on commits that changed the `version` line of Cargo.toml. +if ! git diff -U0 HEAD~1 HEAD -- Cargo.toml 2>/dev/null | grep -qE '^\+version = "[0-9]+\.[0-9]+\.[0-9]+"'; then + exit 0 +fi + +VERSION="$(grep -m1 '^version = ' Cargo.toml | sed 's/version = "\(.*\)"/\1/')" +TAG="v$VERSION" + +# Nothing to do if the tag already exists (e.g. created manually). +[ -z "$(git tag -l "$TAG")" ] || exit 0 + +git tag "$TAG" +echo "post-commit: created tag $TAG" +echo "publish the release with: git push origin master --tags" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..acc9300 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Require every code change to bump the crate version in the SAME commit. +# "Code" means anything that ships in the release binary: src/, tests/, +# Cargo.toml and Cargo.lock. Docs, scripts and CI changes are exempt. +# +# Rationale: releases are tag-driven and the version lives in Cargo.toml, +# so a code commit without a version bump can never become a release. +# +# Enable the repo hooks once per clone: +# git config core.hooksPath .githooks (or ./scripts/install-hooks.sh) +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +CODE_RE='^(src/|tests/|Cargo\.toml$|Cargo\.lock$)' + +# No code changes in this commit: nothing to check. +git diff --cached --name-only | grep -qE "$CODE_RE" || exit 0 + +# The commit must change the `version` line of Cargo.toml (staged). +if ! git diff --cached -U0 -- Cargo.toml | grep -qE '^\+version = "[0-9]+\.[0-9]+\.[0-9]+"'; then + cat >&2 <<'EOF' +error: this commit changes code but does not bump the version. + +Every code change must ship as a new release: bump the version and +stage it together with your changes, e.g.: + + ./scripts/bump-version.sh patch # or: minor / major + git add Cargo.toml Cargo.lock + +(bypass with: git commit --no-verify) +EOF + exit 1 +fi + +NEW_VER="$(git show :Cargo.toml | grep -m1 '^version = ' | sed 's/version = "\(.*\)"/\1/')" + +# The tag must be fresh — reusing a released version would clash. +if [ -n "$(git tag -l "v$NEW_VER")" ]; then + echo "error: tag v$NEW_VER already exists; bump to a fresh version" >&2 + exit 1 +fi + +# Cargo.lock must be staged in sync with the new version. +LOCK_VER="$(git show :Cargo.lock | awk '/^name = "basilk"$/{getline; print; exit}' | sed 's/version = "\(.*\)"/\1/')" +if [ "$NEW_VER" != "$LOCK_VER" ]; then + echo "error: Cargo.toml bumps to $NEW_VER but the staged Cargo.lock still says $LOCK_VER" >&2 + echo "run ./scripts/bump-version.sh (it updates both) and stage Cargo.lock" >&2 + exit 1 +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..26f0e03 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + version-bump: + name: Version bumped with code changes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + # Backstop for the local pre-commit hook: any change to the code + # that ships in the release binary (src/, tests/, Cargo manifests) + # must come with a version bump in Cargo.toml, otherwise the + # change can never become a GitHub Release. + - name: Code changes must bump the version + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + else + BASE="${{ github.event.before }}" + fi + if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then + echo "no base revision to compare against; skipping" + exit 0 + fi + if ! git diff --name-only "$BASE" HEAD | grep -qE '^(src/|tests/|Cargo\.toml$|Cargo\.lock$)'; then + echo "no code changes; nothing to check" + exit 0 + fi + OLD="$(git show "$BASE":Cargo.toml | grep -m1 '^version = ')" + NEW="$(grep -m1 '^version = ' Cargo.toml)" + if [ "$OLD" = "$NEW" ]; then + echo "::error::code changed but the Cargo.toml version was not bumped (still ${NEW#version = })" + echo "::error::bump it with ./scripts/bump-version.sh [patch|minor|major] and include it in the change" + exit 1 + fi + echo "version bumped: $OLD -> $NEW" + + check: + name: Build & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: cargo-${{ runner.os }}- + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Build + run: cargo build --verbose + + - name: Test + run: cargo test --verbose diff --git a/.github/workflows/lint_rust.yaml b/.github/workflows/lint_rust.yaml index a8e3e16..068846a 100644 --- a/.github/workflows/lint_rust.yaml +++ b/.github/workflows/lint_rust.yaml @@ -9,10 +9,21 @@ jobs: lint_rust: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - uses: hecrj/setup-rust-action@v2 with: rust-version: "stable" - name: "Lint Rust" run: "cargo fmt --all -- --check" -... + - name: "Install cargo-llvm-cov" + uses: taiki-e/install-action@cargo-llvm-cov + - name: "Add llvm-tools-preview" + run: "rustup component add llvm-tools-preview" + # The whole test suite runs under coverage instrumentation. Local runs + # measure ~99.5% line coverage; the only uncovered lines are the + # real-terminal glue in main.rs (main/init_terminal/restore_terminal + # and CrosstermSource::next_key) which cannot run inside a unit test. + # The 95% gate catches regressions without being flaky across + # platforms. Measure locally with: cargo llvm-cov --workspace + - name: "Test with coverage" + run: "cargo llvm-cov --workspace --fail-under-lines 95" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e8935f8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,126 @@ +--- +name: Release + +# Tag-driven release pipeline: +# - Pushing a `vX.Y.Z` tag builds release binaries for Linux, macOS +# (Intel + Apple Silicon) and Windows and publishes them to a GitHub +# Release with auto-generated notes. +# - The version number is bumped manually with each code change +# (./scripts/bump-version.sh or by editing Cargo.toml) and committed +# alongside the code; the post-commit hook creates the vX.Y.Z tag. +# The workflow never bumps versions or commits on its own. +# - The tag must match the version declared in Cargo.toml. +# - workflow_dispatch: re-publish an existing tag (e.g. after a build fix). +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to build & publish, e.g. v0.2.6 (must match Cargo.toml)' + required: true + +permissions: + contents: write + +# One release at a time; concurrent runs queue up instead of racing. +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + name: Resolve & verify version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.meta.outputs.version }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Resolve version from tag + id: meta + run: | + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.tag }}" + else + TAG="${GITHUB_REF_NAME}" + fi + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + # Build from the exact release commit, not the latest master + - uses: actions/checkout@v5 + with: + ref: v${{ steps.meta.outputs.version }} + + - name: Verify tag matches Cargo.toml + run: | + CARGO_VERSION="$(grep -m1 '^version = ' Cargo.toml | sed 's/version = "\(.*\)"/\1/')" + if [ "$CARGO_VERSION" != "${{ steps.meta.outputs.version }}" ]; then + echo "::error::tag v${{ steps.meta.outputs.version }} but Cargo.toml declares $CARGO_VERSION" + exit 1 + fi + + build: + name: Build ${{ matrix.target }} + needs: release + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + bin: basilk + asset: basilk-x86_64-unknown-linux-gnu + - os: macos-14 + target: aarch64-apple-darwin + bin: basilk + asset: basilk-aarch64-apple-darwin + # Cross-compiled from the ARM runner; the project has no C deps + - os: macos-14 + target: x86_64-apple-darwin + bin: basilk + asset: basilk-x86_64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + bin: basilk.exe + asset: basilk-x86_64-pc-windows-msvc.exe + steps: + - uses: actions/checkout@v5 + with: + ref: v${{ needs.release.outputs.version }} + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} + - name: Stage binary with platform name + run: cp target/${{ matrix.target }}/release/${{ matrix.bin }} ${{ matrix.asset }} + shell: bash + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.asset }} + path: ${{ matrix.asset }} + if-no-files-found: error + + publish: + name: Publish GitHub Release + needs: [release, build] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v8 + with: + path: artifacts + merge-multiple: true + - name: List artifacts + run: ls -lh artifacts + - name: Create GitHub Release + uses: softprops/action-gh-release@v3 + with: + tag_name: v${{ needs.release.outputs.version }} + name: v${{ needs.release.outputs.version }} + files: artifacts/* + generate_release_notes: true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..906cfbf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,78 @@ +# Basilk Agent Guide + +Basilk is a TUI-based kanban task manager written in Rust using `ratatui`. + +## Essential Commands + +- **Build**: `cargo build` +- **Run**: `cargo run` +- **Test**: `cargo test` (unit tests live in per-module `#[cfg(test)]` blocks; `src/property_tests.rs` holds proptest-based fuzz/property tests) +- **Coverage**: `cargo llvm-cov --workspace` (install with `cargo install cargo-llvm-cov`; CI runs it via `taiki-e/install-action` with `--fail-under-lines 95`). Local runs measure ~99.5% line coverage. The only uncovered lines are the real-terminal glue in `main.rs` (`main`, `init_terminal`, `restore_terminal`, `CrosstermSource::next_key`), which cannot run inside a unit test; everything else — including the full event loop and every view/modal render — is covered by in-process tests. +- **Releases**: Tag-driven. **Every code change must bump the version in the same commit** — a code commit without a version bump can never become a release, so this is enforced twice: locally by `.githooks/pre-commit` (blocks the commit; enable once per clone with `./scripts/install-hooks.sh`) and on GitHub by the `version-bump` job in `.github/workflows/ci.yml`. "Code" means `src/`, `tests/`, `Cargo.toml`, `Cargo.lock`; docs/scripts/CI changes are exempt. `./scripts/bump-version.sh [patch|minor|major]` updates `Cargo.toml` + `Cargo.lock` in the working tree only (no commit, no tag); the `.githooks/post-commit` hook then creates the `vX.Y.Z` tag automatically after a version-bump commit. Pushing that tag triggers `.github/workflows/release.yml`, which verifies the tag matches `Cargo.toml`, builds binaries for Linux / macOS (Intel + ARM) / Windows and publishes a GitHub Release. The workflow never bumps versions or commits on its own; `workflow_dispatch` re-publishes an existing tag (takes the tag name as input). A `concurrency: group: release` queue prevents racing builds. +- **Benchmarks**: `./scripts/bench.sh` (or `cargo test --release --bin basilk perf_tests -- --ignored --nocapture`) runs the opt-in `perf_tests` module — scaling of `load_items`, full-frame render at 2k tasks, Markdown rendering, JSON persistence. Typical release-build numbers: ~0.3 ms/frame at 2k tasks (250 ms budget), ~2 ms for 10k-task `load_items`, idle CPU 0%. +- **Lint**: `cargo fmt --all -- --check` and `cargo clippy --all-targets --all-features -- -D warnings` (both used in CI) +- **Format**: `cargo fmt` + +## Project Structure + +- `src/main.rs`: Entry point, terminal initialization/restore (including the panic hook), CLI dispatch, and `CrosstermSource` (the real-terminal `KeySource`). Everything else lives in `app.rs`. +- `src/app.rs`: The `App` struct, `ViewMode`, `KeyAction`, the `KeySource` trait, all `handle_*` key handlers, board logic, timer tick/settle, `App::render`, and the `run_with_source` event loop. Its tests live in `src/app/tests.rs` (handlers, board, event loop, timer behaviour, rendering) and `src/app/perf_tests.rs` (opt-in benchmarks). +- `src/cli.rs`: Simple CLI argument handling (`--version` prints the version, `--help`/`-h` prints the `USAGE` text; both exit without starting the TUI). +- `src/json.rs`: Data persistence layer (JSON format). +- `src/markdown.rs`: Markdown → ratatui `Text` renderer (`pulldown-cmark` event stream + style stack) used by the note preview. +- `src/migration.rs`: JSON data schema migrations. +- `src/note.rs`: Global note data model and logic (project-independent Markdown memos). +- `src/project.rs`: Project data model and logic. +- `src/task.rs`: Task data model, status/priority constants, and logic. +- `src/timer.rs`: Task-bound stopwatch/countdown runtime state (not persisted); on settle the elapsed seconds are accumulated into `Task.time_spent_secs`. +- `src/ui.rs`: UI utility functions for creating modals and layouts. +- `src/view.rs`: Higher-level UI rendering logic (rendering specific views/modals). +- `src/util.rs`: Miscellaneous utility functions. +- `src/property_tests.rs`: `#[cfg(test)]`-only module with proptest property/fuzz tests. +- `src/test_utils.rs`: `#[cfg(test)]`-only shared fixtures (`make_app`, `make_task`, `setup_temp_config`, `ENV_LOCK`). + +## Code Patterns + +### App State Management +The `App` struct in `src/app.rs` manages the application state, including selected indices for projects and tasks, the current `ViewMode`, and loaded data. + +### View Modes +`ViewMode` enum in `src/app.rs` defines the different screens and states (e.g., `ViewProjects`, `AddTask`, `ViewTasks`). + +### Data Persistence +- Data is stored in `basilk_data.json` (a versioned wrapper around the project list, plus a global `notes` list) in the user's config directory; older releases used per-version files (e.g., `911fc.json`) that are migrated on startup. +- `Json::read()` (returns `Result`; a corrupt data file is a startup error with the file path, not a panic) and `Json::write()` handle loading and saving the entire project list; `Json::read_notes()` / `Json::write_notes()` do the same for notes. Both write paths read the counterpart back from disk first with a strict parse, so writing projects never drops notes and vice versa — and a write fails without touching the file when the on-disk data cannot be parsed, instead of overwriting the corrupt file with an empty counterpart. Writes are atomic: the payload goes to a sibling `basilk_data.json.tmp` file which is then renamed over the target, so an interrupted write cannot leave a truncated data file. +- Set the `BASILK_CONFIG_DIR` env var to redirect storage (used by tests with a temp dir). +- Migrations are handled in `migration.rs` by mapping version hashes to transformation functions. A legacy migration deletes the old versioned file only after the rewritten `basilk_data.json` verifies (a recursive `Json::check()`), and the deletion is best-effort. + +### TUI Logic +- Uses `ratatui` with `crossterm` backend. +- `App::render` in `src/app.rs` delegates rendering to `View` methods in `view.rs`. +- Modals are created using `Ui` helper methods in `ui.rs`. + +## Conventions + +- **Naming**: Standard Rust naming conventions (CamelCase for types, snake_case for functions/variables). +- **Static Constants**: Used for task statuses (`TASK_STATUS_DONE`, etc.) and priorities. +- **Error Handling**: Uses `Box` in `main` and `Result` elsewhere. `unwrap()` is frequently used in data operations. + +## Gotchas + +- **Input Handling**: `tui-input` is used for text fields. Note that the event loop (`App::run_with_source` in `src/app.rs`) filters for `KeyEventKind::Press` to avoid double-processing on Windows. +- **Panic Safety**: `init_terminal` installs a panic hook that calls `restore_terminal()` before delegating to the default hook, so a panic never strands the terminal in raw mode / alternate screen. Keep this hook in place when touching terminal setup. +- **Event Loop**: `CrosstermSource` in `src/main.rs` uses `event::poll(250ms)` instead of a blocking read so the task timer (`App.timer`) can tick and redraw every second; `App::tick_timer` runs after each iteration of `App::run_with_source` in `src/app.rs`. +- **Timers**: `s` in the task view starts a stopwatch bound to the selected task; `c` in either view starts a global pomodoro countdown (`src/timer.rs`, binding is `Option`). A stopwatch persists its seconds on settle (`App::settle_timer` → `Task::add_time_spent` into `time_spent_secs`): on stop or on quit; a pomodoro never persists — at zero it rings the terminal bell once and stays visible in a finished state (modal shows big block digits and "time's up!") until the user dismisses it with any key. Timers keep running across view switches; deleting the bound task drops the timer, deleting a project drops or re-indexes it. Timer modals return to the view they were opened from via `previous_view_mode`. +- **Time Estimate**: Each task has an `estimated_hours` field (0 = no estimate, editable with `g` in the task details view); the details view and timer modal show the percentage of the estimate already spent (both computed via `Task::estimate_progress`, so the two cannot drift), and the task list renders a `[x%]` suffix. Progress math lives in `Task::estimate_progress` (saturating arithmetic — arbitrary JSON values must not overflow). Timers are bound to a task by `(project_index, task_title)`; renaming a bound task updates the timer, deleting it drops the timer. Task titles are unique within a project (the title is the task's stable identity): `Task::create` and `Task::rename` reject duplicates and return `false`, and the add/rename handlers keep the input view open on a rejection so nothing is silently dropped. +- **Data Loading**: `Project::reload` and `Task::reload` read the entire JSON file from disk. Changes are written back to disk immediately after most operations (create, rename, delete, change status/priority). +- **Sorting**: Tasks are sorted during `Task::load_items`. `sort_by_key` is stable and the priority sort runs last, so the final order is **priority-major** with status as the tie-breaker; done tasks (priority reset to NONE) end up last. Unknown statuses/priorities (hand-edited JSON) map past the end of the known sort keys, so they sort last instead of first. `load_items` also normalizes `status == Done ⇒ priority = NONE` on every load, restoring the invariant that hand-edited JSON can break (in-app transitions enforce it in `Task::change_status`). +- **Board View**: `b` in the task view toggles a kanban board (three lanes: Up Next / On Going / Done) rendered by `View::show_board` in `view.rs`. It is a display mode of `ViewMode::ViewTasks`, not a separate `ViewMode`, so all task keybindings work unchanged. State lives in `App.board_view`, `App.board_lane`, and `App.board_lane_states` (per-lane `ListState`); `selected_task_index` (full sorted-list index) remains the selection source of truth — lane navigation just translates (lane, row) into it via `Task::lane_indices`, and `App::board_sync` (called at the end of `Task::load_items` when the board is active, plus explicitly after `DeleteTask`'s `select_previous` via `App::delete_current_task`) re-derives the lane/row after any mutation so the focus follows a task that changed status. While the board is active, `Task::load_items` skips the `hide_done_tasks` filter (the board always shows the Done lane) and `t` is a no-op; `←`/`→` switch lanes (`←` no longer goes back to projects), `Esc` still does. Task lines are built by the shared `Task::repr_spans` helper so the list and board renderings cannot drift. +- **Notes**: `m` in the project view opens the global notes list (`ViewMode::ViewNotes`); notes live in `App.notes` (model in `src/note.rs`) and are stored in the same `basilk_data.json` wrapper (`#[serde(default)]`, so pre-notes files load unchanged; the `e5a1c` migration only bumps the version). `Enter`/`v` opens `ViewNote`, a full-page Markdown preview (`View::show_note` → `markdown::render_markdown`) scrolled via `App.note_scroll`, which is clamped at render time against an estimated wrapped line count (`G` just sets `u16::MAX`). `e` opens `EditNote`, a full-page `tui-textarea` editor stored in `App.note_textarea` (created on entry from the body lines); every key except `Esc` is forwarded to the textarea, and `Esc` saves (`Note::update_body`) and returns to the preview. `ViewNote`/`EditNote` bypass `View::show_items` in `App::render` and take the whole main area. `Note::create` and `Note::rename` reject empty titles (silent no-op, matching the project/task style); `rename` and `update_body` both stamp `updated_at`. +- **Testing**: Tests share fixtures from `src/test_utils.rs`. Any test touching the disk layer must hold `ENV_LOCK` and use `setup_temp_config()` (sets `BASILK_CONFIG_DIR`). + - **Event loop**: `App::run_with_source` drives the whole draw/dispatch/tick loop against an injected `KeySource`; tests feed it a queue of synthetic `KeyEvent`s (see `app::tests::event_loop` in `src/app/tests.rs`). `CrosstermSource` (the real terminal poll/read, in `src/main.rs`) is the one `KeySource` that tests cannot drive in-process. + - **Rendering**: `App::render` (and the `View::show_*` functions) are exercised with `ratatui::TestBackend` across every `ViewMode`, including the timer/help/details/delete modals and the board view; `app::tests::render` asserts on the drawn buffer content (`CompletedFrame::buffer`). + - **Key handlers**: every `App::handle_*` method has direct unit tests (`app::tests::handlers`). Handlers change views themselves via `self.change_view(...)`; tests must set `app.view_mode` before calling a handler that depends on `use_state()` (navigation, modal lists), because `use_state()` picks the `ListState` from the current `view_mode`. + - **CLI**: `Cli::parse` is pure and unit-tested; the `--version` / `--help` print/exit lives in `main` (uncovered glue) and is verified end-to-end by `tests/cli.rs`, which spawns the real binary via `CARGO_BIN_EXE_basilk`. +- **Migrations**: If you change the data schema (e.g., in `Project` or `Task` structs), you **must** add a new migration in `migration.rs` and update `JSON_VERSIONS`. + +## Configuration +There is currently no config file mechanism in the codebase (an earlier `src/config.rs` with `ui.show_help` no longer exists). All behavior is compiled in; per-task settings like `estimated_hours` live in the JSON data. diff --git a/Cargo.lock b/Cargo.lock index 2a1e253..3a9c776 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ahash" @@ -11,7 +11,7 @@ dependencies = [ "cfg-if", "once_cell", "version_check", - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -20,6 +20,15 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "autocfg" version = "1.3.0" @@ -28,21 +37,46 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "basilk" -version = "0.2.1" +version = "0.2.28" dependencies = [ + "chrono", "dirs", + "proptest", + "pulldown-cmark", "ratatui", "serde", "serde_json", - "toml", + "tempfile", "tui-input", + "tui-textarea", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" -version = "2.6.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "cassowary" @@ -59,12 +93,35 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.2.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "compact_str" version = "0.7.1" @@ -78,6 +135,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossterm" version = "0.27.0" @@ -121,7 +184,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -131,10 +194,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] -name = "equivalent" -version = "1.0.1" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.2", +] [[package]] name = "getrandom" @@ -147,6 +241,29 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -164,13 +281,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "indexmap" -version = "2.5.0" +name = "iana-time-zone" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ - "equivalent", - "hashbrown", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", ] [[package]] @@ -188,11 +319,21 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" -version = "0.2.155" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" @@ -204,6 +345,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.12" @@ -244,7 +391,16 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.48.0", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", ] [[package]] @@ -288,6 +444,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy 0.8.27", +] + [[package]] name = "proc-macro2" version = "1.0.86" @@ -297,6 +462,50 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.36" @@ -306,6 +515,56 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "ratatui" version = "0.27.0" @@ -324,7 +583,7 @@ dependencies = [ "strum_macros", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -342,17 +601,48 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" dependencies = [ - "getrandom", + "getrandom 0.2.15", "libredox", "thiserror", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.18" @@ -398,13 +688,10 @@ dependencies = [ ] [[package]] -name = "serde_spanned" -version = "0.6.8" +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" -dependencies = [ - "serde", -] +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" @@ -491,6 +778,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.63" @@ -512,48 +812,37 @@ dependencies = [ ] [[package]] -name = "toml" -version = "0.8.19" +name = "tui-input" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +checksum = "9b02e86628a225c39b2602863f244a01668184149928ceb410e47a8022d7597e" dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "crossterm", + "unicode-width 0.1.13", ] [[package]] -name = "toml_datetime" -version = "0.6.8" +name = "tui-textarea" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "00524c1366ee838839dd327d1f339ff51846ad4ea85bfa1332859e79adec612c" dependencies = [ - "serde", + "crossterm", + "ratatui", + "unicode-width 0.1.13", ] [[package]] -name = "toml_edit" -version = "0.22.22" +name = "unarray" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "winnow", -] +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] -name = "tui-input" -version = "0.9.0" +name = "unicase" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b02e86628a225c39b2602863f244a01668184149928ceb410e47a8022d7597e" -dependencies = [ - "crossterm", - "unicode-width", -] +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" @@ -575,7 +864,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -584,18 +873,87 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi" version = "0.3.9" @@ -618,6 +976,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -627,6 +1044,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -749,13 +1175,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "winnow" -version = "0.6.20" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" -dependencies = [ - "memchr", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "zerocopy" @@ -763,7 +1186,16 @@ version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ - "zerocopy-derive", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive 0.8.27", ] [[package]] @@ -776,3 +1208,14 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 0c06b5f..4c74069 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "basilk" -version = "0.2.1" +version = "0.2.28" edition = "2021" description = "A Terminal User Interface (TUI) to manage your tasks with minimal kanban logic" license = "MIT OR Apache-2.0" @@ -12,12 +12,22 @@ keywords = ["tui", "kanban", "tasks", "ratatui", "terminal"] categories = ["command-line-utilities"] default-run = "basilk" +[features] +# Opt-in performance benchmarks: `./scripts/bench.sh` +bench = [] + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +chrono = "0.4.43" dirs = "5.0.1" +pulldown-cmark = "0.13.4" ratatui = "0.27.0" serde = { version = "1.0.204", features = ["derive"] } serde_json = "1.0.122" -toml = "0.8.19" tui-input = "0.9.0" +tui-textarea = "0.5" + +[dev-dependencies] +proptest = "1.5" +tempfile = "3.10" diff --git a/README.md b/README.md index 61133f6..3bf1717 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@

illustration generated using perchance.org

+

English | 中文

+

basilk

A Terminal User Interface (TUI) to manage your tasks with minimal kanban logic

@@ -36,49 +38,59 @@ Windows ``` The choice to use the JSON format is to make easier to export -## Installation -### Cargo - -from [crates.io](https://crates.io/crates/basilk) using [`cargo`](https://doc.rust-lang.org/cargo/) - -```sh -cargo install basilk -``` - -### AUR - -from the [AUR](https://aur.archlinux.org/packages/basilk) with using an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers). - -```sh -paru -S basilk -``` - -### Homebrew -from a [homebrew tap](https://docs.brew.sh/Taps) using [`brew`](https://brew.sh/) +This is a fork of the original [basilk](https://github.com/GabAlpha/basilk) project. For the original version, please refer to the upstream repository. -```sh -brew tap GabAlpha/tap -brew install basilk -``` - -### X-CMD -from a [x install](https://x-cmd.com/install/basilk) using [x-cmd](https://x-cmd.com) +## Installation ```sh -x install basilk +git clone https://github.com/LiuYinCarl/basilk && cd basilk +cargo install --path . ``` -### Build from source - -1. Clone the repository -```sh -git clone https://github.com/GabAlpha/basilk && cd basilk -``` -2. Build -```sh -cargo build --release -``` -Binary will be located at `target/release/basilk` +Prebuilt binaries for Linux, macOS (Intel & Apple Silicon) and Windows are +attached to every [GitHub Release](https://github.com/LiuYinCarl/basilk/releases). + +## Versioning & Releases + +Basilk follows [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`, +stored in `Cargo.toml`). Releases are **tag-driven** — pushing the `vX.Y.Z` +tag triggers the [Release workflow](.github/workflows/release.yml) to build +and publish. + +**Every code change must bump the version in the same commit** — a code +commit without a version bump can never become a release, so this is +enforced for you: + +- A `pre-commit` hook blocks code commits that don't bump the version + (code = `src/`, `tests/`, `Cargo.toml`, `Cargo.lock`). Enable the repo + hooks once per clone: + ```sh + ./scripts/install-hooks.sh + ``` +- A CI check (`version-bump` job) fails any push/PR that changes code + without bumping the version. + +The flow with hooks enabled: + +1. Make your code changes, then bump the version (updates `Cargo.toml` + + `Cargo.lock` in the working tree): + ```sh + ./scripts/bump-version.sh patch # or minor / major + ``` +2. Stage everything and commit — the `post-commit` hook creates the + `vX.Y.Z` tag automatically. +3. Push — the tag triggers the release build: + ```sh + git push origin master --tags + ``` + +The workflow then builds binaries for Linux, macOS (Intel & Apple Silicon) +and Windows, attaches them to a GitHub Release with auto-generated notes, +and verifies the tag matches the version in `Cargo.toml` (it fails otherwise, +so release binaries always report the tagged version). + +The manual **Run workflow** button can re-publish an existing tag (e.g. after +a CI fix); it takes the tag name as input. ## Usage Run @@ -86,7 +98,121 @@ Run ```sh basilk ``` -All available commands are displayed inside + +## Keybindings + +Press `h` to view the keybinding list in-app. + +### Global +| Key | Action | +|---|---| +| `q` | Quit | + +### Project List View +| Key | Action | +|---|---| +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | Navigate projects | +| `Enter` `→` `l` | Enter project (view tasks) | +| `m` | Open notes | +| `n` | New project | +| `r` | Rename selected project | +| `d` | Delete selected project | +| `c` | Pomodoro (countdown) timer | +| `h` | Help | + +### Task List View +| Key | Action | +|---|---| +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | Navigate tasks | +| `←` `→` | Switch lane (board view) | +| `b` | Toggle board / list view | +| `Esc` `←` | Back to project list | +| `Enter` | Change task status | +| `p` | Change task priority | +| `n` | New task | +| `r` | Rename selected task | +| `v` | View task details | +| `e` | Edit task note | +| `d` | Delete selected task | +| `t` | Toggle show/hide completed tasks | +| `s` | Stopwatch timer for selected task | +| `c` | Pomodoro (countdown) timer | +| `h` | Help | + +### Change Status / Priority Modals +| Key | Action | +|---|---| +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | Navigate options | +| `Enter` | Confirm selection | +| `Esc` | Cancel | + +### Input Modals (New / Rename / Edit Note) +| Key | Action | +|---|---| +| `Enter` | Confirm | +| `Esc` | Cancel | + +### Delete Confirmation Modals +| Key | Action | +|---|---| +| `y` | Confirm delete | +| `n` | Cancel | + +### Task Details View +| Key | Action | +|---|---| +| `e` | Edit task note | +| `g` | Edit estimated time (hours, `0` = no estimate) | +| Any other key | Close details | + +### Timer View +| Key | Action | +|---|---| +| `Space` | Pause / resume | +| `Enter` | Stop (a stopwatch saves its elapsed time to the bound task) | +| `Esc` | Close (timer keeps running in the background) | + +### Notes List View +| Key | Action | +|---|---| +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | Navigate notes | +| `Enter` `→` `l` `v` | Open the note preview | +| `n` | New note | +| `r` | Rename selected note | +| `d` | Delete selected note | +| `Esc` `←` | Back to project list | +| `h` | Help | + +### Note Preview View +| Key | Action | +|---|---| +| `↑` `↓` `k` `j` | Scroll | +| `PageUp` `PageDown` | Page up / down | +| `g` `G` | Top / bottom | +| `e` | Edit (Markdown source) | +| `Esc` `Enter` | Back to notes list | +| `h` | Help | + +### Note Editor View +| Key | Action | +|---|---| +| `Esc` | Save and return to the preview | +| Any other key | Editing (multi-line, handled by the editor) | + +The timer keeps running while you navigate (even back to the project list); it stops when you press `Enter` in the timer view or when you quit. + +- **Stopwatch** (`s`, task list): bound to the selected task; the elapsed time accumulates into the task's **Time Spent**, shown in the details view next to the task's **Estimate** (how long you expect the task to take, editable per task with `g`; the details view shows what percentage of the estimate has been spent). +- **Pomodoro** (`c`, both views): a global countdown for focus sessions; at zero it rings the terminal bell and stays on screen (showing `time's up!`) until you press any key. It is not tied to any task, so nothing is accumulated. + +Tasks are displayed as `[Status] Title` with optional `[Priority]` prefix and, for tasks with an estimate set, a `[x%]` suffix showing how much of the estimate has been spent (red once it reaches 100%). +- Statuses: **UpNext** (magenta), **OnGoing** (yellow), **Done** (green, ~~crossed out~~) +- Priorities: `!` (highest), `!!` (high), `!!!` (low) + +Completed tasks are **hidden by default** in the task list view. Press `t` to toggle their visibility. + +Press `b` in the task list view to switch to a kanban **board view**: three vertical lanes (**Up Next** / **On Going** / **Done**), each titled with its task count, the focused lane highlighted in its status color. Use `←`/`→` to move between lanes and `↑`/`↓` to select a task within a lane; every other shortcut (`v` details, `Enter` status, `p` priority, timers, …) works the same, and changing a task's status moves it to the matching lane. The board always shows the Done lane, regardless of the `t` setting (which is a no-op while the board is active). + +Press `m` in the project list view to open **notes**: global, project-independent memos. A note is a titled entry whose body is Markdown; opening one shows a full-page rendered preview (headings, bold/italic, code blocks, lists, quotes, links), and `e` switches to a full-page multi-line editor for the Markdown source (`Esc` saves and returns to the preview). ## Contributing > [!NOTE] @@ -94,6 +220,10 @@ All available commands are displayed inside As I mentioned above, this is my first project in Rust, so contributions and help are welcome! If you have any suggestions, improvements, or bug fixes, feel free to submit a pull request or open a new issue. +### Testing & coverage + +The test suite runs with `cargo test` and includes property tests (`src/property_tests.rs`). Coverage is measured with [cargo-llvm-cov](https://github.com/taiki-e/cargo-llvm-cov) (`cargo llvm-cov --workspace`) and enforced in CI (`--fail-under-lines 95`); the suite currently sits around **99.5% line coverage** — the only uncovered lines are the real-terminal glue in `main.rs` (`main`, `init_terminal`, `restore_terminal`, `CrosstermSource::next_key`), which cannot run inside a unit test. The full event loop, every view mode, and every key handler are exercised in-process via synthetic events and a `TestBackend`. + ## License [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat&logo=GitHub&labelColor=1D272B&color=819188&logoColor=white)](./LICENSE-MIT) diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..7adae67 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,183 @@ +

+

插画由 perchance.org 生成

+ +

English | 中文

+ +

basilk

+

一个基于终端界面 (TUI) 的任务管理工具,具有简洁的看板逻辑

+ + + +## 缘起 +那是一个[炎热的八月夜晚](https://www.meteo.it/notizie/meteo-caldo-in-aumento-la-tendenza-verso-ferragosto-c95aa7dc),我正在整理待办事项,突然觉得需要一个简单、便携的软件来帮助我。**basilk** 由此诞生——既是一个学习 Rust 的暑期项目,也能在任何地方使用。 + +名字 [_/ˈbæzəlkeɪ/_](https://gabalpha.github.io/read-audio/?p=https://github.com/GabAlpha/basilk/raw/master/assets/basil-k.wav) 源于罗勒(basil)——一种易于种植和维护的植物,而 "k" 代表看板(kanban)。 + +
+另一个故事 + +

+

插画由 perchance.org 生成

+ +名字 [_/ˈbæzsɪlk/_](https://gabalpha.github.io/read-audio/?p=https://github.com/GabAlpha/basilk/raw/master/assets/bas-silk.wav) 源自 basil 与 silk 的结合,象征其制作过程的精巧。 +
+ +## 关于 +**basilk** 以项目为单位组织任务,每个项目内的任务可设置不同的状态(Up Next / On Going / Done)。 + +数据以 `.json` 格式存储,文件位于: +``` +Linux +~/.config/basilk + +macOS +~/Library/Application Support/basilk + +Windows +\AppData\Roaming\basilk +``` +选择 JSON 格式是为了方便导出。 + +本项目是原始 [basilk](https://github.com/GabAlpha/basilk) 的一个 fork 版本。如需原始版本,请参考上游仓库。 + +## 安装 + +```sh +git clone https://github.com/LiuYinCarl/basilk && cd basilk +cargo install --path . +``` + +## 使用 +运行 + +```sh +basilk +``` + +## 快捷键 + +在应用内按 `h` 即可查看快捷键列表。 + +### 全局 +| 按键 | 功能 | +|---|---| +| `q` | 退出 | + +### 项目列表视图 +| 按键 | 功能 | +|---|---| +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | 浏览项目 | +| `Enter` `→` `l` | 进入项目(查看任务) | +| `m` | 打开便签 | +| `n` | 新建项目 | +| `r` | 重命名所选项目 | +| `d` | 删除所选项目 | +| `c` | 番茄钟(倒计时) | +| `h` | 帮助 | + +### 任务列表视图 +| 按键 | 功能 | +|---|---| +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | 浏览任务 | +| `←` `→` | 切换泳道(看板视图) | +| `b` | 切换看板 / 列表视图 | +| `Esc` `←` | 返回项目列表 | +| `Enter` | 更改任务状态 | +| `p` | 更改任务优先级 | +| `n` | 新建任务 | +| `r` | 重命名所选任务 | +| `v` | 查看任务详情 | +| `e` | 编辑任务备注 | +| `d` | 删除所选任务 | +| `t` | 切换显示/隐藏已完成任务 | +| `s` | 所选任务的正向计时器 | +| `c` | 番茄钟(倒计时) | +| `h` | 帮助 | + +### 更改状态 / 优先级弹窗 +| 按键 | 功能 | +|---|---| +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | 浏览选项 | +| `Enter` | 确认选择 | +| `Esc` | 取消 | + +### 输入弹窗(新建 / 重命名 / 编辑备注) +| 按键 | 功能 | +|---|---| +| `Enter` | 确认 | +| `Esc` | 取消 | + +### 删除确认弹窗 +| 按键 | 功能 | +|---|---| +| `y` | 确认删除 | +| `n` | 取消 | + +### 任务详情视图 +| 按键 | 功能 | +|---|---| +| `e` | 编辑任务备注 | +| `g` | 编辑预期时长(小时,`0` = 不设预期) | +| 任意其他按键 | 关闭详情 | + +### 计时器视图 +| 按键 | 功能 | +|---|---| +| `Space` | 暂停 / 继续 | +| `Enter` | 停止(正向计时会把时长累计到绑定的任务) | +| `Esc` | 关闭(计时器在后台继续运行) | + +### 便签列表视图 +| 按键 | 功能 | +|---|---| +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | 浏览便签 | +| `Enter` `→` `l` `v` | 打开便签预览 | +| `n` | 新建便签 | +| `r` | 重命名所选便签 | +| `d` | 删除所选便签 | +| `Esc` `←` | 返回项目列表 | +| `h` | 帮助 | + +### 便签预览视图 +| 按键 | 功能 | +|---|---| +| `↑` `↓` `k` `j` | 滚动 | +| `PageUp` `PageDown` | 翻页 | +| `g` `G` | 顶部 / 底部 | +| `e` | 编辑(Markdown 源码) | +| `Esc` `Enter` | 返回便签列表 | +| `h` | 帮助 | + +### 便签编辑视图 +| 按键 | 功能 | +|---|---| +| `Esc` | 保存并返回预览 | +| 其他按键 | 多行编辑(由编辑器处理) | + +计时器在你切换视图(包括返回项目列表)时持续运行,只有在计时器视图按 `Enter` 停止、或退出程序时才会结算。 + +- **正向计时**(`s`,任务列表):绑定所选任务,计时累计到任务的**累计时长**,在详情视图中与任务的**预期时长**(你预计这个任务要做多久,可按 `g` 为每个任务单独设置)并列展示,并显示已消耗预期时长的百分比。 +- **番茄钟**(`c`,两个视图均可):全局倒计时,用于专注时段;归零时响铃提醒,界面停留在结束状态(显示“时间到!”),按任意键关闭。不关联任何任务,也不累计时长。 + +任务以 `[状态] 标题` 格式显示,可选 `[优先级]` 前缀;设置了预期时长的任务还会带上 `[x%]` 后缀,显示已消耗预期时长的百分比(达到 100% 后变红)。 +- 状态:**UpNext**(品红)、**OnGoing**(黄色)、**Done**(绿色,~~删除线~~) +- 优先级:`!`(最高)、`!!`(高)、`!!!`(低) + +已完成的任务在任务列表视图中**默认隐藏**,按 `t` 可切换显示。 + +在任务列表视图按 `b` 可切换到看板**泳道视图**:三条竖排泳道(**Up Next** / **On Going** / **Done**),标题带任务计数,当前聚焦的泳道以其状态颜色高亮边框。用 `←`/`→` 在泳道间切换,`↑`/`↓` 在泳道内选择任务;其余快捷键(`v` 详情、`Enter` 状态、`p` 优先级、计时器等)照常工作,更改任务状态后任务会移动到对应泳道。看板始终显示 Done 泳道,不受 `t` 设置影响(看板模式下 `t` 无操作)。 + +在项目列表视图按 `m` 可打开**便签**:全局的、不依附于项目的备忘录。便签由标题和 Markdown 正文组成,打开后进入整页渲染预览(标题、粗体/斜体、代码块、列表、引用、链接等),按 `e` 切换到整页多行编辑器修改 Markdown 源码,`Esc` 保存并返回预览。 + +## 参与贡献 +> [!NOTE] +> 本项目目前处于 beta 阶段,可能存在 bug。 + +如上所述,这是我的第一个 Rust 项目,欢迎任何形式的贡献和帮助!如果你有任何建议、改进或 bug 修复,欢迎提交 pull request 或提出新的 issue。 + +## 许可证 + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat&logo=GitHub&labelColor=1D272B&color=819188&logoColor=white)](./LICENSE-MIT) +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat&logo=GitHub&labelColor=1D272B&color=819188&logoColor=white)](./LICENSE-APACHE) + +根据您的选择,许可协议为 [Apache License Version 2.0](./LICENSE-APACHE) 或 [The MIT License](./LICENSE-MIT)。 diff --git a/proptest-regressions/property_tests.txt b/proptest-regressions/property_tests.txt new file mode 100644 index 0000000..c3c2716 --- /dev/null +++ b/proptest-regressions/property_tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 7739710e9b2ca992bc3ead3dbb2de2e7a1f2c7cd67afefb11cb3ce5310111781 # shrinks to tasks = [Task { title: "", status: "UpNext", priority: 0, created_at: None, completed_at: None, note: "", time_spent_secs: 0, estimated_hours: 5124095576030432 }], hide_done = false, selected = 0 diff --git a/scripts/bench.sh b/scripts/bench.sh new file mode 100755 index 0000000..6d42711 --- /dev/null +++ b/scripts/bench.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# +# Run the opt-in performance benchmarks (release build, timing printed). +# +# Usage: ./scripts/bench.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +cargo test --release --features bench --bin basilk perf_tests -- --ignored --nocapture diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..55844bd --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# +# Bump the semantic version of the crate in Cargo.toml + Cargo.lock. +# The files are edited in the working tree only — nothing is committed +# or tagged here. +# +# Usage: +# ./scripts/bump-version.sh [patch|minor|major] +# +# patch (default): 0.2.1 -> 0.2.2 +# minor: 0.2.1 -> 0.3.0 +# major: 0.2.1 -> 1.0.0 +# +# Flow: the pre-commit hook (see .githooks/) requires every code commit +# to include a version bump, so run this while preparing your commit, +# stage everything together, and commit. The post-commit hook then +# creates the vX.Y.Z tag automatically; pushing it triggers the CI +# release pipeline (.github/workflows/release.yml). +set -euo pipefail + +cd "$(dirname "$0")/.." + +BUMP="${1:-patch}" +case "$BUMP" in + patch | minor | major) ;; + *) echo "usage: $0 [patch|minor|major]" >&2; exit 1 ;; +esac + +CURRENT="$(grep -m1 '^version = ' Cargo.toml | sed 's/version = "\(.*\)"/\1/')" +MAJOR="$(echo "$CURRENT" | cut -d. -f1)" +MINOR="$(echo "$CURRENT" | cut -d. -f2)" +PATCH="$(echo "$CURRENT" | cut -d. -f3)" + +case "$BUMP" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; +esac + +NEW="$MAJOR.$MINOR.$PATCH" + +if [ -n "$(git tag -l "v$NEW")" ]; then + echo "error: tag v$NEW already exists" >&2 + exit 1 +fi + +python3 - <$NEW', s, count=1) +open(path, "w").write(s) +EOF + +echo "bumped $CURRENT -> $NEW" +echo "next: stage Cargo.toml + Cargo.lock together with your code changes and commit" +echo "(the post-commit hook tags v$NEW; pushing the tag triggers the release pipeline)" diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..1235d1f --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# +# Point git at the repository-managed hooks in .githooks/ (one-time +# setup per clone): +# +# pre-commit block code commits that do not bump the crate version +# post-commit auto-create the vX.Y.Z tag after a version-bump commit +set -euo pipefail + +cd "$(dirname "$0")/.." + +git config core.hooksPath .githooks +echo "git hooks enabled (core.hooksPath=.githooks)" diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..bd727fa --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1490 @@ +use std::{error::Error, io}; + +use ratatui::{ + crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}, + prelude::*, + widgets::*, +}; +use tui_input::{backend::crossterm::EventHandler, Input}; +use tui_textarea::TextArea; + +use crate::{ + json::Json, + note::Note, + project::Project, + task::{Task, TASK_PRIORITIES, TASK_STATUSES}, + timer::{TimerKind, TimerState}, + util::Util, + view::View, +}; + +#[derive(Default, PartialEq, Debug)] +pub(crate) enum ViewMode { + #[default] + ViewProjects, + RenameProject, + AddProject, + DeleteProject, + + ViewTasks, + RenameTask, + ChangeStatusTask, + ChangePriorityTask, + AddTask, + DeleteTask, + ViewTaskDetails, + EditTaskNote, + SetTaskEstimate, + TimerTask, + SetCountdown, + ViewHelp, + + ViewNotes, + AddNote, + RenameNote, + DeleteNote, + ViewNote, + EditNote, + + InfoMigration, +} + +pub(crate) struct App { + // TODO: Better list state mgmt + pub(crate) selected_project_index: ListState, + pub(crate) selected_task_index: ListState, + pub(crate) selected_status_task_index: ListState, + pub(crate) selected_priority_task_index: ListState, + pub(crate) delete_confirm_index: ListState, + pub(crate) view_mode: ViewMode, + pub(crate) previous_view_mode: ViewMode, + pub(crate) projects: Vec, + pub(crate) hide_done_tasks: bool, + pub(crate) timer: Option, + /// When true, the task view renders as a three-lane kanban board + /// (Up Next / On Going / Done) instead of the classic list. + pub(crate) board_view: bool, + /// Currently focused board lane: index into `TASK_STATUSES`. + pub(crate) board_lane: usize, + /// Per-lane selection/scroll state for the board view. + pub(crate) board_lane_states: [ListState; 3], + /// Global notes, independent of projects. + pub(crate) notes: Vec, + pub(crate) selected_note_index: ListState, + /// Vertical scroll offset of the note preview page. + pub(crate) note_scroll: u16, + /// Editor state while `ViewMode::EditNote` is active. + pub(crate) note_textarea: Option>, +} + +/// What the event loop should do after a key press was handled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyAction { + /// No special action; continue the loop normally. + None, + /// Skip the rest of this iteration (mirrors the previous `continue` + /// inside the event loop: modal navigation and empty-list guards + /// redraw on the next iteration without ticking the timer). + Skip, + /// Quit the application; the caller settles the timer. + Quit, +} + +/// Source of key events for the event loop. The production implementation +/// reads from the terminal; tests inject a queue of synthetic events so the +/// whole loop (draw, key dispatch, timer tick) runs in-process. +pub(crate) trait KeySource { + fn next_key(&mut self) -> io::Result>; +} + +impl App { + pub(crate) fn setup() -> Result> { + Ok(Self { + selected_project_index: ListState::default().with_selected(Some(0)), + selected_task_index: ListState::default().with_selected(Some(0)), + selected_status_task_index: ListState::default().with_selected(Some(0)), + selected_priority_task_index: ListState::default().with_selected(Some(0)), + delete_confirm_index: ListState::default().with_selected(Some(0)), + view_mode: ViewMode::default(), + previous_view_mode: ViewMode::default(), + projects: Json::read()?, + hide_done_tasks: true, + timer: None, + board_view: false, + board_lane: 0, + board_lane_states: [ + ListState::default().with_selected(Some(0)), + ListState::default().with_selected(Some(0)), + ListState::default().with_selected(Some(0)), + ], + notes: Json::read_notes(), + selected_note_index: ListState::default().with_selected(Some(0)), + note_scroll: 0, + note_textarea: None, + }) + } + + /// Run the event loop against a `KeySource`. The production entry point + /// is `main` (with `CrosstermSource`); tests drive this method directly + /// with a queue of synthetic keys so the whole loop runs in-process. + pub(crate) fn run_with_source( + &mut self, + mut terminal: Terminal, + were_applied_migrations: bool, + source: &mut impl KeySource, + ) -> io::Result<()> { + let mut input = Input::default(); + + let mut items: Vec = vec![]; + Project::load_items(self, &mut items); + + let mut status_items: Vec = vec![]; + Task::load_statuses_items(&mut status_items); + + let mut priority_items: Vec = vec![]; + Task::load_priority_items(&mut priority_items); + + if were_applied_migrations { + self.view_mode = ViewMode::InfoMigration + } + + loop { + terminal.draw(|f| { + self.render(f, f.size(), &input, &items, &status_items, &priority_items) + })?; + + if let Some(key) = source.next_key()? { + // Capture only the "Press" event to prevent double input on Windows + if key.kind == KeyEventKind::Press { + match self.handle_key( + key, + &mut input, + &mut items, + &status_items, + &priority_items, + ) { + KeyAction::Quit => { + self.settle_timer(); + return Ok(()); + } + KeyAction::Skip => continue, + KeyAction::None => {} + } + } + } + + self.tick_timer(); + } + } + + /// Dispatch a pressed key to the handler of the current view mode. + /// + /// Each mode owns the keys it understands, so the event loop stays + /// shallow instead of nesting every binding in one giant `match`. + fn handle_key( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + status_items: &[ListItem], + priority_items: &[ListItem], + ) -> KeyAction { + match self.view_mode { + ViewMode::ViewProjects => self.handle_view_projects(key, input, items), + ViewMode::RenameProject => self.handle_rename_project(key, input, items), + ViewMode::AddProject => self.handle_add_project(key, input, items), + ViewMode::DeleteProject => self.handle_delete_project(key, items), + ViewMode::ViewTasks => self.handle_view_tasks(key, input, items), + ViewMode::RenameTask => self.handle_rename_task(key, input, items), + ViewMode::ChangeStatusTask => self.handle_change_status_task(key, items, status_items), + ViewMode::ChangePriorityTask => { + self.handle_change_priority_task(key, items, priority_items) + } + ViewMode::AddTask => self.handle_add_task(key, input, items), + ViewMode::DeleteTask => self.handle_delete_task(key, items), + ViewMode::ViewTaskDetails => self.handle_view_task_details(key, input), + ViewMode::EditTaskNote => self.handle_edit_task_note(key, input, items), + ViewMode::SetTaskEstimate => self.handle_set_task_estimate(key, input, items), + ViewMode::TimerTask => self.handle_timer_task(key), + ViewMode::SetCountdown => self.handle_set_countdown(key, input), + ViewMode::ViewHelp => { + self.back_to_previous_view(); + KeyAction::None + } + ViewMode::ViewNotes => self.handle_view_notes(key, input, items), + ViewMode::AddNote => self.handle_add_note(key, input, items), + ViewMode::RenameNote => self.handle_rename_note(key, input, items), + ViewMode::DeleteNote => self.handle_delete_note(key, items), + ViewMode::ViewNote => self.handle_view_note(key, items), + ViewMode::EditNote => self.handle_edit_note(key), + ViewMode::InfoMigration => { + self.change_view(ViewMode::ViewProjects); + KeyAction::None + } + } + } + + fn handle_view_projects( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Char('h') => { + self.previous_view_mode = ViewMode::ViewProjects; + self.change_view(ViewMode::ViewHelp); + } + Enter | Right | Char('l') => { + if items.is_empty() { + return KeyAction::Skip; + } + + Task::load_items(self, items); + self.selected_task_index.select(Some(0)); + + // The sync inside `load_items` ran before the + // selection was reset to the top + if self.board_view { + self.board_sync(); + } + + self.change_view(ViewMode::ViewTasks); + } + Char('r') => { + if items.is_empty() { + return KeyAction::Skip; + } + + *input = Input::default().with_value(Project::get_current(self).title.clone()); + + self.change_view(ViewMode::RenameProject); + } + Char('n') => { + input.reset(); + + self.change_view(ViewMode::AddProject); + } + Char('d') => { + if items.is_empty() { + return KeyAction::Skip; + } + + self.change_view(ViewMode::DeleteProject); + } + Down | Tab | Char('j') => { + self.next(items); + } + Up | BackTab | Char('k') => { + self.previous(items); + } + Char('c') => { + self.previous_view_mode = ViewMode::ViewProjects; + + if self.timer.is_some() { + self.change_view(ViewMode::TimerTask); + } else { + input.reset(); + + self.change_view(ViewMode::SetCountdown); + } + } + Char('m') => { + Note::load_items(self, items); + + self.change_view(ViewMode::ViewNotes); + } + Char('q') => { + return KeyAction::Quit; + } + _ => {} + } + KeyAction::None + } + + fn handle_rename_project( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Enter => { + Project::rename(self, items, input.value()); + input.reset(); + + self.change_view(ViewMode::ViewProjects); + } + Esc => { + input.reset(); + + self.change_view(ViewMode::ViewProjects); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_add_project( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Esc => { + self.change_view(ViewMode::ViewProjects); + } + Enter => { + if !input.value().is_empty() { + Project::create(self, items, input.value()); + self.selected_project_index + .select(Some(self.projects.len() - 1)); + } + + self.change_view(ViewMode::ViewProjects); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_delete_project(&mut self, key: KeyEvent, items: &mut Vec) -> KeyAction { + if self.handle_modal_nav( + key.code, + &View::delete_confirm_items(), + ViewMode::ViewProjects, + ) { + return KeyAction::Skip; + } + if key.code == KeyCode::Enter { + if self.delete_confirm_index.selected() == Some(0) { + let deleted_index = self.selected_project_index.selected().unwrap(); + + Project::delete(self, items); + self.selected_project_index.select_previous(); + + // Keep the timer binding consistent: a timer on the + // deleted project is dropped, later indexes shift down + let bound_index = self + .timer + .as_ref() + .and_then(|t| t.bound.as_ref()) + .map(|b| b.project_index); + + if bound_index == Some(deleted_index) { + self.timer = None; + } else if let Some(timer) = self.timer.as_mut() { + if let Some(bound) = timer.bound.as_mut() { + if bound.project_index > deleted_index { + bound.project_index -= 1; + } + } + } + } + self.delete_confirm_index.select(Some(0)); + self.change_view(ViewMode::ViewProjects); + } + KeyAction::None + } + + fn handle_view_tasks( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + // In board mode the focused lane (not the list) decides + // whether task actions are available: the list can be + // empty only because done tasks are hidden, while the + // Done lane still shows them. + let no_current_task = if self.board_view { + self.board_lane_is_empty() + } else { + items.is_empty() + }; + + match key.code { + Char('h') => { + self.previous_view_mode = ViewMode::ViewTasks; + self.change_view(ViewMode::ViewHelp); + } + Esc => { + Project::load_items(self, items); + + self.change_view(ViewMode::ViewProjects); + } + Left => { + if self.board_view { + self.board_switch_lane(false); + } else { + Project::load_items(self, items); + + self.change_view(ViewMode::ViewProjects); + } + } + Right => { + if self.board_view { + self.board_switch_lane(true); + } + } + Char('b') => { + self.board_view = !self.board_view; + + // Rebuild the item list: the board shows + // done tasks, the list view may hide them. + // When toggling on, `load_items` also + // re-syncs the board focus. + Task::load_items(self, items); + } + Enter => { + if no_current_task { + return KeyAction::Skip; + } + + let index = TASK_STATUSES + .into_iter() + .position(|t| t == Task::get_current(self).status) + .unwrap(); + + self.selected_status_task_index.select(Some(index)); + + self.change_view(ViewMode::ChangeStatusTask); + } + Char('p') => { + if no_current_task { + return KeyAction::Skip; + } + + let index = TASK_PRIORITIES + .into_iter() + .position(|t| t == Task::get_current(self).priority) + .unwrap(); + + self.selected_priority_task_index.select(Some(index)); + + self.change_view(ViewMode::ChangePriorityTask); + } + Char('r') => { + if no_current_task { + return KeyAction::Skip; + } + + *input = Input::default().with_value(Task::get_current(self).title.clone()); + + self.change_view(ViewMode::RenameTask); + } + Char('n') => { + input.reset(); + + self.change_view(ViewMode::AddTask); + } + Char('d') => { + if no_current_task { + return KeyAction::Skip; + } + + self.change_view(ViewMode::DeleteTask); + } + Char('v') => { + if no_current_task { + return KeyAction::Skip; + } + + self.change_view(ViewMode::ViewTaskDetails); + } + Char('e') => { + if no_current_task { + return KeyAction::Skip; + } + + *input = Input::default().with_value(Task::get_current(self).note.clone()); + + self.change_view(ViewMode::EditTaskNote); + } + Down | Tab | Char('j') => { + if self.board_view { + self.board_move(true); + } else { + self.next(items); + } + } + Up | BackTab | Char('k') => { + if self.board_view { + self.board_move(false); + } else { + self.previous(items); + } + } + Char('t') => { + // The board always shows the Done lane, so there + // is nothing to toggle while it is active + if !self.board_view { + self.hide_done_tasks = !self.hide_done_tasks; + Task::load_items(self, items); + } + } + Char('s') => { + self.previous_view_mode = ViewMode::ViewTasks; + + if self.timer.is_some() { + self.change_view(ViewMode::TimerTask); + } else if !no_current_task { + let project_index = self.selected_project_index.selected().unwrap(); + let task_title = Task::get_current(self).title.clone(); + + self.timer = Some(TimerState::new_stopwatch(project_index, task_title)); + + self.change_view(ViewMode::TimerTask); + } + } + Char('c') => { + self.previous_view_mode = ViewMode::ViewTasks; + + if self.timer.is_some() { + self.change_view(ViewMode::TimerTask); + } else { + input.reset(); + + self.change_view(ViewMode::SetCountdown); + } + } + Char('q') => { + return KeyAction::Quit; + } + _ => {} + } + KeyAction::None + } + + fn handle_rename_task( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Enter => { + let project_index = self.selected_project_index.selected().unwrap(); + let old_title = Task::get_current(self).title.clone(); + let new_title = input.value().to_string(); + + // A duplicate title is rejected: keep the input and stay in + // the rename view so the user can pick another name. + if !Task::rename(self, items, &new_title) { + return KeyAction::None; + } + input.reset(); + + // Keep a running timer bound to the renamed task + if let Some(timer) = self.timer.as_mut() { + if timer.is_bound_to(project_index, &old_title) { + if let Some(bound) = timer.bound.as_mut() { + bound.task_title = new_title; + } + } + } + + self.change_view(ViewMode::ViewTasks); + } + Esc => { + input.reset(); + + self.change_view(ViewMode::ViewTasks); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_change_status_task( + &mut self, + key: KeyEvent, + items: &mut Vec, + status_items: &[ListItem], + ) -> KeyAction { + if self.handle_modal_nav(key.code, status_items, ViewMode::ViewTasks) { + return KeyAction::Skip; + } + if key.code == KeyCode::Enter { + Task::change_status( + self, + items, + TASK_STATUSES[self.selected_status_task_index.selected().unwrap()], + ); + + self.selected_status_task_index.select(Some(0)); + self.change_view(ViewMode::ViewTasks); + } + KeyAction::None + } + + fn handle_change_priority_task( + &mut self, + key: KeyEvent, + items: &mut Vec, + priority_items: &[ListItem], + ) -> KeyAction { + if self.handle_modal_nav(key.code, priority_items, ViewMode::ViewTasks) { + return KeyAction::Skip; + } + if key.code == KeyCode::Enter { + Task::change_priority( + self, + items, + TASK_PRIORITIES[self.selected_priority_task_index.selected().unwrap()], + ); + + self.selected_priority_task_index.select(Some(0)); + self.change_view(ViewMode::ViewTasks); + } + KeyAction::None + } + + fn handle_add_task( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Enter => { + // Empty and duplicate titles are rejected: keep the input + // and stay in the add view so nothing is silently dropped. + if Task::create(self, items, input.value()) { + self.change_view(ViewMode::ViewTasks); + } + } + Esc => { + self.change_view(ViewMode::ViewTasks); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_delete_task(&mut self, key: KeyEvent, items: &mut Vec) -> KeyAction { + if self.handle_modal_nav(key.code, &View::delete_confirm_items(), ViewMode::ViewTasks) { + return KeyAction::Skip; + } + if key.code == KeyCode::Enter { + if self.delete_confirm_index.selected() == Some(0) { + // Drop a timer bound to the task being deleted + let project_index = self.selected_project_index.selected().unwrap(); + let task_title = Task::get_current(self).title.clone(); + let bound = matches!( + self.timer.as_ref(), + Some(t) if t.is_bound_to(project_index, &task_title) + ); + if bound { + self.timer = None; + } + + self.delete_current_task(items); + } + self.delete_confirm_index.select(Some(0)); + self.change_view(ViewMode::ViewTasks); + } + KeyAction::None + } + + fn handle_view_task_details(&mut self, key: KeyEvent, input: &mut Input) -> KeyAction { + use KeyCode::*; + match key.code { + Char('e') => { + *input = Input::default().with_value(Task::get_current(self).note.clone()); + + self.change_view(ViewMode::EditTaskNote); + } + Char('g') => { + // Prefill the current estimate; an empty field + // is less friction than a "0" to delete first + let current = Task::get_current(self).estimated_hours; + *input = Input::default().with_value(if current > 0 { + current.to_string() + } else { + String::new() + }); + + self.change_view(ViewMode::SetTaskEstimate); + } + _ => { + self.change_view(ViewMode::ViewTasks); + } + } + KeyAction::None + } + + fn handle_edit_task_note( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Enter => { + Task::update_note(self, items, input.value()); + input.reset(); + + self.change_view(ViewMode::ViewTaskDetails); + } + Esc => { + input.reset(); + + self.change_view(ViewMode::ViewTaskDetails); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_set_task_estimate( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Enter => { + let raw = input.value().trim().to_string(); + + if let Ok(hours) = raw.parse::() { + Task::update_estimate(self, items, hours); + input.reset(); + + self.change_view(ViewMode::ViewTaskDetails); + } else if raw.is_empty() { + input.reset(); + + self.change_view(ViewMode::ViewTaskDetails); + } + // Invalid non-empty input: stay in the modal + } + Esc => { + input.reset(); + + self.change_view(ViewMode::ViewTaskDetails); + } + // Only digits make sense here + Char(c) if !c.is_ascii_digit() => {} + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_timer_task(&mut self, key: KeyEvent) -> KeyAction { + if self.timer.as_ref().is_some_and(TimerState::is_finished) { + // Finished countdown stays on screen; any key dismisses it + self.settle_timer(); + self.back_to_previous_view(); + return KeyAction::Skip; + } + + use KeyCode::*; + match key.code { + Char(' ') => { + if let Some(timer) = self.timer.as_mut() { + if timer.is_running() { + timer.pause(); + } else { + timer.resume(); + } + } + } + Enter => { + self.settle_timer(); + self.back_to_previous_view(); + } + Esc => { + self.back_to_previous_view(); + } + _ => {} + } + KeyAction::None + } + + fn handle_set_countdown(&mut self, key: KeyEvent, input: &mut Input) -> KeyAction { + use KeyCode::*; + match key.code { + Enter => { + let raw = input.value().trim().to_string(); + let minutes = raw.parse::().unwrap_or(0.0); + let secs = (minutes * 60.0).round() as u64; + + if secs > 0 { + input.reset(); + + self.timer = Some(TimerState::new_countdown(secs)); + + self.change_view(ViewMode::TimerTask); + } else if raw.is_empty() { + input.reset(); + + self.back_to_previous_view(); + } + // Invalid non-empty input: stay in the modal + } + Esc => { + input.reset(); + + self.back_to_previous_view(); + } + // Only digits and a decimal point make sense here + Char(c) if !c.is_ascii_digit() && c != '.' => {} + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_view_notes( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Char('h') => { + self.previous_view_mode = ViewMode::ViewNotes; + self.change_view(ViewMode::ViewHelp); + } + Esc | Left => { + Project::load_items(self, items); + + self.change_view(ViewMode::ViewProjects); + } + Enter | Right | Char('l') | Char('v') => { + if items.is_empty() { + return KeyAction::Skip; + } + + self.note_scroll = 0; + + self.change_view(ViewMode::ViewNote); + } + Char('n') => { + input.reset(); + + self.change_view(ViewMode::AddNote); + } + Char('r') => { + if items.is_empty() { + return KeyAction::Skip; + } + + *input = Input::default().with_value(Note::get_current(self).title.clone()); + + self.change_view(ViewMode::RenameNote); + } + Char('d') => { + if items.is_empty() { + return KeyAction::Skip; + } + + self.change_view(ViewMode::DeleteNote); + } + Down | Tab | Char('j') => { + self.next(items); + } + Up | BackTab | Char('k') => { + self.previous(items); + } + Char('q') => { + return KeyAction::Quit; + } + _ => {} + } + KeyAction::None + } + + fn handle_add_note( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Enter => { + if !input.value().is_empty() { + Note::create(self, items, input.value()); + input.reset(); + + self.selected_note_index + .select(Some(self.notes.len().saturating_sub(1))); + } + + self.change_view(ViewMode::ViewNotes); + } + Esc => { + input.reset(); + + self.change_view(ViewMode::ViewNotes); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_rename_note( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Enter => { + Note::rename(self, items, input.value()); + input.reset(); + + self.change_view(ViewMode::ViewNotes); + } + Esc => { + input.reset(); + + self.change_view(ViewMode::ViewNotes); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_delete_note(&mut self, key: KeyEvent, items: &mut Vec) -> KeyAction { + if self.handle_modal_nav(key.code, &View::delete_confirm_items(), ViewMode::ViewNotes) { + return KeyAction::Skip; + } + if key.code == KeyCode::Enter { + if self.delete_confirm_index.selected() == Some(0) { + Note::delete(self, items); + } + self.delete_confirm_index.select(Some(0)); + self.change_view(ViewMode::ViewNotes); + } + KeyAction::None + } + + /// Full-page Markdown preview: scroll keys adjust `note_scroll` + /// (clamped against the wrapped content height at render time). + fn handle_view_note(&mut self, key: KeyEvent, items: &mut Vec) -> KeyAction { + use KeyCode::*; + match key.code { + Char('h') => { + self.previous_view_mode = ViewMode::ViewNote; + self.change_view(ViewMode::ViewHelp); + } + Char('e') => { + let body = Note::get_current(self).body.clone(); + let mut lines: Vec = body.lines().map(|l| l.to_string()).collect(); + if lines.is_empty() { + lines.push(String::new()); + } + + let mut textarea = TextArea::from(lines); + textarea.set_block(Block::bordered().title(" Edit Note — Esc: save & back ")); + self.note_textarea = Some(textarea); + + self.change_view(ViewMode::EditNote); + } + Down | Char('j') => { + self.note_scroll = self.note_scroll.saturating_add(1); + } + Up | Char('k') => { + self.note_scroll = self.note_scroll.saturating_sub(1); + } + PageDown => { + self.note_scroll = self.note_scroll.saturating_add(10); + } + PageUp => { + self.note_scroll = self.note_scroll.saturating_sub(10); + } + Home | Char('g') => { + self.note_scroll = 0; + } + End | Char('G') => { + // Clamped to the real bottom when rendering + self.note_scroll = u16::MAX; + } + Esc | Enter => { + // The body may have changed in the editor; refresh the + // list so the snippet is up to date + Note::load_items(self, items); + + self.change_view(ViewMode::ViewNotes); + } + Char('q') => { + return KeyAction::Quit; + } + _ => {} + } + KeyAction::None + } + + /// Full-page editor: every key except Esc goes to the textarea; + /// Esc saves the body and returns to the rendered preview. + fn handle_edit_note(&mut self, key: KeyEvent) -> KeyAction { + if key.code == KeyCode::Esc { + if let Some(textarea) = self.note_textarea.take() { + let body = textarea.into_lines().join("\n"); + // Skip the write (and the `updated_at` bump) when nothing changed + if body != Note::get_current(self).body { + Note::update_body(self, &body); + } + } + + self.change_view(ViewMode::ViewNote); + return KeyAction::None; + } + + if let Some(textarea) = self.note_textarea.as_mut() { + textarea.input(key); + } + KeyAction::None + } + + #[allow(clippy::too_many_arguments)] + fn render( + &mut self, + f: &mut Frame, + area: Rect, + input: &Input, + items: &[ListItem], + status_items: &[ListItem], + priority_items: &[ListItem], + ) { + let layout = Layout::vertical([ + Constraint::Percentage(2), + Constraint::Percentage(96), + Constraint::Percentage(2), + ]); + + let [header_area, main_area, hint_area] = layout.areas(area); + + // Header + f.render_widget( + Paragraph::new(format!("::{}::", env!("CARGO_PKG_NAME"))).centered(), + header_area, + ); + + // Hint and timer readout + self.render_hint(f, hint_area); + + // Main view: the note preview and editor take the whole main area + match self.view_mode { + ViewMode::ViewNote => View::show_note(self, f, main_area), + ViewMode::EditNote => View::show_note_editor(self, f, main_area), + _ => View::show_items(self, items, f, main_area), + } + + // Modal on top of the current view, if any + self.render_modal(f, area, input, status_items, priority_items); + } + + /// The hint line, plus the running timer readout (right aligned). + fn render_hint(&self, f: &mut Frame, hint_area: Rect) { + f.render_widget( + Paragraph::new(Line::from(Span::styled( + " h help", + Style::default().fg(Color::Green), + ))), + hint_area, + ); + + if let Some(timer) = &self.timer { + let secs = match timer.kind { + TimerKind::Stopwatch => timer.elapsed().as_secs(), + TimerKind::Countdown => timer.remaining().unwrap_or_default().as_secs(), + }; + + let icon = match timer.kind { + TimerKind::Stopwatch => { + if timer.is_running() { + "▶" + } else { + "❚❚" + } + } + TimerKind::Countdown => "▼", + }; + + let color = if !timer.is_running() { + Color::Yellow + } else if timer.kind == TimerKind::Countdown && secs <= 10 { + Color::Red + } else { + Color::Green + }; + + // Keep the readout short enough to not overlap the help hint + let label = match &timer.bound { + Some(bound) => { + let title: String = bound.task_title.chars().take(20).collect(); + let ellipsis = if bound.task_title.chars().count() > 20 { + "…" + } else { + "" + }; + format!("{}{}", title, ellipsis) + } + None => "pomodoro".to_string(), + }; + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + format!("{} {} {}", icon, Util::format_secs(secs), label), + Style::default().fg(color), + ))) + .alignment(Alignment::Right), + hint_area, + ); + } + } + + /// Render the modal for the current view mode, if any. + fn render_modal( + &mut self, + f: &mut Frame, + area: Rect, + input: &Input, + status_items: &[ListItem], + priority_items: &[ListItem], + ) { + match self.view_mode { + ViewMode::InfoMigration => View::show_migration_info_modal(f, area), + ViewMode::AddTask | ViewMode::AddProject | ViewMode::AddNote => { + View::show_new_item_modal(f, area, input) + } + ViewMode::RenameTask | ViewMode::RenameProject | ViewMode::RenameNote => { + View::show_rename_item_modal(f, area, input) + } + ViewMode::EditTaskNote => View::show_edit_note_modal(f, area, input), + ViewMode::SetCountdown => View::show_countdown_modal(f, area, input), + ViewMode::SetTaskEstimate => View::show_task_estimate_modal(f, area, input), + ViewMode::TimerTask => View::show_timer_modal(self, f, area), + ViewMode::DeleteTask | ViewMode::DeleteProject | ViewMode::DeleteNote => { + View::show_delete_item_modal(self, f, area) + } + ViewMode::ChangeStatusTask => { + View::show_select_task_status_modal(self, status_items, f, area) + } + ViewMode::ChangePriorityTask => { + View::show_select_task_priority_modal(self, priority_items, f, area) + } + ViewMode::ViewHelp => View::show_help_modal(self, f, area), + ViewMode::ViewTaskDetails => View::show_task_details_modal(self, f, area), + _ => {} + } + } + + fn next(&mut self, items: &[ListItem]) { + if items.is_empty() { + return; + } + + let i = match self.use_state().selected() { + Some(i) => { + if i >= items.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + + self.use_state().select(Some(i)) + } + + fn previous(&mut self, items: &[ListItem]) { + if items.is_empty() { + return; + } + + let i = match self.use_state().selected() { + Some(i) => { + if i == 0 { + items.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + + self.use_state().select(Some(i)) + } + + /// Delete the selected task, move the selection to the previous one, + /// and keep the board focus consistent with the action target + /// (`select_previous` runs after the sync inside `Task::load_items`, + /// so the board must be re-synced afterwards). + fn delete_current_task(&mut self, items: &mut Vec) { + Task::delete(self, items); + self.selected_task_index.select_previous(); + + if self.board_view { + self.board_sync(); + } + } + + /// Number of tasks in a board lane. + fn board_lane_len(&self, lane: usize) -> usize { + Task::lane_indices(self, TASK_STATUSES[lane]).len() + } + + /// Whether the currently focused board lane has no tasks. + fn board_lane_is_empty(&self) -> bool { + self.board_lane_len(self.board_lane) == 0 + } + + /// Derive the focused lane and the per-lane selection from + /// `selected_task_index`. Called at the end of `Task::load_items` + /// while the board is active, so the board follows a task that + /// changed lane (status change), and when the board view is + /// (re)entered. + pub(crate) fn board_sync(&mut self) { + let Some(selected) = self.selected_task_index.selected() else { + return; + }; + let Some(project) = self + .projects + .get(self.selected_project_index.selected().unwrap_or(0)) + else { + return; + }; + let Some(task) = project.tasks.get(selected) else { + return; + }; + // A task with an unknown status (hand-edited JSON) belongs to no + // lane; keep the current focus instead of deriving a stale one. + let Some(lane) = TASK_STATUSES.into_iter().position(|s| s == task.status) else { + return; + }; + + self.board_lane = lane; + + let row = Task::lane_indices(self, TASK_STATUSES[lane]) + .iter() + .position(|&i| i == selected) + .unwrap_or(0); + self.board_lane_states[lane].select(Some(row)); + } + + /// Focus the previous/next board lane (wrapping) and move the task + /// selection to the remembered row of that lane. Switching to an + /// empty lane only moves the focus; task actions are guarded by + /// `board_lane_is_empty`. + fn board_switch_lane(&mut self, forward: bool) { + let lane_count = TASK_STATUSES.len(); + let lane = if forward { + (self.board_lane + 1) % lane_count + } else { + (self.board_lane + lane_count - 1) % lane_count + }; + self.board_lane = lane; + + let indices = Task::lane_indices(self, TASK_STATUSES[lane]); + if indices.is_empty() { + return; + } + + let row = self.board_lane_states[lane] + .selected() + .unwrap_or(0) + .min(indices.len() - 1); + self.board_lane_states[lane].select(Some(row)); + self.selected_task_index.select(Some(indices[row])); + } + + /// Move the selection one row up/down within the focused lane + /// (wrapping), keeping `selected_task_index` pointed at the same task. + fn board_move(&mut self, down: bool) { + let lane = self.board_lane; + let indices = Task::lane_indices(self, TASK_STATUSES[lane]); + if indices.is_empty() { + return; + } + + let row = self.board_lane_states[lane] + .selected() + .unwrap_or(0) + .min(indices.len() - 1); + let row = if down { + if row >= indices.len() - 1 { + 0 + } else { + row + 1 + } + } else if row == 0 { + indices.len() - 1 + } else { + row - 1 + }; + + self.board_lane_states[lane].select(Some(row)); + self.selected_task_index.select(Some(indices[row])); + } + + pub(crate) fn use_state(&mut self) -> &mut ListState { + match self.view_mode { + ViewMode::ViewProjects => &mut self.selected_project_index, + ViewMode::RenameProject => &mut self.selected_project_index, + ViewMode::AddProject => &mut self.selected_project_index, + ViewMode::DeleteProject => &mut self.delete_confirm_index, + + ViewMode::ViewTasks => &mut self.selected_task_index, + ViewMode::RenameTask => &mut self.selected_task_index, + ViewMode::ChangeStatusTask => &mut self.selected_status_task_index, + ViewMode::ChangePriorityTask => &mut self.selected_priority_task_index, + ViewMode::AddTask => &mut self.selected_task_index, + ViewMode::DeleteTask => &mut self.delete_confirm_index, + ViewMode::ViewTaskDetails => &mut self.selected_task_index, + ViewMode::EditTaskNote => &mut self.selected_task_index, + // Timer modals can be opened from either list view; the list + // underneath keeps the selection state of the originating view + ViewMode::TimerTask | ViewMode::SetCountdown => { + if self.previous_view_mode == ViewMode::ViewProjects { + return &mut self.selected_project_index; + } + &mut self.selected_task_index + } + ViewMode::SetTaskEstimate => &mut self.selected_task_index, + + ViewMode::ViewNotes => &mut self.selected_note_index, + ViewMode::AddNote => &mut self.selected_note_index, + ViewMode::RenameNote => &mut self.selected_note_index, + ViewMode::DeleteNote => &mut self.delete_confirm_index, + ViewMode::ViewNote => &mut self.selected_note_index, + ViewMode::EditNote => &mut self.selected_note_index, + + // Help renders the list of the view it was opened from, so it uses + // that view's selection state. Using the project state here used + // to clear `selected_project_index` when the list behind the help + // modal was empty (ratatui resets the state of an empty list), + // which then panicked in `Project::get_current` on the next frame. + ViewMode::ViewHelp => match self.previous_view_mode { + ViewMode::ViewTasks => &mut self.selected_task_index, + ViewMode::ViewNotes | ViewMode::ViewNote | ViewMode::EditNote => { + &mut self.selected_note_index + } + _ => &mut self.selected_project_index, + }, + ViewMode::InfoMigration => &mut self.selected_project_index, + } + } + + fn change_view(&mut self, mode: ViewMode) { + self.view_mode = mode + } + + /// Stop the active timer (if any). A stopwatch accumulates its seconds + /// into the bound task; a pomodoro countdown is simply discarded. + fn settle_timer(&mut self) { + if let Some(timer) = self.timer.take() { + if let Some(bound) = &timer.bound { + Task::add_time_spent( + self, + bound.project_index, + &bound.task_title, + timer.elapsed().as_secs(), + ); + } + } + } + + /// Return from a modal to whichever list view opened it. + fn back_to_previous_view(&mut self) { + let prev = std::mem::replace(&mut self.previous_view_mode, ViewMode::ViewProjects); + self.change_view(prev); + } + + /// Countdown bookkeeping, run on every event-loop iteration: when the + /// pomodoro reaches zero, ring the terminal bell once and leave the + /// timer (and its modal, if open) in the finished state until the + /// user dismisses it with any key. + fn tick_timer(&mut self) { + let hit_zero = matches!( + self.timer.as_ref(), + Some(t) if t.is_finished() && !t.rung + ); + + if !hit_zero { + return; + } + + print!("\x07"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + if let Some(timer) = self.timer.as_mut() { + timer.rung = true; + } + } + + fn handle_modal_nav( + &mut self, + key: KeyCode, + items: &[ListItem], + return_mode: ViewMode, + ) -> bool { + match key { + KeyCode::Esc => { + self.use_state().select(Some(0)); + self.change_view(return_mode); + true + } + KeyCode::Down | KeyCode::Tab | KeyCode::Char('j') => { + self.next(items); + true + } + KeyCode::Up | KeyCode::BackTab | KeyCode::Char('k') => { + self.previous(items); + true + } + _ => false, + } + } +} + +#[cfg(test)] +mod tests; + +/// Performance benchmarks (opt-in, not part of the normal test run). +/// +/// Enable the `bench` feature and run with: +/// cargo test --release --features bench --bin basilk perf_tests -- --ignored --nocapture +/// or: ./scripts/bench.sh +/// +/// Typical numbers on a 2023 M-series Mac (release build): +/// load_items 10k tasks ~2 ms +/// full frame @ 2k tasks ~0.3 ms (budget is 250 ms) +/// render_markdown 312 KB ~3 ms +/// Json write/read 2k tasks <0.5 ms +/// idle CPU (poll loop) 0.0% +#[cfg(all(test, feature = "bench"))] +mod perf_tests; diff --git a/src/app/perf_tests.rs b/src/app/perf_tests.rs new file mode 100644 index 0000000..6586fe4 --- /dev/null +++ b/src/app/perf_tests.rs @@ -0,0 +1,122 @@ +use super::*; +use crate::markdown::render_markdown; +use crate::project::Project; +use crate::task::{Task, TASK_STATUS_UP_NEXT}; +use crate::test_utils::{make_app, make_task, setup_temp_config, ENV_LOCK}; +use ratatui::{backend::TestBackend, Terminal}; +use std::time::Instant; + +fn big_app(tasks_per_project: usize, projects: usize) -> App { + let mut projects_vec = Vec::new(); + for p in 0..projects { + let mut tasks = Vec::new(); + for t in 0..tasks_per_project { + tasks.push(make_task( + &format!("p{p} task {t}"), + TASK_STATUS_UP_NEXT, + (t % 3) as u8, + )); + } + projects_vec.push(Project { + title: format!("project {p}"), + tasks, + }); + } + make_app(projects_vec) +} + +#[test] +#[ignore] +fn probe_scaling() { + // --- Task::load_items (rebuilds the item list + sorts) --- + for (tasks, projects) in [(1000, 1), (10_000, 1)] { + let mut app = big_app(tasks, projects); + let mut items = Vec::new(); + let t = Instant::now(); + Task::load_items(&mut app, &mut items); + println!( + "load_items: {tasks} tasks -> {:?} ({} items)", + t.elapsed(), + items.len() + ); + } + + // --- Project::load_items --- + for projects in [100, 1000] { + let mut app = big_app(5, projects); + let mut items = Vec::new(); + let t = Instant::now(); + Project::load_items(&mut app, &mut items); + println!( + "project_load_items: {projects} projects -> {:?}", + t.elapsed() + ); + } + + // --- Full-frame render, list view --- + let mut app = big_app(2000, 1); + app.view_mode = ViewMode::ViewTasks; + let mut items = Vec::new(); + Task::load_items(&mut app, &mut items); + let backend = TestBackend::new(120, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let input = Input::default(); + let mut status_items = Vec::new(); + Task::load_statuses_items(&mut status_items); + let mut priority_items = Vec::new(); + Task::load_priority_items(&mut priority_items); + + let frames = 120; + let t = Instant::now(); + for _ in 0..frames { + terminal + .draw(|f| app.render(f, f.size(), &input, &items, &status_items, &priority_items)) + .unwrap(); + } + println!( + "render list @2000 tasks: {frames} frames in {:?} ({:?}/frame)", + t.elapsed(), + t.elapsed() / frames + ); + + // --- Board view render --- + app.board_view = true; + app.board_sync(); + let t = Instant::now(); + for _ in 0..frames { + terminal + .draw(|f| app.render(f, f.size(), &input, &items, &status_items, &priority_items)) + .unwrap(); + } + println!( + "render board @2000 tasks: {frames} frames in {:?} ({:?}/frame)", + t.elapsed(), + t.elapsed() / frames + ); + + // --- Markdown rendering (note preview) --- + let md = format!( + "# big doc\n\n{}", + "paragraph with **bold**, *italic* and `code`\n\n- item one\n- item two\n\n> quote\n\n" + .repeat(4000) + ); + let t = Instant::now(); + let text = render_markdown(&md); + println!( + "render_markdown: {} chars -> {:?} ({} lines)", + md.len(), + t.elapsed(), + text.lines.len() + ); + + // --- JSON persistence --- + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let app2 = big_app(2000, 1); + let t = Instant::now(); + Json::write(&app2.projects).unwrap(); + println!("Json::write @2000 tasks: {:?}", t.elapsed()); + let t = Instant::now(); + let _p = Json::read().unwrap(); + println!("Json::read @2000 tasks: {:?}", t.elapsed()); +} diff --git a/src/app/tests.rs b/src/app/tests.rs new file mode 100644 index 0000000..5e8ec2f --- /dev/null +++ b/src/app/tests.rs @@ -0,0 +1,3210 @@ +use super::*; +use crate::test_utils::{make_app, make_task, sample_projects, setup_temp_config, ENV_LOCK}; +use crate::timer::TimerTaskBinding; +use ratatui::crossterm::event::KeyModifiers; +use std::time::Duration; + +// ---------- Shared helpers for the handler / loop / render tests ---------- + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn release_key(code: KeyCode) -> KeyEvent { + KeyEvent::new_with_kind(code, KeyModifiers::NONE, KeyEventKind::Release) +} + +fn input_with(value: &str) -> Input { + Input::default().with_value(value.to_string()) +} + +fn confirm_items() -> Vec> { + View::delete_confirm_items() +} + +fn note(title: &str, body: &str) -> Note { + Note { + title: title.to_string(), + body: body.to_string(), + created_at: None, + updated_at: None, + } +} + +/// A stopped (paused) stopwatch with a fixed accumulated time, so tests +/// do not depend on wall-clock timing. +fn paused_stopwatch(secs: u64, project_index: usize, task_title: &str) -> TimerState { + TimerState { + kind: TimerKind::Stopwatch, + target_secs: 0, + accumulated: Duration::from_secs(secs), + started_at: None, + bound: Some(TimerTaskBinding { + project_index, + task_title: task_title.to_string(), + }), + rung: false, + } +} + +/// A stopped countdown with a fixed accumulated time. +fn paused_countdown(target_secs: u64, accumulated_secs: u64) -> TimerState { + TimerState { + kind: TimerKind::Countdown, + target_secs, + accumulated: Duration::from_secs(accumulated_secs), + started_at: None, + bound: None, + rung: false, + } +} + +#[test] +fn next_on_empty_items_does_not_panic() { + let mut app = make_app(vec![]); + let items: Vec = vec![]; + + app.next(&items); + app.previous(&items); + + assert_eq!(app.selected_project_index.selected(), Some(0)); +} + +#[test] +fn next_wraps_around_at_the_end() { + let mut app = make_app(vec![]); + let items: Vec = vec![ListItem::from("a"), ListItem::from("b")]; + + app.next(&items); + assert_eq!(app.selected_project_index.selected(), Some(1)); + + app.next(&items); + assert_eq!(app.selected_project_index.selected(), Some(0)); +} + +#[test] +fn previous_wraps_around_at_the_start() { + let mut app = make_app(vec![]); + let items: Vec = vec![ListItem::from("a"), ListItem::from("b")]; + + app.previous(&items); + assert_eq!(app.selected_project_index.selected(), Some(1)); +} + +#[test] +fn use_state_maps_view_modes_to_the_right_list_state() { + fn assert_state(app: &mut App, mode: ViewMode, expected: fn(&App) -> *const ListState) { + app.view_mode = mode; + let actual = app.use_state() as *const ListState; + assert_eq!(actual, expected(app)); + } + + let mut app = make_app(vec![]); + + assert_state(&mut app, ViewMode::ViewProjects, |a| { + &a.selected_project_index + }); + assert_state(&mut app, ViewMode::RenameProject, |a| { + &a.selected_project_index + }); + assert_state(&mut app, ViewMode::ViewTasks, |a| &a.selected_task_index); + assert_state(&mut app, ViewMode::RenameTask, |a| &a.selected_task_index); + assert_state(&mut app, ViewMode::ViewTaskDetails, |a| { + &a.selected_task_index + }); + assert_state(&mut app, ViewMode::ChangeStatusTask, |a| { + &a.selected_status_task_index + }); + assert_state(&mut app, ViewMode::ChangePriorityTask, |a| { + &a.selected_priority_task_index + }); + assert_state(&mut app, ViewMode::DeleteProject, |a| { + &a.delete_confirm_index + }); + assert_state(&mut app, ViewMode::DeleteTask, |a| &a.delete_confirm_index); + assert_state(&mut app, ViewMode::ViewNotes, |a| &a.selected_note_index); + assert_state(&mut app, ViewMode::AddNote, |a| &a.selected_note_index); + assert_state(&mut app, ViewMode::RenameNote, |a| &a.selected_note_index); + assert_state(&mut app, ViewMode::ViewNote, |a| &a.selected_note_index); + assert_state(&mut app, ViewMode::EditNote, |a| &a.selected_note_index); + assert_state(&mut app, ViewMode::DeleteNote, |a| &a.delete_confirm_index); +} + +mod board { + use super::*; + use crate::task::{ + TASK_PRIORITY_NONE, TASK_STATUS_DONE, TASK_STATUS_ON_GOING, TASK_STATUS_UP_NEXT, + }; + use crate::test_utils::{make_task, setup_temp_config, ENV_LOCK}; + + fn board_app() -> App { + make_app(vec![Project { + title: "p".to_string(), + tasks: vec![ + make_task("ongoing", TASK_STATUS_ON_GOING, TASK_PRIORITY_NONE), + make_task("upnext", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + make_task("done", TASK_STATUS_DONE, TASK_PRIORITY_NONE), + ], + }]) + } + + #[test] + fn board_sync_focuses_the_lane_of_the_selected_task() { + let mut app = board_app(); + app.selected_task_index.select(Some(0)); // "ongoing" + + app.board_sync(); + + // TASK_STATUSES order: UpNext = 0, OnGoing = 1, Done = 2 + assert_eq!(app.board_lane, 1); + assert_eq!(app.board_lane_states[1].selected(), Some(0)); + } + + #[test] + fn board_sync_follows_a_task_that_changed_lane() { + let mut app = board_app(); + app.board_view = true; + app.selected_task_index.select(Some(0)); + app.projects[0].tasks[0].status = TASK_STATUS_DONE.to_string(); + + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + // The selection follows the task into the Done lane instead of + // jumping to the first still-visible list item + assert_eq!(Task::get_current(&app).title, "ongoing"); + assert_eq!(app.board_lane, 2); + assert_eq!(app.selected_task_index.selected(), Some(1)); + assert_eq!(app.board_lane_states[2].selected(), Some(0)); + } + + #[test] + fn lane_indices_groups_by_status_and_ignores_hide_done() { + let app = board_app(); + assert!(app.hide_done_tasks); + + assert_eq!(Task::lane_indices(&app, TASK_STATUS_UP_NEXT), vec![1]); + assert_eq!(Task::lane_indices(&app, TASK_STATUS_ON_GOING), vec![0]); + assert_eq!(Task::lane_indices(&app, TASK_STATUS_DONE), vec![2]); + } + + #[test] + fn board_switch_lane_wraps_and_moves_the_task_selection() { + let mut app = board_app(); + app.selected_task_index.select(Some(0)); + app.board_sync(); // lane 1 (OnGoing) + + app.board_switch_lane(true); // lane 2 (Done) + assert_eq!(app.board_lane, 2); + assert_eq!(app.selected_task_index.selected(), Some(2)); + + app.board_switch_lane(true); // wraps to lane 0 (UpNext) + assert_eq!(app.board_lane, 0); + assert_eq!(app.selected_task_index.selected(), Some(1)); + + app.board_switch_lane(false); // back to lane 2 (Done) + assert_eq!(app.board_lane, 2); + assert_eq!(app.selected_task_index.selected(), Some(2)); + } + + #[test] + fn board_switch_lane_into_an_empty_lane_keeps_the_task_selection() { + let mut app = board_app(); + app.projects[0] + .tasks + .retain(|t| t.status != TASK_STATUS_ON_GOING); + app.selected_task_index.select(Some(0)); // "upnext" + app.board_sync(); + assert_eq!(app.board_lane, 0); + + app.board_switch_lane(true); // OnGoing lane, now empty + + assert_eq!(app.board_lane, 1); + assert!(app.board_lane_is_empty()); + assert_eq!(app.selected_task_index.selected(), Some(0)); + } + + #[test] + fn board_move_wraps_within_the_lane() { + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![ + make_task("a", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + make_task("b", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + ], + }]); + app.selected_task_index.select(Some(0)); + app.board_sync(); + + app.board_move(true); + assert_eq!(app.selected_task_index.selected(), Some(1)); + + app.board_move(true); // wraps to the top + assert_eq!(app.selected_task_index.selected(), Some(0)); + + app.board_move(false); // wraps to the bottom + assert_eq!(app.selected_task_index.selected(), Some(1)); + } + + /// Regression test: deleting a task in board mode used to leave the + /// board focus on the deleted task's successor while + /// `selected_task_index` (the action target) moved to the + /// predecessor — the two must agree after the delete sequence. + #[test] + fn delete_in_board_mode_keeps_focus_and_action_target_consistent() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![ + make_task("a", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + make_task("b", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + make_task("c", TASK_STATUS_ON_GOING, TASK_PRIORITY_NONE), + ], + }]); + app.board_view = true; + + let mut items = vec![]; + Task::load_items(&mut app, &mut items); // sorted: [c, a, b] + app.selected_task_index.select(Some(1)); // "a" + app.board_sync(); + assert_eq!(Task::get_current(&app).title, "a"); + + // Exercise the same code path as the DeleteTask handler + app.delete_current_task(&mut items); + + assert_eq!(Task::get_current(&app).title, "c"); + let lane = app.board_lane; + let row = app.board_lane_states[lane].selected().unwrap(); + let lane_indices = Task::lane_indices(&app, TASK_STATUSES[lane]); + assert_eq!( + lane_indices.get(row).copied(), + app.selected_task_index.selected(), + "board focus and action target diverged" + ); + } +} + +// ------------------------------------------------------------------------ +// Key handlers +// ------------------------------------------------------------------------ +mod handlers { + use super::*; + use crate::note::Note; + use crate::project::Project; + use crate::task::{ + TASK_PRIORITY_NONE, TASK_STATUS_DONE, TASK_STATUS_ON_GOING, TASK_STATUS_UP_NEXT, + }; + use tui_textarea::TextArea; + + fn tasks_app() -> App { + make_app(vec![Project { + title: "p".to_string(), + tasks: vec![ + make_task("a", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + make_task("b", TASK_STATUS_UP_NEXT, 1), + ], + }]) + } + + // ---- ViewProjects ---- + + #[test] + fn projects_h_opens_help() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + let action = app.handle_view_projects(key(KeyCode::Char('h')), &mut input, &mut items); + + assert_eq!(action, KeyAction::None); + assert_eq!(app.view_mode, ViewMode::ViewHelp); + assert_eq!(app.previous_view_mode, ViewMode::ViewProjects); + } + + #[test] + fn projects_enter_on_an_empty_list_skips() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + let action = app.handle_view_projects(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(action, KeyAction::Skip); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + } + + #[test] + fn projects_enter_opens_tasks_and_loads_them() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Project::load_items(&mut app, &mut items); + + let action = app.handle_view_projects(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(action, KeyAction::None); + assert_eq!(app.view_mode, ViewMode::ViewTasks); + assert_eq!(app.selected_task_index.selected(), Some(0)); + assert_eq!(items.len(), 2); + } + + #[test] + fn projects_enter_in_board_mode_resyncs_the_board() { + let mut app = tasks_app(); + app.board_view = true; + let mut input = Input::default(); + let mut items = vec![]; + Project::load_items(&mut app, &mut items); + + app.handle_view_projects(key(KeyCode::Right), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTasks); + assert_eq!(app.board_lane, 0); // UpNext lane of the selected task + assert_eq!(app.board_lane_states[0].selected(), Some(0)); + } + + #[test] + fn projects_l_opens_tasks_too() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Project::load_items(&mut app, &mut items); + + app.handle_view_projects(key(KeyCode::Char('l')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTasks); + } + + #[test] + fn projects_r_starts_renaming_the_selected_project() { + let mut app = make_app(sample_projects()); + let mut input = Input::default(); + let mut items = vec![]; + Project::load_items(&mut app, &mut items); + + app.handle_view_projects(key(KeyCode::Char('r')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::RenameProject); + assert_eq!(input.value(), "alpha"); + } + + #[test] + fn projects_r_on_an_empty_list_skips() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + assert_eq!( + app.handle_view_projects(key(KeyCode::Char('r')), &mut input, &mut items), + KeyAction::Skip + ); + } + + #[test] + fn projects_n_starts_adding_a_project() { + let mut app = make_app(vec![]); + let mut input = input_with("stale"); + let mut items = vec![]; + + app.handle_view_projects(key(KeyCode::Char('n')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::AddProject); + assert_eq!(input.value(), ""); + } + + #[test] + fn projects_d_opens_the_delete_modal() { + let mut app = make_app(sample_projects()); + let mut input = Input::default(); + let mut items = vec![]; + Project::load_items(&mut app, &mut items); + + app.handle_view_projects(key(KeyCode::Char('d')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::DeleteProject); + } + + #[test] + fn projects_d_on_an_empty_list_skips() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + assert_eq!( + app.handle_view_projects(key(KeyCode::Char('d')), &mut input, &mut items), + KeyAction::Skip + ); + } + + #[test] + fn projects_navigation_keys_move_the_selection() { + let mut app = make_app(sample_projects()); + let mut input = Input::default(); + let mut items = vec![]; + Project::load_items(&mut app, &mut items); + + app.handle_view_projects(key(KeyCode::Down), &mut input, &mut items); + assert_eq!(app.selected_project_index.selected(), Some(1)); + + app.handle_view_projects(key(KeyCode::Char('j')), &mut input, &mut items); + assert_eq!(app.selected_project_index.selected(), Some(0)); // wraps + + app.handle_view_projects(key(KeyCode::Up), &mut input, &mut items); + assert_eq!(app.selected_project_index.selected(), Some(1)); // wraps + + app.handle_view_projects(key(KeyCode::BackTab), &mut input, &mut items); + app.handle_view_projects(key(KeyCode::Char('k')), &mut input, &mut items); + assert_eq!(app.selected_project_index.selected(), Some(1)); + } + + #[test] + fn projects_c_starts_a_countdown_when_no_timer_runs() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + app.handle_view_projects(key(KeyCode::Char('c')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::SetCountdown); + assert_eq!(app.previous_view_mode, ViewMode::ViewProjects); + assert!(app.timer.is_none()); + } + + #[test] + fn projects_c_opens_the_timer_when_one_runs() { + let mut app = make_app(vec![]); + app.timer = Some(paused_stopwatch(10, 0, "t")); + let mut input = Input::default(); + let mut items = vec![]; + + app.handle_view_projects(key(KeyCode::Char('c')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::TimerTask); + assert_eq!(app.previous_view_mode, ViewMode::ViewProjects); + } + + #[test] + fn projects_m_opens_the_notes_view() { + let mut app = make_app(vec![]); + app.notes = vec![note("n", "body")]; + let mut input = Input::default(); + let mut items = vec![]; + + app.handle_view_projects(key(KeyCode::Char('m')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewNotes); + assert_eq!(items.len(), 1); + } + + #[test] + fn projects_q_quits() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + assert_eq!( + app.handle_view_projects(key(KeyCode::Char('q')), &mut input, &mut items), + KeyAction::Quit + ); + } + + #[test] + fn projects_unknown_keys_do_nothing() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + let action = app.handle_view_projects(key(KeyCode::Char('z')), &mut input, &mut items); + + assert_eq!(action, KeyAction::None); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + } + + // ---- RenameProject / AddProject / DeleteProject ---- + + #[test] + fn rename_project_enter_renames_and_returns() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(sample_projects()); + let mut input = input_with("renamed"); + let mut items = vec![]; + + app.handle_rename_project(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewProjects); + assert_eq!(app.projects[0].title, "renamed"); + assert_eq!(input.value(), ""); + } + + #[test] + fn rename_project_esc_cancels() { + let mut app = make_app(sample_projects()); + let mut input = input_with("discard me"); + let mut items = vec![]; + + app.handle_rename_project(key(KeyCode::Esc), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewProjects); + assert_eq!(input.value(), ""); + assert_eq!(app.projects[0].title, "alpha"); + } + + #[test] + fn rename_project_other_keys_edit_the_input() { + let mut app = make_app(sample_projects()); + app.view_mode = ViewMode::RenameProject; + let mut input = input_with("ab"); + let mut items = vec![]; + + app.handle_rename_project(key(KeyCode::Char('c')), &mut input, &mut items); + + assert_eq!(input.value(), "abc"); + assert_eq!(app.view_mode, ViewMode::RenameProject); + } + + #[test] + fn add_project_esc_cancels() { + let mut app = make_app(vec![]); + let mut input = input_with("todo"); + let mut items = vec![]; + + app.handle_add_project(key(KeyCode::Esc), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewProjects); + assert!(app.projects.is_empty()); + } + + #[test] + fn add_project_empty_enter_just_returns() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + app.handle_add_project(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewProjects); + assert!(app.projects.is_empty()); + } + + #[test] + fn add_project_enter_creates_and_selects_the_new_project() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![]); + let mut input = input_with("todo"); + let mut items = vec![]; + + app.handle_add_project(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewProjects); + assert_eq!(app.projects.len(), 1); + assert_eq!(app.projects[0].title, "todo"); + assert_eq!(app.selected_project_index.selected(), Some(0)); + } + + #[test] + fn add_project_other_keys_edit_the_input() { + let mut app = make_app(vec![]); + app.view_mode = ViewMode::AddProject; + let mut input = Input::default(); + let mut items = vec![]; + + app.handle_add_project(key(KeyCode::Char('x')), &mut input, &mut items); + + assert_eq!(input.value(), "x"); + assert_eq!(app.view_mode, ViewMode::AddProject); + } + + #[test] + fn delete_project_esc_returns_and_resets_the_selection() { + let mut app = make_app(sample_projects()); + let mut items = vec![]; + + let action = app.handle_delete_project(key(KeyCode::Esc), &mut items); + + assert_eq!(action, KeyAction::Skip); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + assert_eq!(app.delete_confirm_index.selected(), Some(0)); + } + + #[test] + fn delete_project_navigation_moves_the_confirm_selection() { + let mut app = make_app(sample_projects()); + app.view_mode = ViewMode::DeleteProject; + let mut items = vec![]; + + app.handle_delete_project(key(KeyCode::Down), &mut items); + assert_eq!(app.delete_confirm_index.selected(), Some(1)); + + app.handle_delete_project(key(KeyCode::Char('k')), &mut items); + assert_eq!(app.delete_confirm_index.selected(), Some(0)); + + app.handle_delete_project(key(KeyCode::Char('j')), &mut items); + assert_eq!(app.delete_confirm_index.selected(), Some(1)); + } + + #[test] + fn delete_project_confirm_deletes_and_returns() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(sample_projects()); + let mut items = vec![]; + Project::load_items(&mut app, &mut items); + + app.handle_delete_project(key(KeyCode::Enter), &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewProjects); + assert_eq!(app.projects.len(), 1); + assert_eq!(app.projects[0].title, "beta"); + assert_eq!(app.delete_confirm_index.selected(), Some(0)); + } + + #[test] + fn delete_project_cancel_keeps_the_project() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(sample_projects()); + app.delete_confirm_index.select(Some(1)); + let mut items = vec![]; + + app.handle_delete_project(key(KeyCode::Enter), &mut items); + + assert_eq!(app.projects.len(), 2); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + } + + #[test] + fn delete_project_drops_a_timer_bound_to_it() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(sample_projects()); // alpha=0, beta=1 + app.selected_project_index.select(Some(1)); + app.timer = Some(paused_stopwatch(10, 1, "t")); + let mut items = vec![]; + + app.handle_delete_project(key(KeyCode::Enter), &mut items); + + assert!(app.timer.is_none()); + } + + #[test] + fn delete_project_reindexes_a_timer_bound_to_a_later_project() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![ + Project { + title: "a".to_string(), + tasks: vec![], + }, + Project { + title: "b".to_string(), + tasks: vec![], + }, + Project { + title: "c".to_string(), + tasks: vec![], + }, + ]); + app.selected_project_index.select(Some(1)); // delete "b" + app.timer = Some(paused_stopwatch(10, 2, "t")); // bound to "c" + let mut items = vec![]; + + app.handle_delete_project(key(KeyCode::Enter), &mut items); + + let bound = app.timer.as_ref().unwrap().bound.as_ref().unwrap(); + assert_eq!(bound.project_index, 1); + } + + // ---- ViewTasks ---- + + #[test] + fn tasks_h_opens_help() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + app.handle_view_tasks(key(KeyCode::Char('h')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewHelp); + assert_eq!(app.previous_view_mode, ViewMode::ViewTasks); + } + + #[test] + fn tasks_esc_returns_to_projects() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + app.handle_view_tasks(key(KeyCode::Esc), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewProjects); + assert_eq!(items.len(), 1); // project items reloaded + } + + #[test] + fn tasks_left_returns_in_list_mode_but_switches_lane_in_board_mode() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + app.handle_view_tasks(key(KeyCode::Left), &mut input, &mut items); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + + // Board mode: Left moves the lane focus instead of going back + let mut app = tasks_app(); + app.board_view = true; + app.view_mode = ViewMode::ViewTasks; + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + app.selected_task_index.select(Some(0)); + app.board_sync(); + assert_eq!(app.board_lane, 0); + + app.handle_view_tasks(key(KeyCode::Left), &mut input, &mut items); + assert_eq!(app.view_mode, ViewMode::ViewTasks); + assert_eq!(app.board_lane, 2); // wrapped backwards to Done + } + + #[test] + fn tasks_right_switches_lane_in_board_mode() { + let mut app = tasks_app(); + app.board_view = true; + app.view_mode = ViewMode::ViewTasks; + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + app.selected_task_index.select(Some(0)); + app.board_sync(); + + app.handle_view_tasks(key(KeyCode::Right), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTasks); + assert_eq!(app.board_lane, 1); + } + + #[test] + fn tasks_b_toggles_the_board_and_rebuilds_the_items() { + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![ + make_task("a", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + make_task("done", TASK_STATUS_DONE, TASK_PRIORITY_NONE), + ], + }]); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + assert_eq!(items.len(), 1); // done tasks are hidden in the list + + app.handle_view_tasks(key(KeyCode::Char('b')), &mut input, &mut items); + assert!(app.board_view); + assert_eq!(items.len(), 2); // the board always shows the Done lane + + app.handle_view_tasks(key(KeyCode::Char('b')), &mut input, &mut items); + assert!(!app.board_view); + } + + #[test] + fn tasks_enter_opens_the_status_modal() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + assert_eq!(Task::get_current(&app).status, TASK_STATUS_UP_NEXT); + + app.handle_view_tasks(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ChangeStatusTask); + assert_eq!(app.selected_status_task_index.selected(), Some(0)); + } + + #[test] + fn tasks_enter_on_an_empty_list_skips() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + let action = app.handle_view_tasks(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(action, KeyAction::Skip); + } + + #[test] + fn tasks_actions_on_an_empty_board_lane_skip() { + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![make_task("a", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE)], + }]); + app.board_view = true; + app.view_mode = ViewMode::ViewTasks; + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + app.board_lane = 2; // the Done lane is empty + + for code in [ + KeyCode::Enter, + KeyCode::Char('p'), + KeyCode::Char('r'), + KeyCode::Char('d'), + KeyCode::Char('v'), + KeyCode::Char('e'), + ] { + assert_eq!( + app.handle_view_tasks(key(code), &mut input, &mut items), + KeyAction::Skip + ); + } + + // 's' is also a no-op without a current task, but stays in the view + app.handle_view_tasks(key(KeyCode::Char('s')), &mut input, &mut items); + assert!(app.timer.is_none()); + assert_eq!(app.view_mode, ViewMode::ViewTasks); + } + + #[test] + fn tasks_p_opens_the_priority_modal() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + app.handle_view_tasks(key(KeyCode::Char('p')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ChangePriorityTask); + let task = Task::get_current(&app); + let index = TASK_PRIORITIES + .into_iter() + .position(|p| p == task.priority) + .unwrap(); + assert_eq!(app.selected_priority_task_index.selected(), Some(index)); + } + + #[test] + fn tasks_r_prefills_the_rename_input() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let title = Task::get_current(&app).title.clone(); + + app.handle_view_tasks(key(KeyCode::Char('r')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::RenameTask); + assert_eq!(input.value(), title); + } + + #[test] + fn tasks_n_starts_adding_a_task() { + let mut app = tasks_app(); + let mut input = input_with("stale"); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + app.handle_view_tasks(key(KeyCode::Char('n')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::AddTask); + assert_eq!(input.value(), ""); + } + + #[test] + fn tasks_d_opens_the_delete_modal() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + app.handle_view_tasks(key(KeyCode::Char('d')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::DeleteTask); + } + + #[test] + fn tasks_v_opens_the_details_view() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + app.handle_view_tasks(key(KeyCode::Char('v')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTaskDetails); + } + + #[test] + fn tasks_e_prefills_the_note_editor() { + let mut app = tasks_app(); + app.projects[0].tasks[0].note = "my note".to_string(); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + app.handle_view_tasks(key(KeyCode::Char('e')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::EditTaskNote); + assert_eq!(input.value(), "my note"); + } + + #[test] + fn tasks_board_jk_moves_within_the_lane() { + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![ + make_task("x", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + make_task("y", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + ], + }]); + app.board_view = true; + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + app.selected_task_index.select(Some(0)); + app.board_sync(); + + app.handle_view_tasks(key(KeyCode::Char('j')), &mut input, &mut items); + assert_eq!(app.selected_task_index.selected(), Some(1)); + + app.handle_view_tasks(key(KeyCode::Char('k')), &mut input, &mut items); + assert_eq!(app.selected_task_index.selected(), Some(0)); + } + + #[test] + fn tasks_jk_navigate_the_list_in_list_mode() { + let mut app = tasks_app(); + app.view_mode = ViewMode::ViewTasks; + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + app.selected_task_index.select(Some(0)); + + app.handle_view_tasks(key(KeyCode::Char('j')), &mut input, &mut items); + assert_eq!(app.selected_task_index.selected(), Some(1)); + + app.handle_view_tasks(key(KeyCode::Char('k')), &mut input, &mut items); + assert_eq!(app.selected_task_index.selected(), Some(0)); + } + + #[test] + fn tasks_t_toggles_done_visibility() { + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![ + make_task("a", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + make_task("done", TASK_STATUS_DONE, TASK_PRIORITY_NONE), + ], + }]); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + assert_eq!(items.len(), 1); + + app.handle_view_tasks(key(KeyCode::Char('t')), &mut input, &mut items); + + assert!(!app.hide_done_tasks); + assert_eq!(items.len(), 2); + } + + #[test] + fn tasks_t_is_a_no_op_in_board_mode() { + let mut app = tasks_app(); + app.board_view = true; + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + app.handle_view_tasks(key(KeyCode::Char('t')), &mut input, &mut items); + + assert!(app.hide_done_tasks); + } + + #[test] + fn tasks_s_starts_a_stopwatch_for_the_selected_task() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let title = Task::get_current(&app).title.clone(); + + app.handle_view_tasks(key(KeyCode::Char('s')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::TimerTask); + assert_eq!(app.previous_view_mode, ViewMode::ViewTasks); + let timer = app.timer.as_ref().unwrap(); + assert_eq!(timer.kind, TimerKind::Stopwatch); + assert_eq!(timer.bound.as_ref().unwrap().project_index, 0); + assert_eq!(timer.bound.as_ref().unwrap().task_title, title); + } + + #[test] + fn tasks_s_opens_the_timer_when_one_runs() { + let mut app = tasks_app(); + app.timer = Some(paused_stopwatch(10, 0, "a")); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + app.handle_view_tasks(key(KeyCode::Char('s')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::TimerTask); + assert_eq!(app.previous_view_mode, ViewMode::ViewTasks); + } + + #[test] + fn tasks_c_opens_the_countdown_or_timer() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + app.handle_view_tasks(key(KeyCode::Char('c')), &mut input, &mut items); + assert_eq!(app.view_mode, ViewMode::SetCountdown); + + app.timer = Some(paused_stopwatch(10, 0, "a")); + app.handle_view_tasks(key(KeyCode::Char('c')), &mut input, &mut items); + assert_eq!(app.view_mode, ViewMode::TimerTask); + } + + #[test] + fn tasks_q_quits() { + let mut app = tasks_app(); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + assert_eq!( + app.handle_view_tasks(key(KeyCode::Char('q')), &mut input, &mut items), + KeyAction::Quit + ); + } + + #[test] + fn tasks_unknown_keys_do_nothing() { + let mut app = tasks_app(); + app.view_mode = ViewMode::ViewTasks; + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + + let action = app.handle_view_tasks(key(KeyCode::Char('z')), &mut input, &mut items); + + assert_eq!(action, KeyAction::None); + assert_eq!(app.view_mode, ViewMode::ViewTasks); + } + + // ---- RenameTask / status / priority / AddTask / DeleteTask ---- + + #[test] + fn rename_task_enter_renames_and_retargets_the_timer() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let old_title = Task::get_current(&app).title.clone(); + app.timer = Some(paused_stopwatch(10, 0, &old_title)); + let mut input = input_with("renamed"); + + app.handle_rename_task(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTasks); + assert_eq!(Task::get_current(&app).title, "renamed"); + assert_eq!( + app.timer + .as_ref() + .unwrap() + .bound + .as_ref() + .unwrap() + .task_title, + "renamed" + ); + assert_eq!(input.value(), ""); + } + + #[test] + fn rename_task_esc_cancels() { + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let mut input = input_with("discard"); + + app.handle_rename_task(key(KeyCode::Esc), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTasks); + assert_eq!(input.value(), ""); + } + + #[test] + fn rename_task_other_keys_edit_the_input() { + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let mut input = input_with("ab"); + + app.handle_rename_task(key(KeyCode::Char('c')), &mut input, &mut items); + + assert_eq!(input.value(), "abc"); + } + + #[test] + fn change_status_enter_applies_the_selected_status() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + app.selected_status_task_index.select(Some(1)); // OnGoing + + app.handle_change_status_task(key(KeyCode::Enter), &mut items, &[]); + + assert_eq!(app.view_mode, ViewMode::ViewTasks); + assert_eq!(Task::get_current(&app).status, TASK_STATUS_ON_GOING); + assert_eq!(app.selected_status_task_index.selected(), Some(0)); + } + + #[test] + fn change_status_navigation_moves_the_selection() { + let mut app = tasks_app(); + app.view_mode = ViewMode::ChangeStatusTask; + let mut items = vec![]; + let mut status_items = vec![]; + Task::load_statuses_items(&mut status_items); + + let action = app.handle_change_status_task(key(KeyCode::Down), &mut items, &status_items); + + assert_eq!(action, KeyAction::Skip); + assert_eq!(app.selected_status_task_index.selected(), Some(1)); + } + + #[test] + fn change_priority_enter_applies_the_selected_priority() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + app.selected_priority_task_index.select(Some(2)); // priority 3 + + app.handle_change_priority_task(key(KeyCode::Enter), &mut items, &[]); + + assert_eq!(app.view_mode, ViewMode::ViewTasks); + assert_eq!(Task::get_current(&app).priority, 3); + assert_eq!(app.selected_priority_task_index.selected(), Some(0)); + } + + #[test] + fn change_priority_navigation_moves_the_selection() { + let mut app = tasks_app(); + app.view_mode = ViewMode::ChangePriorityTask; + let mut items = vec![]; + let mut priority_items = vec![]; + Task::load_priority_items(&mut priority_items); + + let action = + app.handle_change_priority_task(key(KeyCode::Char('j')), &mut items, &priority_items); + + assert_eq!(action, KeyAction::Skip); + assert_eq!(app.selected_priority_task_index.selected(), Some(1)); + } + + #[test] + fn add_task_enter_creates_the_task() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let count = app.projects[0].tasks.len(); + let mut input = input_with("new task"); + + app.handle_add_task(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTasks); + assert_eq!(app.projects[0].tasks.len(), count + 1); + assert_eq!(items.len(), count + 1); + } + + #[test] + fn add_task_enter_with_a_duplicate_title_stays_in_the_view() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + app.view_mode = ViewMode::AddTask; + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let count = app.projects[0].tasks.len(); + let mut input = input_with("a"); // already exists + + app.handle_add_task(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::AddTask); + assert_eq!(app.projects[0].tasks.len(), count); + assert_eq!(input.value(), "a"); // input kept for another try + } + + #[test] + fn add_task_enter_with_an_empty_title_stays_in_the_view() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + app.view_mode = ViewMode::AddTask; + let mut items = vec![]; + let count = app.projects[0].tasks.len(); + let mut input = Input::default(); + + app.handle_add_task(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::AddTask); + assert_eq!(app.projects[0].tasks.len(), count); + } + + #[test] + fn rename_task_enter_with_a_duplicate_title_stays_in_the_view() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); // selected task is "a", "b" exists + app.view_mode = ViewMode::RenameTask; + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let mut input = input_with("b"); + + app.handle_rename_task(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::RenameTask); + assert_eq!(Task::get_current(&app).title, "a"); + assert_eq!(input.value(), "b"); // input kept for another try + } + + #[test] + fn add_task_esc_cancels() { + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let count = app.projects[0].tasks.len(); + let mut input = input_with("new task"); + + app.handle_add_task(key(KeyCode::Esc), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTasks); + assert_eq!(app.projects[0].tasks.len(), count); + } + + #[test] + fn add_task_other_keys_edit_the_input() { + let mut app = tasks_app(); + let mut items = vec![]; + let mut input = Input::default(); + + app.handle_add_task(key(KeyCode::Char('x')), &mut input, &mut items); + + assert_eq!(input.value(), "x"); + } + + #[test] + fn delete_task_confirm_removes_the_task() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let count = app.projects[0].tasks.len(); + + app.handle_delete_task(key(KeyCode::Enter), &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTasks); + assert_eq!(app.projects[0].tasks.len(), count - 1); + } + + #[test] + fn delete_task_cancel_keeps_the_task() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let count = app.projects[0].tasks.len(); + app.delete_confirm_index.select(Some(1)); + + app.handle_delete_task(key(KeyCode::Enter), &mut items); + + assert_eq!(app.projects[0].tasks.len(), count); + assert_eq!(app.view_mode, ViewMode::ViewTasks); + } + + #[test] + fn delete_task_drops_a_bound_timer() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let title = Task::get_current(&app).title.clone(); + app.timer = Some(paused_stopwatch(10, 0, &title)); + + app.handle_delete_task(key(KeyCode::Enter), &mut items); + + assert!(app.timer.is_none()); + } + + #[test] + fn delete_task_esc_returns() { + let mut app = tasks_app(); + let mut items = vec![]; + + let action = app.handle_delete_task(key(KeyCode::Esc), &mut items); + + assert_eq!(action, KeyAction::Skip); + assert_eq!(app.view_mode, ViewMode::ViewTasks); + } + + // ---- ViewTaskDetails / EditTaskNote / SetTaskEstimate ---- + + #[test] + fn task_details_e_edits_the_note() { + let mut app = tasks_app(); + app.projects[0].tasks[0].note = "my note".to_string(); + let mut input = Input::default(); + + app.handle_view_task_details(key(KeyCode::Char('e')), &mut input); + + assert_eq!(app.view_mode, ViewMode::EditTaskNote); + assert_eq!(input.value(), "my note"); + } + + #[test] + fn task_details_g_edits_the_estimate() { + let mut app = tasks_app(); + app.projects[0].tasks[0].estimated_hours = 7; + let mut input = Input::default(); + + app.handle_view_task_details(key(KeyCode::Char('g')), &mut input); + + assert_eq!(app.view_mode, ViewMode::SetTaskEstimate); + assert_eq!(input.value(), "7"); + } + + #[test] + fn task_details_g_without_an_estimate_starts_empty() { + let mut app = tasks_app(); + let mut input = Input::default(); + + app.handle_view_task_details(key(KeyCode::Char('g')), &mut input); + + assert_eq!(app.view_mode, ViewMode::SetTaskEstimate); + assert_eq!(input.value(), ""); + } + + #[test] + fn task_details_any_other_key_closes() { + let mut app = tasks_app(); + let mut input = Input::default(); + + app.handle_view_task_details(key(KeyCode::Esc), &mut input); + + assert_eq!(app.view_mode, ViewMode::ViewTasks); + } + + #[test] + fn edit_task_note_enter_saves() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let mut input = input_with("the note"); + + app.handle_edit_task_note(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTaskDetails); + assert_eq!(Task::get_current(&app).note, "the note"); + assert_eq!(input.value(), ""); + } + + #[test] + fn edit_task_note_esc_cancels() { + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let mut input = input_with("discard"); + + app.handle_edit_task_note(key(KeyCode::Esc), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTaskDetails); + assert_eq!(input.value(), ""); + assert_eq!(Task::get_current(&app).note, ""); + } + + #[test] + fn edit_task_note_other_keys_edit_the_input() { + let mut app = tasks_app(); + let mut items = vec![]; + let mut input = input_with("ab"); + + app.handle_edit_task_note(key(KeyCode::Char('c')), &mut input, &mut items); + + assert_eq!(input.value(), "abc"); + } + + #[test] + fn set_estimate_enter_applies_the_value() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let mut input = input_with(" 5 "); + + app.handle_set_task_estimate(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTaskDetails); + assert_eq!(Task::get_current(&app).estimated_hours, 5); + } + + #[test] + fn set_estimate_empty_enter_returns_without_changes() { + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let mut input = Input::default(); + + app.handle_set_task_estimate(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTaskDetails); + assert_eq!(Task::get_current(&app).estimated_hours, 0); + } + + #[test] + fn set_estimate_invalid_enter_stays_in_the_modal() { + let mut app = tasks_app(); + app.view_mode = ViewMode::SetTaskEstimate; + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let mut input = input_with("abc"); + + app.handle_set_task_estimate(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::SetTaskEstimate); + assert_eq!(Task::get_current(&app).estimated_hours, 0); + } + + #[test] + fn set_estimate_esc_returns() { + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let mut input = input_with("5"); + + app.handle_set_task_estimate(key(KeyCode::Esc), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewTaskDetails); + assert_eq!(input.value(), ""); + } + + #[test] + fn set_estimate_filters_non_digit_keys() { + let mut app = tasks_app(); + let mut items = vec![]; + let mut input = Input::default(); + + app.handle_set_task_estimate(key(KeyCode::Char('a')), &mut input, &mut items); + assert_eq!(input.value(), ""); + + app.handle_set_task_estimate(key(KeyCode::Char('7')), &mut input, &mut items); + assert_eq!(input.value(), "7"); + + app.handle_set_task_estimate(key(KeyCode::Backspace), &mut input, &mut items); + assert_eq!(input.value(), ""); + } + + // ---- TimerTask / SetCountdown ---- + + #[test] + fn timer_task_space_pauses_and_resumes() { + let mut app = make_app(vec![]); + app.timer = Some(TimerState::new_stopwatch(0, "t".to_string())); + + app.handle_timer_task(key(KeyCode::Char(' '))); + assert!(!app.timer.as_ref().unwrap().is_running()); + + app.handle_timer_task(key(KeyCode::Char(' '))); + assert!(app.timer.as_ref().unwrap().is_running()); + } + + #[test] + fn timer_task_enter_settles_and_returns() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let title = Task::get_current(&app).title.clone(); + app.timer = Some(paused_stopwatch(90, 0, &title)); + app.previous_view_mode = ViewMode::ViewTasks; + + app.handle_timer_task(key(KeyCode::Enter)); + + assert!(app.timer.is_none()); + assert_eq!(app.view_mode, ViewMode::ViewTasks); + assert_eq!(Task::get_current(&app).time_spent_secs, 90); + } + + #[test] + fn timer_task_esc_returns_without_settling() { + let mut app = make_app(vec![]); + app.timer = Some(paused_stopwatch(90, 0, "t")); + app.previous_view_mode = ViewMode::ViewProjects; + + app.handle_timer_task(key(KeyCode::Esc)); + + assert!(app.timer.is_some()); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + } + + #[test] + fn timer_task_finished_countdown_dismisses_on_any_key() { + let mut app = make_app(vec![]); + app.timer = Some(paused_countdown(60, 60)); // finished + app.previous_view_mode = ViewMode::ViewTasks; + + let action = app.handle_timer_task(key(KeyCode::Char('x'))); + + assert_eq!(action, KeyAction::Skip); + assert!(app.timer.is_none()); + assert_eq!(app.view_mode, ViewMode::ViewTasks); + } + + #[test] + fn set_countdown_enter_starts_a_countdown() { + let mut app = make_app(vec![]); + app.previous_view_mode = ViewMode::ViewProjects; + let mut input = input_with("2.5"); + + app.handle_set_countdown(key(KeyCode::Enter), &mut input); + + assert_eq!(app.view_mode, ViewMode::TimerTask); + let timer = app.timer.as_ref().unwrap(); + assert_eq!(timer.kind, TimerKind::Countdown); + assert_eq!(timer.target_secs, 150); + assert_eq!(input.value(), ""); + } + + #[test] + fn set_countdown_empty_enter_returns_to_the_previous_view() { + let mut app = make_app(vec![]); + app.previous_view_mode = ViewMode::ViewProjects; + let mut input = Input::default(); + + app.handle_set_countdown(key(KeyCode::Enter), &mut input); + + assert_eq!(app.view_mode, ViewMode::ViewProjects); + assert!(app.timer.is_none()); + } + + #[test] + fn set_countdown_invalid_enter_stays_in_the_modal() { + let mut app = make_app(vec![]); + app.view_mode = ViewMode::SetCountdown; + let mut input = input_with("abc"); + + app.handle_set_countdown(key(KeyCode::Enter), &mut input); + + assert_eq!(app.view_mode, ViewMode::SetCountdown); + assert!(app.timer.is_none()); + } + + #[test] + fn set_countdown_esc_returns() { + let mut app = make_app(vec![]); + app.previous_view_mode = ViewMode::ViewNotes; + let mut input = input_with("25"); + + app.handle_set_countdown(key(KeyCode::Esc), &mut input); + + assert_eq!(app.view_mode, ViewMode::ViewNotes); + assert_eq!(input.value(), ""); + } + + #[test] + fn set_countdown_filters_input() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + + app.handle_set_countdown(key(KeyCode::Char('a')), &mut input); + assert_eq!(input.value(), ""); + + app.handle_set_countdown(key(KeyCode::Char('.')), &mut input); + assert_eq!(input.value(), "."); + + app.handle_set_countdown(key(KeyCode::Char('9')), &mut input); + assert_eq!(input.value(), ".9"); + } + + // ---- ViewNotes / AddNote / RenameNote / DeleteNote ---- + + #[test] + fn notes_h_opens_help() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + app.handle_view_notes(key(KeyCode::Char('h')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewHelp); + assert_eq!(app.previous_view_mode, ViewMode::ViewNotes); + } + + #[test] + fn notes_esc_returns_to_projects() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + app.handle_view_notes(key(KeyCode::Esc), &mut input, &mut items); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + + app.handle_view_notes(key(KeyCode::Left), &mut input, &mut items); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + } + + #[test] + fn notes_enter_on_an_empty_list_skips() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + let action = app.handle_view_notes(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(action, KeyAction::Skip); + } + + #[test] + fn notes_enter_opens_the_preview_and_resets_the_scroll() { + let mut app = make_app(vec![]); + app.notes = vec![note("n", "body")]; + let mut input = Input::default(); + let mut items = vec![]; + Note::load_items(&mut app, &mut items); + app.note_scroll = 5; + + app.handle_view_notes(key(KeyCode::Char('v')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewNote); + assert_eq!(app.note_scroll, 0); + } + + #[test] + fn notes_n_starts_adding_a_note() { + let mut app = make_app(vec![]); + let mut input = input_with("stale"); + let mut items = vec![]; + + app.handle_view_notes(key(KeyCode::Char('n')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::AddNote); + assert_eq!(input.value(), ""); + } + + #[test] + fn notes_r_prefills_the_rename_input() { + let mut app = make_app(vec![]); + app.notes = vec![note("my note", "")]; + let mut input = Input::default(); + let mut items = vec![]; + Note::load_items(&mut app, &mut items); + + app.handle_view_notes(key(KeyCode::Char('r')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::RenameNote); + assert_eq!(input.value(), "my note"); + } + + #[test] + fn notes_r_on_an_empty_list_skips() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + assert_eq!( + app.handle_view_notes(key(KeyCode::Char('r')), &mut input, &mut items), + KeyAction::Skip + ); + } + + #[test] + fn notes_d_opens_the_delete_modal() { + let mut app = make_app(vec![]); + app.notes = vec![note("n", "")]; + let mut input = Input::default(); + let mut items = vec![]; + Note::load_items(&mut app, &mut items); + + app.handle_view_notes(key(KeyCode::Char('d')), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::DeleteNote); + } + + #[test] + fn notes_d_on_an_empty_list_skips() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + assert_eq!( + app.handle_view_notes(key(KeyCode::Char('d')), &mut input, &mut items), + KeyAction::Skip + ); + } + + #[test] + fn notes_unknown_keys_do_nothing() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + let action = app.handle_view_notes(key(KeyCode::Char('z')), &mut input, &mut items); + + assert_eq!(action, KeyAction::None); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + } + + #[test] + fn notes_navigation_moves_the_selection() { + let mut app = make_app(vec![]); + app.view_mode = ViewMode::ViewNotes; + app.notes = vec![note("n1", ""), note("n2", "")]; + let mut input = Input::default(); + let mut items = vec![]; + Note::load_items(&mut app, &mut items); + + app.handle_view_notes(key(KeyCode::Down), &mut input, &mut items); + assert_eq!(app.selected_note_index.selected(), Some(1)); + + app.handle_view_notes(key(KeyCode::Char('k')), &mut input, &mut items); + assert_eq!(app.selected_note_index.selected(), Some(0)); + } + + #[test] + fn notes_q_quits() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + assert_eq!( + app.handle_view_notes(key(KeyCode::Char('q')), &mut input, &mut items), + KeyAction::Quit + ); + } + + #[test] + fn add_note_enter_creates_and_selects_the_new_note() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![]); + let mut input = input_with("shopping"); + let mut items = vec![]; + + app.handle_add_note(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewNotes); + assert_eq!(app.notes.len(), 1); + assert_eq!(app.notes[0].title, "shopping"); + assert_eq!(app.selected_note_index.selected(), Some(0)); + } + + #[test] + fn add_note_empty_enter_just_returns() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + app.handle_add_note(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewNotes); + assert!(app.notes.is_empty()); + } + + #[test] + fn add_note_esc_cancels() { + let mut app = make_app(vec![]); + let mut input = input_with("shopping"); + let mut items = vec![]; + + app.handle_add_note(key(KeyCode::Esc), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewNotes); + assert!(app.notes.is_empty()); + } + + #[test] + fn add_note_other_keys_edit_the_input() { + let mut app = make_app(vec![]); + let mut input = Input::default(); + let mut items = vec![]; + + app.handle_add_note(key(KeyCode::Char('x')), &mut input, &mut items); + + assert_eq!(input.value(), "x"); + } + + #[test] + fn rename_note_enter_renames_and_returns() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![]); + app.notes = vec![note("old", "")]; + let mut input = input_with("new"); + let mut items = vec![]; + + app.handle_rename_note(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewNotes); + assert_eq!(app.notes[0].title, "new"); + } + + #[test] + fn rename_note_enter_with_an_empty_title_keeps_the_note() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![]); + app.notes = vec![note("old", "")]; + let mut input = Input::default(); + let mut items = vec![]; + + app.handle_rename_note(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewNotes); + assert_eq!(app.notes[0].title, "old"); + } + + #[test] + fn rename_note_esc_cancels() { + let mut app = make_app(vec![]); + app.notes = vec![note("old", "")]; + let mut input = input_with("discard"); + let mut items = vec![]; + + app.handle_rename_note(key(KeyCode::Esc), &mut input, &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewNotes); + assert_eq!(app.notes[0].title, "old"); + } + + #[test] + fn rename_note_other_keys_edit_the_input() { + let mut app = make_app(vec![]); + let mut input = input_with("ab"); + let mut items = vec![]; + + app.handle_rename_note(key(KeyCode::Char('c')), &mut input, &mut items); + + assert_eq!(input.value(), "abc"); + } + + #[test] + fn delete_note_confirm_removes_the_note() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![]); + app.notes = vec![note("n1", ""), note("n2", "")]; + app.selected_note_index.select(Some(1)); + let mut items = vec![]; + Note::load_items(&mut app, &mut items); + + app.handle_delete_note(key(KeyCode::Enter), &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewNotes); + assert_eq!(app.notes.len(), 1); + assert_eq!(app.notes[0].title, "n1"); + } + + #[test] + fn delete_note_cancel_keeps_the_note() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![]); + app.notes = vec![note("n1", ""), note("n2", "")]; + app.delete_confirm_index.select(Some(1)); + let mut items = vec![]; + Note::load_items(&mut app, &mut items); + + app.handle_delete_note(key(KeyCode::Enter), &mut items); + + assert_eq!(app.notes.len(), 2); + } + + #[test] + fn delete_note_esc_returns() { + let mut app = make_app(vec![]); + app.notes = vec![note("n1", "")]; + let mut items = vec![]; + + let action = app.handle_delete_note(key(KeyCode::Esc), &mut items); + + assert_eq!(action, KeyAction::Skip); + assert_eq!(app.view_mode, ViewMode::ViewNotes); + } + + // ---- ViewNote / EditNote ---- + + #[test] + fn view_note_scrolling_keys() { + let mut app = make_app(vec![]); + app.notes = vec![note("n", "body")]; + let mut items = vec![]; + + app.handle_view_note(key(KeyCode::Down), &mut items); + assert_eq!(app.note_scroll, 1); + app.handle_view_note(key(KeyCode::Char('j')), &mut items); + assert_eq!(app.note_scroll, 2); + app.handle_view_note(key(KeyCode::Up), &mut items); + assert_eq!(app.note_scroll, 1); + app.handle_view_note(key(KeyCode::Char('k')), &mut items); + assert_eq!(app.note_scroll, 0); + app.handle_view_note(key(KeyCode::Up), &mut items); // clamped at 0 + assert_eq!(app.note_scroll, 0); + app.handle_view_note(key(KeyCode::PageDown), &mut items); + assert_eq!(app.note_scroll, 10); + app.handle_view_note(key(KeyCode::PageUp), &mut items); + assert_eq!(app.note_scroll, 0); + app.handle_view_note(key(KeyCode::End), &mut items); + assert_eq!(app.note_scroll, u16::MAX); + app.handle_view_note(key(KeyCode::Home), &mut items); + assert_eq!(app.note_scroll, 0); + app.handle_view_note(key(KeyCode::Char('g')), &mut items); + assert_eq!(app.note_scroll, 0); + } + + #[test] + fn view_note_e_opens_the_editor() { + let mut app = make_app(vec![]); + app.notes = vec![note("n", "# body")]; + let mut items = vec![]; + + app.handle_view_note(key(KeyCode::Char('e')), &mut items); + + assert_eq!(app.view_mode, ViewMode::EditNote); + let textarea = app.note_textarea.as_ref().unwrap(); + assert_eq!(textarea.lines(), &["# body"]); + } + + #[test] + fn view_note_h_opens_help() { + let mut app = make_app(vec![]); + app.notes = vec![note("n", "")]; + let mut items = vec![]; + + app.handle_view_note(key(KeyCode::Char('h')), &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewHelp); + assert_eq!(app.previous_view_mode, ViewMode::ViewNote); + } + + #[test] + fn view_note_esc_returns_to_the_list() { + let mut app = make_app(vec![]); + app.notes = vec![note("n", "")]; + let mut items = vec![]; + Note::load_items(&mut app, &mut items); + items.clear(); + + app.handle_view_note(key(KeyCode::Enter), &mut items); + + assert_eq!(app.view_mode, ViewMode::ViewNotes); + assert_eq!(items.len(), 1); // list refreshed + } + + #[test] + fn view_note_e_with_an_empty_body_starts_with_one_line() { + let mut app = make_app(vec![]); + app.notes = vec![note("n", "")]; + let mut items = vec![]; + + app.handle_view_note(key(KeyCode::Char('e')), &mut items); + + assert_eq!(app.view_mode, ViewMode::EditNote); + assert_eq!( + app.note_textarea.as_ref().unwrap().lines(), + &["".to_string()] + ); + } + + #[test] + fn view_note_unknown_keys_do_nothing() { + let mut app = make_app(vec![]); + app.notes = vec![note("n", "")]; + let mut items = vec![]; + + let action = app.handle_view_note(key(KeyCode::Char('z')), &mut items); + + assert_eq!(action, KeyAction::None); + } + + #[test] + fn view_note_q_quits() { + let mut app = make_app(vec![]); + app.notes = vec![note("n", "")]; + let mut items = vec![]; + + assert_eq!( + app.handle_view_note(key(KeyCode::Char('q')), &mut items), + KeyAction::Quit + ); + } + + #[test] + fn edit_note_esc_saves_changes_and_returns_to_the_preview() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![]); + app.notes = vec![note("n", "old")]; + app.note_textarea = Some(TextArea::from(vec!["new body".to_string()])); + + app.handle_edit_note(key(KeyCode::Esc)); + + assert_eq!(app.view_mode, ViewMode::ViewNote); + assert!(app.note_textarea.is_none()); + assert_eq!(app.notes[0].body, "new body"); + assert!(app.notes[0].updated_at.is_some()); + } + + #[test] + fn edit_note_esc_without_changes_skips_the_write() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![]); + app.notes = vec![note("n", "")]; + app.note_textarea = Some(TextArea::from(vec![String::new()])); + + app.handle_edit_note(key(KeyCode::Esc)); + + assert_eq!(app.view_mode, ViewMode::ViewNote); + assert_eq!(app.notes[0].updated_at, None); + } + + #[test] + fn edit_note_esc_without_a_textarea_returns() { + let mut app = make_app(vec![]); + app.notes = vec![note("n", "")]; + + app.handle_edit_note(key(KeyCode::Esc)); + + assert_eq!(app.view_mode, ViewMode::ViewNote); + } + + #[test] + fn edit_note_other_keys_go_to_the_textarea() { + let mut app = make_app(vec![]); + app.view_mode = ViewMode::EditNote; + app.notes = vec![note("n", "")]; + app.note_textarea = Some(TextArea::from(vec![String::new()])); + + app.handle_edit_note(key(KeyCode::Char('a'))); + + assert_eq!(app.view_mode, ViewMode::EditNote); + assert_eq!( + app.note_textarea.as_ref().unwrap().lines(), + &["a".to_string()] + ); + } + + // ---- handle_modal_nav ---- + + #[test] + fn delete_project_leaves_a_countdown_untouched() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(sample_projects()); + app.selected_project_index.select(Some(1)); + app.timer = Some(paused_countdown(60, 10)); // not bound to any task + let mut items = vec![]; + + app.handle_delete_project(key(KeyCode::Enter), &mut items); + + assert!(app.timer.is_some()); + assert_eq!(app.timer.as_ref().unwrap().kind, TimerKind::Countdown); + } + + #[test] + fn rename_task_without_a_timer_is_fine() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let mut input = input_with("renamed"); + + app.handle_rename_task(key(KeyCode::Enter), &mut input, &mut items); + + assert!(app.timer.is_none()); + assert_eq!(Task::get_current(&app).title, "renamed"); + } + + #[test] + fn timer_task_space_without_a_timer_is_a_no_op() { + let mut app = make_app(vec![]); + + app.handle_timer_task(key(KeyCode::Char(' '))); // must not panic + + assert!(app.timer.is_none()); + } + + #[test] + fn delete_project_keeps_a_timer_bound_to_an_earlier_project() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![ + Project { + title: "a".to_string(), + tasks: vec![], + }, + Project { + title: "b".to_string(), + tasks: vec![], + }, + ]); + app.selected_project_index.select(Some(1)); // delete "b" + app.timer = Some(paused_stopwatch(10, 0, "t")); // bound to "a" + let mut items = vec![]; + + app.handle_delete_project(key(KeyCode::Enter), &mut items); + + let bound = app.timer.as_ref().unwrap().bound.as_ref().unwrap(); + assert_eq!(bound.project_index, 0); // unchanged + } + + #[test] + fn rename_task_leaves_a_timer_bound_to_another_task_alone() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + app.timer = Some(paused_stopwatch(10, 0, "other task")); + let mut input = input_with("renamed"); + + app.handle_rename_task(key(KeyCode::Enter), &mut input, &mut items); + + assert_eq!( + app.timer + .as_ref() + .unwrap() + .bound + .as_ref() + .unwrap() + .task_title, + "other task" + ); + } + + #[test] + fn next_and_previous_with_no_selection_start_at_zero() { + let mut app = make_app(vec![]); + let items: Vec = vec![ListItem::from("a"), ListItem::from("b")]; + app.selected_project_index.select(None); + + app.next(&items); + assert_eq!(app.selected_project_index.selected(), Some(0)); + + app.selected_project_index.select(None); + app.previous(&items); + assert_eq!(app.selected_project_index.selected(), Some(0)); + } + + #[test] + fn board_sync_without_a_selection_is_a_no_op() { + let mut app = tasks_app(); + app.selected_task_index.select(None); + + app.board_sync(); // must not panic + + assert_eq!(app.board_lane, 0); + } + + #[test] + fn board_sync_with_an_out_of_range_project_is_a_no_op() { + let mut app = tasks_app(); + app.selected_project_index.select(Some(5)); // only one project + + app.board_sync(); // must not panic + + assert_eq!(app.board_lane, 0); + } + + #[test] + fn board_sync_with_an_unknown_status_keeps_the_current_lane() { + let mut app = tasks_app(); + app.selected_task_index.select(Some(0)); + app.projects[0].tasks[0].status = "Bogus".to_string(); + app.board_lane = 2; + + app.board_sync(); // must not panic + + assert_eq!(app.board_lane, 2); + } + + #[test] + fn board_move_on_an_empty_lane_is_a_no_op() { + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![make_task("a", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE)], + }]); + app.board_lane = 2; // Done lane is empty + + app.board_move(true); + app.board_move(false); + + assert_eq!(app.selected_task_index.selected(), Some(0)); + } + + #[test] + fn modal_nav_esc_resets_and_returns() { + let mut app = make_app(vec![]); + app.view_mode = ViewMode::DeleteProject; + app.delete_confirm_index.select(Some(1)); + let items = confirm_items(); + + let handled = app.handle_modal_nav(KeyCode::Esc, &items, ViewMode::ViewProjects); + + assert!(handled); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + assert_eq!(app.delete_confirm_index.selected(), Some(0)); + } + + #[test] + fn modal_nav_keys_move_the_selection() { + let mut app = make_app(vec![]); + app.view_mode = ViewMode::DeleteProject; + let items = confirm_items(); + + assert!(app.handle_modal_nav(KeyCode::Down, &items, ViewMode::ViewProjects)); + assert_eq!(app.delete_confirm_index.selected(), Some(1)); + assert!(app.handle_modal_nav(KeyCode::Tab, &items, ViewMode::ViewProjects)); + assert_eq!(app.delete_confirm_index.selected(), Some(0)); // next wraps + assert!(app.handle_modal_nav(KeyCode::BackTab, &items, ViewMode::ViewProjects)); + assert_eq!(app.delete_confirm_index.selected(), Some(1)); // previous wraps + assert!(app.handle_modal_nav(KeyCode::Char('j'), &items, ViewMode::ViewProjects)); + assert_eq!(app.delete_confirm_index.selected(), Some(0)); + assert!(app.handle_modal_nav(KeyCode::Char('k'), &items, ViewMode::ViewProjects)); + assert_eq!(app.delete_confirm_index.selected(), Some(1)); + } + + #[test] + fn modal_nav_other_keys_are_not_handled() { + let mut app = make_app(vec![]); + let items = confirm_items(); + + assert!(!app.handle_modal_nav(KeyCode::Enter, &items, ViewMode::ViewProjects)); + } + + // ---- misc ---- + + #[test] + fn delete_current_task_moves_to_the_previous_task() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + app.selected_task_index.select(Some(1)); + + app.delete_current_task(&mut items); + + assert_eq!(app.projects[0].tasks.len(), 1); + assert_eq!(app.selected_task_index.selected(), Some(0)); + } + + #[test] + fn handle_key_dispatches_every_view_mode() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = tasks_app(); + app.notes = vec![note("n", "")]; + app.timer = Some(paused_countdown(60, 10)); + let mut input = Input::default(); + let mut items = vec![]; + Task::load_items(&mut app, &mut items); + let mut status_items = vec![]; + Task::load_statuses_items(&mut status_items); + let mut priority_items = vec![]; + Task::load_priority_items(&mut priority_items); + + let modes = [ + ViewMode::ViewProjects, + ViewMode::RenameProject, + ViewMode::AddProject, + ViewMode::DeleteProject, + ViewMode::ViewTasks, + ViewMode::RenameTask, + ViewMode::ChangeStatusTask, + ViewMode::ChangePriorityTask, + ViewMode::AddTask, + ViewMode::DeleteTask, + ViewMode::ViewTaskDetails, + ViewMode::EditTaskNote, + ViewMode::SetTaskEstimate, + ViewMode::TimerTask, + ViewMode::SetCountdown, + ViewMode::ViewHelp, + ViewMode::ViewNotes, + ViewMode::AddNote, + ViewMode::RenameNote, + ViewMode::DeleteNote, + ViewMode::ViewNote, + ViewMode::EditNote, + ViewMode::InfoMigration, + ]; + + for mode in modes { + app.view_mode = mode; + app.handle_key( + key(KeyCode::Char('q')), + &mut input, + &mut items, + &status_items, + &priority_items, + ); + } + } + + #[test] + fn use_state_maps_the_remaining_view_modes() { + fn assert_state(app: &mut App, mode: ViewMode, expected: fn(&App) -> *const ListState) { + app.view_mode = mode; + let actual = app.use_state() as *const ListState; + assert_eq!(actual, expected(app)); + } + + let mut app = make_app(vec![]); + + assert_state(&mut app, ViewMode::AddProject, |a| { + &a.selected_project_index + }); + assert_state(&mut app, ViewMode::AddTask, |a| &a.selected_task_index); + assert_state(&mut app, ViewMode::EditTaskNote, |a| &a.selected_task_index); + assert_state(&mut app, ViewMode::SetTaskEstimate, |a| { + &a.selected_task_index + }); + assert_state(&mut app, ViewMode::ViewHelp, |a| &a.selected_project_index); + assert_state(&mut app, ViewMode::InfoMigration, |a| { + &a.selected_project_index + }); + + // Help uses the selection state of the view it was opened from + app.previous_view_mode = ViewMode::ViewTasks; + assert_state(&mut app, ViewMode::ViewHelp, |a| &a.selected_task_index); + app.previous_view_mode = ViewMode::ViewNotes; + assert_state(&mut app, ViewMode::ViewHelp, |a| &a.selected_note_index); + app.previous_view_mode = ViewMode::ViewNote; + assert_state(&mut app, ViewMode::ViewHelp, |a| &a.selected_note_index); + app.previous_view_mode = ViewMode::ViewTaskDetails; + assert_state(&mut app, ViewMode::ViewHelp, |a| &a.selected_project_index); + + app.previous_view_mode = ViewMode::ViewProjects; + assert_state(&mut app, ViewMode::TimerTask, |a| &a.selected_project_index); + assert_state(&mut app, ViewMode::SetCountdown, |a| { + &a.selected_project_index + }); + + app.previous_view_mode = ViewMode::ViewTasks; + assert_state(&mut app, ViewMode::TimerTask, |a| &a.selected_task_index); + assert_state(&mut app, ViewMode::SetCountdown, |a| &a.selected_task_index); + } +} + +// ------------------------------------------------------------------------ +// Event loop (`run_with_source`) +// ------------------------------------------------------------------------ +mod event_loop { + use super::*; + use crate::task::{TASK_PRIORITY_NONE, TASK_STATUS_DONE, TASK_STATUS_UP_NEXT}; + use crate::test_utils::make_task; + use crate::CrosstermSource; + use ratatui::{backend::TestBackend, Terminal}; + use std::collections::VecDeque; + + struct QueuedKeys { + keys: VecDeque>, + } + + impl QueuedKeys { + fn from_keys(keys: Vec) -> Self { + Self { + keys: keys.into_iter().map(Some).collect(), + } + } + } + + impl KeySource for QueuedKeys { + fn next_key(&mut self) -> io::Result> { + Ok(self.keys.pop_front().flatten()) + } + } + + fn run(app: &mut App, keys: Vec, migrations: bool) -> io::Result<()> { + let backend = TestBackend::new(80, 24); + let terminal = Terminal::new(backend).unwrap(); + let mut source = QueuedKeys::from_keys(keys); + app.run_with_source(terminal, migrations, &mut source) + } + + #[test] + fn quit_key_ends_the_loop() { + let mut app = make_app(vec![]); + + let result = run(&mut app, vec![key(KeyCode::Char('q'))], false); + + assert!(result.is_ok()); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + } + + #[test] + fn crossterm_source_polls_without_panicking() { + let mut source = CrosstermSource; + // On CI stdin is /dev/null (no events); on a local tty this waits + // up to 250 ms and returns `None` unless the user happens to type. + // Either outcome is fine — the poll itself must not panic. + let _ = source.next_key(); + } + + #[test] + fn applied_migrations_start_in_the_info_view() { + let mut app = make_app(vec![]); + + let result = run( + &mut app, + vec![key(KeyCode::Char('x')), key(KeyCode::Char('q'))], + true, + ); + + assert!(result.is_ok()); + // 'x' dismissed the migration info, 'q' quit from ViewProjects + assert_eq!(app.view_mode, ViewMode::ViewProjects); + } + + #[test] + fn skip_keys_redraw_and_continue() { + let mut app = make_app(vec![]); // no projects: Enter skips + + let result = run( + &mut app, + vec![key(KeyCode::Enter), key(KeyCode::Char('q'))], + false, + ); + + assert!(result.is_ok()); + } + + #[test] + fn timeouts_tick_the_timer_without_input() { + let mut app = make_app(vec![]); + let mut source = QueuedKeys { + keys: VecDeque::from([None, None, Some(key(KeyCode::Char('q')))]), + }; + let backend = TestBackend::new(80, 24); + let terminal = Terminal::new(backend).unwrap(); + + let result = app.run_with_source(terminal, false, &mut source); + + assert!(result.is_ok()); + } + + #[test] + fn help_over_an_all_done_task_list_does_not_panic() { + // Regression: when every task is done (and done tasks are + // hidden), the task list is empty. Opening help must not + // corrupt the project selection — ratatui clears the state of + // an empty list on render, which used to wipe + // `selected_project_index` and panic in `Project::get_current` + // on the next frame. + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![make_task("t", TASK_STATUS_DONE, TASK_PRIORITY_NONE)], + }]); + + let result = run( + &mut app, + vec![ + key(KeyCode::Enter), + key(KeyCode::Char('h')), + key(KeyCode::Char('x')), + key(KeyCode::Char('q')), + ], + false, + ); + + assert!(result.is_ok()); + assert_eq!(app.selected_project_index.selected(), Some(0)); + } + + struct ExhaustiveKeys { + keys: VecDeque>, + } + + impl KeySource for ExhaustiveKeys { + fn next_key(&mut self) -> io::Result> { + match self.keys.pop_front() { + Some(k) => Ok(k), + // End the loop once the scripted keys are used up — + // a bare `None` would tick forever. + None => Err(io::Error::other("keys exhausted")), + } + } + } + + /// Brute-force smoke test: random key sequences over projects with + /// all-done / empty / mixed task lists must never panic. Guards the + /// selection-state invariants (ratatui clears or clamps the state of + /// a list rendered empty/short, so a view using the wrong `ListState` + /// corrupts it). + #[test] + fn fuzz_random_keys_never_panics() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let pool = [ + KeyCode::Char('h'), + KeyCode::Enter, + KeyCode::Esc, + KeyCode::Left, + KeyCode::Right, + KeyCode::Up, + KeyCode::Down, + KeyCode::Char('b'), + KeyCode::Char('t'), + KeyCode::Char('n'), + KeyCode::Char('r'), + KeyCode::Char('d'), + KeyCode::Char('v'), + KeyCode::Char('e'), + KeyCode::Char('p'), + KeyCode::Char('s'), + KeyCode::Char('c'), + KeyCode::Char('m'), + KeyCode::Char('j'), + KeyCode::Char('k'), + KeyCode::Char('g'), + KeyCode::Char('1'), + KeyCode::Char(' '), + KeyCode::Tab, + KeyCode::BackTab, + KeyCode::Char('x'), + KeyCode::Char('q'), + ]; + let mut seed: u64 = 0x1234_5678_9abc_def0; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + + for round in 0..300 { + let mut app = make_app(vec![ + Project { + title: "all done".to_string(), + tasks: vec![ + make_task("t1", TASK_STATUS_DONE, TASK_PRIORITY_NONE), + make_task("t2", TASK_STATUS_DONE, 1), + ], + }, + Project { + title: "empty".to_string(), + tasks: vec![], + }, + Project { + title: "mixed".to_string(), + tasks: vec![ + make_task("m1", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + make_task("m2", TASK_STATUS_DONE, 2), + ], + }, + ]); + let keys: Vec = (0..40) + .map(|_| key(pool[(next() % pool.len() as u64) as usize])) + .collect(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let backend = TestBackend::new(80, 24); + let terminal = Terminal::new(backend).unwrap(); + let mut source = ExhaustiveKeys { + keys: keys.clone().into_iter().map(Some).collect(), + }; + let _ = app.run_with_source(terminal, false, &mut source); + })); + if result.is_err() { + panic!( + "round {round} panicked with keys: {:?}", + keys.iter().map(|k| k.code).collect::>() + ); + } + } + } + + #[test] + fn release_events_are_ignored() { + let mut app = make_app(vec![]); + // If the Release event were handled, 'n' would open AddProject and + // the following 'q' would just be input text — the loop would not + // terminate, so reaching the end proves the filter works. + let keys = vec![release_key(KeyCode::Char('n')), key(KeyCode::Char('q'))]; + + let result = run(&mut app, keys, false); + + assert!(result.is_ok()); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + } + + #[test] + fn quitting_settles_a_running_stopwatch() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![make_task("t", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE)], + }]); + app.timer = Some(paused_stopwatch(45, 0, "t")); + + let result = run(&mut app, vec![key(KeyCode::Char('q'))], false); + + assert!(result.is_ok()); + assert!(app.timer.is_none()); + assert_eq!(app.projects[0].tasks[0].time_spent_secs, 45); + } +} + +// ------------------------------------------------------------------------ +// Timer behaviour (tick / settle / navigation helpers) +// ------------------------------------------------------------------------ +mod timer_behaviour { + use super::*; + use crate::task::{TASK_PRIORITY_NONE, TASK_STATUS_UP_NEXT}; + use crate::test_utils::make_task; + + #[test] + fn tick_timer_rings_the_bell_once_at_zero() { + let mut app = make_app(vec![]); + app.timer = Some(paused_countdown(60, 60)); + + app.tick_timer(); + assert!(app.timer.as_ref().unwrap().rung); + + // Already rung: nothing more happens (and it must not panic) + app.tick_timer(); + assert!(app.timer.as_ref().unwrap().rung); + } + + #[test] + fn tick_timer_does_nothing_without_a_timer() { + let mut app = make_app(vec![]); + + app.tick_timer(); + + assert!(app.timer.is_none()); + } + + #[test] + fn tick_timer_ignores_running_timers() { + let mut app = make_app(vec![]); + app.timer = Some(TimerState::new_stopwatch(0, "t".to_string())); + + app.tick_timer(); + + assert!(!app.timer.as_ref().unwrap().rung); + } + + #[test] + fn settle_timer_accumulates_stopwatch_seconds_into_the_task() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![make_task("t", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE)], + }]); + app.timer = Some(paused_stopwatch(90, 0, "t")); + + app.settle_timer(); + + assert!(app.timer.is_none()); + assert_eq!(app.projects[0].tasks[0].time_spent_secs, 90); + } + + #[test] + fn settle_timer_discards_a_countdown() { + let mut app = make_app(vec![]); + app.timer = Some(paused_countdown(60, 10)); + + app.settle_timer(); + + assert!(app.timer.is_none()); + } + + #[test] + fn back_to_previous_view_restores_and_resets() { + let mut app = make_app(vec![]); + app.previous_view_mode = ViewMode::ViewNotes; + + app.back_to_previous_view(); + + assert_eq!(app.view_mode, ViewMode::ViewNotes); + assert_eq!(app.previous_view_mode, ViewMode::ViewProjects); + } + + #[test] + fn setup_reads_persisted_state() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + + let app = App::setup().unwrap(); + + assert!(app.projects.is_empty()); + assert!(app.notes.is_empty()); + assert_eq!(app.view_mode, ViewMode::ViewProjects); + assert_eq!(app.selected_project_index.selected(), Some(0)); + assert!(app.hide_done_tasks); + assert!(!app.board_view); + assert!(app.timer.is_none()); + } + + #[test] + fn setup_errors_on_a_corrupt_data_file() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + std::fs::write(Json::get_data_path(), "{ not json").unwrap(); + + assert!(App::setup().is_err()); + } +} + +// ------------------------------------------------------------------------ +// Rendering (all view modes, hint readouts, modal content) +// ------------------------------------------------------------------------ +mod render { + use super::*; + use crate::task::{ + TASK_PRIORITY_NONE, TASK_STATUS_DONE, TASK_STATUS_ON_GOING, TASK_STATUS_UP_NEXT, + }; + use crate::test_utils::make_task; + use ratatui::{backend::TestBackend, Terminal}; + + fn draw_with(app: &mut App, input: &Input, width: u16, height: u16) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + let mut items = vec![]; + Project::load_items(app, &mut items); + let mut status_items = vec![]; + Task::load_statuses_items(&mut status_items); + let mut priority_items = vec![]; + Task::load_priority_items(&mut priority_items); + + let frame = terminal + .draw(|f| app.render(f, f.size(), input, &items, &status_items, &priority_items)) + .unwrap(); + + frame.buffer.content.iter().map(|c| c.symbol()).collect() + } + + fn draw_text(app: &mut App, width: u16, height: u16) -> String { + let input = Input::default(); + draw_with(app, &input, width, height) + } + + #[test] + fn render_never_panics_in_any_view_mode() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![ + make_task("a", TASK_STATUS_UP_NEXT, 1), + make_task("b", TASK_STATUS_ON_GOING, TASK_PRIORITY_NONE), + make_task("c", TASK_STATUS_DONE, TASK_PRIORITY_NONE), + ], + }]); + app.projects[0].tasks[0].note = "note".to_string(); + app.projects[0].tasks[1].estimated_hours = 2; + app.notes = vec![note("n", "# hi")]; + app.timer = Some(paused_countdown(60, 30)); + + let modes = [ + ViewMode::ViewProjects, + ViewMode::RenameProject, + ViewMode::AddProject, + ViewMode::DeleteProject, + ViewMode::ViewTasks, + ViewMode::RenameTask, + ViewMode::ChangeStatusTask, + ViewMode::ChangePriorityTask, + ViewMode::AddTask, + ViewMode::DeleteTask, + ViewMode::ViewTaskDetails, + ViewMode::EditTaskNote, + ViewMode::SetTaskEstimate, + ViewMode::TimerTask, + ViewMode::SetCountdown, + ViewMode::ViewHelp, + ViewMode::ViewNotes, + ViewMode::AddNote, + ViewMode::RenameNote, + ViewMode::DeleteNote, + ViewMode::ViewNote, + ViewMode::EditNote, + ViewMode::InfoMigration, + ]; + + for mode in modes { + app.view_mode = mode; + app.previous_view_mode = ViewMode::ViewProjects; + draw_text(&mut app, 80, 24); + draw_text(&mut app, 40, 10); // tiny sizes must not panic either + } + + // Board rendering goes through the same `show_items` path + app.view_mode = ViewMode::ViewTasks; + app.board_view = true; + app.selected_task_index.select(Some(0)); + app.board_sync(); + draw_text(&mut app, 80, 24); + } + + #[test] + fn hint_shows_help_and_no_timer_readout_without_a_timer() { + let mut app = make_app(vec![]); + + let text = draw_text(&mut app, 120, 60); + + assert!(text.contains("h help")); + assert!(!text.contains("pomodoro")); + } + + #[test] + fn hint_renders_a_running_stopwatch() { + let mut app = make_app(vec![]); + app.timer = Some(TimerState::new_stopwatch(0, "t".to_string())); + + let text = draw_text(&mut app, 120, 60); + + assert!(text.contains('▶')); + } + + #[test] + fn hint_renders_a_paused_stopwatch() { + let mut app = make_app(vec![]); + app.timer = Some(paused_stopwatch(65, 0, "short")); + + let text = draw_text(&mut app, 120, 60); + + assert!(text.contains("❚❚")); + assert!(text.contains("00:01:05")); + assert!(text.contains("short")); + } + + #[test] + fn hint_truncates_long_task_titles_with_an_ellipsis() { + let mut app = make_app(vec![]); + app.timer = Some(paused_stopwatch(0, 0, &"x".repeat(30))); + + let text = draw_text(&mut app, 120, 60); + + assert!(text.contains('…')); + assert_eq!(text.matches('x').count(), 20); + } + + #[test] + fn hint_renders_a_low_running_countdown() { + let mut app = make_app(vec![]); + app.timer = Some(TimerState::new_countdown(10)); // ~10s left + + let text = draw_text(&mut app, 120, 60); + + assert!(text.contains('▼')); + } + + #[test] + fn hint_renders_an_unbound_timer_as_pomodoro() { + let mut app = make_app(vec![]); + app.timer = Some(paused_countdown(600, 60)); + + let text = draw_text(&mut app, 120, 60); + + assert!(text.contains("pomodoro")); + assert!(text.contains("00:09:00")); + } + + #[test] + fn timer_modal_running_countdown_turns_red_when_low() { + let mut app = make_app(vec![]); + // Running (started just now) with ~10s left: red readout + app.timer = Some(TimerState::new_countdown(10)); + app.view_mode = ViewMode::TimerTask; + + let text = draw_text(&mut app, 80, 24); + + assert!(text.contains("running")); + assert!(text.contains("█")); + } + + #[test] + fn timer_modal_running_countdown_is_green_when_plenty_left() { + let mut app = make_app(vec![]); + app.timer = Some(TimerState::new_countdown(600)); + app.view_mode = ViewMode::TimerTask; + + let text = draw_text(&mut app, 80, 24); + + assert!(text.contains("running")); + } + + #[test] + fn timer_modal_stopwatch_shows_an_over_estimate_in_red() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![make_task("t", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE)], + }]); + app.projects[0].tasks[0].estimated_hours = 1; + app.projects[0].tasks[0].time_spent_secs = 7200; // 200% + app.timer = Some(paused_stopwatch(0, 0, "t")); + app.view_mode = ViewMode::TimerTask; + app.previous_view_mode = ViewMode::ViewTasks; + + let text = draw_text(&mut app, 80, 24); + + assert!(text.contains("1h (200% spent)")); + } + + #[test] + fn timer_modal_bound_without_an_estimate_hides_the_estimate_line() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![make_task("t", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE)], + }]); + app.timer = Some(paused_stopwatch(0, 0, "t")); + app.view_mode = ViewMode::TimerTask; + app.previous_view_mode = ViewMode::ViewTasks; + + let text = draw_text(&mut app, 80, 24); + + assert!(text.contains("Task: t")); + assert!(!text.contains("Estimate:")); + } + + #[test] + fn timer_modal_finished_countdown_shows_time_is_up() { + let mut app = make_app(vec![]); + app.timer = Some(paused_countdown(60, 60)); // finished + app.view_mode = ViewMode::TimerTask; + app.previous_view_mode = ViewMode::ViewProjects; + + let text = draw_text(&mut app, 80, 24); + + assert!(text.contains("time's up!")); + assert!(text.contains("Press any key to close")); + } + + #[test] + fn timer_modal_stopwatch_shows_estimate_progress() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![make_task("t", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE)], + }]); + app.projects[0].tasks[0].estimated_hours = 2; // 7200s + app.projects[0].tasks[0].time_spent_secs = 3600; // 50% + app.timer = Some(paused_stopwatch(0, 0, "t")); + app.view_mode = ViewMode::TimerTask; + app.previous_view_mode = ViewMode::ViewTasks; + + let text = draw_text(&mut app, 80, 24); + + assert!(text.contains("Task: t")); + assert!(text.contains("Estimate:")); + assert!(text.contains("2h (50% spent)")); + } + + #[test] + fn timer_modal_countdown_shows_the_target() { + let mut app = make_app(vec![]); + app.timer = Some(paused_countdown(1500, 600)); + app.view_mode = ViewMode::TimerTask; + + let text = draw_text(&mut app, 80, 24); + + assert!(text.contains("Pomodoro")); + assert!(text.contains("of 00:25:00")); + // The readout itself is rendered as block digits + assert!(text.contains("█")); + } + + #[test] + fn timer_modal_handles_a_missing_timer_gracefully() { + let mut app = make_app(vec![]); + app.view_mode = ViewMode::TimerTask; + + draw_text(&mut app, 80, 24); // must not panic + } + + #[test] + fn details_modal_renders_all_task_fields() { + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![make_task("t", TASK_STATUS_ON_GOING, 2)], + }]); + app.projects[0].tasks[0].created_at = Some(1_700_000_000); + app.projects[0].tasks[0].completed_at = Some(1_700_000_100); + app.projects[0].tasks[0].note = "a note".to_string(); + app.projects[0].tasks[0].time_spent_secs = 5400; // 1h 30m + app.projects[0].tasks[0].estimated_hours = 3; // 50% of 3h + app.view_mode = ViewMode::ViewTaskDetails; + + let text = draw_text(&mut app, 80, 24); + + assert!(text.contains("Task: t")); + assert!(text.contains("Status: OnGoing")); + assert!(text.contains("Priority: 2 (!!)")); + assert!(text.contains("Note: a note")); + assert!(text.contains("Created:")); + assert!(text.contains("Completed:")); + assert!(text.contains("Time Consumed:")); + assert!(text.contains("Time Spent:")); + assert!(text.contains("1h 30m")); + assert!(text.contains("Estimate: 3h (50% spent)")); + } + + #[test] + fn details_modal_without_dates_or_estimate() { + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![make_task("t", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE)], + }]); + app.projects[0].tasks[0].created_at = None; + app.projects[0].tasks[0].completed_at = None; + app.view_mode = ViewMode::ViewTaskDetails; + + let text = draw_text(&mut app, 80, 24); + + assert!(text.contains("Priority: None")); + assert!(text.contains("Estimate: none")); + assert!(!text.contains("Created:")); + assert!(!text.contains("Completed:")); + } + + #[test] + fn delete_modal_titles_the_selected_item() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![Project { + title: "proj".to_string(), + tasks: vec![make_task("task", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE)], + }]); + app.notes = vec![note("note", "")]; + + app.view_mode = ViewMode::DeleteTask; + assert!(draw_text(&mut app, 80, 24).contains("Delete \"task\"?")); + + app.view_mode = ViewMode::DeleteProject; + assert!(draw_text(&mut app, 80, 24).contains("Delete \"proj\"?")); + + app.view_mode = ViewMode::DeleteNote; + assert!(draw_text(&mut app, 80, 24).contains("Delete \"note\"?")); + } + + #[test] + fn help_over_an_empty_task_list_keeps_the_project_selection() { + // Regression: entering a project with no tasks, opening help, and + // letting the loop redraw used to clear `selected_project_index` + // (the empty task list was rendered with the project state) and + // panic in `Project::get_current` on the next frame. + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![], + }]); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut input = Input::default(); + let mut items = vec![]; + Project::load_items(&mut app, &mut items); + let mut status_items = vec![]; + Task::load_statuses_items(&mut status_items); + let mut priority_items = vec![]; + Task::load_priority_items(&mut priority_items); + + // projects -> Enter -> tasks (empty) -> h -> help + app.handle_view_projects(key(KeyCode::Enter), &mut input, &mut items); + app.handle_view_tasks(key(KeyCode::Char('h')), &mut input, &mut items); + + for _ in 0..3 { + terminal + .draw(|f| app.render(f, f.size(), &input, &items, &status_items, &priority_items)) + .unwrap(); + } + + assert_eq!(app.view_mode, ViewMode::ViewHelp); + assert_eq!(app.selected_project_index.selected(), Some(0)); + } + + #[test] + fn help_over_an_empty_notes_list_keeps_the_project_selection() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![]); // no projects and no notes + app.view_mode = ViewMode::ViewHelp; + app.previous_view_mode = ViewMode::ViewNotes; + + let text = draw_text(&mut app, 80, 24); + draw_text(&mut app, 80, 24); + + assert!(text.contains("Press any key to close")); + assert_eq!(app.selected_project_index.selected(), Some(0)); + } + + #[test] + fn help_modal_lists_the_bindings_of_the_previous_view() { + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![make_task("t", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE)], + }]); + app.view_mode = ViewMode::ViewHelp; + + app.previous_view_mode = ViewMode::ViewProjects; + assert!(draw_text(&mut app, 80, 24).contains("go to tasks")); + + app.previous_view_mode = ViewMode::ViewNotes; + assert!(draw_text(&mut app, 80, 24).contains("preview")); + + app.previous_view_mode = ViewMode::ViewNote; + assert!(draw_text(&mut app, 80, 24).contains("scroll")); + + app.previous_view_mode = ViewMode::ViewTasks; + app.board_view = false; + assert!(draw_text(&mut app, 80, 24).contains("toggle done")); + + app.board_view = true; + assert!(draw_text(&mut app, 80, 24).contains("switch lane")); + + app.previous_view_mode = ViewMode::ViewTaskDetails; // no bindings + assert!(draw_text(&mut app, 80, 24).contains("Press any key to close")); + } + + #[test] + fn input_modal_scrolls_a_long_value() { + let mut app = make_app(vec![]); + app.view_mode = ViewMode::AddProject; + let input = input_with(&"x".repeat(200)); + + let text = draw_with(&mut app, &input, 40, 10); + + assert!(text.contains(&"x".repeat(30))); + } +} diff --git a/src/cli.rs b/src/cli.rs index f41bb91..6dd4176 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,20 +1,84 @@ -use std::{env, process::exit}; - pub struct Cli; -impl Cli { - pub fn read() { - // If you use `cargo run main.rs`, skip must be 2 - let mut args = env::args().skip(1); +/// What the program should do based on its command-line arguments. +#[derive(Debug, PartialEq, Eq)] +pub enum CliAction { + Run, + ShowVersion, + ShowHelp, +} + +pub const USAGE: &str = "basilk - a TUI kanban task manager + +Usage: basilk [OPTIONS] +Options: + -h, --help Print this help message and exit + --version Print the version and exit +"; + +impl Cli { + /// Decide what to do from the command-line arguments (without the + /// program name, which is always the first argument). + pub fn parse(mut args: impl Iterator) -> CliAction { match args.next() { - Some(arg) => { - if arg == "--version" { - print!(env!("CARGO_PKG_VERSION")); - exit(0) - } - } - None => (), + Some(arg) if arg == "--version" => CliAction::ShowVersion, + Some(arg) if arg == "--help" || arg == "-h" => CliAction::ShowHelp, + _ => CliAction::Run, } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_arguments_means_run() { + assert_eq!(Cli::parse(std::iter::empty()), CliAction::Run); + } + + #[test] + fn version_flag_is_recognized() { + assert_eq!( + Cli::parse(["--version".to_string()].into_iter()), + CliAction::ShowVersion + ); + } + + #[test] + fn help_flags_are_recognized() { + assert_eq!( + Cli::parse(["--help".to_string()].into_iter()), + CliAction::ShowHelp + ); + assert_eq!( + Cli::parse(["-h".to_string()].into_iter()), + CliAction::ShowHelp + ); + } + + #[test] + fn usage_mentions_the_program_and_every_flag() { + assert!(USAGE.contains("basilk")); + assert!(USAGE.contains("--version")); + assert!(USAGE.contains("--help")); + } + + #[test] + fn unknown_arguments_are_ignored() { + assert_eq!( + Cli::parse(["--nope".to_string()].into_iter()), + CliAction::Run + ); + assert_eq!( + Cli::parse(["basilk".to_string()].into_iter()), + CliAction::Run + ); + // Only the first argument counts + assert_eq!( + Cli::parse(["basilk".to_string(), "--version".to_string()].into_iter()), + CliAction::Run + ); + } +} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 3a400e1..0000000 --- a/src/config.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::{ - fs::{self, File}, - io::Write, - path::PathBuf, - process::exit, -}; - -use serde::{Deserialize, Serialize}; - -use crate::json::Json; - -#[derive(Deserialize, Serialize)] -pub struct ConfigToml { - pub ui: Ui, -} - -#[derive(Deserialize, Serialize)] -pub struct Ui { - pub show_help: bool, -} - -pub struct Config; - -static CONFIG_FILE_NAME: &str = "config"; - -impl Config { - fn get_default() -> ConfigToml { - ConfigToml { - ui: Ui { show_help: true }, - } - } - - fn get_config_path() -> PathBuf { - let mut path = PathBuf::new(); - path.push(Json::get_dir_path().as_path()); - path.push(format!("{CONFIG_FILE_NAME}.toml")); - - return path; - } - - pub fn read() -> ConfigToml { - let path = Config::get_config_path(); - let config_raw = match fs::read_to_string(&path) { - Ok(c) => c, - // If config.toml file doesn't exist, create it by default - Err(_) => { - let default_config = toml::to_string(&Config::get_default()).unwrap(); - - let mut file = File::create(&path).unwrap(); - let _ = file.write_all(default_config.as_bytes()); - - default_config - } - }; - - let data: ConfigToml = match toml::from_str(&config_raw) { - Ok(c) => c, - // If config.toml is not valid, throw a error message - Err(_) => { - eprint!( - "{} - ERROR: The configuration file is invalid. Please check the wiki for correct formatting or delete the file", - env!("CARGO_PKG_NAME") - ); - exit(1) - } - }; - - return data; - } -} diff --git a/src/json.rs b/src/json.rs index ae4a369..9597f82 100644 --- a/src/json.rs +++ b/src/json.rs @@ -1,29 +1,55 @@ -use std::{ - error::Error, - fs::{self, File}, - io::Write, - path::{Path, PathBuf}, - sync::Mutex, -}; +use std::{error::Error, fs, io, path::PathBuf}; -use serde_json::{from_str, to_string, Value}; +use serde::{Deserialize, Serialize}; +use serde_json::{from_str, to_string}; use crate::{ migration::{Migration, JSON_VERSIONS}, + note::Note, project::Project, }; pub struct Json; static DIR_CONFIG_NAME: &str = env!("CARGO_PKG_NAME"); -static VERSION: Mutex = Mutex::new(String::new()); +static DATA_FILE_NAME: &str = "basilk_data.json"; + +#[derive(Serialize, Deserialize)] +struct DataWrapper { + version: String, + data: Vec, + /// Global notes; `default` keeps pre-notes data files loadable. + #[serde(default)] + notes: Vec, +} + +/// Borrowed view of `DataWrapper` for serialization, so the write paths +/// do not deep-copy projects and notes. +#[derive(Serialize)] +struct DataWrapperRef<'a> { + version: &'a str, + data: &'a [Project], + notes: &'a [Note], +} impl Json { pub fn get_dir_path() -> PathBuf { + if let Ok(dir) = std::env::var("BASILK_CONFIG_DIR") { + return PathBuf::from(dir); + } + let mut path = dirs::config_dir().unwrap(); path.push(DIR_CONFIG_NAME); - return path; + path + } + + pub(crate) fn get_data_path() -> PathBuf { + let mut path = PathBuf::new(); + path.push(Json::get_dir_path().as_path()); + path.push(DATA_FILE_NAME); + + path } fn get_json_path(version: String) -> PathBuf { @@ -31,84 +57,421 @@ impl Json { path.push(Json::get_dir_path().as_path()); path.push(format!("{version}.json")); - return path; + path } + /// Bring the data file up to date: migrate the current file in place, + /// or bootstrap it from the legacy versioned files. pub fn check() -> Result> { fs::create_dir_all(Json::get_dir_path())?; + let data_path = Json::get_data_path(); - // Create the state to save the json version - let mut version_state = VERSION.lock().unwrap(); - - // Pick the version from the internal file - let mut json_version_from_file: Vec<&str> = JSON_VERSIONS - .into_iter() - .filter(|version| Path::new(&Json::get_json_path(version.to_string())).is_file()) - .collect(); + if data_path.is_file() { + Json::check_data_file(&data_path) + } else { + Json::check_legacy_files(&data_path) + } + } - // If the file doesn't exist create a new one with the last version - if json_version_from_file.is_empty() { - let last_json_version = JSON_VERSIONS.last().unwrap(); - let path = Json::get_json_path(last_json_version.to_string()); + /// The current data file exists. An empty file is reset to the latest + /// version; otherwise any pending migrations are applied one by one. + fn check_data_file(data_path: &PathBuf) -> Result> { + let json_raw = fs::read_to_string(data_path)?; - let mut file = File::create(path).unwrap(); - let _ = file.write_all(b"[]"); + if json_raw.trim().is_empty() { + Json::write_internal(data_path, JSON_VERSIONS.last().unwrap(), &[], &[])?; + return Ok(false); + } - json_version_from_file = vec![last_json_version]; - version_state.push_str(json_version_from_file[0]); + let wrapper: DataWrapper = from_str(&json_raw)?; + let migrations = Migration::get_migrations(&wrapper.version, wrapper.data); + if migrations.is_empty() { return Ok(false); } - // Save into the internal state the last json version - version_state.push_str(json_version_from_file[0]); + for (version, migration_data) in migrations { + Json::write_internal(data_path, version, &migration_data, &wrapper.notes)?; + } + + Ok(true) + } - // Read the internal file - let path = Json::get_json_path(json_version_from_file[0].to_string()); - let json_raw = fs::read_to_string(&path).unwrap(); - let json = from_str::>(&json_raw).unwrap(); + /// No current data file: migrate the oldest legacy versioned file into + /// the new format, or seed a fresh empty data file when none exists. + /// After a legacy migration the whole check re-runs to verify the new + /// file and apply any further migrations on top; the legacy file is + /// removed only once that verification passed. + fn check_legacy_files(data_path: &PathBuf) -> Result> { + let old_version = JSON_VERSIONS + .into_iter() + .find(|version| Json::get_json_path(version.to_string()).is_file()); - if json.is_empty() { + let Some(old_version) = old_version else { + Json::write_internal(data_path, JSON_VERSIONS.last().unwrap(), &[], &[])?; return Ok(false); - } + }; - // Load all migrations - let migrations = Migration::get_migrations(json_version_from_file[0], json); + let old_path = Json::get_json_path(old_version.to_string()); + let json_raw = fs::read_to_string(&old_path)?; + let data = from_str::>(&json_raw)?; - if migrations.is_empty() { - return Ok(false); + Json::write_internal(data_path, old_version, &data, &[])?; + + // Verify the rewritten file (and apply further migrations) before + // removing the legacy copy. + let migrated = Json::check()?; + + // Best-effort cleanup; a leftover legacy file is harmless. + let _ = fs::remove_file(old_path); + + Ok(migrated) + } + + /// Strict read of the on-disk wrapper: a missing file yields `None`, + /// while an unreadable or unparseable file is an error, so the write + /// paths never clobber corrupt data with an empty counterpart. + fn read_wrapper() -> Result, Box> { + let path = Json::get_data_path(); + match fs::read_to_string(&path) { + Ok(json) => Ok(Some(from_str(&json)?)), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(Box::new(e)), } + } + + pub fn read() -> Result, Box> { + Ok(Json::read_wrapper()?.map(|w| w.data).unwrap_or_default()) + } + + /// Write the project list, keeping the notes already on disk. Fails + /// without writing when the on-disk data cannot be parsed. + pub fn write(projects: &[Project]) -> Result<(), Box> { + let path = Json::get_data_path(); + let wrapper = Json::read_wrapper()?; + // A missing file is seeded at the latest version; an existing one + // keeps its version even when newer than this binary knows about. + let version = wrapper + .as_ref() + .map(|w| w.version.clone()) + .unwrap_or_else(|| JSON_VERSIONS.last().unwrap().to_string()); + let notes = wrapper.map(|w| w.notes).unwrap_or_default(); + + Json::write_internal(&path, &version, projects, ¬es) + } - // Loop thru all migrations and apply them! - for (version, migration) in migrations.iter() { - let path = Json::get_json_path(version_state.to_string()); - let new_path = Json::get_json_path(version.to_string()); + /// Lenient startup read: a missing or unparseable file yields no notes. + pub fn read_notes() -> Vec { + Json::read_wrapper() + .ok() + .flatten() + .map(|wrapper| wrapper.notes) + .unwrap_or_default() + } + + /// Write the note list, keeping the projects already on disk. Fails + /// without writing when the on-disk data cannot be parsed. + pub fn write_notes(notes: &[Note]) -> Result<(), Box> { + let path = Json::get_data_path(); + let wrapper = Json::read_wrapper()?; + // A missing file is seeded at the latest version; an existing one + // keeps its version even when newer than this binary knows about. + let version = wrapper + .as_ref() + .map(|w| w.version.clone()) + .unwrap_or_else(|| JSON_VERSIONS.last().unwrap().to_string()); + let projects = wrapper.map(|w| w.data).unwrap_or_default(); + + Json::write_internal(&path, &version, &projects, notes) + } - let new_json = migration; + fn write_internal( + path: &PathBuf, + version: &str, + data: &[Project], + notes: &[Note], + ) -> Result<(), Box> { + let json = to_string(&DataWrapperRef { + version, + data, + notes, + }) + .unwrap(); - fs::write(&path, new_json).unwrap(); - fs::rename(&path, new_path)?; + // Write to a temp file in the same directory and rename it over the + // target, so an interrupted write never leaves a truncated data file + // behind (the rename is atomic only within one filesystem). + let mut tmp_name = path.file_name().unwrap().to_os_string(); + tmp_name.push(".tmp"); + let tmp_path = path.with_file_name(tmp_name); - // Save into the internal state the json version of the last migration applied - version_state.clear(); - version_state.push_str(&version) + if let Err(e) = fs::write(&tmp_path, &json).and_then(|()| fs::rename(&tmp_path, path)) { + let _ = fs::remove_file(&tmp_path); + return Err(format!("failed to write {}: {e}", path.display()).into()); } - Ok(true) + Ok(()) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::note::Note; + use crate::task::{TASK_PRIORITY_NONE, TASK_STATUS_DONE}; + use crate::test_utils::{make_task, ENV_LOCK}; - pub fn read() -> Vec { - let version = VERSION.lock().unwrap().to_string(); - let path = Json::get_json_path(version); + #[test] + fn check_creates_empty_data_file_and_read_returns_empty() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); - let json = fs::read_to_string(path).unwrap(); - return from_str::>(&json).unwrap(); + let migrated = Json::check().unwrap(); + assert!(!migrated); + assert!(Json::get_data_path().is_file()); + assert_eq!(Json::read().unwrap(), vec![]); } - pub fn write(projects: Vec) { - let version = VERSION.lock().unwrap().to_string(); - let path = Json::get_json_path(version); + #[test] + fn write_then_read_round_trips() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + Json::check().unwrap(); + + let projects = vec![Project { + title: "p".to_string(), + tasks: vec![make_task("t", TASK_STATUS_DONE, TASK_PRIORITY_NONE)], + }]; + + Json::write(&projects).unwrap(); + assert_eq!(Json::read().unwrap(), projects); + } + + #[test] + fn check_resets_an_empty_data_file() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + + fs::write(Json::get_data_path(), " ").unwrap(); + let migrated = Json::check().unwrap(); + + assert!(!migrated); + assert_eq!(Json::read().unwrap(), vec![]); + } + + #[test] + fn notes_round_trip_and_projects_write_preserves_them() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + Json::check().unwrap(); + + let notes = vec![Note { + title: "n".to_string(), + body: "# hi".to_string(), + created_at: Some(1_700_000_000), + updated_at: None, + }]; + Json::write_notes(¬es).unwrap(); + assert_eq!(Json::read_notes(), notes); + + // A projects write (the common mutation path) must not drop notes + let projects = vec![Project { + title: "p".to_string(), + tasks: vec![make_task("t", TASK_STATUS_DONE, TASK_PRIORITY_NONE)], + }]; + Json::write(&projects).unwrap(); + assert_eq!(Json::read().unwrap(), projects); + assert_eq!(Json::read_notes(), notes); + + // ...and a notes write must not drop projects + Json::write_notes(&[]).unwrap(); + assert_eq!(Json::read().unwrap(), projects); + } + + #[test] + fn write_does_not_leave_a_temp_file_behind() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + Json::check().unwrap(); + + Json::write(&[]).unwrap(); + + let leftovers: Vec<_> = fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .filter(|name| name != DATA_FILE_NAME) + .collect(); + assert!(leftovers.is_empty(), "leftover files: {leftovers:?}"); + } + + #[test] + fn get_dir_path_honors_the_env_var() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + + assert_eq!(Json::get_dir_path(), dir.path()); + } + + #[test] + fn get_dir_path_falls_back_to_the_config_dir() { + // Must not run in parallel with tests that set BASILK_CONFIG_DIR + let _guard = ENV_LOCK.lock().unwrap(); + std::env::remove_var("BASILK_CONFIG_DIR"); + + let path = Json::get_dir_path(); + + assert!(path.ends_with(env!("CARGO_PKG_NAME"))); + } + + #[test] + fn check_returns_false_when_the_data_file_is_already_current() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + Json::check().unwrap(); // creates the file at the current version + + // Second run reads the existing (current) file: no migrations + let migrated = Json::check().unwrap(); + + assert!(!migrated); + } + + #[test] + fn check_migrates_old_versioned_files() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + + // Old format: a bare Vec stored in `.json` + let old_projects = vec![Project { + title: "legacy".to_string(), + tasks: vec![make_task("old task", TASK_STATUS_DONE, 3)], + }]; + let old_path = Json::get_json_path(JSON_VERSIONS[0].to_string()); + fs::write(&old_path, to_string(&old_projects).unwrap()).unwrap(); + + let migrated = Json::check().unwrap(); + + assert!(migrated); + assert!(!old_path.is_file(), "old versioned file is removed"); + + let projects = Json::read().unwrap(); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].title, "legacy"); + // Migrations reset priority to NONE and clear the note + assert_eq!(projects[0].tasks[0].priority, TASK_PRIORITY_NONE); + assert_eq!(projects[0].tasks[0].note, ""); + } + + #[test] + fn failed_legacy_migration_keeps_the_old_file() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + + let old_path = Json::get_json_path(JSON_VERSIONS[0].to_string()); + fs::write(&old_path, to_string(&Vec::::new()).unwrap()).unwrap(); + + // A directory where the data file should be makes the rename fail, + // so the migration errors out before touching the legacy file. + fs::create_dir(Json::get_data_path()).unwrap(); + + assert!(Json::check().is_err()); + assert!( + old_path.is_file(), + "legacy file survives a failed migration" + ); + } + + #[test] + fn read_errors_on_a_corrupt_data_file() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + Json::check().unwrap(); + + fs::write(Json::get_data_path(), "{ not json").unwrap(); + + assert!(Json::read().is_err()); + } + + #[test] + fn write_refuses_to_overwrite_a_corrupt_data_file() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + Json::check().unwrap(); + + let corrupt = "{ not json"; + fs::write(Json::get_data_path(), corrupt).unwrap(); + + assert!(Json::write(&[]).is_err()); + assert!(Json::write_notes(&[]).is_err()); + assert_eq!( + fs::read_to_string(Json::get_data_path()).unwrap(), + corrupt, + "the corrupt file is left untouched" + ); + } + + #[test] + fn read_notes_returns_empty_for_a_missing_file() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + + assert_eq!(Json::read_notes(), vec![]); + } + + #[test] + fn writes_seed_a_missing_file_at_the_latest_version() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + let last_version = *JSON_VERSIONS.last().unwrap(); + + Json::write(&[]).unwrap(); + let wrapper: DataWrapper = + from_str(&fs::read_to_string(Json::get_data_path()).unwrap()).unwrap(); + assert_eq!(wrapper.version, last_version); + + fs::remove_file(Json::get_data_path()).unwrap(); + Json::write_notes(&[]).unwrap(); + let wrapper: DataWrapper = + from_str(&fs::read_to_string(Json::get_data_path()).unwrap()).unwrap(); + assert_eq!(wrapper.version, last_version); + } + + #[test] + fn write_preserves_an_unknown_newer_version() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + Json::check().unwrap(); + + // Simulate a file written by a newer binary: the version must not + // be downgraded by a write. + fs::write( + Json::get_data_path(), + to_string(&DataWrapper { + version: "zzz99".to_string(), + data: vec![], + notes: vec![], + }) + .unwrap(), + ) + .unwrap(); + + Json::write(&[]).unwrap(); - fs::write(path, to_string(&projects).unwrap()).unwrap(); + let wrapper: DataWrapper = + from_str(&fs::read_to_string(Json::get_data_path()).unwrap()).unwrap(); + assert_eq!(wrapper.version, "zzz99"); } } diff --git a/src/main.rs b/src/main.rs index 26f79ff..4a80293 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,67 +1,63 @@ use std::{ + env, error::Error, - fmt::Debug, io::{self, stdout}, + process::exit, + time::Duration, }; -use cli::Cli; +use cli::{Cli, CliAction}; use ratatui::{ crossterm::{ - event::{self, Event, KeyCode, KeyEventKind}, + event::{self, Event, KeyEvent}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }, prelude::*, - widgets::*, }; -use tui_input::{backend::crossterm::EventHandler, Input}; +mod app; mod cli; -mod config; mod json; +mod markdown; mod migration; +mod note; mod project; mod task; +mod timer; mod ui; mod util; mod view; -use config::{Config, ConfigToml}; +use app::{App, KeySource}; use json::Json; -use project::Project; -use task::{Task, TASK_PRIORITIES, TASK_STATUSES}; -use view::View; -#[derive(Default, PartialEq, Debug)] -pub enum ViewMode { - #[default] - ViewProjects, - RenameProject, - AddProject, - DeleteProject, +/// Reads one key event from the terminal, blocking up to 250 ms so the +/// timer can tick between key presses; `None` on timeout. Touches the real +/// terminal, so it is excluded from coverage (tests use `QueuedKeys`). +pub(crate) struct CrosstermSource; - ViewTasks, - RenameTask, - ChangeStatusTask, - ChangePriorityTask, - AddTask, - DeleteTask, - - InfoMigration, -} - -pub struct App { - // TODO: Better list state mgmt - selected_project_index: ListState, - selected_task_index: ListState, - selected_status_task_index: ListState, - selected_priority_task_index: ListState, - view_mode: ViewMode, - projects: Vec, - config: ConfigToml, +impl KeySource for CrosstermSource { + fn next_key(&mut self) -> io::Result> { + if event::poll(Duration::from_millis(250))? { + if let Event::Key(key) = event::read()? { + return Ok(Some(key)); + } + } + Ok(None) + } } fn init_terminal() -> Result, Box> { + // Restore the terminal even if the app panics later: without this a + // panic leaves the terminal in raw mode / alternate screen, which + // looks like "the terminal can no longer accept input". + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = restore_terminal(); + default_hook(panic_info); + })); + enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout()); @@ -76,438 +72,43 @@ fn restore_terminal() -> Result<(), Box> { } fn main() -> Result<(), Box> { - Cli::read(); - - // setup terminal - let terminal = init_terminal()?; + match Cli::parse(env::args().skip(1)) { + CliAction::ShowVersion => { + print!(env!("CARGO_PKG_VERSION")); + exit(0); + } + CliAction::ShowHelp => { + print!("{}", cli::USAGE); + exit(0); + } + CliAction::Run => {} + } // Check the version of the json file let were_applied_migrations = Json::check()?; + // Load the data before touching the terminal so a corrupt data file + // fails with a clear message instead of panicking in raw mode. + let mut app = App::setup().map_err(|e| { + format!( + "failed to load data from {}: {e}", + Json::get_data_path().display() + ) + })?; + + // setup terminal + let terminal = init_terminal()?; + // create app and run it - App::setup().run(terminal, were_applied_migrations)?; + app.run_with_source(terminal, were_applied_migrations, &mut CrosstermSource)?; restore_terminal()?; Ok(()) } -impl App { - fn setup() -> Self { - Self { - selected_project_index: ListState::default().with_selected(Some(0)), - selected_task_index: ListState::default().with_selected(Some(0)), - selected_status_task_index: ListState::default().with_selected(Some(0)), - selected_priority_task_index: ListState::default().with_selected(Some(0)), - view_mode: ViewMode::default(), - projects: Json::read(), - config: Config::read(), - } - } - - fn run( - &mut self, - mut terminal: Terminal, - were_applied_migrations: bool, - ) -> io::Result<()> { - let mut input = Input::default(); - - let mut items: Vec = vec![]; - Project::load_items(self, &mut items); - - let mut status_items: Vec = vec![]; - Task::load_statues_items(&mut status_items); - - let mut priority_items: Vec = vec![]; - Task::load_priority_items(&mut priority_items); - - if were_applied_migrations { - self.view_mode = ViewMode::InfoMigration - } - - loop { - terminal.draw(|f| { - self.render(f, f.size(), &input, &items, &status_items, &priority_items) - })?; - - if let Event::Key(key) = event::read()? { - // Capture only the "Press" event to prevent double input on Windows - if key.kind == KeyEventKind::Press { - use KeyCode::*; - match self.view_mode { - ViewMode::ViewProjects => match key.code { - Enter | Right | Char('l') => { - if items.is_empty() { - continue; - } - - Task::load_items(self, &mut items); - self.selected_task_index.select(Some(0)); - - App::change_view(self, ViewMode::ViewTasks); - } - Char('r') => { - if items.is_empty() { - continue; - } - - input = input - .clone() - .with_value(Project::get_current(self).title.clone()); - - App::change_view(self, ViewMode::RenameProject); - } - Char('n') => { - input.reset(); - - App::change_view(self, ViewMode::AddProject); - } - Char('d') => { - if items.is_empty() { - continue; - } - - App::change_view(self, ViewMode::DeleteProject); - } - Down | Tab | Char('j') => { - self.next(&items); - } - Up | BackTab | Char('k') => { - self.previous(&items); - } - Char('q') => { - return Ok(()); - } - _ => {} - }, - ViewMode::RenameProject => match key.code { - Enter => { - Project::rename(self, &mut items, input.value()); - input.reset(); - - App::change_view(self, ViewMode::ViewProjects); - } - Esc => { - input.reset(); - - App::change_view(self, ViewMode::ViewProjects); - } - _ => { - input.handle_event(&Event::Key(key)); - } - }, - ViewMode::AddProject => match key.code { - Esc => { - App::change_view(self, ViewMode::ViewProjects); - } - Enter => { - Project::create(self, &mut items, input.value()); - self.selected_project_index - .select(Some(self.projects.len())); - - App::change_view(self, ViewMode::ViewProjects); - } - _ => { - input.handle_event(&Event::Key(key)); - } - }, - ViewMode::DeleteProject => match key.code { - Char('y') => { - Project::delete(self, &mut items); - self.selected_project_index.select_previous(); - - App::change_view(self, ViewMode::ViewProjects); - } - Char('n') => { - App::change_view(self, ViewMode::ViewProjects); - } - _ => {} - }, - - ViewMode::ViewTasks => match key.code { - Esc | Left | Char('h') => { - Project::load_items(self, &mut items); - - App::change_view(self, ViewMode::ViewProjects); - } - Enter => { - if items.is_empty() { - continue; - } - - let index = TASK_STATUSES - .into_iter() - .position(|t| t == &Task::get_current(self).status) - .unwrap(); - - self.selected_status_task_index.select(Some(index)); - - App::change_view(self, ViewMode::ChangeStatusTask); - } - Char('p') => { - if items.is_empty() { - continue; - } - - let index = TASK_PRIORITIES - .into_iter() - .position(|t| t == Task::get_current(self).priority) - .unwrap(); - - self.selected_priority_task_index.select(Some(index)); - - App::change_view(self, ViewMode::ChangePriorityTask); - } - Char('r') => { - if items.is_empty() { - continue; - } - - input = input - .clone() - .with_value(Task::get_current(self).title.clone()); +#[cfg(test)] +mod property_tests; - App::change_view(self, ViewMode::RenameTask); - } - Char('n') => { - input.reset(); - - App::change_view(self, ViewMode::AddTask); - } - Char('d') => { - if items.is_empty() { - continue; - } - - App::change_view(self, ViewMode::DeleteTask); - } - Down | Tab | Char('j') => { - self.next(&items); - } - Up | BackTab | Char('k') => { - self.previous(&items); - } - Char('q') => { - return Ok(()); - } - _ => {} - }, - ViewMode::RenameTask => match key.code { - Enter => { - Task::rename(self, &mut items, input.value()); - input.reset(); - - App::change_view(self, ViewMode::ViewTasks); - } - Esc => { - input.reset(); - - App::change_view(self, ViewMode::ViewTasks); - } - _ => { - input.handle_event(&Event::Key(key)); - } - }, - ViewMode::ChangeStatusTask => match key.code { - Enter => { - Task::change_status( - self, - &mut items, - TASK_STATUSES - [self.selected_status_task_index.selected().unwrap()], - ); - - self.selected_status_task_index.select(Some(0)); - App::change_view(self, ViewMode::ViewTasks); - } - - Down | BackTab | Char('j') => { - self.next(&status_items); - } - Up | Tab | Char('k') => { - self.previous(&status_items); - } - Esc => { - App::change_view(self, ViewMode::ViewTasks); - } - _ => {} - }, - ViewMode::ChangePriorityTask => match key.code { - Enter => { - Task::change_priority( - self, - &mut items, - TASK_PRIORITIES - [self.selected_priority_task_index.selected().unwrap()], - ); - - self.selected_priority_task_index.select(Some(0)); - App::change_view(self, ViewMode::ViewTasks); - } - Down | BackTab | Char('j') => { - self.next(&priority_items); - } - Up | Tab | Char('k') => { - self.previous(&priority_items); - } - Esc => { - App::change_view(self, ViewMode::ViewTasks); - } - _ => {} - }, - ViewMode::AddTask => match key.code { - Enter => { - Task::create(self, &mut items, input.value()); - - App::change_view(self, ViewMode::ViewTasks); - } - Esc => { - App::change_view(self, ViewMode::ViewTasks); - } - _ => { - input.handle_event(&Event::Key(key)); - } - }, - ViewMode::DeleteTask => match key.code { - Char('y') => { - Task::delete(self, &mut items); - self.selected_task_index.select_previous(); - - App::change_view(self, ViewMode::ViewTasks); - } - Char('n') => { - App::change_view(self, ViewMode::ViewTasks); - } - _ => {} - }, - - ViewMode::InfoMigration => match key.code { - _ => { - App::change_view(self, ViewMode::ViewProjects); - } - }, - } - } - } - } - } - - fn render( - &mut self, - f: &mut Frame, - area: Rect, - input: &Input, - items: &Vec, - status_items: &Vec, - priority_items: &Vec, - ) { - let layout = Layout::vertical(if self.config.ui.show_help { - [ - Constraint::Percentage(2), - Constraint::Percentage(93), - // Space for the footer helper - Constraint::Percentage(5), - ] - } else { - [ - Constraint::Percentage(2), - // Expand the main area - Constraint::Percentage(98), - // Remove the footer help area - Constraint::Percentage(0), - ] - }); - - let [header_area, main_area, footer_area] = layout.areas(area); - - // Header - f.render_widget( - Paragraph::new(format!("::{}::", env!("CARGO_PKG_NAME"))).centered(), - header_area, - ); - - // Main view - View::show_items(self, items, f, main_area); - - // Other views - if self.view_mode == ViewMode::InfoMigration { - View::show_migration_info_modal(f, area); - } - - if self.view_mode == ViewMode::AddTask || self.view_mode == ViewMode::AddProject { - View::show_new_item_modal(f, area, input) - } - - if self.view_mode == ViewMode::RenameTask || self.view_mode == ViewMode::RenameProject { - View::show_rename_item_modal(f, area, input) - } - - if self.view_mode == ViewMode::DeleteTask || self.view_mode == ViewMode::DeleteProject { - View::show_delete_item_modal(self, f, area) - } - - if self.view_mode == ViewMode::ChangeStatusTask { - View::show_select_task_status_modal(self, status_items, f, area) - } - - if self.view_mode == ViewMode::ChangePriorityTask { - View::show_select_task_priority_modal(self, priority_items, f, area) - } - - if self.config.ui.show_help { - View::show_footer_helper(self, f, footer_area) - } - } - - fn next(&mut self, items: &Vec) -> () { - let i = match self.use_state().selected() { - Some(i) => { - if i >= items.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - - self.use_state().select(Some(i)) - } - - fn previous(&mut self, items: &Vec) { - let i = match self.use_state().selected() { - Some(i) => { - if i == 0 { - items.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - - self.use_state().select(Some(i)) - } - - fn use_state(&mut self) -> &mut ListState { - match self.view_mode { - ViewMode::ViewProjects => return &mut self.selected_project_index, - ViewMode::RenameProject => return &mut self.selected_project_index, - ViewMode::AddProject => return &mut self.selected_project_index, - ViewMode::DeleteProject => return &mut self.selected_project_index, - - ViewMode::ViewTasks => return &mut self.selected_task_index, - ViewMode::RenameTask => return &mut self.selected_task_index, - ViewMode::ChangeStatusTask => return &mut self.selected_status_task_index, - ViewMode::ChangePriorityTask => return &mut self.selected_priority_task_index, - ViewMode::AddTask => return &mut self.selected_task_index, - ViewMode::DeleteTask => return &mut self.selected_task_index, - - ViewMode::InfoMigration => return &mut self.selected_project_index, - }; - } - - fn change_view(&mut self, mode: ViewMode) { - self.view_mode = mode - } -} +#[cfg(test)] +pub(crate) mod test_utils; diff --git a/src/markdown.rs b/src/markdown.rs new file mode 100644 index 0000000..4f2ae52 --- /dev/null +++ b/src/markdown.rs @@ -0,0 +1,407 @@ +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, +}; + +/// Render Markdown source into a ratatui `Text` with terminal styles. +/// Supported: headings, bold/italic/strikethrough, inline code, code +/// blocks, lists (bullets and numbers, nested), blockquotes, links and +/// horizontal rules. Tables are not parsed (the pulldown-cmark tables +/// extension is off), so their source renders as plain text; raw HTML +/// is dropped entirely. +pub fn render_markdown(md: &str) -> Text<'static> { + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + let parser = Parser::new_ext(md, options); + + let mut renderer = Renderer::default(); + renderer.run(parser); + Text::from(renderer.finish()) +} + +#[derive(Default)] +struct Renderer { + lines: Vec>, + spans: Vec>, + style: Style, + style_stack: Vec