Repository: enthought/chaco

Issue:
BetterZoom position attribute not initialized
The `position` attribute used by the `BetterZoom` tool only gets set by mouse moves.  Interactions which cause zooming prior to the mouse entering the component the tool is attached to will throw an exception when they try to access the attribute.

A sensible default should probably be provided for the position (eg. the centre of the component).



Discussion:

Resolving patch:
diff --git a/chaco/tools/better_zoom.py b/chaco/tools/better_zoom.py
--- a/chaco/tools/better_zoom.py
+++ b/chaco/tools/better_zoom.py
@@ -1,8 +1,17 @@
-import numpy
+# Copyright (c) 2005-2014, Enthought, Inc.
+# All rights reserved.
+#
+# This software is provided without warranty under the terms of the BSD
+# license included in LICENSE.txt and may be redistributed only
+# under the conditions described in the aforementioned license.  The license
+# is also available online at http://www.enthought.com/licenses/BSD.txt
+# Thanks for using Enthought open source!
+#
+# Author: Enthought, Inc.
 
 from chaco.grid_mapper import GridMapper
 from enable.api import BaseTool, KeySpec
-from traits.api import Enum, Float, Instance, Bool, HasTraits, List
+from traits.api import Enum, Float, Instance, Bool, List, Tuple
 
 from tool_history_mixin import ToolHistoryMixin
 from tool_states import ZoomState, PanState, GroupedToolState, ToolState
@@ -43,19 +52,22 @@ class BetterZoom(BaseTool, ToolHistoryMixin):
     # only applies in 'range' mode.
     axis = Enum("both", "index", "value")
 
-    # The maximum ratio between the original data space bounds and the zoomed-in
-    # data space bounds.  If No limit is desired, set to inf
+    # The maximum ratio between the original data space bounds and the
+    # zoomed-in data space bounds.  If No limit is desired, set to inf
     x_max_zoom_factor = Float(1e5)
     y_max_zoom_factor = Float(1e5)
 
-    # The maximum ratio between the zoomed-out data space bounds and the original
-    # bounds.  If No limit is desired, set to -inf
+    # The maximum ratio between the zoomed-out data space bounds and the
+    # original bounds.  If No limit is desired, set to -inf
     x_min_zoom_factor = Float(1e-5)
     y_min_zoom_factor = Float(1e-5)
 
     # The amount to zoom in by. The zoom out will be inversely proportional
     zoom_factor = Float(2.0)
 
+    #: the position to zoom on (usually the mouse location)
+    position = Tuple(Float, Float)
+
     # The zoom factor on each axis
     _index_factor = Float(1.0)
     _value_factor = Float(1.0)
@@ -83,7 +95,7 @@ def _do_zoom(self, new_index_factor, new_value_factor):
                 y = y_map.map_data(location[1])
                 nexty = y + (cy - y)*(self._value_factor/new_value_factor)
 
-            pan_state = PanState((cx,cy), (nextx, nexty))
+            pan_state = PanState((cx, cy), (nextx, nexty))
             zoom_state = ZoomState((self._index_factor, self._value_factor),
                                    (new_index_factor, new_value_factor))
 
@@ -185,7 +197,6 @@ def zoom_out_x(self, factor=0):
                 return
         self._do_zoom(new_index_factor, new_value_factor)
 
-
     def zoom_in_y(self, factor=0):
         if factor == 0:
             factor = self.zoom_factor
@@ -287,13 +298,11 @@ def _zoom_limit_reached(self, factor, xy_axis):
         """
 
         if xy_axis == 'x':
-            if factor <= self.x_max_zoom_factor and factor >= self.x_min_zoom_factor:
-                return False
-            return True
+            return not (self.x_min_zoom_factor <=
+                        factor <= self.x_max_zoom_factor)
         else:
-            if factor <= self.y_max_zoom_factor and factor >= self.y_min_zoom_factor:
-                return False
-            return True
+            return not (self.y_min_zoom_factor <=
+                        factor <= self.y_max_zoom_factor)
 
     def _zoom_in_mapper(self, mapper, factor):
 
@@ -358,3 +367,11 @@ def _reset_state_pressed(self):
         for state in self._history[::-1]:
             state.revert(self)
         self._history = []
+
+    #--------------------------------------------------------------------------
+    #  Traits defaults
+    #--------------------------------------------------------------------------
+
+    def _position_default(self):
+        # center of the component is a sensible default
+        return self._center_screen()


Test patch:
diff --git a/chaco/tools/tests/better_zoom_test_case.py b/chaco/tools/tests/better_zoom_test_case.py
new file mode 100644
--- /dev/null
+++ b/chaco/tools/tests/better_zoom_test_case.py
@@ -0,0 +1,50 @@
+# Copyright (c) 2014, Enthought, Inc.
+# All rights reserved.
+#
+# This software is provided without warranty under the terms of the BSD
+# license included in LICENSE.txt and may be redistributed only
+# under the conditions described in the aforementioned license.  The license
+# is also available online at http://www.enthought.com/licenses/BSD.txt
+# Thanks for using Enthought open source!
+#
+# Author: Enthought, Inc.
+
+""" Tests for the BetterZoom Chaco tool """
+
+import unittest
+
+import numpy
+
+from chaco.api import create_line_plot
+from chaco.tools.api import BetterZoom
+from enable.testing import EnableTestAssistant
+
+
+class TestBetterZoomTool(EnableTestAssistant, unittest.TestCase):
+    """ Tests for the BetterZoom Chaco tool """
+
+    def setUp(self):
+        values = numpy.arange(10)
+        self.plot = create_line_plot((values, values))
+        self.plot.bounds = [100, 100]
+        self.plot._window = self.create_mock_window()
+        self.tool = BetterZoom(component=self.plot)
+        self.plot.active_tool = self.tool
+        self.plot.do_layout()
+
+    def tearDown(self):
+        del self.tool
+        del self.plot
+
+    def test_default_position(self):
+        tool = self.tool
+
+        # this doesn't throw an exception
+        self.send_key(tool, '+')
+
+        self.assertEqual(tool.position, (50, 50))
+
+        # expected behaviour for a normal zoom in operation
+        self.assertNotEqual(tool._index_factor, 1.0)
+        self.assertNotEqual(tool._value_factor, 1.0)
+        self.assertEqual(len(tool._history), 2)