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:
+167
-18
@@ -1,6 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
@@ -14,22 +16,64 @@ const (
|
||||
stateInput
|
||||
stateTagFilter
|
||||
stateConfirm
|
||||
statePromote
|
||||
)
|
||||
|
||||
type viewMode int
|
||||
|
||||
const (
|
||||
modeStream viewMode = iota
|
||||
modeCards
|
||||
)
|
||||
|
||||
type cardsSort int
|
||||
|
||||
const (
|
||||
sortNewest cardsSort = iota
|
||||
sortOldest
|
||||
sortMostUsed
|
||||
)
|
||||
|
||||
func (s cardsSort) String() string {
|
||||
switch s {
|
||||
case sortOldest:
|
||||
return "oldest"
|
||||
case sortMostUsed:
|
||||
return "most used"
|
||||
default:
|
||||
return "newest"
|
||||
}
|
||||
}
|
||||
|
||||
func (s cardsSort) next() cardsSort {
|
||||
switch s {
|
||||
case sortNewest:
|
||||
return sortOldest
|
||||
case sortOldest:
|
||||
return sortMostUsed
|
||||
default:
|
||||
return sortNewest
|
||||
}
|
||||
}
|
||||
|
||||
type model struct {
|
||||
store *db.Store
|
||||
state viewState
|
||||
mode viewMode
|
||||
width int
|
||||
height int
|
||||
|
||||
list listModel
|
||||
cards cardsModel
|
||||
detail detailModel
|
||||
input inputModel
|
||||
filter filterModel
|
||||
promote promoteModel
|
||||
showHelp bool
|
||||
|
||||
filterTag string
|
||||
confirmID string
|
||||
cardsSort cardsSort
|
||||
|
||||
status string
|
||||
err error
|
||||
@@ -39,7 +83,9 @@ func newModel(store *db.Store) model {
|
||||
return model{
|
||||
store: store,
|
||||
state: stateList,
|
||||
mode: modeStream,
|
||||
list: newListModel(),
|
||||
cards: newCardsModel(),
|
||||
detail: newDetailModel(),
|
||||
input: newInputModel(),
|
||||
filter: newFilterModel(),
|
||||
@@ -55,6 +101,20 @@ func (m model) listParams() db.ListParams {
|
||||
if m.filterTag != "" {
|
||||
p.Tag = &m.filterTag
|
||||
}
|
||||
if m.mode == modeCards {
|
||||
p.CardsOnly = true
|
||||
switch m.cardsSort {
|
||||
case sortNewest:
|
||||
p.Sort = "created"
|
||||
p.Order = "desc"
|
||||
case sortOldest:
|
||||
p.Sort = "created"
|
||||
p.Order = "asc"
|
||||
case sortMostUsed:
|
||||
p.Sort = "use_count"
|
||||
p.Order = "desc"
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -64,12 +124,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.list.setSize(m.width, m.contentHeight())
|
||||
m.cards.setSize(m.width, m.contentHeight())
|
||||
m.detail.setSize(m.width, m.contentHeight())
|
||||
m.filter.setHeight(m.contentHeight())
|
||||
return m, nil
|
||||
|
||||
case entitiesLoadedMsg:
|
||||
m.list.setEntities(msg.entities)
|
||||
if m.mode == modeCards {
|
||||
m.cards.setEntities(msg.entities)
|
||||
} else {
|
||||
m.list.setEntities(msg.entities)
|
||||
}
|
||||
m.err = nil
|
||||
return m, nil
|
||||
|
||||
@@ -92,8 +157,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
|
||||
case entityPromotedMsg:
|
||||
m.status = "promoted → snippet"
|
||||
return m, m.reloadDetail(msg.id)
|
||||
m.status = fmt.Sprintf("promoted → %s", msg.cardType)
|
||||
m.state = stateList
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
|
||||
case entityDemotedMsg:
|
||||
m.status = "demoted → fluid"
|
||||
@@ -136,6 +202,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.updateTagFilter(msg)
|
||||
case stateConfirm:
|
||||
return m.updateConfirm(msg)
|
||||
case statePromote:
|
||||
return m.updatePromote(msg)
|
||||
default:
|
||||
return m.updateKeys(msg)
|
||||
}
|
||||
@@ -166,6 +234,39 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.showHelp = true
|
||||
return m, nil
|
||||
|
||||
case "1":
|
||||
if m.mode != modeStream {
|
||||
m.mode = modeStream
|
||||
m.state = stateList
|
||||
m.status = ""
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "2":
|
||||
if m.mode != modeCards {
|
||||
m.mode = modeCards
|
||||
m.state = stateList
|
||||
m.status = ""
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "s":
|
||||
if m.mode == modeCards && m.state == stateList {
|
||||
m.cardsSort = m.cardsSort.next()
|
||||
m.status = "sort: " + m.cardsSort.String()
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "tab":
|
||||
if m.mode == modeCards && m.state == stateList {
|
||||
m.cards.setIntent(m.cards.intent.next())
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "a":
|
||||
if m.state == stateList {
|
||||
m.state = stateInput
|
||||
@@ -187,7 +288,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "enter":
|
||||
if m.state == stateList {
|
||||
if e := m.list.selected(); e != nil {
|
||||
if e := m.selectedEntity(); e != nil {
|
||||
m.detail.setEntity(e)
|
||||
m.state = stateDetail
|
||||
}
|
||||
@@ -196,7 +297,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "d":
|
||||
if m.state == stateList {
|
||||
if e := m.list.selected(); e != nil {
|
||||
if e := m.selectedEntity(); e != nil {
|
||||
m.confirmID = e.ID
|
||||
m.state = stateConfirm
|
||||
return m, confirmTimeout()
|
||||
@@ -206,7 +307,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "x":
|
||||
if m.state == stateList {
|
||||
if e := m.list.selected(); e != nil && e.Glyph == db.GlyphTodo {
|
||||
if e := m.selectedEntity(); e != nil && e.Glyph == db.GlyphTodo {
|
||||
return m, toggleTodo(m.store, e)
|
||||
}
|
||||
}
|
||||
@@ -231,12 +332,15 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
|
||||
case "p":
|
||||
if m.state == stateDetail && m.detail.entity != nil {
|
||||
if m.detail.entity.CardType != nil {
|
||||
e := m.selectedEntity()
|
||||
if e != nil {
|
||||
if e.CardType != nil {
|
||||
m.status = "already a card"
|
||||
return m, nil
|
||||
}
|
||||
return m, promoteEntity(m.store, m.detail.entity.ID)
|
||||
m.promote = newPromoteModel(e.ID, e.Body)
|
||||
m.state = statePromote
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
|
||||
@@ -265,7 +369,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
switch m.state {
|
||||
case stateList:
|
||||
m.list = m.list.update(msg)
|
||||
if m.mode == modeCards {
|
||||
m.cards = m.cards.update(msg)
|
||||
} else {
|
||||
m.list = m.list.update(msg)
|
||||
}
|
||||
case stateDetail:
|
||||
m.detail = m.detail.update(msg)
|
||||
}
|
||||
@@ -319,6 +427,20 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
m.state = stateList
|
||||
return m, nil
|
||||
case "enter":
|
||||
ct := m.promote.selectedType()
|
||||
return m, promoteEntity(m.store, m.promote.entityID, ct, m.promote.body)
|
||||
default:
|
||||
m.promote = m.promote.update(msg.String())
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if m.showHelp {
|
||||
return renderHelp(m.width, m.height)
|
||||
@@ -327,21 +449,47 @@ func (m model) View() string {
|
||||
var content string
|
||||
switch m.state {
|
||||
case stateList, stateInput, stateConfirm:
|
||||
content = m.list.view(m.width)
|
||||
if m.mode == modeCards {
|
||||
content = m.cards.view(m.width)
|
||||
} else {
|
||||
content = m.list.view(m.width)
|
||||
}
|
||||
case stateDetail:
|
||||
content = m.detail.view(m.width)
|
||||
case stateTagFilter:
|
||||
content = m.filter.view(m.width)
|
||||
case statePromote:
|
||||
content = m.promote.view(m.width)
|
||||
}
|
||||
|
||||
header := m.headerView()
|
||||
footer := m.footerView()
|
||||
|
||||
return header + "\n" + content + "\n" + footer
|
||||
}
|
||||
|
||||
func (m model) headerView() string {
|
||||
header := titleStyle.Render("nib")
|
||||
|
||||
modeName := "stream"
|
||||
if m.mode == modeCards {
|
||||
modeName = "cards"
|
||||
}
|
||||
header += " " + modeStyle.Render(modeName)
|
||||
|
||||
if m.filterTag != "" {
|
||||
header += " " + filterPillStyle.Render("#"+m.filterTag)
|
||||
}
|
||||
|
||||
footer := m.footerView()
|
||||
if m.mode == modeCards && m.cards.intent != intentAll {
|
||||
header += " " + affordanceStyle.Render(m.cards.intent.String())
|
||||
}
|
||||
|
||||
return header + "\n" + content + "\n" + footer
|
||||
if m.mode == modeCards {
|
||||
header += " " + idStyle.Render("("+m.cardsSort.String()+")")
|
||||
}
|
||||
|
||||
return header
|
||||
}
|
||||
|
||||
func (m model) footerView() string {
|
||||
@@ -369,13 +517,14 @@ func (m model) contentHeight() int {
|
||||
}
|
||||
|
||||
func (m model) selectedEntity() *db.Entity {
|
||||
switch m.state {
|
||||
case stateList:
|
||||
return m.list.selected()
|
||||
case stateDetail:
|
||||
switch {
|
||||
case m.state == stateDetail:
|
||||
return m.detail.entity
|
||||
case m.mode == modeCards:
|
||||
return m.cards.selected()
|
||||
default:
|
||||
return m.list.selected()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) reloadDetail(id string) tea.Cmd {
|
||||
|
||||
Reference in New Issue
Block a user