asktty 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +107 -0
- data/lib/asktty/internal/ansi_style.rb +55 -0
- data/lib/asktty/internal/options.rb +34 -0
- data/lib/asktty/internal/rendering.rb +214 -0
- data/lib/asktty/internal/terminal.rb +112 -0
- data/lib/asktty/internal/text_input_prompt.rb +148 -0
- data/lib/asktty/internal/validation.rb +19 -0
- data/lib/asktty/prompts/confirm_prompt.rb +91 -0
- data/lib/asktty/prompts/input_prompt.rb +19 -0
- data/lib/asktty/prompts/multi_select_prompt.rb +122 -0
- data/lib/asktty/prompts/select_prompt.rb +101 -0
- data/lib/asktty/prompts/text_prompt.rb +19 -0
- data/lib/asktty/version.rb +5 -0
- data/lib/asktty.rb +18 -0
- metadata +71 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4918843985d1b7a0204a197a2e8499ca7493e171bc4b16307407bab000572c15
|
|
4
|
+
data.tar.gz: af5db2494b82ba83bddd0813d40a86dd50833276ba6a00e8df3d2b9b79d1a5e4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0fa70b12b633518b2ab3aac623dada69b67e337401c0d21d466126b0c6a24927921323941357ac81c259e16bc97c2eb75a9a03323af73aa4e0a17f10911bf300
|
|
7
|
+
data.tar.gz: 6fba726118b1e937822797c90b84cf840e69c70c77c1f0cc6a7ce772cc063e4ff242559157d031cae16592ddf1b150bafa5a3f35c8bf0d3348677591e6da4e94
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vlad Suchy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# AskTTY
|
|
2
|
+
|
|
3
|
+
> Note: AskTTY is still under development and has not been released to RubyGems yet.
|
|
4
|
+
|
|
5
|
+
A Ruby gem for interactive terminal prompts.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Install it globaly:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
gem install asktty
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
or, add it to your application gemfile:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
bundle add asktty
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "asktty"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Input Prompt
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
name = AskTTY::InputPrompt.ask(
|
|
31
|
+
title: "Name",
|
|
32
|
+
details: "Enter the name you want displayed on your badge.",
|
|
33
|
+
placeholder: "Vlad"
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Text Prompt
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
notes = AskTTY::TextPrompt.ask(
|
|
41
|
+
title: "Ruby Project",
|
|
42
|
+
placeholder: "AskTTY\nTerminal prompts for Ruby"
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Select Prompt
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
drink = AskTTY::SelectPrompt.ask(
|
|
50
|
+
title: "Experience Level",
|
|
51
|
+
options: [
|
|
52
|
+
{ label: "Beginner", value: :beginner },
|
|
53
|
+
{ label: "Intermediate", value: :intermediate },
|
|
54
|
+
{ label: "Advanced", value: :advanced }
|
|
55
|
+
],
|
|
56
|
+
value: :intermediate
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### MultiSelect Prompt
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
toppings = AskTTY::MultiSelectPrompt.ask(
|
|
64
|
+
title: "Topics",
|
|
65
|
+
details: "Select all topics you are interested in.",
|
|
66
|
+
options: [
|
|
67
|
+
{ label: "Rails", value: :rails },
|
|
68
|
+
{ label: "Metaprogramming", value: :metaprogramming },
|
|
69
|
+
{ label: "API development", value: :api_development },
|
|
70
|
+
{ label: "Background jobs", value: :background_jobs },
|
|
71
|
+
{ label: "Testing", value: :testing }
|
|
72
|
+
],
|
|
73
|
+
values: [:metaprogramming]
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Confirm Prompt
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
confirmed = AskTTY::ConfirmPrompt.ask(
|
|
81
|
+
title: "Confirmation",
|
|
82
|
+
details: "Confirm that you plan to attend the event.",
|
|
83
|
+
value: true
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Validation
|
|
88
|
+
|
|
89
|
+
Pass a block that returns `true` when the current value is valid, or an error message when it is not:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
name = AskTTY::InputPrompt.ask(title: "Name") do |value|
|
|
93
|
+
value.length >= 3 || "Name must be at least 3 characters"
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Examples
|
|
98
|
+
|
|
99
|
+
Run the interactive example from the project root:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
ruby examples/all_prompts.rb
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Credit
|
|
106
|
+
|
|
107
|
+
The prompt UI in AskTTY is based on [huh?](https://github.com/charmbracelet/huh).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AskTTY
|
|
4
|
+
module Internal
|
|
5
|
+
module ANSIStyle
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def style(text, foreground: nil, background: nil)
|
|
9
|
+
codes = []
|
|
10
|
+
codes << "38;5;#{foreground}" if foreground
|
|
11
|
+
codes << "48;5;#{background}" if background
|
|
12
|
+
|
|
13
|
+
return text.to_s if codes.empty?
|
|
14
|
+
|
|
15
|
+
"\e[#{codes.join(';')}m#{text}\e[0m"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def title(text)
|
|
19
|
+
style(text, foreground: 6)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def muted(text)
|
|
23
|
+
style(text, foreground: 8)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def prompt(text)
|
|
27
|
+
style(text, foreground: 3)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def text(text)
|
|
31
|
+
style(text, foreground: 7)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def selected(text)
|
|
35
|
+
style(text, foreground: 2)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def focused_button(text)
|
|
39
|
+
style(" #{text} ", foreground: 0, background: 2)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def blurred_button(text)
|
|
43
|
+
style(" #{text} ", foreground: 7, background: 0)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def cursor(text = " ")
|
|
47
|
+
style(text, foreground: 7, background: 2)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def error(text)
|
|
51
|
+
style(text, foreground: 9)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AskTTY
|
|
4
|
+
module Internal
|
|
5
|
+
module Options
|
|
6
|
+
Option = Struct.new(:label, :value)
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def normalize(options)
|
|
11
|
+
raise AskTTY::Error, "options must not be empty" if options.nil? || options.empty?
|
|
12
|
+
|
|
13
|
+
options.map do |option|
|
|
14
|
+
next option if option.is_a?(Option)
|
|
15
|
+
|
|
16
|
+
raise AskTTY::Error, "options must be hashes with label and value" unless option.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
label = option[:label] || option["label"]
|
|
19
|
+
has_value = option.key?(:value) || option.key?("value")
|
|
20
|
+
value = option[:value] if option.key?(:value)
|
|
21
|
+
value = option["value"] if option.key?("value")
|
|
22
|
+
|
|
23
|
+
raise AskTTY::Error, "options must include label and value" unless label && has_value
|
|
24
|
+
|
|
25
|
+
Option.new(label.to_s, value)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def values(options)
|
|
30
|
+
options.map(&:value)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unicode/display_width"
|
|
4
|
+
|
|
5
|
+
module AskTTY
|
|
6
|
+
module Internal
|
|
7
|
+
module Rendering
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def chop_grapheme(text)
|
|
11
|
+
graphemes(text.to_s)[0...-1].join
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def display_width(text)
|
|
15
|
+
Unicode::DisplayWidth.of(text.to_s.gsub(/\e\[[\d;]*m/, ""), ambiguous: 1)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def placeholder_with_cursor(text)
|
|
19
|
+
first_grapheme, *rest = graphemes(text.to_s)
|
|
20
|
+
return ANSIStyle.cursor unless first_grapheme
|
|
21
|
+
|
|
22
|
+
ANSIStyle.style(first_grapheme, foreground: 8, background: 2) + ANSIStyle.muted(rest.join)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def prompt_frame(title:, details:, help_items:, error_message:, width:, gap_before_body: false)
|
|
26
|
+
content_width = self.content_width(width)
|
|
27
|
+
|
|
28
|
+
lines = header_lines(title, details, width: content_width)
|
|
29
|
+
lines << "" if gap_before_body && !lines.empty?
|
|
30
|
+
lines.concat(Array(yield(content_width)))
|
|
31
|
+
lines.concat(
|
|
32
|
+
footer_lines(
|
|
33
|
+
error_message: error_message, help_line: help_line(help_items, width: content_width), width: content_width
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
frame(lines)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def submitted_frame(title, value, width:)
|
|
41
|
+
prefix = "#{title}:"
|
|
42
|
+
summary = value.to_s.empty? ? prefix : "#{prefix} #{value}"
|
|
43
|
+
lines = wrap(summary, content_width(width))
|
|
44
|
+
|
|
45
|
+
frame(style_submitted_lines(lines, title_length: graphemes(prefix).length))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def wrap(text, width)
|
|
49
|
+
split_lines(text.to_s).flat_map { |line| wrap_line(line, width) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def wrap_exact(text, width)
|
|
53
|
+
split_lines(text.to_s).flat_map { |line| wrap_exact_line(line, width) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def header_lines(title, details, width:)
|
|
57
|
+
lines = wrap(title.to_s, width).map { |line| ANSIStyle.title(line) }
|
|
58
|
+
|
|
59
|
+
return lines unless details
|
|
60
|
+
|
|
61
|
+
lines + wrap(details.to_s, width).map { |line| ANSIStyle.muted(line) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def footer_lines(error_message:, help_line:, width:)
|
|
65
|
+
lines = []
|
|
66
|
+
|
|
67
|
+
if error_message && !error_message.empty?
|
|
68
|
+
lines.concat(wrap(error_message, width).map do |line|
|
|
69
|
+
ANSIStyle.error(line)
|
|
70
|
+
end)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
lines << help_line if help_line && !help_line.empty?
|
|
74
|
+
|
|
75
|
+
return [] if lines.empty?
|
|
76
|
+
|
|
77
|
+
[""] + lines
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def help_line(items, width:)
|
|
81
|
+
items = Array(items).map(&:to_s).reject(&:empty?)
|
|
82
|
+
return "" if items.empty?
|
|
83
|
+
|
|
84
|
+
line = +""
|
|
85
|
+
total_width = 0
|
|
86
|
+
|
|
87
|
+
items.each do |item|
|
|
88
|
+
segment = total_width.zero? ? item : " • #{item}"
|
|
89
|
+
segment_width = display_width(segment)
|
|
90
|
+
|
|
91
|
+
if width.to_i.positive? && total_width + segment_width > width
|
|
92
|
+
line << " …" if total_width + display_width(" …") <= width
|
|
93
|
+
break
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
line << segment
|
|
97
|
+
total_width += segment_width
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
return "" if line.empty?
|
|
101
|
+
|
|
102
|
+
ANSIStyle.muted(line)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def content_width(width)
|
|
106
|
+
[width.to_i - 2, 1].max
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def frame(lines)
|
|
110
|
+
content_lines = Array(lines).flat_map { |line| split_lines(line.to_s) }
|
|
111
|
+
border = ANSIStyle.muted("┃")
|
|
112
|
+
|
|
113
|
+
content_lines.map { |line| "#{border} #{line}" }.join("\n")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def graphemes(text)
|
|
117
|
+
text.scan(/\X/)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def split_lines(text)
|
|
121
|
+
return [""] if text.empty?
|
|
122
|
+
|
|
123
|
+
text.split("\n", -1)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def style_submitted_lines(lines, title_length:)
|
|
127
|
+
remaining_title_length = title_length
|
|
128
|
+
|
|
129
|
+
lines.map do |line|
|
|
130
|
+
line_graphemes = graphemes(line)
|
|
131
|
+
|
|
132
|
+
if remaining_title_length >= line_graphemes.length
|
|
133
|
+
remaining_title_length -= line_graphemes.length
|
|
134
|
+
ANSIStyle.title(line)
|
|
135
|
+
elsif remaining_title_length.positive?
|
|
136
|
+
title_text = line_graphemes[0, remaining_title_length].join
|
|
137
|
+
value_text = line_graphemes[remaining_title_length..].to_a.join
|
|
138
|
+
remaining_title_length = 0
|
|
139
|
+
|
|
140
|
+
ANSIStyle.title(title_text) + ANSIStyle.text(value_text)
|
|
141
|
+
else
|
|
142
|
+
ANSIStyle.text(line)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def wrap_line(line, width)
|
|
148
|
+
return [""] if line.empty?
|
|
149
|
+
|
|
150
|
+
lines = []
|
|
151
|
+
remaining = graphemes(line)
|
|
152
|
+
|
|
153
|
+
until remaining.empty?
|
|
154
|
+
segment, last_whitespace_index = take_segment(remaining, width)
|
|
155
|
+
|
|
156
|
+
if segment.length == remaining.length
|
|
157
|
+
lines << segment.join
|
|
158
|
+
break
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if last_whitespace_index&.positive?
|
|
162
|
+
lines << segment[0...last_whitespace_index].join.rstrip
|
|
163
|
+
remaining = remaining[(last_whitespace_index + 1)..] || []
|
|
164
|
+
remaining = remaining.drop_while { |grapheme| grapheme.match?(/\s/) }
|
|
165
|
+
else
|
|
166
|
+
lines << segment.join
|
|
167
|
+
remaining = remaining[segment.length..] || []
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
lines
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def wrap_exact_line(line, width)
|
|
175
|
+
return [""] if line.empty?
|
|
176
|
+
|
|
177
|
+
lines = []
|
|
178
|
+
remaining = graphemes(line)
|
|
179
|
+
|
|
180
|
+
until remaining.empty?
|
|
181
|
+
segment, = take_segment(remaining, width)
|
|
182
|
+
lines << segment.join
|
|
183
|
+
remaining = remaining[segment.length..] || []
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
lines
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def take_segment(graphemes, width)
|
|
190
|
+
segment = []
|
|
191
|
+
segment_width = 0
|
|
192
|
+
last_whitespace_index = nil
|
|
193
|
+
|
|
194
|
+
graphemes.each_with_index do |grapheme, index|
|
|
195
|
+
grapheme_width = display_width(grapheme)
|
|
196
|
+
|
|
197
|
+
if segment_width.zero? && grapheme_width > width
|
|
198
|
+
return [[grapheme], grapheme.match?(/\s/) ? 0 : nil]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
break if segment_width.positive? && segment_width + grapheme_width > width
|
|
202
|
+
|
|
203
|
+
segment << grapheme
|
|
204
|
+
segment_width += grapheme_width
|
|
205
|
+
last_whitespace_index = index if grapheme.match?(/\s/)
|
|
206
|
+
|
|
207
|
+
break if segment_width >= width
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
[segment, last_whitespace_index]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module AskTTY
|
|
6
|
+
module Internal
|
|
7
|
+
class Terminal
|
|
8
|
+
attr_reader :width
|
|
9
|
+
|
|
10
|
+
def self.open(&)
|
|
11
|
+
raise AskTTY::Error, "interactive prompts require a TTY input and output" unless $stdin.tty? && $stdout.tty?
|
|
12
|
+
|
|
13
|
+
new(input: $stdin, output: $stdout).open(&)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(input:, output:)
|
|
17
|
+
@input = input
|
|
18
|
+
@output = output
|
|
19
|
+
@line_count = 0
|
|
20
|
+
@width = output.winsize[1]
|
|
21
|
+
@width = 80 if @width.nil? || @width <= 0
|
|
22
|
+
rescue StandardError
|
|
23
|
+
@width = 80
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def open
|
|
27
|
+
@output.print "\e[?25l"
|
|
28
|
+
@output.flush
|
|
29
|
+
|
|
30
|
+
@input.raw do
|
|
31
|
+
yield self
|
|
32
|
+
end
|
|
33
|
+
ensure
|
|
34
|
+
@line_count = 0
|
|
35
|
+
@output.print "\e[0m\r\n\e[?25h"
|
|
36
|
+
@output.flush
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def read_key
|
|
40
|
+
character = @input.getch
|
|
41
|
+
|
|
42
|
+
case character
|
|
43
|
+
when "\u0003"
|
|
44
|
+
raise Interrupt
|
|
45
|
+
when "\r"
|
|
46
|
+
:enter
|
|
47
|
+
when "\n"
|
|
48
|
+
:ctrl_j
|
|
49
|
+
when "\u007F", "\b"
|
|
50
|
+
:backspace
|
|
51
|
+
when "\e"
|
|
52
|
+
decode_escape_sequence(read_escape_sequence)
|
|
53
|
+
else
|
|
54
|
+
character
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render(text)
|
|
59
|
+
text = text.to_s
|
|
60
|
+
|
|
61
|
+
if @line_count > 1
|
|
62
|
+
@output.print "\e[#{@line_count - 1}F"
|
|
63
|
+
else
|
|
64
|
+
@output.print "\r"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@output.print "\e[J"
|
|
68
|
+
@output.print normalize_output(text)
|
|
69
|
+
@output.flush
|
|
70
|
+
|
|
71
|
+
@line_count = [text.split("\n", -1).length, 1].max
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def normalize_output(text)
|
|
77
|
+
text.gsub("\n", "\r\n")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def read_escape_sequence
|
|
81
|
+
sequence = +""
|
|
82
|
+
|
|
83
|
+
while @input.wait_readable(0.01)
|
|
84
|
+
chunk = @input.read_nonblock(1, exception: false)
|
|
85
|
+
break if chunk == :wait_readable || chunk.nil?
|
|
86
|
+
|
|
87
|
+
sequence << chunk
|
|
88
|
+
break if chunk.match?(/[A-Za-z~]/)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
sequence
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def decode_escape_sequence(sequence)
|
|
95
|
+
case sequence
|
|
96
|
+
when "[A", "OA"
|
|
97
|
+
:up
|
|
98
|
+
when "[B", "OB"
|
|
99
|
+
:down
|
|
100
|
+
when "[D", "OD"
|
|
101
|
+
:left
|
|
102
|
+
when "[C", "OC"
|
|
103
|
+
:right
|
|
104
|
+
when "[13;2u", "[13;2~", "[27;2;13~"
|
|
105
|
+
:shift_enter
|
|
106
|
+
else
|
|
107
|
+
:escape
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AskTTY
|
|
4
|
+
module Internal
|
|
5
|
+
class TextInputPrompt
|
|
6
|
+
def initialize(title:, details: nil, placeholder: nil, value: nil, validator: nil, multiline: false)
|
|
7
|
+
@title = title.to_s
|
|
8
|
+
@details = details&.to_s
|
|
9
|
+
@placeholder = placeholder&.to_s
|
|
10
|
+
@value = value.to_s
|
|
11
|
+
@validator = validator
|
|
12
|
+
@multiline = multiline
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def ask
|
|
16
|
+
value = @value.dup
|
|
17
|
+
validation_active = false
|
|
18
|
+
|
|
19
|
+
Terminal.open do |session|
|
|
20
|
+
loop do
|
|
21
|
+
session.render(
|
|
22
|
+
render(
|
|
23
|
+
value,
|
|
24
|
+
width: session.width,
|
|
25
|
+
show_cursor: true,
|
|
26
|
+
error_message: validation_message(value, validation_active)
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
key = session.read_key
|
|
31
|
+
|
|
32
|
+
case key
|
|
33
|
+
when :enter
|
|
34
|
+
validation_active = true
|
|
35
|
+
next if validation_message(value, validation_active)
|
|
36
|
+
|
|
37
|
+
session.render(submitted_render(value, width: session.width))
|
|
38
|
+
return value
|
|
39
|
+
when :shift_enter, :ctrl_j
|
|
40
|
+
next unless @multiline
|
|
41
|
+
|
|
42
|
+
value << "\n"
|
|
43
|
+
validation_active = true
|
|
44
|
+
when :backspace
|
|
45
|
+
updated_value = Rendering.chop_grapheme(value)
|
|
46
|
+
validation_active ||= updated_value != value
|
|
47
|
+
value = updated_value
|
|
48
|
+
when String
|
|
49
|
+
next unless printable?(key)
|
|
50
|
+
|
|
51
|
+
value << key
|
|
52
|
+
validation_active = true
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def render(value, width:, show_cursor:, error_message: nil)
|
|
61
|
+
Rendering.prompt_frame(
|
|
62
|
+
title: @title, details: @details, help_items: help_items, error_message: error_message, width: width
|
|
63
|
+
) do |content_width|
|
|
64
|
+
body_lines(value, content_width: content_width, show_cursor: show_cursor)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def submitted_render(value, width:)
|
|
69
|
+
Rendering.submitted_frame(@title, summary_value(value), width: width)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def body_lines(value, content_width:, show_cursor:)
|
|
73
|
+
return placeholder_lines(content_width) if @placeholder && value.empty? && show_cursor
|
|
74
|
+
|
|
75
|
+
render_segments(value_lines(value), content_width: content_width, show_cursor: show_cursor)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def placeholder_lines(content_width)
|
|
79
|
+
render_segments(
|
|
80
|
+
@placeholder.to_s.split("\n", -1),
|
|
81
|
+
content_width: content_width,
|
|
82
|
+
show_cursor: false,
|
|
83
|
+
first_style: method(:placeholder_first_line),
|
|
84
|
+
style: ANSIStyle.method(:muted)
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def value_lines(value)
|
|
89
|
+
return [value] unless @multiline
|
|
90
|
+
|
|
91
|
+
value.split("\n", -1)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def help_items
|
|
95
|
+
return ["enter (submit)"] unless @multiline
|
|
96
|
+
|
|
97
|
+
["enter (submit)", "shift+enter/ctrl+j (new line)"]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def placeholder_first_line(text)
|
|
101
|
+
Rendering.placeholder_with_cursor(text)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def printable?(character)
|
|
105
|
+
character.length == 1 && character >= " "
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def render_segments(
|
|
109
|
+
segments, content_width:, show_cursor:, first_style: ANSIStyle.method(:text), style: first_style
|
|
110
|
+
)
|
|
111
|
+
segments = [""] if segments.empty?
|
|
112
|
+
wrap_width = [content_width - 2, 1].max
|
|
113
|
+
last_index = segments.length - 1
|
|
114
|
+
|
|
115
|
+
segments.each_with_index.flat_map do |segment, segment_index|
|
|
116
|
+
wrapped_segments = Rendering.wrap_exact(segment, wrap_width)
|
|
117
|
+
wrapped_segments = [""] if wrapped_segments.empty?
|
|
118
|
+
|
|
119
|
+
if show_cursor && segment_index == last_index && Rendering.display_width(wrapped_segments.last) >= wrap_width
|
|
120
|
+
wrapped_segments << ""
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
wrapped_segments.each_with_index.map do |part, wrapped_index|
|
|
124
|
+
prefix = segment_index.zero? && wrapped_index.zero? ? ANSIStyle.prompt("> ") : " "
|
|
125
|
+
current_style = segment_index.zero? && wrapped_index.zero? ? first_style : style
|
|
126
|
+
line = prefix + current_style.call(part)
|
|
127
|
+
|
|
128
|
+
if show_cursor && segment_index == last_index && wrapped_index == wrapped_segments.length - 1
|
|
129
|
+
line << ANSIStyle.cursor
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
line
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def summary_value(value)
|
|
138
|
+
return value unless @multiline
|
|
139
|
+
|
|
140
|
+
value.tr("\n", " ")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validation_message(value, validation_active)
|
|
144
|
+
Validation.message_for(value, @validator, active: validation_active)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AskTTY
|
|
4
|
+
module Internal
|
|
5
|
+
module Validation
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def message_for(value, validator, active:)
|
|
9
|
+
return nil unless active && validator
|
|
10
|
+
|
|
11
|
+
result = validator.call(value)
|
|
12
|
+
return nil if result == true
|
|
13
|
+
return result if result.is_a?(String)
|
|
14
|
+
|
|
15
|
+
raise AskTTY::Error, "validator must return true or an error message"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AskTTY
|
|
4
|
+
class ConfirmPrompt
|
|
5
|
+
def self.ask(title:, details: nil, value: false, &validator)
|
|
6
|
+
new(title: title, details: details, value: value, validator: validator).ask
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(title:, details: nil, value: false, validator: nil)
|
|
10
|
+
@title = title.to_s
|
|
11
|
+
@details = details&.to_s
|
|
12
|
+
|
|
13
|
+
raise AskTTY::Error, "value must be true or false" unless [true, false].include?(value)
|
|
14
|
+
|
|
15
|
+
@value = value
|
|
16
|
+
@validator = validator
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ask
|
|
20
|
+
validation_active = false
|
|
21
|
+
|
|
22
|
+
Internal::Terminal.open do |session|
|
|
23
|
+
loop do
|
|
24
|
+
session.render(render(width: session.width, error_message: validation_message(validation_active)))
|
|
25
|
+
|
|
26
|
+
case session.read_key
|
|
27
|
+
when :enter
|
|
28
|
+
validation_active = true
|
|
29
|
+
next if validation_message(validation_active)
|
|
30
|
+
|
|
31
|
+
session.render(submitted_render(width: session.width))
|
|
32
|
+
return @value
|
|
33
|
+
when :left, "h"
|
|
34
|
+
previous_value = @value
|
|
35
|
+
@value = true
|
|
36
|
+
validation_active ||= @value != previous_value
|
|
37
|
+
when :right, "l"
|
|
38
|
+
previous_value = @value
|
|
39
|
+
@value = false
|
|
40
|
+
validation_active ||= @value != previous_value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def render(width:, error_message: nil)
|
|
49
|
+
Internal::Rendering.prompt_frame(
|
|
50
|
+
title: @title,
|
|
51
|
+
details: @details,
|
|
52
|
+
help_items: help_items,
|
|
53
|
+
error_message: error_message,
|
|
54
|
+
width: width,
|
|
55
|
+
gap_before_body: true
|
|
56
|
+
) do |content_width|
|
|
57
|
+
button_lines(content_width)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def submitted_render(width:)
|
|
62
|
+
Internal::Rendering.submitted_frame(@title, @value ? "Yes" : "No", width: width)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def button_lines(content_width)
|
|
66
|
+
yes_button = @value ? Internal::ANSIStyle.focused_button("Yes") : Internal::ANSIStyle.blurred_button("Yes")
|
|
67
|
+
no_button = @value ? Internal::ANSIStyle.blurred_button("No") : Internal::ANSIStyle.focused_button("No")
|
|
68
|
+
|
|
69
|
+
row = "#{yes_button} #{no_button}"
|
|
70
|
+
|
|
71
|
+
return [row] if Internal::Rendering.display_width(row) <= content_width
|
|
72
|
+
|
|
73
|
+
button_width = [
|
|
74
|
+
Internal::Rendering.display_width(yes_button),
|
|
75
|
+
Internal::Rendering.display_width(no_button)
|
|
76
|
+
].max
|
|
77
|
+
|
|
78
|
+
raise AskTTY::Error, "terminal is too narrow for confirmation prompt" if button_width > content_width
|
|
79
|
+
|
|
80
|
+
[yes_button, no_button]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def help_items
|
|
84
|
+
["enter (submit)", "left/right (select option)"]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validation_message(validation_active)
|
|
88
|
+
Internal::Validation.message_for(@value, @validator, active: validation_active)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AskTTY
|
|
4
|
+
class InputPrompt
|
|
5
|
+
def self.ask(title:, details: nil, placeholder: nil, value: nil, &validator)
|
|
6
|
+
new(title: title, details: details, placeholder: placeholder, value: value, validator: validator).ask
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(title:, details: nil, placeholder: nil, value: nil, validator: nil)
|
|
10
|
+
@prompt = Internal::TextInputPrompt.new(
|
|
11
|
+
title: title, details: details, placeholder: placeholder, value: value, validator: validator, multiline: false
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def ask
|
|
16
|
+
@prompt.ask
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AskTTY
|
|
4
|
+
class MultiSelectPrompt
|
|
5
|
+
def self.ask(title:, options:, details: nil, values: nil, &validator)
|
|
6
|
+
new(title: title, details: details, options: options, values: values, validator: validator).ask
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(title:, options:, details: nil, values: nil, validator: nil)
|
|
10
|
+
@title = title.to_s
|
|
11
|
+
@details = details&.to_s
|
|
12
|
+
@options = Internal::Options.normalize(options)
|
|
13
|
+
@values = Array(values).uniq
|
|
14
|
+
@validator = validator
|
|
15
|
+
|
|
16
|
+
option_values = Internal::Options.values(@options)
|
|
17
|
+
|
|
18
|
+
unknown_values = @values - option_values
|
|
19
|
+
raise AskTTY::Error, "values contain unknown option values" unless unknown_values.empty?
|
|
20
|
+
|
|
21
|
+
@index = first_selected_index || 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def ask
|
|
25
|
+
validation_active = false
|
|
26
|
+
|
|
27
|
+
Internal::Terminal.open do |session|
|
|
28
|
+
loop do
|
|
29
|
+
session.render(render(width: session.width, error_message: validation_message(validation_active)))
|
|
30
|
+
|
|
31
|
+
case session.read_key
|
|
32
|
+
when :enter
|
|
33
|
+
validation_active = true
|
|
34
|
+
next if validation_message(validation_active)
|
|
35
|
+
|
|
36
|
+
session.render(submitted_render(width: session.width))
|
|
37
|
+
return selected_results
|
|
38
|
+
when :up, "k"
|
|
39
|
+
move(-1)
|
|
40
|
+
when :down, "j"
|
|
41
|
+
move(1)
|
|
42
|
+
when " "
|
|
43
|
+
previous_results = selected_results
|
|
44
|
+
toggle_current
|
|
45
|
+
validation_active ||= selected_results != previous_results
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def render(width:, error_message: nil)
|
|
54
|
+
Internal::Rendering.prompt_frame(
|
|
55
|
+
title: @title, details: @details, help_items: help_items, error_message: error_message, width: width
|
|
56
|
+
) do |content_width|
|
|
57
|
+
option_lines(content_width)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def submitted_render(width:)
|
|
62
|
+
labels = selected_options.map(&:label).join(", ")
|
|
63
|
+
|
|
64
|
+
Internal::Rendering.submitted_frame(@title, labels, width: width)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def option_lines(content_width)
|
|
68
|
+
@options.each_with_index.flat_map do |option, index|
|
|
69
|
+
cursor = index == @index ? Internal::ANSIStyle.prompt("> ") : " "
|
|
70
|
+
selected = @values.include?(option.value)
|
|
71
|
+
prefix = selected ? Internal::ANSIStyle.selected("[•] ") : Internal::ANSIStyle.text("[ ] ")
|
|
72
|
+
style = selected ? Internal::ANSIStyle.method(:selected) : Internal::ANSIStyle.method(:text)
|
|
73
|
+
|
|
74
|
+
wrap_option(option.label, cursor: cursor, prefix: prefix, width: content_width, &style)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def first_selected_index
|
|
79
|
+
@options.index { |option| @values.include?(option.value) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def help_items
|
|
83
|
+
["enter (submit)", "up/down (select item)", "space (toggle item)"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def move(offset)
|
|
87
|
+
@index = (@index + offset) % @options.length
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def selected_options
|
|
91
|
+
@options.select { |option| @values.include?(option.value) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def selected_results
|
|
95
|
+
selected_options.map(&:value)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def toggle_current
|
|
99
|
+
value = @options[@index].value
|
|
100
|
+
|
|
101
|
+
if @values.include?(value)
|
|
102
|
+
@values.delete(value)
|
|
103
|
+
else
|
|
104
|
+
@values << value
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validation_message(validation_active)
|
|
109
|
+
Internal::Validation.message_for(selected_results, @validator, active: validation_active)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def wrap_option(label, cursor:, prefix:, width:, &style)
|
|
113
|
+
prefix_width = Internal::Rendering.display_width(cursor + prefix)
|
|
114
|
+
wrapped = Internal::Rendering.wrap(label, [width - prefix_width, 1].max)
|
|
115
|
+
|
|
116
|
+
wrapped.each_with_index.map do |line, index|
|
|
117
|
+
current_prefix = index.zero? ? cursor + prefix : (" " * prefix_width)
|
|
118
|
+
current_prefix + style.call(line)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AskTTY
|
|
4
|
+
class SelectPrompt
|
|
5
|
+
UNSET = Object.new
|
|
6
|
+
|
|
7
|
+
def self.ask(title:, options:, details: nil, value: UNSET, &validator)
|
|
8
|
+
new(title: title, details: details, options: options, value: value, validator: validator).ask
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(title:, options:, details: nil, value: UNSET, validator: nil)
|
|
12
|
+
@title = title.to_s
|
|
13
|
+
@details = details&.to_s
|
|
14
|
+
@options = Internal::Options.normalize(options)
|
|
15
|
+
@index = index_for(value)
|
|
16
|
+
@validator = validator
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ask
|
|
20
|
+
validation_active = false
|
|
21
|
+
|
|
22
|
+
Internal::Terminal.open do |session|
|
|
23
|
+
loop do
|
|
24
|
+
session.render(render(width: session.width, error_message: validation_message(validation_active)))
|
|
25
|
+
|
|
26
|
+
case session.read_key
|
|
27
|
+
when :enter
|
|
28
|
+
validation_active = true
|
|
29
|
+
next if validation_message(validation_active)
|
|
30
|
+
|
|
31
|
+
session.render(submitted_render(width: session.width))
|
|
32
|
+
return selected_option.value
|
|
33
|
+
when :up, "k"
|
|
34
|
+
previous_value = selected_option.value
|
|
35
|
+
move(-1)
|
|
36
|
+
validation_active ||= selected_option.value != previous_value
|
|
37
|
+
when :down, "j"
|
|
38
|
+
previous_value = selected_option.value
|
|
39
|
+
move(1)
|
|
40
|
+
validation_active ||= selected_option.value != previous_value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def render(width:, error_message: nil)
|
|
49
|
+
Internal::Rendering.prompt_frame(
|
|
50
|
+
title: @title, details: @details, help_items: help_items, error_message: error_message, width: width
|
|
51
|
+
) do |content_width|
|
|
52
|
+
option_lines(content_width)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def submitted_render(width:)
|
|
57
|
+
Internal::Rendering.submitted_frame(@title, selected_option.label, width: width)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def option_lines(content_width)
|
|
61
|
+
@options.each_with_index.flat_map do |option, index|
|
|
62
|
+
prefix = index == @index ? Internal::ANSIStyle.prompt("> ") : " "
|
|
63
|
+
style = index == @index ? Internal::ANSIStyle.method(:selected) : Internal::ANSIStyle.method(:text)
|
|
64
|
+
|
|
65
|
+
wrap_option(option.label, prefix: prefix, width: content_width, &style)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def help_items
|
|
70
|
+
["enter (submit)", "up/down (select item)"]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def index_for(value)
|
|
74
|
+
return 0 if value.equal?(UNSET)
|
|
75
|
+
|
|
76
|
+
@options.index { |option| option.value == value } ||
|
|
77
|
+
raise(AskTTY::Error, "value is not a valid option value")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def move(offset)
|
|
81
|
+
@index = (@index + offset) % @options.length
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def selected_option
|
|
85
|
+
@options[@index]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validation_message(validation_active)
|
|
89
|
+
Internal::Validation.message_for(selected_option.value, @validator, active: validation_active)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def wrap_option(label, prefix:, width:, &style)
|
|
93
|
+
wrapped = Internal::Rendering.wrap(label, [width - 2, 1].max)
|
|
94
|
+
|
|
95
|
+
wrapped.each_with_index.map do |line, index|
|
|
96
|
+
current_prefix = index.zero? ? prefix : " "
|
|
97
|
+
current_prefix + style.call(line)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AskTTY
|
|
4
|
+
class TextPrompt
|
|
5
|
+
def self.ask(title:, details: nil, placeholder: nil, value: nil, &validator)
|
|
6
|
+
new(title: title, details: details, placeholder: placeholder, value: value, validator: validator).ask
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(title:, details: nil, placeholder: nil, value: nil, validator: nil)
|
|
10
|
+
@prompt = Internal::TextInputPrompt.new(
|
|
11
|
+
title: title, details: details, placeholder: placeholder, value: value, validator: validator, multiline: true
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def ask
|
|
16
|
+
@prompt.ask
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/asktty.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "asktty/version"
|
|
4
|
+
require_relative "asktty/internal/ansi_style"
|
|
5
|
+
require_relative "asktty/internal/options"
|
|
6
|
+
require_relative "asktty/internal/rendering"
|
|
7
|
+
require_relative "asktty/internal/terminal"
|
|
8
|
+
require_relative "asktty/internal/validation"
|
|
9
|
+
require_relative "asktty/internal/text_input_prompt"
|
|
10
|
+
require_relative "asktty/prompts/input_prompt"
|
|
11
|
+
require_relative "asktty/prompts/text_prompt"
|
|
12
|
+
require_relative "asktty/prompts/select_prompt"
|
|
13
|
+
require_relative "asktty/prompts/multi_select_prompt"
|
|
14
|
+
require_relative "asktty/prompts/confirm_prompt"
|
|
15
|
+
|
|
16
|
+
module AskTTY
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: asktty
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Vlad Suchy
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: unicode-display_width
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.2'
|
|
26
|
+
description: |
|
|
27
|
+
AskTTY is a Ruby library for interactive terminal prompts in CLI applications and scripts.
|
|
28
|
+
It provides input, text, select, multi-select and confirm prompts
|
|
29
|
+
through a small, direct API with a polished terminal UI.
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- LICENSE.txt
|
|
35
|
+
- README.md
|
|
36
|
+
- lib/asktty.rb
|
|
37
|
+
- lib/asktty/internal/ansi_style.rb
|
|
38
|
+
- lib/asktty/internal/options.rb
|
|
39
|
+
- lib/asktty/internal/rendering.rb
|
|
40
|
+
- lib/asktty/internal/terminal.rb
|
|
41
|
+
- lib/asktty/internal/text_input_prompt.rb
|
|
42
|
+
- lib/asktty/internal/validation.rb
|
|
43
|
+
- lib/asktty/prompts/confirm_prompt.rb
|
|
44
|
+
- lib/asktty/prompts/input_prompt.rb
|
|
45
|
+
- lib/asktty/prompts/multi_select_prompt.rb
|
|
46
|
+
- lib/asktty/prompts/select_prompt.rb
|
|
47
|
+
- lib/asktty/prompts/text_prompt.rb
|
|
48
|
+
- lib/asktty/version.rb
|
|
49
|
+
homepage: https://github.com/vsuchy/asktty
|
|
50
|
+
licenses:
|
|
51
|
+
- MIT
|
|
52
|
+
metadata:
|
|
53
|
+
rubygems_mfa_required: 'true'
|
|
54
|
+
rdoc_options: []
|
|
55
|
+
require_paths:
|
|
56
|
+
- lib
|
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: 3.3.0
|
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
requirements: []
|
|
68
|
+
rubygems_version: 4.0.10
|
|
69
|
+
specification_version: 4
|
|
70
|
+
summary: Terminal prompts for Ruby.
|
|
71
|
+
test_files: []
|