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 { type EntityUpdate struct {
Body *string Body *string
Title *string Title *string
Description *string Description *string
Glyph *Glyph Glyph *Glyph
TimeAnchor *string TimeAnchor *string
ClearTime bool ClearTime bool
Pinned *bool CompletedAt *time.Time
CardType *CardType ClearCompleted bool
CardData *string Pinned *bool
Tags *[]string CardType *CardType
CardData *string
Tags *[]string
} }
func (s *Store) Create(e *Entity) error { 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 = ?") sets = append(sets, "time_anchor = ?")
args = append(args, *u.TimeAnchor) 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 { if u.Pinned != nil {
sets = append(sets, "pinned = ?") sets = append(sets, "pinned = ?")
args = append(args, boolToInt(*u.Pinned)) args = append(args, boolToInt(*u.Pinned))
+155
View File
@@ -1,6 +1,11 @@
package tui package tui
import ( import (
"os"
"os/exec"
"time"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/db"
@@ -18,6 +23,29 @@ type entityDeletedMsg struct {
id string 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 { type errMsg struct {
err error err error
} }
@@ -49,3 +77,130 @@ func deleteEntity(store *db.Store, id string) tea.Cmd {
return entityDeletedMsg{id} 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" import "github.com/charmbracelet/bubbles/key"
type keyMap struct { type keyMap struct {
Up key.Binding Up key.Binding
Down key.Binding Down key.Binding
Enter key.Binding Enter key.Binding
Back key.Binding Back key.Binding
Add key.Binding Add key.Binding
Delete key.Binding Delete key.Binding
Quit key.Binding Quit key.Binding
Help key.Binding Help key.Binding
PageUp key.Binding PageUp key.Binding
PageDn key.Binding PageDn key.Binding
Top key.Binding Top key.Binding
Bottom 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{ var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")), Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")),
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")), 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")), PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")),
Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")), Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")),
Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")), 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 ( import (
"fmt" "fmt"
"strings" "strings"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -87,18 +88,49 @@ func (l listModel) view(width int) string {
return statusStyle.Render("no entities") 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() visible := l.visibleCount()
end := min(l.offset+visible, len(l.entities))
for i := l.offset; i < end; i++ { offset := 0
e := l.entities[i] if cursorLine >= visible {
line := renderEntity(e, width-4) offset = cursorLine - visible + 1
}
if i == l.cursor { var b strings.Builder
b.WriteString(selectedItemStyle.Render(" " + line)) 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 { } else {
b.WriteString(listItemStyle.Render(line)) b.WriteString(listItemStyle.Render(dl.text))
} }
if i < end-1 { if i < end-1 {
b.WriteString("\n") b.WriteString("\n")
@@ -108,6 +140,22 @@ func (l listModel) view(width int) string {
return b.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 { func (l listModel) visibleCount() int {
if l.height <= 0 { if l.height <= 0 {
return 20 return 20
@@ -115,8 +163,44 @@ func (l listModel) visibleCount() int {
return l.height 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 { 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) + "]") id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
body := e.Body body := e.Body
@@ -124,20 +208,28 @@ func renderEntity(e *db.Entity, maxWidth int) string {
body = *e.Title body = *e.Title
} }
var tags string var extras []string
if e.Pinned {
extras = append(extras, pinnedStyle.Render("•"))
}
if len(e.Tags) > 0 { if len(e.Tags) > 0 {
tagParts := make([]string, len(e.Tags)) tagParts := make([]string, len(e.Tags))
for i, t := range e.Tags { for i, t := range e.Tags {
tagParts[i] = tagStyle.Render("#" + t) 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 { if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-20) 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 return line
+253 -30
View File
@@ -12,6 +12,8 @@ const (
stateList viewState = iota stateList viewState = iota
stateDetail stateDetail
stateInput stateInput
stateTagFilter
stateConfirm
) )
type model struct { type model struct {
@@ -20,9 +22,14 @@ type model struct {
width int width int
height int height int
list listModel list listModel
detail detailModel detail detailModel
input inputModel input inputModel
filter filterModel
showHelp bool
filterTag string
confirmID string
status string status string
err error err error
@@ -35,11 +42,20 @@ func newModel(store *db.Store) model {
list: newListModel(), list: newListModel(),
detail: newDetailModel(), detail: newDetailModel(),
input: newInputModel(), input: newInputModel(),
filter: newFilterModel(),
} }
} }
func (m model) Init() tea.Cmd { 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) { 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.height = msg.Height
m.list.setSize(m.width, m.contentHeight()) m.list.setSize(m.width, m.contentHeight())
m.detail.setSize(m.width, m.contentHeight()) m.detail.setSize(m.width, m.contentHeight())
m.filter.setHeight(m.contentHeight())
return m, nil return m, nil
case entitiesLoadedMsg: case entitiesLoadedMsg:
m.list.setEntities(msg.entities) m.list.setEntities(msg.entities)
m.status = ""
m.err = nil m.err = nil
return m, nil return m, nil
@@ -61,52 +77,188 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateList m.state = stateList
m.input.reset() m.input.reset()
m.status = "created" m.status = "created"
return m, loadEntities(m.store, db.DefaultListParams()) return m, loadEntities(m.store, m.listParams())
case entityDeletedMsg: case entityDeletedMsg:
m.status = "deleted" 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: case errMsg:
m.err = msg.err m.err = msg.err
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
if m.state == stateInput { m.err = nil
switch m.state {
case stateInput:
return m.updateInput(msg) 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 return m, nil
} }
func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch { if m.showHelp {
case msg.String() == "q" || msg.String() == "ctrl+c": 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 return m, tea.Quit
case msg.String() == "a" && m.state == stateList: case "q":
m.state = stateInput if m.state == stateList {
m.input.focus() return m, tea.Quit
return m, m.input.ti.Focus() }
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 { if m.state == stateDetail {
m.state = stateList 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 return m, nil
case msg.String() == "enter" && m.state == stateList: case "enter":
if e := m.list.selected(); e != nil { if m.state == stateList {
m.detail.setEntity(e) if e := m.list.selected(); e != nil {
m.state = stateDetail m.detail.setEntity(e)
m.state = stateDetail
}
} }
return m, nil return m, nil
case msg.String() == "d" && m.state == stateList: case "d":
if e := m.list.selected(); e != nil { if m.state == stateList {
return m, deleteEntity(m.store, e.ID) 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 return m, nil
} }
@@ -137,19 +289,56 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m model) View() string { func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
var content string 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 { switch m.state {
case stateList: case stateList, stateInput, stateConfirm:
content = m.list.view(m.width) content = m.list.view(m.width)
case stateDetail: case stateDetail:
content = m.detail.view(m.width) content = m.detail.view(m.width)
case stateInput: case stateTagFilter:
content = m.list.view(m.width) content = m.filter.view(m.width)
} }
header := titleStyle.Render("nib") header := titleStyle.Render("nib")
if m.filterTag != "" {
header += " " + filterPillStyle.Render("#"+m.filterTag)
}
footer := m.footerView() footer := m.footerView()
return header + "\n" + content + "\n" + footer return header + "\n" + content + "\n" + footer
@@ -160,17 +349,51 @@ func (m model) footerView() string {
return m.input.view(m.width) return m.input.view(m.width)
} }
if m.state == stateConfirm {
return renderConfirm(m.confirmID)
}
if m.err != nil { if m.err != nil {
return errorStyle.Render("error: " + m.err.Error()) return errorStyle.Render("error: " + m.err.Error())
} }
if m.status != "" { 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 { func (m model) contentHeight() int {
return m.height - 3 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(). glyphStyle = lipgloss.NewStyle().
Width(2) Width(2)
completedGlyphStyle = lipgloss.NewStyle().
Width(2).
Foreground(dim)
tagStyle = lipgloss.NewStyle(). tagStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
@@ -54,4 +58,23 @@ var (
errorStyle = lipgloss.NewStyle(). errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")). Foreground(lipgloss.Color("#FF0000")).
PaddingLeft(1) 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)
) )