clack 0.1.4 → 0.4.1
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 +4 -4
- data/CHANGELOG.md +76 -1
- data/README.md +184 -10
- data/examples/advanced_prompts.rb +63 -0
- data/examples/basic.rb +15 -0
- data/examples/create_app.rb +86 -0
- data/examples/date_demo.rb +40 -0
- data/examples/demo.rb +179 -0
- data/examples/full_demo.rb +84 -0
- data/examples/group_demo.rb +79 -0
- data/examples/images/confirm_example.rb +12 -0
- data/examples/images/multiselect_example.rb +15 -0
- data/examples/images/password_example.rb +10 -0
- data/examples/images/select_example.rb +15 -0
- data/examples/images/spinner_example.rb +11 -0
- data/examples/images/text_example.rb +11 -0
- data/examples/spinner_demo.rb +38 -0
- data/examples/tasks_demo.rb +59 -0
- data/examples/validation.rb +73 -0
- data/lib/clack/colors.rb +97 -3
- data/lib/clack/core/ci_mode.rb +35 -0
- data/lib/clack/core/cursor.rb +1 -0
- data/lib/clack/core/fuzzy_matcher.rb +121 -0
- data/lib/clack/core/key_reader.rb +5 -0
- data/lib/clack/core/prompt.rb +98 -20
- data/lib/clack/core/scroll_helper.rb +54 -0
- data/lib/clack/core/settings.rb +11 -3
- data/lib/clack/core/text_input_helper.rb +28 -8
- data/lib/clack/environment.rb +1 -1
- data/lib/clack/log.rb +51 -0
- data/lib/clack/note.rb +7 -0
- data/lib/clack/prompts/autocomplete.rb +27 -34
- data/lib/clack/prompts/autocomplete_multiselect.rb +23 -66
- data/lib/clack/prompts/date.rb +280 -0
- data/lib/clack/prompts/group_multiselect.rb +46 -18
- data/lib/clack/prompts/multiline_text.rb +8 -9
- data/lib/clack/prompts/multiselect.rb +3 -5
- data/lib/clack/prompts/password.rb +5 -10
- data/lib/clack/prompts/path.rb +24 -27
- data/lib/clack/prompts/progress.rb +2 -6
- data/lib/clack/prompts/range.rb +112 -0
- data/lib/clack/prompts/select.rb +2 -6
- data/lib/clack/prompts/select_key.rb +5 -8
- data/lib/clack/prompts/spinner.rb +12 -10
- data/lib/clack/prompts/tasks.rb +47 -62
- data/lib/clack/prompts/text.rb +61 -5
- data/lib/clack/stream.rb +32 -3
- data/lib/clack/symbols.rb +25 -0
- data/lib/clack/task_log.rb +3 -5
- data/lib/clack/testing.rb +171 -0
- data/lib/clack/transformers.rb +8 -7
- data/lib/clack/validators.rb +33 -2
- data/lib/clack/version.rb +2 -1
- data/lib/clack.rb +123 -215
- metadata +23 -1
data/lib/clack/prompts/tasks.rb
CHANGED
|
@@ -8,8 +8,12 @@ module Clack
|
|
|
8
8
|
# Displays success/error status after each task completes.
|
|
9
9
|
#
|
|
10
10
|
# Each task is a hash with:
|
|
11
|
-
# -
|
|
12
|
-
# -
|
|
11
|
+
# - +:title+ - display title
|
|
12
|
+
# - +:task+ - Proc to execute (exceptions are caught).
|
|
13
|
+
# Optionally accepts a message-update callable to change
|
|
14
|
+
# the spinner message mid-execution.
|
|
15
|
+
# - +:enabled+ - optional boolean (default true). When false,
|
|
16
|
+
# the task is skipped entirely.
|
|
13
17
|
#
|
|
14
18
|
# @example Basic usage
|
|
15
19
|
# results = Clack.tasks(tasks: [
|
|
@@ -18,6 +22,19 @@ module Clack
|
|
|
18
22
|
# { title: "Running tests", task: -> { run_tests } }
|
|
19
23
|
# ])
|
|
20
24
|
#
|
|
25
|
+
# @example Updating spinner message mid-task
|
|
26
|
+
# Clack.tasks(tasks: [
|
|
27
|
+
# { title: "Installing", task: ->(message) {
|
|
28
|
+
# message.call("Step 1..."); step1
|
|
29
|
+
# message.call("Step 2..."); step2
|
|
30
|
+
# }}
|
|
31
|
+
# ])
|
|
32
|
+
#
|
|
33
|
+
# @example Conditionally skipping tasks
|
|
34
|
+
# Clack.tasks(tasks: [
|
|
35
|
+
# { title: "Deploy", task: -> { deploy }, enabled: ENV["DEPLOY"] == "true" }
|
|
36
|
+
# ])
|
|
37
|
+
#
|
|
21
38
|
# @example Checking results
|
|
22
39
|
# results.each do |r|
|
|
23
40
|
# if r.status == :error
|
|
@@ -26,12 +43,18 @@ module Clack
|
|
|
26
43
|
# end
|
|
27
44
|
#
|
|
28
45
|
class Tasks
|
|
46
|
+
# A single task definition with title, callable, and enabled flag.
|
|
47
|
+
#
|
|
29
48
|
# @!attribute [r] title
|
|
30
49
|
# @return [String] the task title
|
|
31
50
|
# @!attribute [r] task
|
|
32
51
|
# @return [Proc] the task to execute
|
|
33
|
-
|
|
52
|
+
# @!attribute [r] enabled
|
|
53
|
+
# @return [Boolean] whether the task should run (default: true)
|
|
54
|
+
Task = Struct.new(:title, :task, :enabled, keyword_init: true)
|
|
34
55
|
|
|
56
|
+
# Result of a completed task, including status and any error.
|
|
57
|
+
#
|
|
35
58
|
# @!attribute [r] title
|
|
36
59
|
# @return [String] the task title
|
|
37
60
|
# @!attribute [r] status
|
|
@@ -40,16 +63,18 @@ module Clack
|
|
|
40
63
|
# @return [String, nil] error message if failed
|
|
41
64
|
TaskResult = Struct.new(:title, :status, :error, keyword_init: true)
|
|
42
65
|
|
|
43
|
-
# @param tasks [Array<Hash>] tasks with :title and :
|
|
66
|
+
# @param tasks [Array<Hash>] tasks with :title, :task, and optional :enabled keys
|
|
44
67
|
# @param output [IO] output stream (default: $stdout)
|
|
45
68
|
def initialize(tasks:, output: $stdout)
|
|
46
|
-
@tasks = tasks.map
|
|
69
|
+
@tasks = tasks.map do |task_data|
|
|
70
|
+
Task.new(
|
|
71
|
+
title: task_data[:title],
|
|
72
|
+
task: task_data[:task],
|
|
73
|
+
enabled: task_data.fetch(:enabled, true)
|
|
74
|
+
)
|
|
75
|
+
end
|
|
47
76
|
@output = output
|
|
48
77
|
@results = []
|
|
49
|
-
@current_index = 0
|
|
50
|
-
@frame_index = 0
|
|
51
|
-
@spinning = false
|
|
52
|
-
@mutex = Mutex.new
|
|
53
78
|
end
|
|
54
79
|
|
|
55
80
|
# Run all tasks sequentially.
|
|
@@ -57,8 +82,9 @@ module Clack
|
|
|
57
82
|
# @return [Array<TaskResult>] results for each task
|
|
58
83
|
def run
|
|
59
84
|
@output.print Core::Cursor.hide
|
|
60
|
-
@tasks.
|
|
61
|
-
|
|
85
|
+
@tasks.each do |task|
|
|
86
|
+
next unless task.enabled
|
|
87
|
+
|
|
62
88
|
run_task(task)
|
|
63
89
|
end
|
|
64
90
|
@output.print Core::Cursor.show
|
|
@@ -68,63 +94,22 @@ module Clack
|
|
|
68
94
|
private
|
|
69
95
|
|
|
70
96
|
def run_task(task)
|
|
71
|
-
|
|
97
|
+
spinner = Spinner.new(output: @output)
|
|
98
|
+
spinner.start(task.title)
|
|
72
99
|
|
|
73
100
|
begin
|
|
74
|
-
task.task.
|
|
101
|
+
if task.task.arity.zero?
|
|
102
|
+
task.task.call
|
|
103
|
+
else
|
|
104
|
+
task.task.call(spinner.method(:message))
|
|
105
|
+
end
|
|
106
|
+
spinner.stop(task.title)
|
|
75
107
|
@results << TaskResult.new(title: task.title, status: :success, error: nil)
|
|
76
|
-
render_success(task.title)
|
|
77
108
|
rescue => exception
|
|
109
|
+
spinner.error(task.title)
|
|
110
|
+
@output.puts "#{Colors.gray(Symbols::S_BAR)} #{Colors.red(exception.message)}"
|
|
78
111
|
@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
112
|
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
113
|
end
|
|
129
114
|
end
|
|
130
115
|
end
|
data/lib/clack/prompts/text.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Clack
|
|
4
|
+
# Interactive prompt implementations (text, select, confirm, etc.).
|
|
4
5
|
module Prompts
|
|
5
6
|
# Single-line text input prompt with cursor navigation.
|
|
6
7
|
#
|
|
@@ -11,6 +12,7 @@ module Clack
|
|
|
11
12
|
# - Default value (used if submitted empty)
|
|
12
13
|
# - Initial value (pre-filled, editable)
|
|
13
14
|
# - Validation support
|
|
15
|
+
# - Tab completion (optional)
|
|
14
16
|
#
|
|
15
17
|
# @example Basic usage
|
|
16
18
|
# name = Clack.text(message: "What is your name?")
|
|
@@ -24,6 +26,18 @@ module Clack
|
|
|
24
26
|
# validate: ->(v) { "Required!" if v.empty? }
|
|
25
27
|
# )
|
|
26
28
|
#
|
|
29
|
+
# @example With tab completion
|
|
30
|
+
# cmd = Clack.text(
|
|
31
|
+
# message: "Command?",
|
|
32
|
+
# completions: %w[build test deploy lint format]
|
|
33
|
+
# )
|
|
34
|
+
#
|
|
35
|
+
# @example With dynamic completions
|
|
36
|
+
# cmd = Clack.text(
|
|
37
|
+
# message: "Command?",
|
|
38
|
+
# completions: ->(input) { Dir.glob("#{input}*") }
|
|
39
|
+
# )
|
|
40
|
+
#
|
|
27
41
|
class Text < Core::Prompt
|
|
28
42
|
include Core::TextInputHelper
|
|
29
43
|
|
|
@@ -31,12 +45,15 @@ module Clack
|
|
|
31
45
|
# @param placeholder [String, nil] dim text shown when input is empty
|
|
32
46
|
# @param default_value [String, nil] value used if submitted empty
|
|
33
47
|
# @param initial_value [String, nil] pre-filled editable text
|
|
34
|
-
# @param
|
|
35
|
-
#
|
|
36
|
-
|
|
48
|
+
# @param completions [Array<String>, Proc, nil] tab completion candidates. Array of
|
|
49
|
+
# strings or a proc that receives current input and returns candidates.
|
|
50
|
+
# @option opts [Proc, nil] :validate validation proc returning error string or nil
|
|
51
|
+
# @option opts [Hash] additional options passed to {Core::Prompt}
|
|
52
|
+
def initialize(message:, placeholder: nil, default_value: nil, initial_value: nil, completions: nil, **opts)
|
|
37
53
|
super(message:, **opts)
|
|
38
54
|
@placeholder = placeholder
|
|
39
55
|
@default_value = default_value
|
|
56
|
+
@completions = completions
|
|
40
57
|
@value = initial_value || ""
|
|
41
58
|
@cursor = @value.grapheme_clusters.length
|
|
42
59
|
end
|
|
@@ -58,7 +75,11 @@ module Clack
|
|
|
58
75
|
end
|
|
59
76
|
end
|
|
60
77
|
|
|
61
|
-
|
|
78
|
+
if key == "\t" && @completions
|
|
79
|
+
tab_complete
|
|
80
|
+
else
|
|
81
|
+
handle_text_input(key)
|
|
82
|
+
end
|
|
62
83
|
end
|
|
63
84
|
|
|
64
85
|
def submit
|
|
@@ -72,7 +93,7 @@ module Clack
|
|
|
72
93
|
lines << "#{symbol_for_state} #{@message}\n"
|
|
73
94
|
lines << help_line
|
|
74
95
|
lines << "#{active_bar} #{input_display}\n"
|
|
75
|
-
lines << "#{bar_end}\n" if @state
|
|
96
|
+
lines << "#{bar_end}\n" if @state in :active | :initial
|
|
76
97
|
|
|
77
98
|
validation_lines = validation_message_lines
|
|
78
99
|
if validation_lines.any?
|
|
@@ -93,6 +114,41 @@ module Clack
|
|
|
93
114
|
|
|
94
115
|
lines.join
|
|
95
116
|
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Complete the current input using the longest common prefix of matching candidates.
|
|
121
|
+
# Single match: fills the full candidate. Multiple matches: fills the shared prefix.
|
|
122
|
+
def tab_complete
|
|
123
|
+
candidates = if @completions.respond_to?(:call)
|
|
124
|
+
@completions.call(@value)
|
|
125
|
+
else
|
|
126
|
+
@completions.select { |candidate| candidate.downcase.start_with?(@value.downcase) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
return if candidates.empty?
|
|
130
|
+
|
|
131
|
+
completion = if candidates.length == 1
|
|
132
|
+
candidates.first
|
|
133
|
+
else
|
|
134
|
+
common_prefix(candidates)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
return if completion.length <= @value.length
|
|
138
|
+
|
|
139
|
+
@value = completion
|
|
140
|
+
@cursor = @value.grapheme_clusters.length
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def common_prefix(strings)
|
|
144
|
+
return "" if strings.empty?
|
|
145
|
+
|
|
146
|
+
ref = strings.first
|
|
147
|
+
ref.each_char.with_index do |char, idx|
|
|
148
|
+
return ref[0, idx] unless strings.all? { |s| s[idx]&.downcase == char.downcase }
|
|
149
|
+
end
|
|
150
|
+
ref
|
|
151
|
+
end
|
|
96
152
|
end
|
|
97
153
|
end
|
|
98
154
|
end
|
data/lib/clack/stream.rb
CHANGED
|
@@ -4,30 +4,59 @@ require "English"
|
|
|
4
4
|
require "stringio"
|
|
5
5
|
|
|
6
6
|
module Clack
|
|
7
|
-
# Stream logging utility for iterables, enumerables, and IO streams
|
|
8
|
-
# Similar to Log but works with streaming data in real-time
|
|
7
|
+
# Stream logging utility for iterables, enumerables, and IO streams.
|
|
8
|
+
# Similar to Log but works with streaming data in real-time.
|
|
9
9
|
module Stream
|
|
10
10
|
class << self
|
|
11
|
+
# Stream lines with an info symbol (cyan).
|
|
12
|
+
# @param source [IO, String, Enumerable] data source to stream
|
|
13
|
+
# @param output [IO] output stream
|
|
14
|
+
# @yield [line] optional block called for each line
|
|
15
|
+
# @return [void]
|
|
11
16
|
def info(source, output: $stdout, &block)
|
|
12
17
|
stream_with_symbol(source, Symbols::S_INFO, :cyan, output, &block)
|
|
13
18
|
end
|
|
14
19
|
|
|
20
|
+
# Stream lines with a success symbol (green).
|
|
21
|
+
# @param source [IO, String, Enumerable] data source to stream
|
|
22
|
+
# @param output [IO] output stream
|
|
23
|
+
# @yield [line] optional block called for each line
|
|
24
|
+
# @return [void]
|
|
15
25
|
def success(source, output: $stdout, &block)
|
|
16
26
|
stream_with_symbol(source, Symbols::S_SUCCESS, :green, output, &block)
|
|
17
27
|
end
|
|
18
28
|
|
|
29
|
+
# Stream lines with a step symbol (green).
|
|
30
|
+
# @param source [IO, String, Enumerable] data source to stream
|
|
31
|
+
# @param output [IO] output stream
|
|
32
|
+
# @yield [line] optional block called for each line
|
|
33
|
+
# @return [void]
|
|
19
34
|
def step(source, output: $stdout, &block)
|
|
20
35
|
stream_with_symbol(source, Symbols::S_STEP_SUBMIT, :green, output, &block)
|
|
21
36
|
end
|
|
22
37
|
|
|
38
|
+
# Stream lines with a warning symbol (yellow).
|
|
39
|
+
# @param source [IO, String, Enumerable] data source to stream
|
|
40
|
+
# @param output [IO] output stream
|
|
41
|
+
# @yield [line] optional block called for each line
|
|
42
|
+
# @return [void]
|
|
23
43
|
def warn(source, output: $stdout, &block)
|
|
24
44
|
stream_with_symbol(source, Symbols::S_WARN, :yellow, output, &block)
|
|
25
45
|
end
|
|
26
46
|
|
|
47
|
+
# Stream lines with an error symbol (red).
|
|
48
|
+
# @param source [IO, String, Enumerable] data source to stream
|
|
49
|
+
# @param output [IO] output stream
|
|
50
|
+
# @yield [line] optional block called for each line
|
|
51
|
+
# @return [void]
|
|
27
52
|
def error(source, output: $stdout, &block)
|
|
28
53
|
stream_with_symbol(source, Symbols::S_ERROR, :red, output, &block)
|
|
29
54
|
end
|
|
30
55
|
|
|
56
|
+
# Stream lines with a plain bar prefix (no symbol).
|
|
57
|
+
# @param source [IO, String, Enumerable] data source to stream
|
|
58
|
+
# @param output [IO] output stream
|
|
59
|
+
# @return [void]
|
|
31
60
|
def message(source, output: $stdout)
|
|
32
61
|
each_line(source) do |line|
|
|
33
62
|
output.puts "#{Colors.gray(Symbols::S_BAR)} #{line.chomp}"
|
|
@@ -35,7 +64,7 @@ module Clack
|
|
|
35
64
|
end
|
|
36
65
|
end
|
|
37
66
|
|
|
38
|
-
# Stream from a subprocess command
|
|
67
|
+
# Stream from a subprocess command.
|
|
39
68
|
# Usage: Clack.stream.command("npm install", type: :info)
|
|
40
69
|
# Returns true on success, false on failure or if command cannot be executed
|
|
41
70
|
def command(cmd, type: :info, output: $stdout)
|
data/lib/clack/symbols.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Clack
|
|
4
|
+
# Unicode and ASCII symbols used for prompt rendering.
|
|
5
|
+
# Automatically selects Unicode or ASCII fallback based on terminal capabilities.
|
|
4
6
|
module Symbols
|
|
5
7
|
class << self
|
|
6
8
|
# Check if unicode output is enabled.
|
|
@@ -12,6 +14,8 @@ module Clack
|
|
|
12
14
|
@unicode = compute_unicode_support
|
|
13
15
|
end
|
|
14
16
|
|
|
17
|
+
# Reset cached unicode detection (useful for testing).
|
|
18
|
+
# @return [void]
|
|
15
19
|
def reset!
|
|
16
20
|
remove_instance_variable(:@unicode) if defined?(@unicode)
|
|
17
21
|
end
|
|
@@ -29,17 +33,23 @@ module Clack
|
|
|
29
33
|
|
|
30
34
|
# Step indicators
|
|
31
35
|
S_STEP_ACTIVE = unicode? ? "◆" : "*"
|
|
36
|
+
# Unicode cancel step indicator, or ASCII fallback.
|
|
32
37
|
S_STEP_CANCEL = unicode? ? "■" : "x"
|
|
38
|
+
# Unicode error step indicator, or ASCII fallback.
|
|
33
39
|
S_STEP_ERROR = unicode? ? "▲" : "x"
|
|
40
|
+
# Unicode submit step indicator, or ASCII fallback.
|
|
34
41
|
S_STEP_SUBMIT = unicode? ? "◇" : "o"
|
|
35
42
|
|
|
36
43
|
# Radio buttons
|
|
37
44
|
S_RADIO_ACTIVE = unicode? ? "●" : ">"
|
|
45
|
+
# Unicode inactive radio button, or ASCII fallback.
|
|
38
46
|
S_RADIO_INACTIVE = unicode? ? "○" : " "
|
|
39
47
|
|
|
40
48
|
# Checkboxes
|
|
41
49
|
S_CHECKBOX_ACTIVE = unicode? ? "◻" : "[•]"
|
|
50
|
+
# Unicode selected checkbox, or ASCII fallback.
|
|
42
51
|
S_CHECKBOX_SELECTED = unicode? ? "◼" : "[+]"
|
|
52
|
+
# Unicode inactive checkbox, or ASCII fallback.
|
|
43
53
|
S_CHECKBOX_INACTIVE = unicode? ? "◻" : "[ ]"
|
|
44
54
|
|
|
45
55
|
# Password mask
|
|
@@ -47,35 +57,50 @@ module Clack
|
|
|
47
57
|
|
|
48
58
|
# Bars and connectors
|
|
49
59
|
S_BAR = unicode? ? "│" : "|"
|
|
60
|
+
# Unicode bar start connector, or ASCII fallback.
|
|
50
61
|
S_BAR_START = unicode? ? "┌" : "+"
|
|
62
|
+
# Unicode bar end connector, or ASCII fallback.
|
|
51
63
|
S_BAR_END = unicode? ? "└" : "+"
|
|
64
|
+
# Unicode horizontal bar, or ASCII fallback.
|
|
52
65
|
S_BAR_H = unicode? ? "─" : "-"
|
|
66
|
+
# Unicode top-right corner, or ASCII fallback.
|
|
53
67
|
S_CORNER_TOP_RIGHT = unicode? ? "╮" : "+"
|
|
68
|
+
# Unicode top-left corner, or ASCII fallback.
|
|
54
69
|
S_CORNER_TOP_LEFT = unicode? ? "╭" : "+"
|
|
70
|
+
# Unicode bottom-right corner, or ASCII fallback.
|
|
55
71
|
S_CORNER_BOTTOM_RIGHT = unicode? ? "╯" : "+"
|
|
72
|
+
# Unicode bottom-left corner, or ASCII fallback.
|
|
56
73
|
S_CORNER_BOTTOM_LEFT = unicode? ? "╰" : "+"
|
|
74
|
+
# Unicode left T-connector, or ASCII fallback.
|
|
57
75
|
S_CONNECT_LEFT = unicode? ? "├" : "+"
|
|
58
76
|
|
|
59
77
|
# Square corners (for box with rounded: false)
|
|
60
78
|
S_BAR_START_RIGHT = unicode? ? "┐" : "+"
|
|
79
|
+
# Unicode square bottom-right corner, or ASCII fallback.
|
|
61
80
|
S_BAR_END_RIGHT = unicode? ? "┘" : "+"
|
|
62
81
|
|
|
63
82
|
# Log symbols
|
|
64
83
|
S_INFO = unicode? ? "●" : "*"
|
|
84
|
+
# Unicode success log symbol, or ASCII fallback.
|
|
65
85
|
S_SUCCESS = unicode? ? "◆" : "*"
|
|
86
|
+
# Unicode warning log symbol, or ASCII fallback.
|
|
66
87
|
S_WARN = unicode? ? "▲" : "!"
|
|
88
|
+
# Unicode error log symbol, or ASCII fallback.
|
|
67
89
|
S_ERROR = unicode? ? "■" : "x"
|
|
68
90
|
|
|
69
91
|
# File system
|
|
70
92
|
S_FOLDER = unicode? ? "📁" : "[D]"
|
|
93
|
+
# Unicode file icon, or ASCII fallback.
|
|
71
94
|
S_FILE = unicode? ? "📄" : "[F]"
|
|
72
95
|
|
|
73
96
|
# Spinner frames - quarter circle rotation pattern
|
|
74
97
|
SPINNER_FRAMES = unicode? ? %w[◒ ◐ ◓ ◑] : %w[• o O 0]
|
|
98
|
+
# Delay between spinner frame updates (seconds).
|
|
75
99
|
SPINNER_DELAY = unicode? ? 0.08 : 0.12
|
|
76
100
|
|
|
77
101
|
# Progress bar characters
|
|
78
102
|
S_PROGRESS_FILLED = unicode? ? "█" : "#"
|
|
103
|
+
# Unicode empty progress segment, or ASCII fallback.
|
|
79
104
|
S_PROGRESS_EMPTY = unicode? ? "░" : "-"
|
|
80
105
|
|
|
81
106
|
# Alternative progress bar (smoother gradient)
|
data/lib/clack/task_log.rb
CHANGED
|
@@ -69,7 +69,8 @@ module Clack
|
|
|
69
69
|
reset_buffers
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
#
|
|
72
|
+
# Add a message from a group to the log buffer.
|
|
73
|
+
# @private
|
|
73
74
|
def add_group_message(_group, msg)
|
|
74
75
|
clear_buffer
|
|
75
76
|
@buffer << msg.to_s
|
|
@@ -149,15 +150,12 @@ module Clack
|
|
|
149
150
|
|
|
150
151
|
# A group within a TaskLog
|
|
151
152
|
class TaskLogGroup
|
|
152
|
-
def initialize(
|
|
153
|
-
@name = name
|
|
153
|
+
def initialize(_name, parent)
|
|
154
154
|
@parent = parent
|
|
155
|
-
@buffer = []
|
|
156
155
|
end
|
|
157
156
|
|
|
158
157
|
# Add a message to this group
|
|
159
158
|
def message(msg, raw: false)
|
|
160
|
-
@buffer << msg.to_s
|
|
161
159
|
@parent.add_group_message(self, msg)
|
|
162
160
|
end
|
|
163
161
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Clack
|
|
6
|
+
# First-class test helpers for simulating prompt interactions.
|
|
7
|
+
#
|
|
8
|
+
# Works with RSpec, Minitest, or any test framework. Provides a DSL
|
|
9
|
+
# for feeding keystrokes to prompts without a real terminal.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic text prompt
|
|
12
|
+
# result = Clack::Testing.simulate(Clack.method(:text), message: "Name?") do |p|
|
|
13
|
+
# p.type("Alice")
|
|
14
|
+
# p.submit
|
|
15
|
+
# end
|
|
16
|
+
# assert_equal "Alice", result
|
|
17
|
+
#
|
|
18
|
+
# @example Select prompt
|
|
19
|
+
# result = Clack::Testing.simulate(Clack.method(:select), message: "Pick", options: %w[a b c]) do |p|
|
|
20
|
+
# p.down
|
|
21
|
+
# p.submit
|
|
22
|
+
# end
|
|
23
|
+
# assert_equal "b", result
|
|
24
|
+
#
|
|
25
|
+
# @example Multiselect
|
|
26
|
+
# result = Clack::Testing.simulate(Clack.method(:multiselect), message: "Pick", options: %w[a b c]) do |p|
|
|
27
|
+
# p.toggle # select "a"
|
|
28
|
+
# p.down
|
|
29
|
+
# p.toggle # select "b"
|
|
30
|
+
# p.submit
|
|
31
|
+
# end
|
|
32
|
+
# assert_equal %w[a b], result
|
|
33
|
+
#
|
|
34
|
+
# @example Cancellation
|
|
35
|
+
# result = Clack::Testing.simulate(Clack.method(:text), message: "Name?") do |p|
|
|
36
|
+
# p.cancel
|
|
37
|
+
# end
|
|
38
|
+
# assert Clack.cancel?(result)
|
|
39
|
+
module Testing
|
|
40
|
+
# Key constants matching what KeyReader returns
|
|
41
|
+
KEYS = {
|
|
42
|
+
enter: "\r",
|
|
43
|
+
escape: "\e",
|
|
44
|
+
ctrl_c: "\u0003",
|
|
45
|
+
ctrl_d: "\u0004",
|
|
46
|
+
up: "\e[A",
|
|
47
|
+
down: "\e[B",
|
|
48
|
+
right: "\e[C",
|
|
49
|
+
left: "\e[D",
|
|
50
|
+
backspace: "\u007F",
|
|
51
|
+
space: " ",
|
|
52
|
+
tab: "\t",
|
|
53
|
+
shift_tab: "\e[Z"
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
# DSL for building a key sequence to feed to a prompt.
|
|
57
|
+
class PromptDriver
|
|
58
|
+
# @return [Array<String>] accumulated key sequence
|
|
59
|
+
attr_reader :keys
|
|
60
|
+
|
|
61
|
+
def initialize
|
|
62
|
+
@keys = []
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Type a string of text character by character.
|
|
66
|
+
# @param text [String] text to type
|
|
67
|
+
def type(text)
|
|
68
|
+
text.each_char { |char| @keys << char }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Press Enter to submit.
|
|
72
|
+
def submit = @keys << KEYS[:enter]
|
|
73
|
+
|
|
74
|
+
# Press Escape to cancel.
|
|
75
|
+
def cancel = @keys << KEYS[:escape]
|
|
76
|
+
|
|
77
|
+
# Press arrow down.
|
|
78
|
+
def down = @keys << KEYS[:down]
|
|
79
|
+
|
|
80
|
+
# Press arrow up.
|
|
81
|
+
def up = @keys << KEYS[:up]
|
|
82
|
+
|
|
83
|
+
# Press arrow left.
|
|
84
|
+
def left = @keys << KEYS[:left]
|
|
85
|
+
|
|
86
|
+
# Press arrow right.
|
|
87
|
+
def right = @keys << KEYS[:right]
|
|
88
|
+
|
|
89
|
+
# Press Space (toggle selection in multiselect).
|
|
90
|
+
def toggle = @keys << KEYS[:space]
|
|
91
|
+
|
|
92
|
+
# Press Tab.
|
|
93
|
+
def tab = @keys << KEYS[:tab]
|
|
94
|
+
|
|
95
|
+
# Press Backspace.
|
|
96
|
+
def backspace = @keys << KEYS[:backspace]
|
|
97
|
+
|
|
98
|
+
# Press Ctrl+D (submit multiline text).
|
|
99
|
+
def ctrl_d = @keys << KEYS[:ctrl_d]
|
|
100
|
+
|
|
101
|
+
# Press an arbitrary key by name or character.
|
|
102
|
+
# @param key [Symbol, String] key name (e.g. :escape) or raw character
|
|
103
|
+
def key(key)
|
|
104
|
+
@keys << (key.is_a?(Symbol) ? KEYS.fetch(key) : key)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@mutex = Mutex.new
|
|
109
|
+
|
|
110
|
+
class << self
|
|
111
|
+
# Simulate a prompt interaction by feeding a predefined key sequence.
|
|
112
|
+
#
|
|
113
|
+
# @param prompt_method [Method, Proc] the Clack method to call (e.g. +Clack.method(:text)+)
|
|
114
|
+
# @param kwargs [Hash] keyword arguments for the prompt
|
|
115
|
+
# @yield [PromptDriver] block to define the interaction
|
|
116
|
+
# @return [Object] the prompt result
|
|
117
|
+
def simulate(prompt_method, **kwargs, &block)
|
|
118
|
+
with_stubbed_input(block) do |output|
|
|
119
|
+
prompt_method.call(**kwargs, output: output)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Capture the rendered output of a prompt simulation.
|
|
124
|
+
# Returns both the result and the raw output string.
|
|
125
|
+
#
|
|
126
|
+
# @param prompt_method [Method, Proc] the Clack method to call
|
|
127
|
+
# @param kwargs [Hash] keyword arguments for the prompt
|
|
128
|
+
# @yield [PromptDriver] block to define the interaction
|
|
129
|
+
# @return [Array(Object, String)] [result, output_string]
|
|
130
|
+
def simulate_with_output(prompt_method, **kwargs, &block)
|
|
131
|
+
output_io = nil
|
|
132
|
+
result = with_stubbed_input(block) do |output|
|
|
133
|
+
output_io = output
|
|
134
|
+
prompt_method.call(**kwargs, output: output)
|
|
135
|
+
end
|
|
136
|
+
[result, output_io.string]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def with_stubbed_input(driver_block)
|
|
142
|
+
driver = PromptDriver.new
|
|
143
|
+
driver_block.call(driver)
|
|
144
|
+
|
|
145
|
+
queue = driver.keys.dup
|
|
146
|
+
output = StringIO.new
|
|
147
|
+
read_count = 0
|
|
148
|
+
|
|
149
|
+
verbose, $VERBOSE = $VERBOSE, nil
|
|
150
|
+
Core::KeyReader.singleton_class.alias_method(:_original_read, :read)
|
|
151
|
+
|
|
152
|
+
Core::KeyReader.define_singleton_method(:read) do
|
|
153
|
+
read_count += 1
|
|
154
|
+
raise "Too many reads (#{read_count}) - possible infinite loop in test" if read_count > 100
|
|
155
|
+
|
|
156
|
+
queue.shift || KEYS[:enter]
|
|
157
|
+
end
|
|
158
|
+
$VERBOSE = verbose
|
|
159
|
+
|
|
160
|
+
yield output
|
|
161
|
+
ensure
|
|
162
|
+
if Core::KeyReader.singleton_class.method_defined?(:_original_read)
|
|
163
|
+
verbose, $VERBOSE = $VERBOSE, nil
|
|
164
|
+
Core::KeyReader.singleton_class.alias_method(:read, :_original_read)
|
|
165
|
+
Core::KeyReader.singleton_class.remove_method(:_original_read)
|
|
166
|
+
$VERBOSE = verbose
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
data/lib/clack/transformers.rb
CHANGED
|
@@ -31,19 +31,20 @@ module Clack
|
|
|
31
31
|
REGISTRY = {}
|
|
32
32
|
|
|
33
33
|
class << self
|
|
34
|
-
# Resolve a transformer from a symbol,
|
|
35
|
-
# @param transformer [Symbol,
|
|
36
|
-
# @return [
|
|
34
|
+
# Resolve a transformer from a symbol, callable, or return as-is.
|
|
35
|
+
# @param transformer [Symbol, #call, nil] the transformer to resolve
|
|
36
|
+
# @return [#call, nil] the resolved transformer
|
|
37
37
|
def resolve(transformer)
|
|
38
38
|
case transformer
|
|
39
39
|
when Symbol
|
|
40
|
-
REGISTRY[transformer] || raise(ArgumentError,
|
|
41
|
-
|
|
42
|
-
transformer
|
|
40
|
+
REGISTRY[transformer] || raise(ArgumentError,
|
|
41
|
+
"Unknown transformer: #{transformer.inspect}. Available: #{REGISTRY.keys.map(&:inspect).join(", ")}")
|
|
43
42
|
when nil
|
|
44
43
|
nil
|
|
45
44
|
else
|
|
46
|
-
|
|
45
|
+
return transformer if transformer.respond_to?(:call)
|
|
46
|
+
|
|
47
|
+
raise ArgumentError, "Transform must be a Symbol or respond to #call, got #{transformer.class}"
|
|
47
48
|
end
|
|
48
49
|
end
|
|
49
50
|
|