The new filter comes with these enhancements: - Processing is rune-wise rather than byte-wise; it assumes UTF-8 input and single-cell wide characters, but this condition should be /usually/ satisfied. - Unprocessed control characters are escaped, `cat -v` style. - A lot of escape sequences is at least recognised, if not processed. - Rudimentary preparation for efficient dynamic updates of task views, through Javascript. We make terminal resets and screen clearing commands flush all output and assume that the terminal has a new origin for any later positioning commands. This appears to work well enough with GRUB, at least. The filter is now exposed through a command line option.
This commit is contained in:
		
							
								
								
									
										369
									
								
								terminal.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										369
									
								
								terminal.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,369 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"io"
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"unicode/utf8"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type terminalLine struct {
 | 
			
		||||
	// For simplicity, we assume that all runes take up one cell,
 | 
			
		||||
	// including TAB and non-spacing ones.
 | 
			
		||||
	// The next step would be grouping non-spacing characters,
 | 
			
		||||
	// in particular Unicode modifier letters, with their base.
 | 
			
		||||
	columns []rune
 | 
			
		||||
 | 
			
		||||
	// updateGroup is the topmost line that has changed since this line
 | 
			
		||||
	// has appeared, for the purpose of update tracking.
 | 
			
		||||
	updateGroup int
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// terminalWriter does a best-effort approximation of an infinite-size
 | 
			
		||||
// virtual terminal.
 | 
			
		||||
type terminalWriter struct {
 | 
			
		||||
	sync.Mutex
 | 
			
		||||
	Tee   io.WriteCloser
 | 
			
		||||
	lines []terminalLine
 | 
			
		||||
 | 
			
		||||
	// Zero-based coordinates within lines.
 | 
			
		||||
	column, line int
 | 
			
		||||
 | 
			
		||||
	// lineTop is used as the base for positioning commands.
 | 
			
		||||
	lineTop int
 | 
			
		||||
 | 
			
		||||
	written    int
 | 
			
		||||
	byteBuffer []byte
 | 
			
		||||
	runeBuffer []rune
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (tw *terminalWriter) log(format string, v ...interface{}) {
 | 
			
		||||
	if os.Getenv("ACID_TERMINAL_DEBUG") != "" {
 | 
			
		||||
		log.Printf("terminal: "+format+"\n", v...)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (tw *terminalWriter) Serialize(top int) []byte {
 | 
			
		||||
	var b bytes.Buffer
 | 
			
		||||
	for i := top; i < len(tw.lines); i++ {
 | 
			
		||||
		b.WriteString(string(tw.lines[i].columns))
 | 
			
		||||
		b.WriteByte('\n')
 | 
			
		||||
	}
 | 
			
		||||
	return b.Bytes()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (tw *terminalWriter) Write(p []byte) (written int, err error) {
 | 
			
		||||
	tw.Lock()
 | 
			
		||||
	defer tw.Unlock()
 | 
			
		||||
 | 
			
		||||
	// TODO(p): Rather use io.MultiWriter?
 | 
			
		||||
	// Though I'm not sure what to do about closing (FD leaks).
 | 
			
		||||
	// Eventually, any handles would be garbage collected in any case.
 | 
			
		||||
	if tw.Tee != nil {
 | 
			
		||||
		tw.Tee.Write(p)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Enough is enough, writing too much is highly suspicious.
 | 
			
		||||
	ok, remaining := true, 64<<20-tw.written
 | 
			
		||||
	if remaining < 0 {
 | 
			
		||||
		ok, p = false, nil
 | 
			
		||||
	} else if remaining < len(p) {
 | 
			
		||||
		ok, p = false, p[:remaining]
 | 
			
		||||
	}
 | 
			
		||||
	tw.written += len(p)
 | 
			
		||||
 | 
			
		||||
	// By now, more or less everything should run in UTF-8.
 | 
			
		||||
	//
 | 
			
		||||
	// This might have better performance with a ring buffer,
 | 
			
		||||
	// so as to avoid reallocations.
 | 
			
		||||
	b := append(tw.byteBuffer, p...)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		b = append(b, "\nToo much terminal output\n"...)
 | 
			
		||||
	}
 | 
			
		||||
	for utf8.FullRune(b) {
 | 
			
		||||
		r, len := utf8.DecodeRune(b)
 | 
			
		||||
		b, tw.runeBuffer = b[len:], append(tw.runeBuffer, r)
 | 
			
		||||
	}
 | 
			
		||||
	tw.byteBuffer = b
 | 
			
		||||
	for tw.processRunes() {
 | 
			
		||||
	}
 | 
			
		||||
	return len(p), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (tw *terminalWriter) processPrint(r rune) {
 | 
			
		||||
	// Extend the buffer vertically.
 | 
			
		||||
	for len(tw.lines) <= tw.line {
 | 
			
		||||
		tw.lines = append(tw.lines,
 | 
			
		||||
			terminalLine{updateGroup: len(tw.lines)})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Refresh update trackers, if necessary.
 | 
			
		||||
	if tw.lines[len(tw.lines)-1].updateGroup > tw.line {
 | 
			
		||||
		for i := tw.line; i < len(tw.lines); i++ {
 | 
			
		||||
			tw.lines[i].updateGroup = tw.line
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Emulate `cat -v` for C0 characters.
 | 
			
		||||
	seq := make([]rune, 0, 2)
 | 
			
		||||
	if r < 32 && r != '\t' {
 | 
			
		||||
		seq = append(seq, '^', 64+r)
 | 
			
		||||
	} else {
 | 
			
		||||
		seq = append(seq, r)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Extend the line horizontally and write the rune.
 | 
			
		||||
	for _, r := range seq {
 | 
			
		||||
		line := &tw.lines[tw.line]
 | 
			
		||||
		for len(line.columns) <= tw.column {
 | 
			
		||||
			line.columns = append(line.columns, ' ')
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		line.columns[tw.column] = r
 | 
			
		||||
		tw.column++
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (tw *terminalWriter) processFlush() {
 | 
			
		||||
	tw.column = 0
 | 
			
		||||
	tw.line = len(tw.lines)
 | 
			
		||||
	tw.lineTop = tw.line
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (tw *terminalWriter) processParsedCSI(
 | 
			
		||||
	private rune, param, intermediate []rune, final rune) bool {
 | 
			
		||||
	var params []int
 | 
			
		||||
	if len(param) > 0 {
 | 
			
		||||
		for _, p := range strings.Split(string(param), ";") {
 | 
			
		||||
			i, _ := strconv.Atoi(p)
 | 
			
		||||
			params = append(params, i)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if private == '?' && len(intermediate) == 0 &&
 | 
			
		||||
		(final == 'h' || final == 'l') {
 | 
			
		||||
		for _, p := range params {
 | 
			
		||||
			// 25 (DECTCEM): There is no cursor to show or hide.
 | 
			
		||||
			// 7 (DECAWM): We cannot wrap, we're infinite.
 | 
			
		||||
			if !(p == 25 || (p == 7 && final == 'l')) {
 | 
			
		||||
				return false
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return true
 | 
			
		||||
	}
 | 
			
		||||
	if private != 0 || len(intermediate) > 0 {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	switch {
 | 
			
		||||
	case final == 'C': // Cursor Forward
 | 
			
		||||
		if len(params) == 0 {
 | 
			
		||||
			tw.column++
 | 
			
		||||
		} else if len(params) >= 1 {
 | 
			
		||||
			tw.column += params[0]
 | 
			
		||||
		}
 | 
			
		||||
		return true
 | 
			
		||||
	case final == 'D': // Cursor Backward
 | 
			
		||||
		if len(params) == 0 {
 | 
			
		||||
			tw.column--
 | 
			
		||||
		} else if len(params) >= 1 {
 | 
			
		||||
			tw.column -= params[0]
 | 
			
		||||
		}
 | 
			
		||||
		if tw.column < 0 {
 | 
			
		||||
			tw.column = 0
 | 
			
		||||
		}
 | 
			
		||||
		return true
 | 
			
		||||
	case final == 'E': // Cursor Next Line
 | 
			
		||||
		if len(params) == 0 {
 | 
			
		||||
			tw.line++
 | 
			
		||||
		} else if len(params) >= 1 {
 | 
			
		||||
			tw.line += params[0]
 | 
			
		||||
		}
 | 
			
		||||
		tw.column = 0
 | 
			
		||||
		return true
 | 
			
		||||
	case final == 'F': // Cursor Preceding Line
 | 
			
		||||
		if len(params) == 0 {
 | 
			
		||||
			tw.line--
 | 
			
		||||
		} else if len(params) >= 1 {
 | 
			
		||||
			tw.line -= params[0]
 | 
			
		||||
		}
 | 
			
		||||
		if tw.line < tw.lineTop {
 | 
			
		||||
			tw.line = tw.lineTop
 | 
			
		||||
		}
 | 
			
		||||
		tw.column = 0
 | 
			
		||||
		return true
 | 
			
		||||
	case final == 'H': // Cursor Position
 | 
			
		||||
		if len(params) == 0 {
 | 
			
		||||
			tw.line = tw.lineTop
 | 
			
		||||
			tw.column = 0
 | 
			
		||||
		} else if len(params) >= 2 && params[0] != 0 && params[1] != 0 {
 | 
			
		||||
			tw.line = tw.lineTop + params[0] - 1
 | 
			
		||||
			tw.column = params[1] - 1
 | 
			
		||||
		} else {
 | 
			
		||||
			return false
 | 
			
		||||
		}
 | 
			
		||||
		return true
 | 
			
		||||
	case final == 'J': // Erase in Display
 | 
			
		||||
		if len(params) == 0 || params[0] == 0 || params[0] == 2 {
 | 
			
		||||
			// We're not going to erase anything, thank you very much.
 | 
			
		||||
			tw.processFlush()
 | 
			
		||||
		} else {
 | 
			
		||||
			return false
 | 
			
		||||
		}
 | 
			
		||||
		return true
 | 
			
		||||
	case final == 'K': // Erase in Line
 | 
			
		||||
		if tw.line >= len(tw.lines) {
 | 
			
		||||
			return true
 | 
			
		||||
		}
 | 
			
		||||
		line := &tw.lines[tw.line]
 | 
			
		||||
		if len(params) == 0 || params[0] == 0 {
 | 
			
		||||
			if len(line.columns) > tw.column {
 | 
			
		||||
				line.columns = line.columns[:tw.column]
 | 
			
		||||
			}
 | 
			
		||||
		} else if params[0] == 1 {
 | 
			
		||||
			for i := 0; i < tw.column; i++ {
 | 
			
		||||
				line.columns[i] = ' '
 | 
			
		||||
			}
 | 
			
		||||
		} else if params[0] == 2 {
 | 
			
		||||
			line.columns = nil
 | 
			
		||||
		} else {
 | 
			
		||||
			return false
 | 
			
		||||
		}
 | 
			
		||||
		return true
 | 
			
		||||
	case final == 'm':
 | 
			
		||||
		// Straight up ignoring all attributes, at least for now.
 | 
			
		||||
		return true
 | 
			
		||||
	}
 | 
			
		||||
	return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (tw *terminalWriter) processCSI(rb []rune) ([]rune, bool) {
 | 
			
		||||
	if len(rb) < 3 {
 | 
			
		||||
		return nil, true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	i, private, param, intermediate := 2, rune(0), []rune{}, []rune{}
 | 
			
		||||
	if rb[i] >= 0x3C && rb[i] <= 0x3F {
 | 
			
		||||
		private = rb[i]
 | 
			
		||||
		i++
 | 
			
		||||
	}
 | 
			
		||||
	for i < len(rb) && ((rb[i] >= '0' && rb[i] <= '9') || rb[i] == ';') {
 | 
			
		||||
		param = append(param, rb[i])
 | 
			
		||||
		i++
 | 
			
		||||
	}
 | 
			
		||||
	for i < len(rb) && rb[i] >= 0x20 && rb[i] <= 0x2F {
 | 
			
		||||
		intermediate = append(intermediate, rb[i])
 | 
			
		||||
		i++
 | 
			
		||||
	}
 | 
			
		||||
	if i == len(rb) {
 | 
			
		||||
		return nil, true
 | 
			
		||||
	}
 | 
			
		||||
	if rb[i] < 0x40 || rb[i] > 0x7E {
 | 
			
		||||
		return rb, false
 | 
			
		||||
	}
 | 
			
		||||
	if !tw.processParsedCSI(private, param, intermediate, rb[i]) {
 | 
			
		||||
		tw.log("unhandled CSI %s", string(rb[2:i+1]))
 | 
			
		||||
		return rb, false
 | 
			
		||||
	}
 | 
			
		||||
	return rb[i+1:], true
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (tw *terminalWriter) processEscape(rb []rune) ([]rune, bool) {
 | 
			
		||||
	if len(rb) < 2 {
 | 
			
		||||
		return nil, true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Very roughly following https://vt100.net/emu/dec_ansi_parser
 | 
			
		||||
	// but being a bit stricter.
 | 
			
		||||
	switch r := rb[1]; {
 | 
			
		||||
	case r == '[':
 | 
			
		||||
		return tw.processCSI(rb)
 | 
			
		||||
	case r == ']':
 | 
			
		||||
		// TODO(p): Skip this properly, once we actually hit it.
 | 
			
		||||
		tw.log("unhandled OSC")
 | 
			
		||||
		return rb, false
 | 
			
		||||
	case r == 'P':
 | 
			
		||||
		// TODO(p): Skip this properly, once we actually hit it.
 | 
			
		||||
		tw.log("unhandled DCS")
 | 
			
		||||
		return rb, false
 | 
			
		||||
 | 
			
		||||
		// Only handling sequences we've seen bother us in real life.
 | 
			
		||||
	case r == 'c':
 | 
			
		||||
		// Full reset, use this to flush all output.
 | 
			
		||||
		tw.processFlush()
 | 
			
		||||
		return rb[2:], true
 | 
			
		||||
	case r == 'M':
 | 
			
		||||
		tw.line--
 | 
			
		||||
		return rb[2:], true
 | 
			
		||||
 | 
			
		||||
	case (r >= 0x30 && r <= 0x4F) || (r >= 0x51 && r <= 0x57) ||
 | 
			
		||||
		r == 0x59 || r == 0x5A || r == 0x5C || (r >= 0x60 && r <= 0x7E):
 | 
			
		||||
		// → esc_dispatch
 | 
			
		||||
		tw.log("unhandled ESC %c", r)
 | 
			
		||||
		return rb, false
 | 
			
		||||
		//return rb[2:], true
 | 
			
		||||
	case r >= 0x20 && r <= 0x2F:
 | 
			
		||||
		// → escape intermediate
 | 
			
		||||
		i := 2
 | 
			
		||||
		for i < len(rb) && rb[i] >= 0x20 && rb[i] <= 0x2F {
 | 
			
		||||
			i++
 | 
			
		||||
		}
 | 
			
		||||
		if i == len(rb) {
 | 
			
		||||
			return nil, true
 | 
			
		||||
		}
 | 
			
		||||
		if rb[i] < 0x30 || rb[i] > 0x7E {
 | 
			
		||||
			return rb, false
 | 
			
		||||
		}
 | 
			
		||||
		// → esc_dispatch
 | 
			
		||||
		tw.log("unhandled ESC %s", string(rb[1:i+1]))
 | 
			
		||||
		return rb, false
 | 
			
		||||
		//return rb[i+1:], true
 | 
			
		||||
	default:
 | 
			
		||||
		// Note that Debian 12 has been seen to produce ESC<U+2026>
 | 
			
		||||
		// and such due to some very blind string processing.
 | 
			
		||||
		return rb, false
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (tw *terminalWriter) processRunes() bool {
 | 
			
		||||
	rb := tw.runeBuffer
 | 
			
		||||
	if len(rb) == 0 {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	switch rb[0] {
 | 
			
		||||
	case '\a':
 | 
			
		||||
		// Ding dong!
 | 
			
		||||
	case '\b':
 | 
			
		||||
		if tw.column > 0 {
 | 
			
		||||
			tw.column--
 | 
			
		||||
		}
 | 
			
		||||
	case '\n', '\v':
 | 
			
		||||
		tw.line++
 | 
			
		||||
 | 
			
		||||
		// Forced ONLCR flag, because that's what most shell output expects.
 | 
			
		||||
		fallthrough
 | 
			
		||||
	case '\r':
 | 
			
		||||
		tw.column = 0
 | 
			
		||||
 | 
			
		||||
	case '\x1b':
 | 
			
		||||
		var ok bool
 | 
			
		||||
		if rb, ok = tw.processEscape(rb); rb == nil {
 | 
			
		||||
			return false
 | 
			
		||||
		} else if ok {
 | 
			
		||||
			tw.runeBuffer = rb
 | 
			
		||||
			return true
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Unsuccessful parses get printed for later inspection.
 | 
			
		||||
		fallthrough
 | 
			
		||||
	default:
 | 
			
		||||
		tw.processPrint(rb[0])
 | 
			
		||||
	}
 | 
			
		||||
	tw.runeBuffer = rb[1:]
 | 
			
		||||
	return true
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user