feat(tui): add bubbletea terminal UI with entity list, detail, and capture
Adds `nib tui` command and `make tui` target. Scrollable entity list with j/k navigation, enter for detail view, `a` to capture new entries using the existing parse grammar, and `d` to delete.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/display"
|
||||
)
|
||||
|
||||
type listModel struct {
|
||||
entities []*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
|
||||
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) selected() *db.Entity {
|
||||
if len(l.entities) == 0 || l.cursor >= len(l.entities) {
|
||||
return nil
|
||||
}
|
||||
return l.entities[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.entities)-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.entities)-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.entities)-1, l.cursor+l.visibleCount())
|
||||
visible := l.visibleCount()
|
||||
if l.cursor >= l.offset+visible {
|
||||
l.offset = l.cursor - visible + 1
|
||||
}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l listModel) view(width int) string {
|
||||
if len(l.entities) == 0 {
|
||||
return statusStyle.Render("no entities")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
visible := l.visibleCount()
|
||||
end := min(l.offset+visible, len(l.entities))
|
||||
|
||||
for i := l.offset; i < end; i++ {
|
||||
e := l.entities[i]
|
||||
line := renderEntity(e, width-4)
|
||||
|
||||
if i == l.cursor {
|
||||
b.WriteString(selectedItemStyle.Render(" " + line))
|
||||
} else {
|
||||
b.WriteString(listItemStyle.Render(line))
|
||||
}
|
||||
if i < end-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (l listModel) visibleCount() int {
|
||||
if l.height <= 0 {
|
||||
return 20
|
||||
}
|
||||
return l.height
|
||||
}
|
||||
|
||||
func renderEntity(e *db.Entity, maxWidth int) string {
|
||||
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
|
||||
|
||||
body := e.Body
|
||||
if e.Title != nil {
|
||||
body = *e.Title
|
||||
}
|
||||
|
||||
var tags string
|
||||
if len(e.Tags) > 0 {
|
||||
tagParts := make([]string, len(e.Tags))
|
||||
for i, t := range e.Tags {
|
||||
tagParts[i] = tagStyle.Render("#" + t)
|
||||
}
|
||||
tags = " " + strings.Join(tagParts, " ")
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
|
||||
|
||||
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||
body = truncate(body, maxWidth-20)
|
||||
line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user