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.
- checksums.yaml +5 -5
- data/LICENSE.txt +21 -0
- data/README.md +601 -80
- data/bubbles.gemspec +29 -21
- data/lib/bubbles/ansi.rb +71 -0
- data/lib/bubbles/cryptic_spinner.rb +274 -0
- data/lib/bubbles/cursor.rb +169 -0
- data/lib/bubbles/file_picker.rb +397 -0
- data/lib/bubbles/help.rb +165 -0
- data/lib/bubbles/key.rb +96 -0
- data/lib/bubbles/list.rb +365 -0
- data/lib/bubbles/paginator.rb +158 -0
- data/lib/bubbles/progress.rb +276 -0
- data/lib/bubbles/spinner/spinners.rb +77 -0
- data/lib/bubbles/spinner.rb +122 -0
- data/lib/bubbles/stopwatch.rb +189 -0
- data/lib/bubbles/table.rb +248 -0
- data/lib/bubbles/text_area.rb +503 -0
- data/lib/bubbles/text_input.rb +543 -0
- data/lib/bubbles/timer.rb +196 -0
- data/lib/bubbles/version.rb +4 -1
- data/lib/bubbles/viewport.rb +283 -0
- data/lib/bubbles.rb +20 -35
- data/sig/bubbles/ansi.rbs +23 -0
- data/sig/bubbles/cryptic_spinner.rbs +143 -0
- data/sig/bubbles/cursor.rbs +87 -0
- data/sig/bubbles/file_picker.rbs +138 -0
- data/sig/bubbles/help.rbs +85 -0
- data/sig/bubbles/key.rbs +63 -0
- data/sig/bubbles/list.rbs +138 -0
- data/sig/bubbles/paginator.rbs +90 -0
- data/sig/bubbles/progress.rbs +123 -0
- data/sig/bubbles/spinner/spinners.rbs +32 -0
- data/sig/bubbles/spinner.rbs +74 -0
- data/sig/bubbles/stopwatch.rbs +97 -0
- data/sig/bubbles/table.rbs +119 -0
- data/sig/bubbles/text_area.rbs +161 -0
- data/sig/bubbles/text_input.rbs +183 -0
- data/sig/bubbles/timer.rbs +107 -0
- data/sig/bubbles/version.rbs +5 -0
- data/sig/bubbles/viewport.rbs +119 -0
- data/sig/bubbles.rbs +4 -0
- metadata +70 -67
- data/.gitignore +0 -14
- data/.rspec +0 -2
- data/.travis.yml +0 -10
- data/Gemfile +0 -4
- data/LICENSE +0 -20
- data/Rakefile +0 -6
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/exe/bubbles +0 -5
- data/lib/bubbles/bubblicious_file.rb +0 -42
- data/lib/bubbles/command_queue.rb +0 -43
- data/lib/bubbles/common_uploader_interface.rb +0 -13
- data/lib/bubbles/config.rb +0 -149
- data/lib/bubbles/dir_watcher.rb +0 -53
- data/lib/bubbles/uploaders/local_dir.rb +0 -39
- data/lib/bubbles/uploaders/s3.rb +0 -36
- data/lib/bubbles/uploaders/s3_ensure_connection.rb +0 -26
- data/tmp/dummy_local_dir_uploader_dir/.gitkeep +0 -0
- data/tmp/dummy_processing_dir/.gitkeep +0 -0
data/bubbles.gemspec
CHANGED
|
@@ -1,28 +1,36 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
8
|
-
spec.version
|
|
9
|
-
spec.authors
|
|
10
|
-
spec.
|
|
11
|
-
|
|
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.
|
|
14
|
-
|
|
15
|
-
|
|
22
|
+
spec.files = Dir[
|
|
23
|
+
"bubbles.gemspec",
|
|
24
|
+
"LICENSE.txt",
|
|
25
|
+
"README.md",
|
|
26
|
+
"{lib,sig}/**/*"
|
|
27
|
+
]
|
|
16
28
|
|
|
17
|
-
spec.
|
|
18
|
-
|
|
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 "
|
|
25
|
-
spec.
|
|
26
|
-
spec.
|
|
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
|
data/lib/bubbles/ansi.rb
ADDED
|
@@ -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
|