diff --git a/ansi_windows.go b/ansi_windows.go index 63b908c..48f1aba 100644 --- a/ansi_windows.go +++ b/ansi_windows.go @@ -122,7 +122,7 @@ func (a *ANSIWriterCtx) ioloopEscSeq(w *bufio.Writer, r rune, argptr *[]string) arg := *argptr var err error - if r >= 'A' && r <= 'D' { + if (r >= 'A' && r <= 'D') || r == 'G' { count := short(GetInt(arg, 1)) info, err := GetConsoleScreenBufferInfo() if err != nil { @@ -137,6 +137,8 @@ func (a *ANSIWriterCtx) ioloopEscSeq(w *bufio.Writer, r rune, argptr *[]string) info.dwCursorPosition.x += count case 'D': // left info.dwCursorPosition.x -= count + case 'G': // Absolute horizontal position + info.dwCursorPosition.x = count - 1 // windows origin is 0, unix is 1 } SetConsoleCursorPosition(&info.dwCursorPosition) return false diff --git a/go.mod b/go.mod index 66180f6..535d5a8 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.15 require ( github.com/chzyer/test v1.0.0 golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 + golang.org/x/text v0.3.7 ) require github.com/chzyer/logex v1.2.1 diff --git a/go.sum b/go.sum index 2358df0..8e87d21 100644 --- a/go.sum +++ b/go.sum @@ -4,3 +4,6 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/operation.go b/operation.go index b60939a..4bf6fa0 100644 --- a/operation.go +++ b/operation.go @@ -27,6 +27,8 @@ type Operation struct { errchan chan error w io.Writer + isPrompting bool // true when prompt written and waiting for input + history *opHistory *opSearch *opCompleter @@ -39,29 +41,43 @@ func (o *Operation) SetBuffer(what string) { } type wrapWriter struct { - r *Operation - t *Terminal + o *Operation target io.Writer } func (w *wrapWriter) Write(b []byte) (int, error) { - if !w.t.IsReading() { - return w.target.Write(b) + return w.o.write(w.target, b) +} + +func (o *Operation) write(target io.Writer, b []byte) (int, error) { + o.m.Lock() + defer o.m.Unlock() + + if !o.isPrompting { + return target.Write(b) } var ( n int err error ) - w.r.buf.Refresh(func() { - n, err = w.target.Write(b) + o.buf.Refresh(func() { + n, err = target.Write(b) + // Adjust the prompt start position by b + rout := runes.ColorFilter([]rune(string(b[:]))) + sp := SplitByLine(rout, []rune{}, o.buf.ppos, o.buf.width, 1) + if len(sp) > 1 { + o.buf.ppos = len(sp[len(sp)-1]) + } else { + o.buf.ppos += len(rout) + } }) - if w.r.IsSearchMode() { - w.r.SearchRefresh(-1) + if o.IsSearchMode() { + o.SearchRefresh(-1) } - if w.r.IsInCompleteMode() { - w.r.CompleteRefresh() + if o.IsInCompleteMode() { + o.CompleteRefresh() } return n, err } @@ -234,6 +250,7 @@ func (o *Operation) ioloop() { o.Refresh() case CharCtrlL: ClearScreen(o.w) + o.buf.SetOffset("1;1") o.Refresh() case MetaBackspace, CharCtrlW: o.buf.BackEscapeWord() @@ -351,7 +368,7 @@ func (o *Operation) ioloop() { } else if o.IsInCompleteMode() { if !keepInCompleteMode { o.ExitCompleteMode(false) - o.Refresh() + o.refresh() } else { o.buf.Refresh(nil) o.CompleteRefresh() @@ -366,11 +383,11 @@ func (o *Operation) ioloop() { } func (o *Operation) Stderr() io.Writer { - return &wrapWriter{target: o.GetConfig().Stderr, r: o, t: o.t} + return &wrapWriter{target: o.GetConfig().Stderr, o: o} } func (o *Operation) Stdout() io.Writer { - return &wrapWriter{target: o.GetConfig().Stdout, r: o, t: o.t} + return &wrapWriter{target: o.GetConfig().Stdout, o: o} } func (o *Operation) String() (string, error) { @@ -387,8 +404,29 @@ func (o *Operation) Runes() ([]rune, error) { listener.OnChange(nil, 0, 0) } - o.buf.Refresh(nil) // print prompt + // Before writing the prompt and starting to read, get a lock + // so we don't race with wrapWriter trying to write and refresh. + o.m.Lock() + o.isPrompting = true + + // Query cursor position before printing the prompt as there + // maybe existing text on the same line that ideally we don't + // want to overwrite and cause prompt to jump left. Note that + // this is not perfect but works the majority of the time. + o.buf.getAndSetOffset(o.t) + o.buf.Print() // print prompt & buffer contents o.t.KickRead() + + // Prompt written safely, unlock until read completes and then + // lock again to unset. + o.m.Unlock() + defer func() { + o.m.Lock() + o.isPrompting = false + o.buf.SetOffset("1;1") + o.m.Unlock() + }() + select { case r := <-o.outchan: return r, nil @@ -501,7 +539,13 @@ func (o *Operation) SaveHistory(content string) error { } func (o *Operation) Refresh() { - if o.t.IsReading() { + o.m.Lock() + defer o.m.Unlock() + o.refresh() +} + +func (o *Operation) refresh() { + if o.isPrompting { o.buf.Refresh(nil) } } diff --git a/runebuf.go b/runebuf.go index d95df1e..c6754cc 100644 --- a/runebuf.go +++ b/runebuf.go @@ -3,8 +3,8 @@ package readline import ( "bufio" "bytes" + "fmt" "io" - "strconv" "strings" "sync" ) @@ -20,7 +20,6 @@ type RuneBuffer struct { prompt []rune w io.Writer - hadClean bool interactive bool cfg *Config @@ -28,7 +27,8 @@ type RuneBuffer struct { bck *runeBufferBck - offset string + offset string // is offset useful? scrolling means row varies + ppos int // prompt start position (0 == column 1) lastKill []rune @@ -163,11 +163,25 @@ func (r *RuneBuffer) WriteRune(s rune) { } func (r *RuneBuffer) WriteRunes(s []rune) { - r.Refresh(func() { - tail := append(s, r.buf[r.idx:]...) - r.buf = append(r.buf[:r.idx], tail...) + r.Lock() + defer r.Unlock() + + if r.idx == len(r.buf) { + // cursor is already at end of buf data so just call + // append instead of refesh to save redrawing. + r.buf = append(r.buf, s...) r.idx += len(s) - }) + if r.interactive { + r.append(s) + } + } else { + // writing into the data somewhere so do a refresh + r.refresh(func() { + tail := append(s, r.buf[r.idx:]...) + r.buf = append(r.buf[:r.idx], tail...) + r.idx += len(s) + }) + } } func (r *RuneBuffer) MoveForward() { @@ -371,11 +385,12 @@ func (r *RuneBuffer) Backspace() { } func (r *RuneBuffer) MoveToLineEnd() { - r.Refresh(func() { - if r.idx == len(r.buf) { - return - } - + r.Lock() + defer r.Unlock() + if r.idx == len(r.buf) { + return + } + r.refresh(func() { r.idx = len(r.buf) }) } @@ -421,12 +436,18 @@ func (r *RuneBuffer) isInLineEdge() bool { if isWindows { return false } - sp := r.getSplitByLine(r.buf) - return len(sp[len(sp)-1]) == 0 + sp := r.getSplitByLine(r.buf, 1) + return len(sp[len(sp)-1]) == 0 // last line is 0 len } -func (r *RuneBuffer) getSplitByLine(rs []rune) []string { - return SplitByLine(r.promptLen(), r.width, rs) +func (r *RuneBuffer) getSplitByLine(rs []rune, nextWidth int) [][]rune { + if r.cfg.EnableMask { + w := runes.Width(r.cfg.MaskRune) + masked := []rune(strings.Repeat(string(r.cfg.MaskRune), len(rs))) + return SplitByLine(runes.ColorFilter(r.prompt), masked, r.ppos, r.width, w) + } else { + return SplitByLine(runes.ColorFilter(r.prompt), rs, r.ppos, r.width, nextWidth) + } } func (r *RuneBuffer) IdxLine(width int) int { @@ -439,7 +460,11 @@ func (r *RuneBuffer) idxLine(width int) int { if width == 0 { return 0 } - sp := r.getSplitByLine(r.buf[:r.idx]) + nextWidth := 1 + if r.idx < len(r.buf) { + nextWidth = runes.Width(r.buf[r.idx]) + } + sp := r.getSplitByLine(r.buf[:r.idx], nextWidth) return len(sp) - 1 } @@ -450,7 +475,10 @@ func (r *RuneBuffer) CursorLineCount() int { func (r *RuneBuffer) Refresh(f func()) { r.Lock() defer r.Unlock() + r.refresh(f) +} +func (r *RuneBuffer) refresh(f func()) { if !r.interactive { if f != nil { f() @@ -465,31 +493,100 @@ func (r *RuneBuffer) Refresh(f func()) { r.print() } +// getAndSetOffset queries the terminal for the current cursor position by +// writing a control sequence to the terminal. This call is asynchronous +// and it returns before any offset has actually been set as the terminal +// will write the offset back to us via stdin and there may already be +// other data in the stdin buffer ahead of it. +// This function is called at the start of readline each time. +func (r *RuneBuffer) getAndSetOffset(t *Terminal) { + if !r.interactive { + return + } + if !isWindows { + // Handle lineedge cases where existing text before before + // the prompt is printed would leave us at the right edge of + // the screen but the next character would actually be printed + // at the beginning of the next line. + r.w.Write([]byte(" \b")) + } + t.GetOffset(r.SetOffset) +} + func (r *RuneBuffer) SetOffset(offset string) { r.Lock() + defer r.Unlock() + r.setOffset(offset) +} + +func (r *RuneBuffer) setOffset(offset string) { r.offset = offset - r.Unlock() + if _, c, ok := (&escapeKeyPair{attr:offset}).Get2(); ok && c > 0 && c < r.width { + r.ppos = c - 1 // c should be 1..width + } else { + r.ppos = 0 + } +} + +// append s to the end of the current output. append is called in +// place of print() when clean() was avoided. As output is appended on +// the end, the cursor also needs no extra adjustment. +// NOTE: assumes len(s) >= 1 which should always be true for append. +func (r *RuneBuffer) append(s []rune) { + buf := bytes.NewBuffer(nil) + slen := len(s) + if r.cfg.EnableMask { + if slen > 1 && r.cfg.MaskRune != 0 { + // write a mask character for all runes except the last rune + buf.WriteString(strings.Repeat(string(r.cfg.MaskRune), slen-1)) + } + // for the last rune, write \n or mask it otherwise. + if s[slen-1] == '\n' { + buf.WriteRune('\n') + } else if r.cfg.MaskRune != 0 { + buf.WriteRune(r.cfg.MaskRune) + } + } else { + for _, e := range r.cfg.Painter.Paint(s, slen) { + if e == '\t' { + buf.WriteString(strings.Repeat(" ", TabWidth)) + } else { + buf.WriteRune(e) + } + } + } + if r.isInLineEdge() { + buf.WriteString(" \b") + } + r.w.Write(buf.Bytes()) +} + +// Print writes out the prompt and buffer contents at the current cursor position +func (r *RuneBuffer) Print() { + r.Lock() + defer r.Unlock() + if !r.interactive { + return + } + r.print() } func (r *RuneBuffer) print() { r.w.Write(r.output()) - r.hadClean = false } func (r *RuneBuffer) output() []byte { buf := bytes.NewBuffer(nil) buf.WriteString(string(r.prompt)) if r.cfg.EnableMask && len(r.buf) > 0 { - buf.Write([]byte(strings.Repeat(string(r.cfg.MaskRune), len(r.buf)-1))) - if r.buf[len(r.buf)-1] == '\n' { - buf.Write([]byte{'\n'}) - } else { - buf.Write([]byte(string(r.cfg.MaskRune))) + if r.cfg.MaskRune != 0 { + buf.WriteString(strings.Repeat(string(r.cfg.MaskRune), len(r.buf)-1)) } - if len(r.buf) > r.idx { - buf.Write(r.getBackspaceSequence()) + if r.buf[len(r.buf)-1] == '\n' { + buf.WriteRune('\n') + } else if r.cfg.MaskRune != 0 { + buf.WriteRune(r.cfg.MaskRune) } - } else { for _, e := range r.cfg.Painter.Paint(r.buf, r.idx) { if e == '\t' { @@ -498,9 +595,9 @@ func (r *RuneBuffer) output() []byte { buf.WriteRune(e) } } - if r.isInLineEdge() { - buf.Write([]byte(" \b")) - } + } + if r.isInLineEdge() { + buf.WriteString(" \b") } // cursor position if len(r.buf) > r.idx { @@ -510,33 +607,41 @@ func (r *RuneBuffer) output() []byte { } func (r *RuneBuffer) getBackspaceSequence() []byte { - var sep = map[int]bool{} - - var i int - for { - if i >= runes.WidthAll(r.buf) { + bcnt := len(r.buf) - r.idx // backwards count to index + sp := r.getSplitByLine(r.buf, 1) + + // Calculate how many lines up to the index line + up := 0 + spi := len(sp) - 1 + for spi >= 0 { + bcnt -= len(sp[spi]) + if bcnt <= 0 { break } + up++ + spi-- + } - if i == 0 { - i -= r.promptLen() - } - i += r.width - - sep[i] = true + // Calculate what column the index should be set to + column := 1 + if spi == 0 { + column += r.ppos } - var buf []byte - for i := len(r.buf); i > r.idx; i-- { - // move input to the left of one - buf = append(buf, '\b') - if sep[i] { - // up one line, go to the start of the line and move cursor right to the end (r.width) - buf = append(buf, "\033[A\r"+"\033["+strconv.Itoa(r.width)+"C"...) + for _, rune := range sp[spi] { + if bcnt >= 0 { + break } + column += runes.Width(rune) + bcnt++ } - return buf + buf := bytes.NewBuffer(nil) + if up > 0 { + fmt.Fprintf(buf, "\033[%dA", up) // move cursor up to index line + } + fmt.Fprintf(buf, "\033[%dG", column) // move cursor to column + return buf.Bytes() } func (r *RuneBuffer) Reset() []rune { @@ -595,16 +700,11 @@ func (r *RuneBuffer) cleanOutput(w io.Writer, idxLine int) { buf.WriteString(strings.Repeat("\r\b", len(r.buf)+r.promptLen())) buf.Write([]byte("\033[J")) } else { - buf.Write([]byte("\033[J")) // just like ^k :) - if idxLine == 0 { - buf.WriteString("\033[2K") - buf.WriteString("\r") - } else { - for i := 0; i < idxLine; i++ { - io.WriteString(buf, "\033[2K\r\033[A") - } - io.WriteString(buf, "\033[2K\r") + if idxLine > 0 { + fmt.Fprintf(buf, "\033[%dA", idxLine) // move cursor up by idxLine } + fmt.Fprintf(buf, "\033[%dG", r.ppos + 1) // move cursor back to initial ppos position + buf.Write([]byte("\033[J")) // clear from cursor to end of screen } buf.Flush() return @@ -621,9 +721,8 @@ func (r *RuneBuffer) clean() { } func (r *RuneBuffer) cleanWithIdxLine(idxLine int) { - if r.hadClean || !r.interactive { + if !r.interactive { return } - r.hadClean = true r.cleanOutput(r.w, idxLine) } diff --git a/runes.go b/runes.go index a669bc4..57a0d38 100644 --- a/runes.go +++ b/runes.go @@ -4,6 +4,7 @@ import ( "bytes" "unicode" "unicode/utf8" + "golang.org/x/text/width" ) var runes = Runes{} @@ -150,10 +151,12 @@ func (Runes) Width(r rune) int { if unicode.IsOneOf(zeroWidth, r) { return 0 } - if unicode.IsOneOf(doubleWidth, r) { + switch width.LookupRune(r).Kind() { + case width.EastAsianWide, width.EastAsianFullwidth: return 2 + default: + return 1 } - return 1 } func (Runes) WidthAll(r []rune) (length int) { diff --git a/runes_test.go b/runes_test.go index 9c56d79..b4028c0 100644 --- a/runes_test.go +++ b/runes_test.go @@ -10,16 +10,52 @@ type twidth struct { length int } +func TestSingleRuneWidth(t *testing.T) { + type test struct { + r rune + w int + } + + tests := []test{ + {0, 0}, // default rune is 0 - default mask + {'a', 1}, + {'☭', 1}, + {'你', 2}, + {'日', 2}, // kanji + {'カ', 1}, // half-width katakana + {'カ', 2}, // full-width katakana + {'ひ', 2}, // full-width hiragana + {'W', 2}, // full-width romanji + {')', 2}, // full-width symbols + {'😅', 2}, // emoji + } + + for _, test := range tests { + if w := runes.Width(test.r); w != test.w { + t.Error("result is not expected", string(test.r), test.w, w) + } + } +} + func TestRuneWidth(t *testing.T) { rs := []twidth{ + {[]rune(""), 0}, {[]rune("☭"), 1}, {[]rune("a"), 1}, {[]rune("你"), 2}, {runes.ColorFilter([]rune("☭\033[13;1m你")), 3}, + {[]rune("漢字"), 4}, // kanji + {[]rune("カタカナ"), 4}, // half-width katakana + {[]rune("カタカナ"), 8}, // full-width katakana + {[]rune("ひらがな"), 8}, // full-width hiragana + {[]rune("WIDE"), 8}, // full-width romanji + {[]rune("ー。"), 4}, // full-width symbols + {[]rune("안녕하세요"), 10}, // full-width Hangul + {[]rune("😅"), 2}, // emoji } for _, r := range rs { if w := runes.WidthAll(r.r); w != r.length { - t.Fatal("result not expect", r.r, r.length, w) + t.Error("result is not expected", string(r.r), r.length, w) } } } diff --git a/terminal.go b/terminal.go index 38413d0..d1faba1 100644 --- a/terminal.go +++ b/terminal.go @@ -17,7 +17,6 @@ type Terminal struct { stopChan chan struct{} kickChan chan struct{} wg sync.WaitGroup - isReading int32 sleeping int32 sizeChan chan string @@ -80,7 +79,7 @@ func (t *Terminal) GetOffset(f func(offset string)) { go func() { f(<-t.sizeChan) }() - t.Write([]byte("\033[6n")) + SendCursorPosition(t) } func (t *Terminal) Print(s string) { @@ -104,10 +103,6 @@ func (t *Terminal) ReadRune() rune { return ch } -func (t *Terminal) IsReading() bool { - return atomic.LoadInt32(&t.isReading) == 1 -} - func (t *Terminal) KickRead() { select { case t.kickChan <- struct{}{}: @@ -132,10 +127,8 @@ func (t *Terminal) ioloop() { buf := bufio.NewReader(t.getStdin()) for { if !expectNextChar { - atomic.StoreInt32(&t.isReading, 0) select { case <-t.kickChan: - atomic.StoreInt32(&t.isReading, 1) case <-t.stopChan: return } @@ -210,7 +203,6 @@ func (t *Terminal) ioloop() { t.outchan <- r } } - } func (t *Terminal) Bell() { diff --git a/utils.go b/utils.go index 0706dd4..ea73428 100644 --- a/utils.go +++ b/utils.go @@ -212,21 +212,33 @@ func escapeKey(r rune, reader *bufio.Reader) rune { return r } -func SplitByLine(start, screenWidth int, rs []rune) []string { - var ret []string - buf := bytes.NewBuffer(nil) - currentWidth := start - for _, r := range rs { +// split prompt + runes into lines by screenwidth starting from an offset. +// the prompt should be filtered before passing to only its display runes. +// if you know the width of the next character, pass it in as it is used +// to decide if we generate an extra empty rune array to show next is new +// line. +func SplitByLine(prompt, rs []rune, offset, screenWidth, nextWidth int) [][]rune { + ret := make([][]rune, 0) + prs := append(prompt, rs...) + si := 0 + currentWidth := offset + for i, r := range prs { w := runes.Width(r) - currentWidth += w - buf.WriteRune(r) - if currentWidth >= screenWidth { - ret = append(ret, buf.String()) - buf.Reset() + if r == '\n' { + ret = append(ret, prs[si:i+1]) + si = i + 1 + currentWidth = 0 + } else if currentWidth + w > screenWidth { + ret = append(ret, prs[si:i]) + si = i currentWidth = 0 } + currentWidth += w + } + ret = append(ret, prs[si:len(prs)]) + if currentWidth + nextWidth > screenWidth { + ret = append(ret, []rune{}) } - ret = append(ret, buf.String()) return ret } diff --git a/utils_unix.go b/utils_unix.go index fc49492..8ea158e 100644 --- a/utils_unix.go +++ b/utils_unix.go @@ -44,6 +44,12 @@ func GetScreenWidth() int { return w } +// Ask the terminal for the current cursor position. The terminal will then +// write the position back to us via termainal stdin asynchronously. +func SendCursorPosition(t *Terminal) { + t.Write([]byte("\033[6n")) +} + // ClearScreen clears the console screen func ClearScreen(w io.Writer) (int, error) { return w.Write([]byte("\033[H")) diff --git a/utils_windows.go b/utils_windows.go index 5bfa55d..bb75fc3 100644 --- a/utils_windows.go +++ b/utils_windows.go @@ -3,6 +3,7 @@ package readline import ( + "fmt" "io" "syscall" ) @@ -27,6 +28,16 @@ func GetScreenWidth() int { return int(info.dwSize.x) } +// Send the Current cursor position to t.sizeChan. +func SendCursorPosition(t *Terminal) { + info, err := GetConsoleScreenBufferInfo() + if err != nil || info == nil { + t.sizeChan <- "-1;-1" + } else { + t.sizeChan <- fmt.Sprintf("%d;%d", info.dwCursorPosition.y, info.dwCursorPosition.x) + } +} + // ClearScreen clears the console screen func ClearScreen(_ io.Writer) error { return SetConsoleCursorPosition(&_COORD{0, 0})