From 64c36be6c23c1f8207531012d0e23cb0ad79f949 Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Fri, 6 Nov 2026 11:47:16 +1100 Subject: [PATCH] Reduce spinner flickering Instead of clearing the line and rewriting from the beginning (which can lead to flickering for terminals that refresh very quickly, because they show the line in its temporarily blank state), draw _over_ the previous frame and then clear any remining characters to the right hand side. --- lib/dry/cli/ansi.rb | 6 ++++++ lib/dry/cli/spinner.rb | 6 +++++- lib/dry/cli/spinner/dsl.rb | 5 +++-- spec/unit/dry/cli/ansi_spec.rb | 7 +++++++ spec/unit/dry/cli/spinner_spec.rb | 27 ++++++++++++++++++++++++--- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/lib/dry/cli/ansi.rb b/lib/dry/cli/ansi.rb index dadccec..c517ca7 100644 --- a/lib/dry/cli/ansi.rb +++ b/lib/dry/cli/ansi.rb @@ -31,6 +31,12 @@ def self.carriage_return = CR EL2 = "\e[2K" def self.erase_line = EL2 + # Erase from the cursor to the end of the line + # @api public + # @since x.y.z + EL0 = "\e[K" + def self.erase_to_end_of_line = EL0 + # Reset all styles # @api public # @since x.y.z diff --git a/lib/dry/cli/spinner.rb b/lib/dry/cli/spinner.rb index 0306f96..d4fdb6a 100644 --- a/lib/dry/cli/spinner.rb +++ b/lib/dry/cli/spinner.rb @@ -68,7 +68,9 @@ def run(format = "%{spinner}", fps: self.fps, stream: $stderr, &) frames = self.frames.cycle ticker = Ticker.start(interval: 1.0 / fps) do - stream.raw.print ANSI.clear_line + # Draw over the previous frame, then erase what is left of it. Erasing the whole line + # first would show a blank line in terminals that redraw between writes. + stream.raw.print ANSI.carriage_return data = {spinner: frames.next} before_tick.call(stream, data) @@ -76,6 +78,8 @@ def run(format = "%{spinner}", fps: self.fps, stream: $stderr, &) stream.print format % data after_tick.call(stream) + + stream.raw.print ANSI.erase_to_end_of_line end end diff --git a/lib/dry/cli/spinner/dsl.rb b/lib/dry/cli/spinner/dsl.rb index 3920131..e780e4b 100644 --- a/lib/dry/cli/spinner/dsl.rb +++ b/lib/dry/cli/spinner/dsl.rb @@ -50,8 +50,9 @@ def handlers # @since x.y.z def run(&block) = @events[:run] = block - # Executed before each tick of the spinner. The line has been erased, so the current state - # of the stream will be a blank slate. + # Executed before each tick of the spinner. The cursor is at the start of the line. The line + # still shows the previous frame, but anything you write draws over it, and any old text left + # to the right is erased after {#after_tick} runs. # # This runs in a separate thread, so do not `throw :exit` or raise {Dry::CLI::ExitError} from # it. Do that from the {#run} block instead. diff --git a/spec/unit/dry/cli/ansi_spec.rb b/spec/unit/dry/cli/ansi_spec.rb index 52de8be..3d670d5 100644 --- a/spec/unit/dry/cli/ansi_spec.rb +++ b/spec/unit/dry/cli/ansi_spec.rb @@ -37,6 +37,12 @@ end end + describe ".erase_to_end_of_line" do + it "returns the ANSI escape sequence to erase from the cursor to the end of the line" do + expect(described_class.erase_to_end_of_line).to eq("\e[K") + end + end + describe ".enter_alt_screen" do it "returns the ANSI escape sequence to switch to the alternate screen buffer" do expect(described_class.enter_alt_screen).to eq("\e[?1049h") @@ -143,6 +149,7 @@ expect(described_class::CNORM).to eq("\e[?25h") expect(described_class::CR).to eq("\r") expect(described_class::EL2).to eq("\e[2K") + expect(described_class::EL0).to eq("\e[K") expect(described_class::SGR0).to eq("\e[0m") expect(described_class::SMCUP).to eq("\e[?1049h") expect(described_class::RMCUP).to eq("\e[?1049l") diff --git a/spec/unit/dry/cli/spinner_spec.rb b/spec/unit/dry/cli/spinner_spec.rb index d57eb87..984d6a0 100644 --- a/spec/unit/dry/cli/spinner_spec.rb +++ b/spec/unit/dry/cli/spinner_spec.rb @@ -151,7 +151,7 @@ def render_ticks(count = 1) end render_ticks - expect(terminal.string).to include "#{Dry::CLI::ANSI.clear_line}" + expect(terminal.string).to include "\r\e[K" end it "does not need the DSL to register a worker" do @@ -175,8 +175,29 @@ def render_ticks(count = 1) it "cycles through the frames on each tick" do spinner.run(stream: terminal) { render_ticks(2) } - expect(terminal.string).to include "#{Dry::CLI::ANSI.clear_line}|" - expect(terminal.string).to include "#{Dry::CLI::ANSI.clear_line}/" + expect(terminal.string).to include "\r|\e[K" + expect(terminal.string).to include "\r/\e[K" + end + + it "draws over the previous frame and then erases to the end of the line" do + spinner.run("%{spinner} Loading", stream: terminal) { render_ticks(2) } + + expect(terminal.string).to eq( + Dry::CLI::ANSI.hide_cursor + + "\r| Loading\e[K" \ + "\r/ Loading\e[K" + + Dry::CLI::ANSI.clear_line + + Dry::CLI::ANSI.show_cursor + ) + end + + it "does not erase the whole line before drawing a frame" do + spinner.run(stream: terminal) { render_ticks(2) } + + ticks = terminal.string.delete_prefix(Dry::CLI::ANSI.hide_cursor) + .delete_suffix(Dry::CLI::ANSI.clear_line + Dry::CLI::ANSI.show_cursor) + + expect(ticks).to_not include Dry::CLI::ANSI.erase_line end end