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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +24 -0
- data/README.md +424 -0
- data/exe/clack-demo +9 -0
- data/lib/clack/box.rb +120 -0
- data/lib/clack/colors.rb +55 -0
- data/lib/clack/core/cursor.rb +61 -0
- data/lib/clack/core/key_reader.rb +45 -0
- data/lib/clack/core/options_helper.rb +96 -0
- data/lib/clack/core/prompt.rb +215 -0
- data/lib/clack/core/settings.rb +97 -0
- data/lib/clack/core/text_input_helper.rb +83 -0
- data/lib/clack/environment.rb +137 -0
- data/lib/clack/group.rb +100 -0
- data/lib/clack/log.rb +42 -0
- data/lib/clack/note.rb +49 -0
- data/lib/clack/prompts/autocomplete.rb +162 -0
- data/lib/clack/prompts/autocomplete_multiselect.rb +280 -0
- data/lib/clack/prompts/confirm.rb +100 -0
- data/lib/clack/prompts/group_multiselect.rb +250 -0
- data/lib/clack/prompts/multiselect.rb +185 -0
- data/lib/clack/prompts/password.rb +77 -0
- data/lib/clack/prompts/path.rb +226 -0
- data/lib/clack/prompts/progress.rb +145 -0
- data/lib/clack/prompts/select.rb +134 -0
- data/lib/clack/prompts/select_key.rb +100 -0
- data/lib/clack/prompts/spinner.rb +206 -0
- data/lib/clack/prompts/tasks.rb +131 -0
- data/lib/clack/prompts/text.rb +93 -0
- data/lib/clack/stream.rb +82 -0
- data/lib/clack/symbols.rb +84 -0
- data/lib/clack/task_log.rb +174 -0
- data/lib/clack/utils.rb +135 -0
- data/lib/clack/validators.rb +145 -0
- data/lib/clack/version.rb +5 -0
- data/lib/clack.rb +576 -0
- metadata +83 -0
|
@@ -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
|