diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 5e930f7..17155df 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -58,6 +58,8 @@ type tagsLoadedMsg struct { tags []db.TagCount } +type statusClearMsg struct{} + type editorFinishedMsg struct { err error } @@ -267,3 +269,9 @@ func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd { return templateCopiedMsg{} } } + +func clearStatusAfter(d time.Duration) tea.Cmd { + return tea.Tick(d, func(time.Time) tea.Msg { + return statusClearMsg{} + }) +} diff --git a/internal/tui/input.go b/internal/tui/input.go index 52027ff..b782107 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -107,7 +107,10 @@ func (i inputModel) view(width int) string { b.WriteString("\n") b.WriteString(i.ti.View()) b.WriteString("\n") - b.WriteString(drawerHintsStyle.Render("enter:submit esc:cancel ?:search -:todo @:event !:reminder")) + b.WriteString(drawerHintsStyle.Render(renderHints([]hint{ + {"enter", "submit"}, {"esc", "cancel"}, {"?", "search"}, + {"-", "todo"}, {"@", "event"}, {"!", "reminder"}, + }))) b.WriteString("\n") b.WriteString(i.renderPreview(width)) return b.String() diff --git a/internal/tui/model.go b/internal/tui/model.go index a5e7c5b..cbe06f9 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -10,6 +11,8 @@ import ( "github.com/lerko/nib/internal/db" ) +const statusTimeout = 2 * time.Second + type viewState int const ( @@ -192,37 +195,37 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.input.reset() m.recalcSizes() m.status = "created" - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) case entityDeletedMsg: m.status = "deleted" m.state = stateList - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) case entityUpdatedMsg: m.status = msg.action if m.state == stateDetail { m.detail.setEntity(msg.entity) } - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) case entityPromotedMsg: m.status = fmt.Sprintf("promoted → %s", msg.cardType) m.state = stateList - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) case entityDemotedMsg: m.status = "demoted → fluid" - return m, m.reloadDetail(msg.id) + return m, tea.Batch(m.reloadDetail(msg.id), clearStatusAfter(statusTimeout)) case entityCopiedMsg: m.status = "copied" - return m, nil + return m, clearStatusAfter(statusTimeout) case entityAbsorbedMsg: m.status = "absorbed" m.state = stateList - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) case absorbSourcesLoadedMsg: m.absorb = newAbsorbModel(msg.targetID) @@ -234,12 +237,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case stepsPersistedMsg: m.status = "steps saved" m.detail.mode = detailPreview - return m, m.reloadDetail(m.detail.entity.ID) + return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), clearStatusAfter(statusTimeout)) case templateCopiedMsg: m.status = "copied resolved" m.detail.mode = detailPreview - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) case tagsLoadedMsg: m.filter.setTags(msg.tags) @@ -249,10 +252,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case editorFinishedMsg: if msg.err != nil { m.err = msg.err - } else { - m.status = "updated" + return m, m.reloadAfterEdit() } - return m, m.reloadAfterEdit() + m.status = "updated" + return m, tea.Batch(m.reloadAfterEdit(), clearStatusAfter(statusTimeout)) case confirmTimeoutMsg: if m.state == stateConfirm { @@ -261,6 +264,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case statusClearMsg: + m.status = "" + return m, nil + case errMsg: m.err = msg.err return m, nil @@ -415,7 +422,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) } return m, nil @@ -532,7 +539,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if e != nil { if e.CardType != nil { m.status = "target must be fluid" - return m, nil + return m, clearStatusAfter(statusTimeout) } return m, loadAbsorbSources(m.store, e.ID) } @@ -543,7 +550,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if e != nil { if e.CardType != nil { m.status = "already a card" - return m, nil + return m, clearStatusAfter(statusTimeout) } m.promote = newPromoteModel(e.ID, e.Body) m.state = statePromote @@ -555,7 +562,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.state == stateDetail && m.detail.entity != nil { if m.detail.entity.CardType == nil { m.status = "already fluid" - return m, nil + return m, clearStatusAfter(statusTimeout) } return m, demoteEntity(m.store, m.detail.entity.ID) } @@ -758,6 +765,8 @@ func (m model) View() string { header := m.headerView() footer := m.footerView() + content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content) + return header + "\n" + content + "\n" + footer } @@ -824,10 +833,6 @@ func (m model) footerView() string { return errorStyle.Render("error: " + m.err.Error()) } - if m.status != "" { - return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m)) - } - return renderStatusBar(m, m.width) } diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index bdbfb09..609d7cb 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -2,24 +2,40 @@ package tui import ( "fmt" + "strings" "github.com/charmbracelet/lipgloss" ) +type hint struct { + key string + desc string +} + +func renderHints(hints []hint) string { + parts := make([]string, len(hints)) + for i, h := range hints { + parts[i] = hintKeyStyle.Render(h.key) + " " + hintDescStyle.Render(h.desc) + } + return strings.Join(parts, " ") +} + func renderStatusBar(m model, width int) string { left := countText(m) - right := contextHints(m) + if m.status != "" { + left = m.status + } + right := renderHints(contextHints(m)) leftRendered := statusStyle.Render(left) - rightRendered := helpStyle.Render(right) - gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(rightRendered) + gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(right) if gap < 0 { gap = 0 } pad := lipgloss.NewStyle().Width(gap).Render("") - return leftRendered + pad + rightRendered + return leftRendered + pad + right } func countText(m model) string { @@ -35,37 +51,37 @@ func countText(m model) string { return fmt.Sprintf("%d entities", total) } -func contextHints(m model) string { +func contextHints(m model) []hint { switch m.state { case stateDetail: switch m.detail.mode { case detailRun: - return "space:toggle j/k:nav r:reset esc:save+exit" + return []hint{{"space", "toggle"}, {"j/k", "nav"}, {"r", "reset"}, {"esc", "save+exit"}} case detailFill: - return "tab:next shift+tab:prev enter:copy esc:cancel" + return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}} default: - return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back" + return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}} } case stateInput: - return "" + return nil case stateTagFilter: - return "j/k:nav enter:select esc:cancel" + return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} case stateConfirm: - return "y:confirm n:cancel" + return []hint{{"y", "confirm"}, {"n", "cancel"}} case statePromote: - return "j/k:nav enter:select esc:cancel" + return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} case stateAbsorb: - return "j/k:nav enter:absorb esc:cancel" + return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}} default: if m.splitDetail { if m.focus == focusDetail { - return "h:list c:copy e:edit p:promote D:demote !:pin esc:back" + return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"D", "demote"}, {"!", "pin"}, {"esc", "back"}} } - return "l:detail a:add d:del #:filter esc:close ?:help q:quit" + return []hint{{"l", "detail"}, {"a", "add"}, {"d", "del"}, {"#", "filter"}, {"esc", "close"}, {"?", "help"}, {"q", "quit"}} } if m.mode == modeCards { - return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit" + return []hint{{"1", "stream"}, {"2", "cards"}, {"s", "sort"}, {"tab", "intent"}, {"a", "add"}, {"?", "help"}, {"q", "quit"}} } - return "1:stream 2:cards a:add/?search m:absorb d:del #:filter ?:help q:quit" + return []hint{{"1", "stream"}, {"2", "cards"}, {"a", "add"}, {"?", "search"}, {"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"?", "help"}, {"q", "quit"}} } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index ef837a2..19c1773 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -122,4 +122,11 @@ var ( separatorStyle = lipgloss.NewStyle(). Foreground(dim) + + hintKeyStyle = lipgloss.NewStyle(). + Foreground(highlight). + Bold(true) + + hintDescStyle = lipgloss.NewStyle(). + Foreground(dim) )