1
1
mirror of https://github.com/walles/moar.git synced 2024-11-27 11:03:58 +03:00
moar/m/pager.go
2024-01-07 08:38:52 +01:00

629 lines
16 KiB
Go

package m
import (
"fmt"
"regexp"
"strings"
"time"
"github.com/alecthomas/chroma/v2"
log "github.com/sirupsen/logrus"
"github.com/walles/moar/m/linenumbers"
"github.com/walles/moar/m/textstyles"
"github.com/walles/moar/twin"
)
type _PagerMode int
const (
_Viewing _PagerMode = iota
_Searching
_NotFound
_GotoLine
)
type StatusBarOption int
const (
//revive:disable-next-line:var-naming
STATUSBAR_STYLE_INVERSE StatusBarOption = iota
//revive:disable-next-line:var-naming
STATUSBAR_STYLE_PLAIN
//revive:disable-next-line:var-naming
STATUSBAR_STYLE_BOLD
)
type eventSpinnerUpdate struct {
spinner string
}
type eventMoreLinesAvailable struct{}
// Either reading, highlighting or both are done. Check reader.Done() and
// reader.HighlightingDone() for details.
type eventMaybeDone struct{}
// Pager is the main on-screen pager
type Pager struct {
reader *Reader
screen twin.Screen
quit bool
scrollPosition scrollPosition
leftColumnZeroBased int
mode _PagerMode
searchString string
searchPattern *regexp.Regexp
gotoLineString string
// We used to have a "Following" field here. If you want to follow, set
// TargetLineNumber to LineNumberMax() instead, see below.
isShowingHelp bool
preHelpState *_PreHelpState
// NewPager shows lines by default, this field can hide them
ShowLineNumbers bool
StatusBarStyle StatusBarOption
ShowStatusBar bool
UnprintableStyle textstyles.UnprintableStyleT
WrapLongLines bool
// Ref: https://github.com/walles/moar/issues/113
QuitIfOneScreen bool
// Ref: https://github.com/walles/moar/issues/94
ScrollLeftHint twin.Cell
ScrollRightHint twin.Cell
SideScrollAmount int // Should be positive
// If non-nil, scroll to this line number as soon as possible. Set this
// value to LineNumberMax() to follow the end of the input (tail).
TargetLineNumber *linenumbers.LineNumber
// If true, pager will clear the screen on return. If false, pager will
// clear the last line, and show the cursor.
DeInit bool
// Optional ANSI to prefix each text line with. Initialised using
// ChromaStyle and ChromaFormatter. Used for coloring unstyled text lines
// based on the Chroma style.
linePrefix string
// Length of the longest line displayed. This is used for limiting scrolling to the right.
longestLineLength int
}
type _PreHelpState struct {
reader *Reader
scrollPosition scrollPosition
leftColumnZeroBased int
targetLineNumber *linenumbers.LineNumber
}
const _EofMarkerFormat = "\x1b[7m" // Reverse video
var _HelpReader = NewReaderFromText("Help", `
Welcome to Moar, the nice pager!
Miscellaneous
-------------
* Press 'q' or 'ESC' to quit
* Press 'w' to toggle wrapping of long lines
* Press '=' to toggle showing the status bar at the bottom
Moving around
-------------
* Arrow keys
* Alt key plus left / right arrow steps one column at a time
* Left / right can be used to hide / show line numbers
* CTRL-p moves to the previous line
* CTRL-n moves to the next line
* 'g' for going to a specific line number
* PageUp / 'b' and PageDown / 'f'
* SPACE moves down a page
* Home and End for start / end of the document
* < / 'gg' to go to the start of the document
* > / 'G' to go to the end of the document
* 'h', 'l' for left and right (as in vim)
* Half page 'u'p / 'd'own, or CTRL-u / CTRL-d
* RETURN moves down one line
Searching
---------
* Type / to start searching, then type what you want to find
* Type RETURN to stop searching
* Find next by typing 'n' (for "next")
* Find previous by typing SHIFT-N or 'p' (for "previous")
* Search is case sensitive if it contains any UPPER CASE CHARACTERS
* Search is interpreted as a regexp if it is a valid one
Reporting bugs
--------------
File issues at https://github.com/walles/moar/issues, or post
questions to johan.walles@gmail.com.
Installing Moar as your default pager
-------------------------------------
Put the following line in your ~/.bashrc, ~/.bash_profile or ~/.zshrc:
export PAGER=moar
Source Code
-----------
Available at https://github.com/walles/moar/.
`)
func (pm _PagerMode) isViewing() bool {
return pm == _Viewing || pm == _NotFound
}
// NewPager creates a new Pager with default settings
func NewPager(r *Reader) *Pager {
var name string
if r == nil || r.name == nil || len(*r.name) == 0 {
name = "Pager"
} else {
name = "Pager " + *r.name
}
return &Pager{
reader: r,
quit: false,
ShowLineNumbers: true,
ShowStatusBar: true,
DeInit: true,
SideScrollAmount: 16,
ScrollLeftHint: twin.NewCell('<', twin.StyleDefault.WithAttr(twin.AttrReverse)),
ScrollRightHint: twin.NewCell('>', twin.StyleDefault.WithAttr(twin.AttrReverse)),
scrollPosition: newScrollPosition(name),
}
}
// How many lines are visible on screen? Depends on screen height and whether or
// not the status bar is visible.
func (p *Pager) visibleHeight() int {
_, height := p.screen.Size()
if p.ShowStatusBar {
return height - 1
}
return height
}
func (p *Pager) setFooter(footer string) {
width, height := p.screen.Size()
pos := 0
for _, token := range footer {
p.screen.SetCell(pos, height-1, twin.NewCell(token, statusbarStyle))
pos++
}
for ; pos < width; pos++ {
p.screen.SetCell(pos, height-1, twin.NewCell(' ', statusbarStyle))
}
}
// Quit leaves the help screen or quits the pager
func (p *Pager) Quit() {
if !p.isShowingHelp {
p.quit = true
return
}
// Reset help
p.isShowingHelp = false
p.reader = p.preHelpState.reader
p.scrollPosition = p.preHelpState.scrollPosition
p.leftColumnZeroBased = p.preHelpState.leftColumnZeroBased
p.TargetLineNumber = p.preHelpState.targetLineNumber
p.preHelpState = nil
}
// Negative deltas move left instead
func (p *Pager) moveRight(delta int) {
if p.ShowLineNumbers && delta > 0 {
p.ShowLineNumbers = false
return
}
if p.leftColumnZeroBased == 0 && delta < 0 {
p.ShowLineNumbers = true
return
}
result := p.leftColumnZeroBased + delta
if result < 0 {
p.leftColumnZeroBased = 0
} else {
p.leftColumnZeroBased = result
}
// If we try to move past the characters when moving right, stop scrolling to
// avoid moving infinitely into the void.
if p.leftColumnZeroBased > p.longestLineLength {
p.leftColumnZeroBased = p.longestLineLength
}
}
func (p *Pager) handleScrolledUp() {
p.TargetLineNumber = nil
}
func (p *Pager) handleScrolledDown() {
if p.isScrolledToEnd() {
reallyHigh := linenumbers.LineNumberMax()
p.TargetLineNumber = &reallyHigh
} else {
p.TargetLineNumber = &linenumbers.LineNumber{}
}
}
func (p *Pager) onKey(keyCode twin.KeyCode) {
if p.mode == _Searching {
p.onSearchKey(keyCode)
return
}
if p.mode == _GotoLine {
p.onGotoLineKey(keyCode)
return
}
if p.mode != _Viewing && p.mode != _NotFound {
panic(fmt.Sprint("Unhandled mode: ", p.mode))
}
// Reset the not-found marker on non-search keypresses
p.mode = _Viewing
switch keyCode {
case twin.KeyEscape:
p.Quit()
case twin.KeyUp:
// Clipping is done in _Redraw()
p.scrollPosition = p.scrollPosition.PreviousLine(1)
p.handleScrolledUp()
case twin.KeyDown, twin.KeyEnter:
// Clipping is done in _Redraw()
p.scrollPosition = p.scrollPosition.NextLine(1)
p.handleScrolledDown()
case twin.KeyRight:
p.moveRight(p.SideScrollAmount)
case twin.KeyLeft:
p.moveRight(-p.SideScrollAmount)
case twin.KeyAltRight:
p.moveRight(1)
case twin.KeyAltLeft:
p.moveRight(-1)
case twin.KeyHome:
p.scrollPosition = newScrollPosition("Pager scroll position")
p.handleScrolledUp()
case twin.KeyEnd:
p.scrollToEnd()
case twin.KeyPgUp:
p.scrollPosition = p.scrollPosition.PreviousLine(p.visibleHeight())
p.handleScrolledUp()
case twin.KeyPgDown:
p.scrollPosition = p.scrollPosition.NextLine(p.visibleHeight())
p.handleScrolledDown()
default:
log.Debugf("Unhandled key event %v", keyCode)
}
}
func (p *Pager) onRune(char rune) {
if p.mode == _Searching {
p.onSearchRune(char)
return
}
if p.mode == _GotoLine {
p.onGotoLineRune(char)
return
}
if p.mode != _Viewing && p.mode != _NotFound {
panic(fmt.Sprint("Unhandled mode: ", p.mode))
}
switch char {
case 'q':
p.Quit()
case '?':
if !p.isShowingHelp {
p.preHelpState = &_PreHelpState{
reader: p.reader,
scrollPosition: p.scrollPosition,
leftColumnZeroBased: p.leftColumnZeroBased,
targetLineNumber: p.TargetLineNumber,
}
p.reader = _HelpReader
p.scrollPosition = newScrollPosition("Pager scroll position")
p.leftColumnZeroBased = 0
p.TargetLineNumber = nil
p.isShowingHelp = true
}
case '=':
p.ShowStatusBar = !p.ShowStatusBar
// '\x10' = CTRL-p, should scroll up one line.
// Ref: https://github.com/walles/moar/issues/107#issuecomment-1328354080
case 'k', 'y', '\x10':
// Clipping is done in _Redraw()
p.scrollPosition = p.scrollPosition.PreviousLine(1)
p.handleScrolledUp()
// '\x0e' = CTRL-n, should scroll down one line.
// Ref: https://github.com/walles/moar/issues/107#issuecomment-1328354080
case 'j', 'e', '\x0e':
// Clipping is done in _Redraw()
p.scrollPosition = p.scrollPosition.NextLine(1)
p.handleScrolledDown()
case 'l':
// vim right
p.moveRight(p.SideScrollAmount)
case 'h':
// vim left
p.moveRight(-p.SideScrollAmount)
case '<':
p.scrollPosition = newScrollPosition("Pager scroll position")
p.handleScrolledUp()
case '>', 'G':
p.scrollToEnd()
case 'f', ' ':
p.scrollPosition = p.scrollPosition.NextLine(p.visibleHeight())
p.handleScrolledDown()
case 'b':
p.scrollPosition = p.scrollPosition.PreviousLine(p.visibleHeight())
p.handleScrolledUp()
// '\x15' = CTRL-u, should work like just 'u'.
// Ref: https://github.com/walles/moar/issues/90
case 'u', '\x15':
p.scrollPosition = p.scrollPosition.PreviousLine(p.visibleHeight() / 2)
p.handleScrolledUp()
// '\x04' = CTRL-d, should work like just 'd'.
// Ref: https://github.com/walles/moar/issues/90
case 'd', '\x04':
p.scrollPosition = p.scrollPosition.NextLine(p.visibleHeight() / 2)
p.handleScrolledDown()
case '/':
p.mode = _Searching
p.searchString = ""
p.searchPattern = nil
case 'g':
p.mode = _GotoLine
p.gotoLineString = ""
case 'n':
p.scrollToNextSearchHit()
case 'p', 'N':
p.scrollToPreviousSearchHit()
case 'w':
p.WrapLongLines = !p.WrapLongLines
default:
log.Debugf("Unhandled rune keypress '%s'/0x%08x", string(char), int32(char))
}
}
// Return an ANSI SGR sequence to use for plain text. Can be "".
func getLineColorPrefix(chromaStyle *chroma.Style, chromaFormatter *chroma.Formatter) string {
if chromaStyle == nil && chromaFormatter == nil {
return ""
}
if chromaStyle == nil || chromaFormatter == nil {
panic("Both ChromaStyle and ChromaFormatter should be set or neither")
}
stringBuilder := strings.Builder{}
err := (*chromaFormatter).Format(&stringBuilder, chromaStyle, chroma.Literator(chroma.Token{
Type: chroma.None,
Value: "XXX",
}))
if err != nil {
panic(err)
}
formatted := stringBuilder.String()
cutoff := strings.Index(formatted, "XXX")
if cutoff < 0 {
panic("XXX not found in " + formatted)
}
return formatted[:cutoff]
}
// StartPaging brings up the pager on screen
func (p *Pager) StartPaging(screen twin.Screen, chromaStyle *chroma.Style, chromaFormatter *chroma.Formatter) {
log.Trace("Pager starting")
defer log.Trace("Pager done")
defer func() {
if p.reader.err != nil {
log.Warnf("Reader reported an error: %s", p.reader.err.Error())
}
}()
textstyles.UnprintableStyle = p.UnprintableStyle
consumeLessTermcapEnvs(chromaStyle, chromaFormatter)
styleUI(chromaStyle, chromaFormatter, p.StatusBarStyle)
p.screen = screen
p.linePrefix = getLineColorPrefix(chromaStyle, chromaFormatter)
go func() {
for range p.reader.moreLinesAdded {
// Notify the main loop about the new lines so it can show them
screen.Events() <- eventMoreLinesAvailable{}
// Delay updates a bit so that we don't waste time refreshing
// the screen too often.
//
// Note that the delay is *after* reacting, this way single-line
// updates are reacted to immediately, and the first output line
// read will appear on screen without delay.
time.Sleep(200 * time.Millisecond)
}
}()
go func() {
// Spin the spinner as long as contents is still loading
spinnerFrames := [...]string{"/.\\", "-o-", "\\O/", "| |"}
spinnerIndex := 0
for {
if p.reader.done.Load() {
break
}
screen.Events() <- eventSpinnerUpdate{spinnerFrames[spinnerIndex]}
spinnerIndex++
if spinnerIndex >= len(spinnerFrames) {
spinnerIndex = 0
}
time.Sleep(200 * time.Millisecond)
}
// Empty our spinner, loading done!
screen.Events() <- eventSpinnerUpdate{""}
}()
go func() {
for range p.reader.maybeDone {
screen.Events() <- eventMaybeDone{}
}
}()
// Main loop
spinner := ""
for !p.quit {
if len(screen.Events()) == 0 {
// Nothing more to process for now, redraw the screen
overflow := p.redraw(spinner)
// Ref:
// https://github.com/gwsw/less/blob/ff8869aa0485f7188d942723c9fb50afb1892e62/command.c#L828-L831
if p.QuitIfOneScreen && overflow == didFit && !p.isShowingHelp {
// Do the slow (atomic) checks only if the fast ones (no locking
// required) passed
if p.reader.done.Load() && p.reader.highlightingDone.Load() {
// Ref:
// https://github.com/walles/moar/issues/113#issuecomment-1368294132
p.ShowLineNumbers = false // Requires a redraw to take effect, see below
p.DeInit = false
p.quit = true
// Without this the line numbers setting ^ won't take effect
p.redraw(spinner)
break
}
}
}
event := <-screen.Events()
switch event := event.(type) {
case twin.EventKeyCode:
log.Tracef("Handling key event %d...", event.KeyCode())
p.onKey(event.KeyCode())
case twin.EventRune:
log.Tracef("Handling rune event '%c'/0x%04x...", event.Rune(), event.Rune())
p.onRune(event.Rune())
case twin.EventMouse:
log.Tracef("Handling mouse event %d...", event.Buttons())
switch event.Buttons() {
case twin.MouseWheelUp:
// Clipping is done in _Redraw()
p.scrollPosition = p.scrollPosition.PreviousLine(1)
case twin.MouseWheelDown:
// Clipping is done in _Redraw()
p.scrollPosition = p.scrollPosition.NextLine(1)
case twin.MouseWheelLeft:
p.moveRight(-p.SideScrollAmount)
case twin.MouseWheelRight:
p.moveRight(p.SideScrollAmount)
}
case twin.EventResize:
// We'll be implicitly redrawn just by taking another lap in the loop
case twin.EventExit:
log.Debug("Got a Twin exit event, exiting")
return
case eventMoreLinesAvailable:
if p.mode.isViewing() && p.TargetLineNumber != nil {
// The user wants to scroll down to a specific line number
if linenumbers.LineNumberFromLength(p.reader.GetLineCount()).IsBefore(*p.TargetLineNumber) {
// Not there yet, keep scrolling
p.scrollToEnd()
} else {
p.scrollPosition = NewScrollPositionFromLineNumber(*p.TargetLineNumber, "goToTargetLineNumber")
p.TargetLineNumber = nil
}
}
case eventMaybeDone:
// Do nothing. We got this just so that we'll do the QuitIfOneScreen
// check (above) as soon as highlighting is done.
case eventSpinnerUpdate:
spinner = event.spinner
default:
log.Warnf("Unhandled event type: %v", event)
}
}
}
// After the pager has exited and the normal screen has been restored, you can
// call this method to print the pager contents to screen again, faking
// "leaving" pager contents on screen after exit.
func (p *Pager) ReprintAfterExit() error {
// Figure out how many screen lines are used by pager contents
renderedScreenLines, _, _ := p.renderScreenLines()
screenLinesCount := len(renderedScreenLines)
_, screenHeight := p.screen.Size()
screenHeightWithoutFooter := screenHeight - 1
if screenLinesCount > screenHeightWithoutFooter {
screenLinesCount = screenHeightWithoutFooter
}
if screenLinesCount > 0 {
p.screen.ShowNLines(screenLinesCount)
}
fmt.Println()
return nil
}