feat(tui): layout and interaction polish #33
@@ -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{}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+25
-20
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+33
-17
@@ -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"}}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,4 +122,11 @@ var (
|
||||
|
||||
separatorStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
|
||||
hintKeyStyle = lipgloss.NewStyle().
|
||||
Foreground(highlight).
|
||||
Bold(true)
|
||||
|
||||
hintDescStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user