diff --git a/render/gemtext.go b/render/gemtext.go index b13368d..3160796 100644 --- a/render/gemtext.go +++ b/render/gemtext.go @@ -2,37 +2,42 @@ package render import ( "bufio" + "fmt" "io" + urlPkg "net/url" + "strconv" + "strings" + + "github.com/makeworld-the-better-one/amfora/config" + "github.com/spf13/viper" + "gitlab.com/tslocum/cview" ) // Renderer for gemtext. Other Renderers are in renderer.go. type GemtextRenderer struct { - r *io.PipeReader - w *io.PipeWriter - - // scanner is used to process line by line. - scanner *bufio.Scanner - - // scanWriter is used to send data to the scanner, which reads out of the other - // end of the pipe. - scanWriter *io.PipeWriter - - // lineEnd holds the rest of line when the Read call cuts off the line being returned. - lineEnd []byte + // Buffers and I/O + r *io.PipeReader + w *io.PipeWriter links chan string - // numLinks is the number of links that exist so far. - numLinks int + // 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 + 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 } // NewGemtextRenderer. @@ -43,29 +48,268 @@ type GemtextRenderer struct { // If it's not a gemini:// page, set this to true. func NewGemtextRenderer(width int, proxied bool) *GemtextRenderer { pr, pw := io.Pipe() - scanReader, scanWriter := io.Pipe() - scanner := bufio.NewScanner(scanReader) links := make(chan string, 10) - return &GemtextRenderer{ - r: pr, - w: pw, - scanner: scanner, - scanWriter: scanWriter, - lineEnd: make([]byte, 0), - links: links, - numLinks: 0, - width: width, - proxied: proxied, - pre: false, + 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 + } + + return &GemtextRenderer{ + r: pr, + w: pw, + links: links, + width: width, + proxied: proxied, + ansiEnabled: ansiEnabled, + colorEnabled: colorEnabled, + } +} + +// renderLine handles all lines except preformatted markings. The input line +// should not end with any line delimiters, but the output line does. +func (r *GemtextRenderer) renderLine(line string) string { + if r.pre { + if r.ansiEnabled { + line = cview.TranslateANSI(line) + // The TranslateANSI function injects tags like [-:-:-] + // but this will reset the background to use the user's terminal color. + // These tags need to be replaced with resets that use the theme color. + line = strings.ReplaceAll(line, "[-:-:-]", + fmt.Sprintf("[%s:%s:-]", config.GetColorString("preformatted_text"), config.GetColorString("bg")), + ) + + // 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, r.width, tag, "[-::-]", true)...) + } else { + // Just bold, no colors + wrappedLines = append(wrappedLines, wrapLine(line, r.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" + } + + r.links <- url + r.numLinks++ + num := r.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 !r.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, r.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, r.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, r.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:], r.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...) + } + // 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, r.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, r.width, + fmt.Sprintf("[%s]", config.GetColorString("regular_text")), + "[-]", true)...) + } + + return strings.Join(wrappedLines, "\n") + "\n" +} + +func (ren *GemtextRenderer) ReadFrom(r io.Reader) (int64, error) { + // Go through lines, render, and write each line + // TODO: Should writes be buffered? + + var n int64 + scanner := bufio.NewScanner(r) + scanner.Split(ScanLines) + + for scanner.Scan() { + n += int64(len(scanner.Bytes())) + line := scanner.Text() + + if strings.HasPrefix(line, "```") { + ren.pre = !ren.pre + continue + } + + // Render line and write it + + //nolint:errcheck + ren.w.Write([]byte( + ren.renderLine(strings.TrimRight(line, "\r\n")), + )) + + } + return n, scanner.Err() +} + +// Write will panic, use ReadFrom instead. +func (r *GemtextRenderer) Write(p []byte) (n int, err error) { + // This renderer is line based, and so it can't process arbitrary bytes. + // One solution would be to handle rendering on the other end of the pipe, + // the Read call, but it's simpler to just implement ReadFrom. + panic("func Write not allowed for GemtextRenderer") +} + +func (r *GemtextRenderer) Read(p []byte) (n int, err error) { + return r.r.Read(p) } func (r *GemtextRenderer) Links() <-chan string { return r.links } - -func (r *GemtextRenderer) Write(p []byte) (n int, err error) { - // Just write to the scanner, all logic is in Read() - return r.scanWriter.Write(p) -} diff --git a/render/renderer.go b/render/renderer.go index 68278f2..8107802 100644 --- a/render/renderer.go +++ b/render/renderer.go @@ -1,6 +1,7 @@ package render import ( + "bufio" "bytes" "fmt" "io" @@ -12,17 +13,45 @@ import ( // Renderer renderers network bytes into something that can be displayed on a // cview.TextView. +// +// Write calls may block if the Lines channel buffer is full. +// +// Current implementations don't actually implement io.Writer, and calling Write +// will panic. ReadFrom should be used instead. type Renderer interface { io.ReadWriter + io.ReaderFrom // Links returns a channel that yields Link URLs as they are parsed. - // It is buffered. The channel might be closed to indicate links are supported + // It is buffered. The channel might be closed to indicate links aren't supported // for this renderer. Links() <-chan string } +// ScanLines is copied from bufio.ScanLines and is used with bufio.Scanner. +// The only difference is that this func doesn't get rid of the end-of-line marker. +// This is so that the number of read bytes can be counted correctly in ReadFrom. +// +// It also simplifes code by no longer having to append a newline character. +func ScanLines(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := bytes.IndexByte(data, '\n'); i >= 0 { + // We have a full newline-terminated line. + return i + 1, data[0 : i+1], nil + } + // If we're at EOF, we have a final, non-terminated line. Return it. + if atEOF { + return len(data), data, nil + } + // Request more data. + return 0, nil, nil +} + +// PlaintextRenderer escapes text for cview usage and does nothing else. type PlaintextRenderer struct { - *io.PipeReader + r *io.PipeReader w *io.PipeWriter } @@ -31,10 +60,33 @@ func NewPlaintextRenderer() *PlaintextRenderer { return &PlaintextRenderer{pr, pw} } +func (ren *PlaintextRenderer) ReadFrom(r io.Reader) (int64, error) { + // Go through lines and escape bytes and write each line + // TODO: Should writes be buffered? + + var n int64 + scanner := bufio.NewScanner(r) + scanner.Split(ScanLines) + + for scanner.Scan() { + n += int64(len(scanner.Bytes())) + + //nolint:errcheck + ren.w.Write(cview.EscapeBytes(scanner.Bytes())) + } + return n, scanner.Err() +} + +// Write will panic, use ReadFrom instead. func (r *PlaintextRenderer) Write(p []byte) (n int, err error) { - // TODO: The escaping will fail if the Write bytes end in the middle of a tag - // How can this be avoided by users of this func? - return r.w.Write(cview.EscapeBytes(p)) + // This function would normally use cview.EscapeBytes + // But the escaping will fail if the Write bytes end in the middle of a tag + // So instead it just panics, because it should never be used. + panic("func Write not allowed for PlaintextRenderer") +} + +func (r *PlaintextRenderer) Read(p []byte) (n int, err error) { + return r.r.Read(p) } func (r *PlaintextRenderer) Links() <-chan string { @@ -43,11 +95,13 @@ func (r *PlaintextRenderer) Links() <-chan string { return ch } +// ANSIRenderer escapes text for cview usage, as well as converting ANSI codes +// into cview tags if the config allows it. type ANSIRenderer struct { - *io.PipeReader - pw *io.PipeWriter - ansiWriter io.Writer // cview.ANSIWriter - buf bytes.Buffer + r *io.PipeReader + w *io.PipeWriter + ansiWriter io.Writer // cview.ANSIWriter + buf bytes.Buffer // Where ansiWriter writes to } func NewANSIRenderer() *ANSIRenderer { @@ -63,25 +117,56 @@ func NewANSIRenderer() *ANSIRenderer { return &ANSIRenderer{pr, pw, ansiWriter, buf} } +// Write will panic, use ReadFrom instead. func (r *ANSIRenderer) Write(p []byte) (n int, err error) { - if r.ansiWriter == nil { - // ANSI disabled - return r.pw.Write(ansiRegex.ReplaceAll(p, []byte{})) - } - // ANSI enabled + // This function would normally use cview.EscapeBytes among other things. + // But the escaping will fail if the Write bytes end in the middle of a tag + // So instead it just panics, because it should never be used. + panic("func Write not allowed for ANSIRenderer") +} - r.buf.Reset() - r.ansiWriter.Write(p) // Shouldn't error because everything it writes to are all bytes.Buffer - return r.pw.Write( - // The ANSIWriter injects tags like [-:-:-] - // but this will reset the background to use the user's terminal color. - // These tags need to be replaced with resets that use the theme color. - bytes.ReplaceAll( - r.buf.Bytes(), - []byte("[-:-:-]"), - []byte(fmt.Sprintf("[-:%s:-]", config.GetColorString("bg"))), - ), - ) +func (ren *ANSIRenderer) ReadFrom(r io.Reader) (int64, error) { + // Go through lines, render, and write each line + // TODO: Should writes be buffered? + + var n int64 + scanner := bufio.NewScanner(r) + scanner.Split(ScanLines) + + for scanner.Scan() { + n += int64(len(scanner.Bytes())) + line := scanner.Bytes() + line = cview.EscapeBytes(line) + + if ren.ansiWriter == nil { + // ANSI disabled + line = ansiRegex.ReplaceAll(scanner.Bytes(), nil) + } else { + // ANSI enabled + + ren.buf.Reset() + + // Shouldn't error because everything it writes to are all bytes.Buffer + ren.ansiWriter.Write(line) //nolint:errcheck + + // The ANSIWriter injects tags like [-:-:-] + // but this will reset the background to use the user's terminal color. + // These tags need to be replaced with resets that use the theme color. + line = bytes.ReplaceAll( + ren.buf.Bytes(), + []byte("[-:-:-]"), + []byte(fmt.Sprintf("[-:%s:-]", config.GetColorString("bg"))), + ) + } + + ren.w.Write(line) //nolint:errcheck + } + + return n, scanner.Err() +} + +func (r *ANSIRenderer) Read(p []byte) (n int, err error) { + return r.r.Read(p) } func (r *ANSIRenderer) Links() <-chan string {