diff --git a/README.md b/README.md index eb9e30c..e0951cf 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,8 @@ herdr plugin install jwarykowski/shepherd | `S` | add a subtask to the selected item | | `u` | edit item (or subtask) text | | `d` | open detail view (shows every field) | -| `v` | cycle view: category / priority / tag / table | +| `v` | cycle view: category / priority / tag / table / lane | +| `←`/`→` | switch the active column in the [lane view](#lane-view) | | `F` | hide / show the footer help grid (the `jwarykowski/shepherd` · version line stays); `hidefooter` config sets the default | | `A` | toggle the [global view](#global-view) across all boards | | `b` | open the board picker — every board with done/total counts; `enter` jumps, `a` creates a board, `r` renames, `A` archives, `x` deletes (confirmed), `d` shows detail (name, dir, paths, counts) for the selected board (rename/archive/delete don't apply to the default board); `e` toggles the archived-boards view where `u` unarchives the selected board | @@ -135,6 +136,17 @@ idle pause (`autosave` seconds, default 60; `0` disables), or on demand with automatically when you have no unsaved edits, so external edits (or a dotfile sync) show up on their own. +## lane view + +A kanban board: one column per configured `statuses` entry, cycled to with +`v`. `←`/`→` switch the active column; `j`/`k` move within it. `tab` cycles the +selected card's status same as everywhere else — in lane view that also moves +it into the next column, cursor and all. Every other key still acts on the +card under the cursor (`h`/`m`/`l` priority, `g` category, `T` tags, `t` due, +`s` defer, `L` link, `o` open, `y` copy, `u` edit, `d` detail, `space` toggle, +`x` delete). Not available in the [global view](#global-view), since boards +can each configure different statuses. + ## subtasks Any item can hold **one level** of subtasks — the steps that make up a task. @@ -399,7 +411,7 @@ Optional `config.toml` at `$XDG_CONFIG_HOME/shepherd/config.toml` (defaults to `SHEPHERD_CONFIG`): ```toml -view = "category" # category (default) | priority | tag | table +view = "category" # category (default) | priority | tag | table | lane density = "compact" # compact (default) | comfort autosave = 60 # seconds idle before writing; 0 disables categories = ["work", "home", "personal"] # tab-cycles in the category prompt diff --git a/herdr-plugin.toml b/herdr-plugin.toml index a2ee53d..e79a26c 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "jwarykowski.herdr-shepherd" name = "Shepherd" -version = "0.20.0" +version = "0.21.0" min_herdr_version = "0.7.0" description = "Shepherd — your todos herded. Interactive todo board in a split, tab, overlay, or zoomed pane. Category/priority/due ordering, overdue pinning, notes, due dates, undo/redo, filter, archive. Backed by a markdown file." platforms = ["linux", "macos"] diff --git a/internal/todo/statuses.go b/internal/todo/statuses.go index 9ea12bc..c02e296 100644 --- a/internal/todo/statuses.go +++ b/internal/todo/statuses.go @@ -17,6 +17,22 @@ func SetStatus(it *Item, name string) { } } +// StatusOf is an item's effective status name against the configured order: +// "done" (last after normalization) when Done, the first status when Status is +// left implicit (empty), else Status itself. +func StatusOf(it Item, statuses []string) string { + if len(statuses) == 0 { + return "" + } + if it.Done { + return statuses[len(statuses)-1] + } + if it.Status == "" { + return statuses[0] + } + return it.Status +} + // CycleStatus advances an item to the next status in the configured order, // wrapping around. statuses is the ordered list from config with "done" last // (e.g. ["open", "in-progress", "done"]). The terminal "done" state is owned by @@ -27,14 +43,10 @@ func CycleStatus(it *Item, statuses []string) { return } cur := 0 - if it.Done { - cur = len(statuses) - 1 // "done" is last after config normalization - } else if it.Status != "" { - for i, s := range statuses { - if s == it.Status { - cur = i - break - } + for i, s := range statuses { + if s == StatusOf(*it, statuses) { + cur = i + break } } next := (cur + 1) % len(statuses) diff --git a/internal/tui/model.go b/internal/tui/model.go index dbfda2b..7d98077 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -51,6 +51,8 @@ func loadConfig(path string) config { c.view = viewTag case "table": c.view = viewTable + case "lane": + c.view = viewLane default: c.view = viewCategory } @@ -173,6 +175,7 @@ const ( viewPriority // grouped under priority headers viewTag // grouped under the item's first tag viewTable // flat bubbles/table + viewLane // kanban columns, one per configured status viewBoard // grouped by source board (global view only) ) @@ -184,7 +187,7 @@ const ( viewCountGlobal = viewCount + 1 ) -var viewName = map[viewMode]string{viewCategory: "category", viewPriority: "priority", viewTag: "tag", viewTable: "table", viewBoard: "board"} +var viewName = map[viewMode]string{viewCategory: "category", viewPriority: "priority", viewTag: "tag", viewTable: "table", viewLane: "lane", viewBoard: "board"} type model struct { path string @@ -193,6 +196,7 @@ type model struct { arcRows []todo.Item // archive browse set (modeArchive); all boards' when global arcCur int // cursor into arcRows cursor int // index into the VISIBLE subset, not items + lane int // active column in viewLane (index into statuses) filter string mode mode view viewMode diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index e1caabb..ead7b13 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -32,6 +32,14 @@ func key(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyCtrlS} case "ctrl+c": return tea.KeyMsg{Type: tea.KeyCtrlC} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "left": + return tea.KeyMsg{Type: tea.KeyLeft} + case "right": + return tea.KeyMsg{Type: tea.KeyRight} } return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } @@ -792,7 +800,10 @@ func TestDetailNoteWraps(t *testing.T) { } func TestView(t *testing.T) { - m := model{input: textinput.New(), w: 50, height: 20, items: []todo.Item{ + // height 21: one more than the two-item body needs, so the footer's help + // grid (now one row taller for the lane view's ←/→ hint) doesn't window out + // the uncategorized group. + m := model{input: textinput.New(), w: 50, height: 21, items: []todo.Item{ {Text: "ship release", Prio: 'H', Category: "work"}, {Text: "buy milk"}, }} @@ -810,7 +821,7 @@ func TestView(t *testing.T) { t.Fatalf("view missing %q", want) } } - if got := strings.Count(v, "\n") + 1; got != 20 { + if got := strings.Count(v, "\n") + 1; got != 21 { t.Fatalf("frame not pinned to height: %d rows", got) } } @@ -970,7 +981,15 @@ func TestViewToggle(t *testing.T) { if m.view != viewTag { t.Fatalf("tag view expected after priority: %d", m.view) } - m = drive(m, "v", "v") + m = drive(m, "v") + if m.view != viewTable { + t.Fatalf("table view expected after tag: %d", m.view) + } + m = drive(m, "v") + if m.view != viewLane { + t.Fatalf("lane view expected after table: %d", m.view) + } + m = drive(m, "v") if m.view != viewCategory { t.Fatalf("view did not cycle back: %d", m.view) } @@ -1169,9 +1188,10 @@ func TestRenderAllViews(t *testing.T) { {Text: "ship release", Prio: 'H', Category: "work", Due: "2026-07-01"}, {Text: "buy milk"}, }} - for _, v := range []viewMode{viewCategory, viewPriority, viewTable} { + for _, v := range []viewMode{viewCategory, viewPriority, viewTable, viewLane} { m := base m.view = v + m.statuses = []string{"open", "in-progress", "done"} if !strings.Contains(m.View(), appName) { t.Errorf("view %d missing brand", v) } @@ -1267,6 +1287,92 @@ func TestStatusCycleAndDoneToggle(t *testing.T) { } } +// TestLaneView drives the kanban view end to end: cycling in via v, left/right +// switching the active column (clamped at both ends), j/k staying inside that +// column, tab moving a card to the next lane with the cursor following it, and +// a mutation key (h, priority) acting on the lane-selected card rather than +// whatever the flat cursor would have pointed at. +func TestLaneView(t *testing.T) { + newModel := func() model { + return model{input: textinput.New(), note: textarea.New(), w: 60, height: 24, + statuses: []string{"open", "in-progress", "done"}, + items: []todo.Item{ + {Text: "a"}, {Text: "b"}, {Text: "c", Status: "in-progress"}, + }} + } + + // v cycles category -> priority -> tag -> table -> lane + m := drive(newModel(), "v", "v", "v", "v") + if m.view != viewLane { + t.Fatalf("v did not cycle into lane view: %v", m.view) + } + if !strings.Contains(m.View(), "in-progress") { + t.Fatalf("lane view missing a configured status column:\n%s", m.View()) + } + + // left is a no-op at the first lane + if got := drive(m, "left"); got.lane != 0 { + t.Fatalf("left should clamp at lane 0: %d", got.lane) + } + // right moves into the in-progress lane, whose only row is "c" + m = drive(m, "right") + if m.lane != 1 { + t.Fatalf("right did not move to lane 1: %d", m.lane) + } + if ref := m.selRef(); m.rowItem(ref).Text != "c" { + t.Fatalf("lane cursor did not land on the in-progress card: %+v", m.rowItem(ref)) + } + // j/k don't leak into other lanes: only one row here, so both are no-ops + if got := drive(m, "j"); got.cursor != 0 { + t.Fatalf("j moved past the active lane's only row: cursor=%d", got.cursor) + } + + // tab moves the lane-selected card ("c") to done, and the cursor follows it + m = drive(m, "tab") + if m.lane != 2 { + t.Fatalf("tab-in-lane did not follow the card to lane 2: %d", m.lane) + } + if ref := m.selRef(); m.rowItem(ref).Text != "c" || !m.rowItem(ref).Done { + t.Fatalf("cursor did not follow the moved card: %+v", m.rowItem(ref)) + } + + // h (priority) acts on the lane-selected card, not the flat-list cursor + m = drive(m, "h") + if got := m.rowItem(m.selRef()); got.Text != "c" || got.Prio != 'H' { + t.Fatalf("h did not set priority on the lane-selected card: %+v", got) + } +} + +// TestLaneViewScrolls checks a lane with more cards than fit the pane scrolls +// as the cursor moves, rather than silently truncating. +func TestLaneViewScrolls(t *testing.T) { + items := make([]todo.Item, 8) + for i := range items { + items[i] = todo.Item{Text: fmt.Sprintf("task %d", i)} + } + m := model{input: textinput.New(), w: 60, height: 20, view: viewLane, + statuses: []string{"open", "done"}, items: items} + + first := ansi.Strip(m.View()) + if !strings.Contains(first, "task 0") { + t.Fatalf("first render should show the top of the lane:\n%s", first) + } + + // walk the cursor to the bottom of the lane + downs := make([]string, len(items)-1) + for i := range downs { + downs[i] = "j" + } + m = drive(m, downs...) + last := ansi.Strip(m.View()) + if !strings.Contains(last, "task 7") { + t.Fatalf("scrolled view should reach the last card:\n%s", last) + } + if strings.Contains(last, "task 0") { + t.Fatalf("scrolled view should no longer show the first card:\n%s", last) + } +} + func TestGlobalReadOnly(t *testing.T) { m := model{ input: textinput.New(), diff --git a/internal/tui/update.go b/internal/tui/update.go index 4c28004..8fffacf 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -116,9 +116,50 @@ func (m model) rows() []rowRef { return rs } -// selRef is the row under the cursor, or {-1,-1} when there are no rows. +// laneRows is the visible rows whose effective status matches the given lane +// name — the per-column cursor space for viewLane. Built from rows() so it +// stays consistent with the active filter and subtask flattening. +func (m model) laneRows(status string) []rowRef { + var out []rowRef + for _, r := range m.rows() { + if todo.StatusOf(m.rowItem(r), m.statuses) == status { + out = append(out, r) + } + } + return out +} + +// currentLane is the status name of the active column in viewLane, clamped +// into range. Empty when m.statuses is empty (loadConfig's normalizeStatuses +// guarantees it isn't at runtime, but model{} test literals often skip it). +func (m model) currentLane() string { + if len(m.statuses) == 0 { + return "" + } + i := m.lane + if i < 0 { + i = 0 + } + if i >= len(m.statuses) { + i = len(m.statuses) - 1 + } + return m.statuses[i] +} + +// cursorRows is the row slice m.cursor indexes: the active lane's rows in +// viewLane, the full flat list otherwise. The single choke point for cursor +// bounds so j/k, selRef and clamp can't disagree on what's in range. +func (m model) cursorRows() []rowRef { + if m.view == viewLane { + return m.laneRows(m.currentLane()) + } + return m.rows() +} + +// selRef is the row under the cursor, or {-1,-1} when there are no rows. In +// viewLane the cursor indexes the active column's own rows, not the full list. func (m model) selRef() rowRef { - rs := m.rows() + rs := m.cursorRows() if len(rs) == 0 { return rowRef{-1, -1} } @@ -190,7 +231,14 @@ func (m model) sel() int { } func (m *model) clamp() { - n := len(m.rows()) + if m.view == viewLane { + if m.lane < 0 || len(m.statuses) == 0 { + m.lane = 0 + } else if m.lane >= len(m.statuses) { + m.lane = len(m.statuses) - 1 + } + } + n := len(m.cursorRows()) if m.cursor >= n { m.cursor = n - 1 } @@ -200,8 +248,26 @@ func (m *model) clamp() { } // place moves the cursor onto the parent row for a given item value (used after -// a sort re-orders the list). Lands on the parent row, not a subtask. +// a sort re-orders the list). Lands on the parent row, not a subtask. In +// viewLane it first switches to the item's own lane, so a card followed here +// after a status change stays under the cursor in its new column. func (m *model) place(target todo.Item) { + if m.view == viewLane { + lane := todo.StatusOf(target, m.statuses) + for i, s := range m.statuses { + if s == lane { + m.lane = i + break + } + } + for p, r := range m.laneRows(lane) { + if r.sub == -1 && sameItem(m.items[r.item], target) { + m.cursor = p + return + } + } + return + } for p, r := range m.rows() { if r.sub == -1 && sameItem(m.items[r.item], target) { m.cursor = p @@ -325,7 +391,11 @@ func (m model) updateGlobal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if has { cur = m.items[m.sel()] } - m.view = (m.view + 1) % viewMode(viewCountGlobal) + next := (m.view + 1) % viewMode(viewCountGlobal) + if next == viewLane { // lane view is board-only: boards can configure + next = (next + 1) % viewMode(viewCountGlobal) // different statuses + } + m.view = next m.resort() if has { m.place(cur) @@ -381,7 +451,7 @@ func (m model) quit() (tea.Model, tea.Cmd) { } func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - rows := m.rows() + rows := m.cursorRows() ref := m.selRef() idx := ref.item switch msg.String() { @@ -400,6 +470,16 @@ func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.cursor > 0 { m.cursor-- } + case "left": + if m.view == viewLane && m.lane > 0 { + m.lane-- + m.clamp() + } + case "right": + if m.view == viewLane && m.lane < len(m.statuses)-1 { + m.lane++ + m.clamp() + } case " ": if idx >= 0 { m.beforeMutate() @@ -414,9 +494,13 @@ func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.beforeMutate() if ref.sub == -1 { todo.CycleStatus(&m.items[idx], m.statuses) + if m.view == viewLane { // follow the card into its new column + m.place(m.items[idx]) + } } else { todo.CycleSubStatus(&m.items[idx], ref.sub, m.statuses) } + m.clamp() } case "d": if idx >= 0 { diff --git a/internal/tui/view.go b/internal/tui/view.go index 65ae854..b67b23f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -65,6 +65,8 @@ func (m model) View() string { content = m.detailView() case m.view == viewTable: content = m.tableView() + case m.view == viewLane: + content = m.laneView() default: content = m.listView() } @@ -309,6 +311,19 @@ func (m model) listView() string { return m.frame(body, footer) } +// scrollOffset is the top-of-viewport index that keeps cursorLine centered in +// a vh-tall viewport over total rows, clamped at both ends. +func scrollOffset(total, cursorLine, vh int) int { + off := cursorLine - vh/2 + if off < 0 { + off = 0 + } + if off > total-vh { + off = total - vh + } + return off +} + // windowRows clips the list body to what fits between the header and footer, // keeping the cursor line centered in the viewport (clamped at both ends). It // returns rows unchanged when the terminal size is unknown or everything fits. @@ -321,13 +336,7 @@ func (m model) windowRows(rows []string, cursorLine, footLines int) []string { if vh < 1 || len(rows) <= vh { return rows } - off := cursorLine - vh/2 - if off < 0 { - off = 0 - } - if off > len(rows)-vh { - off = len(rows) - vh - } + off := scrollOffset(len(rows), cursorLine, vh) return rows[off : off+vh] } @@ -672,7 +681,7 @@ type keyCol struct { // helpGrid is the list footer's key hints. func (m model) helpGrid() string { cols := []keyCol{ - {"move", [][2]string{{"j/k", "move"}, {"space", "toggle"}, {"d", "detail"}, {"v", "view"}, {"A", "global"}, {"e", "archive"}, {"b", "boards"}, {"F", "footer"}}}, + {"move", [][2]string{{"j/k", "move"}, {"←/→", "lane"}, {"space", "toggle"}, {"d", "detail"}, {"v", "view"}, {"A", "global"}, {"e", "archive"}, {"b", "boards"}, {"F", "footer"}}}, {"edit", [][2]string{{"a", "add"}, {"S", "sub"}, {"u", "edit"}, {"tab", "status"}, {"x", "del"}, {"c", "sweep"}, {"C", "arch"}}}, {"fields", [][2]string{{"h/m/l", "prio"}, {"g", "cat"}, {"T", "tags"}, {"t", "due"}, {"s", "defer"}, {"L", "link"}, {"o", "open"}, {"y", "copy"}}}, {"board", [][2]string{{"w", "save"}, {"^e", "editor"}, {"U", "undo"}, {"^r", "redo"}, {"/", "filter"}, {",", "settings"}, {"?", "help"}, {"q", "quit"}}}, @@ -727,14 +736,14 @@ func (m model) keyGrid(cols []keyCol, dim func(key string) bool) string { for i, c := range cols { keyW := 0 for _, e := range c.entries { - if len(e[0]) > keyW { - keyW = len(e[0]) + if kw := lipgloss.Width(e[0]); kw > keyW { + keyW = kw } } lines := []string{catStyle.Render(c.head)} w := lipgloss.Width(lines[0]) for _, e := range c.entries { - key := fmt.Sprintf("%-*s", keyW, e[0]) + key := e[0] + strings.Repeat(" ", keyW-lipgloss.Width(e[0])) if dim(e[0]) { key = dimStyle.Render(key) } @@ -856,6 +865,104 @@ func (m model) tableView() string { return m.frame(head+"\n"+t.View(), footer) } +// laneCard renders one lane-view card line: a priority marker plus the title, +// truncated to width w. Status is implied by the column, so unlike rowContent +// there's no box glyph. +func (m model) laneCard(it todo.Item, w int) string { + marker, markSt := " ", dimStyle + if _, ok := prioLabel[it.Prio]; ok { + marker, markSt = string(it.Prio), prioStyles[it.Prio] + } + text := it.Text + if it.Done { + text = doneStyle.Render(text) + } + return ansi.Truncate(markSt.Render(marker)+" "+text, w, "…") +} + +// laneView renders the kanban board: one column per configured status. All +// columns are windowed together against the active lane's cursor position, so +// the whole board scrolls in lockstep as the cursor moves within its column. +const laneGap = 2 // blank columns between lanes, so headers/cards don't touch + +func (m model) laneView() string { + w := m.width() + lanes := m.statuses + if len(lanes) == 0 { // config invariant, but test model{} literals often skip it + lanes = []string{""} + } + colW := (w - laneGap*(len(lanes)-1)) / len(lanes) + if colW < 8 { + colW = 8 + } + active := m.lane + if active < 0 { + active = 0 + } + if active >= len(lanes) { + active = len(lanes) - 1 + } + cur := m.cursor + if cur < 0 { + cur = 0 + } + + headers := make([]string, len(lanes)) + cardLines := make([][]string, len(lanes)) + for i, status := range lanes { + rows := m.laneRows(status) + headers[i] = spread(colW, catStyle.Render(status), countStyle.Render(fmt.Sprintf("%d", len(rows)))) + lines := make([]string, len(rows)) + for j, r := range rows { + line := m.laneCard(m.rowItem(r), colW) + if i == active && j == cur { + line = cursorStyle.Width(colW).Render(ansi.Strip(line)) + } + lines[j] = line + } + cardLines[i] = lines + } + + footer := m.listFooter() + vh := 0 + if ih := m.innerHeight(); ih > 0 { + vh = ih - lines(footer) - 3 // m.header (2 lines) + this column's own header line + } + off := 0 + if vh > 0 && len(cardLines[active]) > vh { + off = scrollOffset(len(cardLines[active]), cur, vh) + } + + cols := make([]string, len(lanes)) + for i, visible := range cardLines { + if vh > 0 { + start := off + if start > len(visible) { + start = len(visible) + } + end := start + vh + if end > len(visible) { + end = len(visible) + } + visible = visible[start:end] + } + if len(visible) == 0 { + visible = []string{dimStyle.Render("(empty)")} + } + cols[i] = lipgloss.NewStyle().Width(colW).Render(headers[i] + "\n" + strings.Join(visible, "\n")) + } + gap := lipgloss.NewStyle().Width(laneGap).Render("") + parts := make([]string, 0, len(cols)*2-1) + for i, c := range cols { + if i > 0 { + parts = append(parts, gap) + } + parts = append(parts, c) + } + body := m.header() + "\n" + lipgloss.JoinHorizontal(lipgloss.Top, parts...) + return m.frame(body, footer) +} + // helpBody returns the full help content as individual (already-wrapped) lines. func (m model) helpBody() []string { w := m.width() @@ -891,7 +998,8 @@ func (m model) helpBody() []string { line("today · tomorrow · Nd/Nw/Nm/Ny (e.g. 3d, 2w) · DD-MM-YYYY. Anything unrecognised clears the date. Overdue items are pinned to a group at the top.") blank() sec("view & find") - line("v — cycle view: category / priority / tag / table (the global view adds a board grouping). The tag view groups by an item's first tag (untagged last) and shows its other tags on the row") + line("v — cycle view: category / priority / tag / table / lane (the global view adds a board grouping instead of lane). The tag view groups by an item's first tag (untagged last) and shows its other tags on the row") + line("lane view: one column per configured status — a kanban board. ←/→ switches the active column, tab moves the selected card into the next one; every other key (h/m/l, g, T, t, s, L, o, y, u, d, space, x) still acts on the card under the cursor. Not available in the global view, since boards can configure different statuses") line("/ — filter text, note, category, tags, due (also greps the archive)") line("A — toggle the read-only global view across all boards (esc to leave)") line("e — browse the archive (read-only; all boards in the global view; esc to leave)")