feat(tui): fzf-style move-to-project picker + create-project (§8.1)

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) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 18:20:10 -07:00
commit 9c932c8d9a
6 changed files with 252 additions and 53 deletions

View file

@ -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<String>,
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<Project>,
/// 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<MoveOption>,
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<char> = 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>) -> Attention {
@ -537,42 +607,55 @@ impl<B: Backend> App<B> {
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<B: Backend> App<B> {
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.

View file

@ -65,6 +65,8 @@ pub trait Backend {
recurrence: Option<&str>,
project_id: Option<&str>,
) -> Result<String>;
/// Create a new project node; returns its node id.
fn create_project(&mut self, name: &str) -> Result<String>;
}
/// 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<String> {
let v = self.call("node.create", json!({ "kind": "project", "title": name }))?;
let node: heph_core::Node = serde_json::from_value(v)?;
Ok(node.id)
}
}

View file

@ -119,13 +119,19 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
return None;
}
// The move-to-project picker captures navigation/select/cancel.
// The move-to-project picker is an fzf-style filter: typing narrows the list,
// arrows / Ctrl-n,p move, Enter selects (or creates), Esc cancels.
if matches!(app.mode, Mode::MoveToProject(_)) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => 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;

View file

@ -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<B: Backend>(frame: &mut Frame, app: &App<B>) {
}
}
/// 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<ListItem> = 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).

View file

@ -29,6 +29,7 @@ struct Recorder {
scheduled: Vec<(String, SchedulePatch)>,
tombstoned: Vec<String>,
refiled: Vec<(String, Option<String>)>,
created_projects: Vec<String>,
}
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<String> {
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::<Vec<_>>(),
m.options.iter().map(|o| o.label()).collect::<Vec<_>>(),
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;

View file

@ -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.