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,365 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ # List is a feature-rich component for browsing a list of items.
6
+ # It features optional filtering, pagination, and status messages.
7
+ #
8
+ # Example:
9
+ # items = [
10
+ # { title: "Item 1", description: "Description 1" },
11
+ # { title: "Item 2", description: "Description 2" }
12
+ # ]
13
+ # list = Bubbles::List.new(items, width: 40, height: 10)
14
+ #
15
+ # # In update:
16
+ # list, command = list.update(message)
17
+ #
18
+ # # In view:
19
+ # list.view
20
+ #
21
+ class List
22
+ UNFILTERED = :unfiltered #: Symbol
23
+ FILTERING = :filtering #: Symbol
24
+ FILTER_APPLIED = :filter_applied #: Symbol
25
+
26
+ class StatusMessageTimeoutMessage < Bubbletea::Message
27
+ end
28
+
29
+ attr_accessor :width #: Integer
30
+ attr_accessor :height #: Integer
31
+ attr_accessor :title #: String
32
+ attr_accessor :show_title #: bool
33
+ attr_accessor :show_filter #: bool
34
+ attr_accessor :show_pagination #: bool
35
+ attr_accessor :show_status_bar #: bool
36
+ attr_accessor :status_message_lifetime #: Float
37
+ attr_accessor :fill_height #: bool
38
+ attr_accessor :title_style #: Lipgloss::Style?
39
+ attr_accessor :item_style #: Lipgloss::Style?
40
+ attr_accessor :selected_item_style #: Lipgloss::Style?
41
+ attr_accessor :filter_prompt #: String
42
+ attr_accessor :filter_style #: Lipgloss::Style?
43
+ attr_accessor :status_bar_style #: Lipgloss::Style?
44
+ attr_accessor :pagination_style #: Lipgloss::Style?
45
+
46
+ attr_reader :filter_state #: Symbol
47
+ attr_reader :filter_input #: TextInput
48
+
49
+ #: (?Array[untyped], ?width: Integer, ?height: Integer) -> void
50
+ def initialize(items = [], width: 40, height: 10)
51
+ @width = width
52
+ @height = height
53
+
54
+ @items = items
55
+ @filtered_items = items.dup
56
+ @selected = 0
57
+ @offset = 0
58
+
59
+ @title = "List"
60
+ @show_title = true
61
+ @show_filter = true
62
+ @show_pagination = true
63
+ @show_status_bar = true
64
+ @fill_height = true
65
+
66
+ @filter_state = UNFILTERED
67
+ @filter_input = TextInput.new
68
+ @filter_input.prompt = "Filter: "
69
+ @filter_prompt = "/"
70
+
71
+ @paginator = Paginator.new(type: Paginator::DOTS)
72
+ @status_message = ""
73
+ @status_message_lifetime = 1.0
74
+
75
+ @title_style = nil
76
+ @item_style = nil
77
+ @selected_item_style = nil
78
+ @filter_style = nil
79
+ @status_bar_style = nil
80
+ @pagination_style = nil
81
+
82
+ update_pagination
83
+ end
84
+
85
+ #: (Array[untyped]) -> void
86
+ def items=(items)
87
+ @items = items
88
+ reset_filter
89
+ end
90
+
91
+ attr_reader :items #: Array[untyped]
92
+
93
+ #: () -> Array[untyped]
94
+ def visible_items
95
+ @filtered_items
96
+ end
97
+
98
+ #: () -> Integer
99
+ def selected_index
100
+ @selected
101
+ end
102
+
103
+ #: () -> untyped
104
+ def selected_item
105
+ @filtered_items[@selected]
106
+ end
107
+
108
+ #: (Integer) -> void
109
+ def select(index)
110
+ @selected = index.clamp(0, [@filtered_items.length - 1, 0].max)
111
+ update_offset
112
+ end
113
+
114
+ #: () -> void
115
+ def select_next
116
+ select(@selected + 1)
117
+ end
118
+
119
+ #: () -> void
120
+ def select_prev
121
+ select(@selected - 1)
122
+ end
123
+
124
+ #: (String) -> Bubbletea::Command
125
+ def set_status_message(message) # rubocop:disable Naming/AccessorMethodName
126
+ @status_message = message
127
+ Bubbletea.tick(@status_message_lifetime) { StatusMessageTimeoutMessage.new }
128
+ end
129
+
130
+ #: () -> void
131
+ def clear_status_message
132
+ @status_message = ""
133
+ end
134
+
135
+ #: () -> Bubbletea::Command?
136
+ def start_filtering
137
+ @filter_state = FILTERING
138
+ @filter_input.focus
139
+ end
140
+
141
+ #: () -> void
142
+ def apply_filter
143
+ return if @filter_state != FILTERING
144
+
145
+ if @filter_input.value.empty?
146
+ reset_filter
147
+ else
148
+ @filter_state = FILTER_APPLIED
149
+ @filter_input.blur
150
+
151
+ do_filter
152
+ end
153
+ end
154
+
155
+ #: () -> void
156
+ def reset_filter
157
+ @filter_state = UNFILTERED
158
+ @filter_input.reset
159
+ @filter_input.blur
160
+ @filtered_items = @items.dup
161
+ @selected = 0
162
+
163
+ update_pagination
164
+ end
165
+
166
+ #: (Bubbletea::Message) -> [List, Bubbletea::Command?]
167
+ def update(message)
168
+ commands = [] #: Array[Bubbletea::Command]
169
+
170
+ case message
171
+ when StatusMessageTimeoutMessage
172
+ clear_status_message
173
+
174
+ return [self, nil]
175
+ when Bubbletea::KeyMessage
176
+ if @filter_state == FILTERING
177
+ case message.to_s
178
+ when "enter"
179
+ apply_filter
180
+ when "esc"
181
+ reset_filter
182
+ else
183
+ @filter_input, command = @filter_input.update(message)
184
+ commands << command if command
185
+
186
+ do_filter
187
+ end
188
+ else
189
+ case message.to_s
190
+ when "up", "k"
191
+ select_prev
192
+ when "down", "j"
193
+ select_next
194
+ when "home", "g"
195
+ select(0)
196
+ when "end", "G"
197
+ select(@filtered_items.length - 1)
198
+ when "pgup", "ctrl+b"
199
+ select(@selected - visible_item_count)
200
+ when "pgdown", "ctrl+f"
201
+ select(@selected + visible_item_count)
202
+ when "/"
203
+ return [self, start_filtering] if @show_filter
204
+ when "esc"
205
+ reset_filter if @filter_state == FILTER_APPLIED
206
+ end
207
+ end
208
+ end
209
+
210
+ update_pagination
211
+
212
+ [self, commands.empty? ? nil : Bubbletea.batch(*commands)]
213
+ end
214
+
215
+ #: () -> String
216
+ def view
217
+ sections = [] #: Array[String]
218
+
219
+ if @show_title
220
+ title = (style = @title_style) ? style.render(@title) : @title
221
+ sections << title
222
+ sections << ""
223
+ end
224
+
225
+ if @show_filter && @filter_state != UNFILTERED
226
+ sections << @filter_input.view
227
+ sections << ""
228
+ end
229
+
230
+ sections << items_view
231
+
232
+ if @show_pagination && @filtered_items.length > visible_item_count
233
+ sections << ""
234
+ pagination = @paginator.view
235
+ sections << ((style = @pagination_style) ? style.render(pagination) : pagination)
236
+ end
237
+
238
+ if @show_status_bar && !@status_message.empty?
239
+ sections << ""
240
+ status = (style = @status_bar_style) ? style.render(@status_message) : @status_message
241
+ sections << status
242
+ end
243
+
244
+ sections.join("\n")
245
+ end
246
+
247
+ private
248
+
249
+ #: () -> String
250
+ def items_view
251
+ return "No items" if @filtered_items.empty?
252
+
253
+ lines = [] #: Array[String]
254
+ end_index = [@offset + visible_item_count, @filtered_items.length].min
255
+
256
+ (@offset...end_index).each do |i|
257
+ item = @filtered_items[i]
258
+ is_selected = i == @selected
259
+
260
+ line = render_item(item, i, is_selected)
261
+ lines << line
262
+ end
263
+
264
+ (lines << "" while lines.length < visible_item_count) if @fill_height
265
+
266
+ lines.join("\n")
267
+ end
268
+
269
+ #: (untyped, Integer, bool) -> String
270
+ def render_item(item, _index, selected)
271
+ text = item_title(item)
272
+
273
+ if selected
274
+ prefix = "> "
275
+ if (style = @selected_item_style)
276
+ style.render(prefix + text)
277
+ else
278
+ "\e[7m#{prefix}#{text}\e[0m"
279
+ end
280
+ else
281
+ prefix = " "
282
+ if (style = @item_style)
283
+ style.render(prefix + text)
284
+ else
285
+ prefix + text
286
+ end
287
+ end
288
+ end
289
+
290
+ #: (untyped) -> String
291
+ def item_title(item)
292
+ if item.respond_to?(:title)
293
+ item.title
294
+ elsif item.is_a?(Hash) && item[:title]
295
+ item[:title]
296
+ elsif item.respond_to?(:filter_value)
297
+ item.filter_value
298
+ else
299
+ item.to_s
300
+ end
301
+ end
302
+
303
+ #: (untyped) -> String
304
+ def item_filter_value(item)
305
+ if item.respond_to?(:filter_value)
306
+ item.filter_value
307
+ elsif item.respond_to?(:title)
308
+ item.title
309
+ elsif item.is_a?(Hash) && item[:title]
310
+ item[:title]
311
+ else
312
+ item.to_s
313
+ end
314
+ end
315
+
316
+ #: () -> void
317
+ def do_filter
318
+ query = @filter_input.value.downcase
319
+
320
+ @filtered_items = if query.empty?
321
+ @items.dup
322
+ else
323
+ @items.select do |item|
324
+ item_filter_value(item).downcase.include?(query)
325
+ end
326
+ end
327
+
328
+ @selected = 0 if @selected >= @filtered_items.length
329
+ update_offset
330
+ update_pagination
331
+ end
332
+
333
+ #: () -> Integer
334
+ def visible_item_count
335
+ overhead = 0
336
+
337
+ overhead += 2 if @show_title # Title + blank line
338
+ overhead += 2 if @show_filter && @filter_state != UNFILTERED # Filter + blank line
339
+ overhead += 2 if @show_pagination && @filtered_items.length > @height - overhead
340
+ overhead += 2 if @show_status_bar && !@status_message.empty?
341
+
342
+ [@height - overhead, 1].max
343
+ end
344
+
345
+ #: () -> void
346
+ def update_offset
347
+ visible = visible_item_count
348
+
349
+ if @selected < @offset
350
+ @offset = @selected
351
+ elsif @selected >= @offset + visible
352
+ @offset = @selected - visible + 1
353
+ end
354
+
355
+ @offset = @offset.clamp(0, [@filtered_items.length - visible, 0].max)
356
+ end
357
+
358
+ #: () -> void
359
+ def update_pagination
360
+ @paginator.per_page = visible_item_count
361
+ @paginator.update_total_pages(@filtered_items.length)
362
+ @paginator.page = @offset / [visible_item_count, 1].max
363
+ end
364
+ end
365
+ end
@@ -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