Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
105 changes: 89 additions & 16 deletions internal/block/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 9 additions & 2 deletions internal/block/kanban.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import "strings"
type KanbanCard struct {
Text string
Priority Priority
Tag KanbanTag
Done bool
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -89,14 +91,16 @@ 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]
}
current.Cards = append(current.Cards, KanbanCard{
Text: text,
Priority: prio,
Tag: tag,
Done: done,
})
lastCard = &current.Cards[len(current.Cards)-1]
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 32 additions & 4 deletions internal/block/kanban_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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},
Expand Down Expand Up @@ -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") {
Expand Down
40 changes: 40 additions & 0 deletions internal/block/priority_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
44 changes: 36 additions & 8 deletions internal/editor/kanban.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading