diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 140f773..68e3896 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -58,6 +58,10 @@ type tagsLoadedMsg struct { tags []db.TagCount } +type railTagsLoadedMsg struct { + tags []db.TagCount +} + type statusClearMsg struct{ seq int } 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 { editorEnv := os.Getenv("EDITOR") if editorEnv == "" { diff --git a/internal/tui/help.go b/internal/tui/help.go index 1aea60c..84176fb 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -8,9 +8,12 @@ func renderHelp(width, height int) string { binds [][2]string }{ {"Focus", [][2]string{ - {"tab", "cycle focus: capture → list → detail"}, + {"tab", "toggle capture ↔ list"}, {"esc", "back / clear filter / to capture"}, {"a", "focus capture bar"}, + {"h", "focus tag rail (from list)"}, + {"l", "focus detail (split view)"}, + {"ctrl+b", "toggle tag rail"}, }}, {"Capture Bar", [][2]string{ {"enter", "submit (or browse if empty)"}, diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 3f215fb..2ebe7dc 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -32,6 +32,7 @@ type keyMap struct { FocusLeft key.Binding FocusRight key.Binding Tab key.Binding + ToggleRail key.Binding } var keys = keyMap{ @@ -64,4 +65,5 @@ var keys = keyMap{ FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")), FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")), 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")), } diff --git a/internal/tui/model.go b/internal/tui/model.go index f1c816c..3b95780 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -65,6 +65,7 @@ type focusPane int const ( focusCapture focusPane = iota + focusTagRail focusList focusDetail ) @@ -83,10 +84,12 @@ type model struct { filter filterModel promote promoteModel absorb absorbModel + tagRail tagRailModel showHelp bool focus focusPane splitDetail bool + showTagRail bool filterTag string confirmID string @@ -103,15 +106,17 @@ func newModel(store *db.Store) model { inp := newInputModel() inp.ti.Focus() return model{ - store: store, - state: stateList, - mode: modeStream, - focus: focusCapture, - list: newListModel(), - cards: newCardsModel(), - detail: newDetailModel(), - input: inp, - filter: newFilterModel(), + store: store, + state: stateList, + mode: modeStream, + focus: focusCapture, + showTagRail: true, + list: newListModel(), + cards: newCardsModel(), + detail: newDetailModel(), + input: inp, + filter: newFilterModel(), + tagRail: newTagRailModel(), } } @@ -131,7 +136,7 @@ func (m *model) setStatus(msg string) 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 { @@ -188,6 +193,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.splitDetail = false m.focus = focusList } + if !m.railVisible() && m.focus == focusTagRail { + m.focus = focusList + } m.recalcSizes() return m, nil @@ -203,13 +211,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = nil return m, nil + case railTagsLoadedMsg: + m.tagRail.setTags(msg.tags) + m.tagRail.activeTag = m.filterTag + return m, nil + case entityCreatedMsg: 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: 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: if m.state == stateDetail { @@ -229,7 +242,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case entityAbsorbedMsg: 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: m.absorb = newAbsorbModel(msg.targetID) @@ -248,6 +261,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tagsLoadedMsg: m.filter.setTags(msg.tags) + m.tagRail.setTags(msg.tags) m.state = stateTagFilter 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) { + // 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 { switch msg.String() { case "tab": - if m.focus == focusList { - m.focus = focusDetail - return m, nil - } cmd := m.setFocus(focusCapture) return m, cmd case "l": @@ -371,6 +422,10 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.focus = focusList return m, nil } + if m.focus == focusList && m.railVisible() { + m.focus = focusTagRail + return m, nil + } case "esc": if m.focus == focusDetail { m.focus = focusList @@ -491,6 +546,13 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { cmd := m.setFocus(focusCapture) 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": if m.state == stateList { cmd := m.setFocus(focusCapture) @@ -775,6 +837,13 @@ func (m model) View() string { } else { 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: content = m.detail.view(m.width) case stateTagFilter: @@ -794,17 +863,22 @@ func (m model) View() string { return header + "\n" + content + "\n" + captureBar + "\n" + statusLine } -func (m model) listContent() string { - if m.mode == modeCards { - lw := m.width - if m.splitDetail { - lw, _ = m.splitWidths() - } - return m.cards.view(lw) - } - lw := m.width +func (m model) listWidth() int { 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) } @@ -859,26 +933,49 @@ func (m model) contentHeight() int { func (m *model) recalcSizes() { ch := m.contentHeight() + lw := m.listWidth() if m.isSplit() && m.splitDetail { - lw, rw := m.splitWidths() + _, rw := m.splitWidths() m.list.setSize(lw, ch) m.cards.setSize(lw, ch) m.detail.setSize(rw, ch) } else { - m.list.setSize(m.width, ch) - m.cards.setSize(m.width, ch) - m.detail.setSize(m.width, ch) + m.list.setSize(lw, ch) + m.cards.setSize(lw, ch) + m.detail.setSize(lw, ch) } m.filter.setHeight(ch) + if m.railVisible() { + m.tagRail.setSize(m.railWidth(), ch) + } } func (m model) isSplit() bool { 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) { - left := m.width * 40 / 100 - right := m.width - left - 1 + avail := m.width - m.railWidth() + if m.railVisible() { + avail-- + } + left := avail * 40 / 100 + right := avail - left - 1 return left, right } diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index 7d1ea45..482ec06 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -87,6 +87,8 @@ func contextHints(m model) []hint { switch m.focus { case focusCapture: 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: if m.splitDetail { return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 19c1773..1eccd94 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -129,4 +129,18 @@ var ( hintDescStyle = lipgloss.NewStyle(). 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) ) diff --git a/internal/tui/tagrail.go b/internal/tui/tagrail.go new file mode 100644 index 0000000..3ddbe80 --- /dev/null +++ b/internal/tui/tagrail.go @@ -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() +}