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 +4 -4
- data/README.md +6 -0
- data/lib/asktty/internal/ansi_style.rb +19 -42
- data/lib/asktty/internal/prompt.rb +106 -0
- data/lib/asktty/internal/rendering.rb +56 -142
- data/lib/asktty/internal/terminal.rb +89 -46
- data/lib/asktty/prompts/confirm_prompt.rb +25 -79
- data/lib/asktty/prompts/input_prompt.rb +86 -13
- data/lib/asktty/prompts/multi_select_prompt.rb +25 -95
- data/lib/asktty/prompts/select_prompt.rb +67 -78
- data/lib/asktty/prompts/text_prompt.rb +5 -12
- data/lib/asktty/version.rb +1 -1
- data/lib/asktty.rb +2 -3
- metadata +2 -4
- data/lib/asktty/internal/options.rb +0 -33
- data/lib/asktty/internal/text_input_prompt.rb +0 -156
- data/lib/asktty/internal/validation.rb +0 -19
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ea8ecccbeef5886609092cc74ef77888a4c9a467947be62f42137e9c5174063
|
|
4
|
+
data.tar.gz: 7e26f19e4b7352daab2d9878f644e4724b48537bd30745592df4057813cbcc62
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
|
|
23
|
+
def apply(text, role)
|
|
24
|
+
"\e[#{PALETTE.fetch(role)}m#{text}\e[0m"
|
|
48
25
|
end
|
|
49
26
|
|
|
50
|
-
def
|
|
51
|
-
|
|
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
|
|
11
|
-
|
|
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(
|
|
17
|
+
Unicode::DisplayWidth.of(ANSIStyle.strip(text), ambiguous: 1)
|
|
16
18
|
end
|
|
17
19
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
return ANSIStyle.cursor unless first_grapheme
|
|
20
|
+
def frame(lines)
|
|
21
|
+
border = ANSIStyle.apply("┃", :muted)
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
lines.map { |line| "#{border} #{line}" }.join("\n")
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
def
|
|
26
|
-
|
|
26
|
+
def help_line(items, width:)
|
|
27
|
+
line = +""
|
|
28
|
+
total_width = 0
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
38
|
-
|
|
34
|
+
if total_width + segment_width > width
|
|
35
|
+
ellipsis = line.empty? ? "…" : " …"
|
|
39
36
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
41
|
+
line << ANSIStyle.apply("#{prefix}#{key} ", :text)
|
|
42
|
+
line << ANSIStyle.apply("(#{description})", :muted)
|
|
47
43
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
end
|
|
44
|
+
total_width += segment_width
|
|
45
|
+
end
|
|
51
46
|
|
|
52
|
-
|
|
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,
|
|
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 +
|
|
56
|
+
current_prefix + ANSIStyle.apply(line, style)
|
|
63
57
|
end
|
|
64
58
|
end
|
|
65
59
|
|
|
66
|
-
def
|
|
67
|
-
|
|
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
|
-
|
|
63
|
+
text.split("\n", -1)
|
|
88
64
|
end
|
|
89
65
|
|
|
90
|
-
def
|
|
91
|
-
|
|
92
|
-
|
|
66
|
+
def take_segment(graphemes, width)
|
|
67
|
+
last_whitespace_index = nil
|
|
68
|
+
segment = []
|
|
69
|
+
segment_width = 0
|
|
93
70
|
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
break
|
|
105
|
-
end
|
|
77
|
+
last_whitespace_index = index if grapheme.match?(/\s/)
|
|
78
|
+
segment << grapheme
|
|
79
|
+
segment_width += grapheme_width
|
|
106
80
|
|
|
107
|
-
|
|
108
|
-
total_width += segment_width
|
|
81
|
+
break if segment_width >= width
|
|
109
82
|
end
|
|
110
83
|
|
|
111
|
-
|
|
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
|
|
127
|
-
|
|
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
|
-
|
|
134
|
-
|
|
90
|
+
start = (focus - (height / 2)).clamp(0, lines.length - height)
|
|
91
|
+
lines.slice(start, height)
|
|
135
92
|
end
|
|
136
93
|
|
|
137
|
-
def
|
|
138
|
-
|
|
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.
|
|
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 =
|
|
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
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
ESCAPE_CONTINUATION_TIMEOUT = 0.01
|
|
9
|
+
Paste = Data.define(:text)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
ESCAPE_TIMEOUT = 0.1
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
@
|
|
25
|
-
@width =
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
|
|
65
|
+
def render
|
|
66
|
+
@height, @width = dimensions
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
73
|
+
@output.print text.gsub("\n", "\r\n")
|
|
74
74
|
@output.flush
|
|
75
75
|
|
|
76
|
-
@
|
|
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
|