diff --git a/README.md b/README.md index e9ca65df..3694bf6d 100644 --- a/README.md +++ b/README.md @@ -629,7 +629,30 @@ choices = [ ] ``` -You can specify `:key` as an additional option which will be used as short name for selecting the choice via keyboard key press. +You can specify `:key` as an additional option which will be used as short name for selecting the choice via keyboard key press. When used with `:enum`, the key is displayed instead of a number. + +```ruby +choices = [{name: "small", key: "s"}, {name: "medium", key: "m"}, {name: "large", key: "l"}] +prompt.select("What size?", choices, enum: ')') +# => +# What size? (Press ↑/↓ arrow to move and Enter to select) +# ‣ s) small +# m) medium +# l) large +``` + +When providing `:key`, you can override the displayed text with `:key_name` - as in the case of using the escape key: + + +```ruby +choices = [{name: "next", key: "n"}, {name: "previous", key: "p"}, {name: "exit", key: :escape, key_name: "esc"}] +prompt.select("Do what?", choices, enum: ')') +# => +# Do what? (Press ↑/↓ arrow to move and Enter to select) +# ‣ n) next +# p) previous +# esc) exit +``` Another way to create menu with choices is using the DSL and the `choice` method. For example, the previous array of choices with hash values can be translated as: @@ -768,6 +791,30 @@ end # ‣ 3. Jax ``` +If your choices include the `:key` and (optionally) `:key_name` settings, those values will be displayed instead of numbers. + +```ruby +choices = [{name: "small", key: "s"}, {name: "medium", key: "m"}, {name: "large", key: "l"}] +prompt.select("What size?", choices, enum: ')') +# => +# What size? (Press ↑/↓ arrow to move and Enter to select) +# ‣ s) small +# m) medium +# l) large +``` + +#### 2.6.2.3 `:key_action` + +When using `:enum` or Choice `:key` settings, you can change the keypress behavior. By default, `:key_action` is `:move`: pressing a number key or corresponding `:key` will move the cursor to select the choice. You can also use `key_action: :select` to make a keypress immediately select the item. + + +```ruby +choices = [{name: "small", key: "s"}, {name: "medium", key: "m"}, {name: "large", key: "l"}] +prompt.select("What size?", choices, enum: ')', key_action: :select) +``` + +In the above example, when pressing "l", the "large" option will be immediately selected and the prompt will exit. + #### 2.6.2.3 `:help` You can configure help message with `:help` and when to display it with `:show_help` options. The help can be displayed on `start`, `never` or `always`: diff --git a/lib/tty/prompt/choice.rb b/lib/tty/prompt/choice.rb index 7fdc4421..8f503b34 100644 --- a/lib/tty/prompt/choice.rb +++ b/lib/tty/prompt/choice.rb @@ -67,6 +67,13 @@ def self.convert_hash(val) # @api public attr_reader :key + # The text to display when this choice is used with :enum (i.e. in List) + # Defaults to :key. Use `nil` or `false` to disable showing the enum for + # this Choice. + # + # @api public + attr_reader :key_name + # The text to display for disabled choice # # @api public @@ -76,9 +83,10 @@ def self.convert_hash(val) # # @api public def initialize(name, value, **options) - @name = name - @value = value - @key = options[:key] + @name = name + @value = value + @key = options[:key] + @key_name = options[:key_name] || @key @disabled = options[:disabled].nil? ? false : options[:disabled] freeze end @@ -113,7 +121,8 @@ def ==(other) return false unless other.is_a?(self.class) name == other.name && value == other.value && - key == other.key + key == other.key && + key_name == other.key_name end # Object string representation diff --git a/lib/tty/prompt/choices.rb b/lib/tty/prompt/choices.rb index dd71d616..016ad50e 100644 --- a/lib/tty/prompt/choices.rb +++ b/lib/tty/prompt/choices.rb @@ -14,7 +14,7 @@ class Choices extend Forwardable def_delegators :choices, :length, :size, :to_ary, :empty?, - :values_at, :index, :== + :values_at, :index, :==, :last # Convenience for creating choices # diff --git a/lib/tty/prompt/list.rb b/lib/tty/prompt/list.rb index 98e823fa..192b638c 100644 --- a/lib/tty/prompt/list.rb +++ b/lib/tty/prompt/list.rb @@ -16,6 +16,14 @@ class List # Allowed keys for filter, along with backspace and canc. FILTER_KEYS_MATCHER = /\A([[:alnum:]]|[[:punct:]])\Z/.freeze + # Allowed values for :key_action + # move: move to the choice + # select: select the choice + ALLOWED_KEY_ACTIONS = [ + :move, + :select + ] + # Create instance of TTY::Prompt::List menu. # # @param Hash options @@ -36,6 +44,7 @@ def initialize(prompt, **options) @prompt = prompt @prefix = options.fetch(:prefix) { @prompt.prefix } @enum = options.fetch(:enum) { nil } + @key_action = options.fetch(:key_action) { :move } @default = Array(options[:default]) @choices = Choices.new @active_color = options.fetch(:active_color) { @prompt.active_color } @@ -108,6 +117,9 @@ def per_page(value) @per_page = value end + # Get the number of items per page. + # + # @api public def page_size (@per_page || Paginator::DEFAULT_PAGE_SIZE) end @@ -201,6 +213,7 @@ def choice(*value, &block) else @choices << value end + check_choice_consistency(@choices.last) end # Add multiple choices, or return them. @@ -222,7 +235,10 @@ def choices(values = (not_set = true)) end else @filter_cache = {} - values.each { |val| @choices << val } + values.each do |val| + @choices << val + check_choice_consistency(@choices.last) + end end end @@ -249,6 +265,13 @@ def enumerate? !@enum.nil? end + def search_choice_in(searchable) + searchable.find { |i| !choices[i - 1].disabled? } + end + + # Handle pressed numeric keys. Used to select/move to enumerated choices. + # + # @api private def keynum(event) return unless enumerate? @@ -256,18 +279,21 @@ def keynum(event) return unless (1..choices.count).cover?(value) return if choices[value - 1].disabled? @active = value + @done = true if @key_action == :select end + # Select the currently hilighted item. + # + # @api private 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 - + # Move the the selection up. + # + # @api private def keyup(*) searchable = (@active - 1).downto(1).to_a prev_active = search_choice_in(searchable) @@ -285,6 +311,9 @@ def keyup(*) @by_page = false end + # Move the selection down. + # + # @api private def keydown(*) searchable = ((@active + 1)..choices.length) next_active = search_choice_in(searchable) @@ -307,6 +336,8 @@ def keydown(*) # # When the choice on a page is outside of next page range then # adjust it to the last item, otherwise leave unchanged. + # + # @api private def keyright(*) if (@active + page_size) <= @choices.size searchable = ((@active + page_size)..choices.length) @@ -328,6 +359,7 @@ def keyright(*) end alias keypage_down keyright + # @api private def keyleft(*) if (@active - page_size) > 0 searchable = ((@active - page_size)..choices.length) @@ -341,15 +373,32 @@ def keyleft(*) end alias keypage_up keyleft + # Handle entered non-numeric characters. When `filterable?`, apply them + # to the filter text. Otherwise, match the input character against Choice + # :key settings. + # + # @api private def keypress(event) - return unless filterable? - - if event.value =~ FILTER_KEYS_MATCHER - @filter << event.value - @active = 1 + # if filterable, ignore :key? + if filterable? + if event.value =~ FILTER_KEYS_MATCHER + @filter << event.value + @active = 1 + end + else + # check for a matching Choice :key by key 'value', and then by 'name' + # this allows for either an alnum like "a", or a special like :escape + choice = choices.find_by(:key, event.value) || choices.find_by(:key, event.key.name) + if choice + @active = choices.index(choice) + 1 + @done = true if @key_action == :select + end end end + # Remove characters from the filter text. + # + # @api private def keydelete(*) return unless filterable? @@ -357,6 +406,9 @@ def keydelete(*) @active = 1 end + # Remove characters from the filter text. + # + # @api private def keybackspace(*) return unless filterable? @@ -366,11 +418,30 @@ def keybackspace(*) private + # Make sure incompatible options or bad values are not present. + # + # @api private def check_options_consistency(options) if options.key?(:enum) && options.key?(:filter) raise ConfigurationError, "Enumeration can't be used with filter" end + + if options.key?(:key_action) && !ALLOWED_KEY_ACTIONS.include?(options[:key_action]) + raise ConfigurationError, + "Invalid :key_action => %p. Must be one of: %s" % + [ options[:key_action], ALLOWED_KEY_ACTIONS.map(&:inspect).join(', ') ] + end + end + + # Make sure settings on a Choice are not incompatible with any options. + # + # @api private + def check_choice_consistency(choice) + if filterable? && choice.key + raise ConfigurationError, + "Filtering can't be used with Choice :key settings" + end end # Setup default option and active selection @@ -536,7 +607,16 @@ def render_menu sync_paginators if @paging_changed paginator.paginate(choices, @active, @per_page) do |choice, index| - num = enumerate? ? (index + 1).to_s + @enum + " " : "" + # enumerate by provided :key, :key_name, or index number. + num = "" + if enumerate? + if choice.key && choice.key_name + num = choice.key_name.to_s + @enum + " " + elsif !choice.key + num = (index + 1).to_s + @enum + " " + end + end + message = if index + 1 == @active && !choice.disabled? selected = "#{@symbols[:marker]} #{num}#{choice.name}" @prompt.decorate(selected.to_s, @active_color) diff --git a/spec/unit/choice/eql_spec.rb b/spec/unit/choice/eql_spec.rb index 015ffe59..f387b8c0 100644 --- a/spec/unit/choice/eql_spec.rb +++ b/spec/unit/choice/eql_spec.rb @@ -16,6 +16,16 @@ not_to eq(described_class.new(:large, 2)) end + it "is false with different key attribute" do + expect(described_class.new(:large, 1, key: "h")). + not_to eq(described_class.new(:large, 1, key: "g")) + end + + it "is false with different key_name attribute" do + expect(described_class.new(:large, 1, key: "h", key_name: "aych")). + not_to eq(described_class.new(:large, 1, key: "h", key_name: "gee")) + end + it "is false with non-choice object" do expect(described_class.new(:large, 1)).not_to eq(:other) end diff --git a/spec/unit/choice/from_spec.rb b/spec/unit/choice/from_spec.rb index aa07c295..8bb3d421 100644 --- a/spec/unit/choice/from_spec.rb +++ b/spec/unit/choice/from_spec.rb @@ -91,11 +91,26 @@ it "creates choice from hash with key property" do default = {key: "h", name: "Help", value: :help} expected_choice = described_class.new("Help", :help, key: "h") - choice = described_class.from(default) + choice = described_class.from(default) expect(choice).to eq(expected_choice) expect(choice.name).to eq("Help") expect(choice.value).to eq(:help) + expect(choice.key).to eq("h") + expect(choice.key_name).to eq("h") + expect(choice.disabled?).to eq(false) + end + + it "creates choice from hash with key_name property" do + default = {key: :escape, key_name: "esc", name: "Help", value: :help} + expected_choice = described_class.new("Help", :help, key: :escape, key_name: "esc") + choice = described_class.from(default) + + expect(choice).to eq(expected_choice) + expect(choice.name).to eq("Help") + expect(choice.value).to eq(:help) + expect(choice.key).to eq(:escape) + expect(choice.key_name).to eq("esc") expect(choice.disabled?).to eq(false) end diff --git a/spec/unit/choices/each_spec.rb b/spec/unit/choices/each_spec.rb index fd9b9ede..92791976 100644 --- a/spec/unit/choices/each_spec.rb +++ b/spec/unit/choices/each_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe TTY::Prompt::Choices, ".each" do +RSpec.describe TTY::Prompt::Choices, "#each" do it "iterates over collection" do choices = described_class[:large, :medium, :small] actual = [] diff --git a/spec/unit/choices/find_by_spec.rb b/spec/unit/choices/find_by_spec.rb index 9b79c434..4684e0d2 100644 --- a/spec/unit/choices/find_by_spec.rb +++ b/spec/unit/choices/find_by_spec.rb @@ -1,10 +1,36 @@ # frozen_string_literal: true RSpec.describe TTY::Prompt::Choices, "#find_by" do - it "finds a matching choice by key name" do - collection = [{name: "large"},{name: "medium"},{name: "small"}] - choice = TTY::Prompt::Choice.from(name: "small") + + let( :collection ) { + [ + {name: "large", value: "lg", key: "l", key_name: "L"}, + {name: "medium", value: "md", key: "m", key_name: "M"}, + {name: "small", value: "sm", key: "s", key_name: "S"} + ] + } + + it "finds a matching choice by key :name" do + choice = TTY::Prompt::Choice.from(name: "small", value: "sm", key: "s", key_name: "S") choices = described_class[*collection] expect(choices.find_by(:name, "small")).to eq(choice) end + + it "finds a matching choice by key :value" do + choice = TTY::Prompt::Choice.from(name: "medium", value: "md", key: "m", key_name: "M") + choices = described_class[*collection] + expect(choices.find_by(:value, "md")).to eq(choice) + end + + it "finds a matching choice by key :key" do + choice = TTY::Prompt::Choice.from(name: "medium", value: "md", key: "m", key_name: "M") + choices = described_class[*collection] + expect(choices.find_by(:key, "m")).to eq(choice) + end + + it "finds a matching choice by key :key_name" do + choice = TTY::Prompt::Choice.from(name: "large", value: "lg", key: "l", key_name: "L") + choices = described_class[*collection] + expect(choices.find_by(:key_name, "L")).to eq(choice) + end end diff --git a/spec/unit/choices/last_spec.rb b/spec/unit/choices/last_spec.rb new file mode 100644 index 00000000..dbd2b9a0 --- /dev/null +++ b/spec/unit/choices/last_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.describe TTY::Prompt::Choices, "#last" do + it "retuens choice last added to collection" do + choices = described_class.new + choice_one = TTY::Prompt::Choice.from([:label1, 1]) + choice_two = TTY::Prompt::Choice.from([:label2, 2]) + + choices << [:label1, 1] + expect(choices.size).to eq(1) + expect(choices.last).to eq(choice_one) + + choices << [:label2, 2] + expect(choices.size).to eq(2) + expect(choices.last).to eq(choice_two) + end +end diff --git a/spec/unit/choices/new_spec.rb b/spec/unit/choices/new_spec.rb index 68fe2ea1..09c918c1 100644 --- a/spec/unit/choices/new_spec.rb +++ b/spec/unit/choices/new_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe TTY::Prompt::Choices, ".new" do +RSpec.describe TTY::Prompt::Choices, "#new" do it "creates choices collection" do choice_1 = TTY::Prompt::Choice.from(:label1) choice_2 = TTY::Prompt::Choice.from(:label2) diff --git a/spec/unit/select_spec.rb b/spec/unit/select_spec.rb index 8ed3a640..975fac08 100644 --- a/spec/unit/select_spec.rb +++ b/spec/unit/select_spec.rb @@ -20,7 +20,15 @@ def output_helper(prompt, choices, active, options = {}) out << choices.map.with_index do |c, i| name = c.is_a?(Hash) ? c[:name] : c disabled = c.is_a?(Hash) ? c[:disabled] : false - num = (i + 1).to_s + enum if enum + num = "" + if enum + if c.is_a?(Hash) && c[:key] && c[:key_name] + num = c[:key_name].to_s + enum + elsif !c.is_a?(Hash) || !c[:key] + num = (i + 1).to_s + enum + end + end + if disabled "\e[31m#{symbols[:cross]}\e[0m #{num}#{name} #{disabled}" elsif name == active @@ -639,7 +647,7 @@ def exit_message(prompt, choice) expect(prompt.output.string).to eq(expected_output) end - it "cycles around when configured to do so" do + it "cycles from bottom when configured to do so" do choices = %w(A B C) prompt.on(:keypress) { |e| prompt.trigger(:keydown) if e.value == "j" } prompt.input << "j" << "j" << "j" << "\r" @@ -659,6 +667,25 @@ def exit_message(prompt, choice) expect(prompt.output.string).to eq(expected_output) end + it "cycles from top when configured to do so" do + choices = %w(A B C) + prompt.on(:keypress) { |e| prompt.trigger(:keyup) if e.value == "j" } + prompt.input << "j" << "j" << "\r" + prompt.input.rewind + + answer = prompt.select("What letter?", choices, cycle: true) + + expect(answer).to eq("B") + expected_output = [ + output_helper("What letter?", choices, "A", init: true, + hint: "Press #{up_down} arrow to move and Enter to select"), + output_helper("What letter?", choices, "C"), + output_helper("What letter?", choices, "B"), + "What letter? \e[32mB\e[0m\n\e[?25h" + ].join + expect(prompt.output.string).to eq(expected_output) + end + it "cycles around disabled items" do choices = [ {name: "A", disabled: "(out)"}, @@ -983,4 +1010,138 @@ def exit_message(prompt, choice) }.to raise_error(TTY::Prompt::ConfigurationError, /default index `1` matches disabled choice item/) end end + + context "keypress customization" do + it "doesn't allow mixing Choice#key and filter" do + expect { + prompt.select("What size?", [{name: "test", key: "t"}], filter: true) + }.to raise_error(TTY::Prompt::ConfigurationError, "Filtering can't be used with Choice :key settings") + end + + it "emits an error when using an invalid :key_action" do + expect { + prompt.select("What size?", [{name: "test", key: "t"}], key_action: :explode) + }.to raise_error(TTY::Prompt::ConfigurationError, /Invalid :key_action => :explode/) + end + + it "displays the set Choice#key when enumerating" do + choices = [ + {name: "Small", key: "s", key_name: "s"}, + {name: "Medium", key: "m", key_name: "m"}, + {name: "Large", key: "l", key_name: "l"} + ] + prompt.input << "\r" + prompt.input.rewind + answer = prompt.select("What size?", choices, enum: ")") + expect(answer).to eq("Small") + + expected_output = + output_helper("What size?", choices, "Small", init: true, enum: ") ", + hint: "Press #{up_down} arrow or 1-3 number to move and Enter to select") + + "What size? \e[32mSmall\e[0m\n\e[?25h" + + expect(prompt.output.string).to eq(expected_output) + end + + it "preferentially displays the set Choice#key_name when enumerating" do + choices = [ + {name: "Small", key: "s", key_name: "S"}, + {name: "Medium", key: "m", key_name: "S"}, + {name: "Large", key: "l", key_name: "L"} + ] + prompt.input << "\r" + prompt.input.rewind + answer = prompt.select("What size?", choices, enum: ")") + expect(answer).to eq("Small") + + expected_output = + output_helper("What size?", choices, "Small", init: true, enum: ") ", + hint: "Press #{up_down} arrow or 1-3 number to move and Enter to select") + + "What size? \e[32mSmall\e[0m\n\e[?25h" + + expect(prompt.output.string).to eq(expected_output) + end + + context ":key_action => :move" do + it "pressing the set Choice#key moves to the choice" do + choices = [ + {name: "Small", key: "s", key_name: "s"}, + {name: "Medium", key: "m", key_name: "m"}, + {name: "Large", key: "l", key_name: "l"} + ] + prompt.input << "l" << "\r" + prompt.input.rewind + answer = prompt.select("What size?", choices, enum: ")") + expect(answer).to eq("Large") + + expected_output = + output_helper("What size?", choices, "Small", init: true, enum: ") ", + hint: "Press #{up_down} arrow or 1-3 number to move and Enter to select") + + output_helper("What size?", choices, "Large", enum: ") " ) + + "What size? \e[32mLarge\e[0m\n\e[?25h" + + expect(prompt.output.string).to eq(expected_output) + end + + it "pressing a number key when :enum enabled moves to the choice" do + choices = [ + {name: "Small"}, + {name: "Medium"}, + {name: "Large"} + ] + prompt.input << "2" << "\r" + prompt.input.rewind + answer = prompt.select("What size?", choices, enum: ")") + expect(answer).to eq("Medium") + + expected_output = + output_helper("What size?", choices, "Small", init: true, enum: ") ", + hint: "Press #{up_down} arrow or 1-3 number to move and Enter to select") + + output_helper("What size?", choices, "Medium", enum: ") " ) + + "What size? \e[32mMedium\e[0m\n\e[?25h" + + expect(prompt.output.string).to eq(expected_output) + end + end + + context ":key_action => :select" do + it "pressing the set Choice#key selects the choice" do + choices = [ + {name: "Small", key: "s", key_name: "s"}, + {name: "Medium", key: "m", key_name: "m"}, + {name: "Large", key: "l", key_name: "l"} + ] + prompt.input << "m" + prompt.input.rewind + answer = prompt.select("What size?", choices, enum: ")", key_action: :select) + expect(answer).to eq("Medium") + + expected_output = + output_helper("What size?", choices, "Small", init: true, enum: ") ", + hint: "Press #{up_down} arrow or 1-3 number to move and Enter to select") + + "What size? \e[32mMedium\e[0m\n\e[?25h" + + expect(prompt.output.string).to eq(expected_output) + end + + it "pressing a number key when :enum enabled selects the choice" do + choices = [ + {name: "Small"}, + {name: "Medium"}, + {name: "Large"} + ] + prompt.input << "3" + prompt.input.rewind + answer = prompt.select("What size?", choices, enum: ")", key_action: :select) + expect(answer).to eq("Large") + + expected_output = + output_helper("What size?", choices, "Small", init: true, enum: ") ", + hint: "Press #{up_down} arrow or 1-3 number to move and Enter to select") + + "What size? \e[32mLarge\e[0m\n\e[?25h" + + expect(prompt.output.string).to eq(expected_output) + end + end + end end