commit c1aecf68b13bf2eb12a104b98dab9dde6a2a46f0 Author: Damian Johnson atagar@torproject.org Date: Sat Jun 4 13:52:09 2011 -0700
Alternative menu implementation
This is a smaller and (I think) simpler implementation of a menu. Its model and presentation logic are done so the menu can be opened and used, but handlers haven't been attached yet so nothing happens when a selection is made. --- README | 5 + src/cli/controller.py | 5 +- src/cli/menu/__init__.py | 6 ++ src/cli/menu/item.py | 146 ++++++++++++++++++++++++++++++ src/cli/menu/menu.py | 222 ++++++++++++++++++++++++++++++++++++++++++++++ src/cli/popups.py | 15 ++-- src/util/panel.py | 2 +- 7 files changed, 393 insertions(+), 8 deletions(-)
diff --git a/README b/README index b20ec1c..0f26163 100644 --- a/README +++ b/README @@ -176,6 +176,11 @@ Layout: connEntry.py - individual connections to or from the system entries.py - common parent for connPanel display entries
+ menu/ + __init__.py + menu.py - provides an interactive menu + item.py - individual items within the menu + __init__.py controller.py - main display loop, handling input and layout headerPanel.py - top of all pages, providing general information diff --git a/src/cli/controller.py b/src/cli/controller.py index fcbaed5..fac43a4 100644 --- a/src/cli/controller.py +++ b/src/cli/controller.py @@ -8,6 +8,7 @@ import curses import threading
import cli.menu +import cli.menu.menu import cli.popups import cli.headerPanel import cli.logPanel @@ -510,7 +511,7 @@ def drawTorMonitor(stdscr, startTime): control.prevPage() elif key == ord('p') or key == ord('P'): control.setPaused(not control.isPaused()) - elif key == ord('m') or key == ord('M'): + elif key == ord('n') or key == ord('N'): menu = cli.menu.Menu() menuKeys = menu.showMenu(keys=menuKeys) if menuKeys != []: @@ -518,6 +519,8 @@ def drawTorMonitor(stdscr, startTime): if key in menuKeys: menuKeys.remove(key) overrideKey = key + elif key == ord('m') or key == ord('M'): + cli.menu.menu.showMenu() elif key == ord('q') or key == ord('Q'): # provides prompt to confirm that arm should exit if CONFIG["features.confirmQuit"]: diff --git a/src/cli/menu/__init__.py b/src/cli/menu/__init__.py new file mode 100644 index 0000000..e6b5f10 --- /dev/null +++ b/src/cli/menu/__init__.py @@ -0,0 +1,6 @@ +""" +Resources for displaying the menu. +""" + +__all__ = ["item", "menu"] + diff --git a/src/cli/menu/item.py b/src/cli/menu/item.py new file mode 100644 index 0000000..bfc7ffd --- /dev/null +++ b/src/cli/menu/item.py @@ -0,0 +1,146 @@ +""" +Menu item, representing an option in the drop-down menu. +""" + +class MenuItem(): + """ + Option in a drop-down menu. + """ + + def __init__(self, label, callback): + self._label = label + self._callback = callback + self._parent = None + + def getLabel(self): + """ + Provides a tuple of three strings representing the prefix, label, and + suffix for this item. + """ + + return ("", self._label, "") + + def getParent(self): + """ + Provides the Submenu we're contained within. + """ + + return self._parent + + def getHierarchy(self): + """ + Provides a list with all of our parents, up to the root. + """ + + myHierarchy = [self] + while myHierarchy[-1].getParent(): + myHierarchy.append(myHierarchy[-1].getParent()) + + myHierarchy.reverse() + return myHierarchy + + def getRoot(self): + """ + Provides the base submenu we belong to. + """ + + if self._parent: return self._parent.getRoot() + else: return self + + def select(self): + """ + Performs the callback for the menu item, returning true if we should close + the menu and false otherwise. + """ + + if self._callback: return self._callback() + else: return False + + def next(self): + """ + Provides the next option for the submenu we're in, raising a ValueError + if we don't have a parent. + """ + + return self._getSibling(1) + + def prev(self): + """ + Provides the previous option for the submenu we're in, raising a ValueError + if we don't have a parent. + """ + + return self._getSibling(-1) + + def _getSibling(self, offset): + """ + Provides our sibling with a given index offset from us, raising a + ValueError if we don't have a parent. + + Arguments: + offset - index offset for the sibling to be returned + """ + + if self._parent: + mySiblings = self._parent.getChildren() + + try: + myIndex = mySiblings.index(self) + return mySiblings[(myIndex + offset) % len(mySiblings)] + except ValueError: + # We expect a bidirectional references between submenus and their + # children. If we don't have this then our menu's screwed up. + + msg = "The '%s' submenu doesn't contain '%s' (children: '%s')" % (self, self._parent, "', '".join(mySiblings)) + raise ValueError(msg) + else: raise ValueError("Menu option '%s' doesn't have a parent" % self) + + def __str__(self): + return self._label + +class Submenu(MenuItem): + """ + Menu item that lists other menu options. + """ + + def __init__(self, label): + MenuItem.__init__(self, label, None) + self._children = [] + + def getLabel(self): + """ + Provides our label with a ">" suffix to indicate that we have suboptions. + """ + + myLabel = MenuItem.getLabel(self)[1] + return ("", myLabel, " >") + + def add(self, menuItem): + """ + Adds the given menu item to our listing. This raises a ValueError if the + item already has a parent. + + Arguments: + menuItem - menu option to be added + """ + + if menuItem.getParent(): + raise ValueError("Menu option '%s' already has a parent" % menuItem) + else: + menuItem._parent = self + self._children.append(menuItem) + + def getChildren(self): + """ + Provides the menu and submenus we contain. + """ + + return list(self._children) + + def isEmpty(self): + """ + True if we have no children, false otherwise. + """ + + return not bool(self._children) + diff --git a/src/cli/menu/menu.py b/src/cli/menu/menu.py new file mode 100644 index 0000000..02f87ee --- /dev/null +++ b/src/cli/menu/menu.py @@ -0,0 +1,222 @@ + +import curses + +import cli.popups +import cli.controller +import cli.menu.item + +from util import uiTools + +def makeMenu(): + """ + Constructs the base menu and all of its contents. + """ + + baseMenu = cli.menu.item.Submenu("") + + fileMenu = cli.menu.item.Submenu("File") + fileMenu.add(cli.menu.item.MenuItem("Exit", None)) + baseMenu.add(fileMenu) + + logsMenu = cli.menu.item.Submenu("Logs") + logsMenu.add(cli.menu.item.MenuItem("Events", None)) + logsMenu.add(cli.menu.item.MenuItem("Clear", None)) + logsMenu.add(cli.menu.item.MenuItem("Save", None)) + logsMenu.add(cli.menu.item.MenuItem("Filter", None)) + + duplicatesSubmenu = cli.menu.item.Submenu("Duplicates") + duplicatesSubmenu.add(cli.menu.item.MenuItem("Hidden", None)) + duplicatesSubmenu.add(cli.menu.item.MenuItem("Visible", None)) + logsMenu.add(duplicatesSubmenu) + baseMenu.add(logsMenu) + + viewMenu = cli.menu.item.Submenu("View") + viewMenu.add(cli.menu.item.MenuItem("Graph", None)) + viewMenu.add(cli.menu.item.MenuItem("Connections", None)) + viewMenu.add(cli.menu.item.MenuItem("Configuration", None)) + viewMenu.add(cli.menu.item.MenuItem("Configuration File", None)) + baseMenu.add(viewMenu) + + graphMenu = cli.menu.item.Submenu("Graph") + graphMenu.add(cli.menu.item.MenuItem("Stats", None)) + + sizeSubmenu = cli.menu.item.Submenu("Size") + sizeSubmenu.add(cli.menu.item.MenuItem("Increase", None)) + sizeSubmenu.add(cli.menu.item.MenuItem("Decrease", None)) + graphMenu.add(sizeSubmenu) + + graphMenu.add(cli.menu.item.MenuItem("Update Interval", None)) + + boundsSubmenu = cli.menu.item.Submenu("Bounds") + boundsSubmenu.add(cli.menu.item.MenuItem("Local Max", None)) + boundsSubmenu.add(cli.menu.item.MenuItem("Global Max", None)) + boundsSubmenu.add(cli.menu.item.MenuItem("Tight", None)) + graphMenu.add(boundsSubmenu) + baseMenu.add(graphMenu) + + connectionsMenu = cli.menu.item.Submenu("Connections") + connectionsMenu.add(cli.menu.item.MenuItem("Identity", None)) + connectionsMenu.add(cli.menu.item.MenuItem("Resolver", None)) + connectionsMenu.add(cli.menu.item.MenuItem("Sort Order", None)) + baseMenu.add(connectionsMenu) + + configurationMenu = cli.menu.item.Submenu("Configuration") + + commentsSubmenu = cli.menu.item.Submenu("Comments") + commentsSubmenu.add(cli.menu.item.MenuItem("Hidden", None)) + commentsSubmenu.add(cli.menu.item.MenuItem("Visible", None)) + configurationMenu.add(commentsSubmenu) + + configurationMenu.add(cli.menu.item.MenuItem("Reload", None)) + configurationMenu.add(cli.menu.item.MenuItem("Reset Tor", None)) + baseMenu.add(configurationMenu) + + return baseMenu + +class MenuCursor: + """ + Tracks selection and key handling in the menu. + """ + + def __init__(self, initialSelection): + self._selection = initialSelection + self._isDone = False + + def isDone(self): + """ + Provides true if a selection has indicated that we should close the menu. + False otherwise. + """ + + return self._isDone + + def getSelection(self): + """ + Provides the currently selected menu item. + """ + + return self._selection + + def handleKey(self, key): + isSelectionSubmenu = isinstance(self._selection, cli.menu.item.Submenu) + selectionHierarchy = self._selection.getHierarchy() + + if uiTools.isSelectionKey(key): + if isSelectionSubmenu: + if not self._selection.isEmpty(): + self._selection = self._selection.getChildren()[0] + else: self._isDone = self._selection.select() + elif key == curses.KEY_UP: + self._selection = self._selection.prev() + elif key == curses.KEY_DOWN: + self._selection = self._selection.next() + elif key == curses.KEY_LEFT: + if len(selectionHierarchy) <= 3: + # shift to the previous main submenu + prevSubmenu = selectionHierarchy[1].prev() + self._selection = prevSubmenu.getChildren()[0] + else: + # go up a submenu level + self._selection = self._selection.getParent() + elif key == curses.KEY_RIGHT: + if isSelectionSubmenu: + # open submenu (same as making a selection) + if not self._selection.isEmpty(): + self._selection = self._selection.getChildren()[0] + else: + # shift to the next main submenu + nextSubmenu = selectionHierarchy[1].next() + self._selection = nextSubmenu.getChildren()[0] + elif key in (27, ord('m'), ord('M')): + # close menu + self._isDone = True + +def showMenu(): + popup, _, _ = cli.popups.init(1, belowStatic = False) + if not popup: return + control = cli.controller.getController() + + try: + # generates the menu and uses the initial selection of the first item in + # the file menu + menu = makeMenu() + cursor = MenuCursor(menu.getChildren()[0].getChildren()[0]) + + while not cursor.isDone(): + # sets the background color + popup.win.clear() + popup.win.bkgd(' ', curses.A_STANDOUT | uiTools.getColor("red")) + selectionHierarchy = cursor.getSelection().getHierarchy() + + # renders the menu bar, noting where the open submenu is positioned + drawLeft, selectionLeft = 0, 0 + + for topLevelItem in menu.getChildren(): + drawFormat = curses.A_BOLD + if topLevelItem == selectionHierarchy[1]: + drawFormat |= curses.A_UNDERLINE + selectionLeft = drawLeft + + drawLabel = " %s " % topLevelItem.getLabel()[1] + popup.addstr(0, drawLeft, drawLabel, drawFormat) + popup.addch(0, drawLeft + len(drawLabel), curses.ACS_VLINE) + + drawLeft += len(drawLabel) + 1 + + # recursively shows opened submenus + _drawSubmenu(cursor, 1, 1, selectionLeft) + + popup.win.refresh() + + key = control.getScreen().getch() + cursor.handleKey(key) + + # redraws the rest of the interface if we're rendering on it again + if not cursor.isDone(): + for panelImpl in control.getDisplayPanels(): + panelImpl.redraw(True) + finally: cli.popups.finalize() + +def _drawSubmenu(cursor, level, top, left): + selectionHierarchy = cursor.getSelection().getHierarchy() + + # checks if there's nothing to display + if len(selectionHierarchy) < level + 2: return + + # fetches the submenu and selection we're displaying + submenu = selectionHierarchy[level] + selection = selectionHierarchy[level + 1] + + # gets the size of the prefix, middle, and suffix columns + allLabelSets = [entry.getLabel() for entry in submenu.getChildren()] + prefixColSize = max([len(entry[0]) for entry in allLabelSets]) + middleColSize = max([len(entry[1]) for entry in allLabelSets]) + suffixColSize = max([len(entry[2]) for entry in allLabelSets]) + + # formatted string so we can display aligned menu entries + labelFormat = " %%-%is%%-%is%%-%is " % (prefixColSize, middleColSize, suffixColSize) + menuWidth = len(labelFormat % ("", "", "")) + + popup, _, _ = cli.popups.init(len(submenu.getChildren()), menuWidth, top, left, belowStatic = False) + if not popup: return + + try: + # sets the background color + popup.win.bkgd(' ', curses.A_STANDOUT | uiTools.getColor("red")) + + drawTop, selectionTop = 0, 0 + for menuItem in submenu.getChildren(): + if menuItem == selection: + drawFormat = curses.A_BOLD | uiTools.getColor("white") + selectionTop = drawTop + else: drawFormat = curses.A_NORMAL + + popup.addstr(drawTop, 0, labelFormat % menuItem.getLabel(), drawFormat) + drawTop += 1 + + popup.win.refresh() + + # shows the next submenu + _drawSubmenu(cursor, level + 1, top + selectionTop, left + menuWidth) + finally: cli.popups.finalize() + diff --git a/src/cli/popups.py b/src/cli/popups.py index 0e50aa1..b90f95d 100644 --- a/src/cli/popups.py +++ b/src/cli/popups.py @@ -8,7 +8,7 @@ import cli.controller
from util import panel, uiTools
-def init(height = -1, width = -1, top = 0, left = 0): +def init(height = -1, width = -1, top = 0, left = 0, belowStatic = True): """ Preparation for displaying a popup. This creates a popup with a valid subwindow instance. If that's successful then the curses lock is acquired @@ -17,14 +17,17 @@ def init(height = -1, width = -1, top = 0, left = 0): Otherwise this leaves curses unlocked and returns None.
Arguments: - height - maximum height of the popup - width - maximum width of the popup - top - top position, relative to the sticky content - left - left position from the screen + height - maximum height of the popup + width - maximum width of the popup + top - top position, relative to the sticky content + left - left position from the screen + belowStatic - positions popup below static content if true """
control = cli.controller.getController() - stickyHeight = sum(stickyPanel.getHeight() for stickyPanel in control.getStickyPanels()) + if belowStatic: + stickyHeight = sum(stickyPanel.getHeight() for stickyPanel in control.getStickyPanels()) + else: stickyHeight = 0
popup = panel.Panel(control.getScreen(), "popup", top + stickyHeight, left, height, width) popup.setVisible(True) diff --git a/src/util/panel.py b/src/util/panel.py index 93b44c2..6fae341 100644 --- a/src/util/panel.py +++ b/src/util/panel.py @@ -484,7 +484,7 @@ class Panel(): # direction) from actual content to prevent crash when shrank if self.win and self.maxX > x and self.maxY > y: try: - self.win.addstr(y, x, msg[:self.maxX - x - 1], attr) + self.win.addstr(y, x, msg[:self.maxX - x], attr) except: # this might produce a _curses.error during edge cases, for instance # when resizing with visible popups
tor-commits@lists.torproject.org