diff --git a/cmd/promote.go b/cmd/promote.go index 37d60b0..7b99927 100644 --- a/cmd/promote.go +++ b/cmd/promote.go @@ -1,11 +1,9 @@ package cmd import ( - "encoding/json" "fmt" - "regexp" - "strings" + "github.com/lerko/nib/internal/carddata" "github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/display" "github.com/spf13/cobra" @@ -47,9 +45,9 @@ func runPromote(_ *cobra.Command, args []string) error { return err } - cardData := generateCardData(cardType, e.Body) + cd := carddata.GenerateCardData(cardType, e.Body) - if err := store.Promote(id, cardType, cardData); err != nil { + if err := store.Promote(id, cardType, cd); err != nil { if err == db.ErrAlreadyPromoted { return fmt.Errorf("invalid_promote — entity %s is already a %s", display.FormatID(id), *e.CardType) @@ -60,74 +58,3 @@ func runPromote(_ *cobra.Command, args []string) error { fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType) return nil } - -var templateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`) - -func generateCardData(ct db.CardType, body string) *string { - var data string - switch ct { - case db.CardTemplate: - matches := templateSlotRe.FindAllStringSubmatch(body, -1) - type slot struct { - Name string `json:"name"` - Default string `json:"default"` - } - var slots []slot - seen := map[string]bool{} - for _, m := range matches { - name := m[1] - if !seen[name] { - slots = append(slots, slot{Name: name, Default: ""}) - seen[name] = true - } - } - if slots == nil { - slots = []slot{} - } - b, _ := json.Marshal(map[string]any{"slots": slots}) - data = string(b) - - case db.CardChecklist: - type step struct { - Text string `json:"text"` - Done bool `json:"done"` - } - var steps []step - for _, line := range strings.Split(body, "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") { - text := strings.TrimSpace(line[3:]) - done := strings.HasPrefix(line, "[x]") - steps = append(steps, step{Text: text, Done: done}) - } - } - if steps == nil { - steps = []step{{Text: body, Done: false}} - } - b, _ := json.Marshal(map[string]any{"steps": steps}) - data = string(b) - - case db.CardDecision: - b, _ := json.Marshal(map[string]any{ - "chose": "", - "why": "", - "rejected": []string{}, - }) - data = string(b) - - case db.CardLink: - url := "" - for _, word := range strings.Fields(body) { - if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") { - url = word - break - } - } - b, _ := json.Marshal(map[string]any{"url": url}) - data = string(b) - - default: - data = "{}" - } - return &data -} diff --git a/internal/carddata/carddata.go b/internal/carddata/carddata.go new file mode 100644 index 0000000..aba9607 --- /dev/null +++ b/internal/carddata/carddata.go @@ -0,0 +1,102 @@ +package carddata + +import ( + "encoding/json" + "regexp" + "strings" + + "github.com/lerko/nib/internal/db" +) + +var TemplateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`) + +func GenerateCardData(ct db.CardType, body string) *string { + var data string + switch ct { + case db.CardTemplate: + matches := TemplateSlotRe.FindAllStringSubmatch(body, -1) + type slot struct { + Name string `json:"name"` + Default string `json:"default"` + } + var slots []slot + seen := map[string]bool{} + for _, m := range matches { + name := m[1] + if !seen[name] { + slots = append(slots, slot{Name: name, Default: ""}) + seen[name] = true + } + } + if slots == nil { + slots = []slot{} + } + b, _ := json.Marshal(map[string]any{"slots": slots}) + data = string(b) + + case db.CardChecklist: + type step struct { + Text string `json:"text"` + Done bool `json:"done"` + } + var steps []step + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") { + text := strings.TrimSpace(line[3:]) + done := strings.HasPrefix(line, "[x]") + steps = append(steps, step{Text: text, Done: done}) + } + } + if steps == nil { + steps = []step{{Text: body, Done: false}} + } + b, _ := json.Marshal(map[string]any{"steps": steps}) + data = string(b) + + case db.CardDecision: + b, _ := json.Marshal(map[string]any{ + "chose": "", + "why": "", + "rejected": []string{}, + }) + data = string(b) + + case db.CardLink: + url := "" + for _, word := range strings.Fields(body) { + if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") { + url = word + break + } + } + b, _ := json.Marshal(map[string]any{"url": url}) + data = string(b) + + default: + data = "{}" + } + return &data +} + +func DetectCardType(body string) *db.CardType { + if TemplateSlotRe.MatchString(body) { + ct := db.CardTemplate + return &ct + } + if strings.Contains(body, "chose:") || strings.Contains(body, "why:") { + ct := db.CardDecision + return &ct + } + if strings.Contains(body, "[ ]") || strings.Contains(body, "[x]") { + ct := db.CardChecklist + return &ct + } + for _, word := range strings.Fields(body) { + if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") { + ct := db.CardLink + return &ct + } + } + return nil +} diff --git a/cmd/promote_test.go b/internal/carddata/carddata_test.go similarity index 86% rename from cmd/promote_test.go rename to internal/carddata/carddata_test.go index b200278..86a7964 100644 --- a/cmd/promote_test.go +++ b/internal/carddata/carddata_test.go @@ -1,4 +1,4 @@ -package cmd +package carddata import ( "encoding/json" @@ -8,14 +8,14 @@ import ( ) func TestGenerateCardData_Snippet(t *testing.T) { - data := generateCardData(db.CardSnippet, "some snippet") + data := GenerateCardData(db.CardSnippet, "some snippet") if data == nil || *data != "{}" { t.Errorf("snippet should produce {}, got %v", data) } } func TestGenerateCardData_Template(t *testing.T) { - data := generateCardData(db.CardTemplate, "deploy ${host} to ${env}") + data := GenerateCardData(db.CardTemplate, "deploy ${host} to ${env}") if data == nil { t.Fatal("expected non-nil data") } @@ -41,7 +41,7 @@ func TestGenerateCardData_Template(t *testing.T) { } func TestGenerateCardData_TemplateDedupe(t *testing.T) { - data := generateCardData(db.CardTemplate, "${x} and ${x}") + data := GenerateCardData(db.CardTemplate, "${x} and ${x}") var parsed struct { Slots []struct { Name string `json:"name"` @@ -54,7 +54,7 @@ func TestGenerateCardData_TemplateDedupe(t *testing.T) { } func TestGenerateCardData_TemplateNoSlots(t *testing.T) { - data := generateCardData(db.CardTemplate, "no placeholders here") + data := GenerateCardData(db.CardTemplate, "no placeholders here") var parsed struct { Slots []struct { Name string `json:"name"` @@ -68,7 +68,7 @@ func TestGenerateCardData_TemplateNoSlots(t *testing.T) { func TestGenerateCardData_Checklist(t *testing.T) { body := "[ ] step one\n[x] step two\n[ ] step three" - data := generateCardData(db.CardChecklist, body) + data := GenerateCardData(db.CardChecklist, body) if data == nil { t.Fatal("expected non-nil data") } @@ -94,7 +94,7 @@ func TestGenerateCardData_Checklist(t *testing.T) { } func TestGenerateCardData_ChecklistFallback(t *testing.T) { - data := generateCardData(db.CardChecklist, "no checkbox syntax") + data := GenerateCardData(db.CardChecklist, "no checkbox syntax") var parsed struct { Steps []struct { Text string `json:"text"` @@ -111,7 +111,7 @@ func TestGenerateCardData_ChecklistFallback(t *testing.T) { } func TestGenerateCardData_Decision(t *testing.T) { - data := generateCardData(db.CardDecision, "which db?") + data := GenerateCardData(db.CardDecision, "which db?") var parsed struct { Chose string `json:"chose"` Why string `json:"why"` @@ -129,7 +129,7 @@ func TestGenerateCardData_Decision(t *testing.T) { } func TestGenerateCardData_Link(t *testing.T) { - data := generateCardData(db.CardLink, "check https://example.com/path for details") + data := GenerateCardData(db.CardLink, "check https://example.com/path for details") var parsed struct { URL string `json:"url"` } @@ -140,7 +140,7 @@ func TestGenerateCardData_Link(t *testing.T) { } func TestGenerateCardData_LinkNoURL(t *testing.T) { - data := generateCardData(db.CardLink, "no url here") + data := GenerateCardData(db.CardLink, "no url here") var parsed struct { URL string `json:"url"` } diff --git a/internal/tui/cards.go b/internal/tui/cards.go new file mode 100644 index 0000000..f08038a --- /dev/null +++ b/internal/tui/cards.go @@ -0,0 +1,262 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" +) + +type intent int + +const ( + intentAll intent = iota + intentGrab + intentRead + intentFill +) + +func (i intent) String() string { + switch i { + case intentGrab: + return "grab" + case intentRead: + return "read" + case intentFill: + return "fill" + default: + return "all" + } +} + +func (i intent) next() intent { + switch i { + case intentAll: + return intentGrab + case intentGrab: + return intentRead + case intentRead: + return intentFill + default: + return intentAll + } +} + +func matchesIntent(e *db.Entity, i intent) bool { + if i == intentAll { + return true + } + ct := e.CardType + if ct == nil { + return i == intentGrab + } + switch i { + case intentGrab: + return *ct == db.CardSnippet + case intentRead: + return *ct == db.CardNote || *ct == db.CardLink || *ct == db.CardDecision + case intentFill: + return *ct == db.CardTemplate || *ct == db.CardChecklist + } + return false +} + +type cardsModel struct { + entities []*db.Entity + filtered []*db.Entity + cursor int + offset int + height int + width int + intent intent +} + +func newCardsModel() cardsModel { + return cardsModel{} +} + +func (c *cardsModel) setEntities(entities []*db.Entity) { + c.entities = entities + c.applyFilter() +} + +func (c *cardsModel) setIntent(i intent) { + c.intent = i + c.cursor = 0 + c.offset = 0 + c.applyFilter() +} + +func (c *cardsModel) applyFilter() { + c.filtered = nil + var pinned, rest []*db.Entity + for _, e := range c.entities { + if !matchesIntent(e, c.intent) { + continue + } + if e.Pinned { + pinned = append(pinned, e) + } else { + rest = append(rest, e) + } + } + c.filtered = append(pinned, rest...) + if c.cursor >= len(c.filtered) { + c.cursor = max(0, len(c.filtered)-1) + } +} + +func (c *cardsModel) setSize(width, height int) { + c.width = width + c.height = height +} + +func (c cardsModel) selected() *db.Entity { + if len(c.filtered) == 0 || c.cursor >= len(c.filtered) { + return nil + } + return c.filtered[c.cursor] +} + +func (c cardsModel) update(msg tea.KeyMsg) cardsModel { + switch msg.String() { + case "up", "k": + if c.cursor > 0 { + c.cursor-- + if c.cursor < c.offset { + c.offset = c.cursor + } + } + case "down", "j": + if c.cursor < len(c.filtered)-1 { + c.cursor++ + visible := c.visibleCount() + if c.cursor >= c.offset+visible { + c.offset = c.cursor - visible + 1 + } + } + case "home", "g": + c.cursor = 0 + c.offset = 0 + case "end", "G": + c.cursor = max(0, len(c.filtered)-1) + visible := c.visibleCount() + if c.cursor >= visible { + c.offset = c.cursor - visible + 1 + } + case "pgup", "ctrl+u": + c.cursor = max(0, c.cursor-c.visibleCount()) + if c.cursor < c.offset { + c.offset = c.cursor + } + case "pgdown", "ctrl+d": + c.cursor = min(len(c.filtered)-1, c.cursor+c.visibleCount()) + visible := c.visibleCount() + if c.cursor >= c.offset+visible { + c.offset = c.cursor - visible + 1 + } + } + return c +} + +func (c cardsModel) view(width int) string { + if len(c.filtered) == 0 { + return statusStyle.Render("no cards") + } + + var b strings.Builder + visible := c.visibleCount() + end := min(c.offset+visible, len(c.filtered)) + + for i := c.offset; i < end; i++ { + e := c.filtered[i] + line := renderCard(e, width-4) + + if i == c.cursor { + b.WriteString(selectedItemStyle.Render(" " + line)) + } else { + b.WriteString(listItemStyle.Render(line)) + } + if i < end-1 { + b.WriteString("\n") + } + } + + return b.String() +} + +func (c cardsModel) visibleCount() int { + if c.height <= 0 { + return 20 + } + return c.height +} + +func renderCard(e *db.Entity, maxWidth int) string { + glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType)) + id := idStyle.Render("[" + display.FormatID(e.ID) + "]") + + body := e.Body + if e.Title != nil { + body = *e.Title + } + + affordance := detectAffordance(e) + affordStr := "" + if affordance != "" { + affordStr = " " + affordanceStyle.Render(affordance) + } + + var extras []string + if e.Pinned { + extras = append(extras, pinnedStyle.Render("•")) + } + if len(e.Tags) > 0 { + limit := min(2, len(e.Tags)) + for _, t := range e.Tags[:limit] { + extras = append(extras, tagStyle.Render("#"+t)) + } + } + + extraStr := "" + if len(extras) > 0 { + extraStr = " " + strings.Join(extras, " ") + } + + useStr := "" + if e.UseCount > 0 { + useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount)) + } + + line := fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id) + + if maxWidth > 0 && len(stripAnsi(line)) > maxWidth { + body = truncate(body, maxWidth-30) + line = fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id) + } + + return line +} + +func detectAffordance(e *db.Entity) string { + if e.CardType == nil { + return "" + } + switch *e.CardType { + case db.CardSnippet: + return "code" + case db.CardTemplate: + return "fill" + case db.CardChecklist: + return "steps" + case db.CardDecision: + return "decide" + case db.CardLink: + return "link" + default: + return "" + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 5b0ae8e..ee98959 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -8,6 +8,7 @@ import ( "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" + "github.com/lerko/nib/internal/carddata" "github.com/lerko/nib/internal/db" ) @@ -29,7 +30,8 @@ type entityUpdatedMsg struct { } type entityPromotedMsg struct { - id string + id string + cardType db.CardType } type entityDemotedMsg struct { @@ -122,12 +124,13 @@ func pinEntity(store *db.Store, e *db.Entity) tea.Cmd { } } -func promoteEntity(store *db.Store, id string) tea.Cmd { +func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd { return func() tea.Msg { - if err := store.Promote(id, db.CardSnippet, nil); err != nil { + cd := carddata.GenerateCardData(ct, body) + if err := store.Promote(id, ct, cd); err != nil { return errMsg{err} } - return entityPromotedMsg{id} + return entityPromotedMsg{id, ct} } } diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 2bf1073..1cc8376 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -54,6 +54,9 @@ func (d detailModel) view(width int) string { glyph := display.DisplayGlyph(e.Glyph, e.CardType) header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID)) + if e.CardType != nil { + header += " " + affordanceStyle.Render(string(*e.CardType)) + } b.WriteString(detailHeaderStyle.Render(header)) b.WriteString("\n\n") @@ -65,6 +68,14 @@ func (d detailModel) view(width int) string { b.WriteString(detailBodyStyle.Render(e.Body)) b.WriteString("\n") + if e.CardType != nil { + cardSection := renderCardData(e) + if cardSection != "" { + b.WriteString("\n") + b.WriteString(cardSection) + } + } + if len(e.Tags) > 0 { tagParts := make([]string, len(e.Tags)) for i, t := range e.Tags { @@ -84,11 +95,14 @@ func (d detailModel) view(width int) string { meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor) } if e.Pinned { - meta += "\npinned" + meta += "\n" + pinnedStyle.Render("pinned") } if e.CardType != nil { meta += fmt.Sprintf("\ncard %s", *e.CardType) } + if e.UseCount > 0 { + meta += fmt.Sprintf("\nused %d×", e.UseCount) + } if e.CompletedAt != nil { meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime)) } @@ -104,3 +118,87 @@ func (d detailModel) view(width int) string { return strings.Join(lines, "\n") } + +func renderCardData(e *db.Entity) string { + if e.CardData == nil { + return "" + } + + data, err := e.CardDataJSON() + if err != nil || data == nil { + return "" + } + + var b strings.Builder + + switch *e.CardType { + case db.CardChecklist: + steps, ok := data["steps"].([]interface{}) + if !ok { + break + } + done := 0 + for _, s := range steps { + step, ok := s.(map[string]interface{}) + if !ok { + continue + } + text, _ := step["text"].(string) + isDone, _ := step["done"].(bool) + if isDone { + done++ + b.WriteString(" " + checkDoneStyle.Render("[✓] "+text) + "\n") + } else { + b.WriteString(" " + checkPendingStyle.Render("[ ] "+text) + "\n") + } + } + progress := fmt.Sprintf(" %d/%d steps", done, len(steps)) + b.WriteString(detailLabelStyle.Render(progress)) + + case db.CardTemplate: + slots, ok := data["slots"].([]interface{}) + if !ok { + break + } + b.WriteString(detailLabelStyle.Render(" slots:") + "\n") + for _, s := range slots { + slot, ok := s.(map[string]interface{}) + if !ok { + continue + } + name, _ := slot["name"].(string) + def, _ := slot["default"].(string) + line := " ${" + name + "}" + if def != "" { + line += " " + detailValueStyle.Render("default: "+def) + } + b.WriteString(line + "\n") + } + + case db.CardDecision: + if chose, ok := data["chose"].(string); ok && chose != "" { + b.WriteString(" " + detailLabelStyle.Render("chose: ") + detailValueStyle.Render(chose) + "\n") + } + if why, ok := data["why"].(string); ok && why != "" { + b.WriteString(" " + detailLabelStyle.Render("why: ") + detailValueStyle.Render(why) + "\n") + } + if rejected, ok := data["rejected"].([]interface{}); ok && len(rejected) > 0 { + items := make([]string, 0, len(rejected)) + for _, r := range rejected { + if s, ok := r.(string); ok { + items = append(items, s) + } + } + if len(items) > 0 { + b.WriteString(" " + detailLabelStyle.Render("rejected: ") + detailValueStyle.Render(strings.Join(items, ", ")) + "\n") + } + } + + case db.CardLink: + if url, ok := data["url"].(string); ok && url != "" { + b.WriteString(" " + detailLabelStyle.Render("↗ ") + detailValueStyle.Render(url) + "\n") + } + } + + return b.String() +} diff --git a/internal/tui/help.go b/internal/tui/help.go index 74d565c..bc50754 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -12,7 +12,13 @@ func renderHelp(width, height int) string { {"g/G home/end", "top / bottom"}, {"pgup/pgdn", "page up / down"}, {"enter", "view detail"}, - {"esc", "back / cancel"}, + {"esc", "back / clear filter"}, + }}, + {"Views", [][2]string{ + {"1", "stream view"}, + {"2", "cards view"}, + {"s", "cycle sort (cards)"}, + {"tab", "cycle intent (cards)"}, }}, {"Actions", [][2]string{ {"a", "add entity"}, @@ -20,6 +26,7 @@ func renderHelp(width, height int) string { {"x", "toggle todo completion"}, {"!", "toggle pin"}, {"#", "filter by tag"}, + {"p", "promote to card"}, }}, {"Detail View", [][2]string{ {"p", "promote to card"}, diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 708599c..19b26fb 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -22,6 +22,10 @@ type keyMap struct { Demote key.Binding Copy key.Binding Edit key.Binding + Stream key.Binding + Cards key.Binding + Sort key.Binding + Intent key.Binding } var keys = keyMap{ @@ -44,4 +48,8 @@ var keys = keyMap{ Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")), Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")), Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), + Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")), + Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")), + Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), + Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")), } diff --git a/internal/tui/model.go b/internal/tui/model.go index 8696fbe..90240ab 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,6 +1,8 @@ package tui import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/lerko/nib/internal/db" @@ -14,22 +16,64 @@ const ( stateInput stateTagFilter stateConfirm + statePromote ) +type viewMode int + +const ( + modeStream viewMode = iota + modeCards +) + +type cardsSort int + +const ( + sortNewest cardsSort = iota + sortOldest + sortMostUsed +) + +func (s cardsSort) String() string { + switch s { + case sortOldest: + return "oldest" + case sortMostUsed: + return "most used" + default: + return "newest" + } +} + +func (s cardsSort) next() cardsSort { + switch s { + case sortNewest: + return sortOldest + case sortOldest: + return sortMostUsed + default: + return sortNewest + } +} + type model struct { store *db.Store state viewState + mode viewMode width int height int list listModel + cards cardsModel detail detailModel input inputModel filter filterModel + promote promoteModel showHelp bool filterTag string confirmID string + cardsSort cardsSort status string err error @@ -39,7 +83,9 @@ func newModel(store *db.Store) model { return model{ store: store, state: stateList, + mode: modeStream, list: newListModel(), + cards: newCardsModel(), detail: newDetailModel(), input: newInputModel(), filter: newFilterModel(), @@ -55,6 +101,20 @@ func (m model) listParams() db.ListParams { if m.filterTag != "" { p.Tag = &m.filterTag } + if m.mode == modeCards { + p.CardsOnly = true + switch m.cardsSort { + case sortNewest: + p.Sort = "created" + p.Order = "desc" + case sortOldest: + p.Sort = "created" + p.Order = "asc" + case sortMostUsed: + p.Sort = "use_count" + p.Order = "desc" + } + } return p } @@ -64,12 +124,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.list.setSize(m.width, m.contentHeight()) + m.cards.setSize(m.width, m.contentHeight()) m.detail.setSize(m.width, m.contentHeight()) m.filter.setHeight(m.contentHeight()) return m, nil case entitiesLoadedMsg: - m.list.setEntities(msg.entities) + if m.mode == modeCards { + m.cards.setEntities(msg.entities) + } else { + m.list.setEntities(msg.entities) + } m.err = nil return m, nil @@ -92,8 +157,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, loadEntities(m.store, m.listParams()) case entityPromotedMsg: - m.status = "promoted → snippet" - return m, m.reloadDetail(msg.id) + m.status = fmt.Sprintf("promoted → %s", msg.cardType) + m.state = stateList + return m, loadEntities(m.store, m.listParams()) case entityDemotedMsg: m.status = "demoted → fluid" @@ -136,6 +202,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateTagFilter(msg) case stateConfirm: return m.updateConfirm(msg) + case statePromote: + return m.updatePromote(msg) default: return m.updateKeys(msg) } @@ -166,6 +234,39 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.showHelp = true return m, nil + case "1": + if m.mode != modeStream { + m.mode = modeStream + m.state = stateList + m.status = "" + return m, loadEntities(m.store, m.listParams()) + } + return m, nil + + case "2": + if m.mode != modeCards { + m.mode = modeCards + m.state = stateList + m.status = "" + return m, loadEntities(m.store, m.listParams()) + } + return m, nil + + case "s": + if m.mode == modeCards && m.state == stateList { + m.cardsSort = m.cardsSort.next() + m.status = "sort: " + m.cardsSort.String() + return m, loadEntities(m.store, m.listParams()) + } + return m, nil + + case "tab": + if m.mode == modeCards && m.state == stateList { + m.cards.setIntent(m.cards.intent.next()) + return m, nil + } + return m, nil + case "a": if m.state == stateList { m.state = stateInput @@ -187,7 +288,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter": if m.state == stateList { - if e := m.list.selected(); e != nil { + if e := m.selectedEntity(); e != nil { m.detail.setEntity(e) m.state = stateDetail } @@ -196,7 +297,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "d": if m.state == stateList { - if e := m.list.selected(); e != nil { + if e := m.selectedEntity(); e != nil { m.confirmID = e.ID m.state = stateConfirm return m, confirmTimeout() @@ -206,7 +307,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "x": if m.state == stateList { - if e := m.list.selected(); e != nil && e.Glyph == db.GlyphTodo { + if e := m.selectedEntity(); e != nil && e.Glyph == db.GlyphTodo { return m, toggleTodo(m.store, e) } } @@ -231,12 +332,15 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "p": - if m.state == stateDetail && m.detail.entity != nil { - if m.detail.entity.CardType != nil { + e := m.selectedEntity() + if e != nil { + if e.CardType != nil { m.status = "already a card" return m, nil } - return m, promoteEntity(m.store, m.detail.entity.ID) + m.promote = newPromoteModel(e.ID, e.Body) + m.state = statePromote + return m, nil } return m, nil @@ -265,7 +369,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch m.state { case stateList: - m.list = m.list.update(msg) + if m.mode == modeCards { + m.cards = m.cards.update(msg) + } else { + m.list = m.list.update(msg) + } case stateDetail: m.detail = m.detail.update(msg) } @@ -319,6 +427,20 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "q": + m.state = stateList + return m, nil + case "enter": + ct := m.promote.selectedType() + return m, promoteEntity(m.store, m.promote.entityID, ct, m.promote.body) + default: + m.promote = m.promote.update(msg.String()) + return m, nil + } +} + func (m model) View() string { if m.showHelp { return renderHelp(m.width, m.height) @@ -327,21 +449,47 @@ func (m model) View() string { var content string switch m.state { case stateList, stateInput, stateConfirm: - content = m.list.view(m.width) + if m.mode == modeCards { + content = m.cards.view(m.width) + } else { + content = m.list.view(m.width) + } case stateDetail: content = m.detail.view(m.width) case stateTagFilter: content = m.filter.view(m.width) + case statePromote: + content = m.promote.view(m.width) } + header := m.headerView() + footer := m.footerView() + + return header + "\n" + content + "\n" + footer +} + +func (m model) headerView() string { header := titleStyle.Render("nib") + + modeName := "stream" + if m.mode == modeCards { + modeName = "cards" + } + header += " " + modeStyle.Render(modeName) + if m.filterTag != "" { header += " " + filterPillStyle.Render("#"+m.filterTag) } - footer := m.footerView() + if m.mode == modeCards && m.cards.intent != intentAll { + header += " " + affordanceStyle.Render(m.cards.intent.String()) + } - return header + "\n" + content + "\n" + footer + if m.mode == modeCards { + header += " " + idStyle.Render("("+m.cardsSort.String()+")") + } + + return header } func (m model) footerView() string { @@ -369,13 +517,14 @@ func (m model) contentHeight() int { } func (m model) selectedEntity() *db.Entity { - switch m.state { - case stateList: - return m.list.selected() - case stateDetail: + switch { + case m.state == stateDetail: return m.detail.entity + case m.mode == modeCards: + return m.cards.selected() + default: + return m.list.selected() } - return nil } func (m model) reloadDetail(id string) tea.Cmd { diff --git a/internal/tui/promote.go b/internal/tui/promote.go new file mode 100644 index 0000000..ed47598 --- /dev/null +++ b/internal/tui/promote.go @@ -0,0 +1,84 @@ +package tui + +import ( + "github.com/lerko/nib/internal/carddata" + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" +) + +type promoteOption struct { + cardType db.CardType + label string + group string +} + +var promoteOptions = []promoteOption{ + {db.CardSnippet, "snippet", "grab"}, + {db.CardNote, "note", "read"}, + {db.CardLink, "link", "read"}, + {db.CardDecision, "decision", "read"}, + {db.CardTemplate, "template", "fill"}, + {db.CardChecklist, "checklist", "fill"}, +} + +type promoteModel struct { + cursor int + entityID string + body string + suggested *db.CardType +} + +func newPromoteModel(entityID, body string) promoteModel { + return promoteModel{ + entityID: entityID, + body: body, + suggested: carddata.DetectCardType(body), + } +} + +func (p promoteModel) selectedType() db.CardType { + return promoteOptions[p.cursor].cardType +} + +func (p promoteModel) update(key string) promoteModel { + switch key { + case "up", "k": + if p.cursor > 0 { + p.cursor-- + } + case "down", "j": + if p.cursor < len(promoteOptions)-1 { + p.cursor++ + } + } + return p +} + +func (p promoteModel) view(width int) string { + var b string + b += titleStyle.Render("promote to card") + "\n\n" + + currentGroup := "" + for i, opt := range promoteOptions { + if opt.group != currentGroup { + currentGroup = opt.group + b += dateHeaderStyle.Render("── "+currentGroup+" ──") + "\n" + } + + glyph := display.DisplayGlyph(db.GlyphNote, &opt.cardType) + label := glyph + " " + opt.label + + if p.suggested != nil && *p.suggested == opt.cardType { + label += " " + affordanceStyle.Render("*") + } + + if i == p.cursor { + b += selectedItemStyle.Render(" "+label) + "\n" + } else { + b += listItemStyle.Render(label) + "\n" + } + } + + b += "\n" + helpStyle.Render("enter:select esc:cancel") + return b +} diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index fca7441..d102bf2 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -23,7 +23,12 @@ func renderStatusBar(m model, width int) string { } func countText(m model) string { - total := len(m.list.entities) + var total int + if m.mode == modeCards { + total = len(m.cards.filtered) + } else { + total = len(m.list.entities) + } if m.filterTag != "" { return fmt.Sprintf("%d entities #%s", total, m.filterTag) } @@ -40,7 +45,12 @@ func contextHints(m model) string { return "j/k:nav enter:select esc:cancel" case stateConfirm: return "y:confirm n:cancel" + case statePromote: + return "j/k:nav enter:select esc:cancel" default: - return "a:add d:del x:todo #:filter ?:help q:quit" + if m.mode == modeCards { + return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit" + } + return "1:stream 2:cards a:add d:del x:todo #:filter ?:help q:quit" } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index e4f31d7..fbad368 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -77,4 +77,28 @@ var ( helpDescStyle = lipgloss.NewStyle(). Foreground(dim) + + affordanceStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#5B8EF0", Dark: "#7AAFFF"}). + Bold(true) + + useCountStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#B07D3A", Dark: "#D4A54A"}) + + modeStyle = lipgloss.NewStyle(). + Foreground(dim). + Bold(true) + + detailLabelStyle = lipgloss.NewStyle(). + Foreground(highlight). + Bold(true) + + detailValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#BBBBBB"}) + + checkDoneStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) + + checkPendingStyle = lipgloss.NewStyle(). + Foreground(dim) )