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 cursor int offset int height int width int } func newListModel() listModel { return listModel{} } func (l *listModel) setEntities(entities []*db.Entity) { l.entities = entities 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) selected() *db.Entity { if len(l.entities) == 0 || l.cursor >= len(l.entities) { return nil } return l.entities[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.entities)-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.entities)-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.entities)-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 { if len(l.entities) == 0 { return statusStyle.Render("no entities") } 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() 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() }