1066c0bc7d
Search uses existing parse grammar ?prefix — type `?query #tag` in capture bar to filter entities client-side. Substring match on body+title+description with AND tag filtering. Esc clears search. Absorb via m key on fluid entities — opens source picker showing all other entities, enter merges source into target. Uses existing store.Absorb() backend.
278 lines
5.3 KiB
Go
278 lines
5.3 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"github.com/lerko/nib/internal/db"
|
|
"github.com/lerko/nib/internal/display"
|
|
)
|
|
|
|
type listModel struct {
|
|
entities []*db.Entity
|
|
filtered []*db.Entity
|
|
cursor int
|
|
offset int
|
|
height int
|
|
width int
|
|
}
|
|
|
|
func newListModel() listModel {
|
|
return listModel{}
|
|
}
|
|
|
|
func (l *listModel) setEntities(entities []*db.Entity) {
|
|
l.entities = entities
|
|
l.filtered = nil
|
|
if l.cursor >= len(entities) {
|
|
l.cursor = max(0, len(entities)-1)
|
|
}
|
|
}
|
|
|
|
func (l *listModel) setSize(width, height int) {
|
|
l.width = width
|
|
l.height = height
|
|
}
|
|
|
|
func (l listModel) displayEntities() []*db.Entity {
|
|
if l.filtered != nil {
|
|
return l.filtered
|
|
}
|
|
return l.entities
|
|
}
|
|
|
|
func (l listModel) selected() *db.Entity {
|
|
ents := l.displayEntities()
|
|
if len(ents) == 0 || l.cursor >= len(ents) {
|
|
return nil
|
|
}
|
|
return ents[l.cursor]
|
|
}
|
|
|
|
func (l listModel) update(msg tea.KeyMsg) listModel {
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if l.cursor > 0 {
|
|
l.cursor--
|
|
if l.cursor < l.offset {
|
|
l.offset = l.cursor
|
|
}
|
|
}
|
|
case "down", "j":
|
|
if l.cursor < len(l.displayEntities())-1 {
|
|
l.cursor++
|
|
visible := l.visibleCount()
|
|
if l.cursor >= l.offset+visible {
|
|
l.offset = l.cursor - visible + 1
|
|
}
|
|
}
|
|
case "home", "g":
|
|
l.cursor = 0
|
|
l.offset = 0
|
|
case "end", "G":
|
|
l.cursor = max(0, len(l.displayEntities())-1)
|
|
visible := l.visibleCount()
|
|
if l.cursor >= visible {
|
|
l.offset = l.cursor - visible + 1
|
|
}
|
|
case "pgup", "ctrl+u":
|
|
l.cursor = max(0, l.cursor-l.visibleCount())
|
|
if l.cursor < l.offset {
|
|
l.offset = l.cursor
|
|
}
|
|
case "pgdown", "ctrl+d":
|
|
l.cursor = min(len(l.displayEntities())-1, l.cursor+l.visibleCount())
|
|
visible := l.visibleCount()
|
|
if l.cursor >= l.offset+visible {
|
|
l.offset = l.cursor - visible + 1
|
|
}
|
|
}
|
|
return l
|
|
}
|
|
|
|
func (l listModel) view(width int) string {
|
|
ents := l.displayEntities()
|
|
if len(ents) == 0 {
|
|
return statusStyle.Render("no entities")
|
|
}
|
|
|
|
groups := groupByDate(ents)
|
|
|
|
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()
|
|
|
|
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 {
|
|
b.WriteString(listItemStyle.Render(dl.text))
|
|
}
|
|
if i < end-1 {
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
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 {
|
|
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
|
|
if e.Title != nil {
|
|
body = *e.Title
|
|
}
|
|
|
|
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)
|
|
}
|
|
extras = append(extras, strings.Join(tagParts, " "))
|
|
}
|
|
|
|
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, extraStr, id)
|
|
}
|
|
|
|
return line
|
|
}
|
|
|
|
func truncate(s string, maxLen int) string {
|
|
if maxLen <= 3 {
|
|
return "…"
|
|
}
|
|
runes := []rune(s)
|
|
if len(runes) <= maxLen {
|
|
return s
|
|
}
|
|
return string(runes[:maxLen-1]) + "…"
|
|
}
|
|
|
|
func stripAnsi(s string) string {
|
|
var b strings.Builder
|
|
inEsc := false
|
|
for _, r := range s {
|
|
if r == '\x1b' {
|
|
inEsc = true
|
|
continue
|
|
}
|
|
if inEsc {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
|
inEsc = false
|
|
}
|
|
continue
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
return b.String()
|
|
}
|