package tui import ( "fmt" "strings" 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") } var b strings.Builder 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) if i == l.cursor { b.WriteString(selectedItemStyle.Render(" " + line)) } else { b.WriteString(listItemStyle.Render(line)) } if i < end-1 { b.WriteString("\n") } } return b.String() } func (l listModel) visibleCount() int { if l.height <= 0 { return 20 } return l.height } func renderEntity(e *db.Entity, maxWidth int) string { glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType)) id := idStyle.Render("[" + display.FormatID(e.ID) + "]") body := e.Body if e.Title != nil { body = *e.Title } var tags string 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, " ") } line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id) if maxWidth > 0 && len(stripAnsi(line)) > maxWidth { body = truncate(body, maxWidth-20) line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, 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() }