clack 0.4.5 → 0.5.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,6 +39,9 @@ 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
47
  valid_values = Set.new(@options.map { |o| o[:value] })
@@ -45,8 +49,8 @@ module Clack
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,31 +104,16 @@ module Clack
103
104
  lines.join
104
105
  end
105
106
 
106
- def build_final_frame
107
- lines = []
108
- lines << "#{bar}\n"
109
- lines << "#{symbol_for_state} #{@message}\n"
110
-
111
- labels = @options.select { |o| @selected.include?(o[:value]) }.map { |o| o[:label] }
112
- display_text = labels.join(", ")
113
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display_text)) : Colors.dim(display_text)
114
- lines << "#{bar} #{display}\n"
115
-
116
- lines.join
117
- end
107
+ def final_display = selected_labels(@options)
118
108
 
119
109
  private
120
110
 
121
111
  def toggle_current
122
- opt = @options[@cursor]
112
+ opt = @options[@option_index]
123
113
  return if opt[:disabled]
124
114
 
125
- if @selected.include?(opt[:value])
126
- @selected.delete(opt[:value])
127
- else
128
- @selected.add(opt[:value])
129
- end
130
- update_value
115
+ toggle_value(opt[:value])
116
+ update_selection_value
131
117
  end
132
118
 
133
119
  def toggle_all
@@ -137,24 +123,18 @@ module Clack
137
123
  else
138
124
  @selected.merge(enabled)
139
125
  end
140
- update_value
126
+ update_selection_value
141
127
  end
142
128
 
143
129
  def invert_selection
144
130
  @options.each do |opt|
145
131
  next if opt[:disabled]
146
132
 
147
- if @selected.include?(opt[:value])
148
- @selected.delete(opt[:value])
149
- else
150
- @selected.add(opt[:value])
151
- end
133
+ toggle_value(opt[:value])
152
134
  end
153
- update_value
135
+ update_selection_value
154
136
  end
155
137
 
156
- def update_value = @value = @selected.to_a
157
-
158
138
  def keyboard_hints
159
139
  hints = [
160
140
  "#{Colors.dim("space")} select",
@@ -165,7 +145,7 @@ module Clack
165
145
  end
166
146
 
167
147
  def option_display(opt, idx)
168
- active = idx == @cursor
148
+ active = idx == @option_index
169
149
  selected = @selected.include?(opt[:value])
170
150
 
171
151
  symbol, label = option_parts(opt, active, selected)
@@ -35,33 +35,10 @@ 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
- def build_final_frame
55
- lines = []
56
- lines << "#{bar}\n"
57
- lines << "#{symbol_for_state} #{@message}\n"
58
-
59
- masked = @mask * @value.grapheme_clusters.length
60
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(masked)) : Colors.dim(masked)
61
- lines << "#{bar} #{display}\n"
62
-
63
- lines.join
64
- end
41
+ def final_display = @mask * @value.grapheme_clusters.length
65
42
 
66
43
  private
67
44
 
@@ -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,37 +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
123
-
124
- lines.join
125
- end
126
-
127
- def build_final_frame
128
- lines = []
129
- lines << "#{bar}\n"
130
- lines << "#{symbol_for_state} #{@message}\n"
131
-
132
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(@value)) : Colors.dim(@value)
133
- lines << "#{bar} #{display}\n"
107
+ "#{bar} #{suggestion_display(path, actual_idx == @option_index)}\n"
108
+ end.join
134
109
 
135
- lines.join
110
+ "#{frame_header}#{active_bar} #{input_display}\n#{suggestion_lines}#{frame_footer}"
136
111
  end
137
112
 
138
113
  private
@@ -213,7 +188,7 @@ module Clack
213
188
  expanded == @root || expanded.start_with?("#{@root}/")
214
189
  end
215
190
 
216
- def scroll_items = @suggestions
191
+ def navigable_items = @suggestions
217
192
 
218
193
  # Override to use @root as placeholder
219
194
  def placeholder_display
@@ -51,33 +51,10 @@ 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
- def build_final_frame
71
- lines = []
72
- lines << "#{bar}\n"
73
- lines << "#{symbol_for_state} #{@message}\n"
74
-
75
- display = format_value(@value)
76
- styled = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display)) : Colors.dim(display)
77
- lines << "#{bar} #{styled}\n"
78
-
79
- lines.join
80
- end
57
+ def final_display = format_value(@value)
81
58
 
82
59
  private
83
60
 
@@ -40,57 +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 build_final_frame
84
- lines = []
85
- lines << "#{bar}\n"
86
- lines << "#{symbol_for_state} #{@message}\n"
87
-
88
- label = current_option[:label]
89
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(label)) : Colors.dim(label)
90
- lines << "#{bar} #{display}\n"
91
-
92
- lines.join
93
- end
69
+ def final_display = current_option[:label]
94
70
 
95
71
  private
96
72
 
@@ -101,7 +77,7 @@ module Clack
101
77
 
102
78
  def update_value = @value = current_option[:value]
103
79
 
104
- def current_option = @options[@cursor]
80
+ def current_option = @options[@option_index]
105
81
 
106
82
  def option_display(opt, active)
107
83
  return disabled_option_display(opt) if opt[:disabled]
@@ -63,17 +63,7 @@ module Clack
63
63
  lines.join
64
64
  end
65
65
 
66
- def build_final_frame
67
- lines = []
68
- lines << "#{bar}\n"
69
- lines << "#{symbol_for_state} #{@message}\n"
70
-
71
- label = @options.find { |o| o[:value] == @value }&.dig(:label).to_s
72
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(label)) : Colors.dim(label)
73
- lines << "#{bar} #{display}\n"
74
-
75
- lines.join
76
- end
66
+ def final_display = @options.find { |o| o[:value] == @value }&.dig(:label).to_s
77
67
 
78
68
  private
79
69
 
@@ -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"
@@ -88,31 +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
105
- end
106
-
107
- def build_final_frame
108
- lines = []
109
- lines << "#{bar}\n"
110
- lines << "#{symbol_for_state} #{@message}\n"
111
-
112
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(@value)) : Colors.dim(@value)
113
- lines << "#{bar} #{display}\n"
114
-
115
- lines.join
91
+ "#{frame_header}#{active_bar} #{input_display}\n#{frame_footer}"
116
92
  end
117
93
 
118
94
  private
data/lib/clack/symbols.rb CHANGED
@@ -26,8 +26,7 @@ module Clack
26
26
  # Explicit override
27
27
  return ENV["CLACK_UNICODE"] == "1" if ENV["CLACK_UNICODE"]
28
28
 
29
- # Default: TTY and not dumb terminal
30
- $stdout.tty? && ENV["TERM"] != "dumb" && !ENV["NO_COLOR"]
29
+ Environment.colors_supported?
31
30
  end
32
31
  end
33
32
 
@@ -36,7 +35,7 @@ module Clack
36
35
  # Unicode cancel step indicator, or ASCII fallback.
37
36
  S_STEP_CANCEL = unicode? ? "■" : "x"
38
37
  # Unicode error step indicator, or ASCII fallback.
39
- S_STEP_ERROR = unicode? ? "▲" : "x"
38
+ S_STEP_ERROR = unicode? ? "▲" : "!"
40
39
  # Unicode submit step indicator, or ASCII fallback.
41
40
  S_STEP_SUBMIT = unicode? ? "◇" : "o"
42
41
 
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
data/lib/clack/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Clack
4
4
  # Current gem version.
5
- VERSION = "0.4.5"
5
+ VERSION = "0.5.0"
6
6
  end