fun_ci 1.0.0 → 1.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +3 -1
- data/exe/fun-ci-tui +6 -1
- data/lib/fun_ci/admin_tui.rb +15 -3
- data/lib/fun_ci/animation.rb +47 -0
- data/lib/fun_ci/animation_compositor.rb +105 -0
- data/lib/fun_ci/animation_frames.rb +111 -0
- data/lib/fun_ci/animation_library.rb +44 -0
- data/lib/fun_ci/animation_renderer.rb +142 -0
- data/lib/fun_ci/animations/celebrate.rb +210 -0
- data/lib/fun_ci/animations/explosion.rb +138 -0
- data/lib/fun_ci/animations/flash.rb +205 -0
- data/lib/fun_ci/animations/idle.rb +127 -0
- data/lib/fun_ci/animations/leprechauns.rb +240 -0
- data/lib/fun_ci/animations/running.rb +129 -0
- data/lib/fun_ci/animations/success.rb +281 -0
- data/lib/fun_ci/animations/yay.rb +169 -0
- data/lib/fun_ci/ansi.rb +11 -0
- data/lib/fun_ci/cli.rb +6 -1
- data/lib/fun_ci/header_animation_manager.rb +69 -0
- data/lib/fun_ci/header_animation_player.rb +43 -0
- data/lib/fun_ci/looping_animation_player.rb +43 -0
- data/lib/fun_ci/screen.rb +14 -0
- data/lib/fun_ci/stage_change_detector.rb +56 -0
- data/lib/fun_ci.rb +1 -1
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 432ab6458948397326b4ee6e80846066d59d3548e54a3a3d189f42740562e613
|
|
4
|
+
data.tar.gz: 8da12eaeb1c172b64825a2d9c214c56143e47a9d82ba7b5054091662fa8edce1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1de07fd9136f7648663a57832d31197435343c6c599c8b77c17e06a72b94a7d4a021205810944e0f3ccae4257bc440138bf2ffdc775f569dd7bc323c532c23e
|
|
7
|
+
data.tar.gz: 1a92c980274f3ff0040429ae5efc4b124f1c8b6706d49a2993d1d27f03fc9211d1b8475c81a7a30a8d37cb5b611fbb46d8dd5a90bbcac2a4672b0fbc600eaee3
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.0] - 2026-02-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Multi-line ASCII art header animations for pipeline events (explosion on failure, celebrations on success)
|
|
12
|
+
- Looping idle starfield animation in the TUI header
|
|
13
|
+
- Rocket animation while pipelines are running, with flickering exhaust and parallax starfield
|
|
14
|
+
- HeaderAnimationManager with idle/running/event state transitions
|
|
15
|
+
- AnimationLibrary with 8 animation data files (explosion, success, celebrate, flash, leprechauns, yay, idle, running)
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Footer animations now display 8x slower so text is actually readable
|
|
19
|
+
- Animation refresh no longer blocks for 5 seconds between frames when no pipelines are running
|
|
20
|
+
- Removed stale single-line header banner that conflicted with multi-line animations
|
|
21
|
+
- Animation frames padded to global max width across all frames, preventing horizontal jitter
|
|
22
|
+
|
|
8
23
|
## [1.0.0] - 2026-02-21
|
|
9
24
|
|
|
10
25
|
### Added
|
data/README.md
CHANGED
|
@@ -74,7 +74,9 @@ After `fun-ci install-hooks`, two hooks are active:
|
|
|
74
74
|
fun-ci console
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
Opens a terminal UI showing pipeline status across all branches.
|
|
77
|
+
Opens a terminal UI showing pipeline status across all branches. The header area displays animated ASCII art: a gentle starfield when idle, a rocket while pipelines run, and celebration/explosion animations on success or failure.
|
|
78
|
+
|
|
79
|
+
Navigation:
|
|
78
80
|
|
|
79
81
|
- `j` / `k` -- scroll up/down
|
|
80
82
|
- `c` -- cancel a running pipeline
|
data/exe/fun-ci-tui
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require_relative "../lib/fun_ci"
|
|
5
5
|
require_relative "../lib/fun_ci/admin_tui"
|
|
6
|
+
require_relative "../lib/fun_ci/animation_renderer"
|
|
6
7
|
|
|
7
8
|
require "tmpdir"
|
|
8
9
|
require "io/console"
|
|
@@ -14,5 +15,9 @@ db_path = File.join(db_dir, "db.sqlite3")
|
|
|
14
15
|
db = FunCi::Database.connection(db_path)
|
|
15
16
|
FunCi::Database.migrate!(db)
|
|
16
17
|
|
|
17
|
-
tui = FunCi::AdminTui.new(
|
|
18
|
+
tui = FunCi::AdminTui.new(
|
|
19
|
+
db: db,
|
|
20
|
+
width_provider: -> { IO.console&.winsize&.dig(1) || 80 },
|
|
21
|
+
animation_renderer: FunCi::AnimationRenderer.new
|
|
22
|
+
)
|
|
18
23
|
tui.run
|
data/lib/fun_ci/admin_tui.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "board_data"
|
|
4
|
+
require_relative "header_animation_manager"
|
|
4
5
|
require_relative "row_formatter"
|
|
5
6
|
require_relative "screen"
|
|
6
7
|
require_relative "spinner"
|
|
@@ -11,11 +12,13 @@ module FunCi
|
|
|
11
12
|
class AdminTui
|
|
12
13
|
FAST_REFRESH = 0.1 # seconds (spinner + timer)
|
|
13
14
|
SLOW_REFRESH = 5.0 # seconds (settled board)
|
|
15
|
+
HEADER_HEIGHT = HeaderAnimationManager::HEADER_HEIGHT
|
|
14
16
|
|
|
15
|
-
def initialize(db:, output: $stdout, input: $stdin, width: 80,
|
|
17
|
+
def initialize(db:, output: $stdout, input: $stdin, width: 80,
|
|
18
|
+
width_provider: nil, page_size: nil, animation_renderer: nil)
|
|
16
19
|
@board_data = BoardData.new(db, page_size: page_size)
|
|
17
20
|
@screen = Screen.new(output: output, width: width)
|
|
18
|
-
@
|
|
21
|
+
@animation_renderer = animation_renderer
|
|
19
22
|
@input = input
|
|
20
23
|
@width_provider = width_provider
|
|
21
24
|
@spinner = Spinner.new
|
|
@@ -31,7 +34,7 @@ module FunCi
|
|
|
31
34
|
streak_text = StreakCounter.format_text(streak)
|
|
32
35
|
|
|
33
36
|
@screen.render_header(streak_text: streak_text)
|
|
34
|
-
@screen.println
|
|
37
|
+
(HEADER_HEIGHT - 1).times { @screen.println }
|
|
35
38
|
|
|
36
39
|
if runs.empty?
|
|
37
40
|
@screen.render_empty_state
|
|
@@ -44,6 +47,7 @@ module FunCi
|
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
@screen.clear_below
|
|
50
|
+
render_animations(runs)
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
def run
|
|
@@ -96,6 +100,12 @@ module FunCi
|
|
|
96
100
|
|
|
97
101
|
private
|
|
98
102
|
|
|
103
|
+
def render_animations(runs)
|
|
104
|
+
return unless @animation_renderer
|
|
105
|
+
|
|
106
|
+
@animation_renderer.render(@screen, runs)
|
|
107
|
+
end
|
|
108
|
+
|
|
99
109
|
def format_run(run)
|
|
100
110
|
opts = {}
|
|
101
111
|
if run[:status] == "running"
|
|
@@ -115,6 +125,8 @@ module FunCi
|
|
|
115
125
|
end
|
|
116
126
|
|
|
117
127
|
def refresh_interval
|
|
128
|
+
return FAST_REFRESH if @animation_renderer
|
|
129
|
+
|
|
118
130
|
runs = @board_data.runs
|
|
119
131
|
any_running = runs.any? { |r| r[:status] == "running" }
|
|
120
132
|
any_running ? FAST_REFRESH : SLOW_REFRESH
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
class Animation
|
|
5
|
+
TYPES = {
|
|
6
|
+
failure: { total_frames: 40, priority: 3 },
|
|
7
|
+
timeout: { total_frames: 4, priority: 2 },
|
|
8
|
+
success: { total_frames: 40, priority: 1 },
|
|
9
|
+
stage_pass: { total_frames: 3, priority: 0 }
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :type, :run_id, :stage, :frame
|
|
13
|
+
|
|
14
|
+
def initialize(type:, run_id:, stage: nil)
|
|
15
|
+
raise ArgumentError, "Unknown animation type: #{type}" unless TYPES.key?(type)
|
|
16
|
+
|
|
17
|
+
@type = type
|
|
18
|
+
@run_id = run_id
|
|
19
|
+
@stage = stage
|
|
20
|
+
@frame = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def advance!
|
|
24
|
+
@frame += 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def finished?
|
|
28
|
+
frame >= total_frames
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def total_frames
|
|
32
|
+
TYPES[@type][:total_frames]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def priority
|
|
36
|
+
TYPES[@type][:priority]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def has_header?
|
|
40
|
+
%i[failure timeout success].include?(@type)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def has_footer?
|
|
44
|
+
%i[failure success].include?(@type)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
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 AnimationCompositor
|
|
10
|
+
RESET = Ansi::RESET
|
|
11
|
+
STAGE_STAGGER = { "lint" => 0, "build" => 2, "fast" => 4, "slow" => 6 }.freeze
|
|
12
|
+
|
|
13
|
+
def self.header_overlay(animation, width)
|
|
14
|
+
frames = case animation.type
|
|
15
|
+
when :failure then AnimationFrames.failure_header(width)
|
|
16
|
+
when :success then AnimationFrames.success_header(width)
|
|
17
|
+
when :timeout then AnimationFrames.timeout_header(width)
|
|
18
|
+
else return nil
|
|
19
|
+
end
|
|
20
|
+
frame_at(frames, animation.frame)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.footer_overlay(animation, width)
|
|
24
|
+
frames = case animation.type
|
|
25
|
+
when :failure
|
|
26
|
+
AnimationFrames.failure_footer(animation.stage || "stage", width)
|
|
27
|
+
when :success then AnimationFrames.success_footer(width)
|
|
28
|
+
else return nil
|
|
29
|
+
end
|
|
30
|
+
frame_at(frames, animation.frame)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.stage_column_overlay(animation, stage_text, col)
|
|
34
|
+
case animation.type
|
|
35
|
+
when :stage_pass then color_flash(animation, stage_text, AnimationFrames.stage_pass_colors)
|
|
36
|
+
when :timeout then color_flash(animation, stage_text, AnimationFrames.timeout_colors)
|
|
37
|
+
when :failure then failure_flanks(animation, stage_text)
|
|
38
|
+
when :success then sparkle_sweep(animation, stage_text)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.stage_text_for(run, stage_name)
|
|
43
|
+
stage = run[:stages]&.find { |s| s[:stage] == stage_name }
|
|
44
|
+
return nil unless stage
|
|
45
|
+
|
|
46
|
+
name = RowFormatter::STAGE_NAMES[stage[:stage]] || stage[:stage]
|
|
47
|
+
dur = stage[:duration] ? DurationFormatter.format(stage[:duration]) : "--"
|
|
48
|
+
case stage[:status]
|
|
49
|
+
when "completed" then "#{name} #{dur}"
|
|
50
|
+
when "failed" then "#{name} FAIL #{dur}"
|
|
51
|
+
when "timed_out" then "#{name} TIMEOUT #{dur}"
|
|
52
|
+
else "#{name} --"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.stage_col_for(run, stage_name)
|
|
57
|
+
project = run[:project_path] ? " #{File.basename(run[:project_path])}" : ""
|
|
58
|
+
prefix_len = 2 + 7 + 2 + run[:branch].to_s.length + project.length + 2
|
|
59
|
+
(run[:stages] || []).each do |s|
|
|
60
|
+
return prefix_len + 1 if s[:stage] == stage_name
|
|
61
|
+
prefix_len += stage_text_for(run, s[:stage]).to_s.length + 2
|
|
62
|
+
end
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.color_flash(animation, stage_text, colors)
|
|
67
|
+
idx = [animation.frame, colors.length - 1].min
|
|
68
|
+
"#{colors[idx]}#{Ansi.strip(stage_text)}#{RESET}"
|
|
69
|
+
end
|
|
70
|
+
private_class_method :color_flash
|
|
71
|
+
|
|
72
|
+
def self.failure_flanks(animation, stage_text)
|
|
73
|
+
particles = AnimationFrames.failure_particles
|
|
74
|
+
return nil if animation.frame >= particles.length
|
|
75
|
+
|
|
76
|
+
p = particles[animation.frame]
|
|
77
|
+
plain = Ansi.strip(stage_text)
|
|
78
|
+
return plain if p[:chars].empty?
|
|
79
|
+
|
|
80
|
+
left = p[:chars].reverse
|
|
81
|
+
right = p[:chars]
|
|
82
|
+
"#{p[:color]}#{left}#{RESET} #{Ansi.bold_red(plain)} #{p[:color]}#{right}#{RESET}"
|
|
83
|
+
end
|
|
84
|
+
private_class_method :failure_flanks
|
|
85
|
+
|
|
86
|
+
def self.sparkle_sweep(animation, stage_text)
|
|
87
|
+
plain = Ansi.strip(stage_text)
|
|
88
|
+
stagger = STAGE_STAGGER[animation.stage] || 0
|
|
89
|
+
pos = animation.frame - stagger
|
|
90
|
+
return nil if pos < 0 || pos > plain.length
|
|
91
|
+
|
|
92
|
+
result = plain.chars.map { |c| "\e[32m#{c}" }
|
|
93
|
+
result[pos] = "\e[1;33m#{plain[pos]}" if pos < plain.length
|
|
94
|
+
"#{result.join}#{RESET}"
|
|
95
|
+
end
|
|
96
|
+
private_class_method :sparkle_sweep
|
|
97
|
+
|
|
98
|
+
def self.frame_at(frames, index)
|
|
99
|
+
return nil if index >= frames.length
|
|
100
|
+
|
|
101
|
+
frames[index]
|
|
102
|
+
end
|
|
103
|
+
private_class_method :frame_at
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ansi"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module AnimationFrames
|
|
7
|
+
RESET = Ansi::RESET
|
|
8
|
+
FOOTER_HOLD = 8
|
|
9
|
+
|
|
10
|
+
def self.failure_particles
|
|
11
|
+
# Left/right flanking debris per frame (chars expanding outward)
|
|
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
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "animations/explosion"
|
|
4
|
+
require_relative "animations/success"
|
|
5
|
+
require_relative "animations/celebrate"
|
|
6
|
+
require_relative "animations/flash"
|
|
7
|
+
require_relative "animations/leprechauns"
|
|
8
|
+
require_relative "animations/yay"
|
|
9
|
+
require_relative "animations/idle"
|
|
10
|
+
require_relative "animations/running"
|
|
11
|
+
|
|
12
|
+
module FunCi
|
|
13
|
+
module AnimationLibrary
|
|
14
|
+
FAILURE = [Animations::Explosion::DATA].freeze
|
|
15
|
+
|
|
16
|
+
SUCCESS = [
|
|
17
|
+
Animations::Success::DATA,
|
|
18
|
+
Animations::Celebrate::DATA,
|
|
19
|
+
Animations::Flash::DATA,
|
|
20
|
+
Animations::Leprechauns::DATA,
|
|
21
|
+
Animations::Yay::DATA
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
IDLE = Animations::Idle::DATA
|
|
25
|
+
|
|
26
|
+
RUNNING = Animations::Running::DATA
|
|
27
|
+
|
|
28
|
+
def self.random_failure
|
|
29
|
+
FAILURE.sample
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.random_success
|
|
33
|
+
SUCCESS.sample
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.idle
|
|
37
|
+
IDLE
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.running
|
|
41
|
+
RUNNING
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "animation"
|
|
4
|
+
require_relative "animation_compositor"
|
|
5
|
+
require_relative "animation_library"
|
|
6
|
+
require_relative "header_animation_manager"
|
|
7
|
+
require_relative "stage_change_detector"
|
|
8
|
+
|
|
9
|
+
module FunCi
|
|
10
|
+
class AnimationRenderer
|
|
11
|
+
HEADER_HEIGHT = HeaderAnimationManager::HEADER_HEIGHT
|
|
12
|
+
|
|
13
|
+
def initialize(animation_library: AnimationLibrary)
|
|
14
|
+
@previous_runs = []
|
|
15
|
+
@animations = []
|
|
16
|
+
@header_manager = HeaderAnimationManager.new(animation_library: animation_library)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render(screen, runs)
|
|
20
|
+
detect_and_queue(runs)
|
|
21
|
+
|
|
22
|
+
screen.save_cursor
|
|
23
|
+
render_header_overlay(screen)
|
|
24
|
+
render_stage_overlays(screen, runs)
|
|
25
|
+
render_footer_overlay(screen, runs)
|
|
26
|
+
screen.restore_cursor
|
|
27
|
+
|
|
28
|
+
advance_all
|
|
29
|
+
expire_finished
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def any_active?
|
|
33
|
+
@animations.any? { |a| !a.finished? } || @header_manager.any_active_event?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def active_count
|
|
37
|
+
@animations.count { |a| !a.finished? }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def detect_and_queue(runs)
|
|
43
|
+
changes = StageChangeDetector.detect(@previous_runs, runs)
|
|
44
|
+
@previous_runs = snapshot(runs)
|
|
45
|
+
changes.each { |change| queue_animation(change, runs) }
|
|
46
|
+
update_running_state(runs)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def update_running_state(runs)
|
|
50
|
+
any_running = runs.any? { |r| r[:status] == "running" }
|
|
51
|
+
any_running ? @header_manager.start_running : @header_manager.stop_running
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def queue_animation(change, runs)
|
|
55
|
+
run_status = runs.find { |r| r[:id] == change.run_id }&.dig(:status)
|
|
56
|
+
|
|
57
|
+
case change.to
|
|
58
|
+
when "failed"
|
|
59
|
+
add_animation(:failure, change.run_id, change.stage)
|
|
60
|
+
@header_manager.trigger_failure
|
|
61
|
+
when "timed_out"
|
|
62
|
+
add_animation(:timeout, change.run_id, change.stage)
|
|
63
|
+
when "completed"
|
|
64
|
+
if run_status == "completed"
|
|
65
|
+
add_animation(:success, change.run_id, change.stage)
|
|
66
|
+
@header_manager.trigger_success
|
|
67
|
+
else
|
|
68
|
+
add_animation(:stage_pass, change.run_id, change.stage)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def add_animation(type, run_id, stage)
|
|
74
|
+
@animations.reject! do |a|
|
|
75
|
+
a.type == type && a.run_id == run_id && a.stage == stage
|
|
76
|
+
end
|
|
77
|
+
@animations << Animation.new(type: type, run_id: run_id, stage: stage)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def render_header_overlay(screen)
|
|
81
|
+
@header_manager.current_lines(screen.width).each_with_index do |line, i|
|
|
82
|
+
screen.write_at(1 + i, 1, line)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render_footer_overlay(screen, runs)
|
|
87
|
+
anim = highest_priority(:has_footer?)
|
|
88
|
+
return unless anim
|
|
89
|
+
|
|
90
|
+
footer_row = HEADER_HEIGHT + (runs.length * 2) + 1
|
|
91
|
+
overlay = AnimationCompositor.footer_overlay(anim, screen.width)
|
|
92
|
+
screen.write_at(footer_row, 1, "#{overlay}\e[K") if overlay
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def render_stage_overlays(screen, runs)
|
|
96
|
+
@animations.each do |anim|
|
|
97
|
+
next if anim.finished?
|
|
98
|
+
|
|
99
|
+
run_index = runs.index { |r| r[:id] == anim.run_id }
|
|
100
|
+
next unless run_index
|
|
101
|
+
|
|
102
|
+
row = HEADER_HEIGHT + 1 + (run_index * 2)
|
|
103
|
+
run = runs[run_index]
|
|
104
|
+
text = AnimationCompositor.stage_text_for(run, anim.stage)
|
|
105
|
+
next unless text
|
|
106
|
+
|
|
107
|
+
overlay = AnimationCompositor.stage_column_overlay(anim, text, 0)
|
|
108
|
+
next unless overlay
|
|
109
|
+
|
|
110
|
+
col = AnimationCompositor.stage_col_for(run, anim.stage)
|
|
111
|
+
screen.write_at(row, col, overlay) if col
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def highest_priority(predicate)
|
|
116
|
+
@animations
|
|
117
|
+
.select { |a| a.send(predicate) && !a.finished? }
|
|
118
|
+
.max_by(&:priority)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def advance_all
|
|
122
|
+
@animations.each { |a| a.advance! unless a.finished? }
|
|
123
|
+
@header_manager.advance!
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def expire_finished
|
|
127
|
+
@animations.reject!(&:finished?)
|
|
128
|
+
@header_manager.expire_if_finished!
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def snapshot(runs)
|
|
132
|
+
runs.map do |r|
|
|
133
|
+
{
|
|
134
|
+
id: r[:id],
|
|
135
|
+
stages: (r[:stages] || []).map do |s|
|
|
136
|
+
{ stage: s[:stage], status: s[:status] }
|
|
137
|
+
end
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|