1
0
mirror of https://github.com/makew0rld/amfora.git synced 2025-02-02 15:07:34 -05:00
amfora/renderer/renderer.go
Frank Seifferth 1115614463
Use preformatted_text color for rendering plain text files (#351)
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.
2024-12-18 16:55:12 -05:00

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
}