clack 0.1.4 → 0.2.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -1
  3. data/README.md +108 -8
  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/cursor.rb +1 -0
  22. data/lib/clack/core/key_reader.rb +5 -0
  23. data/lib/clack/core/prompt.rb +52 -8
  24. data/lib/clack/core/settings.rb +4 -0
  25. data/lib/clack/core/text_input_helper.rb +4 -0
  26. data/lib/clack/log.rb +51 -0
  27. data/lib/clack/note.rb +7 -0
  28. data/lib/clack/prompts/autocomplete.rb +29 -11
  29. data/lib/clack/prompts/autocomplete_multiselect.rb +5 -6
  30. data/lib/clack/prompts/date.rb +280 -0
  31. data/lib/clack/prompts/group_multiselect.rb +46 -18
  32. data/lib/clack/prompts/multiline_text.rb +8 -9
  33. data/lib/clack/prompts/multiselect.rb +3 -5
  34. data/lib/clack/prompts/password.rb +5 -10
  35. data/lib/clack/prompts/path.rb +2 -2
  36. data/lib/clack/prompts/progress.rb +2 -6
  37. data/lib/clack/prompts/select.rb +2 -6
  38. data/lib/clack/prompts/select_key.rb +5 -7
  39. data/lib/clack/prompts/spinner.rb +2 -6
  40. data/lib/clack/prompts/tasks.rb +50 -9
  41. data/lib/clack/prompts/text.rb +4 -3
  42. data/lib/clack/stream.rb +32 -3
  43. data/lib/clack/symbols.rb +25 -0
  44. data/lib/clack/task_log.rb +3 -5
  45. data/lib/clack/transformers.rb +8 -7
  46. data/lib/clack/validators.rb +33 -2
  47. data/lib/clack/version.rb +2 -1
  48. data/lib/clack.rb +72 -213
  49. metadata +18 -1
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Demonstrates Clack.group and group_multiselect
5
+ # Run with: ruby examples/group_demo.rb
6
+
7
+ require_relative "../lib/clack" # Or: require "clack" if installed as a gem
8
+
9
+ Clack.intro "group-demo"
10
+
11
+ # Prompt group with on_cancel handler
12
+ result = Clack.group(
13
+ on_cancel: ->(partial) {
14
+ Clack.cancel "Cancelled (collected: #{partial.keys.select { |k| partial[k] != :cancelled }.join(", ")})"
15
+ exit 0
16
+ }
17
+ ) do |g|
18
+ g.prompt(:name) { Clack.text(message: "Project name:", placeholder: "my-project") }
19
+
20
+ g.prompt(:visibility) do |results|
21
+ Clack.select(
22
+ message: "Visibility for #{results[:name]}?",
23
+ options: [
24
+ {value: "public", label: "Public", hint: "visible to everyone"},
25
+ {value: "private", label: "Private", hint: "invite only"}
26
+ ]
27
+ )
28
+ end
29
+
30
+ g.prompt(:confirm) do |results|
31
+ Clack.confirm(message: "Create #{results[:visibility]} project '#{results[:name]}'?")
32
+ end
33
+ end
34
+
35
+ unless result[:confirm]
36
+ Clack.outro "Project creation skipped."
37
+ exit 0
38
+ end
39
+
40
+ Clack.log.success "Project: #{result[:name]} (#{result[:visibility]})"
41
+
42
+ # Group multiselect with selectable group headers and spacing
43
+ stack = Clack.group_multiselect(
44
+ message: "Select stack components:",
45
+ selectable_groups: true,
46
+ group_spacing: 1,
47
+ required: true,
48
+ options: [
49
+ {
50
+ label: "Frontend",
51
+ options: [
52
+ {value: "react", label: "React"},
53
+ {value: "tailwind", label: "Tailwind CSS"},
54
+ {value: "typescript", label: "TypeScript"}
55
+ ]
56
+ },
57
+ {
58
+ label: "Backend",
59
+ options: [
60
+ {value: "rails", label: "Ruby on Rails"},
61
+ {value: "sidekiq", label: "Sidekiq"},
62
+ {value: "graphql", label: "GraphQL"}
63
+ ]
64
+ },
65
+ {
66
+ label: "Infrastructure",
67
+ options: [
68
+ {value: "docker", label: "Docker"},
69
+ {value: "k8s", label: "Kubernetes"},
70
+ {value: "terraform", label: "Terraform"}
71
+ ]
72
+ }
73
+ ]
74
+ )
75
+ exit 0 if Clack.cancel?(stack)
76
+
77
+ Clack.log.step "Stack: #{stack.join(", ")}"
78
+
79
+ Clack.outro "Done!"
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Run with: ruby examples/images/confirm_example.rb
5
+
6
+ require_relative "../../lib/clack" # Or: require "clack" if installed as a gem
7
+
8
+ Clack.confirm(
9
+ message: "Deploy to production?",
10
+ active: "Yes, ship it!",
11
+ inactive: "No, abort"
12
+ )
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Run with: ruby examples/images/multiselect_example.rb
5
+
6
+ require_relative "../../lib/clack" # Or: require "clack" if installed as a gem
7
+
8
+ Clack.multiselect(
9
+ message: "Select features to install",
10
+ options: [
11
+ {value: "api", label: "API Mode"},
12
+ {value: "auth", label: "Authentication"},
13
+ {value: "jobs", label: "Background Jobs"}
14
+ ]
15
+ )
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Run with: ruby examples/images/password_example.rb
5
+
6
+ require_relative "../../lib/clack" # Or: require "clack" if installed as a gem
7
+
8
+ Clack.password(
9
+ message: "Enter your API key"
10
+ )
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Run with: ruby examples/images/select_example.rb
5
+
6
+ require_relative "../../lib/clack" # Or: require "clack" if installed as a gem
7
+
8
+ Clack.select(
9
+ message: "Choose a database",
10
+ options: [
11
+ {value: "pg", label: "PostgreSQL", hint: "recommended"},
12
+ {value: "mysql", label: "MySQL"},
13
+ {value: "sqlite", label: "SQLite"}
14
+ ]
15
+ )
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Run with: ruby examples/images/spinner_example.rb
5
+
6
+ require_relative "../../lib/clack" # Or: require "clack" if installed as a gem
7
+
8
+ spinner = Clack.spinner
9
+ spinner.start("Installing dependencies...")
10
+ sleep 1.5
11
+ spinner.stop("Dependencies installed!")
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Run with: ruby examples/images/text_example.rb
5
+
6
+ require_relative "../../lib/clack" # Or: require "clack" if installed as a gem
7
+
8
+ Clack.text(
9
+ message: "What is your project named?",
10
+ placeholder: "my-project"
11
+ )
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Run with: ruby examples/spinner_demo.rb
5
+
6
+ require_relative "../lib/clack" # Or: require "clack" if installed as a gem
7
+
8
+ Clack.intro "spinner-demo"
9
+
10
+ # Success state
11
+ s = Clack.spinner
12
+ s.start "Installing dependencies..."
13
+ sleep 2
14
+ s.stop "Dependencies installed!"
15
+
16
+ # Update message mid-spin
17
+ s = Clack.spinner
18
+ s.start "Building project..."
19
+ sleep 1
20
+ s.message "Compiling assets..."
21
+ sleep 1
22
+ s.message "Optimizing bundles..."
23
+ sleep 1
24
+ s.stop "Build complete!"
25
+
26
+ # Error state
27
+ s = Clack.spinner
28
+ s.start "Running tests..."
29
+ sleep 1.5
30
+ s.error "Tests failed!"
31
+
32
+ # Cancel state
33
+ s = Clack.spinner
34
+ s.start "Deploying to production..."
35
+ sleep 1
36
+ s.cancel "Deployment cancelled"
37
+
38
+ Clack.outro "Demo complete!"
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Demonstrates Clack.tasks and Clack.task_log
5
+ # Run with: ruby examples/tasks_demo.rb
6
+
7
+ require_relative "../lib/clack" # Or: require "clack" if installed as a gem
8
+
9
+ Clack.intro "tasks-demo"
10
+
11
+ # Sequential tasks with spinner (message callback updates the spinner text)
12
+ Clack.tasks(tasks: [
13
+ {
14
+ title: "Checking dependencies",
15
+ task: -> {
16
+ sleep 1
17
+ }
18
+ },
19
+ {
20
+ title: "Installing packages",
21
+ task: ->(message) {
22
+ sleep 0.5
23
+ message.call("Installing packages... (1/3) core")
24
+ sleep 0.5
25
+ message.call("Installing packages... (2/3) dev tools")
26
+ sleep 0.5
27
+ message.call("Installing packages... (3/3) plugins")
28
+ sleep 0.5
29
+ }
30
+ },
31
+ {
32
+ title: "Compiling assets",
33
+ task: -> {
34
+ sleep 1.5
35
+ }
36
+ }
37
+ ])
38
+
39
+ # Task log with streaming output
40
+ tl = Clack.task_log(title: "Running test suite", limit: 5)
41
+
42
+ test_files = %w[
43
+ models/user_test.rb
44
+ models/post_test.rb
45
+ controllers/auth_test.rb
46
+ controllers/api_test.rb
47
+ helpers/format_test.rb
48
+ integration/signup_test.rb
49
+ integration/login_test.rb
50
+ ]
51
+
52
+ test_files.each do |file|
53
+ tl.message "Running #{file}..."
54
+ sleep 0.3
55
+ end
56
+
57
+ tl.success "All #{test_files.length} test files passed!"
58
+
59
+ Clack.outro "Done!"
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Run with: ruby examples/validation.rb
5
+
6
+ require_relative "../lib/clack" # Or: require "clack" if installed as a gem
7
+
8
+ Clack.intro "validation-demo"
9
+
10
+ # Required field
11
+ name = Clack.text(
12
+ message: "Username (required)",
13
+ validate: ->(v) { "Username is required" if v.to_s.strip.empty? }
14
+ )
15
+ exit 0 if Clack.cancel?(name)
16
+
17
+ # Length validation
18
+ password = Clack.password(
19
+ message: "Password (min 8 chars)",
20
+ validate: ->(v) { "Password must be at least 8 characters" if v.to_s.length < 8 }
21
+ )
22
+ exit 0 if Clack.cancel?(password)
23
+
24
+ # Format validation
25
+ email = Clack.text(
26
+ message: "Email address",
27
+ validate: lambda { |v|
28
+ return "Email is required" if v.to_s.strip.empty?
29
+ return "Invalid email format" unless v.to_s.include?("@")
30
+
31
+ nil
32
+ }
33
+ )
34
+ exit 0 if Clack.cancel?(email)
35
+
36
+ # Phone number with validation and custom transform
37
+ phone = Clack.text(
38
+ message: "Phone number",
39
+ validate: ->(v) { "Enter 10 digits" unless v.gsub(/\D/, "").length == 10 },
40
+ transform: ->(v) {
41
+ digits = v.gsub(/\D/, "")
42
+ "(#{digits[0, 3]}) #{digits[3, 3]}-#{digits[6, 4]}"
43
+ }
44
+ )
45
+ exit 0 if Clack.cancel?(phone)
46
+
47
+ # Username with transform (symbol shortcuts + custom)
48
+ handle = Clack.text(
49
+ message: "Twitter handle",
50
+ transform: Clack::Transformers.chain(:strip, :downcase, ->(v) { v.delete_prefix("@") })
51
+ )
52
+ exit 0 if Clack.cancel?(handle)
53
+
54
+ # Multiselect required
55
+ features = Clack.multiselect(
56
+ message: "Select at least one feature",
57
+ options: [
58
+ {value: "a", label: "Feature A"},
59
+ {value: "b", label: "Feature B"},
60
+ {value: "c", label: "Feature C"}
61
+ ],
62
+ required: true # Built-in validation
63
+ )
64
+ exit 0 if Clack.cancel?(features)
65
+
66
+ Clack.log.success "All validations passed!"
67
+ Clack.log.info "Username: #{name}"
68
+ Clack.log.info "Email: #{email}"
69
+ Clack.log.info "Phone: #{phone}"
70
+ Clack.log.info "Handle: @#{handle}"
71
+ Clack.log.info "Features: #{features.join(", ")}"
72
+
73
+ Clack.outro "Done!"
data/lib/clack/colors.rb CHANGED
@@ -15,34 +15,128 @@ module Clack
15
15
  $stdout.tty?
16
16
  end
17
17
 
18
- # Foreground colors (standard)
18
+ # @!group Foreground Colors (standard)
19
+
20
+ # Apply gray foreground color (ANSI 90).
21
+ # @param text [#to_s] text to colorize
22
+ # @return [String] ANSI-wrapped text
19
23
  def gray(text) = wrap(text, "90")
24
+
25
+ # Apply cyan foreground color (ANSI 36).
26
+ # @param text [#to_s] text to colorize
27
+ # @return [String] ANSI-wrapped text
20
28
  def cyan(text) = wrap(text, "36")
29
+
30
+ # Apply green foreground color (ANSI 32).
31
+ # @param text [#to_s] text to colorize
32
+ # @return [String] ANSI-wrapped text
21
33
  def green(text) = wrap(text, "32")
34
+
35
+ # Apply yellow foreground color (ANSI 33).
36
+ # @param text [#to_s] text to colorize
37
+ # @return [String] ANSI-wrapped text
22
38
  def yellow(text) = wrap(text, "33")
39
+
40
+ # Apply red foreground color (ANSI 31).
41
+ # @param text [#to_s] text to colorize
42
+ # @return [String] ANSI-wrapped text
23
43
  def red(text) = wrap(text, "31")
44
+
45
+ # Apply blue foreground color (ANSI 34).
46
+ # @param text [#to_s] text to colorize
47
+ # @return [String] ANSI-wrapped text
24
48
  def blue(text) = wrap(text, "34")
49
+
50
+ # Apply magenta foreground color (ANSI 35).
51
+ # @param text [#to_s] text to colorize
52
+ # @return [String] ANSI-wrapped text
25
53
  def magenta(text) = wrap(text, "35")
54
+
55
+ # Apply white foreground color (ANSI 37).
56
+ # @param text [#to_s] text to colorize
57
+ # @return [String] ANSI-wrapped text
26
58
  def white(text) = wrap(text, "37")
27
59
 
28
- # Text styles
60
+ # @!endgroup
61
+
62
+ # @!group Text Styles
63
+
64
+ # Apply dim/faint style (ANSI 2).
65
+ # @param text [#to_s] text to style
66
+ # @return [String] ANSI-wrapped text
29
67
  def dim(text) = wrap(text, "2")
68
+
69
+ # Apply bold style (ANSI 1).
70
+ # @param text [#to_s] text to style
71
+ # @return [String] ANSI-wrapped text
30
72
  def bold(text) = wrap(text, "1")
73
+
74
+ # Apply italic style (ANSI 3).
75
+ # @param text [#to_s] text to style
76
+ # @return [String] ANSI-wrapped text
31
77
  def italic(text) = wrap(text, "3")
78
+
79
+ # Apply underline style (ANSI 4).
80
+ # @param text [#to_s] text to style
81
+ # @return [String] ANSI-wrapped text
32
82
  def underline(text) = wrap(text, "4")
83
+
84
+ # Apply inverse/reverse video style (ANSI 7).
85
+ # @param text [#to_s] text to style
86
+ # @return [String] ANSI-wrapped text
33
87
  def inverse(text) = wrap(text, "7")
88
+
89
+ # Apply strikethrough style (ANSI 9).
90
+ # @param text [#to_s] text to style
91
+ # @return [String] ANSI-wrapped text
34
92
  def strikethrough(text) = wrap(text, "9")
93
+
94
+ # Apply hidden/invisible style (ANSI 8).
95
+ # @param text [#to_s] text to style
96
+ # @return [String] ANSI-wrapped text
35
97
  def hidden(text) = wrap(text, "8")
36
98
 
37
- # Bright/vivid foreground colors (higher contrast)
99
+ # @!endgroup
100
+
101
+ # @!group Bright Foreground Colors (high contrast)
102
+
103
+ # Apply bright cyan foreground color (ANSI 96).
104
+ # @param text [#to_s] text to colorize
105
+ # @return [String] ANSI-wrapped text
38
106
  def bright_cyan(text) = wrap(text, "96")
107
+
108
+ # Apply bright green foreground color (ANSI 92).
109
+ # @param text [#to_s] text to colorize
110
+ # @return [String] ANSI-wrapped text
39
111
  def bright_green(text) = wrap(text, "92")
112
+
113
+ # Apply bright yellow foreground color (ANSI 93).
114
+ # @param text [#to_s] text to colorize
115
+ # @return [String] ANSI-wrapped text
40
116
  def bright_yellow(text) = wrap(text, "93")
117
+
118
+ # Apply bright red foreground color (ANSI 91).
119
+ # @param text [#to_s] text to colorize
120
+ # @return [String] ANSI-wrapped text
41
121
  def bright_red(text) = wrap(text, "91")
122
+
123
+ # Apply bright blue foreground color (ANSI 94).
124
+ # @param text [#to_s] text to colorize
125
+ # @return [String] ANSI-wrapped text
42
126
  def bright_blue(text) = wrap(text, "94")
127
+
128
+ # Apply bright magenta foreground color (ANSI 95).
129
+ # @param text [#to_s] text to colorize
130
+ # @return [String] ANSI-wrapped text
43
131
  def bright_magenta(text) = wrap(text, "95")
132
+
133
+ # Apply bright white foreground color (ANSI 97).
134
+ # @param text [#to_s] text to colorize
135
+ # @return [String] ANSI-wrapped text
44
136
  def bright_white(text) = wrap(text, "97")
45
137
 
138
+ # @!endgroup
139
+
46
140
  private
47
141
 
48
142
  def wrap(text, code)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clack
4
+ # Core building blocks for prompt rendering and interaction.
4
5
  module Core
5
6
  # ANSI escape sequences for cursor control.
6
7
  # See: https://en.wikipedia.org/wiki/ANSI_escape_code
@@ -16,6 +16,11 @@ module Clack
16
16
  SEQUENCE_TIMEOUT = 0.01
17
17
 
18
18
  class << self
19
+ # Read a single keystroke from the terminal in raw mode.
20
+ # Handles multi-byte escape sequences (arrow keys, etc.).
21
+ #
22
+ # @return [String, nil] the key code, or nil on EOF
23
+ # @raise [IOError] if no console is available
19
24
  def read
20
25
  console = IO.console
21
26
  raise IOError, "No console available (not a TTY?)" unless console
@@ -41,6 +41,7 @@ module Clack
41
41
  @active_prompts << prompt
42
42
  end
43
43
 
44
+ # Unregister a prompt instance from resize notifications.
44
45
  def unregister(prompt)
45
46
  @active_prompts.delete(prompt)
46
47
  end
@@ -109,7 +110,7 @@ module Clack
109
110
 
110
111
  loop do
111
112
  key = KeyReader.read
112
- handle_key(key)
113
+ dispatch_key(key)
113
114
  render
114
115
 
115
116
  break if terminal_state?
@@ -124,17 +125,26 @@ module Clack
124
125
 
125
126
  protected
126
127
 
127
- # Process a keypress and update state accordingly.
128
- # Delegates to {#handle_input} for prompt-specific behavior.
128
+ # Dispatch a keypress, handling warning state before delegating to {#handle_key}.
129
+ #
130
+ # This method ensures ALL prompts participate in the warning validation flow,
131
+ # even those that override {#handle_key}. The run loop calls this instead of
132
+ # handle_key directly.
133
+ #
134
+ # Warning state behavior:
135
+ # - Enter confirms the warning and re-submits
136
+ # - Cancel (Escape/Ctrl+C) aborts from warning state
137
+ # - Any other input clears the warning and delegates to handle_key
138
+ #
139
+ # Also clears error state on any keypress, so subclasses don't need to
140
+ # manage error-to-active transitions themselves.
129
141
  #
130
142
  # @param key [String] the key code from {KeyReader}
131
- def handle_key(key)
143
+ def dispatch_key(key)
132
144
  return if terminal_state?
133
145
 
134
- action = Settings.action?(key)
135
-
136
- # Handle warning state: Enter confirms, Cancel aborts, other input clears warning
137
146
  if @state == :warning
147
+ action = Settings.action?(key)
138
148
  case action
139
149
  when :enter
140
150
  confirm_warning
@@ -143,13 +153,34 @@ module Clack
143
153
  @state = :cancel
144
154
  else
145
155
  clear_warning
146
- handle_input(key, action)
156
+ handle_key(key)
147
157
  end
148
158
  return
149
159
  end
150
160
 
151
161
  @state = :active if @state == :error
152
162
 
163
+ handle_key(key)
164
+ end
165
+
166
+ # Process a keypress and update state accordingly.
167
+ # Delegates to {#handle_input} for prompt-specific behavior.
168
+ #
169
+ # Override this in subclasses that need custom key dispatch (e.g., Select,
170
+ # Confirm, Autocomplete). The warning state and error-clearing are handled
171
+ # by {#dispatch_key} before this method is called, so overrides do not need
172
+ # to manage those transitions.
173
+ #
174
+ # For prompts that only need custom handling of printable/navigation input
175
+ # (not cancel/enter), override {#handle_input} instead. That is simpler and
176
+ # preserves the default cancel/enter behavior from this method.
177
+ #
178
+ # @param key [String] the key code from {KeyReader}
179
+ def handle_key(key)
180
+ return if terminal_state?
181
+
182
+ action = Settings.action?(key)
183
+
153
184
  case action
154
185
  when :cancel
155
186
  @state = :cancel
@@ -162,6 +193,11 @@ module Clack
162
193
 
163
194
  # Handle prompt-specific input. Override in subclasses.
164
195
  #
196
+ # This is the simplest extension point for prompts that only need to handle
197
+ # navigation keys and printable input. Cancel and Enter are handled by
198
+ # {#handle_key}, and warning/error state transitions are handled by
199
+ # {#dispatch_key}.
200
+ #
165
201
  # @param key [String] the raw key code
166
202
  # @param action [Symbol, nil] the mapped action (:up, :down, etc.) or nil
167
203
  def handle_input(key, action)
@@ -188,6 +224,14 @@ module Clack
188
224
  # Validate a value and return the resulting state.
189
225
  # Handles errors, warnings, and the warning confirmation flow.
190
226
  #
227
+ # Validation contract: the validate proc receives the current value and returns:
228
+ # - nil or false: validation passes, prompt submits
229
+ # - String: hard error, prompt enters :error state and displays the message
230
+ # - Exception: hard error, the exception's message is displayed
231
+ # - Clack::Warning: soft warning, prompt enters :warning state. The user can
232
+ # press Enter to confirm and submit, or edit their input to clear the warning.
233
+ # - Any other truthy value: treated as an error (to_s is called for the message)
234
+ #
191
235
  # @param value [Object] the value to validate
192
236
  # @return [Symbol] :submit, :warning, or :error
193
237
  def validate_value(value)
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Clack
4
4
  module Core
5
+ # Global configuration for key bindings, guide bar display, and input classification.
5
6
  module Settings
6
7
  # Navigation and control actions
7
8
  ACTIONS = %i[up down left right space enter cancel].freeze
@@ -78,6 +79,9 @@ module Clack
78
79
  @config_mutex.synchronize { @config[:with_guide] }
79
80
  end
80
81
 
82
+ # Look up the action mapped to a key code.
83
+ # @param key [String] key code from {KeyReader}
84
+ # @return [Symbol, nil] the action (:up, :down, :enter, etc.) or nil
81
85
  def action?(key)
82
86
  aliases = @config_mutex.synchronize { @config[:aliases] }
83
87
  aliases[key] if ACTIONS.include?(aliases[key])
@@ -25,10 +25,14 @@ module Clack
25
25
  format_placeholder_with_cursor(text)
26
26
  end
27
27
 
28
+ # @return [String, nil] the placeholder text, or nil if none set
28
29
  def current_placeholder
29
30
  defined?(@placeholder) ? @placeholder : nil
30
31
  end
31
32
 
33
+ # Render placeholder text with an inverse cursor on the first character.
34
+ # @param text [String] placeholder text to format
35
+ # @return [String] formatted placeholder with cursor highlight
32
36
  def format_placeholder_with_cursor(text)
33
37
  chars = text.grapheme_clusters
34
38
  first = chars.first || ""