Merge pull request 'feat(tui): collapsible tag rail with ambient tag awareness' (#35) from feat/tag-rail into main
Reviewed-on: #35
This commit was merged in pull request #35.
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
@@ -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)"},
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
|
||||
+130
-33
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user