package tui import ( "context" "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/lerko/nib/internal/db" ) const statusTimeout = 2 * time.Second type viewState int const ( stateList viewState = iota stateDetail stateTagFilter stateConfirm statePromote stateAbsorb stateStumble ) 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 focusPane int const ( focusCapture focusPane = iota focusTagRail focusList focusDetail ) 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 absorb absorbModel tagRail tagRailModel stumble stumbleModel showHelp bool autocomplete autocompleteModel focus focusPane splitDetail bool showTagRail bool filterTag string confirmID string cardsSort cardsSort searchQuery string searchTags []string queryDateFrom *string queryDateTo *string queryCardType *db.CardType status string statusSeq int err error } func newModel(store *db.Store) model { loadTheme() applyTheme() inp := newInputModel() inp.ti.Focus() return model{ store: store, state: stateList, mode: modeStream, focus: focusCapture, showTagRail: true, list: newListModel(), cards: newCardsModel(), detail: newDetailModel(), input: inp, filter: newFilterModel(), tagRail: newTagRailModel(), } } func (m *model) setFocus(f focusPane) tea.Cmd { m.focus = f if f == focusCapture { return m.input.ti.Focus() } m.input.ti.Blur() return nil } func (m *model) setStatus(msg string) tea.Cmd { m.statusSeq++ m.status = msg return clearStatusAfter(statusTimeout, m.statusSeq) } func (m model) Init() tea.Cmd { return tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.input.ti.Focus()) } func (m model) listParams() db.ListParams { p := db.DefaultListParams() if m.filterTag != "" { p.Tag = &m.filterTag } if m.queryDateFrom != nil { p.From = m.queryDateFrom } if m.queryDateTo != nil { p.To = m.queryDateTo } if m.queryCardType != nil { p.CardTypeFilter = m.queryCardType } 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) hasSearch() bool { return m.searchQuery != "" || len(m.searchTags) > 0 || m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil } func (m *model) applySearch() { if m.mode == modeCards { searchFiltered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags) var intentFiltered []*db.Entity for _, e := range searchFiltered { if matchesIntent(e, m.cards.intent) { intentFiltered = append(intentFiltered, e) } } m.cards.filtered, m.cards.groups = sortAndGroupCards(intentFiltered, m.cards.intent) if m.cards.cursor >= len(m.cards.filtered) { m.cards.cursor = max(0, len(m.cards.filtered)-1) } } else { m.list.filtered = filterEntities(m.list.entities, m.searchQuery, m.searchTags) } } 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 if !m.isSplit() && m.splitDetail { m.state = stateDetail m.splitDetail = false m.focus = focusList } if !m.railVisible() && m.focus == focusTagRail { m.focus = focusList } m.recalcSizes() return m, nil case entitiesLoadedMsg: if m.mode == modeCards { m.cards.setEntities(msg.entities) } else { m.list.setEntities(msg.entities) } if m.hasSearch() { m.applySearch() } m.err = nil return m, nil case railTagsLoadedMsg: m.tagRail.setTags(msg.tags) m.tagRail.activeTag = m.filterTag return m, nil case entityCreatedMsg: m.input.clearText() return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("created")) case entityDeletedMsg: m.state = stateList return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("deleted")) case entityUpdatedMsg: if m.state == stateDetail { m.detail.setEntity(msg.entity) } return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(msg.action)) case entityPromotedMsg: if !m.stumble.done && len(m.stumble.entries) > 0 { m.stumble.advance() m.state = stateStumble } else { m.state = stateList } return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType))) case entityDemotedMsg: return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid")) case entityCopiedMsg: return m, m.setStatus("copied") case entityAbsorbedMsg: if !m.stumble.done && len(m.stumble.entries) > 0 { m.stumble.advance() m.state = stateStumble } else { m.state = stateList } return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("absorbed")) case absorbSourcesLoadedMsg: m.absorb = newAbsorbModel(msg.targetID) m.absorb.setSources(msg.entities) m.absorb.setHeight(m.contentHeight()) m.state = stateAbsorb return m, nil case stepsPersistedMsg: m.detail.mode = detailPreview return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), m.setStatus("steps saved")) case templateCopiedMsg: m.detail.mode = detailPreview return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("copied resolved")) case backlinksLoadedMsg: if m.detail.entity != nil { m.detail.backlinks = msg.backlinks } return m, nil case tagsLoadedMsg: m.filter.setTags(msg.tags) m.tagRail.setTags(msg.tags) m.state = stateTagFilter return m, nil case staleEntitiesLoadedMsg: m.stumble = newStumbleModel() m.stumble.setEntries(msg.entities) m.stumble.setSize(m.width, m.contentHeight()) m.state = stateStumble return m, nil case stumbleActionMsg: return m, m.setStatus(msg.action) case editorFinishedMsg: if msg.err != nil { m.err = msg.err return m, m.reloadAfterEdit() } return m, tea.Batch(m.reloadAfterEdit(), m.setStatus("updated")) case confirmTimeoutMsg: if m.state == stateConfirm { m.state = stateList m.confirmID = "" } return m, nil case statusClearMsg: if msg.seq == m.statusSeq { m.status = "" } return m, nil case errMsg: m.err = msg.err return m, nil case tea.KeyMsg: m.err = nil switch m.state { case stateTagFilter: return m.updateTagFilter(msg) case stateConfirm: return m.updateConfirm(msg) case statePromote: return m.updatePromote(msg) case stateAbsorb: return m.updateAbsorb(msg) case stateStumble: return m.updateStumble(msg) default: return m.updateKeys(msg) } default: if m.focus == focusCapture { var cmd tea.Cmd m.input.ti, cmd = m.input.ti.Update(msg) return m, cmd } } 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 } if msg.String() == "ctrl+c" { return m, tea.Quit } if m.focus == focusCapture { return m.updateCapture(msg) } return m.updateBrowse(msg) } func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": if m.autocomplete.active { m.acceptAutocomplete() return m, nil } val := m.input.ti.Value() if val == "" { cmd := m.setFocus(focusList) return m, cmd } result := m.input.submit() if result == nil { return m, nil } if result.query { m.searchQuery = result.body m.searchTags = result.tags m.queryDateFrom = result.dateFrom m.queryDateTo = result.dateTo m.queryCardType = result.cardType m.input.clearText() m.autocomplete.active = false if result.dateFrom != nil || result.dateTo != nil || result.cardType != nil { cmd := m.setFocus(focusList) return m, tea.Batch(cmd, loadEntities(m.store, m.listParams())) } m.applySearch() cmd := m.setFocus(focusList) return m, cmd } if result.entity != nil { m.autocomplete.active = false return m, createEntity(m.store, result.entity) } return m, nil case "tab": if m.autocomplete.active { m.acceptAutocomplete() return m, nil } cmd := m.setFocus(focusList) return m, cmd case "esc": if m.autocomplete.active { m.autocomplete.active = false return m, nil } cmd := m.setFocus(focusList) return m, cmd case "up": if m.autocomplete.active { m.autocomplete.moveUp() return m, nil } case "down": if m.autocomplete.active { m.autocomplete.moveDown() return m, nil } } m.input = m.input.updateKey(msg) m.updateAutocompleteSuggestions() return m, nil } func (m *model) updateAutocompleteSuggestions() { val := m.input.ti.Value() pos := m.input.ti.Position() start, end, prefix, ok := tagTokenAtCursor(val, pos) if !ok || prefix == "" { m.autocomplete.active = false return } suggestions := filterTagSuggestions(m.tagRail.tags, prefix) if len(suggestions) == 0 { m.autocomplete.active = false return } m.autocomplete.suggestions = suggestions m.autocomplete.prefix = prefix m.autocomplete.tokenStart = start m.autocomplete.tokenEnd = end m.autocomplete.active = true if m.autocomplete.cursor >= len(suggestions) { m.autocomplete.cursor = 0 } } func (m *model) acceptAutocomplete() { if !m.autocomplete.active || len(m.autocomplete.suggestions) == 0 { return } selected := m.autocomplete.selected() if selected == "" { return } val := m.input.ti.Value() newVal := val[:m.autocomplete.tokenStart] + "#" + selected + " " + val[m.autocomplete.tokenEnd:] m.input.ti.SetValue(newVal) m.input.ti.SetCursor(m.autocomplete.tokenStart + 1 + len(selected) + 1) m.autocomplete.active = false } func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Tag rail focus handling if m.focus == focusTagRail && m.state == stateList { switch msg.String() { case "j", "k", "up", "down": m.tagRail = m.tagRail.update(msg.String()) return m, nil case "enter": tag := m.tagRail.selectedTag() if tag != "" { if tag == m.filterTag { m.filterTag = "" m.tagRail.activeTag = "" } else { m.filterTag = tag m.tagRail.activeTag = tag } m.focus = focusList return m, loadEntities(m.store, m.listParams()) } return m, nil case "l", "tab", "esc": m.focus = focusList return m, nil case "ctrl+b": m.showTagRail = false m.focus = focusList m.recalcSizes() return m, nil } } // ctrl+b toggle from any browse focus if msg.String() == "ctrl+b" && m.state == stateList { m.showTagRail = !m.showTagRail if !m.railVisible() && m.focus == focusTagRail { m.focus = focusList } m.recalcSizes() return m, nil } if m.splitDetail && m.state == stateList { switch msg.String() { case "tab": cmd := m.setFocus(focusCapture) return m, cmd case "l": if m.focus == focusList { m.focus = focusDetail return m, nil } case "h": if m.focus == focusDetail { m.focus = focusList return m, nil } if m.focus == focusList && m.railVisible() { m.focus = focusTagRail return m, nil } case "esc": if m.focus == focusDetail { m.focus = focusList return m, nil } m.splitDetail = false m.recalcSizes() return m, nil } if m.focus == focusDetail { switch msg.String() { case "j", "k", "up", "down", "pgup", "pgdown", "ctrl+u", "ctrl+d": var cmd tea.Cmd m.detail, cmd = m.detail.update(msg) return m, cmd case "c": if m.detail.entity != nil { return m, copyToClipboard(m.store, m.detail.entity) } return m, nil case "e": if m.detail.entity != nil && m.detail.mode == detailPreview { return m, editInEditor(m.store, m.detail.entity) } return m, nil case "p": if m.detail.entity != nil && m.detail.entity.CardType == nil { m.promote = newPromoteModel(m.detail.entity.ID, m.detail.entity.Body) m.state = statePromote m.splitDetail = false m.recalcSizes() return m, nil } return m, nil case "D": if m.detail.entity != nil && m.detail.entity.CardType != nil { return m, demoteEntity(m.store, m.detail.entity.ID) } return m, nil case "!": if m.detail.entity != nil { return m, pinEntity(m.store, m.detail.entity) } return m, nil case "r": if m.detail.entity != nil && m.detail.mode == detailPreview { if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist { m.detail.run = newRunModel(m.detail.entity.ID, m.detail.entity.CardData) m.detail.mode = detailRun m.splitDetail = false m.state = stateDetail m.recalcSizes() return m, nil } } return m, nil case "f": if m.detail.entity != nil && m.detail.mode == detailPreview { if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardTemplate { m.detail.fill = newFillModel(m.detail.entity.ID, m.detail.entity.Body) m.detail.mode = detailFill m.splitDetail = false m.state = stateDetail m.recalcSizes() return m, m.detail.fill.ti.Focus() } } return m, nil } } } switch msg.String() { 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() return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("sort: "+m.cardsSort.String())) } return m, nil case "S": if m.state == stateList { return m, loadStaleEntities(m.store) } return m, nil case "T": t := cycleTheme() return m, m.setStatus("theme: " + t.Name) case "i": if m.mode == modeCards && m.state == stateList { m.cards.setIntent(m.cards.intent.next()) if m.hasSearch() { m.applySearch() } return m, nil } return m, nil case "tab": cmd := m.setFocus(focusCapture) return m, cmd case "h": if m.state == stateList && m.railVisible() && m.focus == focusList { m.focus = focusTagRail return m, nil } return m, nil case "a": if m.state == stateList { cmd := m.setFocus(focusCapture) return m, cmd } case "esc": if m.state == stateDetail { if m.detail.mode == detailRun { var cmd tea.Cmd if m.detail.run.dirty { cmd = persistSteps(m.store, m.detail.run.entityID, m.detail.run.stepsJSON()) } m.detail.mode = detailPreview if m.isSplit() { m.state = stateList m.splitDetail = true m.focus = focusList m.recalcSizes() } return m, cmd } if m.detail.mode == detailFill { m.detail.mode = detailPreview if m.isSplit() { m.state = stateList m.splitDetail = true m.focus = focusList m.recalcSizes() } return m, nil } if m.isSplit() { m.state = stateList m.splitDetail = true m.focus = focusList m.recalcSizes() return m, nil } m.state = stateList return m, nil } if m.state == stateList && m.hasSearch() { hadDBFilters := m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil m.searchQuery = "" m.searchTags = nil m.queryDateFrom = nil m.queryDateTo = nil m.queryCardType = nil m.status = "" if hadDBFilters { return m, loadEntities(m.store, m.listParams()) } if m.mode == modeCards { m.cards.applyFilter() } else { m.list.filtered = nil } return m, nil } if m.state == stateList && m.filterTag != "" { m.filterTag = "" m.status = "" return m, loadEntities(m.store, m.listParams()) } if m.state == stateList { cmd := m.setFocus(focusCapture) return m, cmd } 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 "m": e := m.selectedEntity() if e != nil { if e.CardType != nil { return m, m.setStatus("target must be fluid") } return m, loadAbsorbSources(m.store, e.ID) } return m, nil case "p": e := m.selectedEntity() if e != nil { if e.CardType != nil { return m, m.setStatus("already a card") } 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 { return m, m.setStatus("already fluid") } 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 && m.detail.mode == detailPreview { return m, editInEditor(m.store, m.detail.entity) } return m, nil case "r": if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview { if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist { m.detail.run = newRunModel(m.detail.entity.ID, m.detail.entity.CardData) m.detail.mode = detailRun return m, nil } } return m, nil case "f": if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview { if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardTemplate { m.detail.fill = newFillModel(m.detail.entity.ID, m.detail.entity.Body) m.detail.mode = detailFill return m, m.detail.fill.ti.Focus() } } return m, nil case "enter": if m.state == stateDetail && m.detail.mode == detailFill { m.detail.fill.commitActive() resolved := m.detail.fill.resolve() return m, copyResolved(m.store, m.detail.fill.entityID, resolved) } if m.state == stateList { if e := m.selectedEntity(); e != nil { m.detail.setEntity(e) if m.isSplit() { m.splitDetail = true m.focus = focusDetail m.recalcSizes() } else { m.state = stateDetail } return m, loadBacklinks(m.store, e.ID) } } 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) } if m.splitDetail { if e := m.selectedEntity(); e != nil { m.detail.setEntity(e) } } case stateDetail: var cmd tea.Cmd m.detail, cmd = m.detail.update(msg) return m, cmd } 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) updateAbsorb(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc", "q": m.state = stateList return m, nil case "enter": source := m.absorb.selectedSource() if source != nil { return m, absorbEntity(m.store, m.absorb.targetID, source.ID) } return m, nil default: m.absorb = m.absorb.update(msg) return m, nil } } func (m model) updateStumble(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.stumble.done { m.state = stateList return m, loadEntities(m.store, m.listParams()) } switch msg.String() { case "esc", "q": m.state = stateList return m, loadEntities(m.store, m.listParams()) case "n", "right": m.stumble.advance() if m.stumble.done { return m, m.setStatus("all caught up") } return m, nil case "d": e := m.stumble.current() if e != nil { m.stumble.removeCurrent() return m, stumbleDismiss(m.store, e.ID) } return m, nil case "!": e := m.stumble.current() if e != nil { m.stumble.advance() return m, stumblePin(m.store, e.ID) } return m, nil case "p": e := m.stumble.current() if e != nil { m.promote = newPromoteModel(e.ID, e.Body) m.state = statePromote return m, nil } return m, nil case "m": e := m.stumble.current() if e != nil { if e.CardType != nil { return m, m.setStatus("target must be fluid") } return m, loadAbsorbSources(m.store, e.ID) } return m, nil } 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, stateConfirm: listContent := m.listContent() if m.splitDetail { lw, rw := m.splitWidths() ch := m.contentHeight() left := lipgloss.NewStyle().Width(lw).Height(ch).Render(listContent) sep := m.renderSeparator() right := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.detail.view(rw)) content = lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right) } else { content = listContent } if m.railVisible() { rw := m.railWidth() ch := m.contentHeight() rail := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.tagRail.view(m.focus == focusTagRail)) sep := m.renderSeparator() content = lipgloss.JoinHorizontal(lipgloss.Top, rail, sep, content) } case stateDetail: content = m.detail.view(m.width) case stateTagFilter: content = m.filter.view(m.width) case statePromote: content = m.promote.view(m.width) case stateAbsorb: content = m.absorb.view(m.width) case stateStumble: content = m.stumble.view() } header := m.headerView() captureBar := m.input.viewBar(m.width, m.focus == focusCapture) statusLine := m.statusLine() content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content) acView := m.autocomplete.view(m.width) if acView != "" { return header + "\n" + content + "\n" + acView + "\n" + captureBar + "\n" + statusLine } return header + "\n" + content + "\n" + captureBar + "\n" + statusLine } func (m model) listWidth() int { if m.splitDetail { lw, _ := m.splitWidths() return lw } w := m.width - m.railWidth() if m.railVisible() { w-- } return w } func (m model) listContent() string { lw := m.listWidth() if m.mode == modeCards { return m.cards.view(lw) } return m.list.view(lw) } func (m model) headerView() string { header := titleStyle.Render("nib") + " " header += renderTab("stream", "1", m.mode == modeStream) header += " " + separatorStyle.Render("│") + " " header += renderTab("cards", "2", m.mode == modeCards) if m.filterTag != "" { header += " " + filterPillStyle.Render("#"+m.filterTag) } if m.hasSearch() { pill := "?" if m.searchQuery != "" { pill += m.searchQuery } for _, t := range m.searchTags { pill += " #" + t } if m.queryDateFrom != nil { pill += " from:" + *m.queryDateFrom } if m.queryDateTo != nil { pill += " to:" + *m.queryDateTo } if m.queryCardType != nil { pill += " ^" + string(*m.queryCardType) } header += " " + searchPillStyle.Render(pill) } 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) statusLine() string { if m.state == stateConfirm { return renderConfirm(m.confirmID) } if m.err != nil { return errorStyle.Render("error: " + m.err.Error()) } return renderStatusBar(m, m.width) } func (m model) contentHeight() int { h := m.height - 4 if m.autocomplete.active && len(m.autocomplete.suggestions) > 0 { n := m.autocomplete.visibleCount() if len(m.autocomplete.suggestions) > maxSuggestions { n++ } h -= n + 1 } if h < 1 { h = 1 } return h } func (m *model) recalcSizes() { ch := m.contentHeight() lw := m.listWidth() if m.isSplit() && m.splitDetail { _, rw := m.splitWidths() m.list.setSize(lw, ch) m.cards.setSize(lw, ch) m.detail.setSize(rw, ch) } else { m.list.setSize(lw, ch) m.cards.setSize(lw, ch) m.detail.setSize(lw, ch) } m.filter.setHeight(ch) if m.railVisible() { m.tagRail.setSize(m.railWidth(), ch) } } func (m model) isSplit() bool { return m.width >= 100 } func (m model) railVisible() bool { return m.showTagRail && m.width >= 100 } func (m model) railWidth() int { if !m.railVisible() { return 0 } w := m.width * 18 / 100 if w < 16 { w = 16 } return w } func (m model) splitWidths() (int, int) { avail := m.width - m.railWidth() if m.railVisible() { avail-- } left := avail * 40 / 100 right := avail - left - 1 return left, right } func (m model) renderSeparator() string { ch := m.contentHeight() lines := make([]string, ch) for i := range lines { lines[i] = "│" } return separatorStyle.Render(strings.Join(lines, "\n")) } 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()), loadBacklinks(m.store, id), func() tea.Msg { e, err := m.store.Get(context.Background(), 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) }