feat(tui): add bubbletea terminal UI #30
@@ -95,6 +95,8 @@ type EntityUpdate struct {
|
|||||||
Glyph *Glyph
|
Glyph *Glyph
|
||||||
TimeAnchor *string
|
TimeAnchor *string
|
||||||
ClearTime bool
|
ClearTime bool
|
||||||
|
CompletedAt *time.Time
|
||||||
|
ClearCompleted bool
|
||||||
Pinned *bool
|
Pinned *bool
|
||||||
CardType *CardType
|
CardType *CardType
|
||||||
CardData *string
|
CardData *string
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@ type keyMap struct {
|
|||||||
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{
|
||||||
@@ -30,4 +37,11 @@ var keys = keyMap{
|
|||||||
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
@@ -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)
|
||||||
visible := l.visibleCount()
|
|
||||||
end := min(l.offset+visible, len(l.entities))
|
|
||||||
|
|
||||||
for i := l.offset; i < end; i++ {
|
type displayLine struct {
|
||||||
e := l.entities[i]
|
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)
|
line := renderEntity(e, width-4)
|
||||||
|
lines = append(lines, displayLine{
|
||||||
|
text: line,
|
||||||
|
entityIdx: entityIdx,
|
||||||
|
})
|
||||||
|
entityIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if i == l.cursor {
|
cursorLine := l.cursorDisplayLine(groups)
|
||||||
b.WriteString(selectedItemStyle.Render(" " + line))
|
visible := l.visibleCount()
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
if cursorLine >= visible {
|
||||||
|
offset = cursorLine - visible + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
} 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
|
||||||
|
|||||||
+249
-26
@@ -12,6 +12,8 @@ const (
|
|||||||
stateList viewState = iota
|
stateList viewState = iota
|
||||||
stateDetail
|
stateDetail
|
||||||
stateInput
|
stateInput
|
||||||
|
stateTagFilter
|
||||||
|
stateConfirm
|
||||||
)
|
)
|
||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
@@ -23,6 +25,11 @@ type model struct {
|
|||||||
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()
|
|
||||||
|
|
||||||
case msg.String() == "esc":
|
|
||||||
if m.state == stateDetail {
|
|
||||||
m.state = stateList
|
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case msg.String() == "enter" && m.state == stateList:
|
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 "enter":
|
||||||
|
if m.state == stateList {
|
||||||
if e := m.list.selected(); e != nil {
|
if e := m.list.selected(); e != nil {
|
||||||
m.detail.setEntity(e)
|
m.detail.setEntity(e)
|
||||||
m.state = stateDetail
|
m.state = stateDetail
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case msg.String() == "d" && m.state == stateList:
|
case "d":
|
||||||
|
if m.state == stateList {
|
||||||
if e := m.list.selected(); e != nil {
|
if e := m.list.selected(); e != nil {
|
||||||
return m, deleteEntity(m.store, e.ID)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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().
|
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)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user