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.
This commit is contained in:
+18
-10
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user