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:
@@ -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{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user