feat(tui): status debounce, scroll indicator, drawer label, card grouping

Status messages now use a sequence counter so rapid actions don't
cause premature clearing. Detail pane shows scroll position and
supports pgup/pgdown/g/G. Capture drawer border includes inline
label. Cards view groups by intent (pinned/grab/read/fill) with
gutter labels matching the stream view's date grouping pattern.
This commit is contained in:
2026-05-20 11:49:11 -04:00
parent cb10d1e93d
commit c26e2d2022
5 changed files with 191 additions and 62 deletions
+117 -13
View File
@@ -64,9 +64,16 @@ func matchesIntent(e *db.Entity, i intent) bool {
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
@@ -91,24 +98,69 @@ func (c *cardsModel) setIntent(i intent) {
}
func (c *cardsModel) applyFilter() {
c.filtered = nil
var pinned, rest []*db.Entity
for _, e := range c.entities {
if !matchesIntent(e, c.intent) {
continue
}
if e.Pinned {
pinned = append(pinned, e)
} else {
rest = append(rest, e)
}
}
c.filtered = append(pinned, rest...)
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
@@ -166,6 +218,9 @@ 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()
@@ -188,6 +243,55 @@ func (c cardsModel) view(width int) string {
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