diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..df00d6a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,70 @@ +# 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) +- **Lint**: `cargo fmt --all -- --check` (used in CI) +- **Format**: `cargo fmt` + +## Project Structure + +- `src/main.rs`: Entry point, terminal initialization, main event loop, and app state management. +- `src/app.rs` (Wait, I saw `App` in `main.rs`, let me check if there is a separate file or it's all in `main.rs`): App struct and core logic are in `main.rs`. +- `src/cli.rs`: Simple CLI argument handling (e.g., `--version`). +- `src/config.rs`: Configuration management (TOML format). +- `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. + +## Code Patterns + +### App State Management +The `App` struct in `main.rs` manages the application state, including selected indices for projects and tasks, the current `ViewMode`, and loaded data. + +### View Modes +`ViewMode` enum in `main.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()` 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, so writing projects never drops notes and vice versa. +- 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. + +### TUI Logic +- Uses `ratatui` with `crossterm` backend. +- `App::render` in `main.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 in `main.rs` filters for `KeyEventKind::Press` to avoid double-processing on Windows. +- **Event Loop**: The main loop 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. +- **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, 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. +- **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. +- **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. +- **Testing**: Tests share fixtures from `test_utils` in `main.rs`. Any test touching the disk layer must hold `ENV_LOCK` and use `setup_temp_config()` (sets `BASILK_CONFIG_DIR`). +- **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..49ecb7e 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" @@ -30,19 +39,44 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" name = "basilk" version = "0.2.1" 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 = "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 = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +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..db3f463 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,15 @@ default-run = "basilk" # 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..fa5a1ee 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,14 @@ 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) - -```sh -x install basilk -``` - -### Build from source +## Installation -1. Clone the repository ```sh -git clone https://github.com/GabAlpha/basilk && cd basilk +git clone https://github.com/LiuYinCarl/basilk && cd basilk +cargo install --path . ``` -2. Build -```sh -cargo build --release -``` -Binary will be located at `target/release/basilk` ## Usage Run @@ -86,7 +53,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] 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/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..7a185d6 100644 --- a/src/json.rs +++ b/src/json.rs @@ -1,31 +1,54 @@ use std::{ error::Error, - fs::{self, File}, - io::Write, - path::{Path, PathBuf}, - sync::Mutex, + fs, + path::PathBuf, + sync::{Mutex, MutexGuard}, }; -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 DATA_FILE_NAME: &str = "basilk_data.json"; static VERSION: Mutex = Mutex::new(String::new()); +#[derive(Serialize, Deserialize)] +struct DataWrapper { + version: String, + data: Vec, + /// Global notes; `default` keeps pre-notes data files loadable. + #[serde(default)] + notes: Vec, +} + 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; } + fn get_data_path() -> PathBuf { + let mut path = PathBuf::new(); + path.push(Json::get_dir_path().as_path()); + path.push(DATA_FILE_NAME); + + return path; + } + fn get_json_path(version: String) -> PathBuf { let mut path = PathBuf::new(); path.push(Json::get_dir_path().as_path()); @@ -36,79 +59,255 @@ impl Json { pub fn check() -> Result> { fs::create_dir_all(Json::get_dir_path())?; + Json::check_data() + } - // Create the state to save the json version - let mut version_state = VERSION.lock().unwrap(); + /// Decide how to bring the data file up to date: migrate the current + /// file in place, or bootstrap it from the legacy versioned files. + fn check_data() -> Result> { + let version_state = VERSION.lock().unwrap(); + let data_path = Json::get_data_path(); - // 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(version_state, &data_path) + } else { + Json::check_legacy_files(version_state, &data_path) + } + } + + /// The current data file exists. An empty file is reset to the latest + /// version; otherwise the stored version is recorded and any pending + /// migrations are applied one by one. + fn check_data_file( + mut version_state: MutexGuard, + data_path: &PathBuf, + ) -> Result> { + let json_raw = fs::read_to_string(data_path)?; - // If the file doesn't exist create a new one with the last version - if json_version_from_file.is_empty() { + if json_raw.trim().is_empty() { let last_json_version = JSON_VERSIONS.last().unwrap(); - let path = Json::get_json_path(last_json_version.to_string()); + version_state.clear(); + version_state.push_str(last_json_version); + drop(version_state); + Json::write(vec![]); + return Ok(false); + } - let mut file = File::create(path).unwrap(); - let _ = file.write_all(b"[]"); + let wrapper: DataWrapper = from_str(&json_raw)?; + version_state.clear(); + version_state.push_str(&wrapper.version); - json_version_from_file = vec![last_json_version]; - version_state.push_str(json_version_from_file[0]); + 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.iter() { + version_state.clear(); + version_state.push_str(version); + Json::write_internal( + data_path, + version_state.to_string(), + migration_data.clone(), + wrapper.notes.clone(), + ); + } - // 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(); + Ok(true) + } + + /// 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 so that any further + /// migrations are applied on top. + fn check_legacy_files( + mut version_state: MutexGuard, + 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 { + let last_json_version = JSON_VERSIONS.last().unwrap(); + version_state.clear(); + version_state.push_str(last_json_version); + drop(version_state); + Json::write(vec![]); 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); - } + version_state.clear(); + version_state.push_str(old_version); + let wrapper = DataWrapper { + version: old_version.to_string(), + data, + notes: vec![], + }; + fs::write(data_path, to_string(&wrapper).unwrap()).unwrap(); - // 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()); + // Optionally delete old file + let _ = fs::remove_file(old_path); - let new_json = migration; + // Re-run check to apply any further migrations + drop(version_state); + Json::check() + } - fs::write(&path, new_json).unwrap(); - fs::rename(&path, new_path)?; + pub fn read() -> Vec { + let path = Json::get_data_path(); + let json = fs::read_to_string(path).unwrap(); + let wrapper: DataWrapper = from_str(&json).unwrap(); - // Save into the internal state the json version of the last migration applied - version_state.clear(); - version_state.push_str(&version) - } + let mut version_state = VERSION.lock().unwrap(); + version_state.clear(); + version_state.push_str(&wrapper.version); - Ok(true) + return wrapper.data; } - pub fn read() -> Vec { + /// Write the project list, keeping the notes already on disk. + pub fn write(projects: Vec) { let version = VERSION.lock().unwrap().to_string(); - let path = Json::get_json_path(version); + let path = Json::get_data_path(); - let json = fs::read_to_string(path).unwrap(); - return from_str::>(&json).unwrap(); + Json::write_internal(&path, version, projects, Json::read_notes()); } - pub fn write(projects: Vec) { + pub fn read_notes() -> Vec { + let path = Json::get_data_path(); + fs::read_to_string(path) + .ok() + .and_then(|json| from_str::(&json).ok()) + .map(|wrapper| wrapper.notes) + .unwrap_or_default() + } + + /// Write the note list, keeping the projects already on disk. + pub fn write_notes(notes: Vec) { let version = VERSION.lock().unwrap().to_string(); - let path = Json::get_json_path(version); + let path = Json::get_data_path(); + + Json::write_internal(&path, version, Json::read(), notes); + } + + fn write_internal(path: &PathBuf, version: String, data: Vec, notes: Vec) { + let wrapper = DataWrapper { + version, + data, + notes, + }; + fs::write(path, to_string(&wrapper).unwrap()).unwrap(); + } +} + +#[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}; + + #[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 migrated = Json::check().unwrap(); + assert!(!migrated); + assert!(Json::get_data_path().is_file()); + assert_eq!(Json::read(), vec![]); + } + + #[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.clone()); + assert_eq!(Json::read(), 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(), 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(notes.clone()); + 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.clone()); + assert_eq!(Json::read(), projects); + assert_eq!(Json::read_notes(), notes); + + // ...and a notes write must not drop projects + Json::write_notes(vec![]); + assert_eq!(Json::read(), projects); + } + + #[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"); - fs::write(path, to_string(&projects).unwrap()).unwrap(); + let projects = Json::read(); + 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, ""); } } diff --git a/src/main.rs b/src/main.rs index 26f79ff..eab645c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,12 +2,13 @@ use std::{ error::Error, fmt::Debug, io::{self, stdout}, + time::Duration, }; use cli::Cli; use ratatui::{ crossterm::{ - event::{self, Event, KeyCode, KeyEventKind}, + event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }, @@ -17,19 +18,25 @@ use ratatui::{ use tui_input::{backend::crossterm::EventHandler, Input}; 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 json::Json; +use note::Note; use project::Project; use task::{Task, TASK_PRIORITIES, TASK_STATUSES}; +use timer::{TimerKind, TimerState}; +use tui_textarea::TextArea; +use ui::Ui; +use util::Util; use view::View; #[derive(Default, PartialEq, Debug)] @@ -46,6 +53,19 @@ pub enum ViewMode { ChangePriorityTask, AddTask, DeleteTask, + ViewTaskDetails, + EditTaskNote, + SetTaskEstimate, + TimerTask, + SetCountdown, + ViewHelp, + + ViewNotes, + AddNote, + RenameNote, + DeleteNote, + ViewNote, + EditNote, InfoMigration, } @@ -56,9 +76,39 @@ pub struct App { selected_task_index: ListState, selected_status_task_index: ListState, selected_priority_task_index: ListState, + delete_confirm_index: ListState, view_mode: ViewMode, + previous_view_mode: ViewMode, projects: Vec, - config: ConfigToml, + hide_done_tasks: bool, + timer: Option, + /// When true, the task view renders as a three-lane kanban board + /// (Up Next / On Going / Done) instead of the classic list. + board_view: bool, + /// Currently focused board lane: index into `TASK_STATUSES`. + board_lane: usize, + /// Per-lane selection/scroll state for the board view. + board_lane_states: [ListState; 3], + /// Global notes, independent of projects. + notes: Vec, + selected_note_index: ListState, + /// Vertical scroll offset of the note preview page. + note_scroll: u16, + /// Editor state while `ViewMode::EditNote` is active. + 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, } fn init_terminal() -> Result, Box> { @@ -78,12 +128,12 @@ fn restore_terminal() -> Result<(), Box> { fn main() -> Result<(), Box> { Cli::read(); - // setup terminal - let terminal = init_terminal()?; - // Check the version of the json file let were_applied_migrations = Json::check()?; + // setup terminal + let terminal = init_terminal()?; + // create app and run it App::setup().run(terminal, were_applied_migrations)?; @@ -99,9 +149,23 @@ impl App { 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(), - config: Config::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, } } @@ -121,275 +185,958 @@ impl App { let mut priority_items: Vec = vec![]; Task::load_priority_items(&mut priority_items); + let mut delete_confirm_items: Vec = vec![]; + Ui::load_delete_confirm_items(&mut delete_confirm_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) + self.render( + f, + f.size(), + &input, + &items, + &status_items, + &priority_items, + &delete_confirm_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); + if event::poll(Duration::from_millis(250))? { + if let Event::Key(key) = event::read()? { + // 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, + &delete_confirm_items, + ) { + KeyAction::Quit => { + self.settle_timer(); + return Ok(()); } - Char('r') => { - if items.is_empty() { - continue; - } + KeyAction::Skip => continue, + KeyAction::None => {} + } + } + } + } - input = input - .clone() - .with_value(Project::get_current(self).title.clone()); + self.tick_timer(); + } + } - App::change_view(self, ViewMode::RenameProject); - } - Char('n') => { - input.reset(); + /// 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: &Vec, + priority_items: &Vec, + delete_confirm_items: &Vec, + ) -> 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, delete_confirm_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, delete_confirm_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, delete_confirm_items), + ViewMode::ViewNote => self.handle_view_note(key, items), + ViewMode::EditNote => self.handle_edit_note(key), + ViewMode::InfoMigration => { + App::change_view(self, ViewMode::ViewProjects); + KeyAction::None + } + } + } - App::change_view(self, ViewMode::AddProject); - } - Char('d') => { - if items.is_empty() { - continue; - } + 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; + App::change_view(self, ViewMode::ViewHelp); + } + Enter | Right | Char('l') => { + if items.is_empty() { + return KeyAction::Skip; + } - 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(); + Task::load_items(self, items); + self.selected_task_index.select(Some(0)); - 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())); + // The sync inside `load_items` ran before the + // selection was reset to the top + if self.board_view { + self.board_sync(); + } - 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::ViewTasks); + } + Char('r') => { + if items.is_empty() { + return KeyAction::Skip; + } - App::change_view(self, ViewMode::ViewProjects); - } - Char('n') => { - App::change_view(self, ViewMode::ViewProjects); - } - _ => {} - }, + *input = input + .clone() + .with_value(Project::get_current(self).title.clone()); - ViewMode::ViewTasks => match key.code { - Esc | Left | Char('h') => { - Project::load_items(self, &mut items); + App::change_view(self, ViewMode::RenameProject); + } + Char('n') => { + input.reset(); - App::change_view(self, ViewMode::ViewProjects); - } - Enter => { - if items.is_empty() { - continue; - } + App::change_view(self, ViewMode::AddProject); + } + Char('d') => { + if items.is_empty() { + return KeyAction::Skip; + } - let index = TASK_STATUSES - .into_iter() - .position(|t| t == &Task::get_current(self).status) - .unwrap(); + App::change_view(self, ViewMode::DeleteProject); + } + Down | Tab | Char('j') => { + self.next(items); + } + Up | BackTab | Char('k') => { + self.previous(items); + } + Char('c') => { + self.previous_view_mode = ViewMode::ViewProjects; - self.selected_status_task_index.select(Some(index)); + if self.timer.is_some() { + App::change_view(self, ViewMode::TimerTask); + } else { + input.reset(); - App::change_view(self, ViewMode::ChangeStatusTask); - } - Char('p') => { - if items.is_empty() { - continue; - } + App::change_view(self, ViewMode::SetCountdown); + } + } + Char('m') => { + Note::load_items(self, items); - let index = TASK_PRIORITIES - .into_iter() - .position(|t| t == Task::get_current(self).priority) - .unwrap(); + App::change_view(self, ViewMode::ViewNotes); + } + Char('q') => { + return KeyAction::Quit; + } + _ => {} + } + KeyAction::None + } - self.selected_priority_task_index.select(Some(index)); + 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(); + + App::change_view(self, ViewMode::ViewProjects); + } + Esc => { + input.reset(); - App::change_view(self, ViewMode::ChangePriorityTask); - } - Char('r') => { - if items.is_empty() { - continue; - } + App::change_view(self, ViewMode::ViewProjects); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } - input = input - .clone() - .with_value(Task::get_current(self).title.clone()); + fn handle_add_project( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Esc => { + App::change_view(self, ViewMode::ViewProjects); + } + Enter => { + if !input.value().is_empty() { + Project::create(self, items, input.value()); + self.selected_project_index + .select(Some(self.projects.len() - 1)); + } - App::change_view(self, ViewMode::RenameTask); - } - Char('n') => { - input.reset(); + App::change_view(self, ViewMode::ViewProjects); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } - App::change_view(self, ViewMode::AddTask); - } - Char('d') => { - if items.is_empty() { - continue; - } + fn handle_delete_project( + &mut self, + key: KeyEvent, + items: &mut Vec, + delete_confirm_items: &Vec, + ) -> KeyAction { + if self.handle_modal_nav(key.code, 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)); + App::change_view(self, ViewMode::ViewProjects); + } + KeyAction::None + } - 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(); + 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() + }; - 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); - } + match key.code { + Char('h') => { + self.previous_view_mode = ViewMode::ViewTasks; + App::change_view(self, ViewMode::ViewHelp); + } + Esc => { + Project::load_items(self, items); - 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::ViewProjects); + } + Left => { + if self.board_view { + self.board_switch_lane(false); + } else { + Project::load_items(self, items); - 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::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; + } - App::change_view(self, ViewMode::ViewTasks); - } - Char('n') => { - App::change_view(self, ViewMode::ViewTasks); - } - _ => {} - }, + let index = TASK_STATUSES + .into_iter() + .position(|t| t == &Task::get_current(self).status) + .unwrap(); - ViewMode::InfoMigration => match key.code { - _ => { - App::change_view(self, ViewMode::ViewProjects); - } - }, + self.selected_status_task_index.select(Some(index)); + + App::change_view(self, 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)); + + App::change_view(self, ViewMode::ChangePriorityTask); + } + Char('r') => { + if no_current_task { + return KeyAction::Skip; + } + + *input = input + .clone() + .with_value(Task::get_current(self).title.clone()); + + App::change_view(self, ViewMode::RenameTask); + } + Char('n') => { + input.reset(); + + App::change_view(self, ViewMode::AddTask); + } + Char('d') => { + if no_current_task { + return KeyAction::Skip; + } + + App::change_view(self, ViewMode::DeleteTask); + } + Char('v') => { + if no_current_task { + return KeyAction::Skip; + } + + App::change_view(self, ViewMode::ViewTaskDetails); + } + Char('e') => { + if no_current_task { + return KeyAction::Skip; + } + + *input = input + .clone() + .with_value(Task::get_current(self).note.clone()); + + App::change_view(self, 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() { + App::change_view(self, 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)); + + App::change_view(self, ViewMode::TimerTask); + } + } + Char('c') => { + self.previous_view_mode = ViewMode::ViewTasks; + + if self.timer.is_some() { + App::change_view(self, ViewMode::TimerTask); + } else { + input.reset(); + + App::change_view(self, 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(); + + Task::rename(self, items, &new_title); + 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; + } } } + + App::change_view(self, ViewMode::ViewTasks); + } + Esc => { + input.reset(); + + App::change_view(self, ViewMode::ViewTasks); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_change_status_task( + &mut self, + key: KeyEvent, + items: &mut Vec, + status_items: &Vec, + ) -> 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)); + App::change_view(self, ViewMode::ViewTasks); + } + KeyAction::None + } + + fn handle_change_priority_task( + &mut self, + key: KeyEvent, + items: &mut Vec, + priority_items: &Vec, + ) -> 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)); + App::change_view(self, 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 => { + Task::create(self, items, input.value()); + + App::change_view(self, ViewMode::ViewTasks); + } + Esc => { + App::change_view(self, ViewMode::ViewTasks); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_delete_task( + &mut self, + key: KeyEvent, + items: &mut Vec, + delete_confirm_items: &Vec, + ) -> KeyAction { + if self.handle_modal_nav(key.code, 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)); + App::change_view(self, 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 + .clone() + .with_value(Task::get_current(self).note.clone()); + + App::change_view(self, 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.clone().with_value(if current > 0 { + current.to_string() + } else { + String::new() + }); + + App::change_view(self, ViewMode::SetTaskEstimate); + } + _ => { + App::change_view(self, 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(); + + App::change_view(self, ViewMode::ViewTaskDetails); + } + Esc => { + input.reset(); + + App::change_view(self, 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(); + + App::change_view(self, ViewMode::ViewTaskDetails); + } else if raw.is_empty() { + input.reset(); + + App::change_view(self, ViewMode::ViewTaskDetails); + } + // Invalid non-empty input: stay in the modal + } + Esc => { + input.reset(); + + App::change_view(self, 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)); + + App::change_view(self, 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; + App::change_view(self, ViewMode::ViewHelp); + } + Esc | Left => { + Project::load_items(self, items); + + App::change_view(self, ViewMode::ViewProjects); + } + Enter | Right | Char('l') | Char('v') => { + if items.is_empty() { + return KeyAction::Skip; + } + + self.note_scroll = 0; + + App::change_view(self, ViewMode::ViewNote); + } + Char('n') => { + input.reset(); + + App::change_view(self, ViewMode::AddNote); + } + Char('r') => { + if items.is_empty() { + return KeyAction::Skip; + } + + *input = input + .clone() + .with_value(Note::get_current(self).title.clone()); + + App::change_view(self, ViewMode::RenameNote); + } + Char('d') => { + if items.is_empty() { + return KeyAction::Skip; + } + + App::change_view(self, 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))); + } + + App::change_view(self, ViewMode::ViewNotes); + } + Esc => { + input.reset(); + + App::change_view(self, 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(); + + App::change_view(self, ViewMode::ViewNotes); + } + Esc => { + input.reset(); + + App::change_view(self, ViewMode::ViewNotes); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_delete_note( + &mut self, + key: KeyEvent, + items: &mut Vec, + delete_confirm_items: &Vec, + ) -> KeyAction { + if self.handle_modal_nav(key.code, 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)); + App::change_view(self, 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; + App::change_view(self, 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); + + App::change_view(self, 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); + + App::change_view(self, 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); + } + } + + App::change_view(self, ViewMode::ViewNote); + return KeyAction::None; + } + + if let Some(textarea) = self.note_textarea.as_mut() { + textarea.input(key); + } + KeyAction::None } fn render( @@ -400,25 +1147,15 @@ impl App { items: &Vec, status_items: &Vec, priority_items: &Vec, + delete_confirm_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 layout = Layout::vertical([ + Constraint::Percentage(2), + Constraint::Percentage(96), + Constraint::Percentage(2), + ]); - let [header_area, main_area, footer_area] = layout.areas(area); + let [header_area, main_area, hint_area] = layout.areas(area); // Header f.render_widget( @@ -426,40 +1163,129 @@ impl App { 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); - } + // Hint and timer readout + self.render_hint(f, hint_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) + // 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), } - if self.view_mode == ViewMode::DeleteTask || self.view_mode == ViewMode::DeleteProject { - View::show_delete_item_modal(self, f, area) - } + // Modal on top of the current view, if any + self.render_modal( + f, + area, + input, + status_items, + priority_items, + delete_confirm_items, + ); + } - if self.view_mode == ViewMode::ChangeStatusTask { - View::show_select_task_status_modal(self, status_items, f, area) - } + /// 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 self.view_mode == ViewMode::ChangePriorityTask { - View::show_select_task_priority_modal(self, priority_items, f, 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, + ); } + } - if self.config.ui.show_help { - View::show_footer_helper(self, f, footer_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: &Vec, + priority_items: &Vec, + delete_confirm_items: &Vec, + ) { + 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, delete_confirm_items, 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: &Vec) -> () { + if items.is_empty() { + return; + } + let i = match self.use_state().selected() { Some(i) => { if i >= items.len() - 1 { @@ -475,6 +1301,10 @@ impl App { } fn previous(&mut self, items: &Vec) { + if items.is_empty() { + return; + } + let i = match self.use_state().selected() { Some(i) => { if i == 0 { @@ -489,20 +1319,148 @@ impl App { 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. + 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; + }; + 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])); + } + 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::DeleteProject => return &mut self.delete_confirm_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::DeleteTask => return &mut self.delete_confirm_index, + ViewMode::ViewTaskDetails => return &mut self.selected_task_index, + ViewMode::EditTaskNote => return &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; + } + return &mut self.selected_task_index; + } + ViewMode::SetTaskEstimate => return &mut self.selected_task_index, + ViewMode::ViewNotes => return &mut self.selected_note_index, + ViewMode::AddNote => return &mut self.selected_note_index, + ViewMode::RenameNote => return &mut self.selected_note_index, + ViewMode::DeleteNote => return &mut self.delete_confirm_index, + ViewMode::ViewNote => return &mut self.selected_note_index, + ViewMode::EditNote => return &mut self.selected_note_index, + + ViewMode::ViewHelp => return &mut self.selected_project_index, ViewMode::InfoMigration => return &mut self.selected_project_index, }; } @@ -510,4 +1468,382 @@ impl App { 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); + App::change_view(self, 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: &Vec, + return_mode: ViewMode, + ) -> bool { + match key { + KeyCode::Esc => { + self.use_state().select(Some(0)); + App::change_view(self, return_mode); + true + } + KeyCode::Down | KeyCode::BackTab | KeyCode::Char('j') => { + self.next(items); + true + } + KeyCode::Up | KeyCode::Tab | KeyCode::Char('k') => { + self.previous(items); + true + } + _ => false, + } + } +} + +#[cfg(test)] +mod property_tests; + +#[cfg(test)] +pub(crate) mod test_utils { + use super::*; + use crate::task::{TASK_PRIORITY_NONE, TASK_STATUS_UP_NEXT}; + use std::sync::Mutex; + use tempfile::TempDir; + + /// Serializes tests that mutate the process-wide `BASILK_CONFIG_DIR` + /// env var and the JSON `VERSION` state. + pub(crate) static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// Points the JSON storage at a fresh temporary directory. + /// The returned `TempDir` must be kept alive for the duration of the test. + pub(crate) fn setup_temp_config() -> TempDir { + let dir = TempDir::new().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + Json::check().unwrap(); + dir + } + + pub(crate) fn make_app(projects: Vec) -> App { + App { + 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, + 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: vec![], + selected_note_index: ListState::default().with_selected(Some(0)), + note_scroll: 0, + note_textarea: None, + } + } + + pub(crate) fn make_task(title: &str, status: &str, priority: u8) -> Task { + Task { + title: title.to_string(), + status: status.to_string(), + priority, + created_at: Some(1_700_000_000), + completed_at: None, + note: String::new(), + time_spent_secs: 0, + estimated_hours: 0, + } + } + + pub(crate) fn sample_projects() -> Vec { + vec![ + Project { + title: "alpha".to_string(), + tasks: vec![ + make_task("a1", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + make_task("a2", TASK_STATUS_UP_NEXT, 1), + ], + }, + Project { + title: "beta".to_string(), + tasks: vec![], + }, + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_utils::make_app; + + #[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 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(&mut 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(&mut app).title, "a"); + + // Exercise the same code path as the DeleteTask handler + app.delete_current_task(&mut items); + + assert_eq!(Task::get_current(&mut 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" + ); + } + } } diff --git a/src/markdown.rs b/src/markdown.rs new file mode 100644 index 0000000..ab00759 --- /dev/null +++ b/src/markdown.rs @@ -0,0 +1,374 @@ +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 and raw HTML fall back to plain text. +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