mirror of
https://github.com/makeworld-the-better-one/amfora.git
synced 2024-11-22 15:46:51 +03:00
3823a46152
Fixes #280
459 lines
15 KiB
Go
459 lines
15 KiB
Go
// Package renderer provides functions to convert various data into a cview primitive.
|
|
// Example objects include a Gemini response, and an error.
|
|
//
|
|
// Rendered lines always end with \r\n, in an effort to be Window compatible.
|
|
package renderer
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
urlPkg "net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"code.rocketnine.space/tslocum/cview"
|
|
"github.com/alecthomas/chroma/formatters"
|
|
"github.com/alecthomas/chroma/lexers"
|
|
"github.com/alecthomas/chroma/styles"
|
|
"github.com/makeworld-the-better-one/amfora/config"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// Terminal color information, set during display initialization by display/display.go
|
|
var TermColor string
|
|
|
|
// Regex for identifying ANSI color codes
|
|
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
|
|
|
// Regex for identifying possible language string, based on RFC 6838 and lexers used by Chroma
|
|
var langRegex = regexp.MustCompile(`^([a-zA-Z0-9]+/)?[a-zA-Z0-9]+([a-zA-Z0-9!_\#\$\&\-\^\.\+]+)*`)
|
|
|
|
// Regex for removing trailing newline (without disturbing ANSI codes) from code formatted with Chroma
|
|
var trailingNewline = regexp.MustCompile(`(\r?\n)(?:\x1b\[[0-9;]*m)*$`)
|
|
|
|
// RenderANSI renders plain text pages containing ANSI codes.
|
|
// Practically, it is used for the text/x-ansi.
|
|
func RenderANSI(s string) string {
|
|
s = cview.Escape(s)
|
|
if viper.GetBool("a-general.color") && viper.GetBool("a-general.ansi") {
|
|
s = cview.TranslateANSI(s)
|
|
} else {
|
|
s = ansiRegex.ReplaceAllString(s, "")
|
|
}
|
|
return s
|
|
}
|
|
|
|
// RenderPlainText should be used to format plain text pages.
|
|
func RenderPlainText(s string) string {
|
|
// It used to add a left margin, now this is done elsewhere.
|
|
// The function is kept for convenience and in case rendering
|
|
// is needed in the future.
|
|
return cview.Escape(s)
|
|
}
|
|
|
|
// wrapLine wraps a line to the provided width, and adds the provided prefix and suffix to each wrapped line.
|
|
// It recovers from wrapping panics and should never cause a panic.
|
|
// It returns a slice of lines, without newlines at the end.
|
|
//
|
|
// Set includeFirst to true if the prefix and suffix should be applied to the first wrapped line as well
|
|
func wrapLine(line string, width int, prefix, suffix string, includeFirst bool) []string {
|
|
if width < 1 {
|
|
width = 1
|
|
}
|
|
|
|
// Anonymous function to allow recovery from potential WordWrap panic
|
|
var ret []string
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
// Use unwrapped line instead
|
|
if includeFirst {
|
|
ret = []string{prefix + line + suffix}
|
|
} else {
|
|
ret = []string{line}
|
|
}
|
|
}
|
|
}()
|
|
|
|
wrapped := cview.WordWrap(line, width)
|
|
for i := range wrapped {
|
|
if !includeFirst && i == 0 {
|
|
continue
|
|
}
|
|
wrapped[i] = prefix + wrapped[i] + suffix
|
|
}
|
|
ret = wrapped
|
|
}()
|
|
return ret
|
|
}
|
|
|
|
// convertRegularGemini converts non-preformatted blocks of text/gemini
|
|
// into a cview-compatible format.
|
|
// Since this only works on non-preformatted blocks, RenderGemini
|
|
// should always be used instead.
|
|
//
|
|
// It also returns a slice of link URLs.
|
|
// numLinks is the number of links that exist so far.
|
|
// width is the number of columns to wrap to.
|
|
//
|
|
//
|
|
// proxied is whether the request is through the gemini:// scheme.
|
|
// If it's not a gemini:// page, set this to true.
|
|
func convertRegularGemini(s string, numLinks, width int, proxied bool) (string, []string) {
|
|
links := make([]string, 0)
|
|
lines := strings.Split(s, "\n")
|
|
wrappedLines := make([]string, 0) // Final result
|
|
|
|
for i := range lines {
|
|
lines[i] = strings.TrimRight(lines[i], " \r\t\n")
|
|
|
|
if strings.HasPrefix(lines[i], "#") {
|
|
// Headings
|
|
var tag string
|
|
if viper.GetBool("a-general.color") {
|
|
if strings.HasPrefix(lines[i], "###") {
|
|
tag = fmt.Sprintf("[%s::b]", config.GetColorString("hdg_3"))
|
|
} else if strings.HasPrefix(lines[i], "##") {
|
|
tag = fmt.Sprintf("[%s::b]", config.GetColorString("hdg_2"))
|
|
} else if strings.HasPrefix(lines[i], "#") {
|
|
tag = fmt.Sprintf("[%s::b]", config.GetColorString("hdg_1"))
|
|
}
|
|
wrappedLines = append(wrappedLines, wrapLine(lines[i], width, tag, "[-::-]", true)...)
|
|
} else {
|
|
// Just bold, no colors
|
|
wrappedLines = append(wrappedLines, wrapLine(lines[i], width, "[::b]", "[-::-]", true)...)
|
|
}
|
|
|
|
// Links
|
|
} else if strings.HasPrefix(lines[i], "=>") && len([]rune(lines[i])) >= 3 {
|
|
// Trim whitespace and separate link from link text
|
|
|
|
lines[i] = strings.Trim(lines[i][2:], " \t") // Remove `=>` part too
|
|
delim := strings.IndexAny(lines[i], " \t") // Whitespace between link and link text
|
|
|
|
var url string
|
|
var linkText string
|
|
if delim == -1 {
|
|
// No link text
|
|
url = lines[i]
|
|
linkText = url
|
|
} else {
|
|
// There is link text
|
|
url = lines[i][:delim]
|
|
linkText = strings.Trim(lines[i][delim:], " \t")
|
|
if viper.GetBool("a-general.show_link") {
|
|
linkText += " (" + url + ")"
|
|
}
|
|
}
|
|
|
|
if strings.TrimSpace(lines[i]) == "" || strings.TrimSpace(url) == "" {
|
|
// Link was just whitespace, reset it and move on
|
|
lines[i] = "=>"
|
|
wrappedLines = append(wrappedLines, lines[i])
|
|
continue
|
|
}
|
|
|
|
links = append(links, url)
|
|
num := numLinks + len(links) // Visible link number, one-indexed
|
|
|
|
var indent int
|
|
if num > 99 {
|
|
// Indent link text by 3 or more spaces
|
|
indent = len(strconv.Itoa(num)) + 4 // +4 indent for spaces and brackets
|
|
} else {
|
|
// One digit and two digit links have the same spacing - see #60
|
|
indent = 5 // +4 indent for spaces and brackets, and 1 for link number
|
|
}
|
|
|
|
// Spacing after link number: 1 or 2 spaces?
|
|
var spacing string
|
|
if num > 9 {
|
|
// One space to keep it in line with other links - see #60
|
|
spacing = " "
|
|
} else {
|
|
// One digit numbers use two spaces
|
|
spacing = " "
|
|
}
|
|
|
|
// Underline non-gemini links if enabled
|
|
var linkTag string
|
|
if viper.GetBool("a-general.underline") {
|
|
linkTag = `[` + config.GetColorString("foreign_link") + `::u]`
|
|
} else {
|
|
linkTag = `[` + config.GetColorString("foreign_link") + `]`
|
|
}
|
|
|
|
// Wrap and add link text
|
|
// Wrap the link text, but add some spaces to indent the wrapped lines past the link number
|
|
// Set the style tags
|
|
// Add them to the first line
|
|
|
|
var wrappedLink []string
|
|
|
|
pU, err := urlPkg.Parse(url)
|
|
if !proxied && err == nil &&
|
|
(pU.Scheme == "" || pU.Scheme == "gemini" || pU.Scheme == "about") {
|
|
// A gemini link
|
|
|
|
if viper.GetBool("a-general.color") {
|
|
// Add the link text in blue (in a region), and a gray link number to the left of it
|
|
// Those are the default colors, anyway
|
|
|
|
wrappedLink = wrapLine(linkText, width-indent,
|
|
strings.Repeat(" ", indent)+
|
|
`["`+strconv.Itoa(num-1)+`"][`+config.GetColorString("amfora_link")+`]`,
|
|
`[-][""]`,
|
|
false, // Don't indent the first line, it's the one with link number
|
|
)
|
|
|
|
// Add special stuff to first line, like the link number
|
|
wrappedLink[0] = fmt.Sprintf(`[%s::b][`, config.GetColorString("link_number")) +
|
|
strconv.Itoa(num) + "[]" + "[-::-]" + spacing +
|
|
`["` + strconv.Itoa(num-1) + `"][` + config.GetColorString("amfora_link") + `]` +
|
|
wrappedLink[0] + `[-][""]`
|
|
} else {
|
|
// No color
|
|
|
|
wrappedLink = wrapLine(linkText, width-indent,
|
|
strings.Repeat(" ", indent)+ // +4 for spaces and brackets
|
|
`["`+strconv.Itoa(num-1)+`"]`,
|
|
`[""]`,
|
|
false, // Don't indent the first line, it's the one with link number
|
|
)
|
|
|
|
wrappedLink[0] = `[::b][` + strconv.Itoa(num) + "[][::-] " +
|
|
`["` + strconv.Itoa(num-1) + `"]` +
|
|
wrappedLink[0] + `[""]`
|
|
}
|
|
} else {
|
|
// Not a gemini link
|
|
|
|
if viper.GetBool("a-general.color") {
|
|
// Color
|
|
|
|
wrappedLink = wrapLine(linkText, width-indent,
|
|
strings.Repeat(" ", indent)+
|
|
`["`+strconv.Itoa(num-1)+`"]`+linkTag,
|
|
`[-::-][""]`,
|
|
false, // Don't indent the first line, it's the one with link number
|
|
)
|
|
|
|
wrappedLink[0] = fmt.Sprintf(`[%s::b][`, config.GetColorString("link_number")) +
|
|
strconv.Itoa(num) + "[][-::-]" + spacing +
|
|
`["` + strconv.Itoa(num-1) + `"]` + linkTag +
|
|
wrappedLink[0] + `[-::-][""]`
|
|
} else {
|
|
// No color
|
|
|
|
wrappedLink = wrapLine(linkText, width-indent,
|
|
strings.Repeat(" ", indent)+
|
|
`["`+strconv.Itoa(num-1)+`"]`,
|
|
`[::-][""]`,
|
|
false, // Don't indent the first line, it's the one with link number
|
|
)
|
|
|
|
wrappedLink[0] = `[::b][` + strconv.Itoa(num) + "[][::-]" + spacing +
|
|
`["` + strconv.Itoa(num-1) + `"]` +
|
|
wrappedLink[0] + `[::-][""]`
|
|
}
|
|
}
|
|
|
|
wrappedLines = append(wrappedLines, wrappedLink...)
|
|
|
|
// Lists
|
|
} else if strings.HasPrefix(lines[i], "* ") {
|
|
if viper.GetBool("a-general.bullets") {
|
|
// Wrap list item, and indent wrapped lines past the bullet
|
|
wrappedItem := wrapLine(lines[i][1:],
|
|
width-4, // Subtract the 4 indent spaces
|
|
fmt.Sprintf(" [%s]", config.GetColorString("list_text")),
|
|
"[-]", false)
|
|
// Add bullet
|
|
wrappedItem[0] = fmt.Sprintf(" [%s]\u2022", config.GetColorString("list_text")) +
|
|
wrappedItem[0] + "[-]"
|
|
wrappedLines = append(wrappedLines, wrappedItem...)
|
|
} else {
|
|
wrappedItem := wrapLine(lines[i][1:],
|
|
width-4, // Subtract the 4 indent spaces
|
|
fmt.Sprintf(" [%s]", config.GetColorString("list_text")),
|
|
"[-]", false)
|
|
// Add "*"
|
|
wrappedItem[0] = fmt.Sprintf(" [%s]*", config.GetColorString("list_text")) +
|
|
wrappedItem[0] + "[-]"
|
|
wrappedLines = append(wrappedLines, wrappedItem...)
|
|
|
|
}
|
|
// Optionally list lines could be colored here too, if color is enabled
|
|
} else if strings.HasPrefix(lines[i], ">") {
|
|
// It's a quote line, add extra quote symbols and italics to the start of each wrapped line
|
|
|
|
if len(lines[i]) == 1 {
|
|
// Just an empty quote line
|
|
wrappedLines = append(wrappedLines, fmt.Sprintf("[%s::i]>[-::-]", config.GetColorString("quote_text")))
|
|
} else {
|
|
// Remove beginning quote and maybe space
|
|
lines[i] = strings.TrimPrefix(lines[i], ">")
|
|
lines[i] = strings.TrimPrefix(lines[i], " ")
|
|
wrappedLines = append(wrappedLines,
|
|
wrapLine(lines[i],
|
|
width-2, // Subtract 2 for width of prefix string
|
|
fmt.Sprintf("[%s::i]> ", config.GetColorString("quote_text")),
|
|
"[-::-]", true)...,
|
|
)
|
|
}
|
|
|
|
} else if strings.TrimSpace(lines[i]) == "" {
|
|
// Just add empty line without processing
|
|
wrappedLines = append(wrappedLines, "")
|
|
} else {
|
|
// Regular line, just wrap it
|
|
wrappedLines = append(wrappedLines, wrapLine(lines[i], width,
|
|
fmt.Sprintf("[%s]", config.GetColorString("regular_text")),
|
|
"[-]", true)...)
|
|
}
|
|
}
|
|
|
|
return strings.Join(wrappedLines, "\r\n"), links
|
|
}
|
|
|
|
// RenderGemini converts text/gemini into a cview displayable format.
|
|
// It also returns a slice of link URLs.
|
|
//
|
|
// width is the number of columns to wrap to.
|
|
// leftMargin is the number of blank spaces to prepend to each line.
|
|
//
|
|
// proxied is whether the request is through the gemini:// scheme.
|
|
// If it's not a gemini:// page, set this to true.
|
|
func RenderGemini(s string, width int, proxied bool) (string, []string) {
|
|
s = cview.Escape(s)
|
|
|
|
lines := strings.Split(s, "\n")
|
|
links := make([]string, 0)
|
|
|
|
// Process and wrap non preformatted lines
|
|
rendered := "" // Final result
|
|
pre := false
|
|
buf := "" // Block of regular or preformatted lines
|
|
|
|
// Language, formatter, and style for syntax highlighting
|
|
lang := ""
|
|
formatterName := TermColor
|
|
styleName := viper.GetString("a-general.highlight_style")
|
|
|
|
// processPre is for rendering preformatted blocks
|
|
processPre := func() {
|
|
|
|
syntaxHighlighted := false
|
|
|
|
// Perform syntax highlighting if language is set
|
|
if lang != "" {
|
|
style := styles.Get(styleName)
|
|
if style == nil {
|
|
style = styles.Fallback
|
|
}
|
|
formatter := formatters.Get(formatterName)
|
|
if formatter == nil {
|
|
formatter = formatters.Fallback
|
|
}
|
|
lexer := lexers.Get(lang)
|
|
if lexer == nil {
|
|
lexer = lexers.Fallback
|
|
}
|
|
|
|
// Tokenize and format the text after stripping ANSI codes, replacing buffer if there are no errors
|
|
iterator, err := lexer.Tokenise(nil, ansiRegex.ReplaceAllString(buf, ""))
|
|
if err == nil {
|
|
formattedBuffer := new(bytes.Buffer)
|
|
if formatter.Format(formattedBuffer, style, iterator) == nil {
|
|
// Strip extra newline added by Chroma and replace buffer
|
|
buf = string(trailingNewline.ReplaceAll(formattedBuffer.Bytes(), []byte{}))
|
|
}
|
|
syntaxHighlighted = true
|
|
}
|
|
}
|
|
|
|
// Support ANSI color codes in preformatted blocks - see #59
|
|
// This will also execute if code highlighting was successful for this block
|
|
if viper.GetBool("a-general.color") && (viper.GetBool("a-general.ansi") || syntaxHighlighted) {
|
|
buf = cview.TranslateANSI(buf)
|
|
// The TranslateANSI function will reset the colors when it encounters
|
|
// an ANSI reset code, injecting a full reset tag: [-:-:-]
|
|
// This uses the default foreground and background colors of the
|
|
// application, but in this case we want it to use the preformatted text
|
|
// color as the foreground, as we're still in a preformat block.
|
|
buf = strings.ReplaceAll(
|
|
buf, "[-:-:-]",
|
|
fmt.Sprintf("[%s:-:-]", config.GetColorString("preformatted_text")),
|
|
)
|
|
} else {
|
|
buf = ansiRegex.ReplaceAllString(buf, "")
|
|
}
|
|
|
|
// The final newline is removed (and re-added) to prevent background glitches
|
|
// where the terminal background color slips through. This only happens on
|
|
// preformatted blocks with ANSI characters.
|
|
//
|
|
// Lines are modified below to always end with \r\n
|
|
buf = strings.TrimSuffix(buf, "\r\n")
|
|
|
|
if viper.GetBool("a-general.color") {
|
|
rendered += fmt.Sprintf("[%s]", config.GetColorString("preformatted_text")) +
|
|
buf + fmt.Sprintf("[%s:%s:-]\r\n", config.GetColorString("regular_text"), config.GetColorString("bg"))
|
|
} else {
|
|
rendered += buf + "\r\n"
|
|
}
|
|
}
|
|
|
|
// processRegular processes non-preformatted sections
|
|
processRegular := func() {
|
|
// ANSI not allowed in regular text - see #59
|
|
buf = ansiRegex.ReplaceAllString(buf, "")
|
|
|
|
ren, lks := convertRegularGemini(buf, len(links), width, proxied)
|
|
links = append(links, lks...)
|
|
rendered += ren
|
|
}
|
|
|
|
for i := range lines {
|
|
if strings.HasPrefix(lines[i], "```") {
|
|
if pre {
|
|
// In a preformatted block, so add the text as is
|
|
// Don't add the current line with backticks
|
|
processPre()
|
|
|
|
// Clear the language
|
|
lang = ""
|
|
} else {
|
|
// Not preformatted, regular text
|
|
processRegular()
|
|
|
|
if viper.GetBool("a-general.highlight_code") {
|
|
// Check for alt text indicating a language that Chroma can highlight
|
|
alt := strings.TrimSpace(strings.TrimPrefix(lines[i], "```"))
|
|
if matches := langRegex.FindStringSubmatch(alt); matches != nil {
|
|
if lexers.Get(matches[0]) != nil {
|
|
lang = matches[0]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
buf = "" // Clear buffer for next block
|
|
pre = !pre
|
|
continue
|
|
}
|
|
// Lines always end with \r\n for Windows compatibility
|
|
buf += strings.TrimSuffix(lines[i], "\r") + "\r\n"
|
|
}
|
|
// Gone through all the lines, but there still is likely a block in the buffer
|
|
if pre {
|
|
// File ended without closing the preformatted block
|
|
processPre()
|
|
} else {
|
|
// Not preformatted, regular text
|
|
processRegular()
|
|
}
|
|
|
|
return rendered, links
|
|
}
|