mirror of
https://github.com/walles/moar.git
synced 2024-11-22 21:50:43 +03:00
d511e50652
As neeed. This enables you to view 24 bit color documents in a 256 color terminal. With reduced quality obviously, but still.
611 lines
16 KiB
Go
611 lines
16 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 MouseMode int
|
|
|
|
const (
|
|
MouseModeAuto MouseMode = iota
|
|
|
|
// Don't capture mouse events. This makes marking with the mouse work. On
|
|
// some terminals mouse scrolling will work using arrow keys emulation, and
|
|
// on some not.
|
|
MouseModeMark
|
|
|
|
// Capture mouse events. This makes mouse scrolling work. Special gymnastics
|
|
// will be required for marking with the mouse to copy text.
|
|
MouseModeScroll
|
|
)
|
|
|
|
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
|
|
|
|
terminalColorCount ColorType
|
|
}
|
|
|
|
// 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) {
|
|
return NewScreenWithMouseMode(MouseModeAuto)
|
|
}
|
|
|
|
func NewScreenWithMouseMode(mouseMode MouseMode) (Screen, error) {
|
|
terminalColorCount := ColorType24bit
|
|
if strings.Contains(os.Getenv("TERM"), "256") {
|
|
// Covers "xterm-256color" as used by the macOS Terminal
|
|
terminalColorCount = ColorType256
|
|
}
|
|
return NewScreenWithMouseModeAndColorType(mouseMode, terminalColorCount)
|
|
}
|
|
|
|
func NewScreenWithMouseModeAndColorType(mouseMode MouseMode, terminalColorCount ColorType) (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{
|
|
terminalColorCount: terminalColorCount,
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Bumped to 160 because of: https://github.com/walles/moar/issues/164
|
|
screen.events = make(chan Event, 160)
|
|
|
|
screen.setupSigwinchNotification()
|
|
err := screen.setupTtyInTtyOut()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("problem setting up TTY: %w", err)
|
|
}
|
|
screen.setAlternateScreenMode(true)
|
|
|
|
if mouseMode == MouseModeAuto {
|
|
screen.enableMouseTracking(!terminalHasArrowKeysEmulation())
|
|
} else if mouseMode == MouseModeMark {
|
|
screen.enableMouseTracking(false)
|
|
} else if mouseMode == MouseModeScroll {
|
|
screen.enableMouseTracking(true)
|
|
} else {
|
|
panic(fmt.Errorf("unknown mouse mode: %d", mouseMode))
|
|
}
|
|
|
|
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 {
|
|
// Debug logging because this is expected to fail in some cases:
|
|
// * https://github.com/walles/moar/issues/145
|
|
// * https://github.com/walles/moar/issues/149
|
|
// * https://github.com/walles/moar/issues/150
|
|
log.Debug("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")
|
|
}
|
|
}
|
|
|
|
// Some terminals convert mouse events to key events making scrolling better
|
|
// without our built-in mouse support, and some do not.
|
|
//
|
|
// For those that do, we're better off without mouse tracking.
|
|
//
|
|
// To test your terminal, run with `moar --mousemode=mark` and see if mouse
|
|
// scrolling still works (both down and then back up to the top). If it does,
|
|
// add another check to this function!
|
|
//
|
|
// See also: https://github.com/walles/moar/issues/53
|
|
func terminalHasArrowKeysEmulation() bool {
|
|
// Untested:
|
|
// * The Windows terminal
|
|
|
|
// Better off with mouse tracking:
|
|
// * iTerm2 (macOS)
|
|
// * Terminal.app (macOS)
|
|
// * Contour, thanks to @postsolar (GitHub username) for testing, 2023-12-18
|
|
|
|
// Hyper, tested on macOS, December 14th 2023
|
|
if os.Getenv("TERM_PROGRAM") == "Hyper" {
|
|
return true
|
|
}
|
|
|
|
// Kitty, tested on macOS, December 14th 2023
|
|
if os.Getenv("KITTY_WINDOW_ID") != "" {
|
|
return true
|
|
}
|
|
|
|
// Alacritty, tested on macOS, December 14th 2023
|
|
if os.Getenv("ALACRITTY_WINDOW_ID") != "" {
|
|
return true
|
|
}
|
|
|
|
// Warp, tested on macOS, December 14th 2023
|
|
if os.Getenv("TERM_PROGRAM") == "WarpTerminal" {
|
|
return true
|
|
}
|
|
|
|
// GNOME Terminal, tested on Ubuntu 22.04, December 16th 2023
|
|
if os.Getenv("GNOME_TERMINAL_SCREEN") != "" {
|
|
return true
|
|
}
|
|
|
|
// Tilix, tested on Ubuntu 22.04, December 16th 2023
|
|
if os.Getenv("TILIX_ID") != "" {
|
|
return true
|
|
}
|
|
|
|
// Konsole, tested on Ubuntu 22.04, December 16th 2023
|
|
if os.Getenv("KONSOLE_VERSION") != "" {
|
|
return true
|
|
}
|
|
|
|
// Terminator, tested on Ubuntu 22.04, December 16th 2023
|
|
if os.Getenv("TERMINATOR_UUID") != "" {
|
|
return true
|
|
}
|
|
|
|
// Foot, tested on Ubuntu 22.04, December 16th 2023
|
|
if os.Getenv("TERM") == "foot" || strings.HasPrefix(os.Getenv("TERM"), "foot-") {
|
|
// Note that this test isn't very good, somebody could be running Foot
|
|
// with some other TERM setting. Other suggestions welcome.
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
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 {
|
|
// Ref:
|
|
// * https://github.com/walles/moar/issues/145
|
|
// * https://github.com/walles/moar/issues/149
|
|
// * https://github.com/walles/moar/issues/150
|
|
log.Debug("ttyin read error, twin giving up: ", err)
|
|
|
|
var event Event = EventExit{}
|
|
screen.events <- event
|
|
return
|
|
}
|
|
|
|
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.Debugf("Events buffer (size %d) full, events are being dropped", cap(screen.events))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Turn ESC into <0x1b> and other low ASCII characters into <0xXX> for logging
|
|
// purposes.
|
|
func humanizeLowAscii(withLowAsciis string) string {
|
|
humanized := ""
|
|
for _, char := range withLowAsciis {
|
|
if char < ' ' {
|
|
humanized += fmt.Sprintf("<0x%2x>", char)
|
|
continue
|
|
}
|
|
humanized += string(char)
|
|
}
|
|
return humanized
|
|
}
|
|
|
|
// 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): {",
|
|
humanizeLowAscii(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): {",
|
|
humanizeLowAscii(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
|
|
func renderLine(row []Cell, terminalColorCount ColorType) (string, int) {
|
|
// Strip trailing whitespace
|
|
lastSignificantCellIndex := len(row) - 1
|
|
for ; lastSignificantCellIndex >= 0; lastSignificantCellIndex-- {
|
|
lastCell := row[lastSignificantCellIndex]
|
|
if lastCell.Rune != ' ' || lastCell.Style != StyleDefault {
|
|
break
|
|
}
|
|
}
|
|
row = row[0 : lastSignificantCellIndex+1]
|
|
|
|
var builder strings.Builder
|
|
|
|
// Set initial line style to normal
|
|
builder.WriteString("\x1b[m")
|
|
lastStyle := StyleDefault
|
|
|
|
for column := 0; column < len(row); 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, terminalColorCount))
|
|
lastStyle = style
|
|
}
|
|
|
|
builder.WriteRune(runeToWrite)
|
|
}
|
|
|
|
// Clear to end of line
|
|
// https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences
|
|
builder.WriteString(StyleDefault.RenderUpdateFrom(lastStyle, terminalColorCount))
|
|
builder.WriteString("\x1b[K")
|
|
|
|
return builder.String(), len(row)
|
|
}
|
|
|
|
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], screen.terminalColorCount)
|
|
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())
|
|
}
|