feat(tui): layout and interaction polish #33
@@ -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 {
|
|
||||||
m.status = "updated"
|
|
||||||
}
|
|
||||||
return m, m.reloadAfterEdit()
|
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