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,543 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ # TextInput is a single-line text input component.
6
+ #
7
+ # Example:
8
+ # input = Bubbles::TextInput.new
9
+ # input.placeholder = "Enter your name..."
10
+ # input.focus
11
+ #
12
+ # # In update:
13
+ # input, command = input.update(message)
14
+ #
15
+ # # In view:
16
+ # input.view
17
+ #
18
+ class TextInput
19
+ ECHO_NORMAL = :normal #: Symbol
20
+ ECHO_PASSWORD = :password #: Symbol
21
+ ECHO_NONE = :none #: Symbol
22
+
23
+ class PasteMessage < Bubbletea::Message
24
+ attr_reader :text #: String
25
+
26
+ #: (String) -> void
27
+ def initialize(text)
28
+ super()
29
+ @text = text
30
+ end
31
+ end
32
+
33
+ class PasteErrorMessage < Bubbletea::Message
34
+ attr_reader :error #: StandardError
35
+
36
+ #: (StandardError) -> void
37
+ def initialize(error)
38
+ super()
39
+
40
+ @error = error
41
+ end
42
+ end
43
+
44
+ attr_accessor :prompt #: String
45
+ attr_accessor :placeholder #: String
46
+ attr_accessor :echo_mode #: Symbol
47
+ attr_accessor :echo_character #: String
48
+ attr_accessor :char_limit #: Integer
49
+ attr_accessor :width #: Integer
50
+ attr_accessor :prompt_style #: Lipgloss::Style?
51
+ attr_accessor :text_style #: Lipgloss::Style?
52
+ attr_accessor :placeholder_style #: Lipgloss::Style?
53
+ attr_accessor :cursor_style #: Lipgloss::Style?
54
+ attr_accessor :validate #: (^(String) -> StandardError?)?
55
+ attr_accessor :show_suggestions #: bool
56
+
57
+ attr_reader :position #: Integer
58
+ attr_reader :cursor #: Cursor
59
+ attr_reader :error #: StandardError?
60
+
61
+ #: () -> void
62
+ def initialize
63
+ @prompt = "> "
64
+ @placeholder = ""
65
+ @echo_mode = ECHO_NORMAL
66
+ @echo_character = "*"
67
+ @char_limit = 0
68
+ @width = 0
69
+
70
+ @prompt_style = nil
71
+ @text_style = nil
72
+ @placeholder_style = nil
73
+ @cursor_style = nil
74
+
75
+ @cursor = Cursor.new
76
+ @value = [] #: Array[String]
77
+ @position = 0
78
+ @focus = false
79
+ @offset = 0
80
+ @offset_right = 0
81
+
82
+ @validate = nil
83
+ @error = nil
84
+
85
+ @show_suggestions = false
86
+ @suggestions = [] #: Array[Array[String]]
87
+ @matched_suggestions = [] #: Array[Array[String]]
88
+ @current_suggestion_index = 0
89
+ end
90
+
91
+ #: () -> String
92
+ def value
93
+ @value.join
94
+ end
95
+
96
+ #: (String) -> void
97
+ def value=(text)
98
+ runes = sanitize(text.chars)
99
+ @error = run_validate(runes)
100
+ apply_value(runes)
101
+ update_suggestions
102
+ end
103
+
104
+ #: (Integer) -> void
105
+ def position=(position)
106
+ @position = position.clamp(0, @value.length)
107
+
108
+ handle_overflow
109
+ end
110
+
111
+ #: () -> void
112
+ def cursor_start
113
+ self.position = 0
114
+ end
115
+
116
+ #: () -> void
117
+ def cursor_end
118
+ self.position = @value.length
119
+ end
120
+
121
+ #: () -> bool
122
+ def focused?
123
+ @focus
124
+ end
125
+
126
+ #: () -> Bubbletea::Command?
127
+ def focus
128
+ @focus = true
129
+ @cursor.focus
130
+ end
131
+
132
+ #: () -> void
133
+ def blur
134
+ @focus = false
135
+ @cursor.blur
136
+ end
137
+
138
+ #: () -> void
139
+ def reset
140
+ @value = [] #: Array[String]
141
+ self.position = 0
142
+ end
143
+
144
+ #: (Array[String]) -> void
145
+ def suggestions=(suggestions)
146
+ @suggestions = suggestions.map(&:chars)
147
+ update_suggestions
148
+ end
149
+
150
+ #: (Bubbletea::Message) -> [TextInput, Bubbletea::Command?]
151
+ def update(message)
152
+ return [self, nil] unless @focus
153
+
154
+ old_pos = @position
155
+
156
+ case message
157
+ when Bubbletea::KeyMessage
158
+ handle_key(message)
159
+ update_suggestions
160
+
161
+ when PasteMessage
162
+ insert_runes(message.text.chars)
163
+
164
+ when PasteErrorMessage
165
+ @error = message.error
166
+
167
+ when Cursor::BlinkMessage, Cursor::InitialBlinkMessage
168
+ @cursor, command = @cursor.update(message)
169
+ return [self, command]
170
+ end
171
+
172
+ commands = [] #: Array[Bubbletea::Command]
173
+
174
+ @cursor, command = @cursor.update(message)
175
+ commands << command if command
176
+
177
+ if old_pos != @position && @cursor.mode == Cursor::MODE_BLINK
178
+ @cursor.instance_variable_set(:@blink, false)
179
+ commands << @cursor.send(:blink_command)
180
+ end
181
+
182
+ handle_overflow
183
+
184
+ [self, commands.empty? ? nil : Bubbletea.batch(*commands)]
185
+ end
186
+
187
+ #: () -> String
188
+ def view
189
+ return placeholder_view if @value.empty? && !@placeholder.empty?
190
+
191
+ prompt_text = render_prompt
192
+
193
+ visible_value = @value[@offset...@offset_right] || []
194
+ position = [0, @position - @offset].max
195
+
196
+ before = echo_transform(visible_value[0...position].join)
197
+ v = render_text(before)
198
+
199
+ if position < visible_value.length
200
+ char = echo_transform(visible_value[position])
201
+ @cursor.char = char
202
+ v += @cursor.view
203
+ after = echo_transform(visible_value[(position + 1)..].join)
204
+ v += render_text(after)
205
+ else
206
+ suggestion_text = current_suggestion_remainder
207
+
208
+ if suggestion_text && !suggestion_text.empty? && (first_char = suggestion_text[0])
209
+ @cursor.char = first_char
210
+ v += @cursor.view
211
+ v += "\e[90m#{suggestion_text[1..]}\e[0m" if suggestion_text.length > 1
212
+ else
213
+ @cursor.char = " "
214
+ v += @cursor.view
215
+ end
216
+ end
217
+
218
+ prompt_text + v
219
+ end
220
+
221
+ private
222
+
223
+ #: (Bubbletea::KeyMessage) -> void
224
+ def handle_key(message)
225
+ key_name = message.to_s
226
+
227
+ case key_name
228
+ when "backspace", "ctrl+h"
229
+ delete_character_backward
230
+ when "delete", "ctrl+d"
231
+ delete_character_forward
232
+ when "left", "ctrl+b"
233
+ self.position = @position - 1 if @position.positive?
234
+ when "right", "ctrl+f"
235
+ self.position = @position + 1 if @position < @value.length
236
+ when "home", "ctrl+a"
237
+ cursor_start
238
+ when "end", "ctrl+e"
239
+ cursor_end
240
+ when "ctrl+k"
241
+ delete_after_cursor
242
+ when "ctrl+u"
243
+ delete_before_cursor
244
+ when "ctrl+w", "alt+backspace"
245
+ delete_word_backward
246
+ when "alt+d", "alt+delete"
247
+ delete_word_forward
248
+ when "alt+b", "alt+left", "ctrl+left"
249
+ word_backward
250
+ when "alt+f", "alt+right", "ctrl+right"
251
+ word_forward
252
+ when "tab"
253
+ accept_suggestion if can_accept_suggestion?
254
+ when "down", "ctrl+n"
255
+ next_suggestion
256
+ when "up", "ctrl+p"
257
+ previous_suggestion
258
+ else
259
+ return if key_name.start_with?("ctrl+", "alt+", "meta+", "shift+")
260
+ return if key_name.start_with?("f") && key_name.length > 1 && key_name[1..].match?(/^\d+$/) # F1-F12
261
+
262
+ if message.respond_to?(:runes) && message.runes && !message.runes.empty?
263
+ chars = message.runes.map do |r|
264
+ r.chr(Encoding::UTF_8)
265
+ rescue StandardError
266
+ nil
267
+ end.compact
268
+ insert_runes(chars) unless chars.empty?
269
+ elsif key_name.length == 1
270
+ insert_runes([key_name])
271
+ end
272
+ end
273
+ end
274
+
275
+ #: () -> void
276
+ def delete_character_backward
277
+ return if @value.empty? || @position.zero?
278
+
279
+ @value = @value[0...(@position - 1)] + @value[@position..]
280
+ @error = run_validate(@value)
281
+ self.position = @position - 1
282
+ end
283
+
284
+ #: () -> void
285
+ def delete_character_forward
286
+ return if @value.empty? || @position >= @value.length
287
+
288
+ @value = @value[0...@position] + @value[(@position + 1)..]
289
+ @error = run_validate(@value)
290
+ end
291
+
292
+ #: () -> void
293
+ def delete_before_cursor
294
+ @value = @value[@position..]
295
+ @error = run_validate(@value)
296
+ @offset = 0
297
+ self.position = 0
298
+ end
299
+
300
+ #: () -> void
301
+ def delete_after_cursor
302
+ @value = @value[0...@position]
303
+ @error = run_validate(@value)
304
+ self.position = @value.length
305
+ end
306
+
307
+ #: () -> void
308
+ def delete_word_backward
309
+ return if @position.zero? || @value.empty?
310
+
311
+ return delete_before_cursor if @echo_mode != ECHO_NORMAL
312
+
313
+ old_pos = @position
314
+
315
+ self.position = @position - 1 while @position.positive? && @value[@position - 1] =~ /\s/
316
+ self.position = @position - 1 while @position.positive? && @value[@position - 1] !~ /\s/
317
+
318
+ @value = @value[0...@position] + @value[old_pos..]
319
+ @error = run_validate(@value)
320
+ end
321
+
322
+ #: () -> void
323
+ def delete_word_forward
324
+ return if @position >= @value.length || @value.empty?
325
+
326
+ return delete_after_cursor if @echo_mode != ECHO_NORMAL
327
+
328
+ old_pos = @position
329
+
330
+ self.position = @position + 1 while @position < @value.length && @value[@position] =~ /\s/
331
+ self.position = @position + 1 while @position < @value.length && @value[@position] !~ /\s/
332
+
333
+ @value = @value[0...old_pos] + @value[@position..]
334
+ @error = run_validate(@value)
335
+
336
+ self.position = old_pos
337
+ end
338
+
339
+ #: () -> void
340
+ def word_backward
341
+ return if @position.zero? || @value.empty?
342
+
343
+ return cursor_start if @echo_mode != ECHO_NORMAL
344
+
345
+ self.position = @position - 1 while @position.positive? && @value[@position - 1] =~ /\s/
346
+ self.position = @position - 1 while @position.positive? && @value[@position - 1] !~ /\s/
347
+ end
348
+
349
+ #: () -> void
350
+ def word_forward
351
+ return if @position >= @value.length || @value.empty?
352
+
353
+ return cursor_end if @echo_mode != ECHO_NORMAL
354
+
355
+ self.position = @position + 1 while @position < @value.length && @value[@position] =~ /\s/
356
+ self.position = @position + 1 while @position < @value.length && @value[@position] !~ /\s/
357
+ end
358
+
359
+ #: (Array[String]) -> void
360
+ def insert_runes(runes)
361
+ insert_chars = sanitize(runes)
362
+
363
+ if @char_limit.positive?
364
+ avail = @char_limit - @value.length
365
+ return if avail <= 0
366
+
367
+ insert_chars = insert_chars[0...avail] || [] if insert_chars.length > avail
368
+ end
369
+
370
+ head = @value[0...@position] || [] #: Array[String]
371
+ tail = @value[@position..] || [] #: Array[String]
372
+
373
+ insert_chars.each do |r|
374
+ head << r
375
+ @position += 1
376
+ end
377
+
378
+ @value = head + tail
379
+ @error = run_validate(@value)
380
+ handle_overflow
381
+ end
382
+
383
+ #: (Array[String]) -> Array[String]
384
+ def sanitize(runes)
385
+ runes.map { |r| r =~ /[\t\n\r]/ ? " " : r }
386
+ end
387
+
388
+ #: () -> void
389
+ def handle_overflow
390
+ if @width <= 0
391
+ @offset = 0
392
+ @offset_right = @value.length
393
+ return
394
+ end
395
+
396
+ text_width = @value.join.length
397
+
398
+ if text_width <= @width
399
+ @offset = 0
400
+ @offset_right = @value.length
401
+ return
402
+ end
403
+
404
+ @offset_right = [@offset_right, @value.length].min
405
+
406
+ if @position < @offset
407
+ @offset = @position
408
+ @offset_right = [@offset + @width, @value.length].min
409
+ elsif @position >= @offset_right
410
+ @offset_right = @position + 1
411
+ @offset = [@offset_right - @width, 0].max
412
+ end
413
+ end
414
+
415
+ #: (String) -> String
416
+ def echo_transform(text)
417
+ case @echo_mode
418
+ when ECHO_PASSWORD
419
+ @echo_character * text.length
420
+ when ECHO_NONE
421
+ ""
422
+ else
423
+ text
424
+ end
425
+ end
426
+
427
+ #: (Array[String]) -> void
428
+ def apply_value(runes)
429
+ @value = if @char_limit.positive? && runes.length > @char_limit
430
+ runes[0...@char_limit]
431
+ else
432
+ runes
433
+ end
434
+
435
+ self.position = @value.length if @position.zero? || @position > @value.length
436
+
437
+ handle_overflow
438
+ end
439
+
440
+ #: (Array[String]) -> StandardError?
441
+ def run_validate(runes)
442
+ validate = @validate
443
+ return nil unless validate
444
+
445
+ validate.call(runes.join)
446
+ rescue StandardError => e
447
+ e
448
+ end
449
+
450
+ #: () -> String
451
+ def render_prompt
452
+ (style = @prompt_style) ? style.render(@prompt) : @prompt
453
+ end
454
+
455
+ #: (String) -> String
456
+ def render_text(text)
457
+ (style = @text_style) ? style.render(text) : text
458
+ end
459
+
460
+ #: (String) -> String
461
+ def render_placeholder(text)
462
+ (style = @placeholder_style) ? style.render(text) : "\e[90m#{text}\e[0m"
463
+ end
464
+
465
+ #: () -> String
466
+ def placeholder_view
467
+ prompt_text = render_prompt
468
+
469
+ first_char = @placeholder[0] || ""
470
+ rest = @placeholder[1..] || ""
471
+
472
+ @cursor.char = first_char
473
+ @cursor.text_style = @placeholder_style
474
+
475
+ v = @cursor.view
476
+ v += render_placeholder(rest)
477
+
478
+ @cursor.text_style = nil
479
+
480
+ prompt_text + v
481
+ end
482
+
483
+ #: () -> String?
484
+ def current_suggestion_remainder
485
+ return nil unless @show_suggestions
486
+ return nil if @matched_suggestions.empty?
487
+ return nil if @value.empty?
488
+
489
+ suggestion = @matched_suggestions[@current_suggestion_index]
490
+ return nil unless suggestion
491
+
492
+ remaining = suggestion[@value.length..]
493
+ return nil if remaining.nil? || remaining.empty?
494
+
495
+ remaining.join
496
+ end
497
+
498
+ #: () -> void
499
+ def update_suggestions
500
+ return unless @show_suggestions
501
+
502
+ if @value.empty? || @suggestions.empty?
503
+ @matched_suggestions = [] #: Array[Array[String]]
504
+ return
505
+ end
506
+
507
+ value_string = @value.join.downcase
508
+ matches = @suggestions.select do |suggestion|
509
+ suggestion.join.downcase.start_with?(value_string)
510
+ end
511
+
512
+ @current_suggestion_index = 0 if matches != @matched_suggestions
513
+
514
+ @matched_suggestions = matches
515
+ end
516
+
517
+ #: () -> bool
518
+ def can_accept_suggestion?
519
+ !@matched_suggestions.empty?
520
+ end
521
+
522
+ #: () -> void
523
+ def accept_suggestion
524
+ return unless can_accept_suggestion?
525
+
526
+ suggestion = @matched_suggestions[@current_suggestion_index]
527
+ @value = suggestion.dup
528
+
529
+ cursor_end
530
+ end
531
+
532
+ #: () -> void
533
+ def next_suggestion
534
+ @current_suggestion_index = (@current_suggestion_index + 1) % [@matched_suggestions.length, 1].max
535
+ end
536
+
537
+ #: () -> void
538
+ def previous_suggestion
539
+ @current_suggestion_index -= 1
540
+ @current_suggestion_index = @matched_suggestions.length - 1 if @current_suggestion_index.negative?
541
+ end
542
+ end
543
+ end