diff --git a/internal/ticket/checklist.go b/internal/ticket/checklist.go new file mode 100644 index 0000000..c7ab087 --- /dev/null +++ b/internal/ticket/checklist.go @@ -0,0 +1,63 @@ +package ticket + +import ( + "fmt" + "regexp" + "strings" +) + +// taskCheckboxRe matches GFM/YouTrack-style task list items: +// +// - [ ] unchecked +// - [x] checked +// * [X] also accepted +var taskCheckboxRe = regexp.MustCompile(`^(\s*)([-*+])\s+\[([ xX])\](\s*)(.*)$`) + +// Checkbox is one task-list item inside a Markdown description. +type Checkbox struct { + Line int // 0-based line index in the source + Checked bool + Text string // label after the checkbox marker +} + +// ListCheckboxes returns all task-list items in markdown source order. +func ListCheckboxes(markdown string) []Checkbox { + if markdown == "" { + return nil + } + lines := strings.Split(markdown, "\n") + var out []Checkbox + for i, line := range lines { + m := taskCheckboxRe.FindStringSubmatch(line) + if m == nil { + continue + } + out = append(out, Checkbox{ + Line: i, + Checked: m[3] == "x" || m[3] == "X", + Text: m[5], + }) + } + return out +} + +// ToggleCheckbox flips the checked state of the checkbox at index +// (0-based among ListCheckboxes results) and returns the updated markdown. +func ToggleCheckbox(markdown string, index int) (string, error) { + boxes := ListCheckboxes(markdown) + if index < 0 || index >= len(boxes) { + return markdown, fmt.Errorf("checkbox %d: not found", index) + } + lines := strings.Split(markdown, "\n") + lineIdx := boxes[index].Line + m := taskCheckboxRe.FindStringSubmatch(lines[lineIdx]) + if m == nil { + return markdown, fmt.Errorf("checkbox %d: line changed", index) + } + mark := " " + if !(m[3] == "x" || m[3] == "X") { + mark = "x" + } + lines[lineIdx] = m[1] + m[2] + " [" + mark + "]" + m[4] + m[5] + return strings.Join(lines, "\n"), nil +} diff --git a/internal/ticket/checklist_test.go b/internal/ticket/checklist_test.go new file mode 100644 index 0000000..6e22736 --- /dev/null +++ b/internal/ticket/checklist_test.go @@ -0,0 +1,65 @@ +package ticket + +import ( + "testing" +) + +func TestListCheckboxes(t *testing.T) { + src := `# Steps + +- [ ] one +- [x] two +* [X] three + - [ ] nested + +Not a box: [ ] plain +` + got := ListCheckboxes(src) + if len(got) != 4 { + t.Fatalf("len=%d want 4: %+v", len(got), got) + } + if got[0].Text != "one" || got[0].Checked || got[0].Line != 2 { + t.Fatalf("first: %+v", got[0]) + } + if !got[1].Checked || got[1].Text != "two" { + t.Fatalf("second: %+v", got[1]) + } + if !got[2].Checked || got[2].Text != "three" { + t.Fatalf("third: %+v", got[2]) + } + if got[3].Text != "nested" || got[3].Checked || got[3].Line != 5 { + t.Fatalf("nested: %+v", got[3]) + } +} + +func TestToggleCheckbox(t *testing.T) { + src := "- [ ] a\n- [x] b\n" + out, err := ToggleCheckbox(src, 0) + if err != nil { + t.Fatal(err) + } + if out != "- [x] a\n- [x] b\n" { + t.Fatalf("toggle 0: %q", out) + } + out, err = ToggleCheckbox(out, 1) + if err != nil { + t.Fatal(err) + } + if out != "- [x] a\n- [ ] b\n" { + t.Fatalf("toggle 1: %q", out) + } + if _, err := ToggleCheckbox(src, 9); err == nil { + t.Fatal("expected error for bad index") + } +} + +func TestToggleCheckboxPreservesIndentAndBullet(t *testing.T) { + src := " * [X] deep" + out, err := ToggleCheckbox(src, 0) + if err != nil { + t.Fatal(err) + } + if out != " * [ ] deep" { + t.Fatalf("got %q", out) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 9891aaa..0fe1d75 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -382,6 +382,23 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.mode = ModeTimeEntries return m, nil + case key.Matches(msg, m.keys.ChecklistNext): + m.clearStatus() + m.tickets.MoveChecklistDown() + return m, nil + + case key.Matches(msg, m.keys.ChecklistPrev): + m.clearStatus() + m.tickets.MoveChecklistUp() + return m, nil + + case key.Matches(msg, m.keys.Toggle): + m.clearStatus() + if err := m.toggleSelectedCheckbox(); err != nil { + m.setError(err) + } + return m, nil + case key.Matches(msg, m.keys.Down): m.tickets.MoveDown() return m, nil @@ -985,6 +1002,52 @@ func (m *Model) applyStatus(st ticket.Status) error { return nil } +func (m *Model) toggleSelectedCheckbox() error { + t, ok := m.tickets.Selected() + if !ok { + return fmt.Errorf("no ticket selected") + } + boxes := ticket.ListCheckboxes(t.Description) + if len(boxes) == 0 { + return fmt.Errorf("no checkboxes in description") + } + checkIdx := m.tickets.ChecklistCursor + if checkIdx < 0 || checkIdx >= len(boxes) { + checkIdx = 0 + } + newDesc, err := ticket.ToggleCheckbox(t.Description, checkIdx) + if err != nil { + return err + } + after := t + after.Description = newDesc + if err := m.exec.Do(&command.UpdateTicket{ + Store: m.store, + Before: t, + After: after, + }); err != nil { + return err + } + ticketIdx := m.tickets.CursorIndex() + if err := m.reloadTickets(); err != nil { + return err + } + if ticketIdx < m.tickets.Len() { + m.tickets.Select(ticketIdx) + } + m.tickets.SetChecklistCursor(checkIdx) + state := "unchecked" + if !boxes[checkIdx].Checked { + state = "checked" + } + label := strings.TrimSpace(boxes[checkIdx].Text) + if label == "" { + label = fmt.Sprintf("#%d", checkIdx+1) + } + m.setStatus(fmt.Sprintf("%s %s", state, label)) + return nil +} + func (m *Model) undo() error { name := m.exec.LastUndoName() if err := m.exec.Undo(); err != nil { diff --git a/internal/ui/helpbar.go b/internal/ui/helpbar.go index 8409dae..7f1a91a 100644 --- a/internal/ui/helpbar.go +++ b/internal/ui/helpbar.go @@ -98,6 +98,9 @@ func (m Model) helpEntries() []helpEntry { default: return []helpEntry{ {"j/k", "navigate"}, + {"J/K", "checklist"}, + {"S-↑/↓", "checklist"}, + {"space", "check"}, {"n", "new"}, {"e", "edit"}, {"t/T", "timer/log"}, diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 6d54c53..52f063b 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -17,16 +17,18 @@ type KeyMap struct { Delete key.Binding // x — confirm first DeleteForce key.Binding // X — immediate ConfirmYes key.Binding - Toggle key.Binding // space — toggle multi-select - Timer key.Binding // t — start/stop timer - TimeManual key.Binding // T — manual duration - TimeEntries key.Binding // m — manage time entries - Report key.Binding // r — time report - Undo key.Binding - Redo key.Binding - Quit key.Binding - Enter key.Binding - Escape key.Binding + Toggle key.Binding // space — toggle multi-select / checklist item + ChecklistPrev key.Binding // K / shift+up — previous checkbox + ChecklistNext key.Binding // J / shift+down — next checkbox + Timer key.Binding // t — start/stop timer + TimeManual key.Binding // T — manual duration + TimeEntries key.Binding // m — manage time entries + Report key.Binding // r — time report + Undo key.Binding + Redo key.Binding + Quit key.Binding + Enter key.Binding + Escape key.Binding } // DefaultKeyMap returns the default key bindings. @@ -88,6 +90,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys(" "), key.WithHelp("space", "toggle"), ), + ChecklistPrev: key.NewBinding( + key.WithKeys("K", "shift+up"), + key.WithHelp("J/K", "checklist"), + ), + ChecklistNext: key.NewBinding( + key.WithKeys("J", "shift+down"), + key.WithHelp("J/K", "checklist"), + ), Timer: key.NewBinding( key.WithKeys("t"), key.WithHelp("t", "timer"), diff --git a/internal/ui/views/markdown.go b/internal/ui/views/markdown.go index fa55bba..742696e 100644 --- a/internal/ui/views/markdown.go +++ b/internal/ui/views/markdown.go @@ -1,11 +1,15 @@ package views import ( + "strings" "sync" "github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour/styles" + "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" + + "task/internal/ticket" ) type mdCacheKey struct { @@ -79,6 +83,78 @@ func getRenderer(width int) (*glamour.TermRenderer, error) { return r, nil } +// RenderDescription renders ticket Markdown with interactive task-list items. +// checklistCursor is the 0-based index among checkboxes (highlight); ignored when +// there are none. +func RenderDescription(src string, width int, checklistCursor int) string { + if strings.TrimSpace(src) == "" { + return "" + } + boxes := ticket.ListCheckboxes(src) + if len(boxes) == 0 { + return RenderMarkdown(src, width) + } + + boxByLine := make(map[int]int, len(boxes)) + for i, b := range boxes { + boxByLine[b.Line] = i + } + + lines := strings.Split(src, "\n") + var parts []string + var mdBuf []string + flushMD := func() { + if len(mdBuf) == 0 { + return + } + block := strings.Join(mdBuf, "\n") + if strings.TrimSpace(block) != "" { + parts = append(parts, RenderMarkdown(block, width)) + } + mdBuf = nil + } + + for i, line := range lines { + if bi, ok := boxByLine[i]; ok { + flushMD() + parts = append(parts, renderCheckboxLine(boxes[bi], bi == checklistCursor, width)) + continue + } + mdBuf = append(mdBuf, line) + } + flushMD() + if len(parts) == 0 { + return "" + } + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} + +func renderCheckboxLine(box ticket.Checkbox, selected bool, width int) string { + // ASCII markers — widely supported; bold + padding reads a notch bigger. + mark := "[ ]" + if box.Checked { + mark = "[x]" + } + prefix := " " + if selected { + prefix = "> " + } + label := strings.TrimSpace(box.Text) + avail := max(10, width) - lipgloss.Width(prefix+mark+" ") + label = truncateOneLine(label, avail) + + markStyle := lipgloss.NewStyle().Bold(true) + labelStyle := lipgloss.NewStyle() + if selected { + markStyle = markStyle.Foreground(lipgloss.Color("39")) + labelStyle = labelStyle.Bold(true).Foreground(lipgloss.Color("39")) + } else if box.Checked { + labelStyle = labelStyle.Faint(true) + } + line := prefix + markStyle.Render(mark) + " " + labelStyle.Render(label) + return lipgloss.NewStyle().Width(max(10, width)).Render(line) +} + // ClearMarkdownCache drops cached renders (e.g. after theme change). func ClearMarkdownCache() { mdMu.Lock() diff --git a/internal/ui/views/markdown_test.go b/internal/ui/views/markdown_test.go index 7af72e6..696d043 100644 --- a/internal/ui/views/markdown_test.go +++ b/internal/ui/views/markdown_test.go @@ -30,3 +30,14 @@ func TestRenderMarkdownFastWithoutAutoStyle(t *testing.T) { } t.Logf("first=%v cached=%v", elapsed, time.Since(start)) } + +func TestRenderDescriptionHighlightsCheckbox(t *testing.T) { + src := "Intro\n\n- [ ] one\n- [x] two\n" + out := views.RenderDescription(src, 40, 0) + if !strings.Contains(out, "one") || !strings.Contains(out, "[ ]") { + t.Fatalf("expected unchecked item in output: %q", out) + } + if !strings.Contains(out, "two") || !strings.Contains(out, "[x]") { + t.Fatalf("expected checked item in output: %q", out) + } +} diff --git a/internal/ui/views/tickets.go b/internal/ui/views/tickets.go index 420542d..aaa2afe 100644 --- a/internal/ui/views/tickets.go +++ b/internal/ui/views/tickets.go @@ -26,13 +26,14 @@ type TicketListStyles struct { // TicketList is a custom viewport list of ticket cards. type TicketList struct { - Items []ticket.Ticket - Cursor int - Offset int - Width int - Height int - Styles TicketListStyles - SpentByID map[string]time.Duration // optional; shown on selected card + Items []ticket.Ticket + Cursor int + Offset int + Width int + Height int + Styles TicketListStyles + SpentByID map[string]time.Duration // optional; shown on selected card + ChecklistCursor int // index into checkboxes of the selected ticket } // SetSize updates the viewport dimensions. @@ -49,6 +50,7 @@ func (l *TicketList) SetItems(items []ticket.Ticket) { l.Cursor = max(0, len(l.Items)-1) } l.Offset = 0 + l.clampChecklistCursor() l.ensureVisible() } @@ -70,6 +72,7 @@ func (l *TicketList) Len() int { return len(l.Items) } func (l *TicketList) MoveDown() { if l.Cursor < len(l.Items)-1 { l.Cursor++ + l.ChecklistCursor = 0 l.ensureVisible() } } @@ -78,6 +81,7 @@ func (l *TicketList) MoveDown() { func (l *TicketList) MoveUp() { if l.Cursor > 0 { l.Cursor-- + l.ChecklistCursor = 0 l.ensureVisible() } } @@ -87,10 +91,61 @@ func (l *TicketList) Select(idx int) { if idx < 0 || idx >= len(l.Items) { return } + if l.Cursor != idx { + l.ChecklistCursor = 0 + } l.Cursor = idx + l.clampChecklistCursor() l.ensureVisible() } +// ChecklistLen returns how many task-list items the selected ticket has. +func (l *TicketList) ChecklistLen() int { + t, ok := l.Selected() + if !ok { + return 0 + } + return len(ticket.ListCheckboxes(t.Description)) +} + +// MoveChecklistDown advances the checkbox cursor (wraps). +func (l *TicketList) MoveChecklistDown() { + n := l.ChecklistLen() + if n == 0 { + return + } + l.ChecklistCursor = (l.ChecklistCursor + 1) % n +} + +// MoveChecklistUp moves the checkbox cursor up (wraps). +func (l *TicketList) MoveChecklistUp() { + n := l.ChecklistLen() + if n == 0 { + return + } + l.ChecklistCursor = (l.ChecklistCursor - 1 + n) % n +} + +// SetChecklistCursor sets the checkbox highlight index (clamped). +func (l *TicketList) SetChecklistCursor(idx int) { + l.ChecklistCursor = idx + l.clampChecklistCursor() +} + +func (l *TicketList) clampChecklistCursor() { + n := l.ChecklistLen() + if n == 0 { + l.ChecklistCursor = 0 + return + } + if l.ChecklistCursor < 0 { + l.ChecklistCursor = 0 + } + if l.ChecklistCursor >= n { + l.ChecklistCursor = n - 1 + } +} + // View renders only the visible ticket cards. func (l *TicketList) View() string { if l.Height < 1 { @@ -126,16 +181,15 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string { var content string if selected { - // Expanded: headline + optional spent + glamour description. + // Expanded: headline + optional spent + description (interactive checkboxes). content = headerLine if spent, ok := l.SpentByID[t.ID]; ok && spent > 0 { content += "\n" + lipgloss.NewStyle().Faint(true).Render( "spent: "+ticket.FormatDurationHuman(spent), ) } - desc := strings.TrimSpace(t.Description) - if desc != "" { - content += "\n" + RenderMarkdown(desc, innerWidth) + if strings.TrimSpace(t.Description) != "" { + content += "\n" + RenderDescription(t.Description, innerWidth, l.ChecklistCursor) } } else { content = headerLine