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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/README.md +184 -10
  4. data/examples/advanced_prompts.rb +63 -0
  5. data/examples/basic.rb +15 -0
  6. data/examples/create_app.rb +86 -0
  7. data/examples/date_demo.rb +40 -0
  8. data/examples/demo.rb +179 -0
  9. data/examples/full_demo.rb +84 -0
  10. data/examples/group_demo.rb +79 -0
  11. data/examples/images/confirm_example.rb +12 -0
  12. data/examples/images/multiselect_example.rb +15 -0
  13. data/examples/images/password_example.rb +10 -0
  14. data/examples/images/select_example.rb +15 -0
  15. data/examples/images/spinner_example.rb +11 -0
  16. data/examples/images/text_example.rb +11 -0
  17. data/examples/spinner_demo.rb +38 -0
  18. data/examples/tasks_demo.rb +59 -0
  19. data/examples/validation.rb +73 -0
  20. data/lib/clack/colors.rb +97 -3
  21. data/lib/clack/core/ci_mode.rb +35 -0
  22. data/lib/clack/core/cursor.rb +1 -0
  23. data/lib/clack/core/fuzzy_matcher.rb +121 -0
  24. data/lib/clack/core/key_reader.rb +5 -0
  25. data/lib/clack/core/prompt.rb +98 -20
  26. data/lib/clack/core/scroll_helper.rb +54 -0
  27. data/lib/clack/core/settings.rb +11 -3
  28. data/lib/clack/core/text_input_helper.rb +28 -8
  29. data/lib/clack/environment.rb +1 -1
  30. data/lib/clack/log.rb +51 -0
  31. data/lib/clack/note.rb +7 -0
  32. data/lib/clack/prompts/autocomplete.rb +27 -34
  33. data/lib/clack/prompts/autocomplete_multiselect.rb +23 -66
  34. data/lib/clack/prompts/date.rb +280 -0
  35. data/lib/clack/prompts/group_multiselect.rb +46 -18
  36. data/lib/clack/prompts/multiline_text.rb +8 -9
  37. data/lib/clack/prompts/multiselect.rb +3 -5
  38. data/lib/clack/prompts/password.rb +5 -10
  39. data/lib/clack/prompts/path.rb +24 -27
  40. data/lib/clack/prompts/progress.rb +2 -6
  41. data/lib/clack/prompts/range.rb +112 -0
  42. data/lib/clack/prompts/select.rb +2 -6
  43. data/lib/clack/prompts/select_key.rb +5 -8
  44. data/lib/clack/prompts/spinner.rb +12 -10
  45. data/lib/clack/prompts/tasks.rb +47 -62
  46. data/lib/clack/prompts/text.rb +61 -5
  47. data/lib/clack/stream.rb +32 -3
  48. data/lib/clack/symbols.rb +25 -0
  49. data/lib/clack/task_log.rb +3 -5
  50. data/lib/clack/testing.rb +171 -0
  51. data/lib/clack/transformers.rb +8 -7
  52. data/lib/clack/validators.rb +33 -2
  53. data/lib/clack/version.rb +2 -1
  54. data/lib/clack.rb +123 -215
  55. metadata +23 -1
@@ -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
- # - `:title` - display title
12
- # - `:task` - Proc to execute (exceptions are caught)
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
- Task = Struct.new(:title, :task, keyword_init: true)
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 :task keys
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 { |task_data| Task.new(title: task_data[:title], task: task_data[:task]) }
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.each_with_index do |task, idx|
61
- @current_index = idx
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
- render_pending(task.title)
97
+ spinner = Spinner.new(output: @output)
98
+ spinner.start(task.title)
72
99
 
73
100
  begin
74
- task.task.call
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
@@ -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 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)
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
- handle_text_input(key)
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 == :active || @state == :initial
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)
@@ -69,7 +69,8 @@ module Clack
69
69
  reset_buffers
70
70
  end
71
71
 
72
- # @api private
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(name, parent)
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
@@ -31,19 +31,20 @@ module Clack
31
31
  REGISTRY = {}
32
32
 
33
33
  class << self
34
- # Resolve a transformer from a symbol, proc, or return as-is.
35
- # @param transformer [Symbol, Proc, nil] the transformer to resolve
36
- # @return [Proc, nil] the resolved transformer proc
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, "Unknown transformer: #{transformer}")
41
- when Proc
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
- raise ArgumentError, "Transform must be a Symbol or Proc, got #{transformer.class}"
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