b5b7f6b6ee
Persistent left panel showing tags with counts. Provides ambient awareness of tag landscape without requiring a modal. - New tagRailModel in tagrail.go: tag list with cursor, scroll, counts - Rail visible at >=100 cols width, 18% width (min 16 chars) - ctrl+b toggles rail visibility - focusTagRail added to focus cycle: capture → tags → list → detail - j/k navigates, enter filters/unfilters by tag - Active filter tag highlighted bold in rail - Tags refresh after entity create/delete/absorb - Rail auto-hides on narrow terminals, # modal still works as fallback - Width allocation accounts for rail in split and non-split layouts
139 lines
2.5 KiB
Go
139 lines
2.5 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/lerko/nib/internal/db"
|
|
)
|
|
|
|
type tagRailModel struct {
|
|
tags []db.TagCount
|
|
cursor int
|
|
offset int
|
|
height int
|
|
width int
|
|
activeTag string
|
|
}
|
|
|
|
func newTagRailModel() tagRailModel {
|
|
return tagRailModel{}
|
|
}
|
|
|
|
func (r *tagRailModel) setTags(tags []db.TagCount) {
|
|
r.tags = tags
|
|
if r.cursor >= len(tags) {
|
|
r.cursor = max(0, len(tags)-1)
|
|
}
|
|
}
|
|
|
|
func (r *tagRailModel) setSize(width, height int) {
|
|
r.width = width
|
|
r.height = height
|
|
}
|
|
|
|
func (r tagRailModel) selectedTag() string {
|
|
if len(r.tags) == 0 || r.cursor >= len(r.tags) {
|
|
return ""
|
|
}
|
|
return r.tags[r.cursor].Tag
|
|
}
|
|
|
|
func (r tagRailModel) update(key string) tagRailModel {
|
|
switch key {
|
|
case "up", "k":
|
|
if r.cursor > 0 {
|
|
r.cursor--
|
|
if r.cursor < r.offset {
|
|
r.offset = r.cursor
|
|
}
|
|
}
|
|
case "down", "j":
|
|
if r.cursor < len(r.tags)-1 {
|
|
r.cursor++
|
|
visible := r.visibleCount()
|
|
if r.cursor >= r.offset+visible {
|
|
r.offset = r.cursor - visible + 1
|
|
}
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (r tagRailModel) visibleCount() int {
|
|
v := r.height - 2
|
|
if v <= 0 {
|
|
return 10
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (r tagRailModel) view(focused bool) string {
|
|
w := r.width
|
|
if w <= 0 {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
headerStyle := railHeaderStyle
|
|
if focused {
|
|
headerStyle = headerStyle.Foreground(highlight)
|
|
}
|
|
b.WriteString(headerStyle.Render("tags"))
|
|
b.WriteString("\n")
|
|
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
|
|
b.WriteString("\n")
|
|
|
|
if len(r.tags) == 0 {
|
|
b.WriteString(hintDescStyle.Render(" no tags"))
|
|
return b.String()
|
|
}
|
|
|
|
visible := r.visibleCount()
|
|
end := min(r.offset+visible, len(r.tags))
|
|
|
|
countW := 0
|
|
for _, tc := range r.tags {
|
|
cw := len(fmt.Sprintf("%d", tc.Count))
|
|
if cw > countW {
|
|
countW = cw
|
|
}
|
|
}
|
|
|
|
nameW := w - countW - 3
|
|
if nameW < 4 {
|
|
nameW = 4
|
|
}
|
|
|
|
for i := r.offset; i < end; i++ {
|
|
tc := r.tags[i]
|
|
name := "#" + tc.Tag
|
|
if len(name) > nameW {
|
|
name = name[:nameW-1] + "…"
|
|
}
|
|
|
|
count := fmt.Sprintf("%*d", countW, tc.Count)
|
|
gap := w - len(name) - len(count) - 1
|
|
if gap < 1 {
|
|
gap = 1
|
|
}
|
|
|
|
var line string
|
|
if i == r.cursor && focused {
|
|
line = selectedItemStyle.Render(" " + name + strings.Repeat(" ", gap) + railCountStyle.Render(count))
|
|
} else if tc.Tag == r.activeTag {
|
|
line = " " + railActiveTagStyle.Render(name) + strings.Repeat(" ", gap) + railCountStyle.Render(count)
|
|
} else {
|
|
line = " " + railTagStyle.Render(name) + strings.Repeat(" ", gap) + railCountStyle.Render(count)
|
|
}
|
|
|
|
b.WriteString(line)
|
|
if i < end-1 {
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|