package render import ( "fmt" "strings" "time" "golang.org/x/text/width" "git.pablomurad.com/pablo/patchwork/internal/diff" ) // TextOptions shape terminal output. type TextOptions struct { Width int // columns; 48..160 Color bool // ANSI colors } func (o TextOptions) normalized() TextOptions { switch { case o.Width <= 0: o.Width = 80 case o.Width < 48: o.Width = 48 case o.Width > 160: o.Width = 160 } return o } type style int const ( plain style = iota dim bold red // deleted words inside a changed block green // inserted words inside a changed block // Whole deleted or inserted blocks: colored, but in plain output the // line's own "-" or "+" already says it, so no brackets. redBlock greenBlock ) var ansi = map[style]string{ dim: "\x1b[2m", bold: "\x1b[1m", red: "\x1b[31m", green: "\x1b[32m", redBlock: "\x1b[31m", greenBlock: "\x1b[32m", } type run struct { s string st style } // textWriter accumulates lines of styled text. type textWriter struct { o TextOptions lines []string } func (w *textWriter) paint(st style, s string) string { if !w.o.Color || st == plain || s == "" { return s } return ansi[st] + s + "\x1b[0m" } func (w *textWriter) blank() { w.lines = append(w.lines, "") } func (w *textWriter) line(st style, s string) { w.lines = append(w.lines, w.paint(st, s)) } // para word-wraps runs into lines of at most o.Width columns. The first line // starts with indent (which may carry a marker like " + "); the rest are // indented to the same width with spaces. Without colors, deleted words are // marked [-like this-] and inserted words {+like this+}, so the diff // survives a pipe or a file. func (w *textWriter) para(indent string, runs ...run) { first, rest := indent, strings.Repeat(" ", visibleWidth(indent)) if !w.o.Color { for i, r := range runs { switch r.st { case red: runs[i] = run{"[-" + r.s + "-]", plain} case green: runs[i] = run{"{+" + r.s + "+}", plain} } } } max := w.o.Width - visibleWidth(indent) var line strings.Builder col := 0 flush := func() { w.lines = append(w.lines, strings.TrimRight(first+line.String(), " ")) first = rest line.Reset() col = 0 } for _, r := range runs { for i, piece := range strings.Split(r.s, "\n") { if i > 0 { flush() } for _, word := range splitKeepSpace(piece) { n := visibleWidth(word) if strings.TrimSpace(word) == "" { if col > 0 && col+n <= max { line.WriteString(word) col += n } continue } if col > 0 && col+n > max { flush() } line.WriteString(w.paint(r.st, word)) col += n } } } if col > 0 || len(runs) > 0 { flush() } } func (w *textWriter) String() string { return strings.Join(w.lines, "\n") + "\n" } // splitKeepSpace splits into words and the spaces between them. func splitKeepSpace(s string) []string { var out []string start := 0 for i := 1; i <= len(s); i++ { if i == len(s) || (s[i] == ' ') != (s[i-1] == ' ') { out = append(out, s[start:i]) start = i } } return out } // visibleWidth counts terminal columns: East Asian wide and fullwidth runes // take two. func visibleWidth(s string) int { n := 0 for _, r := range s { switch width.LookupRune(r).Kind() { case width.EastAsianWide, width.EastAsianFullwidth: n += 2 default: n++ } } return n } func (w *textWriter) rule() { w.line(dim, strings.Repeat("─", w.o.Width)) } func (w *textWriter) ops(ops []diff.Op) { for _, op := range ops { switch op.Op { case diff.OpGap: if op.Removed { w.line(redBlock, fmt.Sprintf(" - … %d more removed", op.Count)) } else { w.line(dim, fmt.Sprintf(" … %d unchanged", op.Count)) } case diff.OpEq: w.para(" ", run{op.Text, dim}) case diff.OpInsert: w.para(" + ", run{op.Text, greenBlock}) case diff.OpDelete: w.para(" - ", run{op.Text, redBlock}) case diff.OpMove: w.line(dim, " (moved)") w.para(" ", run{op.Text, plain}) case diff.OpModify: if op.Replaced { for _, s := range op.Segments { if s.T == diff.SegDel { w.para(" - ", run{s.S, redBlock}) } else { w.para(" + ", run{s.S, greenBlock}) } } break } runs := make([]run, 0, len(op.Segments)) for i, s := range op.Segments { st := plain switch s.T { case diff.SegDel: st = red case diff.SegIns: st = green // In color, removed words right before inserted ones // would read as a single word. if w.o.Color && i > 0 && op.Segments[i-1].T == diff.SegDel { runs = append(runs, run{" ", plain}) } } runs = append(runs, run{s.S, st}) } w.para(" ~ ", runs...) } w.blank() } } func (w *textWriter) revision(r Revision, m *Model) { when := humanDate(r.published, m.Cfg.Location()) w.para("", run{EntryTitle(r), bold}) facts := []string{when, fmt.Sprintf("version %d", r.Number), fmt.Sprintf("+%d -%d words", r.Stats.WordsAdded, r.Stats.WordsRemoved)} if r.Maturity != "" { facts = append(facts, r.Maturity) } w.para("", run{strings.Join(facts, " · "), dim}) if r.Note != "" { w.blank() w.para(" ", run{"“" + r.Note + "”", plain}) } w.blank() w.ops(r.Excerpt.Blocks) if r.Excerpt.Truncated { w.line(dim, " … more changes in the full version") w.blank() } w.para("", run{r.VersionURL, dim}) } // TextRevisions is the recent revisions across the site. func (m *Model) TextRevisions(revs []Revision, o TextOptions) string { w := &textWriter{o: o.normalized()} title := m.Cfg.Site.Title if title == "" { title = "Patchwork" } w.para("", run{title + ": revisions", bold}) w.para("", run{"What changed in pages that keep growing.", dim}) w.rule() if len(revs) == 0 { w.blank() w.line(plain, "No revisions yet.") return w.String() } for i, r := range revs { if i > 0 { w.rule() } w.blank() w.revision(r, m) } return w.String() } // TextHistory lists one page's public versions. func (m *Model) TextHistory(slug string, o TextOptions) string { w := &textWriter{o: o.normalized()} p, ok := m.Page(slug) if !ok { w.line(plain, "No page "+slug+".") return w.String() } w.para("", run{p.Title, bold}) facts := []string{"first published " + p.OriginalDate, fmt.Sprintf("%d revisions", p.RevisionCount)} if p.Maturity != "" { facts = append(facts, p.Maturity) } w.para("", run{strings.Join(facts, " · "), dim}) w.rule() for _, v := range p.Versions { w.blank() label := fmt.Sprintf("v%d", v.Number) if v.Baseline { w.para("", run{label + " first version", bold}) } else { kind := kindLabel[v.Kind] if kind == "" { kind = "Revised" } w.para("", run{label + " " + kind, bold}, run{fmt.Sprintf(" +%d -%d", v.WordsAdded, v.WordsRemoved), dim}) if v.Note != "" { w.para(" ", run{"“" + v.Note + "”", plain}) } } if t, err := time.Parse(time.RFC3339, v.PublishedAt); err == nil { w.para(" ", run{humanDate(t, m.Cfg.Location()), dim}) } } return w.String() } // TextVersion shows one version's diff against the previous public version. func (m *Model) TextVersion(slug string, n int, o TextOptions) (string, bool) { w := &textWriter{o: o.normalized()} for _, v := range m.Versions[slug] { if v.Number != n { continue } w.para("", run{fmt.Sprintf("%s · version %d of %d", v.Title, v.Number, v.LatestNumber), bold}) if v.Note != "" { w.para(" ", run{"“" + v.Note + "”", plain}) } w.rule() w.blank() if v.Diff == nil { w.para("", run{v.BodyMarkdown, plain}) return w.String(), true } for _, mc := range v.Diff.Meta { w.para(" ", run{mc.Key + ": ", dim}, run{metaText(mc.Old), red}, run{" → ", dim}, run{metaText(mc.New), green}) } ops, _ := diff.Excerpt(v.Diff.Ops, 1, len(v.Diff.Ops)) w.ops(ops) return w.String(), true } return "", false } func humanDate(t time.Time, loc *time.Location) string { return t.In(loc).Format("Jan 2, 2006 15:04") }