This is part 2/n of a blog series in which I develop a terminal-based Kanban
board application. To read part 1, go here. I want to talk about one
feature that I needed for this and how I implemented it using Ruby, and that's
having scroll-able menu options.
The Goal
I wanted a way to display a menu of options to my users that they could
navigate by scrolling (with their keyboard), and make a selection using the
Enter key. I initially expected there to be a method or class to perform this
function for me since it seemed like such a common use case, but that's not the
case! Turns out we need to do things a bit more manually. Let's start with a
basic example:
Simple Scroll-able Menu
I've made a really basic example of a scroll-able menu here.
All this program does is display a collection of items as a list and allow
users to scroll through it. I've defined a constant, OPTIONS
, which is an
array of strings - these are going to be our list items. We also have an
instance variable, @highlight
, which will serve as a pointer to the
index of the currently "selected" item, initialized at 0
. You'll also
find methods named move_highlight_up
and move_highlight_down
. These handle
moving the highlighted item up and down. The way they are currently written,
the selection wraps around from bottom to top and top to bottom when reached.
In other words, if you're trying to scroll down while at the bottom of the
list, then the selection wraps around to the top. Let's have a look at one of
these to see how it works:
def move_highlight_down
if @highlight == OPTIONS.length - 1
@highlight = 0
else
@highlight += 1
end
end
Since we're moving down the list, we want to check first if we're at the
bottom. The index of the last item in a collection will be one less than its
length, so that's why we're subtracting 1 from the OPTIONS.length
. If that's
the case, we want to wrap back to the first item, index 0. If we're not at the
bottom, we increment the highlight pointer. We do the same in reverse for
moving the highlight up. If we wanted a user to be able to scroll to the end of
a list and stop instead of wrapping, we could write the method like so:
def move_highlight_down
unless @highlight >= OPTIONS.length - 1
@highlight += 1
end
end
In this example, we're only going to increment the highlight pointer if we're
not yet at the bottom of the list. There are a couple other ways to implement
this (as with anything Ruby), but this should give you a couple starting
options.
Displaying the menu
We have a method called display_menu
that actually handles drawing the menu
on the user's screen. This is where the visual element of "selecting" an item
comes in. We're going to use some attributes in order to make our
selected item visually different from the rest. The attribute we're interested
in is A_STANDOUT
because it makes text well... standout. We want to apply
this attribute when an item's index matches our highlight pointer. That code
looks like this:
def display_menu
current_line = 1
OPTIONS.each_with_index do |opt, idx|
WINDOW.setpos(current_line, 1)
if @highlight == idx
WINDOW.attron(Curses::A_STANDOUT)
WINDOW.addstr(opt)
WINDOW.attroff(Curses::A_STANDOUT)
else
WINDOW.addstr(opt)
end
current_line += 1
end
end
It looks like there's a lot going on here, but it's not as complicated once we
break it down. First, we want to initialize a variable to hold our current line
position. We iterate over all of the list items (OPTIONS
here). For each
iteration, we are setting the cursor position (using our current_line
variable) and using WINDOW.addstr
to display that option. However, we're also
checking to see if that option's index matches our highlight pointer first. If
that is true, then we're applying the A_STANDOUT
attribute. We also don't
want to forget to increment our current_line
in each iteration!
Letting our users scroll
Now that we have our nice helper methods to display the menu and move our
highlight around, we need a way to let our users interact with our menu. I'm
going to focus on keyboard navigation, but Curses does support mouse
integration as well. My application won't have mouse support (maybe a
stretch goal), so we'll skip over that for now. The way I've made these
scrolling menus work is with a basic loop
like this:
loop do
display_menu
Curses.refresh
ch = WINDOW.getch
case ch
when 'j'
move_highlight_down
when 'k'
move_highlight_up
when 'q'
break
end
end
The first three lines of the loop call our display_menu
method, refresh the
screen, and waits for a key press from the user. We store which key is pressed
in a variable called ch
, and run a case
statement on that. Our control
characters are 'j', 'k', and 'q' for this basic example. Any control characters
you define as "exit" characters will break
the loop and allow the program to
continue. Right now the only other thing it does is close, so pressing 'q' here
quits the program. Pressing 'j' calls our move_highlight_down
method, and 'k'
calls our move_highlight_up
method.
Making a Selection
We now have a menu that our users can scroll through, but that's not super
useful all on its own. What makes this actually useful is when we can use that
scroll-able menu to enable our users to make a selection from that menu in
order to perform an action. Our options above are structured as an array, and
our @highlight
pointer is a reference to the index of whichever item is
currently selected. This is structured like so intentionally, because that
means we can use the pointer to return a specific element from that array!
Let's take a look at what that code may look like:
def get_choice
loop do
display_menu
Curses.refresh
ch = WINDOW.getch
case ch
when 'j'
move_highlight_down
when 'k'
move_highlight_up
when 10 # '10' is the key code for 'Enter'
return OPTIONS[@highlight]
end
end
end
The main difference (besides being put into a method) is that we use the enter
key as a control character to define making a selection, and we've removed the
option to quit with the 'q' key. That's optional, we could leave the quit
option there as a way to break out of the program quickly, entirely up to how
you would like to define the interface. The important thing is that we're
returning the currently highlighted option when the user presses enter.
More complicated menus
Having a basic menu is great, but for my use case I needed a way for users to
scroll options both vertically and horizontally. The interface I'm working with
has three collections of items arranged in three columns, so a basic pointer
didn't seem like the ideal way to do this. In rethinking which data structure
would be appropriate, I narrowed it down to a couple key features I wanted - a
data structure that would hold two integers and let me access those in a way
that made their purpose clear. For this, I turned to a Struct. A
struct would let me access the information in a way that semantically made
sense, while reducing the overhead of creating an entire class. It looks a
little something like this:
@highlight = Struct.new(:col, :itm).new(1, 1)
The advantage this gives us over our simpler highlight pointer before is that we now can hold references to the column number as well as the row number. The way we access these are also descriptive of their values - @highlight.col
to access the selected column and @highlight.itm
to get the index of the item within that collection. You can see this code in action in the [tknbn repository][tknbn] under the curses-playground
branch. In the lib/tknbn/curses/main_menu.rb
file, we've got a Project
object that we're working with. That object has many Items
in three different stages - TODO, In Progress, and Done. The methods for moving the highlight up and down are largely the same with the addition of a get_num_items
method which returns the length of the collection for the currently selected column. Our methods for moving left and right look like this:
def move_cursor_right
if @hl.col == 3 # If rightmost selected
@hl.col = 1 # Move to leftmost
num_items = get_num_items
if @hl.itm > num_items
@hl.itm = num_items
end
elsif @hl.col == 1 # If leftmost selected
@hl.col = 2 # Move to middle
num_items = get_num_items
if @hl.itm > num_items
@hl.itm = num_items
end
else
@hl.col = 3 # Move rightmost
num_items = get_num_items
if @hl.itm > num_items
@hl.itm = num_items
end
end
end
def move_cursor_left
if @hl.col == 1 # If leftmost selected
@hl.col = 3 # Move to rightmost
num_items = get_num_items
if @hl.itm > num_items
@hl.itm = num_items
end
elsif @hl.col == 3 # If rightmost selected
@hl.col = 2 # Move to middle
num_items = get_num_items
if @hl.itm > num_items
@hl.itm = num_items
end
else
@hl.col = 1 # Move leftmost
num_items = get_num_items
if @hl.itm > num_items
@hl.itm = num_items
end
end
end
If you notice the repeated code, you're right! There is an opportunity to
refactor some pieces there. What we're trying to accomplish with the line
@hl.itm = num_items
is a way to handle adjusting which item index we're
pointing to in case it is outside the range of the collection that @hl.col
now refers to. This can happen if we have the 5th element of a column selected
and we move to a column that has less than 5 elements. Whenever we move to a
new column, we want to make sure we're pointing to a valid element. There's
another method I'm working on there called adjust_highlight
that does much
the same thing, but can also account for deleting the last item in a
collection, etc. I don't write many recursive methods/functions, so it could
likely be optimized, but I think it's pretty cool.
What's next
I'm starting to feel like I have most of the pieces of this project figured
out, so it's just about time for a round of some major refactoring and cleaning
up code. I have intentionally been letting myself write code all over the place
while I learn some of the best ways to work with this library with the idea
that there would be a pretty major refactor back into the master branch once I
got somewhere comfortable. I want to explore forms next, which I'll
probably do in the same fashion before refactoring. The next blog installment
will likely be about that major refactor itself.
Top comments (0)