Files
nib-v1/internal/tui/list.go
T
lerko 989aa86679 fix(tui): compute truncation budget from actual overhead, not magic numbers
Tags wrapped past pane edge when detail split narrowed the list.
Truncation used fixed constants that didn't account for real tag width.
Now measures everything-except-body and gives body exactly what remains.
2026-05-20 18:49:38 -04:00

271 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 {
overhead := len(stripAnsi(line)) - len([]rune(body))
body = truncate(body, maxWidth-overhead)
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()
}