DEV Community

Aspen James
Aspen James

Posted on

Scroll-able Menus with Curses in Ruby

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.

Oldest comments (0)