Add interactive Markdown checklist toggles in the ticket preview.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glamour/styles"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
type mdCacheKey struct {
|
||||
@@ -79,6 +83,78 @@ func getRenderer(width int) (*glamour.TermRenderer, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// RenderDescription renders ticket Markdown with interactive task-list items.
|
||||
// checklistCursor is the 0-based index among checkboxes (highlight); ignored when
|
||||
// there are none.
|
||||
func RenderDescription(src string, width int, checklistCursor int) string {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return ""
|
||||
}
|
||||
boxes := ticket.ListCheckboxes(src)
|
||||
if len(boxes) == 0 {
|
||||
return RenderMarkdown(src, width)
|
||||
}
|
||||
|
||||
boxByLine := make(map[int]int, len(boxes))
|
||||
for i, b := range boxes {
|
||||
boxByLine[b.Line] = i
|
||||
}
|
||||
|
||||
lines := strings.Split(src, "\n")
|
||||
var parts []string
|
||||
var mdBuf []string
|
||||
flushMD := func() {
|
||||
if len(mdBuf) == 0 {
|
||||
return
|
||||
}
|
||||
block := strings.Join(mdBuf, "\n")
|
||||
if strings.TrimSpace(block) != "" {
|
||||
parts = append(parts, RenderMarkdown(block, width))
|
||||
}
|
||||
mdBuf = nil
|
||||
}
|
||||
|
||||
for i, line := range lines {
|
||||
if bi, ok := boxByLine[i]; ok {
|
||||
flushMD()
|
||||
parts = append(parts, renderCheckboxLine(boxes[bi], bi == checklistCursor, width))
|
||||
continue
|
||||
}
|
||||
mdBuf = append(mdBuf, line)
|
||||
}
|
||||
flushMD()
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
}
|
||||
|
||||
func renderCheckboxLine(box ticket.Checkbox, selected bool, width int) string {
|
||||
// ASCII markers — widely supported; bold + padding reads a notch bigger.
|
||||
mark := "[ ]"
|
||||
if box.Checked {
|
||||
mark = "[x]"
|
||||
}
|
||||
prefix := " "
|
||||
if selected {
|
||||
prefix = "> "
|
||||
}
|
||||
label := strings.TrimSpace(box.Text)
|
||||
avail := max(10, width) - lipgloss.Width(prefix+mark+" ")
|
||||
label = truncateOneLine(label, avail)
|
||||
|
||||
markStyle := lipgloss.NewStyle().Bold(true)
|
||||
labelStyle := lipgloss.NewStyle()
|
||||
if selected {
|
||||
markStyle = markStyle.Foreground(lipgloss.Color("39"))
|
||||
labelStyle = labelStyle.Bold(true).Foreground(lipgloss.Color("39"))
|
||||
} else if box.Checked {
|
||||
labelStyle = labelStyle.Faint(true)
|
||||
}
|
||||
line := prefix + markStyle.Render(mark) + " " + labelStyle.Render(label)
|
||||
return lipgloss.NewStyle().Width(max(10, width)).Render(line)
|
||||
}
|
||||
|
||||
// ClearMarkdownCache drops cached renders (e.g. after theme change).
|
||||
func ClearMarkdownCache() {
|
||||
mdMu.Lock()
|
||||
|
||||
@@ -30,3 +30,14 @@ func TestRenderMarkdownFastWithoutAutoStyle(t *testing.T) {
|
||||
}
|
||||
t.Logf("first=%v cached=%v", elapsed, time.Since(start))
|
||||
}
|
||||
|
||||
func TestRenderDescriptionHighlightsCheckbox(t *testing.T) {
|
||||
src := "Intro\n\n- [ ] one\n- [x] two\n"
|
||||
out := views.RenderDescription(src, 40, 0)
|
||||
if !strings.Contains(out, "one") || !strings.Contains(out, "[ ]") {
|
||||
t.Fatalf("expected unchecked item in output: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "two") || !strings.Contains(out, "[x]") {
|
||||
t.Fatalf("expected checked item in output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,14 @@ type TicketListStyles struct {
|
||||
|
||||
// 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
|
||||
Items []ticket.Ticket
|
||||
Cursor int
|
||||
Offset int
|
||||
Width int
|
||||
Height int
|
||||
Styles TicketListStyles
|
||||
SpentByID map[string]time.Duration // optional; shown on selected card
|
||||
ChecklistCursor int // index into checkboxes of the selected ticket
|
||||
}
|
||||
|
||||
// SetSize updates the viewport dimensions.
|
||||
@@ -49,6 +50,7 @@ func (l *TicketList) SetItems(items []ticket.Ticket) {
|
||||
l.Cursor = max(0, len(l.Items)-1)
|
||||
}
|
||||
l.Offset = 0
|
||||
l.clampChecklistCursor()
|
||||
l.ensureVisible()
|
||||
}
|
||||
|
||||
@@ -70,6 +72,7 @@ func (l *TicketList) Len() int { return len(l.Items) }
|
||||
func (l *TicketList) MoveDown() {
|
||||
if l.Cursor < len(l.Items)-1 {
|
||||
l.Cursor++
|
||||
l.ChecklistCursor = 0
|
||||
l.ensureVisible()
|
||||
}
|
||||
}
|
||||
@@ -78,6 +81,7 @@ func (l *TicketList) MoveDown() {
|
||||
func (l *TicketList) MoveUp() {
|
||||
if l.Cursor > 0 {
|
||||
l.Cursor--
|
||||
l.ChecklistCursor = 0
|
||||
l.ensureVisible()
|
||||
}
|
||||
}
|
||||
@@ -87,10 +91,61 @@ func (l *TicketList) Select(idx int) {
|
||||
if idx < 0 || idx >= len(l.Items) {
|
||||
return
|
||||
}
|
||||
if l.Cursor != idx {
|
||||
l.ChecklistCursor = 0
|
||||
}
|
||||
l.Cursor = idx
|
||||
l.clampChecklistCursor()
|
||||
l.ensureVisible()
|
||||
}
|
||||
|
||||
// ChecklistLen returns how many task-list items the selected ticket has.
|
||||
func (l *TicketList) ChecklistLen() int {
|
||||
t, ok := l.Selected()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return len(ticket.ListCheckboxes(t.Description))
|
||||
}
|
||||
|
||||
// MoveChecklistDown advances the checkbox cursor (wraps).
|
||||
func (l *TicketList) MoveChecklistDown() {
|
||||
n := l.ChecklistLen()
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
l.ChecklistCursor = (l.ChecklistCursor + 1) % n
|
||||
}
|
||||
|
||||
// MoveChecklistUp moves the checkbox cursor up (wraps).
|
||||
func (l *TicketList) MoveChecklistUp() {
|
||||
n := l.ChecklistLen()
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
l.ChecklistCursor = (l.ChecklistCursor - 1 + n) % n
|
||||
}
|
||||
|
||||
// SetChecklistCursor sets the checkbox highlight index (clamped).
|
||||
func (l *TicketList) SetChecklistCursor(idx int) {
|
||||
l.ChecklistCursor = idx
|
||||
l.clampChecklistCursor()
|
||||
}
|
||||
|
||||
func (l *TicketList) clampChecklistCursor() {
|
||||
n := l.ChecklistLen()
|
||||
if n == 0 {
|
||||
l.ChecklistCursor = 0
|
||||
return
|
||||
}
|
||||
if l.ChecklistCursor < 0 {
|
||||
l.ChecklistCursor = 0
|
||||
}
|
||||
if l.ChecklistCursor >= n {
|
||||
l.ChecklistCursor = n - 1
|
||||
}
|
||||
}
|
||||
|
||||
// View renders only the visible ticket cards.
|
||||
func (l *TicketList) View() string {
|
||||
if l.Height < 1 {
|
||||
@@ -126,16 +181,15 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
|
||||
|
||||
var content string
|
||||
if selected {
|
||||
// Expanded: headline + optional spent + glamour description.
|
||||
// Expanded: headline + optional spent + description (interactive checkboxes).
|
||||
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)
|
||||
if strings.TrimSpace(t.Description) != "" {
|
||||
content += "\n" + RenderDescription(t.Description, innerWidth, l.ChecklistCursor)
|
||||
}
|
||||
} else {
|
||||
content = headerLine
|
||||
|
||||
Reference in New Issue
Block a user