cli-ui 1.2.1 โ†’ 1.5.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.
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) }