ce335cabd6
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.
263 lines
4.9 KiB
Go
263 lines
4.9 KiB
Go
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 ""
|
||
}
|
||
}
|