From 9c932c8d9a898750275accbcdfcff2f86ccbecf2 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 18:20:10 -0700 Subject: [PATCH] =?UTF-8?q?feat(tui):=20fzf-style=20move-to-project=20pick?= =?UTF-8?q?er=20+=20create-project=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `m` move-to-project overlay is now a filterable picker: a prompt line narrows the project list by fuzzy subsequence match as you type (↑/↓ or Ctrl-n/p move, Enter selects, Esc cancels), so there's no scrolling a long list. When the filter names no existing project, a "+ New project" row creates it and files the task there in one step (Backend::create_project → node.create). Tests cover fuzzy narrowing and the create-then-file flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-tui/src/app.rs | 168 ++++++++++++++---- crates/heph-tui/src/backend.rs | 8 + crates/heph-tui/src/main.rs | 12 +- crates/heph-tui/src/ui.rs | 48 +++-- crates/heph-tui/tests/navigation.rs | 68 ++++++- .../changelog.d/v1-tui-move-picker.feature.md | 1 + 6 files changed, 252 insertions(+), 53 deletions(-) create mode 100644 docs/changelog.d/v1-tui-move-picker.feature.md diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index d5549aa..d07d1b4 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -125,23 +125,93 @@ pub struct PendingDelete { pub title: String, } -/// One choice in the move-to-project picker: a project (or `None` = unfile). +/// One choice in the move-to-project picker. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct MoveOption { - pub project_id: Option, - pub label: String, +pub enum MoveOption { + /// Remove the task from any project. + Unfile, + /// File under an existing project. + Project { id: String, title: String }, + /// Create a new project named after the filter text, then file under it. + Create { name: String }, } -/// The move-to-project picker state: which task is being re-filed, the choices -/// (an "(Unfile)" entry then every project), and the highlighted row. +impl MoveOption { + /// The row label as shown in the picker. + pub fn label(&self) -> String { + match self { + MoveOption::Unfile => "(Unfile)".to_string(), + MoveOption::Project { title, .. } => title.clone(), + MoveOption::Create { name } => format!("+ New project \"{name}\""), + } + } +} + +/// The move-to-project picker state: which task is being re-filed, a live filter +/// over the projects, the matching choices, and the highlighted row. The picker +/// is fzf-style — typing narrows the list; a non-matching name offers to create. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MoveState { pub task_id: String, pub task_title: String, + /// All projects, title-sorted — the source the `filter` narrows. + projects: Vec, + /// The live filter query (fzf-style subsequence match). + pub filter: String, + /// The currently visible choices (`(Unfile)` + matching projects + an + /// optional "create" row), recomputed whenever `filter` changes. pub options: Vec, pub cursor: usize, } +impl MoveState { + /// Rebuild `options` from `projects` + `filter`: `(Unfile)` (when it matches + /// or the filter is empty), the fuzzy-matching projects, and — when the text + /// names no existing project — a "create" row. Clamps the cursor. + fn recompute(&mut self) { + let f = self.filter.trim(); + let mut opts = Vec::new(); + if f.is_empty() || fuzzy_match(f, "Unfile") { + opts.push(MoveOption::Unfile); + } + for p in &self.projects { + if f.is_empty() || fuzzy_match(f, &p.title) { + opts.push(MoveOption::Project { + id: p.id.clone(), + title: p.title.clone(), + }); + } + } + if !f.is_empty() && !self.projects.iter().any(|p| p.title.eq_ignore_ascii_case(f)) { + opts.push(MoveOption::Create { name: f.to_string() }); + } + self.options = opts; + self.cursor = self.cursor.min(self.options.len().saturating_sub(1)); + } +} + +/// fzf-style match: every char of `query` appears in `cand` in order, +/// case-insensitively, ignoring whitespace in the query. Empty query matches all. +fn fuzzy_match(query: &str, cand: &str) -> bool { + let cand: Vec = cand.chars().flat_map(char::to_lowercase).collect(); + let mut ci = 0; + 'next: for qc in query + .chars() + .filter(|c| !c.is_whitespace()) + .flat_map(char::to_lowercase) + { + while ci < cand.len() { + let matched = cand[ci] == qc; + ci += 1; + if matched { + continue 'next; + } + } + return false; + } + true +} + /// The attention cycle for the `A` gesture: default → top-of-mind → consequence /// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression. pub fn next_attention(current: Option) -> Attention { @@ -537,42 +607,55 @@ impl App { let Some(t) = self.selected_task().cloned() else { return; }; - let mut options = vec![MoveOption { - project_id: None, - label: "(Unfile)".into(), - }]; - for p in self.project_list() { - options.push(MoveOption { - project_id: Some(p.id), - label: p.title, - }); - } - let cursor = t - .project_id - .as_deref() - .and_then(|pid| { - options - .iter() - .position(|o| o.project_id.as_deref() == Some(pid)) - }) - .unwrap_or(0); - self.mode = Mode::MoveToProject(MoveState { + let mut state = MoveState { task_id: t.node_id, task_title: t.title, - options, - cursor, - }); + projects: self.project_list(), + filter: String::new(), + options: Vec::new(), + cursor: 0, + }; + state.recompute(); + // Start on the task's current project, if it has one. + if let Some(pid) = t.project_id.as_deref() { + if let Some(i) = state.options.iter().position(|o| match o { + MoveOption::Project { id, .. } => id.as_str() == pid, + _ => false, + }) { + state.cursor = i; + } + } + self.mode = Mode::MoveToProject(state); } /// Move the picker cursor by `delta` (clamped). pub fn move_picker_move(&mut self, delta: isize) { if let Mode::MoveToProject(m) = &mut self.mode { let max = m.options.len() as isize - 1; - m.cursor = (m.cursor as isize + delta).clamp(0, max) as usize; + m.cursor = (m.cursor as isize + delta).clamp(0, max.max(0)) as usize; } } - /// Apply the highlighted choice: re-file (or unfile) the task and reload. + /// Append to the picker filter (resets the cursor to the top match). + pub fn move_filter_push(&mut self, c: char) { + if let Mode::MoveToProject(m) = &mut self.mode { + m.filter.push(c); + m.cursor = 0; + m.recompute(); + } + } + + /// Delete the last filter char. + pub fn move_filter_backspace(&mut self) { + if let Mode::MoveToProject(m) = &mut self.mode { + m.filter.pop(); + m.cursor = 0; + m.recompute(); + } + } + + /// Apply the highlighted choice: unfile, re-file, or create-then-file, and + /// reload. pub fn move_picker_submit(&mut self) { let Mode::MoveToProject(m) = &self.mode else { return; @@ -581,11 +664,26 @@ impl App { return; }; let task_id = m.task_id.clone(); - let ok = format!("→ {}: {}", choice.label, m.task_title); + let title = m.task_title.clone(); self.mode = Mode::Normal; - self.mutate(ok, |b| { - b.set_project(&task_id, choice.project_id.as_deref()) - }); + match choice { + MoveOption::Unfile => { + self.mutate(format!("→ (Unfile): {title}"), |b| { + b.set_project(&task_id, None) + }); + } + MoveOption::Project { id, title: pt } => { + self.mutate(format!("→ {pt}: {title}"), move |b| { + b.set_project(&task_id, Some(&id)) + }); + } + MoveOption::Create { name } => { + self.mutate(format!("→ new project \"{name}\": {title}"), move |b| { + let id = b.create_project(&name)?; + b.set_project(&task_id, Some(&id)) + }); + } + } } /// Dismiss the picker without re-filing. diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 03bf5e3..974fa12 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -65,6 +65,8 @@ pub trait Backend { recurrence: Option<&str>, project_id: Option<&str>, ) -> Result; + /// Create a new project node; returns its node id. + fn create_project(&mut self, name: &str) -> Result; } /// The real backend: a thin client of the `hephd` unix socket. @@ -209,4 +211,10 @@ impl Backend for ClientBackend { let task: heph_core::Task = serde_json::from_value(v)?; Ok(task.node_id) } + + fn create_project(&mut self, name: &str) -> Result { + let v = self.call("node.create", json!({ "kind": "project", "title": name }))?; + let node: heph_core::Node = serde_json::from_value(v)?; + Ok(node.id) + } } diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index b0da4e9..930b9ac 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -119,13 +119,19 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option app.move_picker_cancel(), KeyCode::Enter => app.move_picker_submit(), - KeyCode::Char('j') | KeyCode::Down => app.move_picker_move(1), - KeyCode::Char('k') | KeyCode::Up => app.move_picker_move(-1), + KeyCode::Down => app.move_picker_move(1), + KeyCode::Up => app.move_picker_move(-1), + KeyCode::Char('n') if ctrl => app.move_picker_move(1), + KeyCode::Char('p') if ctrl => app.move_picker_move(-1), + KeyCode::Backspace => app.move_filter_backspace(), + KeyCode::Char(c) if !ctrl => app.move_filter_push(c), _ => {} } return None; diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 18c6027..dde3ee4 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -13,7 +13,7 @@ use ratatui::{ Frame, }; -use crate::app::{App, Focus, InputState, Mode, MoveState, SidebarEntry, SortMode}; +use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode}; use crate::backend::Backend; use crate::fmt::{fmt_date, project_color, today_local}; @@ -54,12 +54,15 @@ pub fn render(frame: &mut Frame, app: &App) { } } -/// A centered list popup for re-filing the highlighted task to a project. +/// A centered fzf-style popup for re-filing the highlighted task: a filter line +/// on top, the matching projects (+ an optional "create") below. fn render_move(frame: &mut Frame, state: &MoveState) { let area = frame.area(); - let width = area.width.saturating_sub(8).clamp(24, 50); + let width = area.width.saturating_sub(8).clamp(28, 56); let rows = state.options.len() as u16; - let height = (rows + 2).min(area.height.saturating_sub(2)).max(3); + // input box (3) + list box (rows + 2 borders), bounded to the screen. + let list_h = (rows + 2).clamp(3, area.height.saturating_sub(5).max(3)); + let height = (3 + list_h).min(area.height.saturating_sub(2)); let popup = Rect { x: area.x + (area.width.saturating_sub(width)) / 2, y: area.y + area.height.saturating_sub(height) / 3, @@ -67,25 +70,48 @@ fn render_move(frame: &mut Frame, state: &MoveState) { height, }; frame.render_widget(Clear, popup); + let chunks = + Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(popup); + + // Filter input (with the task being moved in the title). + let input = Paragraph::new(Line::from(vec![ + Span::styled("> ", Style::default().fg(Color::Cyan)), + Span::raw(&state.filter), + Span::styled("▏", Style::default().fg(Color::Cyan)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(format!(" Move \"{}\" to ", state.task_title)), + ); + frame.render_widget(input, chunks[0]); + + // Matching choices. let items: Vec = state .options .iter() .enumerate() .map(|(i, o)| { - let mut style = Style::default(); - if i == state.cursor { - style = style.fg(Color::Black).bg(Color::Cyan); - } - ListItem::new(Line::from(Span::styled(o.label.clone(), style))) + let base = match o { + MoveOption::Create { .. } => Style::default().fg(Color::Green), + _ => Style::default(), + }; + let style = if i == state.cursor { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + base + }; + ListItem::new(Line::from(Span::styled(o.label(), style))) }) .collect(); let list = List::new(items).block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)) - .title(format!(" Move \"{}\" to ", state.task_title)), + .title_bottom(" ↑↓ move · ⏎ select · esc cancel "), ); - frame.render_widget(list, popup); + frame.render_widget(list, chunks[1]); } /// A centered single-line input popup (guided add / reschedule). diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index c79570f..bde0a9b 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -29,6 +29,7 @@ struct Recorder { scheduled: Vec<(String, SchedulePatch)>, tombstoned: Vec, refiled: Vec<(String, Option)>, + created_projects: Vec, } fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask { @@ -127,6 +128,10 @@ impl Backend for Fake { )); Ok("new".into()) } + fn create_project(&mut self, name: &str) -> Result { + self.rec.borrow_mut().created_projects.push(name.into()); + Ok(format!("proj:{name}")) + } } fn fixture() -> Fake { @@ -347,10 +352,7 @@ fn move_to_project_picker_refiles_the_selected_task() { Mode::MoveToProject(m) => { assert_eq!(m.task_id, "t1"); assert_eq!( - m.options - .iter() - .map(|o| o.label.as_str()) - .collect::>(), + m.options.iter().map(|o| o.label()).collect::>(), vec!["(Unfile)", "Camano"] ); assert_eq!(m.cursor, 0, "t1 has no project → cursor starts at (Unfile)"); @@ -368,6 +370,64 @@ fn move_to_project_picker_refiles_the_selected_task() { assert_eq!(refiled[0], ("t1".into(), Some("p1".into()))); } +#[test] +fn move_picker_fuzzy_filter_narrows_to_matching_projects() { + use heph_tui::app::{Mode, MoveOption}; + let mut app = App::new(fixture()).unwrap(); + app.begin_move(); + // "cm" is a subsequence of "Camano" but not of "Unfile". + app.move_filter_push('c'); + app.move_filter_push('m'); + match &app.mode { + Mode::MoveToProject(m) => { + let projects: Vec<_> = m + .options + .iter() + .filter_map(|o| match o { + MoveOption::Project { title, .. } => Some(title.as_str()), + _ => None, + }) + .collect(); + assert_eq!(projects, vec!["Camano"]); + assert_eq!(m.cursor, 0, "typing resets the cursor to the top match"); + } + _ => panic!("expected the move picker"), + } +} + +#[test] +fn move_picker_creates_a_project_from_the_filter_text() { + use heph_tui::app::{Mode, MoveOption}; + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + app.begin_move(); // on t1 + for c in "Garden".chars() { + app.move_filter_push(c); + } + // No project named "Garden" → a Create row is offered (last). + let create_idx = match &app.mode { + Mode::MoveToProject(m) => { + let i = m + .options + .iter() + .position(|o| matches!(o, MoveOption::Create { name } if name == "Garden")); + i.expect("a create row is offered") + } + _ => panic!("expected the move picker"), + }; + // Highlight the create row and submit. + app.move_picker_move(create_idx as isize); + app.move_picker_submit(); + + assert_eq!(rec.borrow().created_projects, vec!["Garden".to_string()]); + let refiled = &rec.borrow().refiled; + assert_eq!(refiled.last().unwrap().0, "t1"); + assert_eq!(refiled.last().unwrap().1.as_deref(), Some("proj:Garden")); +} + #[test] fn toggle_sort_switches_mode_and_regroups_by_project() { use heph_tui::app::SortMode; diff --git a/docs/changelog.d/v1-tui-move-picker.feature.md b/docs/changelog.d/v1-tui-move-picker.feature.md new file mode 100644 index 0000000..9d75e0a --- /dev/null +++ b/docs/changelog.d/v1-tui-move-picker.feature.md @@ -0,0 +1 @@ +- `heph-tui` move-to-project picker (`m`) is now an **fzf-style filter** (§8.1): a prompt line on top narrows the project list by fuzzy subsequence match as you type (↑/↓ or Ctrl-n/p move, Enter selects, Esc cancels) — no more scrolling the full list. When the typed text names no existing project, a **"+ New project"** row is offered: selecting it **creates the project and files the task under it** in one step (via `node.create`), so projects can be created without leaving the TUI.