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
data/bubbles.gemspec CHANGED
@@ -1,28 +1,36 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
3
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require 'bubbles/version'
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/bubbles/version"
5
4
 
6
5
  Gem::Specification.new do |spec|
7
- spec.name = "bubbles"
8
- spec.version = Bubbles::VERSION
9
- spec.authors = ["Tomas Valent"]
10
- spec.licenses = ['MIT']
11
- spec.email = ["equivalent@eq8.eu"]
6
+ spec.name = "bubbles"
7
+ spec.version = Bubbles::VERSION
8
+ spec.authors = ["Marco Roth"]
9
+ spec.email = ["marco.roth@intergga.ch"]
10
+
11
+ spec.summary = "TUI components for Bubble Tea."
12
+ spec.description = "Ruby port of Charm's Bubbles. Common UI components for building terminal applications with Bubble Tea."
13
+ spec.homepage = "https://github.com/marcoroth/bubbles-ruby"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.2.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/marcoroth/bubbles-ruby"
19
+ spec.metadata["changelog_uri"] = "https://github.com/marcoroth/bubbles-ruby/releases"
20
+ spec.metadata["rubygems_mfa_required"] = "true"
12
21
 
13
- spec.summary = %q{Lightweight daemon file uploader to cloud}
14
- spec.description = %q{Daemonized file uploader that watch a folder and uploads any files files to AWS S3. Designed for Raspberry pi zero}
15
- spec.homepage = "https://github.com/equivalent/bubbles"
22
+ spec.files = Dir[
23
+ "bubbles.gemspec",
24
+ "LICENSE.txt",
25
+ "README.md",
26
+ "{lib,sig}/**/*"
27
+ ]
16
28
 
17
- spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
- f.match(%r{^(test|spec|features)/})
19
- end
20
- spec.bindir = "exe"
21
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
22
31
  spec.require_paths = ["lib"]
23
32
 
24
- spec.add_dependency "aws-sdk", "~> 2"
25
- spec.add_development_dependency "bundler", "~> 1.14"
26
- spec.add_development_dependency "rake", "~> 10.0"
27
- spec.add_development_dependency "rspec", "~> 3.0"
33
+ spec.add_dependency "bubbletea"
34
+ spec.add_dependency "harmonica"
35
+ spec.add_dependency "lipgloss"
28
36
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ module ANSI
6
+ PATTERN = /\e\[[0-9;]*[A-Za-z]/ #: Regexp
7
+
8
+ # Extracts a visible character range from a string while preserving ANSI escape codes.
9
+ # ANSI codes before the start position are carried forward and applied to the result.
10
+ # A reset sequence is appended if the result contains any ANSI codes.
11
+ #
12
+ # @rbs string String -- the input string potentially containing ANSI codes
13
+ # @rbs start_column Integer -- the starting visible character position (0-indexed)
14
+ # @rbs end_column Integer -- the ending visible character position (exclusive)
15
+ # @rbs return String -- the extracted substring with ANSI codes preserved
16
+ #
17
+ def self.cut_string(string, start_column, end_column)
18
+ result = +""
19
+ active_codes = +""
20
+ visible_position = 0
21
+ scanner_position = 0
22
+
23
+ while scanner_position < string.length
24
+ if string[scanner_position..] =~ /\A(#{PATTERN})/
25
+ ansi_sequence = ::Regexp.last_match(1)
26
+ next unless ansi_sequence
27
+
28
+ if visible_position < start_column
29
+ if ansi_sequence == "\e[0m" # rubocop:disable Metrics/BlockNesting
30
+ active_codes = +""
31
+ else
32
+ active_codes << ansi_sequence
33
+ end
34
+ elsif visible_position < end_column
35
+ result << ansi_sequence
36
+ end
37
+
38
+ scanner_position += ansi_sequence.length
39
+ else
40
+ char = string[scanner_position]
41
+ next unless char
42
+
43
+ if visible_position >= start_column && visible_position < end_column
44
+ result << active_codes unless active_codes.empty?
45
+ active_codes = +""
46
+ result << char
47
+ end
48
+
49
+ visible_position += 1
50
+ scanner_position += 1
51
+ end
52
+ end
53
+
54
+ stripped = strip(result)
55
+ return "" if stripped.empty?
56
+
57
+ result << "\e[0m" unless result == stripped
58
+
59
+ result
60
+ end
61
+
62
+ # Removes all ANSI escape codes from a string.
63
+ #
64
+ # @rbs string String -- the input string
65
+ # @rbs return String -- the string with all ANSI codes removed
66
+ #
67
+ def self.strip(string)
68
+ string.gsub(PATTERN, "")
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "lipgloss"
5
+
6
+ module Bubbles
7
+ # CrypticSpinner is an animated activity indicator that displays cycling
8
+ # random characters with gradient colors. Inspired by the Charm CLI crush
9
+ # animation.
10
+ #
11
+ # Example:
12
+ # spinner = Bubbles::CrypticSpinner.new(
13
+ # size: 15,
14
+ # rows: 1,
15
+ # label: "Loading",
16
+ # color_a: "#ff0000",
17
+ # color_b: "#0000ff",
18
+ # cycle_colors: true
19
+ # )
20
+ #
21
+ # Multi-row example (matrix style):
22
+ # spinner = Bubbles::CrypticSpinner.new(
23
+ # size: 30,
24
+ # rows: 5,
25
+ # cycle_colors: true
26
+ # )
27
+ #
28
+ # # In your model's init:
29
+ # def init
30
+ # [self, @spinner.tick]
31
+ # end
32
+ #
33
+ # # In your model's update:
34
+ # def update(message)
35
+ # case message
36
+ # when Bubbles::CrypticSpinner::TickMessage
37
+ # @spinner, command = @spinner.update(message)
38
+ # [self, command]
39
+ # end
40
+ # end
41
+ #
42
+ # # In your model's view:
43
+ # def view
44
+ # @spinner.view
45
+ # end
46
+ #
47
+ class CrypticSpinner
48
+ AVAILABLE_CHARS = "0123456789abcdefABCDEF~!@#$%^&*()+=_".chars.freeze #: Array[String]
49
+ INITIAL_CHAR = "." #: String
50
+ ELLIPSIS_FRAMES = [".", "..", "...", ""].freeze #: Array[String]
51
+
52
+ FPS = 20 #: Integer
53
+ FRAME_DURATION = 1.0 / FPS #: Float
54
+ MAX_BIRTH_OFFSET = 1.0 #: Float
55
+ ELLIPSIS_ANIM_SPEED = 8 #: Integer
56
+
57
+ DEFAULT_SIZE = 10 #: Integer
58
+ DEFAULT_ROWS = 1 #: Integer
59
+ DEFAULT_COLOR_A = "#6B50FF" #: String
60
+ DEFAULT_COLOR_B = "#FF60FF" #: String
61
+ DEFAULT_LABEL_COLOR = "#DFDBDD" #: String
62
+
63
+ class TickMessage < Bubbletea::Message
64
+ attr_reader :id #: Integer
65
+ attr_reader :tag #: Integer
66
+
67
+ #: (id: Integer, tag: Integer) -> void
68
+ def initialize(id:, tag:)
69
+ super()
70
+ @id = id
71
+ @tag = tag
72
+ end
73
+ end
74
+
75
+ # @rbs self.@next_id: Integer
76
+ # @rbs self.@id_mutex: Mutex
77
+ @next_id = 0
78
+ @id_mutex = Mutex.new
79
+
80
+ class << self
81
+ #: () -> Integer
82
+ def next_id
83
+ @id_mutex.synchronize do
84
+ @next_id += 1
85
+ end
86
+ end
87
+ end
88
+
89
+ attr_reader :id #: Integer
90
+ attr_reader :size #: Integer
91
+ attr_reader :rows #: Integer
92
+ attr_accessor :label
93
+ attr_reader :label_color #: String
94
+ attr_reader :color_a #: String
95
+ attr_reader :color_b #: String
96
+ attr_reader :cycle_colors #: bool
97
+
98
+ #: (
99
+ #: ?size: Integer,
100
+ #: ?rows: Integer,
101
+ #: ?label: String,
102
+ #: ?label_color: String,
103
+ #: ?color_a: String,
104
+ #: ?color_b: String,
105
+ #: ?cycle_colors: bool
106
+ #: ) -> void
107
+ def initialize(
108
+ size: DEFAULT_SIZE,
109
+ rows: DEFAULT_ROWS,
110
+ label: "",
111
+ label_color: DEFAULT_LABEL_COLOR,
112
+ color_a: DEFAULT_COLOR_A,
113
+ color_b: DEFAULT_COLOR_B,
114
+ cycle_colors: false
115
+ )
116
+ @id = self.class.next_id
117
+ @tag = 0
118
+ @size = size
119
+ @rows = rows
120
+ @label = label
121
+ @label_color = label_color
122
+ @color_a = color_a
123
+ @color_b = color_b
124
+ @cycle_colors = cycle_colors
125
+
126
+ @step = 0
127
+ @ellipsis_step = 0
128
+ @start_time = Time.now
129
+ @initialized = false
130
+
131
+ @birth_offsets = Array.new(@rows) do |row|
132
+ Array.new(@size) { (rand * MAX_BIRTH_OFFSET) + (row * 0.1) }
133
+ end
134
+
135
+ @gradient = generate_gradient
136
+
137
+ prerender_frames
138
+ end
139
+
140
+ #: () -> [CrypticSpinner, Bubbletea::Command]
141
+ def init
142
+ [self, tick]
143
+ end
144
+
145
+ #: (Bubbletea::Message) -> [CrypticSpinner, Bubbletea::Command?]
146
+ def update(message)
147
+ case message
148
+ when TickMessage
149
+ return [self, nil] if message.id.positive? && message.id != @id
150
+ return [self, nil] if message.tag.positive? && message.tag != @tag
151
+
152
+ @step = (@step + 1) % @cycling_frames.length
153
+ @tag += 1
154
+
155
+ if @initialized && !@label.empty?
156
+ @ellipsis_step = (@ellipsis_step + 1) % (ELLIPSIS_ANIM_SPEED * ELLIPSIS_FRAMES.length)
157
+ elsif !@initialized && (Time.now - @start_time) >= MAX_BIRTH_OFFSET
158
+ @initialized = true
159
+ end
160
+
161
+ [self, tick]
162
+ else
163
+ [self, nil]
164
+ end
165
+ end
166
+
167
+ #: () -> String
168
+ def view
169
+ elapsed = Time.now - @start_time
170
+ lines = [] #: Array[String]
171
+
172
+ @rows.times do |row|
173
+ line = String.new
174
+
175
+ @size.times do |i|
176
+ line << if !@initialized && elapsed < @birth_offsets[row][i]
177
+ @initial_frames[@step][row][i]
178
+ else
179
+ @cycling_frames[@step][row][i]
180
+ end
181
+ end
182
+
183
+ if row == @rows - 1 && !@label.empty?
184
+ line << " "
185
+ line << render_label
186
+
187
+ if @initialized
188
+ ellipsis_index = @ellipsis_step / ELLIPSIS_ANIM_SPEED
189
+ line << render_ellipsis(ELLIPSIS_FRAMES[ellipsis_index])
190
+ end
191
+ end
192
+
193
+ lines << line
194
+ end
195
+
196
+ lines.join("\n")
197
+ end
198
+
199
+ #: () -> Bubbletea::Command
200
+ def tick
201
+ current_id = @id
202
+ current_tag = @tag
203
+
204
+ Bubbletea.tick(FRAME_DURATION) { TickMessage.new(id: current_id, tag: current_tag) }
205
+ end
206
+
207
+ #: () -> Integer
208
+ def width
209
+ w = @size
210
+
211
+ w += 1 + @label.length + (ELLIPSIS_FRAMES.max_by(&:length) || "").length unless @label.empty?
212
+
213
+ w
214
+ end
215
+
216
+ #: () -> Integer
217
+ def height
218
+ @rows
219
+ end
220
+
221
+ #: (String) -> void
222
+
223
+ private
224
+
225
+ #: () -> Array[String]
226
+ def generate_gradient
227
+ num_colors = @cycle_colors ? @size * 3 : @size
228
+
229
+ Lipgloss::ColorBlend.blends(@color_a, @color_b, num_colors, mode: :hcl)
230
+ end
231
+
232
+ #: () -> void
233
+ def prerender_frames
234
+ num_frames = @cycle_colors ? @size * 2 : 10
235
+
236
+ @initial_frames = Array.new(num_frames) do |frame_index|
237
+ Array.new(@rows) do |row|
238
+ offset = @cycle_colors ? frame_index + row : row
239
+
240
+ Array.new(@size) do |char_index|
241
+ color_index = (char_index + offset) % @gradient.length
242
+ style = Lipgloss::Style.new.foreground(@gradient[color_index])
243
+ style.render(INITIAL_CHAR)
244
+ end
245
+ end
246
+ end
247
+
248
+ @cycling_frames = Array.new(num_frames) do |frame_index|
249
+ Array.new(@rows) do |row|
250
+ offset = @cycle_colors ? frame_index + row : row
251
+
252
+ Array.new(@size) do |char_index|
253
+ color_index = (char_index + offset) % @gradient.length
254
+ char = AVAILABLE_CHARS.sample
255
+ style = Lipgloss::Style.new.foreground(@gradient[color_index])
256
+ style.render(char)
257
+ end
258
+ end
259
+ end
260
+ end
261
+
262
+ #: () -> String
263
+ def render_label
264
+ style = Lipgloss::Style.new.foreground(@label_color)
265
+ style.render(@label)
266
+ end
267
+
268
+ #: (String) -> String
269
+ def render_ellipsis(ellipsis)
270
+ style = Lipgloss::Style.new.foreground(@label_color)
271
+ style.render(ellipsis)
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ # Cursor provides cursor functionality for text input components.
6
+ #
7
+ # Example:
8
+ # cursor = Bubbles::Cursor.new
9
+ # cursor.char = "a"
10
+ # cursor.focus
11
+ #
12
+ # # In update:
13
+ # cursor, command = cursor.update(message)
14
+ #
15
+ # # In view:
16
+ # cursor.view
17
+ #
18
+ class Cursor
19
+ MODE_BLINK = :blink #: Symbol
20
+ MODE_STATIC = :static #: Symbol
21
+ MODE_HIDE = :hide #: Symbol
22
+
23
+ DEFAULT_BLINK_SPEED = 0.53 #: Float
24
+
25
+ class InitialBlinkMessage < Bubbletea::Message
26
+ end
27
+
28
+ class BlinkMessage < Bubbletea::Message
29
+ attr_reader :id #: Integer
30
+ attr_reader :tag #: Integer
31
+
32
+ #: (id: Integer, tag: Integer) -> void
33
+ def initialize(id:, tag:)
34
+ super()
35
+
36
+ @id = id
37
+ @tag = tag
38
+ end
39
+ end
40
+
41
+ class BlinkCanceledMessage < Bubbletea::Message
42
+ end
43
+
44
+ # rubocop:disable Style/ClassVars
45
+ @@last_id = 0 #: Integer
46
+ @@id_mutex = Mutex.new #: Mutex
47
+
48
+ #: () -> Integer
49
+ def self.next_id
50
+ @@id_mutex.synchronize do
51
+ @@last_id += 1
52
+ end
53
+ end
54
+ # rubocop:enable Style/ClassVars
55
+
56
+ attr_reader :id #: Integer
57
+ attr_reader :mode #: Symbol
58
+ attr_accessor :char #: String
59
+
60
+ attr_accessor :blink_speed #: Float
61
+ attr_accessor :style #: Lipgloss::Style?
62
+ attr_accessor :text_style #: Lipgloss::Style?
63
+
64
+ #: () -> void
65
+ def initialize
66
+ @id = self.class.next_id
67
+ @blink_speed = DEFAULT_BLINK_SPEED
68
+ @style = nil
69
+ @text_style = nil
70
+ @char = ""
71
+ @focus = false
72
+ @blink = true
73
+ @blink_tag = 0
74
+ @mode = MODE_BLINK
75
+ end
76
+
77
+ #: () -> bool
78
+ def blink?
79
+ @blink
80
+ end
81
+
82
+ #: () -> bool
83
+ def focused?
84
+ @focus
85
+ end
86
+
87
+ #: (Bubbletea::Message) -> [Cursor, Bubbletea::Command?]
88
+ def update(message)
89
+ case message
90
+ when InitialBlinkMessage
91
+ return [self, nil] if @mode != MODE_BLINK || !@focus
92
+
93
+ [self, blink_command]
94
+ when BlinkMessage
95
+ return [self, nil] if @mode != MODE_BLINK || !@focus
96
+ return [self, nil] if message.id != @id || message.tag != @blink_tag
97
+
98
+ @blink = !@blink
99
+
100
+ [self, blink_command]
101
+ when BlinkCanceledMessage
102
+ [self, nil]
103
+ else # rubocop:disable Lint/DuplicateBranch
104
+ [self, nil]
105
+ end
106
+ end
107
+
108
+ #: (Symbol) -> Bubbletea::Command?
109
+ def set_mode(mode) # rubocop:disable Naming/AccessorMethodName
110
+ return nil unless [MODE_BLINK, MODE_STATIC, MODE_HIDE].include?(mode)
111
+
112
+ @mode = mode
113
+ @blink = @mode == MODE_HIDE || !@focus
114
+
115
+ mode == MODE_BLINK ? self.class.blink : nil
116
+ end
117
+
118
+ #: () -> Bubbletea::Command?
119
+ def focus
120
+ @focus = true
121
+ @blink = @mode == MODE_HIDE
122
+
123
+ return unless @mode == MODE_BLINK && @focus
124
+
125
+ blink_command
126
+ end
127
+
128
+ #: () -> void
129
+ def blur
130
+ @focus = false
131
+ @blink = true
132
+ end
133
+
134
+ #: () -> String
135
+ def view
136
+ if @blink
137
+ if (text_style = @text_style)
138
+ text_style.render(@char)
139
+ else
140
+ @char
141
+ end
142
+ elsif (style = @style)
143
+ style.reverse(true).render(@char)
144
+ else
145
+ "\e[7m#{@char}\e[0m"
146
+ end
147
+ end
148
+
149
+ #: () -> Bubbletea::SendMessage
150
+ def self.blink
151
+ Bubbletea.send_message(InitialBlinkMessage.new)
152
+ end
153
+
154
+ private
155
+
156
+ #: () -> Bubbletea::Command?
157
+ def blink_command
158
+ return nil unless @mode == MODE_BLINK
159
+
160
+ @blink_tag += 1
161
+ current_id = @id
162
+ current_tag = @blink_tag
163
+
164
+ Bubbletea.tick(@blink_speed) do
165
+ BlinkMessage.new(id: current_id, tag: current_tag)
166
+ end
167
+ end
168
+ end
169
+ end