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.
@@ -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