cli-ui 1.2.1 โ†’ 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/cli/ui/glyph.rb CHANGED
@@ -5,6 +5,7 @@ module CLI
5
5
  class Glyph
6
6
  class InvalidGlyphHandle < ArgumentError
7
7
  def initialize(handle)
8
+ super
8
9
  @handle = handle
9
10
  end
10
11
 
@@ -15,7 +16,7 @@ module CLI
15
16
  end
16
17
  end
17
18
 
18
- attr_reader :handle, :codepoint, :color, :char, :to_s, :fmt
19
+ attr_reader :handle, :codepoint, :color, :to_s, :fmt
19
20
 
20
21
  # Creates a new glyph
21
22
  #
@@ -23,35 +24,40 @@ module CLI
23
24
  #
24
25
  # * +handle+ - The handle in the +MAP+ constant
25
26
  # * +codepoint+ - The codepoint used to create the glyph (e.g. +0x2717+ for a ballot X)
27
+ # * +plain+ - A fallback plain string to be used in case glyphs are disabled
26
28
  # * +color+ - What color to output the glyph. Check +CLI::UI::Color+ for options.
27
29
  #
28
- def initialize(handle, codepoint, color)
30
+ def initialize(handle, codepoint, plain, color)
29
31
  @handle = handle
30
32
  @codepoint = codepoint
31
33
  @color = color
32
- @char = [codepoint].pack('U')
34
+ @plain = plain
35
+ @char = Array(codepoint).pack('U*')
33
36
  @to_s = color.code + char + Color::RESET.code
34
37
  @fmt = "{{#{color.name}:#{char}}}"
35
38
 
36
39
  MAP[handle] = self
37
40
  end
38
41
 
42
+ # Fetches the actual character(s) to be displayed for a glyph, based on the current OS support
43
+ #
44
+ # ==== Returns
45
+ # Returns the glyph string
46
+ def char
47
+ CLI::UI::OS.current.supports_emoji? ? @char : @plain
48
+ end
49
+
39
50
  # Mapping of glyphs to terminal output
40
51
  MAP = {}
41
- # YELLOw SMALL STAR (โญ‘)
42
- STAR = new('*', 0x2b51, Color::YELLOW)
43
- # BLUE MATHEMATICAL SCRIPT SMALL i (๐’พ)
44
- INFO = new('i', 0x1d4be, Color::BLUE)
45
- # BLUE QUESTION MARK (?)
46
- QUESTION = new('?', 0x003f, Color::BLUE)
47
- # GREEN CHECK MARK (โœ“)
48
- CHECK = new('v', 0x2713, Color::GREEN)
49
- # RED BALLOT X (โœ—)
50
- X = new('x', 0x2717, Color::RED)
51
- # Bug emoji (๐Ÿ›)
52
- BUG = new('b', 0x1f41b, Color::WHITE)
53
- # RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK (ยป)
54
- CHEVRON = new('>', 0xbb, Color::YELLOW)
52
+ STAR = new('*', 0x2b51, '*', Color::YELLOW) # YELLOW SMALL STAR (โญ‘)
53
+ INFO = new('i', 0x1d4be, 'i', Color::BLUE) # BLUE MATHEMATICAL SCRIPT SMALL i (๐’พ)
54
+ QUESTION = new('?', 0x003f, '?', Color::BLUE) # BLUE QUESTION MARK (?)
55
+ CHECK = new('v', 0x2713, 'โˆš', Color::GREEN) # GREEN CHECK MARK (โœ“)
56
+ X = new('x', 0x2717, 'X', Color::RED) # RED BALLOT X (โœ—)
57
+ BUG = new('b', 0x1f41b, '!', Color::WHITE) # Bug emoji (๐Ÿ›)
58
+ CHEVRON = new('>', 0xbb, 'ยป', Color::YELLOW) # RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK (ยป)
59
+ HOURGLASS = new('H', [0x231b, 0xfe0e], 'H', Color::BLUE) # HOURGLASS + VARIATION SELECTOR 15 (โŒ›๏ธŽ)
60
+ WARNING = new('!', [0x26a0, 0xfe0f], '!', Color::YELLOW) # WARNING SIGN + VARIATION SELECTOR 16 (โš ๏ธ )
55
61
 
56
62
  # Looks up a glyph by name
57
63
  #
data/lib/cli/ui/os.rb ADDED
@@ -0,0 +1,67 @@
1
+ require 'rbconfig'
2
+
3
+ module CLI
4
+ module UI
5
+ module OS
6
+ # Determines which OS is currently running the UI, to make it easier to
7
+ # adapt its behaviour to the features of the OS.
8
+ def self.current
9
+ @current_os ||= case RbConfig::CONFIG['host_os']
10
+ when /darwin/
11
+ Mac
12
+ when /linux/
13
+ Linux
14
+ else
15
+ if RUBY_PLATFORM !~ /cygwin/ && ENV['OS'] == 'Windows_NT'
16
+ Windows
17
+ else
18
+ raise "Could not determine OS from host_os #{RbConfig::CONFIG["host_os"]}"
19
+ end
20
+ end
21
+ end
22
+
23
+ class Mac
24
+ class << self
25
+ def supports_emoji?
26
+ true
27
+ end
28
+
29
+ def supports_color_prompt?
30
+ true
31
+ end
32
+
33
+ def supports_arrow_keys?
34
+ true
35
+ end
36
+
37
+ def shift_cursor_on_line_reset?
38
+ false
39
+ end
40
+ end
41
+ end
42
+
43
+ class Linux < Mac
44
+ end
45
+
46
+ class Windows
47
+ class << self
48
+ def supports_emoji?
49
+ false
50
+ end
51
+
52
+ def supports_color_prompt?
53
+ false
54
+ end
55
+
56
+ def supports_arrow_keys?
57
+ false
58
+ end
59
+
60
+ def shift_cursor_on_line_reset?
61
+ true
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,59 @@
1
+ require 'cli/ui'
2
+
3
+ module CLI
4
+ module UI
5
+ class Printer
6
+ # Print a message to a stream with common utilities.
7
+ # Allows overriding the color, encoding, and target stream.
8
+ # By default, it formats the string using CLI:UI and rescues common stream errors.
9
+ #
10
+ # ==== Attributes
11
+ #
12
+ # * +msg+ - (required) the string to output. Can be frozen.
13
+ #
14
+ # ==== Options
15
+ #
16
+ # * +:frame_color+ - Override the frame color. Defaults to nil.
17
+ # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with a puts method. Defaults to $stdout.
18
+ # * +:encoding+ - Force the output to be in a certain encoding. Defaults to UTF-8.
19
+ # * +:format+ - Whether to format the string using CLI::UI.fmt. Defaults to true.
20
+ # * +:graceful+ - Whether to gracefully ignore common I/O errors. Defaults to true.
21
+ # * +:wrap+ - Whether to wrap text at word boundaries to terminal width. Defaults to true.
22
+ #
23
+ # ==== Returns
24
+ # Returns whether the message was successfully printed,
25
+ # which can be useful if +:graceful+ is set to true.
26
+ #
27
+ # ==== Example
28
+ #
29
+ # CLI::UI::Printer.puts('{{x}} Ouch', to: $stderr)
30
+ #
31
+ def self.puts(
32
+ msg,
33
+ frame_color:
34
+ nil,
35
+ to:
36
+ $stdout,
37
+ encoding: Encoding::UTF_8,
38
+ format: true,
39
+ graceful: true,
40
+ wrap: true
41
+ )
42
+ msg = (+msg).force_encoding(encoding) if encoding
43
+ msg = CLI::UI.fmt(msg) if format
44
+ msg = CLI::UI.wrap(msg) if wrap
45
+
46
+ if frame_color
47
+ CLI::UI::Frame.with_frame_color_override(frame_color) { to.puts(msg) }
48
+ else
49
+ to.puts(msg)
50
+ end
51
+
52
+ true
53
+ rescue Errno::EIO, Errno::EPIPE, IOError => e
54
+ raise(e) unless graceful
55
+ false
56
+ end
57
+ end
58
+ end
59
+ end
@@ -26,11 +26,11 @@ module CLI
26
26
  #
27
27
  # Increase the percent by X
28
28
  # CLI::UI::Progress.progress do |bar|
29
- # bar.tick(percent: 5)
29
+ # bar.tick(percent: 0.05)
30
30
  # end
31
31
  def self.progress(width: Terminal.width)
32
32
  bar = Progress.new(width: width)
33
- print CLI::UI::ANSI.hide_cursor
33
+ print(CLI::UI::ANSI.hide_cursor)
34
34
  yield(bar)
35
35
  ensure
36
36
  puts bar.to_s
@@ -59,29 +59,31 @@ module CLI
59
59
  # * +:percent+ - Increment progress by a specific percent amount
60
60
  # * +:set_percent+ - Set progress to a specific percent
61
61
  #
62
+ # *Note:* The +:percent+ and +:set_percent must be between 0.00 and 1.0
63
+ #
62
64
  def tick(percent: 0.01, set_percent: nil)
63
65
  raise ArgumentError, 'percent and set_percent cannot both be specified' if percent != 0.01 && set_percent
64
66
  @percent_done += percent
65
67
  @percent_done = set_percent if set_percent
66
68
  @percent_done = [@percent_done, 1.0].min # Make sure we can't go above 1.0
67
69
 
68
- print to_s
69
- print CLI::UI::ANSI.previous_line + "\n"
70
+ print(to_s)
71
+ print(CLI::UI::ANSI.previous_line + "\n")
70
72
  end
71
73
 
72
74
  # Format the progress bar to be printed to terminal
73
75
  #
74
76
  def to_s
75
- suffix = " #{(@percent_done * 100).round(2)}%"
77
+ suffix = " #{(@percent_done * 100).floor}%".ljust(5)
76
78
  workable_width = @max_width - Frame.prefix_width - suffix.size
77
79
  filled = [(@percent_done * workable_width.to_f).ceil, 0].max
78
80
  unfilled = [workable_width - filled, 0].max
79
81
 
80
- CLI::UI.resolve_text [
82
+ CLI::UI.resolve_text([
81
83
  FILLED_BAR + ' ' * filled,
82
84
  UNFILLED_BAR + ' ' * unfilled,
83
- CLI::UI::Color::RESET.code + suffix
84
- ].join
85
+ CLI::UI::Color::RESET.code + suffix,
86
+ ].join)
85
87
  end
86
88
  end
87
89
  end
data/lib/cli/ui/prompt.rb CHANGED
@@ -1,6 +1,22 @@
1
+ # coding: utf-8
1
2
  require 'cli/ui'
2
3
  require 'readline'
3
4
 
5
+ module Readline
6
+ unless const_defined?(:FILENAME_COMPLETION_PROC)
7
+ FILENAME_COMPLETION_PROC = proc do |input|
8
+ directory = input[-1] == '/' ? input : File.dirname(input)
9
+ filename = input[-1] == '/' ? '' : File.basename(input)
10
+
11
+ (Dir.entries(directory).select do |fp|
12
+ fp.start_with?(filename)
13
+ end - (input[-1] == '.' ? [] : ['.', '..'])).map do |fp|
14
+ File.join(directory, fp).gsub(/\A\.\//, '')
15
+ end
16
+ end
17
+ end
18
+ end
19
+
4
20
  module CLI
5
21
  module UI
6
22
  module Prompt
@@ -30,11 +46,16 @@ module CLI
30
46
  # * +:default+ - The default answer to the question (e.g. they just press enter and don't input anything)
31
47
  # * +:is_file+ - Tells the input to use file auto-completion (tab completion)
32
48
  # * +:allow_empty+ - Allows the answer to be empty
49
+ # * +:multiple+ - Allow multiple options to be selected
50
+ # * +:filter_ui+ - Enable option filtering (default: true)
51
+ # * +:select_ui+ - Enable long-form option selection (default: true)
33
52
  #
34
53
  # Note:
35
- # * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+, you cannot set options with either of these keywords
54
+ # * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+,
55
+ # you cannot set options with either of these keywords
36
56
  # * +:default+ conflicts with +:allow_empty:, you cannot set these together
37
57
  # * +:options+ conflicts with providing a +Block+ , you may only set one
58
+ # * +:multiple+ can only be used with +:options+ or a +Block+; it is ignored, otherwise.
38
59
  #
39
60
  # ==== Block (optional)
40
61
  #
@@ -44,7 +65,7 @@ module CLI
44
65
  # ==== Return Value
45
66
  #
46
67
  # * If a +Block+ was not provided, the selected option or response to the free form question will be returned
47
- # * If a +Block+ was provided, the evaluted value of the +Block+ will be returned
68
+ # * If a +Block+ was provided, the evaluated value of the +Block+ will be returned
48
69
  #
49
70
  # ==== Example Usage:
50
71
  #
@@ -71,18 +92,67 @@ module CLI
71
92
  # handler.option('python') { |selection| selection }
72
93
  # end
73
94
  #
74
- def ask(question, options: nil, default: nil, is_file: nil, allow_empty: true, multiple: false, &options_proc)
75
- if ((options || block_given?) && (default || is_file))
95
+ def ask(
96
+ question,
97
+ options: nil,
98
+ default: nil,
99
+ is_file: nil,
100
+ allow_empty: true,
101
+ multiple: false,
102
+ filter_ui: true,
103
+ select_ui: true,
104
+ &options_proc
105
+ )
106
+ if (options || block_given?) && ((default && !multiple) || is_file)
76
107
  raise(ArgumentError, 'conflicting arguments: options provided with default or is_file')
77
108
  end
78
109
 
110
+ if options && multiple && default && !(default - options).empty?
111
+ raise(ArgumentError, 'conflicting arguments: default should only include elements present in options')
112
+ end
113
+
79
114
  if options || block_given?
80
- ask_interactive(question, options, multiple: multiple, &options_proc)
115
+ ask_interactive(
116
+ question,
117
+ options,
118
+ multiple: multiple,
119
+ default: default,
120
+ filter_ui: filter_ui,
121
+ select_ui: select_ui,
122
+ &options_proc
123
+ )
81
124
  else
82
125
  ask_free_form(question, default, is_file, allow_empty)
83
126
  end
84
127
  end
85
128
 
129
+ # Asks the user for a single-line answer, without displaying the characters while typing.
130
+ # Typically used for password prompts
131
+ #
132
+ # ==== Return Value
133
+ #
134
+ # The password, without a trailing newline.
135
+ # If the user simply presses "Enter" without typing any password, this will return an empty string.
136
+ def ask_password(question)
137
+ require 'io/console'
138
+
139
+ CLI::UI.with_frame_color(:blue) do
140
+ STDOUT.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
141
+
142
+ # noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
143
+ # No fancy Readline integration (like echoing back) is required for a password prompt anyway.
144
+ password = STDIN.noecho do
145
+ # Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
146
+ # " 123 \n".chomp => " 123 "
147
+ STDIN.gets.chomp
148
+ end
149
+
150
+ STDOUT.puts # Complete the line
151
+
152
+ password
153
+ end
154
+ end
155
+
86
156
  # Asks the user a yes/no question.
87
157
  # Can use arrows, y/n, numbers (1/2), and vim bindings to control
88
158
  #
@@ -91,14 +161,18 @@ module CLI
91
161
  # Confirmation question
92
162
  # CLI::UI::Prompt.confirm('Is the sky blue?')
93
163
  #
94
- def confirm(question)
95
- ask_interactive(question, %w(yes no)) == 'yes'
164
+ # CLI::UI::Prompt.confirm('Do a dangerous thing?', default: false)
165
+ #
166
+ def confirm(question, default: true)
167
+ ask_interactive(question, default ? %w(yes no) : %w(no yes), filter_ui: false) == 'yes'
96
168
  end
97
169
 
98
170
  private
99
171
 
100
172
  def ask_free_form(question, default, is_file, allow_empty)
101
- raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false') if (default && !allow_empty)
173
+ if default && !allow_empty
174
+ raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false')
175
+ end
102
176
 
103
177
  if default
104
178
  puts_question("#{question} (empty = #{default})")
@@ -121,7 +195,7 @@ module CLI
121
195
  end
122
196
  end
123
197
 
124
- def ask_interactive(question, options = nil, multiple: false)
198
+ def ask_interactive(question, options = nil, multiple: false, default: nil, filter_ui: true, select_ui: true)
125
199
  raise(ArgumentError, 'conflicting arguments: options and block given') if options && block_given?
126
200
 
127
201
  options ||= if block_given?
@@ -130,24 +204,32 @@ module CLI
130
204
  handler.options
131
205
  end
132
206
 
133
- raise(ArgumentError, 'insufficient options') if options.nil? || options.size < 2
134
- instructions = (multiple ? "Toggle options. " : "") + "Choose with โ†‘ โ†“ โŽ"
207
+ raise(ArgumentError, 'insufficient options') if options.nil? || options.empty?
208
+ navigate_text = if CLI::UI::OS.current.supports_arrow_keys?
209
+ 'Choose with โ†‘ โ†“ โŽ'
210
+ else
211
+ "Navigate up with 'k' and down with 'j', press Enter to select"
212
+ end
213
+
214
+ instructions = (multiple ? 'Toggle options. ' : '') + navigate_text
215
+ instructions += ", filter with 'f'" if filter_ui
216
+ instructions += ", enter option with 'e'" if select_ui && (options.size > 9)
135
217
  puts_question("#{question} {{yellow:(#{instructions})}}")
136
- resp = interactive_prompt(options, multiple: multiple)
218
+ resp = interactive_prompt(options, multiple: multiple, default: default)
137
219
 
138
220
  # Clear the line
139
- print ANSI.previous_line + ANSI.clear_to_end_of_line
221
+ print(ANSI.previous_line + ANSI.clear_to_end_of_line)
140
222
  # Force StdoutRouter to prefix
141
- print ANSI.previous_line + "\n"
223
+ print(ANSI.previous_line + "\n")
142
224
 
143
225
  # reset the question to include the answer
144
226
  resp_text = resp
145
227
  if multiple
146
228
  resp_text = case resp.size
147
229
  when 0
148
- "<nothing>"
230
+ '<nothing>'
149
231
  when 1..2
150
- resp.join(" and ")
232
+ resp.join(' and ')
151
233
  else
152
234
  "#{resp.size} items"
153
235
  end
@@ -159,8 +241,8 @@ module CLI
159
241
  end
160
242
 
161
243
  # Useful for stubbing in tests
162
- def interactive_prompt(options, multiple: false)
163
- InteractiveOptions.call(options, multiple: multiple)
244
+ def interactive_prompt(options, multiple: false, default: nil)
245
+ InteractiveOptions.call(options, multiple: multiple, default: default)
164
246
  end
165
247
 
166
248
  def write_default_over_empty_input(default)
@@ -184,10 +266,10 @@ module CLI
184
266
  def readline(is_file: false)
185
267
  if is_file
186
268
  Readline.completion_proc = Readline::FILENAME_COMPLETION_PROC
187
- Readline.completion_append_character = ""
269
+ Readline.completion_append_character = ''
188
270
  else
189
271
  Readline.completion_proc = proc { |*| nil }
190
- Readline.completion_append_character = " "
272
+ Readline.completion_append_character = ' '
191
273
  end
192
274
 
193
275
  # because Readline is a C library, CLI::UI's hooks into $stdout don't
@@ -195,10 +277,14 @@ module CLI
195
277
  # thread to manage output, but the current strategy feels like a
196
278
  # better tradeoff.
197
279
  prefix = CLI::UI.with_frame_color(:blue) { CLI::UI::Frame.prefix }
198
- prompt = prefix + CLI::UI.fmt('{{blue:> }}{{yellow:')
280
+ # If a prompt is interrupted on Windows it locks the colour of the terminal from that point on, so we should
281
+ # not change the colour here.
282
+ prompt = prefix + CLI::UI.fmt('{{blue:> }}')
283
+ prompt += CLI::UI::Color::YELLOW.code if CLI::UI::OS.current.supports_color_prompt?
199
284
 
200
285
  begin
201
286
  line = Readline.readline(prompt, true)
287
+ print(CLI::UI::Color::RESET.code)
202
288
  line.to_s.chomp
203
289
  rescue Interrupt
204
290
  CLI::UI.raw { STDERR.puts('^C' + CLI::UI::Color::RESET.code) }