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.