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) }