Source code for visidata.menu

import string
import textwrap
import time
import curses

from typing import List, Union
from visidata import vd, drawcache, colors, clipdraw, dispwidth
from visidata import BaseSheet, VisiData, AttrDict, ENTER

vd.option('disp_menu', True, 'show menu on top line when not active', sheettype=None)
vd.theme_option('disp_menu_keys', True, 'show keystrokes inline in submenus', sheettype=None)
vd.theme_option('color_menu', 'black on 68 blue', 'color of menu items in general')
vd.theme_option('color_menu_active', '223 yellow on black', 'color of active menu items')
vd.theme_option('color_menu_spec', 'black on 34 green', 'color of sheet-specific menu items')
vd.theme_option('color_menu_help', 'black italic on 68 blue', 'color of helpbox')

vd.theme_option('disp_menu_boxchars', '││──┌┐└┘├┤', 'box characters to use for menus')
vd.theme_option('disp_menu_more', '»', 'command submenu indicator')
vd.theme_option('disp_menu_push', '⎘', 'indicator if command pushes sheet onto sheet stack')
vd.theme_option('disp_menu_input', '…', 'indicator if input required for command')
vd.option('disp_menu_fmt', '| VisiData {vd.version} | {vd.motd}', 'right-side menu format string')

BaseSheet.init('activeMenuItems', list)
vd.menuRunning = False

@VisiData.property
def hintStatus(vd):
    if vd.wantsHelp('hints'):
        if int(time.time()/60) % 2 == 0:
            return 'Alt+H for help menu'
        else:
            return 'Ctrl+G to cycle help sidebar'

    return vd.sheet.getHint()

def menudraw(*args):
    return clipdraw(*args, truncator='')






def walkmenu(item, menupath=[]):
    if item.menus:
        for i in item.menus:
            yield from walkmenu(i, menupath+[i.title])
    else:
        yield item, menupath


def _finditem(menus, item:Union[str,int]):
    if isinstance(item, str):
        for m in menus:
            if item == m.title:
                return m
        return None

    return menus[item]


@BaseSheet.api
def getMenuItem(sheet, menupath:List[str]=None):
    if not menupath:
        menupath = sheet.activeMenuItems

    currentItem = None
    try:
        currentItem = sheet
        for i in menupath:
            currentItem = _finditem(currentItem.menus, i)
    except IndexError as e:
        vd.exceptionCaught(e)
#        sheet.activeMenuItems = sheet.activeMenuItems[:i-1]

    return currentItem


[docs] @VisiData.api def addMenuItem(vd, *args): 'Add one command to hierarchical menu at given menupath. Last argument must be valid command longname. Append menupath elements that do not exist. Example: vd.addMenuItem("File", "Options", "more options", "options-more")' m = Menu(*args[-2:]) for x in reversed(args[:-2]): m = Menu(x, m) vd.addMenu(m)
@VisiData.api def addMenuItems(vd, *itemgroups): '''Add any number of commands to menu, separated by lines, with individual menupaths separated by '>' character. Example: vd.addMenuItems(""" Help > About > credits > show-credits Help > About > environment > show-env """) ''' for itemgroup in itemgroups: for itemline in itemgroup.splitlines(): if not itemline: continue menupath = [x.strip() for x in itemline.split('>')] vd.addMenuItem(*menupath)
[docs] @VisiData.api def addMenu(vd, *args): '''Incorporate submenus and commands into hierarchical menu. Wrap all in Menu() objects. Example: vd.addMenu(Menu("Help", Menu("About", Menu("credits", "show-credits"), Menu("environment", "show-env"), )))''' m = Menu('top', *args) for item, menupath in walkmenu(m): obj = vd for p in menupath: c = _finditem(obj.menus, p) if not c: c = Menu(p) obj.menus.append(c) obj = c assert not obj.menus, 'cannot override submenu with longname' obj.longname = item.longname
def _intMenuPath(obj, menupath): 'Return list of numeric indexes of *menupath* (which may be numeric or string titles) through obj.menus.' if not menupath: return [] i = menupath[0] if isinstance(i, str): try: i = [x.title for x in obj.menus].index(i) except ValueError: vd.warning('no menupath %s' % menupath) return [] return [i] + _intMenuPath(obj.menus[i], menupath[1:]) vd.menus = [] vd.addMenu( Menu('File'), Menu('Edit'), Menu('View'), Menu('Column'), Menu('Row'), Menu('Data'), Menu('Plot'), Menu('System'), Menu('Help') ) @BaseSheet.api def menuitemAvailable(sheet, item): return any(sheet.menuitemAvailable(i) for i in item.menus) @VisiData.api def drawSubmenu(vd, scr, sheet, y, x, menus, level, disp_menu_boxchars=''): if not menus: return ls,rs,ts,bs,tl,tr,bl,br,lsr,rsl = disp_menu_boxchars try: sheet.activeMenuItems[level] %= len(menus) except IndexError: pass # sheet.activeMenuItems = sheet.activeMenuItems[:-1] for item in menus: if item.cmd: mainbinding = sheet.revbinds.get(item.cmd.longname, [None])[0] item.binding = vd.prettykeys(mainbinding) maxbinding = 0 if vd.options.disp_menu_keys: maxbinding = max(dispwidth(item.binding or '') for item in menus)+1 w = max(dispwidth(item.title) for item in menus)+maxbinding+2 # draw borders before/under submenus if level > 1: menudraw(scr, y-1, x, tl+ts*(w+2)+tr, colors.color_menu) # top menudraw(scr, y+len(menus), x, bl+bs*(w+2)+br, colors.color_menu) # bottom i = 0 for j, item in enumerate(menus): attr = colors.color_menu if any(foo.obj not in ['BaseSheet', 'TableSheet'] for foo, _ in walkmenu(item)): attr = colors.color_menu_spec if level < len(sheet.activeMenuItems): if j == sheet.activeMenuItems[level]: attr = colors.color_menu_active if level < len(sheet.activeMenuItems): vd.drawSubmenu(scr, sheet, y+i, x+w+4, item.menus, level+1, disp_menu_boxchars=disp_menu_boxchars) menudraw(scr, y+i, x, ls, colors.color_menu) title = item.title pretitle= ' ' titlenote= ' ' if item.menus: titlenote = vd.options.disp_menu_more mainbinding = '' # special notes if item.cmd: if item.cmd.execstr: if 'push(' in item.cmd.execstr: titlenote = vd.options.disp_menu_push + ' ' if 'input' in item.cmd.execstr: title += vd.options.disp_menu_input if maxbinding: revbinds = sheet.revbinds.get(item.cmd.longname, []) if revbinds: mainbinding = vd.prettykeys(revbinds[0]) # actually display the menu item title += ' '*(w-dispwidth(pretitle)-dispwidth(item.title)+1) # padding menudraw(scr, y+i, x+1, pretitle+title, attr) if maxbinding and mainbinding: menudraw(scr, y+i, x+1+w-dispwidth(mainbinding), mainbinding, attr.update(colors.keystrokes)) menudraw(scr, y+i, x+2+w, titlenote, attr) menudraw(scr, y+i, x+3+w, ls, colors.color_menu) vd.onMouse(scr, x, y+i, w+3, 1, BUTTON1_PRESSED=lambda y,x,key,p=sheet.activeMenuItems[:level]+[j]: sheet.pressMenu(*p), BUTTON2_PRESSED=vd.nop, BUTTON3_PRESSED=vd.nop, BUTTON1_CLICKED=lambda y,x,key,p=sheet.activeMenuItems[:level]+[j]: sheet.pressMenu(*p), BUTTON1_RELEASED=vd.nop, BUTTON2_RELEASED=vd.nop, BUTTON3_RELEASED=vd.nop) i += 1 @VisiData.api def nop(vd, *args, **kwargs): return False def _done(vd, *args, **kwargs): 'Accept and execute current menu item (like pressing Enter).' return True @BaseSheet.property @drawcache def menus(sheet): 'List of hierarchical menu items for commands available on this sheet.' def _menus(sheet, item): if item.longname: cmd = sheet.getCommand(item.longname) if cmd: item.cmd = cmd if vd.commands[item.longname].get('TableSheet', None): item.obj = 'TableSheet' elif vd.commands[item.longname].get('BaseSheet', None): item.obj = 'BaseSheet' else: item.obj = '' return item elif item.menus: menus = _menu_list(sheet, item.menus) if menus: title = getattr(item, 'title', '') return AttrDict(title=title, menus=menus, longname='') else: return item def _menu_list(sheet, menus): ret = [] for i in menus: m = _menus(sheet, i) if m: ret.append(m) return ret return _menu_list(sheet, vd.menus) @VisiData.api def drawMenu(vd, scr, sheet): h, w = scr.getmaxyx() scr.addstr(0, 0, ' '*(w-1), colors.color_menu.attr) disp_menu_boxchars = sheet.options.disp_menu_boxchars x = 1 toplevel = sheet.menus for i, item in enumerate(toplevel): if sheet.activeMenuItems and i == sheet.activeMenuItems[0]: cattr = colors.color_menu_active vd.drawSubmenu(scr, sheet, 1, x, item.menus, 1, disp_menu_boxchars) else: cattr = colors.color_menu menutitle = ' ' + ''.join(f'[:underline]{ch}[/]' if ch.isupper() else ch for ch in item.title) + ' ' menudraw(scr, 0, x, menutitle, cattr) vd.onMouse(scr, x, 0, dispwidth(item.title)+2, 1, BUTTON1_PRESSED=lambda y,x,key,i=i,sheet=sheet: sheet.pressMenu(i), BUTTON2_PRESSED=vd.nop, BUTTON3_PRESSED=vd.nop, BUTTON1_CLICKED=lambda y,x,key,i=i,sheet=sheet: sheet.pressMenu(i), BUTTON1_RELEASED=vd.nop, BUTTON2_RELEASED=vd.nop, BUTTON3_RELEASED=vd.nop) x += dispwidth(item.title)+2 rightdisp = sheet.options.disp_menu_fmt.format(sheet=sheet, vd=vd) menudraw(scr, 0, x+4, rightdisp, colors.color_menu) if not sheet.activeMenuItems: return currentItem = sheet.getMenuItem() cmd = currentItem.cmd if not cmd: return # helpbox sidelines = [] if 'push(' in cmd.execstr: sidelines += [vd.options.disp_menu_push + ' pushes sheet'] if 'input' in cmd.execstr: sidelines += [vd.options.disp_menu_input + ' needs input'] helpattr = colors.color_menu_help helpx = 30 helpw = min(w-helpx-4, 76) ls,rs,ts,bs,tl,tr,bl,br,lsr,rsl = disp_menu_boxchars helplines = textwrap.wrap(cmd.helpstr or '(no help available)', width=helpw-4) # place helpbox just below deepest menu menuh = 2+sum(sheet.activeMenuItems[1:-1]) menuh += len(sheet.getMenuItem(sheet.activeMenuItems[:-1]).menus) menuy = 16 # min(menuh, h-len(helplines)-3) y = menuy menudraw(scr, y, helpx, tl+ts*(helpw-2)+tr, helpattr) # top line y += 1 # cmd.helpstr text for i, line in enumerate(helplines): menudraw(scr, y+i, helpx, ls+' '+line+' '*(helpw-dispwidth(line)-3)+rs, helpattr) y += len(helplines) if sidelines: menudraw(scr, y, helpx, ls+' '*(helpw-2)+rs, helpattr) for i, line in enumerate(sidelines): menudraw(scr, y+i+1, helpx, ls+' '+line+' '*(helpw-dispwidth(line)-6)+rs, helpattr) y += len(sidelines)+1 menudraw(scr, y, helpx, bl+bs*(helpw-2)+br, helpattr) mainbinding = sheet.revbinds.get(cmd.longname, [None])[0] if mainbinding: menudraw(scr, menuy, helpx+2, rsl, helpattr) ks = vd.prettykeys(mainbinding or '(unbound)') menudraw(scr, menuy, helpx+3, ' '+ks+' ', colors.color_menu_active) menudraw(scr, menuy, helpx+2+dispwidth(ks)+3, lsr, helpattr) menudraw(scr, menuy, helpx+19, ' '+cmd.longname+' ', helpattr) vd.onMouse(scr, helpx, menuy, helpw, y-menuy+1, BUTTON1_PRESSED=_done, BUTTON1_CLICKED=_done, BUTTON1_RELEASED=vd.nop, BUTTON2_RELEASED=vd.nop, BUTTON3_RELEASED=vd.nop) @BaseSheet.api def pressMenu(sheet, *args): '''Navigate to given menupath in *args* and activate menu if not already activated. Return True if pressing current item. Example: sheet.pressMenu("Help", "Version") ''' ret = False p = _intMenuPath(sheet, args) if p == sheet.activeMenuItems: # clicking on current menu item if sheet.getMenuItem(p).longname: ret = True else: p += [0] sheet.activeMenuItems = p if not vd.menuRunning: vd.runMenu() return ret @BaseSheet.api def checkMenu(sheet): sheet.activeMenuItems[0] %= len(sheet.menus)
[docs] @VisiData.api def runMenu(vd): 'Activate menu, with sheet.activeMenuItems containing the navigated menu path. Does not return until menu is deactivated.' old_disp_menu = vd.options.disp_menu vd.options.disp_menu=True vd.menuRunning = True sheet = vd.activeSheet vd.setWindows(vd.scrFull) nEscapes = 0 def _clickedDuringMenu(): r = vd.parseMouse(menu=vd.scrMenu, top=vd.winTop, bot=vd.winBottom) f = vd.getMouse(r.x, r.y, r.keystroke) if f: if f(r.y, r.x, r.keystroke): # call each function until one returns a true-ish value return 'doit' else: return 'offmenu' try: while True: if len(sheet.activeMenuItems) < 2: sheet.activeMenuItems.append(0) vd.draw_all() k = vd.getkeystroke(vd.scrMenu, sheet) if not k: continue currentItem = sheet.getMenuItem() if k == 'Esc': # ESC nEscapes += 1 #1470 if nEscapes > 1: return else: vd.status('press Esc again to exit the menu') continue else: nEscapes = 0 if k in ['Ctrl+C', 'Ctrl+Q', 'q', 'Ctrl+H', 'Bksp']: return elif k in ['KEY_MOUSE']: r = _clickedDuringMenu() if r == 'offmenu': return # clicking off the menu is an escape elif r == 'doit': break elif k in ['Right', 'l']: if currentItem.menus and sheet.activeMenuItems[1] != 0: # not first item sheet.activeMenuItems.append(0) else: sheet.activeMenuItems = [sheet.activeMenuItems[0]+1, 0] elif k in ['Left', 'h']: if len(sheet.activeMenuItems) > 2: sheet.activeMenuItems.pop(-1) else: sheet.activeMenuItems = [sheet.activeMenuItems[0]-1, 0] elif k in ['Down', 'j']: sheet.activeMenuItems[-1] += 1 elif k in ['Up', 'k']: sheet.activeMenuItems[-1] -= 1 elif k in ['Space', 'Enter']: if currentItem.menus: sheet.activeMenuItems.append(0) else: break elif k in main_menu.keys(): sheet.pressMenu(main_menu[k]) else: vd.warning(f'unknown keystroke {k}') sheet.checkMenu() finally: vd.menuRunning = False sheet.activeMenuItems = [] vd.options.disp_menu=old_disp_menu vd.draw_all() sheet.execCommand(currentItem.longname)
main_menu = {'f': 'File', 'e': 'Edit', 'v': 'View', 'c': 'Column', 'r': 'Row', 'd': 'Data', 'p': 'Plot', 's': 'System', 'h': 'Help'} BaseSheet.addCommand('Alt+f', 'menu-file', 'pressMenu("File")', '') BaseSheet.addCommand('Alt+e', 'menu-edit', 'pressMenu("Edit")', '') BaseSheet.addCommand('Alt+v', 'menu-view', 'pressMenu("View")', '') BaseSheet.addCommand('Alt+c', 'menu-column', 'pressMenu("Column")', '') BaseSheet.addCommand('Alt+r', 'menu-row', 'pressMenu("Row")', '') BaseSheet.addCommand('Alt+d', 'menu-data', 'pressMenu("Data")', '') BaseSheet.addCommand('Alt+p', 'menu-plot', 'pressMenu("Plot")', '') BaseSheet.addCommand('Alt+s', 'menu-system', 'pressMenu("System")', '') BaseSheet.addCommand('Alt+h', 'menu-help', 'pressMenu("Help")', 'open the Help menu') BaseSheet.bindkey('Ctrl+H', 'menu-help') BaseSheet.bindkey('Bksp', 'menu-help') vd.addGlobals({'Menu': Menu})