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 }