diff --git a/internal/tui/cards.go b/internal/tui/cards.go index f08038a..72416cc 100644 --- a/internal/tui/cards.go +++ b/internal/tui/cards.go @@ -64,9 +64,16 @@ func matchesIntent(e *db.Entity, i intent) bool { return false } +type cardGroup struct { + label string + start int + count int +} + type cardsModel struct { entities []*db.Entity filtered []*db.Entity + groups []cardGroup cursor int offset int height int @@ -91,24 +98,69 @@ func (c *cardsModel) setIntent(i intent) { } func (c *cardsModel) applyFilter() { - c.filtered = nil - var pinned, rest []*db.Entity - for _, e := range c.entities { - if !matchesIntent(e, c.intent) { - continue - } - if e.Pinned { - pinned = append(pinned, e) - } else { - rest = append(rest, e) - } - } - c.filtered = append(pinned, rest...) + 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 + for _, e := range entities { + if !matchesIntent(e, intentFilter) { + continue + } + if e.Pinned { + pinned = append(pinned, e) + } else { + rest = append(rest, e) + } + } + return append(pinned, rest...), nil + } + + 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) { c.width = width c.height = height @@ -166,6 +218,9 @@ func (c cardsModel) view(width int) string { if len(c.filtered) == 0 { return statusStyle.Render("no cards") } + if len(c.groups) > 0 { + return c.groupedView(width) + } var b strings.Builder visible := c.visibleCount() @@ -188,6 +243,55 @@ func (c cardsModel) view(width int) 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 { if c.height <= 0 { return 20 diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 17155df..140f773 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -58,7 +58,7 @@ type tagsLoadedMsg struct { tags []db.TagCount } -type statusClearMsg struct{} +type statusClearMsg struct{ seq int } type editorFinishedMsg struct { 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 statusClearMsg{} + return statusClearMsg{seq: seq} }) } diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 4e51db8..2130ddd 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -62,6 +62,17 @@ func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) { } case "down", "j": 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 } @@ -156,8 +167,24 @@ func (d detailModel) previewView(width int) string { b.WriteString(idStyle.Render(meta)) lines := strings.Split(b.String(), "\n") - if d.scroll > 0 && d.scroll < len(lines) { - lines = lines[d.scroll:] + totalLines := len(lines) + + 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 { lines = lines[:d.height] diff --git a/internal/tui/input.go b/internal/tui/input.go index b782107..5e38948 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -103,7 +103,16 @@ func (i inputModel) updateKey(msg tea.KeyMsg) inputModel { func (i inputModel) view(width int) string { 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(i.ti.View()) b.WriteString("\n") diff --git a/internal/tui/model.go b/internal/tui/model.go index d0692df..18c2bb5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -94,8 +94,9 @@ type model struct { searchQuery string searchTags []string - status string - err error + status string + statusSeq int + err error } func newModel(store *db.Store) model { @@ -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 { return loadEntities(m.store, m.listParams()) } @@ -143,20 +150,14 @@ func (m model) hasSearch() bool { func (m *model) applySearch() { if m.mode == modeCards { - filtered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags) - m.cards.filtered = nil - var pinned, rest []*db.Entity - for _, e := range filtered { - if !matchesIntent(e, m.cards.intent) { - continue - } - if e.Pinned { - pinned = append(pinned, e) - } else { - rest = append(rest, e) + searchFiltered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags) + var intentFiltered []*db.Entity + for _, e := range searchFiltered { + if matchesIntent(e, m.cards.intent) { + intentFiltered = append(intentFiltered, 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) { 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.input.reset() m.recalcSizes() - m.status = "created" - return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("created")) case entityDeletedMsg: - m.status = "deleted" 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: - m.status = msg.action if m.state == stateDetail { 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: - m.status = fmt.Sprintf("promoted → %s", msg.cardType) 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: - m.status = "demoted → fluid" - return m, tea.Batch(m.reloadDetail(msg.id), clearStatusAfter(statusTimeout)) + return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid")) case entityCopiedMsg: - m.status = "copied" - return m, clearStatusAfter(statusTimeout) + return m, m.setStatus("copied") case entityAbsorbedMsg: - m.status = "absorbed" 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: m.absorb = newAbsorbModel(msg.targetID) @@ -235,14 +229,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case stepsPersistedMsg: - m.status = "steps saved" 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: - m.status = "copied resolved" 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: m.filter.setTags(msg.tags) @@ -254,8 +246,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err return m, m.reloadAfterEdit() } - m.status = "updated" - return m, tea.Batch(m.reloadAfterEdit(), clearStatusAfter(statusTimeout)) + return m, tea.Batch(m.reloadAfterEdit(), m.setStatus("updated")) case confirmTimeoutMsg: if m.state == stateConfirm { @@ -265,7 +256,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case statusClearMsg: - m.status = "" + if msg.seq == m.statusSeq { + m.status = "" + } return m, nil case errMsg: @@ -421,8 +414,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "s": if m.mode == modeCards && m.state == stateList { m.cardsSort = m.cardsSort.next() - m.status = "sort: " + m.cardsSort.String() - return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("sort: "+m.cardsSort.String())) } return m, nil @@ -538,8 +530,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { e := m.selectedEntity() if e != nil { if e.CardType != nil { - m.status = "target must be fluid" - return m, clearStatusAfter(statusTimeout) + return m, m.setStatus("target must be fluid") } 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() if e != nil { if e.CardType != nil { - m.status = "already a card" - return m, clearStatusAfter(statusTimeout) + return m, m.setStatus("already a card") } m.promote = newPromoteModel(e.ID, e.Body) m.state = statePromote @@ -561,8 +551,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "D": if m.state == stateDetail && m.detail.entity != nil { if m.detail.entity.CardType == nil { - m.status = "already fluid" - return m, clearStatusAfter(statusTimeout) + return m, m.setStatus("already fluid") } return m, demoteEntity(m.store, m.detail.entity.ID) }