From c2ea63dd160ce58612636ca02ca6f2ab066b75fa Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 20:33:34 -0400 Subject: [PATCH] feat(tui): add status bar, help overlay, tag filter, and entity actions Status bar with entity count and context-sensitive key hints. Help overlay via ? key. Tag filter via # with cursor-navigable tag list. Todo toggle (x), pin (!), promote (p), demote (D), copy (c), edit (e) via $EDITOR. Delete confirmation with 3s timeout. Date-grouped list with completed todo and pinned indicators. Esc clears active tag filter. Adds CompletedAt/ClearCompleted to EntityUpdate for todo toggling. --- internal/db/entities.go | 28 ++-- internal/tui/commands.go | 155 +++++++++++++++++++++ internal/tui/confirm.go | 23 ++++ internal/tui/filter.go | 84 +++++++++++ internal/tui/help.go | 59 ++++++++ internal/tui/keys.go | 62 +++++---- internal/tui/list.go | 118 ++++++++++++++-- internal/tui/model.go | 283 ++++++++++++++++++++++++++++++++++---- internal/tui/statusbar.go | 46 +++++++ internal/tui/styles.go | 23 ++++ 10 files changed, 804 insertions(+), 77 deletions(-) create mode 100644 internal/tui/confirm.go create mode 100644 internal/tui/filter.go create mode 100644 internal/tui/help.go create mode 100644 internal/tui/statusbar.go diff --git a/internal/db/entities.go b/internal/db/entities.go index c257d7a..c3488f1 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -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)) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 7a14e8d..5b0ae8e 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -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} + }) +} diff --git a/internal/tui/confirm.go b/internal/tui/confirm.go new file mode 100644 index 0000000..84746f0 --- /dev/null +++ b/internal/tui/confirm.go @@ -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)) +} diff --git a/internal/tui/filter.go b/internal/tui/filter.go new file mode 100644 index 0000000..cb01f8e --- /dev/null +++ b/internal/tui/filter.go @@ -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() +} diff --git a/internal/tui/help.go b/internal/tui/help.go new file mode 100644 index 0000000..74d565c --- /dev/null +++ b/internal/tui/help.go @@ -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") +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index b779956..708599c 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -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")), } diff --git a/internal/tui/list.go b/internal/tui/list.go index 03f615f..57462e7 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -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 diff --git a/internal/tui/model.go b/internal/tui/model.go index 30a6571..8696fbe 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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) +} diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go new file mode 100644 index 0000000..fca7441 --- /dev/null +++ b/internal/tui/statusbar.go @@ -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" + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 97432ac..e4f31d7 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -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) )