require "option_parser" BASE_SHAPE = [ " # ", " ### ", " ##### ", " ####### ", " ######### ", " ####### ", " ##### ", " ### ", " # ", ] struct TermSize getter cols : Int32 getter rows : Int32 def initialize(@cols : Int32, @rows : Int32); end end def terminal_size : TermSize if STDOUT.tty? if (cols_env = ENV["COLUMNS"]?) && (rows_env = ENV["LINES"]?) cols = cols_env.to_i rows = rows_env.to_i return TermSize.new(cols: cols, rows: rows) if cols > 0 && rows > 0 end buf = IO::Memory.new status = Process.run("stty", ["size"], output: buf, input: Process::Redirect::Close, error: Process::Redirect::Close) if status.success? parts = buf.to_s.strip.split(/\s+/) if parts.size == 2 rows = parts[0].to_i cols = parts[1].to_i return TermSize.new(cols: cols, rows: rows) if cols > 0 && rows > 0 end end end TermSize.new(cols: 80, rows: 24) end def enter_alt(io : IO) io << "\e[?1049h\e[?25l" end def leave_alt(io : IO) io << "\e[0m\e[?25h\e[?1049l" end def move_cursor(io : IO, row : Int32, col : Int32) io << "\e[#{row};#{col}H" end def scale_shape(shape : Array(String), factor : Int32) : Array(String) return shape if factor <= 1 scaled = [] of String shape.each do |row| expanded = String.build do |io| row.each_char do |ch| fill = ch == ' ' ? ' ' : '#' factor.times { io << fill } end end factor.times { scaled << expanded } end scaled end fps = 24 scale = 1 band = 2 frames : Int32? = nil no_color = false use_alt = true show_last = false OptionParser.parse do |p| p.banner = "Animated crystal" p.on("--fps=FPS", "Frames per second") { |s| fps = s.to_i } p.on("--scale=N", "Integer scale >=1") { |s| scale = s.to_i; scale = 1 if scale < 1; scale = 8 if scale > 8 } p.on("--band=W", "Highlight band width") { |s| band = s.to_i; band = 1 if band < 1; band = 10 if band > 10 } p.on("--frames=N", "Stop after N frames") { |s| frames = s.to_i } p.on("--no-color", "Disable ANSI colors") { no_color = true } p.on("--no-alt", "Do not use alternate screen") { use_alt = false } p.on("--show-last", "Print last frame after exit") { show_last = true } p.on("-h", "--help", "Show help") { puts p; exit 0 } end shape = scale_shape(BASE_SHAPE, scale) height = shape.size width = shape.max_of(&.size) base_color = no_color ? nil : "\e[38;5;45m" highlight_color = no_color ? nil : "\e[38;5;123m" reset = no_color ? nil : "\e[0m" running = true Signal::INT.trap { running = false } phase = 0 period = (width + height) band_units = band * scale if use_alt STDOUT << String.build { |io| enter_alt(io) } else STDOUT << "\e[?25l" end STDOUT.flush begin frame_target_ns = (1_000_000_000_i64 // fps) frames_left = frames last_frame : String? = nil while running sz = terminal_size top = ((sz.rows - height) // 2) + 1 left = ((sz.cols - width) // 2) + 1 top = 1 if top < 1 left = 1 if left < 1 start = Time.monotonic frame_str = String.build do |io| io << "\e[2J" y = 0 while y < height move_cursor(io, top + y, left) row = shape[y] x = 0 while x < row.size ch = row.byte_at(x).unsafe_chr if ch != ' ' sum = x + y d = sum - phase d %= period d += period if d < 0 d = period - d if d > period // 2 if d <= band_units io << highlight_color if highlight_color else io << base_color if base_color end io << '#' else io << ' ' end x += 1 end io << reset if reset y += 1 end io << reset if reset end last_frame = frame_str STDOUT << frame_str STDOUT.flush phase += 1 phase = 0 if phase >= period if frames_left frames_left -= 1 break if frames_left <= 0 end elapsed = Time.monotonic - start sleep_ns = frame_target_ns - elapsed.nanoseconds if sleep_ns > 0 sleep sleep_ns.nanoseconds end end ensure if use_alt STDOUT << String.build { |io| leave_alt(io) } else STDOUT << "\e[0m\e[?25h" end STDOUT.flush if show_last && last_frame STDOUT << last_frame STDOUT.flush end end