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:
+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
|
||||
|
||||
Reference in New Issue
Block a user