feat(tui): status debounce, scroll indicator, drawer label, card grouping
Status messages now use a sequence counter so rapid actions don't cause premature clearing. Detail pane shows scroll position and supports pgup/pgdown/g/G. Capture drawer border includes inline label. Cards view groups by intent (pinned/grab/read/fill) with gutter labels matching the stream view's date grouping pattern.
This commit is contained in:
+110
-6
@@ -64,9 +64,16 @@ func matchesIntent(e *db.Entity, i intent) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cardGroup struct {
|
||||||
|
label string
|
||||||
|
start int
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
type cardsModel struct {
|
type cardsModel struct {
|
||||||
entities []*db.Entity
|
entities []*db.Entity
|
||||||
filtered []*db.Entity
|
filtered []*db.Entity
|
||||||
|
groups []cardGroup
|
||||||
cursor int
|
cursor int
|
||||||
offset int
|
offset int
|
||||||
height int
|
height int
|
||||||
@@ -91,10 +98,17 @@ func (c *cardsModel) setIntent(i intent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *cardsModel) applyFilter() {
|
func (c *cardsModel) applyFilter() {
|
||||||
c.filtered = nil
|
c.filtered, c.groups = sortAndGroupCards(c.entities, c.intent)
|
||||||
|
if c.cursor >= len(c.filtered) {
|
||||||
|
c.cursor = max(0, len(c.filtered)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortAndGroupCards(entities []*db.Entity, intentFilter intent) ([]*db.Entity, []cardGroup) {
|
||||||
|
if intentFilter != intentAll {
|
||||||
var pinned, rest []*db.Entity
|
var pinned, rest []*db.Entity
|
||||||
for _, e := range c.entities {
|
for _, e := range entities {
|
||||||
if !matchesIntent(e, c.intent) {
|
if !matchesIntent(e, intentFilter) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if e.Pinned {
|
if e.Pinned {
|
||||||
@@ -103,10 +117,48 @@ func (c *cardsModel) applyFilter() {
|
|||||||
rest = append(rest, e)
|
rest = append(rest, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.filtered = append(pinned, rest...)
|
return append(pinned, rest...), nil
|
||||||
if c.cursor >= len(c.filtered) {
|
|
||||||
c.cursor = max(0, len(c.filtered)-1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pinned, grab, read, fill []*db.Entity
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.Pinned {
|
||||||
|
pinned = append(pinned, e)
|
||||||
|
} else {
|
||||||
|
switch {
|
||||||
|
case matchesIntent(e, intentGrab):
|
||||||
|
grab = append(grab, e)
|
||||||
|
case matchesIntent(e, intentRead):
|
||||||
|
read = append(read, e)
|
||||||
|
case matchesIntent(e, intentFill):
|
||||||
|
fill = append(fill, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered []*db.Entity
|
||||||
|
var groups []cardGroup
|
||||||
|
for _, bucket := range []struct {
|
||||||
|
label string
|
||||||
|
entities []*db.Entity
|
||||||
|
}{
|
||||||
|
{"pinned", pinned},
|
||||||
|
{"grab", grab},
|
||||||
|
{"read", read},
|
||||||
|
{"fill", fill},
|
||||||
|
} {
|
||||||
|
if len(bucket.entities) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups = append(groups, cardGroup{
|
||||||
|
label: bucket.label,
|
||||||
|
start: len(filtered),
|
||||||
|
count: len(bucket.entities),
|
||||||
|
})
|
||||||
|
filtered = append(filtered, bucket.entities...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered, groups
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *cardsModel) setSize(width, height int) {
|
func (c *cardsModel) setSize(width, height int) {
|
||||||
@@ -166,6 +218,9 @@ func (c cardsModel) view(width int) string {
|
|||||||
if len(c.filtered) == 0 {
|
if len(c.filtered) == 0 {
|
||||||
return statusStyle.Render("no cards")
|
return statusStyle.Render("no cards")
|
||||||
}
|
}
|
||||||
|
if len(c.groups) > 0 {
|
||||||
|
return c.groupedView(width)
|
||||||
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
visible := c.visibleCount()
|
visible := c.visibleCount()
|
||||||
@@ -188,6 +243,55 @@ func (c cardsModel) view(width int) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) groupedView(width int) string {
|
||||||
|
entityWidth := width - 4 - dateGutterWidth
|
||||||
|
|
||||||
|
type displayLine struct {
|
||||||
|
text string
|
||||||
|
entityIdx int
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []displayLine
|
||||||
|
for _, g := range c.groups {
|
||||||
|
for i := 0; i < g.count; i++ {
|
||||||
|
eIdx := g.start + i
|
||||||
|
var gutter string
|
||||||
|
if i == 0 {
|
||||||
|
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
|
||||||
|
} else {
|
||||||
|
gutter = gutterStyle.Render(" │ ")
|
||||||
|
}
|
||||||
|
line := gutter + renderCard(c.filtered[eIdx], entityWidth)
|
||||||
|
lines = append(lines, displayLine{text: line, entityIdx: eIdx})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visible := c.visibleCount()
|
||||||
|
offset := c.offset
|
||||||
|
if c.cursor < offset {
|
||||||
|
offset = c.cursor
|
||||||
|
}
|
||||||
|
if c.cursor >= offset+visible {
|
||||||
|
offset = c.cursor - visible + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
end := min(offset+visible, len(lines))
|
||||||
|
for i := offset; i < end; i++ {
|
||||||
|
dl := lines[i]
|
||||||
|
if dl.entityIdx == c.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + dl.text))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(dl.text))
|
||||||
|
}
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
func (c cardsModel) visibleCount() int {
|
func (c cardsModel) visibleCount() int {
|
||||||
if c.height <= 0 {
|
if c.height <= 0 {
|
||||||
return 20
|
return 20
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ type tagsLoadedMsg struct {
|
|||||||
tags []db.TagCount
|
tags []db.TagCount
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusClearMsg struct{}
|
type statusClearMsg struct{ seq int }
|
||||||
|
|
||||||
type editorFinishedMsg struct {
|
type editorFinishedMsg struct {
|
||||||
err error
|
err error
|
||||||
@@ -270,8 +270,8 @@ func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearStatusAfter(d time.Duration) tea.Cmd {
|
func clearStatusAfter(d time.Duration, seq int) tea.Cmd {
|
||||||
return tea.Tick(d, func(time.Time) tea.Msg {
|
return tea.Tick(d, func(time.Time) tea.Msg {
|
||||||
return statusClearMsg{}
|
return statusClearMsg{seq: seq}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-2
@@ -62,6 +62,17 @@ func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
d.scroll++
|
d.scroll++
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
d.scroll += d.height
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
d.scroll -= d.height
|
||||||
|
if d.scroll < 0 {
|
||||||
|
d.scroll = 0
|
||||||
|
}
|
||||||
|
case "home", "g":
|
||||||
|
d.scroll = 0
|
||||||
|
case "end", "G":
|
||||||
|
d.scroll = 1<<31 - 1
|
||||||
}
|
}
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
@@ -156,8 +167,24 @@ func (d detailModel) previewView(width int) string {
|
|||||||
b.WriteString(idStyle.Render(meta))
|
b.WriteString(idStyle.Render(meta))
|
||||||
|
|
||||||
lines := strings.Split(b.String(), "\n")
|
lines := strings.Split(b.String(), "\n")
|
||||||
if d.scroll > 0 && d.scroll < len(lines) {
|
totalLines := len(lines)
|
||||||
lines = lines[d.scroll:]
|
|
||||||
|
maxScroll := totalLines - d.height
|
||||||
|
if maxScroll < 0 {
|
||||||
|
maxScroll = 0
|
||||||
|
}
|
||||||
|
scroll := d.scroll
|
||||||
|
if scroll > maxScroll {
|
||||||
|
scroll = maxScroll
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalLines > d.height && d.height > 0 && len(lines) > 0 {
|
||||||
|
indicator := idStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, totalLines))
|
||||||
|
lines[0] = lines[0] + indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
if scroll > 0 && scroll < totalLines {
|
||||||
|
lines = lines[scroll:]
|
||||||
}
|
}
|
||||||
if d.height > 0 && len(lines) > d.height {
|
if d.height > 0 && len(lines) > d.height {
|
||||||
lines = lines[:d.height]
|
lines = lines[:d.height]
|
||||||
|
|||||||
+10
-1
@@ -103,7 +103,16 @@ func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
|
|||||||
|
|
||||||
func (i inputModel) view(width int) string {
|
func (i inputModel) view(width int) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(drawerBorderStyle.Render(strings.Repeat("─", width)))
|
label := "capture"
|
||||||
|
prefix := "── "
|
||||||
|
suffix := " "
|
||||||
|
dashCount := width - len(prefix) - len(label) - len(suffix)
|
||||||
|
if dashCount < 0 {
|
||||||
|
dashCount = 0
|
||||||
|
}
|
||||||
|
b.WriteString(drawerBorderStyle.Render(prefix) +
|
||||||
|
hintDescStyle.Render(label) +
|
||||||
|
drawerBorderStyle.Render(suffix+strings.Repeat("─", dashCount)))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(i.ti.View())
|
b.WriteString(i.ti.View())
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|||||||
+29
-40
@@ -95,6 +95,7 @@ type model struct {
|
|||||||
searchTags []string
|
searchTags []string
|
||||||
|
|
||||||
status string
|
status string
|
||||||
|
statusSeq int
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +112,12 @@ func newModel(store *db.Store) model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *model) setStatus(msg string) tea.Cmd {
|
||||||
|
m.statusSeq++
|
||||||
|
m.status = msg
|
||||||
|
return clearStatusAfter(statusTimeout, m.statusSeq)
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
func (m model) Init() tea.Cmd {
|
||||||
return loadEntities(m.store, m.listParams())
|
return loadEntities(m.store, m.listParams())
|
||||||
}
|
}
|
||||||
@@ -143,20 +150,14 @@ func (m model) hasSearch() bool {
|
|||||||
|
|
||||||
func (m *model) applySearch() {
|
func (m *model) applySearch() {
|
||||||
if m.mode == modeCards {
|
if m.mode == modeCards {
|
||||||
filtered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
|
searchFiltered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
|
||||||
m.cards.filtered = nil
|
var intentFiltered []*db.Entity
|
||||||
var pinned, rest []*db.Entity
|
for _, e := range searchFiltered {
|
||||||
for _, e := range filtered {
|
if matchesIntent(e, m.cards.intent) {
|
||||||
if !matchesIntent(e, m.cards.intent) {
|
intentFiltered = append(intentFiltered, e)
|
||||||
continue
|
|
||||||
}
|
|
||||||
if e.Pinned {
|
|
||||||
pinned = append(pinned, e)
|
|
||||||
} else {
|
|
||||||
rest = append(rest, e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.cards.filtered = append(pinned, rest...)
|
m.cards.filtered, m.cards.groups = sortAndGroupCards(intentFiltered, m.cards.intent)
|
||||||
if m.cards.cursor >= len(m.cards.filtered) {
|
if m.cards.cursor >= len(m.cards.filtered) {
|
||||||
m.cards.cursor = max(0, len(m.cards.filtered)-1)
|
m.cards.cursor = max(0, len(m.cards.filtered)-1)
|
||||||
}
|
}
|
||||||
@@ -194,38 +195,31 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.state = stateList
|
m.state = stateList
|
||||||
m.input.reset()
|
m.input.reset()
|
||||||
m.recalcSizes()
|
m.recalcSizes()
|
||||||
m.status = "created"
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("created"))
|
||||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
|
|
||||||
|
|
||||||
case entityDeletedMsg:
|
case entityDeletedMsg:
|
||||||
m.status = "deleted"
|
|
||||||
m.state = stateList
|
m.state = stateList
|
||||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("deleted"))
|
||||||
|
|
||||||
case entityUpdatedMsg:
|
case entityUpdatedMsg:
|
||||||
m.status = msg.action
|
|
||||||
if m.state == stateDetail {
|
if m.state == stateDetail {
|
||||||
m.detail.setEntity(msg.entity)
|
m.detail.setEntity(msg.entity)
|
||||||
}
|
}
|
||||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(msg.action))
|
||||||
|
|
||||||
case entityPromotedMsg:
|
case entityPromotedMsg:
|
||||||
m.status = fmt.Sprintf("promoted → %s", msg.cardType)
|
|
||||||
m.state = stateList
|
m.state = stateList
|
||||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType)))
|
||||||
|
|
||||||
case entityDemotedMsg:
|
case entityDemotedMsg:
|
||||||
m.status = "demoted → fluid"
|
return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid"))
|
||||||
return m, tea.Batch(m.reloadDetail(msg.id), clearStatusAfter(statusTimeout))
|
|
||||||
|
|
||||||
case entityCopiedMsg:
|
case entityCopiedMsg:
|
||||||
m.status = "copied"
|
return m, m.setStatus("copied")
|
||||||
return m, clearStatusAfter(statusTimeout)
|
|
||||||
|
|
||||||
case entityAbsorbedMsg:
|
case entityAbsorbedMsg:
|
||||||
m.status = "absorbed"
|
|
||||||
m.state = stateList
|
m.state = stateList
|
||||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("absorbed"))
|
||||||
|
|
||||||
case absorbSourcesLoadedMsg:
|
case absorbSourcesLoadedMsg:
|
||||||
m.absorb = newAbsorbModel(msg.targetID)
|
m.absorb = newAbsorbModel(msg.targetID)
|
||||||
@@ -235,14 +229,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case stepsPersistedMsg:
|
case stepsPersistedMsg:
|
||||||
m.status = "steps saved"
|
|
||||||
m.detail.mode = detailPreview
|
m.detail.mode = detailPreview
|
||||||
return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), clearStatusAfter(statusTimeout))
|
return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), m.setStatus("steps saved"))
|
||||||
|
|
||||||
case templateCopiedMsg:
|
case templateCopiedMsg:
|
||||||
m.status = "copied resolved"
|
|
||||||
m.detail.mode = detailPreview
|
m.detail.mode = detailPreview
|
||||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("copied resolved"))
|
||||||
|
|
||||||
case tagsLoadedMsg:
|
case tagsLoadedMsg:
|
||||||
m.filter.setTags(msg.tags)
|
m.filter.setTags(msg.tags)
|
||||||
@@ -254,8 +246,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
return m, m.reloadAfterEdit()
|
return m, m.reloadAfterEdit()
|
||||||
}
|
}
|
||||||
m.status = "updated"
|
return m, tea.Batch(m.reloadAfterEdit(), m.setStatus("updated"))
|
||||||
return m, tea.Batch(m.reloadAfterEdit(), clearStatusAfter(statusTimeout))
|
|
||||||
|
|
||||||
case confirmTimeoutMsg:
|
case confirmTimeoutMsg:
|
||||||
if m.state == stateConfirm {
|
if m.state == stateConfirm {
|
||||||
@@ -265,7 +256,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case statusClearMsg:
|
case statusClearMsg:
|
||||||
|
if msg.seq == m.statusSeq {
|
||||||
m.status = ""
|
m.status = ""
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case errMsg:
|
case errMsg:
|
||||||
@@ -421,8 +414,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "s":
|
case "s":
|
||||||
if m.mode == modeCards && m.state == stateList {
|
if m.mode == modeCards && m.state == stateList {
|
||||||
m.cardsSort = m.cardsSort.next()
|
m.cardsSort = m.cardsSort.next()
|
||||||
m.status = "sort: " + m.cardsSort.String()
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("sort: "+m.cardsSort.String()))
|
||||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
|
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -538,8 +530,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
e := m.selectedEntity()
|
e := m.selectedEntity()
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if e.CardType != nil {
|
if e.CardType != nil {
|
||||||
m.status = "target must be fluid"
|
return m, m.setStatus("target must be fluid")
|
||||||
return m, clearStatusAfter(statusTimeout)
|
|
||||||
}
|
}
|
||||||
return m, loadAbsorbSources(m.store, e.ID)
|
return m, loadAbsorbSources(m.store, e.ID)
|
||||||
}
|
}
|
||||||
@@ -549,8 +540,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
e := m.selectedEntity()
|
e := m.selectedEntity()
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if e.CardType != nil {
|
if e.CardType != nil {
|
||||||
m.status = "already a card"
|
return m, m.setStatus("already a card")
|
||||||
return m, clearStatusAfter(statusTimeout)
|
|
||||||
}
|
}
|
||||||
m.promote = newPromoteModel(e.ID, e.Body)
|
m.promote = newPromoteModel(e.ID, e.Body)
|
||||||
m.state = statePromote
|
m.state = statePromote
|
||||||
@@ -561,8 +551,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "D":
|
case "D":
|
||||||
if m.state == stateDetail && m.detail.entity != nil {
|
if m.state == stateDetail && m.detail.entity != nil {
|
||||||
if m.detail.entity.CardType == nil {
|
if m.detail.entity.CardType == nil {
|
||||||
m.status = "already fluid"
|
return m, m.setStatus("already fluid")
|
||||||
return m, clearStatusAfter(statusTimeout)
|
|
||||||
}
|
}
|
||||||
return m, demoteEntity(m.store, m.detail.entity.ID)
|
return m, demoteEntity(m.store, m.detail.entity.ID)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user