generated from eblume/project-template
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:
parent
01ae561a74
commit
9c932c8d9a
6 changed files with 252 additions and 53 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
1
docs/changelog.d/v1-tui-move-picker.feature.md
Normal file
1
docs/changelog.d/v1-tui-move-picker.feature.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue