From 76cee1b7b184f79a9d93bbfcb287d0c03391928c Mon Sep 17 00:00:00 2001 From: Adam Langley Date: Fri, 1 Aug 2014 11:22:47 -0700 Subject: [PATCH] go.crypto/ssh/terminal: better handling of window resizing. There doesn't appear to be perfect behaviour for line editing code in the face of terminal resizing. But this change works pretty well on xterm and gnome-terminal and certainly a lot better than it used to. LGTM=bradfitz R=bradfitz CC=golang-codereviews https://golang.org/cl/105580043 --- ssh/terminal/terminal.go | 143 +++++++++++++++++++++++++++++++-------- 1 file changed, 116 insertions(+), 27 deletions(-) diff --git a/ssh/terminal/terminal.go b/ssh/terminal/terminal.go index 18ac2ba..123de5e 100644 --- a/ssh/terminal/terminal.go +++ b/ssh/terminal/terminal.go @@ -53,7 +53,7 @@ type Terminal struct { lock sync.Mutex c io.ReadWriter - prompt string + prompt []rune // line is the current line being entered. line []rune @@ -98,7 +98,7 @@ func NewTerminal(c io.ReadWriter, prompt string) *Terminal { return &Terminal{ Escape: &vt100EscapeCodes, c: c, - prompt: prompt, + prompt: []rune(prompt), termWidth: 80, termHeight: 24, echo: true, @@ -220,7 +220,7 @@ func (t *Terminal) moveCursorToPos(pos int) { return } - x := len(t.prompt) + pos + x := visualLength(t.prompt) + pos y := x / t.termWidth x = x % t.termWidth @@ -300,6 +300,29 @@ func (t *Terminal) setLine(newLine []rune, newPos int) { t.pos = newPos } +func (t *Terminal) advanceCursor(places int) { + t.cursorX += places + t.cursorY += t.cursorX / t.termWidth + if t.cursorY > t.maxLine { + t.maxLine = t.cursorY + } + t.cursorX = t.cursorX % t.termWidth + + if places > 0 && t.cursorX == 0 { + // Normally terminals will advance the current position + // when writing a character. But that doesn't happen + // for the last character in a line. However, when + // writing a character (except a new line) that causes + // a line wrap, the position will be advanced two + // places. + // + // So, if we are stopping at the end of a line, we + // need to write a newline so that our cursor can be + // advanced to the next line. + t.outBuf = append(t.outBuf, '\n') + } +} + func (t *Terminal) eraseNPreviousChars(n int) { if n == 0 { return @@ -318,7 +341,7 @@ func (t *Terminal) eraseNPreviousChars(n int) { for i := 0; i < n; i++ { t.queue(space) } - t.cursorX += n + t.advanceCursor(n) t.moveCursorToPos(t.pos) } } @@ -367,6 +390,27 @@ func (t *Terminal) countToRightWord() int { return pos - t.pos } +// visualLength returns the number of visible glyphs in s. +func visualLength(runes []rune) int { + inEscapeSeq := false + length := 0 + + for _, r := range runes { + switch { + case inEscapeSeq: + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + inEscapeSeq = false + } + case r == '\x1b': + inEscapeSeq = true + default: + length++ + } + } + + return length +} + // handleKey processes the given key and, optionally, returns a line of text // that the user has entered. func (t *Terminal) handleKey(key rune) (line string, ok bool) { @@ -453,7 +497,7 @@ func (t *Terminal) handleKey(key rune) (line string, ok bool) { // end of line. for i := t.pos; i < len(t.line); i++ { t.queue(space) - t.cursorX++ + t.advanceCursor(1) } t.line = t.line[:t.pos] t.moveCursorToPos(t.pos) @@ -470,9 +514,9 @@ func (t *Terminal) handleKey(key rune) (line string, ok bool) { case keyClearScreen: // Erases the screen and moves the cursor to the home position. t.queue([]rune("\x1b[2J\x1b[H")) - t.queue([]rune(t.prompt)) - t.cursorX = len(t.prompt) - t.cursorY = 0 + t.queue(t.prompt) + t.cursorX, t.cursorY = 0, 0 + t.advanceCursor(visualLength(t.prompt)) t.setLine(t.line, t.pos) default: if t.AutoCompleteCallback != nil { @@ -519,16 +563,8 @@ func (t *Terminal) writeLine(line []rune) { todo = remainingOnLine } t.queue(line[:todo]) - t.cursorX += todo + t.advanceCursor(visualLength(line[:todo])) line = line[todo:] - - if t.cursorX == t.termWidth { - t.cursorX = 0 - t.cursorY++ - if t.cursorY > t.maxLine { - t.maxLine = t.cursorY - } - } } } @@ -563,14 +599,11 @@ func (t *Terminal) Write(buf []byte) (n int, err error) { return } - t.queue([]rune(t.prompt)) - chars := len(t.prompt) + t.writeLine(t.prompt) if t.echo { - t.queue(t.line) - chars += len(t.line) + t.writeLine(t.line) } - t.cursorX = chars % t.termWidth - t.cursorY = chars / t.termWidth + t.moveCursorToPos(t.pos) if _, err = t.c.Write(t.outBuf); err != nil { @@ -587,7 +620,7 @@ func (t *Terminal) ReadPassword(prompt string) (line string, err error) { defer t.lock.Unlock() oldPrompt := t.prompt - t.prompt = prompt + t.prompt = []rune(prompt) t.echo = false line, err = t.readLine() @@ -610,7 +643,7 @@ func (t *Terminal) readLine() (line string, err error) { // t.lock must be held at this point if t.cursorX == 0 && t.cursorY == 0 { - t.writeLine([]rune(t.prompt)) + t.writeLine(t.prompt) t.c.Write(t.outBuf) t.outBuf = t.outBuf[:0] } @@ -671,14 +704,70 @@ func (t *Terminal) SetPrompt(prompt string) { t.lock.Lock() defer t.lock.Unlock() - t.prompt = prompt + t.prompt = []rune(prompt) } -func (t *Terminal) SetSize(width, height int) { +func (t *Terminal) clearAndRepaintLinePlusNPrevious(numPrevLines int) { + // Move cursor to column zero at the start of the line. + t.move(t.cursorY, 0, t.cursorX, 0) + t.cursorX, t.cursorY = 0, 0 + t.clearLineToRight() + for t.cursorY < numPrevLines { + // Move down a line + t.move(0, 1, 0, 0) + t.cursorY++ + t.clearLineToRight() + } + // Move back to beginning. + t.move(t.cursorY, 0, 0, 0) + t.cursorX, t.cursorY = 0, 0 + + t.queue(t.prompt) + t.advanceCursor(visualLength(t.prompt)) + t.writeLine(t.line) + t.moveCursorToPos(t.pos) +} + +func (t *Terminal) SetSize(width, height int) error { t.lock.Lock() defer t.lock.Unlock() + oldWidth := t.termWidth t.termWidth, t.termHeight = width, height + + switch { + case width == oldWidth || len(t.line) == 0: + // If the width didn't change then nothing else needs to be + // done. + return nil + case width < oldWidth: + // Some terminals (e.g. xterm) will truncate lines that were + // too long when shinking. Others, (e.g. gnome-terminal) will + // attempt to wrap them. For the former, repainting t.maxLine + // works great, but that behaviour goes badly wrong in the case + // of the latter because they have doubled every full line. + + // We assume that we are working on a terminal that wraps lines + // and adjust the cursor position based on every previous line + // wrapping and turning into two. This causes the prompt on + // xterms to move upwards, which isn't great, but it avoids a + // huge mess with gnome-terminal. + t.cursorY *= 2 + t.clearAndRepaintLinePlusNPrevious(t.maxLine * 2) + case width > oldWidth: + // If the terminal expands then our position calculations will + // be wrong in the future because we think the cursor is + // |t.pos| chars into the string, but there will be a gap at + // the end of any wrapped line. + // + // But the position will actually be correct until we move, so + // we can move back to the beginning and repaint everything. + t.clearAndRepaintLinePlusNPrevious(t.maxLine) + } + + _, err := t.c.Write(t.outBuf) + t.outBuf = t.outBuf[:0] + return err } // stRingBuffer is a ring buffer of strings.