1
0
mirror of https://github.com/makew0rld/amfora.git synced 2024-12-04 14:46:29 -05:00
amfora/render/gemtext.go

375 lines
11 KiB
Go

package render
import (
"bufio"
"fmt"
"io"
urlPkg "net/url"
"strconv"
"strings"
"code.rocketnine.space/tslocum/cview"
"github.com/makeworld-the-better-one/amfora/config"
"github.com/spf13/viper"
)
// Renderer for gemtext. Other Renderers are in renderer.go.
type GemtextRenderer struct {
// Buffers and I/O
readOut *io.PipeReader
readIn *io.PipeWriter
writeIn *io.PipeWriter
writeOut *io.PipeReader
links chan string
// Configurable options
// width is the number of columns to wrap to.
width int
// proxied is whether the request is through the gemini:// scheme.
proxied bool
ansiEnabled bool
colorEnabled bool
// State
// pre indicates whether the renderer is currently in a preformatted block
// or not.
pre bool
// numLinks is the number of links that exist so far.
numLinks int
}
// 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 {
// 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
}
// NewGemtextRenderer.
//
// 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 NewGemtextRenderer(width int, proxied bool) *GemtextRenderer {
pr1, pw1 := io.Pipe()
pr2, pw2 := io.Pipe()
ansiEnabled := false
if viper.GetBool("a-general.color") && viper.GetBool("a-general.ansi") {
ansiEnabled = true
}
colorEnabled := false
if viper.GetBool("a-general.color") {
colorEnabled = true
}
ren := GemtextRenderer{
readOut: pr1,
readIn: pw1,
writeIn: pw2,
writeOut: pr2,
links: make(chan string, 10),
width: width,
proxied: proxied,
ansiEnabled: ansiEnabled,
colorEnabled: colorEnabled,
}
go ren.handler()
return &ren
}
// handler is supposed to run in a goroutine as soon as the renderer is created.
// It handles the buffering and parsing in the background.
func (ren *GemtextRenderer) handler() {
// Go through lines, render, and write each line
// Splits on lines and drops terminators, unlike the other renderers
scanner := bufio.NewScanner(ren.writeOut)
for scanner.Scan() {
line := scanner.Text()
// Process the one possibly invisible line
if strings.HasPrefix(line, "```") {
ren.pre = !ren.pre
continue
}
// Render line and write it
//nolint:errcheck
ren.readIn.Write([]byte(ren.renderLine(line)))
}
// Everything has been read, no more links
close(ren.links)
if err := scanner.Err(); err != nil {
// Close the ends this func touches, shouldn't matter really
ren.writeOut.CloseWithError(err)
ren.readIn.CloseWithError(err)
}
}
// renderLine handles all lines except preformatted markings. The input line
// should not end with any line delimiters, but the output line does.
func (ren *GemtextRenderer) renderLine(line string) string {
if ren.pre {
if ren.ansiEnabled {
line = cview.TranslateANSI(line)
// 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.
line = strings.ReplaceAll(
line, "[-:-:-]",
fmt.Sprintf("[%s:-:-]", config.GetColorString("preformatted_text")),
)
// Set color at beginning and end of line to prevent background glitches
// where the terminal background color slips through. This only happens on
// preformatted blocks with ANSI characters.
line = fmt.Sprintf("[%s]", config.GetColorString("preformatted_text")) +
line + fmt.Sprintf("[%s:%s:-]", config.GetColorString("regular_text"), config.GetColorString("bg"))
} else {
line = ansiRegex.ReplaceAllString(line, "")
}
return line + "\n"
}
// Not preformatted, regular lines
wrappedLines := make([]string, 0) // Final result
// ANSI not allowed in regular text - see #59
line = ansiRegex.ReplaceAllString(line, "")
if strings.HasPrefix(line, "#") {
// Headings
var tag string
if viper.GetBool("a-general.color") {
if strings.HasPrefix(line, "###") {
tag = fmt.Sprintf("[%s::b]", config.GetColorString("hdg_3"))
} else if strings.HasPrefix(line, "##") {
tag = fmt.Sprintf("[%s::b]", config.GetColorString("hdg_2"))
} else if strings.HasPrefix(line, "#") {
tag = fmt.Sprintf("[%s::b]", config.GetColorString("hdg_1"))
}
wrappedLines = append(wrappedLines, wrapLine(line, ren.width, tag, "[-::-]", true)...)
} else {
// Just bold, no colors
wrappedLines = append(wrappedLines, wrapLine(line, ren.width, "[::b]", "[-::-]", true)...)
}
// Links
} else if strings.HasPrefix(line, "=>") && len([]rune(line)) >= 3 {
// Trim whitespace and separate link from link text
line = strings.Trim(line[2:], " \t") // Remove `=>` part too
delim := strings.IndexAny(line, " \t") // Whitespace between link and link text
var url string
var linkText string
if delim == -1 {
// No link text
url = line
linkText = url
} else {
// There is link text
url = line[:delim]
linkText = strings.Trim(line[delim:], " \t")
if viper.GetBool("a-general.show_link") {
linkText += " (" + url + ")"
}
}
if strings.TrimSpace(line) == "" || strings.TrimSpace(url) == "" {
// Link was just whitespace, return it
return "=>\n"
}
ren.links <- url
ren.numLinks++
num := ren.numLinks // 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 = " "
}
// 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
if viper.GetBool("a-general.color") {
pU, err := urlPkg.Parse(url)
if !ren.proxied && err == nil &&
(pU.Scheme == "" || pU.Scheme == "gemini" || pU.Scheme == "about") {
// A gemini link
// 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, ren.width,
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 {
// Not a gemini link
wrappedLink = wrapLine(linkText, ren.width,
strings.Repeat(" ", indent)+
`["`+strconv.Itoa(num-1)+`"][`+config.GetColorString("foreign_link")+`]`,
`[-][""]`,
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) + `"][` + config.GetColorString("foreign_link") + `]` +
wrappedLink[0] + `[-][""]`
}
} else {
// No colors allowed
wrappedLink = wrapLine(linkText, ren.width,
strings.Repeat(" ", len(strconv.Itoa(num))+4)+ // +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] + `[""]`
}
wrappedLines = append(wrappedLines, wrappedLink...)
// Lists
} else if strings.HasPrefix(line, "* ") {
if viper.GetBool("a-general.bullets") {
// Wrap list item, and indent wrapped lines past the bullet
wrappedItem := wrapLine(line[1:], ren.width,
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(line[1:], ren.width,
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(line, ">") {
// It's a quote line, add extra quote symbols and italics to the start of each wrapped line
if len(line) == 1 {
// Just an empty quote line
wrappedLines = append(wrappedLines, fmt.Sprintf("[%s::i]>[-::-]", config.GetColorString("quote_text")))
} else {
// Remove beginning quote and maybe space
line = strings.TrimPrefix(line, ">")
line = strings.TrimPrefix(line, " ")
wrappedLines = append(wrappedLines,
wrapLine(line, ren.width, fmt.Sprintf("[%s::i]> ", config.GetColorString("quote_text")),
"[-::-]", true)...,
)
}
} else if strings.TrimSpace(line) == "" {
// Just add empty line without processing
wrappedLines = append(wrappedLines, "")
} else {
// Regular line, just wrap it
wrappedLines = append(wrappedLines, wrapLine(line, ren.width,
fmt.Sprintf("[%s]", config.GetColorString("regular_text")),
"[-]", true)...)
}
return strings.Join(wrappedLines, "\n") + "\n"
}
func (ren *GemtextRenderer) Write(p []byte) (n int, err error) {
return ren.writeIn.Write(p)
}
func (ren *GemtextRenderer) Read(p []byte) (n int, err error) {
return ren.readOut.Read(p)
}
func (ren *GemtextRenderer) Close() error {
// Close user-facing ends of the pipes. Shouldn't matter which ends though
ren.writeIn.Close()
ren.readOut.Close()
return nil
}
func (ren *GemtextRenderer) Links() <-chan string {
return ren.links
}