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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AskTTY
4
+ VERSION = "1.0.0"
5
+ 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: []