Files
task-master/internal/ui/views/tickets.go
T
SkrueckenandCursor bfc1cbd4d9 Add ticket time tracking with reports and entry management.
Support start/stop timer, manual durations, grouped reports, and per-ticket edit/delete of time entries with undo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 11:05:10 +02:00

253 lines
5.7 KiB
Go

package views
import (
"fmt"
"strings"
"time"
"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
SpentByID map[string]time.Duration // optional; shown on selected card
}
// 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 + optional spent + glamour description.
content = headerLine
if spent, ok := l.SpentByID[t.ID]; ok && spent > 0 {
content += "\n" + lipgloss.NewStyle().Faint(true).Render(
"spent: "+ticket.FormatDurationHuman(spent),
)
}
desc := strings.TrimSpace(t.Description)
if desc != "" {
content += "\n" + RenderMarkdown(desc, innerWidth)
}
} 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 {
t := l.Items[index]
if strings.TrimSpace(t.Description) != "" || (l.SpentByID != nil && l.SpentByID[t.ID] > 0) {
return lipgloss.Height(l.renderCard(t, 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
}