Add offline-first task-master TUI with tickets, board lists, and undo.
Charm-based CLI with JSON persistence, status/list filters, external editor editing, command-history undo/redo, and glamour markdown without AutoStyle OSC hangs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,694 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/config"
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
"task/internal/ui/views"
|
||||
"task/internal/version"
|
||||
)
|
||||
|
||||
// editorFinishedMsg is sent after the external $EDITOR exits.
|
||||
type editorFinishedMsg struct {
|
||||
ticketID string
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
// Mode is the active UI mode.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
ModeList Mode = iota
|
||||
ModeCreate
|
||||
ModeSearch
|
||||
ModeProjects
|
||||
ModeStatus
|
||||
ModeBoard
|
||||
)
|
||||
|
||||
// Model is the root Bubble Tea model.
|
||||
type Model struct {
|
||||
cfg *config.Config
|
||||
store ticket.Store
|
||||
projects project.Store
|
||||
exec command.Executor
|
||||
|
||||
mode Mode
|
||||
tickets views.TicketList
|
||||
projectList list.Model
|
||||
statusMenu views.StatusMenu
|
||||
boardMenu views.BoardMenu
|
||||
boardList ticket.BoardList
|
||||
input textinput.Model
|
||||
width int
|
||||
height int
|
||||
status string
|
||||
statusIsError bool
|
||||
filter string
|
||||
keys KeyMap
|
||||
styles Styles
|
||||
version string
|
||||
allTickets []ticket.Ticket
|
||||
creatingProject bool
|
||||
}
|
||||
|
||||
// New creates the root UI model.
|
||||
func New(cfg *config.Config, store ticket.Store, projects project.Store, exec command.Executor) Model {
|
||||
styles := DefaultStyles()
|
||||
if exec == nil {
|
||||
exec = command.NewHistory()
|
||||
}
|
||||
|
||||
m := Model{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
projects: projects,
|
||||
exec: exec,
|
||||
mode: ModeList,
|
||||
boardList: ticket.ParseBoardList(cfg.ActiveBoardList(cfg.ActiveProject)),
|
||||
tickets: views.TicketList{
|
||||
Styles: views.TicketListStyles{
|
||||
Card: styles.Card,
|
||||
CardSelected: styles.CardSelected,
|
||||
StatusOPEN: styles.StatusTodo,
|
||||
StatusWIP: styles.StatusWIP,
|
||||
StatusDONE: styles.StatusDone,
|
||||
Headline: styles.Headline,
|
||||
},
|
||||
},
|
||||
input: views.NewCreateInput(),
|
||||
keys: DefaultKeyMap(),
|
||||
styles: styles,
|
||||
version: version.BuildVersion,
|
||||
}
|
||||
_ = m.reloadTickets()
|
||||
return m
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update implements tea.Model.
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.resizeLists()
|
||||
return m, nil
|
||||
|
||||
case editorFinishedMsg:
|
||||
return m.handleEditorFinished(msg)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.mode {
|
||||
case ModeCreate, ModeSearch:
|
||||
return m.updateInput(msg)
|
||||
case ModeProjects:
|
||||
return m.updateProjects(msg)
|
||||
case ModeStatus:
|
||||
return m.updateStatus(msg)
|
||||
case ModeBoard:
|
||||
return m.updateBoard(msg)
|
||||
default:
|
||||
return m.updateList(msg)
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.mode {
|
||||
case ModeProjects:
|
||||
m.projectList, cmd = m.projectList.Update(msg)
|
||||
case ModeCreate, ModeSearch:
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// View implements tea.Model.
|
||||
func (m Model) View() string {
|
||||
if m.width == 0 {
|
||||
return "loading…"
|
||||
}
|
||||
header := m.renderHeader()
|
||||
footer := m.renderFooter()
|
||||
bodyH := m.height - lipgloss.Height(header) - lipgloss.Height(footer)
|
||||
if bodyH < 1 {
|
||||
bodyH = 1
|
||||
}
|
||||
body := m.renderBody(bodyH)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
|
||||
}
|
||||
|
||||
func (m *Model) resizeLists() {
|
||||
headerH := 1
|
||||
footerH := 1
|
||||
bodyH := m.height - headerH - footerH
|
||||
if bodyH < 1 {
|
||||
bodyH = 1
|
||||
}
|
||||
m.tickets.SetSize(m.width, bodyH)
|
||||
if m.mode == ModeProjects {
|
||||
m.projectList.SetSize(m.width, bodyH)
|
||||
}
|
||||
m.input.Width = max(10, m.width-4)
|
||||
m.statusMenu.Width = m.width
|
||||
m.boardMenu.Width = m.width
|
||||
}
|
||||
|
||||
func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
return m, tea.Quit
|
||||
|
||||
case key.Matches(msg, m.keys.New):
|
||||
m.clearStatus()
|
||||
m.creatingProject = false
|
||||
m.mode = ModeCreate
|
||||
m.input = views.NewCreateInput()
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.keys.Search):
|
||||
m.clearStatus()
|
||||
m.creatingProject = false
|
||||
m.mode = ModeSearch
|
||||
m.input = views.NewSearchInput()
|
||||
m.input.SetValue(m.filter)
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.keys.Project):
|
||||
m.clearStatus()
|
||||
if err := m.openProjects(); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeProjects
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Board):
|
||||
m.clearStatus()
|
||||
m.boardMenu = views.NewBoardMenu(m.boardList, m.width, views.StatusMenuStyles{
|
||||
Box: m.styles.StatusMenuBox,
|
||||
Title: m.styles.StatusMenuTitle,
|
||||
Selected: m.styles.StatusMenuSelected,
|
||||
Normal: m.styles.StatusMenuNormal,
|
||||
})
|
||||
m.mode = ModeBoard
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Delete):
|
||||
m.clearStatus()
|
||||
if err := m.deleteSelected(); err != nil {
|
||||
m.setError(err)
|
||||
} else {
|
||||
m.setStatus("deleted")
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Undo):
|
||||
m.clearStatus()
|
||||
if err := m.undo(); err != nil {
|
||||
m.setError(err)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Redo):
|
||||
m.clearStatus()
|
||||
if err := m.redo(); err != nil {
|
||||
m.setError(err)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Edit):
|
||||
m.clearStatus()
|
||||
cmd, err := m.openEditorForSelected()
|
||||
if err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case key.Matches(msg, m.keys.Status):
|
||||
m.clearStatus()
|
||||
t, ok := m.tickets.Selected()
|
||||
if !ok {
|
||||
m.setError(fmt.Errorf("no ticket selected"))
|
||||
return m, nil
|
||||
}
|
||||
m.statusMenu = views.NewStatusMenu(t.Status, m.width, views.StatusMenuStyles{
|
||||
Box: m.styles.StatusMenuBox,
|
||||
Title: m.styles.StatusMenuTitle,
|
||||
Selected: m.styles.StatusMenuSelected,
|
||||
Normal: m.styles.StatusMenuNormal,
|
||||
})
|
||||
m.mode = ModeStatus
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
m.tickets.MoveDown()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
m.tickets.MoveUp()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateStatus(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
m.mode = ModeList
|
||||
m.clearStatus()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
if err := m.applyStatus(m.statusMenu.Selected()); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeList
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
m.statusMenu.MoveDown()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
m.statusMenu.MoveUp()
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
m.mode = ModeList
|
||||
m.clearStatus()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
if err := m.applyBoardList(m.boardMenu.Selected()); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeList
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
m.boardMenu.MoveDown()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
m.boardMenu.MoveUp()
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
wasProject := m.creatingProject
|
||||
m.creatingProject = false
|
||||
m.input.Blur()
|
||||
m.clearStatus()
|
||||
if wasProject {
|
||||
m.mode = ModeProjects
|
||||
} else {
|
||||
m.mode = ModeList
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
value := strings.TrimSpace(m.input.Value())
|
||||
if m.mode == ModeCreate {
|
||||
if value == "" {
|
||||
m.setError(fmt.Errorf("name required"))
|
||||
return m, nil
|
||||
}
|
||||
if m.creatingProject {
|
||||
id := sanitizeProjectID(value)
|
||||
if err := m.projects.Ensure(id); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
if err := m.switchProject(id); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.creatingProject = false
|
||||
m.mode = ModeList
|
||||
m.input.Blur()
|
||||
m.setStatus(fmt.Sprintf("created project %s", id))
|
||||
return m, nil
|
||||
}
|
||||
if err := m.createTicket(value); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeList
|
||||
m.input.Blur()
|
||||
m.setStatus("created")
|
||||
return m, nil
|
||||
}
|
||||
m.filter = value
|
||||
m.applyFilter()
|
||||
m.mode = ModeList
|
||||
m.input.Blur()
|
||||
if value == "" {
|
||||
m.setStatus("filter cleared")
|
||||
} else {
|
||||
m.setStatus(fmt.Sprintf("filter: %q", value))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
m.mode = ModeList
|
||||
m.clearStatus()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.New):
|
||||
m.clearStatus()
|
||||
m.creatingProject = true
|
||||
m.mode = ModeCreate
|
||||
m.input = views.NewCreateInput()
|
||||
m.input.Placeholder = "Project name…"
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
if err := m.switchProject(item.Project.ID); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeList
|
||||
m.setStatus(fmt.Sprintf("switched to %s", item.Project.ID))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.projectList, cmd = m.projectList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) openEditorForSelected() (tea.Cmd, error) {
|
||||
t, ok := m.tickets.Selected()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no ticket selected")
|
||||
}
|
||||
path, err := ticket.WriteEditFile(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := ticket.EditorCmd(path)
|
||||
ticketID := t.ID
|
||||
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
return editorFinishedMsg{ticketID: ticketID, path: path, err: err}
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd) {
|
||||
defer func() { _ = os.Remove(msg.path) }()
|
||||
|
||||
if msg.err != nil {
|
||||
m.setError(fmt.Errorf("editor: %w", msg.err))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(msg.path)
|
||||
if err != nil {
|
||||
m.setError(fmt.Errorf("read edited file: %w", err))
|
||||
return m, nil
|
||||
}
|
||||
title, description, err := ticket.ParseEditDocument(string(data))
|
||||
if err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
before, err := m.store.Get(msg.ticketID)
|
||||
if err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
after := before
|
||||
after.Title = title
|
||||
after.Description = description
|
||||
if err := m.exec.Do(&command.UpdateTicket{
|
||||
Store: m.store,
|
||||
Before: before,
|
||||
After: after,
|
||||
}); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
idx := m.tickets.CursorIndex()
|
||||
if err := m.reloadTickets(); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
if idx < m.tickets.Len() {
|
||||
m.tickets.Select(idx)
|
||||
}
|
||||
m.setStatus("saved")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) createTicket(title string) error {
|
||||
cmd := &command.CreateTicket{
|
||||
Store: m.store,
|
||||
Ticket: ticket.Ticket{
|
||||
Title: title,
|
||||
ProjectID: m.cfg.ActiveProject,
|
||||
Status: m.boardList.DefaultStatus(),
|
||||
},
|
||||
}
|
||||
if err := m.exec.Do(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.reloadTickets()
|
||||
}
|
||||
|
||||
func (m *Model) deleteSelected() error {
|
||||
t, ok := m.tickets.Selected()
|
||||
if !ok {
|
||||
return fmt.Errorf("no ticket selected")
|
||||
}
|
||||
idx := m.tickets.CursorIndex()
|
||||
if err := m.exec.Do(&command.DeleteTicket{Store: m.store, Snapshot: t}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.reloadTickets(); err != nil {
|
||||
return err
|
||||
}
|
||||
if idx >= m.tickets.Len() && m.tickets.Len() > 0 {
|
||||
m.tickets.Select(m.tickets.Len() - 1)
|
||||
} else if idx < m.tickets.Len() {
|
||||
m.tickets.Select(idx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) applyStatus(st ticket.Status) error {
|
||||
t, ok := m.tickets.Selected()
|
||||
if !ok {
|
||||
return fmt.Errorf("no ticket selected")
|
||||
}
|
||||
after := t
|
||||
after.Status = st
|
||||
if err := m.exec.Do(&command.UpdateTicket{
|
||||
Store: m.store,
|
||||
Before: t,
|
||||
After: after,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
idx := m.tickets.CursorIndex()
|
||||
if err := m.reloadTickets(); err != nil {
|
||||
return err
|
||||
}
|
||||
if idx < m.tickets.Len() {
|
||||
m.tickets.Select(idx)
|
||||
}
|
||||
m.setStatus(fmt.Sprintf("status → %s", st.Label()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) undo() error {
|
||||
name := m.exec.LastUndoName()
|
||||
if err := m.exec.Undo(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.reloadTickets(); err != nil {
|
||||
return err
|
||||
}
|
||||
if name != "" {
|
||||
m.setStatus("undo: " + name)
|
||||
} else {
|
||||
m.setStatus("undo")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) redo() error {
|
||||
name := m.exec.LastRedoName()
|
||||
if err := m.exec.Redo(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.reloadTickets(); err != nil {
|
||||
return err
|
||||
}
|
||||
if name != "" {
|
||||
m.setStatus("redo: " + name)
|
||||
} else {
|
||||
m.setStatus("redo")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) applyBoardList(bl ticket.BoardList) error {
|
||||
m.boardList = bl
|
||||
m.cfg.SetActiveBoardList(m.cfg.ActiveProject, string(bl))
|
||||
if err := config.Save(m.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
m.applyFilter()
|
||||
m.setStatus(fmt.Sprintf("list → %s", bl.Label()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) switchProject(id string) error {
|
||||
if err := m.projects.Ensure(id); err != nil {
|
||||
return err
|
||||
}
|
||||
m.cfg.ActiveProject = id
|
||||
m.boardList = ticket.ParseBoardList(m.cfg.ActiveBoardList(id))
|
||||
if err := config.Save(m.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
m.filter = ""
|
||||
return m.reloadTickets()
|
||||
}
|
||||
|
||||
func (m *Model) openProjects() error {
|
||||
projects, err := m.projects.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(projects) == 0 {
|
||||
if err := m.projects.Ensure(m.cfg.ActiveProject); err != nil {
|
||||
return err
|
||||
}
|
||||
projects, err = m.projects.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
headerH, footerH := 1, 1
|
||||
bodyH := m.height - headerH - footerH
|
||||
if bodyH < 1 {
|
||||
bodyH = 1
|
||||
}
|
||||
m.projectList = views.NewProjectList(projects, m.cfg.ActiveProject, m.width, bodyH)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) reloadTickets() error {
|
||||
tickets, err := m.store.List(m.cfg.ActiveProject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.allTickets = tickets
|
||||
m.applyFilter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) applyFilter() {
|
||||
filtered := make([]ticket.Ticket, 0, len(m.allTickets))
|
||||
for _, t := range m.allTickets {
|
||||
if m.boardList.Matches(t.Status) {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
filtered = views.FilterTickets(filtered, m.filter)
|
||||
cursor := m.tickets.CursorIndex()
|
||||
m.tickets.SetItems(filtered)
|
||||
if cursor < m.tickets.Len() {
|
||||
m.tickets.Select(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) ticketCount() int {
|
||||
return m.tickets.Len()
|
||||
}
|
||||
|
||||
func (m *Model) setStatus(s string) {
|
||||
m.status = s
|
||||
m.statusIsError = false
|
||||
}
|
||||
|
||||
func (m *Model) setError(err error) {
|
||||
m.status = err.Error()
|
||||
m.statusIsError = true
|
||||
}
|
||||
|
||||
func (m *Model) clearStatus() {
|
||||
m.status = ""
|
||||
m.statusIsError = false
|
||||
}
|
||||
|
||||
func sanitizeProjectID(name string) string {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
case r == ' ':
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
id := b.String()
|
||||
if id == "" {
|
||||
return "project"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type helpEntry struct {
|
||||
key string
|
||||
desc string
|
||||
}
|
||||
|
||||
func (m Model) helpEntries() []helpEntry {
|
||||
switch m.mode {
|
||||
case ModeCreate:
|
||||
return []helpEntry{
|
||||
{"enter", "save"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeSearch:
|
||||
return []helpEntry{
|
||||
{"enter", "apply"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeProjects:
|
||||
return []helpEntry{
|
||||
{"j/k", "navigate"},
|
||||
{"enter", "select"},
|
||||
{"n", "new project"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeStatus:
|
||||
return []helpEntry{
|
||||
{"j/k", "select"},
|
||||
{"enter", "apply"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeBoard:
|
||||
return []helpEntry{
|
||||
{"j/k", "select"},
|
||||
{"enter", "apply"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
default:
|
||||
return []helpEntry{
|
||||
{"j/k", "navigate"},
|
||||
{"n", "new"},
|
||||
{"e", "edit"},
|
||||
{"u/U", "undo/redo"},
|
||||
{"/", "search"},
|
||||
{"p", "project"},
|
||||
{"l", "list"},
|
||||
{"s", "status"},
|
||||
{"X", "delete"},
|
||||
{"q", "quit"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderHelpbar() string {
|
||||
var parts []string
|
||||
for _, e := range m.helpEntries() {
|
||||
parts = append(parts, m.styles.HelpKey.Render(e.key)+" "+m.styles.HelpDesc.Render(e.desc))
|
||||
}
|
||||
sep := m.styles.HelpSep.String()
|
||||
if sep == "" {
|
||||
sep = " "
|
||||
}
|
||||
return strings.Join(parts, sep)
|
||||
}
|
||||
|
||||
func (m Model) renderFooter() string {
|
||||
help := m.renderHelpbar()
|
||||
ver := m.styles.Version.Render(m.version)
|
||||
|
||||
width := m.width
|
||||
if width <= 0 {
|
||||
width = lipgloss.Width(help) + lipgloss.Width(ver) + 1
|
||||
}
|
||||
|
||||
helpW := lipgloss.Width(help)
|
||||
verW := lipgloss.Width(ver)
|
||||
gap := width - helpW - verW
|
||||
if gap < 1 {
|
||||
maxHelp := width - verW - 1
|
||||
if maxHelp < 0 {
|
||||
maxHelp = 0
|
||||
}
|
||||
help = truncateWidth(help, maxHelp)
|
||||
helpW = lipgloss.Width(help)
|
||||
gap = width - helpW - verW
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
}
|
||||
return help + strings.Repeat(" ", gap) + ver
|
||||
}
|
||||
|
||||
func truncateWidth(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
if lipgloss.Width(s) <= max {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
for len(runes) > 0 {
|
||||
runes = runes[:len(runes)-1]
|
||||
if lipgloss.Width(string(runes)) <= max-1 {
|
||||
return string(runes) + "…"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ui
|
||||
|
||||
import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
// KeyMap defines vim-style bindings for the TUI.
|
||||
type KeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
New key.Binding
|
||||
Search key.Binding
|
||||
Project key.Binding
|
||||
Board key.Binding
|
||||
Status key.Binding
|
||||
Edit key.Binding
|
||||
Delete key.Binding
|
||||
Undo key.Binding
|
||||
Redo key.Binding
|
||||
Quit key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
}
|
||||
|
||||
// DefaultKeyMap returns the default key bindings.
|
||||
func DefaultKeyMap() KeyMap {
|
||||
return KeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("k", "up"),
|
||||
key.WithHelp("j/k", "navigate"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("j", "down"),
|
||||
key.WithHelp("j/k", "navigate"),
|
||||
),
|
||||
New: key.NewBinding(
|
||||
key.WithKeys("n"),
|
||||
key.WithHelp("n", "new"),
|
||||
),
|
||||
Search: key.NewBinding(
|
||||
key.WithKeys("/"),
|
||||
key.WithHelp("/", "search"),
|
||||
),
|
||||
Project: key.NewBinding(
|
||||
key.WithKeys("p"),
|
||||
key.WithHelp("p", "project"),
|
||||
),
|
||||
Board: key.NewBinding(
|
||||
key.WithKeys("l", "tab"),
|
||||
key.WithHelp("l", "list"),
|
||||
),
|
||||
Status: key.NewBinding(
|
||||
key.WithKeys("s", "enter"),
|
||||
key.WithHelp("s", "status"),
|
||||
),
|
||||
Edit: key.NewBinding(
|
||||
key.WithKeys("e"),
|
||||
key.WithHelp("e", "edit"),
|
||||
),
|
||||
Delete: key.NewBinding(
|
||||
key.WithKeys("X"),
|
||||
key.WithHelp("X", "delete"),
|
||||
),
|
||||
Undo: key.NewBinding(
|
||||
key.WithKeys("u", "ctrl+z"),
|
||||
key.WithHelp("u", "undo"),
|
||||
),
|
||||
Redo: key.NewBinding(
|
||||
key.WithKeys("ctrl+r", "U"),
|
||||
key.WithHelp("U", "redo"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("q", "ctrl+c"),
|
||||
key.WithHelp("q", "quit"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "confirm"),
|
||||
),
|
||||
Escape: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "cancel"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k KeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Delete, k.Undo, k.Redo, k.Quit}
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
func (k KeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{k.ShortHelp()}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"task/internal/config"
|
||||
)
|
||||
|
||||
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()))
|
||||
count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount()))
|
||||
|
||||
parts := []string{brand, project, board, count}
|
||||
if m.filter != "" {
|
||||
parts = append(parts, m.styles.FilterHint.Render(fmt.Sprintf("filter: %q", m.filter)))
|
||||
}
|
||||
if m.status != "" {
|
||||
style := m.styles.StatusMsg
|
||||
if m.statusIsError {
|
||||
style = m.styles.ErrorMsg
|
||||
}
|
||||
parts = append(parts, style.Render(m.status))
|
||||
}
|
||||
|
||||
line := strings.Join(parts, m.styles.HeaderMeta.Render(" │ "))
|
||||
return m.styles.Header.Width(m.width).Render(line)
|
||||
}
|
||||
|
||||
func (m Model) renderBody(height int) string {
|
||||
if height < 1 {
|
||||
height = 1
|
||||
}
|
||||
|
||||
switch m.mode {
|
||||
case ModeCreate:
|
||||
label := "New ticket:"
|
||||
if m.creatingProject {
|
||||
label = "New project:"
|
||||
}
|
||||
prompt := m.styles.InputPrompt.Render(label)
|
||||
content := prompt + "\n" + m.input.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
||||
|
||||
case ModeSearch:
|
||||
prompt := m.styles.InputPrompt.Render("Search:")
|
||||
content := prompt + "\n" + m.input.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
||||
|
||||
case ModeProjects:
|
||||
m.projectList.SetSize(m.width, height)
|
||||
return m.projectList.View()
|
||||
|
||||
case ModeStatus:
|
||||
menu := m.statusMenu.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||
|
||||
case ModeBoard:
|
||||
menu := m.boardMenu.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||
|
||||
default:
|
||||
m.tickets.SetSize(m.width, height)
|
||||
return m.tickets.View()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package ui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Styles holds Lipgloss styles for the TUI.
|
||||
type Styles struct {
|
||||
Header lipgloss.Style
|
||||
HeaderBrand lipgloss.Style
|
||||
HeaderMeta lipgloss.Style
|
||||
HelpKey lipgloss.Style
|
||||
HelpDesc lipgloss.Style
|
||||
HelpSep lipgloss.Style
|
||||
Version lipgloss.Style
|
||||
StatusTodo lipgloss.Style
|
||||
StatusWIP lipgloss.Style
|
||||
StatusDone lipgloss.Style
|
||||
StatusMsg lipgloss.Style
|
||||
ErrorMsg lipgloss.Style
|
||||
Selected lipgloss.Style
|
||||
Normal lipgloss.Style
|
||||
InputPrompt lipgloss.Style
|
||||
FilterHint lipgloss.Style
|
||||
Card lipgloss.Style
|
||||
CardSelected lipgloss.Style
|
||||
Headline lipgloss.Style
|
||||
StatusMenuBox lipgloss.Style
|
||||
StatusMenuTitle lipgloss.Style
|
||||
StatusMenuSelected lipgloss.Style
|
||||
StatusMenuNormal lipgloss.Style
|
||||
}
|
||||
|
||||
// DefaultStyles returns the neutral terminal theme.
|
||||
func DefaultStyles() Styles {
|
||||
accent := lipgloss.Color("39")
|
||||
faint := lipgloss.Color("245")
|
||||
green := lipgloss.Color("42")
|
||||
yellow := lipgloss.Color("214")
|
||||
muted := lipgloss.Color("240")
|
||||
border := lipgloss.Color("238")
|
||||
borderSel := lipgloss.Color("39")
|
||||
|
||||
return Styles{
|
||||
Header: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Padding(0, 1),
|
||||
HeaderBrand: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(accent),
|
||||
HeaderMeta: lipgloss.NewStyle().
|
||||
Foreground(faint),
|
||||
HelpKey: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(accent),
|
||||
HelpDesc: lipgloss.NewStyle().
|
||||
Foreground(faint),
|
||||
HelpSep: lipgloss.NewStyle().
|
||||
Foreground(muted).
|
||||
SetString(" "),
|
||||
Version: lipgloss.NewStyle().
|
||||
Faint(true).
|
||||
Foreground(muted),
|
||||
StatusTodo: lipgloss.NewStyle().
|
||||
Foreground(faint).
|
||||
Background(lipgloss.Color("236")),
|
||||
StatusWIP: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("0")).
|
||||
Background(yellow).
|
||||
Bold(true),
|
||||
StatusDone: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("0")).
|
||||
Background(green),
|
||||
StatusMsg: lipgloss.NewStyle().
|
||||
Foreground(faint),
|
||||
ErrorMsg: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")),
|
||||
Selected: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("230")).
|
||||
Background(accent).
|
||||
Bold(true),
|
||||
Normal: lipgloss.NewStyle(),
|
||||
InputPrompt: lipgloss.NewStyle().
|
||||
Foreground(accent).
|
||||
Bold(true),
|
||||
FilterHint: lipgloss.NewStyle().
|
||||
Foreground(yellow),
|
||||
// Left accent only — full box borders stacked a blank line between tickets.
|
||||
Card: lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderLeft(true).
|
||||
BorderTop(false).
|
||||
BorderRight(false).
|
||||
BorderBottom(false).
|
||||
BorderForeground(border).
|
||||
PaddingLeft(1),
|
||||
CardSelected: lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
BorderTop(false).
|
||||
BorderRight(false).
|
||||
BorderBottom(false).
|
||||
BorderForeground(borderSel).
|
||||
PaddingLeft(1),
|
||||
Headline: lipgloss.NewStyle().
|
||||
Bold(true),
|
||||
StatusMenuBox: lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(accent).
|
||||
Padding(1, 2),
|
||||
StatusMenuTitle: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(accent).
|
||||
MarginBottom(1),
|
||||
StatusMenuSelected: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("230")).
|
||||
Background(accent).
|
||||
Bold(true),
|
||||
StatusMenuNormal: lipgloss.NewStyle().
|
||||
Foreground(faint),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// BoardMenu renders a board-list picker overlay.
|
||||
type BoardMenu struct {
|
||||
Cursor int
|
||||
Width int
|
||||
Styles StatusMenuStyles
|
||||
}
|
||||
|
||||
// NewBoardMenu creates a menu with the given board list pre-selected.
|
||||
func NewBoardMenu(current ticket.BoardList, width int, styles StatusMenuStyles) BoardMenu {
|
||||
return BoardMenu{
|
||||
Cursor: current.Index(),
|
||||
Width: width,
|
||||
Styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
// MoveDown moves the selection down.
|
||||
func (m *BoardMenu) MoveDown() {
|
||||
if m.Cursor < len(ticket.AllBoardLists)-1 {
|
||||
m.Cursor++
|
||||
}
|
||||
}
|
||||
|
||||
// MoveUp moves the selection up.
|
||||
func (m *BoardMenu) MoveUp() {
|
||||
if m.Cursor > 0 {
|
||||
m.Cursor--
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
return ticket.AllBoardLists[m.Cursor]
|
||||
}
|
||||
|
||||
// View renders the board menu.
|
||||
func (m BoardMenu) View() string {
|
||||
var lines []string
|
||||
lines = append(lines, m.Styles.Title.Render("Switch list"))
|
||||
for i, bl := range ticket.AllBoardLists {
|
||||
label := " " + bl.Label()
|
||||
if i == m.Cursor {
|
||||
label = m.Styles.Selected.Render("▸ " + bl.Label())
|
||||
} else {
|
||||
label = m.Styles.Normal.Render(label)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
)
|
||||
|
||||
// NewCreateInput returns a text input configured for creating a ticket.
|
||||
func NewCreateInput() textinput.Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Ticket headline…"
|
||||
ti.CharLimit = 200
|
||||
ti.Width = 60
|
||||
return ti
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glamour/styles"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
type mdCacheKey struct {
|
||||
width int
|
||||
src string
|
||||
}
|
||||
|
||||
var (
|
||||
mdMu sync.Mutex
|
||||
mdRenderer *glamour.TermRenderer
|
||||
mdWidth int
|
||||
mdCache = map[mdCacheKey]string{}
|
||||
)
|
||||
|
||||
// RenderMarkdown renders Markdown to ANSI using a cached glamour renderer.
|
||||
//
|
||||
// Important: we intentionally use a fixed dark style — NOT WithAutoStyle().
|
||||
// AutoStyle calls termenv.HasDarkBackground(), which sends an OSC query to the
|
||||
// terminal and waits up to ~5s for a reply. In Cursor/some PTYs that reply
|
||||
// never arrives, so the TUI appears to hang on startup (first selected ticket
|
||||
// with a description triggers the renderer in ensureVisible/View).
|
||||
func RenderMarkdown(src string, width int) string {
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
if width < 20 {
|
||||
width = 20
|
||||
}
|
||||
|
||||
key := mdCacheKey{width: width, src: src}
|
||||
mdMu.Lock()
|
||||
defer mdMu.Unlock()
|
||||
|
||||
if out, ok := mdCache[key]; ok {
|
||||
return out
|
||||
}
|
||||
|
||||
r, err := getRenderer(width)
|
||||
if err != nil {
|
||||
return src
|
||||
}
|
||||
out, err := r.Render(src)
|
||||
if err != nil {
|
||||
return src
|
||||
}
|
||||
for len(out) > 0 && (out[len(out)-1] == '\n' || out[len(out)-1] == ' ') {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
if len(mdCache) > 128 {
|
||||
mdCache = map[mdCacheKey]string{}
|
||||
}
|
||||
mdCache[key] = out
|
||||
return out
|
||||
}
|
||||
|
||||
func getRenderer(width int) (*glamour.TermRenderer, error) {
|
||||
if mdRenderer != nil && mdWidth == width {
|
||||
return mdRenderer, nil
|
||||
}
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(styles.DarkStyleConfig),
|
||||
glamour.WithWordWrap(width),
|
||||
glamour.WithColorProfile(termenv.ANSI256),
|
||||
glamour.WithEmoji(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mdRenderer = r
|
||||
mdWidth = width
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ClearMarkdownCache drops cached renders (e.g. after theme change).
|
||||
func ClearMarkdownCache() {
|
||||
mdMu.Lock()
|
||||
defer mdMu.Unlock()
|
||||
mdCache = map[mdCacheKey]string{}
|
||||
mdRenderer = nil
|
||||
mdWidth = 0
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package views_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/ui/views"
|
||||
)
|
||||
|
||||
func TestRenderMarkdownFastWithoutAutoStyle(t *testing.T) {
|
||||
src := "## Hello\n\nSome **bold** text\n\n- a\n- b\n"
|
||||
start := time.Now()
|
||||
out := views.RenderMarkdown(src, 80)
|
||||
elapsed := time.Since(start)
|
||||
if elapsed > 2*time.Second {
|
||||
t.Fatalf("RenderMarkdown took %v — WithAutoStyle/OSC hang likely still present", elapsed)
|
||||
}
|
||||
if out == "" || out == src {
|
||||
// glamour should style bold; at least produce non-empty output
|
||||
if strings.TrimSpace(out) == "" {
|
||||
t.Fatal("empty output")
|
||||
}
|
||||
}
|
||||
// cached path should be near-instant
|
||||
start = time.Now()
|
||||
_ = views.RenderMarkdown(src, 80)
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("cached render too slow: %v", time.Since(start))
|
||||
}
|
||||
t.Logf("first=%v cached=%v", elapsed, time.Since(start))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
|
||||
"task/internal/project"
|
||||
)
|
||||
|
||||
// ProjectItem wraps a project for bubbles/list.
|
||||
type ProjectItem struct {
|
||||
Project project.Project
|
||||
Active bool
|
||||
}
|
||||
|
||||
func (i ProjectItem) Title() string {
|
||||
if i.Active {
|
||||
return i.Project.Name + " *"
|
||||
}
|
||||
return i.Project.Name
|
||||
}
|
||||
func (i ProjectItem) Description() string { return i.Project.ID }
|
||||
func (i ProjectItem) FilterValue() string { return i.Project.Name }
|
||||
|
||||
// NewProjectList builds a list of projects.
|
||||
func NewProjectList(projects []project.Project, activeID string, width, height int) list.Model {
|
||||
items := make([]list.Item, len(projects))
|
||||
selected := 0
|
||||
for i, p := range projects {
|
||||
items[i] = ProjectItem{Project: p, Active: p.ID == activeID}
|
||||
if p.ID == activeID {
|
||||
selected = i
|
||||
}
|
||||
}
|
||||
|
||||
delegate := list.NewDefaultDelegate()
|
||||
delegate.ShowDescription = false
|
||||
l := list.New(items, delegate, width, height)
|
||||
l.Title = "Projects"
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetShowHelp(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
l.DisableQuitKeybindings()
|
||||
if len(items) > 0 {
|
||||
l.Select(selected)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// ProjectsToItems converts projects to list items.
|
||||
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
|
||||
items := make([]list.Item, len(projects))
|
||||
for i, p := range projects {
|
||||
items[i] = ProjectItem{Project: p, Active: p.ID == activeID}
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
)
|
||||
|
||||
// NewSearchInput returns a text input configured for search/filter.
|
||||
func NewSearchInput() textinput.Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Filter tasks…"
|
||||
ti.CharLimit = 100
|
||||
ti.Width = 60
|
||||
return ti
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// StatusMenu renders a status picker overlay.
|
||||
type StatusMenu struct {
|
||||
Cursor int
|
||||
Width int
|
||||
Styles StatusMenuStyles
|
||||
}
|
||||
|
||||
// StatusMenuStyles holds styles for the status picker.
|
||||
type StatusMenuStyles struct {
|
||||
Box lipgloss.Style
|
||||
Title lipgloss.Style
|
||||
Selected lipgloss.Style
|
||||
Normal lipgloss.Style
|
||||
}
|
||||
|
||||
// NewStatusMenu creates a menu with the given status pre-selected.
|
||||
func NewStatusMenu(current ticket.Status, width int, styles StatusMenuStyles) StatusMenu {
|
||||
return StatusMenu{
|
||||
Cursor: current.Index(),
|
||||
Width: width,
|
||||
Styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
// MoveDown moves the selection down.
|
||||
func (m *StatusMenu) MoveDown() {
|
||||
if m.Cursor < len(ticket.AllStatuses)-1 {
|
||||
m.Cursor++
|
||||
}
|
||||
}
|
||||
|
||||
// MoveUp moves the selection up.
|
||||
func (m *StatusMenu) MoveUp() {
|
||||
if m.Cursor > 0 {
|
||||
m.Cursor--
|
||||
}
|
||||
}
|
||||
|
||||
// Selected returns the currently highlighted status.
|
||||
func (m StatusMenu) Selected() ticket.Status {
|
||||
if m.Cursor < 0 || m.Cursor >= len(ticket.AllStatuses) {
|
||||
return ticket.StatusTodo
|
||||
}
|
||||
return ticket.AllStatuses[m.Cursor]
|
||||
}
|
||||
|
||||
// View renders the status menu.
|
||||
func (m StatusMenu) View() string {
|
||||
var lines []string
|
||||
lines = append(lines, m.Styles.Title.Render("Set status"))
|
||||
for i, st := range ticket.AllStatuses {
|
||||
label := " " + st.Label()
|
||||
if i == m.Cursor {
|
||||
label = m.Styles.Selected.Render("▸ " + st.Label())
|
||||
} else {
|
||||
label = m.Styles.Normal.Render(label)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// CollapsedCardHeight is the fixed row height for non-expanded cards
|
||||
// (single content line; left accent border adds no vertical space).
|
||||
const CollapsedCardHeight = 1
|
||||
|
||||
// TicketListStyles holds styles for card rendering.
|
||||
type TicketListStyles struct {
|
||||
Card lipgloss.Style
|
||||
CardSelected lipgloss.Style
|
||||
StatusOPEN lipgloss.Style
|
||||
StatusWIP lipgloss.Style
|
||||
StatusDONE lipgloss.Style
|
||||
Headline lipgloss.Style
|
||||
}
|
||||
|
||||
// TicketList is a custom viewport list of ticket cards.
|
||||
type TicketList struct {
|
||||
Items []ticket.Ticket
|
||||
Cursor int
|
||||
Offset int
|
||||
Width int
|
||||
Height int
|
||||
Styles TicketListStyles
|
||||
}
|
||||
|
||||
// SetSize updates the viewport dimensions.
|
||||
func (l *TicketList) SetSize(width, height int) {
|
||||
l.Width = width
|
||||
l.Height = height
|
||||
l.ensureVisible()
|
||||
}
|
||||
|
||||
// SetItems replaces the ticket list, clamping the cursor.
|
||||
func (l *TicketList) SetItems(items []ticket.Ticket) {
|
||||
l.Items = items
|
||||
if l.Cursor >= len(l.Items) {
|
||||
l.Cursor = max(0, len(l.Items)-1)
|
||||
}
|
||||
l.Offset = 0
|
||||
l.ensureVisible()
|
||||
}
|
||||
|
||||
// Selected returns the currently selected ticket and whether one exists.
|
||||
func (l *TicketList) Selected() (ticket.Ticket, bool) {
|
||||
if len(l.Items) == 0 || l.Cursor < 0 || l.Cursor >= len(l.Items) {
|
||||
return ticket.Ticket{}, false
|
||||
}
|
||||
return l.Items[l.Cursor], true
|
||||
}
|
||||
|
||||
// CursorIndex returns the current cursor.
|
||||
func (l *TicketList) CursorIndex() int { return l.Cursor }
|
||||
|
||||
// Len returns the number of tickets.
|
||||
func (l *TicketList) Len() int { return len(l.Items) }
|
||||
|
||||
// MoveDown moves the cursor down.
|
||||
func (l *TicketList) MoveDown() {
|
||||
if l.Cursor < len(l.Items)-1 {
|
||||
l.Cursor++
|
||||
l.ensureVisible()
|
||||
}
|
||||
}
|
||||
|
||||
// MoveUp moves the cursor up.
|
||||
func (l *TicketList) MoveUp() {
|
||||
if l.Cursor > 0 {
|
||||
l.Cursor--
|
||||
l.ensureVisible()
|
||||
}
|
||||
}
|
||||
|
||||
// Select sets the cursor to idx if in range.
|
||||
func (l *TicketList) Select(idx int) {
|
||||
if idx < 0 || idx >= len(l.Items) {
|
||||
return
|
||||
}
|
||||
l.Cursor = idx
|
||||
l.ensureVisible()
|
||||
}
|
||||
|
||||
// View renders only the visible ticket cards.
|
||||
func (l *TicketList) View() string {
|
||||
if l.Height < 1 {
|
||||
return ""
|
||||
}
|
||||
if len(l.Items) == 0 {
|
||||
empty := lipgloss.NewStyle().Faint(true).Render("No tickets — press n to create one")
|
||||
return lipgloss.NewStyle().Width(l.Width).Height(l.Height).Render(empty)
|
||||
}
|
||||
|
||||
var visible []string
|
||||
used := 0
|
||||
for i := l.Offset; i < len(l.Items); i++ {
|
||||
card := l.renderCard(l.Items[i], i == l.Cursor)
|
||||
h := lipgloss.Height(card)
|
||||
if used > 0 && used+h > l.Height {
|
||||
break
|
||||
}
|
||||
visible = append(visible, card)
|
||||
used += h
|
||||
}
|
||||
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, visible...)
|
||||
return lipgloss.NewStyle().Width(l.Width).Height(l.Height).MaxHeight(l.Height).Render(body)
|
||||
}
|
||||
|
||||
func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
|
||||
innerWidth := max(10, l.Width-4)
|
||||
|
||||
badge := l.statusBadge(t.Status)
|
||||
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-2))
|
||||
headerLine := badge + " " + l.Styles.Headline.Render(headline)
|
||||
|
||||
var content string
|
||||
if selected {
|
||||
// Expanded: headline + glamour-rendered description (fixed dark style).
|
||||
desc := strings.TrimSpace(t.Description)
|
||||
if desc != "" {
|
||||
content = headerLine + "\n" + RenderMarkdown(desc, innerWidth)
|
||||
} else {
|
||||
content = headerLine
|
||||
}
|
||||
} else {
|
||||
content = headerLine
|
||||
}
|
||||
|
||||
style := l.Styles.Card
|
||||
if selected {
|
||||
style = l.Styles.CardSelected
|
||||
}
|
||||
return style.Width(max(4, l.Width-2)).Render(content)
|
||||
}
|
||||
|
||||
func (l *TicketList) statusBadge(s ticket.Status) string {
|
||||
label := fmt.Sprintf(" %s ", s.Label())
|
||||
switch s {
|
||||
case ticket.StatusInProgress:
|
||||
return l.Styles.StatusWIP.Render(label)
|
||||
case ticket.StatusDone:
|
||||
return l.Styles.StatusDONE.Render(label)
|
||||
default:
|
||||
return l.Styles.StatusOPEN.Render(label)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *TicketList) ensureVisible() {
|
||||
if len(l.Items) == 0 {
|
||||
l.Offset = 0
|
||||
return
|
||||
}
|
||||
if l.Cursor < l.Offset {
|
||||
l.Offset = l.Cursor
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
used := 0
|
||||
fits := false
|
||||
for i := l.Offset; i < len(l.Items); i++ {
|
||||
h := l.estimateHeight(i)
|
||||
if used > 0 && used+h > l.Height {
|
||||
break
|
||||
}
|
||||
used += h
|
||||
if i == l.Cursor {
|
||||
fits = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if fits || l.Offset >= l.Cursor {
|
||||
break
|
||||
}
|
||||
l.Offset++
|
||||
}
|
||||
}
|
||||
|
||||
// estimateHeight avoids full glamour for collapsed cards.
|
||||
func (l *TicketList) estimateHeight(index int) int {
|
||||
if index < 0 || index >= len(l.Items) {
|
||||
return CollapsedCardHeight
|
||||
}
|
||||
if index == l.Cursor && strings.TrimSpace(l.Items[index].Description) != "" {
|
||||
return lipgloss.Height(l.renderCard(l.Items[index], true))
|
||||
}
|
||||
return CollapsedCardHeight
|
||||
}
|
||||
|
||||
// FilterTickets returns tickets matching query in title or description.
|
||||
func FilterTickets(tickets []ticket.Ticket, query string) []ticket.Ticket {
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
if q == "" {
|
||||
return tickets
|
||||
}
|
||||
var out []ticket.Ticket
|
||||
for _, t := range tickets {
|
||||
if strings.Contains(strings.ToLower(t.Title), q) ||
|
||||
strings.Contains(strings.ToLower(t.Description), q) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func plainHeadline(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
title = strings.TrimLeft(title, "#")
|
||||
return strings.TrimSpace(title)
|
||||
}
|
||||
|
||||
func truncateOneLine(s string, maxWidth int) string {
|
||||
if maxWidth <= 0 {
|
||||
return ""
|
||||
}
|
||||
if lipgloss.Width(s) <= maxWidth {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
for len(runes) > 0 {
|
||||
runes = runes[:len(runes)-1]
|
||||
if lipgloss.Width(string(runes)) <= maxWidth-1 {
|
||||
return string(runes) + "…"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user