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,170 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ # Help renders help text from key bindings.
6
+ #
7
+ # Example:
8
+ # help = Bubbles::Help.new
9
+ # help.width = 80
10
+ #
11
+ # # Define a keymap with short_help and full_help methods
12
+ # class MyKeyMap
13
+ # BINDINGS = {
14
+ # up: Bubbles::Key.binding(keys: ["up", "k"], help: ["↑/k", "up"]),
15
+ # down: Bubbles::Key.binding(keys: ["down", "j"], help: ["↓/j", "down"]),
16
+ # help: Bubbles::Key.binding(keys: ["?"], help: ["?", "help"]),
17
+ # quit: Bubbles::Key.binding(keys: ["q"], help: ["q", "quit"])
18
+ # }
19
+ #
20
+ # def short_help
21
+ # [BINDINGS[:help], BINDINGS[:quit]]
22
+ # end
23
+ #
24
+ # def full_help
25
+ # [
26
+ # [BINDINGS[:up], BINDINGS[:down]],
27
+ # [BINDINGS[:help], BINDINGS[:quit]]
28
+ # ]
29
+ # end
30
+ # end
31
+ #
32
+ # keymap = MyKeyMap.new
33
+ # puts help.view(keymap)
34
+ #
35
+ class Help
36
+ DEFAULT_KEY_STYLE = nil #: Lipgloss::Style?
37
+ DEFAULT_DESC_STYLE = nil #: Lipgloss::Style?
38
+ DEFAULT_SEPARATOR_STYLE = nil #: Lipgloss::Style?
39
+
40
+ attr_accessor :width #: Integer
41
+ attr_accessor :show_all #: bool
42
+ attr_accessor :short_separator #: String
43
+ attr_accessor :full_separator #: String
44
+ attr_accessor :full_help_column_gap #: Integer
45
+ attr_accessor :key_style #: Lipgloss::Style?
46
+ attr_accessor :desc_style #: Lipgloss::Style?
47
+ attr_accessor :separator_style #: Lipgloss::Style?
48
+
49
+ #: () -> void
50
+ def initialize
51
+ @width = 0
52
+ @show_all = false
53
+ @short_separator = " • "
54
+ @full_separator = " "
55
+ @full_help_column_gap = 2
56
+ @key_style = nil
57
+ @desc_style = nil
58
+ @separator_style = nil
59
+ end
60
+
61
+ #: (untyped) -> String
62
+ def view(keymap)
63
+ if @show_all && keymap.respond_to?(:full_help)
64
+ full_help_view(keymap.full_help)
65
+ elsif keymap.respond_to?(:short_help)
66
+ short_help_view(keymap.short_help)
67
+ else
68
+ ""
69
+ end
70
+ end
71
+
72
+ #: (Array[Key::Binding]?) -> String
73
+ def short_help_view(bindings)
74
+ return "" if bindings.nil? || bindings.empty?
75
+
76
+ parts = bindings.filter_map do |binding|
77
+ next unless binding.is_a?(Key::Binding)
78
+ next unless binding.enabled? && binding.help?
79
+
80
+ render_binding(binding)
81
+ end
82
+
83
+ return "" if parts.empty?
84
+
85
+ separator = render_separator(@short_separator)
86
+ result = parts.join(separator)
87
+
88
+ truncate_to_width(result)
89
+ end
90
+
91
+ #: (Array[Array[Key::Binding]]?) -> String
92
+ def full_help_view(binding_groups)
93
+ return "" if binding_groups.nil? || binding_groups.empty?
94
+
95
+ columns = binding_groups.map do |group|
96
+ group.filter_map do |binding|
97
+ next unless binding.is_a?(Key::Binding)
98
+ next unless binding.enabled? && binding.help?
99
+
100
+ render_binding(binding)
101
+ end
102
+ end
103
+
104
+ columns.reject!(&:empty?)
105
+ return "" if columns.empty?
106
+
107
+ max_rows = columns.map(&:length).max
108
+
109
+ rows = (0...max_rows).map do |row_index|
110
+ row_parts = columns.map do |col|
111
+ col[row_index] || ""
112
+ end
113
+
114
+ row_parts.join(@full_separator)
115
+ end
116
+
117
+ result = rows.join("\n")
118
+
119
+ truncate_to_width(result)
120
+ end
121
+
122
+ private
123
+
124
+ #: (Key::Binding) -> String
125
+ def render_binding(binding)
126
+ key, desc = binding.help
127
+ key_rendered = render_key(key)
128
+ desc_rendered = render_desc(desc)
129
+
130
+ "#{key_rendered} #{desc_rendered}"
131
+ end
132
+
133
+ #: (String) -> String
134
+ def render_key(text)
135
+ (style = @key_style) ? style.render(text) : text
136
+ end
137
+
138
+ #: (String) -> String
139
+ def render_desc(text)
140
+ (style = @desc_style) ? style.render(text) : text
141
+ end
142
+
143
+ #: (String) -> String
144
+ def render_separator(text)
145
+ (style = @separator_style) ? style.render(text) : text
146
+ end
147
+
148
+ #: (String) -> String
149
+ def truncate_to_width(text)
150
+ return text if @width <= 0
151
+
152
+ lines = text.split("\n")
153
+
154
+ lines.map do |line|
155
+ plain = strip_ansi(line)
156
+
157
+ if plain.length > @width
158
+ line[0, @width]
159
+ else
160
+ line
161
+ end
162
+ end.join("\n")
163
+ end
164
+
165
+ #: (String) -> String
166
+ def strip_ansi(str)
167
+ str.gsub(/\e\[[0-9;]*[A-Za-z]/, "")
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ # Key provides helpers for defining and matching key bindings.
6
+ #
7
+ # Example:
8
+ # # Define key bindings
9
+ # KEYS = {
10
+ # up: Bubbles::Key.binding(
11
+ # keys: ["up", "k"],
12
+ # help: ["↑/k", "move up"]
13
+ # ),
14
+ # down: Bubbles::Key.binding(
15
+ # keys: ["down", "j"],
16
+ # help: ["↓/j", "move down"]
17
+ # ),
18
+ # quit: Bubbles::Key.binding(
19
+ # keys: ["q", "esc", "ctrl+c"],
20
+ # help: ["q", "quit"]
21
+ # )
22
+ # }
23
+ #
24
+ # # Match in update
25
+ # def update(message)
26
+ # case message
27
+ # when Bubbletea::KeyMessage
28
+ # if Bubbles::Key.matches?(message, KEYS[:up])
29
+ # # handle up
30
+ # elsif Bubbles::Key.matches?(message, KEYS[:down])
31
+ # # handle down
32
+ # end
33
+ # end
34
+ # end
35
+ #
36
+ module Key
37
+ class Binding
38
+ attr_accessor :keys #: Array[String]
39
+ attr_accessor :help_key #: String?
40
+ attr_accessor :help_desc #: String?
41
+ attr_accessor :enabled #: bool
42
+
43
+ #: (keys: Array[String], ?help_key: String?, ?help_desc: String?, ?enabled: bool) -> void
44
+ def initialize(keys:, help_key: nil, help_desc: nil, enabled: true)
45
+ @keys = Array(keys)
46
+ @help_key = help_key
47
+ @help_desc = help_desc
48
+ @enabled = enabled
49
+ end
50
+
51
+ #: () -> bool
52
+ def enabled?
53
+ @enabled
54
+ end
55
+
56
+ #: () -> bool
57
+ def help?
58
+ !!((key = @help_key) && !key.empty? && (desc = @help_desc) && !desc.empty?)
59
+ end
60
+
61
+ #: () -> [String, String]
62
+ def help
63
+ [@help_key || "", @help_desc || ""]
64
+ end
65
+ end
66
+
67
+ class << self
68
+ #: (keys: Array[String] | String, ?help: Array[String]?, ?enabled: bool) -> Binding
69
+ def binding(keys:, help: nil, enabled: true)
70
+ help_key, help_desc = help || [nil, nil]
71
+ keys_array = keys.is_a?(Array) ? keys : [keys] #: Array[String]
72
+
73
+ Binding.new(
74
+ keys: keys_array,
75
+ help_key: help_key,
76
+ help_desc: help_desc,
77
+ enabled: enabled
78
+ )
79
+ end
80
+
81
+ #: (Bubbletea::KeyMessage, *Binding) -> bool
82
+ def matches?(message, *bindings)
83
+ return false unless message.is_a?(Bubbletea::KeyMessage)
84
+
85
+ key_string = message.to_s
86
+
87
+ bindings.flatten.any? do |binding|
88
+ next false unless binding.is_a?(Binding)
89
+ next false unless binding.enabled?
90
+
91
+ binding.keys.any? { |k| k == key_string }
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -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