From 57452ef86728729e1d3f1f671b5420a97a74d520 Mon Sep 17 00:00:00 2001 From: Lain Iwakura Date: Sat, 23 Aug 2025 18:28:21 +0300 Subject: [PATCH] first commit --- .gitignore | 3 + crystal.cr | 186 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 .gitignore create mode 100644 crystal.cr diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10f4d0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +crystal.dwarf +crystal +.DS_Store diff --git a/crystal.cr b/crystal.cr new file mode 100644 index 0000000..72fd42b --- /dev/null +++ b/crystal.cr @@ -0,0 +1,186 @@ +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 + +