Skip to content

Commit d9af567

Browse files
committed
Various screen redraw fixes for wide characters, narrow screens etc.
- Don't overwrite existing text on same line as the prompt - Don't refresh screen when simply appending characters to buffer - Don't refresh screen unnessarily when pressing enter key - Handle prompts longer than screen width. - Fix wide characters in prompt - Fix screen edge issue when next character is wide. - Fix screen edge issue for masked characters - Fix narrow masked characteter, masking wide input - Fix wide masked character, masking narrow input - Reworked backspacesequence for index to use same algorithm as used for lineedge and reduce the control sequences to 2. - Reworked cleanup to incorporate initial cursor column position and avoid overwriting existing text as well as simplifying the control sequences used. - Fixed double width character detection and updated unit tests - Handle emoji in text or prompts. - Implement windows ANSI absolute horizonal position ansi code. - Get windows cursor position directly and don't send ansi DSR code - Don't write out empty mask runes - Cleanup - removed unused hadCLean variable
1 parent 7f93d88 commit d9af567

File tree

11 files changed

+253
-77
lines changed

11 files changed

+253
-77
lines changed

ansi_windows.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ func (a *ANSIWriterCtx) ioloopEscSeq(w *bufio.Writer, r rune, argptr *[]string)
122122
arg := *argptr
123123
var err error
124124

125-
if r >= 'A' && r <= 'D' {
125+
if (r >= 'A' && r <= 'D') || r == 'G' {
126126
count := short(GetInt(arg, 1))
127127
info, err := GetConsoleScreenBufferInfo()
128128
if err != nil {
@@ -137,6 +137,8 @@ func (a *ANSIWriterCtx) ioloopEscSeq(w *bufio.Writer, r rune, argptr *[]string)
137137
info.dwCursorPosition.x += count
138138
case 'D': // left
139139
info.dwCursorPosition.x -= count
140+
case 'G': // Absolute horizontal position
141+
info.dwCursorPosition.x = count - 1 // windows origin is 0, unix is 1
140142
}
141143
SetConsoleCursorPosition(&info.dwCursorPosition)
142144
return false

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.15
55
require (
66
github.com/chzyer/test v1.0.0
77
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5
8+
golang.org/x/text v0.3.7
89
)
910

1011
require github.com/chzyer/logex v1.2.1

go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
44
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
55
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng=
66
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
7+
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
8+
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
9+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

operation.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ func (o *Operation) ioloop() {
234234
o.Refresh()
235235
case CharCtrlL:
236236
ClearScreen(o.w)
237+
o.buf.SetOffset("1;1")
237238
o.Refresh()
238239
case MetaBackspace, CharCtrlW:
239240
o.buf.BackEscapeWord()
@@ -387,8 +388,14 @@ func (o *Operation) Runes() ([]rune, error) {
387388
listener.OnChange(nil, 0, 0)
388389
}
389390

390-
o.buf.Refresh(nil) // print prompt
391+
// Query cursor position before printing the prompt as there
392+
// maybe existing text on the same line that ideally we don't
393+
// want to overwrite and cause prompt to jump left. Note that
394+
// this is not perfect but works the majority of the time.
395+
o.buf.getAndSetOffset(o.t)
396+
o.buf.Print() // print prompt & buffer contents
391397
o.t.KickRead()
398+
392399
select {
393400
case r := <-o.outchan:
394401
return r, nil

runebuf.go

Lines changed: 159 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ package readline
33
import (
44
"bufio"
55
"bytes"
6+
"fmt"
67
"io"
7-
"strconv"
88
"strings"
99
"sync"
1010
)
@@ -20,15 +20,15 @@ type RuneBuffer struct {
2020
prompt []rune
2121
w io.Writer
2222

23-
hadClean bool
2423
interactive bool
2524
cfg *Config
2625

2726
width int
2827

2928
bck *runeBufferBck
3029

31-
offset string
30+
offset string // is offset useful? scrolling means row varies
31+
ppos int // prompt start position (0 == column 1)
3232

3333
lastKill []rune
3434

@@ -163,11 +163,25 @@ func (r *RuneBuffer) WriteRune(s rune) {
163163
}
164164

165165
func (r *RuneBuffer) WriteRunes(s []rune) {
166-
r.Refresh(func() {
167-
tail := append(s, r.buf[r.idx:]...)
168-
r.buf = append(r.buf[:r.idx], tail...)
166+
r.Lock()
167+
defer r.Unlock()
168+
169+
if r.idx == len(r.buf) {
170+
// cursor is already at end of buf data so just call
171+
// append instead of refesh to save redrawing.
172+
r.buf = append(r.buf, s...)
169173
r.idx += len(s)
170-
})
174+
if r.interactive {
175+
r.append(s)
176+
}
177+
} else {
178+
// writing into the data somewhere so do a refresh
179+
r.refresh(func() {
180+
tail := append(s, r.buf[r.idx:]...)
181+
r.buf = append(r.buf[:r.idx], tail...)
182+
r.idx += len(s)
183+
})
184+
}
171185
}
172186

173187
func (r *RuneBuffer) MoveForward() {
@@ -371,11 +385,12 @@ func (r *RuneBuffer) Backspace() {
371385
}
372386

373387
func (r *RuneBuffer) MoveToLineEnd() {
374-
r.Refresh(func() {
375-
if r.idx == len(r.buf) {
376-
return
377-
}
378-
388+
r.Lock()
389+
defer r.Unlock()
390+
if r.idx == len(r.buf) {
391+
return
392+
}
393+
r.refresh(func() {
379394
r.idx = len(r.buf)
380395
})
381396
}
@@ -421,12 +436,18 @@ func (r *RuneBuffer) isInLineEdge() bool {
421436
if isWindows {
422437
return false
423438
}
424-
sp := r.getSplitByLine(r.buf)
425-
return len(sp[len(sp)-1]) == 0
439+
sp := r.getSplitByLine(r.buf, 1)
440+
return len(sp[len(sp)-1]) == 0 // last line is 0 len
426441
}
427442

428-
func (r *RuneBuffer) getSplitByLine(rs []rune) []string {
429-
return SplitByLine(r.promptLen(), r.width, rs)
443+
func (r *RuneBuffer) getSplitByLine(rs []rune, nextWidth int) [][]rune {
444+
if r.cfg.EnableMask {
445+
w := runes.Width(r.cfg.MaskRune)
446+
masked := []rune(strings.Repeat(string(r.cfg.MaskRune), len(rs)))
447+
return SplitByLine(runes.ColorFilter(r.prompt), masked, r.ppos, r.width, w)
448+
} else {
449+
return SplitByLine(runes.ColorFilter(r.prompt), rs, r.ppos, r.width, nextWidth)
450+
}
430451
}
431452

432453
func (r *RuneBuffer) IdxLine(width int) int {
@@ -439,7 +460,11 @@ func (r *RuneBuffer) idxLine(width int) int {
439460
if width == 0 {
440461
return 0
441462
}
442-
sp := r.getSplitByLine(r.buf[:r.idx])
463+
nextWidth := 1
464+
if r.idx < len(r.buf) {
465+
nextWidth = runes.Width(r.buf[r.idx])
466+
}
467+
sp := r.getSplitByLine(r.buf[:r.idx], nextWidth)
443468
return len(sp) - 1
444469
}
445470

@@ -450,7 +475,10 @@ func (r *RuneBuffer) CursorLineCount() int {
450475
func (r *RuneBuffer) Refresh(f func()) {
451476
r.Lock()
452477
defer r.Unlock()
478+
r.refresh(f)
479+
}
453480

481+
func (r *RuneBuffer) refresh(f func()) {
454482
if !r.interactive {
455483
if f != nil {
456484
f()
@@ -465,31 +493,100 @@ func (r *RuneBuffer) Refresh(f func()) {
465493
r.print()
466494
}
467495

496+
// getAndSetOffset queries the terminal for the current cursor position by
497+
// writing a control sequence to the terminal. This call is asynchronous
498+
// and it returns before any offset has actually been set as the terminal
499+
// will write the offset back to us via stdin and there may already be
500+
// other data in the stdin buffer ahead of it.
501+
// This function is called at the start of readline each time.
502+
func (r *RuneBuffer) getAndSetOffset(t *Terminal) {
503+
if !r.interactive {
504+
return
505+
}
506+
if !isWindows {
507+
// Handle lineedge cases where existing text before before
508+
// the prompt is printed would leave us at the right edge of
509+
// the screen but the next character would actually be printed
510+
// at the beginning of the next line.
511+
r.w.Write([]byte(" \b"))
512+
}
513+
t.GetOffset(r.setOffset)
514+
}
515+
468516
func (r *RuneBuffer) SetOffset(offset string) {
469517
r.Lock()
518+
defer r.Unlock()
519+
r.setOffset(offset)
520+
}
521+
522+
func (r *RuneBuffer) setOffset(offset string) {
470523
r.offset = offset
471-
r.Unlock()
524+
if _, c, ok := (&escapeKeyPair{attr:offset}).Get2(); ok && c > 0 && c < r.width {
525+
r.ppos = c - 1 // c should be 1..width
526+
} else {
527+
r.ppos = 0
528+
}
529+
}
530+
531+
// append s to the end of the current output. append is called in
532+
// place of print() when clean() was avoided. As output is appended on
533+
// the end, the cursor also needs no extra adjustment.
534+
// NOTE: assumes len(s) >= 1 which should always be true for append.
535+
func (r *RuneBuffer) append(s []rune) {
536+
buf := bytes.NewBuffer(nil)
537+
slen := len(s)
538+
if r.cfg.EnableMask {
539+
if slen > 1 && r.cfg.MaskRune != 0 {
540+
// write a mask character for all runes except the last rune
541+
buf.WriteString(strings.Repeat(string(r.cfg.MaskRune), slen-1))
542+
}
543+
// for the last rune, write \n or mask it otherwise.
544+
if s[slen-1] == '\n' {
545+
buf.WriteRune('\n')
546+
} else if r.cfg.MaskRune != 0 {
547+
buf.WriteRune(r.cfg.MaskRune)
548+
}
549+
} else {
550+
for _, e := range r.cfg.Painter.Paint(s, slen) {
551+
if e == '\t' {
552+
buf.WriteString(strings.Repeat(" ", TabWidth))
553+
} else {
554+
buf.WriteRune(e)
555+
}
556+
}
557+
}
558+
if r.isInLineEdge() {
559+
buf.WriteString(" \b")
560+
}
561+
r.w.Write(buf.Bytes())
562+
}
563+
564+
// Print writes out the prompt and buffer contents at the current cursor position
565+
func (r *RuneBuffer) Print() {
566+
r.Lock()
567+
defer r.Unlock()
568+
if !r.interactive {
569+
return
570+
}
571+
r.print()
472572
}
473573

474574
func (r *RuneBuffer) print() {
475575
r.w.Write(r.output())
476-
r.hadClean = false
477576
}
478577

479578
func (r *RuneBuffer) output() []byte {
480579
buf := bytes.NewBuffer(nil)
481580
buf.WriteString(string(r.prompt))
482581
if r.cfg.EnableMask && len(r.buf) > 0 {
483-
buf.Write([]byte(strings.Repeat(string(r.cfg.MaskRune), len(r.buf)-1)))
484-
if r.buf[len(r.buf)-1] == '\n' {
485-
buf.Write([]byte{'\n'})
486-
} else {
487-
buf.Write([]byte(string(r.cfg.MaskRune)))
582+
if r.cfg.MaskRune != 0 {
583+
buf.WriteString(strings.Repeat(string(r.cfg.MaskRune), len(r.buf)-1))
488584
}
489-
if len(r.buf) > r.idx {
490-
buf.Write(r.getBackspaceSequence())
585+
if r.buf[len(r.buf)-1] == '\n' {
586+
buf.WriteRune('\n')
587+
} else if r.cfg.MaskRune != 0 {
588+
buf.WriteRune(r.cfg.MaskRune)
491589
}
492-
493590
} else {
494591
for _, e := range r.cfg.Painter.Paint(r.buf, r.idx) {
495592
if e == '\t' {
@@ -498,9 +595,9 @@ func (r *RuneBuffer) output() []byte {
498595
buf.WriteRune(e)
499596
}
500597
}
501-
if r.isInLineEdge() {
502-
buf.Write([]byte(" \b"))
503-
}
598+
}
599+
if r.isInLineEdge() {
600+
buf.WriteString(" \b")
504601
}
505602
// cursor position
506603
if len(r.buf) > r.idx {
@@ -510,33 +607,41 @@ func (r *RuneBuffer) output() []byte {
510607
}
511608

512609
func (r *RuneBuffer) getBackspaceSequence() []byte {
513-
var sep = map[int]bool{}
514-
515-
var i int
516-
for {
517-
if i >= runes.WidthAll(r.buf) {
610+
bcnt := len(r.buf) - r.idx // backwards count to index
611+
sp := r.getSplitByLine(r.buf, 1)
612+
613+
// Calculate how many lines up to the index line
614+
up := 0
615+
spi := len(sp) - 1
616+
for spi >= 0 {
617+
bcnt -= len(sp[spi])
618+
if bcnt <= 0 {
518619
break
519620
}
621+
up++
622+
spi--
623+
}
520624

521-
if i == 0 {
522-
i -= r.promptLen()
523-
}
524-
i += r.width
525-
526-
sep[i] = true
625+
// Calculate what column the index should be set to
626+
column := 1
627+
if spi == 0 {
628+
column += r.ppos
527629
}
528-
var buf []byte
529-
for i := len(r.buf); i > r.idx; i-- {
530-
// move input to the left of one
531-
buf = append(buf, '\b')
532-
if sep[i] {
533-
// up one line, go to the start of the line and move cursor right to the end (r.width)
534-
buf = append(buf, "\033[A\r"+"\033["+strconv.Itoa(r.width)+"C"...)
630+
for _, rune := range sp[spi] {
631+
if bcnt >= 0 {
632+
break
535633
}
634+
column += runes.Width(rune)
635+
bcnt++
536636
}
537637

538-
return buf
638+
buf := bytes.NewBuffer(nil)
639+
if up > 0 {
640+
fmt.Fprintf(buf, "\033[%dA", up) // move cursor up to index line
641+
}
642+
fmt.Fprintf(buf, "\033[%dG", column) // move cursor to column
539643

644+
return buf.Bytes()
540645
}
541646

542647
func (r *RuneBuffer) Reset() []rune {
@@ -595,16 +700,11 @@ func (r *RuneBuffer) cleanOutput(w io.Writer, idxLine int) {
595700
buf.WriteString(strings.Repeat("\r\b", len(r.buf)+r.promptLen()))
596701
buf.Write([]byte("\033[J"))
597702
} else {
598-
buf.Write([]byte("\033[J")) // just like ^k :)
599-
if idxLine == 0 {
600-
buf.WriteString("\033[2K")
601-
buf.WriteString("\r")
602-
} else {
603-
for i := 0; i < idxLine; i++ {
604-
io.WriteString(buf, "\033[2K\r\033[A")
605-
}
606-
io.WriteString(buf, "\033[2K\r")
703+
if idxLine > 0 {
704+
fmt.Fprintf(buf, "\033[%dA", idxLine) // move cursor up by idxLine
607705
}
706+
fmt.Fprintf(buf, "\033[%dG", r.ppos + 1) // move cursor back to initial ppos position
707+
buf.Write([]byte("\033[J")) // clear from cursor to end of screen
608708
}
609709
buf.Flush()
610710
return
@@ -621,9 +721,8 @@ func (r *RuneBuffer) clean() {
621721
}
622722

623723
func (r *RuneBuffer) cleanWithIdxLine(idxLine int) {
624-
if r.hadClean || !r.interactive {
724+
if !r.interactive {
625725
return
626726
}
627-
r.hadClean = true
628727
r.cleanOutput(r.w, idxLine)
629728
}

0 commit comments

Comments
 (0)