Files
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

370 lines
7.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type intent int
const (
intentAll intent = iota
intentGrab
intentRead
intentFill
)
func (i intent) String() string {
switch i {
case intentGrab:
return "grab"
case intentRead:
return "read"
case intentFill:
return "fill"
default:
return "all"
}
}
func (i intent) next() intent {
switch i {
case intentAll:
return intentGrab
case intentGrab:
return intentRead
case intentRead:
return intentFill
default:
return intentAll
}
}
func matchesIntent(e *db.Entity, i intent) bool {
if i == intentAll {
return true
}
ct := e.CardType
if ct == nil {
return i == intentGrab
}
switch i {
case intentGrab:
return *ct == db.CardSnippet
case intentRead:
return *ct == db.CardNote || *ct == db.CardLink || *ct == db.CardDecision
case intentFill:
return *ct == db.CardTemplate || *ct == db.CardChecklist
}
return false
}
type cardGroup struct {
label string
start int
count int
}
type cardsModel struct {
entities []*db.Entity
filtered []*db.Entity
groups []cardGroup
cursor int
offset int
height int
width int
intent intent
}
func newCardsModel() cardsModel {
return cardsModel{}
}
func (c *cardsModel) setEntities(entities []*db.Entity) {
c.entities = entities
c.applyFilter()
}
func (c *cardsModel) setIntent(i intent) {
c.intent = i
c.cursor = 0
c.offset = 0
c.applyFilter()
}
func (c *cardsModel) applyFilter() {
c.filtered, c.groups = sortAndGroupCards(c.entities, c.intent)
if c.cursor >= len(c.filtered) {
c.cursor = max(0, len(c.filtered)-1)
}
}
func sortAndGroupCards(entities []*db.Entity, intentFilter intent) ([]*db.Entity, []cardGroup) {
if intentFilter != intentAll {
var pinned, rest []*db.Entity
for _, e := range entities {
if !matchesIntent(e, intentFilter) {
continue
}
if e.Pinned {
pinned = append(pinned, e)
} else {
rest = append(rest, e)
}
}
return append(pinned, rest...), nil
}
var pinned, grab, read, fill []*db.Entity
for _, e := range entities {
if e.Pinned {
pinned = append(pinned, e)
} else {
switch {
case matchesIntent(e, intentGrab):
grab = append(grab, e)
case matchesIntent(e, intentRead):
read = append(read, e)
case matchesIntent(e, intentFill):
fill = append(fill, e)
}
}
}
var filtered []*db.Entity
var groups []cardGroup
for _, bucket := range []struct {
label string
entities []*db.Entity
}{
{"pinned", pinned},
{"grab", grab},
{"read", read},
{"fill", fill},
} {
if len(bucket.entities) == 0 {
continue
}
groups = append(groups, cardGroup{
label: bucket.label,
start: len(filtered),
count: len(bucket.entities),
})
filtered = append(filtered, bucket.entities...)
}
return filtered, groups
}
func (c *cardsModel) setSize(width, height int) {
c.width = width
c.height = height
}
func (c cardsModel) selected() *db.Entity {
if len(c.filtered) == 0 || c.cursor >= len(c.filtered) {
return nil
}
return c.filtered[c.cursor]
}
func (c cardsModel) update(msg tea.KeyMsg) cardsModel {
switch msg.String() {
case "up", "k":
if c.cursor > 0 {
c.cursor--
if c.cursor < c.offset {
c.offset = c.cursor
}
}
case "down", "j":
if c.cursor < len(c.filtered)-1 {
c.cursor++
visible := c.visibleCount()
if c.cursor >= c.offset+visible {
c.offset = c.cursor - visible + 1
}
}
case "home", "g":
c.cursor = 0
c.offset = 0
case "end", "G":
c.cursor = max(0, len(c.filtered)-1)
visible := c.visibleCount()
if c.cursor >= visible {
c.offset = c.cursor - visible + 1
}
case "pgup", "ctrl+u":
c.cursor = max(0, c.cursor-c.visibleCount())
if c.cursor < c.offset {
c.offset = c.cursor
}
case "pgdown", "ctrl+d":
c.cursor = min(len(c.filtered)-1, c.cursor+c.visibleCount())
visible := c.visibleCount()
if c.cursor >= c.offset+visible {
c.offset = c.cursor - visible + 1
}
}
return c
}
func (c cardsModel) view(width int) string {
if len(c.filtered) == 0 {
return statusStyle.Render("no cards")
}
if len(c.groups) > 0 {
return c.groupedView(width)
}
var b strings.Builder
visible := c.visibleCount()
end := min(c.offset+visible, len(c.filtered))
for i := c.offset; i < end; i++ {
e := c.filtered[i]
line := renderCard(e, width-4)
if i == c.cursor {
b.WriteString(selectedItemStyle.Render(" " + line))
} else {
b.WriteString(listItemStyle.Render(line))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
func (c cardsModel) groupedView(width int) string {
entityWidth := width - 4 - dateGutterWidth
type displayLine struct {
text string
entityIdx int
}
var lines []displayLine
for _, g := range c.groups {
for i := 0; i < g.count; i++ {
eIdx := g.start + i
var gutter string
if i == 0 {
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
} else {
gutter = gutterStyle.Render(" │ ")
}
line := gutter + renderCard(c.filtered[eIdx], entityWidth)
lines = append(lines, displayLine{text: line, entityIdx: eIdx})
}
}
visible := c.visibleCount()
offset := c.offset
if c.cursor < offset {
offset = c.cursor
}
if c.cursor >= offset+visible {
offset = c.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 == c.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 (c cardsModel) visibleCount() int {
if c.height <= 0 {
return 20
}
return c.height
}
func renderCard(e *db.Entity, maxWidth int) string {
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
body := e.Body
if e.Title != nil {
body = *e.Title
}
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
body = body[:idx]
}
affordance := detectAffordance(e)
affordStr := ""
if affordance != "" {
affordStr = " " + affordanceStyle.Render(affordance)
}
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, " ")
}
useStr := ""
if e.UseCount > 0 {
useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount))
}
line := fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
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%s%s", glyph, body, affordStr, extraStr, useStr)
}
return line
}
func detectAffordance(e *db.Entity) string {
if e.CardType == nil {
return ""
}
switch *e.CardType {
case db.CardSnippet:
return "code"
case db.CardTemplate:
return "fill"
case db.CardChecklist:
return "steps"
case db.CardDecision:
return "decide"
case db.CardLink:
return "link"
default:
return ""
}
}