generated from eblume/project-template
feat(tui): attention flag column + project-colored bullets + scrollbar (§8.1)
Each task row now leads with a colored attention flag (⚑ for red/orange/blue, blank for white/none) and a project-colored bullet (●). The bullet color is derived stably from the project id (FNV-1a → HSL → truecolor RGB) so it survives projects being added/removed; a per-project override on the model is a later refinement. The glyph shape is reserved for future semantics. The task list also gains a scrollbar and ListState-driven scroll-to-visible so a selected task below the fold stays reachable. Tests: fmt::project_color determinism unit; a flag-glyph render assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
288e902573
commit
ecfe64435c
5 changed files with 116 additions and 22 deletions
|
|
@ -2,6 +2,7 @@
|
|||
//! read the wall clock (unlike `heph-core`, which is clock-injected).
|
||||
|
||||
use chrono::{DateTime, Datelike, Local, NaiveDate};
|
||||
use ratatui::style::Color;
|
||||
|
||||
/// Format an epoch-ms do/late date relative to `today`: `today`, `tomorrow`,
|
||||
/// `yesterday`, `MM-DD` within the year, else `YYYY-MM-DD`.
|
||||
|
|
@ -24,6 +25,46 @@ pub fn today_local() -> NaiveDate {
|
|||
Local::now().date_naive()
|
||||
}
|
||||
|
||||
/// A stable display color for a project, derived from its node id (§8.1) so the
|
||||
/// task list's bullets read as project identity. Hashing the id (rather than a
|
||||
/// position-based palette) keeps each project's color **stable as others are
|
||||
/// added or removed**, trading perfect spread for occasional near-collisions —
|
||||
/// acceptable per the design. `None` (no project) is a neutral gray. A future
|
||||
/// per-project override stored on the model would take precedence over this.
|
||||
pub fn project_color(project_id: Option<&str>) -> Color {
|
||||
let Some(id) = project_id else {
|
||||
return Color::DarkGray;
|
||||
};
|
||||
// FNV-1a over the id → a hue in [0,360); fixed saturation/lightness tuned to
|
||||
// stay legible on a dark terminal background.
|
||||
let mut h: u32 = 0x811c_9dc5;
|
||||
for b in id.bytes() {
|
||||
h ^= b as u32;
|
||||
h = h.wrapping_mul(0x0100_0193);
|
||||
}
|
||||
let hue = (h as f64 / u32::MAX as f64) * 360.0;
|
||||
let (r, g, b) = hsl_to_rgb(hue, 0.55, 0.65);
|
||||
Color::Rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// HSL (hue 0–360, saturation/lightness 0–1) → 8-bit RGB.
|
||||
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let hp = h / 60.0;
|
||||
let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
|
||||
let (r, g, b) = match hp as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = l - c / 2.0;
|
||||
let to = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
(to(r), to(g), to(b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -49,4 +90,21 @@ mod tests {
|
|||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_color_is_stable_distinct_and_neutral_when_absent() {
|
||||
assert_eq!(project_color(None), Color::DarkGray);
|
||||
// Deterministic: the same id always maps to the same color.
|
||||
assert_eq!(
|
||||
project_color(Some("01J_chores")),
|
||||
project_color(Some("01J_chores"))
|
||||
);
|
||||
// Distinct ids generally differ (these two do).
|
||||
assert_ne!(
|
||||
project_color(Some("01J_chores")),
|
||||
project_color(Some("01J_garden"))
|
||||
);
|
||||
// A project id resolves to a concrete RGB (not a named palette slot).
|
||||
assert!(matches!(project_color(Some("01J_work")), Color::Rgb(..)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,19 @@
|
|||
|
||||
use heph_core::Attention;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
layout::{Constraint, Direction, Layout, Margin, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||
widgets::{
|
||||
Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar,
|
||||
ScrollbarOrientation, ScrollbarState, Wrap,
|
||||
},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::{App, Focus, InputState, Mode, MoveState, SidebarEntry};
|
||||
use crate::backend::Backend;
|
||||
use crate::fmt::{fmt_date, today_local};
|
||||
use crate::fmt::{fmt_date, project_color, today_local};
|
||||
|
||||
const HINTS: &str =
|
||||
" j/k move a add x done e date A attn b→blue D del o edit / search q quit";
|
||||
|
|
@ -157,13 +160,15 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
fn attention_style(a: Option<Attention>) -> (char, Style) {
|
||||
/// The leading flag glyph + color for an attention band: a colored `⚑` for the
|
||||
/// bands that demand attention (red/orange/blue), blank for white/none. The
|
||||
/// bullet beside it carries project identity instead (§8.1).
|
||||
fn flag_style(a: Option<Attention>) -> (&'static str, 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)),
|
||||
Some(Attention::Red) => ("⚑", Style::default().fg(Color::Red)),
|
||||
Some(Attention::Orange) => ("⚑", Style::default().fg(Color::Yellow)),
|
||||
Some(Attention::Blue) => ("⚑", Style::default().fg(Color::Blue)),
|
||||
Some(Attention::White) | None => (" ", Style::default()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -214,7 +219,8 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let (glyph, gstyle) = attention_style(t.attention);
|
||||
let (flag, flag_st) = flag_style(t.attention);
|
||||
let bullet_st = Style::default().fg(project_color(t.project_id.as_deref()));
|
||||
// Right-aligned date chip (late > do).
|
||||
let (chip, chip_style) = if let Some(late) = t
|
||||
.late_on
|
||||
|
|
@ -245,7 +251,7 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
// Pad the title so the right side (↻ + date chip) aligns right.
|
||||
let chip_w = chip.len();
|
||||
let recur_w = if recur { 2 } else { 0 }; // "↻ "
|
||||
let fixed = 1 + 2 + 1; // cursor + "glyph " + trailing space
|
||||
let fixed = 1 + 1 + 1 + 1 + 1; // cursor + flag + bullet + space + trailing space
|
||||
let avail = width.saturating_sub(fixed + recur_w + chip_w);
|
||||
let mut title: String = t.title.chars().take(avail).collect();
|
||||
let pad = avail.saturating_sub(title.chars().count());
|
||||
|
|
@ -253,7 +259,9 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
|
||||
let mut header = vec![
|
||||
Span::styled(cursor, Style::default().fg(Color::Cyan)),
|
||||
Span::styled(format!("{glyph} "), gstyle),
|
||||
Span::styled(flag, flag_st),
|
||||
Span::styled("●", bullet_st),
|
||||
Span::raw(" "),
|
||||
Span::styled(title, title_style),
|
||||
Span::raw(" "),
|
||||
];
|
||||
|
|
@ -278,7 +286,32 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
.border_style(pane_border(focused))
|
||||
.title(title),
|
||||
);
|
||||
frame.render_widget(list, area);
|
||||
// A `ListState` selection drives scroll-to-visible so a task below the fold
|
||||
// stays reachable; the row's own REVERSED styling remains the highlight.
|
||||
let mut state = ListState::default();
|
||||
if !app.tasks.is_empty() {
|
||||
state.select(Some(app.task_cursor));
|
||||
}
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
|
||||
// A scrollbar appears once the list can't show every task at once. Position
|
||||
// tracks the selected row (item-indexed — an approximation with the inline
|
||||
// detail block expanded, but a faithful "where am I in the list" signal).
|
||||
let inner_h = area.height.saturating_sub(2) as usize;
|
||||
if app.tasks.len() > inner_h {
|
||||
let mut sb = ScrollbarState::new(app.tasks.len()).position(app.task_cursor);
|
||||
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(None)
|
||||
.end_symbol(None);
|
||||
frame.render_stateful_widget(
|
||||
bar,
|
||||
area.inner(Margin {
|
||||
vertical: 1,
|
||||
horizontal: 0,
|
||||
}),
|
||||
&mut sb,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_search<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ fn agenda_renders_views_projects_and_tasks() {
|
|||
!s.contains("Someday backlog item"),
|
||||
"blue task should not be in Top of Mind:\n{s}"
|
||||
);
|
||||
// The red/orange tasks carry a flag glyph in the leading column (§8.1).
|
||||
assert!(s.contains('⚑'), "attention flag glyph missing:\n{s}");
|
||||
assert!(s.contains("Preview"), "preview pane missing:\n{s}");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,3 +25,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
|
|||
- Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view <name>` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view <name>` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates).
|
||||
- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim.
|
||||
- Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. In `heph-tui`, **`m`** opens a list-pick overlay ("(Unfile)" then every project) on the highlighted task. `heph edit <task> --project <name>` now routes through the same RPC (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task. This closes the last Todoist-parity capture gap.
|
||||
- `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit.
|
||||
|
|
|
|||
|
|
@ -253,16 +253,16 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba
|
|||
> **Status: daily-driver core built** (slices T1–T2c; NL quick-add + search are the remaining T3 polish). The §6.2.1 Todoist study shows the dominant task activity is *interactive triage of a large set* (387 active tasks; daily orange reconfirm, blue keep/drop review, browse-by-project) — work that is awkward as either CLI flags or nvim buffers. A terminal UI owns it; the CLI (capture/scripting) and nvim (context) flank it.
|
||||
|
||||
- **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure.
|
||||
- **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (attention-colored rows with compact human do/late) · **preview** (canonical-context doc body + `log.tail`).
|
||||
- **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (each row: a leading attention **flag** + a **project-colored bullet**, the title, recurrence `↻`, and a compact human do/late chip; a scrollbar appears when the list overflows) · **preview** (canonical-context doc body + `log.tail`).
|
||||
- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `m` **move-to-project** (a list-pick overlay — "(Unfile)" then every project; backed by `task.set_project`) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: humanizing the displayed RRULE is later polish.)*
|
||||
- **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)*
|
||||
- **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend.
|
||||
- **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`.
|
||||
- **`m` move-to-project** ✅ — re-file the selected task via the `task.set_project` RPC (a list-pick overlay), closing the last Todoist-parity gap.
|
||||
- **Planned UX wave** (§14 roadmap, 2026-06-03) — all client-side over the existing `RankedTask` rows:
|
||||
- **flag column + project-colored bullets** — a leading **flag glyph** colored by attention (red/orange/blue; **blank for white**, freeing the bullet for project identity); the **bullet `●` colored by its project** from a stable `hash(project_id) → hue` (HSL→truecolor, 256-color fallback; overlap acceptable; the glyph *shape* is reserved for future semantics). A stored, editable per-project color override is a later refinement.
|
||||
- **flag column + project-colored bullets** ✅ — a leading **flag glyph** (`⚑`) colored by attention (red/orange/blue; **blank for white/none**, freeing the bullet for project identity); the **bullet `●` colored by its project** from a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb` truecolor; overlap acceptable; the glyph *shape* is reserved for future semantics). A stored, editable per-project color override is a later refinement (derived client-side for now).
|
||||
- **scrollbar** ✅ — a `ratatui` `Scrollbar` on the task list once it overflows (a `ListState` selection drives scroll-to-visible so a task below the fold stays reachable).
|
||||
- **Planned UX wave** (§14 roadmap, 2026-06-03) — remaining, client-side over the existing `RankedTask` rows:
|
||||
- **sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (desc; no-date = 0) → project name → `created_at` (FIFO); **project mode**: project primary with **non-selectable `──── Name ────` separators** (`j`/`k` skip them). The view filter always runs before the sort.
|
||||
- **scrollbar** — a `ratatui` `Scrollbar` on the task list when it overflows.
|
||||
|
||||
## 8.2 Filter views (saved agenda slices) — built
|
||||
|
||||
|
|
@ -447,11 +447,11 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi
|
|||
> The remaining work is the **UX roadmap agreed 2026-06-03** (design conversation with the owner). It is documented docs-first — the bigger items have design sections above (`heph-tui` UX in §8.1, frontmatter in §8.3, wiki-links-by-id in §8.4) — and built in this order:
|
||||
|
||||
1. ✅ **`heph-tui` — move-to-project (§8.1) — DONE:** the **`task.set_project` RPC** (tombstone the old `in-project` link + add the new — OR-set semantics, no task-scalar op; given project must be a live project-kind node) plus the TUI `m` list-pick overlay and `heph edit --project <name>|none` (which also fixed a duplicate-link bug in the old re-file path). Unblocks the project-edit path of the frontmatter surface (§8.3).
|
||||
2. ⏳ **`heph-tui` task-list UX wave (§8.1) — no backend (`RankedTask` already carries every field):**
|
||||
- **(a) flag column + project-colored bullets** — a leading **flag glyph** colored by attention (red/orange/blue; **blank for white**), and the **bullet `●` colored by its project** via a stable `hash(project_id) → hue` (HSL→truecolor `Color::Rgb`, 256-color fallback). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change).
|
||||
- **(b) sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with **non-selectable `──── Name ────` separator rows** between groups (`j`/`k` skip them). View filtering always runs **before** the sort.
|
||||
- **(e) scrollbar** — the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows the pane.
|
||||
- **nvim task-navigation polish (§8)** — show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit).
|
||||
2. **`heph-tui` task-list UX wave (§8.1) — no backend (`RankedTask` already carries every field):**
|
||||
- ✅ **(a) flag column + project-colored bullets — DONE:** a leading `⚑` colored by attention (red/orange/blue; blank for white/none), and the bullet `●` colored by its project via a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb`). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change).
|
||||
- ✅ **(e) scrollbar — DONE:** the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows; a `ListState` selection keeps the highlighted row scrolled into view.
|
||||
- ⏳ **(b) sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with **non-selectable `──── Name ────` separator rows** between groups (`j`/`k` skip them). View filtering always runs **before** the sort.
|
||||
- ⏳ **nvim task-navigation polish (§8)** — show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit).
|
||||
3. ⏳ **Tags (§4, §8.3) — promoted from deferred:** `NodeKind::Tag` exists but has no machinery. Add **tag-as-node + an OR-set tag link** (mirroring `in-project`) + `tag.add`/`tag.remove` RPCs + enumeration. Prerequisite for the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import ([[design]]); the goal is **one canonical tag set** across all of heph.
|
||||
4. ⏳ **YAML frontmatter as an edit surface (§8.3) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §8.3.
|
||||
5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue