166 lines
3.9 KiB
Go
166 lines
3.9 KiB
Go
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
|
|
}
|