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,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Prompts
|
|
5
|
+
# Single-selection prompt from a list of options.
|
|
6
|
+
#
|
|
7
|
+
# Navigate with arrow keys or j/k (vim-style). Press Enter to confirm.
|
|
8
|
+
# Supports disabled options, hints, and scrolling for long lists.
|
|
9
|
+
#
|
|
10
|
+
# Options can be:
|
|
11
|
+
# - Strings: `["a", "b", "c"]` (value and label are the same)
|
|
12
|
+
# - Hashes: `[{value: "a", label: "Option A", hint: "details", disabled: false}]`
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# choice = Clack.select(
|
|
16
|
+
# message: "Pick a color",
|
|
17
|
+
# options: %w[red green blue]
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# @example With rich options
|
|
21
|
+
# db = Clack.select(
|
|
22
|
+
# message: "Choose database",
|
|
23
|
+
# options: [
|
|
24
|
+
# { value: "pg", label: "PostgreSQL", hint: "recommended" },
|
|
25
|
+
# { value: "mysql", label: "MySQL" },
|
|
26
|
+
# { value: "sqlite", label: "SQLite", disabled: true }
|
|
27
|
+
# ],
|
|
28
|
+
# initial_value: "pg",
|
|
29
|
+
# max_items: 5
|
|
30
|
+
# )
|
|
31
|
+
#
|
|
32
|
+
class Select < Core::Prompt
|
|
33
|
+
include Core::OptionsHelper
|
|
34
|
+
|
|
35
|
+
# @param message [String] the prompt message
|
|
36
|
+
# @param options [Array<Hash, String>] list of options (see class docs)
|
|
37
|
+
# @param initial_value [Object, nil] value of initially selected option
|
|
38
|
+
# @param max_items [Integer, nil] max visible items (enables scrolling)
|
|
39
|
+
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
40
|
+
def initialize(message:, options:, initial_value: nil, max_items: nil, **opts)
|
|
41
|
+
super(message:, **opts)
|
|
42
|
+
@options = normalize_options(options)
|
|
43
|
+
@cursor = find_initial_cursor(initial_value)
|
|
44
|
+
@max_items = max_items
|
|
45
|
+
@scroll_offset = 0
|
|
46
|
+
update_value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
protected
|
|
50
|
+
|
|
51
|
+
def handle_key(key)
|
|
52
|
+
return if terminal_state?
|
|
53
|
+
|
|
54
|
+
action = Core::Settings.action?(key)
|
|
55
|
+
|
|
56
|
+
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)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_frame
|
|
69
|
+
lines = []
|
|
70
|
+
lines << "#{bar}\n"
|
|
71
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
72
|
+
|
|
73
|
+
visible_options.each_with_index do |opt, idx|
|
|
74
|
+
actual_idx = @scroll_offset + idx
|
|
75
|
+
lines << "#{bar} #{option_display(opt, actual_idx == @cursor)}\n"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
lines << "#{Colors.gray(Symbols::S_BAR_END)}\n"
|
|
79
|
+
lines.join
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_final_frame
|
|
83
|
+
lines = []
|
|
84
|
+
lines << "#{bar}\n"
|
|
85
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
86
|
+
|
|
87
|
+
label = current_option[:label]
|
|
88
|
+
display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(label)) : Colors.dim(label)
|
|
89
|
+
lines << "#{bar} #{display}\n"
|
|
90
|
+
|
|
91
|
+
lines.join
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def move_cursor(delta)
|
|
97
|
+
super
|
|
98
|
+
update_value
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def update_value
|
|
102
|
+
@value = current_option[:value]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def current_option
|
|
106
|
+
@options[@cursor]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def option_display(opt, active)
|
|
110
|
+
return disabled_option_display(opt) if opt[:disabled]
|
|
111
|
+
return active_option_display(opt) if active
|
|
112
|
+
|
|
113
|
+
inactive_option_display(opt)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def disabled_option_display(opt)
|
|
117
|
+
symbol = Colors.dim(Symbols::S_RADIO_INACTIVE)
|
|
118
|
+
label = Colors.strikethrough(Colors.dim(opt[:label]))
|
|
119
|
+
"#{symbol} #{label}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def active_option_display(opt)
|
|
123
|
+
symbol = Colors.green(Symbols::S_RADIO_ACTIVE)
|
|
124
|
+
hint = opt[:hint] ? " #{Colors.dim("(#{opt[:hint]})")}" : ""
|
|
125
|
+
"#{symbol} #{opt[:label]}#{hint}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def inactive_option_display(opt)
|
|
129
|
+
symbol = Colors.dim(Symbols::S_RADIO_INACTIVE)
|
|
130
|
+
"#{symbol} #{Colors.dim(opt[:label])}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Prompts
|
|
5
|
+
# Quick selection via keyboard shortcuts.
|
|
6
|
+
#
|
|
7
|
+
# Each option has an associated key. Pressing that key immediately
|
|
8
|
+
# selects the option and submits.
|
|
9
|
+
#
|
|
10
|
+
# Options format:
|
|
11
|
+
# - `{ value: "x", label: "Do X", key: "x" }` - explicit key
|
|
12
|
+
# - `{ value: "create", label: "Create" }` - key defaults to first char
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# action = Clack.select_key(
|
|
16
|
+
# message: "What to do?",
|
|
17
|
+
# options: [
|
|
18
|
+
# { value: "create", label: "Create new", key: "c" },
|
|
19
|
+
# { value: "open", label: "Open existing", key: "o" },
|
|
20
|
+
# { value: "quit", label: "Quit", key: "q" }
|
|
21
|
+
# ]
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
class SelectKey < Core::Prompt
|
|
25
|
+
# @param message [String] the prompt message
|
|
26
|
+
# @param options [Array<Hash>] options with :value, :label, and optionally :key, :hint
|
|
27
|
+
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
28
|
+
def initialize(message:, options:, **opts)
|
|
29
|
+
super(message:, **opts)
|
|
30
|
+
@options = normalize_options(options)
|
|
31
|
+
@value = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
protected
|
|
35
|
+
|
|
36
|
+
def handle_key(key)
|
|
37
|
+
return if terminal_state?
|
|
38
|
+
|
|
39
|
+
action = Core::Settings.action?(key)
|
|
40
|
+
|
|
41
|
+
case action
|
|
42
|
+
when :cancel
|
|
43
|
+
@state = :cancel
|
|
44
|
+
else
|
|
45
|
+
# Check if key matches any option
|
|
46
|
+
opt = @options.find { |o| o[:key]&.downcase == key&.downcase }
|
|
47
|
+
if opt
|
|
48
|
+
@value = opt[:value]
|
|
49
|
+
@state = :submit
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_frame
|
|
55
|
+
lines = []
|
|
56
|
+
lines << "#{bar}\n"
|
|
57
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
58
|
+
|
|
59
|
+
@options.each do |opt|
|
|
60
|
+
lines << "#{bar} #{option_display(opt)}\n"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
lines << "#{Colors.gray(Symbols::S_BAR_END)}\n"
|
|
64
|
+
lines.join
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_final_frame
|
|
68
|
+
lines = []
|
|
69
|
+
lines << "#{bar}\n"
|
|
70
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
71
|
+
|
|
72
|
+
selected = @options.find { |o| o[:value] == @value }
|
|
73
|
+
label = selected ? selected[:label] : ""
|
|
74
|
+
display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(label)) : Colors.dim(label)
|
|
75
|
+
lines << "#{bar} #{display}\n"
|
|
76
|
+
|
|
77
|
+
lines.join
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def normalize_options(options)
|
|
83
|
+
options.map do |opt|
|
|
84
|
+
{
|
|
85
|
+
value: opt[:value],
|
|
86
|
+
label: opt[:label] || opt[:value].to_s,
|
|
87
|
+
key: opt[:key] || opt[:value].to_s[0],
|
|
88
|
+
hint: opt[:hint]
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def option_display(opt)
|
|
94
|
+
key_display = Colors.cyan("[#{opt[:key]}]")
|
|
95
|
+
hint = opt[:hint] ? " #{Colors.dim("(#{opt[:hint]})")}" : ""
|
|
96
|
+
"#{key_display} #{opt[:label]}#{hint}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Prompts
|
|
5
|
+
# Animated spinner for async operations.
|
|
6
|
+
#
|
|
7
|
+
# Runs animation in a background thread. Call {#start} to begin,
|
|
8
|
+
# {#stop}/{#error}/{#cancel} to finish. Thread-safe message updates.
|
|
9
|
+
#
|
|
10
|
+
# Indicator modes:
|
|
11
|
+
# - `:dots` - animating dots after message (default)
|
|
12
|
+
# - `:timer` - elapsed time display [Xs] or [Xm Ys]
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# s = Clack.spinner
|
|
16
|
+
# s.start("Installing...")
|
|
17
|
+
# # ... do work ...
|
|
18
|
+
# s.stop("Done!")
|
|
19
|
+
#
|
|
20
|
+
# @example With timer
|
|
21
|
+
# s = Clack.spinner(indicator: :timer)
|
|
22
|
+
# s.start("Building")
|
|
23
|
+
# build_project
|
|
24
|
+
# s.stop("Build complete") # => "Build complete [12s]"
|
|
25
|
+
#
|
|
26
|
+
# @example Updating message mid-spin
|
|
27
|
+
# s = Clack.spinner
|
|
28
|
+
# s.start("Step 1...")
|
|
29
|
+
# do_step_1
|
|
30
|
+
# s.message("Step 2...")
|
|
31
|
+
# do_step_2
|
|
32
|
+
# s.stop("All done!")
|
|
33
|
+
#
|
|
34
|
+
class Spinner
|
|
35
|
+
# @param indicator [:dots, :timer] animation style (default: :dots)
|
|
36
|
+
# @param frames [Array<String>, nil] custom spinner frames
|
|
37
|
+
# @param delay [Float, nil] delay between frames in seconds
|
|
38
|
+
# @param style_frame [Proc, nil] proc to style each frame character
|
|
39
|
+
# @param output [IO] output stream (default: $stdout)
|
|
40
|
+
def initialize(
|
|
41
|
+
indicator: :dots,
|
|
42
|
+
frames: nil,
|
|
43
|
+
delay: nil,
|
|
44
|
+
style_frame: nil,
|
|
45
|
+
output: $stdout
|
|
46
|
+
)
|
|
47
|
+
@output = output
|
|
48
|
+
@indicator = indicator
|
|
49
|
+
@frames = frames || Symbols::SPINNER_FRAMES
|
|
50
|
+
@delay = delay || Symbols::SPINNER_DELAY
|
|
51
|
+
@style_frame = style_frame || ->(frame) { Colors.magenta(frame) }
|
|
52
|
+
@running = false
|
|
53
|
+
@cancelled = false
|
|
54
|
+
@message = ""
|
|
55
|
+
@thread = nil
|
|
56
|
+
@frame_idx = 0
|
|
57
|
+
@dot_idx = 0
|
|
58
|
+
@prev_frame = nil
|
|
59
|
+
@start_time = nil
|
|
60
|
+
@mutex = Mutex.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Start the spinner animation.
|
|
64
|
+
#
|
|
65
|
+
# @param message [String, nil] initial message to display
|
|
66
|
+
# @return [self] for method chaining
|
|
67
|
+
def start(message = nil)
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
return if @running
|
|
70
|
+
|
|
71
|
+
@message = remove_trailing_dots(message || "")
|
|
72
|
+
@running = true
|
|
73
|
+
@cancelled = false
|
|
74
|
+
@prev_frame = nil
|
|
75
|
+
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
76
|
+
@dot_idx = 0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@output.print Core::Cursor.hide
|
|
80
|
+
@output.print "#{Colors.gray(Symbols::S_BAR)}\n"
|
|
81
|
+
|
|
82
|
+
@thread = Thread.new { spin_loop }
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Stop with success state.
|
|
87
|
+
#
|
|
88
|
+
# @param message [String, nil] final message (uses current if nil)
|
|
89
|
+
def stop(message = nil)
|
|
90
|
+
finish(:success, message)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Stop with error state.
|
|
94
|
+
#
|
|
95
|
+
# @param message [String, nil] error message (uses current if nil)
|
|
96
|
+
def error(message = nil)
|
|
97
|
+
finish(:error, message)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Stop with cancelled state.
|
|
101
|
+
#
|
|
102
|
+
# @param message [String, nil] cancellation message
|
|
103
|
+
def cancel(message = nil)
|
|
104
|
+
finish(:cancel, message)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Update the spinner message while running.
|
|
108
|
+
#
|
|
109
|
+
# @param msg [String] new message to display
|
|
110
|
+
def message(msg)
|
|
111
|
+
@mutex.synchronize { @message = remove_trailing_dots(msg) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Clear the spinner without showing a final message.
|
|
115
|
+
def clear
|
|
116
|
+
@mutex.synchronize do
|
|
117
|
+
@running = false
|
|
118
|
+
end
|
|
119
|
+
@thread&.join
|
|
120
|
+
restore_cursor
|
|
121
|
+
@output.print Core::Cursor.clear_down
|
|
122
|
+
@output.print Core::Cursor.show
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def cancelled?
|
|
126
|
+
@cancelled
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def remove_trailing_dots(msg)
|
|
132
|
+
msg.to_s.sub(/\.+$/, "")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def format_timer
|
|
136
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
|
|
137
|
+
min = (elapsed / 60).to_i
|
|
138
|
+
secs = (elapsed % 60).to_i
|
|
139
|
+
min.positive? ? "[#{min}m #{secs}s]" : "[#{secs}s]"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def spin_loop
|
|
143
|
+
frame_count = 0
|
|
144
|
+
while @mutex.synchronize { @running }
|
|
145
|
+
frame = @style_frame.call(@frames[@frame_idx])
|
|
146
|
+
msg = @mutex.synchronize { @message }
|
|
147
|
+
render_frame(frame, msg, frame_count)
|
|
148
|
+
|
|
149
|
+
@frame_idx = (@frame_idx + 1) % @frames.length
|
|
150
|
+
frame_count += 1
|
|
151
|
+
sleep @delay
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def render_frame(frame, msg, frame_count)
|
|
156
|
+
suffix = case @indicator
|
|
157
|
+
when :timer
|
|
158
|
+
" #{format_timer}"
|
|
159
|
+
when :dots
|
|
160
|
+
# Animate dots: cycles every 8 frames (0-3 dots)
|
|
161
|
+
dot_count = (frame_count / 2) % 4
|
|
162
|
+
"." * dot_count
|
|
163
|
+
else
|
|
164
|
+
""
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
line = "#{frame} #{msg}#{suffix}"
|
|
168
|
+
@mutex.synchronize do
|
|
169
|
+
return if line == @prev_frame
|
|
170
|
+
|
|
171
|
+
@output.print "\r#{Core::Cursor.clear_to_end}#{line}"
|
|
172
|
+
@prev_frame = line
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def finish(state, message)
|
|
177
|
+
msg, timer_suffix = @mutex.synchronize do
|
|
178
|
+
@running = false
|
|
179
|
+
suffix = (@indicator == :timer) ? " #{format_timer}" : ""
|
|
180
|
+
[message || @message, suffix]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
@thread&.join
|
|
184
|
+
|
|
185
|
+
@output.print "\r#{Core::Cursor.clear_to_end}"
|
|
186
|
+
|
|
187
|
+
symbol = case state
|
|
188
|
+
when :success then Colors.green(Symbols::S_STEP_SUBMIT)
|
|
189
|
+
when :error then Colors.red(Symbols::S_STEP_ERROR)
|
|
190
|
+
when :cancel
|
|
191
|
+
@cancelled = true
|
|
192
|
+
Colors.red(Symbols::S_STEP_CANCEL)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
@output.print "#{symbol} #{msg}#{timer_suffix}\n"
|
|
196
|
+
@output.print Core::Cursor.show
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def restore_cursor
|
|
200
|
+
return unless @prev_frame
|
|
201
|
+
|
|
202
|
+
@output.print "\r"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Prompts
|
|
5
|
+
# Sequential task runner with spinner animation.
|
|
6
|
+
#
|
|
7
|
+
# Runs tasks in order, showing a spinner while each runs.
|
|
8
|
+
# Displays success/error status after each task completes.
|
|
9
|
+
#
|
|
10
|
+
# Each task is a hash with:
|
|
11
|
+
# - `:title` - display title
|
|
12
|
+
# - `:task` - Proc to execute (exceptions are caught)
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# results = Clack.tasks(tasks: [
|
|
16
|
+
# { title: "Checking dependencies", task: -> { check_deps } },
|
|
17
|
+
# { title: "Building project", task: -> { build } },
|
|
18
|
+
# { title: "Running tests", task: -> { run_tests } }
|
|
19
|
+
# ])
|
|
20
|
+
#
|
|
21
|
+
# @example Checking results
|
|
22
|
+
# results.each do |r|
|
|
23
|
+
# if r.status == :error
|
|
24
|
+
# puts "#{r.title} failed: #{r.error}"
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
class Tasks
|
|
29
|
+
# @!attribute [r] title
|
|
30
|
+
# @return [String] the task title
|
|
31
|
+
# @!attribute [r] task
|
|
32
|
+
# @return [Proc] the task to execute
|
|
33
|
+
Task = Struct.new(:title, :task, keyword_init: true)
|
|
34
|
+
|
|
35
|
+
# @!attribute [r] title
|
|
36
|
+
# @return [String] the task title
|
|
37
|
+
# @!attribute [r] status
|
|
38
|
+
# @return [Symbol] :success or :error
|
|
39
|
+
# @!attribute [r] error
|
|
40
|
+
# @return [String, nil] error message if failed
|
|
41
|
+
TaskResult = Struct.new(:title, :status, :error, keyword_init: true)
|
|
42
|
+
|
|
43
|
+
# @param tasks [Array<Hash>] tasks with :title and :task keys
|
|
44
|
+
# @param output [IO] output stream (default: $stdout)
|
|
45
|
+
def initialize(tasks:, output: $stdout)
|
|
46
|
+
@tasks = tasks.map { |task_data| Task.new(title: task_data[:title], task: task_data[:task]) }
|
|
47
|
+
@output = output
|
|
48
|
+
@results = []
|
|
49
|
+
@current_index = 0
|
|
50
|
+
@frame_index = 0
|
|
51
|
+
@spinning = false
|
|
52
|
+
@mutex = Mutex.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Run all tasks sequentially.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<TaskResult>] results for each task
|
|
58
|
+
def run
|
|
59
|
+
@output.print Core::Cursor.hide
|
|
60
|
+
@tasks.each_with_index do |task, idx|
|
|
61
|
+
@current_index = idx
|
|
62
|
+
run_task(task)
|
|
63
|
+
end
|
|
64
|
+
@output.print Core::Cursor.show
|
|
65
|
+
@results
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def run_task(task)
|
|
71
|
+
render_pending(task.title)
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
task.task.call
|
|
75
|
+
@results << TaskResult.new(title: task.title, status: :success, error: nil)
|
|
76
|
+
render_success(task.title)
|
|
77
|
+
rescue => exception
|
|
78
|
+
@results << TaskResult.new(title: task.title, status: :error, error: exception.message)
|
|
79
|
+
render_error(task.title, exception.message)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def render_pending(title)
|
|
84
|
+
@output.print "\r#{Core::Cursor.clear_to_end}"
|
|
85
|
+
@output.print "#{Colors.magenta(spinner_frame)} #{title}"
|
|
86
|
+
@spinner_thread = start_spinner(title)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def render_success(title)
|
|
90
|
+
stop_spinner
|
|
91
|
+
@output.print "\r#{Core::Cursor.clear_to_end}"
|
|
92
|
+
@output.puts "#{Colors.green(Symbols::S_STEP_SUBMIT)} #{title}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def render_error(title, message)
|
|
96
|
+
stop_spinner
|
|
97
|
+
@output.print "\r#{Core::Cursor.clear_to_end}"
|
|
98
|
+
@output.puts "#{Colors.red(Symbols::S_STEP_CANCEL)} #{title}"
|
|
99
|
+
@output.puts "#{Colors.gray(Symbols::S_BAR)} #{Colors.red(message)}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def start_spinner(title)
|
|
103
|
+
@mutex.synchronize do
|
|
104
|
+
@spinning = true
|
|
105
|
+
@frame_index = 0
|
|
106
|
+
end
|
|
107
|
+
Thread.new do
|
|
108
|
+
while @mutex.synchronize { @spinning }
|
|
109
|
+
frame = @mutex.synchronize do
|
|
110
|
+
current_frame = Symbols::SPINNER_FRAMES[@frame_index]
|
|
111
|
+
@frame_index = (@frame_index + 1) % Symbols::SPINNER_FRAMES.length
|
|
112
|
+
current_frame
|
|
113
|
+
end
|
|
114
|
+
@output.print "\r#{Core::Cursor.clear_to_end}"
|
|
115
|
+
@output.print "#{Colors.magenta(frame)} #{title}"
|
|
116
|
+
sleep Symbols::SPINNER_DELAY
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def stop_spinner
|
|
122
|
+
@mutex.synchronize { @spinning = false }
|
|
123
|
+
@spinner_thread&.join
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def spinner_frame
|
|
127
|
+
@mutex.synchronize { Symbols::SPINNER_FRAMES[@frame_index] }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Prompts
|
|
5
|
+
# Single-line text input prompt with cursor navigation.
|
|
6
|
+
#
|
|
7
|
+
# Features:
|
|
8
|
+
# - Arrow key cursor movement (left/right)
|
|
9
|
+
# - Backspace/delete support
|
|
10
|
+
# - Placeholder text (shown when empty)
|
|
11
|
+
# - Default value (used if submitted empty)
|
|
12
|
+
# - Initial value (pre-filled, editable)
|
|
13
|
+
# - Validation support
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage
|
|
16
|
+
# name = Clack.text(message: "What is your name?")
|
|
17
|
+
#
|
|
18
|
+
# @example With all options
|
|
19
|
+
# name = Clack.text(
|
|
20
|
+
# message: "Project name?",
|
|
21
|
+
# placeholder: "my-project",
|
|
22
|
+
# default_value: "untitled",
|
|
23
|
+
# initial_value: "hello",
|
|
24
|
+
# validate: ->(v) { "Required!" if v.empty? }
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
class Text < Core::Prompt
|
|
28
|
+
include Core::TextInputHelper
|
|
29
|
+
|
|
30
|
+
# @param message [String] the prompt message
|
|
31
|
+
# @param placeholder [String, nil] dim text shown when input is empty
|
|
32
|
+
# @param default_value [String, nil] value used if submitted empty
|
|
33
|
+
# @param initial_value [String, nil] pre-filled editable text
|
|
34
|
+
# @param validate [Proc, nil] validation proc returning error string or nil
|
|
35
|
+
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
36
|
+
def initialize(message:, placeholder: nil, default_value: nil, initial_value: nil, **opts)
|
|
37
|
+
super(message:, **opts)
|
|
38
|
+
@placeholder = placeholder
|
|
39
|
+
@default_value = default_value
|
|
40
|
+
@value = initial_value || ""
|
|
41
|
+
@cursor = @value.grapheme_clusters.length
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
protected
|
|
45
|
+
|
|
46
|
+
def handle_input(key, action)
|
|
47
|
+
# Only use arrow key actions for actual arrow keys, not vim h/l keys
|
|
48
|
+
# which should be treated as text input
|
|
49
|
+
if key&.start_with?("\e[")
|
|
50
|
+
max_cursor = @value.grapheme_clusters.length
|
|
51
|
+
case action
|
|
52
|
+
when :left
|
|
53
|
+
@cursor = [@cursor - 1, 0].max
|
|
54
|
+
return
|
|
55
|
+
when :right
|
|
56
|
+
@cursor = [@cursor + 1, max_cursor].min
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
handle_text_input(key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def submit
|
|
65
|
+
@value = @default_value if @value.empty? && @default_value
|
|
66
|
+
super
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_frame
|
|
70
|
+
lines = []
|
|
71
|
+
lines << "#{bar}\n"
|
|
72
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
73
|
+
lines << "#{active_bar} #{input_display}\n"
|
|
74
|
+
lines << "#{bar_end}\n" if @state == :active || @state == :initial
|
|
75
|
+
|
|
76
|
+
lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n" if @state == :error
|
|
77
|
+
|
|
78
|
+
lines.join
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_final_frame
|
|
82
|
+
lines = []
|
|
83
|
+
lines << "#{bar}\n"
|
|
84
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
85
|
+
|
|
86
|
+
display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(@value)) : Colors.dim(@value)
|
|
87
|
+
lines << "#{bar} #{display}\n"
|
|
88
|
+
|
|
89
|
+
lines.join
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|