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 "" } }