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:
@@ -14,7 +14,8 @@ type Config struct {
|
|||||||
ActiveProject string `json:"activeProject"`
|
ActiveProject string `json:"activeProject"`
|
||||||
DataDir string `json:"dataDir"`
|
DataDir string `json:"dataDir"`
|
||||||
Theme string `json:"theme"`
|
Theme string `json:"theme"`
|
||||||
// ProjectLists maps project ID → board list id (all|backlog|in_progress|done).
|
// ProjectLists maps project ID → comma-separated board list ids
|
||||||
|
// (e.g. "all" or "backlog,in_progress").
|
||||||
ProjectLists map[string]string `json:"projectLists,omitempty"`
|
ProjectLists map[string]string `json:"projectLists,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +56,8 @@ func (c *Config) ApplyDefaults(home string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveBoardList returns the persisted board list for a project (default: all).
|
// ActiveBoardLists returns the persisted board filter string for a project (default: "all").
|
||||||
func (c *Config) ActiveBoardList(projectID string) string {
|
func (c *Config) ActiveBoardLists(projectID string) string {
|
||||||
if c.ProjectLists == nil {
|
if c.ProjectLists == nil {
|
||||||
return "all"
|
return "all"
|
||||||
}
|
}
|
||||||
@@ -66,10 +67,10 @@ func (c *Config) ActiveBoardList(projectID string) string {
|
|||||||
return "all"
|
return "all"
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetActiveBoardList stores the board list for a project.
|
// SetActiveBoardLists stores the board filter string for a project.
|
||||||
func (c *Config) SetActiveBoardList(projectID, listID string) {
|
func (c *Config) SetActiveBoardLists(projectID, listIDs string) {
|
||||||
if c.ProjectLists == nil {
|
if c.ProjectLists == nil {
|
||||||
c.ProjectLists = map[string]string{}
|
c.ProjectLists = map[string]string{}
|
||||||
}
|
}
|
||||||
c.ProjectLists[projectID] = listID
|
c.ProjectLists[projectID] = listIDs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package ticket
|
package ticket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
// BoardList is a project-scoped view/filter over tickets by status.
|
// BoardList is a project-scoped view/filter over tickets by status.
|
||||||
type BoardList string
|
type BoardList string
|
||||||
|
|
||||||
@@ -29,6 +34,15 @@ func (b BoardList) Label() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shortcut returns the board-menu hotkey (first letter of the label).
|
||||||
|
func (b BoardList) Shortcut() rune {
|
||||||
|
label := b.Label()
|
||||||
|
if label == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return unicode.ToLower([]rune(label)[0])
|
||||||
|
}
|
||||||
|
|
||||||
// Index returns the position in AllBoardLists.
|
// Index returns the position in AllBoardLists.
|
||||||
func (b BoardList) Index() int {
|
func (b BoardList) Index() int {
|
||||||
for i, bl := range AllBoardLists {
|
for i, bl := range AllBoardLists {
|
||||||
@@ -50,6 +64,114 @@ func ParseBoardList(s string) BoardList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BoardFilter is a multi-select set of board lists (OR semantics).
|
||||||
|
type BoardFilter []BoardList
|
||||||
|
|
||||||
|
// ParseBoardLists parses a comma-separated list id string (legacy single values work).
|
||||||
|
func ParseBoardLists(s string) BoardFilter {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return BoardFilter{BoardAll}
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
var out BoardFilter
|
||||||
|
seen := map[BoardList]bool{}
|
||||||
|
for _, p := range parts {
|
||||||
|
bl := ParseBoardList(strings.TrimSpace(p))
|
||||||
|
if seen[bl] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[bl] = true
|
||||||
|
out = append(out, bl)
|
||||||
|
}
|
||||||
|
return NormalizeFilter(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatBoardLists serializes a filter for config persistence.
|
||||||
|
func FormatBoardLists(f BoardFilter) string {
|
||||||
|
f = NormalizeFilter(f)
|
||||||
|
parts := make([]string, len(f))
|
||||||
|
for i, bl := range f {
|
||||||
|
parts[i] = string(bl)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeFilter ensures a non-empty filter; All alone if empty or only All.
|
||||||
|
// If All is combined with others, All is dropped.
|
||||||
|
func NormalizeFilter(f BoardFilter) BoardFilter {
|
||||||
|
if len(f) == 0 {
|
||||||
|
return BoardFilter{BoardAll}
|
||||||
|
}
|
||||||
|
hasAll := false
|
||||||
|
var others BoardFilter
|
||||||
|
seen := map[BoardList]bool{}
|
||||||
|
for _, bl := range f {
|
||||||
|
if bl == BoardAll {
|
||||||
|
hasAll = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[bl] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[bl] = true
|
||||||
|
others = append(others, bl)
|
||||||
|
}
|
||||||
|
if len(others) > 0 {
|
||||||
|
return others
|
||||||
|
}
|
||||||
|
if hasAll {
|
||||||
|
return BoardFilter{BoardAll}
|
||||||
|
}
|
||||||
|
return BoardFilter{BoardAll}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains reports whether bl is in the filter.
|
||||||
|
func (f BoardFilter) Contains(bl BoardList) bool {
|
||||||
|
for _, x := range f {
|
||||||
|
if x == bl {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label returns a short header label for the active filter.
|
||||||
|
func (f BoardFilter) Label() string {
|
||||||
|
f = NormalizeFilter(f)
|
||||||
|
if len(f) == 1 {
|
||||||
|
return f[0].Label()
|
||||||
|
}
|
||||||
|
parts := make([]string, len(f))
|
||||||
|
for i, bl := range f {
|
||||||
|
parts[i] = bl.Label()
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "+")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches reports whether a ticket status is visible under this filter.
|
||||||
|
func (f BoardFilter) Matches(status Status) bool {
|
||||||
|
f = NormalizeFilter(f)
|
||||||
|
if f.Contains(BoardAll) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, bl := range f {
|
||||||
|
if bl.Matches(status) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStatus is used when creating a ticket under this filter.
|
||||||
|
func (f BoardFilter) DefaultStatus() Status {
|
||||||
|
f = NormalizeFilter(f)
|
||||||
|
if len(f) == 1 {
|
||||||
|
return f[0].DefaultStatus()
|
||||||
|
}
|
||||||
|
return StatusTodo
|
||||||
|
}
|
||||||
|
|
||||||
// Matches reports whether a ticket belongs on this board list.
|
// Matches reports whether a ticket belongs on this board list.
|
||||||
func (b BoardList) Matches(status Status) bool {
|
func (b BoardList) Matches(status Status) bool {
|
||||||
switch b {
|
switch b {
|
||||||
|
|||||||
@@ -41,3 +41,38 @@ func TestBoardListDefaultStatus(t *testing.T) {
|
|||||||
t.Fatal("all default")
|
t.Fatal("all default")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBoardFilterMultiSelect(t *testing.T) {
|
||||||
|
f := ticket.ParseBoardLists("backlog,done")
|
||||||
|
if !f.Matches(ticket.StatusTodo) || !f.Matches(ticket.StatusDone) {
|
||||||
|
t.Fatalf("expected backlog+done match, got %v", f)
|
||||||
|
}
|
||||||
|
if f.Matches(ticket.StatusInProgress) {
|
||||||
|
t.Fatal("should not match in progress")
|
||||||
|
}
|
||||||
|
if got := ticket.FormatBoardLists(f); got != "backlog,done" {
|
||||||
|
t.Fatalf("format = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardFilterAllDominatesNormalize(t *testing.T) {
|
||||||
|
f := ticket.NormalizeFilter(ticket.BoardFilter{ticket.BoardAll, ticket.BoardBacklog})
|
||||||
|
if len(f) != 1 || f[0] != ticket.BoardBacklog {
|
||||||
|
t.Fatalf("got %v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardListShortcut(t *testing.T) {
|
||||||
|
if ticket.BoardAll.Shortcut() != 'a' {
|
||||||
|
t.Fatal("all")
|
||||||
|
}
|
||||||
|
if ticket.BoardBacklog.Shortcut() != 'b' {
|
||||||
|
t.Fatal("backlog")
|
||||||
|
}
|
||||||
|
if ticket.BoardInProgress.Shortcut() != 'i' {
|
||||||
|
t.Fatal("in progress")
|
||||||
|
}
|
||||||
|
if ticket.BoardDone.Shortcut() != 'd' {
|
||||||
|
t.Fatal("done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+27
-11
@@ -52,7 +52,7 @@ type Model struct {
|
|||||||
projectList list.Model
|
projectList list.Model
|
||||||
statusMenu views.StatusMenu
|
statusMenu views.StatusMenu
|
||||||
boardMenu views.BoardMenu
|
boardMenu views.BoardMenu
|
||||||
boardList ticket.BoardList
|
boardFilter ticket.BoardFilter
|
||||||
input textinput.Model
|
input textinput.Model
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
@@ -80,7 +80,7 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, exec co
|
|||||||
projects: projects,
|
projects: projects,
|
||||||
exec: exec,
|
exec: exec,
|
||||||
mode: ModeList,
|
mode: ModeList,
|
||||||
boardList: ticket.ParseBoardList(cfg.ActiveBoardList(cfg.ActiveProject)),
|
boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
|
||||||
tickets: views.TicketList{
|
tickets: views.TicketList{
|
||||||
Styles: views.TicketListStyles{
|
Styles: views.TicketListStyles{
|
||||||
Card: styles.Card,
|
Card: styles.Card,
|
||||||
@@ -208,7 +208,7 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, m.keys.Board):
|
case key.Matches(msg, m.keys.Board):
|
||||||
m.clearStatus()
|
m.clearStatus()
|
||||||
m.boardMenu = views.NewBoardMenu(m.boardList, m.width, views.StatusMenuStyles{
|
m.boardMenu = views.NewBoardMenu(m.boardFilter, m.width, views.StatusMenuStyles{
|
||||||
Box: m.styles.StatusMenuBox,
|
Box: m.styles.StatusMenuBox,
|
||||||
Title: m.styles.StatusMenuTitle,
|
Title: m.styles.StatusMenuTitle,
|
||||||
Selected: m.styles.StatusMenuSelected,
|
Selected: m.styles.StatusMenuSelected,
|
||||||
@@ -329,13 +329,17 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Enter):
|
case key.Matches(msg, m.keys.Enter):
|
||||||
if err := m.applyBoardList(m.boardMenu.Selected()); err != nil {
|
if err := m.applyBoardFilter(m.boardMenu.Selected()); err != nil {
|
||||||
m.setError(err)
|
m.setError(err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.mode = ModeList
|
m.mode = ModeList
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Toggle):
|
||||||
|
m.boardMenu.Toggle()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Down):
|
case key.Matches(msg, m.keys.Down):
|
||||||
m.boardMenu.MoveDown()
|
m.boardMenu.MoveDown()
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -344,6 +348,12 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.boardMenu.MoveUp()
|
m.boardMenu.MoveUp()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
|
||||||
|
if m.boardMenu.JumpToShortcut(msg.Runes[0]) {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +485,12 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
|
||||||
|
if views.JumpProjectByLetter(&m.projectList, msg.Runes[0]) {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.projectList, cmd = m.projectList.Update(msg)
|
m.projectList, cmd = m.projectList.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
@@ -575,7 +591,7 @@ func (m *Model) createTicket(title, description string) error {
|
|||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: description,
|
||||||
ProjectID: m.cfg.ActiveProject,
|
ProjectID: m.cfg.ActiveProject,
|
||||||
Status: m.boardList.DefaultStatus(),
|
Status: m.boardFilter.DefaultStatus(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := m.exec.Do(cmd); err != nil {
|
if err := m.exec.Do(cmd); err != nil {
|
||||||
@@ -661,14 +677,14 @@ func (m *Model) redo() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) applyBoardList(bl ticket.BoardList) error {
|
func (m *Model) applyBoardFilter(f ticket.BoardFilter) error {
|
||||||
m.boardList = bl
|
m.boardFilter = ticket.NormalizeFilter(f)
|
||||||
m.cfg.SetActiveBoardList(m.cfg.ActiveProject, string(bl))
|
m.cfg.SetActiveBoardLists(m.cfg.ActiveProject, ticket.FormatBoardLists(m.boardFilter))
|
||||||
if err := config.Save(m.cfg); err != nil {
|
if err := config.Save(m.cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.applyFilter()
|
m.applyFilter()
|
||||||
m.setStatus(fmt.Sprintf("list → %s", bl.Label()))
|
m.setStatus(fmt.Sprintf("list → %s", m.boardFilter.Label()))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,7 +693,7 @@ func (m *Model) switchProject(id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.cfg.ActiveProject = id
|
m.cfg.ActiveProject = id
|
||||||
m.boardList = ticket.ParseBoardList(m.cfg.ActiveBoardList(id))
|
m.boardFilter = ticket.ParseBoardLists(m.cfg.ActiveBoardLists(id))
|
||||||
if err := config.Save(m.cfg); err != nil {
|
if err := config.Save(m.cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -721,7 +737,7 @@ func (m *Model) reloadTickets() error {
|
|||||||
func (m *Model) applyFilter() {
|
func (m *Model) applyFilter() {
|
||||||
filtered := make([]ticket.Ticket, 0, len(m.allTickets))
|
filtered := make([]ticket.Ticket, 0, len(m.allTickets))
|
||||||
for _, t := range m.allTickets {
|
for _, t := range m.allTickets {
|
||||||
if m.boardList.Matches(t.Status) {
|
if m.boardFilter.Matches(t.Status) {
|
||||||
filtered = append(filtered, t)
|
filtered = append(filtered, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
case ModeProjects:
|
case ModeProjects:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
{"j/k", "navigate"},
|
{"j/k", "navigate"},
|
||||||
|
{"a-z", "jump"},
|
||||||
{"enter", "select"},
|
{"enter", "select"},
|
||||||
{"n", "new project"},
|
{"n", "new project"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
@@ -43,7 +44,9 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
}
|
}
|
||||||
case ModeBoard:
|
case ModeBoard:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
{"j/k", "select"},
|
{"j/k", "navigate"},
|
||||||
|
{"a/b/i/d", "jump"},
|
||||||
|
{"space", "toggle"},
|
||||||
{"enter", "apply"},
|
{"enter", "apply"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type KeyMap struct {
|
|||||||
Delete key.Binding // x — confirm first
|
Delete key.Binding // x — confirm first
|
||||||
DeleteForce key.Binding // X — immediate
|
DeleteForce key.Binding // X — immediate
|
||||||
ConfirmYes key.Binding
|
ConfirmYes key.Binding
|
||||||
|
Toggle key.Binding // space — toggle multi-select
|
||||||
Undo key.Binding
|
Undo key.Binding
|
||||||
Redo key.Binding
|
Redo key.Binding
|
||||||
Quit key.Binding
|
Quit key.Binding
|
||||||
@@ -74,6 +75,10 @@ func DefaultKeyMap() KeyMap {
|
|||||||
key.WithKeys("y", "Y"),
|
key.WithKeys("y", "Y"),
|
||||||
key.WithHelp("y", "confirm"),
|
key.WithHelp("y", "confirm"),
|
||||||
),
|
),
|
||||||
|
Toggle: key.NewBinding(
|
||||||
|
key.WithKeys(" "),
|
||||||
|
key.WithHelp("space", "toggle"),
|
||||||
|
),
|
||||||
Undo: key.NewBinding(
|
Undo: key.NewBinding(
|
||||||
key.WithKeys("u", "ctrl+z"),
|
key.WithKeys("u", "ctrl+z"),
|
||||||
key.WithHelp("u", "undo"),
|
key.WithHelp("u", "undo"),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
func (m Model) renderHeader() string {
|
func (m Model) renderHeader() string {
|
||||||
brand := m.styles.HeaderBrand.Render(config.AppName)
|
brand := m.styles.HeaderBrand.Render(config.AppName)
|
||||||
project := m.styles.HeaderMeta.Render(fmt.Sprintf("project: %s", m.cfg.ActiveProject))
|
project := m.styles.HeaderMeta.Render(fmt.Sprintf("project: %s", m.cfg.ActiveProject))
|
||||||
board := m.styles.FilterHint.Render(fmt.Sprintf("list: %s", m.boardList.Label()))
|
board := m.styles.FilterHint.Render(fmt.Sprintf("list: %s", m.boardFilter.Label()))
|
||||||
count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount()))
|
count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount()))
|
||||||
|
|
||||||
parts := []string{brand, project, board, count}
|
parts := []string{brand, project, board, count}
|
||||||
|
|||||||
+75
-20
@@ -1,24 +1,40 @@
|
|||||||
package views
|
package views
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"task/internal/ticket"
|
"task/internal/ticket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BoardMenu renders a board-list picker overlay.
|
// BoardMenu renders a multi-select board-list picker overlay.
|
||||||
type BoardMenu struct {
|
type BoardMenu struct {
|
||||||
Cursor int
|
Cursor int
|
||||||
Width int
|
Width int
|
||||||
Styles StatusMenuStyles
|
Styles StatusMenuStyles
|
||||||
|
selected map[ticket.BoardList]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBoardMenu creates a menu with the given board list pre-selected.
|
// NewBoardMenu creates a menu with the given filter pre-selected.
|
||||||
func NewBoardMenu(current ticket.BoardList, width int, styles StatusMenuStyles) BoardMenu {
|
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{
|
return BoardMenu{
|
||||||
Cursor: current.Index(),
|
Cursor: cursor,
|
||||||
Width: width,
|
Width: width,
|
||||||
Styles: styles,
|
Styles: styles,
|
||||||
|
selected: sel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,31 +52,70 @@ func (m *BoardMenu) MoveUp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selected returns the currently highlighted board list.
|
// JumpToShortcut moves the cursor to the list whose label starts with r.
|
||||||
func (m BoardMenu) Selected() ticket.BoardList {
|
func (m *BoardMenu) JumpToShortcut(r rune) bool {
|
||||||
if m.Cursor < 0 || m.Cursor >= len(ticket.AllBoardLists) {
|
r = unicode.ToLower(r)
|
||||||
return ticket.BoardAll
|
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.
|
// View renders the board menu.
|
||||||
func (m BoardMenu) View() string {
|
func (m BoardMenu) View() string {
|
||||||
var lines []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 {
|
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 {
|
if i == m.Cursor {
|
||||||
label = m.Styles.Selected.Render("▸ " + bl.Label())
|
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
|
||||||
} else {
|
} else {
|
||||||
label = m.Styles.Normal.Render(label)
|
lines = append(lines, m.Styles.Normal.Render(" "+entry))
|
||||||
}
|
}
|
||||||
lines = append(lines, label)
|
|
||||||
}
|
}
|
||||||
body := strings.Join(lines, "\n")
|
body := strings.Join(lines, "\n")
|
||||||
w := m.Width
|
w := m.Width
|
||||||
if w < 20 {
|
if w < 20 {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package views
|
package views
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
|
||||||
"task/internal/project"
|
"task/internal/project"
|
||||||
@@ -46,6 +48,33 @@ func NewProjectList(projects []project.Project, activeID string, width, height i
|
|||||||
return l
|
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.
|
// ProjectsToItems converts projects to list items.
|
||||||
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
|
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
|
||||||
items := make([]list.Item, len(projects))
|
items := make([]list.Item, len(projects))
|
||||||
|
|||||||
Reference in New Issue
Block a user