1
1
mirror of https://github.com/walles/moar.git synced 2024-09-21 00:49:59 +03:00

Merge branch 'walles/extract-rendering'

Extracts content rendering into its own file.
This commit is contained in:
Johan Walles 2021-05-30 10:16:13 +02:00
commit 531951967d
7 changed files with 278 additions and 249 deletions

View File

@ -3,7 +3,6 @@ package m
import (
"fmt"
"os"
"regexp"
"strings"
"testing"
"unicode"
@ -100,36 +99,6 @@ func TestTokenize(t *testing.T) {
}
}
func TestHighlightSearchHit(t *testing.T) {
pattern, err := regexp.Compile("b")
if err != nil {
panic(err)
}
line := NewLine("abc")
screenLine := createScreenLine(0, 3, line.HighlightedTokens(pattern))
assertTokenRangesEqual(t, screenLine, []twin.Cell{
twin.NewCell('a', twin.StyleDefault),
twin.NewCell('b', twin.StyleDefault.WithAttr(twin.AttrReverse)),
twin.NewCell('c', twin.StyleDefault),
})
}
func TestHighlightUtf8SearchHit(t *testing.T) {
pattern, err := regexp.Compile("ä")
if err != nil {
panic(err)
}
line := NewLine("åäö")
screenLine := createScreenLine(0, 3, line.HighlightedTokens(pattern))
assertTokenRangesEqual(t, screenLine, []twin.Cell{
twin.NewCell('å', twin.StyleDefault),
twin.NewCell('ä', twin.StyleDefault.WithAttr(twin.AttrReverse)),
twin.NewCell('ö', twin.StyleDefault),
})
}
func TestUnderline(t *testing.T) {
tokens := cellsFromString("a\x1b[4mb\x1b[24mc")
assert.Equal(t, len(tokens), 3)

View File

@ -12,7 +12,7 @@ func tokenize(input string) []twin.Cell {
return line.HighlightedTokens(nil)
}
func toString(cellLines [][]twin.Cell) string {
func rowsToString(cellLines [][]twin.Cell) string {
returnMe := ""
for _, cellLine := range cellLines {
lineString := ""
@ -43,7 +43,7 @@ func assertWrap(t *testing.T, input string, width int, wrappedLines ...string) {
}
t.Errorf("When wrapping <%s> at width %d:\n--Expected--\n%s\n\n--Actual--\n%s",
input, width, toString(expected), toString(actual))
input, width, rowsToString(expected), rowsToString(actual))
}
func TestEnoughRoomNoWrapping(t *testing.T) {

View File

@ -117,70 +117,6 @@ func NewPager(r *Reader) *Pager {
}
}
func (p *Pager) addLine(fileLineNumber *int, numberPrefixLength int, screenLineNumber int, cells []twin.Cell) {
screenWidth, _ := p.screen.Size()
lineNumberString := ""
if numberPrefixLength > 0 && fileLineNumber != nil {
lineNumberString = formatNumber(uint(*fileLineNumber))
lineNumberString = fmt.Sprintf("%*s", numberPrefixLength-1, lineNumberString)
if len(lineNumberString) > numberPrefixLength {
panic(fmt.Errorf(
"lineNumberString <%s> longer than numberPrefixLength %d",
lineNumberString, numberPrefixLength))
}
}
for column, digit := range lineNumberString {
if column >= numberPrefixLength {
break
}
p.screen.SetCell(column, screenLineNumber, twin.NewCell(digit, _numberStyle))
}
screenCells := createScreenLine(p.leftColumnZeroBased, screenWidth-numberPrefixLength, cells)
for column, token := range screenCells {
p.screen.SetCell(column+numberPrefixLength, screenLineNumber, token)
}
}
func createScreenLine(
stringIndexAtColumnZero int,
screenColumnsCount int,
cells []twin.Cell,
) []twin.Cell {
var returnMe []twin.Cell
if stringIndexAtColumnZero > 0 {
// Indicate that it's possible to scroll left
returnMe = append(returnMe, twin.Cell{
Rune: '<',
Style: twin.StyleDefault.WithAttr(twin.AttrReverse),
})
}
if stringIndexAtColumnZero >= len(cells) {
// Nothing (more) to display, never mind
return returnMe
}
for _, cell := range cells[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.
returnMe[len(returnMe)-1] = twin.Cell{
Rune: '>',
Style: twin.StyleDefault.WithAttr(twin.AttrReverse),
}
break
}
returnMe = append(returnMe, cell)
}
return returnMe
}
func (p *Pager) _AddSearchFooter() {
_, height := p.screen.Size()
@ -194,95 +130,6 @@ func (p *Pager) _AddSearchFooter() {
p.screen.SetCell(pos, height-1, twin.NewCell(' ', twin.StyleDefault.WithAttr(twin.AttrReverse)))
}
func (p *Pager) _AddLines(spinner string) {
width, height := p.screen.Size()
wantedLineCount := height - 1
lines := p.reader.GetLines(p.firstLineOneBased, wantedLineCount)
// If we're asking for past-the-end lines, the Reader will clip for us,
// and we should adapt to that. Otherwise if you scroll 100 lines past
// the end, you'll then have to scroll 100 lines up again before the
// display starts scrolling visibly.
p.firstLineOneBased = lines.firstLineOneBased
// Count the length of the last line number
//
// Offsets figured out through trial-and-error...
lastLineOneBased := lines.firstLineOneBased + len(lines.lines) - 1
numberPrefixLength := len(formatNumber(uint(lastLineOneBased))) + 1
if numberPrefixLength < 4 {
// 4 = space for 3 digits followed by one whitespace
//
// https://github.com/walles/moar/issues/38
numberPrefixLength = 4
}
if !p.ShowLineNumbers {
numberPrefixLength = 0
}
screenLineNumber := 0
screenFull := false
for lineIndex, line := range lines.lines {
lineNumber := p.firstLineOneBased + lineIndex
highlighted := line.HighlightedTokens(p.searchPattern)
var wrapped [][]twin.Cell
if p.WrapLongLines {
wrapped = wrapLine(width-numberPrefixLength, highlighted)
} else {
// All on one line
wrapped = [][]twin.Cell{highlighted}
}
for wrapIndex, linePart := range wrapped {
visibleLineNumber := &lineNumber
if wrapIndex > 0 {
visibleLineNumber = nil
}
p.addLine(visibleLineNumber, numberPrefixLength, screenLineNumber, linePart)
screenLineNumber++
if screenLineNumber >= height-1 {
// We have shown all the lines that can fit on the screen
screenFull = true
break
}
}
if screenFull {
break
}
}
eofSpinner := spinner
if eofSpinner == "" {
// This happens when we're done
eofSpinner = "---"
}
spinnerLine := cellsFromString(_EofMarkerFormat + eofSpinner)
p.addLine(nil, 0, screenLineNumber, spinnerLine)
switch p.mode {
case _Searching:
p._AddSearchFooter()
case _NotFound:
p._SetFooter("Not found: " + p.searchString)
case _Viewing:
helpText := "Press ESC / q to exit, '/' to search, '?' for help"
if p.isShowingHelp {
helpText = "Press ESC / q to exit help, '/' to search"
}
p._SetFooter(lines.statusText + spinner + " " + helpText)
default:
panic(fmt.Sprint("Unsupported pager mode: ", p.mode))
}
}
func (p *Pager) _SetFooter(footer string) {
width, height := p.screen.Size()
@ -301,7 +148,63 @@ func (p *Pager) _SetFooter(footer string) {
func (p *Pager) _Redraw(spinner string) {
p.screen.Clear()
p._AddLines(spinner)
width, height := p.screen.Size()
wantedLineCount := height - 1
inputLines := p.reader.GetLines(p.firstLineOneBased, wantedLineCount)
screenLines := ScreenLines{
inputLines: inputLines,
firstInputLineOneBased: p.firstLineOneBased,
leftColumnZeroBased: p.leftColumnZeroBased,
width: width,
height: wantedLineCount,
showLineNumbers: p.ShowLineNumbers,
wrapLongLines: p.WrapLongLines,
}
// If we're asking for past-the-end lines, the Reader will clip for us,
// and we should adapt to that. Otherwise if you scroll 100 lines past
// the end, you'll then have to scroll 100 lines up again before the
// display starts scrolling visibly.
p.firstLineOneBased = screenLines.firstLineOneBased()
var screenLineNumber int
for lineNumber, row := range screenLines.getScreenLines(p.searchPattern) {
screenLineNumber = lineNumber
for column, cell := range row {
p.screen.SetCell(column, screenLineNumber, cell)
}
}
eofSpinner := spinner
if eofSpinner == "" {
// This happens when we're done
eofSpinner = "---"
}
spinnerLine := cellsFromString(_EofMarkerFormat + eofSpinner)
for column, cell := range spinnerLine {
p.screen.SetCell(column, screenLineNumber+1, cell)
}
switch p.mode {
case _Searching:
p._AddSearchFooter()
case _NotFound:
p._SetFooter("Not found: " + p.searchString)
case _Viewing:
helpText := "Press ESC / q to exit, '/' to search, '?' for help"
if p.isShowingHelp {
helpText = "Press ESC / q to exit help, '/' to search"
}
p._SetFooter(inputLines.statusText + spinner + " " + helpText)
default:
panic(fmt.Sprint("Unsupported pager mode: ", p.mode))
}
p.screen.Show()
}
@ -501,6 +404,10 @@ func removeLastChar(s string) string {
return s[:len(s)-size]
}
func (p *Pager) _ScrollToEnd() {
p.firstLineOneBased = p.reader.GetLineCount()
}
func (p *Pager) _OnSearchKey(key twin.KeyCode) {
switch key {
case twin.KeyEscape, twin.KeyEnter:
@ -515,12 +422,12 @@ func (p *Pager) _OnSearchKey(key twin.KeyCode) {
p._UpdateSearchPattern()
case twin.KeyUp:
// Clipping is done in _AddLines()
// Clipping is done in _Redraw()
p.firstLineOneBased--
p.mode = _Viewing
case twin.KeyDown:
// Clipping is done in _AddLines()
// Clipping is done in _Redraw()
p.firstLineOneBased++
p.mode = _Viewing
@ -575,11 +482,11 @@ func (p *Pager) _OnKey(keyCode twin.KeyCode) {
p.Quit()
case twin.KeyUp:
// Clipping is done in _AddLines()
// Clipping is done in _Redraw()
p.firstLineOneBased--
case twin.KeyDown, twin.KeyEnter:
// Clipping is done in _AddLines()
// Clipping is done in _Redraw()
p.firstLineOneBased++
case twin.KeyRight:
@ -639,11 +546,11 @@ func (p *Pager) _OnRune(char rune) {
}
case 'k', 'y':
// Clipping is done in _AddLines()
// Clipping is done in _Redraw()
p.firstLineOneBased--
case 'j', 'e':
// Clipping is done in _AddLines()
// Clipping is done in _Redraw()
p.firstLineOneBased++
case 'l':
@ -658,7 +565,7 @@ func (p *Pager) _OnRune(char rune) {
p.firstLineOneBased = 1
case '>', 'G':
p.firstLineOneBased = p.reader.GetLineCount()
p._ScrollToEnd()
case 'f', ' ':
_, height := p.screen.Size()
@ -772,11 +679,11 @@ func (p *Pager) StartPaging(screen twin.Screen) {
log.Tracef("Handling mouse event %d...", event.Buttons())
switch event.Buttons() {
case twin.MouseWheelUp:
// Clipping is done in _AddLines()
// Clipping is done in _Redraw()
p.firstLineOneBased--
case twin.MouseWheelDown:
// Clipping is done in _AddLines()
// Clipping is done in _Redraw()
p.firstLineOneBased++
case twin.MouseWheelLeft:

View File

@ -230,53 +230,6 @@ func TestToPattern(t *testing.T) {
assert.Assert(t, toPattern(")g").MatchString(")g"))
}
func assertTokenRangesEqual(t *testing.T, actual []twin.Cell, expected []twin.Cell) {
if len(actual) != len(expected) {
t.Errorf("String lengths mismatch; expected %d but got %d",
len(expected), len(actual))
}
for pos, expectedToken := range expected {
if pos >= len(expected) || pos >= len(actual) {
break
}
actualToken := actual[pos]
if actualToken.Rune == expectedToken.Rune && actualToken.Style == expectedToken.Style {
// Actual == Expected, keep checking
continue
}
t.Errorf("At (0-based) position %d: Expected %v, got %v", pos, expectedToken, actualToken)
}
}
func TestCreateScreenLineBase(t *testing.T) {
line := cellsFromString("")
screenLine := createScreenLine(0, 3, line)
assert.Assert(t, len(screenLine) == 0)
}
func TestCreateScreenLineOverflowRight(t *testing.T) {
line := cellsFromString("012345")
screenLine := createScreenLine(0, 3, line)
assertTokenRangesEqual(t, screenLine, []twin.Cell{
twin.NewCell('0', twin.StyleDefault),
twin.NewCell('1', twin.StyleDefault),
twin.NewCell('>', twin.StyleDefault.WithAttr(twin.AttrReverse)),
})
}
func TestCreateScreenLineUnderflowLeft(t *testing.T) {
line := cellsFromString("012")
screenLine := createScreenLine(1, 3, line)
assertTokenRangesEqual(t, screenLine, []twin.Cell{
twin.NewCell('<', twin.StyleDefault.WithAttr(twin.AttrReverse)),
twin.NewCell('1', twin.StyleDefault),
twin.NewCell('2', twin.StyleDefault),
})
}
func TestFindFirstLineOneBasedSimple(t *testing.T) {
reader := NewReaderFromStream("", strings.NewReader("AB"))
pager := NewPager(reader)
@ -303,6 +256,16 @@ func TestFindFirstLineOneBasedAnsi(t *testing.T) {
assert.Check(t, *hitLine == 1)
}
// Converts a cell row to a plain string and removes trailing whitespace.
func rowToString(row []twin.Cell) string {
rowString := ""
for _, cell := range row {
rowString += string(cell.Rune)
}
return strings.TrimRight(rowString, " ")
}
func benchmarkSearch(b *testing.B, highlighted bool) {
// Pick a go file so we get something with highlighting
_, sourceFilename, _, ok := runtime.Caller(0)

View File

@ -37,8 +37,8 @@ type Reader struct {
moreLinesAdded chan bool
}
// Lines contains a number of lines from the reader, plus metadata
type Lines struct {
// InputLines contains a number of lines from the reader, plus metadata
type InputLines struct {
lines []*Line
// One-based line number of the first line returned
@ -489,19 +489,19 @@ func (r *Reader) GetLine(lineNumberOneBased int) *Line {
}
// GetLines gets the indicated lines from the input
func (r *Reader) GetLines(firstLineOneBased int, wantedLineCount int) *Lines {
func (r *Reader) GetLines(firstLineOneBased int, wantedLineCount int) *InputLines {
r.lock.Lock()
defer r.lock.Unlock()
return r._GetLinesUnlocked(firstLineOneBased, wantedLineCount)
}
func (r *Reader) _GetLinesUnlocked(firstLineOneBased int, wantedLineCount int) *Lines {
func (r *Reader) _GetLinesUnlocked(firstLineOneBased int, wantedLineCount int) *InputLines {
if firstLineOneBased < 1 {
firstLineOneBased = 1
}
if len(r.lines) == 0 {
return &Lines{
return &InputLines{
lines: nil,
// The line number set here won't matter, we'll clip it anyway when we get it back
@ -530,7 +530,7 @@ func (r *Reader) _GetLinesUnlocked(firstLineOneBased int, wantedLineCount int) *
return r._GetLinesUnlocked(firstLineOneBased, wantedLineCount)
}
return &Lines{
return &InputLines{
lines: r.lines[firstLineZeroBased : lastLineZeroBased+1],
firstLineOneBased: firstLineOneBased,
statusText: r._CreateStatusUnlocked(firstLineOneBased, lastLineZeroBased+1),

152
m/screenLines.go Normal file
View File

@ -0,0 +1,152 @@
package m
import (
"fmt"
"regexp"
"github.com/walles/moar/twin"
)
type ScreenLines struct {
inputLines *InputLines
firstInputLineOneBased int
leftColumnZeroBased int
width int
height int
showLineNumbers bool
wrapLongLines bool
}
func (sl *ScreenLines) getScreenLines(searchPattern *regexp.Regexp) [][]twin.Cell {
// Count the length of the last line number
//
// Offsets figured out through trial-and-error...
lastLineOneBased := sl.inputLines.firstLineOneBased + len(sl.inputLines.lines) - 1
numberPrefixLength := len(formatNumber(uint(lastLineOneBased))) + 1
if numberPrefixLength < 4 {
// 4 = space for 3 digits followed by one whitespace
//
// https://github.com/walles/moar/issues/38
numberPrefixLength = 4
}
if !sl.showLineNumbers {
numberPrefixLength = 0
}
returnLines := make([][]twin.Cell, 0, sl.height)
screenFull := false
for lineIndex, line := range sl.inputLines.lines {
lineNumber := sl.firstLineOneBased() + lineIndex
highlighted := line.HighlightedTokens(searchPattern)
var wrapped [][]twin.Cell
if sl.wrapLongLines {
wrapped = wrapLine(sl.width-numberPrefixLength, highlighted)
} else {
// All on one line
wrapped = [][]twin.Cell{highlighted}
}
for wrapIndex, inputLinePart := range wrapped {
visibleLineNumber := &lineNumber
if wrapIndex > 0 {
visibleLineNumber = nil
}
returnLines = append(returnLines,
sl.createScreenLine(visibleLineNumber, numberPrefixLength, inputLinePart))
if len(returnLines) >= sl.height {
// We have shown all the lines that can fit on the screen
screenFull = true
break
}
}
if screenFull {
break
}
}
return returnLines
}
func (sl *ScreenLines) createScreenLine(lineNumberToShow *int, numberPrefixLength int, contents []twin.Cell) []twin.Cell {
newLine := make([]twin.Cell, 0, sl.width)
newLine = append(newLine, createLineNumberPrefix(lineNumberToShow, numberPrefixLength)...)
startColumn := sl.leftColumnZeroBased
if startColumn < len(contents) {
endColumn := sl.leftColumnZeroBased + (sl.width - numberPrefixLength)
if endColumn > len(contents) {
endColumn = len(contents)
}
newLine = append(newLine, contents[startColumn:endColumn]...)
}
// Add scroll left indicator
if sl.leftColumnZeroBased > 0 && len(contents) > 0 {
if len(newLine) == 0 {
// Don't panic on short lines, this new Cell will be
// overwritten with '<' right after this if statement
newLine = append(newLine, twin.Cell{})
}
// Add can-scroll-left marker
newLine[0] = twin.Cell{
Rune: '<',
Style: twin.StyleDefault.WithAttr(twin.AttrReverse),
}
}
// Add scroll right indicator
if len(contents)+numberPrefixLength-sl.leftColumnZeroBased > sl.width {
newLine[sl.width-1] = twin.Cell{
Rune: '>',
Style: twin.StyleDefault.WithAttr(twin.AttrReverse),
}
}
return newLine
}
// Generate a line number prefix. Can be empty or all-whitespace depending on parameters.
func createLineNumberPrefix(fileLineNumber *int, numberPrefixLength int) []twin.Cell {
if numberPrefixLength == 0 {
return []twin.Cell{}
}
lineNumberPrefix := make([]twin.Cell, 0, numberPrefixLength)
if fileLineNumber == nil {
for len(lineNumberPrefix) < numberPrefixLength {
lineNumberPrefix = append(lineNumberPrefix, twin.Cell{Rune: ' '})
}
return lineNumberPrefix
}
lineNumberString := formatNumber(uint(*fileLineNumber))
lineNumberString = fmt.Sprintf("%*s ", numberPrefixLength-1, lineNumberString)
if len(lineNumberString) > numberPrefixLength {
panic(fmt.Errorf(
"lineNumberString <%s> longer than numberPrefixLength %d",
lineNumberString, numberPrefixLength))
}
for column, digit := range lineNumberString {
if column >= numberPrefixLength {
break
}
lineNumberPrefix = append(lineNumberPrefix, twin.NewCell(digit, _numberStyle))
}
return lineNumberPrefix
}
func (sl *ScreenLines) firstLineOneBased() int {
// FIXME: This is wrong when wrapping is enabled
return sl.inputLines.firstLineOneBased
}

38
m/screenLines_test.go Normal file
View File

@ -0,0 +1,38 @@
package m
import (
"testing"
"gotest.tools/assert"
)
func testCropping(t *testing.T, contents string, firstIndex int, lastIndex int, expected string) {
screenLines := ScreenLines{width: 1 + lastIndex - firstIndex, leftColumnZeroBased: firstIndex}
lineContents := NewLine(contents).HighlightedTokens(nil)
screenLine := screenLines.createScreenLine(nil, 0, lineContents)
assert.Equal(t, rowToString(screenLine), expected)
}
func TestCreateScreenLine(t *testing.T) {
testCropping(t, "abc", 0, 10, "abc")
}
func TestCreateScreenLineCanScrollLeft(t *testing.T) {
testCropping(t, "abc", 1, 10, "<c")
}
func TestCreateScreenLineCanScrollRight(t *testing.T) {
testCropping(t, "abc", 0, 1, "a>")
}
func TestCreateScreenLineCanAlmostScrollRight(t *testing.T) {
testCropping(t, "abc", 0, 2, "abc")
}
func TestCreateScreenLineCanScrollBoth(t *testing.T) {
testCropping(t, "abcde", 1, 3, "<c>")
}
func TestCreateScreenLineCanAlmostScrollBoth(t *testing.T) {
testCropping(t, "abcd", 1, 3, "<cd")
}