feat(tui): collapsible tag rail with ambient tag awareness
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
This commit is contained in:
+133
-30
@@ -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
|
||||
|
||||
@@ -319,6 +333,13 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m.updateBrowse(msg)
|
||||
}
|
||||
|
||||
func (m model) nextFocusFromCapture() focusPane {
|
||||
if m.railVisible() {
|
||||
return focusTagRail
|
||||
}
|
||||
return focusList
|
||||
}
|
||||
|
||||
func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
@@ -343,15 +364,62 @@ func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, createEntity(m.store, result.entity)
|
||||
}
|
||||
return m, nil
|
||||
case "esc", "tab":
|
||||
case "esc":
|
||||
cmd := m.setFocus(focusList)
|
||||
return m, cmd
|
||||
case "tab":
|
||||
cmd := m.setFocus(m.nextFocusFromCapture())
|
||||
return m, cmd
|
||||
}
|
||||
m.input = m.input.updateKey(msg)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
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 "tab":
|
||||
m.focus = focusList
|
||||
return m, nil
|
||||
case "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":
|
||||
@@ -775,6 +843,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 +869,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 +939,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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user