prompts 0.1.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e372e3b7b11291f493908b8b18d2430e9b49d11d9807e4db768326717a1890c4
4
- data.tar.gz: accd4c59fc36421be3561c18ad42f45f65d521fe4c21195a7e886e68a1be3e8b
3
+ metadata.gz: 40fa96bdb16258c1e9612971eeea11a1757e82cdbc68e6084000c26c96f2d86c
4
+ data.tar.gz: b7a7daee831ca3473aa043bfe0b1f94aff8f12a9aa8e2fa4acdb6a4a75fb0634
5
5
  SHA512:
6
- metadata.gz: 10e4d017de9d2854bd059427db91c193fd1b1c5c7a9c6a901552150bdfa82934469ae00bd0397de49db715e53de0dae1208f65692f17219d389884bdcddb9d1b
7
- data.tar.gz: df6f62ffb22cdd98c29e52b55c5ab02fc2e468828ebea09a41e926f3c800a62a1994e59982ad4ff1a98d1305ea50a0b7fe5bdcd315952996660c5c128d01b99c
6
+ metadata.gz: fb8c9184e577f2e7b9f6c003407e028ee1613db35ee288c0ac914d5a8464f3c2135e22d4e280aab7233aa52418f60fed3ca6cf05c37d4c6907ef5937099f1445
7
+ data.tar.gz: cd5e4cb5512224d68c825b28f67398f5366d0d8a5a1026523c3cc431b44efea1ddef5fdb969832af49b2158dc1bb3e94b6a98cb8781c9422c324f02ebbce16c3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.1] - 2024-08-22
4
+
5
+ - Add `Form.submit` method
6
+ - All passing keyword arguments to `Form` prompt methods
7
+ - Return the key of the options hash when using `SelectPrompt`
8
+
9
+ ## [0.2.0] - 2024-08-20
10
+
11
+ - Add `TextPrompt`
12
+ - Add `SelectPrompt`
13
+ - Add `ConfirmPrompt`
14
+ - Add `PausePrompt`
15
+ - Add `Form`
16
+
3
17
  ## [0.1.0] - 2024-08-07
4
18
 
5
19
  - Initial release
data/README.md CHANGED
@@ -1,24 +1,286 @@
1
1
  # Prompts
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ Prompts helps you to add beautiful and user-friendly forms to your command-line applications, with browser-like features including label text, help text, validation, and inline errors.
4
4
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/prompts`. To experiment with that code, run `bin/console` for an interactive prompt.
5
+ It was originally inspired by the [Laravel Prompts](https://laravel.com/docs/11.x/prompts) package.
6
6
 
7
7
  ## Installation
8
8
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
-
11
9
  Install the gem and add to the application's Gemfile by executing:
12
10
 
13
- $ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
11
+ ```bash
12
+ bundle add prompts
13
+ ```
14
14
 
15
15
  If bundler is not being used to manage dependencies, install the gem by executing:
16
16
 
17
- $ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
17
+ ```bash
18
+ gem install prompts
19
+ ```
20
+
21
+ ## Philosophy
22
+
23
+ Prompts aims to provide beautiful and user-friendly forms for your command-line applications while keeping both the API and the implementation simple. This means Prompts is built with constraints in mind.
24
+
25
+ In order to minimize complexity, we build on top of the excellent [`reline`](https://github.com/ruby/reline) gem to provide a full-featured text input. Similarly, the text input is **always** rendered at the bottom of the screen. And inputing text is the **only** way to interact with the form.
26
+
27
+ In this way, this new library is similar to [Charm's Huh library](https://github.com/charmbracelet/huh) when used in "accessible" mode. But, with some UX improvements added to this interaction. Instead of simply appending new fields to the screen, Prompts' forms act like wizards 🧙‍♂️. Each field gets its own screen, and on each render loop, the screen is reset and repainted.
28
+
29
+ Finally, to keep internals simple, Prompts expects users to build and provide their own ANSI-formatted strings. However, we do make available the [`fmt`](https://github.com/hopsoft/fmt) gem as the recommended way to generate well formatted ANSI strings.
30
+
31
+ ## Available Prompts
32
+
33
+ ### Text
34
+
35
+ A `Text` prompt will prompt the user with the given question, accept their input, and then return it:
36
+
37
+ ```ruby
38
+ name = Prompts::TextPrompt.ask(label: "What is your name?")
39
+ ```
40
+
41
+ which generates a terminal screen like this (this representation doesn't show color):
42
+ <pre>
43
+
44
+ <b>What is your name?</b> <em>(Press Enter to submit)</em>
45
+ <b>Type your response and press Enter ⏎</b>
46
+
47
+ <b>></b> |
48
+ </pre>
49
+
50
+ You may also include a default value and an informational hint:
51
+
52
+ ```ruby
53
+ name = Prompts::TextPrompt.ask(
54
+ label: "What is your name?",
55
+ default: "John Doe",
56
+ hint: "This will be displayed on your profile."
57
+ )
58
+ ```
59
+
60
+ which generates a terminal screen like this (this representation doesn't show color):
61
+ <pre>
62
+
63
+ <b>What is your name?</b> <em>(Press Enter to submit)</em>
64
+ <b>This will be displayed on your profile.</b>
65
+
66
+ <b>></b> John Doe|
67
+ </pre>
68
+
69
+ #### Required values
70
+
71
+ If you require a value to be entered, you may pass the `required` argument:
72
+
73
+ ```ruby
74
+ name = Prompts::TextPrompt.ask(
75
+ label: "What is your name?",
76
+ required: true
77
+ )
78
+ ```
79
+
80
+ If you would like to customize the validation message, you may also pass a string:
81
+
82
+ ```ruby
83
+ name = Prompts::TextPrompt.ask(
84
+ label: "What is your name?",
85
+ required: "Your name is required."
86
+ )
87
+ ```
88
+
89
+ #### Additional Validation
90
+
91
+ Finally, if you would like to perform additional validation logic, you may pass a block/proc to the validate argument:
92
+
93
+ ```ruby
94
+ name = Prompts::TextPrompt.ask(
95
+ label: "What is your name?",
96
+ validate: ->(value) do
97
+ if value.length < 3
98
+ "The name must be at least 3 characters."
99
+ elsif value.length > 255
100
+ "The name must not exceed 255 characters."
101
+ end
102
+ end
103
+ )
104
+ ```
105
+
106
+ The block will receive the value that has been entered and may return an error message, or `nil` if the validation passes.
107
+
108
+ ### Select
109
+
110
+ If you need the user to select from a predefined set of choices, you may use the `Select` prompt:
111
+
112
+ ```ruby
113
+ role = Prompts::SelectPrompt.ask(
114
+ label: "What role should the user have?",
115
+ options: ["Member", "Contributor", "Owner"]
116
+ )
117
+ ```
118
+
119
+ which generates a terminal screen like this (this representation doesn't show color):
120
+ <pre>
121
+
122
+ <b>What role should the user have?</b> <em>(Enter the number of your choice)</em>
123
+ <b>Type your response and press Enter ⏎</b>
124
+ <b>1.</b> Member
125
+ <b>2.</b> Contributor
126
+ <b>3.</b> Owner
127
+
128
+ <b>></b> |
129
+ </pre>
130
+
131
+ You may also include a default value and an informational hint:
132
+
133
+ ```ruby
134
+ role = Prompts::SelectPrompt.ask(
135
+ label: "What role should the user have?",
136
+ options: ["Member", "Contributor", "Owner"],
137
+ default: "Owner",
138
+ hint: "The role may be changed at any time."
139
+ )
140
+ ```
141
+
142
+ which generates a terminal screen like this (this representation doesn't show color):
143
+ <pre>
144
+
145
+ <b>What role should the user have?</b> <em>(Enter the number of your choice)</em>
146
+ <b>The role may be changed at any time.</b>
147
+ <b>1.</b> Member
148
+ <b>2.</b> Contributor
149
+ <b>3.</b> Owner
150
+
151
+ <b>></b> 3|
152
+ </pre>
153
+
154
+ You may also pass a hash to the `options` argument to have the selected key returned instead of its value:
155
+
156
+ ```ruby
157
+ role = Prompts::SelectPrompt.ask(
158
+ label: "What role should the user have?",
159
+ options: {
160
+ member: "Member",
161
+ contributor: "Contributor",
162
+ owner: "Owner",
163
+ },
164
+ default: "owner"
165
+ )
166
+ ```
167
+
168
+ #### Additional Validation
169
+
170
+ Unlike other prompt classes, the `SelectPrompt` doesn't accept the `required` argument because it is not possible to select nothing. However, you may pass a block/proc to the `validate` argument if you need to present an option but prevent it from being selected:
171
+
172
+ ```ruby
173
+ role = Prompts::SelectPrompt.ask(
174
+ label: "What role should the user have?",
175
+ options: {
176
+ member: "Member",
177
+ contributor: "Contributor",
178
+ owner: "Owner",
179
+ },
180
+ validate: ->(value) do
181
+ if value == "owner" && User.where(role: "owner").exists?
182
+ "An owner already exists."
183
+ end
184
+ end
185
+ )
186
+ ```
187
+
188
+ If the `options` argument is a hash, then the block will receive the selected key, otherwise it will receive the selected value. The block may return an error message, or `nil` if the validation passes.
189
+
190
+ ### Confirm
191
+
192
+ If you need to ask the user for a "yes or no" confirmation, you may use the `ConfirmPrompt`. Users may press `y` or `n` (or `Y` or `N`) to select their response. This function will return either `true` or `false`.
193
+
194
+ ```ruby
195
+ confirmed = Prompts::ConfirmPrompt.ask(label: "Do you accept the terms?")
196
+ ```
197
+
198
+ which generates a terminal screen like this (this representation doesn't show color):
199
+ <pre>
200
+
201
+ <b>Do you accept the terms?</b> <em>(Press Enter to submit)</em>
202
+
203
+ <b>Choose [y/n]:</b> |
204
+ </pre>
205
+
206
+ You may also include a default value and an informational hint:
207
+
208
+ ```ruby
209
+ confirmed = Prompts::ConfirmPrompt.ask(
210
+ label: "Do you accept the terms?",
211
+ default: false,
212
+ hint: "The terms must be accepted to continue.",
213
+ )
214
+ ```
215
+
216
+ which generates a terminal screen like this (this representation doesn't show color):
217
+ <pre>
218
+
219
+ <b>Do you accept the terms?</b> <em>(Press Enter to submit)</em>
220
+ <b>The terms must be accepted to continue.</b>
221
+
222
+ <b>Choose [y/N]:</b> |
223
+ </pre>
224
+
225
+ #### Requiring "Yes"
226
+
227
+ If necessary, you may require your users to select "Yes" by passing the `required` argument:
228
+
229
+ ```ruby
230
+ confirmed = Prompts::ConfirmPrompt.ask(
231
+ label: "Do you accept the terms?",
232
+ required: true
233
+ )
234
+ ```
235
+
236
+ If you would like to customize the validation message, you may also pass a string:
237
+
238
+ ```ruby
239
+ confirmed = Prompts::ConfirmPrompt.ask(
240
+ label: "Do you accept the terms?",
241
+ required: "You must accept the terms to continue."
242
+ )
243
+ ```
244
+
245
+ ### Pause
246
+
247
+ The `PausePrompt` may be used to display informational text to the user and wait for them to confirm their desire to proceed by pressing the Enter / Return key:
248
+
249
+ ```ruby
250
+ Prompts::PausePrompt.ask
251
+ ```
252
+
253
+ which generates a terminal screen like this (this representation doesn't show color):
254
+ <pre>
255
+
256
+ <b>Press Enter ⏎ to continue...</b> |
257
+ </pre>
258
+
259
+ ## Forms
260
+
261
+ Often, you will have multiple prompts that will be displayed in sequence to collect information before performing additional actions. You may use the `Prompts::Form` class to create a grouped set of prompts for the user to complete:
18
262
 
19
- ## Usage
263
+ ```ruby
264
+ responses = Prompts::Form.submit do |form|
265
+ form.text(
266
+ label: "What is your name?",
267
+ required: true
268
+ )
269
+ form.select(
270
+ label: "What role should the user have?",
271
+ options: {
272
+ member: "Member",
273
+ contributor: "Contributor",
274
+ owner: "Owner",
275
+ }
276
+ )
277
+ form.confirm(
278
+ label: 'Do you accept the terms?'
279
+ )
280
+ end
281
+ ```
20
282
 
21
- TODO: Write usage instructions here
283
+ The `submit` method will return an array containing all of the responses from the form's prompts.
22
284
 
23
285
  ## Development
24
286
 
@@ -28,7 +290,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
28
290
 
29
291
  ## Contributing
30
292
 
31
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/prompts. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/prompts/blob/main/CODE_OF_CONDUCT.md).
293
+ Bug reports and pull requests are welcome on GitHub at https://github.com/fractaledmind/prompts. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/fractaledmind/prompts/blob/main/CODE_OF_CONDUCT.md).
32
294
 
33
295
  ## License
34
296
 
@@ -36,4 +298,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
36
298
 
37
299
  ## Code of Conduct
38
300
 
39
- Everyone interacting in the Prompts project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/prompts/blob/main/CODE_OF_CONDUCT.md).
301
+ Everyone interacting in the Prompts project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/fractaledmind/prompts/blob/main/CODE_OF_CONDUCT.md).
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prompts
4
+ class Box
5
+ include TextUtils
6
+
7
+ SOLID_BORDER = {top_left: "┌", top_right: "┐", bottom_left: "└", bottom_right: "┘", horizontal: "─", vertical: "│"}.freeze
8
+ DOUBLE_BORDER = {top_left: "╔", top_right: "╗", bottom_left: "╚", bottom_right: "╝", horizontal: "═", vertical: "║"}.freeze
9
+ HEAVY_BORDER = {top_left: "┏", top_right: "┓", bottom_left: "┗", bottom_right: "┛", horizontal: "━", vertical: "┃"}.freeze
10
+ ROUNDED_BORDER = {top_left: "╭", top_right: "╮", bottom_left: "╰", bottom_right: "╯", horizontal: "─", vertical: "│"}.freeze
11
+
12
+ def initialize(width: MAX_WIDTH, padded: false, border_color: nil, border_style: :rounded)
13
+ @width = width
14
+ @padded = padded
15
+ @border_color = border_color
16
+ @line_padding = SPACE * 1
17
+ @border_parts = case border_style
18
+ when :solid then SOLID_BORDER
19
+ when :double then DOUBLE_BORDER
20
+ when :heavy then HEAVY_BORDER
21
+ else ROUNDED_BORDER
22
+ end
23
+ @content = []
24
+ end
25
+
26
+ def centered(text)
27
+ @content.concat align(text, :center)
28
+ end
29
+
30
+ def left(text)
31
+ @content.concat align(text, :left)
32
+ end
33
+
34
+ def right(text)
35
+ @content.concat align(text, :right)
36
+ end
37
+
38
+ def gap
39
+ @content.concat align(EMPTY, :center)
40
+ end
41
+
42
+ def lines
43
+ [].tap do |output|
44
+ output << top_border
45
+ align(EMPTY, :center).each { |line| output << @line_padding + line } if @padded
46
+ @content.each do |line|
47
+ output << @line_padding + line
48
+ end
49
+ align(EMPTY, :center).each { |line| output << @line_padding + line } if @padded
50
+ output << bottom_border
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def top_border
57
+ border = @border_parts[:top_left] + @border_parts[:horizontal] * (@width - 2) + @border_parts[:top_right]
58
+ Fmt("#{@line_padding}%{border}#{@border_color}", border: border)
59
+ end
60
+
61
+ def bottom_border
62
+ border = @border_parts[:bottom_left] + @border_parts[:horizontal] * (@width - 2) + @border_parts[:bottom_right]
63
+ Fmt("#{@line_padding}%{border}#{@border_color}", border: border)
64
+ end
65
+
66
+ def align(text, alignment, between: @border_parts[:vertical])
67
+ formatted_boundary = Fmt("%{boundary}#{@border_color}", boundary: between)
68
+ wrap_text(text, width: @width, line_prefix: formatted_boundary + SPACE, line_suffix: SPACE + formatted_boundary, alignment: alignment)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prompts
4
+ class ConfirmPrompt < Prompt
5
+ def initialize(...)
6
+ super
7
+
8
+ @prompt = if @default == false
9
+ "Choose [y/N]:"
10
+ elsif @default == true
11
+ "Choose [Y/n]:"
12
+ else
13
+ "Choose [y/n]:"
14
+ end
15
+ @default_boolean = @default
16
+ @default = nil
17
+ @instructions = "Press Enter to submit"
18
+ @validations << ->(choice) { "Invalid choice." if !["y", "n", "Y", "N", ""].include?(choice) }
19
+ end
20
+
21
+ private
22
+
23
+ def resolve_choice_from(response)
24
+ case response
25
+ when "y", "Y" then true
26
+ when "n", "N" then false
27
+ when "" then @default_boolean
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: false
2
+
3
+ module Prompts
4
+ class Content
5
+ attr_reader :slots
6
+
7
+ def initialize(width: MAX_WIDTH)
8
+ @slots = []
9
+ @frame_stack = []
10
+ @width = width
11
+ end
12
+
13
+ def paragraph(text)
14
+ paragraph = Paragraph.new(text, width: @width)
15
+ @slots.concat paragraph.lines
16
+ self
17
+ end
18
+
19
+ def gap
20
+ @slots << SPACE
21
+ self
22
+ end
23
+
24
+ def box(padded: false, border_color: nil, &block)
25
+ box = Box.new(width: @width, padded: padded, border_color: border_color)
26
+ yield(box)
27
+ @slots.concat box.lines
28
+ self
29
+ end
30
+
31
+ def render
32
+ clear_screen
33
+ render_frame
34
+ end
35
+
36
+ def reset!
37
+ @slots = @frame_stack.first.dup
38
+ end
39
+
40
+ def prepend(*lines)
41
+ @slots.unshift(*lines)
42
+ end
43
+
44
+ private
45
+
46
+ def clear_screen
47
+ jump_cursor_to_top
48
+ erase_down
49
+ end
50
+
51
+ def render_frame
52
+ @frame_stack << @slots.dup
53
+ OUTPUT.puts SPACE
54
+
55
+ return if @slots.empty?
56
+
57
+ OUTPUT.puts @slots.join("\n")
58
+ OUTPUT.puts SPACE
59
+ @slots.clear
60
+ end
61
+
62
+ def jump_cursor_to_top
63
+ OUTPUT.print "\033[H"
64
+ end
65
+
66
+ def erase_down
67
+ OUTPUT.print "\e[J"
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prompts
4
+ class Form
5
+ def self.submit(&block)
6
+ instance = new()
7
+ yield instance if block_given?
8
+ instance.submit
9
+ end
10
+
11
+ def initialize
12
+ @content = Prompts::Content.new
13
+ @prompts = []
14
+ @results = []
15
+ end
16
+
17
+ def content(&block)
18
+ yield @content
19
+ @content
20
+ end
21
+
22
+ def text(label: nil, prompt: "> ", hint: nil, default: nil, required: false, validate: nil, &block)
23
+ prompt = TextPrompt.new(label: label, prompt: prompt, hint: hint, default: default, required: required, validate: validate)
24
+ yield(prompt) if block_given?
25
+ prepend_form_content_to_prompt(prompt)
26
+ @prompts << prompt
27
+ end
28
+
29
+ def select(label: nil, options: nil, prompt: "> ", hint: nil, default: nil, validate: nil, &block)
30
+ prompt = SelectPrompt.new(label: label, options: options, prompt: prompt, hint: hint, default: default, validate: validate)
31
+ yield(prompt) if block_given?
32
+ prepend_form_content_to_prompt(prompt)
33
+ @prompts << prompt
34
+ end
35
+
36
+ def pause(label: nil, prompt: "> ", hint: nil, default: nil, required: false, validate: nil, &block)
37
+ prompt = PausePrompt.new(label: label, prompt: prompt, hint: hint, default: default, required: required, validate: validate)
38
+ yield(prompt) if block_given?
39
+ prepend_form_content_to_prompt(prompt)
40
+ @prompts << prompt
41
+ end
42
+
43
+ def confirm(label: nil, prompt: "> ", hint: nil, default: nil, required: false, validate: nil, &block)
44
+ prompt = ConfirmPrompt.new(label: label, prompt: prompt, hint: hint, default: default, required: required, validate: validate)
45
+ yield(prompt) if block_given?
46
+ prepend_form_content_to_prompt(prompt)
47
+ @prompts << prompt
48
+ end
49
+
50
+ def submit
51
+ @prompts.each do |prompt|
52
+ @results << prompt.ask
53
+ end
54
+ @results
55
+ end
56
+
57
+ private
58
+
59
+ def prepend_form_content_to_prompt(prompt)
60
+ prompt.prepare_content
61
+ @content.gap
62
+ prompt.prepend_content(*@content.slots)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prompts
4
+ class Paragraph
5
+ include TextUtils
6
+
7
+ LINE_PADDING = 3
8
+
9
+ def initialize(text, width: 60)
10
+ @text = text
11
+ @width = width - (LINE_PADDING + 1)
12
+ @line_padding = SPACE * LINE_PADDING
13
+ end
14
+
15
+ def lines
16
+ wrap_text(@text, width: @width, line_prefix: @line_padding, alignment: :none)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prompts
4
+ class PausePrompt < Prompt
5
+ def initialize(...)
6
+ super
7
+
8
+ @prompt = "Press Enter ⏎ to continue..."
9
+ end
10
+
11
+ def resolve_choice_from(response)
12
+ true
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+
5
+ module Prompts
6
+ class Prompt
7
+ def self.ask(label: nil, prompt: "> ", hint: nil, default: nil, required: false, validate: nil)
8
+ instance = new(label: label, prompt: prompt, hint: hint, default: default, required: required, validate: validate)
9
+ yield instance if block_given?
10
+ instance.ask
11
+ end
12
+
13
+ def initialize(label: nil, prompt: "> ", hint: nil, default: nil, required: false, validate: nil)
14
+ @label = label
15
+ @prompt = prompt
16
+ @hint = hint
17
+ @default = default
18
+ @required = required
19
+ @validate = validate
20
+
21
+ @content = nil
22
+ @error = nil
23
+ @attempts = 0
24
+ @instructions = nil
25
+ @validations = []
26
+ @choice = nil
27
+ @content_prepared = false
28
+ end
29
+
30
+ def content(&block)
31
+ @content ||= Prompts::Content.new
32
+ yield @content
33
+ @content
34
+ end
35
+
36
+ # standard:disable Style/TrivialAccessors
37
+ def label(label)
38
+ @label = label
39
+ end
40
+
41
+ def hint(hint)
42
+ @hint = hint
43
+ end
44
+
45
+ def default(default)
46
+ @default = default
47
+ end
48
+ # standard:enable Style/TrivialAccessors
49
+
50
+ def ask
51
+ prepare_content if !@content_prepared
52
+ prepare_default if @default
53
+ prepare_validations
54
+
55
+ loop do
56
+ @content.render
57
+ *initial_prompt_lines, last_prompt_line = formatted_prompt
58
+ puts initial_prompt_lines.join("\n") if initial_prompt_lines.any?
59
+ response = Reline.readline(last_prompt_line, _history = false).chomp
60
+ @choice = resolve_choice_from(response)
61
+
62
+ if (@error = ensure_validity(response))
63
+ @content.reset!
64
+ @content.paragraph Fmt("%{error}red|bold", error: @error + " Try again (×#{@attempts})...")
65
+ @attempts += 1
66
+ next
67
+ else
68
+ break @choice
69
+ end
70
+ end
71
+
72
+ @choice
73
+ rescue Interrupt
74
+ exit 0
75
+ end
76
+
77
+ def prepend_content(*lines)
78
+ @content.prepend(*lines)
79
+ end
80
+
81
+ def prepare_content
82
+ @content ||= Prompts::Content.new
83
+ @content.paragraph formatted_label if @label
84
+ @content.paragraph formatted_hint if @hint
85
+ @content.paragraph formatted_error if @error
86
+ @content_prepared = true
87
+ @content
88
+ end
89
+
90
+ private
91
+
92
+ def prepare_default
93
+ Reline.pre_input_hook = -> do
94
+ Reline.insert_text @default.to_s
95
+ # Remove the hook right away.
96
+ Reline.pre_input_hook = nil
97
+ end
98
+ end
99
+
100
+ def prepare_validations
101
+ if @required
102
+ error_message = @required.is_a?(String) ? @required : "Value cannot be empty."
103
+ @validations << ->(input) { error_message if input.empty? }
104
+ end
105
+
106
+ if @validate
107
+ @validations << @validate
108
+ end
109
+ end
110
+
111
+ def resolve_choice_from(response)
112
+ response
113
+ end
114
+
115
+ def formatted_prompt
116
+ prompt_with_space = @prompt.end_with?(SPACE) ? @prompt : @prompt + SPACE
117
+ ansi_prompt = Fmt("%{prompt}faint|bold", prompt: prompt_with_space)
118
+ @formatted_prompt ||= Paragraph.new(ansi_prompt, width: MAX_WIDTH).lines
119
+ end
120
+
121
+ def formatted_label
122
+ Fmt("%{label}cyan|bold %{instructions}faint|italic", label: @label, instructions: @instructions ? "(#{@instructions})" : "")
123
+ end
124
+
125
+ def formatted_hint
126
+ Fmt("%{hint}faint|bold", hint: @hint)
127
+ end
128
+
129
+ def formatted_error
130
+ Fmt("%{error}red|bold", error: @error + " Try again (×#{@attempts})...")
131
+ end
132
+
133
+ def ensure_validity(response)
134
+ @validations.each do |validation|
135
+ result = validation.call(response)
136
+ return result if result
137
+ end
138
+ nil
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prompts
4
+ class SelectPrompt < Prompt
5
+ def self.ask(options: nil, **kwargs)
6
+ instance = new(options: options, **kwargs)
7
+ yield instance if block_given?
8
+ instance.ask
9
+ end
10
+
11
+ def initialize(options: nil, **kwargs)
12
+ super(**kwargs)
13
+
14
+ @options = options.is_a?(Array) ? options.to_h { |item| [item, item] } : options
15
+ @default = if (index = @options.keys.index(@default))
16
+ index + 1
17
+ end
18
+ @instructions = "Enter the number of your choice"
19
+ @hint ||= "Type your response and press Enter ⏎"
20
+ @validations << ->(choice) { "Invalid choice." if !choice.to_i.between?(1, @options.size) }
21
+ end
22
+
23
+ # standard:disable Style/TrivialAccessors
24
+ def options(options)
25
+ @options = options
26
+ end
27
+ # standard:enable Style/TrivialAccessors
28
+
29
+ def prepare_content
30
+ super
31
+ @options.each_with_index do |(key, value), index|
32
+ @content.paragraph Fmt("%{prefix}faint|bold %{option}", prefix: "#{index + 1}.", option: value)
33
+ end
34
+ @content
35
+ end
36
+
37
+ private
38
+
39
+ def resolve_choice_from(response)
40
+ choice = response.to_i
41
+ key, _value = @options.to_a[choice - 1]
42
+ key
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prompts
4
+ class TextPrompt < Prompt
5
+ def initialize(...)
6
+ super
7
+
8
+ @instructions = "Press Enter to submit"
9
+ @hint ||= "Type your response and press Enter ⏎"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+ require "unicode/emoji"
5
+
6
+ module Prompts
7
+ module TextUtils
8
+ ANSI_REGEX = /\e\[[0-9;]*[a-zA-Z]/
9
+
10
+ def wrap_text(text, width:, line_prefix: EMPTY, line_suffix: EMPTY, alignment: :left)
11
+ words = text.scan(Regexp.union(/\S+/, ANSI_REGEX))
12
+ lines = []
13
+ line = +EMPTY
14
+ line_width = 0
15
+ prefix_width = Unicode::DisplayWidth.of(strip_ansi(line_prefix), 1, {}, emoji: true)
16
+ suffix_width = Unicode::DisplayWidth.of(strip_ansi(line_suffix), 1, {}, emoji: true)
17
+ available_width = width - prefix_width - suffix_width
18
+
19
+ words.each do |word|
20
+ word_width = Unicode::DisplayWidth.of(strip_ansi(word), 1, {}, emoji: true)
21
+
22
+ if (line_width + word_width) > available_width
23
+ lines << format_line(line.rstrip, available_width, alignment, line_prefix, line_suffix)
24
+ line = +EMPTY
25
+ line_width = 0
26
+ end
27
+
28
+ line << word + SPACE
29
+ line_width += word_width + 1
30
+ end
31
+
32
+ lines << format_line(line.rstrip, available_width, alignment, line_prefix, line_suffix)
33
+ lines
34
+ end
35
+
36
+ def format_line(line, available_width, alignment, prefix, suffix)
37
+ line_width = Unicode::DisplayWidth.of(strip_ansi(line), 1, {}, emoji: true)
38
+ padding = [available_width - line_width, 0].max
39
+
40
+ case alignment
41
+ when :none
42
+ prefix + line + suffix
43
+ when :left
44
+ prefix + line + (SPACE * padding) + suffix
45
+ when :right
46
+ prefix + (SPACE * padding) + line + suffix
47
+ when :center
48
+ left_padding = padding / 2
49
+ right_padding = padding - left_padding
50
+ prefix + (SPACE * left_padding) + line + (SPACE * right_padding) + suffix
51
+ end
52
+ end
53
+
54
+ def strip_ansi(text)
55
+ text.gsub(ANSI_REGEX, EMPTY)
56
+ end
57
+ end
58
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Prompts
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/prompts.rb CHANGED
@@ -1,8 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "io/console"
4
+ require "reline"
5
+ require "fmt"
6
+ require "rainbow"
7
+
8
+ Fmt.add_rainbow_filters
9
+
3
10
  require_relative "prompts/version"
11
+ require_relative "prompts/prompt"
12
+ require_relative "prompts/text_utils"
13
+ require_relative "prompts/content"
14
+ require_relative "prompts/paragraph"
15
+ require_relative "prompts/box"
16
+ require_relative "prompts/pause_prompt"
17
+ require_relative "prompts/confirm_prompt"
18
+ require_relative "prompts/text_prompt"
19
+ require_relative "prompts/select_prompt"
20
+ require_relative "prompts/form"
4
21
 
5
22
  module Prompts
23
+ EMPTY = ""
24
+ SPACE = " "
25
+ MAX_WIDTH = 80
26
+ OUTPUT = $stdout
27
+
6
28
  class Error < StandardError; end
7
- # Your code goes here...
29
+
30
+ class << self
31
+ def Form(&block)
32
+ form = Prompts::Form.new
33
+ yield(form)
34
+ form.start
35
+ end
36
+ end
8
37
  end
metadata CHANGED
@@ -1,15 +1,85 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prompts
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen Margheim
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-08-07 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2024-08-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: unicode-display_width
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: unicode-emoji
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: reline
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: fmt
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rainbow
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
13
83
  description: Inspired by the Laravel Prompts project
14
84
  email:
15
85
  - stephen.margheim@gmail.com
@@ -24,6 +94,16 @@ files:
24
94
  - README.md
25
95
  - Rakefile
26
96
  - lib/prompts.rb
97
+ - lib/prompts/box.rb
98
+ - lib/prompts/confirm_prompt.rb
99
+ - lib/prompts/content.rb
100
+ - lib/prompts/form.rb
101
+ - lib/prompts/paragraph.rb
102
+ - lib/prompts/pause_prompt.rb
103
+ - lib/prompts/prompt.rb
104
+ - lib/prompts/select_prompt.rb
105
+ - lib/prompts/text_prompt.rb
106
+ - lib/prompts/text_utils.rb
27
107
  - lib/prompts/version.rb
28
108
  - sig/prompts.rbs
29
109
  homepage: https://github.com/fractaledmind/prompts