Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add the ability to specify keys to select list items #152

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +652 to +654
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel as though the :enum should still act as an enumerated list of items, even when combined with key selection. Potentially the display could resolve this by appending key afterwards like this:

# =>
# Do what? (Press ↑/↓ arrow to move and Enter to select)
# ‣ 1) next [n]
#   2) previous [p]
#   3) exit [esc]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, this could be side-by-side, and would likely be a lot more intuitive (i.e. you expect :enum to add the numbers, but for some reason :key overrides that)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that this can/should be split up into more manageable chunks. As far as display, the options I see are a) as you said, an appended [key] text, or b) underlining, like alt-key hotkey representation in many OSes. The second would be tricky in a situation where you want the :key to be a letter that is not in the word, or up/downcase version of it (i.e. fish, Fish, fiSh).

As for non-select usage, I had not considered that to be honest - it should definitely be supported, and I agree that :key_action => select really only makes sense for select. Maybe there can be a specific Menu class that works more like an interactive menu system, which is what I intended this for, versus adding that functionality into just select.

So, you'd like the changes split like this:

  1. A PR for the Choice/Choices changes and related tests.
  2. The ability to pres to move to an item in select, and "check" item(s) inmulti_select`
  3. The "interactive menu" stuff, i.e. :key_action => select

If that all sounds good to you, I can get 1 & 2 done and we can talk more about 3.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The a) suggestion seems to me a bit more straightforward in a sense that some keys don't directly correspond with letters like esc key. I like the idea of underlined letters but I worry about how the support looks in various terminals. Often the underline is not displayed at all. In general the b) option feels a bit more fragile to me.

There may be an opportunity to create some common menu abstraction. I definitely want to pursue an idea of splitting up the whole library into plugins. It would remain a single gem but all the menus would rely on common interfaces and hence provide a way for people to create their own prompts or enhance the current ones.

The plan sounds good to me. Could I be cheeky and suggest an item 0? When you made changes to slider prompt, you suggested that the default apart from index could accept a name. I like this idea and would extend it to select, multi_select and enum_select prompts as well. This could be then released with the slider changes. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general the b) option feels a bit more fragile to me

Agreed. While it would be cooler looking, it definitely comes with a lot more complication. a) it is.

There may be an opportunity to create some common menu abstraction. I definitely want to pursue an idea of splitting up the whole library into plugins.

This would be neat. Making the "prompt" things more modular would be beneficial. If you need help with that, you know how to find me :)

extend it to select, multi_select and enum_select prompts as well

Ah, of course - I forgot about that! I will definitely do that as well!

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Katelyn, it's been a while since we had our last discussion about this feature. In the meantime, I've updated the default option for all the prompts and made some changes to the Choice. I wonder if you have time to revisit adding key support again?

```

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:

Expand Down Expand Up @@ -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.
Comment on lines +806 to +816
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this concept, makes the selection even more powerful!


#### 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`:
Expand Down
17 changes: 13 additions & 4 deletions lib/tty/prompt/choice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +70 to +75
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In light of the previous comment about :enum option, this comment would potentially need to change to reflect the changes


# The text to display for disabled choice
#
# @api public
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/tty/prompt/choices.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
102 changes: 91 additions & 11 deletions lib/tty/prompt/list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -201,6 +213,7 @@ def choice(*value, &block)
else
@choices << value
end
check_choice_consistency(@choices.last)
end

# Add multiple choices, or return them.
Expand All @@ -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

Expand All @@ -249,25 +265,35 @@ 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?

value = event.value.to_i
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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -341,22 +373,42 @@ 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?

@filter.clear
@active = 1
end

# Remove characters from the filter text.
#
# @api private
def keybackspace(*)
return unless filterable?

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions spec/unit/choice/eql_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion spec/unit/choice/from_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion spec/unit/choices/each_spec.rb
Original file line number Diff line number Diff line change
@@ -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 = []
Expand Down