diff options
Diffstat (limited to 'terminal.go')
| -rw-r--r-- | terminal.go | 369 | 
1 files changed, 369 insertions, 0 deletions
| diff --git a/terminal.go b/terminal.go new file mode 100644 index 0000000..4660c1b --- /dev/null +++ b/terminal.go @@ -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 +} | 
