From a21f9e575b99d7f76ec000f1af0ae058870d1478 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 07:06:48 -0700 Subject: [PATCH] =?UTF-8?q?feat(tui):=20heph-tui=20T1=20=E2=80=94=20read-o?= =?UTF-8?q?nly=203-pane=20agenda=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crate crates/heph-tui: a ratatui terminal agenda, thin client of the hephd unix socket (never touches SQLite, same as heph.nvim). The next big surface — the interactive triage UI the §6.2.1 Todoist study calls for. - 3-pane layout: sidebar (the five §8.2 filter views + projects), task list (attention-colored rows with compact human do/late dates), and a preview pane (the highlighted task's canonical-context doc body + log tail). - App state is generic over a `Backend` seam, so navigation/selection logic is unit-testable without a terminal or daemon; `ClientBackend` forwards to the socket. Rendering is a pure `ui::render(frame, &app)`. - Navigation: j/k within the focused pane, Tab / h / l to move focus, selecting a sidebar source reloads the list, moving the task cursor refreshes the preview. r refresh, q quit. - Socket resolution: --socket flag, then $HEPH_SOCKET, then the standard runtime path (the TUI honors the env var the CLI doesn't). Tests: a headless TestBackend render against a real spawned daemon (asserts views/projects/tasks/preview paint, and Top of Mind excludes blue), plus in-memory navigation unit tests. 8 heph-tui tests; clippy/fmt clean. Mutations (add/done/attention/reschedule/blue) + nvim handoff land in T2. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 296 ++++++++++++++++++++++++++++ Cargo.toml | 3 +- crates/heph-tui/Cargo.toml | 31 +++ crates/heph-tui/src/app.rs | 227 +++++++++++++++++++++ crates/heph-tui/src/backend.rs | 159 +++++++++++++++ crates/heph-tui/src/fmt.rs | 52 +++++ crates/heph-tui/src/lib.rs | 15 ++ crates/heph-tui/src/main.rs | 95 +++++++++ crates/heph-tui/src/ui.rs | 216 ++++++++++++++++++++ crates/heph-tui/tests/agenda.rs | 134 +++++++++++++ crates/heph-tui/tests/navigation.rs | 149 ++++++++++++++ 11 files changed, 1376 insertions(+), 1 deletion(-) create mode 100644 crates/heph-tui/Cargo.toml create mode 100644 crates/heph-tui/src/app.rs create mode 100644 crates/heph-tui/src/backend.rs create mode 100644 crates/heph-tui/src/fmt.rs create mode 100644 crates/heph-tui/src/lib.rs create mode 100644 crates/heph-tui/src/main.rs create mode 100644 crates/heph-tui/src/ui.rs create mode 100644 crates/heph-tui/tests/agenda.rs create mode 100644 crates/heph-tui/tests/navigation.rs diff --git a/Cargo.lock b/Cargo.lock index 0f455f8..c34e345 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,6 +40,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -377,6 +383,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -499,6 +520,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -593,6 +628,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -642,6 +702,40 @@ dependencies = [ "syn", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -776,6 +870,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1106,6 +1206,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1161,6 +1263,22 @@ dependencies = [ "yrs", ] +[[package]] +name = "heph-tui" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "heph-core", + "hephd", + "ratatui", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "hephd" version = "0.0.0" @@ -1417,6 +1535,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1450,6 +1574,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -1460,6 +1593,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1472,6 +1618,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1616,6 +1771,15 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1669,6 +1833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1890,6 +2055,16 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + [[package]] name = "parking_lot_core" version = "0.9.12" @@ -1912,6 +2087,12 @@ dependencies = [ "regex", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.6" @@ -2219,6 +2400,27 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2650,6 +2852,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2759,6 +2982,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3114,6 +3359,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3366,6 +3640,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 54f59a4..be3bb8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/heph-core", "crates/hephd", "crates/heph"] +members = ["crates/heph-core", "crates/hephd", "crates/heph", "crates/heph-tui"] [workspace.package] edition = "2021" @@ -32,6 +32,7 @@ tokio = { version = "1", features = [ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } clap = { version = "4", features = ["derive"] } +ratatui = "0.29" fs4 = "0.12" axum = "0.8" jsonwebtoken = { version = "10", features = ["rust_crypto"] } diff --git a/crates/heph-tui/Cargo.toml b/crates/heph-tui/Cargo.toml new file mode 100644 index 0000000..0224c96 --- /dev/null +++ b/crates/heph-tui/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "heph-tui" +description = "Hephaestus terminal UI: the task agenda/triage surface (a thin client of the local hephd daemon)." +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true +authors.workspace = true +rust-version.workspace = true + +[[bin]] +name = "heph-tui" +path = "src/main.rs" + +[lib] +name = "heph_tui" +path = "src/lib.rs" + +[dependencies] +heph-core = { path = "../heph-core" } +hephd = { path = "../hephd" } +ratatui.workspace = true +chrono.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +clap.workspace = true + +[dev-dependencies] +tempfile = "3" +tokio.workspace = true diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs new file mode 100644 index 0000000..6ef3d12 --- /dev/null +++ b/crates/heph-tui/src/app.rs @@ -0,0 +1,227 @@ +//! The agenda app state + interaction logic (tech-spec §8.1), generic over a +//! [`Backend`] so it is testable without a terminal or a daemon. Rendering +//! lives in [`crate::ui`]; the terminal/event loop in `main.rs`. + +use anyhow::Result; +use heph_core::{RankedTask, BUILTIN_VIEWS}; + +use crate::backend::{Backend, Project}; + +/// Which pane has the keyboard. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + Sidebar, + Tasks, +} + +/// One row of the left sidebar. `Header` rows are labels and are skipped by the +/// cursor; the others are selectable sources for the task list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SidebarEntry { + Header(String), + View { name: String, title: String }, + Project { id: String, title: String }, +} + +impl SidebarEntry { + fn selectable(&self) -> bool { + !matches!(self, SidebarEntry::Header(_)) + } +} + +/// The selected sidebar source, as owned values (so reloads don't hold a borrow +/// on `self.sidebar` while calling the backend). +enum Target { + View(String), + Project(String), +} + +/// The right-pane preview of the highlighted task: its canonical-context doc +/// body plus the tail of its log. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Preview { + pub title: String, + pub body: Vec, + pub log: Vec, +} + +/// The whole TUI state. +pub struct App { + backend: B, + pub sidebar: Vec, + pub sidebar_cursor: usize, + pub tasks: Vec, + pub task_cursor: usize, + pub preview: Preview, + pub focus: Focus, + pub status: String, + pub should_quit: bool, +} + +const PREVIEW_LOG_LINES: usize = 5; + +impl App { + /// Build the sidebar (built-in views + projects), select the first view, and + /// load its tasks + the first task's preview. + pub fn new(mut backend: B) -> Result { + let mut sidebar = vec![SidebarEntry::Header("Views".into())]; + for v in BUILTIN_VIEWS { + sidebar.push(SidebarEntry::View { + name: v.name.into(), + title: v.title.into(), + }); + } + sidebar.push(SidebarEntry::Header("Projects".into())); + for Project { id, title } in backend.projects()? { + sidebar.push(SidebarEntry::Project { id, title }); + } + + let sidebar_cursor = sidebar + .iter() + .position(SidebarEntry::selectable) + .unwrap_or(0); + + let mut app = App { + backend, + sidebar, + sidebar_cursor, + tasks: Vec::new(), + task_cursor: 0, + preview: Preview::default(), + focus: Focus::Sidebar, + status: String::new(), + should_quit: false, + }; + app.reload(); + Ok(app) + } + + /// The title shown above the task list (the selected source). + pub fn task_pane_title(&self) -> String { + match self.sidebar.get(self.sidebar_cursor) { + Some(SidebarEntry::View { title, .. }) => title.clone(), + Some(SidebarEntry::Project { title, .. }) => title.clone(), + _ => "Tasks".into(), + } + } + + /// The highlighted task, if any. + pub fn selected_task(&self) -> Option<&RankedTask> { + self.tasks.get(self.task_cursor) + } + + fn current_target(&self) -> Option { + match self.sidebar.get(self.sidebar_cursor)? { + SidebarEntry::View { name, .. } => Some(Target::View(name.clone())), + SidebarEntry::Project { id, .. } => Some(Target::Project(id.clone())), + SidebarEntry::Header(_) => None, + } + } + + /// Reload the task list for the current sidebar selection, then the preview. + /// Errors surface in the status line rather than crashing the UI. + pub fn reload(&mut self) { + match self.load_tasks() { + Ok(tasks) => { + self.tasks = tasks; + if self.task_cursor >= self.tasks.len() { + self.task_cursor = self.tasks.len().saturating_sub(1); + } + } + Err(e) => self.status = format!("error: {e}"), + } + self.reload_preview(); + } + + fn load_tasks(&mut self) -> Result> { + match self.current_target() { + Some(Target::View(name)) => self.backend.view(&name), + Some(Target::Project(id)) => { + let filter = heph_core::ListFilter { + scope: vec![id], + ..Default::default() + }; + self.backend.list(&filter) + } + None => Ok(Vec::new()), + } + } + + fn reload_preview(&mut self) { + let Some(task) = self.selected_task().cloned() else { + self.preview = Preview::default(); + return; + }; + let mut preview = Preview { + title: task.title.clone(), + ..Default::default() + }; + if let Some(ctx) = &task.canonical_context_id { + match self.backend.node_body(ctx) { + Ok(body) => preview.body = body.lines().map(str::to_string).collect(), + Err(e) => preview.body = vec![format!("(preview error: {e})")], + } + } + if let Ok(log) = self.backend.log_tail(&task.node_id, PREVIEW_LOG_LINES) { + preview.log = log; + } + self.preview = preview; + } + + // --- navigation --- + + /// Move the sidebar cursor by `delta` rows, skipping headers, reloading the + /// task list for the new selection. + pub fn move_sidebar(&mut self, delta: isize) { + let n = self.sidebar.len(); + let mut i = self.sidebar_cursor as isize; + loop { + i += delta; + if i < 0 || i >= n as isize { + return; // off the end — leave selection unchanged + } + if self.sidebar[i as usize].selectable() { + break; + } + } + self.sidebar_cursor = i as usize; + self.task_cursor = 0; + self.reload(); + } + + /// Move the task cursor by `delta`, clamped, refreshing the preview. + pub fn move_task(&mut self, delta: isize) { + if self.tasks.is_empty() { + return; + } + let max = self.tasks.len() as isize - 1; + let i = (self.task_cursor as isize + delta).clamp(0, max); + self.task_cursor = i as usize; + self.reload_preview(); + } + + pub fn focus_sidebar(&mut self) { + self.focus = Focus::Sidebar; + } + + pub fn focus_tasks(&mut self) { + if !self.tasks.is_empty() { + self.focus = Focus::Tasks; + } + } + + pub fn toggle_focus(&mut self) { + self.focus = match self.focus { + Focus::Sidebar => Focus::Tasks, + Focus::Tasks => Focus::Sidebar, + }; + } + + /// Run `f` against the backend; any error lands in the status line. Used by + /// mutation gestures (T2). + pub fn try_mutate(&mut self, f: impl FnOnce(&mut B) -> Result<()>) { + if let Err(e) = f(&mut self.backend) { + self.status = format!("error: {e}"); + } + } +} diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs new file mode 100644 index 0000000..223e9b2 --- /dev/null +++ b/crates/heph-tui/src/backend.rs @@ -0,0 +1,159 @@ +//! The data seam between the TUI and the daemon (tech-spec §8.1). +//! +//! [`Backend`] is the small set of reads/writes the agenda needs; the App is +//! generic over it, so navigation/triage logic is unit-testable against a fake +//! and the real surface ([`ClientBackend`]) just forwards to the `hephd` unix +//! socket — the TUI never touches SQLite, same as `heph.nvim`. + +use anyhow::{Context, Result}; +use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch}; +use hephd::Client; +use serde_json::{json, Value}; + +/// A project node, as the sidebar lists it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Project { + pub id: String, + pub title: String, +} + +/// Everything the agenda surface asks of the daemon. +pub trait Backend { + /// All project nodes (for the sidebar), title-sorted. + fn projects(&mut self) -> Result>; + /// Run a built-in filter view (`tom|ondeck|chores|work|tasks`, §8.2). + fn view(&mut self, name: &str) -> Result>; + /// Run a raw [`ListFilter`] (used for per-project scope). + fn list(&mut self, filter: &ListFilter) -> Result>; + /// A node's markdown body (the canonical-context doc preview). Empty if the + /// node is bodiless or missing. + fn node_body(&mut self, id: &str) -> Result; + /// The last `n` log lines for a task (the resumption breadcrumb). + fn log_tail(&mut self, task_id: &str, n: usize) -> Result>; + + // --- triage mutations (T2) --- + + /// Set a task's lifecycle state (`done` rolls a recurring task forward). + fn set_state(&mut self, task_id: &str, state: &str) -> Result<()>; + /// Skip a recurring task to its next occurrence (no completion logged). + fn skip(&mut self, task_id: &str) -> Result<()>; + /// Set a task's attention band. + fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>; + /// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option. + fn set_schedule(&mut self, task_id: &str, patch: SchedulePatch) -> Result<()>; + /// Capture a committed task; returns its node id. + fn create_task( + &mut self, + title: &str, + attention: Option, + do_date: Option, + project_id: Option<&str>, + ) -> Result; +} + +/// The real backend: a thin client of the `hephd` unix socket. +pub struct ClientBackend { + client: Client, +} + +impl ClientBackend { + pub fn new(client: Client) -> Self { + Self { client } + } + + fn call(&mut self, method: &str, params: Value) -> Result { + self.client + .call(method, params) + .with_context(|| format!("rpc {method}")) + } +} + +impl Backend for ClientBackend { + fn projects(&mut self) -> Result> { + let v = self.call("node.list", json!({ "kind": "project" }))?; + let nodes: Vec = serde_json::from_value(v)?; + let mut projects: Vec = nodes + .into_iter() + .map(|n| Project { + id: n.id, + title: n.title, + }) + .collect(); + projects.sort_by(|a, b| a.title.cmp(&b.title)); + Ok(projects) + } + + fn view(&mut self, name: &str) -> Result> { + let v = self.call("view", json!({ "name": name }))?; + Ok(serde_json::from_value(v)?) + } + + fn list(&mut self, filter: &ListFilter) -> Result> { + let v = self.call("list", json!(filter))?; + Ok(serde_json::from_value(v)?) + } + + fn node_body(&mut self, id: &str) -> Result { + let v = self.call("node.get", json!({ "id": id }))?; + if v.is_null() { + return Ok(String::new()); + } + let node: heph_core::Node = serde_json::from_value(v)?; + Ok(node.body.unwrap_or_default()) + } + + fn log_tail(&mut self, task_id: &str, n: usize) -> Result> { + let v = self.call("log.tail", json!({ "task_id": task_id, "n": n }))?; + Ok(serde_json::from_value(v)?) + } + + fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> { + self.call("task.set_state", json!({ "id": task_id, "state": state }))?; + Ok(()) + } + + fn skip(&mut self, task_id: &str) -> Result<()> { + self.call("task.skip", json!({ "id": task_id }))?; + Ok(()) + } + + fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()> { + self.call( + "task.set_attention", + json!({ "id": task_id, "attention": attention }), + )?; + Ok(()) + } + + fn set_schedule(&mut self, task_id: &str, patch: SchedulePatch) -> Result<()> { + let mut params = json!({ "id": task_id }); + let p = serde_json::to_value(&patch)?; + if let Value::Object(map) = p { + for (k, val) in map { + params[k] = val; + } + } + self.call("task.set_schedule", params)?; + Ok(()) + } + + fn create_task( + &mut self, + title: &str, + attention: Option, + do_date: Option, + project_id: Option<&str>, + ) -> Result { + let v = self.call( + "task.create", + json!({ + "title": title, + "attention": attention, + "do_date": do_date, + "project_id": project_id, + }), + )?; + let task: heph_core::Task = serde_json::from_value(v)?; + Ok(task.node_id) + } +} diff --git a/crates/heph-tui/src/fmt.rs b/crates/heph-tui/src/fmt.rs new file mode 100644 index 0000000..439c979 --- /dev/null +++ b/crates/heph-tui/src/fmt.rs @@ -0,0 +1,52 @@ +//! Small display helpers (compact human dates for task rows). The UI layer may +//! read the wall clock (unlike `heph-core`, which is clock-injected). + +use chrono::{DateTime, Datelike, Local, NaiveDate}; + +/// Format an epoch-ms do/late date relative to `today`: `today`, `tomorrow`, +/// `yesterday`, `MM-DD` within the year, else `YYYY-MM-DD`. +pub fn fmt_date(ms: i64, today: NaiveDate) -> String { + let Some(dt) = DateTime::from_timestamp_millis(ms) else { + return "?".into(); + }; + let date = dt.with_timezone(&Local).date_naive(); + match (date - today).num_days() { + 0 => "today".into(), + 1 => "tomorrow".into(), + -1 => "yesterday".into(), + _ if date.year() == today.year() => format!("{:02}-{:02}", date.month(), date.day()), + _ => date.format("%Y-%m-%d").to_string(), + } +} + +/// Today in the local timezone (the reference for [`fmt_date`]). +pub fn today_local() -> NaiveDate { + Local::now().date_naive() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn day(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + fn ms(date: NaiveDate) -> i64 { + date.and_hms_opt(12, 0, 0) + .unwrap() + .and_local_timezone(Local) + .unwrap() + .timestamp_millis() + } + + #[test] + fn relative_and_absolute_dates() { + let today = day(2026, 6, 3); + assert_eq!(fmt_date(ms(today), today), "today"); + assert_eq!(fmt_date(ms(day(2026, 6, 4)), today), "tomorrow"); + assert_eq!(fmt_date(ms(day(2026, 6, 2)), today), "yesterday"); + assert_eq!(fmt_date(ms(day(2026, 12, 25)), today), "12-25"); + assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01"); + } +} diff --git a/crates/heph-tui/src/lib.rs b/crates/heph-tui/src/lib.rs new file mode 100644 index 0000000..17bb71d --- /dev/null +++ b/crates/heph-tui/src/lib.rs @@ -0,0 +1,15 @@ +//! `heph-tui` — the Hephaestus terminal agenda/triage surface (tech-spec §8.1). +//! +//! A thin client of the local `hephd` unix socket (it never touches SQLite, +//! same as `heph.nvim`). The [`app::App`] holds all state and is generic over a +//! [`backend::Backend`] so its navigation/triage logic is unit-testable without +//! a terminal; [`ui::render`] draws the 3-pane layout; `main.rs` owns the +//! terminal and the event loop. + +pub mod app; +pub mod backend; +pub mod fmt; +pub mod ui; + +pub use app::{App, Focus}; +pub use backend::{Backend, ClientBackend, Project}; diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs new file mode 100644 index 0000000..1cc1827 --- /dev/null +++ b/crates/heph-tui/src/main.rs @@ -0,0 +1,95 @@ +//! `heph-tui` binary: terminal lifecycle + event loop. All state/logic lives in +//! the library ([`heph_tui::App`] + [`heph_tui::ui`]); this file is the I/O shell. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::Parser; +use heph_tui::{app::App, backend::ClientBackend, ui, Focus}; +use hephd::Client; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +#[derive(Parser)] +#[command(name = "heph-tui", about = "Hephaestus task agenda / triage TUI")] +struct Cli { + /// Path to the hephd unix socket. Falls back to $HEPH_SOCKET, then the + /// standard runtime path. + #[arg(long)] + socket: Option, +} + +fn resolve_socket(flag: Option) -> PathBuf { + flag.or_else(|| std::env::var_os("HEPH_SOCKET").map(PathBuf::from)) + .unwrap_or_else(hephd::default_socket_path) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let socket = resolve_socket(cli.socket); + + let client = Client::connect(&socket).with_context(|| { + format!( + "could not connect to hephd at {} — is it running? (try: heph daemon start)", + socket.display() + ) + })?; + let app = App::new(ClientBackend::new(client)).context("loading the agenda")?; + + let mut terminal = ratatui::init(); + let result = run(&mut terminal, app); + ratatui::restore(); + result +} + +fn run( + terminal: &mut ratatui::DefaultTerminal, + mut app: App, +) -> Result<()> { + loop { + terminal.draw(|f| ui::render(f, &app))?; + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + handle_key(&mut app, key); + } + } + if app.should_quit { + return Ok(()); + } + } +} + +/// Translate a key press into an [`App`] action (T1: navigation only). +fn handle_key(app: &mut App, key: KeyEvent) { + // Any keypress clears a stale status message. + app.status.clear(); + + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + app.should_quit = true; + return; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, + KeyCode::Char('r') => app.reload(), + KeyCode::Tab => app.toggle_focus(), + KeyCode::Char('j') | KeyCode::Down => move_down(app), + KeyCode::Char('k') | KeyCode::Up => move_up(app), + KeyCode::Char('h') | KeyCode::Left => app.focus_sidebar(), + KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => app.focus_tasks(), + _ => {} + } +} + +fn move_down(app: &mut App) { + match app.focus { + Focus::Sidebar => app.move_sidebar(1), + Focus::Tasks => app.move_task(1), + } +} + +fn move_up(app: &mut App) { + match app.focus { + Focus::Sidebar => app.move_sidebar(-1), + Focus::Tasks => app.move_task(-1), + } +} diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs new file mode 100644 index 0000000..6a8e799 --- /dev/null +++ b/crates/heph-tui/src/ui.rs @@ -0,0 +1,216 @@ +//! Rendering — the 3-pane agenda (sidebar · task list · preview) + status line +//! (tech-spec §8.1). Pure: it reads [`App`] and draws; no I/O, no mutation. + +use heph_core::Attention; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + Frame, +}; + +use crate::app::{App, Focus, SidebarEntry}; +use crate::backend::Backend; +use crate::fmt::{fmt_date, today_local}; + +const HINTS: &str = " j/k move Tab/h/l pane Enter open r refresh q quit"; + +/// Draw the whole UI for the current frame. +pub fn render(frame: &mut Frame, app: &App) { + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(1)]) + .split(frame.area()); + + let panes = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(22), + Constraint::Min(28), + Constraint::Length(38), + ]) + .split(outer[0]); + + render_sidebar(frame, app, panes[0]); + render_tasks(frame, app, panes[1]); + render_preview(frame, app, panes[2]); + render_status(frame, app, outer[1]); +} + +fn pane_border(focused: bool) -> Style { + if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + } +} + +fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) { + let focused = app.focus == Focus::Sidebar; + let items: Vec = app + .sidebar + .iter() + .enumerate() + .map(|(i, entry)| { + let selected = i == app.sidebar_cursor; + match entry { + SidebarEntry::Header(h) => ListItem::new(Line::from(Span::styled( + h.clone(), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ))), + SidebarEntry::View { title, .. } | SidebarEntry::Project { title, .. } => { + let mut style = Style::default(); + if selected { + style = if focused { + style.fg(Color::Black).bg(Color::Cyan) + } else { + style.add_modifier(Modifier::REVERSED) + }; + } + ListItem::new(Line::from(Span::styled(format!(" {title}"), style))) + } + } + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(pane_border(focused)) + .title(" Views "), + ); + frame.render_widget(list, area); +} + +fn attention_style(a: Option) -> (char, Style) { + match a { + Some(Attention::Red) => ('●', Style::default().fg(Color::Red)), + Some(Attention::Orange) => ('●', Style::default().fg(Color::Yellow)), + Some(Attention::White) => ('○', Style::default().fg(Color::White)), + Some(Attention::Blue) => ('·', Style::default().fg(Color::Blue)), + None => ('·', Style::default().fg(Color::DarkGray)), + } +} + +fn render_tasks(frame: &mut Frame, app: &App, area: Rect) { + let focused = app.focus == Focus::Tasks; + let today = today_local(); + let width = area.width.saturating_sub(2) as usize; // inside borders + + let items: Vec = if app.tasks.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " (nothing here)", + Style::default().fg(Color::DarkGray), + )))] + } else { + app.tasks + .iter() + .enumerate() + .map(|(i, t)| { + let (glyph, gstyle) = attention_style(t.attention); + // Right-aligned date chip (late > do). + let (chip, chip_style) = if let Some(late) = t + .late_on + .filter(|l| chrono::Local::now().timestamp_millis() > *l) + { + ( + format!("late:{}", fmt_date(late, today)), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ) + } else if let Some(do_date) = t.do_date { + ( + format!("do:{}", fmt_date(do_date, today)), + Style::default().fg(Color::DarkGray), + ) + } else { + (String::new(), Style::default()) + }; + + let selected = i == app.task_cursor; + let title_style = if selected && focused { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let cursor = if selected { "▌" } else { " " }; + + // Pad the title so the chip sits at the right edge. + let chip_w = chip.len(); + let fixed = 1 + 2; // cursor + glyph + space + let avail = width.saturating_sub(fixed + chip_w + 1); + let mut title: String = t.title.chars().take(avail).collect(); + let pad = avail.saturating_sub(title.chars().count()); + title.push_str(&" ".repeat(pad)); + + ListItem::new(Line::from(vec![ + Span::styled(cursor, Style::default().fg(Color::Cyan)), + Span::styled(format!("{glyph} "), gstyle), + Span::styled(title, title_style), + Span::raw(" "), + Span::styled(chip, chip_style), + ])) + }) + .collect() + }; + + let title = format!(" {} ({}) ", app.task_pane_title(), app.tasks.len()); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(pane_border(focused)) + .title(title), + ); + frame.render_widget(list, area); +} + +fn render_preview(frame: &mut Frame, app: &App, area: Rect) { + let mut lines: Vec = Vec::new(); + if !app.preview.title.is_empty() { + lines.push(Line::from(Span::styled( + app.preview.title.clone(), + Style::default().add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + } + for l in &app.preview.body { + lines.push(Line::from(l.clone())); + } + if !app.preview.log.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "─ log ─", + Style::default().fg(Color::DarkGray), + ))); + for l in &app.preview.log { + lines.push(Line::from(Span::styled( + l.clone(), + Style::default().fg(Color::DarkGray), + ))); + } + } + + let para = Paragraph::new(lines).wrap(Wrap { trim: false }).block( + Block::default() + .borders(Borders::ALL) + .border_style(pane_border(false)) + .title(" Preview "), + ); + frame.render_widget(para, area); +} + +fn render_status(frame: &mut Frame, app: &App, area: Rect) { + let text = if app.status.is_empty() { + HINTS.to_string() + } else { + format!(" {}", app.status) + }; + let style = if app.status.starts_with("error") { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }; + frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area); +} diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs new file mode 100644 index 0000000..c0197bf --- /dev/null +++ b/crates/heph-tui/tests/agenda.rs @@ -0,0 +1,134 @@ +//! Headless render test (tech-spec §8.1/§9): a real `hephd` over a real unix +//! socket against a temp DB, driven by the TUI's `ClientBackend`, rendered to +//! ratatui's `TestBackend` — asserting the agenda actually paints seeded data. + +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use heph_core::{FixedClock, LocalStore}; +use heph_tui::{app::App, backend::ClientBackend}; +use hephd::{Client, Daemon}; +use ratatui::{backend::TestBackend, Terminal}; +use serde_json::json; +use tokio::net::UnixListener; + +const NOW: i64 = 1_717_400_000_000; // ~2024-06-03 + +fn spawn_daemon() -> (PathBuf, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("heph.db"); + let socket = dir.path().join("d.sock"); + + let store = LocalStore::open(&db, Box::new(FixedClock(NOW))).unwrap(); + let socket_for_thread = socket.clone(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = UnixListener::bind(&socket_for_thread).unwrap(); + let _ = Daemon::new(store).serve(listener).await; + }); + }); + for _ in 0..200 { + if socket.exists() { + break; + } + thread::sleep(Duration::from_millis(5)); + } + (socket, dir) +} + +fn client(socket: &Path) -> Client { + Client::connect(socket).unwrap() +} + +/// Render the app to a fixed-size TestBackend and return the screen as text. +fn screen(app: &App) -> String { + let mut terminal = Terminal::new(TestBackend::new(110, 24)).unwrap(); + terminal.draw(|f| heph_tui::ui::render(f, app)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out +} + +#[test] +fn agenda_renders_views_projects_and_tasks() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + // Seed: a red task (Top of Mind) and a blue one (On Deck), plus a project. + c.call( + "task.create", + json!({ "title": "Pay the water bill", "attention": "red" }), + ) + .unwrap(); + c.call( + "task.create", + json!({ "title": "Someday backlog item", "attention": "blue" }), + ) + .unwrap(); + c.call( + "node.create", + json!({ "kind": "project", "title": "Camano" }), + ) + .unwrap(); + + let app = App::new(ClientBackend::new(client(&socket))).unwrap(); + let s = screen(&app); + + // Sidebar shows the built-in views and the project. + assert!(s.contains("Top of Mind"), "missing view in sidebar:\n{s}"); + assert!(s.contains("On Deck"), "missing On Deck view:\n{s}"); + assert!(s.contains("Camano"), "missing project in sidebar:\n{s}"); + // The default selection (Top of Mind) lists the red task, not the blue one. + assert!(s.contains("Pay the water bill"), "red task missing:\n{s}"); + assert!( + !s.contains("Someday backlog item"), + "blue task should not be in Top of Mind:\n{s}" + ); + assert!(s.contains("Preview"), "preview pane missing:\n{s}"); +} + +#[test] +fn preview_shows_the_selected_tasks_context_body() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let task = c + .call( + "task.create", + json!({ "title": "Renew passport", "attention": "orange" }), + ) + .unwrap(); + // Find the canonical-context doc and give it a body. + let links = c + .call("links.outgoing", json!({ "id": task["node_id"] })) + .unwrap(); + let ctx = links + .as_array() + .unwrap() + .iter() + .find(|l| l["link_type"] == "canonical-context") + .unwrap()["dst_id"] + .as_str() + .unwrap() + .to_string(); + c.call( + "node.update", + json!({ "id": ctx, "body": "- [ ] fill form\n- [ ] passport photo" }), + ) + .unwrap(); + + let app = App::new(ClientBackend::new(client(&socket))).unwrap(); + let s = screen(&app); + assert!(s.contains("fill form"), "context body not previewed:\n{s}"); +} diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs new file mode 100644 index 0000000..10c2f90 --- /dev/null +++ b/crates/heph-tui/tests/navigation.rs @@ -0,0 +1,149 @@ +//! Navigation/selection logic against an in-memory fake backend — no terminal, +//! no daemon. Asserts the App's cursor + reload behavior (tech-spec §8.1). + +use std::collections::HashMap; + +use anyhow::Result; +use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch, TaskState}; +use heph_tui::{ + app::{App, Focus}, + backend::{Backend, Project}, +}; + +fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask { + RankedTask { + node_id: id.into(), + title: title.into(), + attention: Some(attention), + do_date: None, + late_on: None, + state: TaskState::Outstanding, + tombstoned: false, + project_id: None, + canonical_context_id: ctx.map(str::to_string), + created_at: 0, + } +} + +#[derive(Default)] +struct Fake { + views: HashMap>, + projects: Vec, + by_project: HashMap>, + bodies: HashMap, +} + +impl Backend for Fake { + fn projects(&mut self) -> Result> { + Ok(self.projects.clone()) + } + fn view(&mut self, name: &str) -> Result> { + Ok(self.views.get(name).cloned().unwrap_or_default()) + } + fn list(&mut self, filter: &ListFilter) -> Result> { + let id = filter.scope.first().cloned().unwrap_or_default(); + Ok(self.by_project.get(&id).cloned().unwrap_or_default()) + } + fn node_body(&mut self, id: &str) -> Result { + Ok(self.bodies.get(id).cloned().unwrap_or_default()) + } + fn log_tail(&mut self, _task_id: &str, _n: usize) -> Result> { + Ok(Vec::new()) + } + fn set_state(&mut self, _t: &str, _s: &str) -> Result<()> { + Ok(()) + } + fn skip(&mut self, _t: &str) -> Result<()> { + Ok(()) + } + fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> { + Ok(()) + } + fn set_schedule(&mut self, _t: &str, _p: SchedulePatch) -> Result<()> { + Ok(()) + } + fn create_task( + &mut self, + _title: &str, + _a: Option, + _d: Option, + _p: Option<&str>, + ) -> Result { + Ok("new".into()) + } +} + +fn fixture() -> Fake { + let mut f = Fake::default(); + f.views.insert( + "tom".into(), + vec![ + task("t1", "red one", Attention::Red, Some("c1")), + task("t2", "orange two", Attention::Orange, Some("c2")), + ], + ); + f.views.insert( + "ondeck".into(), + vec![task("b1", "blue one", Attention::Blue, None)], + ); + f.projects.push(Project { + id: "p1".into(), + title: "Camano".into(), + }); + f.by_project.insert( + "p1".into(), + vec![task("pt", "project task", Attention::White, None)], + ); + f.bodies.insert("c1".into(), "body of red one".into()); + f.bodies.insert("c2".into(), "body of orange two".into()); + f +} + +#[test] +fn starts_on_the_first_view_with_its_tasks() { + let app = App::new(fixture()).unwrap(); + assert_eq!(app.task_pane_title(), "Top of Mind"); + assert_eq!(app.tasks.len(), 2); + assert_eq!(app.selected_task().unwrap().title, "red one"); + assert_eq!(app.preview.body, vec!["body of red one"]); + assert_eq!(app.focus, Focus::Sidebar); +} + +#[test] +fn moving_the_sidebar_switches_the_task_list() { + let mut app = App::new(fixture()).unwrap(); + app.move_sidebar(1); // Top of Mind -> On Deck + assert_eq!(app.task_pane_title(), "On Deck"); + assert_eq!(app.tasks.len(), 1); + assert_eq!(app.selected_task().unwrap().title, "blue one"); +} + +#[test] +fn sidebar_skips_headers_into_the_projects_section() { + let mut app = App::new(fixture()).unwrap(); + // 5 built-in views: step down 5 times crosses the "Projects" header to p1. + for _ in 0..5 { + app.move_sidebar(1); + } + assert_eq!(app.task_pane_title(), "Camano"); + assert_eq!(app.selected_task().unwrap().title, "project task"); +} + +#[test] +fn moving_the_task_cursor_updates_the_preview() { + let mut app = App::new(fixture()).unwrap(); + app.focus_tasks(); + assert_eq!(app.focus, Focus::Tasks); + app.move_task(1); + assert_eq!(app.selected_task().unwrap().title, "orange two"); + assert_eq!(app.preview.body, vec!["body of orange two"]); +} + +#[test] +fn move_task_clamps_at_the_ends() { + let mut app = App::new(fixture()).unwrap(); + app.move_task(-5); // already at top + assert_eq!(app.task_cursor, 0); + app.move_task(50); // past the end + assert_eq!(app.task_cursor, 1); +}