fun_ci 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +3 -1
  4. data/exe/fun-ci-trigger +6 -6
  5. data/exe/fun-ci-tui +9 -4
  6. data/lib/fun_ci/animations/celebrate.rb +210 -0
  7. data/lib/fun_ci/animations/explosion.rb +138 -0
  8. data/lib/fun_ci/animations/flash.rb +205 -0
  9. data/lib/fun_ci/animations/idle.rb +127 -0
  10. data/lib/fun_ci/animations/leprechauns.rb +240 -0
  11. data/lib/fun_ci/animations/running.rb +129 -0
  12. data/lib/fun_ci/animations/success.rb +281 -0
  13. data/lib/fun_ci/animations/yay.rb +169 -0
  14. data/lib/fun_ci/cli.rb +20 -14
  15. data/lib/fun_ci/persistence/database.rb +52 -0
  16. data/lib/fun_ci/persistence/pipeline_recorder.rb +74 -0
  17. data/lib/fun_ci/persistence/pipeline_run.rb +59 -0
  18. data/lib/fun_ci/persistence/stage_job.rb +46 -0
  19. data/lib/fun_ci/{state_machine.rb → persistence/state_machine.rb} +3 -12
  20. data/lib/fun_ci/pipeline/background_wrapper.rb +27 -0
  21. data/lib/fun_ci/pipeline/pipeline_forker.rb +38 -0
  22. data/lib/fun_ci/pipeline/process_runner.rb +28 -0
  23. data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
  24. data/lib/fun_ci/pipeline/stage_runner.rb +85 -0
  25. data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +53 -0
  26. data/lib/fun_ci/pipeline/trigger.rb +153 -0
  27. data/lib/fun_ci/setup/hook_writer.rb +75 -0
  28. data/lib/fun_ci/setup/installer.rb +55 -0
  29. data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
  30. data/lib/fun_ci/setup/project_config.rb +42 -0
  31. data/lib/fun_ci/setup/project_detector.rb +22 -0
  32. data/lib/fun_ci/setup/setup_checker.rb +30 -0
  33. data/lib/fun_ci/setup/template_writer.rb +53 -0
  34. data/lib/fun_ci/tui/admin_tui.rb +90 -0
  35. data/lib/fun_ci/tui/animation.rb +49 -0
  36. data/lib/fun_ci/tui/animation_compositor.rb +107 -0
  37. data/lib/fun_ci/tui/animation_frames.rb +112 -0
  38. data/lib/fun_ci/tui/animation_library.rb +46 -0
  39. data/lib/fun_ci/tui/animation_renderer.rb +144 -0
  40. data/lib/fun_ci/tui/ansi.rb +34 -0
  41. data/lib/fun_ci/tui/board_data.rb +53 -0
  42. data/lib/fun_ci/tui/board_renderer.rb +105 -0
  43. data/lib/fun_ci/tui/duration_formatter.rb +24 -0
  44. data/lib/fun_ci/tui/header_animation_manager.rb +71 -0
  45. data/lib/fun_ci/tui/header_animation_player.rb +45 -0
  46. data/lib/fun_ci/tui/key_handler.rb +86 -0
  47. data/lib/fun_ci/tui/looping_animation_player.rb +45 -0
  48. data/lib/fun_ci/tui/relative_time.rb +22 -0
  49. data/lib/fun_ci/tui/row_formatter.rb +108 -0
  50. data/lib/fun_ci/tui/screen.rb +103 -0
  51. data/lib/fun_ci/tui/spinner.rb +24 -0
  52. data/lib/fun_ci/tui/stage_change_detector.rb +58 -0
  53. data/lib/fun_ci/tui/streak_counter.rb +29 -0
  54. data/lib/fun_ci/tui/terminal_input.rb +69 -0
  55. data/lib/fun_ci.rb +6 -6
  56. metadata +49 -28
  57. data/lib/fun_ci/admin_tui.rb +0 -226
  58. data/lib/fun_ci/ansi.rb +0 -21
  59. data/lib/fun_ci/background_wrapper.rb +0 -27
  60. data/lib/fun_ci/board_data.rb +0 -51
  61. data/lib/fun_ci/database.rb +0 -50
  62. data/lib/fun_ci/duration_formatter.rb +0 -23
  63. data/lib/fun_ci/hook_writer.rb +0 -73
  64. data/lib/fun_ci/installer.rb +0 -53
  65. data/lib/fun_ci/maven_linter_detector.rb +0 -24
  66. data/lib/fun_ci/pipeline_forker.rb +0 -36
  67. data/lib/fun_ci/pipeline_recorder.rb +0 -72
  68. data/lib/fun_ci/pipeline_run.rb +0 -57
  69. data/lib/fun_ci/progress_reporter.rb +0 -29
  70. data/lib/fun_ci/project_config.rb +0 -40
  71. data/lib/fun_ci/project_detector.rb +0 -18
  72. data/lib/fun_ci/relative_time.rb +0 -20
  73. data/lib/fun_ci/row_formatter.rb +0 -106
  74. data/lib/fun_ci/screen.rb +0 -81
  75. data/lib/fun_ci/setup_checker.rb +0 -28
  76. data/lib/fun_ci/spinner.rb +0 -22
  77. data/lib/fun_ci/stage_job.rb +0 -44
  78. data/lib/fun_ci/stage_runner.rb +0 -108
  79. data/lib/fun_ci/stale_pipeline_canceller.rb +0 -51
  80. data/lib/fun_ci/streak_counter.rb +0 -30
  81. data/lib/fun_ci/template_writer.rb +0 -51
  82. data/lib/fun_ci/trigger.rb +0 -150
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module FunCi
6
+ module Setup
7
+ class HookWriter
8
+ ALLOWED_HOOKS = %w[pre-commit pre-push].freeze
9
+ MARKER = "# fun-ci-managed-hook"
10
+
11
+ HOOK_COMMANDS = {
12
+ "pre-commit" => "fun-ci trigger --no-validate",
13
+ "pre-push" => "fun-ci trigger"
14
+ }.freeze
15
+
16
+ HOOK_TEMPLATE = <<~SH
17
+ #!/bin/sh
18
+ #{MARKER}
19
+ COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "0000000000000000000000000000000000000000")
20
+ BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
21
+ %<command>s "$COMMIT" "$BRANCH"
22
+ SH
23
+
24
+ def self.run(project_root:, hook_type:, stdout: $stdout)
25
+ new(project_root: project_root, hook_type: hook_type, stdout: stdout).run
26
+ end
27
+
28
+ def initialize(project_root:, hook_type:, stdout:)
29
+ @project_root = project_root
30
+ @hook_type = hook_type
31
+ @stdout = stdout
32
+ end
33
+
34
+ def run
35
+ return reject("Not a git repository — no .git/ found.") unless git_repo?
36
+ return reject("Unknown hook type: #{@hook_type}") unless ALLOWED_HOOKS.include?(@hook_type)
37
+ return skip("Hook #{@hook_type} already exists from another tool — skipping.") if foreign_hook?
38
+
39
+ write_hook
40
+ @stdout.puts "Installed #{@hook_type} hook."
41
+ 0
42
+ end
43
+
44
+ private
45
+
46
+ def git_repo?
47
+ Dir.exist?(File.join(@project_root, ".git"))
48
+ end
49
+
50
+ def hook_path
51
+ File.join(@project_root, ".git", "hooks", @hook_type)
52
+ end
53
+
54
+ def foreign_hook?
55
+ File.exist?(hook_path) && !File.read(hook_path).include?(MARKER)
56
+ end
57
+
58
+ def write_hook
59
+ FileUtils.mkdir_p(File.join(@project_root, ".git", "hooks"))
60
+ File.write(hook_path, format(HOOK_TEMPLATE, command: HOOK_COMMANDS[@hook_type]))
61
+ File.chmod(0o755, hook_path)
62
+ end
63
+
64
+ def reject(message)
65
+ @stdout.puts message
66
+ 1
67
+ end
68
+
69
+ def skip(message)
70
+ @stdout.puts message
71
+ 0
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "project_detector"
4
+ require_relative "template_writer"
5
+ require_relative "maven_linter_detector"
6
+
7
+ module FunCi
8
+ module Setup
9
+ class Installer
10
+ def self.run(project_root:, stdout: $stdout, pom_reader: nil)
11
+ new(project_root: project_root, stdout: stdout, pom_reader: pom_reader).run
12
+ end
13
+
14
+ def initialize(project_root:, stdout:, pom_reader: nil)
15
+ @project_root = project_root
16
+ @stdout = stdout
17
+ @pom_reader = pom_reader || ->(path) { File.read(path) }
18
+ end
19
+
20
+ def run
21
+ if Dir.exist?(File.join(@project_root, ".fun-ci"))
22
+ @stdout.puts ".fun-ci/ already exists — skipping init."
23
+ return 0
24
+ end
25
+
26
+ filenames = Dir.children(@project_root)
27
+ detected = ProjectDetector.new(filenames).detect
28
+
29
+ if detected == :unknown
30
+ @stdout.puts "Could not detect project type. Create .fun-ci/ manually."
31
+ return 1
32
+ end
33
+
34
+ @stdout.puts "Detected: #{detected.to_s.tr("_", " ")}"
35
+
36
+ lint_override = detect_maven_linter(detected)
37
+ TemplateWriter.new(detected, @project_root, lint_override: lint_override).write
38
+
39
+ @stdout.puts "Created .fun-ci/ with template scripts."
40
+ 0
41
+ end
42
+
43
+ private
44
+
45
+ def detect_maven_linter(detected)
46
+ return nil unless detected == :jvm_maven
47
+
48
+ pom_path = File.join(@project_root, "pom.xml")
49
+ pom_content = @pom_reader.call(pom_path)
50
+ command = MavenLinterDetector.new(pom_content).lint_command
51
+ command == MavenLinterDetector::DEFAULT_COMMAND ? nil : command
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ class MavenLinterDetector
6
+ LINTERS = [
7
+ { artifact_id: "detekt-maven-plugin", command: "mvn detekt:check" },
8
+ { artifact_id: "ktlint-maven-plugin", command: "mvn ktlint:check" },
9
+ { artifact_id: "maven-checkstyle-plugin", command: "mvn checkstyle:check" },
10
+ { artifact_id: "spotbugs-maven-plugin", command: "mvn spotbugs:check" },
11
+ { artifact_id: "maven-pmd-plugin", command: "mvn pmd:check" }
12
+ ].freeze
13
+
14
+ DEFAULT_COMMAND = "mvn verify -DskipTests"
15
+
16
+ def initialize(pom_content)
17
+ @pom_content = pom_content
18
+ end
19
+
20
+ def lint_command
21
+ match = LINTERS.find { |linter| @pom_content.include?(linter[:artifact_id]) }
22
+ match ? match[:command] : DEFAULT_COMMAND
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ class ProjectConfig
6
+ REQUIRED_SCRIPTS = %w[lint.sh build.sh fast.sh slow.sh].freeze
7
+
8
+ def initialize(project_root)
9
+ @project_root = project_root
10
+ @fun_ci_dir = File.join(project_root, ".fun-ci")
11
+ end
12
+
13
+ def folder_exists?
14
+ Dir.exist?(@fun_ci_dir)
15
+ end
16
+
17
+ def validate
18
+ errors = []
19
+
20
+ unless folder_exists?
21
+ errors << "No .fun-ci/ folder found in #{@project_root}"
22
+ return errors
23
+ end
24
+
25
+ REQUIRED_SCRIPTS.each do |script|
26
+ path = File.join(@fun_ci_dir, script)
27
+ if !File.exist?(path)
28
+ errors << ".fun-ci/#{script} is not found"
29
+ elsif !File.executable?(path)
30
+ errors << ".fun-ci/#{script} is not executable"
31
+ end
32
+ end
33
+
34
+ errors
35
+ end
36
+
37
+ def script_path(stage)
38
+ File.join(@fun_ci_dir, "#{stage}.sh")
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ class ProjectDetector
6
+ def initialize(filenames)
7
+ @filenames = filenames
8
+ end
9
+
10
+ def detect
11
+ return :ruby_bundler if @filenames.include?("Gemfile")
12
+ return :jvm_gradle_kotlin if @filenames.include?("build.gradle.kts")
13
+ return :jvm_gradle_kotlin if @filenames.include?("settings.gradle.kts")
14
+ return :jvm_gradle_groovy if @filenames.include?("build.gradle")
15
+ return :jvm_gradle_groovy if @filenames.include?("settings.gradle")
16
+ return :jvm_maven if @filenames.include?("pom.xml")
17
+
18
+ :unknown
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "project_config"
4
+
5
+ module FunCi
6
+ module Setup
7
+ class SetupChecker
8
+ def self.run(project_root:, stdout: $stdout, stderr: $stderr)
9
+ new(project_root: project_root, stdout: stdout).run
10
+ end
11
+
12
+ def initialize(project_root:, stdout:)
13
+ @config = ProjectConfig.new(project_root)
14
+ @stdout = stdout
15
+ end
16
+
17
+ def run
18
+ errors = @config.validate
19
+
20
+ if errors.empty?
21
+ @stdout.puts "All OK — project is configured."
22
+ 0
23
+ else
24
+ errors.each { |e| @stdout.puts e }
25
+ 1
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ class TemplateWriter
6
+ TEMPLATES = {
7
+ ruby_bundler: {
8
+ "lint.sh" => "#!/bin/sh\nbundle exec rubocop\n",
9
+ "build.sh" => "#!/bin/sh\nbundle install --quiet\n",
10
+ "fast.sh" => "#!/bin/sh\nbundle exec rake test\n",
11
+ "slow.sh" => "#!/bin/sh\nbundle exec rake test:slow\n"
12
+ },
13
+ jvm_gradle_kotlin: {
14
+ "lint.sh" => "#!/bin/sh\n./gradlew check -x test\n",
15
+ "build.sh" => "#!/bin/sh\n./gradlew assemble\n",
16
+ "fast.sh" => "#!/bin/sh\n./gradlew test\n",
17
+ "slow.sh" => "#!/bin/sh\n./gradlew integrationTest\n"
18
+ },
19
+ jvm_gradle_groovy: {
20
+ "lint.sh" => "#!/bin/sh\n./gradlew check -x test\n",
21
+ "build.sh" => "#!/bin/sh\n./gradlew assemble\n",
22
+ "fast.sh" => "#!/bin/sh\n./gradlew test\n",
23
+ "slow.sh" => "#!/bin/sh\n./gradlew integrationTest\n"
24
+ },
25
+ jvm_maven: {
26
+ "lint.sh" => "#!/bin/sh\nmvn verify -DskipTests\n",
27
+ "build.sh" => "#!/bin/sh\nmvn compile\n",
28
+ "fast.sh" => "#!/bin/sh\nmvn test\n",
29
+ "slow.sh" => "#!/bin/sh\nmvn verify\n"
30
+ }
31
+ }.freeze
32
+
33
+ def initialize(template_id, target_dir, lint_override: nil)
34
+ @template_id = template_id
35
+ @target_dir = target_dir
36
+ @lint_override = lint_override
37
+ end
38
+
39
+ def write
40
+ scripts = TEMPLATES.fetch(@template_id)
41
+ fun_ci_dir = File.join(@target_dir, ".fun-ci")
42
+ Dir.mkdir(fun_ci_dir)
43
+
44
+ scripts.each do |name, content|
45
+ content = "#!/bin/sh\n#{@lint_override}\n" if name == "lint.sh" && @lint_override
46
+ path = File.join(fun_ci_dir, name)
47
+ File.write(path, content)
48
+ File.chmod(0o755, path)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "board_data"
4
+ require_relative "board_renderer"
5
+ require_relative "key_handler"
6
+ require_relative "screen"
7
+ require_relative "spinner"
8
+ require_relative "terminal_input"
9
+ require "io/console"
10
+
11
+ module FunCi
12
+ module Tui
13
+ class AdminTui
14
+ FAST_REFRESH = 0.1
15
+ SLOW_REFRESH = 5.0
16
+
17
+ def initialize(db:, output: $stdout, input: $stdin, width: 80,
18
+ width_provider: nil, height_provider: nil,
19
+ page_size: nil, animation_renderer: nil)
20
+ @board_data = BoardData.new(db, page_size: page_size)
21
+ @terminal_input = TerminalInput.new(input: input, width_provider: width_provider)
22
+ @key_handler = KeyHandler.new(board_data: @board_data)
23
+ @renderer = BoardRenderer.new(
24
+ screen: Screen.new(output: output, width: width),
25
+ spinner: Spinner.new,
26
+ animation_renderer: animation_renderer,
27
+ height_provider: height_provider
28
+ )
29
+ end
30
+
31
+ def render_once
32
+ update_width_from_provider
33
+ @renderer.render(
34
+ runs: @board_data.runs,
35
+ streak: @board_data.streak,
36
+ cursor_index: @key_handler.cursor_index,
37
+ confirming: @key_handler.confirming?
38
+ )
39
+ end
40
+
41
+ def run
42
+ @renderer.clear
43
+
44
+ begin
45
+ @terminal_input.setup_raw_mode
46
+ @terminal_input.setup_signal_trap
47
+ loop do
48
+ @renderer.begin_frame
49
+ render_once
50
+ key = @terminal_input.read_key_with_timeout(refresh_interval)
51
+ break if key && @key_handler.handle_key(key) == :quit
52
+ end
53
+ ensure
54
+ @terminal_input.restore_terminal
55
+ end
56
+ end
57
+
58
+ def resize(new_width)
59
+ @renderer.resize(new_width)
60
+ end
61
+
62
+ def confirming?
63
+ @key_handler.confirming?
64
+ end
65
+
66
+ def confirmation_run
67
+ @key_handler.confirmation_run
68
+ end
69
+
70
+ def handle_key(key)
71
+ @key_handler.handle_key(key)
72
+ end
73
+
74
+ private
75
+
76
+ def refresh_interval
77
+ return FAST_REFRESH if @renderer.animation_renderer?
78
+
79
+ runs = @board_data.runs
80
+ any_running = runs.any? { |r| r[:status] == "running" }
81
+ any_running ? FAST_REFRESH : SLOW_REFRESH
82
+ end
83
+
84
+ def update_width_from_provider
85
+ new_width = @terminal_input.check_width
86
+ @renderer.resize(new_width) if new_width
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Tui
5
+ class Animation
6
+ TYPES = {
7
+ failure: { total_frames: 40, priority: 3 },
8
+ timeout: { total_frames: 4, priority: 2 },
9
+ success: { total_frames: 40, priority: 1 },
10
+ stage_pass: { total_frames: 3, priority: 0 }
11
+ }.freeze
12
+
13
+ attr_reader :type, :run_id, :stage, :frame
14
+
15
+ def initialize(type:, run_id:, stage: nil)
16
+ raise ArgumentError, "Unknown animation type: #{type}" unless TYPES.key?(type)
17
+
18
+ @type = type
19
+ @run_id = run_id
20
+ @stage = stage
21
+ @frame = 0
22
+ end
23
+
24
+ def advance!
25
+ @frame += 1
26
+ end
27
+
28
+ def finished?
29
+ frame >= total_frames
30
+ end
31
+
32
+ def total_frames
33
+ TYPES[@type][:total_frames]
34
+ end
35
+
36
+ def priority
37
+ TYPES[@type][:priority]
38
+ end
39
+
40
+ def has_header?
41
+ %i[failure timeout success].include?(@type)
42
+ end
43
+
44
+ def has_footer?
45
+ %i[failure success].include?(@type)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ansi"
4
+ require_relative "animation_frames"
5
+ require_relative "row_formatter"
6
+ require_relative "duration_formatter"
7
+
8
+ module FunCi
9
+ module Tui
10
+ module AnimationCompositor
11
+ RESET = Ansi::RESET
12
+ STAGE_STAGGER = { "lint" => 0, "build" => 2, "fast" => 4, "slow" => 6 }.freeze
13
+
14
+ def self.header_overlay(animation, width)
15
+ frames = case animation.type
16
+ when :failure then AnimationFrames.failure_header(width)
17
+ when :success then AnimationFrames.success_header(width)
18
+ when :timeout then AnimationFrames.timeout_header(width)
19
+ else return nil
20
+ end
21
+ frame_at(frames, animation.frame)
22
+ end
23
+
24
+ def self.footer_overlay(animation, width)
25
+ frames = case animation.type
26
+ when :failure
27
+ AnimationFrames.failure_footer(animation.stage || "stage", width)
28
+ when :success then AnimationFrames.success_footer(width)
29
+ else return nil
30
+ end
31
+ frame_at(frames, animation.frame)
32
+ end
33
+
34
+ def self.stage_column_overlay(animation, stage_text, col)
35
+ case animation.type
36
+ when :stage_pass then color_flash(animation, stage_text, AnimationFrames.stage_pass_colors)
37
+ when :timeout then color_flash(animation, stage_text, AnimationFrames.timeout_colors)
38
+ when :failure then failure_flanks(animation, stage_text)
39
+ when :success then sparkle_sweep(animation, stage_text)
40
+ end
41
+ end
42
+
43
+ def self.stage_text_for(run, stage_name)
44
+ stage = run[:stages]&.find { |s| s[:stage] == stage_name }
45
+ return nil unless stage
46
+
47
+ name = RowFormatter::STAGE_NAMES[stage[:stage]] || stage[:stage]
48
+ dur = stage[:duration] ? DurationFormatter.format(stage[:duration]) : "--"
49
+ case stage[:status]
50
+ when "completed" then "#{name} #{dur}"
51
+ when "failed" then "#{name} FAIL #{dur}"
52
+ when "timed_out" then "#{name} TIMEOUT #{dur}"
53
+ else "#{name} --"
54
+ end
55
+ end
56
+
57
+ def self.stage_col_for(run, stage_name)
58
+ project = run[:project_path] ? " #{File.basename(run[:project_path])}" : ""
59
+ prefix_len = 2 + 7 + 2 + run[:branch].to_s.length + project.length + 2
60
+ (run[:stages] || []).each do |s|
61
+ return prefix_len + 1 if s[:stage] == stage_name
62
+ prefix_len += stage_text_for(run, s[:stage]).to_s.length + 2
63
+ end
64
+ nil
65
+ end
66
+
67
+ def self.color_flash(animation, stage_text, colors)
68
+ idx = [animation.frame, colors.length - 1].min
69
+ "#{colors[idx]}#{Ansi.strip(stage_text)}#{RESET}"
70
+ end
71
+ private_class_method :color_flash
72
+
73
+ def self.failure_flanks(animation, stage_text)
74
+ particles = AnimationFrames.failure_particles
75
+ return nil if animation.frame >= particles.length
76
+
77
+ p = particles[animation.frame]
78
+ plain = Ansi.strip(stage_text)
79
+ return plain if p[:chars].empty?
80
+
81
+ left = p[:chars].reverse
82
+ right = p[:chars]
83
+ "#{p[:color]}#{left}#{RESET} #{Ansi.bold_red(plain)} #{p[:color]}#{right}#{RESET}"
84
+ end
85
+ private_class_method :failure_flanks
86
+
87
+ def self.sparkle_sweep(animation, stage_text)
88
+ plain = Ansi.strip(stage_text)
89
+ stagger = STAGE_STAGGER[animation.stage] || 0
90
+ pos = animation.frame - stagger
91
+ return nil if pos < 0 || pos > plain.length
92
+
93
+ result = plain.chars.map { |c| "\e[32m#{c}" }
94
+ result[pos] = "\e[1;33m#{plain[pos]}" if pos < plain.length
95
+ "#{result.join}#{RESET}"
96
+ end
97
+ private_class_method :sparkle_sweep
98
+
99
+ def self.frame_at(frames, index)
100
+ return nil if index >= frames.length
101
+
102
+ frames[index]
103
+ end
104
+ private_class_method :frame_at
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ansi"
4
+
5
+ module FunCi
6
+ module Tui
7
+ module AnimationFrames
8
+ RESET = Ansi::RESET
9
+ FOOTER_HOLD = 8
10
+
11
+ def self.failure_particles
12
+ [
13
+ { chars: "*", color: "\e[1;31m" },
14
+ { chars: ".*", color: "\e[1;31m" },
15
+ { chars: ".+*.", color: "\e[38;5;208m" },
16
+ { chars: "*.+'", color: "\e[38;5;208m" },
17
+ { chars: "' .", color: "\e[38;5;52m" },
18
+ { chars: ".", color: "\e[38;5;52m" },
19
+ { chars: "", color: "" }
20
+ ]
21
+ end
22
+
23
+ def self.failure_header(width)
24
+ boom = "BOOM"
25
+ [
26
+ nil,
27
+ banner(boom, width, "\e[48;5;124m", particles: "* "),
28
+ banner(boom, width, "\e[48;5;166m", particles: "* * "),
29
+ banner(boom, width, "\e[48;5;52m", particles: ". * * "),
30
+ banner(boom, width, "\e[48;5;236m", particles: "' . * "),
31
+ banner(boom, width, "\e[48;5;236m", particles: ". "),
32
+ nil
33
+ ]
34
+ end
35
+
36
+ def self.failure_footer(stage_name, width)
37
+ up = stage_name.upcase
38
+ down = stage_name.downcase
39
+ hold [
40
+ nil,
41
+ center("\e[1;31m>>> #{up} FAILED <<<#{RESET}", ">>> #{up} FAILED <<<", width),
42
+ center("\e[31m>> #{down} failed <<#{RESET}", ">> #{down} failed <<", width),
43
+ center("\e[2;31m> #{down} failed <#{RESET}", "> #{down} failed <", width),
44
+ nil
45
+ ]
46
+ end
47
+
48
+ def self.success_header(width)
49
+ text = "ALL PASSED"
50
+ [
51
+ nil,
52
+ banner(text, width, "\e[48;5;22m", particles: ""),
53
+ banner(text, width, "\e[48;5;22m", particles: "* * "),
54
+ banner(text, width, "\e[48;5;22m", particles: "* . . "),
55
+ banner(text, width, "\e[48;5;58m", particles: ". ' ' "),
56
+ banner(text, width, "\e[48;5;22m", particles: "' "),
57
+ banner(text, width, "\e[48;5;22m", particles: ""),
58
+ nil
59
+ ]
60
+ end
61
+
62
+ def self.success_footer(width)
63
+ hold [
64
+ nil,
65
+ center("\e[1;32m* * * NICE! * * *#{RESET}", "* * * NICE! * * *", width),
66
+ center("\e[32m. + . * NICE! * . + .#{RESET}", ". + . * NICE! * . + .", width),
67
+ center("\e[2;32m' . + . nice . + . '#{RESET}", "' . + . nice . + . '", width),
68
+ nil
69
+ ]
70
+ end
71
+
72
+ def self.timeout_header(width)
73
+ [
74
+ nil,
75
+ banner("TIMED OUT", width, "\e[48;5;58m", particles: ""),
76
+ banner("timed out", width, "\e[48;5;58m", particles: ""),
77
+ nil
78
+ ]
79
+ end
80
+
81
+ def self.stage_pass_colors
82
+ ["\e[1;33m", "\e[1;32m", "\e[32m"]
83
+ end
84
+
85
+ def self.timeout_colors
86
+ ["\e[1;33m", "\e[33m", "\e[1;33m", "\e[33m"]
87
+ end
88
+
89
+ def self.banner(text, width, bg_code, particles: "")
90
+ padded = if particles.empty?
91
+ text.center(width)
92
+ else
93
+ inner = "#{particles} #{text} #{particles.reverse}"
94
+ inner.center(width)
95
+ end
96
+ "#{bg_code}\e[1;37m#{padded}#{RESET}"
97
+ end
98
+ private_class_method :banner
99
+
100
+ def self.hold(frames)
101
+ frames.flat_map { |f| Array.new(FOOTER_HOLD, f) }
102
+ end
103
+ private_class_method :hold
104
+
105
+ def self.center(colored_text, plain_text, width)
106
+ pad = [(width - plain_text.length) / 2, 0].max
107
+ "#{" " * pad}#{colored_text}"
108
+ end
109
+ private_class_method :center
110
+ end
111
+ end
112
+ end