cli-ui 1.2.0 โ†’ 1.4.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.
@@ -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