cli-ui 1.2.0 โ†’ 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,39 @@ 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 (โŒ›๏ธŽ)
55
60
 
56
61
  # Looks up a glyph by name
57
62
  #
@@ -0,0 +1,63 @@
1
+ module CLI
2
+ module UI
3
+ module OS
4
+ # Determines which OS is currently running the UI, to make it easier to
5
+ # adapt its behaviour to the features of the OS.
6
+ def self.current
7
+ @current_os ||= case RUBY_PLATFORM
8
+ when /darwin/
9
+ Mac
10
+ when /linux/
11
+ Linux
12
+ when /mingw32/
13
+ Windows
14
+ else
15
+ raise "Could not determine OS from platform #{RUBY_PLATFORM}"
16
+ end
17
+ end
18
+
19
+ class Mac
20
+ class << self
21
+ def supports_emoji?
22
+ true
23
+ end
24
+
25
+ def supports_color_prompt?
26
+ true
27
+ end
28
+
29
+ def supports_arrow_keys?
30
+ true
31
+ end
32
+
33
+ def shift_cursor_on_line_reset?
34
+ false
35
+ end
36
+ end
37
+ end
38
+
39
+ class Linux < Mac
40
+ end
41
+
42
+ class Windows
43
+ class << self
44
+ def supports_emoji?
45
+ false
46
+ end
47
+
48
+ def supports_color_prompt?
49
+ false
50
+ end
51
+
52
+ def supports_arrow_keys?
53
+ false
54
+ end
55
+
56
+ def shift_cursor_on_line_reset?
57
+ true
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,47 @@
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
+ #
22
+ # ==== Returns
23
+ # Returns whether the message was successfully printed,
24
+ # which can be useful if +:graceful+ is set to true.
25
+ #
26
+ # ==== Example
27
+ #
28
+ # CLI::UI::Printer.puts('{x} Ouch', stream: $stderr, color: :red)
29
+ #
30
+ def self.puts(msg, frame_color: nil, to: $stdout, encoding: Encoding::UTF_8, format: true, graceful: true)
31
+ msg = (+msg).force_encoding(encoding) if encoding
32
+ msg = CLI::UI.fmt(msg) if format
33
+
34
+ if frame_color
35
+ CLI::UI::Frame.with_frame_color_override(frame_color) { to.puts(msg) }
36
+ else
37
+ to.puts(msg)
38
+ end
39
+
40
+ true
41
+ rescue Errno::EIO, Errno::EPIPE, IOError => e
42
+ raise(e) unless graceful
43
+ false
44
+ end
45
+ end
46
+ end
47
+ 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
@@ -1,3 +1,4 @@
1
+ # coding: utf-8
1
2
  require 'cli/ui'
2
3
  require 'readline'
3
4
 
@@ -30,11 +31,16 @@ module CLI
30
31
  # * +:default+ - The default answer to the question (e.g. they just press enter and don't input anything)
31
32
  # * +:is_file+ - Tells the input to use file auto-completion (tab completion)
32
33
  # * +:allow_empty+ - Allows the answer to be empty
34
+ # * +:multiple+ - Allow multiple options to be selected
35
+ # * +:filter_ui+ - Enable option filtering (default: true)
36
+ # * +:select_ui+ - Enable long-form option selection (default: true)
33
37
  #
34
38
  # Note:
35
- # * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+, you cannot set options with either of these keywords
39
+ # * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+,
40
+ # you cannot set options with either of these keywords
36
41
  # * +:default+ conflicts with +:allow_empty:, you cannot set these together
37
42
  # * +:options+ conflicts with providing a +Block+ , you may only set one
43
+ # * +:multiple+ can only be used with +:options+ or a +Block+; it is ignored, otherwise.
38
44
  #
39
45
  # ==== Block (optional)
40
46
  #
@@ -44,7 +50,7 @@ module CLI
44
50
  # ==== Return Value
45
51
  #
46
52
  # * 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
53
+ # * If a +Block+ was provided, the evaluated value of the +Block+ will be returned
48
54
  #
49
55
  # ==== Example Usage:
50
56
  #
@@ -71,18 +77,67 @@ module CLI
71
77
  # handler.option('python') { |selection| selection }
72
78
  # end
73
79
  #
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))
80
+ def ask(
81
+ question,
82
+ options: nil,
83
+ default: nil,
84
+ is_file: nil,
85
+ allow_empty: true,
86
+ multiple: false,
87
+ filter_ui: true,
88
+ select_ui: true,
89
+ &options_proc
90
+ )
91
+ if (options || block_given?) && ((default && !multiple) || is_file)
76
92
  raise(ArgumentError, 'conflicting arguments: options provided with default or is_file')
77
93
  end
78
94
 
95
+ if options && multiple && default && !(default - options).empty?
96
+ raise(ArgumentError, 'conflicting arguments: default should only include elements present in options')
97
+ end
98
+
79
99
  if options || block_given?
80
- ask_interactive(question, options, multiple: multiple, &options_proc)
100
+ ask_interactive(
101
+ question,
102
+ options,
103
+ multiple: multiple,
104
+ default: default,
105
+ filter_ui: filter_ui,
106
+ select_ui: select_ui,
107
+ &options_proc
108
+ )
81
109
  else
82
110
  ask_free_form(question, default, is_file, allow_empty)
83
111
  end
84
112
  end
85
113
 
114
+ # Asks the user for a single-line answer, without displaying the characters while typing.
115
+ # Typically used for password prompts
116
+ #
117
+ # ==== Return Value
118
+ #
119
+ # The password, without a trailing newline.
120
+ # If the user simply presses "Enter" without typing any password, this will return an empty string.
121
+ def ask_password(question)
122
+ require 'io/console'
123
+
124
+ CLI::UI.with_frame_color(:blue) do
125
+ STDOUT.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
126
+
127
+ # noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
128
+ # No fancy Readline integration (like echoing back) is required for a password prompt anyway.
129
+ password = STDIN.noecho do
130
+ # Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
131
+ # " 123 \n".chomp => " 123 "
132
+ STDIN.gets.chomp
133
+ end
134
+
135
+ STDOUT.puts # Complete the line
136
+
137
+ password
138
+ end
139
+ end
140
+
86
141
  # Asks the user a yes/no question.
87
142
  # Can use arrows, y/n, numbers (1/2), and vim bindings to control
88
143
  #
@@ -91,14 +146,18 @@ module CLI
91
146
  # Confirmation question
92
147
  # CLI::UI::Prompt.confirm('Is the sky blue?')
93
148
  #
94
- def confirm(question)
95
- ask_interactive(question, %w(yes no)) == 'yes'
149
+ # CLI::UI::Prompt.confirm('Do a dangerous thing?', default: false)
150
+ #
151
+ def confirm(question, default: true)
152
+ ask_interactive(question, default ? %w(yes no) : %w(no yes), filter_ui: false) == 'yes'
96
153
  end
97
154
 
98
155
  private
99
156
 
100
157
  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)
158
+ if default && !allow_empty
159
+ raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false')
160
+ end
102
161
 
103
162
  if default
104
163
  puts_question("#{question} (empty = #{default})")
@@ -121,7 +180,7 @@ module CLI
121
180
  end
122
181
  end
123
182
 
124
- def ask_interactive(question, options = nil, multiple: false)
183
+ def ask_interactive(question, options = nil, multiple: false, default: nil, filter_ui: true, select_ui: true)
125
184
  raise(ArgumentError, 'conflicting arguments: options and block given') if options && block_given?
126
185
 
127
186
  options ||= if block_given?
@@ -130,15 +189,23 @@ module CLI
130
189
  handler.options
131
190
  end
132
191
 
133
- raise(ArgumentError, 'insufficient options') if options.nil? || options.size < 2
134
- instructions = (multiple ? "Toggle options. " : "") + "Choose with โ†‘ โ†“ โŽ"
192
+ raise(ArgumentError, 'insufficient options') if options.nil? || options.empty?
193
+ navigate_text = if CLI::UI::OS.current.supports_arrow_keys?
194
+ "Choose with โ†‘ โ†“ โŽ"
195
+ else
196
+ "Navigate up with 'k' and down with 'j', press Enter to select"
197
+ end
198
+
199
+ instructions = (multiple ? "Toggle options. " : "") + navigate_text
200
+ instructions += ", filter with 'f'" if filter_ui
201
+ instructions += ", enter option with 'e'" if select_ui && (options.size > 9)
135
202
  puts_question("#{question} {{yellow:(#{instructions})}}")
136
- resp = interactive_prompt(options, multiple: multiple)
203
+ resp = interactive_prompt(options, multiple: multiple, default: default)
137
204
 
138
205
  # Clear the line
139
- print ANSI.previous_line + ANSI.clear_to_end_of_line
206
+ print(ANSI.previous_line + ANSI.clear_to_end_of_line)
140
207
  # Force StdoutRouter to prefix
141
- print ANSI.previous_line + "\n"
208
+ print(ANSI.previous_line + "\n")
142
209
 
143
210
  # reset the question to include the answer
144
211
  resp_text = resp
@@ -159,8 +226,8 @@ module CLI
159
226
  end
160
227
 
161
228
  # Useful for stubbing in tests
162
- def interactive_prompt(options, multiple: false)
163
- InteractiveOptions.call(options, multiple: multiple)
229
+ def interactive_prompt(options, multiple: false, default: nil)
230
+ InteractiveOptions.call(options, multiple: multiple, default: default)
164
231
  end
165
232
 
166
233
  def write_default_over_empty_input(default)
@@ -195,10 +262,14 @@ module CLI
195
262
  # thread to manage output, but the current strategy feels like a
196
263
  # better tradeoff.
197
264
  prefix = CLI::UI.with_frame_color(:blue) { CLI::UI::Frame.prefix }
198
- prompt = prefix + CLI::UI.fmt('{{blue:> }}{{yellow:')
265
+ # If a prompt is interrupted on Windows it locks the colour of the terminal from that point on, so we should
266
+ # not change the colour here.
267
+ prompt = prefix + CLI::UI.fmt('{{blue:> }}')
268
+ prompt += CLI::UI::Color::YELLOW.code if CLI::UI::OS.current.supports_color_prompt?
199
269
 
200
270
  begin
201
271
  line = Readline.readline(prompt, true)
272
+ print(CLI::UI::Color::RESET.code)
202
273
  line.to_s.chomp
203
274
  rescue Interrupt
204
275
  CLI::UI.raw { STDERR.puts('^C' + CLI::UI::Color::RESET.code) }
@@ -1,3 +1,4 @@
1
+ # coding: utf-8
1
2
  require 'io/console'
2
3
 
3
4
  module CLI
@@ -10,6 +11,9 @@ module CLI
10
11
  # Prompts the user with options
11
12
  # Uses an interactive session to allow the user to pick an answer
12
13
  # Can use arrows, y/n, numbers (1/2), and vim bindings to control
14
+ # For more than 9 options, hitting 'e', ':', or 'G' will enter select
15
+ # mode allowing the user to type in longer numbers
16
+ # Pressing 'f' or '/' will allow the user to filter the results
13
17
  #
14
18
  # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
15
19
  #
@@ -18,8 +22,8 @@ module CLI
18
22
  # Ask an interactive question
19
23
  # CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
20
24
  #
21
- def self.call(options, multiple: false)
22
- list = new(options, multiple: multiple)
25
+ def self.call(options, multiple: false, default: nil)
26
+ list = new(options, multiple: multiple, default: default)
23
27
  selected = list.call
24
28
  if multiple
25
29
  selected.map { |s| options[s - 1] }
@@ -35,23 +39,35 @@ module CLI
35
39
  #
36
40
  # CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
37
41
  #
38
- def initialize(options, multiple: false)
42
+ def initialize(options, multiple: false, default: nil)
39
43
  @options = options
40
44
  @active = 1
41
45
  @marker = '>'
42
46
  @answer = nil
43
47
  @state = :root
44
48
  @multiple = multiple
49
+ # Indicate that an extra line (the "metadata" line) is present and
50
+ # the terminal output should be drawn over when processing user input
51
+ @displaying_metadata = false
52
+ @filter = ''
45
53
  # 0-indexed array representing if selected
46
54
  # @options[0] is selected if @chosen[0]
47
- @chosen = Array.new(@options.size) { false } if multiple
55
+ if multiple
56
+ @chosen = if default
57
+ @options.map { |option| default.include?(option) }
58
+ else
59
+ Array.new(@options.size) { false }
60
+ end
61
+ end
48
62
  @redraw = true
63
+ @presented_options = []
49
64
  end
50
65
 
51
66
  # Calls the +InteractiveOptions+ and asks the question
52
67
  # Usually used from +self.call+
53
68
  #
54
69
  def call
70
+ calculate_option_line_lengths
55
71
  CLI::UI.raw { print(ANSI.hide_cursor) }
56
72
  while @answer.nil?
57
73
  render_options
@@ -59,6 +75,7 @@ module CLI
59
75
  reset_position
60
76
  end
61
77
  clear_output
78
+
62
79
  @answer
63
80
  ensure
64
81
  CLI::UI.raw do
@@ -68,46 +85,96 @@ module CLI
68
85
 
69
86
  private
70
87
 
71
- def reset_position
88
+ def calculate_option_line_lengths
89
+ @terminal_width_at_calculation_time = CLI::UI::Terminal.width
90
+ # options will be an array of questions but each option can be multi-line
91
+ # so to get the # of lines, you need to join then split
92
+
93
+ # since lines may be longer than the terminal is wide, we need to
94
+ # determine how many extra lines would be taken up by them
95
+ max_width = (@terminal_width_at_calculation_time -
96
+ @options.count.to_s.size - # Width of the displayed number
97
+ 5 - # Extra characters added during rendering
98
+ (@multiple ? 1 : 0) # Space for the checkbox, if rendered
99
+ ).to_f
100
+
101
+ @option_lengths = @options.map do |text|
102
+ width = 1 if text.empty?
103
+ width ||= text
104
+ .split("\n")
105
+ .reject(&:empty?)
106
+ .map { |l| (CLI::UI.fmt(l, enable_color: false).length / max_width).ceil }
107
+ .reduce(&:+)
108
+
109
+ width
110
+ end
111
+ end
112
+
113
+ def reset_position(number_of_lines = num_lines)
72
114
  # This will put us back at the beginning of the options
73
115
  # When we redraw the options, they will be overwritten
74
116
  CLI::UI.raw do
75
- num_lines.times { print(ANSI.previous_line) }
117
+ number_of_lines.times { print(ANSI.previous_line) }
76
118
  end
77
119
  end
78
120
 
79
- def clear_output
121
+ def clear_output(number_of_lines = num_lines)
80
122
  CLI::UI.raw do
81
123
  # Write over all lines with whitespace
82
- num_lines.times { puts(' ' * CLI::UI::Terminal.width) }
124
+ number_of_lines.times { puts(' ' * CLI::UI::Terminal.width) }
83
125
  end
84
- reset_position
126
+ reset_position(number_of_lines)
127
+
128
+ # Update if metadata is being displayed
129
+ # This must be done _after_ the output is cleared or it won't draw over
130
+ # the entire output
131
+ @displaying_metadata = display_metadata?
132
+ end
133
+
134
+ # Don't use this in place of +@displaying_metadata+, this updates too
135
+ # quickly to be useful when drawing to the screen.
136
+ def display_metadata?
137
+ filtering? || selecting? || has_filter?
85
138
  end
86
139
 
87
140
  def num_lines
88
- options = presented_options.map(&:first)
89
- # @options will be an array of questions but each option can be multi-line
90
- # so to get the # of lines, you need to join then split
141
+ calculate_option_line_lengths if terminal_width_changed?
91
142
 
92
- # empty_option_count is needed since empty option titles are omitted
93
- # from the line count when reject(&:empty?) is called
143
+ option_length = presented_options.reduce(0) do |total_length, (_, option_number)|
144
+ # Handle continuation markers and "Done" option when multiple is true
145
+ next total_length + 1 if option_number.nil? || option_number.zero?
146
+ total_length + @option_lengths[option_number - 1]
147
+ end
148
+
149
+ option_length + (@displaying_metadata ? 1 : 0)
150
+ end
94
151
 
95
- empty_option_count = options.count(&:empty?)
96
- joined_options = options.join("\n")
97
- joined_options.split("\n").reject(&:empty?).size + empty_option_count
152
+ def terminal_width_changed?
153
+ @terminal_width_at_calculation_time != CLI::UI::Terminal.width
98
154
  end
99
155
 
100
156
  ESC = "\e"
157
+ BACKSPACE = "\u007F"
158
+ CTRL_C = "\u0003"
159
+ CTRL_D = "\u0004"
101
160
 
102
161
  def up
103
- min_pos = @multiple ? 0 : 1
104
- @active = @active - 1 >= min_pos ? @active - 1 : @options.length
162
+ active_index = @filtered_options.index { |_, num| num == @active } || 0
163
+
164
+ previous_visible = @filtered_options[active_index - 1]
165
+ previous_visible ||= @filtered_options.last
166
+
167
+ @active = previous_visible ? previous_visible.last : -1
105
168
  @redraw = true
106
169
  end
107
170
 
108
171
  def down
109
- min_pos = @multiple ? 0 : 1
110
- @active = @active + 1 <= @options.length ? @active + 1 : min_pos
172
+ active_index = @filtered_options.index { |_, num| num == @active } || 0
173
+
174
+ next_visible = @filtered_options[active_index + 1]
175
+ next_visible ||= @filtered_options.first
176
+
177
+ @active = next_visible ? next_visible.last : -1
111
178
  @redraw = true
112
179
  end
113
180
 
@@ -141,7 +208,36 @@ module CLI
141
208
  @redraw = true
142
209
  end
143
210
 
211
+ def build_selection(char)
212
+ @active = (@active.to_s + char).to_i
213
+ @redraw = true
214
+ end
215
+
216
+ def chop_selection
217
+ @active = @active.to_s.chop.to_i
218
+ @redraw = true
219
+ end
220
+
221
+ def update_search(char)
222
+ @redraw = true
223
+
224
+ # Control+D or Backspace on empty search closes search
225
+ if (char == CTRL_D) || (@filter.empty? && (char == BACKSPACE))
226
+ @filter = ''
227
+ @state = :root
228
+ return
229
+ end
230
+
231
+ if char == BACKSPACE
232
+ @filter.chop!
233
+ else
234
+ @filter += char
235
+ end
236
+ end
237
+
144
238
  def select_current
239
+ # Prevent selection of invisible options
240
+ return unless presented_options.any? { |_, num| num == @active }
145
241
  select_n(@active)
146
242
  end
147
243
 
@@ -150,40 +246,93 @@ module CLI
150
246
  wait_for_user_input until @redraw
151
247
  end
152
248
 
153
- # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
249
+ # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon
154
250
  def wait_for_user_input
155
251
  char = read_char
252
+ @last_char = char
253
+
254
+ case char
255
+ when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
256
+ when CTRL_C ; raise Interrupt
257
+ end
258
+
259
+ max_digit = [@options.size, 9].min.to_s
156
260
  case @state
157
261
  when :root
158
262
  case char
159
- when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
160
- when ESC ; @state = :esc
161
- when 'k' ; up
162
- when 'j' ; down
163
- when '0' ; select_n(char.to_i)
164
- when ('1'..@options.size.to_s) ; select_n(char.to_i)
165
- when 'y', 'n' ; select_bool(char)
166
- when " ", "\r", "\n" ; select_current # <enter>
167
- when "\u0003" ; raise Interrupt # Ctrl-c
263
+ when ESC ; @state = :esc
264
+ when 'k' ; up
265
+ when 'j' ; down
266
+ when 'e', ':', 'G' ; start_line_select
267
+ when 'f', '/' ; start_filter
268
+ when ('0'..max_digit) ; select_n(char.to_i)
269
+ when 'y', 'n' ; select_bool(char)
270
+ when " ", "\r", "\n" ; select_current # <enter>
271
+ end
272
+ when :filter
273
+ case char
274
+ when ESC ; @state = :esc
275
+ when "\r", "\n" ; select_current
276
+ when "\b" ; update_search(BACKSPACE) # Happens on Windows
277
+ else ; update_search(char)
278
+ end
279
+ when :line_select
280
+ case char
281
+ when ESC ; @state = :esc
282
+ when 'k' ; up ; @state = :root
283
+ when 'j' ; down ; @state = :root
284
+ when 'e', ':', 'G', 'q' ; stop_line_select
285
+ when '0'..'9' ; build_selection(char)
286
+ when BACKSPACE ; chop_selection # Pop last input on backspace
287
+ when ' ', "\r", "\n" ; select_current
168
288
  end
169
289
  when :esc
170
290
  case char
171
- when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
172
291
  when '[' ; @state = :esc_bracket
173
292
  else ; raise Interrupt # unhandled escape sequence.
174
293
  end
175
294
  when :esc_bracket
176
- @state = :root
295
+ @state = has_filter? ? :filter : :root
177
296
  case char
178
- when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
179
297
  when 'A' ; up
180
298
  when 'B' ; down
299
+ when 'C' ; # Ignore right key
300
+ when 'D' ; # Ignore left key
181
301
  else ; raise Interrupt # unhandled escape sequence.
182
302
  end
183
303
  end
184
304
  end
185
305
  # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon
186
306
 
307
+ def selecting?
308
+ @state == :line_select
309
+ end
310
+
311
+ def filtering?
312
+ @state == :filter
313
+ end
314
+
315
+ def has_filter?
316
+ !@filter.empty?
317
+ end
318
+
319
+ def start_filter
320
+ @state = :filter
321
+ @redraw = true
322
+ end
323
+
324
+ def start_line_select
325
+ @state = :line_select
326
+ @active = 0
327
+ @redraw = true
328
+ end
329
+
330
+ def stop_line_select
331
+ @state = :root
332
+ @active = 1 if @active.zero?
333
+ @redraw = true
334
+ end
335
+
187
336
  def read_char
188
337
  raw_tty! do
189
338
  getc = $stdin.getc
@@ -205,9 +354,29 @@ module CLI
205
354
  return @presented_options unless recalculate
206
355
 
207
356
  @presented_options = @options.zip(1..Float::INFINITY)
208
- @presented_options.unshift([DONE, 0]) if @multiple
357
+ if has_filter?
358
+ @presented_options.select! { |option, _| option.downcase.include?(@filter.downcase) }
359
+ end
209
360
 
210
- while num_lines > max_options
361
+ # Used for selection purposes
362
+ @presented_options.push([DONE, 0]) if @multiple
363
+ @filtered_options = @presented_options.dup
364
+
365
+ ensure_visible_is_active if has_filter?
366
+
367
+ # Must have more lines before the selection than we can display
368
+ if distance_from_start_to_selection > max_lines
369
+ @presented_options.shift(distance_from_start_to_selection - max_lines)
370
+ ensure_first_item_is_continuation_marker
371
+ end
372
+
373
+ # Must have more lines after the selection than we can display
374
+ if distance_from_selection_to_end > max_lines
375
+ @presented_options.pop(distance_from_selection_to_end - max_lines)
376
+ ensure_last_item_is_continuation_marker
377
+ end
378
+
379
+ while num_lines > max_lines
211
380
  # try to keep the selection centered in the window:
212
381
  if distance_from_selection_to_end > distance_from_start_to_selection
213
382
  # selection is closer to top than bottom, so trim a row from the bottom
@@ -223,14 +392,22 @@ module CLI
223
392
  @presented_options
224
393
  end
225
394
 
395
+ def ensure_visible_is_active
396
+ unless presented_options.any? { |_, num| num == @active }
397
+ @active = presented_options.first&.last.to_i
398
+ end
399
+ end
400
+
226
401
  def distance_from_selection_to_end
227
- last_visible_option_number = @presented_options[-1].last || @presented_options[-2].last
228
- last_visible_option_number - @active
402
+ @presented_options.count - index_of_active_option
229
403
  end
230
404
 
231
405
  def distance_from_start_to_selection
232
- first_visible_option_number = @presented_options[0].last || @presented_options[1].last
233
- @active - first_visible_option_number
406
+ index_of_active_option
407
+ end
408
+
409
+ def index_of_active_option
410
+ @presented_options.index { |_, num| num == @active }.to_i
234
411
  end
235
412
 
236
413
  def ensure_last_item_is_continuation_marker
@@ -241,15 +418,39 @@ module CLI
241
418
  @presented_options.unshift(["...", nil]) if @presented_options.first.last
242
419
  end
243
420
 
244
- def max_options
245
- @max_options ||= CLI::UI::Terminal.height - 2 # Keeps a one line question visible
421
+ def max_lines
422
+ CLI::UI::Terminal.height - (@displaying_metadata ? 3 : 2) # Keeps a one line question visible
246
423
  end
247
424
 
248
425
  def render_options
426
+ previously_displayed_lines = num_lines
427
+
428
+ @displaying_metadata = display_metadata?
429
+
430
+ options = presented_options(recalculate: true)
431
+
432
+ clear_output(previously_displayed_lines) if previously_displayed_lines > num_lines
433
+
249
434
  max_num_length = (@options.size + 1).to_s.length
250
435
 
251
- presented_options(recalculate: true).each do |choice, num|
252
- is_chosen = @multiple && num && @chosen[num - 1]
436
+ metadata_text = if selecting?
437
+ select_text = @active
438
+ select_text = '{{info:e, q, or up/down anytime to exit}}' if @active == 0
439
+ "Select: #{select_text}"
440
+ elsif filtering? || has_filter?
441
+ filter_text = @filter
442
+ filter_text = '{{info:Ctrl-D anytime or Backspace now to exit}}' if @filter.empty?
443
+ "Filter: #{filter_text}"
444
+ end
445
+
446
+ if metadata_text
447
+ CLI::UI.with_frame_color(:blue) do
448
+ puts CLI::UI.fmt(" {{green:#{metadata_text}}}#{ANSI.clear_to_end_of_line}")
449
+ end
450
+ end
451
+
452
+ options.each do |choice, num|
453
+ is_chosen = @multiple && num && @chosen[num - 1] && num != 0
253
454
 
254
455
  padding = ' ' * (max_num_length - num.to_s.length)
255
456
  message = " #{num}#{num ? '.' : ' '}#{padding}"
@@ -260,20 +461,30 @@ module CLI
260
461
  format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
261
462
  format = " #{format}"
262
463
 
263
- message += sprintf(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
264
- message += choice.split("\n").map { |l| sprintf(format, l) }.join("\n")
464
+ message += format(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
465
+ message += format_choice(format, choice)
265
466
 
266
467
  if num == @active
267
- message = message.split("\n").map.with_index do |l, idx|
268
- idx == 0 ? "{{blue:> #{l.strip}}}" : "{{blue:>#{l.strip}}}"
269
- end.join("\n")
468
+
469
+ color = filtering? || selecting? ? 'green' : 'blue'
470
+ message = message.split("\n").map { |l| "{{#{color}:> #{l.strip}}}" }.join("\n")
270
471
  end
271
472
 
272
473
  CLI::UI.with_frame_color(:blue) do
273
- puts CLI::UI.fmt(message) + CLI::UI::ANSI.clear_to_end_of_line
474
+ puts CLI::UI.fmt(message)
274
475
  end
275
476
  end
276
477
  end
478
+
479
+ def format_choice(format, choice)
480
+ eol = CLI::UI::ANSI.clear_to_end_of_line
481
+ lines = choice.split("\n")
482
+
483
+ return eol if lines.empty? # Handle blank options
484
+
485
+ lines.map! { |l| format(format, l) + eol }
486
+ lines.join("\n")
487
+ end
277
488
  end
278
489
  end
279
490
  end