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,503 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ # TextArea is a multi-line text input component.
6
+ #
7
+ # Example:
8
+ # textarea = Bubbles::TextArea.new
9
+ # textarea.placeholder = "Enter text..."
10
+ # textarea.focus
11
+ #
12
+ # # In update:
13
+ # textarea, command = textarea.update(message)
14
+ #
15
+ # # In view:
16
+ # textarea.view
17
+ #
18
+ class TextArea
19
+ DEFAULT_WIDTH = 40 #: Integer
20
+ DEFAULT_HEIGHT = 6 #: Integer
21
+ DEFAULT_CHAR_LIMIT = 0 #: Integer
22
+
23
+ class PasteMessage < Bubbletea::Message
24
+ attr_reader :text #: String
25
+
26
+ #: (String) -> void
27
+ def initialize(text)
28
+ super()
29
+
30
+ @text = text
31
+ end
32
+ end
33
+
34
+ attr_accessor :width #: Integer
35
+ attr_accessor :height #: Integer
36
+ attr_accessor :char_limit #: Integer
37
+ attr_accessor :placeholder #: String
38
+ attr_accessor :prompt #: String
39
+ attr_accessor :show_line_numbers #: bool
40
+ attr_accessor :end_of_buffer_character #: String
41
+ attr_accessor :placeholder_style #: Lipgloss::Style?
42
+ attr_accessor :prompt_style #: Lipgloss::Style?
43
+ attr_accessor :text_style #: Lipgloss::Style?
44
+ attr_accessor :line_number_style #: Lipgloss::Style?
45
+
46
+ attr_reader :cursor #: Cursor
47
+ attr_reader :error #: StandardError?
48
+
49
+ attr_reader :row #: Integer
50
+ attr_reader :col #: Integer
51
+
52
+ #: (?width: Integer, ?height: Integer) -> void
53
+ def initialize(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
54
+ @width = width
55
+ @height = height
56
+ @char_limit = DEFAULT_CHAR_LIMIT
57
+
58
+ @placeholder = ""
59
+ @prompt = ""
60
+ @show_line_numbers = false
61
+ @end_of_buffer_character = "~"
62
+
63
+ @placeholder_style = nil
64
+ @prompt_style = nil
65
+ @text_style = nil
66
+ @line_number_style = nil
67
+
68
+ @cursor = Cursor.new
69
+ @lines = [""]
70
+ @row = 0
71
+ @col = 0
72
+ @focus = false
73
+ @viewport_offset = 0
74
+
75
+ @error = nil
76
+ end
77
+
78
+ #: () -> String
79
+ def value
80
+ @lines.join("\n")
81
+ end
82
+
83
+ #: (String) -> void
84
+ def value=(text)
85
+ @lines = text.split("\n", -1)
86
+ @lines = [""] if @lines.empty?
87
+ @row = @lines.length - 1
88
+ @col = @lines[@row].length
89
+ update_viewport
90
+ end
91
+
92
+ #: () -> Integer
93
+ def line_count
94
+ @lines.length
95
+ end
96
+
97
+ #: () -> String
98
+ def current_line
99
+ @lines[@row] || ""
100
+ end
101
+
102
+ #: () -> bool
103
+ def focused?
104
+ @focus
105
+ end
106
+
107
+ #: () -> Bubbletea::Command?
108
+ def focus
109
+ @focus = true
110
+ @cursor.focus
111
+ end
112
+
113
+ #: () -> void
114
+ def blur
115
+ @focus = false
116
+ @cursor.blur
117
+ end
118
+
119
+ #: () -> void
120
+ def reset
121
+ @lines = [""]
122
+ @row = 0
123
+ @col = 0
124
+ @viewport_offset = 0
125
+ end
126
+
127
+ #: (Bubbletea::Message) -> [TextArea, Bubbletea::Command?]
128
+ def update(message)
129
+ return [self, nil] unless @focus
130
+
131
+ case message
132
+ when Bubbletea::KeyMessage
133
+ handle_key(message)
134
+ when PasteMessage
135
+ insert_text(message.text)
136
+ when Cursor::BlinkMessage, Cursor::InitialBlinkMessage
137
+ @cursor, command = @cursor.update(message)
138
+ return [self, command]
139
+ end
140
+
141
+ commands = [] #: Array[Bubbletea::Command]
142
+ @cursor, command = @cursor.update(message)
143
+ commands << command if command
144
+
145
+ update_viewport
146
+
147
+ [self, commands.empty? ? nil : Bubbletea.batch(*commands)]
148
+ end
149
+
150
+ #: () -> String
151
+ def view
152
+ return placeholder_view if @lines == [""] && !@placeholder.empty?
153
+
154
+ visible_lines = [] #: Array[String]
155
+ end_row = [@viewport_offset + @height, @lines.length].min
156
+
157
+ (@viewport_offset...end_row).each do |i|
158
+ line = render_line(i)
159
+ visible_lines << line
160
+ end
161
+
162
+ while visible_lines.length < @height
163
+ visible_lines << if @show_line_numbers
164
+ " #{render_end_of_buffer}"
165
+ else
166
+ render_end_of_buffer
167
+ end
168
+ end
169
+
170
+ visible_lines.join("\n")
171
+ end
172
+
173
+ private
174
+
175
+ #: (Bubbletea::KeyMessage) -> void
176
+ def handle_key(message)
177
+ key_name = message.to_s
178
+
179
+ case key_name
180
+ when "enter"
181
+ insert_newline
182
+ when "backspace", "ctrl+h"
183
+ delete_backward
184
+ when "delete", "ctrl+d"
185
+ delete_forward
186
+ when "left", "ctrl+b"
187
+ move_left
188
+ when "right", "ctrl+f"
189
+ move_right
190
+ when "up", "ctrl+p"
191
+ move_up
192
+ when "down", "ctrl+n"
193
+ move_down
194
+ when "home", "ctrl+a"
195
+ line_start
196
+ when "end", "ctrl+e"
197
+ line_end
198
+ when "ctrl+k"
199
+ delete_to_end
200
+ when "ctrl+u"
201
+ delete_to_start
202
+ when "alt+backspace", "ctrl+w"
203
+ delete_word_backward
204
+ when "alt+b", "alt+left"
205
+ word_backward
206
+ when "alt+f", "alt+right"
207
+ word_forward
208
+ else
209
+ return if key_name.start_with?("ctrl+", "alt+", "meta+", "shift+")
210
+ return if key_name.start_with?("f") && key_name.length > 1 && key_name[1..].match?(/^\d+$/) # F1-F12
211
+
212
+ if message.respond_to?(:runes) && message.runes && !message.runes.empty?
213
+ chars = message.runes.map do |rune|
214
+ rune.chr(Encoding::UTF_8)
215
+ rescue StandardError
216
+ nil
217
+ end.compact
218
+
219
+ insert_text(chars.join) unless chars.empty?
220
+ elsif key_name.length == 1
221
+ insert_text(key_name)
222
+ end
223
+ end
224
+ end
225
+
226
+ #: (String) -> void
227
+ def insert_text(text)
228
+ return if text.empty?
229
+
230
+ insert_str = text
231
+
232
+ if @char_limit.positive?
233
+ current_len = total_length
234
+ available = @char_limit - current_len
235
+ return if available <= 0
236
+
237
+ insert_str = text[0...available] || "" if text.length > available
238
+ end
239
+
240
+ if insert_str.include?("\n")
241
+ parts = insert_str.split("\n", -1)
242
+ before = @lines[@row][0...@col]
243
+ after = @lines[@row][@col..]
244
+
245
+ @lines[@row] = before + parts[0]
246
+
247
+ rest_parts = parts[1..] || [] #: Array[String]
248
+ rest_parts.each do |part|
249
+ @row += 1
250
+ @lines.insert(@row, part)
251
+ end
252
+
253
+ @lines[@row] += after
254
+ @col = @lines[@row].length - after.length
255
+ else
256
+ @lines[@row] = @lines[@row][0...@col] + insert_str + @lines[@row][@col..]
257
+ @col += insert_str.length
258
+ end
259
+ end
260
+
261
+ #: () -> void
262
+ def insert_newline
263
+ before = @lines[@row][0...@col]
264
+ after = @lines[@row][@col..]
265
+
266
+ @lines[@row] = before
267
+ @row += 1
268
+ @lines.insert(@row, after)
269
+ @col = 0
270
+ end
271
+
272
+ #: () -> void
273
+ def delete_backward
274
+ if @col.positive?
275
+ @lines[@row] = @lines[@row][0...(@col - 1)] + @lines[@row][@col..]
276
+ @col -= 1
277
+ elsif @row.positive?
278
+ prev_line_len = @lines[@row - 1].length
279
+ @lines[@row - 1] += @lines[@row]
280
+ @lines.delete_at(@row)
281
+ @row -= 1
282
+ @col = prev_line_len
283
+ end
284
+ end
285
+
286
+ #: () -> void
287
+ def delete_forward
288
+ if @col < @lines[@row].length
289
+ @lines[@row] = @lines[@row][0...@col] + @lines[@row][(@col + 1)..]
290
+ elsif @row < @lines.length - 1
291
+ @lines[@row] += @lines[@row + 1]
292
+ @lines.delete_at(@row + 1)
293
+ end
294
+ end
295
+
296
+ #: () -> void
297
+ def delete_to_end
298
+ @lines[@row] = @lines[@row][0...@col]
299
+ end
300
+
301
+ #: () -> void
302
+ def delete_to_start
303
+ @lines[@row] = @lines[@row][@col..]
304
+ @col = 0
305
+ end
306
+
307
+ #: () -> void
308
+ def delete_word_backward
309
+ return if @col.zero?
310
+
311
+ @col -= 1 while @col.positive? && @lines[@row][@col - 1] =~ /\s/
312
+
313
+ start_col = @col
314
+
315
+ @col -= 1 while @col.positive? && @lines[@row][@col - 1] !~ /\s/
316
+
317
+ @lines[@row] = @lines[@row][0...@col] + @lines[@row][start_col..]
318
+ end
319
+
320
+ #: () -> void
321
+ def move_left
322
+ if @col.positive?
323
+ @col -= 1
324
+ elsif @row.positive?
325
+ @row -= 1
326
+ @col = @lines[@row].length
327
+ end
328
+ end
329
+
330
+ #: () -> void
331
+ def move_right
332
+ if @col < @lines[@row].length
333
+ @col += 1
334
+ elsif @row < @lines.length - 1
335
+ @row += 1
336
+ @col = 0
337
+ end
338
+ end
339
+
340
+ #: () -> void
341
+ def move_up
342
+ return unless @row.positive?
343
+
344
+ @row -= 1
345
+ @col = [@col, @lines[@row].length].min
346
+ end
347
+
348
+ #: () -> void
349
+ def move_down
350
+ return unless @row < @lines.length - 1
351
+
352
+ @row += 1
353
+ @col = [@col, @lines[@row].length].min
354
+ end
355
+
356
+ #: () -> void
357
+ def line_start
358
+ @col = 0
359
+ end
360
+
361
+ #: () -> void
362
+ def line_end
363
+ @col = @lines[@row].length
364
+ end
365
+
366
+ #: () -> void
367
+ def word_backward
368
+ return if @col.zero?
369
+
370
+ @col -= 1 while @col.positive? && @lines[@row][@col - 1] =~ /\s/
371
+ @col -= 1 while @col.positive? && @lines[@row][@col - 1] !~ /\s/
372
+ end
373
+
374
+ #: () -> void
375
+ def word_forward
376
+ return if @col >= @lines[@row].length
377
+
378
+ @col += 1 while @col < @lines[@row].length && @lines[@row][@col] =~ /\s/
379
+ @col += 1 while @col < @lines[@row].length && @lines[@row][@col] !~ /\s/
380
+ end
381
+
382
+ #: () -> void
383
+ def update_viewport
384
+ if @row < @viewport_offset
385
+ @viewport_offset = @row
386
+ elsif @row >= @viewport_offset + @height
387
+ @viewport_offset = @row - @height + 1
388
+ end
389
+ end
390
+
391
+ #: () -> Integer
392
+ def total_length
393
+ @lines.sum(&:length) + @lines.length - 1 # Account for newlines
394
+ end
395
+
396
+ #: (Integer) -> String
397
+ def render_line(index)
398
+ line = @lines[index] || ""
399
+ is_cursor_line = index == @row && @focus
400
+
401
+ prefix_width = 0
402
+ prefix = ""
403
+
404
+ if @show_line_numbers
405
+ num = (index + 1).to_s.rjust(2)
406
+ prefix = (style = @line_number_style) ? style.render("#{num} ") : "#{num} "
407
+ prefix_width += 3 # "NN "
408
+ end
409
+
410
+ unless @prompt.empty?
411
+ prefix += (style = @prompt_style) ? style.render(@prompt) : @prompt
412
+ prefix_width += @prompt.length
413
+ end
414
+
415
+ available_width = @width - prefix_width
416
+
417
+ h_offset = 0
418
+ h_offset = @col - available_width + 1 if is_cursor_line && @col >= available_width
419
+
420
+ visible_line = line[h_offset, available_width] || ""
421
+
422
+ if is_cursor_line
423
+ cursor_pos = @col - h_offset
424
+ before = visible_line[0...cursor_pos] || ""
425
+ char = visible_line[cursor_pos] || " "
426
+ after = visible_line[(cursor_pos + 1)..] || ""
427
+
428
+ @cursor.char = char
429
+
430
+ text = render_text(before) + @cursor.view
431
+ text += render_text(after) unless after.empty?
432
+ content_width = [visible_line.length, cursor_pos + 1].max
433
+ else
434
+ text = render_text(visible_line)
435
+ content_width = visible_line.length
436
+ end
437
+
438
+ padding_needed = [available_width - content_width, 0].max
439
+ text += " " * padding_needed
440
+
441
+ prefix + text
442
+ end
443
+
444
+ #: (String) -> String
445
+ def render_text(text)
446
+ (style = @text_style) ? style.render(text) : text
447
+ end
448
+
449
+ #: () -> String
450
+ def render_end_of_buffer
451
+ char = @end_of_buffer_character
452
+ prefix_width = @show_line_numbers ? 3 : 0
453
+ prefix_width += @prompt.length unless @prompt.empty?
454
+ available_width = @width - prefix_width
455
+ padding = " " * [available_width - 1, 0].max
456
+
457
+ styled_char = (style = @placeholder_style) ? style.render(char) : "\e[90m#{char}\e[0m"
458
+ styled_char + padding
459
+ end
460
+
461
+ #: () -> String
462
+ def placeholder_view
463
+ first_char = @placeholder[0] || ""
464
+ rest = @placeholder[1..] || ""
465
+
466
+ @cursor.char = first_char
467
+
468
+ prefix = ""
469
+ prefix_width = 0
470
+
471
+ if @show_line_numbers
472
+ prefix = " "
473
+ prefix_width = 3
474
+ end
475
+
476
+ unless @prompt.empty?
477
+ prefix += (style = @prompt_style) ? style.render(@prompt) : @prompt
478
+ prefix_width += @prompt.length
479
+ end
480
+
481
+ text = @cursor.view
482
+ text += (style = @placeholder_style) ? style.render(rest) : "\e[90m#{rest}\e[0m"
483
+
484
+ available_width = @width - prefix_width
485
+ content_width = @placeholder.length
486
+ padding_needed = [available_width - content_width, 0].max
487
+
488
+ text += " " * padding_needed
489
+
490
+ lines = [prefix + text]
491
+
492
+ while lines.length < @height
493
+ lines << if @show_line_numbers
494
+ " #{render_end_of_buffer}"
495
+ else
496
+ render_end_of_buffer
497
+ end
498
+ end
499
+
500
+ lines.join("\n")
501
+ end
502
+ end
503
+ end