diff --git a/internal/config/config.go b/internal/config/config.go index a6d0907..cbc3cec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,8 @@ type Config struct { ActiveProject string `json:"activeProject"` DataDir string `json:"dataDir"` 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"` } @@ -55,8 +56,8 @@ func (c *Config) ApplyDefaults(home string) { } } -// ActiveBoardList returns the persisted board list for a project (default: all). -func (c *Config) ActiveBoardList(projectID string) string { +// ActiveBoardLists returns the persisted board filter string for a project (default: "all"). +func (c *Config) ActiveBoardLists(projectID string) string { if c.ProjectLists == nil { return "all" } @@ -66,10 +67,10 @@ func (c *Config) ActiveBoardList(projectID string) string { return "all" } -// SetActiveBoardList stores the board list for a project. -func (c *Config) SetActiveBoardList(projectID, listID string) { +// SetActiveBoardLists stores the board filter string for a project. +func (c *Config) SetActiveBoardLists(projectID, listIDs string) { if c.ProjectLists == nil { c.ProjectLists = map[string]string{} } - c.ProjectLists[projectID] = listID + c.ProjectLists[projectID] = listIDs } diff --git a/internal/ticket/board.go b/internal/ticket/board.go index 6bbb8a4..5b885d8 100644 --- a/internal/ticket/board.go +++ b/internal/ticket/board.go @@ -1,5 +1,10 @@ package ticket +import ( + "strings" + "unicode" +) + // BoardList is a project-scoped view/filter over tickets by status. 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. func (b BoardList) Index() int { 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. func (b BoardList) Matches(status Status) bool { switch b { diff --git a/internal/ticket/board_test.go b/internal/ticket/board_test.go index 5b5bda3..3ee48ca 100644 --- a/internal/ticket/board_test.go +++ b/internal/ticket/board_test.go @@ -41,3 +41,38 @@ func TestBoardListDefaultStatus(t *testing.T) { 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") + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 4182a14..2e978ff 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -52,7 +52,7 @@ type Model struct { projectList list.Model statusMenu views.StatusMenu boardMenu views.BoardMenu - boardList ticket.BoardList + boardFilter ticket.BoardFilter input textinput.Model width int height int @@ -80,7 +80,7 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, exec co projects: projects, exec: exec, mode: ModeList, - boardList: ticket.ParseBoardList(cfg.ActiveBoardList(cfg.ActiveProject)), + boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)), tickets: views.TicketList{ Styles: views.TicketListStyles{ 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): 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, Title: m.styles.StatusMenuTitle, Selected: m.styles.StatusMenuSelected, @@ -329,13 +329,17 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil 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) return m, nil } m.mode = ModeList return m, nil + case key.Matches(msg, m.keys.Toggle): + m.boardMenu.Toggle() + return m, nil + case key.Matches(msg, m.keys.Down): m.boardMenu.MoveDown() return m, nil @@ -344,6 +348,12 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.boardMenu.MoveUp() 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 } @@ -475,6 +485,12 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 m.projectList, cmd = m.projectList.Update(msg) return m, cmd @@ -575,7 +591,7 @@ func (m *Model) createTicket(title, description string) error { Title: title, Description: description, ProjectID: m.cfg.ActiveProject, - Status: m.boardList.DefaultStatus(), + Status: m.boardFilter.DefaultStatus(), }, } if err := m.exec.Do(cmd); err != nil { @@ -661,14 +677,14 @@ func (m *Model) redo() error { return nil } -func (m *Model) applyBoardList(bl ticket.BoardList) error { - m.boardList = bl - m.cfg.SetActiveBoardList(m.cfg.ActiveProject, string(bl)) +func (m *Model) applyBoardFilter(f ticket.BoardFilter) error { + m.boardFilter = ticket.NormalizeFilter(f) + m.cfg.SetActiveBoardLists(m.cfg.ActiveProject, ticket.FormatBoardLists(m.boardFilter)) if err := config.Save(m.cfg); err != nil { return err } m.applyFilter() - m.setStatus(fmt.Sprintf("list → %s", bl.Label())) + m.setStatus(fmt.Sprintf("list → %s", m.boardFilter.Label())) return nil } @@ -677,7 +693,7 @@ func (m *Model) switchProject(id string) error { return err } 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 { return err } @@ -721,7 +737,7 @@ func (m *Model) reloadTickets() error { func (m *Model) applyFilter() { filtered := make([]ticket.Ticket, 0, len(m.allTickets)) for _, t := range m.allTickets { - if m.boardList.Matches(t.Status) { + if m.boardFilter.Matches(t.Status) { filtered = append(filtered, t) } } diff --git a/internal/ui/helpbar.go b/internal/ui/helpbar.go index 6ad98a1..4ed3932 100644 --- a/internal/ui/helpbar.go +++ b/internal/ui/helpbar.go @@ -30,6 +30,7 @@ func (m Model) helpEntries() []helpEntry { case ModeProjects: return []helpEntry{ {"j/k", "navigate"}, + {"a-z", "jump"}, {"enter", "select"}, {"n", "new project"}, {"esc", "cancel"}, @@ -43,7 +44,9 @@ func (m Model) helpEntries() []helpEntry { } case ModeBoard: return []helpEntry{ - {"j/k", "select"}, + {"j/k", "navigate"}, + {"a/b/i/d", "jump"}, + {"space", "toggle"}, {"enter", "apply"}, {"esc", "cancel"}, } diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 6f47eb0..78aedff 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -16,6 +16,7 @@ type KeyMap struct { Delete key.Binding // x — confirm first DeleteForce key.Binding // X — immediate ConfirmYes key.Binding + Toggle key.Binding // space — toggle multi-select Undo key.Binding Redo key.Binding Quit key.Binding @@ -74,6 +75,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("y", "Y"), key.WithHelp("y", "confirm"), ), + Toggle: key.NewBinding( + key.WithKeys(" "), + key.WithHelp("space", "toggle"), + ), Undo: key.NewBinding( key.WithKeys("u", "ctrl+z"), key.WithHelp("u", "undo"), diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 307ca40..a53299d 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -12,7 +12,7 @@ import ( func (m Model) renderHeader() string { brand := m.styles.HeaderBrand.Render(config.AppName) 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())) parts := []string{brand, project, board, count} diff --git a/internal/ui/views/board.go b/internal/ui/views/board.go index 6fcc90a..ff8d5d1 100644 --- a/internal/ui/views/board.go +++ b/internal/ui/views/board.go @@ -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) } diff --git a/internal/ui/views/projects.go b/internal/ui/views/projects.go index e512820..84871f5 100644 --- a/internal/ui/views/projects.go +++ b/internal/ui/views/projects.go @@ -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))