tty-prompt 0.18.1 → 0.19.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +174 -63
  4. data/Rakefile +2 -2
  5. data/examples/ask_blank.rb +9 -0
  6. data/examples/enum_select_disabled.rb +1 -1
  7. data/examples/enum_select_paged.rb +1 -1
  8. data/examples/expand_auto.rb +29 -0
  9. data/examples/mask.rb +1 -1
  10. data/examples/multi_select.rb +1 -1
  11. data/examples/multi_select_disabled_paged.rb +22 -0
  12. data/examples/multi_select_paged.rb +1 -1
  13. data/examples/select_disabled_paged.rb +22 -0
  14. data/examples/select_paginated.rb +1 -1
  15. data/lib/tty/prompt.rb +46 -10
  16. data/lib/tty/prompt/{enum_paginator.rb → block_paginator.rb} +19 -18
  17. data/lib/tty/prompt/choice.rb +1 -3
  18. data/lib/tty/prompt/enum_list.rb +31 -9
  19. data/lib/tty/prompt/expander.rb +19 -1
  20. data/lib/tty/prompt/keypress.rb +30 -35
  21. data/lib/tty/prompt/list.rb +112 -40
  22. data/lib/tty/prompt/mask_question.rb +2 -3
  23. data/lib/tty/prompt/multi_list.rb +36 -12
  24. data/lib/tty/prompt/paginator.rb +37 -25
  25. data/lib/tty/prompt/question.rb +29 -5
  26. data/lib/tty/prompt/slider.rb +16 -8
  27. data/lib/tty/prompt/symbols.rb +30 -6
  28. data/lib/tty/prompt/timer.rb +75 -0
  29. data/lib/tty/prompt/version.rb +1 -1
  30. data/spec/spec_helper.rb +18 -2
  31. data/spec/unit/ask_spec.rb +45 -4
  32. data/spec/unit/{enum_paginator_spec.rb → block_paginator_spec.rb} +14 -5
  33. data/spec/unit/choice/from_spec.rb +16 -0
  34. data/spec/unit/enum_select_spec.rb +104 -32
  35. data/spec/unit/expand_spec.rb +104 -12
  36. data/spec/unit/keypress_spec.rb +2 -8
  37. data/spec/unit/mask_spec.rb +9 -1
  38. data/spec/unit/multi_select_spec.rb +348 -118
  39. data/spec/unit/paginator_spec.rb +29 -10
  40. data/spec/unit/select_spec.rb +390 -108
  41. data/spec/unit/slider_spec.rb +48 -6
  42. data/spec/unit/timer_spec.rb +29 -0
  43. data/tty-prompt.gemspec +4 -6
  44. metadata +17 -46
  45. data/lib/tty/prompt/timeout.rb +0 -78
@@ -9,7 +9,7 @@ module TTY
9
9
  #
10
10
  # @api private
11
11
  class MultiList < List
12
- HELP = '(Use arrow%s keys, press Space to select and Enter to finish%s)'
12
+ HELP = '(Use %s arrow%s keys, press Space to select and Enter to finish%s)'
13
13
 
14
14
  # Create instance of TTY::Prompt::MultiList menu.
15
15
  #
@@ -17,11 +17,19 @@ module TTY
17
17
  # @param [Hash] options
18
18
  #
19
19
  # @api public
20
- def initialize(prompt, options)
20
+ def initialize(prompt, **options)
21
21
  super
22
22
  @selected = []
23
23
  @help = options[:help]
24
24
  @echo = options.fetch(:echo, true)
25
+ @max = options[:max]
26
+ end
27
+
28
+ # Set a maximum number of choices
29
+ #
30
+ # @api public
31
+ def max(value)
32
+ @max = value
25
33
  end
26
34
 
27
35
  # Callback fired when space key is pressed
@@ -32,6 +40,7 @@ module TTY
32
40
  if @selected.include?(active_choice)
33
41
  @selected.delete(active_choice)
34
42
  else
43
+ return if @max && @selected.size >= @max
35
44
  @selected << active_choice
36
45
  end
37
46
  end
@@ -62,20 +71,34 @@ module TTY
62
71
  @selected.map(&:name).join(', ')
63
72
  end
64
73
 
74
+ # Header part showing the maximum number of choices
75
+ #
76
+ # @return [String]
77
+ #
78
+ # @api private
79
+ def max_help
80
+ "(max. #{@max}) "
81
+ end
82
+
65
83
  # Render initial help text and then currently selected choices
66
84
  #
67
85
  # @api private
68
86
  def render_header
69
87
  instructions = @prompt.decorate(help, :bright_black)
88
+ max_suffix = @max ? max_help : ""
89
+
70
90
  if @done && @echo
71
91
  @prompt.decorate(selected_names, @active_color)
72
92
  elsif @selected.size.nonzero? && @echo
73
93
  help_suffix = filterable? && @filter.any? ? " #{filter_help}" : ""
74
- selected_names + (@first_render ? " #{instructions}" : help_suffix)
94
+ max_suffix + selected_names +
95
+ (@first_render ? " #{instructions}" : help_suffix)
75
96
  elsif @first_render
76
- instructions
97
+ max_suffix + instructions
77
98
  elsif filterable? && @filter.any?
78
- filter_help
99
+ max_suffix + filter_help
100
+ elsif @max
101
+ max_help
79
102
  end
80
103
  end
81
104
 
@@ -96,21 +119,22 @@ module TTY
96
119
  def render_menu
97
120
  output = []
98
121
 
99
- @paginator.paginate(choices, @active, @per_page) do |choice, index|
122
+ sync_paginators if @paging_changed
123
+ paginator.paginate(choices, @active, @per_page) do |choice, index|
100
124
  num = enumerate? ? (index + 1).to_s + @enum + ' ' : ''
101
- indicator = (index + 1 == @active) ? @marker : ' '
125
+ indicator = (index + 1 == @active) ? @symbols[:marker] : ' '
102
126
  indicator += ' '
103
127
  message = if @selected.include?(choice) && !choice.disabled?
104
- selected = @prompt.decorate(symbols[:radio_on], @active_color)
128
+ selected = @prompt.decorate(@symbols[:radio_on], @active_color)
105
129
  "#{selected} #{num}#{choice.name}"
106
130
  elsif choice.disabled?
107
- @prompt.decorate(symbols[:cross], :red) +
131
+ @prompt.decorate(@symbols[:cross], :red) +
108
132
  " #{num}#{choice.name} #{choice.disabled}"
109
133
  else
110
- "#{symbols[:radio_off]} #{num}#{choice.name}"
134
+ "#{@symbols[:radio_off]} #{num}#{choice.name}"
111
135
  end
112
- max_index = paginated? ? @paginator.max_index : choices.size - 1
113
- newline = (index == max_index) ? '' : "\n"
136
+ end_index = paginated? ? paginator.end_index : choices.size - 1
137
+ newline = (index == end_index) ? '' : "\n"
114
138
  output << indicator + message + newline
115
139
  end
116
140
 
@@ -5,23 +5,33 @@ module TTY
5
5
  class Paginator
6
6
  DEFAULT_PAGE_SIZE = 6
7
7
 
8
+ # The 0-based index of the first item on this page
9
+ attr_accessor :start_index
10
+
11
+ # The 0-based index of the last item on this page
12
+ attr_reader :end_index
13
+
14
+ # The 0-based index of the active item on this page
15
+ attr_reader :current_index
16
+
17
+ # The 0-based index of the previously active item on this page
18
+ attr_reader :last_index
19
+
8
20
  # Create a Paginator
9
21
  #
10
22
  # @api private
11
- def initialize(options = {})
23
+ def initialize(**options)
12
24
  @last_index = Array(options[:default]).flatten.first || 0
13
25
  @per_page = options[:per_page]
14
- @lower_index = Array(options[:default]).flatten.first
26
+ @start_index = Array(options[:default]).flatten.first
15
27
  end
16
28
 
17
- # Maximum index for current pagination
29
+ # Reset current page indexes
18
30
  #
19
- # @return [Integer]
20
- #
21
- # @api public
22
- def max_index
23
- raise ArgumentError, 'no max index' unless @per_page
24
- @lower_index + @per_page - 1
31
+ # @api private
32
+ def reset!
33
+ @start_index = nil
34
+ @end_index = nil
25
35
  end
26
36
 
27
37
  # Check if page size is valid
@@ -43,21 +53,21 @@ module TTY
43
53
  # number of choice items per page
44
54
  #
45
55
  # @return [Enumerable]
56
+ # the list between start and end index
46
57
  #
47
58
  # @api public
48
59
  def paginate(list, active, per_page = nil, &block)
49
60
  current_index = active - 1
50
61
  default_size = (list.size <= DEFAULT_PAGE_SIZE ? list.size : DEFAULT_PAGE_SIZE)
51
62
  @per_page = @per_page || per_page || default_size
52
- @lower_index ||= current_index
53
- @upper_index ||= max_index
54
-
55
63
  check_page_size!
64
+ @start_index ||= (current_index / @per_page) * @per_page
65
+ @end_index ||= @start_index + @per_page - 1
56
66
 
57
67
  # Don't paginate short lists
58
68
  if list.size <= @per_page
59
- @lower_index = 0
60
- @upper_index = list.size - 1
69
+ @start_index = 0
70
+ @end_index = list.size - 1
61
71
  if block
62
72
  return list.each_with_index(&block)
63
73
  else
@@ -65,33 +75,35 @@ module TTY
65
75
  end
66
76
  end
67
77
 
78
+ step = (current_index - @last_index).abs
68
79
  if current_index > @last_index # going up
69
- if current_index > @upper_index && current_index < list.size - 1
70
- @lower_index += 1
80
+ if current_index >= @end_index && current_index < list.size - 1
81
+ last_page = list.size - @per_page
82
+ @start_index = [@start_index + step, last_page].min
71
83
  end
72
84
  elsif current_index < @last_index # going down
73
- if current_index < @lower_index && current_index > 0
74
- @lower_index -= 1
85
+ if current_index <= @start_index && current_index > 0
86
+ @start_index = [@start_index - step, 0].max
75
87
  end
76
88
  end
77
89
 
78
90
  # Cycle list
79
91
  if current_index.zero?
80
- @lower_index = 0
92
+ @start_index = 0
81
93
  elsif current_index == list.size - 1
82
- @lower_index = list.size - 1 - (@per_page - 1)
94
+ @start_index = list.size - 1 - (@per_page - 1)
83
95
  end
84
96
 
85
- @upper_index = @lower_index + (@per_page - 1)
97
+ @end_index = @start_index + (@per_page - 1)
86
98
  @last_index = current_index
87
99
 
88
- sliced_list = list[@lower_index..@upper_index]
89
- indices = (@lower_index..@upper_index)
100
+ sliced_list = list[@start_index..@end_index]
101
+ page_range = (@start_index..@end_index)
90
102
 
91
- return sliced_list.zip(indices).to_enum unless block_given?
103
+ return sliced_list.zip(page_range).to_enum unless block_given?
92
104
 
93
105
  sliced_list.each_with_index do |item, index|
94
- block[item, @lower_index + index]
106
+ block[item, @start_index + index]
95
107
  end
96
108
  end
97
109
  end # Paginator
@@ -34,7 +34,7 @@ module TTY
34
34
  # Initialize a Question
35
35
  #
36
36
  # @api public
37
- def initialize(prompt, options = {})
37
+ def initialize(prompt, **options)
38
38
  @prompt = prompt
39
39
  @prefix = options.fetch(:prefix) { @prompt.prefix }
40
40
  @default = options.fetch(:default) { UndefinedSetting }
@@ -47,8 +47,10 @@ module TTY
47
47
  @active_color = options.fetch(:active_color) { @prompt.active_color }
48
48
  @help_color = options.fetch(:help_color) { @prompt.help_color }
49
49
  @error_color = options.fetch(:error_color) { :red }
50
+ @value = options.fetch(:value) { UndefinedSetting }
50
51
  @messages = Utils.deep_copy(options.fetch(:messages) { { } })
51
52
  @done = false
53
+ @first_render = true
52
54
  @input = nil
53
55
 
54
56
  @evaluator = Evaluator.new(self)
@@ -94,8 +96,7 @@ module TTY
94
96
  # @return [self]
95
97
  #
96
98
  # @api public
97
- def call(message, &block)
98
- return if Utils.blank?(message)
99
+ def call(message = '', &block)
99
100
  @message = message
100
101
  block.call(self) if block
101
102
  @prompt.subscribe(self) do
@@ -131,7 +132,10 @@ module TTY
131
132
  #
132
133
  # @api private
133
134
  def render_question
134
- header = ["#{@prefix}#{message} "]
135
+ header = []
136
+ if !Utils.blank?(@prefix) || !Utils.blank?(message)
137
+ header << "#{@prefix}#{message} "
138
+ end
135
139
  if !echo?
136
140
  header
137
141
  elsif @done
@@ -158,7 +162,12 @@ module TTY
158
162
  #
159
163
  # @api private
160
164
  def read_input(question)
161
- @prompt.read_line(question, echo: echo).chomp
165
+ options = {echo: echo}
166
+ if value? && @first_render
167
+ options[:value] = @value
168
+ @first_render = false
169
+ end
170
+ @prompt.read_line(question, options).chomp
162
171
  end
163
172
 
164
173
  # Handle error condition
@@ -266,6 +275,21 @@ module TTY
266
275
  @validation = (value || block)
267
276
  end
268
277
 
278
+ # Prepopulate input with custom content
279
+ #
280
+ # @api public
281
+ def value(val)
282
+ return @value if val.nil?
283
+ @value = val
284
+ end
285
+
286
+ # Check if custom value is present
287
+ #
288
+ # @api private
289
+ def value?
290
+ @value != UndefinedSetting
291
+ end
292
+
269
293
  def validation?
270
294
  @validation != UndefinedSetting
271
295
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'symbols'
4
-
5
3
  module TTY
6
4
  # A class responsible for shell prompt interactions.
7
5
  class Prompt
@@ -9,8 +7,6 @@ module TTY
9
7
  #
10
8
  # @api public
11
9
  class Slider
12
- include Symbols
13
-
14
10
  HELP = '(Use arrow keys, press Enter to select)'.freeze
15
11
 
16
12
  FORMAT = ':slider %d'.freeze
@@ -27,7 +23,7 @@ module TTY
27
23
  # @option options [String] :format The display format
28
24
  #
29
25
  # @api public
30
- def initialize(prompt, options = {})
26
+ def initialize(prompt, **options)
31
27
  @prompt = prompt
32
28
  @prefix = options.fetch(:prefix) { @prompt.prefix }
33
29
  @min = options.fetch(:min) { 0 }
@@ -37,10 +33,22 @@ module TTY
37
33
  @active_color = options.fetch(:active_color) { @prompt.active_color }
38
34
  @help_color = options.fetch(:help_color) { @prompt.help_color }
39
35
  @format = options.fetch(:format) { FORMAT }
36
+ @symbols = @prompt.symbols.merge(options.fetch(:symbols, {}))
40
37
  @first_render = true
41
38
  @done = false
42
39
  end
43
40
 
41
+ # Change symbols used by this prompt
42
+ #
43
+ # @param [Hash] new_symbols
44
+ # the new symbols to use
45
+ #
46
+ # @api public
47
+ def symbols(new_symbols = (not_set = true))
48
+ return @symbols if not_set
49
+ @symbols.merge!(new_symbols)
50
+ end
51
+
44
52
  # Setup initial active position
45
53
  #
46
54
  # @return [Integer]
@@ -180,9 +188,9 @@ module TTY
180
188
  #
181
189
  # @api private
182
190
  def render_slider
183
- slider = (symbols[:line] * @active) +
184
- @prompt.decorate(symbols[:handle], @active_color) +
185
- (symbols[:line] * (range.size - @active - 1))
191
+ slider = (@symbols[:line] * @active) +
192
+ @prompt.decorate(@symbols[:bullet], @active_color) +
193
+ (@symbols[:line] * (range.size - @active - 1))
186
194
  value = " #{range[@active]}"
187
195
  @format.gsub(':slider', slider) % [value]
188
196
  end
@@ -13,17 +13,29 @@ module TTY
13
13
  square: '◼',
14
14
  square_empty: '◻',
15
15
  dot: '•',
16
- pointer: '',
16
+ bullet: '',
17
+ bullet_empty: '○',
18
+ marker: '‣',
17
19
  line: '─',
18
20
  pipe: '|',
19
- handle: 'O',
20
21
  ellipsis: '…',
21
22
  radio_on: '⬢',
22
23
  radio_off: '⬡',
23
24
  checkbox_on: '☒',
24
25
  checkbox_off: '☐',
26
+ circle: '◯',
25
27
  circle_on: 'ⓧ',
26
- circle_off: 'Ⓘ'
28
+ circle_off: 'Ⓘ',
29
+ arrow_up: '↑',
30
+ arrow_down: '↓',
31
+ arrow_up_down: '↕',
32
+ arrow_left: '←',
33
+ arrow_right: '→',
34
+ arrow_left_right: '↔',
35
+ heart: '♥',
36
+ diamond: '♦',
37
+ club: '♣',
38
+ spade: '♠'
27
39
  }.freeze
28
40
 
29
41
  WIN_KEYS = {
@@ -33,17 +45,29 @@ module TTY
33
45
  square: '[█]',
34
46
  square_empty: '[ ]',
35
47
  dot: '.',
36
- pointer: '>',
48
+ bullet: 'O',
49
+ bullet_empty: '○',
50
+ marker: '>',
37
51
  line: '-',
38
52
  pipe: '|',
39
- handle: 'O',
40
53
  ellipsis: '...',
41
54
  radio_on: '(*)',
42
55
  radio_off: '( )',
43
56
  checkbox_on: '[×]',
44
57
  checkbox_off: '[ ]',
58
+ circle: '( )',
45
59
  circle_on: '(x)',
46
- circle_off: '( )'
60
+ circle_off: '( )',
61
+ arrow_up: '↑',
62
+ arrow_down: '↓',
63
+ arrow_up_down: '↕',
64
+ arrow_left: '←',
65
+ arrow_right: '→',
66
+ arrow_left_right: '↔',
67
+ heart: '♥',
68
+ diamond: '♦',
69
+ club: '♣',
70
+ spade: '♠'
47
71
  }.freeze
48
72
 
49
73
  def symbols
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ class Prompt
5
+ class Timer
6
+ attr_reader :duration
7
+
8
+ attr_reader :total
9
+
10
+ attr_reader :interval
11
+
12
+ def initialize(duration, interval)
13
+ @duration = duration
14
+ @interval = interval
15
+ @total = 0.0
16
+ @current = nil
17
+ @events = []
18
+ end
19
+
20
+ def start
21
+ return if @current
22
+
23
+ @current = time_now
24
+ end
25
+
26
+ def stop
27
+ return unless @current
28
+
29
+ @current = nil
30
+ end
31
+
32
+ def runtime
33
+ time_now - @current
34
+ end
35
+
36
+ def on_tick(&block)
37
+ @events << block
38
+ end
39
+
40
+ def while_remaining
41
+ start
42
+ remaining = duration
43
+
44
+ if @duration
45
+ while remaining >= 0.0
46
+ if runtime >= total
47
+ tick = duration - @total
48
+ @events.each { |block| block.(tick) }
49
+ @total += @interval
50
+ end
51
+
52
+ yield(remaining)
53
+ remaining = duration - runtime
54
+ end
55
+ else
56
+ loop { yield }
57
+ end
58
+ ensure
59
+ stop
60
+ end
61
+
62
+ if defined?(Process::CLOCK_MONOTONIC)
63
+ # Object representing current time
64
+ def time_now
65
+ ::Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ end
67
+ else
68
+ # Object represeting current time
69
+ def time_now
70
+ ::Time.now
71
+ end
72
+ end
73
+ end # Timer
74
+ end # Prompt
75
+ end # TTY