clack 0.4.6 → 0.6.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.
@@ -29,7 +29,7 @@ module Clack
29
29
  class AutocompleteMultiselect < Core::Prompt
30
30
  include Core::OptionsHelper
31
31
  include Core::TextInputHelper
32
- include Core::ScrollHelper
32
+ include Core::SelectionManager
33
33
 
34
34
  # @param message [String] the prompt message
35
35
  # @param options [Array<Hash, String>] list of options to filter
@@ -42,6 +42,9 @@ module Clack
42
42
  # across label, value, and hint is used.
43
43
  # @param opts [Hash] additional options passed to {Core::Prompt}
44
44
  def initialize(message:, options:, max_items: 5, placeholder: nil, required: true, initial_values: [], filter: nil, **opts)
45
+ if opts.key?(:initial_value)
46
+ raise ArgumentError, "AutocompleteMultiselect uses initial_values: (plural), not initial_value:"
47
+ end
45
48
  super(message:, **opts)
46
49
  @all_options = normalize_options(options)
47
50
  @max_items = max_items
@@ -50,9 +53,9 @@ module Clack
50
53
  @filter = filter
51
54
  @search_text = ""
52
55
  @cursor = 0
53
- @selected_index = 0
56
+ @option_index = 0
54
57
  @scroll_offset = 0
55
- valid_values = Set.new(@all_options.map { |o| o[:value] })
58
+ valid_values = Set.new(@all_options.map { |o| o.value })
56
59
  @selected = Set.new(initial_values) & valid_values
57
60
  update_filtered
58
61
  end
@@ -84,24 +87,16 @@ module Clack
84
87
  def toggle_current
85
88
  return if @filtered.empty?
86
89
 
87
- opt = @filtered[@selected_index]
88
- return if opt[:disabled]
90
+ opt = @filtered[@option_index]
91
+ return if opt.disabled
89
92
 
90
- if @selected.include?(opt[:value])
91
- @selected.delete(opt[:value])
92
- else
93
- @selected.add(opt[:value])
94
- end
93
+ toggle_value(opt.value)
95
94
  end
96
95
 
97
96
  def submit
98
- if @required && @selected.empty?
99
- @error_message = "Please select at least one option. Press #{Colors.cyan("space")} to select, #{Colors.cyan("enter")} to submit"
100
- @state = :error
101
- return
102
- end
97
+ return unless validate_selection
103
98
 
104
- @value = @selected.to_a
99
+ update_selection_value
105
100
  super
106
101
  end
107
102
 
@@ -112,9 +107,9 @@ module Clack
112
107
  lines << help_line
113
108
  lines << "#{active_bar} #{Colors.dim("Search:")} #{input_display}#{match_count}\n"
114
109
 
115
- visible_items.each_with_index do |opt, idx|
110
+ visible_options.each_with_index do |opt, idx|
116
111
  actual_idx = @scroll_offset + idx
117
- lines << "#{active_bar} #{option_display(opt, actual_idx == @selected_index)}\n"
112
+ lines << "#{active_bar} #{option_display(opt, actual_idx == @option_index)}\n"
118
113
  end
119
114
 
120
115
  lines << "#{active_bar} #{Colors.yellow("No matches found")}\n" if @filtered.empty? && !@search_text.empty?
@@ -130,9 +125,7 @@ module Clack
130
125
  lines.join
131
126
  end
132
127
 
133
- def final_display
134
- @all_options.select { |o| @selected.include?(o[:value]) }.map { |o| o[:label] }.join(", ")
135
- end
128
+ def final_display = selected_labels(@all_options)
136
129
 
137
130
  private
138
131
 
@@ -146,7 +139,7 @@ module Clack
146
139
  def handle_text_input(key)
147
140
  return unless super
148
141
 
149
- @selected_index = 0
142
+ @option_index = 0
150
143
  @scroll_offset = 0
151
144
  update_filtered
152
145
  end
@@ -173,18 +166,18 @@ module Clack
173
166
  end
174
167
  end
175
168
 
176
- def scroll_items = @filtered
169
+ def navigable_items = @filtered
177
170
 
178
171
  def option_display(opt, active)
179
- selected = @selected.include?(opt[:value])
172
+ selected = @selected.include?(opt.value)
180
173
  checkbox = if selected
181
174
  Colors.green(Symbols::S_CHECKBOX_SELECTED)
182
175
  else
183
176
  Colors.dim(Symbols::S_CHECKBOX_INACTIVE)
184
177
  end
185
178
 
186
- label = active ? opt[:label] : Colors.dim(opt[:label])
187
- hint = (opt[:hint] && active) ? Colors.dim(" (#{opt[:hint]})") : ""
179
+ label = active ? opt.label : Colors.dim(opt.label)
180
+ hint = (opt.hint && active) ? Colors.dim(" (#{opt.hint})") : ""
188
181
 
189
182
  "#{checkbox} #{label}#{hint}"
190
183
  end
@@ -33,42 +33,20 @@ module Clack
33
33
 
34
34
  protected
35
35
 
36
- def handle_key(key)
37
- return if terminal_state?
38
-
39
- action = Core::Settings.action?(key)
40
-
36
+ def handle_input(key, action)
41
37
  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
38
+ when :left, :up then @value = true
39
+ when :right, :down then @value = false
50
40
  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
41
+ case key&.downcase
42
+ when "y" then @value = true
43
+ when "n" then @value = false
44
+ end
61
45
  end
62
46
  end
63
47
 
64
48
  def build_frame
65
- lines = []
66
- lines << "#{bar}\n"
67
- lines << "#{symbol_for_state} #{@message}\n"
68
- lines << help_line
69
- lines << "#{bar} #{options_display}\n"
70
- lines << "#{Colors.gray(Symbols::S_BAR_END)}\n"
71
- lines.join
49
+ "#{frame_header}#{bar} #{options_display}\n#{frame_footer}"
72
50
  end
73
51
 
74
52
  def final_display = @value ? @active_label : @inactive_label
@@ -83,20 +83,7 @@ module Clack
83
83
  end
84
84
 
85
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
86
+ "#{frame_header}#{active_bar} #{date_display}\n#{frame_footer}"
100
87
  end
101
88
 
102
89
  def final_display = formatted_date
@@ -34,6 +34,8 @@ module Clack
34
34
  # )
35
35
  #
36
36
  class GroupMultiselect < Core::Prompt
37
+ include Core::SelectionManager
38
+
37
39
  # @param message [String] the prompt message
38
40
  # @param options [Array<Hash>] groups, each with :label and :options (Array<Hash, String>)
39
41
  # @param initial_values [Array] values to pre-select
@@ -52,45 +54,34 @@ module Clack
52
54
  cursor_at: nil,
53
55
  **opts
54
56
  )
57
+ if opts.key?(:initial_value)
58
+ raise ArgumentError, "GroupMultiselect uses initial_values: (plural), not initial_value:"
59
+ end
55
60
  super(message:, **opts)
56
61
  @groups = normalize_groups(options)
57
62
  @flat_items = build_flat_items
58
- valid_values = Set.new(@flat_items.select { |item| item[:type] == :option }.map { |item| item[:value] })
63
+ valid_values = Set.new(@flat_items.select { |item| item.is_a?(Core::GroupOption) }.map(&:value))
59
64
  @selected = Set.new(initial_values) & valid_values
60
65
  @required = required
61
66
  @selectable_groups = selectable_groups
62
67
  @group_spacing = group_spacing
63
- @cursor = find_initial_cursor(cursor_at)
68
+ @option_index = find_initial_cursor(cursor_at)
64
69
  update_value
65
70
  end
66
71
 
67
72
  protected
68
73
 
69
- def handle_key(key)
70
- return if terminal_state?
71
-
72
- action = Core::Settings.action?(key)
73
-
74
+ def handle_input(_key, action)
74
75
  case action
75
- when :cancel
76
- @state = :cancel
77
- when :enter
78
- submit
79
- when :up
80
- move_cursor(-1)
81
- when :down
82
- move_cursor(1)
83
- when :space
84
- toggle_current
76
+ when :up then move_cursor(-1)
77
+ when :down then move_cursor(1)
78
+ when :space then toggle_current
85
79
  end
86
80
  end
87
81
 
88
82
  def submit
89
- if @required && @selected.empty?
90
- @error_message = "Please select at least one option. Press #{Colors.cyan("space")} to select, #{Colors.cyan("enter")} to submit"
91
- @state = :error
92
- return
93
- end
83
+ return unless validate_selection
84
+
94
85
  super
95
86
  end
96
87
 
@@ -102,8 +93,8 @@ module Clack
102
93
 
103
94
  prev_was_group = false
104
95
  @flat_items.each_with_index do |item, idx|
105
- is_group = item[:type] == :group
106
- is_last_in_group = item[:last_in_group]
96
+ is_group = item.is_a?(Core::GroupHeader)
97
+ is_last_in_group = item.is_a?(Core::GroupOption) ? item.last_in_group : false
107
98
 
108
99
  # Add group spacing before groups (except first)
109
100
  if is_group && !prev_was_group && idx.positive? && @group_spacing.positive?
@@ -111,9 +102,9 @@ module Clack
111
102
  end
112
103
 
113
104
  lines << if is_group
114
- group_display(item, idx == @cursor)
105
+ group_display(item, idx == @option_index)
115
106
  else
116
- option_display(item, idx == @cursor, is_last_in_group)
107
+ option_display(item, idx == @option_index, is_last_in_group)
117
108
  end
118
109
 
119
110
  prev_was_group = is_group
@@ -126,7 +117,7 @@ module Clack
126
117
  lines.join
127
118
  end
128
119
 
129
- def final_display = selected_options.map { |o| o[:label] }.join(", ")
120
+ def final_display = selected_options.map(&:label).join(", ")
130
121
 
131
122
  private
132
123
 
@@ -140,41 +131,35 @@ module Clack
140
131
  end
141
132
 
142
133
  def normalize_option(opt)
143
- case opt
144
- when Hash
145
- {value: opt[:value], label: opt[:label] || opt[:value].to_s, hint: opt[:hint], disabled: opt[:disabled] || false}
146
- else
147
- {value: opt, label: opt.to_s, hint: nil, disabled: false}
148
- end
134
+ Core::OptionsHelper.normalize_option(opt)
149
135
  end
150
136
 
151
137
  def build_flat_items = @groups.flat_map { |group| flatten_group(group) }
152
138
 
153
139
  def flatten_group(group)
154
- group_item = {type: :group, label: group[:label], options: group[:options]}
140
+ group_item = Core::GroupHeader.new(label: group[:label], options: group[:options])
155
141
  option_items = group[:options].each_with_index.map do |opt, idx|
156
- {
157
- type: :option,
158
- value: opt[:value],
159
- label: opt[:label],
160
- hint: opt[:hint],
161
- disabled: opt[:disabled],
142
+ Core::GroupOption.new(
143
+ value: opt.value,
144
+ label: opt.label,
145
+ hint: opt.hint,
146
+ disabled: opt.disabled,
162
147
  group: group,
163
148
  last_in_group: idx == group[:options].length - 1
164
- }
149
+ )
165
150
  end
166
151
  [group_item, *option_items]
167
152
  end
168
153
 
169
154
  def selected_options
170
- @flat_items.select { |item| item[:type] == :option && @selected.include?(item[:value]) }
155
+ @flat_items.select { |item| item.is_a?(Core::GroupOption) && @selected.include?(item.value) }
171
156
  end
172
157
 
173
158
  def find_initial_cursor(cursor_at)
174
159
  return 0 if @flat_items.empty?
175
160
 
176
161
  if cursor_at
177
- idx = @flat_items.find_index { |item| item[:value] == cursor_at }
162
+ idx = @flat_items.find_index { |item| item.respond_to?(:value) && item.value == cursor_at }
178
163
  return idx if idx
179
164
  end
180
165
 
@@ -182,14 +167,14 @@ module Clack
182
167
  end
183
168
 
184
169
  def can_select?(item)
185
- return false if item[:disabled]
186
- return @selectable_groups if item[:type] == :group
170
+ return false if item.is_a?(Core::GroupOption) && item.disabled
171
+ return @selectable_groups if item.is_a?(Core::GroupHeader)
187
172
 
188
173
  true
189
174
  end
190
175
 
191
176
  def move_cursor(delta)
192
- new_cursor = @cursor
177
+ new_cursor = @option_index
193
178
  attempts = @flat_items.length
194
179
 
195
180
  loop do
@@ -198,14 +183,14 @@ module Clack
198
183
  break if can_select?(@flat_items[new_cursor]) || attempts <= 0
199
184
  end
200
185
 
201
- @cursor = new_cursor
186
+ @option_index = new_cursor
202
187
  end
203
188
 
204
189
  def toggle_current
205
- item = @flat_items[@cursor]
190
+ item = @flat_items[@option_index]
206
191
  return unless can_select?(item)
207
192
 
208
- if item[:type] == :group
193
+ if item.is_a?(Core::GroupHeader)
209
194
  toggle_group(item)
210
195
  else
211
196
  toggle_option(item)
@@ -214,7 +199,7 @@ module Clack
214
199
  end
215
200
 
216
201
  def toggle_group(group_item)
217
- group_values = group_item[:options].reject { |o| o[:disabled] }.map { |o| o[:value] }
202
+ group_values = group_item.options.reject { |o| o.disabled }.map(&:value)
218
203
  all_selected = group_values.all? { |v| @selected.include?(v) }
219
204
 
220
205
  if all_selected
@@ -225,45 +210,41 @@ module Clack
225
210
  end
226
211
 
227
212
  def toggle_option(item)
228
- if @selected.include?(item[:value])
229
- @selected.delete(item[:value])
230
- else
231
- @selected.add(item[:value])
232
- end
213
+ toggle_value(item.value)
233
214
  end
234
215
 
235
- def update_value = @value = @selected.to_a
216
+ def update_value = update_selection_value
236
217
 
237
218
  def group_display(item, active)
238
219
  if @selectable_groups
239
- all_selected = item[:options].reject { |o| o[:disabled] }.all? { |o| @selected.include?(o[:value]) }
220
+ all_selected = item.options.reject { |o| o.disabled }.all? { |o| @selected.include?(o.value) }
240
221
  checkbox = all_selected ? Colors.green(Symbols::S_CHECKBOX_SELECTED) : Colors.dim(Symbols::S_CHECKBOX_INACTIVE)
241
- label = active ? item[:label] : Colors.dim(item[:label])
222
+ label = active ? item.label : Colors.dim(item.label)
242
223
  "#{active_bar} #{checkbox} #{label}\n"
243
224
  else
244
- "#{active_bar} #{Colors.dim(item[:label])}\n"
225
+ "#{active_bar} #{Colors.dim(item.label)}\n"
245
226
  end
246
227
  end
247
228
 
248
229
  def option_display(item, active, is_last)
249
- selected = @selected.include?(item[:value])
230
+ selected = @selected.include?(item.value)
250
231
  prefix = if @selectable_groups
251
232
  "#{is_last ? Symbols::S_BAR_END : Symbols::S_BAR} "
252
233
  else
253
234
  " "
254
235
  end
255
- hint = (item[:hint] && active) ? " #{Colors.dim("(#{item[:hint]})")}" : ""
236
+ hint = (item.hint && active) ? " #{Colors.dim("(#{item.hint})")}" : ""
256
237
 
257
- if item[:disabled]
258
- "#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.strikethrough(Colors.dim(item[:label]))}\n"
238
+ if item.disabled
239
+ "#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.strikethrough(Colors.dim(item.label))}\n"
259
240
  elsif active && selected
260
- "#{active_bar} #{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{item[:label]}#{hint}\n"
241
+ "#{active_bar} #{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{item.label}#{hint}\n"
261
242
  elsif active
262
- "#{active_bar} #{Colors.dim(prefix)}#{Colors.cyan(Symbols::S_CHECKBOX_ACTIVE)} #{item[:label]}#{hint}\n"
243
+ "#{active_bar} #{Colors.dim(prefix)}#{Colors.cyan(Symbols::S_CHECKBOX_ACTIVE)} #{item.label}#{hint}\n"
263
244
  elsif selected
264
- "#{active_bar} #{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{Colors.dim(item[:label])}\n"
245
+ "#{active_bar} #{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{Colors.dim(item.label)}\n"
265
246
  else
266
- "#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.dim(item[:label])}\n"
247
+ "#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.dim(item.label)}\n"
267
248
  end
268
249
  end
269
250
  end
@@ -21,6 +21,8 @@ module Clack
21
21
  # )
22
22
  #
23
23
  class MultilineText < Core::Prompt
24
+ include Core::TextInputHelper
25
+
24
26
  # @param message [String] the prompt message
25
27
  # @param initial_value [String, nil] pre-filled editable text (can contain newlines)
26
28
  # @option opts [Proc, nil] :validate validation proc returning error string or nil
@@ -29,7 +31,7 @@ module Clack
29
31
  super(message:, **opts)
30
32
  @lines = parse_initial_value(initial_value)
31
33
  @line_index = @lines.length - 1
32
- @column = current_line.grapheme_clusters.length
34
+ @cursor = current_line.grapheme_clusters.length
33
35
  end
34
36
 
35
37
  protected
@@ -64,24 +66,17 @@ module Clack
64
66
  super
65
67
  end
66
68
 
67
- def build_frame
68
- lines = []
69
- lines << "#{bar}\n"
70
- lines << "#{symbol_for_state} #{@message} #{Colors.dim("(Ctrl+D to submit)")}\n"
71
- lines << help_line
72
-
73
- @lines.each_with_index do |line, idx|
74
- display = (idx == @line_index) ? line_with_cursor(line) : line
75
- lines << "#{active_bar} #{display}\n"
76
- end
69
+ def frame_header
70
+ "#{bar}\n#{symbol_for_state} #{@message} #{Colors.dim("(Ctrl+D to submit)")}\n#{help_line}"
71
+ end
77
72
 
78
- if @state in :error | :warning
79
- lines.concat(validation_message_lines)
80
- else
81
- lines << "#{bar_end}\n"
82
- end
73
+ def build_frame
74
+ body = @lines.each_with_index.map do |line, idx|
75
+ display = (idx == @line_index) ? value_with_cursor : line
76
+ "#{active_bar} #{display}\n"
77
+ end.join
83
78
 
84
- lines.join
79
+ "#{frame_header}#{body}#{frame_footer}"
85
80
  end
86
81
 
87
82
  def build_final_frame
@@ -98,6 +93,13 @@ module Clack
98
93
  lines.join
99
94
  end
100
95
 
96
+ # TextInputHelper backing store: delegate to current line.
97
+ def text_value = current_line
98
+
99
+ def text_value=(val)
100
+ @lines[@line_index] = val
101
+ end
102
+
101
103
  private
102
104
 
103
105
  def parse_initial_value(value)
@@ -108,26 +110,15 @@ module Clack
108
110
 
109
111
  def current_line = @lines[@line_index] || ""
110
112
 
111
- def line_with_cursor(line)
112
- chars = line.grapheme_clusters
113
- return cursor_block if chars.empty?
114
- return "#{line}#{cursor_block}" if @column >= chars.length
115
-
116
- before = chars[0...@column].join
117
- current = Colors.inverse(chars[@column])
118
- after = chars[(@column + 1)..].join
119
- "#{before}#{current}#{after}"
120
- end
121
-
122
113
  def insert_newline
123
114
  chars = current_line.grapheme_clusters
124
- before = chars[0...@column].join
125
- after = chars[@column..].join
115
+ before = chars[0...@cursor].join
116
+ after = chars[@cursor..].join
126
117
 
127
118
  @lines[@line_index] = before
128
119
  @lines.insert(@line_index + 1, after)
129
120
  @line_index += 1
130
- @column = 0
121
+ @cursor = 0
131
122
  end
132
123
 
133
124
  def move_up
@@ -145,43 +136,32 @@ module Clack
145
136
  end
146
137
 
147
138
  def move_left
148
- return if @column.zero?
139
+ return if @cursor.zero?
149
140
 
150
- @column -= 1
141
+ @cursor -= 1
151
142
  end
152
143
 
153
144
  def move_right
154
145
  max = current_line.grapheme_clusters.length
155
- return if @column >= max
146
+ return if @cursor >= max
156
147
 
157
- @column += 1
148
+ @cursor += 1
158
149
  end
159
150
 
160
151
  def clamp_column
161
152
  max = current_line.grapheme_clusters.length
162
- @column = [@column, max].min
163
- end
164
-
165
- def handle_text_input(key)
166
- return handle_backspace if Core::Settings.backspace?(key)
167
- return unless Core::Settings.printable?(key)
168
-
169
- chars = current_line.grapheme_clusters
170
- chars.insert(@column, key)
171
- @lines[@line_index] = chars.join
172
- @column += 1
153
+ @cursor = [@cursor, max].min
173
154
  end
174
155
 
156
+ # Override backspace to handle line merging at column 0
175
157
  def handle_backspace
176
- if @column.zero?
177
- return if @line_index.zero?
158
+ if @cursor.zero?
159
+ return false if @line_index.zero?
178
160
 
179
161
  merge_line_up
162
+ true
180
163
  else
181
- chars = current_line.grapheme_clusters
182
- chars.delete_at(@column - 1)
183
- @lines[@line_index] = chars.join
184
- @column -= 1
164
+ super
185
165
  end
186
166
  end
187
167
 
@@ -192,7 +172,7 @@ module Clack
192
172
  @line_index -= 1
193
173
  prev_length = current_line.grapheme_clusters.length
194
174
  @lines[@line_index] = current_line + current_content
195
- @column = prev_length
175
+ @cursor = prev_length
196
176
  end
197
177
  end
198
178
  end