fix(tui): pin footer to bottom, style hint bar, auto-clear status

Content area now enforces full height so the context help bar stays
pinned to the terminal bottom. Hint keys rendered with bold highlight
color for scannability. Status messages (created, deleted, etc.)
auto-clear after 2 seconds, reverting to the entity count.
This commit is contained in:
2026-05-20 11:01:13 -04:00
parent e2d0f3e997
commit 4e0ac8402f
5 changed files with 77 additions and 38 deletions
+8
View File
@@ -58,6 +58,8 @@ type tagsLoadedMsg struct {
tags []db.TagCount tags []db.TagCount
} }
type statusClearMsg struct{}
type editorFinishedMsg struct { type editorFinishedMsg struct {
err error err error
} }
@@ -267,3 +269,9 @@ func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd {
return templateCopiedMsg{} return templateCopiedMsg{}
} }
} }
func clearStatusAfter(d time.Duration) tea.Cmd {
return tea.Tick(d, func(time.Time) tea.Msg {
return statusClearMsg{}
})
}
+4 -1
View File
@@ -107,7 +107,10 @@ func (i inputModel) view(width int) string {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(i.ti.View()) b.WriteString(i.ti.View())
b.WriteString("\n") 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("\n")
b.WriteString(i.renderPreview(width)) b.WriteString(i.renderPreview(width))
return b.String() return b.String()
+25 -20
View File
@@ -3,6 +3,7 @@ package tui
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -10,6 +11,8 @@ import (
"github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/db"
) )
const statusTimeout = 2 * time.Second
type viewState int type viewState int
const ( const (
@@ -192,37 +195,37 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.input.reset() m.input.reset()
m.recalcSizes() m.recalcSizes()
m.status = "created" m.status = "created"
return m, loadEntities(m.store, m.listParams()) return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
case entityDeletedMsg: case entityDeletedMsg:
m.status = "deleted" m.status = "deleted"
m.state = stateList m.state = stateList
return m, loadEntities(m.store, m.listParams()) return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
case entityUpdatedMsg: case entityUpdatedMsg:
m.status = msg.action m.status = msg.action
if m.state == stateDetail { if m.state == stateDetail {
m.detail.setEntity(msg.entity) 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: case entityPromotedMsg:
m.status = fmt.Sprintf("promoted → %s", msg.cardType) m.status = fmt.Sprintf("promoted → %s", msg.cardType)
m.state = stateList m.state = stateList
return m, loadEntities(m.store, m.listParams()) return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
case entityDemotedMsg: case entityDemotedMsg:
m.status = "demoted → fluid" m.status = "demoted → fluid"
return m, m.reloadDetail(msg.id) return m, tea.Batch(m.reloadDetail(msg.id), clearStatusAfter(statusTimeout))
case entityCopiedMsg: case entityCopiedMsg:
m.status = "copied" m.status = "copied"
return m, nil return m, clearStatusAfter(statusTimeout)
case entityAbsorbedMsg: case entityAbsorbedMsg:
m.status = "absorbed" m.status = "absorbed"
m.state = stateList m.state = stateList
return m, loadEntities(m.store, m.listParams()) return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
case absorbSourcesLoadedMsg: case absorbSourcesLoadedMsg:
m.absorb = newAbsorbModel(msg.targetID) m.absorb = newAbsorbModel(msg.targetID)
@@ -234,12 +237,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case stepsPersistedMsg: case stepsPersistedMsg:
m.status = "steps saved" m.status = "steps saved"
m.detail.mode = detailPreview 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: case templateCopiedMsg:
m.status = "copied resolved" m.status = "copied resolved"
m.detail.mode = detailPreview m.detail.mode = detailPreview
return m, loadEntities(m.store, m.listParams()) return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
case tagsLoadedMsg: case tagsLoadedMsg:
m.filter.setTags(msg.tags) m.filter.setTags(msg.tags)
@@ -249,10 +252,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case editorFinishedMsg: case editorFinishedMsg:
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
} else { return m, m.reloadAfterEdit()
m.status = "updated"
} }
return m, m.reloadAfterEdit() m.status = "updated"
return m, tea.Batch(m.reloadAfterEdit(), clearStatusAfter(statusTimeout))
case confirmTimeoutMsg: case confirmTimeoutMsg:
if m.state == stateConfirm { if m.state == stateConfirm {
@@ -261,6 +264,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case statusClearMsg:
m.status = ""
return m, nil
case errMsg: case errMsg:
m.err = msg.err m.err = msg.err
return m, nil 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 { if m.mode == modeCards && m.state == stateList {
m.cardsSort = m.cardsSort.next() m.cardsSort = m.cardsSort.next()
m.status = "sort: " + m.cardsSort.String() 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 return m, nil
@@ -532,7 +539,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if e != nil { if e != nil {
if e.CardType != nil { if e.CardType != nil {
m.status = "target must be fluid" m.status = "target must be fluid"
return m, nil return m, clearStatusAfter(statusTimeout)
} }
return m, loadAbsorbSources(m.store, e.ID) 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 != nil {
if e.CardType != nil { if e.CardType != nil {
m.status = "already a card" m.status = "already a card"
return m, nil return m, clearStatusAfter(statusTimeout)
} }
m.promote = newPromoteModel(e.ID, e.Body) m.promote = newPromoteModel(e.ID, e.Body)
m.state = statePromote 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.state == stateDetail && m.detail.entity != nil {
if m.detail.entity.CardType == nil { if m.detail.entity.CardType == nil {
m.status = "already fluid" m.status = "already fluid"
return m, nil return m, clearStatusAfter(statusTimeout)
} }
return m, demoteEntity(m.store, m.detail.entity.ID) return m, demoteEntity(m.store, m.detail.entity.ID)
} }
@@ -758,6 +765,8 @@ func (m model) View() string {
header := m.headerView() header := m.headerView()
footer := m.footerView() footer := m.footerView()
content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content)
return header + "\n" + content + "\n" + footer return header + "\n" + content + "\n" + footer
} }
@@ -824,10 +833,6 @@ func (m model) footerView() string {
return errorStyle.Render("error: " + m.err.Error()) return errorStyle.Render("error: " + m.err.Error())
} }
if m.status != "" {
return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m))
}
return renderStatusBar(m, m.width) return renderStatusBar(m, m.width)
} }
+33 -17
View File
@@ -2,24 +2,40 @@ package tui
import ( import (
"fmt" "fmt"
"strings"
"github.com/charmbracelet/lipgloss" "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 { func renderStatusBar(m model, width int) string {
left := countText(m) left := countText(m)
right := contextHints(m) if m.status != "" {
left = m.status
}
right := renderHints(contextHints(m))
leftRendered := statusStyle.Render(left) 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 { if gap < 0 {
gap = 0 gap = 0
} }
pad := lipgloss.NewStyle().Width(gap).Render("") pad := lipgloss.NewStyle().Width(gap).Render("")
return leftRendered + pad + rightRendered return leftRendered + pad + right
} }
func countText(m model) string { func countText(m model) string {
@@ -35,37 +51,37 @@ func countText(m model) string {
return fmt.Sprintf("%d entities", total) return fmt.Sprintf("%d entities", total)
} }
func contextHints(m model) string { func contextHints(m model) []hint {
switch m.state { switch m.state {
case stateDetail: case stateDetail:
switch m.detail.mode { switch m.detail.mode {
case detailRun: 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: case detailFill:
return "tab:next shift+tab:prev enter:copy esc:cancel" return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}}
default: 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: case stateInput:
return "" return nil
case stateTagFilter: case stateTagFilter:
return "j/k:nav enter:select esc:cancel" return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
case stateConfirm: case stateConfirm:
return "y:confirm n:cancel" return []hint{{"y", "confirm"}, {"n", "cancel"}}
case statePromote: case statePromote:
return "j/k:nav enter:select esc:cancel" return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
case stateAbsorb: case stateAbsorb:
return "j/k:nav enter:absorb esc:cancel" return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}}
default: default:
if m.splitDetail { if m.splitDetail {
if m.focus == focusDetail { 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 { 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"}}
} }
} }
+7
View File
@@ -122,4 +122,11 @@ var (
separatorStyle = lipgloss.NewStyle(). separatorStyle = lipgloss.NewStyle().
Foreground(dim) Foreground(dim)
hintKeyStyle = lipgloss.NewStyle().
Foreground(highlight).
Bold(true)
hintDescStyle = lipgloss.NewStyle().
Foreground(dim)
) )