package tui import ( "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/lerko/nib/internal/db" ) type viewState int const ( stateList viewState = iota stateDetail 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 } func newModel(store *db.Store) model { return model{ store: store, state: stateList, mode: modeStream, list: newListModel(), cards: newCardsModel(), 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 } 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 } 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.cards.setSize(m.width, m.contentHeight()) m.detail.setSize(m.width, m.contentHeight()) m.filter.setHeight(m.contentHeight()) return m, nil case entitiesLoadedMsg: if m.mode == modeCards { m.cards.setEntities(msg.entities) } else { 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 = fmt.Sprintf("promoted → %s", msg.cardType) m.state = stateList return m, loadEntities(m.store, m.listParams()) 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) case statePromote: return m.updatePromote(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 "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 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.selectedEntity(); e != nil { m.detail.setEntity(e) m.state = stateDetail } } return m, nil case "d": if m.state == stateList { if e := m.selectedEntity(); e != nil { m.confirmID = e.ID m.state = stateConfirm return m, confirmTimeout() } } return m, nil case "x": if m.state == stateList { if e := m.selectedEntity(); 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": e := m.selectedEntity() if e != nil { if e.CardType != nil { m.status = "already a card" return m, nil } m.promote = newPromoteModel(e.ID, e.Body) m.state = statePromote return m, nil } 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: 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) } 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) 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) } var content string switch m.state { case stateList, stateInput, stateConfirm: 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) } if m.mode == modeCards && m.cards.intent != intentAll { header += " " + affordanceStyle.Render(m.cards.intent.String()) } if m.mode == modeCards { header += " " + idStyle.Render("("+m.cardsSort.String()+")") } return header } 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 { case m.state == stateDetail: return m.detail.entity case m.mode == modeCards: return m.cards.selected() default: return m.list.selected() } } 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) }