crystal/crystal.cr
2025-08-23 18:28:21 +03:00

187 lines
4.3 KiB
Crystal

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