Last active
July 11, 2025 21:43
-
-
Save ttscoff/efe9c1284745c4df956457a5707e7450 to your computer and use it in GitHub Desktop.
A command line tool (and library) for creating a ripple effect on text, for progress animation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# frozen_string_literal: true | |
# ripple - A simple Ruby script to create a text ripple effect in the terminal. | |
## From the command line, you can run: | |
# $ ruby ripple "Your Text Here" --speed fast --rainbow --direction bidirectional | |
## Run a command during the animation: | |
# $ ruby ripple "Your Text Here" --speed fast --rainbow --direction bidirectional --command "sleep 5" | |
# This will animate the text "Your Text Here" with the specified options, and run the command in the background. | |
# Can be loaded as a library | |
# require 'ripple' | |
# rippler = Ripple.new(ARGV.join(" "), { speed: options[:speed], format: options[:direction], rainbow: options[:rainbow] }) | |
# rippler.hide_cursor | |
# while true do | |
# rippler.advance | |
# end | |
# rippler.show_cursor | |
## OR | |
# Ripple.progress("IN PROGRESS", :fast) { sleep 5 } | |
COLORS = { | |
"red" => "\e[31m", | |
"green" => "\e[32m", | |
"yellow" => "\e[33m", | |
"blue" => "\e[34m", | |
"magenta" => "\e[35m", | |
"cyan" => "\e[36m", | |
"white" => "\e[37m", | |
"dark_red" => "\e[31;1m", | |
"dark_green" => "\e[32;1m", | |
"dark_yellow" => "\e[33;1m", | |
"dark_blue" => "\e[34;1m", | |
"dark_magenta" => "\e[35;1m", | |
"dark_cyan" => "\e[36;1m", | |
"dark_white" => "\e[37;1m", | |
"light_red" => "\e[31;2m", | |
"light_green" => "\e[32;2m", | |
"light_yellow" => "\e[33;2m", | |
"light_blue" => "\e[34;2m", | |
"light_magenta" => "\e[35;2m", | |
"light_cyan" => "\e[36;2m", | |
"light_white" => "\e[37;2m", | |
"reset" => "\e[0m", | |
}.freeze | |
class ::String | |
COLORS.each do |color_name, color_code| | |
define_method(color_name) do | |
"#{color_code}#{self}#{COLORS['reset']}" | |
end | |
end | |
def rainbow(index = 0) | |
chars = split('') | |
colored_chars = chars.map.with_index do |char, idx| | |
color = COLORS.values[(idx + index) % COLORS.size] | |
"#{color}#{char}#{COLORS['reset']}" | |
end | |
colored_chars.join | |
end | |
end | |
class Ripple | |
attr_accessor :index, :string, :speed, :format, :inverse, :rainbow | |
def initialize(string, options = {}) | |
defaults = { | |
speed: :medium, | |
format: :bidirectional, | |
rainbow: false, | |
inverse: false, | |
output: :error | |
} | |
@options = defaults.merge(options) | |
@string = string | |
@index = 0 | |
@direction = :forward | |
@rainbow = @options[:rainbow] | |
@inverse = @options[:inverse] | |
end | |
def printout | |
letters = @string.dup.split('') | |
i = @index | |
if @inverse | |
pre = letters.slice!(0,i).join | |
pre = @rainbow ? pre.rainbow : pre.light_white | |
char = letters.slice!(0,2).join | |
char = char.dark_white | |
post = letters.slice!(0,letters.length).join | |
post = @rainbow ? post.rainbow : post.light_white | |
else | |
pre = letters.slice!(0,i).join.dark_white | |
char = letters.slice!(0,2).join | |
char = @rainbow ? char.rainbow(i) : char.light_white | |
post = letters.slice!(0,letters.length).join.dark_white | |
end | |
$stderr.print "\e[2K#{pre}#{char}#{post}\r" | |
end | |
# Hide or show the cursor | |
def self.hide_cursor | |
$stderr.print "\e[?25l" | |
end | |
def self.show_cursor | |
$stderr.print "\e[?25h" | |
end | |
def advance | |
if @index == @string.length - 1 && @options[:format] != :forward_only | |
@direction = :backward | |
elsif @index == @string.length && @options[:format] == :forward_only | |
@index = 0 | |
elsif @index == 0 | |
@direction = :forward | |
end | |
@index = @direction == :backward ? @index - 1 : @index + 1 | |
printout | |
case @options[:speed] | |
when :fast | |
sleep 0.05 | |
when :medium | |
sleep 0.1 | |
else | |
sleep 0.2 | |
end | |
end | |
def self.progress(string, options = {}) | |
Signal.trap("INT") do | |
Thread.current.kill | |
nil | |
end | |
defaults = { speed: :medium, format: :bidirectional, rainbow: false, inverse: false, output: :error } | |
options = defaults.merge(options) | |
rippler = new(string, options) | |
Ripple.hide_cursor | |
begin | |
thread = Thread.new do | |
while true | |
rippler.advance | |
end | |
end | |
result = yield if block_given? | |
thread.kill | |
if @options[:output] == :error | |
$?.exitstatus.zero? | |
elsif @options[:output] == :stdout | |
result | |
else | |
nil | |
end | |
rescue StandardError | |
thread&.kill | |
nil | |
ensure | |
Ripple.show_cursor | |
end | |
end | |
end | |
if __FILE__ == $PROGRAM_NAME | |
require 'optparse' | |
trap("INT") { | |
Ripple.show_cursor | |
exit | |
} | |
options = { speed: :medium, direction: :bidirectional, rainbow: false, inverse: false, command: nil } | |
OptionParser.new do |opts| | |
opts.banner = "Usage: ripple [options] STRING" | |
opts.on("-s", "--speed SPEED", [:fast, :medium, :slow], "Set animation speed ((f)ast/(m)edium/(s)low)") do |s| | |
options[:speed] = case s | |
when /^f/ then :fast | |
when /^m/ then :medium | |
when /^s/ then :slow | |
else :slow | |
end | |
end | |
opts.on("-r", "--rainbow", "Enable rainbow mode") do | |
options[:rainbow] = true | |
end | |
opts.on("-d", "--direction DIRECTION", [:forward_only, :bidirectional], "Set animation format ((f)orward/(b)ack-and-forth)") do |f| | |
options[:direction] = f =~ /^f/ ? :forward_only : :bidirectional | |
end | |
opts.on("-i", "--inverse", "Enable inverse mode") do | |
options[:inverse] = true | |
end | |
opts.on("-c", "--command COMMAND", "Run a command during the animation") do |command| | |
options[:command] = command | |
end | |
opts.on("--stdout", "Output captured command result to STDOUT") do |output| | |
options[:output] = :stdout | |
end | |
opts.on("--quiet", "Suppress all output") do |quiet| | |
options[:output] = :quiet | |
end | |
opts.on("-v", "--version", "Display the version") do | |
puts "Ripple version #{Ripple::VERSION || "1.0.0"}" | |
exit | |
end | |
opts.on("-h", "--help", "Display this help message") do | |
puts opts | |
exit | |
end | |
end.parse! | |
if ARGV.empty? | |
puts "Please provide a string to animate as an argument." | |
exit 1 | |
end | |
if options[:command] | |
captured_output = nil | |
res = Ripple.progress(ARGV.join(" "), options) do | |
captured_output = `#{options[:command]} 2>&1` | |
end | |
res = $?.success? | |
if options[:output] == :stdout | |
puts captured_output | |
end | |
exit res ? 0 : 1 | |
end | |
rippler = Ripple.new(ARGV.join(" "), { speed: options[:speed], format: options[:direction], rainbow: options[:rainbow], inverse: options[:inverse] }) | |
Ripple.hide_cursor | |
while true do | |
rippler.advance | |
end | |
Ripple.show_cursor | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thanks for sharing. It inspired me to port it to Python as a context manager. You can check it out here: https://github.com/AlloriMD/koolkit/blob/main/src/koolkit/progress_bars/ripple_progress_bar.py
Usage is simply: