clack 0.6.2 → 0.7.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.
@@ -33,11 +33,21 @@ module Clack
33
33
  # group_spacing: 1
34
34
  # )
35
35
  #
36
+ # @example Hash shorthand (group label => value => label)
37
+ # addons = Clack.group_multiselect(
38
+ # message: "Add-ons",
39
+ # options: {
40
+ # "Observability" => { prom: "Prometheus", dd: "Datadog" },
41
+ # "Storage" => %w[s3 gcs]
42
+ # }
43
+ # )
44
+ #
36
45
  class GroupMultiselect < Core::Prompt
37
46
  include Core::SelectionManager
38
47
 
39
48
  # @param message [String] the prompt message
40
- # @param options [Array<Hash>] groups, each with :label and :options (Array<Hash, String>)
49
+ # @param options [Array<Hash>, Hash] groups as an Array of {label:, options:} hashes, or a Hash of
50
+ # group label => options; each group's options accept every shape {Select} accepts (Array or Hash)
41
51
  # @param initial_values [Array] values to pre-select
42
52
  # @param required [Boolean] require at least one selection (default: true)
43
53
  # @param selectable_groups [Boolean] allow toggling all options in a group at once (default: false)
@@ -86,10 +96,7 @@ module Clack
86
96
  end
87
97
 
88
98
  def build_frame
89
- lines = []
90
- lines << "#{bar}\n"
91
- lines << "#{symbol_for_state} #{@message}\n"
92
- lines << help_line
99
+ lines = [frame_header]
93
100
 
94
101
  prev_was_group = false
95
102
  @flat_items.each_with_index do |item, idx|
@@ -97,9 +104,7 @@ module Clack
97
104
  is_last_in_group = item.is_a?(Core::GroupOption) ? item.last_in_group : false
98
105
 
99
106
  # Add group spacing before groups (except first)
100
- if is_group && !prev_was_group && idx.positive? && @group_spacing.positive?
101
- @group_spacing.times { lines << "#{active_bar}\n" }
102
- end
107
+ lines.concat(group_spacing_lines) if is_group && !prev_was_group && idx.positive?
103
108
 
104
109
  lines << if is_group
105
110
  group_display(item, idx == @option_index)
@@ -110,14 +115,7 @@ module Clack
110
115
  prev_was_group = is_group
111
116
  end
112
117
 
113
- if @state == :error
114
- lines << "#{bar_end}\n"
115
- lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n"
116
- else
117
- lines << "#{bar} #{keyboard_hints}\n"
118
- lines << "#{bar_end}\n"
119
- end
120
-
118
+ lines << frame_footer
121
119
  lines.join
122
120
  end
123
121
 
@@ -125,17 +123,22 @@ module Clack
125
123
 
126
124
  private
127
125
 
128
- def normalize_groups(options) = options.map { |group| normalize_group(group) }
126
+ def normalize_groups(options)
127
+ return options.map { |label, opts| normalize_group(label: label.to_s, options: opts) } if options.is_a?(Hash)
129
128
 
130
- def normalize_group(group)
131
- {
132
- label: group[:label] || group[:group],
133
- options: group[:options].map { |opt| normalize_option(opt) }
134
- }
129
+ options.map { |group| normalize_group(group) }
135
130
  end
136
131
 
137
- def normalize_option(opt)
138
- Core::OptionsHelper.normalize_option(opt)
132
+ def normalize_group(group)
133
+ label = group[:label] || group[:group]
134
+ opts = group[:options]
135
+ # Duck-typed on purpose: any Enumerable (Set, Range) worked before the
136
+ # guard existed and still does; the message names the documented shapes.
137
+ unless opts.respond_to?(:map)
138
+ raise ArgumentError, "group #{label.inspect} options must be an Array or Hash"
139
+ end
140
+
141
+ {label: label, options: Core::OptionsHelper.normalize_list(opts)}
139
142
  end
140
143
 
141
144
  def build_flat_items = @groups.flat_map { |group| flatten_group(group) }
@@ -219,8 +222,21 @@ module Clack
219
222
 
220
223
  def update_value = update_selection_value
221
224
 
225
+ # Blank rail lines between groups; plain newlines when guides are off (upstream behavior).
226
+ def group_spacing_lines
227
+ return [] unless @group_spacing.positive?
228
+
229
+ line = guide? ? "#{active_bar}\n" : "\n"
230
+ Array.new(@group_spacing, line)
231
+ end
232
+
233
+ # Shares upstream MULTISELECT_INSTRUCTIONS with Multiselect.
222
234
  def keyboard_hints
223
- Colors.dim("#{Colors.dim("space")} select")
235
+ [
236
+ key_hint(Symbols::S_ARROWS_UP_DOWN, "to navigate"),
237
+ key_hint("Space:", "select"),
238
+ key_hint("Enter:", "confirm")
239
+ ]
224
240
  end
225
241
 
226
242
  def group_display(item, active)
@@ -228,9 +244,9 @@ module Clack
228
244
  all_selected = item.options.reject { |o| o.disabled }.all? { |o| @selected.include?(o.value) }
229
245
  checkbox = all_selected ? Colors.green(Symbols::S_CHECKBOX_SELECTED) : Colors.dim(Symbols::S_CHECKBOX_INACTIVE)
230
246
  label = active ? item.label : Colors.dim(item.label)
231
- "#{active_bar} #{checkbox} #{label}\n"
247
+ "#{gutter(active_bar)}#{checkbox} #{label}\n"
232
248
  else
233
- "#{active_bar} #{Colors.dim(item.label)}\n"
249
+ "#{gutter(active_bar)}#{Colors.dim(item.label)}\n"
234
250
  end
235
251
  end
236
252
 
@@ -244,15 +260,15 @@ module Clack
244
260
  hint = (item.hint && active) ? " #{Colors.dim("(#{item.hint})")}" : ""
245
261
 
246
262
  if item.disabled
247
- "#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.strikethrough(Colors.dim(item.label))}\n"
263
+ "#{gutter(active_bar)}#{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.strikethrough(Colors.dim(item.label))}\n"
248
264
  elsif active && selected
249
- "#{active_bar} #{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{item.label}#{hint}\n"
265
+ "#{gutter(active_bar)}#{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{item.label}#{hint}\n"
250
266
  elsif active
251
- "#{active_bar} #{Colors.dim(prefix)}#{Colors.cyan(Symbols::S_CHECKBOX_ACTIVE)} #{item.label}#{hint}\n"
267
+ "#{gutter(active_bar)}#{Colors.dim(prefix)}#{Colors.cyan(Symbols::S_CHECKBOX_ACTIVE)} #{item.label}#{hint}\n"
252
268
  elsif selected
253
- "#{active_bar} #{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{Colors.dim(item.label)}\n"
269
+ "#{gutter(active_bar)}#{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{Colors.dim(item.label)}\n"
254
270
  else
255
- "#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.dim(item.label)}\n"
271
+ "#{gutter(active_bar)}#{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.dim(item.label)}\n"
256
272
  end
257
273
  end
258
274
  end
@@ -25,7 +25,7 @@ module Clack
25
25
 
26
26
  # @param message [String] the prompt message
27
27
  # @param initial_value [String, nil] pre-filled editable text (can contain newlines)
28
- # @option opts [Proc, nil] :validate validation proc returning error string or nil
28
+ # @option opts [Proc, Regexp, Symbol, Array, Hash, nil, false] :validate validator (see {Validators.resolve})
29
29
  # @option opts [Hash] additional options passed to {Core::Prompt}
30
30
  def initialize(message:, initial_value: nil, **opts)
31
31
  super(message:, **opts)
@@ -67,13 +67,13 @@ module Clack
67
67
  end
68
68
 
69
69
  def frame_header
70
- "#{bar}\n#{symbol_for_state} #{@message} #{Colors.dim("(Ctrl+D to submit)")}\n#{help_line}"
70
+ "#{guide_line}#{symbol_for_state} #{@message} #{Colors.dim("(Ctrl+D to submit)")}\n#{help_line}"
71
71
  end
72
72
 
73
73
  def build_frame
74
74
  body = @lines.each_with_index.map do |line, idx|
75
75
  display = (idx == @line_index) ? value_with_cursor : line
76
- "#{active_bar} #{display}\n"
76
+ "#{gutter(active_bar)}#{display}\n"
77
77
  end.join
78
78
 
79
79
  "#{frame_header}#{body}#{frame_footer}"
@@ -81,13 +81,13 @@ module Clack
81
81
 
82
82
  def build_final_frame
83
83
  lines = []
84
- lines << "#{bar}\n"
84
+ lines << guide_line
85
85
  lines << "#{symbol_for_state} #{@message}\n"
86
86
 
87
87
  if @state == :cancel
88
- @lines.each { |line| lines << "#{bar} #{Colors.strikethrough(Colors.dim(line))}\n" }
88
+ @lines.each { |line| lines << "#{gutter}#{Colors.strikethrough(Colors.dim(line))}\n" }
89
89
  else
90
- @lines.each { |line| lines << "#{bar} #{Colors.dim(line)}\n" }
90
+ @lines.each { |line| lines << "#{gutter}#{Colors.dim(line)}\n" }
91
91
  end
92
92
 
93
93
  lines.join
@@ -5,7 +5,8 @@ module Clack
5
5
  # Multiple-selection prompt from a list of options.
6
6
  #
7
7
  # Navigate with arrow keys or j/k. Toggle selection with Space.
8
- # Supports shortcuts: 'a' to toggle all, 'i' to invert selection.
8
+ # Supports shortcuts: `a` to toggle all, `i` to invert selection (not
9
+ # listed in the footer; see the README).
9
10
  #
10
11
  # Options format is the same as {Select}.
11
12
  #
@@ -32,7 +33,7 @@ module Clack
32
33
  include Core::SelectionManager
33
34
 
34
35
  # @param message [String] the prompt message
35
- # @param options [Array<Hash, String>] list of options
36
+ # @param options [Array<Hash, String>, Hash] list of options, or a Hash of value => label
36
37
  # @param initial_values [Array] values to pre-select
37
38
  # @param required [Boolean] require at least one selection (default: true)
38
39
  # @param max_items [Integer, nil] max visible items (enables scrolling)
@@ -84,23 +85,14 @@ module Clack
84
85
  end
85
86
 
86
87
  def build_frame
87
- lines = []
88
- lines << "#{bar}\n"
89
- lines << "#{symbol_for_state} #{@message}\n"
90
- lines << help_line
88
+ lines = [frame_header]
91
89
 
92
90
  visible_options.each_with_index do |opt, idx|
93
91
  actual_idx = @scroll_offset + idx
94
- lines << "#{active_bar} #{option_display(opt, actual_idx)}\n"
95
- end
96
-
97
- if @state in :error | :warning
98
- lines.concat(validation_message_lines)
99
- else
100
- lines << "#{bar} #{keyboard_hints}\n"
101
- lines << "#{bar_end}\n"
92
+ lines << "#{gutter(active_bar)}#{option_display(opt, actual_idx)}\n"
102
93
  end
103
94
 
95
+ lines << frame_footer
104
96
  lines.join
105
97
  end
106
98
 
@@ -135,13 +127,14 @@ module Clack
135
127
  update_selection_value
136
128
  end
137
129
 
130
+ # Upstream MULTISELECT_INSTRUCTIONS; a/i are documented, not listed, so the
131
+ # line stays narrow enough not to soft-wrap in a split pane.
138
132
  def keyboard_hints
139
- hints = [
140
- "#{Colors.dim("space")} select",
141
- "#{Colors.dim("a")} all",
142
- "#{Colors.dim("i")} invert"
133
+ [
134
+ key_hint(Symbols::S_ARROWS_UP_DOWN, "to navigate"),
135
+ key_hint("Space:", "select"),
136
+ key_hint("Enter:", "confirm")
143
137
  ]
144
- Colors.dim(hints.join(Colors.dim(" / ")))
145
138
  end
146
139
 
147
140
  def option_display(opt, idx)
@@ -16,7 +16,7 @@ module Clack
16
16
  class Password < Core::Prompt
17
17
  # @param message [String] the prompt message
18
18
  # @param mask [String, nil] character to display (default: "▪")
19
- # @option opts [Proc, nil] :validate validation proc returning error string or nil
19
+ # @option opts [Proc, Regexp, Symbol, Array, Hash, nil, false] :validate validator (see {Validators.resolve})
20
20
  # @option opts [Hash] additional options passed to {Core::Prompt}
21
21
  def initialize(message:, mask: nil, **opts)
22
22
  super(message:, **opts)
@@ -35,7 +35,7 @@ module Clack
35
35
  end
36
36
 
37
37
  def build_frame
38
- "#{frame_header}#{active_bar} #{masked_display}\n#{frame_footer}"
38
+ "#{frame_header}#{gutter(active_bar)}#{masked_display}\n#{frame_footer}"
39
39
  end
40
40
 
41
41
  def final_display = @mask * @value.grapheme_clusters.length
@@ -32,7 +32,8 @@ module Clack
32
32
  # @param root [String] starting/base directory (default: ".")
33
33
  # @param only_directories [Boolean] only show directories (default: false)
34
34
  # @param max_items [Integer] max visible suggestions (default: 5)
35
- # @option opts [Proc, nil] :validate validation proc for the final path
35
+ # @option opts [Proc, Regexp, Symbol, Array, Hash, nil, false] :validate validator for the final path
36
+ # (see {Validators.resolve})
36
37
  # @option opts [Hash] additional options passed to {Core::Prompt}
37
38
  def initialize(message:, root: ".", only_directories: false, max_items: 5, **opts)
38
39
  super(message:, **opts)
@@ -104,14 +105,24 @@ module Clack
104
105
  def build_frame
105
106
  suggestion_lines = visible_options.each_with_index.map do |path, idx|
106
107
  actual_idx = @scroll_offset + idx
107
- "#{bar} #{suggestion_display(path, actual_idx == @option_index)}\n"
108
+ "#{gutter(active_bar)}#{suggestion_display(path, actual_idx == @option_index)}\n"
108
109
  end.join
109
110
 
110
- "#{frame_header}#{active_bar} #{input_display}\n#{suggestion_lines}#{frame_footer}"
111
+ "#{frame_header}#{gutter(active_bar)}#{input_display}\n#{suggestion_lines}#{frame_footer}"
111
112
  end
112
113
 
113
114
  private
114
115
 
116
+ # Upstream autocomplete footer, completeOnTab variant (Ruby always completes on Tab).
117
+ def keyboard_hints
118
+ [
119
+ key_hint(Symbols::S_ARROWS_UP_DOWN, "to select"),
120
+ key_hint("Tab:", "complete"),
121
+ key_hint("Enter:", "confirm"),
122
+ key_hint("Type:", "to search")
123
+ ]
124
+ end
125
+
115
126
  def update_suggestions
116
127
  base_path = resolve_path(@value)
117
128
 
@@ -17,6 +17,9 @@ module Clack
17
17
  # step: 1, initial_value: 4
18
18
  # )
19
19
  #
20
+ # @example Fractional step
21
+ # opacity = Clack.range(message: "Opacity", min: 0, max: 1, step: 0.1)
22
+ #
20
23
  class Range < Core::Prompt
21
24
  TRACK_WIDTH = 30
22
25
  TRACK_CHAR = "\u2501" # ━ (box drawings heavy horizontal)
@@ -25,7 +28,10 @@ module Clack
25
28
  # @param message [String] the prompt message
26
29
  # @param min [Numeric] minimum value (default: 0)
27
30
  # @param max [Numeric] maximum value (default: 100)
28
- # @param step [Numeric] increment size (default: 1)
31
+ # @param step [Numeric] increment size (default: 1). Fractional steps such
32
+ # as 0.1 are snapped exactly (0.1 + 0.1 + 0.1 is 0.3, never 0.30000000000000004).
33
+ # The prompt returns an Integer when min and step are both Integers,
34
+ # a Float when either is a Float, and a Rational for Rational inputs.
29
35
  # @param initial_value [Numeric, nil] initial value (defaults to min)
30
36
  # @param default [Numeric, nil] deprecated alias for initial_value
31
37
  # @param opts [Hash] additional options passed to {Core::Prompt}
@@ -38,6 +44,13 @@ module Clack
38
44
  @min = min
39
45
  @max = max
40
46
  @step = step
47
+ # Snap arithmetic runs on these exact decimal forms (0.1 => 1/10) so
48
+ # repeated steps never accumulate binary Float error.
49
+ @exact_min = exact(min)
50
+ @exact_step = exact(step)
51
+ # Whether `min + step` promotes to Float, which decides how grid_value
52
+ # converts back. Fixed for the life of the prompt, so computed once.
53
+ @float_grid = (min + step).is_a?(Float)
41
54
  @value = clamp(initial_value || default || min)
42
55
  end
43
56
 
@@ -51,7 +64,7 @@ module Clack
51
64
  end
52
65
 
53
66
  def build_frame
54
- "#{frame_header}#{active_bar} #{slider_display}\n#{frame_footer}"
67
+ "#{frame_header}#{gutter(active_bar)}#{slider_display}\n#{frame_footer}"
55
68
  end
56
69
 
57
70
  def final_display = format_value(@value)
@@ -62,10 +75,30 @@ module Clack
62
75
  @value = clamp(@value + delta)
63
76
  end
64
77
 
78
+ # Clamp to [min, max] and snap to the nearest point on the step grid.
79
+ # The grid position is computed in Rational so a step of 0.1 lands on
80
+ # 0.3, not 0.30000000000000004, then converted back to the caller's type.
65
81
  def clamp(val)
66
82
  val = val.clamp(@min, @max)
67
- snapped = @min + (((val - @min).to_f / @step).round * @step)
68
- snapped.clamp(@min, @max)
83
+ steps = ((exact(val) - @exact_min) / @exact_step).round
84
+ grid_value(steps).clamp(@min, @max)
85
+ end
86
+
87
+ # Exact decimal form of a numeric. Floats go through #rationalize so
88
+ # 0.1 becomes 1/10 rather than its binary approximation; every other
89
+ # Numeric converts losslessly with #to_r.
90
+ def exact(num)
91
+ num.is_a?(Float) ? num.rationalize : num.to_r
92
+ end
93
+
94
+ # Value at grid position `steps`, in the type `min + step` promotes to.
95
+ # Float grids take the exact Rational and round once at the end. Every
96
+ # other numeric (Integer, Rational, BigDecimal) is exact under plain
97
+ # arithmetic, so the caller's own `min + n * step` is used unchanged.
98
+ def grid_value(steps)
99
+ return (@exact_min + (steps * @exact_step)).to_f if @float_grid
100
+
101
+ @min + (steps * @step)
69
102
  end
70
103
 
71
104
  def format_value(val)
@@ -10,6 +10,8 @@ module Clack
10
10
  # Options can be:
11
11
  # - Strings: `["a", "b", "c"]` (value and label are the same)
12
12
  # - Hashes: `[{value: "a", label: "Option A", hint: "details", disabled: false}]`
13
+ # - A Hash: `{ a: "Option A", b: { label: "Option B", hint: "details", disabled: false } }`
14
+ # (keys are values, values are labels or spec hashes; insertion order is display order)
13
15
  #
14
16
  # @example Basic usage
15
17
  # choice = Clack.select(
@@ -60,7 +62,7 @@ module Clack
60
62
  def build_frame
61
63
  option_lines = visible_options.each_with_index.map do |opt, idx|
62
64
  actual_idx = @scroll_offset + idx
63
- "#{bar} #{option_display(opt, actual_idx == @option_index)}\n"
65
+ "#{gutter(active_bar)}#{option_display(opt, actual_idx == @option_index)}\n"
64
66
  end.join
65
67
 
66
68
  "#{frame_header}#{option_lines}#{frame_footer}"
@@ -70,6 +72,11 @@ module Clack
70
72
 
71
73
  private
72
74
 
75
+ # Upstream SELECT_INSTRUCTIONS
76
+ def keyboard_hints
77
+ [key_hint(Symbols::S_ARROWS_UP_DOWN, "to navigate"), key_hint("Enter:", "confirm")]
78
+ end
79
+
73
80
  def move_cursor(delta)
74
81
  super
75
82
  update_value
@@ -5,11 +5,14 @@ module Clack
5
5
  # Quick selection via keyboard shortcuts.
6
6
  #
7
7
  # Each option has an associated key. Pressing that key immediately
8
- # selects the option and submits.
8
+ # selects the option and submits (running +validate:+ and +transform:+
9
+ # like every other prompt).
9
10
  #
10
11
  # Options format:
11
12
  # - `{ value: "x", label: "Do X", key: "x" }` - explicit key
12
13
  # - `{ value: "create", label: "Create" }` - key defaults to first char
14
+ # - `{ create: "Create", quit: "Quit" }` - Hash shorthand (value => label, key defaults to first char)
15
+ # - `{ create: { label: "New project", key: "n" } }` - Hash shorthand with explicit key
13
16
  #
14
17
  # @example Basic usage
15
18
  # action = Clack.select_key(
@@ -23,65 +26,85 @@ module Clack
23
26
  #
24
27
  class SelectKey < Core::Prompt
25
28
  # @param message [String] the prompt message
26
- # @param options [Array<Hash>] options with :value, :label, and optionally :key, :hint
29
+ # @param options [Array<Hash>, Hash] options with :value, :label, and optionally :key, :hint,
30
+ # or a Hash of value => label (or value => {label:, key:, hint:})
31
+ # @param case_sensitive [Boolean] match keys exactly instead of case-insensitively (default: false)
32
+ # @param initial_value [Object, nil] value of the option highlighted at start; Enter submits it.
33
+ # The highlight is a color effect, so when colors are off mark the default in that option's +hint:+.
34
+ # A value that matches no option is ignored with a warning on stderr.
27
35
  # @param opts [Hash] additional options passed to {Core::Prompt}
28
- def initialize(message:, options:, **opts)
36
+ def initialize(message:, options:, case_sensitive: false, initial_value: nil, **opts)
29
37
  super(message:, **opts)
30
38
  @options = normalize_options(options)
39
+ @case_sensitive = case_sensitive
40
+ @option_index = find_initial_index(initial_value)
41
+ @value = current_option&.value
31
42
  end
32
43
 
33
44
  protected
34
45
 
46
+ # Option keys take precedence over every action except :cancel, so a
47
+ # custom alias such as `"y" => :enter` cannot hide a `y` option.
35
48
  def handle_key(key)
36
49
  return if terminal_state?
37
50
 
38
- action = Core::Settings.action?(key)
51
+ idx = @options.find_index { |opt| key_match?(opt.key, key) }
52
+ return super if idx.nil? || Core::Settings.action?(key) == :cancel
39
53
 
40
- case action
41
- when :cancel
42
- @state = :cancel
43
- else
44
- opt = @options.find { |o| o.key&.downcase == key&.downcase }
45
- return unless opt
46
-
47
- @value = opt.value
48
- @state = :submit
49
- end
54
+ @option_index = idx
55
+ @value = current_option.value
56
+ submit
50
57
  end
51
58
 
52
- def build_frame
53
- lines = []
54
- lines << "#{bar}\n"
55
- lines << "#{symbol_for_state} #{@message}\n"
56
- lines << help_line
59
+ def can_submit? = !@option_index.nil?
57
60
 
58
- @options.each do |opt|
59
- lines << "#{bar} #{option_display(opt)}\n"
60
- end
61
+ def build_frame
62
+ option_lines = @options.each_with_index.map do |opt, idx|
63
+ "#{gutter(active_bar)}#{option_display(opt, active_row?(idx))}\n"
64
+ end.join
61
65
 
62
- lines << "#{Colors.gray(Symbols::S_BAR_END)}\n"
63
- lines.join
66
+ "#{frame_header}#{option_lines}#{frame_footer}"
64
67
  end
65
68
 
66
- def final_display = @options.find { |o| o.value == @value }&.label.to_s
69
+ def final_display = current_option ? current_option.label.to_s : ""
67
70
 
68
71
  private
69
72
 
73
+ def current_option = @option_index && @options[@option_index]
74
+
70
75
  def normalize_options(options)
71
- options.map do |opt|
76
+ Core::OptionsHelper.option_entries(options).map do |opt|
72
77
  Clack::Core::SelectKeyOption.new(
73
78
  value: opt[:value],
74
79
  label: opt[:label] || opt[:value].to_s,
75
- key: opt[:key] || opt[:value].to_s[0],
80
+ key: (opt[:key] || opt[:value].to_s[0])&.to_s,
76
81
  hint: opt[:hint]
77
82
  )
78
83
  end
79
84
  end
80
85
 
81
- def option_display(opt)
82
- key_display = Colors.cyan("[#{opt.key}]")
86
+ def find_initial_index(initial_value)
87
+ return nil if initial_value.nil?
88
+
89
+ idx = @options.find_index { |opt| opt.value == initial_value }
90
+ warn "[clack] ignoring unknown initial_value: #{initial_value.inspect}" unless idx
91
+ idx
92
+ end
93
+
94
+ def key_match?(option_key, key)
95
+ return false if option_key.nil? || key.nil?
96
+
97
+ @case_sensitive ? option_key == key : option_key.downcase == key.downcase
98
+ end
99
+
100
+ # The highlighted row: only meaningful while the prompt is still open.
101
+ def active_row?(idx) = idx == @option_index && !terminal_state?
102
+
103
+ def option_display(opt, active)
104
+ badge = Colors.cyan("[#{opt.key}]")
105
+ badge = Colors.inverse(badge) if active
83
106
  hint = opt.hint ? " #{Colors.dim("(#{opt.hint})")}" : ""
84
- "#{key_display} #{opt.label}#{hint}"
107
+ "#{badge} #{opt.label}#{hint}"
85
108
  end
86
109
  end
87
110
  end