From e33bcede2f49dd16eb161c9a139e9a3e260d87a5 Mon Sep 17 00:00:00 2001 From: Donggyu Kim Date: Sat, 20 Dec 2025 17:49:44 +0900 Subject: [PATCH] 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 --- changelog/unreleased/issue-5562 | 10 ++++++++ internal/terminal/terminal_posix.go | 13 ++++++++++ internal/terminal/terminal_unix.go | 5 ++++ internal/terminal/terminal_windows.go | 24 ++++++++++++++++++ internal/ui/termstatus/status.go | 36 +++++++++++++++++++++++---- 5 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 changelog/unreleased/issue-5562 diff --git a/changelog/unreleased/issue-5562 b/changelog/unreleased/issue-5562 new file mode 100644 index 000000000..446cdfa51 --- /dev/null +++ b/changelog/unreleased/issue-5562 @@ -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 diff --git a/internal/terminal/terminal_posix.go b/internal/terminal/terminal_posix.go index 08527b777..1a3b0581d 100644 --- a/internal/terminal/terminal_posix.go +++ b/internal/terminal/terminal_posix.go @@ -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 +} diff --git a/internal/terminal/terminal_unix.go b/internal/terminal/terminal_unix.go index 2065f50cf..1f2f35d1b 100644 --- a/internal/terminal/terminal_unix.go +++ b/internal/terminal/terminal_unix.go @@ -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 { diff --git a/internal/terminal/terminal_windows.go b/internal/terminal/terminal_windows.go index 163173286..7d8713acf 100644 --- a/internal/terminal/terminal_windows.go +++ b/internal/terminal/terminal_windows.go @@ -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)) diff --git a/internal/ui/termstatus/status.go b/internal/ui/termstatus/status.go index 94ea6adaa..a20b0c825 100644 --- a/internal/ui/termstatus/status.go +++ b/internal/ui/termstatus/status.go @@ -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) }