diff --git a/README.md b/README.md index e11b063..323b35e 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ notebook path/to/file.md - **Block editor** — 15 block types: paragraphs, headings (3 levels), bullet lists, numbered lists, checklists, code blocks, tables, quotes, definitions, callouts, dividers, embeds, and kanban boards. Press **/** to switch types. - **Tables** — Pipe-delimited GFM tables with per-column widths. Alt+R/C to add rows/columns, Alt+Shift+Backspace/Alt+Shift+D to delete. Press Enter on an empty row to exit the table and drop the row. -- **Kanban boards** — Visual boards with priority cards. Arrows navigate, Shift+arrows move cards, **n** new card, **Opt+K** copy card, **p** cycle priority, **s** toggle auto-sort. Round-trips as a `kanban` fenced block. +- **Kanban boards** — Visual boards with priority cards and issue tags. Arrows navigate, Shift+arrows move cards, **n** new card, **Opt+K** copy card, **p** cycle priority, **Ctrl+T** cycle tag, **s** toggle auto-sort. Round-trips as a `kanban` fenced block. - **Callouts** — Five admonition variants (Note, Tip, Important, Warning, Caution). Ctrl+T to cycle. - **Definitions** — Term/definition pairs. Press **:** to search and jump to definitions. - **Embeds** — Reference other notes inline with `![[notebook/note]]`. Click in view mode to expand. @@ -104,7 +104,7 @@ notebook path/to/file.md | **Alt+Up / Alt+Down** | Move block up/down | | **Tab / Shift+Tab** | Indent / outdent list | | **Ctrl+X** | Toggle checkbox | -| **Ctrl+T** | Cycle callout variant | +| **Ctrl+T** | Cycle callout variant / kanban tag | | **Ctrl+H** | Sort checked items to bottom | | **Ctrl+R** | View mode | | **Ctrl+J / Shift+Enter** | Newline within block | diff --git a/internal/block/block.go b/internal/block/block.go index 1a896d4..b140fcb 100644 --- a/internal/block/block.go +++ b/internal/block/block.go @@ -12,22 +12,22 @@ import "strings" type BlockType int const ( - Paragraph BlockType = iota // plain text paragraph - Heading1 // # heading - Heading2 // ## heading - Heading3 // ### heading - BulletList // - or * list item - NumberedList // 1. numbered list item - Checklist // - [ ] or - [x] checklist item - CodeBlock // fenced code block (``` ... ```) - Quote // > block quote - Divider // ---, ***, or ___ - DefinitionList // term\n: definition - Embed // ![[path]] embedded note reference - Callout // > [!NOTE] callout/admonition - Table // GFM pipe table - Kanban // ```kanban``` fenced kanban board - Link // [title](url) link card + Paragraph BlockType = iota // plain text paragraph + Heading1 // # heading + Heading2 // ## heading + Heading3 // ### heading + BulletList // - or * list item + NumberedList // 1. numbered list item + Checklist // - [ ] or - [x] checklist item + CodeBlock // fenced code block (``` ... ```) + Quote // > block quote + Divider // ---, ***, or ___ + DefinitionList // term\n: definition + Embed // ![[path]] embedded note reference + Callout // > [!NOTE] callout/admonition + Table // GFM pipe table + Kanban // ```kanban``` fenced kanban board + Link // [title](url) link card ) // String returns the human-readable name of a BlockType. @@ -247,6 +247,79 @@ func ParsePriorityMarker(content string) (Priority, string) { return PriorityNone, content } +// KanbanTag enumerates the built-in issue labels available to kanban cards. +type KanbanTag int + +const ( + KanbanTagNone KanbanTag = iota + KanbanTagBug + KanbanTagFeature + KanbanTagDocumentation + KanbanTagQuestion +) + +// Label returns the markdown label text for a KanbanTag. +func (kt KanbanTag) Label() string { + switch kt { + case KanbanTagBug: + return "bug" + case KanbanTagFeature: + return "feature" + case KanbanTagDocumentation: + return "documentation" + case KanbanTagQuestion: + return "question" + default: + return "" + } +} + +// Marker returns the inline markdown marker for a KanbanTag, e.g. "[bug]". +func (kt KanbanTag) Marker() string { + if label := kt.Label(); label != "" { + return "[" + label + "]" + } + return "" +} + +// Next cycles through the supported kanban tags. +func (kt KanbanTag) Next() KanbanTag { + return (kt + 1) % (KanbanTagQuestion + 1) +} + +// ParseKanbanTag returns the KanbanTag for a label string. +func ParseKanbanTag(s string) (KanbanTag, bool) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "bug": + return KanbanTagBug, true + case "feature", "enhancement": + return KanbanTagFeature, true + case "documentation", "docs": + return KanbanTagDocumentation, true + case "question": + return KanbanTagQuestion, true + default: + return KanbanTagNone, false + } +} + +// ParseKanbanTagMarker reads an optional leading "[label]" marker +// followed by a space. Unrecognized bracketed text remains card content. +func ParseKanbanTagMarker(content string) (KanbanTag, string) { + if !strings.HasPrefix(content, "[") { + return KanbanTagNone, content + } + end := strings.Index(content, "] ") + if end <= 1 { + return KanbanTagNone, content + } + tag, ok := ParseKanbanTag(content[1:end]) + if !ok { + return KanbanTagNone, content + } + return tag, content[end+2:] +} + // CountNumberedPosition returns the 1-based position of a numbered list block // among consecutive NumberedList blocks at the same indent level. func CountNumberedPosition(blocks []Block, idx int) int { diff --git a/internal/block/kanban.go b/internal/block/kanban.go index ac7623d..c54eab9 100644 --- a/internal/block/kanban.go +++ b/internal/block/kanban.go @@ -6,6 +6,7 @@ import "strings" type KanbanCard struct { Text string Priority Priority + Tag KanbanTag Done bool } @@ -34,7 +35,8 @@ const DefaultKanbanContent = "## Backlog\n" + // - Card text (open card) // - [x] Card text (done card) // - !! Card text (priority marker before text) -// - [ ] !!! Card text (combined: open + high priority) +// - [bug] Card text (issue label before text) +// - [ ] !!! [bug] Card text (combined: open + high priority + label) // continuation line (indented continuation of previous card) // // Lines that do not match a column header or card are treated as @@ -89,7 +91,8 @@ func ParseKanban(body string) []KanbanColumn { } else if strings.HasPrefix(body, "[ ] ") { body = body[4:] } - prio, text := ParsePriorityMarker(body) + prio, body := ParsePriorityMarker(body) + tag, text := ParseKanbanTagMarker(body) if current == nil { cols = append(cols, KanbanColumn{Title: "Untitled"}) current = &cols[len(cols)-1] @@ -97,6 +100,7 @@ func ParseKanban(body string) []KanbanColumn { current.Cards = append(current.Cards, KanbanCard{ Text: text, Priority: prio, + Tag: tag, Done: done, }) lastCard = ¤t.Cards[len(current.Cards)-1] @@ -149,6 +153,9 @@ func SerializeKanban(cols []KanbanColumn) string { if m := c.Priority.Marker(); m != "" { marker = m + " " } + if m := c.Tag.Marker(); m != "" { + marker += m + " " + } if done { lines = append(lines, "- [x] "+marker+first) } else { diff --git a/internal/block/kanban_test.go b/internal/block/kanban_test.go index 9d8c9bb..5fc82cf 100644 --- a/internal/block/kanban_test.go +++ b/internal/block/kanban_test.go @@ -36,6 +36,34 @@ func TestParseKanbanPriority(t *testing.T) { } } +func TestParseKanbanTag(t *testing.T) { + body := "## Todo\n- [bug] broken\n- [feature] new thing\n- [documentation] readme\n- [x] !! [question] maybe\n- [unknown] literal" + cols := ParseKanban(body) + if len(cols) != 1 || len(cols[0].Cards) != 5 { + t.Fatalf("unexpected: %+v", cols) + } + tests := []struct { + idx int + wantTag KanbanTag + wantText string + wantPrio Priority + wantDone bool + }{ + {0, KanbanTagBug, "broken", PriorityNone, false}, + {1, KanbanTagFeature, "new thing", PriorityNone, false}, + {2, KanbanTagDocumentation, "readme", PriorityNone, false}, + {3, KanbanTagQuestion, "maybe", PriorityMed, true}, + {4, KanbanTagNone, "[unknown] literal", PriorityNone, false}, + } + for _, tt := range tests { + c := cols[0].Cards[tt.idx] + if c.Tag != tt.wantTag || c.Text != tt.wantText || c.Priority != tt.wantPrio || c.Done != tt.wantDone { + t.Errorf("card %d = %+v, want tag=%v text=%q prio=%v done=%v", + tt.idx, c, tt.wantTag, tt.wantText, tt.wantPrio, tt.wantDone) + } + } +} + func TestParseKanbanCheckedAndPriority(t *testing.T) { body := "## Done\n- [x] !!! shipped" cols := ParseKanban(body) @@ -51,8 +79,8 @@ func TestParseKanbanCheckedAndPriority(t *testing.T) { func TestSerializeKanbanRoundTrip(t *testing.T) { cols := []KanbanColumn{ {Title: "Todo", Cards: []KanbanCard{ - {Text: "Buy groceries", Priority: PriorityHigh}, - {Text: "Read book"}, + {Text: "Buy groceries", Priority: PriorityHigh, Tag: KanbanTagBug}, + {Text: "Read book", Tag: KanbanTagDocumentation}, }}, {Title: "In Progress", Cards: []KanbanCard{ {Text: "Email", Priority: PriorityMed}, @@ -180,11 +208,11 @@ func TestKanbanMultilineCardWithBlankLineRoundTrip(t *testing.T) { func TestKanbanDoneColumnAutoMarks(t *testing.T) { cols := []KanbanColumn{ - {Title: "Done", Cards: []KanbanCard{{Text: "shipped"}}}, + {Title: "Done", Cards: []KanbanCard{{Text: "shipped", Tag: KanbanTagFeature}}}, {Title: "Todo", Cards: []KanbanCard{{Text: "next"}}}, } md := SerializeKanban(cols) - if !strings.Contains(md, "## Done\n- [x] shipped") { + if !strings.Contains(md, "## Done\n- [x] [feature] shipped") { t.Errorf("Done column should auto-mark cards: %q", md) } if !strings.Contains(md, "## Todo\n- next") { diff --git a/internal/block/priority_test.go b/internal/block/priority_test.go index 054ba0d..ae51a1d 100644 --- a/internal/block/priority_test.go +++ b/internal/block/priority_test.go @@ -149,3 +149,43 @@ func TestChecklistPriorityRoundTrip(t *testing.T) { }) } } + +func TestKanbanTagMarker(t *testing.T) { + tests := []struct { + input string + wantTag KanbanTag + wantBody string + }{ + {"plain task", KanbanTagNone, "plain task"}, + {"[bug] broken", KanbanTagBug, "broken"}, + {"[enhancement] new", KanbanTagFeature, "new"}, + {"[docs] readme", KanbanTagDocumentation, "readme"}, + {"[unknown] keep literal", KanbanTagNone, "[unknown] keep literal"}, + {"[bug] ", KanbanTagBug, ""}, + {"[bug]no space", KanbanTagNone, "[bug]no space"}, + } + for _, tt := range tests { + gotTag, gotBody := ParseKanbanTagMarker(tt.input) + if gotTag != tt.wantTag || gotBody != tt.wantBody { + t.Errorf("ParseKanbanTagMarker(%q) = (%v, %q), want (%v, %q)", + tt.input, gotTag, gotBody, tt.wantTag, tt.wantBody) + } + } +} + +func TestKanbanTagNext(t *testing.T) { + got := KanbanTagNone + want := []KanbanTag{ + KanbanTagBug, + KanbanTagFeature, + KanbanTagDocumentation, + KanbanTagQuestion, + KanbanTagNone, + } + for i, w := range want { + got = got.Next() + if got != w { + t.Errorf("step %d: got %v, want %v", i, got, w) + } + } +} diff --git a/internal/editor/kanban.go b/internal/editor/kanban.go index cc6815b..b5894fc 100644 --- a/internal/editor/kanban.go +++ b/internal/editor/kanban.go @@ -317,6 +317,15 @@ func (ks *kanbanState) togglePriority() { c.Priority = c.Priority.Next() } +// toggleTag cycles the issue label tag of the selected card. +func (ks *kanbanState) toggleTag() { + c := ks.selectedCard() + if c == nil { + return + } + c.Tag = c.Tag.Next() +} + // sortByPriority sorts cards within each column by priority descending // (HIGH → MED → LOW → none) using a stable sort, so cards of the same // priority keep their relative order. Selection follows the focused card @@ -454,9 +463,9 @@ func (m Model) selectedCardLineRange() (top, bottom int) { if taLines < 1 { taLines = 1 } - // 2 border + priority badge (1 if present). + // 2 border + metadata header (1 if priority or tag is present). extra := 2 - if cards[m.kanban.card].Priority != block.PriorityNone { + if cards[m.kanban.card].Priority != block.PriorityNone || cards[m.kanban.card].Tag != block.KanbanTagNone { extra++ } height = taLines + extra @@ -513,8 +522,8 @@ func cardRenderHeight(card block.KanbanCard, contentWidth int) int { textLines = strings.Count(wrapped, "\n") + 1 } extra := 2 // top + bottom border - if card.Priority != block.PriorityNone { - extra++ // priority header line + if card.Priority != block.PriorityNone || card.Tag != block.KanbanTagNone { + extra++ // metadata header line } return textLines + extra } @@ -559,19 +568,25 @@ func renderKanbanCard(card block.KanbanCard, outerWidth int, selected, editing b text = format.RenderInlineMarkdown(wrapPlain(card.Text, contentWidth)) } - // Priority badge on its own short header line. - header := "" + // Metadata badges on their own short header line. + var headerParts []string if m := card.Priority.Marker(); m != "" { color := priorityColor(card.Priority, th) bs := lipgloss.NewStyle().Foreground(lipgloss.Color(color)) if card.Priority == block.PriorityHigh { bs = bs.Bold(true) } - header = bs.Render(m) + headerParts = append(headerParts, bs.Render(m)) + } + if m := card.Tag.Marker(); m != "" { + headerParts = append(headerParts, lipgloss.NewStyle(). + Foreground(lipgloss.Color(th.Accent)). + Render(m)) } body := text - if header != "" { + if len(headerParts) > 0 { + header := strings.Join(headerParts, " ") body = header + "\n" + text } return style.Render(body) @@ -817,6 +832,13 @@ func (m *Model) handleKanbanKey(msg tea.KeyPressMsg) (handled bool, cmd tea.Cmd) m.kanban.editTA, c = m.kanban.editTA.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m.kanban.editTA.SetHeight(m.kanban.editTA.VisualLineCount()) return true, c + case "ctrl+t": + if c := m.kanban.selectedCard(); c != nil { + c.Text = strings.TrimRight(m.kanban.editTA.Value(), "\n") + m.pushUndo() + m.kanban.toggleTag() + } + return true, nil case "ctrl+s": // Let main handler save; first commit edit so latest text is in state. m.kanban.commitEdit() @@ -986,6 +1008,12 @@ func (m *Model) handleKanbanKey(msg tea.KeyPressMsg) (handled bool, cmd tea.Cmd) } } return true, nil + case "ctrl+t": + if m.kanban.selectedCard() != nil { + m.pushUndo() + m.kanban.toggleTag() + } + return true, nil case "s": // Toggle priority sort for the whole document and persist. m.pushUndo() diff --git a/internal/editor/kanban_test.go b/internal/editor/kanban_test.go index 7f06639..138292a 100644 --- a/internal/editor/kanban_test.go +++ b/internal/editor/kanban_test.go @@ -70,6 +70,8 @@ func keyMsgFromString(key string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: 'p', Text: "p"} case "s": return tea.KeyPressMsg{Code: 's', Text: "s"} + case "ctrl+t": + return tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl} case "space": return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} case "x": @@ -177,6 +179,43 @@ func TestKanbanPriorityCycle(t *testing.T) { } } +func TestKanbanTagCycle(t *testing.T) { + m := newKanbanEditor(t) + m = pressKey(m, "ctrl+t") + if c := m.kanban.selectedCard(); c == nil || c.Tag != block.KanbanTagBug { + t.Errorf("after one ctrl+t: tag = %v, want Bug", c.Tag) + } + m = pressKey(m, "ctrl+t") + if c := m.kanban.selectedCard(); c == nil || c.Tag != block.KanbanTagFeature { + t.Errorf("after two ctrl+t: tag = %v, want Feature", c.Tag) + } + for i := 0; i < 3; i++ { + m = pressKey(m, "ctrl+t") + } + if c := m.kanban.selectedCard(); c == nil || c.Tag != block.KanbanTagNone { + t.Errorf("after full cycle: tag = %v, want None", c.Tag) + } +} + +func TestKanbanTagCycleWhileEditing(t *testing.T) { + m := newKanbanEditor(t) + m = pressKey(m, "enter") + if !m.kanban.edit { + t.Fatalf("enter should put card in edit mode") + } + m.kanban.editTA.SetValue("Edited A") + m = pressKey(m, "ctrl+t") + if !m.kanban.edit { + t.Fatalf("ctrl+t should keep card in edit mode") + } + if c := m.kanban.selectedCard(); c == nil || c.Tag != block.KanbanTagBug || c.Text != "Edited A" { + t.Errorf("card after ctrl+t in edit = %+v, want bug tag and edited text", c) + } + if got := m.kanban.editTA.Value(); got != "Edited A" { + t.Errorf("edit textarea = %q, want Edited A", got) + } +} + func TestKanbanAddCard(t *testing.T) { m := newKanbanEditor(t) before := len(m.kanban.cols[0].Cards)