1
1
mirror of https://github.com/walles/moar.git synced 2024-09-17 15:07:11 +03:00
moar/m/ansiTokenizer.go

636 lines
15 KiB
Go
Raw Normal View History

package m
import (
2019-10-27 23:40:30 +03:00
"fmt"
"os"
"regexp"
2019-10-28 22:18:07 +03:00
"strconv"
"strings"
"unicode"
log "github.com/sirupsen/logrus"
2021-04-15 16:16:06 +03:00
"github.com/walles/moar/twin"
)
2019-06-17 22:39:57 +03:00
const _TabSize = 4
const BACKSPACE = '\b'
2021-04-15 16:16:06 +03:00
var manPageBold = twin.StyleDefault.WithAttr(twin.AttrBold)
var manPageUnderline = twin.StyleDefault.WithAttr(twin.AttrUnderline)
2019-10-30 21:52:49 +03:00
2021-04-14 22:41:56 +03:00
// ESC[...m: https://en.wikipedia.org/wiki/ANSI_escape_code#SGR
var sgrSequencePattern = regexp.MustCompile("\x1b\\[([0-9;]*m)")
Parse lines on demand and only once This improves line processing performance by 40%. Fixes #36. diff --git m/ansiTokenizer.go m/ansiTokenizer.go index d991e23..056a227 100644 --- m/ansiTokenizer.go +++ m/ansiTokenizer.go @@ -23,6 +23,44 @@ type Token struct { Style tcell.Style } +// A Line represents a line of text that can / will be paged +type Line struct { + raw *string + plain *string + tokens []Token +} + +// NewLine creates a new Line from a (potentially ANSI / man page formatted) string +func NewLine(raw string) *Line { + return &Line{ + raw: &raw, + plain: nil, + tokens: nil, + } +} + +// Tokens returns a representation of the string split into styled tokens +func (line *Line) Tokens() []Token { + line.parse() + return line.tokens +} + +// Plain returns a plain text representation of the initial string +func (line *Line) Plain() string { + line.parse() + return *line.plain +} + +func (line *Line) parse() { + if line.raw == nil { + // Already done + return + } + + line.tokens, line.plain = tokensFromString(*line.raw) + line.raw = nil +} + // SetManPageFormatFromEnv parses LESS_TERMCAP_xx environment variables and // adapts the moar output accordingly. func SetManPageFormatFromEnv() { diff --git m/pager.go m/pager.go index 412e05b..98efa9a 100644 --- m/pager.go +++ m/pager.go @@ -111,7 +111,7 @@ func NewPager(r *Reader) *Pager { } } -func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNumber int, line string) { +func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNumber int, line *Line) { screenWidth, _ := p.screen.Size() prefixLength := 0 @@ -138,7 +138,7 @@ func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNum func createScreenLine( stringIndexAtColumnZero int, screenColumnsCount int, - line string, + line *Line, search *regexp.Regexp, ) []Token { var returnMe []Token @@ -152,14 +152,14 @@ func createScreenLine( searchHitDelta = -1 } - tokens, plainString := tokensFromString(line) - if stringIndexAtColumnZero >= len(tokens) { + if stringIndexAtColumnZero >= len(line.Tokens()) { // Nothing (more) to display, never mind return returnMe } - matchRanges := getMatchRanges(plainString, search) - for _, token := range tokens[stringIndexAtColumnZero:] { + plain := line.Plain() + matchRanges := getMatchRanges(&plain, search) + for _, token := range line.Tokens()[stringIndexAtColumnZero:] { if len(returnMe) >= screenColumnsCount { // We are trying to add a character to the right of the screen. // Indicate that this line continues to the right. @@ -232,7 +232,8 @@ func (p *Pager) _AddLines(spinner string) { // This happens when we're done eofSpinner = "---" } - p._AddLine(nil, 0, screenLineNumber, _EofMarkerFormat+eofSpinner) + spinnerLine := NewLine(_EofMarkerFormat + eofSpinner) + p._AddLine(nil, 0, screenLineNumber, spinnerLine) switch p.mode { case _Searching: @@ -329,8 +330,8 @@ func (p *Pager) _FindFirstHitLineOneBased(firstLineOneBased int, backwards bool) return nil } - _, lineText := tokensFromString(*line) - if p.searchPattern.MatchString(*lineText) { + lineText := line.Plain() + if p.searchPattern.MatchString(lineText) { return &lineNumber } diff --git m/pager_test.go m/pager_test.go index 65fa3c2..ce0f79b 100644 --- m/pager_test.go +++ m/pager_test.go @@ -265,13 +265,15 @@ func assertTokenRangesEqual(t *testing.T, actual []Token, expected []Token) { } func TestCreateScreenLineBase(t *testing.T) { - line := createScreenLine(0, 3, "", nil) - assert.Assert(t, len(line) == 0) + line := NewLine("") + screenLine := createScreenLine(0, 3, line, nil) + assert.Assert(t, len(screenLine) == 0) } func TestCreateScreenLineOverflowRight(t *testing.T) { - line := createScreenLine(0, 3, "012345", nil) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("012345") + screenLine := createScreenLine(0, 3, line, nil) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('0', tcell.StyleDefault), createExpectedCell('1', tcell.StyleDefault), createExpectedCell('>', tcell.StyleDefault.Reverse(true)), @@ -279,8 +281,9 @@ func TestCreateScreenLineOverflowRight(t *testing.T) { } func TestCreateScreenLineUnderflowLeft(t *testing.T) { - line := createScreenLine(1, 3, "012", nil) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("012") + screenLine := createScreenLine(1, 3, line, nil) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('1', tcell.StyleDefault), createExpectedCell('2', tcell.StyleDefault), @@ -293,8 +296,9 @@ func TestCreateScreenLineSearchHit(t *testing.T) { panic(err) } - line := createScreenLine(0, 3, "abc", pattern) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("abc") + screenLine := createScreenLine(0, 3, line, pattern) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('a', tcell.StyleDefault), createExpectedCell('b', tcell.StyleDefault.Reverse(true)), createExpectedCell('c', tcell.StyleDefault), @@ -307,8 +311,9 @@ func TestCreateScreenLineUtf8SearchHit(t *testing.T) { panic(err) } - line := createScreenLine(0, 3, "åäö", pattern) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("åäö") + screenLine := createScreenLine(0, 3, line, pattern) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), createExpectedCell('ö', tcell.StyleDefault), @@ -318,9 +323,10 @@ func TestCreateScreenLineUtf8SearchHit(t *testing.T) { func TestCreateScreenLineScrolledUtf8SearchHit(t *testing.T) { pattern := regexp.MustCompile("ä") - line := createScreenLine(1, 4, "ååäö", pattern) + line := NewLine("ååäö") + screenLine := createScreenLine(1, 4, line, pattern) - assertTokenRangesEqual(t, line, []Token{ + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), @@ -331,9 +337,10 @@ func TestCreateScreenLineScrolledUtf8SearchHit(t *testing.T) { func TestCreateScreenLineScrolled2Utf8SearchHit(t *testing.T) { pattern := regexp.MustCompile("ä") - line := createScreenLine(2, 4, "åååäö", pattern) + line := NewLine("åååäö") + screenLine := createScreenLine(2, 4, line, pattern) - assertTokenRangesEqual(t, line, []Token{ + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), diff --git m/reader.go m/reader.go index 418c4c5..d47b710 100644 --- m/reader.go +++ m/reader.go @@ -29,7 +29,7 @@ import ( // // This package provides query methods for the struct, no peeking!! type Reader struct { - lines []string + lines []*Line name *string lock *sync.Mutex err error @@ -41,7 +41,7 @@ type Reader struct { // Lines contains a number of lines from the reader, plus metadata type Lines struct { - lines []string + lines []*Line // One-based line number of the first line returned firstLineOneBased int @@ -136,7 +136,7 @@ func readStream(stream io.Reader, reader *Reader, fromFilter *exec.Cmd) { } reader.lock.Lock() - reader.lines = append(reader.lines, string(completeLine)) + reader.lines = append(reader.lines, NewLine(string(completeLine))) reader.lock.Unlock() // This is how to do a non-blocking write to a channel: @@ -172,7 +172,7 @@ func NewReaderFromStream(name string, reader io.Reader) *Reader { // If fromFilter is not nil this method will wait() for it, // and effectively takes over ownership for it. func newReaderFromStream(reader io.Reader, fromFilter *exec.Cmd) *Reader { - var lines []string + var lines []*Line var lock = &sync.Mutex{} done := make(chan bool, 1) @@ -201,9 +201,11 @@ func newReaderFromStream(reader io.Reader, fromFilter *exec.Cmd) *Reader { // Moar in the bottom left corner of the screen. func NewReaderFromText(name string, text string) *Reader { noExternalNewlines := strings.Trim(text, "\n") - lines := []string{} + lines := []*Line{} if len(noExternalNewlines) > 0 { - lines = strings.Split(noExternalNewlines, "\n") + for _, line := range strings.Split(noExternalNewlines, "\n") { + lines = append(lines, NewLine(line)) + } } done := make(chan bool, 1) done <- true @@ -380,7 +382,7 @@ func (r *Reader) GetLineCount() int { } // GetLine gets a line. If the requested line number is out of bounds, nil is returned. -func (r *Reader) GetLine(lineNumberOneBased int) *string { +func (r *Reader) GetLine(lineNumberOneBased int) *Line { r.lock.Lock() defer r.lock.Unlock() @@ -390,7 +392,7 @@ func (r *Reader) GetLine(lineNumberOneBased int) *string { if lineNumberOneBased > len(r.lines) { return nil } - return &r.lines[lineNumberOneBased-1] + return r.lines[lineNumberOneBased-1] } // GetLines gets the indicated lines from the input diff --git m/reader_test.go m/reader_test.go index 2ba7326..0e2aed2 100644 --- m/reader_test.go +++ m/reader_test.go @@ -158,8 +158,8 @@ func TestGetLongLine(t *testing.T) { assert.Equal(t, len(lines.lines), 1) line := lines.lines[0] - assert.Assert(t, strings.HasPrefix(line, "1 2 3 4"), "<%s>", line) - assert.Assert(t, strings.HasSuffix(line, "0123456789"), line) + assert.Assert(t, strings.HasPrefix(line.Plain(), "1 2 3 4"), "<%s>", line) + assert.Assert(t, strings.HasSuffix(line.Plain(), "0123456789"), line) stat, err := os.Stat(file) if err != nil { @@ -168,7 +168,7 @@ func TestGetLongLine(t *testing.T) { fileSize := stat.Size() // The "+1" is because the Reader strips off the ending linefeed - assert.Equal(t, len(line)+1, int(fileSize)) + assert.Equal(t, len(line.Plain())+1, int(fileSize)) } func getReaderWithLineCount(totalLines int) *Reader { @@ -219,7 +219,7 @@ func testCompressedFile(t *testing.T, filename string) { panic(err) } - assert.Equal(t, reader.GetLines(1, 5).lines[0], "This is a compressed file", "%s", filename) + assert.Equal(t, reader.GetLines(1, 5).lines[0].Plain(), "This is a compressed file", "%s", filename) } func TestCompressedFiles(t *testing.T) { Change-Id: Id8671001ec7c1038e2df0b87a45d346a1f1dd663
2021-01-11 12:42:34 +03:00
// A Line represents a line of text that can / will be paged
type Line struct {
raw string
2021-04-15 16:16:06 +03:00
plain *string
Parse lines on demand and only once This improves line processing performance by 40%. Fixes #36. diff --git m/ansiTokenizer.go m/ansiTokenizer.go index d991e23..056a227 100644 --- m/ansiTokenizer.go +++ m/ansiTokenizer.go @@ -23,6 +23,44 @@ type Token struct { Style tcell.Style } +// A Line represents a line of text that can / will be paged +type Line struct { + raw *string + plain *string + tokens []Token +} + +// NewLine creates a new Line from a (potentially ANSI / man page formatted) string +func NewLine(raw string) *Line { + return &Line{ + raw: &raw, + plain: nil, + tokens: nil, + } +} + +// Tokens returns a representation of the string split into styled tokens +func (line *Line) Tokens() []Token { + line.parse() + return line.tokens +} + +// Plain returns a plain text representation of the initial string +func (line *Line) Plain() string { + line.parse() + return *line.plain +} + +func (line *Line) parse() { + if line.raw == nil { + // Already done + return + } + + line.tokens, line.plain = tokensFromString(*line.raw) + line.raw = nil +} + // SetManPageFormatFromEnv parses LESS_TERMCAP_xx environment variables and // adapts the moar output accordingly. func SetManPageFormatFromEnv() { diff --git m/pager.go m/pager.go index 412e05b..98efa9a 100644 --- m/pager.go +++ m/pager.go @@ -111,7 +111,7 @@ func NewPager(r *Reader) *Pager { } } -func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNumber int, line string) { +func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNumber int, line *Line) { screenWidth, _ := p.screen.Size() prefixLength := 0 @@ -138,7 +138,7 @@ func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNum func createScreenLine( stringIndexAtColumnZero int, screenColumnsCount int, - line string, + line *Line, search *regexp.Regexp, ) []Token { var returnMe []Token @@ -152,14 +152,14 @@ func createScreenLine( searchHitDelta = -1 } - tokens, plainString := tokensFromString(line) - if stringIndexAtColumnZero >= len(tokens) { + if stringIndexAtColumnZero >= len(line.Tokens()) { // Nothing (more) to display, never mind return returnMe } - matchRanges := getMatchRanges(plainString, search) - for _, token := range tokens[stringIndexAtColumnZero:] { + plain := line.Plain() + matchRanges := getMatchRanges(&plain, search) + for _, token := range line.Tokens()[stringIndexAtColumnZero:] { if len(returnMe) >= screenColumnsCount { // We are trying to add a character to the right of the screen. // Indicate that this line continues to the right. @@ -232,7 +232,8 @@ func (p *Pager) _AddLines(spinner string) { // This happens when we're done eofSpinner = "---" } - p._AddLine(nil, 0, screenLineNumber, _EofMarkerFormat+eofSpinner) + spinnerLine := NewLine(_EofMarkerFormat + eofSpinner) + p._AddLine(nil, 0, screenLineNumber, spinnerLine) switch p.mode { case _Searching: @@ -329,8 +330,8 @@ func (p *Pager) _FindFirstHitLineOneBased(firstLineOneBased int, backwards bool) return nil } - _, lineText := tokensFromString(*line) - if p.searchPattern.MatchString(*lineText) { + lineText := line.Plain() + if p.searchPattern.MatchString(lineText) { return &lineNumber } diff --git m/pager_test.go m/pager_test.go index 65fa3c2..ce0f79b 100644 --- m/pager_test.go +++ m/pager_test.go @@ -265,13 +265,15 @@ func assertTokenRangesEqual(t *testing.T, actual []Token, expected []Token) { } func TestCreateScreenLineBase(t *testing.T) { - line := createScreenLine(0, 3, "", nil) - assert.Assert(t, len(line) == 0) + line := NewLine("") + screenLine := createScreenLine(0, 3, line, nil) + assert.Assert(t, len(screenLine) == 0) } func TestCreateScreenLineOverflowRight(t *testing.T) { - line := createScreenLine(0, 3, "012345", nil) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("012345") + screenLine := createScreenLine(0, 3, line, nil) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('0', tcell.StyleDefault), createExpectedCell('1', tcell.StyleDefault), createExpectedCell('>', tcell.StyleDefault.Reverse(true)), @@ -279,8 +281,9 @@ func TestCreateScreenLineOverflowRight(t *testing.T) { } func TestCreateScreenLineUnderflowLeft(t *testing.T) { - line := createScreenLine(1, 3, "012", nil) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("012") + screenLine := createScreenLine(1, 3, line, nil) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('1', tcell.StyleDefault), createExpectedCell('2', tcell.StyleDefault), @@ -293,8 +296,9 @@ func TestCreateScreenLineSearchHit(t *testing.T) { panic(err) } - line := createScreenLine(0, 3, "abc", pattern) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("abc") + screenLine := createScreenLine(0, 3, line, pattern) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('a', tcell.StyleDefault), createExpectedCell('b', tcell.StyleDefault.Reverse(true)), createExpectedCell('c', tcell.StyleDefault), @@ -307,8 +311,9 @@ func TestCreateScreenLineUtf8SearchHit(t *testing.T) { panic(err) } - line := createScreenLine(0, 3, "åäö", pattern) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("åäö") + screenLine := createScreenLine(0, 3, line, pattern) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), createExpectedCell('ö', tcell.StyleDefault), @@ -318,9 +323,10 @@ func TestCreateScreenLineUtf8SearchHit(t *testing.T) { func TestCreateScreenLineScrolledUtf8SearchHit(t *testing.T) { pattern := regexp.MustCompile("ä") - line := createScreenLine(1, 4, "ååäö", pattern) + line := NewLine("ååäö") + screenLine := createScreenLine(1, 4, line, pattern) - assertTokenRangesEqual(t, line, []Token{ + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), @@ -331,9 +337,10 @@ func TestCreateScreenLineScrolledUtf8SearchHit(t *testing.T) { func TestCreateScreenLineScrolled2Utf8SearchHit(t *testing.T) { pattern := regexp.MustCompile("ä") - line := createScreenLine(2, 4, "åååäö", pattern) + line := NewLine("åååäö") + screenLine := createScreenLine(2, 4, line, pattern) - assertTokenRangesEqual(t, line, []Token{ + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), diff --git m/reader.go m/reader.go index 418c4c5..d47b710 100644 --- m/reader.go +++ m/reader.go @@ -29,7 +29,7 @@ import ( // // This package provides query methods for the struct, no peeking!! type Reader struct { - lines []string + lines []*Line name *string lock *sync.Mutex err error @@ -41,7 +41,7 @@ type Reader struct { // Lines contains a number of lines from the reader, plus metadata type Lines struct { - lines []string + lines []*Line // One-based line number of the first line returned firstLineOneBased int @@ -136,7 +136,7 @@ func readStream(stream io.Reader, reader *Reader, fromFilter *exec.Cmd) { } reader.lock.Lock() - reader.lines = append(reader.lines, string(completeLine)) + reader.lines = append(reader.lines, NewLine(string(completeLine))) reader.lock.Unlock() // This is how to do a non-blocking write to a channel: @@ -172,7 +172,7 @@ func NewReaderFromStream(name string, reader io.Reader) *Reader { // If fromFilter is not nil this method will wait() for it, // and effectively takes over ownership for it. func newReaderFromStream(reader io.Reader, fromFilter *exec.Cmd) *Reader { - var lines []string + var lines []*Line var lock = &sync.Mutex{} done := make(chan bool, 1) @@ -201,9 +201,11 @@ func newReaderFromStream(reader io.Reader, fromFilter *exec.Cmd) *Reader { // Moar in the bottom left corner of the screen. func NewReaderFromText(name string, text string) *Reader { noExternalNewlines := strings.Trim(text, "\n") - lines := []string{} + lines := []*Line{} if len(noExternalNewlines) > 0 { - lines = strings.Split(noExternalNewlines, "\n") + for _, line := range strings.Split(noExternalNewlines, "\n") { + lines = append(lines, NewLine(line)) + } } done := make(chan bool, 1) done <- true @@ -380,7 +382,7 @@ func (r *Reader) GetLineCount() int { } // GetLine gets a line. If the requested line number is out of bounds, nil is returned. -func (r *Reader) GetLine(lineNumberOneBased int) *string { +func (r *Reader) GetLine(lineNumberOneBased int) *Line { r.lock.Lock() defer r.lock.Unlock() @@ -390,7 +392,7 @@ func (r *Reader) GetLine(lineNumberOneBased int) *string { if lineNumberOneBased > len(r.lines) { return nil } - return &r.lines[lineNumberOneBased-1] + return r.lines[lineNumberOneBased-1] } // GetLines gets the indicated lines from the input diff --git m/reader_test.go m/reader_test.go index 2ba7326..0e2aed2 100644 --- m/reader_test.go +++ m/reader_test.go @@ -158,8 +158,8 @@ func TestGetLongLine(t *testing.T) { assert.Equal(t, len(lines.lines), 1) line := lines.lines[0] - assert.Assert(t, strings.HasPrefix(line, "1 2 3 4"), "<%s>", line) - assert.Assert(t, strings.HasSuffix(line, "0123456789"), line) + assert.Assert(t, strings.HasPrefix(line.Plain(), "1 2 3 4"), "<%s>", line) + assert.Assert(t, strings.HasSuffix(line.Plain(), "0123456789"), line) stat, err := os.Stat(file) if err != nil { @@ -168,7 +168,7 @@ func TestGetLongLine(t *testing.T) { fileSize := stat.Size() // The "+1" is because the Reader strips off the ending linefeed - assert.Equal(t, len(line)+1, int(fileSize)) + assert.Equal(t, len(line.Plain())+1, int(fileSize)) } func getReaderWithLineCount(totalLines int) *Reader { @@ -219,7 +219,7 @@ func testCompressedFile(t *testing.T, filename string) { panic(err) } - assert.Equal(t, reader.GetLines(1, 5).lines[0], "This is a compressed file", "%s", filename) + assert.Equal(t, reader.GetLines(1, 5).lines[0].Plain(), "This is a compressed file", "%s", filename) } func TestCompressedFiles(t *testing.T) { Change-Id: Id8671001ec7c1038e2df0b87a45d346a1f1dd663
2021-01-11 12:42:34 +03:00
}
// NewLine creates a new Line from a (potentially ANSI / man page formatted) string
func NewLine(raw string) *Line {
return &Line{
raw: raw,
2021-04-15 16:16:06 +03:00
plain: nil,
Parse lines on demand and only once This improves line processing performance by 40%. Fixes #36. diff --git m/ansiTokenizer.go m/ansiTokenizer.go index d991e23..056a227 100644 --- m/ansiTokenizer.go +++ m/ansiTokenizer.go @@ -23,6 +23,44 @@ type Token struct { Style tcell.Style } +// A Line represents a line of text that can / will be paged +type Line struct { + raw *string + plain *string + tokens []Token +} + +// NewLine creates a new Line from a (potentially ANSI / man page formatted) string +func NewLine(raw string) *Line { + return &Line{ + raw: &raw, + plain: nil, + tokens: nil, + } +} + +// Tokens returns a representation of the string split into styled tokens +func (line *Line) Tokens() []Token { + line.parse() + return line.tokens +} + +// Plain returns a plain text representation of the initial string +func (line *Line) Plain() string { + line.parse() + return *line.plain +} + +func (line *Line) parse() { + if line.raw == nil { + // Already done + return + } + + line.tokens, line.plain = tokensFromString(*line.raw) + line.raw = nil +} + // SetManPageFormatFromEnv parses LESS_TERMCAP_xx environment variables and // adapts the moar output accordingly. func SetManPageFormatFromEnv() { diff --git m/pager.go m/pager.go index 412e05b..98efa9a 100644 --- m/pager.go +++ m/pager.go @@ -111,7 +111,7 @@ func NewPager(r *Reader) *Pager { } } -func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNumber int, line string) { +func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNumber int, line *Line) { screenWidth, _ := p.screen.Size() prefixLength := 0 @@ -138,7 +138,7 @@ func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNum func createScreenLine( stringIndexAtColumnZero int, screenColumnsCount int, - line string, + line *Line, search *regexp.Regexp, ) []Token { var returnMe []Token @@ -152,14 +152,14 @@ func createScreenLine( searchHitDelta = -1 } - tokens, plainString := tokensFromString(line) - if stringIndexAtColumnZero >= len(tokens) { + if stringIndexAtColumnZero >= len(line.Tokens()) { // Nothing (more) to display, never mind return returnMe } - matchRanges := getMatchRanges(plainString, search) - for _, token := range tokens[stringIndexAtColumnZero:] { + plain := line.Plain() + matchRanges := getMatchRanges(&plain, search) + for _, token := range line.Tokens()[stringIndexAtColumnZero:] { if len(returnMe) >= screenColumnsCount { // We are trying to add a character to the right of the screen. // Indicate that this line continues to the right. @@ -232,7 +232,8 @@ func (p *Pager) _AddLines(spinner string) { // This happens when we're done eofSpinner = "---" } - p._AddLine(nil, 0, screenLineNumber, _EofMarkerFormat+eofSpinner) + spinnerLine := NewLine(_EofMarkerFormat + eofSpinner) + p._AddLine(nil, 0, screenLineNumber, spinnerLine) switch p.mode { case _Searching: @@ -329,8 +330,8 @@ func (p *Pager) _FindFirstHitLineOneBased(firstLineOneBased int, backwards bool) return nil } - _, lineText := tokensFromString(*line) - if p.searchPattern.MatchString(*lineText) { + lineText := line.Plain() + if p.searchPattern.MatchString(lineText) { return &lineNumber } diff --git m/pager_test.go m/pager_test.go index 65fa3c2..ce0f79b 100644 --- m/pager_test.go +++ m/pager_test.go @@ -265,13 +265,15 @@ func assertTokenRangesEqual(t *testing.T, actual []Token, expected []Token) { } func TestCreateScreenLineBase(t *testing.T) { - line := createScreenLine(0, 3, "", nil) - assert.Assert(t, len(line) == 0) + line := NewLine("") + screenLine := createScreenLine(0, 3, line, nil) + assert.Assert(t, len(screenLine) == 0) } func TestCreateScreenLineOverflowRight(t *testing.T) { - line := createScreenLine(0, 3, "012345", nil) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("012345") + screenLine := createScreenLine(0, 3, line, nil) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('0', tcell.StyleDefault), createExpectedCell('1', tcell.StyleDefault), createExpectedCell('>', tcell.StyleDefault.Reverse(true)), @@ -279,8 +281,9 @@ func TestCreateScreenLineOverflowRight(t *testing.T) { } func TestCreateScreenLineUnderflowLeft(t *testing.T) { - line := createScreenLine(1, 3, "012", nil) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("012") + screenLine := createScreenLine(1, 3, line, nil) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('1', tcell.StyleDefault), createExpectedCell('2', tcell.StyleDefault), @@ -293,8 +296,9 @@ func TestCreateScreenLineSearchHit(t *testing.T) { panic(err) } - line := createScreenLine(0, 3, "abc", pattern) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("abc") + screenLine := createScreenLine(0, 3, line, pattern) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('a', tcell.StyleDefault), createExpectedCell('b', tcell.StyleDefault.Reverse(true)), createExpectedCell('c', tcell.StyleDefault), @@ -307,8 +311,9 @@ func TestCreateScreenLineUtf8SearchHit(t *testing.T) { panic(err) } - line := createScreenLine(0, 3, "åäö", pattern) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("åäö") + screenLine := createScreenLine(0, 3, line, pattern) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), createExpectedCell('ö', tcell.StyleDefault), @@ -318,9 +323,10 @@ func TestCreateScreenLineUtf8SearchHit(t *testing.T) { func TestCreateScreenLineScrolledUtf8SearchHit(t *testing.T) { pattern := regexp.MustCompile("ä") - line := createScreenLine(1, 4, "ååäö", pattern) + line := NewLine("ååäö") + screenLine := createScreenLine(1, 4, line, pattern) - assertTokenRangesEqual(t, line, []Token{ + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), @@ -331,9 +337,10 @@ func TestCreateScreenLineScrolledUtf8SearchHit(t *testing.T) { func TestCreateScreenLineScrolled2Utf8SearchHit(t *testing.T) { pattern := regexp.MustCompile("ä") - line := createScreenLine(2, 4, "åååäö", pattern) + line := NewLine("åååäö") + screenLine := createScreenLine(2, 4, line, pattern) - assertTokenRangesEqual(t, line, []Token{ + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), diff --git m/reader.go m/reader.go index 418c4c5..d47b710 100644 --- m/reader.go +++ m/reader.go @@ -29,7 +29,7 @@ import ( // // This package provides query methods for the struct, no peeking!! type Reader struct { - lines []string + lines []*Line name *string lock *sync.Mutex err error @@ -41,7 +41,7 @@ type Reader struct { // Lines contains a number of lines from the reader, plus metadata type Lines struct { - lines []string + lines []*Line // One-based line number of the first line returned firstLineOneBased int @@ -136,7 +136,7 @@ func readStream(stream io.Reader, reader *Reader, fromFilter *exec.Cmd) { } reader.lock.Lock() - reader.lines = append(reader.lines, string(completeLine)) + reader.lines = append(reader.lines, NewLine(string(completeLine))) reader.lock.Unlock() // This is how to do a non-blocking write to a channel: @@ -172,7 +172,7 @@ func NewReaderFromStream(name string, reader io.Reader) *Reader { // If fromFilter is not nil this method will wait() for it, // and effectively takes over ownership for it. func newReaderFromStream(reader io.Reader, fromFilter *exec.Cmd) *Reader { - var lines []string + var lines []*Line var lock = &sync.Mutex{} done := make(chan bool, 1) @@ -201,9 +201,11 @@ func newReaderFromStream(reader io.Reader, fromFilter *exec.Cmd) *Reader { // Moar in the bottom left corner of the screen. func NewReaderFromText(name string, text string) *Reader { noExternalNewlines := strings.Trim(text, "\n") - lines := []string{} + lines := []*Line{} if len(noExternalNewlines) > 0 { - lines = strings.Split(noExternalNewlines, "\n") + for _, line := range strings.Split(noExternalNewlines, "\n") { + lines = append(lines, NewLine(line)) + } } done := make(chan bool, 1) done <- true @@ -380,7 +382,7 @@ func (r *Reader) GetLineCount() int { } // GetLine gets a line. If the requested line number is out of bounds, nil is returned. -func (r *Reader) GetLine(lineNumberOneBased int) *string { +func (r *Reader) GetLine(lineNumberOneBased int) *Line { r.lock.Lock() defer r.lock.Unlock() @@ -390,7 +392,7 @@ func (r *Reader) GetLine(lineNumberOneBased int) *string { if lineNumberOneBased > len(r.lines) { return nil } - return &r.lines[lineNumberOneBased-1] + return r.lines[lineNumberOneBased-1] } // GetLines gets the indicated lines from the input diff --git m/reader_test.go m/reader_test.go index 2ba7326..0e2aed2 100644 --- m/reader_test.go +++ m/reader_test.go @@ -158,8 +158,8 @@ func TestGetLongLine(t *testing.T) { assert.Equal(t, len(lines.lines), 1) line := lines.lines[0] - assert.Assert(t, strings.HasPrefix(line, "1 2 3 4"), "<%s>", line) - assert.Assert(t, strings.HasSuffix(line, "0123456789"), line) + assert.Assert(t, strings.HasPrefix(line.Plain(), "1 2 3 4"), "<%s>", line) + assert.Assert(t, strings.HasSuffix(line.Plain(), "0123456789"), line) stat, err := os.Stat(file) if err != nil { @@ -168,7 +168,7 @@ func TestGetLongLine(t *testing.T) { fileSize := stat.Size() // The "+1" is because the Reader strips off the ending linefeed - assert.Equal(t, len(line)+1, int(fileSize)) + assert.Equal(t, len(line.Plain())+1, int(fileSize)) } func getReaderWithLineCount(totalLines int) *Reader { @@ -219,7 +219,7 @@ func testCompressedFile(t *testing.T, filename string) { panic(err) } - assert.Equal(t, reader.GetLines(1, 5).lines[0], "This is a compressed file", "%s", filename) + assert.Equal(t, reader.GetLines(1, 5).lines[0].Plain(), "This is a compressed file", "%s", filename) } func TestCompressedFiles(t *testing.T) { Change-Id: Id8671001ec7c1038e2df0b87a45d346a1f1dd663
2021-01-11 12:42:34 +03:00
}
}
// Returns a representation of the string split into styled tokens. Any regexp
// matches are highlighted in inverse video. A nil regexp means no highlighting.
func (line *Line) HighlightedTokens(search *regexp.Regexp) []twin.Cell {
searchHitDelta := 0
plain := line.Plain()
matchRanges := getMatchRanges(&plain, search)
cells := cellsFromString(line.raw)
returnMe := make([]twin.Cell, 0, len(cells))
for _, token := range cells {
style := token.Style
if matchRanges.InRange(len(returnMe) + searchHitDelta) {
// Search hits in reverse video
style = style.WithAttr(twin.AttrReverse)
}
returnMe = append(returnMe, twin.Cell{
Rune: token.Rune,
Style: style,
})
}
return returnMe
Parse lines on demand and only once This improves line processing performance by 40%. Fixes #36. diff --git m/ansiTokenizer.go m/ansiTokenizer.go index d991e23..056a227 100644 --- m/ansiTokenizer.go +++ m/ansiTokenizer.go @@ -23,6 +23,44 @@ type Token struct { Style tcell.Style } +// A Line represents a line of text that can / will be paged +type Line struct { + raw *string + plain *string + tokens []Token +} + +// NewLine creates a new Line from a (potentially ANSI / man page formatted) string +func NewLine(raw string) *Line { + return &Line{ + raw: &raw, + plain: nil, + tokens: nil, + } +} + +// Tokens returns a representation of the string split into styled tokens +func (line *Line) Tokens() []Token { + line.parse() + return line.tokens +} + +// Plain returns a plain text representation of the initial string +func (line *Line) Plain() string { + line.parse() + return *line.plain +} + +func (line *Line) parse() { + if line.raw == nil { + // Already done + return + } + + line.tokens, line.plain = tokensFromString(*line.raw) + line.raw = nil +} + // SetManPageFormatFromEnv parses LESS_TERMCAP_xx environment variables and // adapts the moar output accordingly. func SetManPageFormatFromEnv() { diff --git m/pager.go m/pager.go index 412e05b..98efa9a 100644 --- m/pager.go +++ m/pager.go @@ -111,7 +111,7 @@ func NewPager(r *Reader) *Pager { } } -func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNumber int, line string) { +func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNumber int, line *Line) { screenWidth, _ := p.screen.Size() prefixLength := 0 @@ -138,7 +138,7 @@ func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNum func createScreenLine( stringIndexAtColumnZero int, screenColumnsCount int, - line string, + line *Line, search *regexp.Regexp, ) []Token { var returnMe []Token @@ -152,14 +152,14 @@ func createScreenLine( searchHitDelta = -1 } - tokens, plainString := tokensFromString(line) - if stringIndexAtColumnZero >= len(tokens) { + if stringIndexAtColumnZero >= len(line.Tokens()) { // Nothing (more) to display, never mind return returnMe } - matchRanges := getMatchRanges(plainString, search) - for _, token := range tokens[stringIndexAtColumnZero:] { + plain := line.Plain() + matchRanges := getMatchRanges(&plain, search) + for _, token := range line.Tokens()[stringIndexAtColumnZero:] { if len(returnMe) >= screenColumnsCount { // We are trying to add a character to the right of the screen. // Indicate that this line continues to the right. @@ -232,7 +232,8 @@ func (p *Pager) _AddLines(spinner string) { // This happens when we're done eofSpinner = "---" } - p._AddLine(nil, 0, screenLineNumber, _EofMarkerFormat+eofSpinner) + spinnerLine := NewLine(_EofMarkerFormat + eofSpinner) + p._AddLine(nil, 0, screenLineNumber, spinnerLine) switch p.mode { case _Searching: @@ -329,8 +330,8 @@ func (p *Pager) _FindFirstHitLineOneBased(firstLineOneBased int, backwards bool) return nil } - _, lineText := tokensFromString(*line) - if p.searchPattern.MatchString(*lineText) { + lineText := line.Plain() + if p.searchPattern.MatchString(lineText) { return &lineNumber } diff --git m/pager_test.go m/pager_test.go index 65fa3c2..ce0f79b 100644 --- m/pager_test.go +++ m/pager_test.go @@ -265,13 +265,15 @@ func assertTokenRangesEqual(t *testing.T, actual []Token, expected []Token) { } func TestCreateScreenLineBase(t *testing.T) { - line := createScreenLine(0, 3, "", nil) - assert.Assert(t, len(line) == 0) + line := NewLine("") + screenLine := createScreenLine(0, 3, line, nil) + assert.Assert(t, len(screenLine) == 0) } func TestCreateScreenLineOverflowRight(t *testing.T) { - line := createScreenLine(0, 3, "012345", nil) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("012345") + screenLine := createScreenLine(0, 3, line, nil) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('0', tcell.StyleDefault), createExpectedCell('1', tcell.StyleDefault), createExpectedCell('>', tcell.StyleDefault.Reverse(true)), @@ -279,8 +281,9 @@ func TestCreateScreenLineOverflowRight(t *testing.T) { } func TestCreateScreenLineUnderflowLeft(t *testing.T) { - line := createScreenLine(1, 3, "012", nil) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("012") + screenLine := createScreenLine(1, 3, line, nil) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('1', tcell.StyleDefault), createExpectedCell('2', tcell.StyleDefault), @@ -293,8 +296,9 @@ func TestCreateScreenLineSearchHit(t *testing.T) { panic(err) } - line := createScreenLine(0, 3, "abc", pattern) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("abc") + screenLine := createScreenLine(0, 3, line, pattern) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('a', tcell.StyleDefault), createExpectedCell('b', tcell.StyleDefault.Reverse(true)), createExpectedCell('c', tcell.StyleDefault), @@ -307,8 +311,9 @@ func TestCreateScreenLineUtf8SearchHit(t *testing.T) { panic(err) } - line := createScreenLine(0, 3, "åäö", pattern) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("åäö") + screenLine := createScreenLine(0, 3, line, pattern) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), createExpectedCell('ö', tcell.StyleDefault), @@ -318,9 +323,10 @@ func TestCreateScreenLineUtf8SearchHit(t *testing.T) { func TestCreateScreenLineScrolledUtf8SearchHit(t *testing.T) { pattern := regexp.MustCompile("ä") - line := createScreenLine(1, 4, "ååäö", pattern) + line := NewLine("ååäö") + screenLine := createScreenLine(1, 4, line, pattern) - assertTokenRangesEqual(t, line, []Token{ + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), @@ -331,9 +337,10 @@ func TestCreateScreenLineScrolledUtf8SearchHit(t *testing.T) { func TestCreateScreenLineScrolled2Utf8SearchHit(t *testing.T) { pattern := regexp.MustCompile("ä") - line := createScreenLine(2, 4, "åååäö", pattern) + line := NewLine("åååäö") + screenLine := createScreenLine(2, 4, line, pattern) - assertTokenRangesEqual(t, line, []Token{ + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), diff --git m/reader.go m/reader.go index 418c4c5..d47b710 100644 --- m/reader.go +++ m/reader.go @@ -29,7 +29,7 @@ import ( // // This package provides query methods for the struct, no peeking!! type Reader struct { - lines []string + lines []*Line name *string lock *sync.Mutex err error @@ -41,7 +41,7 @@ type Reader struct { // Lines contains a number of lines from the reader, plus metadata type Lines struct { - lines []string + lines []*Line // One-based line number of the first line returned firstLineOneBased int @@ -136,7 +136,7 @@ func readStream(stream io.Reader, reader *Reader, fromFilter *exec.Cmd) { } reader.lock.Lock() - reader.lines = append(reader.lines, string(completeLine)) + reader.lines = append(reader.lines, NewLine(string(completeLine))) reader.lock.Unlock() // This is how to do a non-blocking write to a channel: @@ -172,7 +172,7 @@ func NewReaderFromStream(name string, reader io.Reader) *Reader { // If fromFilter is not nil this method will wait() for it, // and effectively takes over ownership for it. func newReaderFromStream(reader io.Reader, fromFilter *exec.Cmd) *Reader { - var lines []string + var lines []*Line var lock = &sync.Mutex{} done := make(chan bool, 1) @@ -201,9 +201,11 @@ func newReaderFromStream(reader io.Reader, fromFilter *exec.Cmd) *Reader { // Moar in the bottom left corner of the screen. func NewReaderFromText(name string, text string) *Reader { noExternalNewlines := strings.Trim(text, "\n") - lines := []string{} + lines := []*Line{} if len(noExternalNewlines) > 0 { - lines = strings.Split(noExternalNewlines, "\n") + for _, line := range strings.Split(noExternalNewlines, "\n") { + lines = append(lines, NewLine(line)) + } } done := make(chan bool, 1) done <- true @@ -380,7 +382,7 @@ func (r *Reader) GetLineCount() int { } // GetLine gets a line. If the requested line number is out of bounds, nil is returned. -func (r *Reader) GetLine(lineNumberOneBased int) *string { +func (r *Reader) GetLine(lineNumberOneBased int) *Line { r.lock.Lock() defer r.lock.Unlock() @@ -390,7 +392,7 @@ func (r *Reader) GetLine(lineNumberOneBased int) *string { if lineNumberOneBased > len(r.lines) { return nil } - return &r.lines[lineNumberOneBased-1] + return r.lines[lineNumberOneBased-1] } // GetLines gets the indicated lines from the input diff --git m/reader_test.go m/reader_test.go index 2ba7326..0e2aed2 100644 --- m/reader_test.go +++ m/reader_test.go @@ -158,8 +158,8 @@ func TestGetLongLine(t *testing.T) { assert.Equal(t, len(lines.lines), 1) line := lines.lines[0] - assert.Assert(t, strings.HasPrefix(line, "1 2 3 4"), "<%s>", line) - assert.Assert(t, strings.HasSuffix(line, "0123456789"), line) + assert.Assert(t, strings.HasPrefix(line.Plain(), "1 2 3 4"), "<%s>", line) + assert.Assert(t, strings.HasSuffix(line.Plain(), "0123456789"), line) stat, err := os.Stat(file) if err != nil { @@ -168,7 +168,7 @@ func TestGetLongLine(t *testing.T) { fileSize := stat.Size() // The "+1" is because the Reader strips off the ending linefeed - assert.Equal(t, len(line)+1, int(fileSize)) + assert.Equal(t, len(line.Plain())+1, int(fileSize)) } func getReaderWithLineCount(totalLines int) *Reader { @@ -219,7 +219,7 @@ func testCompressedFile(t *testing.T, filename string) { panic(err) } - assert.Equal(t, reader.GetLines(1, 5).lines[0], "This is a compressed file", "%s", filename) + assert.Equal(t, reader.GetLines(1, 5).lines[0].Plain(), "This is a compressed file", "%s", filename) } func TestCompressedFiles(t *testing.T) { Change-Id: Id8671001ec7c1038e2df0b87a45d346a1f1dd663
2021-01-11 12:42:34 +03:00
}
// Plain returns a plain text representation of the initial string
func (line *Line) Plain() string {
if line.plain == nil {
plain := withoutFormatting(line.raw)
line.plain = &plain
Parse lines on demand and only once This improves line processing performance by 40%. Fixes #36. diff --git m/ansiTokenizer.go m/ansiTokenizer.go index d991e23..056a227 100644 --- m/ansiTokenizer.go +++ m/ansiTokenizer.go @@ -23,6 +23,44 @@ type Token struct { Style tcell.Style } +// A Line represents a line of text that can / will be paged +type Line struct { + raw *string + plain *string + tokens []Token +} + +// NewLine creates a new Line from a (potentially ANSI / man page formatted) string +func NewLine(raw string) *Line { + return &Line{ + raw: &raw, + plain: nil, + tokens: nil, + } +} + +// Tokens returns a representation of the string split into styled tokens +func (line *Line) Tokens() []Token { + line.parse() + return line.tokens +} + +// Plain returns a plain text representation of the initial string +func (line *Line) Plain() string { + line.parse() + return *line.plain +} + +func (line *Line) parse() { + if line.raw == nil { + // Already done + return + } + + line.tokens, line.plain = tokensFromString(*line.raw) + line.raw = nil +} + // SetManPageFormatFromEnv parses LESS_TERMCAP_xx environment variables and // adapts the moar output accordingly. func SetManPageFormatFromEnv() { diff --git m/pager.go m/pager.go index 412e05b..98efa9a 100644 --- m/pager.go +++ m/pager.go @@ -111,7 +111,7 @@ func NewPager(r *Reader) *Pager { } } -func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNumber int, line string) { +func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNumber int, line *Line) { screenWidth, _ := p.screen.Size() prefixLength := 0 @@ -138,7 +138,7 @@ func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNum func createScreenLine( stringIndexAtColumnZero int, screenColumnsCount int, - line string, + line *Line, search *regexp.Regexp, ) []Token { var returnMe []Token @@ -152,14 +152,14 @@ func createScreenLine( searchHitDelta = -1 } - tokens, plainString := tokensFromString(line) - if stringIndexAtColumnZero >= len(tokens) { + if stringIndexAtColumnZero >= len(line.Tokens()) { // Nothing (more) to display, never mind return returnMe } - matchRanges := getMatchRanges(plainString, search) - for _, token := range tokens[stringIndexAtColumnZero:] { + plain := line.Plain() + matchRanges := getMatchRanges(&plain, search) + for _, token := range line.Tokens()[stringIndexAtColumnZero:] { if len(returnMe) >= screenColumnsCount { // We are trying to add a character to the right of the screen. // Indicate that this line continues to the right. @@ -232,7 +232,8 @@ func (p *Pager) _AddLines(spinner string) { // This happens when we're done eofSpinner = "---" } - p._AddLine(nil, 0, screenLineNumber, _EofMarkerFormat+eofSpinner) + spinnerLine := NewLine(_EofMarkerFormat + eofSpinner) + p._AddLine(nil, 0, screenLineNumber, spinnerLine) switch p.mode { case _Searching: @@ -329,8 +330,8 @@ func (p *Pager) _FindFirstHitLineOneBased(firstLineOneBased int, backwards bool) return nil } - _, lineText := tokensFromString(*line) - if p.searchPattern.MatchString(*lineText) { + lineText := line.Plain() + if p.searchPattern.MatchString(lineText) { return &lineNumber } diff --git m/pager_test.go m/pager_test.go index 65fa3c2..ce0f79b 100644 --- m/pager_test.go +++ m/pager_test.go @@ -265,13 +265,15 @@ func assertTokenRangesEqual(t *testing.T, actual []Token, expected []Token) { } func TestCreateScreenLineBase(t *testing.T) { - line := createScreenLine(0, 3, "", nil) - assert.Assert(t, len(line) == 0) + line := NewLine("") + screenLine := createScreenLine(0, 3, line, nil) + assert.Assert(t, len(screenLine) == 0) } func TestCreateScreenLineOverflowRight(t *testing.T) { - line := createScreenLine(0, 3, "012345", nil) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("012345") + screenLine := createScreenLine(0, 3, line, nil) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('0', tcell.StyleDefault), createExpectedCell('1', tcell.StyleDefault), createExpectedCell('>', tcell.StyleDefault.Reverse(true)), @@ -279,8 +281,9 @@ func TestCreateScreenLineOverflowRight(t *testing.T) { } func TestCreateScreenLineUnderflowLeft(t *testing.T) { - line := createScreenLine(1, 3, "012", nil) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("012") + screenLine := createScreenLine(1, 3, line, nil) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('1', tcell.StyleDefault), createExpectedCell('2', tcell.StyleDefault), @@ -293,8 +296,9 @@ func TestCreateScreenLineSearchHit(t *testing.T) { panic(err) } - line := createScreenLine(0, 3, "abc", pattern) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("abc") + screenLine := createScreenLine(0, 3, line, pattern) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('a', tcell.StyleDefault), createExpectedCell('b', tcell.StyleDefault.Reverse(true)), createExpectedCell('c', tcell.StyleDefault), @@ -307,8 +311,9 @@ func TestCreateScreenLineUtf8SearchHit(t *testing.T) { panic(err) } - line := createScreenLine(0, 3, "åäö", pattern) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("åäö") + screenLine := createScreenLine(0, 3, line, pattern) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), createExpectedCell('ö', tcell.StyleDefault), @@ -318,9 +323,10 @@ func TestCreateScreenLineUtf8SearchHit(t *testing.T) { func TestCreateScreenLineScrolledUtf8SearchHit(t *testing.T) { pattern := regexp.MustCompile("ä") - line := createScreenLine(1, 4, "ååäö", pattern) + line := NewLine("ååäö") + screenLine := createScreenLine(1, 4, line, pattern) - assertTokenRangesEqual(t, line, []Token{ + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), @@ -331,9 +337,10 @@ func TestCreateScreenLineScrolledUtf8SearchHit(t *testing.T) { func TestCreateScreenLineScrolled2Utf8SearchHit(t *testing.T) { pattern := regexp.MustCompile("ä") - line := createScreenLine(2, 4, "åååäö", pattern) + line := NewLine("åååäö") + screenLine := createScreenLine(2, 4, line, pattern) - assertTokenRangesEqual(t, line, []Token{ + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), diff --git m/reader.go m/reader.go index 418c4c5..d47b710 100644 --- m/reader.go +++ m/reader.go @@ -29,7 +29,7 @@ import ( // // This package provides query methods for the struct, no peeking!! type Reader struct { - lines []string + lines []*Line name *string lock *sync.Mutex err error @@ -41,7 +41,7 @@ type Reader struct { // Lines contains a number of lines from the reader, plus metadata type Lines struct { - lines []string + lines []*Line // One-based line number of the first line returned firstLineOneBased int @@ -136,7 +136,7 @@ func readStream(stream io.Reader, reader *Reader, fromFilter *exec.Cmd) { } reader.lock.Lock() - reader.lines = append(reader.lines, string(completeLine)) + reader.lines = append(reader.lines, NewLine(string(completeLine))) reader.lock.Unlock() // This is how to do a non-blocking write to a channel: @@ -172,7 +172,7 @@ func NewReaderFromStream(name string, reader io.Reader) *Reader { // If fromFilter is not nil this method will wait() for it, // and effectively takes over ownership for it. func newReaderFromStream(reader io.Reader, fromFilter *exec.Cmd) *Reader { - var lines []string + var lines []*Line var lock = &sync.Mutex{} done := make(chan bool, 1) @@ -201,9 +201,11 @@ func newReaderFromStream(reader io.Reader, fromFilter *exec.Cmd) *Reader { // Moar in the bottom left corner of the screen. func NewReaderFromText(name string, text string) *Reader { noExternalNewlines := strings.Trim(text, "\n") - lines := []string{} + lines := []*Line{} if len(noExternalNewlines) > 0 { - lines = strings.Split(noExternalNewlines, "\n") + for _, line := range strings.Split(noExternalNewlines, "\n") { + lines = append(lines, NewLine(line)) + } } done := make(chan bool, 1) done <- true @@ -380,7 +382,7 @@ func (r *Reader) GetLineCount() int { } // GetLine gets a line. If the requested line number is out of bounds, nil is returned. -func (r *Reader) GetLine(lineNumberOneBased int) *string { +func (r *Reader) GetLine(lineNumberOneBased int) *Line { r.lock.Lock() defer r.lock.Unlock() @@ -390,7 +392,7 @@ func (r *Reader) GetLine(lineNumberOneBased int) *string { if lineNumberOneBased > len(r.lines) { return nil } - return &r.lines[lineNumberOneBased-1] + return r.lines[lineNumberOneBased-1] } // GetLines gets the indicated lines from the input diff --git m/reader_test.go m/reader_test.go index 2ba7326..0e2aed2 100644 --- m/reader_test.go +++ m/reader_test.go @@ -158,8 +158,8 @@ func TestGetLongLine(t *testing.T) { assert.Equal(t, len(lines.lines), 1) line := lines.lines[0] - assert.Assert(t, strings.HasPrefix(line, "1 2 3 4"), "<%s>", line) - assert.Assert(t, strings.HasSuffix(line, "0123456789"), line) + assert.Assert(t, strings.HasPrefix(line.Plain(), "1 2 3 4"), "<%s>", line) + assert.Assert(t, strings.HasSuffix(line.Plain(), "0123456789"), line) stat, err := os.Stat(file) if err != nil { @@ -168,7 +168,7 @@ func TestGetLongLine(t *testing.T) { fileSize := stat.Size() // The "+1" is because the Reader strips off the ending linefeed - assert.Equal(t, len(line)+1, int(fileSize)) + assert.Equal(t, len(line.Plain())+1, int(fileSize)) } func getReaderWithLineCount(totalLines int) *Reader { @@ -219,7 +219,7 @@ func testCompressedFile(t *testing.T, filename string) { panic(err) } - assert.Equal(t, reader.GetLines(1, 5).lines[0], "This is a compressed file", "%s", filename) + assert.Equal(t, reader.GetLines(1, 5).lines[0].Plain(), "This is a compressed file", "%s", filename) } func TestCompressedFiles(t *testing.T) { Change-Id: Id8671001ec7c1038e2df0b87a45d346a1f1dd663
2021-01-11 12:42:34 +03:00
}
return *line.plain
Parse lines on demand and only once This improves line processing performance by 40%. Fixes #36. diff --git m/ansiTokenizer.go m/ansiTokenizer.go index d991e23..056a227 100644 --- m/ansiTokenizer.go +++ m/ansiTokenizer.go @@ -23,6 +23,44 @@ type Token struct { Style tcell.Style } +// A Line represents a line of text that can / will be paged +type Line struct { + raw *string + plain *string + tokens []Token +} + +// NewLine creates a new Line from a (potentially ANSI / man page formatted) string +func NewLine(raw string) *Line { + return &Line{ + raw: &raw, + plain: nil, + tokens: nil, + } +} + +// Tokens returns a representation of the string split into styled tokens +func (line *Line) Tokens() []Token { + line.parse() + return line.tokens +} + +// Plain returns a plain text representation of the initial string +func (line *Line) Plain() string { + line.parse() + return *line.plain +} + +func (line *Line) parse() { + if line.raw == nil { + // Already done + return + } + + line.tokens, line.plain = tokensFromString(*line.raw) + line.raw = nil +} + // SetManPageFormatFromEnv parses LESS_TERMCAP_xx environment variables and // adapts the moar output accordingly. func SetManPageFormatFromEnv() { diff --git m/pager.go m/pager.go index 412e05b..98efa9a 100644 --- m/pager.go +++ m/pager.go @@ -111,7 +111,7 @@ func NewPager(r *Reader) *Pager { } } -func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNumber int, line string) { +func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNumber int, line *Line) { screenWidth, _ := p.screen.Size() prefixLength := 0 @@ -138,7 +138,7 @@ func (p *Pager) _AddLine(fileLineNumber *int, maxPrefixLength int, screenLineNum func createScreenLine( stringIndexAtColumnZero int, screenColumnsCount int, - line string, + line *Line, search *regexp.Regexp, ) []Token { var returnMe []Token @@ -152,14 +152,14 @@ func createScreenLine( searchHitDelta = -1 } - tokens, plainString := tokensFromString(line) - if stringIndexAtColumnZero >= len(tokens) { + if stringIndexAtColumnZero >= len(line.Tokens()) { // Nothing (more) to display, never mind return returnMe } - matchRanges := getMatchRanges(plainString, search) - for _, token := range tokens[stringIndexAtColumnZero:] { + plain := line.Plain() + matchRanges := getMatchRanges(&plain, search) + for _, token := range line.Tokens()[stringIndexAtColumnZero:] { if len(returnMe) >= screenColumnsCount { // We are trying to add a character to the right of the screen. // Indicate that this line continues to the right. @@ -232,7 +232,8 @@ func (p *Pager) _AddLines(spinner string) { // This happens when we're done eofSpinner = "---" } - p._AddLine(nil, 0, screenLineNumber, _EofMarkerFormat+eofSpinner) + spinnerLine := NewLine(_EofMarkerFormat + eofSpinner) + p._AddLine(nil, 0, screenLineNumber, spinnerLine) switch p.mode { case _Searching: @@ -329,8 +330,8 @@ func (p *Pager) _FindFirstHitLineOneBased(firstLineOneBased int, backwards bool) return nil } - _, lineText := tokensFromString(*line) - if p.searchPattern.MatchString(*lineText) { + lineText := line.Plain() + if p.searchPattern.MatchString(lineText) { return &lineNumber } diff --git m/pager_test.go m/pager_test.go index 65fa3c2..ce0f79b 100644 --- m/pager_test.go +++ m/pager_test.go @@ -265,13 +265,15 @@ func assertTokenRangesEqual(t *testing.T, actual []Token, expected []Token) { } func TestCreateScreenLineBase(t *testing.T) { - line := createScreenLine(0, 3, "", nil) - assert.Assert(t, len(line) == 0) + line := NewLine("") + screenLine := createScreenLine(0, 3, line, nil) + assert.Assert(t, len(screenLine) == 0) } func TestCreateScreenLineOverflowRight(t *testing.T) { - line := createScreenLine(0, 3, "012345", nil) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("012345") + screenLine := createScreenLine(0, 3, line, nil) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('0', tcell.StyleDefault), createExpectedCell('1', tcell.StyleDefault), createExpectedCell('>', tcell.StyleDefault.Reverse(true)), @@ -279,8 +281,9 @@ func TestCreateScreenLineOverflowRight(t *testing.T) { } func TestCreateScreenLineUnderflowLeft(t *testing.T) { - line := createScreenLine(1, 3, "012", nil) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("012") + screenLine := createScreenLine(1, 3, line, nil) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('1', tcell.StyleDefault), createExpectedCell('2', tcell.StyleDefault), @@ -293,8 +296,9 @@ func TestCreateScreenLineSearchHit(t *testing.T) { panic(err) } - line := createScreenLine(0, 3, "abc", pattern) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("abc") + screenLine := createScreenLine(0, 3, line, pattern) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('a', tcell.StyleDefault), createExpectedCell('b', tcell.StyleDefault.Reverse(true)), createExpectedCell('c', tcell.StyleDefault), @@ -307,8 +311,9 @@ func TestCreateScreenLineUtf8SearchHit(t *testing.T) { panic(err) } - line := createScreenLine(0, 3, "åäö", pattern) - assertTokenRangesEqual(t, line, []Token{ + line := NewLine("åäö") + screenLine := createScreenLine(0, 3, line, pattern) + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), createExpectedCell('ö', tcell.StyleDefault), @@ -318,9 +323,10 @@ func TestCreateScreenLineUtf8SearchHit(t *testing.T) { func TestCreateScreenLineScrolledUtf8SearchHit(t *testing.T) { pattern := regexp.MustCompile("ä") - line := createScreenLine(1, 4, "ååäö", pattern) + line := NewLine("ååäö") + screenLine := createScreenLine(1, 4, line, pattern) - assertTokenRangesEqual(t, line, []Token{ + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), @@ -331,9 +337,10 @@ func TestCreateScreenLineScrolledUtf8SearchHit(t *testing.T) { func TestCreateScreenLineScrolled2Utf8SearchHit(t *testing.T) { pattern := regexp.MustCompile("ä") - line := createScreenLine(2, 4, "åååäö", pattern) + line := NewLine("åååäö") + screenLine := createScreenLine(2, 4, line, pattern) - assertTokenRangesEqual(t, line, []Token{ + assertTokenRangesEqual(t, screenLine, []Token{ createExpectedCell('<', tcell.StyleDefault.Reverse(true)), createExpectedCell('å', tcell.StyleDefault), createExpectedCell('ä', tcell.StyleDefault.Reverse(true)), diff --git m/reader.go m/reader.go index 418c4c5..d47b710 100644 --- m/reader.go +++ m/reader.go @@ -29,7 +29,7 @@ import ( // // This package provides query methods for the struct, no peeking!! type Reader struct { - lines []string + lines []*Line name *string lock *sync.Mutex err error @@ -41,7 +41,7 @@ type Reader struct { // Lines contains a number of lines from the reader, plus metadata type Lines struct { - lines []string + lines []*Line // One-based line number of the first line returned firstLineOneBased int @@ -136,7 +136,7 @@ func readStream(stream io.Reader, reader *Reader, fromFilter *exec.Cmd) { } reader.lock.Lock() - reader.lines = append(reader.lines, string(completeLine)) + reader.lines = append(reader.lines, NewLine(string(completeLine))) reader.lock.Unlock() // This is how to do a non-blocking write to a channel: @@ -172,7 +172,7 @@ func NewReaderFromStream(name string, reader io.Reader) *Reader { // If fromFilter is not nil this method will wait() for it, // and effectively takes over ownership for it. func newReaderFromStream(reader io.Reader, fromFilter *exec.Cmd) *Reader { - var lines []string + var lines []*Line var lock = &sync.Mutex{} done := make(chan bool, 1) @@ -201,9 +201,11 @@ func newReaderFromStream(reader io.Reader, fromFilter *exec.Cmd) *Reader { // Moar in the bottom left corner of the screen. func NewReaderFromText(name string, text string) *Reader { noExternalNewlines := strings.Trim(text, "\n") - lines := []string{} + lines := []*Line{} if len(noExternalNewlines) > 0 { - lines = strings.Split(noExternalNewlines, "\n") + for _, line := range strings.Split(noExternalNewlines, "\n") { + lines = append(lines, NewLine(line)) + } } done := make(chan bool, 1) done <- true @@ -380,7 +382,7 @@ func (r *Reader) GetLineCount() int { } // GetLine gets a line. If the requested line number is out of bounds, nil is returned. -func (r *Reader) GetLine(lineNumberOneBased int) *string { +func (r *Reader) GetLine(lineNumberOneBased int) *Line { r.lock.Lock() defer r.lock.Unlock() @@ -390,7 +392,7 @@ func (r *Reader) GetLine(lineNumberOneBased int) *string { if lineNumberOneBased > len(r.lines) { return nil } - return &r.lines[lineNumberOneBased-1] + return r.lines[lineNumberOneBased-1] } // GetLines gets the indicated lines from the input diff --git m/reader_test.go m/reader_test.go index 2ba7326..0e2aed2 100644 --- m/reader_test.go +++ m/reader_test.go @@ -158,8 +158,8 @@ func TestGetLongLine(t *testing.T) { assert.Equal(t, len(lines.lines), 1) line := lines.lines[0] - assert.Assert(t, strings.HasPrefix(line, "1 2 3 4"), "<%s>", line) - assert.Assert(t, strings.HasSuffix(line, "0123456789"), line) + assert.Assert(t, strings.HasPrefix(line.Plain(), "1 2 3 4"), "<%s>", line) + assert.Assert(t, strings.HasSuffix(line.Plain(), "0123456789"), line) stat, err := os.Stat(file) if err != nil { @@ -168,7 +168,7 @@ func TestGetLongLine(t *testing.T) { fileSize := stat.Size() // The "+1" is because the Reader strips off the ending linefeed - assert.Equal(t, len(line)+1, int(fileSize)) + assert.Equal(t, len(line.Plain())+1, int(fileSize)) } func getReaderWithLineCount(totalLines int) *Reader { @@ -219,7 +219,7 @@ func testCompressedFile(t *testing.T, filename string) { panic(err) } - assert.Equal(t, reader.GetLines(1, 5).lines[0], "This is a compressed file", "%s", filename) + assert.Equal(t, reader.GetLines(1, 5).lines[0].Plain(), "This is a compressed file", "%s", filename) } func TestCompressedFiles(t *testing.T) { Change-Id: Id8671001ec7c1038e2df0b87a45d346a1f1dd663
2021-01-11 12:42:34 +03:00
}
// SetManPageFormatFromEnv parses LESS_TERMCAP_xx environment variables and
// adapts the moar output accordingly.
func SetManPageFormatFromEnv() {
// Requested here: https://github.com/walles/moar/issues/14
lessTermcapMd := os.Getenv("LESS_TERMCAP_md")
if lessTermcapMd != "" {
manPageBold = termcapToStyle(lessTermcapMd)
}
lessTermcapUs := os.Getenv("LESS_TERMCAP_us")
if lessTermcapUs != "" {
manPageUnderline = termcapToStyle(lessTermcapUs)
}
}
2021-04-15 16:16:06 +03:00
func termcapToStyle(termcap string) twin.Style {
// Add a character to be sure we have one to take the format from
cells := cellsFromString(termcap + "x")
2021-04-15 16:16:06 +03:00
return cells[len(cells)-1].Style
}
func isPlain(s string) bool {
for i := 0; i < len(s); i++ {
byteAtIndex := s[i]
if byteAtIndex < 32 {
return false
}
if byteAtIndex > 127 {
return false
}
}
return true
}
func withoutFormatting(s string) string {
if isPlain(s) {
return s
}
stripped := strings.Builder{}
runeCount := 0
stripped.Grow(len(s))
for _, styledString := range styledStringsFromString(s) {
for _, runeValue := range runesFromStyledString(styledString) {
switch runeValue {
case '\x09': // TAB
for {
stripped.WriteRune(' ')
runeCount++
if runeCount%_TabSize == 0 {
// We arrived at the next tab stop
break
}
}
case '<27>': // Go's broken-UTF8 marker
stripped.WriteRune('?')
runeCount++
case BACKSPACE:
stripped.WriteRune('<')
runeCount++
default:
if !unicode.IsPrint(runeValue) {
stripped.WriteRune('?')
runeCount++
continue
}
stripped.WriteRune(runeValue)
runeCount++
}
}
}
return stripped.String()
}
// Turn a (formatted) string into a series of screen cells
func cellsFromString(s string) []twin.Cell {
var cells []twin.Cell
2021-04-15 16:16:06 +03:00
// Specs: https://en.wikipedia.org/wiki/ANSI_escape_code#3-bit_and_4-bit
styleBrokenUtf8 := twin.StyleDefault.Background(twin.NewColor16(1)).Foreground(twin.NewColor16(7))
for _, styledString := range styledStringsFromString(s) {
for _, token := range tokensFromStyledString(styledString) {
2019-06-27 22:39:46 +03:00
switch token.Rune {
case '\x09': // TAB
2019-06-17 22:39:57 +03:00
for {
cells = append(cells, twin.Cell{
2019-06-17 22:39:57 +03:00
Rune: ' ',
Style: styledString.Style,
})
if (len(cells))%_TabSize == 0 {
2019-06-17 22:39:57 +03:00
// We arrived at the next tab stop
break
}
}
case '<27>': // Go's broken-UTF8 marker
cells = append(cells, twin.Cell{
Rune: '?',
Style: styleBrokenUtf8,
})
case BACKSPACE:
cells = append(cells, twin.Cell{
2019-06-27 22:39:46 +03:00
Rune: '<',
Style: styleBrokenUtf8,
2019-06-17 22:39:57 +03:00
})
2019-06-27 22:39:46 +03:00
default:
if !unicode.IsPrint(token.Rune) {
cells = append(cells, twin.Cell{
Rune: '?',
Style: styleBrokenUtf8,
})
continue
}
cells = append(cells, token)
2019-06-27 22:39:46 +03:00
}
}
}
return cells
2019-06-27 22:39:46 +03:00
}
// Consume 'x<x', where '<' is backspace and the result is a bold 'x'
2021-04-15 16:16:06 +03:00
func consumeBold(runes []rune, index int) (int, *twin.Cell) {
if index+2 >= len(runes) {
// Not enough runes left for a bold
return index, nil
}
2019-06-27 22:39:46 +03:00
if runes[index+1] != BACKSPACE {
// No backspace in the middle, never mind
return index, nil
}
2019-06-27 22:39:46 +03:00
if runes[index] != runes[index+2] {
// First and last rune not the same, never mind
return index, nil
}
// We have a match!
2021-04-15 16:16:06 +03:00
return index + 3, &twin.Cell{
Rune: runes[index],
Style: manPageBold,
}
}
2019-06-27 22:39:46 +03:00
// Consume '_<x', where '<' is backspace and the result is an underlined 'x'
2021-04-15 16:16:06 +03:00
func consumeUnderline(runes []rune, index int) (int, *twin.Cell) {
if index+2 >= len(runes) {
// Not enough runes left for a underline
return index, nil
}
if runes[index+1] != BACKSPACE {
// No backspace in the middle, never mind
return index, nil
}
if runes[index] != '_' {
// No underline, never mind
return index, nil
}
// We have a match!
2021-04-15 16:16:06 +03:00
return index + 3, &twin.Cell{
Rune: runes[index+2],
Style: manPageUnderline,
}
}
// Consume '+<+<o<o' / '+<o', where '<' is backspace and the result is a unicode bullet.
//
// Used on man pages, try "man printf" on macOS for one example.
2021-04-15 16:16:06 +03:00
func consumeBullet(runes []rune, index int) (int, *twin.Cell) {
2021-04-14 22:41:56 +03:00
patterns := [][]byte{[]byte("+\bo"), []byte("+\b+\bo\bo")}
for _, pattern := range patterns {
if index+len(pattern) > len(runes) {
// Not enough runes left for a bullet
continue
}
mismatch := false
2021-04-14 22:41:56 +03:00
for delta, patternByte := range pattern {
if patternByte != byte(runes[index+delta]) {
// Bullet pattern mismatch, never mind
mismatch = true
2021-04-14 22:41:56 +03:00
break
}
}
if mismatch {
continue
}
// We have a match!
2021-04-15 16:16:06 +03:00
return index + len(pattern), &twin.Cell{
Rune: '•', // Unicode bullet point
2021-04-15 16:16:06 +03:00
Style: twin.StyleDefault,
}
}
return index, nil
}
func runesFromStyledString(styledString _StyledString) string {
hasBackspace := false
for _, byteValue := range []byte(styledString.String) {
if byteValue == BACKSPACE {
hasBackspace = true
break
}
}
if !hasBackspace {
// Shortcut when there's no backspace based formatting to worry about
return styledString.String
}
// Special handling for man page formatted lines
cells := tokensFromStyledString(styledString)
returnMe := strings.Builder{}
returnMe.Grow(len(cells))
for _, cell := range cells {
returnMe.WriteRune(cell.Rune)
}
return returnMe.String()
}
2021-04-15 16:16:06 +03:00
func tokensFromStyledString(styledString _StyledString) []twin.Cell {
runes := []rune(styledString.String)
hasBackspace := false
for _, runeValue := range runes {
if runeValue == BACKSPACE {
hasBackspace = true
break
}
}
2021-04-15 16:16:06 +03:00
tokens := make([]twin.Cell, 0, len(runes))
if !hasBackspace {
// Shortcut when there's no backspace based formatting to worry about
for _, runeValue := range runes {
tokens = append(tokens, twin.Cell{
Rune: runeValue,
Style: styledString.Style,
})
}
return tokens
}
// Special handling for man page formatted lines
for index := 0; index < len(runes); index++ {
nextIndex, token := consumeBullet(runes, index)
if nextIndex != index {
tokens = append(tokens, *token)
index = nextIndex - 1
continue
}
nextIndex, token = consumeBold(runes, index)
if nextIndex != index {
tokens = append(tokens, *token)
index = nextIndex - 1
continue
}
nextIndex, token = consumeUnderline(runes, index)
if nextIndex != index {
tokens = append(tokens, *token)
index = nextIndex - 1
continue
}
2019-06-27 22:39:46 +03:00
2021-04-15 16:16:06 +03:00
tokens = append(tokens, twin.Cell{
Rune: runes[index],
2019-06-27 22:39:46 +03:00
Style: styledString.Style,
})
}
return tokens
}
type _StyledString struct {
String string
2021-04-15 16:16:06 +03:00
Style twin.Style
}
func styledStringsFromString(s string) []_StyledString {
hasEsc := false
for _, currentByte := range []byte(s) {
if currentByte == '\x1b' {
hasEsc = true
break
}
}
if !hasEsc {
return []_StyledString{{
String: s,
Style: twin.StyleDefault,
}}
}
// The rest of this function was inspired by the
// https://golang.org/pkg/regexp/#Regexp.Split source code
2021-04-14 22:41:56 +03:00
matches := sgrSequencePattern.FindAllStringIndex(s, -1)
styledStrings := make([]_StyledString, 0, len(matches)+1)
2021-04-15 16:16:06 +03:00
style := twin.StyleDefault
beg := 0
end := 0
for _, match := range matches {
end = match[0]
2019-06-16 10:23:25 +03:00
if end > beg {
2019-06-27 22:39:46 +03:00
// Found non-zero length string
styledStrings = append(styledStrings, _StyledString{
String: s[beg:end],
Style: style,
})
}
matchedPart := s[match[0]:match[1]]
style = updateStyle(style, matchedPart)
beg = match[1]
}
if end != len(s) {
styledStrings = append(styledStrings, _StyledString{
String: s[beg:],
Style: style,
})
}
return styledStrings
}
// updateStyle parses a string of the form "ESC[33m" into changes to style
2021-04-15 16:16:06 +03:00
func updateStyle(style twin.Style, escapeSequence string) twin.Style {
2019-10-27 11:15:16 +03:00
numbers := strings.Split(escapeSequence[2:len(escapeSequence)-1], ";")
index := 0
for index < len(numbers) {
number := numbers[index]
index++
switch strings.TrimLeft(number, "0") {
case "":
2021-04-15 16:16:06 +03:00
style = twin.StyleDefault
2019-06-16 21:57:03 +03:00
2019-06-16 21:58:19 +03:00
case "1":
2021-04-15 16:16:06 +03:00
style = style.WithAttr(twin.AttrBold)
2019-06-16 21:58:19 +03:00
case "2":
2021-04-15 16:16:06 +03:00
style = style.WithAttr(twin.AttrDim)
2020-04-13 17:43:56 +03:00
case "3":
2021-04-15 16:16:06 +03:00
style = style.WithAttr(twin.AttrItalic)
2020-04-13 17:43:56 +03:00
case "4":
2021-04-15 16:16:06 +03:00
style = style.WithAttr(twin.AttrUnderline)
2019-06-16 22:39:27 +03:00
case "7":
2021-04-15 16:16:06 +03:00
style = style.WithAttr(twin.AttrReverse)
2019-06-16 22:39:27 +03:00
case "22":
2021-04-15 16:16:06 +03:00
style = style.WithoutAttr(twin.AttrBold).WithoutAttr(twin.AttrDim)
2020-04-13 17:43:56 +03:00
case "23":
2021-04-15 16:16:06 +03:00
style = style.WithoutAttr(twin.AttrItalic)
2020-04-13 17:43:56 +03:00
case "24":
2021-04-15 16:16:06 +03:00
style = style.WithoutAttr(twin.AttrUnderline)
2019-06-16 22:39:27 +03:00
case "27":
2021-04-15 16:16:06 +03:00
style = style.WithoutAttr(twin.AttrReverse)
2019-06-16 22:39:27 +03:00
// Foreground colors, https://pkg.go.dev/github.com/gdamore/tcell#Color
case "30":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(0))
case "31":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(1))
case "32":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(2))
case "33":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(3))
case "34":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(4))
case "35":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(5))
case "36":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(6))
case "37":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(7))
2019-10-27 11:15:16 +03:00
case "38":
var err error = nil
2021-04-15 16:16:06 +03:00
var color *twin.Color
2019-10-27 23:40:30 +03:00
index, color, err = consumeCompositeColor(numbers, index-1)
2019-10-27 11:15:16 +03:00
if err != nil {
log.Warnf("Foreground: %s", err.Error())
2019-10-27 11:15:16 +03:00
return style
}
style = style.Foreground(*color)
2019-07-15 14:34:42 +03:00
case "39":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.ColorDefault)
2019-06-16 21:57:03 +03:00
2021-04-15 16:16:06 +03:00
// Background colors, see https://pkg.go.dev/github.com/gdamore/Color
2019-06-16 21:57:03 +03:00
case "40":
2021-04-15 16:16:06 +03:00
style = style.Background(twin.NewColor16(0))
2019-06-16 21:57:03 +03:00
case "41":
2021-04-15 16:16:06 +03:00
style = style.Background(twin.NewColor16(1))
2019-06-16 21:57:03 +03:00
case "42":
2021-04-15 16:16:06 +03:00
style = style.Background(twin.NewColor16(2))
2019-06-16 21:57:03 +03:00
case "43":
2021-04-15 16:16:06 +03:00
style = style.Background(twin.NewColor16(3))
2019-06-16 21:57:03 +03:00
case "44":
2021-04-15 16:16:06 +03:00
style = style.Background(twin.NewColor16(4))
2019-06-16 21:57:03 +03:00
case "45":
2021-04-15 16:16:06 +03:00
style = style.Background(twin.NewColor16(5))
2019-06-16 21:57:03 +03:00
case "46":
2021-04-15 16:16:06 +03:00
style = style.Background(twin.NewColor16(6))
2019-06-16 21:57:03 +03:00
case "47":
2021-04-15 16:16:06 +03:00
style = style.Background(twin.NewColor16(7))
2019-10-27 11:15:16 +03:00
case "48":
var err error = nil
2021-04-15 16:16:06 +03:00
var color *twin.Color
2019-10-27 23:40:30 +03:00
index, color, err = consumeCompositeColor(numbers, index-1)
2019-10-27 11:15:16 +03:00
if err != nil {
log.Warnf("Background: %s", err.Error())
2019-10-27 11:15:16 +03:00
return style
}
style = style.Background(*color)
2019-07-15 14:34:42 +03:00
case "49":
2021-04-15 16:16:06 +03:00
style = style.Background(twin.ColorDefault)
2019-06-16 21:57:03 +03:00
2021-04-15 16:16:06 +03:00
// Bright foreground colors: see https://pkg.go.dev/github.com/gdamore/Color
//
// After testing vs less and cat on iTerm2 3.3.9 / macOS Catalina
// 10.15.4 that's how they seem to handle this, tested with:
// * TERM=xterm-256color
// * TERM=screen-256color
case "90":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(8))
case "91":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(9))
case "92":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(10))
case "93":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(11))
case "94":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(12))
case "95":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(13))
case "96":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(14))
case "97":
2021-04-15 16:16:06 +03:00
style = style.Foreground(twin.NewColor16(15))
2019-06-16 21:57:03 +03:00
default:
log.Warnf("Unrecognized ANSI SGR code <%s>", number)
}
}
return style
}
2019-10-27 11:15:16 +03:00
2019-10-27 23:40:30 +03:00
// numbers is a list of numbers from a ANSI SGR string
2019-10-27 11:15:16 +03:00
// index points to either 38 or 48 in that string
//
// This method will return:
// * The first index in the string that this function did not consume
// * A color value that can be applied to a style
2021-04-15 16:16:06 +03:00
func consumeCompositeColor(numbers []string, index int) (int, *twin.Color, error) {
2019-10-28 22:09:08 +03:00
baseIndex := index
2019-10-27 23:40:30 +03:00
if numbers[index] != "38" && numbers[index] != "48" {
err := fmt.Errorf(
2021-04-19 21:29:05 +03:00
"unknown start of color sequence <%s>, expected 38 (foreground) or 48 (background): <CSI %sm>",
2019-10-27 23:40:30 +03:00
numbers[index],
2019-10-28 22:09:08 +03:00
strings.Join(numbers[baseIndex:], ";"))
return -1, nil, err
}
index++
if index >= len(numbers) {
err := fmt.Errorf(
2021-04-19 21:29:05 +03:00
"incomplete color sequence: <CSI %sm>",
2019-10-28 22:09:08 +03:00
strings.Join(numbers[baseIndex:], ";"))
2019-10-27 23:40:30 +03:00
return -1, nil, err
}
2019-10-28 22:18:07 +03:00
if numbers[index] == "5" {
2019-10-28 22:30:04 +03:00
// Handle 8 bit color
2019-10-28 22:18:07 +03:00
index++
if index >= len(numbers) {
err := fmt.Errorf(
2021-04-19 21:29:05 +03:00
"incomplete 8 bit color sequence: <CSI %sm>",
2019-10-28 22:18:07 +03:00
strings.Join(numbers[baseIndex:], ";"))
return -1, nil, err
}
colorNumber, err := strconv.Atoi(numbers[index])
if err != nil {
return -1, nil, err
}
2021-04-15 16:16:06 +03:00
colorValue := twin.NewColor256(uint8(colorNumber))
2019-10-28 22:18:07 +03:00
return index + 1, &colorValue, nil
}
2019-10-28 22:21:39 +03:00
if numbers[index] == "2" {
2019-10-28 22:30:04 +03:00
// Handle 24 bit color
rIndex := index + 1
gIndex := index + 2
bIndex := index + 3
if bIndex >= len(numbers) {
err := fmt.Errorf(
2021-04-19 21:29:05 +03:00
"incomplete 24 bit color sequence, expected N8;2;R;G;Bm: <CSI %sm>",
2019-10-28 22:30:04 +03:00
strings.Join(numbers[baseIndex:], ";"))
return -1, nil, err
}
rValueX, err := strconv.ParseInt(numbers[rIndex], 10, 32)
if err != nil {
return -1, nil, err
}
2021-04-15 16:16:06 +03:00
rValue := uint8(rValueX)
2019-10-28 22:30:04 +03:00
gValueX, err := strconv.Atoi(numbers[gIndex])
if err != nil {
return -1, nil, err
}
2021-04-15 16:16:06 +03:00
gValue := uint8(gValueX)
2019-10-28 22:30:04 +03:00
bValueX, err := strconv.Atoi(numbers[bIndex])
if err != nil {
return -1, nil, err
}
2021-04-15 16:16:06 +03:00
bValue := uint8(bValueX)
2019-10-28 22:30:04 +03:00
2021-04-15 16:16:06 +03:00
colorValue := twin.NewColor24Bit(rValue, gValue, bValue)
2019-10-28 22:30:04 +03:00
return bIndex + 1, &colorValue, nil
2019-10-28 22:21:39 +03:00
}
err := fmt.Errorf(
2021-04-19 21:29:05 +03:00
"unknown color type <%s>, expected 5 (8 bit color) or 2 (24 bit color): <CSI %sm>",
2019-10-28 22:21:39 +03:00
numbers[index],
strings.Join(numbers[baseIndex:], ";"))
return -1, nil, err
2019-10-27 11:15:16 +03:00
}