File: list.rb

package info (click to toggle)
ruby-tty-prompt 0.23.1-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, forky, sid, trixie
  • size: 1,452 kB
  • sloc: ruby: 8,847; makefile: 4
file content (589 lines) | stat: -rw-r--r-- 16,142 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
# frozen_string_literal: true

require "English"

require_relative "choices"
require_relative "paginator"
require_relative "block_paginator"

module TTY
  class Prompt
    # A class responsible for rendering select list menu
    # Used by {Prompt} to display interactive menu.
    #
    # @api private
    class List
      # Allowed keys for filter, along with backspace and canc.
      FILTER_KEYS_MATCHER = /\A([[:alnum:]]|[[:punct:]])\Z/.freeze

      # Checks type of default parameter to be integer
      INTEGER_MATCHER = /\A\d+\Z/.freeze

      # Create instance of TTY::Prompt::List menu.
      #
      # @param Hash options
      #   the configuration options
      # @option options [Symbol] :default
      #   the default active choice, defaults to 1
      # @option options [Symbol] :color
      #   the color for the selected item, defualts to :green
      # @option options [Symbol] :marker
      #   the marker for the selected item
      # @option options [String] :enum
      #   the delimiter for the item index
      #
      # @api public
      def initialize(prompt, **options)
        check_options_consistency(options)

        @prompt       = prompt
        @prefix       = options.fetch(:prefix) { @prompt.prefix }
        @enum         = options.fetch(:enum) { nil }
        @default      = Array(options[:default])
        @choices      = Choices.new
        @active_color = options.fetch(:active_color) { @prompt.active_color }
        @help_color   = options.fetch(:help_color) { @prompt.help_color }
        @cycle        = options.fetch(:cycle) { false }
        @filterable   = options.fetch(:filter) { false }
        @symbols      = @prompt.symbols.merge(options.fetch(:symbols, {}))
        @quiet        = options.fetch(:quiet) { @prompt.quiet }
        @filter       = []
        @filter_cache = {}
        @help         = options[:help]
        @show_help    = options.fetch(:show_help) { :start }
        @first_render = true
        @done         = false
        @per_page     = options[:per_page]
        @paginator    = Paginator.new
        @block_paginator = BlockPaginator.new
        @by_page      = false
        @paging_changed = false
      end

      # Change symbols used by this prompt
      #
      # @param [Hash] new_symbols
      #   the new symbols to use
      #
      # @api public
      def symbols(new_symbols = (not_set = true))
        return @symbols if not_set

        @symbols.merge!(new_symbols)
      end

      # Set default option selected
      #
      # @api public
      def default(*default_values)
        @default = default_values
      end

      # Select paginator based on the current navigation key
      #
      # @return [Paginator]
      #
      # @api private
      def paginator
        @by_page ? @block_paginator : @paginator
      end

      # Synchronize paginators start positions
      #
      # @api private
      def sync_paginators
        if @by_page
          if @paginator.start_index
            @block_paginator.reset!
            @block_paginator.start_index = @paginator.start_index
          end
        else
          if @block_paginator.start_index
            @paginator.reset!
            @paginator.start_index = @block_paginator.start_index
          end
        end
      end

      # Set number of items per page
      #
      # @api public
      def per_page(value)
        @per_page = value
      end

      def page_size
        (@per_page || Paginator::DEFAULT_PAGE_SIZE)
      end

      # Check if list is paginated
      #
      # @return [Boolean]
      #
      # @api private
      def paginated?
        choices.size > page_size
      end

      # Provide help information
      #
      # @param [String] value
      #   the new help text
      #
      # @return [String]
      #
      # @api public
      def help(value = (not_set = true))
        return @help if !@help.nil? && not_set

        @help = (@help.nil? && !not_set) ? value : default_help
      end

      # Change when help is displayed
      #
      # @api public
      def show_help(value = (not_set = true))
        return @show_ehlp if not_set

        @show_help = value
      end

      # Information about arrow keys
      #
      # @return [String]
      #
      # @api private
      def arrows_help
        up_down = @symbols[:arrow_up] + "/" + @symbols[:arrow_down]
        left_right = @symbols[:arrow_left] + "/" + @symbols[:arrow_right]

        arrows = [up_down]
        arrows << "/" if paginated?
        arrows << left_right if paginated?
        arrows.join
      end

      # Default help text
      #
      # Note that enumeration and filter are mutually exclusive
      #
      # @a public
      def default_help
        str = []
        str << "(Press "
        str << "#{arrows_help} arrow"
        str << " or 1-#{choices.size} number" if enumerate?
        str << " to move"
        str << (filterable? ? "," : " and")
        str << " Enter to select"
        str << " and letters to filter" if filterable?
        str << ")"
        str.join
      end

      # Set selecting active index using number pad
      #
      # @api public
      def enum(value)
        @enum = value
      end

      # Set whether selected answers are echoed
      #
      # @api public
      def quiet(value)
        @quiet = value
      end

      # Add a single choice
      #
      # @api public
      def choice(*value, &block)
        @filter_cache = {}
        if block
          @choices << (value << block)
        else
          @choices << value
        end
      end

      # Add multiple choices, or return them.
      #
      # @param [Array[Object]] values
      #   the values to add as choices; if not passed, the current
      #   choices are displayed.
      #
      # @api public
      def choices(values = (not_set = true))
        if not_set
          if !filterable? || @filter.empty?
            @choices
          else
            filter_value = @filter.join.downcase
            @filter_cache[filter_value] ||= @choices.enabled.select do |choice|
              choice.name.to_s.downcase.include?(filter_value)
            end
          end
        else
          @filter_cache = {}
          values.each { |val| @choices << val }
        end
      end

      # Call the list menu by passing question and choices
      #
      # @param [String] question
      #
      # @param
      # @api public
      def call(question, possibilities, &block)
        choices(possibilities)
        @question = question
        block.call(self) if block
        setup_defaults
        @prompt.subscribe(self) do
          render
        end
      end

      # Check if list is enumerated
      #
      # @return [Boolean]
      def enumerate?
        !@enum.nil?
      end

      def keynum(event)
        return unless enumerate?

        value = event.value.to_i
        return unless (1..choices.count).cover?(value)
        return if choices[value - 1].disabled?

        @active = value
      end

      def keyenter(*)
        @done = true unless choices.empty?
      end
      alias keyreturn keyenter
      alias keyspace keyenter

      def search_choice_in(searchable)
        searchable.find { |i| !choices[i - 1].disabled? }
      end

      def keyup(*)
        searchable  = (@active - 1).downto(1).to_a
        prev_active = search_choice_in(searchable)

        if prev_active
          @active = prev_active
        elsif @cycle
          searchable  = choices.length.downto(1).to_a
          prev_active = search_choice_in(searchable)

          @active = prev_active if prev_active
        end

        @paging_changed = @by_page
        @by_page = false
      end

      def keydown(*)
        searchable  = ((@active + 1)..choices.length)
        next_active = search_choice_in(searchable)

        if next_active
          @active = next_active
        elsif @cycle
          searchable = (1..choices.length)
          next_active = search_choice_in(searchable)

          @active = next_active if next_active
        end
        @paging_changed = @by_page
        @by_page = false
      end
      alias keytab keydown

      # Moves all choices page by page keeping the current selected item
      # at the same level on each page.
      #
      # When the choice on a page is outside of next page range then
      # adjust it to the last item, otherwise leave unchanged.
      def keyright(*)
        choices_size = choices.size
        if (@active + page_size) <= choices_size
          searchable = ((@active + page_size)..choices_size)
          @active = search_choice_in(searchable)
        elsif @active <= choices_size # last page shorter
          current   = @active % page_size
          remaining = choices_size % page_size

          if current.zero? || (remaining > 0 && current > remaining)
            searchable = choices_size.downto(0).to_a
            @active = search_choice_in(searchable)
          elsif @cycle
            searchable = ((current.zero? ? page_size : current)..choices_size)
            @active = search_choice_in(searchable)
          end
        end

        @paging_changed = !@by_page
        @by_page = true
      end
      alias keypage_down keyright

      def keyleft(*)
        if (@active - page_size) > 0
          searchable = ((@active - page_size)..choices.size)
          @active = search_choice_in(searchable)
        elsif @cycle
          searchable = choices.size.downto(1).to_a
          @active = search_choice_in(searchable)
        end
        @paging_changed = !@by_page
        @by_page = true
      end
      alias keypage_up keyleft

      def keypress(event)
        return unless filterable?

        if event.value =~ FILTER_KEYS_MATCHER
          @filter << event.value
          @active = 1
        end
      end

      def keydelete(*)
        return unless filterable?

        @filter.clear
        @active = 1
      end

      def keybackspace(*)
        return unless filterable?

        @filter.pop
        @active = 1
      end

      private

      def check_options_consistency(options)
        if options.key?(:enum) && options.key?(:filter)
          raise ConfigurationError,
                "Enumeration can't be used with filter"
        end
      end

      # Setup default option and active selection
      #
      # @return [Integer]
      #
      # @api private
      def setup_defaults
        validate_defaults

        if @default.empty?
          # no default, pick the first non-disabled choice
          @active = choices.index { |choice| !choice.disabled? } + 1
        elsif @default.first.to_s =~ INTEGER_MATCHER
          @active = @default.first
        elsif default_choice = choices.find_by(:name, @default.first)
          @active = choices.index(default_choice) + 1
        end
      end

      # Validate default indexes to be within range
      #
      # @raise [ConfigurationError]
      #   raised when the default index is either non-integer,
      #   out of range or clashes with disabled choice item.
      #
      # @api private
      def validate_defaults
        @default.each do |d|
          msg = if d.nil? || d.to_s.empty?
                  "default index must be an integer in range (1 - #{choices.size})"
                elsif d.to_s !~ INTEGER_MATCHER
                  validate_default_name(d)
                elsif d < 1 || d > choices.size
                  "default index `#{d}` out of range (1 - #{choices.size})"
                elsif (dflt_choice = choices[d - 1]) && dflt_choice.disabled?
                  "default index `#{d}` matches disabled choice"
                end

          raise(ConfigurationError, msg) if msg
        end
      end

      # Validate default choice name
      #
      # @param [String] name
      #   the name to verify
      #
      # @return [String]
      #
      # @api private
      def validate_default_name(name)
        default_choice = choices.find_by(:name, name.to_s)
        if default_choice.nil?
          "no choice found for the default name: #{name.inspect}"
        elsif default_choice.disabled?
          "default name #{name.inspect} matches disabled choice"
        end
      end

      # Render a selection list.
      #
      # By default the result is printed out.
      #
      # @return [Object] value
      #   return the selected value
      #
      # @api private
      def render
        @prompt.print(@prompt.hide)
        until @done
          question = render_question
          @prompt.print(question)
          @prompt.read_keypress

          # Split manually; if the second line is blank (when there are no
          # matching lines), it won't be included by using String#lines.
          question_lines = question.split($INPUT_RECORD_SEPARATOR, -1)

          @prompt.print(refresh(question_lines_count(question_lines)))
        end
        @prompt.print(render_question) unless @quiet
        answer
      ensure
        @prompt.print(@prompt.show)
      end

      # Count how many screen lines the question spans
      #
      # @return [Integer]
      #
      # @api private
      def question_lines_count(question_lines)
        question_lines.reduce(0) do |acc, line|
          acc + @prompt.count_screen_lines(line)
        end
      end

      # Find value for the choice selected
      #
      # @return [nil, Object]
      #
      # @api private
      def answer
        choices[@active - 1].value
      end

      # Clear screen lines
      #
      # @param [String]
      #
      # @api private
      def refresh(lines)
        @prompt.clear_lines(lines)
      end

      # Render question with instructions and menu
      #
      # @return [String]
      #
      # @api private
      def render_question
        header = ["#{@prefix}#{@question} #{render_header}\n"]
        @first_render = false
        unless @done
          header << render_menu
        end
        header.join
      end

      # Is filtering enabled?
      #
      # @return [Boolean]
      #
      # @api private
      def filterable?
        @filterable
      end

      # Header part showing the current filter
      #
      # @return String
      #
      # @api private
      def filter_help
        "(Filter: #{@filter.join.inspect})"
      end

      # Check if help is shown only on start
      #
      # @api private
      def help_start?
        @show_help =~ /start/i
      end

      # Check if help is always displayed
      #
      # @api private
      def help_always?
        @show_help =~ /always/i
      end

      # Render initial help and selected choice
      #
      # @return [String]
      #
      # @api private
      def render_header
        if @done
          selected_item = choices[@active - 1].name
          @prompt.decorate(selected_item.to_s, @active_color)
        elsif (@first_render && (help_start? || help_always?)) ||
              (help_always? && !@filter.any?)
          @prompt.decorate(help, @help_color)
        elsif filterable? && @filter.any?
          @prompt.decorate(filter_help, @help_color)
        end
      end

      # Render menu with choices to select from
      #
      # @return [String]
      #
      # @api private
      def render_menu
        output = []

        sync_paginators if @paging_changed
        paginator.paginate(choices, @active, @per_page) do |choice, index|
          num = enumerate? ? (index + 1).to_s + @enum + " " : ""
          message = if index + 1 == @active && !choice.disabled?
                      selected = "#{@symbols[:marker]} #{num}#{choice.name}"
                      @prompt.decorate(selected.to_s, @active_color)
                    elsif choice.disabled?
                      @prompt.decorate(@symbols[:cross], :red) +
                        " #{num}#{choice.name} #{choice.disabled}"
                    else
                      "  #{num}#{choice.name}"
                    end
          end_index = paginated? ? paginator.end_index : choices.size - 1
          newline = (index == end_index) ? "" : "\n"
          output << (message + newline)
        end

        output.join
      end
    end # List
  end # Prompt
end # TTY