mirror of
https://github.com/golang/term.git
synced 2026-01-29 15:12:09 +03:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7e5b0437f | ||
|
|
943f25d359 | ||
|
|
9b991dd831 | ||
|
|
3863673230 | ||
|
|
1231d5465b | ||
|
|
3475bc8ef1 | ||
|
|
3a0828a666 | ||
|
|
1a11b45a6f | ||
|
|
d862cd548e | ||
|
|
a35244d18d | ||
|
|
4f53e0cd39 | ||
|
|
27f29d8328 | ||
|
|
30da5dd58f | ||
|
|
2ec7864a3e | ||
|
|
a809085bff | ||
|
|
5d2308b09d | ||
|
|
e770dddbf5 | ||
|
|
04218fdaf7 | ||
|
|
208db03875 |
4
go.mod
4
go.mod
@@ -1,5 +1,5 @@
|
||||
module golang.org/x/term
|
||||
|
||||
go 1.18
|
||||
go 1.24.0
|
||||
|
||||
require golang.org/x/sys v0.30.0
|
||||
require golang.org/x/sys v0.40.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,2 +1,2 @@
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
|
||||
@@ -20,12 +20,14 @@ func isTerminal(fd int) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// This is intended to be used on a console input handle.
|
||||
// See https://learn.microsoft.com/en-us/windows/console/setconsolemode
|
||||
func makeRaw(fd int) (*State, error) {
|
||||
var st uint32
|
||||
if err := windows.GetConsoleMode(windows.Handle(fd), &st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := st &^ (windows.ENABLE_ECHO_INPUT | windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_LINE_INPUT | windows.ENABLE_PROCESSED_OUTPUT)
|
||||
raw := st &^ (windows.ENABLE_ECHO_INPUT | windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_LINE_INPUT)
|
||||
raw |= windows.ENABLE_VIRTUAL_TERMINAL_INPUT
|
||||
if err := windows.SetConsoleMode(windows.Handle(fd), raw); err != nil {
|
||||
return nil, err
|
||||
|
||||
118
terminal.go
118
terminal.go
@@ -6,6 +6,7 @@ package term
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
"strconv"
|
||||
@@ -36,6 +37,26 @@ var vt100EscapeCodes = EscapeCodes{
|
||||
Reset: []byte{keyEscape, '[', '0', 'm'},
|
||||
}
|
||||
|
||||
// A History provides a (possibly bounded) queue of input lines read by [Terminal.ReadLine].
|
||||
type History interface {
|
||||
// Add will be called by [Terminal.ReadLine] to add
|
||||
// a new, most recent entry to the history.
|
||||
// It is allowed to drop any entry, including
|
||||
// the entry being added (e.g., if it's deemed an invalid entry),
|
||||
// the least-recent entry (e.g., to keep the history bounded),
|
||||
// or any other entry.
|
||||
Add(entry string)
|
||||
|
||||
// Len returns the number of entries in the history.
|
||||
Len() int
|
||||
|
||||
// At returns an entry from the history.
|
||||
// Index 0 is the most-recently added entry and
|
||||
// index Len()-1 is the least-recently added entry.
|
||||
// If index is < 0 or >= Len(), it panics.
|
||||
At(idx int) string
|
||||
}
|
||||
|
||||
// Terminal contains the state for running a VT100 terminal that is capable of
|
||||
// reading lines of input.
|
||||
type Terminal struct {
|
||||
@@ -44,6 +65,8 @@ type Terminal struct {
|
||||
// bytes, as an index into |line|). If it returns ok=false, the key
|
||||
// press is processed normally. Otherwise it returns a replacement line
|
||||
// and the new cursor position.
|
||||
//
|
||||
// This will be disabled during ReadPassword.
|
||||
AutoCompleteCallback func(line string, pos int, key rune) (newLine string, newPos int, ok bool)
|
||||
|
||||
// Escape contains a pointer to the escape codes for this terminal.
|
||||
@@ -84,9 +107,14 @@ type Terminal struct {
|
||||
remainder []byte
|
||||
inBuf [256]byte
|
||||
|
||||
// history contains previously entered commands so that they can be
|
||||
// accessed with the up and down keys.
|
||||
history stRingBuffer
|
||||
// History records and retrieves lines of input read by [ReadLine] which
|
||||
// a user can retrieve and navigate using the up and down arrow keys.
|
||||
//
|
||||
// It is not safe to call ReadLine concurrently with any methods on History.
|
||||
//
|
||||
// [NewTerminal] sets this to a default implementation that records the
|
||||
// last 100 lines of input.
|
||||
History History
|
||||
// historyIndex stores the currently accessed history entry, where zero
|
||||
// means the immediately previous entry.
|
||||
historyIndex int
|
||||
@@ -109,6 +137,7 @@ func NewTerminal(c io.ReadWriter, prompt string) *Terminal {
|
||||
termHeight: 24,
|
||||
echo: true,
|
||||
historyIndex: -1,
|
||||
History: &stRingBuffer{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +146,7 @@ const (
|
||||
keyCtrlD = 4
|
||||
keyCtrlU = 21
|
||||
keyEnter = '\r'
|
||||
keyLF = '\n'
|
||||
keyEscape = 27
|
||||
keyBackspace = 127
|
||||
keyUnknown = 0xd800 /* UTF-16 surrogate area */ + iota
|
||||
@@ -130,7 +160,9 @@ const (
|
||||
keyEnd
|
||||
keyDeleteWord
|
||||
keyDeleteLine
|
||||
keyDelete
|
||||
keyClearScreen
|
||||
keyTranspose
|
||||
keyPasteStart
|
||||
keyPasteEnd
|
||||
)
|
||||
@@ -164,6 +196,8 @@ func bytesToKey(b []byte, pasteActive bool) (rune, []byte) {
|
||||
return keyDeleteLine, b[1:]
|
||||
case 12: // ^L
|
||||
return keyClearScreen, b[1:]
|
||||
case 20: // ^T
|
||||
return keyTranspose, b[1:]
|
||||
case 23: // ^W
|
||||
return keyDeleteWord, b[1:]
|
||||
case 14: // ^N
|
||||
@@ -198,6 +232,10 @@ func bytesToKey(b []byte, pasteActive bool) (rune, []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
if !pasteActive && len(b) >= 4 && b[0] == keyEscape && b[1] == '[' && b[2] == '3' && b[3] == '~' {
|
||||
return keyDelete, b[4:]
|
||||
}
|
||||
|
||||
if !pasteActive && len(b) >= 6 && b[0] == keyEscape && b[1] == '[' && b[2] == '1' && b[3] == ';' && b[4] == '3' {
|
||||
switch b[5] {
|
||||
case 'C':
|
||||
@@ -383,7 +421,7 @@ func (t *Terminal) eraseNPreviousChars(n int) {
|
||||
}
|
||||
}
|
||||
|
||||
// countToLeftWord returns then number of characters from the cursor to the
|
||||
// countToLeftWord returns the number of characters from the cursor to the
|
||||
// start of the previous word.
|
||||
func (t *Terminal) countToLeftWord() int {
|
||||
if t.pos == 0 {
|
||||
@@ -408,7 +446,7 @@ func (t *Terminal) countToLeftWord() int {
|
||||
return t.pos - pos
|
||||
}
|
||||
|
||||
// countToRightWord returns then number of characters from the cursor to the
|
||||
// countToRightWord returns the number of characters from the cursor to the
|
||||
// start of the next word.
|
||||
func (t *Terminal) countToRightWord() int {
|
||||
pos := t.pos
|
||||
@@ -448,10 +486,27 @@ func visualLength(runes []rune) int {
|
||||
return length
|
||||
}
|
||||
|
||||
// historyAt unlocks the terminal and relocks it while calling History.At.
|
||||
func (t *Terminal) historyAt(idx int) (string, bool) {
|
||||
t.lock.Unlock() // Unlock to avoid deadlock if History methods use the output writer.
|
||||
defer t.lock.Lock() // panic in At (or Len) protection.
|
||||
if idx < 0 || idx >= t.History.Len() {
|
||||
return "", false
|
||||
}
|
||||
return t.History.At(idx), true
|
||||
}
|
||||
|
||||
// historyAdd unlocks the terminal and relocks it while calling History.Add.
|
||||
func (t *Terminal) historyAdd(entry string) {
|
||||
t.lock.Unlock() // Unlock to avoid deadlock if History methods use the output writer.
|
||||
defer t.lock.Lock() // panic in Add protection.
|
||||
t.History.Add(entry)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if t.pasteActive && key != keyEnter {
|
||||
if t.pasteActive && key != keyEnter && key != keyLF {
|
||||
t.addKeyToLine(key)
|
||||
return
|
||||
}
|
||||
@@ -495,7 +550,7 @@ func (t *Terminal) handleKey(key rune) (line string, ok bool) {
|
||||
t.pos = len(t.line)
|
||||
t.moveCursorToPos(t.pos)
|
||||
case keyUp:
|
||||
entry, ok := t.history.NthPreviousEntry(t.historyIndex + 1)
|
||||
entry, ok := t.historyAt(t.historyIndex + 1)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
@@ -514,14 +569,14 @@ func (t *Terminal) handleKey(key rune) (line string, ok bool) {
|
||||
t.setLine(runes, len(runes))
|
||||
t.historyIndex--
|
||||
default:
|
||||
entry, ok := t.history.NthPreviousEntry(t.historyIndex - 1)
|
||||
entry, ok := t.historyAt(t.historyIndex - 1)
|
||||
if ok {
|
||||
t.historyIndex--
|
||||
runes := []rune(entry)
|
||||
t.setLine(runes, len(runes))
|
||||
}
|
||||
}
|
||||
case keyEnter:
|
||||
case keyEnter, keyLF:
|
||||
t.moveCursorToPos(len(t.line))
|
||||
t.queue([]rune("\r\n"))
|
||||
line = string(t.line)
|
||||
@@ -543,7 +598,7 @@ func (t *Terminal) handleKey(key rune) (line string, ok bool) {
|
||||
}
|
||||
t.line = t.line[:t.pos]
|
||||
t.moveCursorToPos(t.pos)
|
||||
case keyCtrlD:
|
||||
case keyCtrlD, keyDelete:
|
||||
// Erase the character under the current position.
|
||||
// The EOF case when the line is empty is handled in
|
||||
// readLine().
|
||||
@@ -553,6 +608,24 @@ func (t *Terminal) handleKey(key rune) (line string, ok bool) {
|
||||
}
|
||||
case keyCtrlU:
|
||||
t.eraseNPreviousChars(t.pos)
|
||||
case keyTranspose:
|
||||
// This transposes the two characters around the cursor and advances the cursor. Best-effort.
|
||||
if len(t.line) < 2 || t.pos < 1 {
|
||||
return
|
||||
}
|
||||
swap := t.pos
|
||||
if swap == len(t.line) {
|
||||
swap-- // special: at end of line, swap previous two chars
|
||||
}
|
||||
t.line[swap-1], t.line[swap] = t.line[swap], t.line[swap-1]
|
||||
if t.pos < len(t.line) {
|
||||
t.pos++
|
||||
}
|
||||
if t.echo {
|
||||
t.moveCursorToPos(swap - 1)
|
||||
t.writeLine(t.line[swap-1:])
|
||||
t.moveCursorToPos(t.pos)
|
||||
}
|
||||
case keyClearScreen:
|
||||
// Erases the screen and moves the cursor to the home position.
|
||||
t.queue([]rune("\x1b[2J\x1b[H"))
|
||||
@@ -692,6 +765,8 @@ func (t *Terminal) Write(buf []byte) (n int, err error) {
|
||||
|
||||
// ReadPassword temporarily changes the prompt and reads a password, without
|
||||
// echo, from the terminal.
|
||||
//
|
||||
// The AutoCompleteCallback is disabled during this call.
|
||||
func (t *Terminal) ReadPassword(prompt string) (line string, err error) {
|
||||
t.lock.Lock()
|
||||
defer t.lock.Unlock()
|
||||
@@ -699,6 +774,11 @@ func (t *Terminal) ReadPassword(prompt string) (line string, err error) {
|
||||
oldPrompt := t.prompt
|
||||
t.prompt = []rune(prompt)
|
||||
t.echo = false
|
||||
oldAutoCompleteCallback := t.AutoCompleteCallback
|
||||
t.AutoCompleteCallback = nil
|
||||
defer func() {
|
||||
t.AutoCompleteCallback = oldAutoCompleteCallback
|
||||
}()
|
||||
|
||||
line, err = t.readLine()
|
||||
|
||||
@@ -759,6 +839,10 @@ func (t *Terminal) readLine() (line string, err error) {
|
||||
if !t.pasteActive {
|
||||
lineIsPasted = false
|
||||
}
|
||||
// If we have CR, consume LF if present (CRLF sequence) to avoid returning an extra empty line.
|
||||
if key == keyEnter && len(rest) > 0 && rest[0] == keyLF {
|
||||
rest = rest[1:]
|
||||
}
|
||||
line, lineOk = t.handleKey(key)
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
@@ -772,7 +856,7 @@ func (t *Terminal) readLine() (line string, err error) {
|
||||
if lineOk {
|
||||
if t.echo {
|
||||
t.historyIndex = -1
|
||||
t.history.Add(line)
|
||||
t.historyAdd(line)
|
||||
}
|
||||
if lineIsPasted {
|
||||
err = ErrPasteIndicator
|
||||
@@ -929,19 +1013,23 @@ func (s *stRingBuffer) Add(a string) {
|
||||
}
|
||||
}
|
||||
|
||||
// NthPreviousEntry returns the value passed to the nth previous call to Add.
|
||||
func (s *stRingBuffer) Len() int {
|
||||
return s.size
|
||||
}
|
||||
|
||||
// At returns the value passed to the nth previous call to Add.
|
||||
// If n is zero then the immediately prior value is returned, if one, then the
|
||||
// next most recent, and so on. If such an element doesn't exist then ok is
|
||||
// false.
|
||||
func (s *stRingBuffer) NthPreviousEntry(n int) (value string, ok bool) {
|
||||
func (s *stRingBuffer) At(n int) string {
|
||||
if n < 0 || n >= s.size {
|
||||
return "", false
|
||||
panic(fmt.Sprintf("term: history index [%d] out of range [0,%d)", n, s.size))
|
||||
}
|
||||
index := s.head - n
|
||||
if index < 0 {
|
||||
index += s.max
|
||||
}
|
||||
return s.entries[index], true
|
||||
return s.entries[index]
|
||||
}
|
||||
|
||||
// readPasswordLine reads from reader until it finds \n or io.EOF.
|
||||
|
||||
157
terminal_test.go
157
terminal_test.go
@@ -6,6 +6,8 @@ package term
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
@@ -208,12 +210,24 @@ var keyPressTests = []struct {
|
||||
line: "efgh",
|
||||
throwAwayLines: 1,
|
||||
},
|
||||
{
|
||||
// Newline in bracketed paste mode should still work.
|
||||
in: "abc\x1b[200~d\nefg\x1b[201~h\r",
|
||||
line: "efgh",
|
||||
throwAwayLines: 1,
|
||||
},
|
||||
{
|
||||
// Lines consisting entirely of pasted data should be indicated as such.
|
||||
in: "\x1b[200~a\r",
|
||||
line: "a",
|
||||
err: ErrPasteIndicator,
|
||||
},
|
||||
{
|
||||
// Lines consisting entirely of pasted data should be indicated as such (\n paste).
|
||||
in: "\x1b[200~a\n",
|
||||
line: "a",
|
||||
err: ErrPasteIndicator,
|
||||
},
|
||||
{
|
||||
// Ctrl-C terminates readline
|
||||
in: "\003",
|
||||
@@ -224,6 +238,91 @@ var keyPressTests = []struct {
|
||||
in: "a\003\r",
|
||||
err: io.EOF,
|
||||
},
|
||||
{
|
||||
// Delete at EOL: nothing
|
||||
in: "abc\x1b[3~\r",
|
||||
line: "abc",
|
||||
},
|
||||
{
|
||||
// Delete in empty string: nothing
|
||||
in: "\x1b[3~\r",
|
||||
line: "",
|
||||
},
|
||||
{
|
||||
// Move left, delete: delete 'c'
|
||||
in: "abc\x1b[D\x1b[3~\r",
|
||||
line: "ab",
|
||||
},
|
||||
{
|
||||
// Home, delete: delete 'a'
|
||||
in: "abc\x1b[H\x1b[3~\r",
|
||||
line: "bc",
|
||||
},
|
||||
{
|
||||
// Home, delete twice: delete 'a' and 'b'
|
||||
in: "abc\x1b[H\x1b[3~\x1b[3~\r",
|
||||
line: "c",
|
||||
},
|
||||
{
|
||||
// Ctrl-T at end of line: transpose last two chars
|
||||
in: "abc\x14\r",
|
||||
line: "acb",
|
||||
},
|
||||
{
|
||||
// Ctrl-T at end then type: cursor stays at end
|
||||
in: "abc\x14N\r",
|
||||
line: "acbN",
|
||||
},
|
||||
{
|
||||
// Ctrl-T in middle: transpose chars before cursor, move cursor forward
|
||||
in: "abc\x1b[D\x14\r",
|
||||
line: "acb",
|
||||
},
|
||||
{
|
||||
// Ctrl-T in middle then type: cursor moved past swapped char
|
||||
in: "abcd\x1b[D\x1b[D\x14N\r",
|
||||
line: "acbNd",
|
||||
},
|
||||
{
|
||||
// Ctrl-T at pos 1 then type: cursor moves to pos 2
|
||||
in: "abc\x1b[H\x1b[C\x14N\r",
|
||||
line: "baNc",
|
||||
},
|
||||
{
|
||||
// Ctrl-T with one char: do nothing
|
||||
in: "a\x14\r",
|
||||
line: "a",
|
||||
},
|
||||
{
|
||||
// Ctrl-T with one char then type: cursor unchanged
|
||||
in: "a\x14N\r",
|
||||
line: "aN",
|
||||
},
|
||||
{
|
||||
// Ctrl-T at beginning: do nothing
|
||||
in: "ab\x1b[H\x14\r",
|
||||
line: "ab",
|
||||
},
|
||||
{
|
||||
// Ctrl-T at beginning then type: cursor unchanged, inserts at start
|
||||
in: "ab\x1b[H\x14N\r",
|
||||
line: "Nab",
|
||||
},
|
||||
{
|
||||
// Ctrl-T on empty line: do nothing
|
||||
in: "\x14\r",
|
||||
line: "",
|
||||
},
|
||||
{
|
||||
// Multiple Ctrl-T at end: keeps swapping last two
|
||||
in: "abc\x14\x14\r",
|
||||
line: "abc",
|
||||
},
|
||||
{
|
||||
// Multiple Ctrl-T in middle: progresses through line
|
||||
in: "abcd\x1b[D\x1b[D\x1b[D\x14\x14\x14\r",
|
||||
line: "bcda",
|
||||
},
|
||||
}
|
||||
|
||||
func TestKeyPresses(t *testing.T) {
|
||||
@@ -296,6 +395,36 @@ func TestRender(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRLF(t *testing.T) {
|
||||
c := &MockTerminal{
|
||||
toSend: []byte("line1\rline2\r\nline3\n"),
|
||||
// bytesPerRead 0 in this test means read all at once
|
||||
// CR+LF need to be in same read for ReadLine to not produce an extra empty line
|
||||
// which is what terminals do for reasonably small paste. if way many lines are pasted
|
||||
// and going over say 1k-16k buffer, readline current implementation will possibly generate 1
|
||||
// extra empty line, if the CR is in chunk1 and LF in chunk2 (and that's fine).
|
||||
}
|
||||
|
||||
ss := NewTerminal(c, "> ")
|
||||
for i := range 3 {
|
||||
line, err := ss.ReadLine()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read line %d: %v", i+1, err)
|
||||
}
|
||||
expected := fmt.Sprintf("line%d", i+1)
|
||||
if line != expected {
|
||||
t.Fatalf("expected '%s', got '%s'", expected, line)
|
||||
}
|
||||
}
|
||||
line, err := ss.ReadLine()
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("expected EOF after 3 lines, got '%s' with error %v", line, err)
|
||||
}
|
||||
if line != "" {
|
||||
t.Fatalf("expected empty line after EOF, got '%s'", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordNotSaved(t *testing.T) {
|
||||
c := &MockTerminal{
|
||||
toSend: []byte("password\r\x1b[A\r"),
|
||||
@@ -343,7 +472,7 @@ func TestReadPasswordLineEnd(t *testing.T) {
|
||||
input string
|
||||
want string
|
||||
}
|
||||
var tests = []testType{
|
||||
tests := []testType{
|
||||
{"\r\n", ""},
|
||||
{"test\r\n", "test"},
|
||||
{"test\r", "test"},
|
||||
@@ -396,6 +525,32 @@ func TestReadPasswordLineEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func MockAutoCompleteCallback(line string, pos int, key rune) (newLine string, newPos int, ok bool) {
|
||||
return "not-good", pos, true
|
||||
}
|
||||
|
||||
func TestReadPasswordDisabledAutoCompleteCallback(t *testing.T) {
|
||||
input := "testgood\ranother line\r"
|
||||
expectedPassword := "testgood"
|
||||
terminal := NewTerminal(
|
||||
&MockTerminal{
|
||||
toSend: []byte(input),
|
||||
bytesPerRead: 1,
|
||||
},
|
||||
"prompt")
|
||||
terminal.AutoCompleteCallback = MockAutoCompleteCallback
|
||||
password, err := terminal.ReadPassword("Password: ")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read password: %v", err)
|
||||
}
|
||||
if password != expectedPassword {
|
||||
t.Fatalf("failed to read password, got %q", password)
|
||||
}
|
||||
if terminal.AutoCompleteCallback == nil {
|
||||
t.Fatalf("AutoCompleteCallback should not be nil after ReadPassword")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeRawState(t *testing.T) {
|
||||
fd := int(os.Stdout.Fd())
|
||||
if !IsTerminal(fd) {
|
||||
|
||||
Reference in New Issue
Block a user