You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
418 lines
11 KiB
418 lines
11 KiB
2 years ago
|
package terminal
|
||
|
|
||
|
import (
|
||
|
"fmt"
|
||
|
"unicode"
|
||
|
|
||
|
"golang.org/x/text/width"
|
||
|
)
|
||
|
|
||
|
type RuneReader struct {
|
||
|
stdio Stdio
|
||
|
state runeReaderState
|
||
|
}
|
||
|
|
||
|
func NewRuneReader(stdio Stdio) *RuneReader {
|
||
|
return &RuneReader{
|
||
|
stdio: stdio,
|
||
|
state: newRuneReaderState(stdio.In),
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (rr *RuneReader) printChar(char rune, mask rune) error {
|
||
|
// if we don't need to mask the input
|
||
|
if mask == 0 {
|
||
|
// just print the character the user pressed
|
||
|
_, err := fmt.Fprintf(rr.stdio.Out, "%c", char)
|
||
|
return err
|
||
|
}
|
||
|
// otherwise print the mask we were given
|
||
|
_, err := fmt.Fprintf(rr.stdio.Out, "%c", mask)
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
type OnRuneFn func(rune, []rune) ([]rune, bool, error)
|
||
|
|
||
|
func (rr *RuneReader) ReadLine(mask rune, onRunes ...OnRuneFn) ([]rune, error) {
|
||
|
return rr.ReadLineWithDefault(mask, []rune{}, onRunes...)
|
||
|
}
|
||
|
|
||
|
func (rr *RuneReader) ReadLineWithDefault(mask rune, d []rune, onRunes ...OnRuneFn) ([]rune, error) {
|
||
|
line := []rune{}
|
||
|
// we only care about horizontal displacements from the origin so start counting at 0
|
||
|
index := 0
|
||
|
|
||
|
cursor := &Cursor{
|
||
|
In: rr.stdio.In,
|
||
|
Out: rr.stdio.Out,
|
||
|
}
|
||
|
|
||
|
onRune := func(r rune, line []rune) ([]rune, bool, error) {
|
||
|
return line, false, nil
|
||
|
}
|
||
|
|
||
|
// if the user pressed a key the caller was interested in capturing
|
||
|
if len(onRunes) > 0 {
|
||
|
onRune = onRunes[0]
|
||
|
}
|
||
|
|
||
|
// we get the terminal width and height (if resized after this point the property might become invalid)
|
||
|
terminalSize, _ := cursor.Size(rr.Buffer())
|
||
|
// we set the current location of the cursor once
|
||
|
cursorCurrent, _ := cursor.Location(rr.Buffer())
|
||
|
|
||
|
increment := func() {
|
||
|
if cursorCurrent.CursorIsAtLineEnd(terminalSize) {
|
||
|
cursorCurrent.X = COORDINATE_SYSTEM_BEGIN
|
||
|
cursorCurrent.Y++
|
||
|
} else {
|
||
|
cursorCurrent.X++
|
||
|
}
|
||
|
}
|
||
|
decrement := func() {
|
||
|
if cursorCurrent.CursorIsAtLineBegin() {
|
||
|
cursorCurrent.X = terminalSize.X
|
||
|
cursorCurrent.Y--
|
||
|
} else {
|
||
|
cursorCurrent.X--
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if len(d) > 0 {
|
||
|
index = len(d)
|
||
|
if _, err := fmt.Fprint(rr.stdio.Out, string(d)); err != nil {
|
||
|
return d, err
|
||
|
}
|
||
|
line = d
|
||
|
for range d {
|
||
|
increment()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
for {
|
||
|
// wait for some input
|
||
|
r, _, err := rr.ReadRune()
|
||
|
if err != nil {
|
||
|
return line, err
|
||
|
}
|
||
|
|
||
|
if l, stop, err := onRune(r, line); stop || err != nil {
|
||
|
return l, err
|
||
|
}
|
||
|
|
||
|
// if the user pressed enter or some other newline/termination like ctrl+d
|
||
|
if r == '\r' || r == '\n' || r == KeyEndTransmission {
|
||
|
// delete what's printed out on the console screen (cleanup)
|
||
|
for index > 0 {
|
||
|
if cursorCurrent.CursorIsAtLineBegin() {
|
||
|
EraseLine(rr.stdio.Out, ERASE_LINE_END)
|
||
|
cursor.PreviousLine(1)
|
||
|
cursor.Forward(int(terminalSize.X))
|
||
|
} else {
|
||
|
cursor.Back(1)
|
||
|
}
|
||
|
decrement()
|
||
|
index--
|
||
|
}
|
||
|
// move the cursor the a new line
|
||
|
cursor.MoveNextLine(cursorCurrent, terminalSize)
|
||
|
|
||
|
// we're done processing the input
|
||
|
return line, nil
|
||
|
}
|
||
|
// if the user interrupts (ie with ctrl+c)
|
||
|
if r == KeyInterrupt {
|
||
|
// go to the beginning of the next line
|
||
|
if _, err := fmt.Fprint(rr.stdio.Out, "\r\n"); err != nil {
|
||
|
return line, err
|
||
|
}
|
||
|
|
||
|
// we're done processing the input, and treat interrupt like an error
|
||
|
return line, InterruptErr
|
||
|
}
|
||
|
|
||
|
// allow for backspace/delete editing of inputs
|
||
|
if r == KeyBackspace || r == KeyDelete {
|
||
|
// and we're not at the beginning of the line
|
||
|
if index > 0 && len(line) > 0 {
|
||
|
// if we are at the end of the word
|
||
|
if index == len(line) {
|
||
|
// just remove the last letter from the internal representation
|
||
|
// also count the number of cells the rune before the cursor occupied
|
||
|
cells := runeWidth(line[len(line)-1])
|
||
|
line = line[:len(line)-1]
|
||
|
// go back one
|
||
|
if cursorCurrent.X == 1 {
|
||
|
cursor.PreviousLine(1)
|
||
|
cursor.Forward(int(terminalSize.X))
|
||
|
} else {
|
||
|
cursor.Back(cells)
|
||
|
}
|
||
|
|
||
|
// clear the rest of the line
|
||
|
EraseLine(rr.stdio.Out, ERASE_LINE_END)
|
||
|
} else {
|
||
|
// we need to remove a character from the middle of the word
|
||
|
|
||
|
cells := runeWidth(line[index-1])
|
||
|
|
||
|
// remove the current index from the list
|
||
|
line = append(line[:index-1], line[index:]...)
|
||
|
|
||
|
// save the current position of the cursor, as we have to move the cursor one back to erase the current symbol
|
||
|
// and then move the cursor for each symbol in line[index-1:] to print it out, afterwards we want to restore
|
||
|
// the cursor to its previous location.
|
||
|
cursor.Save()
|
||
|
|
||
|
// clear the rest of the line
|
||
|
cursor.Back(cells)
|
||
|
|
||
|
// print what comes after
|
||
|
for _, char := range line[index-1:] {
|
||
|
//Erase symbols which are left over from older print
|
||
|
EraseLine(rr.stdio.Out, ERASE_LINE_END)
|
||
|
// print characters to the new line appropriately
|
||
|
if err := rr.printChar(char, mask); err != nil {
|
||
|
return line, err
|
||
|
}
|
||
|
}
|
||
|
// erase what's left over from last print
|
||
|
if cursorCurrent.Y < terminalSize.Y {
|
||
|
cursor.NextLine(1)
|
||
|
EraseLine(rr.stdio.Out, ERASE_LINE_END)
|
||
|
}
|
||
|
// restore cursor
|
||
|
cursor.Restore()
|
||
|
if cursorCurrent.CursorIsAtLineBegin() {
|
||
|
cursor.PreviousLine(1)
|
||
|
cursor.Forward(int(terminalSize.X))
|
||
|
} else {
|
||
|
cursor.Back(cells)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// decrement the index
|
||
|
index--
|
||
|
decrement()
|
||
|
} else {
|
||
|
// otherwise the user pressed backspace while at the beginning of the line
|
||
|
_ = soundBell(rr.stdio.Out)
|
||
|
}
|
||
|
|
||
|
// we're done processing this key
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// if the left arrow is pressed
|
||
|
if r == KeyArrowLeft {
|
||
|
// if we have space to the left
|
||
|
if index > 0 {
|
||
|
//move the cursor to the prev line if necessary
|
||
|
if cursorCurrent.CursorIsAtLineBegin() {
|
||
|
cursor.PreviousLine(1)
|
||
|
cursor.Forward(int(terminalSize.X))
|
||
|
} else {
|
||
|
cursor.Back(runeWidth(line[index-1]))
|
||
|
}
|
||
|
//decrement the index
|
||
|
index--
|
||
|
decrement()
|
||
|
|
||
|
} else {
|
||
|
// otherwise we are at the beginning of where we started reading lines
|
||
|
// sound the bell
|
||
|
_ = soundBell(rr.stdio.Out)
|
||
|
}
|
||
|
|
||
|
// we're done processing this key press
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// if the right arrow is pressed
|
||
|
if r == KeyArrowRight {
|
||
|
// if we have space to the right
|
||
|
if index < len(line) {
|
||
|
// move the cursor to the next line if necessary
|
||
|
if cursorCurrent.CursorIsAtLineEnd(terminalSize) {
|
||
|
cursor.NextLine(1)
|
||
|
} else {
|
||
|
cursor.Forward(runeWidth(line[index]))
|
||
|
}
|
||
|
index++
|
||
|
increment()
|
||
|
|
||
|
} else {
|
||
|
// otherwise we are at the end of the word and can't go past
|
||
|
// sound the bell
|
||
|
_ = soundBell(rr.stdio.Out)
|
||
|
}
|
||
|
|
||
|
// we're done processing this key press
|
||
|
continue
|
||
|
}
|
||
|
// the user pressed one of the special keys
|
||
|
if r == SpecialKeyHome {
|
||
|
for index > 0 {
|
||
|
if cursorCurrent.CursorIsAtLineBegin() {
|
||
|
cursor.PreviousLine(1)
|
||
|
cursor.Forward(int(terminalSize.X))
|
||
|
cursorCurrent.Y--
|
||
|
cursorCurrent.X = terminalSize.X
|
||
|
} else {
|
||
|
cursor.Back(runeWidth(line[index-1]))
|
||
|
cursorCurrent.X -= Short(runeWidth(line[index-1]))
|
||
|
}
|
||
|
index--
|
||
|
}
|
||
|
continue
|
||
|
// user pressed end
|
||
|
} else if r == SpecialKeyEnd {
|
||
|
for index != len(line) {
|
||
|
if cursorCurrent.CursorIsAtLineEnd(terminalSize) {
|
||
|
cursor.NextLine(1)
|
||
|
cursorCurrent.Y++
|
||
|
cursorCurrent.X = COORDINATE_SYSTEM_BEGIN
|
||
|
} else {
|
||
|
cursor.Forward(runeWidth(line[index]))
|
||
|
cursorCurrent.X += Short(runeWidth(line[index]))
|
||
|
}
|
||
|
index++
|
||
|
}
|
||
|
continue
|
||
|
// user pressed forward delete key
|
||
|
} else if r == SpecialKeyDelete {
|
||
|
// if index at the end of the line nothing to delete
|
||
|
if index != len(line) {
|
||
|
// save the current position of the cursor, as we have to erase the current symbol
|
||
|
// and then move the cursor for each symbol in line[index:] to print it out, afterwards we want to restore
|
||
|
// the cursor to its previous location.
|
||
|
cursor.Save()
|
||
|
// remove the symbol after the cursor
|
||
|
line = append(line[:index], line[index+1:]...)
|
||
|
// print the updated line
|
||
|
for _, char := range line[index:] {
|
||
|
EraseLine(rr.stdio.Out, ERASE_LINE_END)
|
||
|
// print out the character
|
||
|
if err := rr.printChar(char, mask); err != nil {
|
||
|
return line, err
|
||
|
}
|
||
|
}
|
||
|
// erase what's left on last line
|
||
|
if cursorCurrent.Y < terminalSize.Y {
|
||
|
cursor.NextLine(1)
|
||
|
EraseLine(rr.stdio.Out, ERASE_LINE_END)
|
||
|
}
|
||
|
// restore cursor
|
||
|
cursor.Restore()
|
||
|
if len(line) == 0 || index == len(line) {
|
||
|
EraseLine(rr.stdio.Out, ERASE_LINE_END)
|
||
|
}
|
||
|
}
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// if the letter is another escape sequence
|
||
|
if unicode.IsControl(r) || r == IgnoreKey {
|
||
|
// ignore it
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// the user pressed a regular key
|
||
|
|
||
|
// if we are at the end of the line
|
||
|
if index == len(line) {
|
||
|
// just append the character at the end of the line
|
||
|
line = append(line, r)
|
||
|
// save the location of the cursor
|
||
|
index++
|
||
|
increment()
|
||
|
// print out the character
|
||
|
if err := rr.printChar(r, mask); err != nil {
|
||
|
return line, err
|
||
|
}
|
||
|
} else {
|
||
|
// we are in the middle of the word so we need to insert the character the user pressed
|
||
|
line = append(line[:index], append([]rune{r}, line[index:]...)...)
|
||
|
// save the current position of the cursor, as we have to move the cursor back to erase the current symbol
|
||
|
// and then move for each symbol in line[index:] to print it out, afterwards we want to restore
|
||
|
// cursor's location to its previous one.
|
||
|
cursor.Save()
|
||
|
EraseLine(rr.stdio.Out, ERASE_LINE_END)
|
||
|
// remove the symbol after the cursor
|
||
|
// print the updated line
|
||
|
for _, char := range line[index:] {
|
||
|
EraseLine(rr.stdio.Out, ERASE_LINE_END)
|
||
|
// print out the character
|
||
|
if err := rr.printChar(char, mask); err != nil {
|
||
|
return line, err
|
||
|
}
|
||
|
increment()
|
||
|
}
|
||
|
// if we are at the last line, we want to visually insert a new line and append to it.
|
||
|
if cursorCurrent.CursorIsAtLineEnd(terminalSize) && cursorCurrent.Y == terminalSize.Y {
|
||
|
// add a new line to the terminal
|
||
|
if _, err := fmt.Fprintln(rr.stdio.Out); err != nil {
|
||
|
return line, err
|
||
|
}
|
||
|
// restore the position of the cursor horizontally
|
||
|
cursor.Restore()
|
||
|
// restore the position of the cursor vertically
|
||
|
cursor.PreviousLine(1)
|
||
|
} else {
|
||
|
// restore cursor
|
||
|
cursor.Restore()
|
||
|
}
|
||
|
// check if cursor needs to move to next line
|
||
|
cursorCurrent, _ = cursor.Location(rr.Buffer())
|
||
|
if cursorCurrent.CursorIsAtLineEnd(terminalSize) {
|
||
|
cursor.NextLine(1)
|
||
|
} else {
|
||
|
cursor.Forward(runeWidth(r))
|
||
|
}
|
||
|
// increment the index
|
||
|
index++
|
||
|
increment()
|
||
|
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// runeWidth returns the number of columns spanned by a rune when printed to the terminal
|
||
|
func runeWidth(r rune) int {
|
||
|
switch width.LookupRune(r).Kind() {
|
||
|
case width.EastAsianWide, width.EastAsianFullwidth:
|
||
|
return 2
|
||
|
}
|
||
|
|
||
|
if !unicode.IsPrint(r) {
|
||
|
return 0
|
||
|
}
|
||
|
return 1
|
||
|
}
|
||
|
|
||
|
// isAnsiMarker returns if a rune denotes the start of an ANSI sequence
|
||
|
func isAnsiMarker(r rune) bool {
|
||
|
return r == '\x1B'
|
||
|
}
|
||
|
|
||
|
// isAnsiTerminator returns if a rune denotes the end of an ANSI sequence
|
||
|
func isAnsiTerminator(r rune) bool {
|
||
|
return (r >= 0x40 && r <= 0x5a) || (r == 0x5e) || (r >= 0x60 && r <= 0x7e)
|
||
|
}
|
||
|
|
||
|
// StringWidth returns the visible width of a string when printed to the terminal
|
||
|
func StringWidth(str string) int {
|
||
|
w := 0
|
||
|
ansi := false
|
||
|
|
||
|
for _, r := range str {
|
||
|
// increase width only when outside of ANSI escape sequences
|
||
|
if ansi || isAnsiMarker(r) {
|
||
|
ansi = !isAnsiTerminator(r)
|
||
|
} else {
|
||
|
w += runeWidth(r)
|
||
|
}
|
||
|
}
|
||
|
return w
|
||
|
}
|