1
1
mirror of https://github.com/walles/moar.git synced 2024-11-27 01:05:23 +03:00
moar/m/pager.go

629 lines
14 KiB
Go
Raw Normal View History

package m
import (
"fmt"
2019-06-16 11:02:19 +03:00
"log"
2019-06-16 22:54:25 +03:00
"math"
"os"
2019-06-29 19:29:37 +03:00
"regexp"
"time"
"unicode"
"github.com/gdamore/tcell"
)
// FIXME: Profile the pager while searching through a large file
2019-07-06 08:45:07 +03:00
type _PagerMode int
const (
_Viewing _PagerMode = 0
_Searching _PagerMode = 1
_NotFound _PagerMode = 2
)
// Pager is the main on-screen pager
type _Pager struct {
reader *Reader
2019-07-06 14:33:41 +03:00
screen tcell.Screen
quit bool
firstLineOneBased int
leftColumnZeroBased int
2019-07-06 08:45:07 +03:00
mode _PagerMode
2019-06-29 19:29:37 +03:00
searchString string
searchPattern *regexp.Regexp
isShowingHelp bool
preHelpState *_PreHelpState
}
type _PreHelpState struct {
reader *Reader
firstLineOneBased int
leftColumnZeroBased int
}
const _EofMarker = "\x1b[7m---" // Reverse video "---""
var _HelpReader = NewReaderFromText("Help", `
2019-07-26 20:15:24 +03:00
Welcome to Moar, the nice pager!
Quitting
--------
* Press 'q' or ESC to quit
Moving around
-------------
* Arrow keys
* PageUp / 'b' and PageDown / 'f'
* Half page 'u'p / 'd'own
* Home and End for start / end of the document
* < to go to the start of the document
* > to go to the end of the document
* RETURN moves down one line
* SPACE moves down a page
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 or .bash_profile:
export PAGER=/usr/local/bin/moar.rb
Source Code
-----------
Available at https://github.com/walles/moar/.
`)
// NewPager creates a new Pager
func NewPager(r *Reader) *_Pager {
return &_Pager{
reader: r,
2019-06-15 10:23:53 +03:00
quit: false,
firstLineOneBased: 1,
}
}
2019-06-16 21:57:03 +03:00
func (p *_Pager) _AddLine(logger *log.Logger, lineNumber int, line string) {
2019-07-06 14:33:41 +03:00
pos := 0
stringIndexAtColumnZero := p.leftColumnZeroBased
2019-07-06 14:33:41 +03:00
if p.leftColumnZeroBased > 0 {
// Indicate that it's possible to scroll left
p.screen.SetContent(pos, lineNumber, '<', nil, tcell.StyleDefault.Reverse(true))
pos++
// This code can be verified by searching for "monkeys" in
// sample-files/long-and-wide.txt and scrolling right. If the
// "monkeys" highlight is in the right place both before and
// after scrolling right then this code is good.
stringIndexAtColumnZero--
2019-07-06 14:33:41 +03:00
}
tokens, plainString := TokensFromString(logger, line)
2019-07-06 14:33:41 +03:00
if p.leftColumnZeroBased >= len(tokens) {
// Nothing to display, never mind
return
}
2019-06-29 19:29:37 +03:00
2019-07-06 14:33:41 +03:00
matchRanges := GetMatchRanges(plainString, p.searchPattern)
for _, token := range tokens[p.leftColumnZeroBased:] {
width, _ := p.screen.Size()
if pos >= width {
// Indicate that this line continues to the right
p.screen.SetContent(pos-1, lineNumber, '>', nil, tcell.StyleDefault.Reverse(true))
break
}
2019-06-29 19:29:37 +03:00
style := token.Style
if matchRanges.InRange(pos + stringIndexAtColumnZero) {
// Search hits in reverse video
2019-06-29 19:29:37 +03:00
style = style.Reverse(true)
}
p.screen.SetContent(pos, lineNumber, token.Rune, nil, style)
2019-07-06 14:33:41 +03:00
pos++
2019-06-12 08:07:13 +03:00
}
}
func (p *_Pager) _AddSearchFooter() {
_, height := p.screen.Size()
pos := 0
for _, token := range "Search: " + p.searchString {
p.screen.SetContent(pos, height-1, token, nil, tcell.StyleDefault)
pos++
}
2019-06-29 12:20:48 +03:00
// Add a cursor
p.screen.SetContent(pos, height-1, ' ', nil, tcell.StyleDefault.Reverse(true))
}
2019-06-16 21:57:03 +03:00
func (p *_Pager) _AddLines(logger *log.Logger) {
2019-07-06 08:45:07 +03:00
_, height := p.screen.Size()
wantedLineCount := height - 1
2019-06-11 19:52:38 +03:00
lines := p.reader.GetLines(p.firstLineOneBased, wantedLineCount)
2019-06-12 08:07:13 +03:00
// 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
screenLineNumber := 0
for _, line := range lines.lines {
2019-06-16 21:57:03 +03:00
p._AddLine(logger, screenLineNumber, line)
screenLineNumber++
2019-06-11 19:52:38 +03:00
}
2019-06-14 08:08:20 +03:00
p._AddLine(logger, screenLineNumber, _EofMarker)
2019-07-06 08:45:07 +03:00
switch p.mode {
case _Searching:
p._AddSearchFooter()
2019-07-06 08:45:07 +03:00
case _NotFound:
p._SetFooter("Not found: " + p.searchString)
case _Viewing:
helpText := "Press ESC / q to exit, '/' to search, 'h' for help"
if p.isShowingHelp {
helpText = "Press ESC / q to exit help, '/' to search"
}
p._SetFooter(lines.statusText + " " + helpText)
2019-07-06 08:45:07 +03:00
default:
2019-07-06 08:54:33 +03:00
panic(fmt.Sprint("Unsupported pager mode: ", p.mode))
}
2019-07-06 08:45:07 +03:00
}
func (p *_Pager) _SetFooter(footer string) {
width, height := p.screen.Size()
pos := 0
footerStyle := tcell.StyleDefault.Reverse(true)
2019-07-06 08:45:07 +03:00
for _, token := range footer {
p.screen.SetContent(pos, height-1, token, nil, footerStyle)
pos++
2019-06-14 08:08:20 +03:00
}
for ; pos < width; pos++ {
p.screen.SetContent(pos, height-1, ' ', nil, footerStyle)
2019-06-14 08:08:20 +03:00
}
2019-06-12 08:07:13 +03:00
}
2019-06-16 21:57:03 +03:00
func (p *_Pager) _Redraw(logger *log.Logger) {
2019-06-11 22:09:57 +03:00
p.screen.Clear()
2019-06-11 19:52:38 +03:00
2019-06-16 21:57:03 +03:00
p._AddLines(logger)
2019-06-11 19:52:38 +03:00
p.screen.Show()
2019-06-11 19:52:38 +03:00
}
func (p *_Pager) Quit() {
if !p.isShowingHelp {
p.quit = true
return
}
// Reset help
p.isShowingHelp = false
p.reader = p.preHelpState.reader
p.firstLineOneBased = p.preHelpState.firstLineOneBased
p.leftColumnZeroBased = p.preHelpState.leftColumnZeroBased
p.preHelpState = nil
2019-06-11 22:21:12 +03:00
}
func (p *_Pager) _ScrollToSearchHits() {
2019-06-30 23:16:04 +03:00
if p.searchPattern == nil {
// This is not a search
return
}
2019-07-06 11:31:40 +03:00
firstHitLine := p._FindFirstHitLineOneBased(p.firstLineOneBased, false)
2019-07-06 08:45:07 +03:00
if firstHitLine == nil {
// No match, give up
return
}
if *firstHitLine <= p._GetLastVisibleLineOneBased() {
// Already on-screen, never mind
return
}
p.firstLineOneBased = *firstHitLine
}
func (p *_Pager) _GetLastVisibleLineOneBased() int {
2019-06-30 23:16:04 +03:00
firstVisibleLineOneBased := p.firstLineOneBased
_, windowHeight := p.screen.Size()
// If first line is 1 and window is 2 high, and one line is the status
// line, the last line will be 1 + 2 - 2 = 1
2019-07-06 08:45:07 +03:00
return firstVisibleLineOneBased + windowHeight - 2
}
2019-07-06 11:31:40 +03:00
func (p *_Pager) _FindFirstHitLineOneBased(firstLineOneBased int, backwards bool) *int {
2019-07-06 08:45:07 +03:00
lineNumber := firstLineOneBased
2019-06-30 23:16:04 +03:00
for {
line := p.reader.GetLine(lineNumber)
if line == nil {
// No match, give up
2019-07-06 08:45:07 +03:00
return nil
2019-06-30 23:16:04 +03:00
}
if p.searchPattern.MatchString(*line) {
2019-07-06 08:45:07 +03:00
return &lineNumber
2019-06-30 23:16:04 +03:00
}
2019-07-06 11:31:40 +03:00
if backwards {
lineNumber--
} else {
lineNumber++
}
2019-06-30 23:16:04 +03:00
}
}
2019-07-06 08:45:07 +03:00
func (p *_Pager) _ScrollToNextSearchHit() {
if p.searchPattern == nil {
// Nothing to search for, never mind
return
}
2019-07-06 11:31:40 +03:00
if p.reader.GetLineCount() == 0 {
// Nothing to search in, never mind
return
}
2019-07-06 08:45:07 +03:00
var firstSearchLineOneBased int
switch p.mode {
case _Viewing:
// Start searching on the first line below the bottom of the screen
firstSearchLineOneBased = p._GetLastVisibleLineOneBased() + 1
case _NotFound:
// Restart searching from the top
p.mode = _Viewing
firstSearchLineOneBased = 1
default:
2019-07-06 08:54:33 +03:00
panic(fmt.Sprint("Unknown search mode when finding next: ", p.mode))
2019-07-06 08:45:07 +03:00
}
2019-07-06 11:31:40 +03:00
firstHitLine := p._FindFirstHitLineOneBased(firstSearchLineOneBased, false)
if firstHitLine == nil {
p.mode = _NotFound
return
}
p.firstLineOneBased = *firstHitLine
}
func (p *_Pager) _ScrollToPreviousSearchHit() {
if p.searchPattern == nil {
// Nothing to search for, never mind
return
}
if p.reader.GetLineCount() == 0 {
// Nothing to search in, never mind
return
}
var firstSearchLineOneBased int
switch p.mode {
case _Viewing:
// Start searching on the first line above the top of the screen
firstSearchLineOneBased = p.firstLineOneBased - 1
case _NotFound:
// Restart searching from the bottom
p.mode = _Viewing
firstSearchLineOneBased = p.reader.GetLineCount()
default:
panic(fmt.Sprint("Unknown search mode when finding previous: ", p.mode))
}
firstHitLine := p._FindFirstHitLineOneBased(firstSearchLineOneBased, true)
2019-07-06 08:45:07 +03:00
if firstHitLine == nil {
p.mode = _NotFound
return
}
p.firstLineOneBased = *firstHitLine
}
func (p *_Pager) _UpdateSearchPattern() {
p.searchPattern = ToPattern(p.searchString)
p._ScrollToSearchHits()
// FIXME: If the user is typing, indicate to user if we didn't find anything
}
2019-06-29 19:29:37 +03:00
// ToPattern compiles a search string into a pattern.
//
// If the string contains only lower-case letter the pattern will be case insensitive.
//
// If the string is empty the pattern will be nil.
//
// If the string does not compile into a regexp the pattern will match the string verbatim
func ToPattern(compileMe string) *regexp.Regexp {
if len(compileMe) == 0 {
return nil
}
hasUppercase := false
for _, char := range compileMe {
if unicode.IsUpper(char) {
hasUppercase = true
}
}
// Smart case; be case insensitive unless there are upper case chars
// in the search string
prefix := "(?i)"
if hasUppercase {
prefix = ""
}
pattern, err := regexp.Compile(prefix + compileMe)
2019-06-29 19:29:37 +03:00
if err == nil {
// Search string is a regexp
return pattern
2019-06-29 19:29:37 +03:00
}
pattern, err = regexp.Compile(prefix + regexp.QuoteMeta(compileMe))
2019-06-29 19:29:37 +03:00
if err == nil {
// Pattern matching the string exactly
return pattern
2019-06-29 19:29:37 +03:00
}
// Unable to create a match-string-verbatim pattern
panic(err)
}
func (p *_Pager) _OnSearchKey(logger *log.Logger, key tcell.Key) {
switch key {
case tcell.KeyEscape, tcell.KeyEnter:
2019-07-06 08:45:07 +03:00
p.mode = _Viewing
2019-06-29 12:20:48 +03:00
case tcell.KeyBackspace, tcell.KeyDEL:
if len(p.searchString) == 0 {
return
}
p.searchString = p.searchString[:len(p.searchString)-1]
p._UpdateSearchPattern()
2019-06-29 12:20:48 +03:00
2019-08-04 08:40:26 +03:00
case tcell.KeyUp:
// Clipping is done in _AddLines()
p.firstLineOneBased--
p.mode = _Viewing
case tcell.KeyDown:
// Clipping is done in _AddLines()
p.firstLineOneBased++
p.mode = _Viewing
case tcell.KeyPgUp:
_, height := p.screen.Size()
p.firstLineOneBased -= (height - 1)
p.mode = _Viewing
case tcell.KeyPgDn:
_, height := p.screen.Size()
p.firstLineOneBased += (height - 1)
p.mode = _Viewing
default:
2019-06-29 12:20:48 +03:00
logger.Printf("Unhandled search key event %v", key)
}
}
2019-06-16 11:02:19 +03:00
func (p *_Pager) _OnKey(logger *log.Logger, key tcell.Key) {
2019-07-06 08:45:07 +03:00
if p.mode == _Searching {
p._OnSearchKey(logger, key)
return
}
2019-07-06 08:54:33 +03:00
if p.mode != _Viewing && p.mode != _NotFound {
panic(fmt.Sprint("Unhandled mode: ", p.mode))
}
2019-07-06 08:45:07 +03:00
// Reset the not-found marker on non-search keypresses
p.mode = _Viewing
switch key {
2019-06-13 16:56:06 +03:00
case tcell.KeyEscape:
p.Quit()
case tcell.KeyUp:
// Clipping is done in _AddLines()
p.firstLineOneBased--
2019-06-13 16:56:06 +03:00
case tcell.KeyDown, tcell.KeyEnter:
// Clipping is done in _AddLines()
p.firstLineOneBased++
2019-06-13 07:14:41 +03:00
2019-07-06 14:33:41 +03:00
case tcell.KeyRight:
p.leftColumnZeroBased += 16
case tcell.KeyLeft:
p.leftColumnZeroBased -= 16
if p.leftColumnZeroBased < 0 {
p.leftColumnZeroBased = 0
}
2019-07-06 08:45:07 +03:00
2019-06-13 07:14:41 +03:00
case tcell.KeyHome:
p.firstLineOneBased = 1
case tcell.KeyEnd:
2019-06-16 22:54:25 +03:00
p.firstLineOneBased = math.MaxInt32
2019-06-13 07:14:41 +03:00
2019-06-13 07:21:43 +03:00
case tcell.KeyPgDn:
_, height := p.screen.Size()
p.firstLineOneBased += (height - 1)
case tcell.KeyPgUp:
_, height := p.screen.Size()
p.firstLineOneBased -= (height - 1)
2019-06-16 11:02:19 +03:00
default:
logger.Printf("Unhandled key event %v", key)
}
}
2019-06-29 12:20:48 +03:00
func (p *_Pager) _OnSearchRune(logger *log.Logger, char rune) {
p.searchString = p.searchString + string(char)
p._UpdateSearchPattern()
}
2019-06-16 11:02:19 +03:00
func (p *_Pager) _OnRune(logger *log.Logger, char rune) {
2019-07-06 08:45:07 +03:00
if p.mode == _Searching {
2019-06-29 12:20:48 +03:00
p._OnSearchRune(logger, char)
return
}
2019-07-06 08:54:33 +03:00
if p.mode != _Viewing && p.mode != _NotFound {
panic(fmt.Sprint("Unhandled mode: ", p.mode))
}
2019-06-11 22:32:24 +03:00
switch char {
case 'q':
p.Quit()
2019-06-13 07:21:43 +03:00
case 'h':
if !p.isShowingHelp {
p.preHelpState = &_PreHelpState{
reader: p.reader,
firstLineOneBased: p.firstLineOneBased,
leftColumnZeroBased: p.leftColumnZeroBased,
}
p.reader = _HelpReader
p.firstLineOneBased = 1
p.leftColumnZeroBased = 0
p.isShowingHelp = true
}
2019-06-13 16:56:06 +03:00
case 'k', 'y':
// Clipping is done in _AddLines()
p.firstLineOneBased--
case 'j', 'e':
// Clipping is done in _AddLines()
p.firstLineOneBased++
2019-06-13 07:14:41 +03:00
case '<', 'g':
p.firstLineOneBased = 1
2019-06-13 07:21:43 +03:00
2019-06-13 07:14:41 +03:00
case '>', 'G':
2019-06-16 22:54:25 +03:00
p.firstLineOneBased = math.MaxInt32
2019-06-13 07:21:43 +03:00
2019-06-13 16:56:06 +03:00
case 'f', ' ':
2019-06-13 07:21:43 +03:00
_, height := p.screen.Size()
p.firstLineOneBased += (height - 1)
case 'b':
_, height := p.screen.Size()
p.firstLineOneBased -= (height - 1)
2019-07-26 20:15:24 +03:00
case 'u':
_, height := p.screen.Size()
p.firstLineOneBased -= (height / 2)
case 'd':
_, height := p.screen.Size()
p.firstLineOneBased += (height / 2)
case '/':
2019-07-06 08:45:07 +03:00
p.mode = _Searching
2019-06-30 23:15:27 +03:00
p.searchString = ""
p.searchPattern = nil
2019-07-06 08:45:07 +03:00
case 'n':
p._ScrollToNextSearchHit()
2019-07-06 11:31:40 +03:00
case 'p', 'N':
p._ScrollToPreviousSearchHit()
2019-07-06 08:45:07 +03:00
2019-06-16 11:02:19 +03:00
default:
2019-07-26 20:39:05 +03:00
logger.Printf("Unhandled rune keypress '%s'", string(char))
2019-06-11 22:32:24 +03:00
}
}
// StartPaging brings up the pager on screen
2019-06-16 11:02:19 +03:00
func (p *_Pager) StartPaging(logger *log.Logger, screen tcell.Screen) {
// We want to match the terminal theme, see screen.Init() source code
os.Setenv("TCELL_TRUECOLOR", "disable")
if e := screen.Init(); e != nil {
fmt.Fprintf(os.Stderr, "%v\n", e)
os.Exit(1)
}
p.screen = screen
screen.Show()
2019-06-16 21:57:03 +03:00
p._Redraw(logger)
go func() {
for {
// Wait for new lines to appear
<-p.reader.moreLinesAdded
screen.PostEvent(tcell.NewEventInterrupt(nil))
// 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)
}
}()
// Main loop
2019-06-15 10:23:53 +03:00
for !p.quit {
ev := screen.PollEvent()
switch ev := ev.(type) {
case *tcell.EventKey:
if ev.Key() == tcell.KeyRune {
2019-06-16 11:02:19 +03:00
p._OnRune(logger, ev.Rune())
2019-06-15 10:23:53 +03:00
} else {
2019-06-16 11:02:19 +03:00
p._OnKey(logger, ev.Key())
}
2019-06-15 10:23:53 +03:00
case *tcell.EventResize:
// We'll be implicitly redrawn just by taking another lap in the loop
2019-06-16 11:02:19 +03:00
case *tcell.EventInterrupt:
// This means we got more lines, look for NewEventInterrupt higher up
// in this file. Doing nothing here is fine, the refresh happens after
// this switch statement.
2019-06-16 11:02:19 +03:00
default:
logger.Printf("Unhandled event type: %v", ev)
}
2019-06-22 00:24:53 +03:00
// FIXME: If more events are ready, skip this redraw, that
// should speed up mouse wheel scrolling
2019-06-16 21:57:03 +03:00
p._Redraw(logger)
2019-06-15 10:23:53 +03:00
}
2019-07-11 19:34:10 +03:00
// FIXME: Log p.reader.err if it's non-nil
}