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 cardGroup struct { label string start int count int } type cardsModel struct { entities []*db.Entity filtered []*db.Entity groups []cardGroup 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, c.groups = sortAndGroupCards(c.entities, c.intent) if c.cursor >= len(c.filtered) { c.cursor = max(0, len(c.filtered)-1) } } func sortAndGroupCards(entities []*db.Entity, intentFilter intent) ([]*db.Entity, []cardGroup) { if intentFilter != intentAll { var pinned, rest []*db.Entity for _, e := range entities { if !matchesIntent(e, intentFilter) { continue } if e.Pinned { pinned = append(pinned, e) } else { rest = append(rest, e) } } return append(pinned, rest...), nil } var pinned, grab, read, fill []*db.Entity for _, e := range entities { if e.Pinned { pinned = append(pinned, e) } else { switch { case matchesIntent(e, intentGrab): grab = append(grab, e) case matchesIntent(e, intentRead): read = append(read, e) case matchesIntent(e, intentFill): fill = append(fill, e) } } } var filtered []*db.Entity var groups []cardGroup for _, bucket := range []struct { label string entities []*db.Entity }{ {"pinned", pinned}, {"grab", grab}, {"read", read}, {"fill", fill}, } { if len(bucket.entities) == 0 { continue } groups = append(groups, cardGroup{ label: bucket.label, start: len(filtered), count: len(bucket.entities), }) filtered = append(filtered, bucket.entities...) } return filtered, groups } 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") } if len(c.groups) > 0 { return c.groupedView(width) } 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) groupedView(width int) string { entityWidth := width - 4 - dateGutterWidth type displayLine struct { text string entityIdx int } var lines []displayLine for _, g := range c.groups { for i := 0; i < g.count; i++ { eIdx := g.start + i var gutter string if i == 0 { gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ") } else { gutter = gutterStyle.Render(" │ ") } line := gutter + renderCard(c.filtered[eIdx], entityWidth) lines = append(lines, displayLine{text: line, entityIdx: eIdx}) } } visible := c.visibleCount() offset := c.offset if c.cursor < offset { offset = c.cursor } if c.cursor >= offset+visible { offset = c.cursor - visible + 1 } var b strings.Builder end := min(offset+visible, len(lines)) for i := offset; i < end; i++ { dl := lines[i] if dl.entityIdx == c.cursor { b.WriteString(selectedItemStyle.Render(" " + dl.text)) } else { b.WriteString(listItemStyle.Render(dl.text)) } 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)) body := e.Body if e.Title != nil { body = *e.Title } if idx := strings.IndexByte(body, '\n'); idx >= 0 { body = body[:idx] } 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", glyph, body, affordStr, extraStr, useStr) if maxWidth > 0 && len(stripAnsi(line)) > maxWidth { body = truncate(body, maxWidth-8) line = fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr) } 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 "" } }