1
1
mirror of https://github.com/walles/moar.git synced 2024-09-11 20:17:13 +03:00
moar/twin/screen.go
Johan Walles cc6819efd0 Display Private Use Unicode characters
Before this change, we rendered those as highlighted question marks.

With this change in place, we just send them to the screen for
displaying.

The reason is that Font Awesome uses these for symbols, this one for
example: https://fontawesome.com/v4/icon/battery-empty

More reading: https://en.wikipedia.org/wiki/Private_Use_Areas
2023-02-28 19:28:37 +01:00

497 lines
13 KiB
Go

// Package twin provides Terminal Window interaction
package twin
import (
"fmt"
"os"
"regexp"
"strings"
"unicode/utf8"
log "github.com/sirupsen/logrus"
"golang.org/x/term"
)
type Screen interface {
// Close() restores terminal to normal state, must be called after you are
// done with your screen
Close()
Clear()
SetCell(column int, row int, cell Cell)
// Render our contents into the terminal window
Show()
// Can be called after Close()ing the screen to fake retaining its output.
// Plain Show() is what you'd call during normal operation.
ShowNLines(lineCountToShow int)
// Returns screen width and height.
//
// NOTE: Never cache this response! On window resizes you'll get an
// EventResize on the Screen.Events channel, and this method will start
// returning the new size instead.
Size() (width int, height int)
// ShowCursorAt() moves the cursor to the given screen position and makes
// sure it is visible.
//
// If the position is outside of the screen, the cursor will be hidden.
ShowCursorAt(column int, row int)
// This channel is what your main loop should be checking.
Events() chan Event
}
type UnixScreen struct {
widthAccessFromSizeOnly int // Access from Size() method only
heightAccessFromSizeOnly int // Access from Size() method only
cells [][]Cell
// Note that the type here doesn't matter, we only want to know whether or
// not this channel has been signalled
sigwinch chan int
events chan Event
ttyIn *os.File
oldTerminalState *term.State //nolint Not used on Windows
oldTtyInMode uint32 //nolint Windows only
ttyOut *os.File
oldTtyOutMode uint32 //nolint Windows only
}
// Example event: "\x1b[<65;127;41M"
//
// Where:
//
// * "\x1b[<" says this is a mouse event
//
// * "65" says this is Wheel Up. "64" would be Wheel Down.
//
// * "127" is the column number on screen, "1" is the first column.
//
// * "41" is the row number on screen, "1" is the first row.
//
// * "M" marks the end of the mouse event.
var MOUSE_EVENT_REGEX = regexp.MustCompile("^\x1b\\[<([0-9]+);([0-9]+);([0-9]+)M")
// NewScreen() requires Close() to be called after you are done with your new
// screen, most likely somewhere in your shutdown code.
func NewScreen() (Screen, error) {
if !term.IsTerminal(int(os.Stdout.Fd())) {
return nil, fmt.Errorf("stdout (fd=%d) must be a terminal for paging to work", os.Stdout.Fd())
}
screen := UnixScreen{}
// The number "80" here is from manual testing on my MacBook:
//
// First, start "./moar.sh sample-files/large-git-log-patch.txt".
//
// Then do a two finger flick initiating a momentum based scroll-up.
//
// Now, if you get "Events buffer full" warnings, the buffer is too small.
//
// By this definition, 40 was too small, and 80 was OK.
screen.events = make(chan Event, 80)
screen.setupSigwinchNotification()
err := screen.setupTtyInTtyOut()
if err != nil {
return nil, err
}
screen.setAlternateScreenMode(true)
screen.enableMouseTracking(true)
screen.hideCursor(true)
go screen.mainLoop()
return &screen, nil
}
// Close() restores terminal to normal state, must be called after you are done
// with the screen returned by NewScreen()
func (screen *UnixScreen) Close() {
screen.hideCursor(false)
screen.enableMouseTracking(false)
screen.setAlternateScreenMode(false)
err := screen.restoreTtyInTtyOut()
if err != nil {
log.Warn("Problem restoring TTY state: ", err)
}
}
func (screen *UnixScreen) Events() chan Event {
return screen.events
}
// Write string to ttyOut, panic on failure, return number of bytes written.
func (screen *UnixScreen) write(string string) int {
bytesWritten, err := screen.ttyOut.Write([]byte(string))
if err != nil {
panic(err)
}
return bytesWritten
}
func (screen *UnixScreen) setAlternateScreenMode(enable bool) {
// Ref: https://stackoverflow.com/a/11024208/473672
if enable {
screen.write("\x1b[?1049h")
} else {
screen.write("\x1b[?1049l")
}
}
func (screen *UnixScreen) hideCursor(hide bool) {
// Ref: https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences
if hide {
screen.write("\x1b[?25l")
} else {
screen.write("\x1b[?25h")
}
}
func (screen *UnixScreen) enableMouseTracking(enable bool) {
if enable {
screen.write("\x1b[?1006;1000h")
} else {
screen.write("\x1b[?1006;1000l")
}
}
// ShowCursorAt() moves the cursor to the given screen position and makes sure
// it is visible.
//
// If the position is outside of the screen, the cursor will be hidden.
func (screen *UnixScreen) ShowCursorAt(column int, row int) {
if column < 0 {
screen.hideCursor(true)
return
}
if row < 0 {
screen.hideCursor(true)
return
}
width, height := screen.Size()
if column >= width {
screen.hideCursor(true)
return
}
if row >= height {
screen.hideCursor(true)
return
}
// https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences
screen.write(fmt.Sprintf("\x1b[%d;%dH", row, column))
screen.hideCursor(false)
}
func (screen *UnixScreen) mainLoop() {
// "1400" comes from me trying fling scroll operations on my MacBook
// trackpad and looking at the high watermark (logged below).
//
// The highest I saw when I tried this was 700 something. 1400 is twice
// that, so 1400 should be good.
buffer := make([]byte, 1400)
maxBytesRead := 0
for {
count, err := screen.ttyIn.Read(buffer)
if err != nil {
panic(err)
}
if count > maxBytesRead {
maxBytesRead = count
log.Trace("ttyin high watermark bumped to ", maxBytesRead, " bytes")
}
encodedKeyCodeSequences := string(buffer[0:count])
if !utf8.ValidString(encodedKeyCodeSequences) {
log.Warn("Got invalid UTF-8 sequence on ttyin: ", encodedKeyCodeSequences)
continue
}
for len(encodedKeyCodeSequences) > 0 {
var event *Event
event, encodedKeyCodeSequences = consumeEncodedEvent(encodedKeyCodeSequences)
if event == nil {
// No event, go wait for more
break
}
// Post the event
select {
case screen.events <- *event:
// Yay
default:
// If this happens, consider increasing the channel size in
// NewScreen()
log.Warn("Events buffer full, events are being dropped")
}
}
}
}
// Consume initial key code from the sequence of encoded keycodes.
//
// Returns a (possibly nil) event that should be posted, and the remainder of
// the encoded events sequence.
func consumeEncodedEvent(encodedEventSequences string) (*Event, string) {
for singleKeyCodeSequence, keyCode := range escapeSequenceToKeyCode {
if !strings.HasPrefix(encodedEventSequences, singleKeyCodeSequence) {
continue
}
// Encoded key code sequence found, report it!
var event Event = EventKeyCode{keyCode}
return &event, strings.TrimPrefix(encodedEventSequences, singleKeyCodeSequence)
}
mouseMatch := MOUSE_EVENT_REGEX.FindStringSubmatch(encodedEventSequences)
if mouseMatch != nil {
if mouseMatch[1] == "64" {
var event Event = EventMouse{buttons: MouseWheelUp}
return &event, strings.TrimPrefix(encodedEventSequences, mouseMatch[0])
}
if mouseMatch[1] == "65" {
var event Event = EventMouse{buttons: MouseWheelDown}
return &event, strings.TrimPrefix(encodedEventSequences, mouseMatch[0])
}
log.Debug("Unhandled multi character mouse escape sequence(s): ", encodedEventSequences)
return nil, ""
}
// No escape sequence prefix matched
runes := []rune(encodedEventSequences)
if len(runes) == 0 {
return nil, ""
}
if runes[0] == '\x1b' {
if len(runes) != 1 {
// This means one or more sequences should be added to
// escapeSequenceToKeyCode in keys.go.
log.Debug("Unhandled multi character terminal escape sequence(s): ", encodedEventSequences)
// Mark everything as consumed since we don't know how to proceed otherwise.
return nil, ""
}
var event Event = EventKeyCode{KeyEscape}
return &event, string(runes[1:])
}
if runes[0] == '\r' {
var event Event = EventKeyCode{KeyEnter}
return &event, string(runes[1:])
}
// Report the single rune
var event Event = EventRune{rune: runes[0]}
return &event, string(runes[1:])
}
// Returns screen width and height.
//
// NOTE: Never cache this response! On window resizes you'll get an EventResize
// on the Screen.Events channel, and this method will start returning the new
// size instead.
func (screen *UnixScreen) Size() (width int, height int) {
select {
case <-screen.sigwinch:
// Resize logic needed, see below
default:
// No resize, go with the existing values
return screen.widthAccessFromSizeOnly, screen.heightAccessFromSizeOnly
}
// Window was resized
width, height, err := term.GetSize(int(screen.ttyOut.Fd()))
if err != nil {
panic(err)
}
if screen.widthAccessFromSizeOnly == width && screen.heightAccessFromSizeOnly == height {
// Not sure when this would happen, but if it does this wasn't really a
// resize, and we don't need to treat it as such.
return screen.widthAccessFromSizeOnly, screen.heightAccessFromSizeOnly
}
newCells := make([][]Cell, height)
for rowNumber := 0; rowNumber < height; rowNumber++ {
newCells[rowNumber] = make([]Cell, width)
}
// FIXME: Copy any existing contents over to the new, resized screen array
// FIXME: Fill any non-initialized cells with whitespace
screen.widthAccessFromSizeOnly = width
screen.heightAccessFromSizeOnly = height
screen.cells = newCells
return screen.widthAccessFromSizeOnly, screen.heightAccessFromSizeOnly
}
func (screen *UnixScreen) SetCell(column int, row int, cell Cell) {
if column < 0 {
return
}
if row < 0 {
return
}
width, height := screen.Size()
if column >= width {
return
}
if row >= height {
return
}
screen.cells[row][column] = cell
}
func (screen *UnixScreen) Clear() {
empty := NewCell(' ', StyleDefault)
width, height := screen.Size()
for row := 0; row < height; row++ {
for column := 0; column < width; column++ {
screen.cells[row][column] = empty
}
}
}
// Returns the rendered line, plus how many information carrying cells went into
// it (see headerLength inside of this function).
func renderLine(row []Cell) (string, int) {
width := len(row)
if width == 0 {
return "", 0
}
lastCell := row[len(row)-1]
// How many trailing whitespace characters are there matching lastCell?
trailerLength := 0
if lastCell.Style.attrs.has(AttrBlink) {
// This block intentionally left blank.
//
// Trailer is rendered by clearing to EOL, and AttrBlink will most
// likely not survive that. Don't bother with any trailer in this case.
} else if lastCell.Style.attrs.has(AttrReverse) {
// This block intentionally left blank.
//
// Trailer is rendered by clearing to EOL, and AttrReverse didn't
// survive that when I tried it using iTerm2 3.4.4 on my MacBook. Don't
// bother with any trailer in this case.
} else if lastCell.Rune == ' ' {
// Line has a number of trailing spaces
for i := len(row) - 1; i >= 0; i-- {
currentCell := row[i]
if currentCell != lastCell {
break
}
trailerLength++
}
}
// How many information carrying cells are there before the trailer?
headerLength := len(row) - trailerLength
var builder strings.Builder
// Set initial line style to normal
builder.WriteString("\x1b[m")
lastStyle := StyleDefault
for column := 0; column < headerLength; column++ {
cell := row[column]
style := cell.Style
runeToWrite := cell.Rune
if !Printable(runeToWrite) {
// Highlight unprintable runes
style = Style{
fg: NewColor16(7), // White
bg: NewColor16(1), // Red
attrs: AttrBold,
}
runeToWrite = '?'
}
if style != lastStyle {
builder.WriteString(style.RenderUpdateFrom(lastStyle))
lastStyle = style
}
builder.WriteRune(runeToWrite)
}
// Set trailer attributes
builder.WriteString(lastCell.Style.RenderUpdateFrom(lastStyle))
// Clear to end of line
// https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences
builder.WriteString("\x1b[K")
if lastStyle != StyleDefault {
// Reset style after each line
builder.WriteString("\x1b[m")
}
return builder.String(), headerLength
}
func (screen *UnixScreen) Show() {
_, height := screen.Size()
screen.showNLines(height, true)
}
func (screen *UnixScreen) ShowNLines(height int) {
screen.showNLines(height, false)
}
func (screen *UnixScreen) showNLines(height int, clearFirst bool) {
var builder strings.Builder
if clearFirst {
// Start in the top left corner:
// https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences
builder.WriteString("\x1b[1;1H")
}
for row := 0; row < height; row++ {
rendered, lineLength := renderLine(screen.cells[row])
builder.WriteString(rendered)
wasLastLine := row == (height - 1)
// NOTE: This <= should *really* be <= and nothing else. Otherwise, if
// one line precisely as long as the terminal window goes before one
// empty line, the empty line will never be rendered.
//
// Can be demonstrated using "moar m/pager.go", scroll right once to
// make the line numbers go away, then make the window narrower until
// some line before an empty line is just as wide as the window.
//
// With the wrong comparison here, then the empty line just disappears.
if lineLength <= len(screen.cells[row]) && !wasLastLine {
builder.WriteString("\r\n")
}
}
// Write out what we have
screen.write(builder.String())
}