1
1
mirror of https://github.com/walles/moar.git synced 2024-11-22 21:50:43 +03:00
moar/m/pager.go
2024-06-21 08:20:08 +02:00

478 lines
13 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 interface {
onKey(key twin.KeyCode)
onRune(char rune)
drawFooter(statusText string, spinner string)
}
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
// Maybe this should be renamed to "controller"? Because it controls the UI?
// But since we replace it in a lot of places based on the UI mode, maybe
// mode is better?
mode PagerMode
searchString string
searchPattern *regexp.Regexp
// 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
// Bookmarks that you can come back to.
//
// Ref: https://github.com/walles/moar/issues/175
marks map[rune]scrollPosition
AfterExit func() error
}
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
* Press 'v' to edit the file in your favorite editor
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
* Home and End for start / end of the document
* 'g' for going to a specific line number
* 'm' sets a mark, you will be asked for a letter to label it with
* ' (single quote) jumps to the mark
* CTRL-p moves to the previous line
* CTRL-n moves to the next line
* PageUp / 'b' and PageDown / 'f'
* SPACE moves down a page
* < / '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/.
`)
// 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
}
pager := 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),
}
pager.mode = PagerModeViewing{pager: &pager}
return &pager
}
// 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
}
// Draw the footer string at the bottom using the status bar style
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{}
}
}
// 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)
p.mode = PagerModeViewing{pager: p}
p.marks = make(map[rune]scrollPosition)
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.mode.onKey(event.KeyCode())
case twin.EventRune:
log.Tracef("Handling rune event '%c'/0x%04x...", event.Rune(), event.Rune())
p.mode.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.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 {
// We see the target, scroll to it
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
case twin.EventTerminalBackgroundDetected:
// Do nothing, we don't care about background color updates
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
}