clack 0.1.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.
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Prompts
5
+ # Multiple-selection prompt from a list of options.
6
+ #
7
+ # Navigate with arrow keys or j/k. Toggle selection with Space.
8
+ # Supports shortcuts: 'a' to toggle all, 'i' to invert selection.
9
+ #
10
+ # Options format is the same as {Select}.
11
+ #
12
+ # @example Basic usage
13
+ # features = Clack.multiselect(
14
+ # message: "Select features",
15
+ # options: %w[api auth admin]
16
+ # )
17
+ #
18
+ # @example With options and validation
19
+ # features = Clack.multiselect(
20
+ # message: "Select features",
21
+ # options: [
22
+ # { value: "api", label: "API Mode" },
23
+ # { value: "auth", label: "Authentication" }
24
+ # ],
25
+ # initial_values: ["api"],
26
+ # required: true,
27
+ # max_items: 5
28
+ # )
29
+ #
30
+ class Multiselect < Core::Prompt
31
+ include Core::OptionsHelper
32
+
33
+ # @param message [String] the prompt message
34
+ # @param options [Array<Hash, String>] list of options
35
+ # @param initial_values [Array] values to pre-select
36
+ # @param required [Boolean] require at least one selection (default: true)
37
+ # @param max_items [Integer, nil] max visible items (enables scrolling)
38
+ # @param cursor_at [Object, nil] value to position cursor at initially
39
+ # @param opts [Hash] additional options passed to {Core::Prompt}
40
+ def initialize(message:, options:, initial_values: [], required: true, max_items: nil, cursor_at: nil, **opts)
41
+ super(message:, **opts)
42
+ @options = normalize_options(options)
43
+ @selected = Set.new(initial_values)
44
+ @required = required
45
+ @max_items = max_items
46
+ @scroll_offset = 0
47
+ @cursor = find_initial_cursor(cursor_at)
48
+ update_value
49
+ end
50
+
51
+ protected
52
+
53
+ def handle_key(key)
54
+ return if terminal_state?
55
+
56
+ @state = :active if @state == :error
57
+ action = Core::Settings.action?(key)
58
+
59
+ case action
60
+ when :cancel
61
+ @state = :cancel
62
+ when :enter
63
+ submit
64
+ when :up
65
+ move_cursor(-1)
66
+ when :down
67
+ move_cursor(1)
68
+ when :space
69
+ toggle_current
70
+ else
71
+ handle_char(key)
72
+ end
73
+ end
74
+
75
+ def handle_char(key)
76
+ case key&.downcase
77
+ when "a"
78
+ toggle_all
79
+ when "i"
80
+ invert_selection
81
+ end
82
+ end
83
+
84
+ def submit
85
+ if @required && @selected.empty?
86
+ @error_message = "Please select at least one option.\nPress #{Colors.cyan("space")} to select, #{Colors.cyan("enter")} to submit"
87
+ @state = :error
88
+ return
89
+ end
90
+ super
91
+ end
92
+
93
+ def build_frame
94
+ lines = []
95
+ lines << "#{bar}\n"
96
+ lines << "#{symbol_for_state} #{@message}\n"
97
+
98
+ visible_options.each_with_index do |opt, idx|
99
+ actual_idx = @scroll_offset + idx
100
+ lines << "#{active_bar} #{option_display(opt, actual_idx)}\n"
101
+ end
102
+
103
+ lines << "#{bar_end}\n"
104
+
105
+ lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n" if @state == :error
106
+
107
+ lines.join
108
+ end
109
+
110
+ def build_final_frame
111
+ lines = []
112
+ lines << "#{bar}\n"
113
+ lines << "#{symbol_for_state} #{@message}\n"
114
+
115
+ labels = @options.select { |o| @selected.include?(o[:value]) }.map { |o| o[:label] }
116
+ display_text = labels.join(", ")
117
+ display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display_text)) : Colors.dim(display_text)
118
+ lines << "#{bar} #{display}\n"
119
+
120
+ lines.join
121
+ end
122
+
123
+ private
124
+
125
+ def toggle_current
126
+ opt = @options[@cursor]
127
+ return if opt[:disabled]
128
+
129
+ if @selected.include?(opt[:value])
130
+ @selected.delete(opt[:value])
131
+ else
132
+ @selected.add(opt[:value])
133
+ end
134
+ update_value
135
+ end
136
+
137
+ def toggle_all
138
+ enabled = @options.reject { |o| o[:disabled] }.map { |o| o[:value] }
139
+ if enabled.all? { |v| @selected.include?(v) }
140
+ @selected.clear
141
+ else
142
+ @selected.merge(enabled)
143
+ end
144
+ update_value
145
+ end
146
+
147
+ def invert_selection
148
+ @options.each do |opt|
149
+ next if opt[:disabled]
150
+
151
+ if @selected.include?(opt[:value])
152
+ @selected.delete(opt[:value])
153
+ else
154
+ @selected.add(opt[:value])
155
+ end
156
+ end
157
+ update_value
158
+ end
159
+
160
+ def update_value
161
+ @value = @selected.to_a
162
+ end
163
+
164
+ def option_display(opt, idx)
165
+ active = idx == @cursor
166
+ selected = @selected.include?(opt[:value])
167
+
168
+ symbol, label = option_parts(opt, active, selected)
169
+ "#{symbol} #{label}"
170
+ end
171
+
172
+ def option_parts(opt, active, selected)
173
+ if opt[:disabled]
174
+ return [Colors.dim(Symbols::S_CHECKBOX_INACTIVE),
175
+ Colors.strikethrough(Colors.dim(opt[:label]))]
176
+ end
177
+ return [Colors.green(Symbols::S_CHECKBOX_SELECTED), opt[:label]] if active && selected
178
+ return [Colors.cyan(Symbols::S_CHECKBOX_ACTIVE), opt[:label]] if active
179
+ return [Colors.green(Symbols::S_CHECKBOX_SELECTED), Colors.dim(opt[:label])] if selected
180
+
181
+ [Colors.dim(Symbols::S_CHECKBOX_INACTIVE), Colors.dim(opt[:label])]
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Prompts
5
+ # Password input prompt with masked display.
6
+ #
7
+ # Displays a mask character for each input character, hiding the actual
8
+ # password. Supports backspace but not cursor movement (for security).
9
+ #
10
+ # @example Basic usage
11
+ # secret = Clack.password(message: "Enter your API key")
12
+ #
13
+ # @example With custom mask
14
+ # secret = Clack.password(message: "Password", mask: "*")
15
+ #
16
+ class Password < Core::Prompt
17
+ # @param message [String] the prompt message
18
+ # @param mask [String, nil] character to display (default: "▪")
19
+ # @param validate [Proc, nil] validation proc returning error string or nil
20
+ # @param opts [Hash] additional options passed to {Core::Prompt}
21
+ def initialize(message:, mask: nil, **opts)
22
+ super(message:, **opts)
23
+ @mask = mask || Symbols::S_PASSWORD_MASK
24
+ @value = ""
25
+ end
26
+
27
+ protected
28
+
29
+ def handle_input(key, _action)
30
+ return unless Core::Settings.printable?(key)
31
+
32
+ if Core::Settings.backspace?(key)
33
+ @value = @value.chop
34
+ else
35
+ @value += key
36
+ end
37
+ end
38
+
39
+ def build_frame
40
+ lines = []
41
+ lines << "#{bar}\n"
42
+ lines << "#{symbol_for_state} #{@message}\n"
43
+ lines << "#{active_bar} #{masked_display}\n"
44
+ lines << "#{bar_end}\n"
45
+
46
+ lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n" if @state == :error
47
+
48
+ lines.join
49
+ end
50
+
51
+ def build_final_frame
52
+ lines = []
53
+ lines << "#{bar}\n"
54
+ lines << "#{symbol_for_state} #{@message}\n"
55
+
56
+ masked = @mask * @value.length
57
+ display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(masked)) : Colors.dim(masked)
58
+ lines << "#{bar} #{display}\n"
59
+
60
+ lines.join
61
+ end
62
+
63
+ private
64
+
65
+ def masked_display
66
+ masked = @mask * @value.length
67
+ return cursor_block if masked.empty?
68
+
69
+ "#{masked}#{cursor_block}"
70
+ end
71
+
72
+ def cursor_block
73
+ Colors.inverse(Colors.hidden("_"))
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Prompts
5
+ # File/directory path selector with filesystem navigation.
6
+ #
7
+ # Type to filter suggestions from the current directory.
8
+ # Press Tab to autocomplete the selected suggestion.
9
+ # Navigate suggestions with arrow keys.
10
+ #
11
+ # Supports:
12
+ # - Absolute paths (starting with /)
13
+ # - Home directory expansion (~/...)
14
+ # - Relative paths (from root directory)
15
+ # - Directory-only filtering
16
+ #
17
+ # @example Basic usage
18
+ # path = Clack.path(message: "Select a file")
19
+ #
20
+ # @example Directory picker
21
+ # dir = Clack.path(
22
+ # message: "Choose project directory",
23
+ # only_directories: true,
24
+ # root: "~/projects"
25
+ # )
26
+ #
27
+ class Path < Core::Prompt
28
+ include Core::TextInputHelper
29
+
30
+ # @param message [String] the prompt message
31
+ # @param root [String] starting/base directory (default: ".")
32
+ # @param only_directories [Boolean] only show directories (default: false)
33
+ # @param max_items [Integer] max visible suggestions (default: 5)
34
+ # @param validate [Proc, nil] validation proc for the final path
35
+ # @param opts [Hash] additional options passed to {Core::Prompt}
36
+ def initialize(message:, root: ".", only_directories: false, max_items: 5, **opts)
37
+ super(message:, **opts)
38
+ @root = File.expand_path(root)
39
+ @only_directories = only_directories
40
+ @max_items = max_items
41
+ @value = ""
42
+ @cursor = 0
43
+ @selected_index = 0
44
+ @scroll_offset = 0
45
+ @suggestions = []
46
+ update_suggestions
47
+ end
48
+
49
+ protected
50
+
51
+ def handle_key(key)
52
+ return if terminal_state?
53
+
54
+ @state = :active if @state == :error
55
+ action = Core::Settings.action?(key)
56
+
57
+ case action
58
+ when :cancel
59
+ @state = :cancel
60
+ when :enter
61
+ submit_selection
62
+ when :up
63
+ move_selection(-1)
64
+ when :down
65
+ move_selection(1)
66
+ else
67
+ # Tab to autocomplete
68
+ if key == "\t" && !@suggestions.empty?
69
+ autocomplete_selection
70
+ else
71
+ handle_text_input(key)
72
+ end
73
+ end
74
+ end
75
+
76
+ def handle_text_input(key)
77
+ return unless super
78
+
79
+ @selected_index = 0
80
+ @scroll_offset = 0
81
+ update_suggestions
82
+ end
83
+
84
+ def autocomplete_selection
85
+ return if @suggestions.empty?
86
+
87
+ @value = @suggestions[@selected_index]
88
+ @cursor = @value.length
89
+ update_suggestions
90
+ end
91
+
92
+ def submit_selection
93
+ path = @value.empty? ? @root : resolve_path(@value)
94
+
95
+ if @validate
96
+ result = @validate.call(path)
97
+ if result
98
+ @error_message = result.is_a?(Exception) ? result.message : result.to_s
99
+ @state = :error
100
+ return
101
+ end
102
+ end
103
+
104
+ @value = path
105
+ @state = :submit
106
+ end
107
+
108
+ def build_frame
109
+ lines = []
110
+ lines << "#{bar}\n"
111
+ lines << "#{symbol_for_state} #{@message}\n"
112
+ lines << "#{active_bar} #{input_display}\n"
113
+
114
+ visible_suggestions.each_with_index do |path, idx|
115
+ actual_idx = @scroll_offset + idx
116
+ lines << "#{bar} #{suggestion_display(path, actual_idx == @selected_index)}\n"
117
+ end
118
+
119
+ lines << "#{bar_end}\n"
120
+
121
+ lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n" if @state == :error
122
+
123
+ lines.join
124
+ end
125
+
126
+ def build_final_frame
127
+ lines = []
128
+ lines << "#{bar}\n"
129
+ lines << "#{symbol_for_state} #{@message}\n"
130
+
131
+ display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(@value)) : Colors.dim(@value)
132
+ lines << "#{bar} #{display}\n"
133
+
134
+ lines.join
135
+ end
136
+
137
+ private
138
+
139
+ def update_suggestions
140
+ base_path = resolve_path(@value)
141
+ search_dir = File.directory?(base_path) ? base_path : File.dirname(base_path)
142
+ prefix = File.directory?(base_path) ? "" : File.basename(base_path).downcase
143
+
144
+ @suggestions = list_entries(search_dir, prefix)
145
+ rescue SystemCallError
146
+ @suggestions = []
147
+ end
148
+
149
+ def list_entries(dir, prefix)
150
+ return [] unless File.directory?(dir)
151
+
152
+ entries = Dir.entries(dir) - [".", ".."]
153
+ entries = entries.select { |entry| File.directory?(File.join(dir, entry)) } if @only_directories
154
+ entries = entries.select { |entry| entry.downcase.start_with?(prefix) } unless prefix.empty?
155
+ entries.sort.first(@max_items * 2).map { |entry| format_entry(dir, entry) }
156
+ end
157
+
158
+ def format_entry(dir, entry)
159
+ full_path = File.join(dir, entry)
160
+ if full_path.start_with?(@root)
161
+ # Show relative path without leading ./
162
+ path = full_path[@root.length..]
163
+ path = path.sub(%r{^/}, "") # Remove leading slash
164
+ path = entry if path.empty?
165
+ else
166
+ path = full_path
167
+ end
168
+ path += "/" if File.directory?(full_path)
169
+ path
170
+ end
171
+
172
+ def resolve_path(input)
173
+ return @root if input.empty?
174
+
175
+ if input.start_with?("/")
176
+ input
177
+ elsif input.start_with?("~")
178
+ File.expand_path(input)
179
+ else
180
+ File.join(@root, input)
181
+ end
182
+ end
183
+
184
+ def visible_suggestions
185
+ return @suggestions if @suggestions.length <= @max_items
186
+
187
+ @suggestions[@scroll_offset, @max_items]
188
+ end
189
+
190
+ def move_selection(delta)
191
+ return if @suggestions.empty?
192
+
193
+ @selected_index = (@selected_index + delta) % @suggestions.length
194
+ update_scroll
195
+ end
196
+
197
+ def update_scroll
198
+ return unless @suggestions.length > @max_items
199
+
200
+ if @selected_index < @scroll_offset
201
+ @scroll_offset = @selected_index
202
+ elsif @selected_index >= @scroll_offset + @max_items
203
+ @scroll_offset = @selected_index - @max_items + 1
204
+ end
205
+ end
206
+
207
+ # Override to use @root as placeholder
208
+ def placeholder_display
209
+ return "" if @root.empty?
210
+
211
+ first = Colors.inverse(@root[0])
212
+ rest = Colors.dim(@root[1..])
213
+ "#{first}#{rest}"
214
+ end
215
+
216
+ def suggestion_display(path, active)
217
+ icon = path.end_with?("/") ? Symbols::S_FOLDER : Symbols::S_FILE
218
+ if active
219
+ "#{Colors.cyan(icon)} #{path}"
220
+ else
221
+ "#{Colors.dim(icon)} #{Colors.dim(path)}"
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Prompts
5
+ # Visual progress bar for measurable operations.
6
+ #
7
+ # Shows a filled/empty bar with percentage. Call {#start} to begin,
8
+ # {#advance} or {#update} to show progress, {#stop} to complete.
9
+ #
10
+ # @example Basic usage
11
+ # progress = Clack.progress(total: 100, message: "Downloading...")
12
+ # progress.start
13
+ # 100.times { |i| progress.update(i + 1) }
14
+ # progress.stop("Download complete!")
15
+ #
16
+ # @example With advance
17
+ # progress = Clack.progress(total: files.size)
18
+ # progress.start("Processing files")
19
+ # files.each do |file|
20
+ # process(file)
21
+ # progress.advance # increments by 1
22
+ # end
23
+ # progress.stop("Done!")
24
+ #
25
+ class Progress
26
+ # @param total [Integer] total number of steps (must be non-negative)
27
+ # @param message [String, nil] initial message to display
28
+ # @param output [IO] output stream (default: $stdout)
29
+ # @raise [ArgumentError] if total is negative
30
+ def initialize(total:, message: nil, output: $stdout)
31
+ raise ArgumentError, "total must be non-negative" if total.negative?
32
+
33
+ @total = total
34
+ @current = 0
35
+ @message = message
36
+ @output = output
37
+ @started = false
38
+ @rendered_once = false
39
+ @width = 40
40
+ end
41
+
42
+ # Start displaying the progress bar.
43
+ #
44
+ # @param message [String, nil] optional message to display
45
+ # @return [self] for method chaining
46
+ def start(message = nil)
47
+ @message = message if message
48
+ @started = true
49
+ render
50
+ self
51
+ end
52
+
53
+ # Advance progress by the given amount.
54
+ #
55
+ # @param amount [Integer] steps to advance (default: 1)
56
+ # @return [self] for method chaining
57
+ def advance(amount = 1)
58
+ @current = [@current + amount, @total].min
59
+ render
60
+ self
61
+ end
62
+
63
+ # Set progress to an absolute value.
64
+ #
65
+ # @param current [Integer] current progress value
66
+ # @return [self] for method chaining
67
+ def update(current)
68
+ @current = [current, @total].min
69
+ render
70
+ self
71
+ end
72
+
73
+ # Update the message without changing progress.
74
+ #
75
+ # @param msg [String] new message
76
+ # @return [self] for method chaining
77
+ def message(msg)
78
+ @message = msg
79
+ render
80
+ self
81
+ end
82
+
83
+ # Complete with success. Sets progress to 100%.
84
+ #
85
+ # @param final_message [String, nil] final message to display
86
+ # @return [self] for method chaining
87
+ def stop(final_message = nil)
88
+ @current = @total
89
+ @message = final_message if final_message
90
+ render_final(:success)
91
+ self
92
+ end
93
+
94
+ # Complete with error state.
95
+ #
96
+ # @param message [String, nil] error message
97
+ # @return [self] for method chaining
98
+ def error(message = nil)
99
+ @message = message if message
100
+ render_final(:error)
101
+ self
102
+ end
103
+
104
+ private
105
+
106
+ def render
107
+ return unless @started
108
+
109
+ # Move cursor up and clear line if not first render
110
+ if @rendered_once
111
+ @output.print "\e[1A\e[2K"
112
+ end
113
+ @rendered_once = true
114
+ @output.puts "#{symbol} #{progress_bar} #{percentage}#{message_text}"
115
+ end
116
+
117
+ def render_final(state)
118
+ # Move up and clear the progress line
119
+ @output.print "\e[1A\e[2K" if @rendered_once
120
+ sym = (state == :success) ? Colors.green(Symbols::S_STEP_SUBMIT) : Colors.red(Symbols::S_STEP_CANCEL)
121
+ @output.puts "#{sym} #{@message}"
122
+ end
123
+
124
+ def symbol
125
+ Colors.cyan(Symbols::S_STEP_ACTIVE)
126
+ end
127
+
128
+ def progress_bar
129
+ filled = @total.zero? ? @width : (@current.to_f / @total * @width).round
130
+ empty = @width - filled
131
+ bar = Colors.green(Symbols::S_PROGRESS_FILLED * filled) + Colors.gray(Symbols::S_PROGRESS_EMPTY * empty)
132
+ "[#{bar}]"
133
+ end
134
+
135
+ def percentage
136
+ pct = @total.zero? ? 100 : (@current.to_f / @total * 100).round
137
+ Colors.dim("#{pct.to_s.rjust(3)}%")
138
+ end
139
+
140
+ def message_text
141
+ @message ? " #{@message}" : ""
142
+ end
143
+ end
144
+ end
145
+ end