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,189 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ # Stopwatch is an elapsed time counter component.
6
+ #
7
+ # Example:
8
+ # stopwatch = Bubbles::Stopwatch.new
9
+ #
10
+ # # In your model's init:
11
+ # def init
12
+ # [self, @stopwatch.init]
13
+ # end
14
+ #
15
+ # # In your model's update:
16
+ # def update(message)
17
+ # case message
18
+ # when Bubbles::Stopwatch::TickMessage, Bubbles::Stopwatch::StartStopMessage, Bubbles::Stopwatch::ResetMessage
19
+ # @stopwatch, command = @stopwatch.update(message)
20
+ #
21
+ # [self, command]
22
+ # end
23
+ # end
24
+ #
25
+ class Stopwatch
26
+ class TickMessage < Bubbletea::Message
27
+ attr_reader :id #: Integer
28
+ attr_reader :tag #: Integer
29
+
30
+ #: (id: Integer, tag: Integer) -> void
31
+ def initialize(id:, tag:)
32
+ super()
33
+
34
+ @id = id
35
+ @tag = tag
36
+ end
37
+ end
38
+
39
+ class StartStopMessage < Bubbletea::Message
40
+ attr_reader :id #: Integer
41
+ attr_reader :running #: bool
42
+
43
+ #: (id: Integer, running: bool) -> void
44
+ def initialize(id:, running:)
45
+ super()
46
+
47
+ @id = id
48
+ @running = running
49
+ end
50
+ end
51
+
52
+ class ResetMessage < Bubbletea::Message
53
+ attr_reader :id #: Integer
54
+
55
+ #: (id: Integer) -> void
56
+ def initialize(id:)
57
+ super()
58
+
59
+ @id = id
60
+ end
61
+ end
62
+
63
+ # @rbs self.@next_id: Integer
64
+ # @rbs self.@id_mutex: Mutex
65
+ @next_id = 0
66
+ @id_mutex = Mutex.new
67
+
68
+ class << self
69
+ #: () -> Integer
70
+ def next_id
71
+ @id_mutex.synchronize do
72
+ @next_id += 1
73
+ end
74
+ end
75
+ end
76
+
77
+ attr_reader :elapsed #: Float
78
+ attr_reader :interval #: Float
79
+ attr_reader :id #: Integer
80
+
81
+ #: (?interval: Float) -> void
82
+ def initialize(interval: 1.0)
83
+ @elapsed = 0.0
84
+ @interval = interval.to_f
85
+ @id = self.class.next_id
86
+ @tag = 0
87
+ @running = false
88
+ end
89
+
90
+ #: () -> Bubbletea::Command
91
+ def init
92
+ start
93
+ end
94
+
95
+ #: () -> bool
96
+ def running?
97
+ @running
98
+ end
99
+
100
+ #: (Bubbletea::Message) -> [Stopwatch, Bubbletea::Command?]
101
+ def update(message)
102
+ case message
103
+ when StartStopMessage
104
+ return [self, nil] if message.id.positive? && message.id != @id
105
+
106
+ @running = message.running
107
+
108
+ [self, @running ? tick : nil]
109
+ when ResetMessage
110
+ return [self, nil] if message.id.positive? && message.id != @id
111
+
112
+ @elapsed = 0.0
113
+
114
+ [self, nil]
115
+ when TickMessage
116
+ return [self, nil] unless @running
117
+ return [self, nil] if message.id.positive? && message.id != @id
118
+ return [self, nil] if message.tag.positive? && message.tag != @tag
119
+
120
+ @elapsed += @interval
121
+ @tag += 1
122
+
123
+ [self, tick]
124
+ else
125
+ [self, nil]
126
+ end
127
+ end
128
+
129
+ #: () -> String
130
+ def view
131
+ format_duration(@elapsed)
132
+ end
133
+
134
+ #: () -> Bubbletea::Command
135
+ def start
136
+ current_id = @id
137
+ current_tag = @tag
138
+
139
+ Bubbletea.sequence(
140
+ Bubbletea.send_message(StartStopMessage.new(id: current_id, running: true)),
141
+ Bubbletea.tick(@interval) { TickMessage.new(id: current_id, tag: current_tag) }
142
+ )
143
+ end
144
+
145
+ #: () -> Bubbletea::Command
146
+ def stop
147
+ current_id = @id
148
+ Bubbletea.send_message(StartStopMessage.new(id: current_id, running: false))
149
+ end
150
+
151
+ #: () -> Bubbletea::Command
152
+ def toggle
153
+ running? ? stop : start
154
+ end
155
+
156
+ #: () -> Bubbletea::Command
157
+ def reset
158
+ current_id = @id
159
+ Bubbletea.send_message(ResetMessage.new(id: current_id))
160
+ end
161
+
162
+ private
163
+
164
+ #: () -> Bubbletea::Command?
165
+ def tick
166
+ return nil unless running?
167
+
168
+ current_id = @id
169
+ current_tag = @tag
170
+
171
+ Bubbletea.tick(@interval) { TickMessage.new(id: current_id, tag: current_tag) }
172
+ end
173
+
174
+ #: (Float) -> String
175
+ def format_duration(seconds)
176
+ total_seconds = seconds.to_i
177
+ hours = total_seconds / 3600
178
+ minutes = (total_seconds % 3600) / 60
179
+ secs = total_seconds % 60
180
+ ms = ((seconds - total_seconds) * 100).to_i
181
+
182
+ if hours.positive?
183
+ format("%<hours>d:%<minutes>02d:%<secs>02d.%<ms>02d", hours: hours, minutes: minutes, secs: secs, ms: ms)
184
+ else
185
+ format("%<minutes>d:%<secs>02d.%<ms>02d", minutes: minutes, secs: secs, ms: ms)
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ # Table is a simple table component for displaying tabular data.
6
+ #
7
+ # Example:
8
+ # columns = [
9
+ # { title: "Name", width: 20 },
10
+ # { title: "Age", width: 5 }
11
+ # ]
12
+ # rows = [
13
+ # ["Alice", "30"],
14
+ # ["Bob", "25"]
15
+ # ]
16
+ # table = Bubbles::Table.new(columns: columns, rows: rows)
17
+ #
18
+ # # In update:
19
+ # table, command = table.update(message)
20
+ #
21
+ # # In view:
22
+ # table.view
23
+ #
24
+ class Table
25
+ Column = Struct.new(:title, :width, keyword_init: true)
26
+
27
+ attr_accessor :height #: Integer
28
+ attr_accessor :focus #: bool
29
+
30
+ attr_reader :cursor #: Integer
31
+ attr_reader :columns #: Array[Column]
32
+ attr_reader :rows #: Array[Array[String]]
33
+
34
+ attr_accessor :header_style #: Lipgloss::Style?
35
+ attr_accessor :cell_style #: Lipgloss::Style?
36
+ attr_accessor :selected_style #: Lipgloss::Style?
37
+
38
+ # @rbs columns: Array[Hash[Symbol, untyped] | Column] -- Column definitions with :title and :width
39
+ # @rbs rows: Array[Array[String]] -- Row data
40
+ # @rbs height: Integer -- Visible rows (excluding header)
41
+ # @rbs return: void
42
+ def initialize(columns: [], rows: [], height: 10)
43
+ @columns = columns.map do |col|
44
+ col.is_a?(Column) ? col : Column.new(**col)
45
+ end
46
+
47
+ @rows = rows
48
+ @height = height
49
+ @cursor = 0
50
+ @focus = true
51
+ @offset = 0
52
+
53
+ @header_style = nil
54
+ @cell_style = nil
55
+ @selected_style = nil
56
+ end
57
+
58
+ #: (Array[Hash[Symbol, untyped] | Column]) -> void
59
+ def columns=(columns)
60
+ @columns = columns.map do |col|
61
+ col.is_a?(Column) ? col : Column.new(**col)
62
+ end
63
+ end
64
+
65
+ #: (Array[Array[String]]) -> void
66
+ def rows=(rows)
67
+ @rows = rows
68
+ @cursor = @cursor.clamp(0, [@rows.length - 1, 0].max)
69
+ update_offset
70
+ end
71
+
72
+ #: () -> Integer
73
+ def selected_row
74
+ @cursor
75
+ end
76
+
77
+ #: () -> Array[String]?
78
+ def selected_row_data
79
+ @rows[@cursor]
80
+ end
81
+
82
+ #: (Integer) -> void
83
+ def go_to_row(index)
84
+ @cursor = index.clamp(0, [@rows.length - 1, 0].max)
85
+ update_offset
86
+ end
87
+
88
+ #: (?Integer) -> void
89
+ def move_up(count = 1)
90
+ go_to_row(@cursor - count)
91
+ end
92
+
93
+ #: (?Integer) -> void
94
+ def move_down(count = 1)
95
+ go_to_row(@cursor + count)
96
+ end
97
+
98
+ #: () -> void
99
+ def go_to_top
100
+ go_to_row(0)
101
+ end
102
+
103
+ #: () -> void
104
+ def go_to_bottom
105
+ go_to_row(@rows.length - 1)
106
+ end
107
+
108
+ #: () -> void
109
+ def page_up
110
+ move_up(@height)
111
+ end
112
+
113
+ #: () -> void
114
+ def page_down
115
+ move_down(@height)
116
+ end
117
+
118
+ #: () -> void
119
+ def focus!
120
+ @focus = true
121
+ end
122
+
123
+ #: () -> void
124
+ def blur
125
+ @focus = false
126
+ end
127
+
128
+ #: () -> bool
129
+ def focused?
130
+ @focus
131
+ end
132
+
133
+ #: (Bubbletea::Message) -> [Table, Bubbletea::Command?]
134
+ def update(message)
135
+ return [self, nil] unless @focus
136
+
137
+ case message
138
+ when Bubbletea::KeyMessage
139
+ case message.to_s
140
+ when "up", "k"
141
+ move_up
142
+ when "down", "j"
143
+ move_down
144
+ when "pgup", "b"
145
+ page_up
146
+ when "pgdown", "f", " ", "space"
147
+ page_down
148
+ when "ctrl+u", "u"
149
+ move_up(@height / 2)
150
+ when "ctrl+d", "d"
151
+ move_down(@height / 2)
152
+ when "home", "g"
153
+ go_to_top
154
+ when "end", "G"
155
+ go_to_bottom
156
+ end
157
+ end
158
+
159
+ [self, nil]
160
+ end
161
+
162
+ #: () -> String
163
+ def view
164
+ return "" if @columns.empty?
165
+
166
+ lines = [] #: Array[String]
167
+
168
+ lines << render_header
169
+ lines << render_separator
170
+
171
+ if @rows.empty?
172
+ lines << " No data"
173
+ else
174
+ visible_end = [@offset + @height, @rows.length].min
175
+ (@offset...visible_end).each do |i|
176
+ lines << render_row(i)
177
+ end
178
+ end
179
+
180
+ lines << "" while lines.length < @height + 2 # +2 for header and separator
181
+
182
+ lines.join("\n")
183
+ end
184
+
185
+ private
186
+
187
+ #: () -> String
188
+ def render_header
189
+ header_style = @header_style
190
+ cells = @columns.map do |col|
191
+ text = truncate_or_pad(col.title, col.width)
192
+ header_style ? header_style.render(text) : "\e[1m#{text}\e[0m"
193
+ end
194
+
195
+ cells.join(" ")
196
+ end
197
+
198
+ #: () -> String
199
+ def render_separator
200
+ cells = @columns.map do |col|
201
+ "─" * col.width
202
+ end
203
+
204
+ cells.join("─")
205
+ end
206
+
207
+ #: (Integer) -> String
208
+ def render_row(index)
209
+ row = @rows[index] || []
210
+ is_selected = index == @cursor && @focus
211
+ selected_style = @selected_style
212
+ cell_style = @cell_style
213
+
214
+ cells = @columns.each_with_index.map do |col, i|
215
+ text = truncate_or_pad(row[i] || "", col.width)
216
+
217
+ if is_selected
218
+ selected_style ? selected_style.render(text) : "\e[7m#{text}\e[0m"
219
+ else
220
+ cell_style ? cell_style.render(text) : text
221
+ end
222
+ end
223
+
224
+ cells.join(" ")
225
+ end
226
+
227
+ #: (String, Integer) -> String
228
+ def truncate_or_pad(text, width)
229
+ text = text.to_s
230
+ if text.length > width
231
+ "#{text[0...(width - 1)]}…"
232
+ else
233
+ text.ljust(width)
234
+ end
235
+ end
236
+
237
+ #: () -> void
238
+ def update_offset
239
+ if @cursor < @offset
240
+ @offset = @cursor
241
+ elsif @cursor >= @offset + @height
242
+ @offset = @cursor - @height + 1
243
+ end
244
+
245
+ @offset = @offset.clamp(0, [0, @rows.length - @height].max)
246
+ end
247
+ end
248
+ end