Files
nib-v1/internal/tui/list.go
T
lerko 3eb778f31b fix(tui): clean up stream row density — drop ID, fix newline leak, align margins
ID cluttered rows and caused wrapping on long entries. Body newlines
leaked into stream rendering extra unindented lines. Cursor glyph
shifted selected rows 1 col right of unselected.

Remove ID from all row renderers (detail pane already shows it),
collapse multiline body to first line, cap tags to 2 in stream,
and reserve cursor column on unselected rows for consistent alignment.
2026-05-20 18:12:18 -04:00

270 lines
5.2 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
}
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()
}