bubbles 0.0.5 → 0.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.
Files changed (58) hide show
  1. checksums.yaml +5 -5
  2. data/LICENSE.txt +21 -0
  3. data/README.md +524 -80
  4. data/bubbles.gemspec +29 -21
  5. data/lib/bubbles/cursor.rb +169 -0
  6. data/lib/bubbles/file_picker.rb +397 -0
  7. data/lib/bubbles/help.rb +170 -0
  8. data/lib/bubbles/key.rb +96 -0
  9. data/lib/bubbles/list.rb +365 -0
  10. data/lib/bubbles/paginator.rb +158 -0
  11. data/lib/bubbles/progress.rb +276 -0
  12. data/lib/bubbles/spinner/spinners.rb +77 -0
  13. data/lib/bubbles/spinner.rb +122 -0
  14. data/lib/bubbles/stopwatch.rb +189 -0
  15. data/lib/bubbles/table.rb +248 -0
  16. data/lib/bubbles/text_area.rb +503 -0
  17. data/lib/bubbles/text_input.rb +543 -0
  18. data/lib/bubbles/timer.rb +196 -0
  19. data/lib/bubbles/version.rb +4 -1
  20. data/lib/bubbles/viewport.rb +296 -0
  21. data/lib/bubbles.rb +18 -35
  22. data/sig/bubbles/cursor.rbs +87 -0
  23. data/sig/bubbles/file_picker.rbs +138 -0
  24. data/sig/bubbles/help.rbs +88 -0
  25. data/sig/bubbles/key.rbs +63 -0
  26. data/sig/bubbles/list.rbs +138 -0
  27. data/sig/bubbles/paginator.rbs +90 -0
  28. data/sig/bubbles/progress.rbs +123 -0
  29. data/sig/bubbles/spinner/spinners.rbs +32 -0
  30. data/sig/bubbles/spinner.rbs +74 -0
  31. data/sig/bubbles/stopwatch.rbs +97 -0
  32. data/sig/bubbles/table.rbs +119 -0
  33. data/sig/bubbles/text_area.rbs +161 -0
  34. data/sig/bubbles/text_input.rbs +183 -0
  35. data/sig/bubbles/timer.rbs +107 -0
  36. data/sig/bubbles/version.rbs +5 -0
  37. data/sig/bubbles/viewport.rbs +125 -0
  38. data/sig/bubbles.rbs +4 -0
  39. metadata +66 -67
  40. data/.gitignore +0 -14
  41. data/.rspec +0 -2
  42. data/.travis.yml +0 -10
  43. data/Gemfile +0 -4
  44. data/LICENSE +0 -20
  45. data/Rakefile +0 -6
  46. data/bin/console +0 -14
  47. data/bin/setup +0 -8
  48. data/exe/bubbles +0 -5
  49. data/lib/bubbles/bubblicious_file.rb +0 -42
  50. data/lib/bubbles/command_queue.rb +0 -43
  51. data/lib/bubbles/common_uploader_interface.rb +0 -13
  52. data/lib/bubbles/config.rb +0 -149
  53. data/lib/bubbles/dir_watcher.rb +0 -53
  54. data/lib/bubbles/uploaders/local_dir.rb +0 -39
  55. data/lib/bubbles/uploaders/s3.rb +0 -36
  56. data/lib/bubbles/uploaders/s3_ensure_connection.rb +0 -26
  57. data/tmp/dummy_local_dir_uploader_dir/.gitkeep +0 -0
  58. data/tmp/dummy_processing_dir/.gitkeep +0 -0
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ # Paginator provides pagination logic and rendering.
6
+ #
7
+ # Example:
8
+ # items = (1..100).to_a
9
+ # paginator = Bubbles::Paginator.new(per_page: 10)
10
+ # paginator.update_total_pages(items.length)
11
+ #
12
+ # # Get current page items
13
+ # start_index, end_index = paginator.slice_bounds(items.length)
14
+ # current_items = items[start_index...end_index]
15
+ #
16
+ # # Navigation
17
+ # paginator.next_page
18
+ # paginator.prev_page
19
+ #
20
+ # # Render pagination indicator
21
+ # puts paginator.view
22
+ #
23
+ class Paginator
24
+ ARABIC = :arabic #: Symbol
25
+ DOTS = :dots #: Symbol
26
+
27
+ attr_accessor :type #: Symbol
28
+ attr_accessor :page #: Integer
29
+ attr_accessor :per_page #: Integer
30
+ attr_accessor :total_pages #: Integer
31
+ attr_accessor :active_dot #: String
32
+ attr_accessor :inactive_dot #: String
33
+ attr_accessor :arabic_format #: String
34
+ attr_accessor :key_style #: Lipgloss::Style?
35
+ attr_accessor :active_dot_style #: Lipgloss::Style?
36
+ attr_accessor :inactive_dot_style #: Lipgloss::Style?
37
+
38
+ #: (?type: Symbol, ?per_page: Integer) -> void
39
+ def initialize(type: ARABIC, per_page: 10)
40
+ @type = type
41
+ @page = 0
42
+ @per_page = per_page
43
+ @total_pages = 1
44
+
45
+ @active_dot = "●"
46
+ @inactive_dot = "○"
47
+
48
+ @arabic_format = "%d/%d"
49
+
50
+ @key_style = nil
51
+ @active_dot_style = nil
52
+ @inactive_dot_style = nil
53
+ end
54
+
55
+ #: (Integer) -> void
56
+ def update_total_pages(total_items)
57
+ @total_pages = [(total_items.to_f / @per_page).ceil, 1].max
58
+ # Clamp current page
59
+ @page = @page.clamp(0, @total_pages - 1)
60
+ end
61
+
62
+ #: (Integer) -> Integer
63
+ def items_on_page(total_items)
64
+ return 0 if total_items <= 0
65
+
66
+ start_index = @page * @per_page
67
+ remaining = total_items - start_index
68
+
69
+ [remaining, @per_page].min
70
+ end
71
+
72
+ #: (Integer) -> [Integer, Integer]
73
+ def slice_bounds(total_items)
74
+ start_index = @page * @per_page
75
+ end_index = [start_index + @per_page, total_items].min
76
+ [start_index, end_index]
77
+ end
78
+
79
+ #: () -> bool
80
+ def prev_page?
81
+ @page.positive?
82
+ end
83
+
84
+ #: () -> bool
85
+ def next_page?
86
+ @page < @total_pages - 1
87
+ end
88
+
89
+ #: () -> bool
90
+ def prev_page # rubocop:disable Naming/PredicateMethod
91
+ return false unless prev_page?
92
+
93
+ @page -= 1
94
+
95
+ true
96
+ end
97
+
98
+ #: () -> bool
99
+ def next_page # rubocop:disable Naming/PredicateMethod
100
+ return false unless next_page?
101
+
102
+ @page += 1
103
+
104
+ true
105
+ end
106
+
107
+ #: (Integer) -> bool
108
+ def go_to_page(page) # rubocop:disable Naming/PredicateMethod
109
+ new_page = page.clamp(0, @total_pages - 1)
110
+ return false if new_page == @page
111
+
112
+ @page = new_page
113
+
114
+ true
115
+ end
116
+
117
+ #: () -> String
118
+ def view
119
+ case @type
120
+ when DOTS
121
+ dots_view
122
+ else
123
+ arabic_view
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ #: () -> String
130
+ def arabic_view
131
+ text = format(@arabic_format, @page + 1, @total_pages)
132
+ (key_style = @key_style) ? key_style.render(text) : text
133
+ end
134
+
135
+ #: () -> String
136
+ def dots_view
137
+ dots = (0...@total_pages).map do |i|
138
+ if i == @page
139
+ render_active_dot
140
+ else
141
+ render_inactive_dot
142
+ end
143
+ end
144
+
145
+ dots.join(" ")
146
+ end
147
+
148
+ #: () -> String
149
+ def render_active_dot
150
+ (style = @active_dot_style) ? style.render(@active_dot) : @active_dot
151
+ end
152
+
153
+ #: () -> String
154
+ def render_inactive_dot
155
+ (style = @inactive_dot_style) ? style.render(@inactive_dot) : @inactive_dot
156
+ end
157
+ end
158
+ end
@@ -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