c2ea63dd16
Status bar with entity count and context-sensitive key hints. Help overlay via ? key. Tag filter via # with cursor-navigable tag list. Todo toggle (x), pin (!), promote (p), demote (D), copy (c), edit (e) via $EDITOR. Delete confirmation with 3s timeout. Date-grouped list with completed todo and pinned indicators. Esc clears active tag filter. Adds CompletedAt/ClearCompleted to EntityUpdate for todo toggling.
400 lines
7.5 KiB
Go
400 lines
7.5 KiB
Go
package tui
|
|
|
|
import (
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"github.com/lerko/nib/internal/db"
|
|
)
|
|
|
|
type viewState int
|
|
|
|
const (
|
|
stateList viewState = iota
|
|
stateDetail
|
|
stateInput
|
|
stateTagFilter
|
|
stateConfirm
|
|
)
|
|
|
|
type model struct {
|
|
store *db.Store
|
|
state viewState
|
|
width int
|
|
height int
|
|
|
|
list listModel
|
|
detail detailModel
|
|
input inputModel
|
|
filter filterModel
|
|
showHelp bool
|
|
|
|
filterTag string
|
|
confirmID string
|
|
|
|
status string
|
|
err error
|
|
}
|
|
|
|
func newModel(store *db.Store) model {
|
|
return model{
|
|
store: store,
|
|
state: stateList,
|
|
list: newListModel(),
|
|
detail: newDetailModel(),
|
|
input: newInputModel(),
|
|
filter: newFilterModel(),
|
|
}
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return loadEntities(m.store, m.listParams())
|
|
}
|
|
|
|
func (m model) listParams() db.ListParams {
|
|
p := db.DefaultListParams()
|
|
if m.filterTag != "" {
|
|
p.Tag = &m.filterTag
|
|
}
|
|
return p
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.list.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)
|
|
m.err = nil
|
|
return m, nil
|
|
|
|
case entityCreatedMsg:
|
|
m.state = stateList
|
|
m.input.reset()
|
|
m.status = "created"
|
|
return m, loadEntities(m.store, m.listParams())
|
|
|
|
case entityDeletedMsg:
|
|
m.status = "deleted"
|
|
m.state = stateList
|
|
return m, loadEntities(m.store, m.listParams())
|
|
|
|
case entityUpdatedMsg:
|
|
m.status = msg.action
|
|
if m.state == stateDetail {
|
|
m.detail.setEntity(msg.entity)
|
|
}
|
|
return m, loadEntities(m.store, m.listParams())
|
|
|
|
case entityPromotedMsg:
|
|
m.status = "promoted → snippet"
|
|
return m, m.reloadDetail(msg.id)
|
|
|
|
case entityDemotedMsg:
|
|
m.status = "demoted → fluid"
|
|
return m, m.reloadDetail(msg.id)
|
|
|
|
case entityCopiedMsg:
|
|
m.status = "copied"
|
|
return m, nil
|
|
|
|
case tagsLoadedMsg:
|
|
m.filter.setTags(msg.tags)
|
|
m.state = stateTagFilter
|
|
return m, nil
|
|
|
|
case editorFinishedMsg:
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
} else {
|
|
m.status = "updated"
|
|
}
|
|
return m, m.reloadAfterEdit()
|
|
|
|
case confirmTimeoutMsg:
|
|
if m.state == stateConfirm {
|
|
m.state = stateList
|
|
m.confirmID = ""
|
|
}
|
|
return m, nil
|
|
|
|
case errMsg:
|
|
m.err = msg.err
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
m.err = nil
|
|
switch m.state {
|
|
case stateInput:
|
|
return m.updateInput(msg)
|
|
case stateTagFilter:
|
|
return m.updateTagFilter(msg)
|
|
case stateConfirm:
|
|
return m.updateConfirm(msg)
|
|
default:
|
|
return m.updateKeys(msg)
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.showHelp {
|
|
if msg.String() == "?" || msg.String() == "esc" || msg.String() == "q" {
|
|
m.showHelp = false
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
|
|
case "q":
|
|
if m.state == stateList {
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
|
|
case "?":
|
|
m.showHelp = true
|
|
return m, nil
|
|
|
|
case "a":
|
|
if m.state == stateList {
|
|
m.state = stateInput
|
|
m.input.focus()
|
|
return m, m.input.ti.Focus()
|
|
}
|
|
|
|
case "esc":
|
|
if m.state == stateDetail {
|
|
m.state = stateList
|
|
return m, nil
|
|
}
|
|
if m.state == stateList && m.filterTag != "" {
|
|
m.filterTag = ""
|
|
m.status = ""
|
|
return m, loadEntities(m.store, m.listParams())
|
|
}
|
|
return m, nil
|
|
|
|
case "enter":
|
|
if m.state == stateList {
|
|
if e := m.list.selected(); e != nil {
|
|
m.detail.setEntity(e)
|
|
m.state = stateDetail
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case "d":
|
|
if m.state == stateList {
|
|
if e := m.list.selected(); e != nil {
|
|
m.confirmID = e.ID
|
|
m.state = stateConfirm
|
|
return m, confirmTimeout()
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case "x":
|
|
if m.state == stateList {
|
|
if e := m.list.selected(); e != nil && e.Glyph == db.GlyphTodo {
|
|
return m, toggleTodo(m.store, e)
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case "!":
|
|
e := m.selectedEntity()
|
|
if e != nil {
|
|
return m, pinEntity(m.store, e)
|
|
}
|
|
return m, nil
|
|
|
|
case "#":
|
|
if m.state == stateList {
|
|
if m.filterTag != "" {
|
|
m.filterTag = ""
|
|
m.status = ""
|
|
return m, loadEntities(m.store, m.listParams())
|
|
}
|
|
return m, loadTags(m.store)
|
|
}
|
|
return m, nil
|
|
|
|
case "p":
|
|
if m.state == stateDetail && m.detail.entity != nil {
|
|
if m.detail.entity.CardType != nil {
|
|
m.status = "already a card"
|
|
return m, nil
|
|
}
|
|
return m, promoteEntity(m.store, m.detail.entity.ID)
|
|
}
|
|
return m, nil
|
|
|
|
case "D":
|
|
if m.state == stateDetail && m.detail.entity != nil {
|
|
if m.detail.entity.CardType == nil {
|
|
m.status = "already fluid"
|
|
return m, nil
|
|
}
|
|
return m, demoteEntity(m.store, m.detail.entity.ID)
|
|
}
|
|
return m, nil
|
|
|
|
case "c":
|
|
if m.state == stateDetail && m.detail.entity != nil {
|
|
return m, copyToClipboard(m.store, m.detail.entity)
|
|
}
|
|
return m, nil
|
|
|
|
case "e":
|
|
if m.state == stateDetail && m.detail.entity != nil {
|
|
return m, editInEditor(m.store, m.detail.entity)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch m.state {
|
|
case stateList:
|
|
m.list = m.list.update(msg)
|
|
case stateDetail:
|
|
m.detail = m.detail.update(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.state = stateList
|
|
m.input.reset()
|
|
return m, nil
|
|
case "enter":
|
|
if e := m.input.submit(); e != nil {
|
|
return m, createEntity(m.store, e)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
m.input = m.input.updateKey(msg)
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc", "q":
|
|
m.state = stateList
|
|
return m, nil
|
|
case "enter":
|
|
tag := m.filter.selectedTag()
|
|
if tag != "" {
|
|
m.filterTag = tag
|
|
m.state = stateList
|
|
return m, loadEntities(m.store, m.listParams())
|
|
}
|
|
return m, nil
|
|
default:
|
|
m.filter = m.filter.update(msg.String())
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
id := m.confirmID
|
|
m.confirmID = ""
|
|
m.state = stateList
|
|
|
|
if msg.String() == "y" && id != "" {
|
|
return m, deleteEntity(m.store, id)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) View() string {
|
|
if m.showHelp {
|
|
return renderHelp(m.width, m.height)
|
|
}
|
|
|
|
var content string
|
|
switch m.state {
|
|
case stateList, stateInput, stateConfirm:
|
|
content = m.list.view(m.width)
|
|
case stateDetail:
|
|
content = m.detail.view(m.width)
|
|
case stateTagFilter:
|
|
content = m.filter.view(m.width)
|
|
}
|
|
|
|
header := titleStyle.Render("nib")
|
|
if m.filterTag != "" {
|
|
header += " " + filterPillStyle.Render("#"+m.filterTag)
|
|
}
|
|
|
|
footer := m.footerView()
|
|
|
|
return header + "\n" + content + "\n" + footer
|
|
}
|
|
|
|
func (m model) footerView() string {
|
|
if m.state == stateInput {
|
|
return m.input.view(m.width)
|
|
}
|
|
|
|
if m.state == stateConfirm {
|
|
return renderConfirm(m.confirmID)
|
|
}
|
|
|
|
if m.err != nil {
|
|
return errorStyle.Render("error: " + m.err.Error())
|
|
}
|
|
|
|
if m.status != "" {
|
|
return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m))
|
|
}
|
|
|
|
return renderStatusBar(m, m.width)
|
|
}
|
|
|
|
func (m model) contentHeight() int {
|
|
return m.height - 3
|
|
}
|
|
|
|
func (m model) selectedEntity() *db.Entity {
|
|
switch m.state {
|
|
case stateList:
|
|
return m.list.selected()
|
|
case stateDetail:
|
|
return m.detail.entity
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m model) reloadDetail(id string) tea.Cmd {
|
|
return tea.Batch(
|
|
loadEntities(m.store, m.listParams()),
|
|
func() tea.Msg {
|
|
e, err := m.store.Get(id)
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return entityUpdatedMsg{e, ""}
|
|
},
|
|
)
|
|
}
|
|
|
|
func (m model) reloadAfterEdit() tea.Cmd {
|
|
if m.detail.entity == nil {
|
|
return loadEntities(m.store, m.listParams())
|
|
}
|
|
return m.reloadDetail(m.detail.entity.ID)
|
|
}
|