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 } const dateGutterWidth = 9 func (l listModel) view(width int) string { ents := l.displayEntities() if len(ents) == 0 { return statusStyle.Render("no entities") } groups := groupByDate(ents) entityWidth := width - 4 - dateGutterWidth type displayLine struct { text string entityIdx int } var lines []displayLine entityIdx := 0 for _, g := range groups { for i, e := range g.entities { var gutter string if i == 0 { gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ") } else { gutter = gutterStyle.Render(" │ ") } line := gutter + renderEntity(e, entityWidth) lines = append(lines, displayLine{ text: line, entityIdx: entityIdx, }) entityIdx++ } } visible := l.visibleCount() offset := 0 if l.cursor >= visible { offset = l.cursor - visible + 1 } var b strings.Builder end := min(offset+visible, len(lines)) for i := offset; i < end; i++ { dl := lines[i] 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) 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 padRight(s string, n int) string { r := []rune(s) if len(r) >= n { return string(r[:n]) } return s + strings.Repeat(" ", n-len(r)) } 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) body := e.Body if e.Title != nil { body = *e.Title } if idx := strings.IndexByte(body, '\n'); idx >= 0 { body = body[:idx] } var extras []string if e.Pinned { extras = append(extras, pinnedStyle.Render("•")) } if len(e.Tags) > 0 { limit := min(2, len(e.Tags)) for _, t := range e.Tags[:limit] { extras = append(extras, tagStyle.Render("#"+t)) } } extraStr := "" if len(extras) > 0 { extraStr = " " + strings.Join(extras, " ") } line := fmt.Sprintf("%s %s%s", glyph, body, extraStr) if maxWidth > 0 && len(stripAnsi(line)) > maxWidth { body = truncate(body, maxWidth-6) line = fmt.Sprintf("%s %s%s", glyph, body, extraStr) } 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() }