mirror of
https://github.com/makew0rld/amfora.git
synced 2025-02-02 15:07:34 -05:00
Using a color from the theme should make the rendering of text files be more in line with the colors chosen for styled content. Furthermore, allowing some form of user override for the color of plain text files allows users to individually fix bad color combinations. Such as in my case, where the combination of a solarized terminal theme and the default color chosen by amfora causes plain text files to be rendered as white text on an almost equally bright background.
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 fmt.Sprintf("[%s]", config.GetColorString("preformatted_text")) + 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
|
|
}
|