Files
nib-v1/internal/tui/list.go
T
lerko 1066c0bc7d feat(tui): add search via capture bar and absorb flow
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.
2026-05-17 21:35:44 -04:00

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()
}