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