feat(tui): layout and interaction polish #33

Merged
lerko merged 4 commits from fix/tui-polish into main 2026-05-20 16:33:58 +00:00
5 changed files with 191 additions and 62 deletions
Showing only changes of commit c26e2d2022 - Show all commits
+117 -13
View File
@@ -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,24 +98,69 @@ 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)
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...)
if c.cursor >= len(c.filtered) { if c.cursor >= len(c.filtered) {
c.cursor = max(0, len(c.filtered)-1) 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) { func (c *cardsModel) setSize(width, height int) {
c.width = width c.width = width
c.height = height c.height = height
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+32 -43
View File
@@ -94,8 +94,9 @@ type model struct {
searchQuery string searchQuery string
searchTags []string searchTags []string
status string status string
err error statusSeq int
err error
} }
func newModel(store *db.Store) model { 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 { 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:
m.status = "" if msg.seq == m.statusSeq {
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)
} }