989aa86679
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.
370 lines
7.1 KiB
Go
370 lines
7.1 KiB
Go
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 ""
|
||
}
|
||
}
|