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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bacc0733c3648d5b5422304a7c319be52f1531e98f1c802e4a3d2a5280928ba9
4
- data.tar.gz: '097d7eaea4e663259c0b745724e6a4d281af1aa60b0ebb3bd78a14c8add5bb57'
3
+ metadata.gz: 432ab6458948397326b4ee6e80846066d59d3548e54a3a3d189f42740562e613
4
+ data.tar.gz: 8da12eaeb1c172b64825a2d9c214c56143e47a9d82ba7b5054091662fa8edce1
5
5
  SHA512:
6
- metadata.gz: feb14f7ff5ba1b67c66b3aa9cddeb9a160320aab302abf9aa60f3cb58b88395e0493abd50dd44db2ddd4030680d0a3c77e99f50cd7408309fe7e3cec9f692163
7
- data.tar.gz: 8c3cf91d027932fbb998bb3f341cfced2752de43d926a10310f1900ce672de041f75b830379c665b3286daf93bb0c0b0364af47ba1dd116af8c1cc393481ea85
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. Navigation:
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(db: db, width_provider: -> { IO.console&.winsize&.dig(1) || 80 })
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
@@ -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, width_provider: nil, page_size: nil)
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
- @output = output
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