[tor-commits] [nyx/master] Merge ui_tools.py into curses.py

atagar at torproject.org atagar at torproject.org
Mon Mar 14 02:13:05 UTC 2016


commit aa58b0d3e2b032e85c1295028ee5d3865bd74508
Author: Damian Johnson <atagar at torproject.org>
Date:   Sat Mar 12 16:32:15 2016 -0800

    Merge ui_tools.py into curses.py
    
    Merging our old util in, revising the functions (especially the Scroller) along
    the way. The draw_box() method better fits with the panel for now.
---
 nyx/controller.py       |   5 +-
 nyx/curses.py           | 214 ++++++++++++++++++++++++++++++++++++++
 nyx/panel/config.py     |  46 ++++-----
 nyx/panel/connection.py |  36 +++----
 nyx/panel/log.py        |  17 ++-
 nyx/panel/torrc.py      |  29 ++++--
 nyx/popups.py           |  14 ++-
 nyx/starter.py          |   4 +-
 nyx/util/__init__.py    |   1 -
 nyx/util/panel.py       |  29 ++++++
 nyx/util/ui_tools.py    | 268 ------------------------------------------------
 11 files changed, 320 insertions(+), 343 deletions(-)

diff --git a/nyx/controller.py b/nyx/controller.py
index 1478779..f188bb1 100644
--- a/nyx/controller.py
+++ b/nyx/controller.py
@@ -9,6 +9,7 @@ import time
 import curses
 import threading
 
+import nyx.curses
 import nyx.menu.menu
 import nyx.popups
 import nyx.util.tracker
@@ -25,7 +26,7 @@ import stem
 from stem.util import conf, log
 
 from nyx.curses import NORMAL, BOLD, HIGHLIGHT
-from nyx.util import panel, tor_controller, ui_tools
+from nyx.util import panel, tor_controller
 
 
 NYX_CONTROLLER = None
@@ -389,7 +390,7 @@ def start_nyx(stdscr):
   control = get_controller()
 
   if not CONFIG['features.acsSupport']:
-    ui_tools.disable_acs()
+    nyx.curses.disable_acs()
 
   # provides notice about any unused config keys
 
diff --git a/nyx/curses.py b/nyx/curses.py
index 1280fc8..af3484b 100644
--- a/nyx/curses.py
+++ b/nyx/curses.py
@@ -14,6 +14,17 @@ if we want Windows support in the future too.
   get_color_override - provides color we override requests with
   set_color_override - sets color we override requests with
 
+  disable_acs - renders replacements for ACS characters
+  is_wide_characters_supported - checks if curses supports wide character
+
+  Scroller - scrolls content with keyboard navigation
+    |- location - present scroll location
+    +- handle_key - moves scroll based on user input
+
+  CursorScroller - scrolls content with a cursor for selecting items
+    |- selection - present selection and scroll location
+    +- handle_key - moves cursor based on user input
+
 .. data:: Color (enum)
 
   Terminal colors.
@@ -51,6 +62,7 @@ import curses
 
 import stem.util.conf
 import stem.util.enum
+import stem.util.system
 
 from nyx.util import msg, log
 
@@ -189,3 +201,205 @@ def _color_attr():
       COLOR_ATTR = DEFAULT_COLOR_ATTR
 
   return COLOR_ATTR
+
+
+def disable_acs():
+  """
+  Replaces ACS characters used for showing borders. This can be preferable if
+  curses is `unable to render them
+  <https://www.atagar.com/arm/images/acs_display_failure.png>`_.
+  """
+
+  for item in curses.__dict__:
+    if item.startswith('ACS_'):
+      curses.__dict__[item] = ord('+')
+
+  # replace common border pipe cahracters
+
+  curses.ACS_SBSB = ord('|')
+  curses.ACS_VLINE = ord('|')
+  curses.ACS_BSBS = ord('-')
+  curses.ACS_HLINE = ord('-')
+
+
+def is_wide_characters_supported():
+  """
+  Checks if our version of curses has wide character support. This is required
+  to print unicode.
+
+  :returns: **bool** that's **True** if curses supports wide characters, and
+    **False** if it either can't or this can't be determined
+  """
+
+  try:
+    # Gets the dynamic library used by the interpretor for curses. This uses
+    # 'ldd' on Linux or 'otool -L' on OSX.
+    #
+    # atagar at fenrir:~/Desktop$ ldd /usr/lib/python2.6/lib-dynload/_curses.so
+    #   linux-gate.so.1 =>  (0x00a51000)
+    #   libncursesw.so.5 => /lib/libncursesw.so.5 (0x00faa000)
+    #   libpthread.so.0 => /lib/tls/i686/cmov/libpthread.so.0 (0x002f1000)
+    #   libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0x00158000)
+    #   libdl.so.2 => /lib/tls/i686/cmov/libdl.so.2 (0x00398000)
+    #   /lib/ld-linux.so.2 (0x00ca8000)
+    #
+    # atagar$ otool -L /System/Library/Frameworks/Python.framework/Versions/2.5/lib/python2.5/lib-dynload/_curses.so
+    # /System/Library/Frameworks/Python.framework/Versions/2.5/lib/python2.5/lib-dynload/_curses.so:
+    #   /usr/lib/libncurses.5.4.dylib (compatibility version 5.4.0, current version 5.4.0)
+    #   /usr/lib/libgcc_s.1.dylib (compatibility version 1.0.0, current version 1.0.0)
+    #   /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 111.1.6)
+
+    import _curses
+
+    if stem.util.system.is_available('ldd'):
+      return 'libncursesw' in '\n'.join(lib_dependency_lines = stem.util.system.call('ldd %s' % _curses.__file__))
+    elif stem.util.system.is_available('otool'):
+      return 'libncursesw' in '\n'.join(lib_dependency_lines = stem.util.system.call('otool -L %s' % _curses.__file__))
+  except:
+    pass
+
+  return False
+
+
+class Scroller(object):
+  """
+  Simple scroller that provides keyboard navigation of content.
+  """
+
+  def __init__(self):
+    self._location = 0
+
+  def location(self, content_height = None, page_height = None):
+    """
+    Provides the position we've scrolled to.
+
+    If a **content_height** and **page_height** are provided this ensures our
+    scroll position falls within a valid range. This should be done when the
+    content changes or panel resized.
+
+    :param int content_height: height of the content being renered
+    :param int page_height: height visible on the page
+
+    :returns: **int** position we've scrolled to
+    """
+
+    if content_height is not None and page_height is not None:
+      self._location = max(0, min(self._location, content_height - page_height))
+
+    return self._location
+
+  def handle_key(self, key, content_height, page_height):
+    """
+    Moves scrolling location according to the given input...
+
+      * up / down - scrolls one position up or down
+      * page up / page down - scrolls by the page_height
+      * home / end - moves to the top or bottom
+
+    :param nyx.util.panel.KeyInput key: pressed key
+    :param int content_height: height of the content being renered
+    :param int page_height: height visible on the page
+
+    :returns: **bool** that's **True** if the scrolling position changed and
+      **False** otherwise
+    """
+
+    new_location = _scroll_position(self._location, key, content_height, page_height, False)
+
+    if new_location != self._location:
+      self._location = new_location
+      return True
+    else:
+      return False
+
+
+class CursorScroller(object):
+  """
+  Scroller that tracks a cursor's position.
+  """
+
+  def __init__(self):
+    self._location = 0
+
+    # We track the cursor location by the item we have selected, so it stays
+    # selected as the content changes. We also keep track of its last location
+    # so we can fall back to that if it disappears.
+
+    self._cursor_location = 0
+    self._cursor_selection = None
+
+  def selection(self, content, page_height = None):
+    """
+    Provides the item from the content that's presently selected. If provided
+    the height of our page this provides the scroll position as well...
+
+    ::
+
+      selected, scroll = my_scroller.selection(content, page_height)
+
+    :param list content: content the scroller is tracking
+    :param int page_height: height visible on the page
+
+    :returns: **tuple** of the form **(cursor, scroll)**, the cursor is
+      **None** if content is empty
+    """
+
+    content = list(content)  # shallow copy for thread safety
+
+    if not content:
+      self._cursor_location = 0
+      self._cursor_selection = None
+      return None if page_height is None else None, 0
+
+    if self._cursor_selection in content:
+      # moves cursor location to track the selection
+      self._cursor_location = content.index(self._cursor_selection)
+    else:
+      # select the next closest entry
+      self._cursor_location = max(0, min(self._cursor_location, len(content) - 1))
+      self._cursor_selection = content[self._cursor_location]
+
+    # ensure our cursor is visible
+
+    if page_height:
+      if self._cursor_location < self._location:
+        self._location = self._cursor_location
+      elif self._cursor_location > self._location + page_height - 1:
+        self._location = self._cursor_location - page_height + 1
+
+    if page_height is None:
+      return self._cursor_selection
+    else:
+      return self._cursor_selection, self._location
+
+  def handle_key(self, key, content, page_height):
+    self.selection(content, page_height)  # reset cursor position
+    new_location = _scroll_position(self._cursor_location, key, len(content), page_height, True)
+
+    if new_location != self._cursor_location:
+      self._cursor_location = new_location
+      self._cursor_selection = content[new_location]
+
+      return True
+    else:
+      return False
+
+
+def _scroll_position(location, key, content_height, page_height, is_cursor):
+  if key.match('up'):
+    shift = -1
+  elif key.match('down'):
+    shift = 1
+  elif key.match('page_up'):
+    shift = -page_height + 1 if is_cursor else -page_height
+  elif key.match('page_down'):
+    shift = page_height - 1 if is_cursor else page_height
+  elif key.match('home'):
+    shift = -content_height
+  elif key.match('end'):
+    shift = content_height
+  else:
+    return location
+
+  max_position = content_height - 1 if is_cursor else content_height - page_height
+  return max(0, min(location + shift, max_position))
diff --git a/nyx/panel/config.py b/nyx/panel/config.py
index fd529ca..2c09a35 100644
--- a/nyx/panel/config.py
+++ b/nyx/panel/config.py
@@ -7,13 +7,14 @@ import curses
 import os
 
 import nyx.controller
+import nyx.curses
 import nyx.popups
 
 import stem.control
 import stem.manual
 
 from nyx.curses import GREEN, CYAN, WHITE, NORMAL, BOLD, HIGHLIGHT
-from nyx.util import DATA_DIR, panel, tor_controller, ui_tools
+from nyx.util import DATA_DIR, panel, tor_controller
 
 from stem.util import conf, enum, log, str_tools
 
@@ -120,7 +121,7 @@ class ConfigPanel(panel.Panel):
     panel.Panel.__init__(self, stdscr, 'configuration', 0)
 
     self._contents = []
-    self._scroller = ui_tools.Scroller(True)
+    self._scroller = nyx.curses.CursorScroller()
     self._sort_order = CONFIG['features.config.order']
     self._show_all = False  # show all options, or just the important ones
 
@@ -237,23 +238,23 @@ class ConfigPanel(panel.Panel):
       if is_changed:
         self.redraw(True)
     elif key.is_selection():
-      selection = self._scroller.get_cursor_selection(self._get_config_options())
-      initial_value = selection.value() if selection.is_set() else ''
-      new_value = nyx.popups.input_prompt('%s Value (esc to cancel): ' % selection.name, initial_value)
+      selected = self._scroller.selection(self._get_config_options())
+      initial_value = selected.value() if selected.is_set() else ''
+      new_value = nyx.popups.input_prompt('%s Value (esc to cancel): ' % selected.name, initial_value)
 
       if new_value != initial_value:
         try:
-          if selection.value_type == 'Boolean':
+          if selected.value_type == 'Boolean':
             # if the value's a boolean then allow for 'true' and 'false' inputs
 
             if new_value.lower() == 'true':
               new_value = '1'
             elif new_value.lower() == 'false':
               new_value = '0'
-          elif selection.value_type == 'LineList':
+          elif selected.value_type == 'LineList':
             new_value = new_value.split(',')  # set_conf accepts list inputs
 
-          tor_controller().set_conf(selection.name, new_value)
+          tor_controller().set_conf(selected.name, new_value)
           self.redraw(True)
         except Exception as exc:
           nyx.popups.show_msg('%s (press any key)' % exc)
@@ -283,12 +284,11 @@ class ConfigPanel(panel.Panel):
 
   def draw(self, width, height):
     contents = self._get_config_options()
-    selection = self._scroller.get_cursor_selection(contents)
-    scroll_location = self._scroller.get_scroll_location(contents, height - DETAILS_HEIGHT)
+    selected, scroll = self._scroller.selection(contents, height - DETAILS_HEIGHT)
     is_scrollbar_visible = len(contents) > height - DETAILS_HEIGHT
 
-    if selection is not None:
-      self._draw_selection_details(selection, width)
+    if selected is not None:
+      self._draw_selection_details(selected, width)
 
     if self.is_title_visible():
       hidden_msg = "press 'a' to hide most options" if self._show_all else "press 'a' to show all options"
@@ -298,9 +298,9 @@ class ConfigPanel(panel.Panel):
 
     if is_scrollbar_visible:
       scroll_offset = 3
-      self.add_scroll_bar(scroll_location, scroll_location + height - DETAILS_HEIGHT, len(contents), DETAILS_HEIGHT)
+      self.add_scroll_bar(scroll, scroll + height - DETAILS_HEIGHT, len(contents), DETAILS_HEIGHT)
 
-      if selection is not None:
+      if selected is not None:
         self.addch(DETAILS_HEIGHT - 1, 1, curses.ACS_TTEE)
 
     # Description column can grow up to eighty characters. After that any extra
@@ -314,10 +314,10 @@ class ConfigPanel(panel.Panel):
     else:
       value_width = VALUE_WIDTH
 
-    for i, entry in enumerate(contents[scroll_location:]):
+    for i, entry in enumerate(contents[scroll:]):
       attr = [CONFIG['attr.config.category_color'].get(entry.manual.category, WHITE)]
       attr.append(BOLD if entry.is_set() else NORMAL)
-      attr.append(HIGHLIGHT if entry == selection else NORMAL)
+      attr.append(HIGHLIGHT if entry == selected else NORMAL)
 
       option_label = str_tools.crop(entry.name, NAME_WIDTH).ljust(NAME_WIDTH + 1)
       value_label = str_tools.crop(entry.value(), value_width).ljust(value_width + 1)
@@ -331,18 +331,18 @@ class ConfigPanel(panel.Panel):
   def _get_config_options(self):
     return self._contents if self._show_all else filter(lambda entry: stem.manual.is_important(entry.name) or entry.is_set(), self._contents)
 
-  def _draw_selection_details(self, selection, width):
+  def _draw_selection_details(self, selected, width):
     """
     Shows details of the currently selected option.
     """
 
-    description = 'Description: %s' % (selection.manual.description)
-    attr = ', '.join(('custom' if selection.is_set() else 'default', selection.value_type, 'usage: %s' % selection.manual.usage))
-    selected_color = CONFIG['attr.config.category_color'].get(selection.manual.category, WHITE)
-    ui_tools.draw_box(self, 0, 0, width, DETAILS_HEIGHT)
+    description = 'Description: %s' % (selected.manual.description)
+    attr = ', '.join(('custom' if selected.is_set() else 'default', selected.value_type, 'usage: %s' % selected.manual.usage))
+    selected_color = CONFIG['attr.config.category_color'].get(selected.manual.category, WHITE)
+    self.draw_box(0, 0, width, DETAILS_HEIGHT)
 
-    self.addstr(1, 2, '%s (%s Option)' % (selection.name, selection.manual.category), selected_color, BOLD)
-    self.addstr(2, 2, 'Value: %s (%s)' % (selection.value(), str_tools.crop(attr, width - len(selection.value()) - 13)), selected_color, BOLD)
+    self.addstr(1, 2, '%s (%s Option)' % (selected.name, selected.manual.category), selected_color, BOLD)
+    self.addstr(2, 2, 'Value: %s (%s)' % (selected.value(), str_tools.crop(attr, width - len(selected.value()) - 13)), selected_color, BOLD)
 
     for i in range(DETAILS_HEIGHT - 4):
       if not description:
diff --git a/nyx/panel/connection.py b/nyx/panel/connection.py
index 2c8fce9..7e01674 100644
--- a/nyx/panel/connection.py
+++ b/nyx/panel/connection.py
@@ -9,12 +9,12 @@ import curses
 import itertools
 import threading
 
+import nyx.curses
 import nyx.popups
 import nyx.util.tracker
-import nyx.util.ui_tools
 
 from nyx.curses import WHITE, NORMAL, BOLD, HIGHLIGHT
-from nyx.util import panel, tor_controller, ui_tools
+from nyx.util import panel, tor_controller
 
 from stem.control import Listener
 from stem.util import datetime_to_unix, conf, connection, enum, str_tools
@@ -263,7 +263,7 @@ class ConnectionPanel(panel.Panel, threading.Thread):
     threading.Thread.__init__(self)
     self.setDaemon(True)
 
-    self._scroller = ui_tools.Scroller(True)
+    self._scroller = nyx.curses.CursorScroller()
     self._entries = []            # last fetched display entries
     self._show_details = False    # presents the details panel if true
     self._sort_order = CONFIG['features.connection.order']
@@ -338,10 +338,10 @@ class ConnectionPanel(panel.Panel, threading.Thread):
 
       resolver = connection_tracker.get_custom_resolver()
       selected_index = 0 if resolver is None else options.index(resolver)
-      selection = nyx.popups.show_menu('Connection Resolver:', options, selected_index)
+      selected = nyx.popups.show_menu('Connection Resolver:', options, selected_index)
 
-      if selection != -1:
-        connection_tracker.set_custom_resolver(None if selection == 0 else options[selection])
+      if selected != -1:
+        connection_tracker.set_custom_resolver(None if selected == 0 else options[selected])
     elif key.match('d'):
       self.set_title_visible(False)
       self.redraw(True)
@@ -349,16 +349,16 @@ class ConnectionPanel(panel.Panel, threading.Thread):
 
       while True:
         lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in entries]))
-        selection = self._scroller.get_cursor_selection(lines)
+        selected = self._scroller.selection(lines)
 
-        if not selection:
+        if not selected:
           break
 
         def is_close_key(key):
           return key.is_selection() or key.match('d') or key.match('left') or key.match('right')
 
-        color = CONFIG['attr.connection.category_color'].get(selection.entry.get_type(), WHITE)
-        key = nyx.popups.show_descriptor_popup(selection.fingerprint, color, self.max_x, is_close_key)
+        color = CONFIG['attr.connection.category_color'].get(selected.entry.get_type(), WHITE)
+        key = nyx.popups.show_descriptor_popup(selected.fingerprint, color, self.max_x, is_close_key)
 
         if not key or key.is_selection() or key.match('d'):
           break  # closes popup
@@ -445,7 +445,9 @@ class ConnectionPanel(panel.Panel, threading.Thread):
     entries = self._entries
 
     lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in entries]))
-    selected = self._scroller.get_cursor_selection(lines)
+    is_showing_details = self._show_details and lines
+    details_offset = DETAILS_HEIGHT + 1 if is_showing_details else 0
+    selected, scroll = self._scroller.selection(lines, height - details_offset - 1)
 
     if self.is_paused():
       current_time = self.get_pause_time()
@@ -454,12 +456,8 @@ class ConnectionPanel(panel.Panel, threading.Thread):
     else:
       current_time = time.time()
 
-    is_showing_details = self._show_details and selected
-    details_offset = DETAILS_HEIGHT + 1 if is_showing_details else 0
-
     is_scrollbar_visible = len(lines) > height - details_offset - 1
     scroll_offset = 2 if is_scrollbar_visible else 0
-    scroll_location = self._scroller.get_scroll_location(lines, height - details_offset - 1)
 
     if self.is_title_visible():
       self._draw_title(entries, self._show_details)
@@ -468,10 +466,10 @@ class ConnectionPanel(panel.Panel, threading.Thread):
       self._draw_details(selected, width, is_scrollbar_visible)
 
     if is_scrollbar_visible:
-      self.add_scroll_bar(scroll_location, scroll_location + height - details_offset - 1, len(lines), 1 + details_offset)
+      self.add_scroll_bar(scroll, scroll + height - details_offset - 1, len(lines), 1 + details_offset)
 
-    for line_number in range(scroll_location, len(lines)):
-      y = line_number + details_offset + 1 - scroll_location
+    for line_number in range(scroll, len(lines)):
+      y = line_number + details_offset + 1 - scroll
       self._draw_line(scroll_offset, y, lines[line_number], lines[line_number] == selected, width - scroll_offset, current_time)
 
       if y >= height:
@@ -547,7 +545,7 @@ class ConnectionPanel(panel.Panel, threading.Thread):
 
     # draw the border, with a 'T' pipe if connecting with the scrollbar
 
-    ui_tools.draw_box(self, 0, 0, width, DETAILS_HEIGHT + 2)
+    self.draw_box(0, 0, width, DETAILS_HEIGHT + 2)
 
     if is_scrollbar_visible:
       self.addch(DETAILS_HEIGHT + 1, 1, curses.ACS_TTEE)
diff --git a/nyx/panel/log.py b/nyx/panel/log.py
index e674ac1..ba22d3c 100644
--- a/nyx/panel/log.py
+++ b/nyx/panel/log.py
@@ -11,11 +11,12 @@ import threading
 import stem.response.events
 
 import nyx.arguments
+import nyx.curses
 import nyx.popups
 import nyx.util.log
 
 from nyx.curses import GREEN, YELLOW, WHITE, NORMAL, BOLD, HIGHLIGHT
-from nyx.util import join, panel, tor_controller, ui_tools
+from nyx.util import join, panel, tor_controller
 from stem.util import conf, log
 
 
@@ -76,6 +77,7 @@ class LogPanel(panel.Panel, threading.Thread):
 
     self.set_pause_attr('_event_log')
 
+    self._scroller = nyx.curses.Scroller()
     self._halt = False  # terminates thread if true
     self._pause_condition = threading.Condition()
     self._has_new_event = False
@@ -96,7 +98,6 @@ class LogPanel(panel.Panel, threading.Thread):
           log.info(str(exc))
 
     self._last_content_height = len(self._event_log)  # height of the rendered content when last drawn
-    self._scroll = 0
 
     # merge NYX_LOGGER into us, and listen for its future events
 
@@ -223,10 +224,9 @@ class LogPanel(panel.Panel, threading.Thread):
   def handle_key(self, key):
     if key.is_scroll():
       page_height = self.get_preferred_size()[0] - 1
-      new_scroll = ui_tools.get_scroll_position(key, self._scroll, page_height, self._last_content_height)
+      is_changed = self._scroller.handle_key(key, self._last_content_height, page_height)
 
-      if self._scroll != new_scroll:
-        self._scroll = new_scroll
+      if is_changed:
         self.redraw(True)
     elif key.match('u'):
       self.set_duplicate_visability(not self._show_duplicates)
@@ -271,12 +271,11 @@ class LogPanel(panel.Panel, threading.Thread):
     ]
 
   def draw(self, width, height):
-    self._scroll = max(0, min(self._scroll, self._last_content_height - height + 1))
+    scroll = self._scroller.location(self._last_content_height, height)
 
     event_log = list(self.get_attr('_event_log'))
     event_filter = self._filter.clone()
     event_types = list(self._event_types)
-    scroll = int(self._scroll)
     last_content_height = self._last_content_height
     show_duplicates = self._show_duplicates
 
@@ -309,7 +308,7 @@ class LogPanel(panel.Panel, threading.Thread):
         for entry in day_to_entries[day]:
           y = self._draw_entry(x, y, width, entry, show_duplicates)
 
-        ui_tools.draw_box(self, original_y, x - 1, width - x + 1, y - original_y + 1, YELLOW, BOLD)
+        self.draw_box(original_y, x - 1, width - x + 1, y - original_y + 1, YELLOW, BOLD)
         time_label = time.strftime(' %B %d, %Y ', time.localtime(day_to_entries[day][0].timestamp))
         self.addstr(original_y, x + 1, time_label, YELLOW, BOLD)
 
@@ -330,7 +329,7 @@ class LogPanel(panel.Panel, threading.Thread):
 
     if content_height_delta >= CONTENT_HEIGHT_REDRAW_THRESHOLD:
       force_redraw_reason = 'estimate was off by %i' % content_height_delta
-    elif new_content_height > height and self._scroll + height - 1 > new_content_height:
+    elif new_content_height > height and scroll + height - 1 > new_content_height:
       force_redraw_reason = 'scrolled off the bottom of the page'
     elif not is_scrollbar_visible and new_content_height > height - 1:
       force_redraw_reason = "scroll bar wasn't previously visible"
diff --git a/nyx/panel/torrc.py b/nyx/panel/torrc.py
index ae26c83..bf10efb 100644
--- a/nyx/panel/torrc.py
+++ b/nyx/panel/torrc.py
@@ -3,9 +3,12 @@ Panel displaying the torrc or nyxrc with the validation done against it.
 """
 
 import math
+import string
+
+import nyx.curses
 
 from nyx.curses import RED, GREEN, YELLOW, CYAN, WHITE, BOLD, HIGHLIGHT
-from nyx.util import expand_path, msg, panel, tor_controller, ui_tools
+from nyx.util import expand_path, msg, panel, tor_controller
 
 from stem import ControllerError
 from stem.control import State
@@ -20,7 +23,7 @@ class TorrcPanel(panel.Panel):
   def __init__(self, stdscr):
     panel.Panel.__init__(self, stdscr, 'torrc', 0)
 
-    self._scroll = 0
+    self._scroller = nyx.curses.Scroller()
     self._show_line_numbers = True  # shows left aligned line numbers
     self._show_comments = True  # shows comments and extra whitespace
     self._last_content_height = 0
@@ -41,9 +44,14 @@ class TorrcPanel(panel.Panel):
     if event_type == State.RESET:
       try:
         self._torrc_location = expand_path(controller.get_info('config-file'))
+        contents = []
 
         with open(self._torrc_location) as torrc_file:
-          self._torrc_content = [ui_tools.get_printable(line.replace('\t', '   ')).rstrip() for line in torrc_file.readlines()]
+          for line in torrc_file.readlines():
+            line = line.replace('\t', '   ').replace('\xc2', "'").rstrip()
+            contents.append(filter(lambda char: char in string.printable, line))
+
+        self._torrc_content = contents
       except ControllerError as exc:
         self._torrc_load_error = msg('panel.torrc.unable_to_find_torrc', error = exc)
         self._torrc_location = None
@@ -75,10 +83,9 @@ class TorrcPanel(panel.Panel):
   def handle_key(self, key):
     if key.is_scroll():
       page_height = self.get_preferred_size()[0] - 1
-      new_scroll = ui_tools.get_scroll_position(key, self._scroll, page_height, self._last_content_height)
+      is_changed = self._scroller.handle_key(key, self._last_content_height, page_height)
 
-      if self._scroll != new_scroll:
-        self._scroll = new_scroll
+      if is_changed:
         self.redraw(True)
     elif key.match('l'):
       self.set_line_number_visible(not self._show_line_numbers)
@@ -101,12 +108,12 @@ class TorrcPanel(panel.Panel):
     ]
 
   def draw(self, width, height):
+    scroll = self._scroller.location(self._last_content_height, height)
+
     if self._torrc_content is None:
       self.addstr(1, 0, self._torrc_load_error, RED, BOLD)
       new_content_height = 1
     else:
-      self._scroll = max(0, min(self._scroll, self._last_content_height - height + 1))
-
       if not self._show_line_numbers:
         line_number_offset = 0
       elif len(self._torrc_content) == 0:
@@ -118,9 +125,9 @@ class TorrcPanel(panel.Panel):
 
       if self._last_content_height > height - 1:
         scroll_offset = 3
-        self.add_scroll_bar(self._scroll, self._scroll + height - 1, self._last_content_height, 1)
+        self.add_scroll_bar(scroll, scroll + height - 1, self._last_content_height, 1)
 
-      y = 1 - self._scroll
+      y = 1 - scroll
       is_multiline = False  # true if we're in the middle of a multiline torrc entry
 
       for line_number, line in enumerate(self._torrc_content):
@@ -159,7 +166,7 @@ class TorrcPanel(panel.Panel):
 
         y += 1
 
-      new_content_height = y + self._scroll - 1
+      new_content_height = y + scroll - 1
 
     if self.is_title_visible():
       self.addstr(0, 0, ' ' * width)  # clear line
diff --git a/nyx/popups.py b/nyx/popups.py
index 1a711c7..c05db05 100644
--- a/nyx/popups.py
+++ b/nyx/popups.py
@@ -9,10 +9,11 @@ import curses
 import operator
 
 import nyx.controller
+import nyx.curses
 
 from nyx import __version__, __release_date__
 from nyx.curses import RED, GREEN, YELLOW, CYAN, WHITE, NORMAL, BOLD, HIGHLIGHT
-from nyx.util import tor_controller, panel, ui_tools
+from nyx.util import tor_controller, panel
 
 NO_STATS_MSG = "Usage stats aren't available yet, press any key..."
 
@@ -463,20 +464,17 @@ def show_descriptor_popup(fingerprint, color, max_width, is_close_key):
     if not popup:
       return None
 
-    scroll, redraw = 0, True
+    scroller, redraw = nyx.curses.Scroller(), True
 
     while True:
       if redraw:
-        _draw(popup, title, lines, color, scroll, show_line_numbers)
+        _draw(popup, title, lines, color, scroller.location(), show_line_numbers)
         redraw = False
 
       key = nyx.controller.get_controller().key_input()
 
       if key.is_scroll():
-        new_scroll = ui_tools.get_scroll_position(key, scroll, height - 2, len(lines))
-
-        if scroll != new_scroll:
-          scroll, redraw = new_scroll, True
+        redraw = scroller.handle_key(key, len(lines), height - 2)
       elif is_close_key(key):
         return key
 
@@ -560,7 +558,7 @@ def _draw(popup, title, lines, entry_color, scroll, show_line_numbers):
     if show_line_numbers:
       popup.addstr(y, 2, str(i + 1).rjust(line_number_width), LINE_NUMBER_COLOR, BOLD)
 
-    x, y = popup.addstr_wrap(y, width, keyword, width, offset, color, BOLD)
+    x, y = popup.addstr_wrap(y, 3 + line_number_width, keyword, width, offset, color, BOLD)
     x, y = popup.addstr_wrap(y, x + 1, value, width, offset, color)
 
     y += 1
diff --git a/nyx/starter.py b/nyx/starter.py
index e46d494..83825e0 100644
--- a/nyx/starter.py
+++ b/nyx/starter.py
@@ -17,9 +17,9 @@ import time
 import nyx
 import nyx.arguments
 import nyx.controller
+import nyx.curses
 import nyx.util.panel
 import nyx.util.tracker
-import nyx.util.ui_tools
 
 import stem
 import stem.util.log
@@ -237,7 +237,7 @@ def _use_unicode(config):
 
   is_lang_unicode = 'utf-' in os.getenv('LANG', '').lower()
 
-  if is_lang_unicode and nyx.util.ui_tools.is_wide_characters_supported():
+  if is_lang_unicode and nyx.curses.is_wide_characters_supported():
     locale.setlocale(locale.LC_ALL, '')
 
 
diff --git a/nyx/util/__init__.py b/nyx/util/__init__.py
index 1bc3d71..176753b 100644
--- a/nyx/util/__init__.py
+++ b/nyx/util/__init__.py
@@ -18,7 +18,6 @@ __all__ = [
   'log',
   'panel',
   'tracker',
-  'ui_tools',
 ]
 
 TOR_CONTROLLER = None
diff --git a/nyx/util/panel.py b/nyx/util/panel.py
index 26b0af1..43e0236 100644
--- a/nyx/util/panel.py
+++ b/nyx/util/panel.py
@@ -769,6 +769,35 @@ class Panel(object):
 
     return recreate
 
+  def draw_box(self, top, left, width, height, *attributes):
+    """
+    Draws a box in the panel with the given bounds.
+
+    Arguments:
+      top    - vertical position of the box's top
+      left   - horizontal position of the box's left side
+      width  - width of the drawn box
+      height - height of the drawn box
+      attr   - text attributes
+    """
+
+    # draws the top and bottom
+
+    self.hline(top, left + 1, width - 2, *attributes)
+    self.hline(top + height - 1, left + 1, width - 2, *attributes)
+
+    # draws the left and right sides
+
+    self.vline(top + 1, left, height - 2, *attributes)
+    self.vline(top + 1, left + width - 1, height - 2, *attributes)
+
+    # draws the corners
+
+    self.addch(top, left, curses.ACS_ULCORNER, *attributes)
+    self.addch(top, left + width - 1, curses.ACS_URCORNER, *attributes)
+    self.addch(top + height - 1, left, curses.ACS_LLCORNER, *attributes)
+    self.addch(top + height - 1, left + width - 1, curses.ACS_LRCORNER, *attributes)
+
 
 class KeyInput(object):
   """
diff --git a/nyx/util/ui_tools.py b/nyx/util/ui_tools.py
deleted file mode 100644
index ccea602..0000000
--- a/nyx/util/ui_tools.py
+++ /dev/null
@@ -1,268 +0,0 @@
-"""
-Toolkit for working with curses.
-"""
-
-import curses
-
-from curses.ascii import isprint
-
-from stem.util import system
-
-
-def disable_acs():
-  """
-  Replaces the curses ACS characters. This can be preferable if curses is
-  unable to render them...
-
-  http://www.atagar.com/nyx/images/acs_display_failure.png
-  """
-
-  for item in curses.__dict__:
-    if item.startswith('ACS_'):
-      curses.__dict__[item] = ord('+')
-
-  # replace a few common border pipes that are better rendered as '|' or
-  # '-' instead
-
-  curses.ACS_SBSB = ord('|')
-  curses.ACS_VLINE = ord('|')
-  curses.ACS_BSBS = ord('-')
-  curses.ACS_HLINE = ord('-')
-
-
-def get_printable(line, keep_newlines = True):
-  """
-  Provides the line back with non-printable characters stripped.
-
-  :param str line: string to be processed
-  :param str keep_newlines: retains newlines if **True**, stripped otherwise
-
-  :returns: **str** of the line with only printable content
-  """
-
-  line = line.replace('\xc2', "'")
-  line = filter(lambda char: isprint(char) or (keep_newlines and char == '\n'), line)
-
-  return line
-
-
-def draw_box(panel, top, left, width, height, *attributes):
-  """
-  Draws a box in the panel with the given bounds.
-
-  Arguments:
-    panel  - panel in which to draw
-    top    - vertical position of the box's top
-    left   - horizontal position of the box's left side
-    width  - width of the drawn box
-    height - height of the drawn box
-    attr   - text attributes
-  """
-
-  # draws the top and bottom
-
-  panel.hline(top, left + 1, width - 2, *attributes)
-  panel.hline(top + height - 1, left + 1, width - 2, *attributes)
-
-  # draws the left and right sides
-
-  panel.vline(top + 1, left, height - 2, *attributes)
-  panel.vline(top + 1, left + width - 1, height - 2, *attributes)
-
-  # draws the corners
-
-  panel.addch(top, left, curses.ACS_ULCORNER, *attributes)
-  panel.addch(top, left + width - 1, curses.ACS_URCORNER, *attributes)
-  panel.addch(top + height - 1, left, curses.ACS_LLCORNER, *attributes)
-  panel.addch(top + height - 1, left + width - 1, curses.ACS_LRCORNER, *attributes)
-
-
-def get_scroll_position(key, position, page_height, content_height, is_cursor = False):
-  """
-  Parses navigation keys, providing the new scroll possition the panel should
-  use. Position is always between zero and (content_height - page_height). This
-  handles the following keys:
-  Up / Down - scrolls a position up or down
-  Page Up / Page Down - scrolls by the page_height
-  Home - top of the content
-  End - bottom of the content
-
-  This provides the input position if the key doesn't correspond to the above.
-
-  Arguments:
-    key           - keycode for the user's input
-    position      - starting position
-    page_height    - size of a single screen's worth of content
-    content_height - total lines of content that can be scrolled
-    is_cursor      - tracks a cursor position rather than scroll if true
-  """
-
-  if key.is_scroll():
-    shift = 0
-
-    if key.match('up'):
-      shift = -1
-    elif key.match('down'):
-      shift = 1
-    elif key.match('page_up'):
-      shift = -page_height + 1 if is_cursor else -page_height
-    elif key.match('page_down'):
-      shift = page_height - 1 if is_cursor else page_height
-    elif key.match('home'):
-      shift = -content_height
-    elif key.match('end'):
-      shift = content_height
-
-    # returns the shift, restricted to valid bounds
-
-    max_location = content_height - 1 if is_cursor else content_height - page_height
-    return max(0, min(position + shift, max_location))
-  else:
-    return position
-
-
-class Scroller:
-  """
-  Tracks the scrolling position when there might be a visible cursor. This
-  expects that there is a single line displayed per an entry in the contents.
-  """
-
-  def __init__(self, is_cursor_enabled):
-    self.scroll_location, self.cursor_location = 0, 0
-    self.cursor_selection = None
-    self.is_cursor_enabled = is_cursor_enabled
-
-  def get_scroll_location(self, content, page_height):
-    """
-    Provides the scrolling location, taking into account its cursor's location
-    content size, and page height.
-
-    Arguments:
-      content    - displayed content
-      page_height - height of the display area for the content
-    """
-
-    if content and page_height:
-      self.scroll_location = max(0, min(self.scroll_location, len(content) - page_height + 1))
-
-      if self.is_cursor_enabled:
-        self.get_cursor_selection(content)  # resets the cursor location
-
-        # makes sure the cursor is visible
-
-        if self.cursor_location < self.scroll_location:
-          self.scroll_location = self.cursor_location
-        elif self.cursor_location > self.scroll_location + page_height - 1:
-          self.scroll_location = self.cursor_location - page_height + 1
-
-      # checks if the bottom would run off the content (this could be the
-      # case when the content's size is dynamic and entries are removed)
-
-      if len(content) > page_height:
-        self.scroll_location = min(self.scroll_location, len(content) - page_height)
-
-    return self.scroll_location
-
-  def get_cursor_selection(self, content):
-    """
-    Provides the selected item in the content. This is the same entry until
-    the cursor moves or it's no longer available (in which case it moves on to
-    the next entry).
-
-    Arguments:
-      content - displayed content
-    """
-
-    # TODO: needs to handle duplicate entries when using this for the
-    # connection panel
-
-    if not self.is_cursor_enabled:
-      return None
-    elif not content:
-      self.cursor_location, self.cursor_selection = 0, None
-      return None
-
-    self.cursor_location = min(self.cursor_location, len(content) - 1)
-
-    if self.cursor_selection is not None and self.cursor_selection in content:
-      # moves cursor location to track the selection
-      self.cursor_location = content.index(self.cursor_selection)
-    else:
-      # select the next closest entry
-      self.cursor_selection = content[self.cursor_location]
-
-    return self.cursor_selection
-
-  def handle_key(self, key, content, page_height):
-    """
-    Moves either the scroll or cursor according to the given input.
-
-    Arguments:
-      key        - key code of user input
-      content    - displayed content
-      page_height - height of the display area for the content
-    """
-
-    if self.is_cursor_enabled:
-      self.get_cursor_selection(content)  # resets the cursor location
-      start_location = self.cursor_location
-    else:
-      start_location = self.scroll_location
-
-    new_location = get_scroll_position(key, start_location, page_height, len(content), self.is_cursor_enabled)
-
-    if start_location != new_location:
-      if self.is_cursor_enabled:
-        self.cursor_selection = content[new_location]
-      else:
-        self.scroll_location = new_location
-
-      return True
-    else:
-      return False
-
-
-def is_wide_characters_supported():
-  """
-  Checks if our version of curses has wide character support. This is required
-  to print unicode.
-
-  :returns: **bool** that's **True** if curses supports wide characters, and
-    **False** if it either can't or this can't be determined
-  """
-
-  try:
-    # Gets the dynamic library used by the interpretor for curses. This uses
-    # 'ldd' on Linux or 'otool -L' on OSX.
-    #
-    # atagar at fenrir:~/Desktop$ ldd /usr/lib/python2.6/lib-dynload/_curses.so
-    #   linux-gate.so.1 =>  (0x00a51000)
-    #   libncursesw.so.5 => /lib/libncursesw.so.5 (0x00faa000)
-    #   libpthread.so.0 => /lib/tls/i686/cmov/libpthread.so.0 (0x002f1000)
-    #   libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0x00158000)
-    #   libdl.so.2 => /lib/tls/i686/cmov/libdl.so.2 (0x00398000)
-    #   /lib/ld-linux.so.2 (0x00ca8000)
-    #
-    # atagar$ otool -L /System/Library/Frameworks/Python.framework/Versions/2.5/lib/python2.5/lib-dynload/_curses.so
-    # /System/Library/Frameworks/Python.framework/Versions/2.5/lib/python2.5/lib-dynload/_curses.so:
-    #   /usr/lib/libncurses.5.4.dylib (compatibility version 5.4.0, current version 5.4.0)
-    #   /usr/lib/libgcc_s.1.dylib (compatibility version 1.0.0, current version 1.0.0)
-    #   /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 111.1.6)
-
-    import _curses
-
-    lib_dependency_lines = None
-
-    if system.is_available('ldd'):
-      lib_dependency_lines = system.call('ldd %s' % _curses.__file__)
-    elif system.is_available('otool'):
-      lib_dependency_lines = system.call('otool -L %s' % _curses.__file__)
-
-    if lib_dependency_lines:
-      for line in lib_dependency_lines:
-        if 'libncursesw' in line:
-          return True
-  except:
-    pass
-
-  return False





More information about the tor-commits mailing list