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:
@@ -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 ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user