asktty 2.0.0 → 3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7afce23120ce01740dd6c9f0e405080565efd3c22546361508611e8573a051aa
4
- data.tar.gz: 8c2e75af399a24f63d9230112acbb07152d604f00da0d0cdc95098b9cf48fb31
3
+ metadata.gz: 1ea8ecccbeef5886609092cc74ef77888a4c9a467947be62f42137e9c5174063
4
+ data.tar.gz: 7e26f19e4b7352daab2d9878f644e4724b48537bd30745592df4057813cbcc62
5
5
  SHA512:
6
- metadata.gz: 2c164a8cd20c460cf1baa0739244155c2f24f862f6b987617245eeac91e1e55938b88d8c109d7ce87df1c05f1181c04e4e983c5ac2febdc8442ecb79364c5d84
7
- data.tar.gz: 17f6e9e671a4ac84b1cb358f7648c03561b9212f06f4cafa7190f002e4c67f552b03f866236104da19cc772f365b3e147b63fdf03b3acc364a830d4136f275f1
6
+ metadata.gz: 0aaf2802c3d81ff02d459d7e74732ef44c3e4a5b6c57ef353bfec622e9bcaaa0c78d353640dda23a5dd9f2415e92bc49dc8a9c81d1829651784b9990f82a40c0
7
+ data.tar.gz: 9f232b5466f6b6cd57ec34c1ec7f2e7d257bc9c0b169bd0a240b22feedff53d692af41170085975ac8b65f1340936ae2c2ebc37214b894d1b2ff1d8b348e2fc0
data/README.md CHANGED
@@ -97,6 +97,12 @@ name = AskTTY::InputPrompt.ask(title: "Name") do |value|
97
97
  end
98
98
  ```
99
99
 
100
+ Validation runs after each edit or selection change. A paste is one edit. If the value has not been validated yet,
101
+ the first submission validates it. Submissions reuse the result until the value changes.
102
+ Moving the focus in a multi-select prompt does not validate until an item is toggled or the selection is submitted.
103
+ Keep validators free of side effects and use checks whose result depends on the supplied value.
104
+ Exceptions raised by the block propagate to the caller after terminal cleanup.
105
+
100
106
  ## Examples
101
107
 
102
108
  Run the interactive example from the project root:
@@ -3,52 +3,29 @@
3
3
  module AskTTY
4
4
  module Internal
5
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
6
+ PALETTE = {
7
+ title: "38;5;6",
8
+ text: "38;5;7",
9
+ prompt: "38;5;3",
10
+ selected: "38;5;2",
11
+ muted: "38;5;8",
12
+ error: "38;5;9",
13
+ focused_button: "38;5;0;48;5;2",
14
+ blurred_button: "38;5;7;48;5;0",
15
+ cursor: "38;5;7;48;5;2",
16
+ placeholder_cursor: "38;5;8;48;5;2"
17
+ }.freeze
18
+
19
+ private_constant :PALETTE
17
20
 
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
21
+ module_function
45
22
 
46
- def cursor(text = " ")
47
- style(text, foreground: 7, background: 2)
23
+ def apply(text, role)
24
+ "\e[#{PALETTE.fetch(role)}m#{text}\e[0m"
48
25
  end
49
26
 
50
- def error(text)
51
- style(text, foreground: 9)
27
+ def strip(text)
28
+ text.gsub(/\e\[[\d;]*m/, "")
52
29
  end
53
30
  end
54
31
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AskTTY
4
+ module Internal
5
+ class Prompt
6
+ def self.ask(...)
7
+ new(...).ask
8
+ end
9
+
10
+ private_class_method :new
11
+
12
+ def initialize(title:, details: nil, &validator)
13
+ @title = title.to_s
14
+ @details = details&.to_s
15
+ @validator = validator
16
+ end
17
+
18
+ def ask
19
+ @validation_result = nil
20
+
21
+ Terminal.open do |session|
22
+ loop do
23
+ session.render { |width, height| render(width: width, height: height) }
24
+ key = session.read_key
25
+
26
+ if key == :enter
27
+ validate if @validation_result.nil?
28
+ next unless @validation_result == true
29
+
30
+ session.render { |width| submitted_render(width: width) }
31
+ return value
32
+ end
33
+
34
+ handle_key(key)
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :value
42
+
43
+ def render(width:, height:)
44
+ content_width = width - 2
45
+ header = header_lines(width: content_width)
46
+ footer = footer_lines(width: content_width)
47
+
48
+ body_height = height - header.length - footer.length
49
+ raise AskTTY::Error, "terminal is too short for prompt" if body_height < 1
50
+
51
+ body = body_lines(width: content_width, height: body_height)
52
+ raise AskTTY::Error, "terminal is too short for prompt" if body.length > body_height
53
+
54
+ Rendering.frame(header + body + footer)
55
+ end
56
+
57
+ def header_lines(width:)
58
+ lines = Rendering.wrap(@title, width).map { |line| ANSIStyle.apply(line, :title) }
59
+ return lines unless @details
60
+
61
+ lines + Rendering.wrap(@details, width).map { |line| ANSIStyle.apply(line, :muted) }
62
+ end
63
+
64
+ def help_items
65
+ [%w[enter submit]]
66
+ end
67
+
68
+ def footer_lines(width:)
69
+ lines = [""]
70
+
71
+ if @validation_result.is_a?(String)
72
+ lines.concat(Rendering.wrap(@validation_result, width).map { |line| ANSIStyle.apply(line, :error) })
73
+ end
74
+
75
+ lines << Rendering.help_line(help_items, width: width)
76
+ end
77
+
78
+ def submitted_render(width:)
79
+ prefix = "#{@title}:"
80
+ summary_value = summary
81
+ text = summary_value.empty? ? prefix : "#{prefix} #{summary_value}"
82
+
83
+ lines = Rendering.wrap(text, width - 2)
84
+
85
+ Rendering.frame(lines.map { |line| ANSIStyle.apply(line, :text) })
86
+ end
87
+
88
+ def summary
89
+ value
90
+ end
91
+
92
+ def validate
93
+ result = @validator ? @validator.call(value) : true
94
+
95
+ @validation_result =
96
+ if result == true
97
+ true
98
+ elsif result.is_a?(String) && !result.empty?
99
+ result
100
+ else
101
+ raise AskTTY::Error, "validator must return true or a non-empty error message"
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -7,209 +7,123 @@ module AskTTY
7
7
  module Rendering
8
8
  module_function
9
9
 
10
- def chop_grapheme(text)
11
- graphemes(text.to_s)[0...-1].join
10
+ def display_text(text)
11
+ text.gsub(/\r\n?/, "\n").gsub("\t", " ").gsub(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/) do
12
+ |character| format("\\x%02X", character.ord)
13
+ end
12
14
  end
13
15
 
14
16
  def display_width(text)
15
- Unicode::DisplayWidth.of(text.to_s.gsub(/\e\[[\d;]*m/, ""), ambiguous: 1)
17
+ Unicode::DisplayWidth.of(ANSIStyle.strip(text), ambiguous: 1)
16
18
  end
17
19
 
18
- def placeholder_with_cursor(text)
19
- first_grapheme, *rest = graphemes(text.to_s)
20
- return ANSIStyle.cursor unless first_grapheme
20
+ def frame(lines)
21
+ border = ANSIStyle.apply("┃", :muted)
21
22
 
22
- ANSIStyle.style(first_grapheme, foreground: 8, background: 2) + ANSIStyle.muted(rest.join)
23
+ lines.map { |line| "#{border} #{line}" }.join("\n")
23
24
  end
24
25
 
25
- def prompt_frame(title:, details:, help_items:, error_message:, width:, gap_before_body: false)
26
- content_width = self.content_width(width)
26
+ def help_line(items, width:)
27
+ line = +""
28
+ total_width = 0
27
29
 
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
- )
30
+ items.each do |key, description|
31
+ prefix = total_width.zero? ? "" : " "
32
+ segment_width = display_width("#{prefix}#{key} (#{description})")
36
33
 
37
- frame(lines)
38
- end
34
+ if total_width + segment_width > width
35
+ ellipsis = line.empty? ? "…" : " …"
39
36
 
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))
37
+ line << ANSIStyle.apply(ellipsis, :text) if total_width + display_width(ellipsis) <= width
38
+ break
39
+ end
44
40
 
45
- frame(lines.map { |line| ANSIStyle.text(line) })
46
- end
41
+ line << ANSIStyle.apply("#{prefix}#{key} ", :text)
42
+ line << ANSIStyle.apply("(#{description})", :muted)
47
43
 
48
- def wrap(text, width)
49
- split_lines(text.to_s).flat_map { |line| wrap_line(line, width) }
50
- end
44
+ total_width += segment_width
45
+ end
51
46
 
52
- def wrap_exact(text, width)
53
- split_lines(text.to_s).flat_map { |line| wrap_exact_line(line, width) }
47
+ line
54
48
  end
55
49
 
56
50
  def prefixed_lines(text, prefix:, width:, style:)
57
51
  prefix_width = display_width(prefix)
58
- wrapped = wrap(text, [width - prefix_width, 1].max)
52
+ wrapped = wrap(text, width - prefix_width)
59
53
 
60
54
  wrapped.each_with_index.map do |line, index|
61
55
  current_prefix = index.zero? ? prefix : (" " * prefix_width)
62
- current_prefix + style.call(line)
56
+ current_prefix + ANSIStyle.apply(line, style)
63
57
  end
64
58
  end
65
59
 
66
- def header_lines(title, details, width:)
67
- lines = wrap(title.to_s, width).map { |line| ANSIStyle.title(line) }
68
-
69
- return lines unless details
70
-
71
- lines + wrap(details.to_s, width).map { |line| ANSIStyle.muted(line) }
72
- end
73
-
74
- def footer_lines(error_message:, help_line:, width:)
75
- lines = []
76
-
77
- if error_message && !error_message.empty?
78
- lines.concat(wrap(error_message, width).map do |line|
79
- ANSIStyle.error(line)
80
- end)
81
- end
82
-
83
- lines << help_line if help_line && !help_line.empty?
84
-
85
- return [] if lines.empty?
60
+ def split_lines(text)
61
+ return [""] if text.empty?
86
62
 
87
- [""] + lines
63
+ text.split("\n", -1)
88
64
  end
89
65
 
90
- def help_line(items, width:)
91
- items = Array(items).map(&:to_s).reject(&:empty?)
92
- return "" if items.empty?
66
+ def take_segment(graphemes, width)
67
+ last_whitespace_index = nil
68
+ segment = []
69
+ segment_width = 0
93
70
 
94
- line = +""
95
- total_width = 0
71
+ graphemes.each_with_index do |grapheme, index|
72
+ grapheme_width = display_width(grapheme)
73
+ raise AskTTY::Error, "terminal is too narrow for prompt content" if grapheme_width > width
96
74
 
97
- items.each do |item|
98
- segment = total_width.zero? ? item : " • #{item}"
99
- segment_width = display_width(segment)
75
+ break if segment_width.positive? && segment_width + grapheme_width > width
100
76
 
101
- if width.to_i.positive? && total_width + segment_width > width
102
- ellipsis = line.empty? ? "…" : " …"
103
- line << ellipsis if total_width + display_width(ellipsis) <= width
104
- break
105
- end
77
+ last_whitespace_index = index if grapheme.match?(/\s/)
78
+ segment << grapheme
79
+ segment_width += grapheme_width
106
80
 
107
- line << segment
108
- total_width += segment_width
81
+ break if segment_width >= width
109
82
  end
110
83
 
111
- return "" if line.empty?
112
-
113
- line.split(/(\([^)]*\))/).reject(&:empty?).map do |segment|
114
- if segment.start_with?("(") && segment.end_with?(")")
115
- ANSIStyle.muted(segment)
116
- else
117
- ANSIStyle.text(segment)
118
- end
119
- end.join
120
- end
121
-
122
- def content_width(width)
123
- [width.to_i - 2, 1].max
84
+ [segment, last_whitespace_index]
124
85
  end
125
86
 
126
- def frame(lines)
127
- content_lines = Array(lines).flat_map { |line| split_lines(line.to_s) }
128
- border = ANSIStyle.muted("┃")
129
-
130
- content_lines.map { |line| "#{border} #{line}" }.join("\n")
131
- end
87
+ def window(lines, height:, focus:)
88
+ return lines if lines.length <= height
132
89
 
133
- def graphemes(text)
134
- text.scan(/\X/)
90
+ start = (focus - (height / 2)).clamp(0, lines.length - height)
91
+ lines.slice(start, height)
135
92
  end
136
93
 
137
- def split_lines(text)
138
- return [""] if text.empty?
94
+ def wrap(text, width, words: true)
95
+ raise AskTTY::Error, "terminal is too narrow for prompt content" if width < 1
139
96
 
140
- text.split("\n", -1)
97
+ split_lines(display_text(text)).flat_map { |line| wrap_line(line, width, words: words) }
141
98
  end
142
99
 
143
- def wrap_line(line, width)
100
+ def wrap_line(line, width, words:)
144
101
  return [""] if line.empty?
145
102
 
146
103
  lines = []
147
- remaining = graphemes(line)
104
+ remaining = line.grapheme_clusters
148
105
 
149
106
  until remaining.empty?
150
- if lines.any?
107
+ if words && lines.any?
151
108
  remaining = remaining.drop_while { |grapheme| grapheme.match?(/\s/) }
152
109
  break if remaining.empty?
153
110
  end
154
111
 
155
112
  segment, last_whitespace_index = take_segment(remaining, width)
113
+ count = segment.length
156
114
 
157
- if segment.length == remaining.length
158
- lines << segment.join
159
- break
160
- end
161
-
162
- if last_whitespace_index&.positive?
115
+ if words && count < remaining.length && last_whitespace_index&.positive?
116
+ count = last_whitespace_index + 1
163
117
  lines << segment[0...last_whitespace_index].join.rstrip
164
- remaining = remaining[(last_whitespace_index + 1)..] || []
165
- remaining = remaining.drop_while { |grapheme| grapheme.match?(/\s/) }
166
118
  else
167
119
  lines << segment.join
168
- remaining = remaining[segment.length..] || []
169
120
  end
170
- end
171
-
172
- lines
173
- end
174
121
 
175
- def wrap_exact_line(line, width)
176
- return [""] if line.empty?
177
-
178
- lines = []
179
- remaining = graphemes(line)
180
-
181
- until remaining.empty?
182
- segment, = take_segment(remaining, width)
183
- lines << segment.join
184
- remaining = remaining[segment.length..] || []
122
+ remaining = remaining.drop(count)
185
123
  end
186
124
 
187
125
  lines
188
126
  end
189
-
190
- def take_segment(graphemes, width)
191
- segment = []
192
- segment_width = 0
193
- last_whitespace_index = nil
194
-
195
- graphemes.each_with_index do |grapheme, index|
196
- grapheme_width = display_width(grapheme)
197
-
198
- if segment_width.zero? && grapheme_width > width
199
- return [[grapheme], grapheme.match?(/\s/) ? 0 : nil]
200
- end
201
-
202
- break if segment_width.positive? && segment_width + grapheme_width > width
203
-
204
- segment << grapheme
205
- segment_width += grapheme_width
206
- last_whitespace_index = index if grapheme.match?(/\s/)
207
-
208
- break if segment_width >= width
209
- end
210
-
211
- [segment, last_whitespace_index]
212
- end
213
127
  end
214
128
  end
215
129
  end
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "io/console"
4
+ require "io/wait"
4
5
 
5
6
  module AskTTY
6
7
  module Internal
7
8
  class Terminal
8
- ESCAPE_START_TIMEOUT = 0.1
9
- ESCAPE_CONTINUATION_TIMEOUT = 0.01
9
+ Paste = Data.define(:text)
10
10
 
11
- private_constant :ESCAPE_START_TIMEOUT, :ESCAPE_CONTINUATION_TIMEOUT
11
+ ESCAPE_TIMEOUT = 0.1
12
12
 
13
- attr_reader :width
13
+ private_constant :ESCAPE_TIMEOUT
14
14
 
15
15
  def self.open(&)
16
16
  raise AskTTY::Error, "interactive prompts require a TTY input and output" unless $stdin.tty? && $stdout.tty?
@@ -21,32 +21,33 @@ module AskTTY
21
21
  def initialize(input:, output:)
22
22
  @input = input
23
23
  @output = output
24
- @line_count = 0
25
- @width = output.winsize[1]
26
- @width = 80 if @width.nil? || @width <= 0
27
- rescue StandardError
28
- @width = 80
24
+ @frame = ""
25
+ @height, @width = dimensions
29
26
  end
30
27
 
31
28
  def open
32
- @output.print "\e[?25l"
29
+ @output.print "\e[?25l\e[?2004h"
33
30
  @output.flush
34
31
 
35
32
  @input.raw do
36
33
  yield self
37
34
  end
38
35
  ensure
39
- @line_count = 0
40
- @output.print "\e[0m\r\n\e[?25h"
36
+ @frame = ""
37
+ @output.print "\e[?2004l\e[0m\r\n\e[?25h"
41
38
  @output.flush
42
39
  end
43
40
 
44
41
  def read_key
45
- character = @input.getch
42
+ loop do
43
+ return :resize if dimensions != [@height, @width]
44
+
45
+ break if @input.wait_readable(0.1)
46
+ end
47
+
48
+ character = read_character
46
49
 
47
50
  case character
48
- when "\u0003"
49
- raise Interrupt
50
51
  when "\r"
51
52
  :enter
52
53
  when "\n"
@@ -54,51 +55,29 @@ module AskTTY
54
55
  when "\u007F", "\b"
55
56
  :backspace
56
57
  when "\e"
57
- decode_escape_sequence(read_escape_sequence)
58
+ sequence = read_escape_sequence
59
+ sequence == "[200~" ? read_paste : decode_escape_sequence(sequence)
58
60
  else
59
61
  character
60
62
  end
61
63
  end
62
64
 
63
- def render(text)
64
- text = text.to_s
65
+ def render
66
+ @height, @width = dimensions
65
67
 
66
- if @line_count > 1
67
- @output.print "\e[#{@line_count - 1}F"
68
- else
69
- @output.print "\r"
70
- end
68
+ text = yield(@width, @height)
69
+ rows = occupied_rows
71
70
 
71
+ @output.print rows > 1 ? "\e[#{rows - 1}F" : "\r"
72
72
  @output.print "\e[J"
73
- @output.print normalize_output(text)
73
+ @output.print text.gsub("\n", "\r\n")
74
74
  @output.flush
75
75
 
76
- @line_count = [text.split("\n", -1).length, 1].max
76
+ @frame = text
77
77
  end
78
78
 
79
79
  private
80
80
 
81
- def normalize_output(text)
82
- text.gsub("\n", "\r\n")
83
- end
84
-
85
- def read_escape_sequence
86
- sequence = +""
87
- timeout = ESCAPE_START_TIMEOUT
88
-
89
- while @input.wait_readable(timeout)
90
- chunk = @input.read_nonblock(1, exception: false)
91
- break if chunk == :wait_readable || chunk.nil?
92
-
93
- sequence << chunk
94
- break if chunk.match?(/[A-Za-z~]/)
95
-
96
- timeout = ESCAPE_CONTINUATION_TIMEOUT
97
- end
98
-
99
- sequence
100
- end
101
-
102
81
  def decode_escape_sequence(sequence)
103
82
  case sequence
104
83
  when "[A", "OA"
@@ -115,6 +94,70 @@ module AskTTY
115
94
  :escape
116
95
  end
117
96
  end
97
+
98
+ def dimensions
99
+ rows, columns = @output.winsize
100
+
101
+ [rows.to_i.positive? ? rows : 24, columns.to_i.positive? ? columns : 80]
102
+ rescue IOError, SystemCallError
103
+ [24, 80]
104
+ end
105
+
106
+ def occupied_rows
107
+ Rendering.split_lines(@frame).sum do |line|
108
+ column = 0
109
+ rows = 1
110
+
111
+ ANSIStyle.strip(line).each_grapheme_cluster do |grapheme|
112
+ width = Rendering.display_width(grapheme)
113
+
114
+ if column.positive? && column + width > @width
115
+ column = 0
116
+ rows += 1
117
+ end
118
+
119
+ column += width
120
+ end
121
+
122
+ rows
123
+ end
124
+ end
125
+
126
+ def read_character
127
+ character = @input.getch
128
+ raise AskTTY::Error, "terminal input closed" unless character
129
+ raise Interrupt if character == "\u0003"
130
+
131
+ character
132
+ end
133
+
134
+ def read_escape_sequence
135
+ sequence = +""
136
+
137
+ while @input.wait_readable(ESCAPE_TIMEOUT)
138
+ chunk = @input.read_nonblock(1, exception: false)
139
+ break if chunk == :wait_readable || chunk.nil?
140
+
141
+ raise Interrupt if chunk == "\u0003"
142
+
143
+ sequence << chunk
144
+
145
+ if sequence.length == 1
146
+ break unless ["[", "O"].include?(chunk)
147
+ elsif chunk.match?(/[@-~]/)
148
+ break
149
+ end
150
+ end
151
+
152
+ sequence
153
+ end
154
+
155
+ def read_paste
156
+ text = +""
157
+ text << read_character until text.end_with?("\e[201~")
158
+
159
+ Paste.new(text.delete_suffix("\e[201~"))
160
+ end
118
161
  end
119
162
  end
120
163
  end