clack 0.1.3 → 0.2.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -2
  3. data/README.md +108 -8
  4. data/examples/advanced_prompts.rb +63 -0
  5. data/examples/basic.rb +15 -0
  6. data/examples/create_app.rb +86 -0
  7. data/examples/date_demo.rb +40 -0
  8. data/examples/demo.rb +179 -0
  9. data/examples/full_demo.rb +84 -0
  10. data/examples/group_demo.rb +79 -0
  11. data/examples/images/confirm_example.rb +12 -0
  12. data/examples/images/multiselect_example.rb +15 -0
  13. data/examples/images/password_example.rb +10 -0
  14. data/examples/images/select_example.rb +15 -0
  15. data/examples/images/spinner_example.rb +11 -0
  16. data/examples/images/text_example.rb +11 -0
  17. data/examples/spinner_demo.rb +38 -0
  18. data/examples/tasks_demo.rb +59 -0
  19. data/examples/validation.rb +73 -0
  20. data/lib/clack/colors.rb +97 -3
  21. data/lib/clack/core/cursor.rb +1 -0
  22. data/lib/clack/core/key_reader.rb +5 -0
  23. data/lib/clack/core/prompt.rb +52 -8
  24. data/lib/clack/core/settings.rb +4 -0
  25. data/lib/clack/core/text_input_helper.rb +4 -0
  26. data/lib/clack/log.rb +51 -0
  27. data/lib/clack/note.rb +7 -0
  28. data/lib/clack/prompts/autocomplete.rb +29 -11
  29. data/lib/clack/prompts/autocomplete_multiselect.rb +5 -6
  30. data/lib/clack/prompts/date.rb +280 -0
  31. data/lib/clack/prompts/group_multiselect.rb +46 -18
  32. data/lib/clack/prompts/multiline_text.rb +8 -9
  33. data/lib/clack/prompts/multiselect.rb +3 -5
  34. data/lib/clack/prompts/password.rb +5 -10
  35. data/lib/clack/prompts/path.rb +2 -2
  36. data/lib/clack/prompts/progress.rb +2 -6
  37. data/lib/clack/prompts/select.rb +2 -6
  38. data/lib/clack/prompts/select_key.rb +5 -7
  39. data/lib/clack/prompts/spinner.rb +2 -6
  40. data/lib/clack/prompts/tasks.rb +50 -9
  41. data/lib/clack/prompts/text.rb +4 -3
  42. data/lib/clack/stream.rb +32 -3
  43. data/lib/clack/symbols.rb +25 -0
  44. data/lib/clack/task_log.rb +3 -5
  45. data/lib/clack/transformers.rb +8 -7
  46. data/lib/clack/validators.rb +33 -2
  47. data/lib/clack/version.rb +2 -1
  48. data/lib/clack.rb +72 -213
  49. metadata +18 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Clack
4
4
  module Core
5
+ # Global configuration for key bindings, guide bar display, and input classification.
5
6
  module Settings
6
7
  # Navigation and control actions
7
8
  ACTIONS = %i[up down left right space enter cancel].freeze
@@ -78,6 +79,9 @@ module Clack
78
79
  @config_mutex.synchronize { @config[:with_guide] }
79
80
  end
80
81
 
82
+ # Look up the action mapped to a key code.
83
+ # @param key [String] key code from {KeyReader}
84
+ # @return [Symbol, nil] the action (:up, :down, :enter, etc.) or nil
81
85
  def action?(key)
82
86
  aliases = @config_mutex.synchronize { @config[:aliases] }
83
87
  aliases[key] if ACTIONS.include?(aliases[key])
@@ -25,10 +25,14 @@ module Clack
25
25
  format_placeholder_with_cursor(text)
26
26
  end
27
27
 
28
+ # @return [String, nil] the placeholder text, or nil if none set
28
29
  def current_placeholder
29
30
  defined?(@placeholder) ? @placeholder : nil
30
31
  end
31
32
 
33
+ # Render placeholder text with an inverse cursor on the first character.
34
+ # @param text [String] placeholder text to format
35
+ # @return [String] formatted placeholder with cursor highlight
32
36
  def format_placeholder_with_cursor(text)
33
37
  chars = text.grapheme_clusters
34
38
  first = chars.first || ""
data/lib/clack/log.rb CHANGED
@@ -1,8 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clack
4
+ # Styled console logging with consistent formatting.
5
+ #
6
+ # Each method prints a message prefixed with a colored symbol. Multi-line
7
+ # messages are automatically aligned with a continuation bar on subsequent lines.
8
+ #
9
+ # Accessed via +Clack.log+:
10
+ #
11
+ # @example
12
+ # Clack.log.info("Starting build...")
13
+ # Clack.log.success("Build completed!")
14
+ # Clack.log.warn("Cache is stale")
15
+ # Clack.log.error("Build failed")
16
+ #
4
17
  module Log
5
18
  class << self
19
+ # Print a message with a custom or default symbol prefix.
20
+ #
21
+ # This is the base method used by all other log methods. Pass +symbol:+
22
+ # to customize the leading character (useful for extending with your own
23
+ # log levels).
24
+ #
25
+ # @param msg [String] the message to display
26
+ # @param symbol [String, nil] custom prefix symbol (default: gray bar)
27
+ # @param output [IO] output stream (default: $stdout)
28
+ # @return [void]
29
+ #
30
+ # @example Custom symbol
31
+ # Clack.log.message("Deploying...", symbol: "\u2708")
6
32
  def message(msg = "", symbol: nil, output: $stdout)
7
33
  symbol ||= Colors.gray(Symbols::S_BAR)
8
34
  lines = msg.to_s.lines
@@ -17,23 +43,48 @@ module Clack
17
43
  end
18
44
  end
19
45
 
46
+ # Print an informational message (blue symbol).
47
+ #
48
+ # @param msg [String] the message to display
49
+ # @param output [IO] output stream (default: $stdout)
50
+ # @return [void]
20
51
  def info(msg, output: $stdout)
21
52
  message(msg, symbol: Colors.blue(Symbols::S_INFO), output:)
22
53
  end
23
54
 
55
+ # Print a success message (green symbol).
56
+ #
57
+ # @param msg [String] the message to display
58
+ # @param output [IO] output stream (default: $stdout)
59
+ # @return [void]
24
60
  def success(msg, output: $stdout)
25
61
  message(msg, symbol: Colors.green(Symbols::S_SUCCESS), output:)
26
62
  end
27
63
 
64
+ # Print a step completion message (green submit symbol).
65
+ #
66
+ # @param msg [String] the message to display
67
+ # @param output [IO] output stream (default: $stdout)
68
+ # @return [void]
28
69
  def step(msg, output: $stdout)
29
70
  message(msg, symbol: Colors.green(Symbols::S_STEP_SUBMIT), output:)
30
71
  end
31
72
 
73
+ # Print a warning message (yellow symbol).
74
+ #
75
+ # @param msg [String] the message to display
76
+ # @param output [IO] output stream (default: $stdout)
77
+ # @return [void]
32
78
  def warn(msg, output: $stdout)
33
79
  message(msg, symbol: Colors.yellow(Symbols::S_WARN), output:)
34
80
  end
35
81
  alias_method :warning, :warn
36
82
 
83
+ # Print an error message (red symbol).
84
+ #
85
+ # @param msg [String] the message to display
86
+ # @param output [IO] output stream (default: $stdout)
87
+ # @return [void]
37
88
  def error(msg, output: $stdout)
38
89
  message(msg, symbol: Colors.red(Symbols::S_ERROR), output:)
39
90
  end
data/lib/clack/note.rb CHANGED
@@ -1,8 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clack
4
+ # Renders a bordered note box with optional title in the terminal.
4
5
  module Note
5
6
  class << self
7
+ # Render a note box to the output stream.
8
+ #
9
+ # @param message [String] the note content
10
+ # @param title [String, nil] optional title displayed above the box
11
+ # @param output [IO] output stream (default: $stdout)
12
+ # @return [void]
6
13
  def render(message = "", title: nil, output: $stdout)
7
14
  lines = message.to_s.lines.map(&:chomp)
8
15
  # Add empty lines at start and end like original
@@ -7,7 +7,9 @@ module Clack
7
7
  # Combines text input with a filtered option list. Type to filter,
8
8
  # use arrow keys to navigate matches, Enter to select.
9
9
  #
10
- # Filtering searches across value, label, and hint fields.
10
+ # By default, filtering searches across value, label, and hint fields
11
+ # using a case-insensitive substring match. Supply a custom +filter+
12
+ # proc to override this behavior.
11
13
  #
12
14
  # @example Basic usage
13
15
  # color = Clack.autocomplete(
@@ -23,6 +25,13 @@ module Clack
23
25
  # max_items: 10
24
26
  # )
25
27
  #
28
+ # @example Custom filter
29
+ # Clack.autocomplete(
30
+ # message: "Select command",
31
+ # options: commands,
32
+ # filter: ->(opt, query) { opt[:label].start_with?(query) }
33
+ # )
34
+ #
26
35
  class Autocomplete < Core::Prompt
27
36
  include Core::OptionsHelper
28
37
  include Core::TextInputHelper
@@ -31,12 +40,16 @@ module Clack
31
40
  # @param options [Array<Hash, String>] list of options to filter
32
41
  # @param max_items [Integer] max visible options (default: 5)
33
42
  # @param placeholder [String, nil] placeholder text when empty
43
+ # @param filter [Proc, nil] custom filter proc receiving (option_hash, query_string)
44
+ # and returning true/false. When nil, the default case-insensitive substring
45
+ # match across label, value, and hint is used.
34
46
  # @param opts [Hash] additional options passed to {Core::Prompt}
35
- def initialize(message:, options:, max_items: 5, placeholder: nil, **opts)
47
+ def initialize(message:, options:, max_items: 5, placeholder: nil, filter: nil, **opts)
36
48
  super(message:, **opts)
37
49
  @all_options = normalize_options(options)
38
50
  @max_items = max_items
39
51
  @placeholder = placeholder
52
+ @filter = filter
40
53
  @value = ""
41
54
  @cursor = 0
42
55
  @selected_index = 0
@@ -49,7 +62,6 @@ module Clack
49
62
  def handle_key(key)
50
63
  return if terminal_state?
51
64
 
52
- @state = :active if @state == :error
53
65
  action = Core::Settings.action?(key)
54
66
 
55
67
  case action
@@ -97,9 +109,11 @@ module Clack
97
109
  lines << "#{bar} #{option_display(opt, actual_idx == @selected_index)}\n"
98
110
  end
99
111
 
100
- lines << "#{bar_end}\n"
101
-
102
- lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n" if @state == :error
112
+ if @state in :error | :warning
113
+ lines.concat(validation_message_lines)
114
+ else
115
+ lines << "#{bar_end}\n"
116
+ end
103
117
 
104
118
  lines.join
105
119
  end
@@ -119,11 +133,15 @@ module Clack
119
133
  private
120
134
 
121
135
  def update_filtered
122
- query = @value.downcase
123
- @filtered = @all_options.select do |opt|
124
- opt[:label].downcase.include?(query) ||
125
- opt[:value].to_s.downcase.include?(query) ||
126
- opt[:hint]&.downcase&.include?(query)
136
+ @filtered = if @filter
137
+ @all_options.select { |opt| @filter.call(opt, @value) }
138
+ else
139
+ query = @value.downcase
140
+ @all_options.select do |opt|
141
+ opt[:label].downcase.include?(query) ||
142
+ opt[:value].to_s.downcase.include?(query) ||
143
+ opt[:hint]&.downcase&.include?(query)
144
+ end
127
145
  end
128
146
  end
129
147
 
@@ -57,7 +57,6 @@ module Clack
57
57
  def handle_key(key)
58
58
  return if terminal_state?
59
59
 
60
- @state = :active if @state == :error
61
60
  action = Core::Settings.action?(key)
62
61
 
63
62
  case action
@@ -123,7 +122,7 @@ module Clack
123
122
 
124
123
  def submit_selection
125
124
  if @required && @selected_values.empty?
126
- @error_message = "Please select at least one option"
125
+ @error_message = "Please select at least one option. Press #{Colors.cyan("space")} to select, #{Colors.cyan("enter")} to submit"
127
126
  @state = :error
128
127
  return
129
128
  end
@@ -193,17 +192,17 @@ module Clack
193
192
 
194
193
  # Override to work with @search_text instead of @value
195
194
  def handle_text_input(key)
196
- return false unless Core::Settings.printable?(key)
197
-
198
- chars = @search_text.grapheme_clusters
199
-
200
195
  if Core::Settings.backspace?(key)
201
196
  return false if @cursor.zero?
202
197
 
198
+ chars = @search_text.grapheme_clusters
203
199
  chars.delete_at(@cursor - 1)
204
200
  @search_text = chars.join
205
201
  @cursor -= 1
206
202
  else
203
+ return false unless Core::Settings.printable?(key)
204
+
205
+ chars = @search_text.grapheme_clusters
207
206
  chars.insert(@cursor, key)
208
207
  @search_text = chars.join
209
208
  @cursor += 1
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Clack
6
+ module Prompts
7
+ # Date picker prompt with inline segmented input.
8
+ #
9
+ # Features:
10
+ # - Three formats: :iso (YYYY-MM-DD), :us (MM/DD/YYYY), :eu (DD/MM/YYYY)
11
+ # - Arrow key navigation between segments
12
+ # - Up/down to increment/decrement values
13
+ # - Direct digit typing with auto-advance
14
+ # - Min/max date bounds validation
15
+ #
16
+ # @example Basic usage
17
+ # date = Clack.date(message: "Select a date")
18
+ #
19
+ # @example With bounds
20
+ # date = Clack.date(
21
+ # message: "When?",
22
+ # min: Date.today,
23
+ # max: Date.today + 365,
24
+ # format: :us
25
+ # )
26
+ #
27
+ class Date < Core::Prompt
28
+ # Supported date format configurations mapping format symbol to segment order and separator.
29
+ FORMATS = {
30
+ iso: {order: [:year, :month, :day], sep: "-"},
31
+ us: {order: [:month, :day, :year], sep: "/"},
32
+ eu: {order: [:day, :month, :year], sep: "/"}
33
+ }.freeze
34
+
35
+ # Non-leap-year days per month (index 1-12; index 0 unused).
36
+ DAYS_IN_MONTH = [nil, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
37
+ KEY_SHIFT_TAB = "\e[Z" # ANSI escape sequence for Shift+Tab
38
+
39
+ # @param message [String] the prompt message
40
+ # @param format [Symbol] date format (:iso, :us, :eu)
41
+ # @param initial_value [Date, Time, String, nil] initial date value
42
+ # @param min [Date, nil] minimum allowed date
43
+ # @param max [Date, nil] maximum allowed date
44
+ # @param opts [Hash] additional options passed to {Core::Prompt}
45
+ def initialize(message:, format: :iso, initial_value: nil, min: nil, max: nil, **opts)
46
+ super(message:, **opts)
47
+
48
+ raise ArgumentError, "Unknown format: #{format}" unless FORMATS.key?(format)
49
+ raise ArgumentError, "min must be before or equal to max" if min && max && min > max
50
+
51
+ @format = format
52
+ @min = min
53
+ @max = max
54
+ @segment = 0
55
+ @input_buffer = ""
56
+
57
+ init_date(initial_value)
58
+ end
59
+
60
+ protected
61
+
62
+ def handle_input(key, action)
63
+ case action
64
+ when :left then move_segment(-1)
65
+ when :right then move_segment(1)
66
+ when :up then adjust_segment(1)
67
+ when :down then adjust_segment(-1)
68
+ else
69
+ case key
70
+ when "\t" then move_segment(1)
71
+ when KEY_SHIFT_TAB then move_segment(-1)
72
+ when /\A\d\z/ then handle_digit(key)
73
+ end
74
+ end
75
+ end
76
+
77
+ def submit
78
+ @value = ::Date.new(@year, @month, @day)
79
+ super
80
+ rescue ArgumentError
81
+ @error_message = friendly_date_error
82
+ @state = :error
83
+ end
84
+
85
+ def build_frame
86
+ lines = []
87
+ lines << "#{bar}\n"
88
+ lines << "#{symbol_for_state} #{@message}\n"
89
+ lines << help_line
90
+ lines << "#{active_bar} #{date_display}\n"
91
+ lines << "#{bar_end}\n" if %i[active initial].include?(@state)
92
+
93
+ validation_lines = validation_message_lines
94
+ if validation_lines.any?
95
+ lines[-1] = validation_lines.first
96
+ lines.concat(validation_lines[1..])
97
+ end
98
+
99
+ lines.join
100
+ end
101
+
102
+ def build_final_frame
103
+ lines = []
104
+ lines << "#{bar}\n"
105
+ lines << "#{symbol_for_state} #{@message}\n"
106
+
107
+ display_text = formatted_date
108
+ display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display_text)) : Colors.dim(display_text)
109
+ lines << "#{bar} #{display}\n"
110
+
111
+ lines.join
112
+ end
113
+
114
+ private
115
+
116
+ def init_date(initial)
117
+ date = parse_initial(initial)
118
+ date = clamp_to_bounds(date)
119
+ @year = date.year
120
+ @month = date.month
121
+ @day = date.day
122
+ end
123
+
124
+ def parse_initial(initial)
125
+ case initial
126
+ when ::Date then initial
127
+ when ::Time then initial.to_date
128
+ when String then ::Date.parse(initial)
129
+ else ::Date.today
130
+ end
131
+ rescue ::Date::Error
132
+ ::Date.today
133
+ end
134
+
135
+ def clamp_to_bounds(date)
136
+ return @min if @min && date < @min
137
+ return @max if @max && date > @max
138
+
139
+ date
140
+ end
141
+
142
+ # Enforce min/max bounds on the current @year/@month/@day components.
143
+ # Constructs a temporary Date (after clamping day to valid range) and
144
+ # writes back if the date falls outside the allowed bounds.
145
+ def enforce_bounds
146
+ return unless @min || @max
147
+
148
+ clamp_day_to_month
149
+ date = ::Date.new(@year, @month, @day)
150
+ clamped = clamp_to_bounds(date)
151
+ return if clamped == date
152
+
153
+ @year = clamped.year
154
+ @month = clamped.month
155
+ @day = clamped.day
156
+ end
157
+
158
+ def move_segment(delta)
159
+ commit_input_buffer
160
+ @segment = (@segment + delta) % 3
161
+ @input_buffer = ""
162
+ end
163
+
164
+ def adjust_segment(delta)
165
+ commit_input_buffer
166
+ @input_buffer = ""
167
+
168
+ case current_segment_type
169
+ when :year
170
+ @year = (@year + delta).clamp(1, 9999)
171
+ clamp_day_to_month
172
+ when :month
173
+ @month += delta
174
+ @month = wrap_value(@month, 1, 12)
175
+ clamp_day_to_month
176
+ when :day
177
+ max_day = days_in_month(@year, @month)
178
+ @day = wrap_value(@day + delta, 1, max_day)
179
+ end
180
+
181
+ enforce_bounds
182
+ end
183
+
184
+ def wrap_value(val, min, max)
185
+ return min if val > max
186
+ return max if val < min
187
+
188
+ val
189
+ end
190
+
191
+ def handle_digit(digit)
192
+ @input_buffer += digit
193
+ expected_length = (current_segment_type == :year) ? 4 : 2
194
+
195
+ if @input_buffer.length >= expected_length
196
+ commit_input_buffer
197
+ move_segment(1) unless @segment == 2
198
+ end
199
+ end
200
+
201
+ def commit_input_buffer
202
+ return if @input_buffer.empty?
203
+
204
+ value = @input_buffer.to_i
205
+ @input_buffer = ""
206
+
207
+ case current_segment_type
208
+ when :year
209
+ @year = value.clamp(1, 9999)
210
+ clamp_day_to_month
211
+ when :month
212
+ @month = value.clamp(1, 12)
213
+ clamp_day_to_month
214
+ when :day
215
+ @day = value.clamp(1, days_in_month(@year, @month))
216
+ end
217
+
218
+ enforce_bounds
219
+ end
220
+
221
+ def current_segment_type = FORMATS[@format][:order][@segment]
222
+
223
+ def days_in_month(year, month)
224
+ return 29 if month == 2 && leap_year?(year)
225
+
226
+ DAYS_IN_MONTH[month]
227
+ end
228
+
229
+ def leap_year?(year) = ::Date.leap?(year)
230
+
231
+ def clamp_day_to_month
232
+ max_day = days_in_month(@year, @month)
233
+ @day = max_day if @day > max_day
234
+ end
235
+
236
+ def formatted_date
237
+ fmt = FORMATS[@format]
238
+ fmt[:order].map { |type|
239
+ case type
240
+ when :year then @year.to_s.rjust(4, "0")
241
+ when :month then @month.to_s.rjust(2, "0")
242
+ when :day then @day.to_s.rjust(2, "0")
243
+ end
244
+ }.join(fmt[:sep])
245
+ end
246
+
247
+ def date_display
248
+ fmt = FORMATS[@format]
249
+ fmt[:order].each_with_index.map { |type, idx|
250
+ text = segment_text_for(type)
251
+ (idx == @segment) ? Colors.inverse(text) : text
252
+ }.join(fmt[:sep])
253
+ end
254
+
255
+ def segment_text_for(type)
256
+ showing_buffer = !@input_buffer.empty? && current_segment_type == type
257
+ case type
258
+ when :year
259
+ showing_buffer ? @input_buffer.ljust(4, "_") : @year.to_s.rjust(4, "0")
260
+ when :month
261
+ showing_buffer ? @input_buffer.ljust(2, "_") : @month.to_s.rjust(2, "0")
262
+ when :day
263
+ showing_buffer ? @input_buffer.ljust(2, "_") : @day.to_s.rjust(2, "0")
264
+ end
265
+ end
266
+
267
+ def friendly_date_error
268
+ max_day = days_in_month(@year, @month)
269
+ month_name = ::Date::MONTHNAMES[@month]
270
+
271
+ if @day > max_day
272
+ leap_note = (@month == 2 && !leap_year?(@year)) ? " (not a leap year)" : ""
273
+ "#{month_name} #{@year} has #{max_day} days#{leap_note}"
274
+ else
275
+ "Invalid date"
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
@@ -2,7 +2,46 @@
2
2
 
3
3
  module Clack
4
4
  module Prompts
5
+ # Multiple-selection prompt with options organized into named groups.
6
+ #
7
+ # Navigate with arrow keys or j/k. Toggle selection with Space.
8
+ # Groups can optionally be toggled as a whole when +selectable_groups+ is enabled.
9
+ #
10
+ # @example Basic usage
11
+ # features = Clack.group_multiselect(
12
+ # message: "Select features",
13
+ # options: [
14
+ # { label: "Frontend", options: %w[hotwire stimulus] },
15
+ # { label: "Backend", options: %w[sidekiq solid_queue] }
16
+ # ]
17
+ # )
18
+ #
19
+ # @example With selectable groups and spacing
20
+ # features = Clack.group_multiselect(
21
+ # message: "Select features",
22
+ # options: [
23
+ # { label: "Frontend", options: [
24
+ # { value: "hotwire", label: "Hotwire" },
25
+ # { value: "stimulus", label: "Stimulus" }
26
+ # ]},
27
+ # { label: "Background", options: [
28
+ # { value: "sidekiq", label: "Sidekiq" },
29
+ # { value: "solid_queue", label: "Solid Queue" }
30
+ # ]}
31
+ # ],
32
+ # selectable_groups: true,
33
+ # group_spacing: 1
34
+ # )
35
+ #
5
36
  class GroupMultiselect < Core::Prompt
37
+ # @param message [String] the prompt message
38
+ # @param options [Array<Hash>] groups, each with :label and :options (Array<Hash, String>)
39
+ # @param initial_values [Array] values to pre-select
40
+ # @param required [Boolean] require at least one selection (default: true)
41
+ # @param selectable_groups [Boolean] allow toggling all options in a group at once (default: false)
42
+ # @param group_spacing [Integer] number of blank lines between groups (default: 0)
43
+ # @param cursor_at [Object, nil] value to position cursor at initially
44
+ # @param opts [Hash] additional options passed to {Core::Prompt}
6
45
  def initialize(
7
46
  message:,
8
47
  options:,
@@ -29,7 +68,6 @@ module Clack
29
68
  def handle_key(key)
30
69
  return if terminal_state?
31
70
 
32
- @state = :active if @state == :error
33
71
  action = Core::Settings.action?(key)
34
72
 
35
73
  case action
@@ -48,7 +86,7 @@ module Clack
48
86
 
49
87
  def submit
50
88
  if @required && @selected.empty?
51
- @error_message = "Please select at least one option."
89
+ @error_message = "Please select at least one option. Press #{Colors.cyan("space")} to select, #{Colors.cyan("enter")} to submit"
52
90
  @state = :error
53
91
  return
54
92
  end
@@ -67,7 +105,7 @@ module Clack
67
105
  is_last_in_group = item[:last_in_group]
68
106
 
69
107
  # Add group spacing before groups (except first)
70
- if is_group && prev_was_group == false && idx.positive? && @group_spacing.positive?
108
+ if is_group && !prev_was_group && idx.positive? && @group_spacing.positive?
71
109
  @group_spacing.times { lines << "#{active_bar}\n" }
72
110
  end
73
111
 
@@ -102,9 +140,7 @@ module Clack
102
140
 
103
141
  private
104
142
 
105
- def normalize_groups(options)
106
- options.map { |group| normalize_group(group) }
107
- end
143
+ def normalize_groups(options) = options.map { |group| normalize_group(group) }
108
144
 
109
145
  def normalize_group(group)
110
146
  {
@@ -122,9 +158,7 @@ module Clack
122
158
  end
123
159
  end
124
160
 
125
- def build_flat_items
126
- @groups.flat_map { |group| flatten_group(group) }
127
- end
161
+ def build_flat_items = @groups.flat_map { |group| flatten_group(group) }
128
162
 
129
163
  def flatten_group(group)
130
164
  group_item = {type: :group, label: group[:label], options: group[:options]}
@@ -138,7 +172,7 @@ module Clack
138
172
  last_in_group: idx == group[:options].length - 1
139
173
  }
140
174
  end
141
- [group_item] + option_items
175
+ [group_item, *option_items]
142
176
  end
143
177
 
144
178
  def selected_options
@@ -153,11 +187,7 @@ module Clack
153
187
  return idx if idx
154
188
  end
155
189
 
156
- # Find first selectable item
157
- @flat_items.each_with_index do |item, idx|
158
- return idx if can_select?(item)
159
- end
160
- 0
190
+ @flat_items.find_index { |item| can_select?(item) } || 0
161
191
  end
162
192
 
163
193
  def can_select?(item)
@@ -211,9 +241,7 @@ module Clack
211
241
  end
212
242
  end
213
243
 
214
- def update_value
215
- @value = @selected.to_a
216
- end
244
+ def update_value = @value = @selected.to_a
217
245
 
218
246
  def group_display(item, active)
219
247
  if @selectable_groups