60705463c1
Tag rail removed from tab cycle to reduce focus confusion. Rail is now ambient-by-default, focusable via h from list (spatial). - Tab: capture ↔ list (no rail, no detail in cycle) - h from list: focus tag rail (when visible) - l from rail: back to list - Split detail reachable via l/enter, not tab - Remove nextFocusFromCapture helper
1021 lines
22 KiB
Go
1021 lines
22 KiB
Go
package tui
|
|
|
|
import (
|
|
"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
|
|
)
|
|
|
|
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
|
|
showHelp bool
|
|
|
|
focus focusPane
|
|
splitDetail bool
|
|
showTagRail bool
|
|
|
|
filterTag string
|
|
confirmID string
|
|
cardsSort cardsSort
|
|
searchQuery string
|
|
searchTags []string
|
|
|
|
status string
|
|
statusSeq int
|
|
err error
|
|
}
|
|
|
|
func newModel(store *db.Store) model {
|
|
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.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
|
|
}
|
|
|
|
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:
|
|
m.state = stateList
|
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), 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:
|
|
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 tagsLoadedMsg:
|
|
m.filter.setTags(msg.tags)
|
|
m.tagRail.setTags(msg.tags)
|
|
m.state = stateTagFilter
|
|
return m, nil
|
|
|
|
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)
|
|
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":
|
|
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.input.clearText()
|
|
m.applySearch()
|
|
cmd := m.setFocus(focusList)
|
|
return m, cmd
|
|
}
|
|
if result.entity != nil {
|
|
return m, createEntity(m.store, result.entity)
|
|
}
|
|
return m, nil
|
|
case "esc", "tab":
|
|
cmd := m.setFocus(focusList)
|
|
return m, cmd
|
|
}
|
|
m.input = m.input.updateKey(msg)
|
|
return m, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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 "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() {
|
|
m.searchQuery = ""
|
|
m.searchTags = nil
|
|
m.status = ""
|
|
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, 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) 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)
|
|
}
|
|
|
|
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)
|
|
|
|
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
|
|
}
|
|
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 {
|
|
return m.height - 4
|
|
}
|
|
|
|
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()),
|
|
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)
|
|
}
|