feat(tui): collapsible tag rail with ambient tag awareness #35

Merged
lerko merged 2 commits from feat/tag-rail into main 2026-05-20 19:24:42 +00:00
7 changed files with 304 additions and 34 deletions
+14
View File
@@ -58,6 +58,10 @@ type tagsLoadedMsg struct {
tags []db.TagCount tags []db.TagCount
} }
type railTagsLoadedMsg struct {
tags []db.TagCount
}
type statusClearMsg struct{ seq int } type statusClearMsg struct{ seq int }
type editorFinishedMsg struct { type editorFinishedMsg struct {
@@ -181,6 +185,16 @@ func loadTags(store *db.Store) tea.Cmd {
} }
} }
func loadRailTags(store *db.Store) tea.Cmd {
return func() tea.Msg {
tags, err := store.ListTags(false)
if err != nil {
return errMsg{err}
}
return railTagsLoadedMsg{tags}
}
}
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd { func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
editorEnv := os.Getenv("EDITOR") editorEnv := os.Getenv("EDITOR")
if editorEnv == "" { if editorEnv == "" {
+4 -1
View File
@@ -8,9 +8,12 @@ func renderHelp(width, height int) string {
binds [][2]string binds [][2]string
}{ }{
{"Focus", [][2]string{ {"Focus", [][2]string{
{"tab", "cycle focus: capture list → detail"}, {"tab", "toggle capture list"},
{"esc", "back / clear filter / to capture"}, {"esc", "back / clear filter / to capture"},
{"a", "focus capture bar"}, {"a", "focus capture bar"},
{"h", "focus tag rail (from list)"},
{"l", "focus detail (split view)"},
{"ctrl+b", "toggle tag rail"},
}}, }},
{"Capture Bar", [][2]string{ {"Capture Bar", [][2]string{
{"enter", "submit (or browse if empty)"}, {"enter", "submit (or browse if empty)"},
+2
View File
@@ -32,6 +32,7 @@ type keyMap struct {
FocusLeft key.Binding FocusLeft key.Binding
FocusRight key.Binding FocusRight key.Binding
Tab key.Binding Tab key.Binding
ToggleRail key.Binding
} }
var keys = keyMap{ var keys = keyMap{
@@ -64,4 +65,5 @@ var keys = keyMap{
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")), FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")), FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")), Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")),
ToggleRail: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle tag rail")),
} }
+130 -33
View File
@@ -65,6 +65,7 @@ type focusPane int
const ( const (
focusCapture focusPane = iota focusCapture focusPane = iota
focusTagRail
focusList focusList
focusDetail focusDetail
) )
@@ -83,10 +84,12 @@ type model struct {
filter filterModel filter filterModel
promote promoteModel promote promoteModel
absorb absorbModel absorb absorbModel
tagRail tagRailModel
showHelp bool showHelp bool
focus focusPane focus focusPane
splitDetail bool splitDetail bool
showTagRail bool
filterTag string filterTag string
confirmID string confirmID string
@@ -103,15 +106,17 @@ func newModel(store *db.Store) model {
inp := newInputModel() inp := newInputModel()
inp.ti.Focus() inp.ti.Focus()
return model{ return model{
store: store, store: store,
state: stateList, state: stateList,
mode: modeStream, mode: modeStream,
focus: focusCapture, focus: focusCapture,
list: newListModel(), showTagRail: true,
cards: newCardsModel(), list: newListModel(),
detail: newDetailModel(), cards: newCardsModel(),
input: inp, detail: newDetailModel(),
filter: newFilterModel(), input: inp,
filter: newFilterModel(),
tagRail: newTagRailModel(),
} }
} }
@@ -131,7 +136,7 @@ func (m *model) setStatus(msg string) tea.Cmd {
} }
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {
return tea.Batch(loadEntities(m.store, m.listParams()), m.input.ti.Focus()) return tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.input.ti.Focus())
} }
func (m model) listParams() db.ListParams { func (m model) listParams() db.ListParams {
@@ -188,6 +193,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.splitDetail = false m.splitDetail = false
m.focus = focusList m.focus = focusList
} }
if !m.railVisible() && m.focus == focusTagRail {
m.focus = focusList
}
m.recalcSizes() m.recalcSizes()
return m, nil return m, nil
@@ -203,13 +211,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = nil m.err = nil
return m, nil return m, nil
case railTagsLoadedMsg:
m.tagRail.setTags(msg.tags)
m.tagRail.activeTag = m.filterTag
return m, nil
case entityCreatedMsg: case entityCreatedMsg:
m.input.clearText() m.input.clearText()
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("created")) return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("created"))
case entityDeletedMsg: case entityDeletedMsg:
m.state = stateList m.state = stateList
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("deleted")) return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("deleted"))
case entityUpdatedMsg: case entityUpdatedMsg:
if m.state == stateDetail { if m.state == stateDetail {
@@ -229,7 +242,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case entityAbsorbedMsg: case entityAbsorbedMsg:
m.state = stateList m.state = stateList
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("absorbed")) return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("absorbed"))
case absorbSourcesLoadedMsg: case absorbSourcesLoadedMsg:
m.absorb = newAbsorbModel(msg.targetID) m.absorb = newAbsorbModel(msg.targetID)
@@ -248,6 +261,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tagsLoadedMsg: case tagsLoadedMsg:
m.filter.setTags(msg.tags) m.filter.setTags(msg.tags)
m.tagRail.setTags(msg.tags)
m.state = stateTagFilter m.state = stateTagFilter
return m, nil return m, nil
@@ -352,13 +366,50 @@ func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Tag rail focus handling
if m.focus == focusTagRail && m.state == stateList {
switch msg.String() {
case "j", "k", "up", "down":
m.tagRail = m.tagRail.update(msg.String())
return m, nil
case "enter":
tag := m.tagRail.selectedTag()
if tag != "" {
if tag == m.filterTag {
m.filterTag = ""
m.tagRail.activeTag = ""
} else {
m.filterTag = tag
m.tagRail.activeTag = tag
}
m.focus = focusList
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "l", "tab", "esc":
m.focus = focusList
return m, nil
case "ctrl+b":
m.showTagRail = false
m.focus = focusList
m.recalcSizes()
return m, nil
}
}
// ctrl+b toggle from any browse focus
if msg.String() == "ctrl+b" && m.state == stateList {
m.showTagRail = !m.showTagRail
if !m.railVisible() && m.focus == focusTagRail {
m.focus = focusList
}
m.recalcSizes()
return m, nil
}
if m.splitDetail && m.state == stateList { if m.splitDetail && m.state == stateList {
switch msg.String() { switch msg.String() {
case "tab": case "tab":
if m.focus == focusList {
m.focus = focusDetail
return m, nil
}
cmd := m.setFocus(focusCapture) cmd := m.setFocus(focusCapture)
return m, cmd return m, cmd
case "l": case "l":
@@ -371,6 +422,10 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.focus = focusList m.focus = focusList
return m, nil return m, nil
} }
if m.focus == focusList && m.railVisible() {
m.focus = focusTagRail
return m, nil
}
case "esc": case "esc":
if m.focus == focusDetail { if m.focus == focusDetail {
m.focus = focusList m.focus = focusList
@@ -491,6 +546,13 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
cmd := m.setFocus(focusCapture) cmd := m.setFocus(focusCapture)
return m, cmd return m, cmd
case "h":
if m.state == stateList && m.railVisible() && m.focus == focusList {
m.focus = focusTagRail
return m, nil
}
return m, nil
case "a": case "a":
if m.state == stateList { if m.state == stateList {
cmd := m.setFocus(focusCapture) cmd := m.setFocus(focusCapture)
@@ -775,6 +837,13 @@ func (m model) View() string {
} else { } else {
content = listContent content = listContent
} }
if m.railVisible() {
rw := m.railWidth()
ch := m.contentHeight()
rail := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.tagRail.view(m.focus == focusTagRail))
sep := m.renderSeparator()
content = lipgloss.JoinHorizontal(lipgloss.Top, rail, sep, content)
}
case stateDetail: case stateDetail:
content = m.detail.view(m.width) content = m.detail.view(m.width)
case stateTagFilter: case stateTagFilter:
@@ -794,17 +863,22 @@ func (m model) View() string {
return header + "\n" + content + "\n" + captureBar + "\n" + statusLine return header + "\n" + content + "\n" + captureBar + "\n" + statusLine
} }
func (m model) listContent() string { func (m model) listWidth() int {
if m.mode == modeCards {
lw := m.width
if m.splitDetail {
lw, _ = m.splitWidths()
}
return m.cards.view(lw)
}
lw := m.width
if m.splitDetail { if m.splitDetail {
lw, _ = m.splitWidths() lw, _ := m.splitWidths()
return lw
}
w := m.width - m.railWidth()
if m.railVisible() {
w--
}
return w
}
func (m model) listContent() string {
lw := m.listWidth()
if m.mode == modeCards {
return m.cards.view(lw)
} }
return m.list.view(lw) return m.list.view(lw)
} }
@@ -859,26 +933,49 @@ func (m model) contentHeight() int {
func (m *model) recalcSizes() { func (m *model) recalcSizes() {
ch := m.contentHeight() ch := m.contentHeight()
lw := m.listWidth()
if m.isSplit() && m.splitDetail { if m.isSplit() && m.splitDetail {
lw, rw := m.splitWidths() _, rw := m.splitWidths()
m.list.setSize(lw, ch) m.list.setSize(lw, ch)
m.cards.setSize(lw, ch) m.cards.setSize(lw, ch)
m.detail.setSize(rw, ch) m.detail.setSize(rw, ch)
} else { } else {
m.list.setSize(m.width, ch) m.list.setSize(lw, ch)
m.cards.setSize(m.width, ch) m.cards.setSize(lw, ch)
m.detail.setSize(m.width, ch) m.detail.setSize(lw, ch)
} }
m.filter.setHeight(ch) m.filter.setHeight(ch)
if m.railVisible() {
m.tagRail.setSize(m.railWidth(), ch)
}
} }
func (m model) isSplit() bool { func (m model) isSplit() bool {
return m.width >= 100 return m.width >= 100
} }
func (m model) railVisible() bool {
return m.showTagRail && m.width >= 100
}
func (m model) railWidth() int {
if !m.railVisible() {
return 0
}
w := m.width * 18 / 100
if w < 16 {
w = 16
}
return w
}
func (m model) splitWidths() (int, int) { func (m model) splitWidths() (int, int) {
left := m.width * 40 / 100 avail := m.width - m.railWidth()
right := m.width - left - 1 if m.railVisible() {
avail--
}
left := avail * 40 / 100
right := avail - left - 1
return left, right return left, right
} }
+2
View File
@@ -87,6 +87,8 @@ func contextHints(m model) []hint {
switch m.focus { switch m.focus {
case focusCapture: case focusCapture:
return []hint{{"enter", "submit"}, {"esc", "browse"}, {"?…", "search"}, {"-", "todo"}, {"@", "event"}} return []hint{{"enter", "submit"}, {"esc", "browse"}, {"?…", "search"}, {"-", "todo"}, {"@", "event"}}
case focusTagRail:
return []hint{{"j/k", "nav"}, {"enter", "filter"}, {"l", "list"}, {"ctrl+b", "hide"}}
case focusDetail: case focusDetail:
if m.splitDetail { if m.splitDetail {
return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}} return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}}
+14
View File
@@ -129,4 +129,18 @@ var (
hintDescStyle = lipgloss.NewStyle(). hintDescStyle = lipgloss.NewStyle().
Foreground(dim) Foreground(dim)
railHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(dim)
railTagStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
railActiveTagStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}).
Bold(true)
railCountStyle = lipgloss.NewStyle().
Foreground(dim)
) )
+138
View File
@@ -0,0 +1,138 @@
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()
}