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 { 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 } // 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() defer mdMu.Unlock() mdCache = map[mdCacheKey]string{} mdRenderer = nil mdWidth = 0 }