#!/usr/bin/env python """ Menu - print menu with hotkeys and return chosen item. The goal was to make an interface for picking an item out of possibly very large number of choices with minimal effort. With this in mind: - Choice is made with just one keypress of a 'hotkey'. - Multiple columns and pages are used to accomodate even thousands of choices. Example: items = [ ('continue', cont), ('exit', exit), ] mymenu = Menu(items) # will show (with 'c' and 'e' colored): # continue # exit response = mymenu.pick() # will run cont() or exit() response() Usually you'll use colors; they're enabled by default. To disable them, do this: menu.use_colors = 0 mymenu = menu.Menu() To set hotkey color to a different value (default is green): menu.hotkey_color = 'red' Menu() accepts optional arguments, see its docstring for details. REQUIRES: silmarill.org/files/avkutil.py Andrei Kulakov """ import sys import string import avkutil from pprint import pprint from types import * __version__ = "0.3" enable_color = 1 highlight = ('white', 'green') # fg, bg hotkey_color = "green" dbg = 1 # debugging def debug(*msgs): if dbg: if type(msgs) == ListType: for m in msgs: print m else: print msgs class Menu: def __init__(self, items, colwidth=None, header=None): """Makes a menu with keyboard shortcuts (hotkeys). Arguments: - items: List of tuples ('name', item, [hotkey], [description]); if you omit the hotkey, it'll be assigned automatically. If name is None, it becomes a one-line blank divider. description is what will be printed after the name, but won't be used for hotkeys. This is useful in a menu that prints a list of items and some data related to these items. Data would go into description. XXX descriptions not yet added (will be ignored) - header: List of lines to put before the menu (because it may have to redraw itself when paging or highlighting items). Each line should fit in screen's width. Hotkeys are case-insensitive usually the first letter of the name. Only 3 items are allowed per one hotkey (unless we run out of keys). If a hotkey is unique, an item is returned right away, otherwise the name is highlighted. Hitting Enter will return that item, repeating the hotkey will cycle through items. """ self.items = items if header: if type(header) != ListType: header = [header] self.header = header self.colwidth = colwidth self.pages = [] self.refresh() self.process() def refresh(self): """Figure out number of columns and menu height.""" h, w = avkutil.Term().size() if self.header: header_height = len(self.header) else: header_height = 0 # 1 for status line & prompt self.menu_height = h - header_height - 1 if not self.colwidth: self.columns = 1 self.colwidth = w else: self.columns = w / self.colwidth def process(self): """Create pages. Each page is a tuple of hotkeys and menulsts. - hotkeys {hotkey: [(name,val), (name, val), ...] ...} - menulst: [(hotkey, name), ...] Note: menulst is for printing the menu, hotkeys is for looking up the value to be returned when user types in a hotkey. """ hotkeys = {} menulst = [] for item in self.items: if len(menulst) == self.menu_height*self.columns: # full page, no more items will fit in: make new page self.pages.append((hotkeys, menulst)) hotkeys = {} menulst = [] else: name, value = item[:2] if not name: # empty divider line menulst.append((None, None)) continue name = name[:self.colwidth-4] # create hotkeys dict {hotkey: [(name,val), (name, val)..] ... } if len(item) == 3: hkey = item[2] else: n = 0 vc = self.valid_chars(name) for l in vc: if hotkeys.has_key(l): if len(hotkeys[l]) < 3 or n == len(vc)-1: hkey = l break else: hkey = l break n+=1 if hotkeys.has_key(hkey): hotkeys[hkey].append((name, value)) else: hotkeys[hkey] = [(name, value)] # create menulist [(hotkey, name) ... ] menulst.append((hkey, name)) # last page self.pages.append((hotkeys, menulst)) def clear_state(self): self.current_name = None self.last_hotkey = None self.last_index = None def help(self): print """ Hit colored hotkey to highlight an item or cycle items with the same hotkey. Enter - select highlighted item q - cancel Space - next page b - previous page ? - this help message , (comma) - redraw screen """ raw_input('Hit Enter to return ') def make_columns(self, menulst): """Split menulst into desired number of columns. Creates and returns list of lines where each line will be a row with a slice of each column. We print out each name, highlighting the hotkey, and status bar with the prompt on the last line. The simplest case is when we have one value associated with a hotkey, then we simply return that value. If there are multiple values, we have to add marker and cycle through values. - Marker is printed by looking at each name before printing it and comparing it with 'self.current_name' variable, which remembers what we have selected at the moment. - To cycle, we need to remember last hotkey and index of last value. On the next menu print, if hotkey is the same, we add to index, wrapping it around to 0 when it gets too big. vars: self.last_hotkey, self.last_index. """ lines = ['']*self.menu_height n = 0 for hkey, name in menulst: if n == self.menu_height: n = 0 marker = ' '*2 if name == self.current_name: if enable_color: mark = avkutil.color('>', highlight) else: mark = '>' marker = mark + ' ' if name: s = '' highlighted = 0 for l in name: if l.lower() == hkey and not highlighted: if enable_color: if name == self.current_name: s += avkutil.color(l, highlight) else: s += avkutil.color(l, hotkey_color) else: s += l + ')' highlighted = 1 else: if name == self.current_name and enable_color: s += avkutil.color(l, highlight) else: s += l text = marker + s _len = 3 + len(name) if not enable_color: _len += 1 # to compensate for ')' lines[n] += text + ' '*(self.colwidth-_len) else: lines[n] += ' '*self.colwidth n += 1 return lines def pick(self): """Print menu and let user make a choice.""" self.pagenum = 0 self.clear_state() while 1: self.refresh() avkutil.Term().clear() print '\r' # somehow 1 space slips into the beginning of 1st line?? if self.header: for line in self.header: print line hotkeys, menulst = self.pages[self.pagenum] lines = self.make_columns(menulst) for line in lines: print line answer = self.user_input(hotkeys) if answer == 'menu:QUIT': return None elif answer: return answer def user_input(self, hotkeys): """Print prompt line and get user input.""" stat = "-------------------(page %d of %d)---(? for help)-- > " print stat % (self.pagenum+1, len(self.pages)), sys.stdout.flush() answer = avkutil.Term().getch() if answer == '\n': if self.last_hotkey: return hotkeys[self.last_hotkey][self.last_index][1] elif answer == '?': self.help() elif answer == ' ': if self.pagenum < len(self.pages)-1: self.clear_state() self.pagenum += 1 elif answer == 'b': if self.pagenum > 0: self.clear_state() self.pagenum -= 1 elif answer in ('q', '\01'): return 'menu:QUIT' elif hotkeys.has_key(answer): length = len(hotkeys[answer]) if length > 1: if self.last_hotkey == answer: index = self.last_index + 1 if index >= length: index = 0 else: index = 0 self.current_name = hotkeys[answer][index][0] self.last_hotkey = answer self.last_index = index else: return hotkeys[answer][0][1] elif answer == ',': # redraw screen pass return None def valid_chars(self, chars): valid = [] for c in chars.lower(): if c in string.letters + string.digits and c not in 'qb': valid.append(c) return valid