clack 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +24 -0
- data/README.md +424 -0
- data/exe/clack-demo +9 -0
- data/lib/clack/box.rb +120 -0
- data/lib/clack/colors.rb +55 -0
- data/lib/clack/core/cursor.rb +61 -0
- data/lib/clack/core/key_reader.rb +45 -0
- data/lib/clack/core/options_helper.rb +96 -0
- data/lib/clack/core/prompt.rb +215 -0
- data/lib/clack/core/settings.rb +97 -0
- data/lib/clack/core/text_input_helper.rb +83 -0
- data/lib/clack/environment.rb +137 -0
- data/lib/clack/group.rb +100 -0
- data/lib/clack/log.rb +42 -0
- data/lib/clack/note.rb +49 -0
- data/lib/clack/prompts/autocomplete.rb +162 -0
- data/lib/clack/prompts/autocomplete_multiselect.rb +280 -0
- data/lib/clack/prompts/confirm.rb +100 -0
- data/lib/clack/prompts/group_multiselect.rb +250 -0
- data/lib/clack/prompts/multiselect.rb +185 -0
- data/lib/clack/prompts/password.rb +77 -0
- data/lib/clack/prompts/path.rb +226 -0
- data/lib/clack/prompts/progress.rb +145 -0
- data/lib/clack/prompts/select.rb +134 -0
- data/lib/clack/prompts/select_key.rb +100 -0
- data/lib/clack/prompts/spinner.rb +206 -0
- data/lib/clack/prompts/tasks.rb +131 -0
- data/lib/clack/prompts/text.rb +93 -0
- data/lib/clack/stream.rb +82 -0
- data/lib/clack/symbols.rb +84 -0
- data/lib/clack/task_log.rb +174 -0
- data/lib/clack/utils.rb +135 -0
- data/lib/clack/validators.rb +145 -0
- data/lib/clack/version.rb +5 -0
- data/lib/clack.rb +576 -0
- metadata +83 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Prompts
|
|
5
|
+
# Type-to-filter autocomplete with multiple selection.
|
|
6
|
+
#
|
|
7
|
+
# Combines text input filtering with checkbox-style selection.
|
|
8
|
+
# Type to filter, Space to toggle, Enter to confirm.
|
|
9
|
+
#
|
|
10
|
+
# Shortcuts:
|
|
11
|
+
# - Space: toggle current option
|
|
12
|
+
# - 'a': toggle all options
|
|
13
|
+
# - 'i': invert selection
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage
|
|
16
|
+
# colors = Clack.autocomplete_multiselect(
|
|
17
|
+
# message: "Pick colors",
|
|
18
|
+
# options: %w[red orange yellow green blue]
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# @example With options
|
|
22
|
+
# tags = Clack.autocomplete_multiselect(
|
|
23
|
+
# message: "Select tags",
|
|
24
|
+
# options: all_tags,
|
|
25
|
+
# placeholder: "Type to filter...",
|
|
26
|
+
# required: true,
|
|
27
|
+
# initial_values: ["important"]
|
|
28
|
+
# )
|
|
29
|
+
#
|
|
30
|
+
class AutocompleteMultiselect < Core::Prompt
|
|
31
|
+
include Core::OptionsHelper
|
|
32
|
+
include Core::TextInputHelper
|
|
33
|
+
|
|
34
|
+
# @param message [String] the prompt message
|
|
35
|
+
# @param options [Array<Hash, String>] list of options to filter
|
|
36
|
+
# @param max_items [Integer] max visible options (default: 5)
|
|
37
|
+
# @param placeholder [String, nil] placeholder text when empty
|
|
38
|
+
# @param required [Boolean] require at least one selection (default: true)
|
|
39
|
+
# @param initial_values [Array, nil] values to pre-select
|
|
40
|
+
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
41
|
+
def initialize(message:, options:, max_items: 5, placeholder: nil, required: true, initial_values: nil, **opts)
|
|
42
|
+
super(message:, **opts)
|
|
43
|
+
@all_options = normalize_options(options)
|
|
44
|
+
@max_items = max_items
|
|
45
|
+
@placeholder = placeholder
|
|
46
|
+
@required = required
|
|
47
|
+
@search_text = ""
|
|
48
|
+
@cursor = 0
|
|
49
|
+
@selected_index = 0
|
|
50
|
+
@scroll_offset = 0
|
|
51
|
+
@selected_values = Set.new(initial_values || [])
|
|
52
|
+
update_filtered
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
protected
|
|
56
|
+
|
|
57
|
+
def handle_key(key)
|
|
58
|
+
return if terminal_state?
|
|
59
|
+
|
|
60
|
+
@state = :active if @state == :error
|
|
61
|
+
action = Core::Settings.action?(key)
|
|
62
|
+
|
|
63
|
+
case action
|
|
64
|
+
when :cancel
|
|
65
|
+
@state = :cancel
|
|
66
|
+
when :enter
|
|
67
|
+
submit_selection
|
|
68
|
+
when :up
|
|
69
|
+
move_selection(-1)
|
|
70
|
+
when :down
|
|
71
|
+
move_selection(1)
|
|
72
|
+
when :space
|
|
73
|
+
toggle_current
|
|
74
|
+
else
|
|
75
|
+
handle_char(key)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def handle_char(key)
|
|
80
|
+
# Shortcut keys only work when search field is empty
|
|
81
|
+
# to avoid interfering with typing filter text
|
|
82
|
+
if @search_text.empty?
|
|
83
|
+
case key&.downcase
|
|
84
|
+
when "a"
|
|
85
|
+
toggle_all
|
|
86
|
+
return
|
|
87
|
+
when "i"
|
|
88
|
+
invert_selection
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
handle_text_input(key)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def toggle_current
|
|
96
|
+
return if @filtered.empty?
|
|
97
|
+
|
|
98
|
+
current_value = @filtered[@selected_index][:value]
|
|
99
|
+
if @selected_values.include?(current_value)
|
|
100
|
+
@selected_values.delete(current_value)
|
|
101
|
+
else
|
|
102
|
+
@selected_values.add(current_value)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def toggle_all
|
|
107
|
+
if @selected_values.size == @all_options.size
|
|
108
|
+
@selected_values.clear
|
|
109
|
+
else
|
|
110
|
+
@all_options.each { |opt| @selected_values.add(opt[:value]) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def invert_selection
|
|
115
|
+
@all_options.each do |opt|
|
|
116
|
+
if @selected_values.include?(opt[:value])
|
|
117
|
+
@selected_values.delete(opt[:value])
|
|
118
|
+
else
|
|
119
|
+
@selected_values.add(opt[:value])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def submit_selection
|
|
125
|
+
if @required && @selected_values.empty?
|
|
126
|
+
@error_message = "Please select at least one option"
|
|
127
|
+
@state = :error
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
@value = @selected_values.to_a
|
|
132
|
+
submit
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def build_frame
|
|
136
|
+
lines = []
|
|
137
|
+
lines << "#{bar}\n"
|
|
138
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
139
|
+
lines << "#{active_bar} #{Colors.dim("Search:")} #{search_input_display}#{match_count}\n"
|
|
140
|
+
|
|
141
|
+
visible_options.each_with_index do |opt, idx|
|
|
142
|
+
actual_idx = @scroll_offset + idx
|
|
143
|
+
lines << "#{active_bar} #{option_display(opt, actual_idx == @selected_index)}\n"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
lines << "#{active_bar} #{Colors.yellow("No matches found")}\n" if @filtered.empty? && !@search_text.empty?
|
|
147
|
+
|
|
148
|
+
lines << "#{active_bar} #{instructions}\n"
|
|
149
|
+
lines << "#{bar_end}\n"
|
|
150
|
+
|
|
151
|
+
if @state == :error
|
|
152
|
+
lines[-2] = "#{Colors.yellow(Symbols::S_BAR)} #{Colors.yellow(@error_message)}\n"
|
|
153
|
+
lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)}\n"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
lines.join
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_final_frame
|
|
160
|
+
lines = []
|
|
161
|
+
lines << "#{bar}\n"
|
|
162
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
163
|
+
|
|
164
|
+
display = if @state == :cancel
|
|
165
|
+
Colors.strikethrough(Colors.dim("cancelled"))
|
|
166
|
+
else
|
|
167
|
+
Colors.dim("#{@selected_values.size} items selected")
|
|
168
|
+
end
|
|
169
|
+
lines << "#{bar} #{display}\n"
|
|
170
|
+
|
|
171
|
+
lines.join
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
# Override TextInputHelper methods to use @search_text instead of @value
|
|
177
|
+
def search_input_display
|
|
178
|
+
return placeholder_display if @search_text.empty?
|
|
179
|
+
|
|
180
|
+
search_value_with_cursor
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def search_value_with_cursor
|
|
184
|
+
chars = @search_text.grapheme_clusters
|
|
185
|
+
return "#{@search_text}#{cursor_block}" if @cursor >= chars.length
|
|
186
|
+
|
|
187
|
+
before = chars[0...@cursor].join
|
|
188
|
+
current = Colors.inverse(chars[@cursor])
|
|
189
|
+
after = chars[(@cursor + 1)..].join
|
|
190
|
+
"#{before}#{current}#{after}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Override to work with @search_text instead of @value
|
|
194
|
+
def handle_text_input(key)
|
|
195
|
+
return false unless Core::Settings.printable?(key)
|
|
196
|
+
|
|
197
|
+
chars = @search_text.grapheme_clusters
|
|
198
|
+
|
|
199
|
+
if Core::Settings.backspace?(key)
|
|
200
|
+
return false if @cursor.zero?
|
|
201
|
+
|
|
202
|
+
chars.delete_at(@cursor - 1)
|
|
203
|
+
@search_text = chars.join
|
|
204
|
+
@cursor -= 1
|
|
205
|
+
else
|
|
206
|
+
chars.insert(@cursor, key)
|
|
207
|
+
@search_text = chars.join
|
|
208
|
+
@cursor += 1
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
@selected_index = 0
|
|
212
|
+
@scroll_offset = 0
|
|
213
|
+
update_filtered
|
|
214
|
+
true
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def match_count
|
|
218
|
+
return "" if @filtered.size == @all_options.size
|
|
219
|
+
|
|
220
|
+
Colors.dim(" (#{@filtered.size} match#{"es" unless @filtered.size == 1})")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def instructions
|
|
224
|
+
Colors.dim([
|
|
225
|
+
"up/down: navigate",
|
|
226
|
+
"space: select",
|
|
227
|
+
"a: all",
|
|
228
|
+
"i: invert",
|
|
229
|
+
"enter: confirm"
|
|
230
|
+
].join(" | "))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def update_filtered
|
|
234
|
+
query = @search_text.downcase
|
|
235
|
+
@filtered = @all_options.select do |opt|
|
|
236
|
+
opt[:label].downcase.include?(query) ||
|
|
237
|
+
opt[:value].to_s.downcase.include?(query) ||
|
|
238
|
+
opt[:hint]&.downcase&.include?(query)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def visible_options
|
|
243
|
+
return @filtered if @filtered.length <= @max_items
|
|
244
|
+
|
|
245
|
+
@filtered[@scroll_offset, @max_items]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def move_selection(delta)
|
|
249
|
+
return if @filtered.empty?
|
|
250
|
+
|
|
251
|
+
@selected_index = (@selected_index + delta) % @filtered.length
|
|
252
|
+
update_scroll
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def update_scroll
|
|
256
|
+
return unless @filtered.length > @max_items
|
|
257
|
+
|
|
258
|
+
if @selected_index < @scroll_offset
|
|
259
|
+
@scroll_offset = @selected_index
|
|
260
|
+
elsif @selected_index >= @scroll_offset + @max_items
|
|
261
|
+
@scroll_offset = @selected_index - @max_items + 1
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def option_display(opt, active)
|
|
266
|
+
is_selected = @selected_values.include?(opt[:value])
|
|
267
|
+
checkbox = if is_selected
|
|
268
|
+
Colors.green(Symbols::S_CHECKBOX_SELECTED)
|
|
269
|
+
else
|
|
270
|
+
Colors.dim(Symbols::S_CHECKBOX_INACTIVE)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
label = active ? opt[:label] : Colors.dim(opt[:label])
|
|
274
|
+
hint = (opt[:hint] && active) ? Colors.dim(" (#{opt[:hint]})") : ""
|
|
275
|
+
|
|
276
|
+
"#{checkbox} #{label}#{hint}"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Prompts
|
|
5
|
+
# Yes/No confirmation prompt.
|
|
6
|
+
#
|
|
7
|
+
# Displays a toggle between two options. Navigate with arrow keys, j/k,
|
|
8
|
+
# or press y/n to select directly.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# proceed = Clack.confirm(message: "Continue?")
|
|
12
|
+
#
|
|
13
|
+
# @example With custom labels
|
|
14
|
+
# deploy = Clack.confirm(
|
|
15
|
+
# message: "Deploy to production?",
|
|
16
|
+
# active: "Yes, ship it!",
|
|
17
|
+
# inactive: "No, abort",
|
|
18
|
+
# initial_value: false
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
class Confirm < Core::Prompt
|
|
22
|
+
# @param message [String] the prompt message
|
|
23
|
+
# @param active [String] label for the "yes" option (default: "Yes")
|
|
24
|
+
# @param inactive [String] label for the "no" option (default: "No")
|
|
25
|
+
# @param initial_value [Boolean] initial selection (default: true)
|
|
26
|
+
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
27
|
+
def initialize(message:, active: "Yes", inactive: "No", initial_value: true, **opts)
|
|
28
|
+
super(message:, **opts)
|
|
29
|
+
@active_label = active
|
|
30
|
+
@inactive_label = inactive
|
|
31
|
+
@value = initial_value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
protected
|
|
35
|
+
|
|
36
|
+
def handle_key(key)
|
|
37
|
+
return if terminal_state?
|
|
38
|
+
|
|
39
|
+
action = Core::Settings.action?(key)
|
|
40
|
+
|
|
41
|
+
case action
|
|
42
|
+
when :cancel
|
|
43
|
+
@state = :cancel
|
|
44
|
+
when :enter
|
|
45
|
+
submit
|
|
46
|
+
when :left, :up
|
|
47
|
+
@value = true
|
|
48
|
+
when :right, :down
|
|
49
|
+
@value = false
|
|
50
|
+
else
|
|
51
|
+
handle_char(key)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def handle_char(key)
|
|
56
|
+
case key&.downcase
|
|
57
|
+
when "y"
|
|
58
|
+
@value = true
|
|
59
|
+
when "n"
|
|
60
|
+
@value = false
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_frame
|
|
65
|
+
lines = []
|
|
66
|
+
lines << "#{bar}\n"
|
|
67
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
68
|
+
lines << "#{bar} #{options_display}\n"
|
|
69
|
+
lines << "#{Colors.gray(Symbols::S_BAR_END)}\n"
|
|
70
|
+
lines.join
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_final_frame
|
|
74
|
+
lines = []
|
|
75
|
+
lines << "#{bar}\n"
|
|
76
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
77
|
+
|
|
78
|
+
selected = @value ? @active_label : @inactive_label
|
|
79
|
+
display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(selected)) : Colors.dim(selected)
|
|
80
|
+
lines << "#{bar} #{display}\n"
|
|
81
|
+
|
|
82
|
+
lines.join
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def options_display
|
|
88
|
+
if @value
|
|
89
|
+
active = "#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{@active_label}"
|
|
90
|
+
inactive = "#{Colors.dim(Symbols::S_RADIO_INACTIVE)} #{Colors.dim(@inactive_label)}"
|
|
91
|
+
else
|
|
92
|
+
active = "#{Colors.dim(Symbols::S_RADIO_INACTIVE)} #{Colors.dim(@active_label)}"
|
|
93
|
+
inactive = "#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{@inactive_label}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
"#{active} #{Colors.dim("/")} #{inactive}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Prompts
|
|
5
|
+
class GroupMultiselect < Core::Prompt
|
|
6
|
+
def initialize(
|
|
7
|
+
message:,
|
|
8
|
+
options:,
|
|
9
|
+
initial_values: [],
|
|
10
|
+
required: true,
|
|
11
|
+
selectable_groups: false,
|
|
12
|
+
group_spacing: 0,
|
|
13
|
+
cursor_at: nil,
|
|
14
|
+
**opts
|
|
15
|
+
)
|
|
16
|
+
super(message:, **opts)
|
|
17
|
+
@groups = normalize_groups(options)
|
|
18
|
+
@flat_items = build_flat_items
|
|
19
|
+
@selected = Set.new(initial_values)
|
|
20
|
+
@required = required
|
|
21
|
+
@selectable_groups = selectable_groups
|
|
22
|
+
@group_spacing = group_spacing
|
|
23
|
+
@cursor = find_initial_cursor(cursor_at)
|
|
24
|
+
update_value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
protected
|
|
28
|
+
|
|
29
|
+
def handle_key(key)
|
|
30
|
+
return if terminal_state?
|
|
31
|
+
|
|
32
|
+
@state = :active if @state == :error
|
|
33
|
+
action = Core::Settings.action?(key)
|
|
34
|
+
|
|
35
|
+
case action
|
|
36
|
+
when :cancel
|
|
37
|
+
@state = :cancel
|
|
38
|
+
when :enter
|
|
39
|
+
submit
|
|
40
|
+
when :up
|
|
41
|
+
move_cursor(-1)
|
|
42
|
+
when :down
|
|
43
|
+
move_cursor(1)
|
|
44
|
+
when :space
|
|
45
|
+
toggle_current
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def submit
|
|
50
|
+
if @required && @selected.empty?
|
|
51
|
+
@error_message = "Please select at least one option."
|
|
52
|
+
@state = :error
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
super
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_frame
|
|
59
|
+
lines = []
|
|
60
|
+
lines << "#{bar}\n"
|
|
61
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
62
|
+
|
|
63
|
+
prev_was_group = false
|
|
64
|
+
@flat_items.each_with_index do |item, idx|
|
|
65
|
+
is_group = item[:type] == :group
|
|
66
|
+
is_last_in_group = item[:last_in_group]
|
|
67
|
+
|
|
68
|
+
# Add group spacing before groups (except first)
|
|
69
|
+
if is_group && prev_was_group == false && idx.positive? && @group_spacing.positive?
|
|
70
|
+
@group_spacing.times { lines << "#{active_bar}\n" }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
lines << if is_group
|
|
74
|
+
group_display(item, idx == @cursor)
|
|
75
|
+
else
|
|
76
|
+
option_display(item, idx == @cursor, is_last_in_group)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
prev_was_group = is_group
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
lines << "#{bar_end}\n"
|
|
83
|
+
|
|
84
|
+
lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n" if @state == :error
|
|
85
|
+
|
|
86
|
+
lines.join
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_final_frame
|
|
90
|
+
lines = []
|
|
91
|
+
lines << "#{bar}\n"
|
|
92
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
93
|
+
|
|
94
|
+
labels = selected_options.map { |o| o[:label] }
|
|
95
|
+
display_text = labels.join(", ")
|
|
96
|
+
display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display_text)) : Colors.dim(display_text)
|
|
97
|
+
lines << "#{bar} #{display}\n"
|
|
98
|
+
|
|
99
|
+
lines.join
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def normalize_groups(options)
|
|
105
|
+
options.map { |group| normalize_group(group) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def normalize_group(group)
|
|
109
|
+
{
|
|
110
|
+
label: group[:label] || group[:group],
|
|
111
|
+
options: group[:options].map { |opt| normalize_option(opt) }
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def normalize_option(opt)
|
|
116
|
+
case opt
|
|
117
|
+
when Hash
|
|
118
|
+
{value: opt[:value], label: opt[:label] || opt[:value].to_s, disabled: opt[:disabled] || false}
|
|
119
|
+
else
|
|
120
|
+
{value: opt, label: opt.to_s, disabled: false}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_flat_items
|
|
125
|
+
@groups.flat_map { |group| flatten_group(group) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def flatten_group(group)
|
|
129
|
+
group_item = {type: :group, label: group[:label], options: group[:options]}
|
|
130
|
+
option_items = group[:options].each_with_index.map do |opt, idx|
|
|
131
|
+
{
|
|
132
|
+
type: :option,
|
|
133
|
+
value: opt[:value],
|
|
134
|
+
label: opt[:label],
|
|
135
|
+
disabled: opt[:disabled],
|
|
136
|
+
group: group,
|
|
137
|
+
last_in_group: idx == group[:options].length - 1
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
[group_item] + option_items
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def selected_options
|
|
144
|
+
@flat_items.select { |item| item[:type] == :option && @selected.include?(item[:value]) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def find_initial_cursor(cursor_at)
|
|
148
|
+
return 0 if @flat_items.empty?
|
|
149
|
+
|
|
150
|
+
if cursor_at
|
|
151
|
+
idx = @flat_items.find_index { |item| item[:value] == cursor_at }
|
|
152
|
+
return idx if idx
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Find first selectable item
|
|
156
|
+
@flat_items.each_with_index do |item, idx|
|
|
157
|
+
return idx if can_select?(item)
|
|
158
|
+
end
|
|
159
|
+
0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def can_select?(item)
|
|
163
|
+
return false if item[:disabled]
|
|
164
|
+
return @selectable_groups if item[:type] == :group
|
|
165
|
+
|
|
166
|
+
true
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def move_cursor(delta)
|
|
170
|
+
new_cursor = @cursor
|
|
171
|
+
attempts = @flat_items.length
|
|
172
|
+
|
|
173
|
+
loop do
|
|
174
|
+
new_cursor = (new_cursor + delta) % @flat_items.length
|
|
175
|
+
attempts -= 1
|
|
176
|
+
break if can_select?(@flat_items[new_cursor]) || attempts <= 0
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
@cursor = new_cursor
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def toggle_current
|
|
183
|
+
item = @flat_items[@cursor]
|
|
184
|
+
return unless can_select?(item)
|
|
185
|
+
|
|
186
|
+
if item[:type] == :group
|
|
187
|
+
toggle_group(item)
|
|
188
|
+
else
|
|
189
|
+
toggle_option(item)
|
|
190
|
+
end
|
|
191
|
+
update_value
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def toggle_group(group_item)
|
|
195
|
+
group_values = group_item[:options].reject { |o| o[:disabled] }.map { |o| o[:value] }
|
|
196
|
+
all_selected = group_values.all? { |v| @selected.include?(v) }
|
|
197
|
+
|
|
198
|
+
if all_selected
|
|
199
|
+
group_values.each { |v| @selected.delete(v) }
|
|
200
|
+
else
|
|
201
|
+
group_values.each { |v| @selected.add(v) }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def toggle_option(item)
|
|
206
|
+
if @selected.include?(item[:value])
|
|
207
|
+
@selected.delete(item[:value])
|
|
208
|
+
else
|
|
209
|
+
@selected.add(item[:value])
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def update_value
|
|
214
|
+
@value = @selected.to_a
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def group_display(item, active)
|
|
218
|
+
if @selectable_groups
|
|
219
|
+
all_selected = item[:options].reject { |o| o[:disabled] }.all? { |o| @selected.include?(o[:value]) }
|
|
220
|
+
checkbox = all_selected ? Colors.green(Symbols::S_CHECKBOX_SELECTED) : Colors.dim(Symbols::S_CHECKBOX_INACTIVE)
|
|
221
|
+
label = active ? item[:label] : Colors.dim(item[:label])
|
|
222
|
+
"#{active_bar} #{checkbox} #{label}\n"
|
|
223
|
+
else
|
|
224
|
+
"#{active_bar} #{Colors.dim(item[:label])}\n"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def option_display(item, active, is_last)
|
|
229
|
+
selected = @selected.include?(item[:value])
|
|
230
|
+
prefix = if @selectable_groups
|
|
231
|
+
"#{is_last ? Symbols::S_BAR_END : Symbols::S_BAR} "
|
|
232
|
+
else
|
|
233
|
+
" "
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
if item[:disabled]
|
|
237
|
+
"#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.strikethrough(Colors.dim(item[:label]))}\n"
|
|
238
|
+
elsif active && selected
|
|
239
|
+
"#{active_bar} #{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{item[:label]}\n"
|
|
240
|
+
elsif active
|
|
241
|
+
"#{active_bar} #{Colors.dim(prefix)}#{Colors.cyan(Symbols::S_CHECKBOX_ACTIVE)} #{item[:label]}\n"
|
|
242
|
+
elsif selected
|
|
243
|
+
"#{active_bar} #{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{Colors.dim(item[:label])}\n"
|
|
244
|
+
else
|
|
245
|
+
"#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.dim(item[:label])}\n"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|