From 0d66ecf499cb385a8b95339c5735ba4678d2ad98 Mon Sep 17 00:00:00 2001 From: LiuYinCarl Date: Mon, 19 Jan 2026 22:07:54 +0800 Subject: [PATCH 01/23] add detail view and create/finish time for task --- Cargo.lock | 206 +++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/main.rs | 18 +++++ src/task.rs | 35 +++++++-- src/view.rs | 111 +++++++++++++++++++++++++++- 5 files changed, 360 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a1e253..69d5d5e 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" @@ -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,6 +39,7 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" name = "basilk" version = "0.2.1" dependencies = [ + "chrono", "dirs", "ratatui", "serde", @@ -44,6 +54,12 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + [[package]] name = "cassowary" version = "0.3.0" @@ -59,12 +75,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 +117,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" @@ -136,6 +181,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + [[package]] name = "getrandom" version = "0.2.15" @@ -163,6 +214,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "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]] name = "indexmap" version = "2.5.0" @@ -188,6 +263,16 @@ 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" @@ -247,6 +332,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -406,6 +500,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook" version = "0.3.17" @@ -596,6 +696,51 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[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 +763,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" diff --git a/Cargo.toml b/Cargo.toml index 0c06b5f..a94f8ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ 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" ratatui = "0.27.0" serde = { version = "1.0.204", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index 26f79ff..6f067dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ pub enum ViewMode { ChangePriorityTask, AddTask, DeleteTask, + ViewTaskDetails, InfoMigration, } @@ -281,6 +282,13 @@ impl App { App::change_view(self, ViewMode::DeleteTask); } + Char('v') => { + if items.is_empty() { + continue; + } + + App::change_view(self, ViewMode::ViewTaskDetails); + } Down | Tab | Char('j') => { self.next(&items); } @@ -380,6 +388,11 @@ impl App { } _ => {} }, + ViewMode::ViewTaskDetails => match key.code { + _ => { + App::change_view(self, ViewMode::ViewTasks); + } + }, ViewMode::InfoMigration => match key.code { _ => { @@ -454,6 +467,10 @@ impl App { View::show_select_task_priority_modal(self, priority_items, f, area) } + if self.view_mode == ViewMode::ViewTaskDetails { + View::show_task_details_modal(self, f, area) + } + if self.config.ui.show_help { View::show_footer_helper(self, f, footer_area) } @@ -502,6 +519,7 @@ impl App { 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::ViewTaskDetails => return &mut self.selected_task_index, ViewMode::InfoMigration => return &mut self.selected_project_index, }; diff --git a/src/task.rs b/src/task.rs index 37bf5dd..8cf6f68 100644 --- a/src/task.rs +++ b/src/task.rs @@ -4,6 +4,7 @@ use ratatui::{ widgets::ListItem, }; use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::{json::Json, util::Util, App}; @@ -12,6 +13,10 @@ pub struct Task { pub title: String, pub status: String, pub priority: u8, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub completed_at: Option, } pub const TASK_STATUS_DONE: &str = "Done"; @@ -28,7 +33,7 @@ const TASK_STATUSES_SORT_ORDER: [&'static str; 3] = pub const TASK_PRIORITIES: [u8; 4] = [1, 2, 3, 0]; impl Task { - fn get_status_color(status: &String) -> ratatui::prelude::Color { + pub fn get_status_color(status: &String) -> ratatui::prelude::Color { match status.as_str() { TASK_STATUS_DONE => return Color::LightGreen, TASK_STATUS_ON_GOING => return Color::Yellow, @@ -37,6 +42,13 @@ impl Task { } } + fn current_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + } + pub fn load_statues_items(items: &mut Vec) { items.clear(); @@ -73,6 +85,8 @@ impl Task { title: "".to_string(), status: "".to_string(), priority: 0, + created_at: None, + completed_at: None, }) .clone() .title; @@ -150,6 +164,8 @@ impl Task { title: value.to_string(), status: TASK_STATUS_UP_NEXT.to_string(), priority: 0, + created_at: Some(Self::current_timestamp()), + completed_at: None, }; let mut internal_projects = app.projects.clone(); @@ -176,14 +192,19 @@ impl Task { let mut internal_projects = app.projects.clone(); let status = value.to_string(); - internal_projects[app.selected_project_index.selected().unwrap()].tasks - [app.selected_task_index.selected().unwrap()] - .status = status.clone(); + let task = &mut internal_projects[app.selected_project_index.selected().unwrap()].tasks + [app.selected_task_index.selected().unwrap()]; + + task.status = status.clone(); if status == TASK_STATUS_DONE { - internal_projects[app.selected_project_index.selected().unwrap()].tasks - [app.selected_task_index.selected().unwrap()] - .priority = 0 + task.priority = 0; + if task.completed_at.is_none() { + task.completed_at = Some(Self::current_timestamp()); + } + } else { + // If changing from Done to another status, clear completion time + task.completed_at = None; } Json::write(internal_projects); diff --git a/src/view.rs b/src/view.rs index 9ccebbd..20bc6a6 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1,11 +1,12 @@ use ratatui::{ layout::{Alignment, Rect}, - style::{Modifier, Style}, - text::{Line, Text}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, widgets::{Block, Clear, HighlightSpacing, List, ListItem, Paragraph, Wrap}, Frame, }; use tui_input::Input; +use chrono::{DateTime, Local}; use crate::{project::Project, task::Task, ui::Ui, util::Util, App, ViewMode}; @@ -111,6 +112,109 @@ impl View { } } + pub fn show_task_details_modal(app: &mut App, f: &mut Frame, area: Rect) { + let task = Task::get_current(app); + + let format_timestamp = |timestamp: Option| -> String { + timestamp + .and_then(|ts| DateTime::from_timestamp(ts as i64, 0)) + .map(|dt| { + let local_dt: DateTime = dt.into(); + local_dt.format("%Y-%m-%d %H:%M:%S %z").to_string() + }) + .unwrap_or_else(|| "N/A".to_string()) + }; + + let format_duration = |start: Option, end: Option| -> String { + match (start, end) { + (Some(s), Some(e)) => { + if e > s { + let duration_secs = e - s; + let days = duration_secs / 86400; + let hours = (duration_secs % 86400) / 3600; + let minutes = (duration_secs % 3600) / 60; + let seconds = duration_secs % 60; + + if days > 0 { + format!("{}d {}h {}m {}s", days, hours, minutes, seconds) + } else if hours > 0 { + format!("{}h {}m {}s", hours, minutes, seconds) + } else if minutes > 0 { + format!("{}m {}s", minutes, seconds) + } else { + format!("{}s", seconds) + } + } else { + "Invalid time range".to_string() + } + } + _ => "Task not completed".to_string(), + } + }; + + let priority_text = if task.priority == 0 { + "None".to_string() + } else { + format!("{} ({})", task.priority, Util::get_priority_indicator(task.priority)) + }; + + let mut lines = vec![ + Line::from(vec![ + Span::styled("Task: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(&task.title), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled("Status: ", Style::default().fg(Color::Cyan)), + Span::styled(&task.status, Style::default().fg(Task::get_status_color(&task.status))), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled("Priority: ", Style::default().fg(Color::Cyan)), + Span::styled(priority_text, Style::default().fg(Color::Red)), + ]), + Line::raw(""), + ]; + + // Add creation time if available + if task.created_at.is_some() { + lines.push(Line::from(vec![ + Span::styled("Created: ", Style::default().fg(Color::Cyan)), + Span::raw(format_timestamp(task.created_at)), + ])); + lines.push(Line::raw("")); + } + + // Add completion time if available + if task.completed_at.is_some() { + lines.push(Line::from(vec![ + Span::styled("Completed: ", Style::default().fg(Color::Cyan)), + Span::raw(format_timestamp(task.completed_at)), + ])); + lines.push(Line::raw("")); + } + + // Add time consumed if both timestamps are available + if task.created_at.is_some() { + lines.push(Line::from(vec![ + Span::styled("Time Consumed: ", Style::default().fg(Color::Cyan)), + Span::raw(format_duration(task.created_at, task.completed_at)), + ])); + lines.push(Line::raw("")); + } + + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::styled("Press any key to close", Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC)), + ])); + + let widget = Paragraph::new(Text::from(lines)) + .alignment(Alignment::Left) + .block(Block::bordered().title(" Task Details ")); + + Ui::create_modal(f, 60, 18, area, widget) + } + pub fn show_footer_helper(app: &mut App, f: &mut Frame, area: Rect) { let help_string = match app.view_mode { ViewMode::ViewProjects => { @@ -121,13 +225,14 @@ impl View { ViewMode::DeleteProject => " confirm - cancel", ViewMode::ViewTasks => { - " next/prev - go to projects - change status -

change priority - new - rename - delete - quit" + " next/prev - go to projects - change status -

change priority - new - rename - details - delete - quit" } ViewMode::RenameTask => " confirm - cancel", ViewMode::ChangeStatusTask => " next/prev - confirm - cancel", ViewMode::ChangePriorityTask => " next/prev - confirm - cancel", ViewMode::AddTask => " confirm - cancel", ViewMode::DeleteTask => " confirm - cancel", + ViewMode::ViewTaskDetails => " close", ViewMode::InfoMigration => "" }; From 0bdf4043c44e26bfda193380ff41d8e9d95ae8b4 Mon Sep 17 00:00:00 2001 From: LiuYinCarl <1427518212@qq.coom> Date: Sat, 24 Jan 2026 12:20:19 +0800 Subject: [PATCH 02/23] add task note and agent support --- AGENTS.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 40 ++++++++++++++++++++++++++++++++ src/migration.rs | 53 +++++++++++++------------------------------ src/task.rs | 15 ++++++++++++ src/view.rs | 13 ++++++++++- 5 files changed, 142 insertions(+), 38 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3f02d36 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# 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` (Note: Currently the project has no automated tests in `src/`) +- **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/migration.rs`: JSON data schema migrations. +- `src/project.rs`: Project data model and logic. +- `src/task.rs`: Task data model, status/priority constants, and logic. +- `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. + +## 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 JSON files named after a version hash (e.g., `911fc.json`) in the user's config directory. +- `Json::read()` and `Json::write()` handle loading and saving the entire project list. +- 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. +- **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 by status and priority during `Task::load_items`. +- **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 +Stored in `config.toml` in the config directory. Currently only supports `ui.show_help`. diff --git a/src/main.rs b/src/main.rs index 6f067dd..6689804 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,7 @@ pub enum ViewMode { AddTask, DeleteTask, ViewTaskDetails, + EditTaskNote, InfoMigration, } @@ -289,6 +290,17 @@ impl App { App::change_view(self, ViewMode::ViewTaskDetails); } + Char('e') => { + if items.is_empty() { + continue; + } + + input = input + .clone() + .with_value(Task::get_current(self).note.clone()); + + App::change_view(self, ViewMode::EditTaskNote); + } Down | Tab | Char('j') => { self.next(&items); } @@ -389,10 +401,33 @@ impl App { _ => {} }, ViewMode::ViewTaskDetails => match key.code { + Char('e') => { + input = input + .clone() + .with_value(Task::get_current(self).note.clone()); + + App::change_view(self, ViewMode::EditTaskNote); + } _ => { App::change_view(self, ViewMode::ViewTasks); } }, + ViewMode::EditTaskNote => match key.code { + Enter => { + Task::update_note(self, &mut 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)); + } + }, ViewMode::InfoMigration => match key.code { _ => { @@ -455,6 +490,10 @@ impl App { View::show_rename_item_modal(f, area, input) } + if self.view_mode == ViewMode::EditTaskNote { + View::show_edit_note_modal(f, area, input) + } + if self.view_mode == ViewMode::DeleteTask || self.view_mode == ViewMode::DeleteProject { View::show_delete_item_modal(self, f, area) } @@ -520,6 +559,7 @@ impl App { ViewMode::AddTask => return &mut self.selected_task_index, ViewMode::DeleteTask => return &mut self.selected_task_index, ViewMode::ViewTaskDetails => return &mut self.selected_task_index, + ViewMode::EditTaskNote => return &mut self.selected_task_index, ViewMode::InfoMigration => return &mut self.selected_project_index, }; diff --git a/src/migration.rs b/src/migration.rs index 4a83687..2fdd8ff 100644 --- a/src/migration.rs +++ b/src/migration.rs @@ -1,10 +1,10 @@ use serde_json::{ - json, to_string, Map, + json, to_string, Value::{self}, }; -// sha of 0.1.0 0.2.0 -pub static JSON_VERSIONS: [&str; 2] = ["6ad96", "911fc"]; +// sha of 0.1.0 0.2.0 0.2.2 +pub static JSON_VERSIONS: [&str; 3] = ["6ad96", "911fc", "a4e1b"]; pub struct Migration; @@ -13,7 +13,8 @@ impl Migration { // Mapper between json version and the relative migration let mapper: Vec<(&str, String)> = vec![ ("6ad96", "".to_string()), - ("911fc", Migration::add_priority(original_json)), + ("911fc", Migration::add_priority(original_json.clone())), + ("a4e1b", Migration::add_note(original_json)), ]; // The start index where the migration are picked @@ -33,39 +34,17 @@ impl Migration { } // Migrations - fn add_priority(original_json: Vec) -> String { - let mut internal_json = original_json.clone(); - - let new_json: Vec> = internal_json - .iter_mut() - .map(|p| { - // Get all tasks from each project and convert into "Map" type from serde - // in order to do some operations with the json - // Vec = Array ; Map = Object - let mut tasks = serde_json::from_value::>>( - p.get("tasks").unwrap().clone(), - ) - .unwrap(); - - // Add to each task a new key value (i.e. {priority: 0}) - tasks.iter_mut().for_each(|t| { - // Entry and or_insert methods are used for add a new key - // cf. https://docs.rs/serde_json/latest/serde_json/map/enum.Entry.html#method.or_insert - t.entry("priority").or_insert(json!(0)); - }); - - // Convert "p" into the "Map" type from serde in order to do some operations with the json - let mut project = serde_json::from_value::>(p.clone()).unwrap(); - - // Replace the "tasks" key with the new one - // Insert method is used for replace a new with a new value - // cf. https://docs.rs/serde_json/latest/serde_json/map/struct.Map.html#method.insert - project.insert("tasks".to_string(), json!(tasks)).unwrap(); - - return project; - }) - .collect(); + fn add_priority(mut json: Vec) -> String { + for t in json.iter_mut().flat_map(|p| p.get_mut("tasks")).flat_map(|t| t.as_array_mut()).flatten() { + t.as_object_mut().unwrap().insert("priority".to_string(), json!(0)); + } + to_string(&json).unwrap() + } - return to_string(&new_json).unwrap(); + fn add_note(mut json: Vec) -> String { + for t in json.iter_mut().flat_map(|p| p.get_mut("tasks")).flat_map(|t| t.as_array_mut()).flatten() { + t.as_object_mut().unwrap().insert("note".to_string(), json!("")); + } + to_string(&json).unwrap() } } diff --git a/src/task.rs b/src/task.rs index 8cf6f68..115c6a5 100644 --- a/src/task.rs +++ b/src/task.rs @@ -17,6 +17,8 @@ pub struct Task { pub created_at: Option, #[serde(default)] pub completed_at: Option, + #[serde(default)] + pub note: String, } pub const TASK_STATUS_DONE: &str = "Done"; @@ -87,6 +89,7 @@ impl Task { priority: 0, created_at: None, completed_at: None, + note: "".to_string(), }) .clone() .title; @@ -166,6 +169,7 @@ impl Task { priority: 0, created_at: Some(Self::current_timestamp()), completed_at: None, + note: "".to_string(), }; let mut internal_projects = app.projects.clone(); @@ -188,6 +192,17 @@ impl Task { Task::reload(app, items) } + pub fn update_note(app: &mut App, items: &mut Vec, value: &str) { + let mut internal_projects = app.projects.clone(); + + internal_projects[app.selected_project_index.selected().unwrap()].tasks + [app.selected_task_index.selected().unwrap()] + .note = value.to_string(); + + Json::write(internal_projects); + Task::reload(app, items) + } + pub fn change_status(app: &mut App, items: &mut Vec, value: &str) { let mut internal_projects = app.projects.clone(); let status = value.to_string(); diff --git a/src/view.rs b/src/view.rs index 20bc6a6..e3b308f 100644 --- a/src/view.rs +++ b/src/view.rs @@ -32,6 +32,10 @@ impl View { Ui::create_input_modal("Rename", f, area, input) } + pub fn show_edit_note_modal(f: &mut Frame, area: Rect, input: &Input) { + Ui::create_input_modal("Note", f, area, input) + } + pub fn show_delete_item_modal(app: &mut App, f: &mut Frame, area: Rect) { let title = match app.view_mode { ViewMode::DeleteTask => &Task::get_current(app).title, @@ -174,6 +178,11 @@ impl View { Span::styled(priority_text, Style::default().fg(Color::Red)), ]), Line::raw(""), + Line::from(vec![ + Span::styled("Note: ", Style::default().fg(Color::Cyan)), + Span::raw(&task.note), + ]), + Line::raw(""), ]; // Add creation time if available @@ -210,6 +219,7 @@ impl View { let widget = Paragraph::new(Text::from(lines)) .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) .block(Block::bordered().title(" Task Details ")); Ui::create_modal(f, 60, 18, area, widget) @@ -232,7 +242,8 @@ impl View { ViewMode::ChangePriorityTask => " next/prev - confirm - cancel", ViewMode::AddTask => " confirm - cancel", ViewMode::DeleteTask => " confirm - cancel", - ViewMode::ViewTaskDetails => " close", + ViewMode::ViewTaskDetails => " edit note - close", + ViewMode::EditTaskNote => " confirm - cancel", ViewMode::InfoMigration => "" }; From 619f63f4b05aeb3315c2f89586c59f97ee478d7e Mon Sep 17 00:00:00 2001 From: LiuYinCarl <1427518212@qq.coom> Date: Sat, 24 Jan 2026 12:32:40 +0800 Subject: [PATCH 03/23] use fixed data file --- src/json.rs | 141 +++++++++++++++++++++++++++++------------------ src/main.rs | 6 +- src/migration.rs | 51 +++++++++-------- 3 files changed, 118 insertions(+), 80 deletions(-) diff --git a/src/json.rs b/src/json.rs index ae4a369..4711435 100644 --- a/src/json.rs +++ b/src/json.rs @@ -1,12 +1,12 @@ use std::{ error::Error, - fs::{self, File}, - io::Write, - path::{Path, PathBuf}, + fs, + path::PathBuf, sync::Mutex, }; -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}, @@ -16,8 +16,15 @@ use crate::{ 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, +} + impl Json { pub fn get_dir_path() -> PathBuf { let mut path = dirs::config_dir().unwrap(); @@ -26,6 +33,14 @@ impl Json { 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()); @@ -37,78 +52,96 @@ impl Json { pub fn check() -> Result> { fs::create_dir_all(Json::get_dir_path())?; - // Create the state to save the json version let mut version_state = VERSION.lock().unwrap(); + let data_path = Json::get_data_path(); + + if data_path.is_file() { + let json_raw = fs::read_to_string(&data_path)?; + if json_raw.trim().is_empty() { + 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); + } + let wrapper: DataWrapper = from_str(&json_raw)?; + version_state.clear(); + version_state.push_str(&wrapper.version); - // 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 the file doesn't exist create a new one with the last version - if json_version_from_file.is_empty() { - let last_json_version = JSON_VERSIONS.last().unwrap(); - let path = Json::get_json_path(last_json_version.to_string()); + let migrations = Migration::get_migrations(&wrapper.version, wrapper.data); - let mut file = File::create(path).unwrap(); - let _ = file.write_all(b"[]"); + if migrations.is_empty() { + return Ok(false); + } - json_version_from_file = vec![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()); + } - return Ok(false); + return Ok(true); } - // Save into the internal state the last json version - version_state.push_str(json_version_from_file[0]); - - // 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(); - - if json.is_empty() { - return Ok(false); - } - - // Load all migrations - let migrations = Migration::get_migrations(json_version_from_file[0], json); + // Migration from old versioned files + let json_version_from_file: Vec<&str> = JSON_VERSIONS + .into_iter() + .filter(|version| { + let p = Json::get_json_path(version.to_string()); + p.is_file() + }) + .collect(); - if migrations.is_empty() { + if json_version_from_file.is_empty() { + let last_json_version = JSON_VERSIONS.last().unwrap(); + version_state.clear(); + version_state.push_str(last_json_version); + let projects: Vec = vec![]; + let wrapper = DataWrapper { version: last_json_version.to_string(), data: projects }; + fs::write(&data_path, to_string(&wrapper).unwrap()).unwrap(); return Ok(false); } - // 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()); + let old_version = json_version_from_file[0]; + 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)?; - let new_json = migration; + version_state.clear(); + version_state.push_str(old_version); + let wrapper = DataWrapper { version: old_version.to_string(), data }; + fs::write(&data_path, to_string(&wrapper).unwrap()).unwrap(); - fs::write(&path, new_json).unwrap(); - fs::rename(&path, new_path)?; + // Optionally delete old file + let _ = fs::remove_file(old_path); - // Save into the internal state the json version of the last migration applied - version_state.clear(); - version_state.push_str(&version) - } - - Ok(true) + // Re-run check to apply any further migrations + drop(version_state); + return Json::check(); } pub fn read() -> 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(); + let wrapper: DataWrapper = from_str(&json).unwrap(); + + let mut version_state = VERSION.lock().unwrap(); + version_state.clear(); + version_state.push_str(&wrapper.version); + + return wrapper.data; } 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(); + + Json::write_internal(&path, version, projects); + } - fs::write(path, to_string(&projects).unwrap()).unwrap(); + fn write_internal(path: &PathBuf, version: String, data: Vec) { + let wrapper = DataWrapper { version, data }; + fs::write(path, to_string(&wrapper).unwrap()).unwrap(); } } diff --git a/src/main.rs b/src/main.rs index 6689804..9c9c368 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,12 +80,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)?; diff --git a/src/migration.rs b/src/migration.rs index 2fdd8ff..77072ff 100644 --- a/src/migration.rs +++ b/src/migration.rs @@ -1,7 +1,4 @@ -use serde_json::{ - json, to_string, - Value::{self}, -}; +use crate::project::Project; // sha of 0.1.0 0.2.0 0.2.2 pub static JSON_VERSIONS: [&str; 3] = ["6ad96", "911fc", "a4e1b"]; @@ -9,42 +6,50 @@ pub static JSON_VERSIONS: [&str; 3] = ["6ad96", "911fc", "a4e1b"]; pub struct Migration; impl Migration { - pub fn get_migrations(version: &str, original_json: Vec) -> Vec<(&str, String)> { + pub fn get_migrations(version: &str, original_json: Vec) -> Vec<(&str, Vec)> { // Mapper between json version and the relative migration - let mapper: Vec<(&str, String)> = vec![ - ("6ad96", "".to_string()), - ("911fc", Migration::add_priority(original_json.clone())), - ("a4e1b", Migration::add_note(original_json)), + let mapper: Vec<(&str, fn(Vec) -> Vec)> = vec![ + ("6ad96", |data| data), + ("911fc", Migration::add_priority), + ("a4e1b", Migration::add_note), ]; // The start index where the migration are picked let start_index = mapper - .clone() - .into_iter() - .position(|(key, _val)| key == version); + .iter() + .position(|(key, _val)| *key == version); if start_index.is_none() { return vec![]; } - let all_migrations: Vec<(&str, String)> = mapper.into_iter().collect(); + let mut results = vec![]; + let mut current_data = original_json; - // Slice for pick only the useful migration - return all_migrations[(start_index.unwrap() + 1)..].to_vec(); + for (v, migration_fn) in mapper.into_iter().skip(start_index.unwrap() + 1) { + current_data = migration_fn(current_data); + results.push((v, current_data.clone())); + } + + return results; } // Migrations - fn add_priority(mut json: Vec) -> String { - for t in json.iter_mut().flat_map(|p| p.get_mut("tasks")).flat_map(|t| t.as_array_mut()).flatten() { - t.as_object_mut().unwrap().insert("priority".to_string(), json!(0)); + fn add_priority(mut data: Vec) -> Vec { + for p in data.iter_mut() { + for t in p.tasks.iter_mut() { + t.priority = 0; + } } - to_string(&json).unwrap() + data } - fn add_note(mut json: Vec) -> String { - for t in json.iter_mut().flat_map(|p| p.get_mut("tasks")).flat_map(|t| t.as_array_mut()).flatten() { - t.as_object_mut().unwrap().insert("note".to_string(), json!("")); + fn add_note(mut data: Vec) -> Vec { + for p in data.iter_mut() { + for t in p.tasks.iter_mut() { + t.note = "".to_string(); + } } - to_string(&json).unwrap() + data } } From 2ed9dd31f1bf27f34f0463a7d06bd0efbcc88cbd Mon Sep 17 00:00:00 2001 From: LiuYinCarl <1427518212@qq.coom> Date: Fri, 26 Jun 2026 22:39:29 +0800 Subject: [PATCH 04/23] Hide done tasks by default with toggle shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completed tasks are now hidden in the task list by default. Press `t` to toggle their visibility and immediately refresh the view. 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- README.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 6 +++++ src/task.rs | 3 +++ src/view.rs | 2 +- 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 61133f6..a7181c1 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,76 @@ Run ```sh basilk ``` -All available commands are displayed inside + +## Keybindings + +### Global +| Key | Action | +|---|---| +| `q` | Quit | + +### Project List View +| Key | Action | +|---|---| +| `↑` `↓` / `k` `j` / `Tab` `Shift+Tab` | Navigate projects | +| `Enter` / `→` / `l` | Enter project (view tasks) | +| `n` | New project | +| `r` | Rename selected project | +| `d` | Delete selected project | + +### Task List View +| Key | Action | +|---|---| +| `↑` `↓` / `k` `j` / `Tab` `Shift+Tab` | Navigate tasks | +| `Esc` / `←` / `h` | 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 | +| `q` | Quit | + +### 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 | +| Any other key | Close details | + +Tasks are displayed as `[Status] Title` with optional `[Priority]` prefix. +- Statuses: **UpNext** (magenta), **OnGoing** (yellow), **Done** (green, ~~crossed out~~) +- Priorities: `!` (highest), `!!` (high), `!!!` (low) + +Completed tasks are **hidden by default**. Press `t` in the task list view to toggle their visibility. + +## Configuration +Configuration is stored in `config.toml` in the same directory as the data file. + +```toml +[ui] +show_help = true # Show/hide the keybinding help bar at the bottom +``` ## Contributing > [!NOTE] diff --git a/src/main.rs b/src/main.rs index 9c9c368..b676b9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,6 +61,7 @@ pub struct App { view_mode: ViewMode, projects: Vec, config: ConfigToml, + hide_done_tasks: bool, } fn init_terminal() -> Result, Box> { @@ -104,6 +105,7 @@ impl App { view_mode: ViewMode::default(), projects: Json::read(), config: Config::read(), + hide_done_tasks: true, } } @@ -307,6 +309,10 @@ impl App { Up | BackTab | Char('k') => { self.previous(&items); } + Char('t') => { + self.hide_done_tasks = !self.hide_done_tasks; + Task::load_items(self, &mut items); + } Char('q') => { return Ok(()); } diff --git a/src/task.rs b/src/task.rs index 115c6a5..58eb5e4 100644 --- a/src/task.rs +++ b/src/task.rs @@ -112,6 +112,9 @@ impl Task { items.clear(); for task in tasks.iter() { + if app.hide_done_tasks && task.status == TASK_STATUS_DONE { + continue; + } let modifier = if task.status == TASK_STATUS_DONE { Modifier::CROSSED_OUT } else { diff --git a/src/view.rs b/src/view.rs index e3b308f..568db60 100644 --- a/src/view.rs +++ b/src/view.rs @@ -235,7 +235,7 @@ impl View { ViewMode::DeleteProject => " confirm - cancel", ViewMode::ViewTasks => { - " next/prev - go to projects - change status -

change priority - new - rename - details - delete - quit" + " next/prev - go to projects - change status -

change priority - new - rename - details - delete - toggle done - quit" } ViewMode::RenameTask => " confirm - cancel", ViewMode::ChangeStatusTask => " next/prev - confirm - cancel", From a7533cac15a4ee28691db35d679ffbd531c6774a Mon Sep 17 00:00:00 2001 From: LiuYinCarl <1427518212@qq.coom> Date: Fri, 26 Jun 2026 23:02:03 +0800 Subject: [PATCH 05/23] Move help to popup panel and hidden done tasks by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- README.md | 23 ++++++--------- src/config.rs | 70 ---------------------------------------------- src/main.rs | 70 ++++++++++++++++++++++++++++------------------ src/task.rs | 10 +++++-- src/view.rs | 77 ++++++++++++++++++++++++++++++++++----------------- 5 files changed, 110 insertions(+), 140 deletions(-) delete mode 100644 src/config.rs diff --git a/README.md b/README.md index a7181c1..fdbf685 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ basilk ## Keybindings +Press `h` to view the keybinding list in-app. + ### Global | Key | Action | |---|---| @@ -97,17 +99,18 @@ basilk ### Project List View | Key | Action | |---|---| -| `↑` `↓` / `k` `j` / `Tab` `Shift+Tab` | Navigate projects | -| `Enter` / `→` / `l` | Enter project (view tasks) | +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | Navigate projects | +| `Enter` `→` `l` | Enter project (view tasks) | | `n` | New project | | `r` | Rename selected project | | `d` | Delete selected project | +| `h` | Help | ### Task List View | Key | Action | |---|---| -| `↑` `↓` / `k` `j` / `Tab` `Shift+Tab` | Navigate tasks | -| `Esc` / `←` / `h` | Back to project list | +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | Navigate tasks | +| `Esc` `←` | Back to project list | | `Enter` | Change task status | | `p` | Change task priority | | `n` | New task | @@ -116,12 +119,12 @@ basilk | `e` | Edit task note | | `d` | Delete selected task | | `t` | Toggle show/hide completed tasks | -| `q` | Quit | +| `h` | Help | ### Change Status / Priority Modals | Key | Action | |---|---| -| `↑` `↓` / `k` `j` / `Tab` `Shift+Tab` | Navigate options | +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | Navigate options | | `Enter` | Confirm selection | | `Esc` | Cancel | @@ -149,14 +152,6 @@ Tasks are displayed as `[Status] Title` with optional `[Priority]` prefix. Completed tasks are **hidden by default**. Press `t` in the task list view to toggle their visibility. -## Configuration -Configuration is stored in `config.toml` in the same directory as the data file. - -```toml -[ui] -show_help = true # Show/hide the keybinding help bar at the bottom -``` - ## Contributing > [!NOTE] > This project is now in beta version and is expected to have bugs 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/main.rs b/src/main.rs index b676b9e..38f06f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,6 @@ use ratatui::{ use tui_input::{backend::crossterm::EventHandler, Input}; mod cli; -mod config; mod json; mod migration; mod project; @@ -26,7 +25,6 @@ mod ui; mod util; mod view; -use config::{Config, ConfigToml}; use json::Json; use project::Project; use task::{Task, TASK_PRIORITIES, TASK_STATUSES}; @@ -48,6 +46,7 @@ pub enum ViewMode { DeleteTask, ViewTaskDetails, EditTaskNote, + ViewHelp, InfoMigration, } @@ -59,8 +58,8 @@ pub struct App { selected_status_task_index: ListState, selected_priority_task_index: ListState, view_mode: ViewMode, + previous_view_mode: ViewMode, projects: Vec, - config: ConfigToml, hide_done_tasks: bool, } @@ -103,8 +102,8 @@ impl App { selected_status_task_index: ListState::default().with_selected(Some(0)), selected_priority_task_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, } } @@ -140,6 +139,10 @@ impl App { use KeyCode::*; match self.view_mode { ViewMode::ViewProjects => 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() { continue; @@ -229,7 +232,11 @@ impl App { }, ViewMode::ViewTasks => match key.code { - Esc | Left | Char('h') => { + Char('h') => { + self.previous_view_mode = ViewMode::ViewTasks; + App::change_view(self, ViewMode::ViewHelp); + } + Esc | Left => { Project::load_items(self, &mut items); App::change_view(self, ViewMode::ViewProjects); @@ -435,6 +442,16 @@ impl App { } }, + ViewMode::ViewHelp => match key.code { + _ => { + let prev = std::mem::replace( + &mut self.previous_view_mode, + ViewMode::ViewProjects, + ); + App::change_view(self, prev); + } + }, + ViewMode::InfoMigration => match key.code { _ => { App::change_view(self, ViewMode::ViewProjects); @@ -455,24 +472,13 @@ impl App { status_items: &Vec, priority_items: &Vec, ) { - let layout = Layout::vertical(if self.config.ui.show_help { - [ - Constraint::Percentage(2), - Constraint::Percentage(93), - // Space for the footer helper - Constraint::Percentage(5), - ] - } else { - [ - Constraint::Percentage(2), - // Expand the main area - Constraint::Percentage(98), - // Remove the footer help area - Constraint::Percentage(0), - ] - }); - - let [header_area, main_area, footer_area] = layout.areas(area); + let layout = Layout::vertical([ + Constraint::Percentage(2), + Constraint::Percentage(96), + Constraint::Percentage(2), + ]); + + let [header_area, main_area, hint_area] = layout.areas(area); // Header f.render_widget( @@ -480,6 +486,15 @@ impl App { header_area, ); + // Hint + f.render_widget( + Paragraph::new(Line::from(Span::styled( + " h help", + Style::default().fg(Color::Green), + ))), + hint_area, + ); + // Main view View::show_items(self, items, f, main_area); @@ -512,12 +527,12 @@ impl App { View::show_select_task_priority_modal(self, priority_items, f, area) } - if self.view_mode == ViewMode::ViewTaskDetails { - View::show_task_details_modal(self, f, area) + if self.view_mode == ViewMode::ViewHelp { + View::show_help_modal(self, f, area) } - if self.config.ui.show_help { - View::show_footer_helper(self, f, footer_area) + if self.view_mode == ViewMode::ViewTaskDetails { + View::show_task_details_modal(self, f, area) } } @@ -567,6 +582,7 @@ impl App { ViewMode::ViewTaskDetails => return &mut self.selected_task_index, ViewMode::EditTaskNote => return &mut self.selected_task_index, + ViewMode::ViewHelp => return &mut self.selected_project_index, ViewMode::InfoMigration => return &mut self.selected_project_index, }; } diff --git a/src/task.rs b/src/task.rs index 58eb5e4..4a146e6 100644 --- a/src/task.rs +++ b/src/task.rs @@ -105,16 +105,20 @@ impl Task { tasks.sort_by_key(|t| TASK_PRIORITIES.into_iter().position(|o| o == t.priority)); let new_index = tasks - .into_iter() + .iter() .position(|t| t.title == last_task_title_selected) .unwrap_or(0); items.clear(); - for task in tasks.iter() { + let mut visible_selected = 0; + for (full_idx, task) in tasks.iter().enumerate() { if app.hide_done_tasks && task.status == TASK_STATUS_DONE { continue; } + if full_idx == new_index { + visible_selected = items.len(); + } let modifier = if task.status == TASK_STATUS_DONE { Modifier::CROSSED_OUT } else { @@ -144,7 +148,7 @@ impl Task { items.push(ListItem::from(line)) } - app.selected_task_index.select(Some(new_index)) + app.selected_task_index.select(Some(visible_selected)) } pub fn reload(app: &mut App, items: &mut Vec) { diff --git a/src/view.rs b/src/view.rs index 568db60..50ad85a 100644 --- a/src/view.rs +++ b/src/view.rs @@ -225,33 +225,58 @@ impl View { Ui::create_modal(f, 60, 18, area, widget) } - pub fn show_footer_helper(app: &mut App, f: &mut Frame, area: Rect) { - let help_string = match app.view_mode { - ViewMode::ViewProjects => { - " next/prev - go to tasks - new - rename - delete - quit" - } - ViewMode::RenameProject => " confirm - cancel", - ViewMode::AddProject => " confirm - cancel", - ViewMode::DeleteProject => " confirm - cancel", - - ViewMode::ViewTasks => { - " next/prev - go to projects - change status -

change priority - new - rename - details - delete - toggle done - quit" - } - ViewMode::RenameTask => " confirm - cancel", - ViewMode::ChangeStatusTask => " next/prev - confirm - cancel", - ViewMode::ChangePriorityTask => " next/prev - confirm - cancel", - ViewMode::AddTask => " confirm - cancel", - ViewMode::DeleteTask => " confirm - cancel", - ViewMode::ViewTaskDetails => " edit note - close", - ViewMode::EditTaskNote => " confirm - cancel", - ViewMode::InfoMigration => "" + pub fn show_help_modal(app: &mut App, f: &mut Frame, area: Rect) { + let bindings: &[(&str, &str)] = match app.previous_view_mode { + ViewMode::ViewProjects => &[ + ("↑ ↓ k j", "next/prev"), + ("Enter → l", "go to tasks"), + ("n", "new"), + ("r", "rename"), + ("d", "delete"), + ("h", "help"), + ("q", "quit"), + ], + ViewMode::ViewTasks => &[ + ("↑ ↓ k j", "next/prev"), + ("Esc ←", "back"), + ("Enter", "change status"), + ("p", "change priority"), + ("n", "new"), + ("r", "rename"), + ("v", "details"), + ("e", "note"), + ("d", "delete"), + ("t", "toggle done"), + ("h", "help"), + ("q", "quit"), + ], + _ => &[], }; - f.render_widget( - Paragraph::new(help_string) - .wrap(Wrap { trim: true }) - .alignment(Alignment::Center), - area, - ); + let mut lines: Vec = bindings + .iter() + .map(|(keys, desc)| { + Line::from(vec![ + Span::styled( + format!("{:<16}", keys), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ), + Span::raw(*desc), + ]) + }) + .collect(); + + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "Press any key to close", + Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC), + ))); + + let widget = Paragraph::new(Text::from(lines)) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .block(Block::bordered().title(" Help ")); + + Ui::create_modal(f, 42, 16, area, widget) } } From b07ec81b723bd46cad87cf61883e39eaa26cd5a2 Mon Sep 17 00:00:00 2001 From: LiuYinCarl <1427518212@qq.coom> Date: Fri, 26 Jun 2026 23:05:27 +0800 Subject: [PATCH 06/23] Add Chinese README translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- README.md | 2 + README.zh-CN.md | 168 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 README.zh-CN.md diff --git a/README.md b/README.md index fdbf685..eb82be8 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

diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..937b4b8 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,168 @@ +

+

插画由 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 格式是为了方便导出。 + +## 安装 +### Cargo + +通过 [crates.io](https://crates.io/crates/basilk) 使用 [`cargo`](https://doc.rust-lang.org/cargo/) 安装 + +```sh +cargo install basilk +``` + +### AUR + +通过 [AUR](https://aur.archlinux.org/packages/basilk) 使用 [AUR 助手](https://wiki.archlinux.org/title/AUR_helpers) 安装 + +```sh +paru -S basilk +``` + +### Homebrew +通过 [homebrew tap](https://docs.brew.sh/Taps) 使用 [`brew`](https://brew.sh/) 安装 + +```sh +brew tap GabAlpha/tap +brew install basilk +``` + +### X-CMD +通过 [x install](https://x-cmd.com/install/basilk) 使用 [x-cmd](https://x-cmd.com) 安装 + +```sh +x install basilk +``` + +### 从源码构建 + +1. 克隆仓库 +```sh +git clone https://github.com/GabAlpha/basilk && cd basilk +``` +2. 构建 +```sh +cargo build --release +``` +二进制文件位于 `target/release/basilk` + +## 使用 +运行 + +```sh +basilk +``` + +## 快捷键 + +在应用内按 `h` 即可查看快捷键列表。 + +### 全局 +| 按键 | 功能 | +|---|---| +| `q` | 退出 | + +### 项目列表视图 +| 按键 | 功能 | +|---|---| +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | 浏览项目 | +| `Enter` `→` `l` | 进入项目(查看任务) | +| `n` | 新建项目 | +| `r` | 重命名所选项目 | +| `d` | 删除所选项目 | +| `h` | 帮助 | + +### 任务列表视图 +| 按键 | 功能 | +|---|---| +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | 浏览任务 | +| `Esc` `←` | 返回项目列表 | +| `Enter` | 更改任务状态 | +| `p` | 更改任务优先级 | +| `n` | 新建任务 | +| `r` | 重命名所选任务 | +| `v` | 查看任务详情 | +| `e` | 编辑任务备注 | +| `d` | 删除所选任务 | +| `t` | 切换显示/隐藏已完成任务 | +| `h` | 帮助 | + +### 更改状态 / 优先级弹窗 +| 按键 | 功能 | +|---|---| +| `↑` `↓` `k` `j` `Tab` `Shift+Tab` | 浏览选项 | +| `Enter` | 确认选择 | +| `Esc` | 取消 | + +### 输入弹窗(新建 / 重命名 / 编辑备注) +| 按键 | 功能 | +|---|---| +| `Enter` | 确认 | +| `Esc` | 取消 | + +### 删除确认弹窗 +| 按键 | 功能 | +|---|---| +| `y` | 确认删除 | +| `n` | 取消 | + +### 任务详情视图 +| 按键 | 功能 | +|---|---| +| `e` | 编辑任务备注 | +| 任意其他按键 | 关闭详情 | + +任务以 `[状态] 标题` 格式显示,可选 `[优先级]` 前缀。 +- 状态:**UpNext**(品红)、**OnGoing**(黄色)、**Done**(绿色,~~删除线~~) +- 优先级:`!`(最高)、`!!`(高)、`!!!`(低) + +已完成的任务**默认隐藏**。在任务列表视图中按 `t` 可切换显示。 + +## 参与贡献 +> [!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)。 From f2c8409d858c3155c7f3ab78b63a9aeb5baeccac Mon Sep 17 00:00:00 2001 From: LiuYinCarl <1427518212@qq.coom> Date: Fri, 26 Jun 2026 23:07:28 +0800 Subject: [PATCH 07/23] Replace install methods with fork-appropriate instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- README.md | 41 +++-------------------------------------- README.zh-CN.md | 41 +++-------------------------------------- 2 files changed, 6 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index eb82be8..b2b6d43 100644 --- a/README.md +++ b/README.md @@ -38,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 +cargo install --path . ``` -2. Build -```sh -cargo build --release -``` -Binary will be located at `target/release/basilk` ## Usage Run diff --git a/README.zh-CN.md b/README.zh-CN.md index 937b4b8..bb68b37 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,49 +38,14 @@ Windows ``` 选择 JSON 格式是为了方便导出。 -## 安装 -### Cargo - -通过 [crates.io](https://crates.io/crates/basilk) 使用 [`cargo`](https://doc.rust-lang.org/cargo/) 安装 - -```sh -cargo install basilk -``` - -### AUR - -通过 [AUR](https://aur.archlinux.org/packages/basilk) 使用 [AUR 助手](https://wiki.archlinux.org/title/AUR_helpers) 安装 - -```sh -paru -S basilk -``` - -### Homebrew -通过 [homebrew tap](https://docs.brew.sh/Taps) 使用 [`brew`](https://brew.sh/) 安装 +本项目是原始 [basilk](https://github.com/GabAlpha/basilk) 的一个 fork 版本。如需原始版本,请参考上游仓库。 -```sh -brew tap GabAlpha/tap -brew install basilk -``` - -### X-CMD -通过 [x install](https://x-cmd.com/install/basilk) 使用 [x-cmd](https://x-cmd.com) 安装 - -```sh -x install basilk -``` - -### 从源码构建 +## 安装 -1. 克隆仓库 ```sh git clone https://github.com/GabAlpha/basilk && cd basilk +cargo install --path . ``` -2. 构建 -```sh -cargo build --release -``` -二进制文件位于 `target/release/basilk` ## 使用 运行 From bbbb2d1bc4ca6cb97b1403c019a05cbcc84f8727 Mon Sep 17 00:00:00 2001 From: LiuYinCarl <1427518212@qq.coom> Date: Fri, 26 Jun 2026 23:08:36 +0800 Subject: [PATCH 08/23] Fix clone URL to point to fork repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- README.md | 2 +- README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b2b6d43..5c2ac70 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ This is a fork of the original [basilk](https://github.com/GabAlpha/basilk) proj ## Installation ```sh -git clone https://github.com/GabAlpha/basilk && cd basilk +git clone https://github.com/LiuYinCarl/basilk && cd basilk cargo install --path . ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index bb68b37..3a6b6b2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -43,7 +43,7 @@ Windows ## 安装 ```sh -git clone https://github.com/GabAlpha/basilk && cd basilk +git clone https://github.com/LiuYinCarl/basilk && cd basilk cargo install --path . ``` From 6caa67706fe54a4673ab8a9e03df407b57c178ab Mon Sep 17 00:00:00 2001 From: kenshin Date: Sat, 27 Jun 2026 00:00:55 +0800 Subject: [PATCH 09/23] Fix help modal vertical centering on macOS terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- src/ui.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index d30e2b4..193b5f2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,10 +10,13 @@ pub struct Ui {} impl Ui { pub fn create_rect_area(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let content_height = percent_y.min(r.height.saturating_sub(2)); + let vertical_margin = (r.height.saturating_sub(content_height)) / 2; + let popup_layout = Layout::vertical([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Min(percent_y), - Constraint::Percentage((100 - percent_y) / 2), + Constraint::Length(vertical_margin), + Constraint::Length(content_height), + Constraint::Min(vertical_margin), ]) .split(r); From 45f04d0607d1248df70992813d92d692a8ce8a95 Mon Sep 17 00:00:00 2001 From: kenshin Date: Sat, 27 Jun 2026 00:01:33 +0800 Subject: [PATCH 10/23] Fix delete modal text clipping on narrow terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- src/ui.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 193b5f2..a1c9c89 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,7 +1,7 @@ use ratatui::{ layout::{Alignment, Constraint, Layout, Rect}, text::{Line, Text}, - widgets::{Block, Borders, Clear, Paragraph, Widget}, + widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}, Frame, }; use tui_input::Input; @@ -68,7 +68,7 @@ impl Ui { f: &mut Frame, area: Rect, ) { - let area = Ui::create_rect_area(20, 4, area); + let area = Ui::create_rect_area(50, 4, area); f.render_widget(Clear, area); //this clears out the background f.render_widget( @@ -77,6 +77,7 @@ impl Ui { Line::raw(text_second_line), ])) .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) .block(Block::bordered().title(title)), area, ); From 5d488e80725d0710b0bf082048d5e4fc717f351b Mon Sep 17 00:00:00 2001 From: kenshin Date: Sat, 27 Jun 2026 00:03:29 +0800 Subject: [PATCH 11/23] Show confirm/cancel hint on delete modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- src/ui.rs | 26 ++------------------------ src/view.rs | 25 +++++++++++++++++++------ 2 files changed, 21 insertions(+), 30 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index a1c9c89..2338113 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,7 +1,6 @@ use ratatui::{ - layout::{Alignment, Constraint, Layout, Rect}, - text::{Line, Text}, - widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}, + layout::{Constraint, Layout, Rect}, + widgets::{Block, Borders, Clear, Paragraph, Widget}, Frame, }; use tui_input::Input; @@ -61,25 +60,4 @@ impl Ui { ) } - pub fn create_question_modal( - text_first_line: &str, - text_second_line: &str, - title: &str, - f: &mut Frame, - area: Rect, - ) { - let area = Ui::create_rect_area(50, 4, area); - - f.render_widget(Clear, area); //this clears out the background - f.render_widget( - Paragraph::new(Text::from(vec![ - Line::raw(text_first_line), - Line::raw(text_second_line), - ])) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }) - .block(Block::bordered().title(title)), - area, - ); - } } diff --git a/src/view.rs b/src/view.rs index 50ad85a..be1f036 100644 --- a/src/view.rs +++ b/src/view.rs @@ -43,13 +43,26 @@ impl View { _ => "", }; - Ui::create_question_modal( - "Are you sure to delete?", - format!("\"{}\"", title).as_str(), - "Delete", - f, + let area = Ui::create_rect_area(50, 5, area); + + f.render_widget(Clear, area); + f.render_widget( + Paragraph::new(Text::from(vec![ + Line::raw("Are you sure to delete?"), + Line::raw(format!("\"{}\"", title)), + Line::raw(""), + Line::from(Span::styled( + "y / n", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + )), + ])) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + .block(Block::bordered().title("Delete")), area, - ) + ); } pub fn show_select_task_status_modal( From d22349d11b447b152883ec643fd997a00ca6e5f3 Mon Sep 17 00:00:00 2001 From: kenshin Date: Sat, 27 Jun 2026 00:04:40 +0800 Subject: [PATCH 12/23] Fix delete modal hint text not visible due to height overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- src/view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view.rs b/src/view.rs index be1f036..9c65aa4 100644 --- a/src/view.rs +++ b/src/view.rs @@ -43,7 +43,7 @@ impl View { _ => "", }; - let area = Ui::create_rect_area(50, 5, area); + let area = Ui::create_rect_area(50, 6, area); f.render_widget(Clear, area); f.render_widget( From a7ab634ef048e4b19a6b7490a267b8359c7dcc38 Mon Sep 17 00:00:00 2001 From: kenshin Date: Sat, 27 Jun 2026 00:06:31 +0800 Subject: [PATCH 13/23] Replace delete modal text with interactive confirm/cancel list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- src/main.rs | 64 +++++++++++++++++++++++++++++++++++++++-------------- src/task.rs | 12 ++++++++++ src/view.rs | 33 ++++++++++++--------------- 3 files changed, 74 insertions(+), 35 deletions(-) diff --git a/src/main.rs b/src/main.rs index 38f06f9..a028c29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,6 +57,7 @@ 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, @@ -101,6 +102,7 @@ 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(), @@ -124,13 +126,16 @@ impl App { let mut priority_items: Vec = vec![]; Task::load_priority_items(&mut priority_items); + let mut delete_confirm_items: Vec = vec![]; + Task::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()? { @@ -219,13 +224,26 @@ impl App { } }, ViewMode::DeleteProject => match key.code { - Char('y') => { - Project::delete(self, &mut items); - self.selected_project_index.select_previous(); - - App::change_view(self, ViewMode::ViewProjects); + Enter => { + if self.delete_confirm_index.selected() == Some(0) { + Project::delete(self, &mut items); + self.selected_project_index.select_previous(); + + self.delete_confirm_index.select(Some(0)); + App::change_view(self, ViewMode::ViewProjects); + } else { + self.delete_confirm_index.select(Some(0)); + App::change_view(self, ViewMode::ViewProjects); + } } - Char('n') => { + Down | BackTab | Char('j') => { + self.next(&delete_confirm_items); + } + Up | Tab | Char('k') => { + self.previous(&delete_confirm_items); + } + Esc => { + self.delete_confirm_index.select(Some(0)); App::change_view(self, ViewMode::ViewProjects); } _ => {} @@ -402,13 +420,26 @@ impl App { } }, ViewMode::DeleteTask => match key.code { - Char('y') => { - Task::delete(self, &mut items); - self.selected_task_index.select_previous(); - - App::change_view(self, ViewMode::ViewTasks); + Enter => { + if self.delete_confirm_index.selected() == Some(0) { + Task::delete(self, &mut items); + self.selected_task_index.select_previous(); + + self.delete_confirm_index.select(Some(0)); + App::change_view(self, ViewMode::ViewTasks); + } else { + self.delete_confirm_index.select(Some(0)); + App::change_view(self, ViewMode::ViewTasks); + } } - Char('n') => { + Down | BackTab | Char('j') => { + self.next(&delete_confirm_items); + } + Up | Tab | Char('k') => { + self.previous(&delete_confirm_items); + } + Esc => { + self.delete_confirm_index.select(Some(0)); App::change_view(self, ViewMode::ViewTasks); } _ => {} @@ -471,6 +502,7 @@ impl App { items: &Vec, status_items: &Vec, priority_items: &Vec, + delete_confirm_items: &Vec, ) { let layout = Layout::vertical([ Constraint::Percentage(2), @@ -516,7 +548,7 @@ impl App { } if self.view_mode == ViewMode::DeleteTask || self.view_mode == ViewMode::DeleteProject { - View::show_delete_item_modal(self, f, area) + View::show_delete_item_modal(self, delete_confirm_items, f, area) } if self.view_mode == ViewMode::ChangeStatusTask { @@ -571,14 +603,14 @@ impl App { 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, diff --git a/src/task.rs b/src/task.rs index 4a146e6..4528dc1 100644 --- a/src/task.rs +++ b/src/task.rs @@ -77,6 +77,18 @@ impl Task { } } + pub fn load_delete_confirm_items(items: &mut Vec) { + items.clear(); + items.push(ListItem::from(Span::styled( + "Confirm", + Style::new().fg(Color::Red), + ))); + items.push(ListItem::from(Span::styled( + "Cancel", + Style::new().fg(Color::Gray), + ))); + } + pub fn load_items(app: &mut App, items: &mut Vec) { let tasks = &mut app.projects[app.selected_project_index.selected().unwrap()].tasks; diff --git a/src/view.rs b/src/view.rs index 9c65aa4..2964238 100644 --- a/src/view.rs +++ b/src/view.rs @@ -36,33 +36,28 @@ impl View { Ui::create_input_modal("Note", f, area, input) } - pub fn show_delete_item_modal(app: &mut App, f: &mut Frame, area: Rect) { + pub fn show_delete_item_modal( + app: &mut App, + confirm_items: &Vec, + f: &mut Frame, + area: Rect, + ) { let title = match app.view_mode { ViewMode::DeleteTask => &Task::get_current(app).title, ViewMode::DeleteProject => &Project::get_current(app).title, _ => "", }; - let area = Ui::create_rect_area(50, 6, area); + let area = Ui::create_rect_area(30, 5, area); + + let list_widget = List::new(confirm_items.clone()) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .highlight_symbol("> ") + .highlight_spacing(HighlightSpacing::Always) + .block(Block::bordered().title(format!("Delete \"{}\"?", title))); f.render_widget(Clear, area); - f.render_widget( - Paragraph::new(Text::from(vec![ - Line::raw("Are you sure to delete?"), - Line::raw(format!("\"{}\"", title)), - Line::raw(""), - Line::from(Span::styled( - "y / n", - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - )), - ])) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }) - .block(Block::bordered().title("Delete")), - area, - ); + f.render_stateful_widget(list_widget, area, app.use_state()) } pub fn show_select_task_status_modal( From 30af37cd89944722b51d6e6bf1a92c0eda48b4b1 Mon Sep 17 00:00:00 2001 From: kenshin Date: Sat, 27 Jun 2026 00:10:15 +0800 Subject: [PATCH 14/23] Fix delete modal navigation not responding to keyboard input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- src/view.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/view.rs b/src/view.rs index 2964238..bd5774f 100644 --- a/src/view.rs +++ b/src/view.rs @@ -117,6 +117,8 @@ impl View { if app.view_mode == ViewMode::ChangeStatusTask || app.view_mode == ViewMode::ChangePriorityTask + || app.view_mode == ViewMode::DeleteTask + || app.view_mode == ViewMode::DeleteProject { f.render_widget(items, area) } else { From 91eae53d48a1f8a4c17ce4450d7a2e7b1ee9f56b Mon Sep 17 00:00:00 2001 From: kenshin Date: Sat, 27 Jun 2026 00:17:21 +0800 Subject: [PATCH 15/23] Technical debt cleanup: reduce bloat and fix bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused `toml` dependency - Fix `get_indicator_done_tasks_color` 50% boundary overlap bug - Move `load_delete_confirm_items` from task.rs to ui.rs - Extract `format_timestamp`/`format_duration` to util.rs - Eliminate clone-write-reload anti-pattern in all CRUD operations - Extract `handle_modal_nav` helper for repeated modal keyboard logic - Remove dead `reload` and `_get_all` methods 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- Cargo.lock | 69 --------------------------------- Cargo.toml | 1 - src/main.rs | 101 ++++++++++++++++++++----------------------------- src/project.rs | 34 ++++++----------- src/task.rs | 89 +++++++++++++------------------------------ src/ui.rs | 16 +++++++- src/util.rs | 39 +++++++++++++++++++ src/view.rs | 44 ++------------------- 8 files changed, 136 insertions(+), 257 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69d5d5e..6779b46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,6 @@ dependencies = [ "ratatui", "serde", "serde_json", - "toml", "tui-input", ] @@ -175,12 +174,6 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" -[[package]] -name = "equivalent" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" - [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -238,16 +231,6 @@ dependencies = [ "cc", ] -[[package]] -name = "indexmap" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" -dependencies = [ - "equivalent", - "hashbrown", -] - [[package]] name = "itertools" version = "0.13.0" @@ -491,15 +474,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_spanned" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" -dependencies = [ - "serde", -] - [[package]] name = "shlex" version = "1.3.0" @@ -611,40 +585,6 @@ dependencies = [ "syn", ] -[[package]] -name = "toml" -version = "0.8.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "winnow", -] - [[package]] name = "tui-input" version = "0.9.0" @@ -952,15 +892,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.6.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" -dependencies = [ - "memchr", -] - [[package]] name = "zerocopy" version = "0.7.35" diff --git a/Cargo.toml b/Cargo.toml index a94f8ec..09c24b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,5 +20,4 @@ dirs = "5.0.1" 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" diff --git a/src/main.rs b/src/main.rs index a028c29..f2b0199 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,7 @@ mod view; use json::Json; use project::Project; use task::{Task, TASK_PRIORITIES, TASK_STATUSES}; +use ui::Ui; use view::View; #[derive(Default, PartialEq, Debug)] @@ -127,7 +128,7 @@ impl App { Task::load_priority_items(&mut priority_items); let mut delete_confirm_items: Vec = vec![]; - Task::load_delete_confirm_items(&mut delete_confirm_items); + Ui::load_delete_confirm_items(&mut delete_confirm_items); if were_applied_migrations { self.view_mode = ViewMode::InfoMigration @@ -223,30 +224,18 @@ impl App { input.handle_event(&Event::Key(key)); } }, - ViewMode::DeleteProject => match key.code { - Enter => { + ViewMode::DeleteProject => { + if self.handle_modal_nav(key.code, &delete_confirm_items, ViewMode::ViewProjects) { + continue; + } + if key.code == Enter { if self.delete_confirm_index.selected() == Some(0) { Project::delete(self, &mut items); self.selected_project_index.select_previous(); - - self.delete_confirm_index.select(Some(0)); - App::change_view(self, ViewMode::ViewProjects); - } else { - self.delete_confirm_index.select(Some(0)); - App::change_view(self, ViewMode::ViewProjects); } - } - Down | BackTab | Char('j') => { - self.next(&delete_confirm_items); - } - Up | Tab | Char('k') => { - self.previous(&delete_confirm_items); - } - Esc => { self.delete_confirm_index.select(Some(0)); App::change_view(self, ViewMode::ViewProjects); } - _ => {} }, ViewMode::ViewTasks => match key.code { @@ -359,8 +348,11 @@ impl App { input.handle_event(&Event::Key(key)); } }, - ViewMode::ChangeStatusTask => match key.code { - Enter => { + ViewMode::ChangeStatusTask => { + if self.handle_modal_nav(key.code, &status_items, ViewMode::ViewTasks) { + continue; + } + if key.code == Enter { Task::change_status( self, &mut items, @@ -371,20 +363,12 @@ impl App { self.selected_status_task_index.select(Some(0)); App::change_view(self, ViewMode::ViewTasks); } - - Down | BackTab | Char('j') => { - self.next(&status_items); - } - Up | Tab | Char('k') => { - self.previous(&status_items); - } - Esc => { - App::change_view(self, ViewMode::ViewTasks); - } - _ => {} }, - ViewMode::ChangePriorityTask => match key.code { - Enter => { + ViewMode::ChangePriorityTask => { + if self.handle_modal_nav(key.code, &priority_items, ViewMode::ViewTasks) { + continue; + } + if key.code == Enter { Task::change_priority( self, &mut items, @@ -395,16 +379,6 @@ impl App { 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 => { @@ -419,30 +393,18 @@ impl App { input.handle_event(&Event::Key(key)); } }, - ViewMode::DeleteTask => match key.code { - Enter => { + ViewMode::DeleteTask => { + if self.handle_modal_nav(key.code, &delete_confirm_items, ViewMode::ViewTasks) { + continue; + } + if key.code == Enter { if self.delete_confirm_index.selected() == Some(0) { Task::delete(self, &mut items); self.selected_task_index.select_previous(); - - self.delete_confirm_index.select(Some(0)); - App::change_view(self, ViewMode::ViewTasks); - } else { - self.delete_confirm_index.select(Some(0)); - App::change_view(self, ViewMode::ViewTasks); } - } - Down | BackTab | Char('j') => { - self.next(&delete_confirm_items); - } - Up | Tab | Char('k') => { - self.previous(&delete_confirm_items); - } - Esc => { self.delete_confirm_index.select(Some(0)); App::change_view(self, ViewMode::ViewTasks); } - _ => {} }, ViewMode::ViewTaskDetails => match key.code { Char('e') => { @@ -622,4 +584,23 @@ impl App { fn change_view(&mut self, mode: ViewMode) { self.view_mode = mode } + + 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, + } + } } diff --git a/src/project.rs b/src/project.rs index f08c1cf..956b677 100644 --- a/src/project.rs +++ b/src/project.rs @@ -22,7 +22,7 @@ impl Project { match percentage { p if p == 0 => return Color::DarkGray, p if p >= 25 && p <= 50 => return Color::LightMagenta, - p if p >= 50 && p < 100 => return Color::LightYellow, + p if p > 50 && p < 100 => return Color::LightYellow, p if p == 100 => return Color::LightGreen, _ => return Color::White, } @@ -57,11 +57,6 @@ impl Project { } } - pub fn reload(app: &mut App, items: &mut Vec) { - app.projects = Json::read(); - Project::load_items(app, items) - } - pub fn get_current(app: &mut App) -> &Project { return &app.projects[app.selected_project_index.selected().unwrap()]; } @@ -71,33 +66,26 @@ impl Project { return; } - let new_project = Project { + app.projects.push(Project { title: value.to_string(), tasks: vec![], - }; + }); - let mut internal_projects = app.projects.clone(); - internal_projects.push(new_project); - - Json::write(internal_projects); - Project::reload(app, items) + Json::write(app.projects.clone()); + Project::load_items(app, items) } pub fn rename(app: &mut App, items: &mut Vec, value: &str) { - let mut internal_projects = app.projects.clone(); + app.projects[app.selected_project_index.selected().unwrap()].title = value.to_string(); - internal_projects[app.selected_project_index.selected().unwrap()].title = value.to_string(); - - Json::write(internal_projects); - Project::reload(app, items) + Json::write(app.projects.clone()); + Project::load_items(app, items) } pub fn delete(app: &mut App, items: &mut Vec) { - let mut internal_projects = app.projects.clone(); + app.projects.remove(app.selected_project_index.selected().unwrap()); - internal_projects.remove(app.selected_project_index.selected().unwrap()); - - Json::write(internal_projects); - Project::reload(app, items) + Json::write(app.projects.clone()); + Project::load_items(app, items) } } diff --git a/src/task.rs b/src/task.rs index 4528dc1..b22b06c 100644 --- a/src/task.rs +++ b/src/task.rs @@ -77,18 +77,6 @@ impl Task { } } - pub fn load_delete_confirm_items(items: &mut Vec) { - items.clear(); - items.push(ListItem::from(Span::styled( - "Confirm", - Style::new().fg(Color::Red), - ))); - items.push(ListItem::from(Span::styled( - "Cancel", - Style::new().fg(Color::Gray), - ))); - } - pub fn load_items(app: &mut App, items: &mut Vec) { let tasks = &mut app.projects[app.selected_project_index.selected().unwrap()].tasks; @@ -163,15 +151,6 @@ impl Task { app.selected_task_index.select(Some(visible_selected)) } - pub fn reload(app: &mut App, items: &mut Vec) { - app.projects = Json::read(); - Task::load_items(app, items) - } - - pub fn _get_all(app: &App) -> &Vec { - return &app.projects[app.selected_project_index.selected().unwrap()].tasks; - } - pub fn get_current(app: &mut App) -> &Task { return &app.projects[app.selected_project_index.selected().unwrap()].tasks [app.selected_task_index.selected().unwrap()]; @@ -182,52 +161,43 @@ impl Task { return; } - let new_task = Task { - title: value.to_string(), - status: TASK_STATUS_UP_NEXT.to_string(), - priority: 0, - created_at: Some(Self::current_timestamp()), - completed_at: None, - note: "".to_string(), - }; - - let mut internal_projects = app.projects.clone(); - internal_projects[app.selected_project_index.selected().unwrap()] + app.projects[app.selected_project_index.selected().unwrap()] .tasks - .push(new_task); + .push(Task { + title: value.to_string(), + status: TASK_STATUS_UP_NEXT.to_string(), + priority: 0, + created_at: Some(Self::current_timestamp()), + completed_at: None, + note: "".to_string(), + }); - Json::write(internal_projects); - Task::reload(app, items) + Json::write(app.projects.clone()); + Task::load_items(app, items) } pub fn rename(app: &mut App, items: &mut Vec, value: &str) { - let mut internal_projects = app.projects.clone(); - - internal_projects[app.selected_project_index.selected().unwrap()].tasks + app.projects[app.selected_project_index.selected().unwrap()].tasks [app.selected_task_index.selected().unwrap()] .title = value.to_string(); - Json::write(internal_projects); - Task::reload(app, items) + Json::write(app.projects.clone()); + Task::load_items(app, items) } pub fn update_note(app: &mut App, items: &mut Vec, value: &str) { - let mut internal_projects = app.projects.clone(); - - internal_projects[app.selected_project_index.selected().unwrap()].tasks + app.projects[app.selected_project_index.selected().unwrap()].tasks [app.selected_task_index.selected().unwrap()] .note = value.to_string(); - Json::write(internal_projects); - Task::reload(app, items) + Json::write(app.projects.clone()); + Task::load_items(app, items) } pub fn change_status(app: &mut App, items: &mut Vec, value: &str) { - let mut internal_projects = app.projects.clone(); let status = value.to_string(); - - let task = &mut internal_projects[app.selected_project_index.selected().unwrap()].tasks - [app.selected_task_index.selected().unwrap()]; + let projects = &mut app.projects[app.selected_project_index.selected().unwrap()].tasks; + let task = &mut projects[app.selected_task_index.selected().unwrap()]; task.status = status.clone(); @@ -237,33 +207,28 @@ impl Task { task.completed_at = Some(Self::current_timestamp()); } } else { - // If changing from Done to another status, clear completion time task.completed_at = None; } - Json::write(internal_projects); - Task::reload(app, items) + Json::write(app.projects.clone()); + Task::load_items(app, items) } pub fn change_priority(app: &mut App, items: &mut Vec, value: u8) { - let mut internal_projects = app.projects.clone(); - - internal_projects[app.selected_project_index.selected().unwrap()].tasks + app.projects[app.selected_project_index.selected().unwrap()].tasks [app.selected_task_index.selected().unwrap()] .priority = value; - Json::write(internal_projects); - Task::reload(app, items) + Json::write(app.projects.clone()); + Task::load_items(app, items) } pub fn delete(app: &mut App, items: &mut Vec) { - let mut internal_projects = app.projects.clone(); - - internal_projects[app.selected_project_index.selected().unwrap()] + app.projects[app.selected_project_index.selected().unwrap()] .tasks .remove(app.selected_task_index.selected().unwrap()); - Json::write(internal_projects); - Task::reload(app, items) + Json::write(app.projects.clone()); + Task::load_items(app, items) } } diff --git a/src/ui.rs b/src/ui.rs index 2338113..327b8c3 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,8 @@ use ratatui::{ layout::{Constraint, Layout, Rect}, - widgets::{Block, Borders, Clear, Paragraph, Widget}, + style::{Color, Style}, + text::Span, + widgets::{Block, Borders, Clear, ListItem, Paragraph, Widget}, Frame, }; use tui_input::Input; @@ -8,6 +10,18 @@ use tui_input::Input; pub struct Ui {} impl Ui { + pub fn load_delete_confirm_items(items: &mut Vec) { + items.clear(); + items.push(ListItem::from(Span::styled( + "Confirm", + Style::new().fg(Color::Red), + ))); + items.push(ListItem::from(Span::styled( + "Cancel", + Style::new().fg(Color::Gray), + ))); + } + pub fn create_rect_area(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let content_height = percent_y.min(r.height.saturating_sub(2)); let vertical_margin = (r.height.saturating_sub(content_height)) / 2; diff --git a/src/util.rs b/src/util.rs index 1d49339..efdbd4d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,3 +1,5 @@ +use chrono::{DateTime, Local}; + use crate::task::TASK_PRIORITIES; pub struct Util; @@ -25,4 +27,41 @@ impl Util { "!!!".chars().take((priority_value).into()).collect() } + + pub fn format_timestamp(timestamp: Option) -> String { + timestamp + .and_then(|ts| DateTime::from_timestamp(ts as i64, 0)) + .map(|dt| { + let local_dt: DateTime = dt.into(); + local_dt.format("%Y-%m-%d %H:%M:%S %z").to_string() + }) + .unwrap_or_else(|| "N/A".to_string()) + } + + pub fn format_duration(start: Option, end: Option) -> String { + match (start, end) { + (Some(s), Some(e)) => { + if e > s { + let duration_secs = e - s; + let days = duration_secs / 86400; + let hours = (duration_secs % 86400) / 3600; + let minutes = (duration_secs % 3600) / 60; + let seconds = duration_secs % 60; + + if days > 0 { + format!("{}d {}h {}m {}s", days, hours, minutes, seconds) + } else if hours > 0 { + format!("{}h {}m {}s", hours, minutes, seconds) + } else if minutes > 0 { + format!("{}m {}s", minutes, seconds) + } else { + format!("{}s", seconds) + } + } else { + "Invalid time range".to_string() + } + } + _ => "Task not completed".to_string(), + } + } } diff --git a/src/view.rs b/src/view.rs index bd5774f..7af40dc 100644 --- a/src/view.rs +++ b/src/view.rs @@ -6,7 +6,6 @@ use ratatui::{ Frame, }; use tui_input::Input; -use chrono::{DateTime, Local}; use crate::{project::Project, task::Task, ui::Ui, util::Util, App, ViewMode}; @@ -129,43 +128,6 @@ impl View { pub fn show_task_details_modal(app: &mut App, f: &mut Frame, area: Rect) { let task = Task::get_current(app); - let format_timestamp = |timestamp: Option| -> String { - timestamp - .and_then(|ts| DateTime::from_timestamp(ts as i64, 0)) - .map(|dt| { - let local_dt: DateTime = dt.into(); - local_dt.format("%Y-%m-%d %H:%M:%S %z").to_string() - }) - .unwrap_or_else(|| "N/A".to_string()) - }; - - let format_duration = |start: Option, end: Option| -> String { - match (start, end) { - (Some(s), Some(e)) => { - if e > s { - let duration_secs = e - s; - let days = duration_secs / 86400; - let hours = (duration_secs % 86400) / 3600; - let minutes = (duration_secs % 3600) / 60; - let seconds = duration_secs % 60; - - if days > 0 { - format!("{}d {}h {}m {}s", days, hours, minutes, seconds) - } else if hours > 0 { - format!("{}h {}m {}s", hours, minutes, seconds) - } else if minutes > 0 { - format!("{}m {}s", minutes, seconds) - } else { - format!("{}s", seconds) - } - } else { - "Invalid time range".to_string() - } - } - _ => "Task not completed".to_string(), - } - }; - let priority_text = if task.priority == 0 { "None".to_string() } else { @@ -199,7 +161,7 @@ impl View { if task.created_at.is_some() { lines.push(Line::from(vec![ Span::styled("Created: ", Style::default().fg(Color::Cyan)), - Span::raw(format_timestamp(task.created_at)), + Span::raw(Util::format_timestamp(task.created_at)), ])); lines.push(Line::raw("")); } @@ -208,7 +170,7 @@ impl View { if task.completed_at.is_some() { lines.push(Line::from(vec![ Span::styled("Completed: ", Style::default().fg(Color::Cyan)), - Span::raw(format_timestamp(task.completed_at)), + Span::raw(Util::format_timestamp(task.completed_at)), ])); lines.push(Line::raw("")); } @@ -217,7 +179,7 @@ impl View { if task.created_at.is_some() { lines.push(Line::from(vec![ Span::styled("Time Consumed: ", Style::default().fg(Color::Cyan)), - Span::raw(format_duration(task.created_at, task.completed_at)), + Span::raw(Util::format_duration(task.created_at, task.completed_at)), ])); lines.push(Line::raw("")); } From 8e9410da8de67febe3470d492544c9c594eb46c4 Mon Sep 17 00:00:00 2001 From: kenshin Date: Sat, 27 Jun 2026 00:20:26 +0800 Subject: [PATCH 16/23] Clarify priority semantics and widen narrow modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TASK_PRIORITY_NONE constant instead of magic 0 - Widen status and priority list modals to prevent text clipping - Update all priority 0 references to TASK_PRIORITY_NONE 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-pro --- src/migration.rs | 3 ++- src/task.rs | 14 ++++++++------ src/util.rs | 4 ++-- src/view.rs | 8 ++++---- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/migration.rs b/src/migration.rs index 77072ff..dd1bab1 100644 --- a/src/migration.rs +++ b/src/migration.rs @@ -1,4 +1,5 @@ use crate::project::Project; +use crate::task::TASK_PRIORITY_NONE; // sha of 0.1.0 0.2.0 0.2.2 pub static JSON_VERSIONS: [&str; 3] = ["6ad96", "911fc", "a4e1b"]; @@ -38,7 +39,7 @@ impl Migration { fn add_priority(mut data: Vec) -> Vec { for p in data.iter_mut() { for t in p.tasks.iter_mut() { - t.priority = 0; + t.priority = TASK_PRIORITY_NONE; } } data diff --git a/src/task.rs b/src/task.rs index b22b06c..9b451b7 100644 --- a/src/task.rs +++ b/src/task.rs @@ -25,14 +25,16 @@ pub const TASK_STATUS_DONE: &str = "Done"; pub const TASK_STATUS_ON_GOING: &str = "OnGoing"; pub const TASK_STATUS_UP_NEXT: &str = "UpNext"; +pub const TASK_PRIORITY_NONE: u8 = 0; + pub const TASK_STATUSES: [&'static str; 3] = [TASK_STATUS_UP_NEXT, TASK_STATUS_ON_GOING, TASK_STATUS_DONE]; const TASK_STATUSES_SORT_ORDER: [&'static str; 3] = [TASK_STATUS_ON_GOING, TASK_STATUS_UP_NEXT, TASK_STATUS_DONE]; -// Ascending order: 1 highest priority; 2 medium; 3 lowest -pub const TASK_PRIORITIES: [u8; 4] = [1, 2, 3, 0]; +// Ascending order: 1 highest priority; 2 medium; 3 lowest; TASK_PRIORITY_NONE = no priority +pub const TASK_PRIORITIES: [u8; 4] = [1, 2, 3, TASK_PRIORITY_NONE]; impl Task { pub fn get_status_color(status: &String) -> ratatui::prelude::Color { @@ -86,7 +88,7 @@ impl Task { .unwrap_or(&Task { title: "".to_string(), status: "".to_string(), - priority: 0, + priority: TASK_PRIORITY_NONE, created_at: None, completed_at: None, note: "".to_string(), @@ -135,7 +137,7 @@ impl Task { Span::styled(task.title.clone(), Style::default().add_modifier(modifier)), ]; - if task.priority != 0 { + if task.priority != TASK_PRIORITY_NONE { let priority_repr = vec![Span::styled( format!("[{}] ", Util::get_priority_indicator(task.priority)), Style::new().fg(Color::Red), @@ -166,7 +168,7 @@ impl Task { .push(Task { title: value.to_string(), status: TASK_STATUS_UP_NEXT.to_string(), - priority: 0, + priority: TASK_PRIORITY_NONE, created_at: Some(Self::current_timestamp()), completed_at: None, note: "".to_string(), @@ -202,7 +204,7 @@ impl Task { task.status = status.clone(); if status == TASK_STATUS_DONE { - task.priority = 0; + task.priority = TASK_PRIORITY_NONE; if task.completed_at.is_none() { task.completed_at = Some(Self::current_timestamp()); } diff --git a/src/util.rs b/src/util.rs index efdbd4d..ff77d30 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Local}; -use crate::task::TASK_PRIORITIES; +use crate::task::{TASK_PRIORITIES, TASK_PRIORITY_NONE}; pub struct Util; @@ -15,7 +15,7 @@ impl Util { // priority: 1 => !!! // priority: 2 => !! // priority: 3 => ! - let priority_value = if value == 0 { + let priority_value = if value == TASK_PRIORITY_NONE { 0 } else { TASK_PRIORITIES diff --git a/src/view.rs b/src/view.rs index 7af40dc..938859b 100644 --- a/src/view.rs +++ b/src/view.rs @@ -7,7 +7,7 @@ use ratatui::{ }; use tui_input::Input; -use crate::{project::Project, task::Task, ui::Ui, util::Util, App, ViewMode}; +use crate::{project::Project, task::{Task, TASK_PRIORITY_NONE}, ui::Ui, util::Util, App, ViewMode}; pub struct View {} @@ -65,7 +65,7 @@ impl View { f: &mut Frame, area: Rect, ) { - let area = Ui::create_rect_area(10, 5, area); + let area = Ui::create_rect_area(20, 5, area); let task_status_list_widget = List::new(status_items.clone()) .highlight_style(Style::default().add_modifier(Modifier::BOLD)) @@ -83,7 +83,7 @@ impl View { f: &mut Frame, area: Rect, ) { - let area = Ui::create_rect_area(10, 6, area); + let area = Ui::create_rect_area(20, 6, area); let task_status_list_widget = List::new(priority_items.clone()) .highlight_style(Style::default().add_modifier(Modifier::BOLD)) @@ -128,7 +128,7 @@ impl View { pub fn show_task_details_modal(app: &mut App, f: &mut Frame, area: Rect) { let task = Task::get_current(app); - let priority_text = if task.priority == 0 { + let priority_text = if task.priority == TASK_PRIORITY_NONE { "None".to_string() } else { format!("{} ({})", task.priority, Util::get_priority_indicator(task.priority)) From b6a939df14e1058cc1d95528c7de6715961bc46e Mon Sep 17 00:00:00 2001 From: kenshin Date: Fri, 24 Jul 2026 23:19:00 +0800 Subject: [PATCH 17/23] Apply cargo fmt with current stable rustfmt --- src/json.rs | 23 ++++++++++++++--------- src/main.rs | 40 +++++++++++++++++++++++++++++++--------- src/migration.rs | 4 +--- src/project.rs | 3 ++- src/ui.rs | 1 - src/view.rs | 43 ++++++++++++++++++++++++++++++++++--------- 6 files changed, 82 insertions(+), 32 deletions(-) diff --git a/src/json.rs b/src/json.rs index 4711435..3e57fca 100644 --- a/src/json.rs +++ b/src/json.rs @@ -1,9 +1,4 @@ -use std::{ - error::Error, - fs, - path::PathBuf, - sync::Mutex, -}; +use std::{error::Error, fs, path::PathBuf, sync::Mutex}; use serde::{Deserialize, Serialize}; use serde_json::{from_str, to_string}; @@ -78,7 +73,11 @@ impl Json { 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()); + Json::write_internal( + &data_path, + version_state.to_string(), + migration_data.clone(), + ); } return Ok(true); @@ -98,7 +97,10 @@ impl Json { version_state.clear(); version_state.push_str(last_json_version); let projects: Vec = vec![]; - let wrapper = DataWrapper { version: last_json_version.to_string(), data: projects }; + let wrapper = DataWrapper { + version: last_json_version.to_string(), + data: projects, + }; fs::write(&data_path, to_string(&wrapper).unwrap()).unwrap(); return Ok(false); } @@ -110,7 +112,10 @@ impl Json { version_state.clear(); version_state.push_str(old_version); - let wrapper = DataWrapper { version: old_version.to_string(), data }; + let wrapper = DataWrapper { + version: old_version.to_string(), + data, + }; fs::write(&data_path, to_string(&wrapper).unwrap()).unwrap(); // Optionally delete old file diff --git a/src/main.rs b/src/main.rs index f2b0199..95c3555 100644 --- a/src/main.rs +++ b/src/main.rs @@ -136,7 +136,15 @@ impl App { loop { terminal.draw(|f| { - self.render(f, f.size(), &input, &items, &status_items, &priority_items, &delete_confirm_items) + self.render( + f, + f.size(), + &input, + &items, + &status_items, + &priority_items, + &delete_confirm_items, + ) })?; if let Event::Key(key) = event::read()? { @@ -225,7 +233,11 @@ impl App { } }, ViewMode::DeleteProject => { - if self.handle_modal_nav(key.code, &delete_confirm_items, ViewMode::ViewProjects) { + if self.handle_modal_nav( + key.code, + &delete_confirm_items, + ViewMode::ViewProjects, + ) { continue; } if key.code == Enter { @@ -236,7 +248,7 @@ impl App { self.delete_confirm_index.select(Some(0)); App::change_view(self, ViewMode::ViewProjects); } - }, + } ViewMode::ViewTasks => match key.code { Char('h') => { @@ -363,9 +375,10 @@ impl App { self.selected_status_task_index.select(Some(0)); App::change_view(self, ViewMode::ViewTasks); } - }, + } ViewMode::ChangePriorityTask => { - if self.handle_modal_nav(key.code, &priority_items, ViewMode::ViewTasks) { + if self.handle_modal_nav(key.code, &priority_items, ViewMode::ViewTasks) + { continue; } if key.code == Enter { @@ -379,7 +392,7 @@ impl App { self.selected_priority_task_index.select(Some(0)); App::change_view(self, ViewMode::ViewTasks); } - }, + } ViewMode::AddTask => match key.code { Enter => { Task::create(self, &mut items, input.value()); @@ -394,7 +407,11 @@ impl App { } }, ViewMode::DeleteTask => { - if self.handle_modal_nav(key.code, &delete_confirm_items, ViewMode::ViewTasks) { + if self.handle_modal_nav( + key.code, + &delete_confirm_items, + ViewMode::ViewTasks, + ) { continue; } if key.code == Enter { @@ -405,7 +422,7 @@ impl App { self.delete_confirm_index.select(Some(0)); App::change_view(self, ViewMode::ViewTasks); } - }, + } ViewMode::ViewTaskDetails => match key.code { Char('e') => { input = input @@ -585,7 +602,12 @@ impl App { self.view_mode = mode } - fn handle_modal_nav(&mut self, key: KeyCode, items: &Vec, return_mode: ViewMode) -> bool { + fn handle_modal_nav( + &mut self, + key: KeyCode, + items: &Vec, + return_mode: ViewMode, + ) -> bool { match key { KeyCode::Esc => { self.use_state().select(Some(0)); diff --git a/src/migration.rs b/src/migration.rs index dd1bab1..bde37c8 100644 --- a/src/migration.rs +++ b/src/migration.rs @@ -16,9 +16,7 @@ impl Migration { ]; // The start index where the migration are picked - let start_index = mapper - .iter() - .position(|(key, _val)| *key == version); + let start_index = mapper.iter().position(|(key, _val)| *key == version); if start_index.is_none() { return vec![]; diff --git a/src/project.rs b/src/project.rs index 956b677..75679cc 100644 --- a/src/project.rs +++ b/src/project.rs @@ -83,7 +83,8 @@ impl Project { } pub fn delete(app: &mut App, items: &mut Vec) { - app.projects.remove(app.selected_project_index.selected().unwrap()); + app.projects + .remove(app.selected_project_index.selected().unwrap()); Json::write(app.projects.clone()); Project::load_items(app, items) diff --git a/src/ui.rs b/src/ui.rs index 327b8c3..f6d5a16 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -73,5 +73,4 @@ impl Ui { area.y + 1, ) } - } diff --git a/src/view.rs b/src/view.rs index 938859b..891bdb7 100644 --- a/src/view.rs +++ b/src/view.rs @@ -7,7 +7,13 @@ use ratatui::{ }; use tui_input::Input; -use crate::{project::Project, task::{Task, TASK_PRIORITY_NONE}, ui::Ui, util::Util, App, ViewMode}; +use crate::{ + project::Project, + task::{Task, TASK_PRIORITY_NONE}, + ui::Ui, + util::Util, + App, ViewMode, +}; pub struct View {} @@ -131,18 +137,30 @@ impl View { let priority_text = if task.priority == TASK_PRIORITY_NONE { "None".to_string() } else { - format!("{} ({})", task.priority, Util::get_priority_indicator(task.priority)) + format!( + "{} ({})", + task.priority, + Util::get_priority_indicator(task.priority) + ) }; let mut lines = vec![ Line::from(vec![ - Span::styled("Task: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled( + "Task: ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), Span::raw(&task.title), ]), Line::raw(""), Line::from(vec![ Span::styled("Status: ", Style::default().fg(Color::Cyan)), - Span::styled(&task.status, Style::default().fg(Task::get_status_color(&task.status))), + Span::styled( + &task.status, + Style::default().fg(Task::get_status_color(&task.status)), + ), ]), Line::raw(""), Line::from(vec![ @@ -185,9 +203,12 @@ impl View { } lines.push(Line::raw("")); - lines.push(Line::from(vec![ - Span::styled("Press any key to close", Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC)), - ])); + lines.push(Line::from(vec![Span::styled( + "Press any key to close", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + )])); let widget = Paragraph::new(Text::from(lines)) .alignment(Alignment::Left) @@ -231,7 +252,9 @@ impl View { Line::from(vec![ Span::styled( format!("{:<16}", keys), - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), ), Span::raw(*desc), ]) @@ -241,7 +264,9 @@ impl View { lines.push(Line::raw("")); lines.push(Line::from(Span::styled( "Press any key to close", - Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), ))); let widget = Paragraph::new(Text::from(lines)) From 49e9b6d101f749e9c5971281aa260e8d9e166ae6 Mon Sep 17 00:00:00 2001 From: kenshin Date: Fri, 24 Jul 2026 23:19:23 +0800 Subject: [PATCH 18/23] Fix out-of-bounds selection after project creation and add test suite - AddProject handler selected projects.len() (one past the end), so opening the tasks of a freshly created project panicked with an index out of bounds error; select len() - 1 and skip empty titles - Guard App::next/previous against empty lists (len() - 1 underflow) - Allow overriding the storage dir via BASILK_CONFIG_DIR so tests never touch the real config directory - Add unit tests for util, migration, json, project, task and app navigation, plus proptest-based property/fuzz tests --- AGENTS.md | 9 +- Cargo.lock | 273 ++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 4 + src/json.rs | 79 ++++++++++++ src/main.rs | 152 ++++++++++++++++++++++- src/migration.rs | 58 +++++++++ src/project.rs | 99 ++++++++++++++- src/property_tests.rs | 130 ++++++++++++++++++++ src/task.rs | 209 +++++++++++++++++++++++++++++++- src/util.rs | 40 +++++++ 10 files changed, 1034 insertions(+), 19 deletions(-) create mode 100644 src/property_tests.rs diff --git a/AGENTS.md b/AGENTS.md index 3f02d36..3c6a20d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Basilk is a TUI-based kanban task manager written in Rust using `ratatui`. - **Build**: `cargo build` - **Run**: `cargo run` -- **Test**: `cargo test` (Note: Currently the project has no automated tests in `src/`) +- **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` @@ -23,6 +23,7 @@ Basilk is a TUI-based kanban task manager written in Rust using `ratatui`. - `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 @@ -33,8 +34,9 @@ The `App` struct in `main.rs` manages the application state, including selected `ViewMode` enum in `main.rs` defines the different screens and states (e.g., `ViewProjects`, `AddTask`, `ViewTasks`). ### Data Persistence -- Data is stored in JSON files named after a version hash (e.g., `911fc.json`) in the user's config directory. +- Data is stored in `basilk_data.json` (a versioned wrapper around the project 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. +- 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 @@ -52,7 +54,8 @@ The `App` struct in `main.rs` manages the application state, including selected - **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. - **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 by status and priority during `Task::load_items`. +- **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. +- **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 diff --git a/Cargo.lock b/Cargo.lock index 6779b46..7310d42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,7 +11,7 @@ dependencies = [ "cfg-if", "once_cell", "version_check", - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -41,17 +41,34 @@ version = "0.2.1" dependencies = [ "chrono", "dirs", + "proptest", "ratatui", "serde", "serde_json", + "tempfile", "tui-input", ] +[[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" @@ -165,7 +182,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -174,12 +191,34 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +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 = "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 = "getrandom" version = "0.2.15" @@ -191,6 +230,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" @@ -258,9 +320,9 @@ dependencies = [ [[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" @@ -272,6 +334,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" @@ -312,7 +380,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -365,6 +433,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" @@ -374,6 +451,31 @@ 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 = "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" @@ -383,6 +485,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" @@ -419,17 +571,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" @@ -565,6 +748,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" @@ -595,6 +791,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -630,12 +832,30 @@ 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" @@ -771,6 +991,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" @@ -892,13 +1121,28 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" 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]] @@ -911,3 +1155,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 09c24b2..9abd81c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,7 @@ ratatui = "0.27.0" serde = { version = "1.0.204", features = ["derive"] } serde_json = "1.0.122" tui-input = "0.9.0" + +[dev-dependencies] +proptest = "1.5" +tempfile = "3.10" diff --git a/src/json.rs b/src/json.rs index 3e57fca..a7a0e11 100644 --- a/src/json.rs +++ b/src/json.rs @@ -22,6 +22,10 @@ struct DataWrapper { 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); @@ -150,3 +154,78 @@ impl Json { fs::write(path, to_string(&wrapper).unwrap()).unwrap(); } } + +#[cfg(test)] +mod tests { + use super::*; + 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 check_migrates_old_versioned_files() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BASILK_CONFIG_DIR", dir.path()); + + // Old format: a bare Vec stored in `.json` + let old_projects = vec![Project { + title: "legacy".to_string(), + tasks: vec![make_task("old task", TASK_STATUS_DONE, 3)], + }]; + let old_path = Json::get_json_path(JSON_VERSIONS[0].to_string()); + fs::write(&old_path, to_string(&old_projects).unwrap()).unwrap(); + + let migrated = Json::check().unwrap(); + + assert!(migrated); + assert!(!old_path.is_file(), "old versioned file is removed"); + + let projects = Json::read(); + 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 95c3555..be8e14d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -222,9 +222,11 @@ impl App { App::change_view(self, ViewMode::ViewProjects); } Enter => { - Project::create(self, &mut items, input.value()); - self.selected_project_index - .select(Some(self.projects.len())); + if !input.value().is_empty() { + Project::create(self, &mut items, input.value()); + self.selected_project_index + .select(Some(self.projects.len() - 1)); + } App::change_view(self, ViewMode::ViewProjects); } @@ -548,6 +550,10 @@ impl App { } 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 { @@ -563,6 +569,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 { @@ -626,3 +636,139 @@ impl App { } } } + +#[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, + } + } + + 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(), + } + } + + 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); + } +} diff --git a/src/migration.rs b/src/migration.rs index bde37c8..90a5391 100644 --- a/src/migration.rs +++ b/src/migration.rs @@ -52,3 +52,61 @@ impl Migration { data } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::task::{Task, TASK_PRIORITY_NONE, TASK_STATUS_ON_GOING}; + + fn sample_data() -> Vec { + vec![Project { + title: "p".to_string(), + tasks: vec![Task { + title: "t".to_string(), + status: TASK_STATUS_ON_GOING.to_string(), + priority: 2, + created_at: None, + completed_at: None, + note: "keep me".to_string(), + }], + }] + } + + #[test] + fn migrations_from_oldest_version_apply_all_steps() { + let migrations = Migration::get_migrations("6ad96", sample_data()); + + assert_eq!(migrations.len(), 2); + assert_eq!(migrations[0].0, "911fc"); + assert_eq!(migrations[1].0, "a4e1b"); + + let task = &migrations[1].1[0].tasks[0]; + assert_eq!(task.priority, TASK_PRIORITY_NONE); + assert_eq!(task.note, ""); + } + + #[test] + fn migrations_from_middle_version_apply_remaining_steps() { + let migrations = Migration::get_migrations("911fc", sample_data()); + + assert_eq!(migrations.len(), 1); + assert_eq!(migrations[0].0, "a4e1b"); + // add_priority is not re-applied: priority is preserved + assert_eq!(migrations[0].1[0].tasks[0].priority, 2); + } + + #[test] + fn no_migrations_for_latest_or_unknown_version() { + assert!(Migration::get_migrations("a4e1b", sample_data()).is_empty()); + assert!(Migration::get_migrations("unknown", sample_data()).is_empty()); + } + + #[test] + fn json_versions_are_chained_in_mapper_order() { + // Every version except the last must produce migrations ending at the last version + for (i, version) in JSON_VERSIONS.iter().enumerate() { + let migrations = Migration::get_migrations(version, vec![]); + assert_eq!(migrations.len(), JSON_VERSIONS.len() - 1 - i); + } + } +} diff --git a/src/project.rs b/src/project.rs index 75679cc..138d185 100644 --- a/src/project.rs +++ b/src/project.rs @@ -11,7 +11,7 @@ use crate::{ App, }; -#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct Project { pub title: String, pub tasks: Vec, @@ -90,3 +90,100 @@ impl Project { Project::load_items(app, items) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{make_app, sample_projects, setup_temp_config, ENV_LOCK}; + + #[test] + fn create_appends_project_and_persists() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![]); + let mut items = vec![]; + + Project::create(&mut app, &mut items, "todo"); + + assert_eq!(app.projects.len(), 1); + assert_eq!(app.projects[0].title, "todo"); + assert_eq!(items.len(), 1); + assert_eq!(Json::read(), app.projects); + } + + #[test] + fn create_with_empty_value_is_a_no_op() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![]); + let mut items = vec![]; + + Project::create(&mut app, &mut items, ""); + + assert!(app.projects.is_empty()); + assert!(items.is_empty()); + } + + #[test] + fn rename_updates_selected_project() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(sample_projects()); + let mut items = vec![]; + app.selected_project_index.select(Some(1)); + + Project::rename(&mut app, &mut items, "renamed"); + + assert_eq!(app.projects[1].title, "renamed"); + assert_eq!(app.projects[0].title, "alpha"); + assert_eq!(Json::read()[1].title, "renamed"); + } + + #[test] + fn delete_removes_selected_project() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(sample_projects()); + let mut items = vec![]; + + Project::delete(&mut app, &mut items); + + assert_eq!(app.projects.len(), 1); + assert_eq!(app.projects[0].title, "beta"); + assert_eq!(items.len(), 1); + assert_eq!(Json::read(), app.projects); + } + + #[test] + fn load_items_builds_one_item_per_project() { + let mut app = make_app(sample_projects()); + let mut items = vec![]; + + Project::load_items(&mut app, &mut items); + + assert_eq!(items.len(), 2); + } + + #[test] + fn done_tasks_color_thresholds() { + assert_eq!(Project::get_indicator_done_tasks_color(0), Color::DarkGray); + assert_eq!( + Project::get_indicator_done_tasks_color(25), + Color::LightMagenta + ); + assert_eq!( + Project::get_indicator_done_tasks_color(50), + Color::LightMagenta + ); + assert_eq!( + Project::get_indicator_done_tasks_color(75), + Color::LightYellow + ); + assert_eq!( + Project::get_indicator_done_tasks_color(100), + Color::LightGreen + ); + // Values outside 0..=100 fall back to white + assert_eq!(Project::get_indicator_done_tasks_color(1), Color::White); + } +} diff --git a/src/property_tests.rs b/src/property_tests.rs new file mode 100644 index 0000000..9c3a8b6 --- /dev/null +++ b/src/property_tests.rs @@ -0,0 +1,130 @@ +//! Property-based (fuzz-style) tests. +//! +//! These tests feed randomized inputs — including invalid statuses and +//! priorities — into the data layer to make sure nothing panics and the +//! documented invariants hold for any input. +use proptest::prelude::*; + +use crate::migration::{Migration, JSON_VERSIONS}; +use crate::project::Project; +use crate::task::{Task, TASK_STATUS_DONE, TASK_STATUS_ON_GOING, TASK_STATUS_UP_NEXT}; +use crate::test_utils::make_app; +use crate::util::Util; + +fn arb_status() -> impl Strategy { + prop::sample::select(vec![ + TASK_STATUS_UP_NEXT.to_string(), + TASK_STATUS_ON_GOING.to_string(), + TASK_STATUS_DONE.to_string(), + // Unknown status values must not crash anything + "Bogus".to_string(), + ]) +} + +fn arb_task() -> impl Strategy { + ( + ".*", + arb_status(), + any::(), + any::>(), + any::>(), + ".*", + ) + .prop_map( + |(title, status, priority, created_at, completed_at, note)| Task { + title, + status, + priority, + created_at, + completed_at, + note, + }, + ) +} + +fn arb_projects() -> impl Strategy> { + prop::collection::vec( + (".*", prop::collection::vec(arb_task(), 0..8)) + .prop_map(|(title, tasks)| Project { title, tasks }), + 0..5, + ) +} + +proptest! { + #[test] + fn serde_round_trip(projects in arb_projects()) { + let raw = serde_json::to_string(&projects).unwrap(); + let parsed: Vec = serde_json::from_str(&raw).unwrap(); + prop_assert_eq!(projects, parsed); + } + + #[test] + fn migrations_never_panic_and_preserve_shape( + projects in arb_projects(), + version in prop::sample::select(JSON_VERSIONS.to_vec()), + ) { + let task_count: usize = projects.iter().map(|p| p.tasks.len()).sum(); + let migrations = Migration::get_migrations(version, projects); + + prop_assert!(migrations.len() < JSON_VERSIONS.len()); + for (_, data) in &migrations { + prop_assert_eq!( + data.iter().map(|p| p.tasks.len()).sum::(), + task_count + ); + } + } + + #[test] + fn task_load_items_never_panics_and_selects_a_valid_index( + tasks in prop::collection::vec(arb_task(), 0..8), + hide_done in any::(), + selected in any::(), + ) { + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks, + }]); + app.hide_done_tasks = hide_done; + app.selected_task_index.select(Some(selected)); + let mut items = vec![]; + + Task::load_items(&mut app, &mut items); + + let expected_visible = app.projects[0] + .tasks + .iter() + .filter(|t| !(hide_done && t.status == TASK_STATUS_DONE)) + .count(); + prop_assert_eq!(items.len(), expected_visible); + if !items.is_empty() { + prop_assert!(app.selected_task_index.selected().unwrap() < items.len()); + } + } + + #[test] + fn project_load_items_matches_project_count(projects in arb_projects()) { + let mut app = make_app(projects.clone()); + let mut items = vec![]; + + Project::load_items(&mut app, &mut items); + + prop_assert_eq!(items.len(), projects.len()); + } + + #[test] + fn format_duration_never_panics(a in any::>(), b in any::>()) { + let out = Util::format_duration(a, b); + prop_assert!(!out.is_empty()); + } + + #[test] + fn format_timestamp_never_panics(ts in any::>()) { + let _ = Util::format_timestamp(ts); + } + + #[test] + fn priority_indicator_is_bounded(value in any::()) { + prop_assert!(Util::get_priority_indicator(value).chars().count() <= 3); + } +} diff --git a/src/task.rs b/src/task.rs index 9b451b7..c69b52a 100644 --- a/src/task.rs +++ b/src/task.rs @@ -8,7 +8,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::{json::Json, util::Util, App}; -#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct Task { pub title: String, pub status: String, @@ -96,14 +96,15 @@ impl Task { .clone() .title; - // Sort by status + // Sort by status first, then by priority: `sort_by_key` is stable, so + // the last sort wins as the primary key and the final order is + // priority-major (status is the tie-breaker within each priority). tasks.sort_by_key(|t| { TASK_STATUSES_SORT_ORDER .into_iter() .position(|o| o == t.status) }); - // Sort by priority tasks.sort_by_key(|t| TASK_PRIORITIES.into_iter().position(|o| o == t.priority)); let new_index = tasks @@ -234,3 +235,205 @@ impl Task { Task::load_items(app, items) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::project::Project; + use crate::test_utils::{make_app, make_task, setup_temp_config, ENV_LOCK}; + + fn app_with_tasks(tasks: Vec) -> App { + make_app(vec![Project { + title: "p".to_string(), + tasks, + }]) + } + + #[test] + fn create_appends_task_and_persists() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = app_with_tasks(vec![]); + let mut items = vec![]; + + Task::create(&mut app, &mut items, "do it"); + + let task = &app.projects[0].tasks[0]; + assert_eq!(task.title, "do it"); + assert_eq!(task.status, TASK_STATUS_UP_NEXT); + assert_eq!(task.priority, TASK_PRIORITY_NONE); + assert!(task.created_at.is_some()); + assert_eq!(items.len(), 1); + assert_eq!(Json::read(), app.projects); + } + + #[test] + fn create_with_empty_value_is_a_no_op() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = app_with_tasks(vec![]); + let mut items = vec![]; + + Task::create(&mut app, &mut items, ""); + + assert!(app.projects[0].tasks.is_empty()); + } + + #[test] + fn change_status_to_done_sets_completed_at_and_clears_priority() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = app_with_tasks(vec![make_task("t", TASK_STATUS_ON_GOING, 1)]); + let mut items = vec![]; + app.hide_done_tasks = false; + + Task::change_status(&mut app, &mut items, TASK_STATUS_DONE); + + let task = &app.projects[0].tasks[0]; + assert_eq!(task.status, TASK_STATUS_DONE); + assert_eq!(task.priority, TASK_PRIORITY_NONE); + assert!(task.completed_at.is_some()); + } + + #[test] + fn change_status_away_from_done_clears_completed_at() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut task = make_task("t", TASK_STATUS_DONE, TASK_PRIORITY_NONE); + task.completed_at = Some(1_700_000_100); + let mut app = app_with_tasks(vec![task]); + let mut items = vec![]; + app.hide_done_tasks = false; + + Task::change_status(&mut app, &mut items, TASK_STATUS_ON_GOING); + + let task = &app.projects[0].tasks[0]; + assert_eq!(task.status, TASK_STATUS_ON_GOING); + assert_eq!(task.completed_at, None); + } + + #[test] + fn change_priority_updates_selected_task() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = app_with_tasks(vec![make_task( + "t", + TASK_STATUS_UP_NEXT, + TASK_PRIORITY_NONE, + )]); + let mut items = vec![]; + + Task::change_priority(&mut app, &mut items, 2); + + assert_eq!(app.projects[0].tasks[0].priority, 2); + } + + #[test] + fn rename_and_update_note_modify_selected_task() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = app_with_tasks(vec![make_task( + "old", + TASK_STATUS_UP_NEXT, + TASK_PRIORITY_NONE, + )]); + let mut items = vec![]; + + Task::rename(&mut app, &mut items, "new"); + Task::update_note(&mut app, &mut items, "a note"); + + let task = &app.projects[0].tasks[0]; + assert_eq!(task.title, "new"); + assert_eq!(task.note, "a note"); + } + + #[test] + fn delete_removes_selected_task() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = app_with_tasks(vec![ + make_task("t1", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + make_task("t2", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + ]); + let mut items = vec![]; + app.selected_task_index.select(Some(1)); + + Task::delete(&mut app, &mut items); + + assert_eq!(app.projects[0].tasks.len(), 1); + assert_eq!(app.projects[0].tasks[0].title, "t1"); + } + + #[test] + fn load_items_sorts_by_priority_then_status() { + let mut app = app_with_tasks(vec![ + make_task("done-task", TASK_STATUS_DONE, TASK_PRIORITY_NONE), + make_task("up-next-low", TASK_STATUS_UP_NEXT, 3), + make_task("on-going", TASK_STATUS_ON_GOING, TASK_PRIORITY_NONE), + make_task("up-next-high", TASK_STATUS_UP_NEXT, 1), + ]); + let mut items = vec![]; + app.hide_done_tasks = false; + + Task::load_items(&mut app, &mut items); + + let titles: Vec<&str> = app.projects[0] + .tasks + .iter() + .map(|t| t.title.as_str()) + .collect(); + // Priority is the primary key (1, 3, then NONE); within the NONE + // group the status order applies, so done tasks come last. + assert_eq!( + titles, + vec!["up-next-high", "up-next-low", "on-going", "done-task"] + ); + } + + #[test] + fn load_items_hides_done_tasks_when_enabled() { + let mut app = app_with_tasks(vec![ + make_task("visible", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + make_task("hidden", TASK_STATUS_DONE, TASK_PRIORITY_NONE), + ]); + let mut items = vec![]; + + Task::load_items(&mut app, &mut items); + + assert_eq!(items.len(), 1); + } + + #[test] + fn load_items_keeps_selection_on_the_same_task_after_resort() { + let mut app = app_with_tasks(vec![ + make_task("a", TASK_STATUS_ON_GOING, TASK_PRIORITY_NONE), + make_task("b", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE), + ]); + let mut items = vec![]; + app.selected_task_index.select(Some(0)); // task "a" + + Task::load_items(&mut app, &mut items); + + let selected = Task::get_current(&mut app); + assert_eq!(selected.title, "a"); + } + + /// Regression test for the reported crash: after creating a project the + /// selection must point at an existing project when opening its tasks. + #[test] + fn create_project_then_view_tasks_does_not_panic() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = make_app(vec![]); + let mut items = vec![]; + + Project::create(&mut app, &mut items, "todo"); + // This is what the fixed AddProject handler does: select the last index + app.selected_project_index + .select(Some(app.projects.len() - 1)); + + Task::load_items(&mut app, &mut items); + + assert!(items.is_empty()); + } +} diff --git a/src/util.rs b/src/util.rs index ff77d30..b0bb299 100644 --- a/src/util.rs +++ b/src/util.rs @@ -65,3 +65,43 @@ impl Util { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spaced_title_wraps_with_spaces() { + assert_eq!(Util::get_spaced_title("hello"), " hello "); + } + + #[test] + fn priority_indicator_mapping() { + assert_eq!(Util::get_priority_indicator(TASK_PRIORITY_NONE), ""); + assert_eq!(Util::get_priority_indicator(1), "!!!"); + assert_eq!(Util::get_priority_indicator(2), "!!"); + assert_eq!(Util::get_priority_indicator(3), "!"); + // Unknown values fall back to an empty indicator + assert_eq!(Util::get_priority_indicator(99), ""); + } + + #[test] + fn format_timestamp_handles_none_and_epoch() { + assert_eq!(Util::format_timestamp(None), "N/A"); + assert!(Util::format_timestamp(Some(0)).contains("1970-01-01")); + } + + #[test] + fn format_duration_variants() { + assert_eq!(Util::format_duration(None, Some(10)), "Task not completed"); + assert_eq!(Util::format_duration(Some(10), None), "Task not completed"); + assert_eq!( + Util::format_duration(Some(10), Some(10)), + "Invalid time range" + ); + assert_eq!(Util::format_duration(Some(0), Some(45)), "45s"); + assert_eq!(Util::format_duration(Some(0), Some(90)), "1m 30s"); + assert_eq!(Util::format_duration(Some(0), Some(3700)), "1h 1m 40s"); + assert_eq!(Util::format_duration(Some(0), Some(90061)), "1d 1h 1m 1s"); + } +} From 39dfc788abcaa63668bb98cfed735b0405b90e8f Mon Sep 17 00:00:00 2001 From: LiuYinCarl <1427518212@qq.coom> Date: Sun, 26 Jul 2026 23:29:22 +0800 Subject: [PATCH 19/23] Add task stopwatch, global pomodoro timer, and time estimates - Stopwatch (s) bound to the selected task accumulates into time_spent_secs; pomodoro (c) is a global countdown that rings the terminal bell at zero and persists nothing - Per-task estimated_hours (g in task details) with progress shown in the task list, details view, and timer modal - Event loop switches to poll-based ticking for live readouts; timers survive view switches and settle on stop, countdown zero, or quit - New c8f21 JSON migration for the timer fields --- AGENTS.md | 6 +- README.md | 18 +- README.zh-CN.md | 18 +- proptest-regressions/property_tests.txt | 7 + src/main.rs | 864 ++++++++++++++++-------- src/migration.rs | 29 +- src/property_tests.rs | 17 +- src/task.rs | 145 ++++ src/timer.rs | 180 +++++ src/util.rs | 19 + src/view.rs | 181 ++++- 11 files changed, 1189 insertions(+), 295 deletions(-) create mode 100644 proptest-regressions/property_tests.txt create mode 100644 src/timer.rs diff --git a/AGENTS.md b/AGENTS.md index 3c6a20d..9c2eb91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Basilk is a TUI-based kanban task manager written in Rust using `ratatui`. - `src/migration.rs`: JSON data schema migrations. - `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. @@ -53,10 +54,13 @@ The `App` struct in `main.rs` manages the application state, including selected ## 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 and is dropped. 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. - **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 -Stored in `config.toml` in the config directory. Currently only supports `ui.show_help`. +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/README.md b/README.md index 5c2ac70..c80abe1 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Press `h` to view the keybinding list in-app. | `n` | New project | | `r` | Rename selected project | | `d` | Delete selected project | +| `c` | Pomodoro (countdown) timer | | `h` | Help | ### Task List View @@ -86,6 +87,8 @@ Press `h` to view the keybinding list in-app. | `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 @@ -111,9 +114,22 @@ Press `h` to view the keybinding list in-app. | Key | Action | |---|---| | `e` | Edit task note | +| `g` | Edit estimated time (hours, `0` = no estimate) | | Any other key | Close details | -Tasks are displayed as `[Status] Title` with optional `[Priority]` prefix. +### 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) | + +The timer keeps running while you navigate (even back to the project list); it stops when you press `Enter` in the timer view, when a countdown finishes, 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; it rings the terminal bell at zero and 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) diff --git a/README.zh-CN.md b/README.zh-CN.md index 3a6b6b2..0d91709 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -71,6 +71,7 @@ basilk | `n` | 新建项目 | | `r` | 重命名所选项目 | | `d` | 删除所选项目 | +| `c` | 番茄钟(倒计时) | | `h` | 帮助 | ### 任务列表视图 @@ -86,6 +87,8 @@ basilk | `e` | 编辑任务备注 | | `d` | 删除所选任务 | | `t` | 切换显示/隐藏已完成任务 | +| `s` | 所选任务的正向计时器 | +| `c` | 番茄钟(倒计时) | | `h` | 帮助 | ### 更改状态 / 优先级弹窗 @@ -111,9 +114,22 @@ basilk | 按键 | 功能 | |---|---| | `e` | 编辑任务备注 | +| `g` | 编辑预期时长(小时,`0` = 不设预期) | | 任意其他按键 | 关闭详情 | -任务以 `[状态] 标题` 格式显示,可选 `[优先级]` 前缀。 +### 计时器视图 +| 按键 | 功能 | +|---|---| +| `Space` | 暂停 / 继续 | +| `Enter` | 停止(正向计时会把时长累计到绑定的任务) | +| `Esc` | 关闭(计时器在后台继续运行) | + +计时器在你切换视图(包括返回项目列表)时持续运行,只有在计时器视图按 `Enter` 停止、倒计时归零、或退出程序时才会结算。 + +- **正向计时**(`s`,任务列表):绑定所选任务,计时累计到任务的**累计时长**,在详情视图中与任务的**预期时长**(你预计这个任务要做多久,可按 `g` 为每个任务单独设置)并列展示,并显示已消耗预期时长的百分比。 +- **番茄钟**(`c`,两个视图均可):全局倒计时,用于专注时段;归零时响铃提醒,不关联任何任务,也不累计时长。 + +任务以 `[状态] 标题` 格式显示,可选 `[优先级]` 前缀;设置了预期时长的任务还会带上 `[x%]` 后缀,显示已消耗预期时长的百分比(达到 100% 后变红)。 - 状态:**UpNext**(品红)、**OnGoing**(黄色)、**Done**(绿色,~~删除线~~) - 优先级:`!`(最高)、`!!`(高)、`!!!`(低) 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/main.rs b/src/main.rs index be8e14d..dce176b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use std::{ error::Error, fmt::Debug, io::{self, stdout}, + time::Duration, }; use cli::Cli; @@ -21,6 +22,7 @@ mod json; mod migration; mod project; mod task; +mod timer; mod ui; mod util; mod view; @@ -28,7 +30,9 @@ mod view; use json::Json; use project::Project; use task::{Task, TASK_PRIORITIES, TASK_STATUSES}; +use timer::{TimerKind, TimerState}; use ui::Ui; +use util::Util; use view::View; #[derive(Default, PartialEq, Debug)] @@ -47,6 +51,9 @@ pub enum ViewMode { DeleteTask, ViewTaskDetails, EditTaskNote, + SetTaskEstimate, + TimerTask, + SetCountdown, ViewHelp, InfoMigration, @@ -63,6 +70,7 @@ pub struct App { previous_view_mode: ViewMode, projects: Vec, hide_done_tasks: bool, + timer: Option, } fn init_terminal() -> Result, Box> { @@ -108,6 +116,7 @@ impl App { previous_view_mode: ViewMode::default(), projects: Json::read(), hide_done_tasks: true, + timer: None, } } @@ -147,331 +156,515 @@ impl App { ) })?; - 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 { - Char('h') => { - self.previous_view_mode = ViewMode::ViewProjects; - App::change_view(self, ViewMode::ViewHelp); - } - Enter | Right | Char('l') => { - if items.is_empty() { - continue; + 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 { + use KeyCode::*; + match self.view_mode { + ViewMode::ViewProjects => 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() { + continue; + } - Task::load_items(self, &mut items); - self.selected_task_index.select(Some(0)); + Task::load_items(self, &mut items); + self.selected_task_index.select(Some(0)); - App::change_view(self, ViewMode::ViewTasks); - } - Char('r') => { - if items.is_empty() { - continue; + App::change_view(self, ViewMode::ViewTasks); } + Char('r') => { + if items.is_empty() { + continue; + } - input = input - .clone() - .with_value(Project::get_current(self).title.clone()); + input = input + .clone() + .with_value(Project::get_current(self).title.clone()); - App::change_view(self, ViewMode::RenameProject); - } - Char('n') => { - input.reset(); + App::change_view(self, ViewMode::RenameProject); + } + Char('n') => { + input.reset(); - App::change_view(self, ViewMode::AddProject); - } - Char('d') => { - if items.is_empty() { - continue; + App::change_view(self, ViewMode::AddProject); } + Char('d') => { + if items.is_empty() { + continue; + } - App::change_view(self, ViewMode::DeleteProject); - } - Down | Tab | Char('j') => { - self.next(&items); - } - Up | BackTab | Char('k') => { - self.previous(&items); - } - Char('q') => { - return Ok(()); - } - _ => {} - }, - ViewMode::RenameProject => match key.code { - Enter => { - Project::rename(self, &mut items, input.value()); - input.reset(); - - App::change_view(self, ViewMode::ViewProjects); - } - Esc => { - input.reset(); + App::change_view(self, ViewMode::DeleteProject); + } + Down | Tab | Char('j') => { + self.next(&items); + } + Up | BackTab | Char('k') => { + self.previous(&items); + } + Char('c') => { + self.previous_view_mode = ViewMode::ViewProjects; - 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 => { - if !input.value().is_empty() { - Project::create(self, &mut items, input.value()); - self.selected_project_index - .select(Some(self.projects.len() - 1)); + if self.timer.is_some() { + App::change_view(self, ViewMode::TimerTask); + } else { + input.reset(); + + App::change_view(self, ViewMode::SetCountdown); + } + } + Char('q') => { + self.settle_timer(); + return Ok(()); + } + _ => {} + }, + ViewMode::RenameProject => match key.code { + Enter => { + Project::rename(self, &mut items, input.value()); + input.reset(); + + App::change_view(self, ViewMode::ViewProjects); } + Esc => { + input.reset(); - App::change_view(self, ViewMode::ViewProjects); - } - _ => { - input.handle_event(&Event::Key(key)); - } - }, - ViewMode::DeleteProject => { - if self.handle_modal_nav( - key.code, - &delete_confirm_items, - ViewMode::ViewProjects, - ) { - continue; - } - if key.code == Enter { - if self.delete_confirm_index.selected() == Some(0) { - Project::delete(self, &mut items); - self.selected_project_index.select_previous(); + 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 => { + if !input.value().is_empty() { + Project::create(self, &mut items, input.value()); + self.selected_project_index + .select(Some(self.projects.len() - 1)); + } + + App::change_view(self, ViewMode::ViewProjects); + } + _ => { + input.handle_event(&Event::Key(key)); + } + }, + ViewMode::DeleteProject => { + if self.handle_modal_nav( + key.code, + &delete_confirm_items, + ViewMode::ViewProjects, + ) { + continue; + } + if key.code == Enter { + if self.delete_confirm_index.selected() == Some(0) { + let deleted_index = + self.selected_project_index.selected().unwrap(); + + Project::delete(self, &mut 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); } - self.delete_confirm_index.select(Some(0)); - App::change_view(self, ViewMode::ViewProjects); } - } - ViewMode::ViewTasks => match key.code { - Char('h') => { - self.previous_view_mode = ViewMode::ViewTasks; - App::change_view(self, ViewMode::ViewHelp); - } - Esc | Left => { - Project::load_items(self, &mut items); + ViewMode::ViewTasks => match key.code { + Char('h') => { + self.previous_view_mode = ViewMode::ViewTasks; + App::change_view(self, ViewMode::ViewHelp); + } + Esc | Left => { + Project::load_items(self, &mut items); - App::change_view(self, ViewMode::ViewProjects); - } - Enter => { - if items.is_empty() { - continue; + App::change_view(self, ViewMode::ViewProjects); } + Enter => { + if items.is_empty() { + continue; + } - let index = TASK_STATUSES - .into_iter() - .position(|t| t == &Task::get_current(self).status) - .unwrap(); + let index = TASK_STATUSES + .into_iter() + .position(|t| t == &Task::get_current(self).status) + .unwrap(); - self.selected_status_task_index.select(Some(index)); + self.selected_status_task_index.select(Some(index)); - App::change_view(self, ViewMode::ChangeStatusTask); - } - Char('p') => { - if items.is_empty() { - continue; + App::change_view(self, ViewMode::ChangeStatusTask); } + Char('p') => { + if items.is_empty() { + continue; + } - let index = TASK_PRIORITIES - .into_iter() - .position(|t| t == Task::get_current(self).priority) - .unwrap(); + let index = TASK_PRIORITIES + .into_iter() + .position(|t| t == Task::get_current(self).priority) + .unwrap(); - self.selected_priority_task_index.select(Some(index)); + self.selected_priority_task_index.select(Some(index)); - App::change_view(self, ViewMode::ChangePriorityTask); - } - Char('r') => { - if items.is_empty() { - continue; + App::change_view(self, ViewMode::ChangePriorityTask); } + Char('r') => { + if items.is_empty() { + continue; + } - input = input - .clone() - .with_value(Task::get_current(self).title.clone()); + 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::RenameTask); + } + Char('n') => { + input.reset(); - App::change_view(self, ViewMode::AddTask); - } - Char('d') => { - if items.is_empty() { - continue; + App::change_view(self, ViewMode::AddTask); } + Char('d') => { + if items.is_empty() { + continue; + } - App::change_view(self, ViewMode::DeleteTask); - } - Char('v') => { - if items.is_empty() { - continue; + App::change_view(self, ViewMode::DeleteTask); } + Char('v') => { + if items.is_empty() { + continue; + } - App::change_view(self, ViewMode::ViewTaskDetails); - } - Char('e') => { - if items.is_empty() { - continue; + App::change_view(self, ViewMode::ViewTaskDetails); } + Char('e') => { + if items.is_empty() { + continue; + } - input = input - .clone() - .with_value(Task::get_current(self).note.clone()); + input = input + .clone() + .with_value(Task::get_current(self).note.clone()); - App::change_view(self, ViewMode::EditTaskNote); - } - Down | Tab | Char('j') => { - self.next(&items); - } - Up | BackTab | Char('k') => { - self.previous(&items); - } - Char('t') => { - self.hide_done_tasks = !self.hide_done_tasks; - Task::load_items(self, &mut items); - } - Char('q') => { - return Ok(()); - } - _ => {} - }, - ViewMode::RenameTask => match key.code { - Enter => { - Task::rename(self, &mut items, input.value()); - input.reset(); - - App::change_view(self, ViewMode::ViewTasks); - } - Esc => { - input.reset(); + App::change_view(self, ViewMode::EditTaskNote); + } + Down | Tab | Char('j') => { + self.next(&items); + } + Up | BackTab | Char('k') => { + self.previous(&items); + } + Char('t') => { + self.hide_done_tasks = !self.hide_done_tasks; + Task::load_items(self, &mut items); + } + Char('s') => { + self.previous_view_mode = ViewMode::ViewTasks; + + if self.timer.is_some() { + App::change_view(self, ViewMode::TimerTask); + } else if !items.is_empty() { + 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; - App::change_view(self, ViewMode::ViewTasks); - } - _ => { - input.handle_event(&Event::Key(key)); - } - }, - ViewMode::ChangeStatusTask => { - if self.handle_modal_nav(key.code, &status_items, ViewMode::ViewTasks) { - continue; - } - if 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); - } - } - ViewMode::ChangePriorityTask => { - if self.handle_modal_nav(key.code, &priority_items, ViewMode::ViewTasks) - { - continue; - } - if 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); - } - } - ViewMode::AddTask => match key.code { - Enter => { - Task::create(self, &mut items, input.value()); + if self.timer.is_some() { + App::change_view(self, ViewMode::TimerTask); + } else { + input.reset(); - App::change_view(self, ViewMode::ViewTasks); - } - Esc => { - App::change_view(self, ViewMode::ViewTasks); - } - _ => { - input.handle_event(&Event::Key(key)); - } - }, - ViewMode::DeleteTask => { - if self.handle_modal_nav( - key.code, - &delete_confirm_items, - ViewMode::ViewTasks, - ) { - continue; - } - if key.code == Enter { - if self.delete_confirm_index.selected() == Some(0) { - Task::delete(self, &mut items); - self.selected_task_index.select_previous(); + App::change_view(self, ViewMode::SetCountdown); + } } - self.delete_confirm_index.select(Some(0)); - App::change_view(self, ViewMode::ViewTasks); - } - } - ViewMode::ViewTaskDetails => match key.code { - Char('e') => { - input = input - .clone() - .with_value(Task::get_current(self).note.clone()); + Char('q') => { + self.settle_timer(); + return Ok(()); + } + _ => {} + }, + ViewMode::RenameTask => 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, &mut 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::EditTaskNote); + App::change_view(self, ViewMode::ViewTasks); + } + _ => { + input.handle_event(&Event::Key(key)); + } + }, + ViewMode::ChangeStatusTask => { + if self.handle_modal_nav( + key.code, + &status_items, + ViewMode::ViewTasks, + ) { + continue; + } + if 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); + } } - _ => { - App::change_view(self, ViewMode::ViewTasks); + ViewMode::ChangePriorityTask => { + if self.handle_modal_nav( + key.code, + &priority_items, + ViewMode::ViewTasks, + ) { + continue; + } + if 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); + } } - }, - ViewMode::EditTaskNote => match key.code { - Enter => { - Task::update_note(self, &mut items, input.value()); - input.reset(); + ViewMode::AddTask => match key.code { + Enter => { + Task::create(self, &mut items, input.value()); - App::change_view(self, ViewMode::ViewTaskDetails); + App::change_view(self, ViewMode::ViewTasks); + } + Esc => { + App::change_view(self, ViewMode::ViewTasks); + } + _ => { + input.handle_event(&Event::Key(key)); + } + }, + ViewMode::DeleteTask => { + if self.handle_modal_nav( + key.code, + &delete_confirm_items, + ViewMode::ViewTasks, + ) { + continue; + } + if key.code == 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; + } + + Task::delete(self, &mut items); + self.selected_task_index.select_previous(); + } + self.delete_confirm_index.select(Some(0)); + App::change_view(self, ViewMode::ViewTasks); + } } - Esc => { - input.reset(); + ViewMode::ViewTaskDetails => match key.code { + Char('e') => { + input = input + .clone() + .with_value(Task::get_current(self).note.clone()); - App::change_view(self, ViewMode::ViewTaskDetails); - } - _ => { - input.handle_event(&Event::Key(key)); - } - }, + 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); + } + }, + ViewMode::EditTaskNote => match key.code { + Enter => { + Task::update_note(self, &mut items, input.value()); + input.reset(); - ViewMode::ViewHelp => match key.code { - _ => { - let prev = std::mem::replace( - &mut self.previous_view_mode, - ViewMode::ViewProjects, - ); - App::change_view(self, prev); - } - }, + App::change_view(self, ViewMode::ViewTaskDetails); + } + Esc => { + input.reset(); - ViewMode::InfoMigration => match key.code { - _ => { - App::change_view(self, ViewMode::ViewProjects); - } - }, + App::change_view(self, ViewMode::ViewTaskDetails); + } + _ => { + input.handle_event(&Event::Key(key)); + } + }, + ViewMode::SetTaskEstimate => match key.code { + Enter => { + let raw = input.value().trim().to_string(); + + if let Ok(hours) = raw.parse::() { + Task::update_estimate(self, &mut 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)); + } + }, + ViewMode::TimerTask => 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(); + } + _ => {} + }, + ViewMode::SetCountdown => 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)); + } + }, + + ViewMode::ViewHelp => match key.code { + _ => { + self.back_to_previous_view(); + } + }, + + ViewMode::InfoMigration => match key.code { + _ => { + App::change_view(self, ViewMode::ViewProjects); + } + }, + } } } } + + self.tick_timer(); } } @@ -508,6 +701,56 @@ impl App { hint_area, ); + // Timer readout (right aligned on the hint line) + 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, + ); + } + // Main view View::show_items(self, items, f, main_area); @@ -528,6 +771,18 @@ impl App { View::show_edit_note_modal(f, area, input) } + if self.view_mode == ViewMode::SetCountdown { + View::show_countdown_modal(f, area, input) + } + + if self.view_mode == ViewMode::SetTaskEstimate { + View::show_task_estimate_modal(f, area, input) + } + + if self.view_mode == ViewMode::TimerTask { + View::show_timer_modal(self, f, area) + } + if self.view_mode == ViewMode::DeleteTask || self.view_mode == ViewMode::DeleteProject { View::show_delete_item_modal(self, delete_confirm_items, f, area) } @@ -602,6 +857,15 @@ impl App { 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::ViewHelp => return &mut self.selected_project_index, ViewMode::InfoMigration => return &mut self.selected_project_index, @@ -612,6 +876,51 @@ impl App { 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, drop the timer, + /// and close the timer modal if open. + fn tick_timer(&mut self) { + let hit_zero = matches!( + self.timer.as_ref(), + Some(t) if t.kind == TimerKind::Countdown + && t.remaining() == Some(Duration::ZERO) + ); + + if !hit_zero { + return; + } + + print!("\x07"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + self.timer = None; + + if self.view_mode == ViewMode::TimerTask { + self.back_to_previous_view(); + } + } + fn handle_modal_nav( &mut self, key: KeyCode, @@ -671,6 +980,7 @@ pub(crate) mod test_utils { previous_view_mode: ViewMode::default(), projects, hide_done_tasks: true, + timer: None, } } @@ -682,6 +992,8 @@ pub(crate) mod test_utils { created_at: Some(1_700_000_000), completed_at: None, note: String::new(), + time_spent_secs: 0, + estimated_hours: 0, } } diff --git a/src/migration.rs b/src/migration.rs index 90a5391..54367c5 100644 --- a/src/migration.rs +++ b/src/migration.rs @@ -1,8 +1,8 @@ use crate::project::Project; use crate::task::TASK_PRIORITY_NONE; -// sha of 0.1.0 0.2.0 0.2.2 -pub static JSON_VERSIONS: [&str; 3] = ["6ad96", "911fc", "a4e1b"]; +// sha of 0.1.0 0.2.0 0.2.2 timer +pub static JSON_VERSIONS: [&str; 4] = ["6ad96", "911fc", "a4e1b", "c8f21"]; pub struct Migration; @@ -13,6 +13,7 @@ impl Migration { ("6ad96", |data| data), ("911fc", Migration::add_priority), ("a4e1b", Migration::add_note), + ("c8f21", Migration::add_timer_fields), ]; // The start index where the migration are picked @@ -51,6 +52,16 @@ impl Migration { } data } + + fn add_timer_fields(mut data: Vec) -> Vec { + for p in data.iter_mut() { + for t in p.tasks.iter_mut() { + t.time_spent_secs = 0; + t.estimated_hours = 0; + } + } + data + } } #[cfg(test)] @@ -68,6 +79,8 @@ mod tests { created_at: None, completed_at: None, note: "keep me".to_string(), + time_spent_secs: 42, + estimated_hours: 7, }], }] } @@ -76,28 +89,32 @@ mod tests { fn migrations_from_oldest_version_apply_all_steps() { let migrations = Migration::get_migrations("6ad96", sample_data()); - assert_eq!(migrations.len(), 2); + assert_eq!(migrations.len(), 3); assert_eq!(migrations[0].0, "911fc"); assert_eq!(migrations[1].0, "a4e1b"); + assert_eq!(migrations[2].0, "c8f21"); - let task = &migrations[1].1[0].tasks[0]; + let task = &migrations[2].1[0].tasks[0]; assert_eq!(task.priority, TASK_PRIORITY_NONE); assert_eq!(task.note, ""); + assert_eq!(task.time_spent_secs, 0); + assert_eq!(task.estimated_hours, 0); } #[test] fn migrations_from_middle_version_apply_remaining_steps() { let migrations = Migration::get_migrations("911fc", sample_data()); - assert_eq!(migrations.len(), 1); + assert_eq!(migrations.len(), 2); assert_eq!(migrations[0].0, "a4e1b"); + assert_eq!(migrations[1].0, "c8f21"); // add_priority is not re-applied: priority is preserved assert_eq!(migrations[0].1[0].tasks[0].priority, 2); } #[test] fn no_migrations_for_latest_or_unknown_version() { - assert!(Migration::get_migrations("a4e1b", sample_data()).is_empty()); + assert!(Migration::get_migrations("c8f21", sample_data()).is_empty()); assert!(Migration::get_migrations("unknown", sample_data()).is_empty()); } diff --git a/src/property_tests.rs b/src/property_tests.rs index 9c3a8b6..a4d8858 100644 --- a/src/property_tests.rs +++ b/src/property_tests.rs @@ -29,15 +29,30 @@ fn arb_task() -> impl Strategy { any::>(), any::>(), ".*", + any::(), + any::(), ) .prop_map( - |(title, status, priority, created_at, completed_at, note)| Task { + |( title, status, priority, created_at, completed_at, note, + time_spent_secs, + estimated_hours, + )| { + Task { + title, + status, + priority, + created_at, + completed_at, + note, + time_spent_secs, + estimated_hours, + } }, ) } diff --git a/src/task.rs b/src/task.rs index c69b52a..730d2e6 100644 --- a/src/task.rs +++ b/src/task.rs @@ -19,6 +19,11 @@ pub struct Task { pub completed_at: Option, #[serde(default)] pub note: String, + #[serde(default)] + pub time_spent_secs: u64, + /// Estimated time the task is expected to take; 0 = no estimate. + #[serde(default)] + pub estimated_hours: u64, } pub const TASK_STATUS_DONE: &str = "Done"; @@ -92,6 +97,8 @@ impl Task { created_at: None, completed_at: None, note: "".to_string(), + time_spent_secs: 0, + estimated_hours: 0, }) .clone() .title; @@ -146,6 +153,14 @@ impl Task { repr = [priority_repr, repr].concat() } + // Estimate progress for tasks with an estimated duration + if let Some(pct) = task.estimate_progress(0) { + repr.push(Span::styled( + format!(" [{}%]", pct), + Style::default().fg(if pct >= 100 { Color::Red } else { Color::Cyan }), + )); + } + let line = Line::from(repr); items.push(ListItem::from(line)) @@ -173,6 +188,8 @@ impl Task { created_at: Some(Self::current_timestamp()), completed_at: None, note: "".to_string(), + time_spent_secs: 0, + estimated_hours: 0, }); Json::write(app.projects.clone()); @@ -197,6 +214,15 @@ impl Task { Task::load_items(app, items) } + pub fn update_estimate(app: &mut App, items: &mut Vec, hours: u64) { + app.projects[app.selected_project_index.selected().unwrap()].tasks + [app.selected_task_index.selected().unwrap()] + .estimated_hours = hours; + + Json::write(app.projects.clone()); + Task::load_items(app, items) + } + pub fn change_status(app: &mut App, items: &mut Vec, value: &str) { let status = value.to_string(); let projects = &mut app.projects[app.selected_project_index.selected().unwrap()].tasks; @@ -226,6 +252,39 @@ impl Task { Task::load_items(app, items) } + /// Percentage of the estimate already spent, `None` when no estimate is + /// set. `extra_secs` accounts for time not yet settled (a running timer). + pub fn estimate_progress(&self, extra_secs: u64) -> Option { + if self.estimated_hours == 0 { + return None; + } + + let pct = self.time_spent_secs.saturating_add(extra_secs) as f64 + / self.estimated_hours.saturating_mul(3600) as f64 + * 100.0; + Some(pct.round() as u64) + } + + /// Accumulate timer seconds into a task, located by project index and + /// title (list indexes shift on sort, so the title is the stable identity). + /// A task deleted while its timer was running is silently skipped. + pub fn add_time_spent(app: &mut App, project_index: usize, task_title: &str, secs: u64) { + if secs == 0 { + return; + } + + let Some(project) = app.projects.get_mut(project_index) else { + return; + }; + let Some(task) = project.tasks.iter_mut().find(|t| t.title == task_title) else { + return; + }; + + task.time_spent_secs += secs; + + Json::write(app.projects.clone()); + } + pub fn delete(app: &mut App, items: &mut Vec) { app.projects[app.selected_project_index.selected().unwrap()] .tasks @@ -364,6 +423,92 @@ mod tests { assert_eq!(app.projects[0].tasks[0].title, "t1"); } + #[test] + fn add_time_spent_accumulates_and_persists() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = app_with_tasks(vec![make_task( + "t", + TASK_STATUS_UP_NEXT, + TASK_PRIORITY_NONE, + )]); + + Task::add_time_spent(&mut app, 0, "t", 120); + Task::add_time_spent(&mut app, 0, "t", 30); + + assert_eq!(app.projects[0].tasks[0].time_spent_secs, 150); + assert_eq!(Json::read()[0].tasks[0].time_spent_secs, 150); + } + + #[test] + fn add_time_spent_ignores_zero_secs_and_missing_tasks() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = app_with_tasks(vec![make_task( + "t", + TASK_STATUS_UP_NEXT, + TASK_PRIORITY_NONE, + )]); + + Task::add_time_spent(&mut app, 0, "t", 0); + Task::add_time_spent(&mut app, 0, "ghost", 60); + Task::add_time_spent(&mut app, 9, "t", 60); + + assert_eq!(app.projects[0].tasks[0].time_spent_secs, 0); + } + + #[test] + fn update_estimate_changes_selected_task_and_persists() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = app_with_tasks(vec![make_task( + "t", + TASK_STATUS_UP_NEXT, + TASK_PRIORITY_NONE, + )]); + let mut items = vec![]; + + Task::update_estimate(&mut app, &mut items, 500); + + assert_eq!(app.projects[0].tasks[0].estimated_hours, 500); + assert_eq!(Json::read()[0].tasks[0].estimated_hours, 500); + } + + #[test] + fn new_tasks_have_no_estimate() { + let _guard = ENV_LOCK.lock().unwrap(); + let _dir = setup_temp_config(); + let mut app = app_with_tasks(vec![]); + let mut items = vec![]; + + Task::create(&mut app, &mut items, "do it"); + + assert_eq!(app.projects[0].tasks[0].estimated_hours, 0); + } + + #[test] + fn estimate_progress_is_none_without_estimate() { + let mut task = make_task("t", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE); + task.time_spent_secs = 3600; + task.estimated_hours = 0; + + assert_eq!(task.estimate_progress(0), None); + } + + #[test] + fn estimate_progress_rounds_and_includes_extra_secs() { + let mut task = make_task("t", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE); + task.estimated_hours = 10; // 36000 secs + + task.time_spent_secs = 3600; // 10% + assert_eq!(task.estimate_progress(0), Some(10)); + // extra (unsettled timer) time counts towards the progress + assert_eq!(task.estimate_progress(1800), Some(15)); + + task.time_spent_secs = 36360; // 101% + assert_eq!(task.estimate_progress(0), Some(101)); + } + #[test] fn load_items_sorts_by_priority_then_status() { let mut app = app_with_tasks(vec![ diff --git a/src/timer.rs b/src/timer.rs new file mode 100644 index 0000000..3d366aa --- /dev/null +++ b/src/timer.rs @@ -0,0 +1,180 @@ +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, PartialEq)] +pub enum TimerKind { + /// Task-bound, open-ended; accumulates into the task's `time_spent_secs`. + Stopwatch, + /// Global pomodoro; not bound to any task, nothing is persisted. + Countdown, +} + +/// The task a stopwatch timer accumulates time into (list indexes shift on +/// sort, so the title is the stable identity, mirroring `Task::load_items`). +#[derive(Debug, Clone)] +pub struct TimerTaskBinding { + pub project_index: usize, + pub task_title: String, +} + +/// Runtime state of a timer. Not persisted; a stopwatch settles its elapsed +/// seconds into the bound task, a countdown (pomodoro) is simply dropped. +#[derive(Debug, Clone)] +pub struct TimerState { + pub kind: TimerKind, + /// Countdown target in seconds (unused for stopwatch). + pub target_secs: u64, + /// Time accumulated across pause/resume cycles. + pub accumulated: Duration, + /// `Some` while the timer is running. + pub started_at: Option, + /// Task binding (stopwatch only). + pub bound: Option, +} + +impl TimerState { + pub fn new_stopwatch(project_index: usize, task_title: String) -> Self { + Self { + kind: TimerKind::Stopwatch, + target_secs: 0, + accumulated: Duration::ZERO, + started_at: Some(Instant::now()), + bound: Some(TimerTaskBinding { + project_index, + task_title, + }), + } + } + + pub fn new_countdown(target_secs: u64) -> Self { + Self { + kind: TimerKind::Countdown, + target_secs, + accumulated: Duration::ZERO, + started_at: Some(Instant::now()), + bound: None, + } + } + + /// Whether this timer is bound to the given task. + pub fn is_bound_to(&self, project_index: usize, task_title: &str) -> bool { + self.bound + .as_ref() + .is_some_and(|b| b.project_index == project_index && b.task_title == task_title) + } + + pub fn elapsed(&self) -> Duration { + let running = self + .started_at + .map(|t| t.elapsed()) + .unwrap_or(Duration::ZERO); + self.accumulated + running + } + + /// `None` for stopwatch; `Some(remaining)` for countdown (zero once done). + pub fn remaining(&self) -> Option { + if self.kind != TimerKind::Countdown { + return None; + } + let target = Duration::from_secs(self.target_secs); + Some(target.saturating_sub(self.elapsed())) + } + + pub fn is_running(&self) -> bool { + self.started_at.is_some() + } + + pub fn pause(&mut self) { + if let Some(started) = self.started_at.take() { + self.accumulated += started.elapsed(); + } + } + + pub fn resume(&mut self) { + if self.started_at.is_none() { + self.started_at = Some(Instant::now()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn paused_timer(kind: TimerKind, accumulated_secs: u64, target_secs: u64) -> TimerState { + let bound = if kind == TimerKind::Stopwatch { + Some(TimerTaskBinding { + project_index: 0, + task_title: "t".to_string(), + }) + } else { + None + }; + TimerState { + kind, + target_secs, + accumulated: Duration::from_secs(accumulated_secs), + started_at: None, + bound, + } + } + + #[test] + fn new_timers_start_running() { + assert!(TimerState::new_stopwatch(0, "t".to_string()).is_running()); + assert!(TimerState::new_countdown(1500).is_running()); + } + + #[test] + fn stopwatch_is_bound_and_countdown_is_global() { + let stopwatch = TimerState::new_stopwatch(1, "t".to_string()); + assert!(stopwatch.is_bound_to(1, "t")); + assert!(!stopwatch.is_bound_to(0, "t")); + assert!(!stopwatch.is_bound_to(1, "other")); + + let countdown = TimerState::new_countdown(60); + assert!(countdown.bound.is_none()); + assert!(!countdown.is_bound_to(0, "t")); + } + + #[test] + fn countdown_stores_target_secs() { + let timer = TimerState::new_countdown(1500); + assert_eq!(timer.target_secs, 1500); + } + + #[test] + fn elapsed_is_accumulated_when_paused() { + let timer = paused_timer(TimerKind::Stopwatch, 90, 0); + assert_eq!(timer.elapsed(), Duration::from_secs(90)); + } + + #[test] + fn pause_freezes_elapsed_and_resume_continues() { + let mut timer = TimerState::new_stopwatch(0, "t".to_string()); + timer.pause(); + let frozen = timer.elapsed(); + std::thread::sleep(Duration::from_millis(20)); + assert_eq!(timer.elapsed(), frozen); + assert!(!timer.is_running()); + + timer.resume(); + assert!(timer.is_running()); + std::thread::sleep(Duration::from_millis(20)); + assert!(timer.elapsed() >= frozen); + } + + #[test] + fn remaining_is_none_for_stopwatch() { + let timer = paused_timer(TimerKind::Stopwatch, 90, 0); + assert_eq!(timer.remaining(), None); + } + + #[test] + fn remaining_counts_down_to_zero() { + let timer = paused_timer(TimerKind::Countdown, 60, 90); + assert_eq!(timer.remaining(), Some(Duration::from_secs(30))); + + let overtime = paused_timer(TimerKind::Countdown, 120, 90); + assert_eq!(overtime.remaining(), Some(Duration::ZERO)); + } +} diff --git a/src/util.rs b/src/util.rs index b0bb299..7a08f15 100644 --- a/src/util.rs +++ b/src/util.rs @@ -28,6 +28,16 @@ impl Util { "!!!".chars().take((priority_value).into()).collect() } + /// Format a plain seconds count as HH:MM:SS (timer readouts). + pub fn format_secs(secs: u64) -> String { + format!( + "{:02}:{:02}:{:02}", + secs / 3600, + (secs % 3600) / 60, + secs % 60 + ) + } + pub fn format_timestamp(timestamp: Option) -> String { timestamp .and_then(|ts| DateTime::from_timestamp(ts as i64, 0)) @@ -85,6 +95,15 @@ mod tests { assert_eq!(Util::get_priority_indicator(99), ""); } + #[test] + fn format_secs_renders_hh_mm_ss() { + assert_eq!(Util::format_secs(0), "00:00:00"); + assert_eq!(Util::format_secs(59), "00:00:59"); + assert_eq!(Util::format_secs(60), "00:01:00"); + assert_eq!(Util::format_secs(3661), "01:01:01"); + assert_eq!(Util::format_secs(90_061), "25:01:01"); + } + #[test] fn format_timestamp_handles_none_and_epoch() { assert_eq!(Util::format_timestamp(None), "N/A"); diff --git a/src/view.rs b/src/view.rs index 891bdb7..ffecf99 100644 --- a/src/view.rs +++ b/src/view.rs @@ -10,6 +10,7 @@ use tui_input::Input; use crate::{ project::Project, task::{Task, TASK_PRIORITY_NONE}, + timer::TimerKind, ui::Ui, util::Util, App, ViewMode, @@ -41,6 +42,127 @@ impl View { Ui::create_input_modal("Note", f, area, input) } + pub fn show_countdown_modal(f: &mut Frame, area: Rect, input: &Input) { + Ui::create_input_modal("Countdown (minutes)", f, area, input) + } + + pub fn show_task_estimate_modal(f: &mut Frame, area: Rect, input: &Input) { + Ui::create_input_modal("Estimate (hours, 0 = none)", f, area, input) + } + + pub fn show_timer_modal(app: &App, f: &mut Frame, area: Rect) { + let Some(timer) = app.timer.as_ref() else { + return; + }; + + let (readout, target) = match timer.kind { + TimerKind::Stopwatch => (Util::format_secs(timer.elapsed().as_secs()), None), + TimerKind::Countdown => ( + Util::format_secs(timer.remaining().unwrap_or_default().as_secs()), + Some(Util::format_secs(timer.target_secs)), + ), + }; + + let running_low = timer.is_running() + && timer.kind == TimerKind::Countdown + && timer.remaining().unwrap_or_default().as_secs() <= 10; + + let readout_color = if running_low { + Color::Red + } else if timer.is_running() { + Color::Green + } else { + Color::Yellow + }; + + let mut lines = vec![ + Line::from(Span::styled( + readout, + Style::default() + .fg(readout_color) + .add_modifier(Modifier::BOLD), + )), + Line::raw(""), + ]; + + if let Some(target) = target { + lines.push(Line::from(Span::styled( + format!("of {}", target), + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::raw("")); + } + + match &timer.bound { + Some(bound) => { + lines.push(Line::from(vec![ + Span::styled("Task: ", Style::default().fg(Color::Cyan)), + Span::raw(&bound.task_title), + ])); + lines.push(Line::raw("")); + + // Live estimate progress, including the unsettled time on this timer + let estimate_line = app + .projects + .get(bound.project_index) + .and_then(|p| p.tasks.iter().find(|t| t.title == bound.task_title)) + .and_then(|t| { + t.estimate_progress(timer.elapsed().as_secs()) + .map(|pct| (t.estimated_hours, pct)) + }); + + if let Some((hours, pct)) = estimate_line { + lines.push(Line::from(vec![ + Span::styled("Estimate: ", Style::default().fg(Color::Cyan)), + Span::styled( + format!("{}h ({}% spent)", hours, pct), + Style::default().fg(if pct >= 100 { + Color::Red + } else { + Color::DarkGray + }), + ), + ])); + lines.push(Line::raw("")); + } + } + None => { + lines.push(Line::from(Span::styled( + "pomodoro", + Style::default().fg(Color::Cyan), + ))); + lines.push(Line::raw("")); + } + } + + lines.push(Line::from(Span::styled( + if timer.is_running() { + "running" + } else { + "paused" + }, + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "Space pause/resume · Enter stop · Esc background", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ))); + + let title = match timer.kind { + TimerKind::Stopwatch => " Timer ", + TimerKind::Countdown => " Pomodoro ", + }; + + let widget = Paragraph::new(Text::from(lines)) + .alignment(Alignment::Center) + .block(Block::bordered().title(title)); + + Ui::create_modal(f, 50, 13, area, widget) + } + pub fn show_delete_item_modal( app: &mut App, confirm_items: &Vec, @@ -102,12 +224,21 @@ impl View { } pub fn show_items(app: &mut App, items: &Vec, f: &mut Frame, area: Rect) { - let block: Block = match app.view_mode { - ViewMode::ViewProjects - | ViewMode::AddProject - | ViewMode::RenameProject - | ViewMode::DeleteProject => Block::bordered(), - _ => Block::bordered().title(Util::get_spaced_title(&Project::get_current(app).title)), + // Timer modals show the list of the view they were opened from + let from_projects = + matches!( + app.view_mode, + ViewMode::ViewProjects + | ViewMode::AddProject + | ViewMode::RenameProject + | ViewMode::DeleteProject + ) || (matches!(app.view_mode, ViewMode::TimerTask | ViewMode::SetCountdown) + && app.previous_view_mode == ViewMode::ViewProjects); + + let block: Block = if from_projects { + Block::bordered() + } else { + Block::bordered().title(Util::get_spaced_title(&Project::get_current(app).title)) }; // Iterate through all elements in the `items` and stylize them. @@ -202,9 +333,37 @@ impl View { lines.push(Line::raw("")); } + // Accumulated timer time vs. the estimated duration + lines.push(Line::from(vec![ + Span::styled("Time Spent: ", Style::default().fg(Color::Cyan)), + Span::raw(format!( + "{}h {}m ({})", + task.time_spent_secs / 3600, + (task.time_spent_secs % 3600) / 60, + Util::format_secs(task.time_spent_secs), + )), + ])); + lines.push(Line::raw("")); + + let estimate_text = if task.estimated_hours > 0 { + format!( + "{}h ({:.2}% spent)", + task.estimated_hours, + task.time_spent_secs as f64 / task.estimated_hours.saturating_mul(3600) as f64 + * 100.0, + ) + } else { + "none".to_string() + }; + lines.push(Line::from(vec![ + Span::styled("Estimate: ", Style::default().fg(Color::Cyan)), + Span::raw(estimate_text), + ])); + lines.push(Line::raw("")); + lines.push(Line::raw("")); lines.push(Line::from(vec![Span::styled( - "Press any key to close", + "e edit note · g edit estimate · any other key to close", Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::ITALIC), @@ -215,7 +374,7 @@ impl View { .wrap(Wrap { trim: true }) .block(Block::bordered().title(" Task Details ")); - Ui::create_modal(f, 60, 18, area, widget) + Ui::create_modal(f, 60, 22, area, widget) } pub fn show_help_modal(app: &mut App, f: &mut Frame, area: Rect) { @@ -226,6 +385,7 @@ impl View { ("n", "new"), ("r", "rename"), ("d", "delete"), + ("c", "pomodoro timer"), ("h", "help"), ("q", "quit"), ], @@ -238,8 +398,11 @@ impl View { ("r", "rename"), ("v", "details"), ("e", "note"), + ("v → g", "details: edit estimate"), ("d", "delete"), ("t", "toggle done"), + ("s", "stopwatch timer"), + ("c", "pomodoro timer"), ("h", "help"), ("q", "quit"), ], @@ -274,6 +437,6 @@ impl View { .wrap(Wrap { trim: true }) .block(Block::bordered().title(" Help ")); - Ui::create_modal(f, 42, 16, area, widget) + Ui::create_modal(f, 42, 19, area, widget) } } From 03f642a309697f1e384d76e1f996a2a3cdfc4de3 Mon Sep 17 00:00:00 2001 From: LiuYinCarl <1427518212@qq.coom> Date: Tue, 28 Jul 2026 00:23:38 +0800 Subject: [PATCH 20/23] Render timer as block digits and keep finished countdowns on screen The timer modal now shows the readout as 5-row block-art digits instead of a single text line, and a finished pomodoro rings the bell once and stays visible ("time's up!") until dismissed with any key instead of closing automatically. --- AGENTS.md | 2 +- README.md | 4 +-- README.zh-CN.md | 4 +-- src/main.rs | 53 +++++++++++++++++++---------------- src/timer.rs | 27 +++++++++++++++++- src/view.rs | 73 +++++++++++++++++++++++++++++++++++++------------ 6 files changed, 117 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9c2eb91..67d2e27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ The `App` struct in `main.rs` manages the application state, including selected - **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 and is dropped. 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`. +- **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. diff --git a/README.md b/README.md index c80abe1..d1d9186 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,10 @@ Press `h` to view the keybinding list in-app. | `Enter` | Stop (a stopwatch saves its elapsed time to the bound task) | | `Esc` | Close (timer keeps running in the background) | -The timer keeps running while you navigate (even back to the project list); it stops when you press `Enter` in the timer view, when a countdown finishes, or when you quit. +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; it rings the terminal bell at zero and is not tied to any task, so nothing is accumulated. +- **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~~) diff --git a/README.zh-CN.md b/README.zh-CN.md index 0d91709..f6f23e0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -124,10 +124,10 @@ basilk | `Enter` | 停止(正向计时会把时长累计到绑定的任务) | | `Esc` | 关闭(计时器在后台继续运行) | -计时器在你切换视图(包括返回项目列表)时持续运行,只有在计时器视图按 `Enter` 停止、倒计时归零、或退出程序时才会结算。 +计时器在你切换视图(包括返回项目列表)时持续运行,只有在计时器视图按 `Enter` 停止、或退出程序时才会结算。 - **正向计时**(`s`,任务列表):绑定所选任务,计时累计到任务的**累计时长**,在详情视图中与任务的**预期时长**(你预计这个任务要做多久,可按 `g` 为每个任务单独设置)并列展示,并显示已消耗预期时长的百分比。 -- **番茄钟**(`c`,两个视图均可):全局倒计时,用于专注时段;归零时响铃提醒,不关联任何任务,也不累计时长。 +- **番茄钟**(`c`,两个视图均可):全局倒计时,用于专注时段;归零时响铃提醒,界面停留在结束状态(显示“时间到!”),按任意键关闭。不关联任何任务,也不累计时长。 任务以 `[状态] 标题` 格式显示,可选 `[优先级]` 前缀;设置了预期时长的任务还会带上 `[x%]` 后缀,显示已消耗预期时长的百分比(达到 100% 后变红)。 - 状态:**UpNext**(品红)、**OnGoing**(黄色)、**Done**(绿色,~~删除线~~) diff --git a/src/main.rs b/src/main.rs index dce176b..a2ec1a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -598,25 +598,34 @@ impl App { input.handle_event(&Event::Key(key)); } }, - ViewMode::TimerTask => match key.code { - Char(' ') => { - if let Some(timer) = self.timer.as_mut() { - if timer.is_running() { - timer.pause(); - } else { - timer.resume(); - } - } - } - Enter => { + ViewMode::TimerTask => { + 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(); + continue; } - Esc => { - self.back_to_previous_view(); + + 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(); + } + _ => {} } - _ => {} - }, + } ViewMode::SetCountdown => match key.code { Enter => { let raw = input.value().trim().to_string(); @@ -898,13 +907,13 @@ impl App { } /// Countdown bookkeeping, run on every event-loop iteration: when the - /// pomodoro reaches zero, ring the terminal bell once, drop the timer, - /// and close the timer modal if open. + /// 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.kind == TimerKind::Countdown - && t.remaining() == Some(Duration::ZERO) + Some(t) if t.is_finished() && !t.rung ); if !hit_zero { @@ -914,10 +923,8 @@ impl App { print!("\x07"); let _ = std::io::Write::flush(&mut std::io::stdout()); - self.timer = None; - - if self.view_mode == ViewMode::TimerTask { - self.back_to_previous_view(); + if let Some(timer) = self.timer.as_mut() { + timer.rung = true; } } diff --git a/src/timer.rs b/src/timer.rs index 3d366aa..04b3ff0 100644 --- a/src/timer.rs +++ b/src/timer.rs @@ -17,7 +17,9 @@ pub struct TimerTaskBinding { } /// Runtime state of a timer. Not persisted; a stopwatch settles its elapsed -/// seconds into the bound task, a countdown (pomodoro) is simply dropped. +/// seconds into the bound task, a countdown (pomodoro) never accumulates +/// anything — at zero it rings the bell once and stays in the finished +/// state until the user dismisses it. #[derive(Debug, Clone)] pub struct TimerState { pub kind: TimerKind, @@ -29,6 +31,9 @@ pub struct TimerState { pub started_at: Option, /// Task binding (stopwatch only). pub bound: Option, + /// Countdown only: set once the terminal bell has been rung at zero, + /// so it rings exactly once while the finished timer stays visible. + pub rung: bool, } impl TimerState { @@ -42,6 +47,7 @@ impl TimerState { project_index, task_title, }), + rung: false, } } @@ -52,6 +58,7 @@ impl TimerState { accumulated: Duration::ZERO, started_at: Some(Instant::now()), bound: None, + rung: false, } } @@ -83,6 +90,11 @@ impl TimerState { self.started_at.is_some() } + /// `true` once a countdown has run down to zero (stopwatches never finish). + pub fn is_finished(&self) -> bool { + self.remaining() == Some(Duration::ZERO) + } + pub fn pause(&mut self) { if let Some(started) = self.started_at.take() { self.accumulated += started.elapsed(); @@ -115,6 +127,7 @@ mod tests { accumulated: Duration::from_secs(accumulated_secs), started_at: None, bound, + rung: false, } } @@ -177,4 +190,16 @@ mod tests { let overtime = paused_timer(TimerKind::Countdown, 120, 90); assert_eq!(overtime.remaining(), Some(Duration::ZERO)); } + + #[test] + fn only_a_run_down_countdown_is_finished() { + let done = paused_timer(TimerKind::Countdown, 120, 90); + assert!(done.is_finished()); + + let running = paused_timer(TimerKind::Countdown, 60, 90); + assert!(!running.is_finished()); + + let stopwatch = paused_timer(TimerKind::Stopwatch, 120, 0); + assert!(!stopwatch.is_finished()); + } } diff --git a/src/view.rs b/src/view.rs index ffecf99..2f3ebe5 100644 --- a/src/view.rs +++ b/src/view.rs @@ -18,6 +18,42 @@ use crate::{ pub struct View {} +/// One 5-row block glyph for a digit or colon in the big timer readout. +fn digit_glyph(c: char) -> [&'static str; 5] { + match c { + '0' => ["███", "█ █", "█ █", "█ █", "███"], + '1' => [" █", " █", " █", " █", " █"], + '2' => ["███", " █", "███", "█ ", "███"], + '3' => ["███", " █", "███", " █", "███"], + '4' => ["█ █", "█ █", "███", " █", " █"], + '5' => ["███", "█ ", "███", " █", "███"], + '6' => ["███", "█ ", "███", "█ █", "███"], + '7' => ["███", " █", " █", " █", " █"], + '8' => ["███", "█ █", "███", "█ █", "███"], + '9' => ["███", "█ █", "███", " █", "███"], + ':' => [" ", "█", " ", "█", " "], + _ => [" ", " ", " ", " ", " "], + } +} + +/// Render an HH:MM:SS readout as 5-row block digits so the timer is +/// readable at a glance instead of a single text line. +fn big_digit_lines(readout: &str, color: Color) -> Vec> { + (0..5) + .map(|row| { + let text = readout + .chars() + .map(|c| digit_glyph(c)[row]) + .collect::>() + .join(" "); + Line::from(Span::styled( + text, + Style::default().fg(color).add_modifier(Modifier::BOLD), + )) + }) + .collect() +} + impl View { pub fn show_new_item_modal(f: &mut Frame, area: Rect, input: &Input) { Ui::create_input_modal("New", f, area, input) @@ -55,6 +91,8 @@ impl View { return; }; + let finished = timer.is_finished(); + let (readout, target) = match timer.kind { TimerKind::Stopwatch => (Util::format_secs(timer.elapsed().as_secs()), None), TimerKind::Countdown => ( @@ -67,7 +105,7 @@ impl View { && timer.kind == TimerKind::Countdown && timer.remaining().unwrap_or_default().as_secs() <= 10; - let readout_color = if running_low { + let readout_color = if finished || running_low { Color::Red } else if timer.is_running() { Color::Green @@ -75,15 +113,9 @@ impl View { Color::Yellow }; - let mut lines = vec![ - Line::from(Span::styled( - readout, - Style::default() - .fg(readout_color) - .add_modifier(Modifier::BOLD), - )), - Line::raw(""), - ]; + let mut lines = vec![Line::raw("")]; + lines.extend(big_digit_lines(&readout, readout_color)); + lines.push(Line::raw("")); if let Some(target) = target { lines.push(Line::from(Span::styled( @@ -135,17 +167,24 @@ impl View { } } + let status = if finished { + "time's up!" + } else if timer.is_running() { + "running" + } else { + "paused" + }; lines.push(Line::from(Span::styled( - if timer.is_running() { - "running" - } else { - "paused" - }, + status, Style::default().fg(Color::DarkGray), ))); lines.push(Line::raw("")); lines.push(Line::from(Span::styled( - "Space pause/resume · Enter stop · Esc background", + if finished { + "Press any key to close" + } else { + "Space pause/resume · Enter stop · Esc background" + }, Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::ITALIC), @@ -160,7 +199,7 @@ impl View { .alignment(Alignment::Center) .block(Block::bordered().title(title)); - Ui::create_modal(f, 50, 13, area, widget) + Ui::create_modal(f, 60, 18, area, widget) } pub fn show_delete_item_modal( From 85aafff825aea9c35488b2a73accf7e959bed6d7 Mon Sep 17 00:00:00 2001 From: LiuYinCarl <1427518212@qq.coom> Date: Wed, 29 Jul 2026 23:04:18 +0800 Subject: [PATCH 21/23] Add kanban board view with three status lanes in the task view Press b in the task view to toggle between the classic list and a board with Up Next / On Going / Done swimlanes. Left/right switch lanes, up/down move within a lane; all task keybindings (details, status, priority, timers, ...) work unchanged since the board is a rendering mode of ViewTasks, not a new ViewMode. The board always shows the Done lane and the focus follows a task across lanes when its status changes. Task lines are built by the shared Task::repr_spans helper so the list and board renderings cannot drift apart. --- AGENTS.md | 1 + README.md | 6 +- README.zh-CN.md | 6 +- src/main.rs | 537 ++++++++++++++++++++++++++++++++++-------- src/property_tests.rs | 5 +- src/task.rs | 105 ++++++--- src/view.rs | 160 ++++++++++++- 7 files changed, 680 insertions(+), 140 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 67d2e27..795fb37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,7 @@ The `App` struct in `main.rs` manages the application state, including selected - **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. - **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`. diff --git a/README.md b/README.md index d1d9186..23edc14 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ Press `h` to view the keybinding list in-app. | 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 | @@ -133,7 +135,9 @@ Tasks are displayed as `[Status] Title` with optional `[Priority]` prefix and, f - Statuses: **UpNext** (magenta), **OnGoing** (yellow), **Done** (green, ~~crossed out~~) - Priorities: `!` (highest), `!!` (high), `!!!` (low) -Completed tasks are **hidden by default**. Press `t` in the task list view to toggle their visibility. +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). ## Contributing > [!NOTE] diff --git a/README.zh-CN.md b/README.zh-CN.md index f6f23e0..22c7230 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -78,6 +78,8 @@ basilk | 按键 | 功能 | |---|---| | `↑` `↓` `k` `j` `Tab` `Shift+Tab` | 浏览任务 | +| `←` `→` | 切换泳道(看板视图) | +| `b` | 切换看板 / 列表视图 | | `Esc` `←` | 返回项目列表 | | `Enter` | 更改任务状态 | | `p` | 更改任务优先级 | @@ -133,7 +135,9 @@ basilk - 状态:**UpNext**(品红)、**OnGoing**(黄色)、**Done**(绿色,~~删除线~~) - 优先级:`!`(最高)、`!!`(高)、`!!!`(低) -已完成的任务**默认隐藏**。在任务列表视图中按 `t` 可切换显示。 +已完成的任务在任务列表视图中**默认隐藏**,按 `t` 可切换显示。 + +在任务列表视图按 `b` 可切换到看板**泳道视图**:三条竖排泳道(**Up Next** / **On Going** / **Done**),标题带任务计数,当前聚焦的泳道以其状态颜色高亮边框。用 `←`/`→` 在泳道间切换,`↑`/`↓` 在泳道内选择任务;其余快捷键(`v` 详情、`Enter` 状态、`p` 优先级、计时器等)照常工作,更改任务状态后任务会移动到对应泳道。看板始终显示 Done 泳道,不受 `t` 设置影响(看板模式下 `t` 无操作)。 ## 参与贡献 > [!NOTE] diff --git a/src/main.rs b/src/main.rs index a2ec1a6..553d8b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,6 +71,13 @@ pub struct App { projects: Vec, 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], } fn init_terminal() -> Result, Box> { @@ -117,6 +124,13 @@ impl App { projects: Json::read(), hide_done_tasks: true, timer: None, + board_view: false, + board_lane: 0, + board_lane_states: [ + ListState::default().with_selected(Some(0)), + ListState::default().with_selected(Some(0)), + ListState::default().with_selected(Some(0)), + ], } } @@ -175,6 +189,12 @@ impl App { Task::load_items(self, &mut items); self.selected_task_index.select(Some(0)); + // The sync inside `load_items` ran before the + // selection was reset to the top + if self.board_view { + self.board_sync(); + } + App::change_view(self, ViewMode::ViewTasks); } Char('r') => { @@ -295,130 +315,177 @@ impl App { } } - ViewMode::ViewTasks => match key.code { - Char('h') => { - self.previous_view_mode = ViewMode::ViewTasks; - App::change_view(self, ViewMode::ViewHelp); - } - Esc | Left => { - Project::load_items(self, &mut items); + ViewMode::ViewTasks => { + // 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::ViewProjects); - } - Enter => { - if items.is_empty() { - continue; + match key.code { + Char('h') => { + self.previous_view_mode = ViewMode::ViewTasks; + App::change_view(self, ViewMode::ViewHelp); } + Esc => { + Project::load_items(self, &mut items); - let index = TASK_STATUSES - .into_iter() - .position(|t| t == &Task::get_current(self).status) - .unwrap(); - - self.selected_status_task_index.select(Some(index)); + App::change_view(self, ViewMode::ViewProjects); + } + Left => { + if self.board_view { + self.board_switch_lane(false); + } else { + Project::load_items(self, &mut items); - App::change_view(self, ViewMode::ChangeStatusTask); - } - Char('p') => { - if items.is_empty() { - continue; + 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, &mut items); + } + Enter => { + if no_current_task { + continue; + } - let index = TASK_PRIORITIES - .into_iter() - .position(|t| t == Task::get_current(self).priority) - .unwrap(); + let index = TASK_STATUSES + .into_iter() + .position(|t| t == &Task::get_current(self).status) + .unwrap(); - self.selected_priority_task_index.select(Some(index)); + self.selected_status_task_index.select(Some(index)); - App::change_view(self, ViewMode::ChangePriorityTask); - } - Char('r') => { - if items.is_empty() { - continue; + App::change_view(self, ViewMode::ChangeStatusTask); } + Char('p') => { + if no_current_task { + continue; + } - input = input - .clone() - .with_value(Task::get_current(self).title.clone()); + let index = TASK_PRIORITIES + .into_iter() + .position(|t| t == Task::get_current(self).priority) + .unwrap(); - App::change_view(self, ViewMode::RenameTask); - } - Char('n') => { - input.reset(); + self.selected_priority_task_index.select(Some(index)); - App::change_view(self, ViewMode::AddTask); - } - Char('d') => { - if items.is_empty() { - continue; + App::change_view(self, ViewMode::ChangePriorityTask); } + Char('r') => { + if no_current_task { + continue; + } - App::change_view(self, ViewMode::DeleteTask); - } - Char('v') => { - if items.is_empty() { - continue; - } + input = input + .clone() + .with_value(Task::get_current(self).title.clone()); - App::change_view(self, ViewMode::ViewTaskDetails); - } - Char('e') => { - if items.is_empty() { - continue; + App::change_view(self, ViewMode::RenameTask); } + Char('n') => { + input.reset(); - input = input - .clone() - .with_value(Task::get_current(self).note.clone()); + App::change_view(self, ViewMode::AddTask); + } + Char('d') => { + if no_current_task { + continue; + } - App::change_view(self, ViewMode::EditTaskNote); - } - Down | Tab | Char('j') => { - self.next(&items); - } - Up | BackTab | Char('k') => { - self.previous(&items); - } - Char('t') => { - self.hide_done_tasks = !self.hide_done_tasks; - Task::load_items(self, &mut items); - } - Char('s') => { - self.previous_view_mode = ViewMode::ViewTasks; + App::change_view(self, ViewMode::DeleteTask); + } + Char('v') => { + if no_current_task { + continue; + } - if self.timer.is_some() { - App::change_view(self, ViewMode::TimerTask); - } else if !items.is_empty() { - let project_index = - self.selected_project_index.selected().unwrap(); - let task_title = Task::get_current(self).title.clone(); + App::change_view(self, ViewMode::ViewTaskDetails); + } + Char('e') => { + if no_current_task { + continue; + } - self.timer = Some(TimerState::new_stopwatch( - project_index, - task_title, - )); + input = input + .clone() + .with_value(Task::get_current(self).note.clone()); - App::change_view(self, ViewMode::TimerTask); + App::change_view(self, ViewMode::EditTaskNote); } - } - Char('c') => { - self.previous_view_mode = ViewMode::ViewTasks; + 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, &mut 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(); + if self.timer.is_some() { + App::change_view(self, ViewMode::TimerTask); + } else { + input.reset(); - App::change_view(self, ViewMode::SetCountdown); + App::change_view(self, ViewMode::SetCountdown); + } } + Char('q') => { + self.settle_timer(); + return Ok(()); + } + _ => {} } - Char('q') => { - self.settle_timer(); - return Ok(()); - } - _ => {} - }, + } ViewMode::RenameTask => match key.code { Enter => { let project_index = @@ -524,8 +591,7 @@ impl App { self.timer = None; } - Task::delete(self, &mut items); - self.selected_task_index.select_previous(); + self.delete_current_task(&mut items); } self.delete_confirm_index.select(Some(0)); App::change_view(self, ViewMode::ViewTasks); @@ -851,6 +917,115 @@ 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, @@ -988,6 +1163,13 @@ pub(crate) mod test_utils { 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)), + ], } } @@ -1090,4 +1272,159 @@ mod tests { }); assert_state(&mut app, ViewMode::DeleteTask, |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/property_tests.rs b/src/property_tests.rs index a4d8858..411f0fc 100644 --- a/src/property_tests.rs +++ b/src/property_tests.rs @@ -94,6 +94,7 @@ proptest! { fn task_load_items_never_panics_and_selects_a_valid_index( tasks in prop::collection::vec(arb_task(), 0..8), hide_done in any::(), + board_view in any::(), selected in any::(), ) { let mut app = make_app(vec![Project { @@ -101,15 +102,17 @@ proptest! { tasks, }]); app.hide_done_tasks = hide_done; + app.board_view = board_view; app.selected_task_index.select(Some(selected)); let mut items = vec![]; Task::load_items(&mut app, &mut items); + // The board always shows the Done lane, ignoring `hide_done_tasks` let expected_visible = app.projects[0] .tasks .iter() - .filter(|t| !(hide_done && t.status == TASK_STATUS_DONE)) + .filter(|t| !(!board_view && hide_done && t.status == TASK_STATUS_DONE)) .count(); prop_assert_eq!(items.len(), expected_visible); if !items.is_empty() { diff --git a/src/task.rs b/src/task.rs index 730d2e6..bd46642 100644 --- a/src/task.rs +++ b/src/task.rs @@ -123,50 +123,87 @@ impl Task { let mut visible_selected = 0; for (full_idx, task) in tasks.iter().enumerate() { - if app.hide_done_tasks && task.status == TASK_STATUS_DONE { + // The board always shows the Done lane, so its item list is the + // full sorted list and visible indices match full-list indices + if app.hide_done_tasks && !app.board_view && task.status == TASK_STATUS_DONE { continue; } if full_idx == new_index { visible_selected = items.len(); } - let modifier = if task.status == TASK_STATUS_DONE { - Modifier::CROSSED_OUT - } else { - Modifier::empty() - }; - - let mut repr = vec![ - Span::styled( - format!("[{}] ", task.status), - Style::default() - .fg(Task::get_status_color(&task.status)) - .add_modifier(modifier), - ), - Span::styled(task.title.clone(), Style::default().add_modifier(modifier)), - ]; - - if task.priority != TASK_PRIORITY_NONE { - let priority_repr = vec![Span::styled( - format!("[{}] ", Util::get_priority_indicator(task.priority)), - Style::new().fg(Color::Red), - )]; - repr = [priority_repr, repr].concat() - } - // Estimate progress for tasks with an estimated duration - if let Some(pct) = task.estimate_progress(0) { - repr.push(Span::styled( - format!(" [{}%]", pct), - Style::default().fg(if pct >= 100 { Color::Red } else { Color::Cyan }), - )); - } + items.push(ListItem::from(Line::from(Task::repr_spans(task, true)))) + } + + app.selected_task_index.select(Some(visible_selected)); + + // Keep the board view consistent after any mutation: the selected + // task may have changed lane (status change) or disappeared. + // List mode is skipped on purpose: its selection is a *visible* + // index, which only matches the full-list index the board + // reasons in while done tasks sort last. + if app.board_view { + app.board_sync(); + } + } + + /// Spans rendering one task line: `[!]` priority prefix (if any), + /// `[status]` prefix (list view only — the board lane already conveys + /// the status), the title (crossed out when done), and the `[x%]` + /// estimate-progress suffix (when an estimate is set). Shared by the + /// list and board views so the two renderings cannot drift apart. + pub fn repr_spans(task: &Task, with_status: bool) -> Vec> { + let modifier = if task.status == TASK_STATUS_DONE { + Modifier::CROSSED_OUT + } else { + Modifier::empty() + }; - let line = Line::from(repr); + let mut repr = vec![]; - items.push(ListItem::from(line)) + if task.priority != TASK_PRIORITY_NONE { + repr.push(Span::styled( + format!("[{}] ", Util::get_priority_indicator(task.priority)), + Style::new().fg(Color::Red), + )); + } + + if with_status { + repr.push(Span::styled( + format!("[{}] ", task.status), + Style::default() + .fg(Task::get_status_color(&task.status)) + .add_modifier(modifier), + )); + } + + repr.push(Span::styled( + task.title.clone(), + Style::default().add_modifier(modifier), + )); + + // Estimate progress for tasks with an estimated duration + if let Some(pct) = task.estimate_progress(0) { + repr.push(Span::styled( + format!(" [{}%]", pct), + Style::default().fg(if pct >= 100 { Color::Red } else { Color::Cyan }), + )); } - app.selected_task_index.select(Some(visible_selected)) + repr + } + + /// Full-list indices of the tasks in a status lane, in display (sorted) + /// order. The board always shows every lane, so `hide_done_tasks` is + /// deliberately not applied here. + pub fn lane_indices(app: &App, status: &str) -> Vec { + app.projects[app.selected_project_index.selected().unwrap()] + .tasks + .iter() + .enumerate() + .filter(|(_, t)| t.status == status) + .map(|(i, _)| i) + .collect() } pub fn get_current(app: &mut App) -> &Task { diff --git a/src/view.rs b/src/view.rs index 2f3ebe5..84213bb 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1,5 +1,5 @@ use ratatui::{ - layout::{Alignment, Rect}, + layout::{Alignment, Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{Block, Clear, HighlightSpacing, List, ListItem, Paragraph, Wrap}, @@ -9,7 +9,7 @@ use tui_input::Input; use crate::{ project::Project, - task::{Task, TASK_PRIORITY_NONE}, + task::{Task, TASK_PRIORITY_NONE, TASK_STATUSES}, timer::TimerKind, ui::Ui, util::Util, @@ -274,6 +274,11 @@ impl View { ) || (matches!(app.view_mode, ViewMode::TimerTask | ViewMode::SetCountdown) && app.previous_view_mode == ViewMode::ViewProjects); + if !from_projects && app.board_view { + View::show_board(app, f, area); + return; + } + let block: Block = if from_projects { Block::bordered() } else { @@ -301,6 +306,84 @@ impl View { } } + /// Kanban-style board: one vertical lane per task status. The focused + /// lane gets the status color for its border and title; every lane is + /// always shown (the Done lane ignores `hide_done_tasks`). + pub fn show_board(app: &mut App, f: &mut Frame, area: Rect) { + const LANE_TITLES: [&str; 3] = ["Up Next", "On Going", "Done"]; + + let outer = + Block::bordered().title(Util::get_spaced_title(&Project::get_current(app).title)); + let inner = outer.inner(area); + f.render_widget(outer, area); + + let columns = Layout::horizontal([ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ]) + .spacing(1) + .split(inner); + + for (lane, status) in TASK_STATUSES.into_iter().enumerate() { + let indices = Task::lane_indices(app, status); + let status_color = Task::get_status_color(&status.to_string()); + let focused = lane == app.board_lane; + + let lane_style = if focused { + Style::default() + .fg(status_color) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + + let lane_block = Block::bordered() + .title(Line::from(Span::styled( + format!(" {} ({}) ", LANE_TITLES[lane], indices.len()), + if focused { + lane_style + } else { + Style::default() + }, + ))) + .border_style(lane_style); + + if indices.is_empty() { + let placeholder = Paragraph::new(Line::from(Span::styled( + "(empty)", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ))) + .alignment(Alignment::Center) + .block(lane_block); + + f.render_widget(placeholder, columns[lane]); + continue; + } + + // Own every string so the item list outlives the `tasks` borrow + // (the mutable lane state is borrowed again at render time). + let lane_items: Vec = { + let tasks = &app.projects[app.selected_project_index.selected().unwrap()].tasks; + + indices + .iter() + .map(|&i| ListItem::new(Line::from(Task::repr_spans(&tasks[i], false)))) + .collect() + }; + + let list = List::new(lane_items) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .highlight_symbol("> ") + .highlight_spacing(HighlightSpacing::Always) + .block(lane_block); + + f.render_stateful_widget(list, columns[lane], &mut app.board_lane_states[lane]); + } + } + pub fn show_task_details_modal(app: &mut App, f: &mut Frame, area: Rect) { let task = Task::get_current(app); @@ -428,8 +511,27 @@ impl View { ("h", "help"), ("q", "quit"), ], + ViewMode::ViewTasks if app.board_view => &[ + ("↑ ↓ k j", "next/prev in lane"), + ("← →", "switch lane"), + ("b", "list view"), + ("Esc", "back"), + ("Enter", "change status"), + ("p", "change priority"), + ("n", "new"), + ("r", "rename"), + ("v", "details"), + ("e", "note"), + ("v → g", "details: edit estimate"), + ("d", "delete"), + ("s", "stopwatch timer"), + ("c", "pomodoro timer"), + ("h", "help"), + ("q", "quit"), + ], ViewMode::ViewTasks => &[ ("↑ ↓ k j", "next/prev"), + ("b", "board view"), ("Esc ←", "back"), ("Enter", "change status"), ("p", "change priority"), @@ -476,6 +578,58 @@ impl View { .wrap(Wrap { trim: true }) .block(Block::bordered().title(" Help ")); - Ui::create_modal(f, 42, 19, area, widget) + // Sized with a little headroom above the longest binding list + Ui::create_modal(f, 42, 23, area, widget) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + project::Project, + task::{TASK_PRIORITY_NONE, TASK_STATUS_DONE, TASK_STATUS_ON_GOING, TASK_STATUS_UP_NEXT}, + test_utils::{make_app, make_task}, + }; + use ratatui::{backend::TestBackend, Terminal}; + + /// Rendering must not panic on any lane composition (all lanes filled, + /// some empty, board narrower than the content). + #[test] + fn show_board_renders_without_panicking() { + for (width, height) in [(120, 30), (60, 20), (30, 10), (12, 4)] { + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![ + make_task("up-next", TASK_STATUS_UP_NEXT, 1), + make_task("on-going", TASK_STATUS_ON_GOING, TASK_PRIORITY_NONE), + make_task("done", TASK_STATUS_DONE, TASK_PRIORITY_NONE), + ], + }]); + app.board_view = true; + app.board_sync(); + + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| View::show_board(&mut app, f, f.size())) + .unwrap(); + } + } + + #[test] + fn show_board_renders_empty_lanes_without_panicking() { + let mut app = make_app(vec![Project { + title: "p".to_string(), + tasks: vec![make_task("only", TASK_STATUS_UP_NEXT, TASK_PRIORITY_NONE)], + }]); + app.board_view = true; + app.board_sync(); + + let backend = TestBackend::new(90, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| View::show_board(&mut app, f, f.size())) + .unwrap(); } } From ef3ac9760bb97ad4e37e7ca865f3666b3d1b9559 Mon Sep 17 00:00:00 2001 From: kenshin Date: Sat, 1 Aug 2026 00:26:26 +0800 Subject: [PATCH 22/23] Refactor event loop and data migration check Split App::run's monolithic per-view key handling into dedicated handlers so the loop stays shallow, and break Json::check into focused paths for the current data file and legacy versioned files. --- src/json.rs | 109 ++-- src/main.rs | 1370 ++++++++++++++++++++++++++++----------------------- 2 files changed, 822 insertions(+), 657 deletions(-) diff --git a/src/json.rs b/src/json.rs index a7a0e11..a9ad73b 100644 --- a/src/json.rs +++ b/src/json.rs @@ -1,4 +1,9 @@ -use std::{error::Error, fs, path::PathBuf, sync::Mutex}; +use std::{ + error::Error, + fs, + path::PathBuf, + sync::{Mutex, MutexGuard}, +}; use serde::{Deserialize, Serialize}; use serde_json::{from_str, to_string}; @@ -50,66 +55,80 @@ impl Json { pub fn check() -> Result> { fs::create_dir_all(Json::get_dir_path())?; + Json::check_data() + } - 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(); if data_path.is_file() { - let json_raw = fs::read_to_string(&data_path)?; - if json_raw.trim().is_empty() { - 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); - } - let wrapper: DataWrapper = from_str(&json_raw)?; + 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 json_raw.trim().is_empty() { + let last_json_version = JSON_VERSIONS.last().unwrap(); version_state.clear(); - version_state.push_str(&wrapper.version); + version_state.push_str(last_json_version); + drop(version_state); + Json::write(vec![]); + return Ok(false); + } - let migrations = Migration::get_migrations(&wrapper.version, wrapper.data); + let wrapper: DataWrapper = from_str(&json_raw)?; + version_state.clear(); + version_state.push_str(&wrapper.version); - if migrations.is_empty() { - return Ok(false); - } + let migrations = Migration::get_migrations(&wrapper.version, wrapper.data); - 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(), - ); - } + if migrations.is_empty() { + return Ok(false); + } - return Ok(true); + 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()); } - // Migration from old versioned files - let json_version_from_file: Vec<&str> = JSON_VERSIONS + 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() - .filter(|version| { - let p = Json::get_json_path(version.to_string()); - p.is_file() - }) - .collect(); + .find(|version| Json::get_json_path(version.to_string()).is_file()); - if json_version_from_file.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); - let projects: Vec = vec![]; - let wrapper = DataWrapper { - version: last_json_version.to_string(), - data: projects, - }; - fs::write(&data_path, to_string(&wrapper).unwrap()).unwrap(); + drop(version_state); + Json::write(vec![]); return Ok(false); - } + }; - let old_version = json_version_from_file[0]; 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)?; @@ -120,14 +139,14 @@ impl Json { version: old_version.to_string(), data, }; - fs::write(&data_path, to_string(&wrapper).unwrap()).unwrap(); + fs::write(data_path, to_string(&wrapper).unwrap()).unwrap(); // Optionally delete old file let _ = fs::remove_file(old_path); // Re-run check to apply any further migrations drop(version_state); - return Json::check(); + Json::check() } pub fn read() -> Vec { diff --git a/src/main.rs b/src/main.rs index 553d8b5..bc6ef09 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use std::{ 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, }, @@ -80,6 +80,19 @@ pub struct App { board_lane_states: [ListState; 3], } +/// 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> { enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?; @@ -174,566 +187,20 @@ impl App { 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 { - Char('h') => { - self.previous_view_mode = ViewMode::ViewProjects; - App::change_view(self, ViewMode::ViewHelp); - } - Enter | Right | Char('l') => { - if items.is_empty() { - continue; - } - - Task::load_items(self, &mut items); - self.selected_task_index.select(Some(0)); - - // The sync inside `load_items` ran before the - // selection was reset to the top - if self.board_view { - self.board_sync(); - } - - App::change_view(self, ViewMode::ViewTasks); - } - Char('r') => { - if items.is_empty() { - continue; - } - - input = input - .clone() - .with_value(Project::get_current(self).title.clone()); - - App::change_view(self, ViewMode::RenameProject); - } - Char('n') => { - input.reset(); - - App::change_view(self, ViewMode::AddProject); - } - Char('d') => { - if items.is_empty() { - continue; - } - - App::change_view(self, ViewMode::DeleteProject); - } - Down | Tab | Char('j') => { - self.next(&items); - } - Up | BackTab | Char('k') => { - self.previous(&items); - } - Char('c') => { - self.previous_view_mode = ViewMode::ViewProjects; - - if self.timer.is_some() { - App::change_view(self, ViewMode::TimerTask); - } else { - input.reset(); - - App::change_view(self, ViewMode::SetCountdown); - } - } - Char('q') => { - self.settle_timer(); - return Ok(()); - } - _ => {} - }, - ViewMode::RenameProject => match key.code { - Enter => { - Project::rename(self, &mut items, input.value()); - input.reset(); - - App::change_view(self, ViewMode::ViewProjects); - } - Esc => { - input.reset(); - - App::change_view(self, ViewMode::ViewProjects); - } - _ => { - input.handle_event(&Event::Key(key)); - } - }, - ViewMode::AddProject => match key.code { - Esc => { - App::change_view(self, ViewMode::ViewProjects); - } - Enter => { - if !input.value().is_empty() { - Project::create(self, &mut items, input.value()); - self.selected_project_index - .select(Some(self.projects.len() - 1)); - } - - App::change_view(self, ViewMode::ViewProjects); - } - _ => { - input.handle_event(&Event::Key(key)); - } - }, - ViewMode::DeleteProject => { - if self.handle_modal_nav( - key.code, - &delete_confirm_items, - ViewMode::ViewProjects, - ) { - continue; - } - if key.code == Enter { - if self.delete_confirm_index.selected() == Some(0) { - let deleted_index = - self.selected_project_index.selected().unwrap(); - - Project::delete(self, &mut 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); - } - } - - ViewMode::ViewTasks => { - // In board mode the focused lane (not the list) decides - // whether task actions are available: the list can be - // empty only because done tasks are hidden, while the - // Done lane still shows them. - let no_current_task = if self.board_view { - self.board_lane_is_empty() - } else { - items.is_empty() - }; - - match key.code { - Char('h') => { - self.previous_view_mode = ViewMode::ViewTasks; - App::change_view(self, ViewMode::ViewHelp); - } - Esc => { - Project::load_items(self, &mut items); - - App::change_view(self, ViewMode::ViewProjects); - } - Left => { - if self.board_view { - self.board_switch_lane(false); - } else { - Project::load_items(self, &mut items); - - 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, &mut items); - } - Enter => { - if no_current_task { - continue; - } - - let index = TASK_STATUSES - .into_iter() - .position(|t| t == &Task::get_current(self).status) - .unwrap(); - - self.selected_status_task_index.select(Some(index)); - - App::change_view(self, ViewMode::ChangeStatusTask); - } - Char('p') => { - if no_current_task { - continue; - } - - let index = TASK_PRIORITIES - .into_iter() - .position(|t| t == Task::get_current(self).priority) - .unwrap(); - - self.selected_priority_task_index.select(Some(index)); - - App::change_view(self, ViewMode::ChangePriorityTask); - } - Char('r') => { - if no_current_task { - continue; - } - - 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 { - continue; - } - - App::change_view(self, ViewMode::DeleteTask); - } - Char('v') => { - if no_current_task { - continue; - } - - App::change_view(self, ViewMode::ViewTaskDetails); - } - Char('e') => { - if no_current_task { - continue; - } - - 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, &mut 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') => { - self.settle_timer(); - return Ok(()); - } - _ => {} - } - } - ViewMode::RenameTask => 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, &mut 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)); - } - }, - ViewMode::ChangeStatusTask => { - if self.handle_modal_nav( - key.code, - &status_items, - ViewMode::ViewTasks, - ) { - continue; - } - if 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 self.handle_key( + key, + &mut input, + &mut items, + &status_items, + &priority_items, + &delete_confirm_items, + ) { + KeyAction::Quit => { + self.settle_timer(); + return Ok(()); } - ViewMode::ChangePriorityTask => { - if self.handle_modal_nav( - key.code, - &priority_items, - ViewMode::ViewTasks, - ) { - continue; - } - if 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); - } - } - ViewMode::AddTask => match key.code { - Enter => { - Task::create(self, &mut items, input.value()); - - App::change_view(self, ViewMode::ViewTasks); - } - Esc => { - App::change_view(self, ViewMode::ViewTasks); - } - _ => { - input.handle_event(&Event::Key(key)); - } - }, - ViewMode::DeleteTask => { - if self.handle_modal_nav( - key.code, - &delete_confirm_items, - ViewMode::ViewTasks, - ) { - continue; - } - if key.code == 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(&mut items); - } - self.delete_confirm_index.select(Some(0)); - App::change_view(self, ViewMode::ViewTasks); - } - } - ViewMode::ViewTaskDetails => 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); - } - }, - ViewMode::EditTaskNote => match key.code { - Enter => { - Task::update_note(self, &mut 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)); - } - }, - ViewMode::SetTaskEstimate => match key.code { - Enter => { - let raw = input.value().trim().to_string(); - - if let Ok(hours) = raw.parse::() { - Task::update_estimate(self, &mut 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)); - } - }, - ViewMode::TimerTask => { - 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(); - continue; - } - - 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(); - } - _ => {} - } - } - ViewMode::SetCountdown => 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)); - } - }, - - ViewMode::ViewHelp => match key.code { - _ => { - self.back_to_previous_view(); - } - }, - - ViewMode::InfoMigration => match key.code { - _ => { - App::change_view(self, ViewMode::ViewProjects); - } - }, + KeyAction::Skip => continue, + KeyAction::None => {} } } } @@ -743,6 +210,685 @@ impl App { } } + /// 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::InfoMigration => { + App::change_view(self, ViewMode::ViewProjects); + KeyAction::None + } + } + } + + fn handle_view_projects( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Char('h') => { + self.previous_view_mode = ViewMode::ViewProjects; + App::change_view(self, ViewMode::ViewHelp); + } + Enter | Right | Char('l') => { + if items.is_empty() { + return KeyAction::Skip; + } + + Task::load_items(self, items); + self.selected_task_index.select(Some(0)); + + // The sync inside `load_items` ran before the + // selection was reset to the top + if self.board_view { + self.board_sync(); + } + + App::change_view(self, ViewMode::ViewTasks); + } + Char('r') => { + if items.is_empty() { + return KeyAction::Skip; + } + + *input = input + .clone() + .with_value(Project::get_current(self).title.clone()); + + App::change_view(self, ViewMode::RenameProject); + } + Char('n') => { + input.reset(); + + App::change_view(self, ViewMode::AddProject); + } + Char('d') => { + if items.is_empty() { + return KeyAction::Skip; + } + + 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; + + 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_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::ViewProjects); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + fn handle_add_project( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + match key.code { + Esc => { + 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::ViewProjects); + } + _ => { + input.handle_event(&Event::Key(key)); + } + } + KeyAction::None + } + + 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 + } + + fn handle_view_tasks( + &mut self, + key: KeyEvent, + input: &mut Input, + items: &mut Vec, + ) -> KeyAction { + use KeyCode::*; + // In board mode the focused lane (not the list) decides + // whether task actions are available: the list can be + // empty only because done tasks are hidden, while the + // Done lane still shows them. + let no_current_task = if self.board_view { + self.board_lane_is_empty() + } else { + items.is_empty() + }; + + match key.code { + Char('h') => { + self.previous_view_mode = ViewMode::ViewTasks; + App::change_view(self, ViewMode::ViewHelp); + } + Esc => { + Project::load_items(self, items); + + 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::ViewProjects); + } + } + Right => { + if self.board_view { + self.board_switch_lane(true); + } + } + Char('b') => { + self.board_view = !self.board_view; + + // Rebuild the item list: the board shows + // done tasks, the list view may hide them. + // When toggling on, `load_items` also + // re-syncs the board focus. + Task::load_items(self, items); + } + Enter => { + if no_current_task { + return KeyAction::Skip; + } + + let index = TASK_STATUSES + .into_iter() + .position(|t| t == &Task::get_current(self).status) + .unwrap(); + + self.selected_status_task_index.select(Some(index)); + + 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 render( &mut self, f: &mut Frame, @@ -767,7 +913,25 @@ impl App { header_area, ); - // Hint + // Hint and timer readout + self.render_hint(f, hint_area); + + // Main view + View::show_items(self, items, f, main_area); + + // Modal on top of the current view, if any + self.render_modal( + f, + area, + input, + status_items, + priority_items, + delete_confirm_items, + ); + } + + /// The hint line, plus the running timer readout (right aligned). + fn render_hint(&self, f: &mut Frame, hint_area: Rect) { f.render_widget( Paragraph::new(Line::from(Span::styled( " h help", @@ -776,7 +940,6 @@ impl App { hint_area, ); - // Timer readout (right aligned on the hint line) if let Some(timer) = &self.timer { let secs = match timer.kind { TimerKind::Stopwatch => timer.elapsed().as_secs(), @@ -825,57 +988,40 @@ impl App { hint_area, ); } + } - // Main view - View::show_items(self, items, f, main_area); - - // Other views - if self.view_mode == ViewMode::InfoMigration { - View::show_migration_info_modal(f, area); - } - - if self.view_mode == ViewMode::AddTask || self.view_mode == ViewMode::AddProject { - View::show_new_item_modal(f, area, input) - } - - if self.view_mode == ViewMode::RenameTask || self.view_mode == ViewMode::RenameProject { - View::show_rename_item_modal(f, area, input) - } - - if self.view_mode == ViewMode::EditTaskNote { - View::show_edit_note_modal(f, area, input) - } - - if self.view_mode == ViewMode::SetCountdown { - View::show_countdown_modal(f, area, input) - } - - if self.view_mode == ViewMode::SetTaskEstimate { - View::show_task_estimate_modal(f, area, input) - } - - if self.view_mode == ViewMode::TimerTask { - View::show_timer_modal(self, f, area) - } - - if self.view_mode == ViewMode::DeleteTask || self.view_mode == ViewMode::DeleteProject { - View::show_delete_item_modal(self, delete_confirm_items, f, area) - } - - if self.view_mode == ViewMode::ChangeStatusTask { - View::show_select_task_status_modal(self, status_items, f, area) - } - - if self.view_mode == ViewMode::ChangePriorityTask { - View::show_select_task_priority_modal(self, priority_items, f, area) - } - - if self.view_mode == ViewMode::ViewHelp { - View::show_help_modal(self, f, area) - } - - if self.view_mode == ViewMode::ViewTaskDetails { - View::show_task_details_modal(self, f, 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 => View::show_new_item_modal(f, area, input), + ViewMode::RenameTask | ViewMode::RenameProject => { + 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 => { + 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), + _ => {} } } From 534b1d4ca7d358ebf8d2fd128dba98851a2e4394 Mon Sep 17 00:00:00 2001 From: kenshin Date: Sat, 1 Aug 2026 22:10:49 +0800 Subject: [PATCH 23/23] Add global Markdown notes with full-page preview and editor Press m in the project list to open global, project-independent notes. A note is a titled entry whose body is Markdown: Enter opens a full-page rendered preview (pulldown-cmark -> ratatui Text with a style stack), and e switches to a full-page tui-textarea editor (Esc saves and returns to the preview). Notes are stored in the same basilk_data.json wrapper behind #[serde(default)], so pre-notes files load unchanged; the e5a1c migration only bumps the version. Both write paths preserve the other half of the file. Also fix show_items classifying ViewHelp/InfoMigration: opening help over the projects or notes view with an empty project list fell into the tasks branch and panicked in Project::get_current (ratatui clears the list selection when rendering an empty list). --- AGENTS.md | 7 +- Cargo.lock | 59 ++++++- Cargo.toml | 2 + README.md | 30 ++++ README.zh-CN.md | 30 ++++ src/json.rs | 71 +++++++- src/main.rs | 283 +++++++++++++++++++++++++++++++- src/markdown.rs | 374 ++++++++++++++++++++++++++++++++++++++++++ src/migration.rs | 17 +- src/note.rs | 216 ++++++++++++++++++++++++ src/property_tests.rs | 5 + src/view.rs | 184 ++++++++++++++++++++- 12 files changed, 1254 insertions(+), 24 deletions(-) create mode 100644 src/markdown.rs create mode 100644 src/note.rs diff --git a/AGENTS.md b/AGENTS.md index 795fb37..df00d6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,9 @@ Basilk is a TUI-based kanban task manager written in Rust using `ratatui`. - `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`. @@ -35,8 +37,8 @@ The `App` struct in `main.rs` manages the application state, including selected `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) 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. +- 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. @@ -60,6 +62,7 @@ The `App` struct in `main.rs` manages the application state, including selected - **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`. diff --git a/Cargo.lock b/Cargo.lock index 7310d42..49ecb7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,11 +42,13 @@ dependencies = [ "chrono", "dirs", "proptest", + "pulldown-cmark", "ratatui", "serde", "serde_json", "tempfile", "tui-input", + "tui-textarea", ] [[package]] @@ -219,6 +221,15 @@ 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" version = "0.2.15" @@ -470,6 +481,25 @@ dependencies = [ "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" @@ -553,7 +583,7 @@ dependencies = [ "strum_macros", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -788,7 +818,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b02e86628a225c39b2602863f244a01668184149928ceb410e47a8022d7597e" dependencies = [ "crossterm", - "unicode-width", + "unicode-width 0.1.13", +] + +[[package]] +name = "tui-textarea" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00524c1366ee838839dd327d1f339ff51846ad4ea85bfa1332859e79adec612c" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width 0.1.13", ] [[package]] @@ -797,6 +838,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -817,7 +864,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -826,6 +873,12 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 9abd81c..db3f463 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,10 +17,12 @@ default-run = "basilk" [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" tui-input = "0.9.0" +tui-textarea = "0.5" [dev-dependencies] proptest = "1.5" diff --git a/README.md b/README.md index 23edc14..fa5a1ee 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Press `h` to view the keybinding list in-app. |---|---| | `↑` `↓` `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 | @@ -126,6 +127,33 @@ Press `h` to view the keybinding list in-app. | `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). @@ -139,6 +167,8 @@ Completed tasks are **hidden by default** in the task list view. Press `t` to to 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] > This project is now in beta version and is expected to have bugs diff --git a/README.zh-CN.md b/README.zh-CN.md index 22c7230..7adae67 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -68,6 +68,7 @@ basilk |---|---| | `↑` `↓` `k` `j` `Tab` `Shift+Tab` | 浏览项目 | | `Enter` `→` `l` | 进入项目(查看任务) | +| `m` | 打开便签 | | `n` | 新建项目 | | `r` | 重命名所选项目 | | `d` | 删除所选项目 | @@ -126,6 +127,33 @@ basilk | `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` 为每个任务单独设置)并列展示,并显示已消耗预期时长的百分比。 @@ -139,6 +167,8 @@ basilk 在任务列表视图按 `b` 可切换到看板**泳道视图**:三条竖排泳道(**Up Next** / **On Going** / **Done**),标题带任务计数,当前聚焦的泳道以其状态颜色高亮边框。用 `←`/`→` 在泳道间切换,`↑`/`↓` 在泳道内选择任务;其余快捷键(`v` 详情、`Enter` 状态、`p` 优先级、计时器等)照常工作,更改任务状态后任务会移动到对应泳道。看板始终显示 Done 泳道,不受 `t` 设置影响(看板模式下 `t` 无操作)。 +在项目列表视图按 `m` 可打开**便签**:全局的、不依附于项目的备忘录。便签由标题和 Markdown 正文组成,打开后进入整页渲染预览(标题、粗体/斜体、代码块、列表、引用、链接等),按 `e` 切换到整页多行编辑器修改 Markdown 源码,`Esc` 保存并返回预览。 + ## 参与贡献 > [!NOTE] > 本项目目前处于 beta 阶段,可能存在 bug。 diff --git a/src/json.rs b/src/json.rs index a9ad73b..7a185d6 100644 --- a/src/json.rs +++ b/src/json.rs @@ -10,6 +10,7 @@ use serde_json::{from_str, to_string}; use crate::{ migration::{Migration, JSON_VERSIONS}, + note::Note, project::Project, }; @@ -23,6 +24,9 @@ static VERSION: Mutex = Mutex::new(String::new()); struct DataWrapper { version: String, data: Vec, + /// Global notes; `default` keeps pre-notes data files loadable. + #[serde(default)] + notes: Vec, } impl Json { @@ -102,7 +106,12 @@ impl Json { 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()); + Json::write_internal( + data_path, + version_state.to_string(), + migration_data.clone(), + wrapper.notes.clone(), + ); } Ok(true) @@ -138,6 +147,7 @@ impl Json { let wrapper = DataWrapper { version: old_version.to_string(), data, + notes: vec![], }; fs::write(data_path, to_string(&wrapper).unwrap()).unwrap(); @@ -161,15 +171,37 @@ impl Json { return wrapper.data; } + /// 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_data_path(); - Json::write_internal(&path, version, projects); + Json::write_internal(&path, version, projects, Json::read_notes()); } - fn write_internal(path: &PathBuf, version: String, data: Vec) { - let wrapper = DataWrapper { version, data }; + 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_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(); } } @@ -177,6 +209,7 @@ impl Json { #[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}; @@ -221,6 +254,36 @@ mod tests { 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(); diff --git a/src/main.rs b/src/main.rs index bc6ef09..eab645c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,9 @@ use tui_input::{backend::crossterm::EventHandler, Input}; mod cli; mod json; +mod markdown; mod migration; +mod note; mod project; mod task; mod timer; @@ -28,9 +30,11 @@ mod util; mod view; 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; @@ -56,6 +60,13 @@ pub enum ViewMode { SetCountdown, ViewHelp, + ViewNotes, + AddNote, + RenameNote, + DeleteNote, + ViewNote, + EditNote, + InfoMigration, } @@ -78,6 +89,13 @@ pub struct App { 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. @@ -144,6 +162,10 @@ impl App { 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, } } @@ -245,6 +267,12 @@ impl App { 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 @@ -320,6 +348,11 @@ impl App { App::change_view(self, ViewMode::SetCountdown); } } + Char('m') => { + Note::load_items(self, items); + + App::change_view(self, ViewMode::ViewNotes); + } Char('q') => { return KeyAction::Quit; } @@ -889,6 +922,223 @@ impl App { 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( &mut self, f: &mut Frame, @@ -916,8 +1166,12 @@ impl App { // Hint and timer readout self.render_hint(f, hint_area); - // Main view - View::show_items(self, items, f, main_area); + // Main view: the note preview and editor take the whole main area + match self.view_mode { + ViewMode::ViewNote => View::show_note(self, f, main_area), + ViewMode::EditNote => View::show_note_editor(self, f, main_area), + _ => View::show_items(self, items, f, main_area), + } // Modal on top of the current view, if any self.render_modal( @@ -1002,15 +1256,17 @@ impl App { ) { match self.view_mode { ViewMode::InfoMigration => View::show_migration_info_modal(f, area), - ViewMode::AddTask | ViewMode::AddProject => View::show_new_item_modal(f, area, input), - ViewMode::RenameTask | ViewMode::RenameProject => { + 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::DeleteTask | ViewMode::DeleteProject | ViewMode::DeleteNote => { View::show_delete_item_modal(self, delete_confirm_items, f, area) } ViewMode::ChangeStatusTask => { @@ -1197,6 +1453,13 @@ impl App { } 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, }; @@ -1316,6 +1579,10 @@ pub(crate) mod test_utils { 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, } } @@ -1417,6 +1684,12 @@ mod tests { &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 { 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