From e33bcede2f49dd16eb161c9a139e9a3e260d87a5 Mon Sep 17 00:00:00 2001 From: Donggyu Kim Date: Sat, 20 Dec 2025 17:49:44 +0900 Subject: [PATCH 1/6] 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) } From cf34130a059606ffdb99673c074d5baf35f4156e Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Wed, 13 May 2026 23:51:41 +0200 Subject: [PATCH 2/6] ui/termstatus: simplify status tracking --- internal/ui/termstatus/status.go | 51 ++++++++++++++------------- internal/ui/termstatus/status_test.go | 4 +-- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/internal/ui/termstatus/status.go b/internal/ui/termstatus/status.go index a20b0c825..712424c5b 100644 --- a/internal/ui/termstatus/status.go +++ b/internal/ui/termstatus/status.go @@ -26,7 +26,7 @@ type Terminal struct { errWriter io.Writer msg chan message status chan status - lastStatusLen int + lastStatus []string inputIsTerminal bool outputIsTerminal bool canUpdateStatus bool @@ -219,12 +219,11 @@ func findUnchangedLines(curr, last []string) []bool { // run listens on the channels and updates the terminal screen. func (t *Terminal) run(ctx context.Context) { var status []string - var lastWrittenStatus []string for { select { case <-ctx.Done(): if !terminal.IsProcessBackground(t.fd) { - t.writeStatus([]string{}, nil) + t.writeStatus([]string{}, false) } return @@ -255,8 +254,7 @@ func (t *Terminal) run(ctx context.Context) { continue } - t.writeStatus(status, nil) - lastWrittenStatus = append([]string{}, status...) + t.writeStatus(status, false) case stat := <-t.status: status = append(status[:0], stat.lines...) @@ -265,28 +263,29 @@ func (t *Terminal) run(ctx context.Context) { continue } - if !slices.Equal(status, lastWrittenStatus) { - unchangedLines := findUnchangedLines(status, lastWrittenStatus) - t.writeStatus(status, unchangedLines) - // Copy the status slice to avoid aliasing - lastWrittenStatus = append([]string{}, status...) - } + t.writeStatus(status, true) } } } -func (t *Terminal) writeStatus(status []string, unchanged []bool) { - statusLen := len(status) - status = append([]string{}, status...) - for i := len(status); i < t.lastStatusLen; i++ { - // clear no longer used status lines - status = append(status, "") - if i > 0 { - // all lines except the last one must have a line break - status[i-1] = status[i-1] + "\n" +func (t *Terminal) writeStatus(status []string, skipUnchanged bool) { + var unchanged []bool + if skipUnchanged { + if slices.Equal(status, t.lastStatus) { + return } + unchanged = findUnchangedLines(status, t.lastStatus) + } + + lastStatusLen := len(t.lastStatus) + // Copy the status slice to avoid aliasing + t.lastStatus = append([]string{}, status...) + + // Extend to clear no longer used status lines + status = append([]string{}, status...) + for i := len(status); i < lastStatusLen; i++ { + status = append(status, "") } - t.lastStatusLen = statusLen for i, line := range status { if unchanged != nil && i < len(unchanged) && unchanged[i] { @@ -308,6 +307,13 @@ func (t *Terminal) writeStatus(status []string, unchanged []bool) { if err != nil { _, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err) } + // all lines except the last one must be followed by a line break + if i < len(status)-1 { + _, err := t.wr.Write([]byte("\n")) + if err != nil { + _, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err) + } + } } if len(status) > 0 { @@ -400,9 +406,6 @@ func sanitizeLines(lines []string, width int) []string { if width > 0 { line = ui.Truncate(line, width-2) } - if i < len(lines)-1 { // Last line gets no line break. - line += "\n" - } lines[i] = line } return lines diff --git a/internal/ui/termstatus/status_test.go b/internal/ui/termstatus/status_test.go index b19e00557..5c36dd6ba 100644 --- a/internal/ui/termstatus/status_test.go +++ b/internal/ui/termstatus/status_test.go @@ -91,8 +91,8 @@ func TestSanitizeLines(t *testing.T) { }{ {[]string{""}, 80, []string{""}}, {[]string{"too long test line"}, 10, []string{"too long"}}, - {[]string{"too long test line", "text"}, 10, []string{"too long\n", "text"}}, - {[]string{"too long test line", "second long test line"}, 10, []string{"too long\n", "second l"}}, + {[]string{"too long test line", "text"}, 10, []string{"too long", "text"}}, + {[]string{"too long test line", "second long test line"}, 10, []string{"too long", "second l"}}, } for _, test := range tests { From bd8aad3b9be172616f166e8240d94b99ade11009 Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Wed, 13 May 2026 23:55:53 +0200 Subject: [PATCH 3/6] ui/termstatus: deduplicate error handling --- internal/ui/termstatus/status.go | 40 ++++++++++++++------------------ 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/internal/ui/termstatus/status.go b/internal/ui/termstatus/status.go index 712424c5b..806c2a394 100644 --- a/internal/ui/termstatus/status.go +++ b/internal/ui/termstatus/status.go @@ -238,7 +238,7 @@ func (t *Terminal) run(ctx context.Context) { continue } if err := t.clearCurrentLine(t.wr, t.fd); err != nil { - _, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err) + t.logWriteErr(err) continue } @@ -250,7 +250,7 @@ func (t *Terminal) run(ctx context.Context) { } if _, err := io.WriteString(dst, msg.line); err != nil { - _, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err) + t.logWriteErr(err) continue } @@ -268,6 +268,12 @@ func (t *Terminal) run(ctx context.Context) { } } +func (t *Terminal) logWriteErr(err error) { + if err != nil { + _, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err) + } +} + func (t *Terminal) writeStatus(status []string, skipUnchanged bool) { var unchanged []bool if skipUnchanged { @@ -292,34 +298,24 @@ func (t *Terminal) writeStatus(status []string, skipUnchanged bool) { // 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) - } + t.logWriteErr(t.moveCursorDown(t.wr, t.fd, 1)) } continue } - if err := t.clearCurrentLine(t.wr, t.fd); err != nil { - _, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err) - } + t.logWriteErr(t.clearCurrentLine(t.wr, t.fd)) _, err := t.wr.Write([]byte(line)) - if err != nil { - _, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err) - } + t.logWriteErr(err) // all lines except the last one must be followed by a line break if i < len(status)-1 { _, err := t.wr.Write([]byte("\n")) - if err != nil { - _, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err) - } + t.logWriteErr(err) } } if len(status) > 0 { - if err := t.moveCursorUp(t.wr, t.fd, len(status)-1); err != nil { - _, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err) - } + t.logWriteErr(t.moveCursorUp(t.wr, t.fd, len(status)-1)) } } @@ -344,17 +340,15 @@ func (t *Terminal) runWithoutStatus(ctx context.Context) { dst = t.wr } - if _, err := io.WriteString(dst, msg.line); err != nil { - _, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err) - } + _, err := io.WriteString(dst, msg.line) + t.logWriteErr(err) case stat := <-t.status: if !slices.Equal(stat.lines, lastStatus) { for _, line := range stat.lines { // Ensure that each message ends with exactly one newline. - if _, err := fmt.Fprintln(t.wr, strings.TrimRight(line, "\n")); err != nil { - _, _ = fmt.Fprintf(t.errWriter, "write failed: %v\n", err) - } + _, err := fmt.Fprintln(t.wr, strings.TrimRight(line, "\n")) + t.logWriteErr(err) } // Copy the status slice to avoid aliasing lastStatus = append([]string{}, stat.lines...) From df2d65bb88681651885b8c0a4b2aaef5790c2754 Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Thu, 14 May 2026 10:24:18 +0200 Subject: [PATCH 4/6] ui/termstatus: test skipping of unchanged lines --- internal/ui/termstatus/status_test.go | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/ui/termstatus/status_test.go b/internal/ui/termstatus/status_test.go index 5c36dd6ba..43f7eba64 100644 --- a/internal/ui/termstatus/status_test.go +++ b/internal/ui/termstatus/status_test.go @@ -50,6 +50,37 @@ func TestSetStatus(t *testing.T) { rtest.Equals(t, exp, buf.String()) } +func TestSetStatusUnchangedLines(t *testing.T) { + buf, term, cancel := setupStatusTest() + + const ( + cl = terminal.PosixControlClearLine + home = terminal.PosixControlMoveCursorHome + up = terminal.PosixControlMoveCursorUp + down = terminal.PosixControlMoveCursorDown + ) + + clearLn := home + cl + stepDown := home + down + + term.SetStatus([]string{"line1", "line2", "line3"}) + exp := clearLn + "line1\n" + clearLn + "line2\n" + clearLn + "line3" + home + up + up + + term.SetStatus([]string{"line1", "line2", "line3-changed"}) + exp += stepDown + stepDown + clearLn + "line3-changed" + home + up + up + + term.SetStatus([]string{"line1", "line2", "line3-changed"}) + + term.SetStatus([]string{"line1", "line2-new", "line3-changed"}) + exp += stepDown + clearLn + "line2-new\n" + home + up + up + + cancel() + exp += clearLn + "\n" + clearLn + "\n" + clearLn + "" + home + up + up + + <-term.closed + rtest.Equals(t, exp, buf.String()) +} + func setupStatusTest() (*bytes.Buffer, *Terminal, context.CancelFunc) { buf := &bytes.Buffer{} term := New(nil, buf, buf, false) @@ -58,6 +89,7 @@ func setupStatusTest() (*bytes.Buffer, *Terminal, context.CancelFunc) { term.fd = ^uintptr(0) term.clearCurrentLine = terminal.PosixClearCurrentLine term.moveCursorUp = terminal.PosixMoveCursorUp + term.moveCursorDown = terminal.PosixMoveCursorDown ctx, cancel := context.WithCancel(context.Background()) go term.Run(ctx) From 59697213f9559f2990ec17949612227a8b8a7b09 Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Thu, 14 May 2026 10:28:13 +0200 Subject: [PATCH 5/6] ui/termstatus: cleanup test code --- internal/ui/termstatus/status_test.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/internal/ui/termstatus/status_test.go b/internal/ui/termstatus/status_test.go index 43f7eba64..989d2e782 100644 --- a/internal/ui/termstatus/status_test.go +++ b/internal/ui/termstatus/status_test.go @@ -20,31 +20,32 @@ func TestSetStatus(t *testing.T) { cl = terminal.PosixControlClearLine home = terminal.PosixControlMoveCursorHome up = terminal.PosixControlMoveCursorUp + + clearLn = home + cl ) term.SetStatus([]string{"first"}) - exp := home + cl + "first" + home + exp := clearLn + "first" + home term.SetStatus([]string{""}) - exp += home + cl + "" + home + exp += clearLn + "" + home term.SetStatus([]string{}) - exp += home + cl + "" + home + exp += clearLn + "" + home // already empty status term.SetStatus([]string{}) term.SetStatus([]string{"foo", "bar", "baz"}) - exp += home + cl + "foo\n" + home + cl + "bar\n" + - home + cl + "baz" + home + up + up + exp += clearLn + "foo\n" + clearLn + "bar\n" + clearLn + "baz" + home + up + up term.SetStatus([]string{"quux", "needs\nquote"}) - exp += home + cl + "quux\n" + - home + cl + "\"needs\\nquote\"\n" + - home + cl + home + up + up // Clear third line + exp += clearLn + "quux\n" + + clearLn + "\"needs\\nquote\"\n" + + clearLn + home + up + up // Clear third line cancel() - exp += home + cl + "\n" + home + cl + home + up // Status cleared + exp += clearLn + "\n" + clearLn + "" + home + up // Status cleared <-term.closed rtest.Equals(t, exp, buf.String()) @@ -58,10 +59,10 @@ func TestSetStatusUnchangedLines(t *testing.T) { home = terminal.PosixControlMoveCursorHome up = terminal.PosixControlMoveCursorUp down = terminal.PosixControlMoveCursorDown - ) - clearLn := home + cl - stepDown := home + down + clearLn = home + cl + stepDown = home + down + ) term.SetStatus([]string{"line1", "line2", "line3"}) exp := clearLn + "line1\n" + clearLn + "line2\n" + clearLn + "line3" + home + up + up From d494e37dc16dfbf05558750b88cdc5d332a75f98 Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Thu, 14 May 2026 10:32:29 +0200 Subject: [PATCH 6/6] ui/termstatus: reorder findUnchangedLines function --- internal/ui/termstatus/status.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/ui/termstatus/status.go b/internal/ui/termstatus/status.go index 806c2a394..46357eca3 100644 --- a/internal/ui/termstatus/status.go +++ b/internal/ui/termstatus/status.go @@ -204,18 +204,6 @@ 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 @@ -319,6 +307,18 @@ func (t *Terminal) writeStatus(status []string, skipUnchanged bool) { } } +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 +} + // runWithoutStatus listens on the channels and just prints out the messages, // without status lines. func (t *Terminal) runWithoutStatus(ctx context.Context) {