feat(tui): add bubbletea terminal UI #30

Merged
lerko merged 12 commits from feat/tui into main 2026-05-20 01:16:57 +00:00
10 changed files with 804 additions and 77 deletions
Showing only changes of commit c2ea63dd16 - Show all commits
+18 -10
View File
@@ -89,16 +89,18 @@ func DefaultListParams() ListParams {
}
type EntityUpdate struct {
Body *string
Title *string
Description *string
Glyph *Glyph
TimeAnchor *string
ClearTime bool
Pinned *bool
CardType *CardType
CardData *string
Tags *[]string
Body *string
Title *string
Description *string
Glyph *Glyph
TimeAnchor *string
ClearTime bool
CompletedAt *time.Time
ClearCompleted bool
Pinned *bool
CardType *CardType
CardData *string
Tags *[]string
}
func (s *Store) Create(e *Entity) error {
@@ -311,6 +313,12 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
sets = append(sets, "time_anchor = ?")
args = append(args, *u.TimeAnchor)
}
if u.ClearCompleted {
sets = append(sets, "completed_at = NULL")
} else if u.CompletedAt != nil {
sets = append(sets, "completed_at = ?")
args = append(args, u.CompletedAt.Format(time.RFC3339))
}
if u.Pinned != nil {
sets = append(sets, "pinned = ?")
args = append(args, boolToInt(*u.Pinned))
+155
View File
@@ -1,6 +1,11 @@
package tui
import (
"os"
"os/exec"
"time"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
@@ -18,6 +23,29 @@ type entityDeletedMsg struct {
id string
}
type entityUpdatedMsg struct {
entity *db.Entity
action string
}
type entityPromotedMsg struct {
id string
}
type entityDemotedMsg struct {
id string
}
type entityCopiedMsg struct{}
type tagsLoadedMsg struct {
tags []db.TagCount
}
type editorFinishedMsg struct {
err error
}
type errMsg struct {
err error
}
@@ -49,3 +77,130 @@ func deleteEntity(store *db.Store, id string) tea.Cmd {
return entityDeletedMsg{id}
}
}
func toggleTodo(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
var update db.EntityUpdate
if e.CompletedAt == nil {
now := time.Now().UTC()
update = db.EntityUpdate{CompletedAt: &now}
} else {
update = db.EntityUpdate{ClearCompleted: true}
}
if err := store.Update(e.ID, &update); err != nil {
return errMsg{err}
}
updated, err := store.Get(e.ID)
if err != nil {
return errMsg{err}
}
action := "completed"
if e.CompletedAt != nil {
action = "reopened"
}
return entityUpdatedMsg{updated, action}
}
}
func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
newPinned := !e.Pinned
update := db.EntityUpdate{Pinned: &newPinned}
if err := store.Update(e.ID, &update); err != nil {
return errMsg{err}
}
updated, err := store.Get(e.ID)
if err != nil {
return errMsg{err}
}
action := "pinned"
if !newPinned {
action = "unpinned"
}
return entityUpdatedMsg{updated, action}
}
}
func promoteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if err := store.Promote(id, db.CardSnippet, nil); err != nil {
return errMsg{err}
}
return entityPromotedMsg{id}
}
}
func demoteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if err := store.Demote(id); err != nil {
return errMsg{err}
}
return entityDemotedMsg{id}
}
}
func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
if err := clipboard.WriteAll(e.Body); err != nil {
return errMsg{err}
}
if err := store.IncrementUse(e.ID); err != nil {
return errMsg{err}
}
return entityCopiedMsg{}
}
}
func loadTags(store *db.Store) tea.Cmd {
return func() tea.Msg {
tags, err := store.ListTags(false)
if err != nil {
return errMsg{err}
}
return tagsLoadedMsg{tags}
}
}
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
f, err := os.CreateTemp("", "nib-edit-*.md")
if err != nil {
return func() tea.Msg { return errMsg{err} }
}
if _, err := f.WriteString(e.Body); err != nil {
f.Close()
os.Remove(f.Name())
return func() tea.Msg { return errMsg{err} }
}
f.Close()
c := exec.Command(editor, f.Name())
return tea.ExecProcess(c, func(err error) tea.Msg {
defer os.Remove(f.Name())
if err != nil {
return editorFinishedMsg{err}
}
content, readErr := os.ReadFile(f.Name())
if readErr != nil {
return editorFinishedMsg{readErr}
}
newBody := string(content)
if newBody == e.Body {
return editorFinishedMsg{nil}
}
update := db.EntityUpdate{Body: &newBody}
if updateErr := store.Update(e.ID, &update); updateErr != nil {
return editorFinishedMsg{updateErr}
}
return editorFinishedMsg{nil}
})
}
+23
View File
@@ -0,0 +1,23 @@
package tui
import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/display"
)
type confirmTimeoutMsg struct{}
func confirmTimeout() tea.Cmd {
return tea.Tick(3*time.Second, func(time.Time) tea.Msg {
return confirmTimeoutMsg{}
})
}
func renderConfirm(entityID string) string {
short := display.FormatID(entityID)
return errorStyle.Render(fmt.Sprintf("delete %s? y to confirm, any key to cancel", short))
}
+84
View File
@@ -0,0 +1,84 @@
package tui
import (
"fmt"
"strings"
"github.com/lerko/nib/internal/db"
)
type filterModel struct {
tags []db.TagCount
cursor int
height int
}
func newFilterModel() filterModel {
return filterModel{}
}
func (f *filterModel) setTags(tags []db.TagCount) {
f.tags = tags
f.cursor = 0
}
func (f *filterModel) setHeight(h int) {
f.height = h
}
func (f filterModel) selectedTag() string {
if len(f.tags) == 0 || f.cursor >= len(f.tags) {
return ""
}
return f.tags[f.cursor].Tag
}
func (f filterModel) update(key string) filterModel {
switch key {
case "up", "k":
if f.cursor > 0 {
f.cursor--
}
case "down", "j":
if f.cursor < len(f.tags)-1 {
f.cursor++
}
}
return f
}
func (f filterModel) view(width int) string {
if len(f.tags) == 0 {
return statusStyle.Render("no tags")
}
var b strings.Builder
b.WriteString(titleStyle.Render("filter by tag"))
b.WriteString("\n\n")
visible := f.height - 4
if visible <= 0 {
visible = 10
}
offset := 0
if f.cursor >= visible {
offset = f.cursor - visible + 1
}
end := min(offset+visible, len(f.tags))
for i := offset; i < end; i++ {
tc := f.tags[i]
tag := fmt.Sprintf("#%-20s %d", tc.Tag, tc.Count)
if i == f.cursor {
b.WriteString(selectedItemStyle.Render(" " + tagStyle.Render(tag)))
} else {
b.WriteString(listItemStyle.Render(tagStyle.Render(tag)))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
+59
View File
@@ -0,0 +1,59 @@
package tui
import "strings"
func renderHelp(width, height int) string {
sections := []struct {
title string
binds [][2]string
}{
{"Navigation", [][2]string{
{"j/k ↑/↓", "move cursor"},
{"g/G home/end", "top / bottom"},
{"pgup/pgdn", "page up / down"},
{"enter", "view detail"},
{"esc", "back / cancel"},
}},
{"Actions", [][2]string{
{"a", "add entity"},
{"d", "delete (with confirm)"},
{"x", "toggle todo completion"},
{"!", "toggle pin"},
{"#", "filter by tag"},
}},
{"Detail View", [][2]string{
{"p", "promote to card"},
{"D", "demote to fluid"},
{"c", "copy to clipboard"},
{"e", "edit in $EDITOR"},
{"!", "toggle pin"},
}},
{"Global", [][2]string{
{"?", "toggle help"},
{"q / ctrl+c", "quit"},
}},
}
var b strings.Builder
b.WriteString(detailHeaderStyle.Render("keybindings"))
b.WriteString("\n\n")
for _, s := range sections {
b.WriteString(titleStyle.Render(s.title))
b.WriteString("\n")
for _, bind := range s.binds {
key := helpKeyStyle.Render(bind[0])
desc := helpDescStyle.Render(bind[1])
b.WriteString(" " + key + " " + desc + "\n")
}
b.WriteString("\n")
}
b.WriteString(helpStyle.Render("press ? or esc to close"))
lines := strings.Split(b.String(), "\n")
if len(lines) > height {
lines = lines[:height]
}
return strings.Join(lines, "\n")
}
+38 -24
View File
@@ -3,31 +3,45 @@ package tui
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Back key.Binding
Add key.Binding
Delete key.Binding
Quit key.Binding
Help key.Binding
PageUp key.Binding
PageDn key.Binding
Top key.Binding
Bottom key.Binding
Up key.Binding
Down key.Binding
Enter key.Binding
Back key.Binding
Add key.Binding
Delete key.Binding
Quit key.Binding
Help key.Binding
PageUp key.Binding
PageDn key.Binding
Top key.Binding
Bottom key.Binding
Todo key.Binding
Pin key.Binding
Filter key.Binding
Promote key.Binding
Demote key.Binding
Copy key.Binding
Edit key.Binding
}
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")),
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")),
PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")),
Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")),
Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")),
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")),
PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")),
Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")),
Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")),
Todo: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle todo")),
Pin: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "toggle pin")),
Filter: key.NewBinding(key.WithKeys("#"), key.WithHelp("#", "filter tag")),
Promote: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "promote")),
Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")),
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")),
Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
}
+105 -13
View File
@@ -3,6 +3,7 @@ package tui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -87,18 +88,49 @@ func (l listModel) view(width int) string {
return statusStyle.Render("no entities")
}
var b strings.Builder
groups := groupByDate(l.entities)
type displayLine struct {
text string
entityIdx int
isHeader bool
}
var lines []displayLine
entityIdx := 0
for _, g := range groups {
lines = append(lines, displayLine{
text: dateHeaderStyle.Render("── " + g.label + " ──"),
isHeader: true,
})
for _, e := range g.entities {
line := renderEntity(e, width-4)
lines = append(lines, displayLine{
text: line,
entityIdx: entityIdx,
})
entityIdx++
}
}
cursorLine := l.cursorDisplayLine(groups)
visible := l.visibleCount()
end := min(l.offset+visible, len(l.entities))
for i := l.offset; i < end; i++ {
e := l.entities[i]
line := renderEntity(e, width-4)
offset := 0
if cursorLine >= visible {
offset = cursorLine - visible + 1
}
if i == l.cursor {
b.WriteString(selectedItemStyle.Render(" " + line))
var b strings.Builder
end := min(offset+visible, len(lines))
for i := offset; i < end; i++ {
dl := lines[i]
if dl.isHeader {
b.WriteString(dl.text)
} else if dl.entityIdx == l.cursor {
b.WriteString(selectedItemStyle.Render(" " + dl.text))
} else {
b.WriteString(listItemStyle.Render(line))
b.WriteString(listItemStyle.Render(dl.text))
}
if i < end-1 {
b.WriteString("\n")
@@ -108,6 +140,22 @@ func (l listModel) view(width int) string {
return b.String()
}
func (l listModel) cursorDisplayLine(groups []dateGroup) int {
line := 0
entityIdx := 0
for _, g := range groups {
line++
for range g.entities {
if entityIdx == l.cursor {
return line
}
line++
entityIdx++
}
}
return 0
}
func (l listModel) visibleCount() int {
if l.height <= 0 {
return 20
@@ -115,8 +163,44 @@ func (l listModel) visibleCount() int {
return l.height
}
type dateGroup struct {
label string
entities []*db.Entity
}
func groupByDate(entities []*db.Entity) []dateGroup {
var groups []dateGroup
var current *dateGroup
for _, e := range entities {
label := formatDateLabel(e.CreatedAt)
if current == nil || current.label != label {
if current != nil {
groups = append(groups, *current)
}
current = &dateGroup{label: label}
}
current.entities = append(current.entities, e)
}
if current != nil {
groups = append(groups, *current)
}
return groups
}
func formatDateLabel(t time.Time) string {
return strings.ToLower(t.Format("Jan 2"))
}
func renderEntity(e *db.Entity, maxWidth int) string {
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
glyphStr := display.DisplayGlyph(e.Glyph, e.CardType)
style := glyphStyle
if e.Glyph == db.GlyphTodo && e.CompletedAt != nil {
glyphStr = "●"
style = completedGlyphStyle
}
glyph := style.Render(glyphStr)
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
body := e.Body
@@ -124,20 +208,28 @@ func renderEntity(e *db.Entity, maxWidth int) string {
body = *e.Title
}
var tags string
var extras []string
if e.Pinned {
extras = append(extras, pinnedStyle.Render("•"))
}
if len(e.Tags) > 0 {
tagParts := make([]string, len(e.Tags))
for i, t := range e.Tags {
tagParts[i] = tagStyle.Render("#" + t)
}
tags = " " + strings.Join(tagParts, " ")
extras = append(extras, strings.Join(tagParts, " "))
}
line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
extraStr := ""
if len(extras) > 0 {
extraStr = " " + strings.Join(extras, " ")
}
line := fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-20)
line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
line = fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id)
}
return line
+253 -30
View File
@@ -12,6 +12,8 @@ const (
stateList viewState = iota
stateDetail
stateInput
stateTagFilter
stateConfirm
)
type model struct {
@@ -20,9 +22,14 @@ type model struct {
width int
height int
list listModel
detail detailModel
input inputModel
list listModel
detail detailModel
input inputModel
filter filterModel
showHelp bool
filterTag string
confirmID string
status string
err error
@@ -35,11 +42,20 @@ func newModel(store *db.Store) model {
list: newListModel(),
detail: newDetailModel(),
input: newInputModel(),
filter: newFilterModel(),
}
}
func (m model) Init() tea.Cmd {
return loadEntities(m.store, db.DefaultListParams())
return loadEntities(m.store, m.listParams())
}
func (m model) listParams() db.ListParams {
p := db.DefaultListParams()
if m.filterTag != "" {
p.Tag = &m.filterTag
}
return p
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -49,11 +65,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
m.list.setSize(m.width, m.contentHeight())
m.detail.setSize(m.width, m.contentHeight())
m.filter.setHeight(m.contentHeight())
return m, nil
case entitiesLoadedMsg:
m.list.setEntities(msg.entities)
m.status = ""
m.err = nil
return m, nil
@@ -61,52 +77,188 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateList
m.input.reset()
m.status = "created"
return m, loadEntities(m.store, db.DefaultListParams())
return m, loadEntities(m.store, m.listParams())
case entityDeletedMsg:
m.status = "deleted"
return m, loadEntities(m.store, db.DefaultListParams())
m.state = stateList
return m, loadEntities(m.store, m.listParams())
case entityUpdatedMsg:
m.status = msg.action
if m.state == stateDetail {
m.detail.setEntity(msg.entity)
}
return m, loadEntities(m.store, m.listParams())
case entityPromotedMsg:
m.status = "promoted → snippet"
return m, m.reloadDetail(msg.id)
case entityDemotedMsg:
m.status = "demoted → fluid"
return m, m.reloadDetail(msg.id)
case entityCopiedMsg:
m.status = "copied"
return m, nil
case tagsLoadedMsg:
m.filter.setTags(msg.tags)
m.state = stateTagFilter
return m, nil
case editorFinishedMsg:
if msg.err != nil {
m.err = msg.err
} else {
m.status = "updated"
}
return m, m.reloadAfterEdit()
case confirmTimeoutMsg:
if m.state == stateConfirm {
m.state = stateList
m.confirmID = ""
}
return m, nil
case errMsg:
m.err = msg.err
return m, nil
case tea.KeyMsg:
if m.state == stateInput {
m.err = nil
switch m.state {
case stateInput:
return m.updateInput(msg)
case stateTagFilter:
return m.updateTagFilter(msg)
case stateConfirm:
return m.updateConfirm(msg)
default:
return m.updateKeys(msg)
}
return m.updateKeys(msg)
}
return m, nil
}
func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case msg.String() == "q" || msg.String() == "ctrl+c":
if m.showHelp {
if msg.String() == "?" || msg.String() == "esc" || msg.String() == "q" {
m.showHelp = false
}
return m, nil
}
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case msg.String() == "a" && m.state == stateList:
m.state = stateInput
m.input.focus()
return m, m.input.ti.Focus()
case "q":
if m.state == stateList {
return m, tea.Quit
}
return m, nil
case msg.String() == "esc":
case "?":
m.showHelp = true
return m, nil
case "a":
if m.state == stateList {
m.state = stateInput
m.input.focus()
return m, m.input.ti.Focus()
}
case "esc":
if m.state == stateDetail {
m.state = stateList
return m, nil
}
if m.state == stateList && m.filterTag != "" {
m.filterTag = ""
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case msg.String() == "enter" && m.state == stateList:
if e := m.list.selected(); e != nil {
m.detail.setEntity(e)
m.state = stateDetail
case "enter":
if m.state == stateList {
if e := m.list.selected(); e != nil {
m.detail.setEntity(e)
m.state = stateDetail
}
}
return m, nil
case msg.String() == "d" && m.state == stateList:
if e := m.list.selected(); e != nil {
return m, deleteEntity(m.store, e.ID)
case "d":
if m.state == stateList {
if e := m.list.selected(); e != nil {
m.confirmID = e.ID
m.state = stateConfirm
return m, confirmTimeout()
}
}
return m, nil
case "x":
if m.state == stateList {
if e := m.list.selected(); e != nil && e.Glyph == db.GlyphTodo {
return m, toggleTodo(m.store, e)
}
}
return m, nil
case "!":
e := m.selectedEntity()
if e != nil {
return m, pinEntity(m.store, e)
}
return m, nil
case "#":
if m.state == stateList {
if m.filterTag != "" {
m.filterTag = ""
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
return m, loadTags(m.store)
}
return m, nil
case "p":
if m.state == stateDetail && m.detail.entity != nil {
if m.detail.entity.CardType != nil {
m.status = "already a card"
return m, nil
}
return m, promoteEntity(m.store, m.detail.entity.ID)
}
return m, nil
case "D":
if m.state == stateDetail && m.detail.entity != nil {
if m.detail.entity.CardType == nil {
m.status = "already fluid"
return m, nil
}
return m, demoteEntity(m.store, m.detail.entity.ID)
}
return m, nil
case "c":
if m.state == stateDetail && m.detail.entity != nil {
return m, copyToClipboard(m.store, m.detail.entity)
}
return m, nil
case "e":
if m.state == stateDetail && m.detail.entity != nil {
return m, editInEditor(m.store, m.detail.entity)
}
return m, nil
}
@@ -137,19 +289,56 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m model) View() string {
var content string
func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
m.state = stateList
return m, nil
case "enter":
tag := m.filter.selectedTag()
if tag != "" {
m.filterTag = tag
m.state = stateList
return m, loadEntities(m.store, m.listParams())
}
return m, nil
default:
m.filter = m.filter.update(msg.String())
return m, nil
}
}
func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
id := m.confirmID
m.confirmID = ""
m.state = stateList
if msg.String() == "y" && id != "" {
return m, deleteEntity(m.store, id)
}
return m, nil
}
func (m model) View() string {
if m.showHelp {
return renderHelp(m.width, m.height)
}
var content string
switch m.state {
case stateList:
case stateList, stateInput, stateConfirm:
content = m.list.view(m.width)
case stateDetail:
content = m.detail.view(m.width)
case stateInput:
content = m.list.view(m.width)
case stateTagFilter:
content = m.filter.view(m.width)
}
header := titleStyle.Render("nib")
if m.filterTag != "" {
header += " " + filterPillStyle.Render("#"+m.filterTag)
}
footer := m.footerView()
return header + "\n" + content + "\n" + footer
@@ -160,17 +349,51 @@ func (m model) footerView() string {
return m.input.view(m.width)
}
if m.state == stateConfirm {
return renderConfirm(m.confirmID)
}
if m.err != nil {
return errorStyle.Render("error: " + m.err.Error())
}
if m.status != "" {
return statusStyle.Render(m.status)
return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m))
}
return helpStyle.Render("a:add enter:view d:delete q:quit ?:help")
return renderStatusBar(m, m.width)
}
func (m model) contentHeight() int {
return m.height - 3
}
func (m model) selectedEntity() *db.Entity {
switch m.state {
case stateList:
return m.list.selected()
case stateDetail:
return m.detail.entity
}
return nil
}
func (m model) reloadDetail(id string) tea.Cmd {
return tea.Batch(
loadEntities(m.store, m.listParams()),
func() tea.Msg {
e, err := m.store.Get(id)
if err != nil {
return errMsg{err}
}
return entityUpdatedMsg{e, ""}
},
)
}
func (m model) reloadAfterEdit() tea.Cmd {
if m.detail.entity == nil {
return loadEntities(m.store, m.listParams())
}
return m.reloadDetail(m.detail.entity.ID)
}
+46
View File
@@ -0,0 +1,46 @@
package tui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
func renderStatusBar(m model, width int) string {
left := countText(m)
right := contextHints(m)
leftRendered := statusStyle.Render(left)
rightRendered := helpStyle.Render(right)
gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(rightRendered)
if gap < 0 {
gap = 0
}
pad := lipgloss.NewStyle().Width(gap).Render("")
return leftRendered + pad + rightRendered
}
func countText(m model) string {
total := len(m.list.entities)
if m.filterTag != "" {
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
}
return fmt.Sprintf("%d entities", total)
}
func contextHints(m model) string {
switch m.state {
case stateDetail:
return "p:promote D:demote c:copy e:edit !:pin esc:back"
case stateInput:
return "enter:submit esc:cancel"
case stateTagFilter:
return "j/k:nav enter:select esc:cancel"
case stateConfirm:
return "y:confirm n:cancel"
default:
return "a:add d:del x:todo #:filter ?:help q:quit"
}
}
+23
View File
@@ -28,6 +28,10 @@ var (
glyphStyle = lipgloss.NewStyle().
Width(2)
completedGlyphStyle = lipgloss.NewStyle().
Width(2).
Foreground(dim)
tagStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
@@ -54,4 +58,23 @@ var (
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
PaddingLeft(1)
dateHeaderStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(1)
pinnedStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#D4A017", Dark: "#FFD700"})
filterPillStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}).
Bold(true)
helpKeyStyle = lipgloss.NewStyle().
Foreground(highlight).
Bold(true).
Width(18)
helpDescStyle = lipgloss.NewStyle().
Foreground(dim)
)