feat(tui): layout and interaction polish #33

Merged
lerko merged 4 commits from fix/tui-polish into main 2026-05-20 16:33:58 +00:00
5 changed files with 77 additions and 38 deletions
Showing only changes of commit 4e0ac8402f - Show all commits
+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)
) )