fun_ci 1.1.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 +19 -0
  3. data/exe/fun-ci-trigger +6 -6
  4. data/exe/fun-ci-tui +6 -6
  5. data/lib/fun_ci/cli.rb +17 -16
  6. data/lib/fun_ci/persistence/database.rb +52 -0
  7. data/lib/fun_ci/persistence/pipeline_recorder.rb +74 -0
  8. data/lib/fun_ci/persistence/pipeline_run.rb +59 -0
  9. data/lib/fun_ci/persistence/stage_job.rb +46 -0
  10. data/lib/fun_ci/{state_machine.rb → persistence/state_machine.rb} +3 -12
  11. data/lib/fun_ci/pipeline/background_wrapper.rb +27 -0
  12. data/lib/fun_ci/pipeline/pipeline_forker.rb +38 -0
  13. data/lib/fun_ci/pipeline/process_runner.rb +28 -0
  14. data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
  15. data/lib/fun_ci/pipeline/stage_runner.rb +85 -0
  16. data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +53 -0
  17. data/lib/fun_ci/pipeline/trigger.rb +153 -0
  18. data/lib/fun_ci/setup/hook_writer.rb +75 -0
  19. data/lib/fun_ci/setup/installer.rb +55 -0
  20. data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
  21. data/lib/fun_ci/setup/project_config.rb +42 -0
  22. data/lib/fun_ci/setup/project_detector.rb +22 -0
  23. data/lib/fun_ci/setup/setup_checker.rb +30 -0
  24. data/lib/fun_ci/setup/template_writer.rb +53 -0
  25. data/lib/fun_ci/tui/admin_tui.rb +90 -0
  26. data/lib/fun_ci/tui/animation.rb +49 -0
  27. data/lib/fun_ci/tui/animation_compositor.rb +107 -0
  28. data/lib/fun_ci/tui/animation_frames.rb +112 -0
  29. data/lib/fun_ci/tui/animation_library.rb +46 -0
  30. data/lib/fun_ci/tui/animation_renderer.rb +144 -0
  31. data/lib/fun_ci/tui/ansi.rb +34 -0
  32. data/lib/fun_ci/tui/board_data.rb +53 -0
  33. data/lib/fun_ci/tui/board_renderer.rb +105 -0
  34. data/lib/fun_ci/tui/duration_formatter.rb +24 -0
  35. data/lib/fun_ci/tui/header_animation_manager.rb +71 -0
  36. data/lib/fun_ci/tui/header_animation_player.rb +45 -0
  37. data/lib/fun_ci/tui/key_handler.rb +86 -0
  38. data/lib/fun_ci/tui/looping_animation_player.rb +45 -0
  39. data/lib/fun_ci/tui/relative_time.rb +22 -0
  40. data/lib/fun_ci/tui/row_formatter.rb +108 -0
  41. data/lib/fun_ci/tui/screen.rb +103 -0
  42. data/lib/fun_ci/tui/spinner.rb +24 -0
  43. data/lib/fun_ci/tui/stage_change_detector.rb +58 -0
  44. data/lib/fun_ci/tui/streak_counter.rb +29 -0
  45. data/lib/fun_ci/tui/terminal_input.rb +69 -0
  46. data/lib/fun_ci.rb +6 -6
  47. metadata +41 -37
  48. data/lib/fun_ci/admin_tui.rb +0 -238
  49. data/lib/fun_ci/animation.rb +0 -47
  50. data/lib/fun_ci/animation_compositor.rb +0 -105
  51. data/lib/fun_ci/animation_frames.rb +0 -111
  52. data/lib/fun_ci/animation_library.rb +0 -44
  53. data/lib/fun_ci/animation_renderer.rb +0 -142
  54. data/lib/fun_ci/ansi.rb +0 -32
  55. data/lib/fun_ci/background_wrapper.rb +0 -27
  56. data/lib/fun_ci/board_data.rb +0 -51
  57. data/lib/fun_ci/database.rb +0 -50
  58. data/lib/fun_ci/duration_formatter.rb +0 -23
  59. data/lib/fun_ci/header_animation_manager.rb +0 -69
  60. data/lib/fun_ci/header_animation_player.rb +0 -43
  61. data/lib/fun_ci/hook_writer.rb +0 -73
  62. data/lib/fun_ci/installer.rb +0 -53
  63. data/lib/fun_ci/looping_animation_player.rb +0 -43
  64. data/lib/fun_ci/maven_linter_detector.rb +0 -24
  65. data/lib/fun_ci/pipeline_forker.rb +0 -36
  66. data/lib/fun_ci/pipeline_recorder.rb +0 -72
  67. data/lib/fun_ci/pipeline_run.rb +0 -57
  68. data/lib/fun_ci/progress_reporter.rb +0 -29
  69. data/lib/fun_ci/project_config.rb +0 -40
  70. data/lib/fun_ci/project_detector.rb +0 -18
  71. data/lib/fun_ci/relative_time.rb +0 -20
  72. data/lib/fun_ci/row_formatter.rb +0 -106
  73. data/lib/fun_ci/screen.rb +0 -95
  74. data/lib/fun_ci/setup_checker.rb +0 -28
  75. data/lib/fun_ci/spinner.rb +0 -22
  76. data/lib/fun_ci/stage_change_detector.rb +0 -56
  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,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
@@ -0,0 +1,46 @@
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 Tui
14
+ module AnimationLibrary
15
+ FAILURE = [Animations::Explosion::DATA].freeze
16
+
17
+ SUCCESS = [
18
+ Animations::Success::DATA,
19
+ Animations::Celebrate::DATA,
20
+ Animations::Flash::DATA,
21
+ Animations::Leprechauns::DATA,
22
+ Animations::Yay::DATA
23
+ ].freeze
24
+
25
+ IDLE = Animations::Idle::DATA
26
+
27
+ RUNNING = Animations::Running::DATA
28
+
29
+ def self.random_failure
30
+ FAILURE.sample
31
+ end
32
+
33
+ def self.random_success
34
+ SUCCESS.sample
35
+ end
36
+
37
+ def self.idle
38
+ IDLE
39
+ end
40
+
41
+ def self.running
42
+ RUNNING
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,144 @@
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
+ module Tui
11
+ class AnimationRenderer
12
+ HEADER_HEIGHT = HeaderAnimationManager::HEADER_HEIGHT
13
+
14
+ def initialize(animation_library: AnimationLibrary)
15
+ @previous_runs = []
16
+ @animations = []
17
+ @header_manager = HeaderAnimationManager.new(animation_library: animation_library)
18
+ end
19
+
20
+ def render(screen, runs)
21
+ detect_and_queue(runs)
22
+
23
+ screen.save_cursor
24
+ render_header_overlay(screen)
25
+ render_stage_overlays(screen, runs)
26
+ render_footer_overlay(screen, runs)
27
+ screen.restore_cursor
28
+
29
+ advance_all
30
+ expire_finished
31
+ end
32
+
33
+ def any_active?
34
+ @animations.any? { |a| !a.finished? } || @header_manager.any_active_event?
35
+ end
36
+
37
+ def active_count
38
+ @animations.count { |a| !a.finished? }
39
+ end
40
+
41
+ private
42
+
43
+ def detect_and_queue(runs)
44
+ changes = StageChangeDetector.detect(@previous_runs, runs)
45
+ @previous_runs = snapshot(runs)
46
+ changes.each { |change| queue_animation(change, runs) }
47
+ update_running_state(runs)
48
+ end
49
+
50
+ def update_running_state(runs)
51
+ any_running = runs.any? { |r| r[:status] == "running" }
52
+ any_running ? @header_manager.start_running : @header_manager.stop_running
53
+ end
54
+
55
+ def queue_animation(change, runs)
56
+ run_status = runs.find { |r| r[:id] == change.run_id }&.dig(:status)
57
+
58
+ case change.to
59
+ when "failed"
60
+ add_animation(:failure, change.run_id, change.stage)
61
+ @header_manager.trigger_failure
62
+ when "timed_out"
63
+ add_animation(:timeout, change.run_id, change.stage)
64
+ when "completed"
65
+ if run_status == "completed"
66
+ add_animation(:success, change.run_id, change.stage)
67
+ @header_manager.trigger_success
68
+ else
69
+ add_animation(:stage_pass, change.run_id, change.stage)
70
+ end
71
+ end
72
+ end
73
+
74
+ def add_animation(type, run_id, stage)
75
+ @animations.reject! do |a|
76
+ a.type == type && a.run_id == run_id && a.stage == stage
77
+ end
78
+ @animations << Animation.new(type: type, run_id: run_id, stage: stage)
79
+ end
80
+
81
+ def render_header_overlay(screen)
82
+ @header_manager.current_lines(screen.width).each_with_index do |line, i|
83
+ screen.write_at(1 + i, 1, line)
84
+ end
85
+ end
86
+
87
+ def render_footer_overlay(screen, runs)
88
+ anim = highest_priority(:has_footer?)
89
+ return unless anim
90
+
91
+ footer_row = HEADER_HEIGHT + (runs.length * 2) + 1
92
+ overlay = AnimationCompositor.footer_overlay(anim, screen.width)
93
+ screen.write_at(footer_row, 1, "#{overlay}\e[K") if overlay
94
+ end
95
+
96
+ def render_stage_overlays(screen, runs)
97
+ @animations.each do |anim|
98
+ next if anim.finished?
99
+
100
+ run_index = runs.index { |r| r[:id] == anim.run_id }
101
+ next unless run_index
102
+
103
+ row = HEADER_HEIGHT + 1 + (run_index * 2)
104
+ run = runs[run_index]
105
+ text = AnimationCompositor.stage_text_for(run, anim.stage)
106
+ next unless text
107
+
108
+ overlay = AnimationCompositor.stage_column_overlay(anim, text, 0)
109
+ next unless overlay
110
+
111
+ col = AnimationCompositor.stage_col_for(run, anim.stage)
112
+ screen.write_at(row, col, overlay) if col
113
+ end
114
+ end
115
+
116
+ def highest_priority(predicate)
117
+ @animations
118
+ .select { |a| a.send(predicate) && !a.finished? }
119
+ .max_by(&:priority)
120
+ end
121
+
122
+ def advance_all
123
+ @animations.each { |a| a.advance! unless a.finished? }
124
+ @header_manager.advance!
125
+ end
126
+
127
+ def expire_finished
128
+ @animations.reject!(&:finished?)
129
+ @header_manager.expire_if_finished!
130
+ end
131
+
132
+ def snapshot(runs)
133
+ runs.map do |r|
134
+ {
135
+ id: r[:id],
136
+ stages: (r[:stages] || []).map do |s|
137
+ { stage: s[:stage], status: s[:status] }
138
+ end
139
+ }
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Tui
5
+ module Ansi
6
+ RESET = "\e[0m"
7
+
8
+ def self.green(text) = "\e[32m#{text}#{RESET}"
9
+ def self.bold_green(text) = "\e[1;32m#{text}#{RESET}"
10
+ def self.bold_red(text) = "\e[1;31m#{text}#{RESET}"
11
+ def self.bold_yellow(text) = "\e[1;33m#{text}#{RESET}"
12
+ def self.cyan(text) = "\e[36m#{text}#{RESET}"
13
+ def self.bold_cyan(text) = "\e[1;36m#{text}#{RESET}"
14
+ def self.dim(text) = "\e[2m#{text}#{RESET}"
15
+ def self.bold_white(text) = "\e[1;37m#{text}#{RESET}"
16
+ def self.white(text) = "\e[37m#{text}#{RESET}"
17
+ def self.yellow(text) = "\e[33m#{text}#{RESET}"
18
+ def self.dim_green(text) = "\e[2;32m#{text}#{RESET}"
19
+ def self.dim_red(text) = "\e[2;31m#{text}#{RESET}"
20
+ def self.orange(text) = "\e[38;5;208m#{text}#{RESET}"
21
+ def self.dark_red(text) = "\e[38;5;52m#{text}#{RESET}"
22
+ def self.bg_charcoal(text) = "\e[48;5;236m#{text}#{RESET}"
23
+ def self.bg_dark_red(text) = "\e[48;5;124m#{text}#{RESET}"
24
+ def self.bg_orange(text) = "\e[48;5;166m#{text}#{RESET}"
25
+ def self.bg_very_dark_red(text) = "\e[48;5;52m#{text}#{RESET}"
26
+ def self.bg_dark_green(text) = "\e[48;5;22m#{text}#{RESET}"
27
+ def self.bg_dark_yellow(text) = "\e[48;5;58m#{text}#{RESET}"
28
+
29
+ def self.strip(text)
30
+ text.gsub(/\e\[[0-9;]*[A-Za-z]/, "")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../persistence/pipeline_run"
5
+ require_relative "../persistence/stage_job"
6
+ require_relative "streak_counter"
7
+
8
+ module FunCi
9
+ module Tui
10
+ class BoardData
11
+ def initialize(db, limit: 15, page_size: nil)
12
+ @db = db
13
+ @page_size = page_size || limit
14
+ @limit = @page_size
15
+ end
16
+
17
+ def load_more
18
+ @limit += @page_size
19
+ end
20
+
21
+ def runs
22
+ pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
23
+ pipeline_runs.map { |run| enrich_with_stages(run) }
24
+ end
25
+
26
+ def streak
27
+ pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
28
+ StreakCounter.count(pipeline_runs)
29
+ end
30
+
31
+ def cancel_run(run_id)
32
+ Persistence::PipelineRun.update_status(@db, run_id, "cancelled")
33
+ end
34
+
35
+ private
36
+
37
+ def enrich_with_stages(run)
38
+ rows = @db.execute(
39
+ "SELECT id, pipeline_run_id, stage, status, started_at, completed_at FROM stage_jobs WHERE pipeline_run_id = ? ORDER BY id",
40
+ [run[:id]]
41
+ )
42
+
43
+ stages = rows.map do |row|
44
+ job = { id: row[0], pipeline_run_id: row[1], stage: row[2], status: row[3], started_at: row[4], completed_at: row[5] }
45
+ duration = Persistence::StageJob.elapsed_duration(job)
46
+ { stage: job[:stage], status: job[:status], duration: duration, started_at: job[:started_at] }
47
+ end
48
+
49
+ run.merge(stages: stages)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "header_animation_manager"
4
+ require_relative "row_formatter"
5
+ require_relative "streak_counter"
6
+
7
+ module FunCi
8
+ module Tui
9
+ class BoardRenderer
10
+ HEADER_HEIGHT = HeaderAnimationManager::HEADER_HEIGHT
11
+
12
+ def initialize(screen:, spinner:, animation_renderer:, height_provider:)
13
+ @screen = screen
14
+ @spinner = spinner
15
+ @animation_renderer = animation_renderer
16
+ @height_provider = height_provider
17
+ end
18
+
19
+ def render(runs:, streak:, cursor_index:, confirming:)
20
+ update_height
21
+ render_header_area(runs, streak)
22
+
23
+ if runs.empty?
24
+ @screen.render_empty_state
25
+ @screen.render_footer(empty: true)
26
+ else
27
+ rows = runs.map { |run| format_run(run) }
28
+ rows = truncate_rows_to_height(rows)
29
+ @screen.render_board(rows, cursor_index: cursor_index)
30
+ @screen.println unless rows.empty?
31
+ @screen.render_footer(empty: false, confirming: confirming)
32
+ end
33
+
34
+ @screen.clear_below
35
+ render_animations(runs)
36
+ end
37
+
38
+ def begin_frame
39
+ @screen.move_cursor_home
40
+ @spinner.advance!
41
+ end
42
+
43
+ def clear
44
+ @screen.clear
45
+ end
46
+
47
+ def resize(new_width)
48
+ @screen.width = new_width
49
+ end
50
+
51
+ def animation_renderer?
52
+ !!@animation_renderer
53
+ end
54
+
55
+ private
56
+
57
+ def render_header_area(runs, streak)
58
+ if @animation_renderer
59
+ @screen.write_at(HEADER_HEIGHT + 1, 1, "")
60
+ else
61
+ streak_text = StreakCounter.format_text(streak)
62
+ @screen.render_header(streak_text: streak_text)
63
+ (HEADER_HEIGHT - 1).times { @screen.println }
64
+ end
65
+ end
66
+
67
+ def format_run(run)
68
+ opts = {}
69
+ if run[:status] == "running"
70
+ active_stage = run[:stages].find { |s| s[:status] == "running" }
71
+ if active_stage && active_stage[:started_at]
72
+ opts[:elapsed_seconds] = Time.now - Time.parse(active_stage[:started_at])
73
+ end
74
+ opts[:spinner_frame] = @spinner.current_frame
75
+ end
76
+ RowFormatter.format(run, **opts)
77
+ end
78
+
79
+ def render_animations(runs)
80
+ return unless @animation_renderer
81
+
82
+ @animation_renderer.render(@screen, runs)
83
+ end
84
+
85
+ def update_height
86
+ return unless @height_provider
87
+
88
+ height = @height_provider.call
89
+ @screen.height = height if height
90
+ end
91
+
92
+ def truncate_rows_to_height(rows)
93
+ height = @screen.height
94
+ return rows unless height
95
+
96
+ # Line budget: N rows consume 2N-1 lines (row + separator between rows).
97
+ # Remaining height after header, post-board separator, and footer:
98
+ # available = height - HEADER_HEIGHT - 2
99
+ # Solving 2N - 1 <= available gives N <= (available) / 2.
100
+ max_rows = [(height - HEADER_HEIGHT - 2) / 2, 0].max
101
+ rows.first(max_rows)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Tui
5
+ module DurationFormatter
6
+ def self.format(seconds)
7
+ if seconds >= 60
8
+ mins = (seconds / 60).to_i
9
+ secs = (seconds % 60).to_i
10
+ "#{mins}m#{secs.to_s.rjust(2, "0")}"
11
+ elsif seconds == seconds.to_i
12
+ "#{seconds.to_i}s"
13
+ else
14
+ "#{format_decimal(seconds)}s"
15
+ end
16
+ end
17
+
18
+ def self.format_decimal(value)
19
+ sprintf("%.1f", value)
20
+ end
21
+ private_class_method :format_decimal
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "header_animation_player"
4
+ require_relative "looping_animation_player"
5
+
6
+ module FunCi
7
+ module Tui
8
+ class HeaderAnimationManager
9
+ HEADER_HEIGHT = 14
10
+
11
+ def initialize(animation_library:)
12
+ @idle_player = LoopingAnimationPlayer.new(animation_library.idle)
13
+ @running_player = nil
14
+ @header_player = nil
15
+ @animation_library = animation_library
16
+ end
17
+
18
+ def trigger_failure
19
+ @header_player = HeaderAnimationPlayer.new(@animation_library.random_failure)
20
+ end
21
+
22
+ def trigger_success
23
+ @header_player = HeaderAnimationPlayer.new(@animation_library.random_success)
24
+ end
25
+
26
+ def start_running
27
+ @running_player ||= LoopingAnimationPlayer.new(@animation_library.running)
28
+ end
29
+
30
+ def stop_running
31
+ @running_player = nil
32
+ end
33
+
34
+ def current_lines(width)
35
+ lines = active_player.current_lines(width)
36
+ pad_to_height(lines, HEADER_HEIGHT)
37
+ end
38
+
39
+ def advance!
40
+ @idle_player.advance!
41
+ @running_player&.advance!
42
+ @header_player&.advance!
43
+ end
44
+
45
+ def expire_if_finished!
46
+ @header_player = nil if @header_player&.finished?
47
+ end
48
+
49
+ def any_active_event?
50
+ @header_player && !@header_player.finished?
51
+ end
52
+
53
+ private
54
+
55
+ def active_player
56
+ return @header_player if any_active_event?
57
+ return @running_player if @running_player
58
+
59
+ @idle_player
60
+ end
61
+
62
+ def pad_to_height(lines, height)
63
+ return lines if lines.length >= height
64
+
65
+ top_pad = (height - lines.length) / 2
66
+ blank = "\e[K"
67
+ Array.new(top_pad, blank) + lines + Array.new(height - lines.length - top_pad, blank)
68
+ end
69
+ end
70
+ end
71
+ end