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