Main Menu and Sub-Menu User Interfaces

Okay, we’ve updated our project layout and refactored the code so it all works the same as it did before the layout changes. Now, before we look at adding the code to provide country/region name search functionality, I’d like to refactor the menu code — if we can call it that.

We currently, in our main loop, just print a one line menu of sorts. Then we ask the user for the country and year for which to plot the population by age category. I plan to print a main menu, then depending on the user’s selection there will be, in effect, sub-menues to collect the information needed to plot or search. As I may wish to expand the main menu, I am going to put printing it into a separate function. That function will also get the user’s selection and return it. It will look something like:

Before I wrote too much of this post I decided to do a little playing around with the menus, the python code to support them, etc. I soon realized that I really had no idea how to properly write a terminal based user interface. Well, any user interface for that matter. In my testing I quickly learned it is a difficult proposition.

Things like: what needs to be said, how to avoid saying too much or too little, what choices need to have their own menu item, which ones can share a single menu choice,… And, that I would be going through many iterations of the menu text and the related code. (Don't know how many of those you'll see.)

So, I have decided not to worry about it too much and just develop something that satisfies my needs as a user. Other users can live with it, or rewrite the user interface.

The About menu item is just meant to give an overview of what the application does and how to use the menu. You know a reminder for when you come back to the application after some lengthy time away. It will look something like this first draft:

Please note that the above is being done on the run, I don’t currently have a real plan. But, I have been thinking for some time about allowing the selection of multiple countries or multiple years for a comparative type plot. So, have added that to the draft About text.

Side Trip: Spinner

While doing some testing of ideas and code for the various charts, I noticed it could take awhile before anything happened between requesting a chart and the chart being displayed. So, I decided to add the option of a command line spinner to the database.poulation module.

I am using the sys module to print a list of simple symbols (in the same position) to simulate a spinning line segment. That module allows me to delete the last character printed and print a new one in the same spot. This could be done with the print statement, but I wanted more control. And a way to clear the line the spinner was on just before displaying the chart.

I am using the itertools module to provide the characters in a repeating cycle. Could probably have coded my own function using yield but this was easier. Which is why the print statement calls next(spinner) The do_spin variable determines whether or not the spinner is actually displayed.

I added the _spn_cnt (private) and SPN_RATE (constant) variables to control the rate at which the characters were changed on screen. The spinner was really going way to fast. So, I wanted to slow it down somewhat. The global statement in prn_spin() allows me to access the two global, or is that module, variables from within the function rather than creating new variables. New local variables in the function isn’t what is wanted.

As mentioned above, I was going to put the spinner code in database/population.py. But, that really dosen’t provide for the separation of concerns we’ve talked about before. An important consideration in all program design/development. I also realized I might want to use it when searching for country/region names, as that could also take a bit of time if multiple searches were being done at one time.

So, I have added the code to an new module database/spinner.py. I still have some issues with how it will work. Especially as I want to be able to control wether or not the spinner is displayed. But hopefully those will eventually sort themselves out.

import itertools
import sys

# to spin or not to spin
do_spin = True
spinner = itertools.cycle(['-', '/', '|', '\\'])
SPN_RATE = 10000
_spn_cnt = 0

def prn_spin(pretext='Getting data'):
  global do_spin, _spn_cnt
  if do_spin and _spn_cnt == 0:
    sys.stdout.write(f"\t\033[1;31m{pretext}:\033[0;0m: ")
  if do_spin and _spn_cnt % SPN_RATE == 0:
    sys.stdout.write(next(spinner))   # write the next character
    sys.stdout.flush()                # flush stdout buffer (actual character display)
    sys.stdout.write('\b')            # erase the last written char
  _spn_cnt += 1

def clr_spin():
  CURSOR_UP_ONE = '\033[K'
  ERASE_LINE = '\x1b[2K'
  sys.stdout.write(CURSOR_UP_ONE)
  sys.stdout.write(ERASE_LINE+'\r')

I will add the prn_spin() function at strategic locations in the functions within database/population.py and probably the calling code. The latter is what I don’t like about my current approach. It will look something like (not video so you won’t actually see any spinning until you code it yourself):

Okay, time to commit the spinner module to our git respository before going any further.

Back to the Menues

Okay, I want to get the basics of the menues sorted. Then I will have to refactor the database related code to work with the menues, utilize the spinner, etc.

I’ve decided to allow for the following charts:

  • 1 country/region, 1-5 adjacent years, all age ranges
  • 1-5 countries/regions, 1 year, all age ranges
  • 1-5 countries/regions, 1-5 years, one age range

And I am thinking the main menu will have one selection for plotting a chart. This will then go to a sub-menu to determine which type of plot. When selected another sub-menu/form will be presented to get the needed data. Each of those should have an option for returning to the previous menu.

So I am going to create a new module, py_play/population/menues.py, for testing purposes. (Sorry, flopping back and forth with the directory separator.) There is something to be said for having all the menu stuff in the main application module (population_by_age.py in our case). And all the actual working code in modules. That is why this module is being created at the same level as our current main application module. I want to test out this concept using the menues.py module. If the approach seems to work, it will become our main application module, though perhaps renamed to population_by_age.py.

I had thought about creating a separate package for the menu module. But that would require the main application module to know too much about how it worked. Not quite a separation of concerns nor a shining example of encapsulation. These are also currently, I believe, issues with my spinner implementation.

I am using a global package variable, a dictionary, to provide the details for the main menu. That should make it easier to add or change menu choices. The dictionary key is what we want the user to enter when making a selection. And the related value is the text describing the selection.

My initial code for the module and the function to display the main menu and get the user’s choice is as follows:

"""Command line menues, submenues and/or 'forms' for all portions of the application

Module level and/or global variables:
----
  M_MENU: dictionary of main menu, key: access code, value: menu text

  TGREEN: terminal escape sequence for colour green
  ENDC: terminal RESET escape sequence 

Functions:

  do_main_menu(print_ttl=False)
    Prints main menu, gets user choice and returns it
    This may require to much knowledge by the calling program?

"""

TGREEN =  '\033[32m' # Green Text
ENDC = '\033[m' # reset to the defaults

MENU_M = {
  'A': 'About',
  'C': 'Plot chart',
  'S': 'Search country/region names',
  'X': 'Exit the application'
}


def do_main_menu(print_ttl=False):
  """Display main menu using menu content contained in MENU_M.
     Get and return user choice.
     
    Paramenters
    ---
    print_ttl : boolean (defaults to False)
      If true, print application heading line to terminal, otherwise do not.
    
    Returns
    ---
    user_choice : str
      The appropriate key from MENU_M
  """

  choice_ok = False
  add_valid = False
  if print_ttl:
    print(f"\n\033[1;34mPlot Population by Age Category\033[0;0m")
  while not choice_ok:
    if add_valid:
      print(f"\n\tPlease make a \033[1;34mvalid\033[0;0m selection:")
    else:
      print(f"\n\tPlease make a selection:")
    for key, value in MENU_M.items():
      print(f"\t\t\033[1;31m{key}\033[0;0m: {value}")
    user_choice = input("\tYour selection: ").upper()
    choice_ok = user_choice in MENU_M.keys()
    add_valid = not choice_ok
  return user_choice

You will notice I have decided to start practising better documentation of my code. Part of a fix for that ‘what to do if have been away from the application code for some time’.

Ok, commit this initial code for the module. Then we can get on to testing the main menu.

Initial Main Menu Test

Okay, let’s add some simple test code to an if __name__ == '__main__': block. What I am expecting to see is something like the following. Give it a shot.

My code to make that happen.

if __name__ == '__main__':
  while True:
    u_choice = do_main_menu()
    print(f"user choice is {u_choice}")
    if u_choice.upper() == 'X':
      break

You will recall the infinite while loop is just what we used in our original module. Though in that code we did check for command line arguments first. And, we may do so in future; but not right now.

Done For Today

I really expected to get most if not all the menu sorted and coded in this post. But, I do believe it is more than long enough for a single post. So much for the best laid plans of mice and men. I will continue in the next post. There will be a some reworking of the database code as well as producing all the menu code.

Will work on a semi-weekly schedule for the next few (several?) posts to keep things moving along at a resonable pace (for me and anyone foolish enough to read the posts).