bubbles 0.0.5 → 0.1.1

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 (62) hide show
  1. checksums.yaml +5 -5
  2. data/LICENSE.txt +21 -0
  3. data/README.md +601 -80
  4. data/bubbles.gemspec +29 -21
  5. data/lib/bubbles/ansi.rb +71 -0
  6. data/lib/bubbles/cryptic_spinner.rb +274 -0
  7. data/lib/bubbles/cursor.rb +169 -0
  8. data/lib/bubbles/file_picker.rb +397 -0
  9. data/lib/bubbles/help.rb +165 -0
  10. data/lib/bubbles/key.rb +96 -0
  11. data/lib/bubbles/list.rb +365 -0
  12. data/lib/bubbles/paginator.rb +158 -0
  13. data/lib/bubbles/progress.rb +276 -0
  14. data/lib/bubbles/spinner/spinners.rb +77 -0
  15. data/lib/bubbles/spinner.rb +122 -0
  16. data/lib/bubbles/stopwatch.rb +189 -0
  17. data/lib/bubbles/table.rb +248 -0
  18. data/lib/bubbles/text_area.rb +503 -0
  19. data/lib/bubbles/text_input.rb +543 -0
  20. data/lib/bubbles/timer.rb +196 -0
  21. data/lib/bubbles/version.rb +4 -1
  22. data/lib/bubbles/viewport.rb +283 -0
  23. data/lib/bubbles.rb +20 -35
  24. data/sig/bubbles/ansi.rbs +23 -0
  25. data/sig/bubbles/cryptic_spinner.rbs +143 -0
  26. data/sig/bubbles/cursor.rbs +87 -0
  27. data/sig/bubbles/file_picker.rbs +138 -0
  28. data/sig/bubbles/help.rbs +85 -0
  29. data/sig/bubbles/key.rbs +63 -0
  30. data/sig/bubbles/list.rbs +138 -0
  31. data/sig/bubbles/paginator.rbs +90 -0
  32. data/sig/bubbles/progress.rbs +123 -0
  33. data/sig/bubbles/spinner/spinners.rbs +32 -0
  34. data/sig/bubbles/spinner.rbs +74 -0
  35. data/sig/bubbles/stopwatch.rbs +97 -0
  36. data/sig/bubbles/table.rbs +119 -0
  37. data/sig/bubbles/text_area.rbs +161 -0
  38. data/sig/bubbles/text_input.rbs +183 -0
  39. data/sig/bubbles/timer.rbs +107 -0
  40. data/sig/bubbles/version.rbs +5 -0
  41. data/sig/bubbles/viewport.rbs +119 -0
  42. data/sig/bubbles.rbs +4 -0
  43. metadata +70 -67
  44. data/.gitignore +0 -14
  45. data/.rspec +0 -2
  46. data/.travis.yml +0 -10
  47. data/Gemfile +0 -4
  48. data/LICENSE +0 -20
  49. data/Rakefile +0 -6
  50. data/bin/console +0 -14
  51. data/bin/setup +0 -8
  52. data/exe/bubbles +0 -5
  53. data/lib/bubbles/bubblicious_file.rb +0 -42
  54. data/lib/bubbles/command_queue.rb +0 -43
  55. data/lib/bubbles/common_uploader_interface.rb +0 -13
  56. data/lib/bubbles/config.rb +0 -149
  57. data/lib/bubbles/dir_watcher.rb +0 -53
  58. data/lib/bubbles/uploaders/local_dir.rb +0 -39
  59. data/lib/bubbles/uploaders/s3.rb +0 -36
  60. data/lib/bubbles/uploaders/s3_ensure_connection.rb +0 -26
  61. data/tmp/dummy_local_dir_uploader_dir/.gitkeep +0 -0
  62. data/tmp/dummy_processing_dir/.gitkeep +0 -0
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "harmonica"
5
+
6
+ module Bubbles
7
+ # Progress renders an animated progress bar.
8
+ #
9
+ # Example:
10
+ # progress = Bubbles::Progress.new
11
+ # progress.width = 40
12
+ #
13
+ # # Static rendering
14
+ # puts progress.view_as(0.5) # 50%
15
+ #
16
+ # # Animated rendering (use in bubbletea)
17
+ # command = progress.set_percent(0.75)
18
+ # # Then in update: progress.update(message)
19
+ # puts progress.view
20
+ #
21
+ class Progress
22
+ class FrameMessage < Bubbletea::Message
23
+ attr_reader :id #: Integer
24
+ attr_reader :tag #: Integer
25
+
26
+ #: (id: Integer, tag: Integer) -> void
27
+ def initialize(id:, tag:)
28
+ super()
29
+ @id = id
30
+ @tag = tag
31
+ end
32
+ end
33
+
34
+ FPS = 60 #: Integer
35
+ DEFAULT_WIDTH = 40 #: Integer
36
+ SPRING_FREQUENCY = 5.0 #: Float
37
+ SPRING_DAMPING = 1.0 #: Float
38
+
39
+ # rubocop:disable Style/ClassVars
40
+ @@last_id = 0 #: Integer
41
+ @@id_mutex = Mutex.new #: Mutex
42
+
43
+ #: () -> Integer
44
+ def self.next_id
45
+ @@id_mutex.synchronize do
46
+ @@last_id += 1
47
+ end
48
+ end
49
+ # rubocop:enable Style/ClassVars
50
+
51
+ attr_accessor :width #: Integer
52
+ attr_accessor :full #: String
53
+ attr_accessor :full_color #: String
54
+ attr_accessor :empty #: String
55
+ attr_accessor :empty_color #: String
56
+ attr_accessor :show_percentage #: bool
57
+ attr_accessor :percent_format #: String
58
+ attr_accessor :percent_style #: Lipgloss::Style?
59
+ attr_accessor :use_gradient #: bool
60
+ attr_accessor :gradient_a #: String?
61
+ attr_accessor :gradient_b #: String?
62
+ attr_accessor :scale_gradient #: bool
63
+
64
+ attr_reader :id #: Integer
65
+
66
+ #: (?width: Integer, ?gradient: Array[String]?, ?scaled_gradient: Array[String]?, ?solid_fill: String?) -> void
67
+ def initialize(width: DEFAULT_WIDTH, gradient: nil, scaled_gradient: nil, solid_fill: nil)
68
+ @id = self.class.next_id
69
+ @tag = 0
70
+
71
+ @width = width
72
+ @full = "█"
73
+ @full_color = "#7571F9"
74
+ @empty = "░"
75
+ @empty_color = "#606060"
76
+
77
+ @show_percentage = true
78
+ @percent_format = " %3.0f%%"
79
+ @percent_style = nil
80
+
81
+ @percent_shown = 0.0
82
+ @target_percent = 0.0
83
+ @velocity = 0.0
84
+
85
+ @spring = Harmonica::Spring.new(
86
+ delta_time: Harmonica.fps(FPS),
87
+ angular_frequency: SPRING_FREQUENCY,
88
+ damping_ratio: SPRING_DAMPING
89
+ )
90
+
91
+ @use_gradient = false
92
+ @gradient_a = nil
93
+ @gradient_b = nil
94
+ @scale_gradient = false
95
+
96
+ if gradient
97
+ gradient(gradient[0], gradient[1], scaled: false)
98
+ elsif scaled_gradient
99
+ gradient(scaled_gradient[0], scaled_gradient[1], scaled: true)
100
+ elsif solid_fill
101
+ @full_color = solid_fill
102
+ @use_gradient = false
103
+ end
104
+ end
105
+
106
+ #: () -> nil
107
+ def init
108
+ nil
109
+ end
110
+
111
+ #: (Bubbletea::Message) -> [Progress, Bubbletea::Command?]
112
+ def update(message)
113
+ command = nil
114
+
115
+ case message
116
+ when FrameMessage
117
+ if message.id == @id && message.tag == @tag && animating?
118
+ @percent_shown, @velocity = @spring.update(
119
+ @percent_shown,
120
+ @velocity,
121
+ @target_percent
122
+ )
123
+
124
+ if (@percent_shown - @target_percent).abs < 0.001 && @velocity.abs < 0.001
125
+ @percent_shown = @target_percent
126
+ @velocity = 0.0
127
+ else
128
+ command = next_frame
129
+ end
130
+ end
131
+ end
132
+
133
+ [self, command]
134
+ end
135
+
136
+ #: () -> String
137
+ def view
138
+ view_as(@percent_shown)
139
+ end
140
+
141
+ #: (Float) -> String
142
+ def view_as(percent)
143
+ percent = percent.clamp(0.0, 1.0)
144
+
145
+ percentage_view = render_percentage(percent)
146
+ bar = render_bar(percent, percentage_view.length)
147
+
148
+ "#{bar}#{percentage_view}"
149
+ end
150
+
151
+ #: () -> Float
152
+ def percent
153
+ @target_percent
154
+ end
155
+
156
+ #: (Float) -> Bubbletea::Command
157
+ def set_percent(percent) # rubocop:disable Naming/AccessorMethodName
158
+ @target_percent = percent.clamp(0.0, 1.0)
159
+ @tag += 1
160
+
161
+ next_frame
162
+ end
163
+
164
+ #: (?Float) -> Bubbletea::Command
165
+ def increment_percent(amount = 1.0)
166
+ set_percent(percent + amount)
167
+ end
168
+
169
+ #: (?Float) -> Bubbletea::Command
170
+ def decrement_percent(amount = 1.0)
171
+ set_percent(percent - amount)
172
+ end
173
+
174
+ #: () -> bool
175
+ def animating?
176
+ (@percent_shown - @target_percent).abs >= 0.001
177
+ end
178
+
179
+ #: (String, String, ?scaled: bool) -> void
180
+ def gradient(color_a, color_b, scaled: false)
181
+ @use_gradient = true
182
+ @gradient_a = color_a
183
+ @gradient_b = color_b
184
+ @scale_gradient = scaled
185
+ end
186
+
187
+ private
188
+
189
+ #: () -> Bubbletea::Command
190
+ def next_frame
191
+ id = @id
192
+ tag = @tag
193
+ interval = 1.0 / FPS
194
+
195
+ Bubbletea.tick(interval) do
196
+ FrameMessage.new(id: id, tag: tag)
197
+ end
198
+ end
199
+
200
+ #: (Float, Integer) -> String
201
+ def render_bar(percent, text_width)
202
+ total_width = [@width - text_width, 0].max
203
+ filled_width = (total_width * percent).round
204
+ filled_width = filled_width.clamp(0, total_width)
205
+ empty_width = [total_width - filled_width, 0].max
206
+
207
+ filled = if @use_gradient && @gradient_a && @gradient_b
208
+ render_gradient_fill(filled_width, total_width)
209
+ else
210
+ render_solid_fill(filled_width)
211
+ end
212
+
213
+ empty = render_empty_fill(empty_width)
214
+
215
+ "#{filled}#{empty}"
216
+ end
217
+
218
+ #: (Integer) -> String
219
+ def render_solid_fill(width)
220
+ return "" if width <= 0
221
+
222
+ char = @full * width
223
+ colorize(char, @full_color)
224
+ end
225
+
226
+ #: (Integer) -> String
227
+ def render_empty_fill(width)
228
+ return "" if width <= 0
229
+
230
+ char = @empty * width
231
+ colorize(char, @empty_color)
232
+ end
233
+
234
+ #: (Integer, Integer) -> String
235
+ def render_gradient_fill(filled_width, total_width)
236
+ return "" if filled_width <= 0
237
+
238
+ result = ""
239
+
240
+ filled_width.times do |i|
241
+ p = if filled_width == 1
242
+ 0.5
243
+ elsif @scale_gradient
244
+ i.to_f / (filled_width - 1)
245
+ else
246
+ i.to_f / [total_width - 1, 1].max
247
+ end
248
+
249
+ color = blend_colors(@gradient_a, @gradient_b, p)
250
+ result += colorize(@full, color)
251
+ end
252
+
253
+ result
254
+ end
255
+
256
+ #: (Float) -> String
257
+ def render_percentage(percent)
258
+ return "" unless @show_percentage
259
+
260
+ text = format(@percent_format, percent * 100)
261
+ (style = @percent_style) ? style.render(text) : text
262
+ end
263
+
264
+ #: (String, String) -> String
265
+ def colorize(text, color)
266
+ Lipgloss::Style.new.foreground(color).render(text)
267
+ end
268
+
269
+ #: (String?, String?, Float) -> String
270
+ def blend_colors(color_a, color_b, ratio)
271
+ a = color_a || @full_color
272
+ b = color_b || @full_color
273
+ Lipgloss::ColorBlend.blend(a, b, ratio)
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ module Spinners
6
+ LINE = {
7
+ frames: ["|", "/", "-", "\\"],
8
+ fps: 0.1,
9
+ }.freeze #: Hash[Symbol, Array[String] | Float]
10
+
11
+ DOT = {
12
+ frames: ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
13
+ fps: 0.1,
14
+ }.freeze #: Hash[Symbol, Array[String] | Float]
15
+
16
+ MINI_DOT = {
17
+ frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
18
+ fps: 0.083,
19
+ }.freeze #: Hash[Symbol, Array[String] | Float]
20
+
21
+ JUMP = {
22
+ frames: ["⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"],
23
+ fps: 0.1,
24
+ }.freeze #: Hash[Symbol, Array[String] | Float]
25
+
26
+ PULSE = {
27
+ frames: ["█", "▓", "▒", "░"],
28
+ fps: 0.125,
29
+ }.freeze #: Hash[Symbol, Array[String] | Float]
30
+
31
+ POINTS = {
32
+ frames: ["∙∙∙", "●∙∙", "∙●∙", "∙∙●"],
33
+ fps: 0.143,
34
+ }.freeze #: Hash[Symbol, Array[String] | Float]
35
+
36
+ GLOBE = {
37
+ frames: ["🌍", "🌎", "🌏"],
38
+ fps: 0.25,
39
+ }.freeze #: Hash[Symbol, Array[String] | Float]
40
+
41
+ MOON = {
42
+ frames: ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"],
43
+ fps: 0.125,
44
+ }.freeze #: Hash[Symbol, Array[String] | Float]
45
+
46
+ MONKEY = {
47
+ frames: ["🙈", "🙉", "🙊"],
48
+ fps: 0.333,
49
+ }.freeze #: Hash[Symbol, Array[String] | Float]
50
+
51
+ METER = {
52
+ frames: [
53
+ "▱▱▱",
54
+ "▰▱▱",
55
+ "▰▰▱",
56
+ "▰▰▰",
57
+ "▰▰▱",
58
+ "▰▱▱",
59
+ "▱▱▱",
60
+ ],
61
+ fps: 0.143,
62
+ }.freeze #: Hash[Symbol, Array[String] | Float]
63
+
64
+ HAMBURGER = {
65
+ frames: ["☱", "☲", "☴", "☲"],
66
+ fps: 0.333,
67
+ }.freeze #: Hash[Symbol, Array[String] | Float]
68
+
69
+ ELLIPSIS = {
70
+ frames: ["", ".", "..", "..."],
71
+ fps: 0.333,
72
+ }.freeze #: Hash[Symbol, Array[String] | Float]
73
+
74
+ # @rbs DEFAULT:
75
+ DEFAULT = LINE #: Hash[Symbol, Array[String] | Float]
76
+ end
77
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require_relative "spinner/spinners"
5
+
6
+ module Bubbles
7
+ # Spinner is an animated activity indicator component.
8
+ #
9
+ # Example:
10
+ # spinner = Bubbles::Spinner.new(spinner: Bubbles::Spinners::DOT)
11
+ # spinner.style = Lipgloss::Style.new.foreground("205")
12
+ #
13
+ # # In your model's init:
14
+ # def init
15
+ # [self, @spinner.tick]
16
+ # end
17
+ #
18
+ # # In your model's update:
19
+ # def update(message)
20
+ # case message
21
+ # when Bubbles::Spinner::TickMessage
22
+ # @spinner, command = @spinner.update(message)
23
+ # [self, command]
24
+ # end
25
+ # end
26
+ #
27
+ # # In your model's view:
28
+ # def view
29
+ # "#{@spinner.view} Loading..."
30
+ # end
31
+ #
32
+ class Spinner
33
+ class TickMessage < Bubbletea::Message
34
+ attr_reader :id #: Integer
35
+ attr_reader :tag #: Integer
36
+
37
+ #: (id: Integer, tag: Integer) -> void
38
+ def initialize(id:, tag:)
39
+ super()
40
+
41
+ @id = id
42
+ @tag = tag
43
+ end
44
+ end
45
+
46
+ # @rbs self.@next_id: Integer
47
+ # @rbs self.@id_mutex: Mutex
48
+ @next_id = 0
49
+ @id_mutex = Mutex.new
50
+
51
+ class << self
52
+ #: () -> Integer
53
+ def next_id
54
+ @id_mutex.synchronize do
55
+ @next_id += 1
56
+ end
57
+ end
58
+ end
59
+
60
+ attr_reader :id #: Integer
61
+ attr_accessor :spinner #: Hash[Symbol, untyped]
62
+ attr_accessor :style #: Lipgloss::Style?
63
+
64
+ #: (?spinner: Hash[Symbol, untyped], ?style: Lipgloss::Style?) -> void
65
+ def initialize(spinner: Spinners::DEFAULT, style: nil)
66
+ @spinner = spinner
67
+ @style = style
68
+ @frame = 0
69
+ @id = self.class.next_id
70
+ @tag = 0
71
+ end
72
+
73
+ #: () -> [Spinner, Bubbletea::Command]
74
+ def init
75
+ [self, tick]
76
+ end
77
+
78
+ #: (Bubbletea::Message) -> [Spinner, Bubbletea::Command?]
79
+ def update(message)
80
+ case message
81
+ when TickMessage
82
+ return [self, nil] if message.id.positive? && message.id != @id
83
+ return [self, nil] if message.tag.positive? && message.tag != @tag
84
+
85
+ @frame = (@frame + 1) % frames.length
86
+ @tag += 1
87
+
88
+ [self, tick]
89
+ else
90
+ [self, nil]
91
+ end
92
+ end
93
+
94
+ #: () -> String
95
+ def view
96
+ return "(error)" if @frame >= frames.length
97
+
98
+ frame_str = frames[@frame]
99
+ (style = @style) ? style.render(frame_str) : frame_str
100
+ end
101
+
102
+ #: () -> Bubbletea::Command
103
+ def tick
104
+ current_id = @id
105
+ current_tag = @tag
106
+
107
+ Bubbletea.tick(fps) { TickMessage.new(id: current_id, tag: current_tag) }
108
+ end
109
+
110
+ private
111
+
112
+ #: () -> Array[String]
113
+ def frames
114
+ @spinner[:frames] || Spinners::DEFAULT[:frames]
115
+ end
116
+
117
+ #: () -> Float
118
+ def fps
119
+ @spinner[:fps] || Spinners::DEFAULT[:fps]
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ # Stopwatch is an elapsed time counter component.
6
+ #
7
+ # Example:
8
+ # stopwatch = Bubbles::Stopwatch.new
9
+ #
10
+ # # In your model's init:
11
+ # def init
12
+ # [self, @stopwatch.init]
13
+ # end
14
+ #
15
+ # # In your model's update:
16
+ # def update(message)
17
+ # case message
18
+ # when Bubbles::Stopwatch::TickMessage, Bubbles::Stopwatch::StartStopMessage, Bubbles::Stopwatch::ResetMessage
19
+ # @stopwatch, command = @stopwatch.update(message)
20
+ #
21
+ # [self, command]
22
+ # end
23
+ # end
24
+ #
25
+ class Stopwatch
26
+ class TickMessage < Bubbletea::Message
27
+ attr_reader :id #: Integer
28
+ attr_reader :tag #: Integer
29
+
30
+ #: (id: Integer, tag: Integer) -> void
31
+ def initialize(id:, tag:)
32
+ super()
33
+
34
+ @id = id
35
+ @tag = tag
36
+ end
37
+ end
38
+
39
+ class StartStopMessage < Bubbletea::Message
40
+ attr_reader :id #: Integer
41
+ attr_reader :running #: bool
42
+
43
+ #: (id: Integer, running: bool) -> void
44
+ def initialize(id:, running:)
45
+ super()
46
+
47
+ @id = id
48
+ @running = running
49
+ end
50
+ end
51
+
52
+ class ResetMessage < Bubbletea::Message
53
+ attr_reader :id #: Integer
54
+
55
+ #: (id: Integer) -> void
56
+ def initialize(id:)
57
+ super()
58
+
59
+ @id = id
60
+ end
61
+ end
62
+
63
+ # @rbs self.@next_id: Integer
64
+ # @rbs self.@id_mutex: Mutex
65
+ @next_id = 0
66
+ @id_mutex = Mutex.new
67
+
68
+ class << self
69
+ #: () -> Integer
70
+ def next_id
71
+ @id_mutex.synchronize do
72
+ @next_id += 1
73
+ end
74
+ end
75
+ end
76
+
77
+ attr_reader :elapsed #: Float
78
+ attr_reader :interval #: Float
79
+ attr_reader :id #: Integer
80
+
81
+ #: (?interval: Float) -> void
82
+ def initialize(interval: 1.0)
83
+ @elapsed = 0.0
84
+ @interval = interval.to_f
85
+ @id = self.class.next_id
86
+ @tag = 0
87
+ @running = false
88
+ end
89
+
90
+ #: () -> Bubbletea::Command
91
+ def init
92
+ start
93
+ end
94
+
95
+ #: () -> bool
96
+ def running?
97
+ @running
98
+ end
99
+
100
+ #: (Bubbletea::Message) -> [Stopwatch, Bubbletea::Command?]
101
+ def update(message)
102
+ case message
103
+ when StartStopMessage
104
+ return [self, nil] if message.id.positive? && message.id != @id
105
+
106
+ @running = message.running
107
+
108
+ [self, @running ? tick : nil]
109
+ when ResetMessage
110
+ return [self, nil] if message.id.positive? && message.id != @id
111
+
112
+ @elapsed = 0.0
113
+
114
+ [self, nil]
115
+ when TickMessage
116
+ return [self, nil] unless @running
117
+ return [self, nil] if message.id.positive? && message.id != @id
118
+ return [self, nil] if message.tag.positive? && message.tag != @tag
119
+
120
+ @elapsed += @interval
121
+ @tag += 1
122
+
123
+ [self, tick]
124
+ else
125
+ [self, nil]
126
+ end
127
+ end
128
+
129
+ #: () -> String
130
+ def view
131
+ format_duration(@elapsed)
132
+ end
133
+
134
+ #: () -> Bubbletea::Command
135
+ def start
136
+ current_id = @id
137
+ current_tag = @tag
138
+
139
+ Bubbletea.sequence(
140
+ Bubbletea.send_message(StartStopMessage.new(id: current_id, running: true)),
141
+ Bubbletea.tick(@interval) { TickMessage.new(id: current_id, tag: current_tag) }
142
+ )
143
+ end
144
+
145
+ #: () -> Bubbletea::Command
146
+ def stop
147
+ current_id = @id
148
+ Bubbletea.send_message(StartStopMessage.new(id: current_id, running: false))
149
+ end
150
+
151
+ #: () -> Bubbletea::Command
152
+ def toggle
153
+ running? ? stop : start
154
+ end
155
+
156
+ #: () -> Bubbletea::Command
157
+ def reset
158
+ current_id = @id
159
+ Bubbletea.send_message(ResetMessage.new(id: current_id))
160
+ end
161
+
162
+ private
163
+
164
+ #: () -> Bubbletea::Command?
165
+ def tick
166
+ return nil unless running?
167
+
168
+ current_id = @id
169
+ current_tag = @tag
170
+
171
+ Bubbletea.tick(@interval) { TickMessage.new(id: current_id, tag: current_tag) }
172
+ end
173
+
174
+ #: (Float) -> String
175
+ def format_duration(seconds)
176
+ total_seconds = seconds.to_i
177
+ hours = total_seconds / 3600
178
+ minutes = (total_seconds % 3600) / 60
179
+ secs = total_seconds % 60
180
+ ms = ((seconds - total_seconds) * 100).to_i
181
+
182
+ if hours.positive?
183
+ format("%<hours>d:%<minutes>02d:%<secs>02d.%<ms>02d", hours: hours, minutes: minutes, secs: secs, ms: ms)
184
+ else
185
+ format("%<minutes>d:%<secs>02d.%<ms>02d", minutes: minutes, secs: secs, ms: ms)
186
+ end
187
+ end
188
+ end
189
+ end