feat(tui): add cards view, mode switching, promote picker, and card detail

Stream/cards toggle with 1/2 keys. Cards view with intent filtering
(tab cycles grab/read/fill/all), sort cycling (s key), pinned-first
ordering, and affordance badges. Promote picker (p key) with card type
selection and auto-detection from body content. Detail view renders
card_data per type: checklist steps, template slots, decision fields,
link URLs.

Extracts generateCardData to internal/carddata for reuse across cmd
and tui packages.
This commit is contained in:
2026-05-17 21:14:14 -04:00
parent c2ea63dd16
commit ce335cabd6
12 changed files with 786 additions and 112 deletions
+3 -76
View File
@@ -1,11 +1,9 @@
package cmd package cmd
import ( import (
"encoding/json"
"fmt" "fmt"
"regexp"
"strings"
"github.com/lerko/nib/internal/carddata"
"github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display" "github.com/lerko/nib/internal/display"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -47,9 +45,9 @@ func runPromote(_ *cobra.Command, args []string) error {
return err 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 { if err == db.ErrAlreadyPromoted {
return fmt.Errorf("invalid_promote — entity %s is already a %s", return fmt.Errorf("invalid_promote — entity %s is already a %s",
display.FormatID(id), *e.CardType) 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) fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType)
return nil 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
}
+102
View File
@@ -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
}
@@ -1,4 +1,4 @@
package cmd package carddata
import ( import (
"encoding/json" "encoding/json"
@@ -8,14 +8,14 @@ import (
) )
func TestGenerateCardData_Snippet(t *testing.T) { func TestGenerateCardData_Snippet(t *testing.T) {
data := generateCardData(db.CardSnippet, "some snippet") data := GenerateCardData(db.CardSnippet, "some snippet")
if data == nil || *data != "{}" { if data == nil || *data != "{}" {
t.Errorf("snippet should produce {}, got %v", data) t.Errorf("snippet should produce {}, got %v", data)
} }
} }
func TestGenerateCardData_Template(t *testing.T) { 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 { if data == nil {
t.Fatal("expected non-nil data") t.Fatal("expected non-nil data")
} }
@@ -41,7 +41,7 @@ func TestGenerateCardData_Template(t *testing.T) {
} }
func TestGenerateCardData_TemplateDedupe(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 { var parsed struct {
Slots []struct { Slots []struct {
Name string `json:"name"` Name string `json:"name"`
@@ -54,7 +54,7 @@ func TestGenerateCardData_TemplateDedupe(t *testing.T) {
} }
func TestGenerateCardData_TemplateNoSlots(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 { var parsed struct {
Slots []struct { Slots []struct {
Name string `json:"name"` Name string `json:"name"`
@@ -68,7 +68,7 @@ func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
func TestGenerateCardData_Checklist(t *testing.T) { func TestGenerateCardData_Checklist(t *testing.T) {
body := "[ ] step one\n[x] step two\n[ ] step three" body := "[ ] step one\n[x] step two\n[ ] step three"
data := generateCardData(db.CardChecklist, body) data := GenerateCardData(db.CardChecklist, body)
if data == nil { if data == nil {
t.Fatal("expected non-nil data") t.Fatal("expected non-nil data")
} }
@@ -94,7 +94,7 @@ func TestGenerateCardData_Checklist(t *testing.T) {
} }
func TestGenerateCardData_ChecklistFallback(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 { var parsed struct {
Steps []struct { Steps []struct {
Text string `json:"text"` Text string `json:"text"`
@@ -111,7 +111,7 @@ func TestGenerateCardData_ChecklistFallback(t *testing.T) {
} }
func TestGenerateCardData_Decision(t *testing.T) { func TestGenerateCardData_Decision(t *testing.T) {
data := generateCardData(db.CardDecision, "which db?") data := GenerateCardData(db.CardDecision, "which db?")
var parsed struct { var parsed struct {
Chose string `json:"chose"` Chose string `json:"chose"`
Why string `json:"why"` Why string `json:"why"`
@@ -129,7 +129,7 @@ func TestGenerateCardData_Decision(t *testing.T) {
} }
func TestGenerateCardData_Link(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 { var parsed struct {
URL string `json:"url"` URL string `json:"url"`
} }
@@ -140,7 +140,7 @@ func TestGenerateCardData_Link(t *testing.T) {
} }
func TestGenerateCardData_LinkNoURL(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 { var parsed struct {
URL string `json:"url"` URL string `json:"url"`
} }
+262
View File
@@ -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 ""
}
}
+6 -3
View File
@@ -8,6 +8,7 @@ import (
"github.com/atotto/clipboard" "github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/carddata"
"github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/db"
) )
@@ -30,6 +31,7 @@ type entityUpdatedMsg struct {
type entityPromotedMsg struct { type entityPromotedMsg struct {
id string id string
cardType db.CardType
} }
type entityDemotedMsg struct { 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 { 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 errMsg{err}
} }
return entityPromotedMsg{id} return entityPromotedMsg{id, ct}
} }
} }
+99 -1
View File
@@ -54,6 +54,9 @@ func (d detailModel) view(width int) string {
glyph := display.DisplayGlyph(e.Glyph, e.CardType) glyph := display.DisplayGlyph(e.Glyph, e.CardType)
header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID)) 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(detailHeaderStyle.Render(header))
b.WriteString("\n\n") b.WriteString("\n\n")
@@ -65,6 +68,14 @@ func (d detailModel) view(width int) string {
b.WriteString(detailBodyStyle.Render(e.Body)) b.WriteString(detailBodyStyle.Render(e.Body))
b.WriteString("\n") b.WriteString("\n")
if e.CardType != nil {
cardSection := renderCardData(e)
if cardSection != "" {
b.WriteString("\n")
b.WriteString(cardSection)
}
}
if len(e.Tags) > 0 { if len(e.Tags) > 0 {
tagParts := make([]string, len(e.Tags)) tagParts := make([]string, len(e.Tags))
for i, t := range 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) meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor)
} }
if e.Pinned { if e.Pinned {
meta += "\npinned" meta += "\n" + pinnedStyle.Render("pinned")
} }
if e.CardType != nil { if e.CardType != nil {
meta += fmt.Sprintf("\ncard %s", *e.CardType) meta += fmt.Sprintf("\ncard %s", *e.CardType)
} }
if e.UseCount > 0 {
meta += fmt.Sprintf("\nused %d×", e.UseCount)
}
if e.CompletedAt != nil { if e.CompletedAt != nil {
meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime)) 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") 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()
}
+8 -1
View File
@@ -12,7 +12,13 @@ func renderHelp(width, height int) string {
{"g/G home/end", "top / bottom"}, {"g/G home/end", "top / bottom"},
{"pgup/pgdn", "page up / down"}, {"pgup/pgdn", "page up / down"},
{"enter", "view detail"}, {"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{ {"Actions", [][2]string{
{"a", "add entity"}, {"a", "add entity"},
@@ -20,6 +26,7 @@ func renderHelp(width, height int) string {
{"x", "toggle todo completion"}, {"x", "toggle todo completion"},
{"!", "toggle pin"}, {"!", "toggle pin"},
{"#", "filter by tag"}, {"#", "filter by tag"},
{"p", "promote to card"},
}}, }},
{"Detail View", [][2]string{ {"Detail View", [][2]string{
{"p", "promote to card"}, {"p", "promote to card"},
+8
View File
@@ -22,6 +22,10 @@ type keyMap struct {
Demote key.Binding Demote key.Binding
Copy key.Binding Copy key.Binding
Edit key.Binding Edit key.Binding
Stream key.Binding
Cards key.Binding
Sort key.Binding
Intent key.Binding
} }
var keys = keyMap{ var keys = keyMap{
@@ -44,4 +48,8 @@ var keys = keyMap{
Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")), Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")),
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")), Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")),
Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), 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")),
} }
+164 -15
View File
@@ -1,6 +1,8 @@
package tui package tui
import ( import (
"fmt"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/db"
@@ -14,22 +16,64 @@ const (
stateInput stateInput
stateTagFilter stateTagFilter
stateConfirm 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 { type model struct {
store *db.Store store *db.Store
state viewState state viewState
mode viewMode
width int width int
height int height int
list listModel list listModel
cards cardsModel
detail detailModel detail detailModel
input inputModel input inputModel
filter filterModel filter filterModel
promote promoteModel
showHelp bool showHelp bool
filterTag string filterTag string
confirmID string confirmID string
cardsSort cardsSort
status string status string
err error err error
@@ -39,7 +83,9 @@ func newModel(store *db.Store) model {
return model{ return model{
store: store, store: store,
state: stateList, state: stateList,
mode: modeStream,
list: newListModel(), list: newListModel(),
cards: newCardsModel(),
detail: newDetailModel(), detail: newDetailModel(),
input: newInputModel(), input: newInputModel(),
filter: newFilterModel(), filter: newFilterModel(),
@@ -55,6 +101,20 @@ func (m model) listParams() db.ListParams {
if m.filterTag != "" { if m.filterTag != "" {
p.Tag = &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 return p
} }
@@ -64,12 +124,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.list.setSize(m.width, m.contentHeight()) m.list.setSize(m.width, m.contentHeight())
m.cards.setSize(m.width, m.contentHeight())
m.detail.setSize(m.width, m.contentHeight()) m.detail.setSize(m.width, m.contentHeight())
m.filter.setHeight(m.contentHeight()) m.filter.setHeight(m.contentHeight())
return m, nil return m, nil
case entitiesLoadedMsg: case entitiesLoadedMsg:
if m.mode == modeCards {
m.cards.setEntities(msg.entities)
} else {
m.list.setEntities(msg.entities) m.list.setEntities(msg.entities)
}
m.err = nil m.err = nil
return m, 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()) return m, loadEntities(m.store, m.listParams())
case entityPromotedMsg: case entityPromotedMsg:
m.status = "promoted → snippet" m.status = fmt.Sprintf("promoted → %s", msg.cardType)
return m, m.reloadDetail(msg.id) m.state = stateList
return m, loadEntities(m.store, m.listParams())
case entityDemotedMsg: case entityDemotedMsg:
m.status = "demoted → fluid" m.status = "demoted → fluid"
@@ -136,6 +202,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateTagFilter(msg) return m.updateTagFilter(msg)
case stateConfirm: case stateConfirm:
return m.updateConfirm(msg) return m.updateConfirm(msg)
case statePromote:
return m.updatePromote(msg)
default: default:
return m.updateKeys(msg) return m.updateKeys(msg)
} }
@@ -166,6 +234,39 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showHelp = true m.showHelp = true
return m, nil 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": case "a":
if m.state == stateList { if m.state == stateList {
m.state = stateInput m.state = stateInput
@@ -187,7 +288,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "enter": case "enter":
if m.state == stateList { if m.state == stateList {
if e := m.list.selected(); e != nil { if e := m.selectedEntity(); e != nil {
m.detail.setEntity(e) m.detail.setEntity(e)
m.state = stateDetail m.state = stateDetail
} }
@@ -196,7 +297,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "d": case "d":
if m.state == stateList { if m.state == stateList {
if e := m.list.selected(); e != nil { if e := m.selectedEntity(); e != nil {
m.confirmID = e.ID m.confirmID = e.ID
m.state = stateConfirm m.state = stateConfirm
return m, confirmTimeout() return m, confirmTimeout()
@@ -206,7 +307,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "x": case "x":
if m.state == stateList { 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) return m, toggleTodo(m.store, e)
} }
} }
@@ -231,12 +332,15 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case "p": case "p":
if m.state == stateDetail && m.detail.entity != nil { e := m.selectedEntity()
if m.detail.entity.CardType != nil { if e != nil {
if e.CardType != nil {
m.status = "already a card" m.status = "already a card"
return m, nil 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 return m, nil
@@ -265,7 +369,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.state { switch m.state {
case stateList: case stateList:
if m.mode == modeCards {
m.cards = m.cards.update(msg)
} else {
m.list = m.list.update(msg) m.list = m.list.update(msg)
}
case stateDetail: case stateDetail:
m.detail = m.detail.update(msg) m.detail = m.detail.update(msg)
} }
@@ -319,6 +427,20 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil 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 { func (m model) View() string {
if m.showHelp { if m.showHelp {
return renderHelp(m.width, m.height) return renderHelp(m.width, m.height)
@@ -327,21 +449,47 @@ func (m model) View() string {
var content string var content string
switch m.state { switch m.state {
case stateList, stateInput, stateConfirm: case stateList, stateInput, stateConfirm:
if m.mode == modeCards {
content = m.cards.view(m.width)
} else {
content = m.list.view(m.width) content = m.list.view(m.width)
}
case stateDetail: case stateDetail:
content = m.detail.view(m.width) content = m.detail.view(m.width)
case stateTagFilter: case stateTagFilter:
content = m.filter.view(m.width) 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") header := titleStyle.Render("nib")
modeName := "stream"
if m.mode == modeCards {
modeName = "cards"
}
header += " " + modeStyle.Render(modeName)
if m.filterTag != "" { if m.filterTag != "" {
header += " " + filterPillStyle.Render("#"+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 { func (m model) footerView() string {
@@ -369,13 +517,14 @@ func (m model) contentHeight() int {
} }
func (m model) selectedEntity() *db.Entity { func (m model) selectedEntity() *db.Entity {
switch m.state { switch {
case stateList: case m.state == stateDetail:
return m.list.selected()
case stateDetail:
return m.detail.entity 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 { func (m model) reloadDetail(id string) tea.Cmd {
+84
View File
@@ -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
}
+12 -2
View File
@@ -23,7 +23,12 @@ func renderStatusBar(m model, width int) string {
} }
func countText(m model) 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 != "" { if m.filterTag != "" {
return fmt.Sprintf("%d entities #%s", total, 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" return "j/k:nav enter:select esc:cancel"
case stateConfirm: case stateConfirm:
return "y:confirm n:cancel" return "y:confirm n:cancel"
case statePromote:
return "j/k:nav enter:select esc:cancel"
default: 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"
} }
} }
+24
View File
@@ -77,4 +77,28 @@ var (
helpDescStyle = lipgloss.NewStyle(). helpDescStyle = lipgloss.NewStyle().
Foreground(dim) 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)
) )