Add multiselect board filters and letter-jump on picker menus.

Persist comma-separated list filters per project, toggle with space, and jump by first letter in board and project pickers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Skruecken
2026-10-19 10:42:59 +02:00
co-authored by Cursor
parent aa8f89b43a
commit 41feba11de
9 changed files with 305 additions and 39 deletions
+75 -20
View File
@@ -1,24 +1,40 @@
package views
import (
"fmt"
"strings"
"unicode"
"task/internal/ticket"
)
// BoardMenu renders a board-list picker overlay.
// BoardMenu renders a multi-select board-list picker overlay.
type BoardMenu struct {
Cursor int
Width int
Styles StatusMenuStyles
Cursor int
Width int
Styles StatusMenuStyles
selected map[ticket.BoardList]bool
}
// NewBoardMenu creates a menu with the given board list pre-selected.
func NewBoardMenu(current ticket.BoardList, width int, styles StatusMenuStyles) BoardMenu {
// NewBoardMenu creates a menu with the given filter pre-selected.
func NewBoardMenu(current ticket.BoardFilter, width int, styles StatusMenuStyles) BoardMenu {
current = ticket.NormalizeFilter(current)
sel := map[ticket.BoardList]bool{}
for _, bl := range current {
sel[bl] = true
}
cursor := 0
for i, bl := range ticket.AllBoardLists {
if sel[bl] {
cursor = i
break
}
}
return BoardMenu{
Cursor: current.Index(),
Width: width,
Styles: styles,
Cursor: cursor,
Width: width,
Styles: styles,
selected: sel,
}
}
@@ -36,31 +52,70 @@ func (m *BoardMenu) MoveUp() {
}
}
// Selected returns the currently highlighted board list.
func (m BoardMenu) Selected() ticket.BoardList {
if m.Cursor < 0 || m.Cursor >= len(ticket.AllBoardLists) {
return ticket.BoardAll
// JumpToShortcut moves the cursor to the list whose label starts with r.
func (m *BoardMenu) JumpToShortcut(r rune) bool {
r = unicode.ToLower(r)
for i, bl := range ticket.AllBoardLists {
if bl.Shortcut() == r {
m.Cursor = i
return true
}
}
return ticket.AllBoardLists[m.Cursor]
return false
}
// Toggle flips the checked state of the highlighted list.
func (m *BoardMenu) Toggle() {
if m.selected == nil {
m.selected = map[ticket.BoardList]bool{}
}
bl := ticket.AllBoardLists[m.Cursor]
if bl == ticket.BoardAll {
m.selected = map[ticket.BoardList]bool{ticket.BoardAll: true}
return
}
delete(m.selected, ticket.BoardAll)
if m.selected[bl] {
delete(m.selected, bl)
} else {
m.selected[bl] = true
}
if len(m.selected) == 0 {
m.selected[ticket.BoardAll] = true
}
}
// Selected returns the current multi-select filter.
func (m BoardMenu) Selected() ticket.BoardFilter {
var out ticket.BoardFilter
for _, bl := range ticket.AllBoardLists {
if m.selected[bl] {
out = append(out, bl)
}
}
return ticket.NormalizeFilter(out)
}
// View renders the board menu.
func (m BoardMenu) View() string {
var lines []string
lines = append(lines, m.Styles.Title.Render("Switch list"))
lines = append(lines, m.Styles.Title.Render("Filter lists"))
for i, bl := range ticket.AllBoardLists {
label := " " + bl.Label()
mark := "[ ]"
if m.selected[bl] {
mark = "[x]"
}
entry := fmt.Sprintf("%s %c %s", mark, bl.Shortcut(), bl.Label())
if i == m.Cursor {
label = m.Styles.Selected.Render("▸ " + bl.Label())
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
} else {
label = m.Styles.Normal.Render(label)
lines = append(lines, m.Styles.Normal.Render(" "+entry))
}
lines = append(lines, label)
}
body := strings.Join(lines, "\n")
w := m.Width
if w < 20 {
w = 20
}
return m.Styles.Box.Width(min(40, w-2)).Render(body)
return m.Styles.Box.Width(min(44, w-2)).Render(body)
}
+29
View File
@@ -1,6 +1,8 @@
package views
import (
"unicode"
"github.com/charmbracelet/bubbles/list"
"task/internal/project"
@@ -46,6 +48,33 @@ func NewProjectList(projects []project.Project, activeID string, width, height i
return l
}
// JumpProjectByLetter cycles to the next project whose name starts with r.
func JumpProjectByLetter(l *list.Model, r rune) bool {
r = unicode.ToLower(r)
items := l.Items()
n := len(items)
if n == 0 {
return false
}
start := l.Index()
for step := 1; step <= n; step++ {
i := (start + step) % n
pi, ok := items[i].(ProjectItem)
if !ok {
continue
}
name := []rune(pi.Project.Name)
if len(name) == 0 {
continue
}
if unicode.ToLower(name[0]) == r {
l.Select(i)
return true
}
}
return false
}
// ProjectsToItems converts projects to list items.
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
items := make([]list.Item, len(projects))