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,6 +29,7 @@ module Clack
29
29
  #
30
30
  class Multiselect < Core::Prompt
31
31
  include Core::OptionsHelper
32
+ include Core::SelectionManager
32
33
 
33
34
  # @param message [String] the prompt message
34
35
  # @param options [Array<Hash, String>] list of options
@@ -38,15 +39,18 @@ module Clack
38
39
  # @param cursor_at [Object, nil] value to position cursor at initially
39
40
  # @param opts [Hash] additional options passed to {Core::Prompt}
40
41
  def initialize(message:, options:, initial_values: [], required: true, max_items: nil, cursor_at: nil, **opts)
42
+ if opts.key?(:initial_value)
43
+ raise ArgumentError, "Multiselect uses initial_values: (plural), not initial_value:"
44
+ end
41
45
  super(message:, **opts)
42
46
  @options = normalize_options(options)
43
- valid_values = Set.new(@options.map { |o| o[:value] })
47
+ valid_values = Set.new(@options.map { |o| o.value })
44
48
  @selected = Set.new(initial_values) & valid_values
45
49
  @required = required
46
50
  @max_items = max_items
47
51
  @scroll_offset = 0
48
- @cursor = find_initial_cursor(cursor_at)
49
- update_value
52
+ @option_index = find_initial_cursor(cursor_at)
53
+ update_selection_value
50
54
  end
51
55
 
52
56
  protected
@@ -74,11 +78,8 @@ module Clack
74
78
  end
75
79
 
76
80
  def submit
77
- if @required && @selected.empty?
78
- @error_message = "Please select at least one option. Press #{Colors.cyan("space")} to select, #{Colors.cyan("enter")} to submit"
79
- @state = :error
80
- return
81
- end
81
+ return unless validate_selection
82
+
82
83
  super
83
84
  end
84
85
 
@@ -103,49 +104,37 @@ module Clack
103
104
  lines.join
104
105
  end
105
106
 
106
- def final_display
107
- @options.select { |o| @selected.include?(o[:value]) }.map { |o| o[:label] }.join(", ")
108
- end
107
+ def final_display = selected_labels(@options)
109
108
 
110
109
  private
111
110
 
112
111
  def toggle_current
113
- opt = @options[@cursor]
114
- return if opt[:disabled]
112
+ opt = @options[@option_index]
113
+ return if opt.disabled
115
114
 
116
- if @selected.include?(opt[:value])
117
- @selected.delete(opt[:value])
118
- else
119
- @selected.add(opt[:value])
120
- end
121
- update_value
115
+ toggle_value(opt.value)
116
+ update_selection_value
122
117
  end
123
118
 
124
119
  def toggle_all
125
- enabled = @options.reject { |o| o[:disabled] }.map { |o| o[:value] }
120
+ enabled = @options.reject { |o| o.disabled }.map { |o| o.value }
126
121
  if enabled.all? { |v| @selected.include?(v) }
127
122
  @selected.clear
128
123
  else
129
124
  @selected.merge(enabled)
130
125
  end
131
- update_value
126
+ update_selection_value
132
127
  end
133
128
 
134
129
  def invert_selection
135
130
  @options.each do |opt|
136
- next if opt[:disabled]
131
+ next if opt.disabled
137
132
 
138
- if @selected.include?(opt[:value])
139
- @selected.delete(opt[:value])
140
- else
141
- @selected.add(opt[:value])
142
- end
133
+ toggle_value(opt.value)
143
134
  end
144
- update_value
135
+ update_selection_value
145
136
  end
146
137
 
147
- def update_value = @value = @selected.to_a
148
-
149
138
  def keyboard_hints
150
139
  hints = [
151
140
  "#{Colors.dim("space")} select",
@@ -156,23 +145,23 @@ module Clack
156
145
  end
157
146
 
158
147
  def option_display(opt, idx)
159
- active = idx == @cursor
160
- selected = @selected.include?(opt[:value])
148
+ active = idx == @option_index
149
+ selected = @selected.include?(opt.value)
161
150
 
162
151
  symbol, label = option_parts(opt, active, selected)
163
152
  "#{symbol} #{label}"
164
153
  end
165
154
 
166
155
  def option_parts(opt, active, selected)
167
- if opt[:disabled]
156
+ if opt.disabled
168
157
  return [Colors.dim(Symbols::S_CHECKBOX_INACTIVE),
169
- Colors.strikethrough(Colors.dim(opt[:label]))]
158
+ Colors.strikethrough(Colors.dim(opt.label))]
170
159
  end
171
- return [Colors.green(Symbols::S_CHECKBOX_SELECTED), opt[:label]] if active && selected
172
- return [Colors.cyan(Symbols::S_CHECKBOX_ACTIVE), opt[:label]] if active
173
- return [Colors.green(Symbols::S_CHECKBOX_SELECTED), Colors.dim(opt[:label])] if selected
160
+ return [Colors.green(Symbols::S_CHECKBOX_SELECTED), opt.label] if active && selected
161
+ return [Colors.cyan(Symbols::S_CHECKBOX_ACTIVE), opt.label] if active
162
+ return [Colors.green(Symbols::S_CHECKBOX_SELECTED), Colors.dim(opt.label)] if selected
174
163
 
175
- [Colors.dim(Symbols::S_CHECKBOX_INACTIVE), Colors.dim(opt[:label])]
164
+ [Colors.dim(Symbols::S_CHECKBOX_INACTIVE), Colors.dim(opt.label)]
176
165
  end
177
166
  end
178
167
  end
@@ -35,20 +35,7 @@ module Clack
35
35
  end
36
36
 
37
37
  def build_frame
38
- lines = []
39
- lines << "#{bar}\n"
40
- lines << "#{symbol_for_state} #{@message}\n"
41
- lines << help_line
42
- lines << "#{active_bar} #{masked_display}\n"
43
- lines << "#{bar_end}\n"
44
-
45
- validation_lines = validation_message_lines
46
- if validation_lines.any?
47
- lines[-1] = validation_lines.first
48
- lines.concat(validation_lines[1..])
49
- end
50
-
51
- lines.join
38
+ "#{frame_header}#{active_bar} #{masked_display}\n#{frame_footer}"
52
39
  end
53
40
 
54
41
  def final_display = @mask * @value.grapheme_clusters.length
@@ -25,8 +25,8 @@ module Clack
25
25
  # )
26
26
  #
27
27
  class Path < Core::Prompt
28
+ include Core::OptionsHelper
28
29
  include Core::TextInputHelper
29
- include Core::ScrollHelper
30
30
 
31
31
  # @param message [String] the prompt message
32
32
  # @param root [String] starting/base directory (default: ".")
@@ -41,7 +41,7 @@ module Clack
41
41
  @max_items = max_items
42
42
  @value = ""
43
43
  @cursor = 0
44
- @selected_index = 0
44
+ @option_index = 0
45
45
  @scroll_offset = 0
46
46
  @suggestions = []
47
47
  @dir_cache = {} # directory path => sorted entries array
@@ -69,7 +69,7 @@ module Clack
69
69
  def handle_text_input(key)
70
70
  return unless super
71
71
 
72
- @selected_index = 0
72
+ @option_index = 0
73
73
  @scroll_offset = 0
74
74
  update_suggestions
75
75
  end
@@ -77,7 +77,7 @@ module Clack
77
77
  def autocomplete_selection
78
78
  return if @suggestions.empty?
79
79
 
80
- @value = @suggestions[@selected_index]
80
+ @value = @suggestions[@option_index]
81
81
  @cursor = @value.length
82
82
  update_suggestions
83
83
  end
@@ -102,26 +102,12 @@ module Clack
102
102
  end
103
103
 
104
104
  def build_frame
105
- lines = []
106
- lines << "#{bar}\n"
107
- lines << "#{symbol_for_state} #{@message}\n"
108
- lines << help_line
109
- lines << "#{active_bar} #{input_display}\n"
110
-
111
- visible_items.each_with_index do |path, idx|
105
+ suggestion_lines = visible_options.each_with_index.map do |path, idx|
112
106
  actual_idx = @scroll_offset + idx
113
- lines << "#{bar} #{suggestion_display(path, actual_idx == @selected_index)}\n"
114
- end
115
-
116
- lines << "#{bar_end}\n"
117
-
118
- validation_lines = validation_message_lines
119
- if validation_lines.any?
120
- lines[-1] = validation_lines.first
121
- lines.concat(validation_lines[1..])
122
- end
107
+ "#{bar} #{suggestion_display(path, actual_idx == @option_index)}\n"
108
+ end.join
123
109
 
124
- lines.join
110
+ "#{frame_header}#{active_bar} #{input_display}\n#{suggestion_lines}#{frame_footer}"
125
111
  end
126
112
 
127
113
  private
@@ -202,7 +188,7 @@ module Clack
202
188
  expanded == @root || expanded.start_with?("#{@root}/")
203
189
  end
204
190
 
205
- def scroll_items = @suggestions
191
+ def navigable_items = @suggestions
206
192
 
207
193
  # Override to use @root as placeholder
208
194
  def placeholder_display
@@ -36,6 +36,7 @@ module Clack
36
36
  @output = output
37
37
  @started = false
38
38
  @width = 40
39
+ @last_frame = nil
39
40
  end
40
41
 
41
42
  # Start displaying the progress bar.
@@ -105,8 +106,11 @@ module Clack
105
106
  def render
106
107
  return unless @started
107
108
 
108
- @output.print "\r\e[2K" # Return to start of line and clear it
109
- @output.print "#{symbol} #{progress_bar} #{percentage}#{message_text}"
109
+ frame = "#{symbol} #{progress_bar} #{percentage}#{message_text}"
110
+ return if frame == @last_frame
111
+
112
+ @last_frame = frame
113
+ @output.print "\r\e[2K#{frame}"
110
114
  @output.flush
111
115
  end
112
116
 
@@ -51,20 +51,7 @@ module Clack
51
51
  end
52
52
 
53
53
  def build_frame
54
- lines = []
55
- lines << "#{bar}\n"
56
- lines << "#{symbol_for_state} #{@message}\n"
57
- lines << help_line
58
- lines << "#{active_bar} #{slider_display}\n"
59
- lines << "#{bar_end}\n" if %i[active initial].include?(@state)
60
-
61
- validation_lines = validation_message_lines
62
- if validation_lines.any?
63
- lines[-1] = validation_lines.first
64
- lines.concat(validation_lines[1..])
65
- end
66
-
67
- lines.join
54
+ "#{frame_header}#{active_bar} #{slider_display}\n#{frame_footer}"
68
55
  end
69
56
 
70
57
  def final_display = format_value(@value)
@@ -40,47 +40,33 @@ module Clack
40
40
  def initialize(message:, options:, initial_value: nil, max_items: nil, **opts)
41
41
  super(message:, **opts)
42
42
  @options = normalize_options(options)
43
- @cursor = find_initial_cursor(initial_value)
44
43
  @max_items = max_items
45
44
  @scroll_offset = 0
45
+ @option_index = find_initial_cursor(initial_value)
46
46
  update_value
47
47
  end
48
48
 
49
49
  protected
50
50
 
51
- def handle_key(key)
52
- return if terminal_state?
53
-
54
- action = Core::Settings.action?(key)
55
-
51
+ def handle_input(_key, action)
56
52
  case action
57
- when :cancel
58
- @state = :cancel
59
- when :enter
60
- submit unless current_option[:disabled]
61
- when :up, :left
62
- move_cursor(-1)
63
- when :down, :right
64
- move_cursor(1)
53
+ when :up, :left then move_cursor(-1)
54
+ when :down, :right then move_cursor(1)
65
55
  end
66
56
  end
67
57
 
68
- def build_frame
69
- lines = []
70
- lines << "#{bar}\n"
71
- lines << "#{symbol_for_state} #{@message}\n"
72
- lines << help_line
58
+ def can_submit? = !current_option.disabled
73
59
 
74
- visible_options.each_with_index do |opt, idx|
60
+ def build_frame
61
+ option_lines = visible_options.each_with_index.map do |opt, idx|
75
62
  actual_idx = @scroll_offset + idx
76
- lines << "#{bar} #{option_display(opt, actual_idx == @cursor)}\n"
77
- end
63
+ "#{bar} #{option_display(opt, actual_idx == @option_index)}\n"
64
+ end.join
78
65
 
79
- lines << "#{Colors.gray(Symbols::S_BAR_END)}\n"
80
- lines.join
66
+ "#{frame_header}#{option_lines}#{frame_footer}"
81
67
  end
82
68
 
83
- def final_display = current_option[:label]
69
+ def final_display = current_option.label
84
70
 
85
71
  private
86
72
 
@@ -89,12 +75,12 @@ module Clack
89
75
  update_value
90
76
  end
91
77
 
92
- def update_value = @value = current_option[:value]
78
+ def update_value = @value = current_option.value
93
79
 
94
- def current_option = @options[@cursor]
80
+ def current_option = @options[@option_index]
95
81
 
96
82
  def option_display(opt, active)
97
- return disabled_option_display(opt) if opt[:disabled]
83
+ return disabled_option_display(opt) if opt.disabled
98
84
  return active_option_display(opt) if active
99
85
 
100
86
  inactive_option_display(opt)
@@ -102,19 +88,19 @@ module Clack
102
88
 
103
89
  def disabled_option_display(opt)
104
90
  symbol = Colors.dim(Symbols::S_RADIO_INACTIVE)
105
- label = Colors.strikethrough(Colors.dim(opt[:label]))
91
+ label = Colors.strikethrough(Colors.dim(opt.label))
106
92
  "#{symbol} #{label}"
107
93
  end
108
94
 
109
95
  def active_option_display(opt)
110
96
  symbol = Colors.green(Symbols::S_RADIO_ACTIVE)
111
- hint = opt[:hint] ? " #{Colors.dim("(#{opt[:hint]})")}" : ""
112
- "#{symbol} #{opt[:label]}#{hint}"
97
+ hint = opt.hint ? " #{Colors.dim("(#{opt.hint})")}" : ""
98
+ "#{symbol} #{opt.label}#{hint}"
113
99
  end
114
100
 
115
101
  def inactive_option_display(opt)
116
102
  symbol = Colors.dim(Symbols::S_RADIO_INACTIVE)
117
- "#{symbol} #{Colors.dim(opt[:label])}"
103
+ "#{symbol} #{Colors.dim(opt.label)}"
118
104
  end
119
105
  end
120
106
  end
@@ -41,10 +41,10 @@ module Clack
41
41
  when :cancel
42
42
  @state = :cancel
43
43
  else
44
- opt = @options.find { |o| o[:key]&.downcase == key&.downcase }
44
+ opt = @options.find { |o| o.key&.downcase == key&.downcase }
45
45
  return unless opt
46
46
 
47
- @value = opt[:value]
47
+ @value = opt.value
48
48
  @state = :submit
49
49
  end
50
50
  end
@@ -63,25 +63,25 @@ module Clack
63
63
  lines.join
64
64
  end
65
65
 
66
- def final_display = @options.find { |o| o[:value] == @value }&.dig(:label).to_s
66
+ def final_display = @options.find { |o| o.value == @value }&.label.to_s
67
67
 
68
68
  private
69
69
 
70
70
  def normalize_options(options)
71
71
  options.map do |opt|
72
- {
72
+ Clack::Core::SelectKeyOption.new(
73
73
  value: opt[:value],
74
74
  label: opt[:label] || opt[:value].to_s,
75
75
  key: opt[:key] || opt[:value].to_s[0],
76
76
  hint: opt[:hint]
77
- }
77
+ )
78
78
  end
79
79
  end
80
80
 
81
81
  def option_display(opt)
82
- key_display = Colors.cyan("[#{opt[:key]}]")
83
- hint = opt[:hint] ? " #{Colors.dim("(#{opt[:hint]})")}" : ""
84
- "#{key_display} #{opt[:label]}#{hint}"
82
+ key_display = Colors.cyan("[#{opt.key}]")
83
+ hint = opt.hint ? " #{Colors.dim("(#{opt.hint})")}" : ""
84
+ "#{key_display} #{opt.label}#{hint}"
85
85
  end
86
86
  end
87
87
  end
@@ -49,9 +49,7 @@ module Clack
49
49
  @frames = frames || Symbols::SPINNER_FRAMES
50
50
  @delay = delay || Symbols::SPINNER_DELAY
51
51
  @style_frame = style_frame || ->(frame) { Colors.magenta(frame) }
52
- @running = false
53
- @cancelled = false
54
- @finished = false
52
+ @state = :idle
55
53
  @message = ""
56
54
  @thread = nil
57
55
  @frame_idx = 0
@@ -66,12 +64,10 @@ module Clack
66
64
  # @return [self] for method chaining
67
65
  def start(message = nil)
68
66
  @mutex.synchronize do
69
- return if @running
67
+ return unless @state == :idle
70
68
 
71
69
  @message = remove_trailing_dots(message || "")
72
- @running = true
73
- @cancelled = false
74
- @finished = false
70
+ @state = :running
75
71
  @prev_frame = nil
76
72
  @frame_idx = 0
77
73
  @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -102,7 +98,7 @@ module Clack
102
98
  #
103
99
  # @param message [String, nil] cancellation message
104
100
  def cancel(message = nil)
105
- finish(:cancel, message)
101
+ finish(:cancelled, message)
106
102
  end
107
103
 
108
104
  # Update the spinner message while running.
@@ -116,7 +112,7 @@ module Clack
116
112
  # Clear the spinner without showing a final message.
117
113
  def clear
118
114
  @mutex.synchronize do
119
- @running = false
115
+ @state = :idle
120
116
  end
121
117
  @thread&.join
122
118
  restore_cursor
@@ -124,7 +120,7 @@ module Clack
124
120
  @output.print Core::Cursor.show
125
121
  end
126
122
 
127
- def cancelled? = @mutex.synchronize { @cancelled }
123
+ def cancelled? = @mutex.synchronize { @state == :cancelled }
128
124
 
129
125
  private
130
126
 
@@ -139,7 +135,8 @@ module Clack
139
135
 
140
136
  def spin_loop
141
137
  frame_count = 0
142
- while @mutex.synchronize { @running }
138
+ while @mutex.synchronize { @state == :running }
139
+ break if @output.closed?
143
140
  frame = @style_frame.call(@frames[@frame_idx])
144
141
  msg = @mutex.synchronize { @message }
145
142
  render_frame(frame, msg, frame_count)
@@ -171,28 +168,26 @@ module Clack
171
168
  end
172
169
  end
173
170
 
174
- def finish(state, message)
171
+ def finish(end_state, message)
175
172
  thread_to_join = nil
176
173
  msg, timer_suffix = @mutex.synchronize do
177
- return if @finished
174
+ return unless @state == :running
178
175
 
179
- @finished = true
180
- @running = false
176
+ @state = end_state
181
177
  thread_to_join = @thread
182
178
  suffix = (@indicator == :timer && @start_time) ? " #{format_timer}" : ""
183
179
  [message || @message, suffix]
184
180
  end
185
181
 
186
- thread_to_join&.join
182
+ thread_to_join&.join(5)
183
+ thread_to_join&.kill if thread_to_join&.alive?
187
184
 
188
185
  @output.print "\r#{Core::Cursor.clear_to_end}"
189
186
 
190
- symbol = case state
187
+ symbol = case end_state
191
188
  when :success then Colors.green(Symbols::S_STEP_SUBMIT)
192
189
  when :error then Colors.red(Symbols::S_STEP_ERROR)
193
- when :cancel
194
- @mutex.synchronize { @cancelled = true }
195
- Colors.red(Symbols::S_STEP_CANCEL)
190
+ when :cancelled then Colors.red(Symbols::S_STEP_CANCEL)
196
191
  end
197
192
 
198
193
  @output.print "#{symbol} #{msg}#{timer_suffix}\n"
@@ -87,8 +87,13 @@ module Clack
87
87
 
88
88
  run_task(task)
89
89
  end
90
- @output.print Core::Cursor.show
91
90
  @results
91
+ ensure
92
+ begin
93
+ @output.print Core::Cursor.show
94
+ rescue IOError, SystemCallError
95
+ # output stream already closed
96
+ end
92
97
  end
93
98
 
94
99
  private
@@ -88,20 +88,7 @@ module Clack
88
88
  end
89
89
 
90
90
  def build_frame
91
- lines = []
92
- lines << "#{bar}\n"
93
- lines << "#{symbol_for_state} #{@message}\n"
94
- lines << help_line
95
- lines << "#{active_bar} #{input_display}\n"
96
- lines << "#{bar_end}\n" if @state in :active | :initial
97
-
98
- validation_lines = validation_message_lines
99
- if validation_lines.any?
100
- lines[-1] = validation_lines.first
101
- lines.concat(validation_lines[1..])
102
- end
103
-
104
- lines.join
91
+ "#{frame_header}#{active_bar} #{input_display}\n#{frame_footer}"
105
92
  end
106
93
 
107
94
  private
data/lib/clack/testing.rb CHANGED
@@ -105,6 +105,25 @@ module Clack
105
105
  end
106
106
  end
107
107
 
108
+ # A StringIO-like object that feeds keys from a queue.
109
+ # Passed as the +input:+ parameter to prompts for testing.
110
+ class KeyQueue
111
+ def initialize(keys)
112
+ @keys = keys
113
+ @read_count = 0
114
+ end
115
+
116
+ def getc
117
+ @read_count += 1
118
+ raise "Too many reads (#{@read_count}) - possible infinite loop in test" if @read_count > 100
119
+
120
+ @keys.shift || KEYS[:enter]
121
+ end
122
+
123
+ # Not a real TTY
124
+ def tty? = false
125
+ end
126
+
108
127
  class << self
109
128
  # Simulate a prompt interaction by feeding a predefined key sequence.
110
129
  #
@@ -113,9 +132,13 @@ module Clack
113
132
  # @yield [PromptDriver] block to define the interaction
114
133
  # @return [Object] the prompt result
115
134
  def simulate(prompt_method, **kwargs, &block)
116
- with_stubbed_input(block) do |output|
117
- prompt_method.call(**kwargs, output: output)
118
- end
135
+ driver = PromptDriver.new
136
+ block.call(driver)
137
+
138
+ input = KeyQueue.new(driver.keys.dup)
139
+ output = StringIO.new
140
+
141
+ prompt_method.call(**kwargs, input: input, output: output)
119
142
  end
120
143
 
121
144
  # Capture the rendered output of a prompt simulation.
@@ -126,43 +149,14 @@ module Clack
126
149
  # @yield [PromptDriver] block to define the interaction
127
150
  # @return [Array(Object, String)] [result, output_string]
128
151
  def simulate_with_output(prompt_method, **kwargs, &block)
129
- output_io = nil
130
- result = with_stubbed_input(block) do |output|
131
- output_io = output
132
- prompt_method.call(**kwargs, output: output)
133
- end
134
- [result, output_io.string]
135
- end
136
-
137
- private
138
-
139
- def with_stubbed_input(driver_block)
140
152
  driver = PromptDriver.new
141
- driver_block.call(driver)
153
+ block.call(driver)
142
154
 
143
- queue = driver.keys.dup
155
+ input = KeyQueue.new(driver.keys.dup)
144
156
  output = StringIO.new
145
- read_count = 0
146
-
147
- verbose, $VERBOSE = $VERBOSE, nil
148
- Core::KeyReader.singleton_class.alias_method(:_original_read, :read)
149
-
150
- Core::KeyReader.define_singleton_method(:read) do
151
- read_count += 1
152
- raise "Too many reads (#{read_count}) - possible infinite loop in test" if read_count > 100
153
-
154
- queue.shift || KEYS[:enter]
155
- end
156
- $VERBOSE = verbose
157
-
158
- yield output
159
- ensure
160
- if Core::KeyReader.singleton_class.method_defined?(:_original_read)
161
- verbose, $VERBOSE = $VERBOSE, nil
162
- Core::KeyReader.singleton_class.alias_method(:read, :_original_read)
163
- Core::KeyReader.singleton_class.remove_method(:_original_read)
164
- $VERBOSE = verbose
165
- end
157
+
158
+ result = prompt_method.call(**kwargs, input: input, output: output)
159
+ [result, output.string]
166
160
  end
167
161
  end
168
162
  end