terminal: Do not write unchanged status lines

Check if each line of status is changed, and write
the line to the terminal only if it has changed
This commit is contained in:
Donggyu Kim
2025-12-20 17:49:44 +09:00
committed by Michael Eischer
parent ff575a978d
commit e33bcede2f
5 changed files with 83 additions and 5 deletions
+10
View File
@@ -0,0 +1,10 @@
Enhancement: Do not rewrite unchanged lines every frame in status bar
Status bars were entirely rewritten every frame if any of its content has updated.
This behavior had made any user interaction (such as selection) with status bar
impossible in certain terminal emulators, even with the unchanged lines.
Now it writes changed lines only at status bar update, thereby improving
user experience in those terminal emulators.
https://github.com/restic/restic/issues/5562
https://github.com/restic/restic/pull/5648
+13
View File
@@ -11,6 +11,8 @@ const (
PosixControlMoveCursorHome = "\r"
// PosixControlMoveCursorUp moves cursor up one line
PosixControlMoveCursorUp = "\x1b[1A"
// PosixControlMoveCursorDown moves cursor down one line
PosixControlMoveCursorDown = "\x1b[1B"
// PosixControlClearLine clears the current line
PosixControlClearLine = "\x1b[2K"
)
@@ -36,3 +38,14 @@ func PosixMoveCursorUp(wr io.Writer, _ uintptr, n int) error {
}
return nil
}
// PosixMoveCursorDown moves the cursor to the line n lines below the current one.
func PosixMoveCursorDown(wr io.Writer, _ uintptr, n int) error {
data := []byte(PosixControlMoveCursorHome)
data = append(data, bytes.Repeat([]byte(PosixControlMoveCursorDown), n)...)
_, err := wr.Write(data)
if err != nil {
return fmt.Errorf("write failed: %w", err)
}
return nil
}
+5
View File
@@ -20,6 +20,11 @@ func MoveCursorUp(_ uintptr) func(io.Writer, uintptr, int) error {
return PosixMoveCursorUp
}
// MoveCursorDown moves the cursor to the line n lines below the current one.
func MoveCursorDown(_ uintptr) func(io.Writer, uintptr, int) error {
return PosixMoveCursorDown
}
// CanUpdateStatus returns true if status lines can be printed, the process
// output is not redirected to a file or pipe.
func CanUpdateStatus(fd uintptr) bool {
+24
View File
@@ -35,6 +35,17 @@ func MoveCursorUp(fd uintptr) func(io.Writer, uintptr, int) error {
return PosixMoveCursorUp
}
// moveCursorDown moves the cursor to the line n lines below the current one.
func MoveCursorDown(fd uintptr) func(io.Writer, uintptr, int) error {
// easy case, the terminal is cmd or psh, without redirection
if isWindowsTerminal(fd) {
return windowsMoveCursorDown
}
// assume we're running in mintty/cygwin
return PosixMoveCursorDown
}
var kernel32 = syscall.NewLazyDLL("kernel32.dll")
var (
@@ -73,6 +84,19 @@ func windowsMoveCursorUp(_ io.Writer, fd uintptr, n int) error {
return nil
}
// windowsMoveCursorDown moves the cursor to the line n lines below the current one.
func windowsMoveCursorDown(_ io.Writer, fd uintptr, n int) error {
var info windows.ConsoleScreenBufferInfo
windows.GetConsoleScreenBufferInfo(windows.Handle(fd), &info)
// move cursor up by n lines and to the first column
windows.SetConsoleCursorPosition(windows.Handle(fd), windows.Coord{
X: 0,
Y: info.CursorPosition.Y + int16(n),
})
return nil
}
// isWindowsTerminal return true if the file descriptor is a windows terminal (cmd, psh).
func isWindowsTerminal(fd uintptr) bool {
return term.IsTerminal(int(fd))
+31 -5
View File
@@ -40,6 +40,7 @@ type Terminal struct {
clearCurrentLine func(io.Writer, uintptr) error
moveCursorUp func(io.Writer, uintptr, int) error
moveCursorDown func(io.Writer, uintptr, int) error
}
type message struct {
@@ -123,6 +124,7 @@ func New(rd io.ReadCloser, wr io.Writer, errWriter io.Writer, disableStatus bool
t.fd = d.Fd()
t.clearCurrentLine = terminal.ClearCurrentLine(t.fd)
t.moveCursorUp = terminal.MoveCursorUp(t.fd)
t.moveCursorDown = terminal.MoveCursorDown(t.fd)
}
if terminal.OutputIsTerminal(d.Fd()) {
t.outputIsTerminal = true
@@ -202,6 +204,18 @@ func (t *Terminal) Run(ctx context.Context) {
t.runWithoutStatus(ctx)
}
func findUnchangedLines(curr, last []string) []bool {
unchanged := make([]bool, len(curr))
for i := range min(len(curr), len(last)) {
if curr[i] == last[i] {
unchanged[i] = true
}
}
return unchanged
}
// run listens on the channels and updates the terminal screen.
func (t *Terminal) run(ctx context.Context) {
var status []string
@@ -210,7 +224,7 @@ func (t *Terminal) run(ctx context.Context) {
select {
case <-ctx.Done():
if !terminal.IsProcessBackground(t.fd) {
t.writeStatus([]string{})
t.writeStatus([]string{}, nil)
}
return
@@ -241,7 +255,7 @@ func (t *Terminal) run(ctx context.Context) {
continue
}
t.writeStatus(status)
t.writeStatus(status, nil)
lastWrittenStatus = append([]string{}, status...)
case stat := <-t.status:
status = append(status[:0], stat.lines...)
@@ -252,7 +266,8 @@ func (t *Terminal) run(ctx context.Context) {
}
if !slices.Equal(status, lastWrittenStatus) {
t.writeStatus(status)
unchangedLines := findUnchangedLines(status, lastWrittenStatus)
t.writeStatus(status, unchangedLines)
// Copy the status slice to avoid aliasing
lastWrittenStatus = append([]string{}, status...)
}
@@ -260,7 +275,7 @@ func (t *Terminal) run(ctx context.Context) {
}
}
func (t *Terminal) writeStatus(status []string) {
func (t *Terminal) writeStatus(status []string, unchanged []bool) {
statusLen := len(status)
status = append([]string{}, status...)
for i := len(status); i < t.lastStatusLen; i++ {
@@ -273,7 +288,18 @@ func (t *Terminal) writeStatus(status []string) {
}
t.lastStatusLen = statusLen
for _, line := range status {
for i, line := range status {
if unchanged != nil && i < len(unchanged) && unchanged[i] {
// don't write unchanged lines every frame
if i < len(status)-1 {
// just move the cursor down to the next line
if err := t.moveCursorDown(t.wr, t.fd, 1); err != nil {
_, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err)
}
}
continue
}
if err := t.clearCurrentLine(t.wr, t.fd); err != nil {
_, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err)
}