Files
nib-v1/internal/tui/cards.go
T
lerko ce335cabd6 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.
2026-05-17 21:14:14 -04:00

263 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}
}