Repository: bokeh/bokeh

Issue:
Range tool gets stuck at minimum width
Working with range tool in Bokeh 1.0.4. All is well except when the box is squeezed to the minimum, the selector doesn't function any more (can't be dragged or resized, see the screenshot below).

![range_tool_stuck](https://user-images.githubusercontent.com/1736058/54990527-eb9c9480-4ff5-11e9-945a-8fa80c2345db.gif)

It can be quickly reproduced in the Bokeh range tool demo - https://bokeh.pydata.org/en/latest/docs/gallery/range_tool.html.

The counterpart in Plot.ly offers an interesting solution where the bars simply reverses when dragged to the other side - https://plot.ly/~PythonPlotBot/1855.embed

Would really appreciate it if this can be fixed or even better, the same trick can be done in Bokeh :)


Discussion:
Letting it flip seems like a reasonable thing to do. 
Thanks @bryevdv !

And sorry there's another minor issue - when the range box goes out of bounds via wheel scroll, the box can no longer be dragged or resized from the out-of-bound side:

![range_tool_oob](https://user-images.githubusercontent.com/1736058/55078418-16a9e580-50d5-11e9-8e34-db95ddeae5e6.gif)
Looks like the issue might be that `_calc_dim()` in `box_annotation.ts` doesn't actually do clipping?

https://github.com/bokeh/bokeh/blob/master/bokehjs/src/lib/models/annotations/box_annotation.ts#L60
@zfei except for the fact that the drawing isn't clipped, I think that behavior is correct. If you pan the edge of the range tool box outside the plot bounds by some means, then it should not be accessible from any out-of-bound side

Edit: Ok, half-correct. The out of bounds side should not be draggable, but the box itself, if any of it is visible, should be. 
Oh, it's not clipping because the default render mode is `"css"`, that would be small pain to fix. I am tempted to change the default to `"canvas"` for the time being. 

Making it pan-able if one side is out of bounds is a simple fix (change condition to be whether the number of final sides in bounds is GEQ initial sides in bounds)

Resolving patch:
diff --git a/bokeh/models/tools.py b/bokeh/models/tools.py
--- a/bokeh/models/tools.py
+++ b/bokeh/models/tools.py
@@ -263,7 +263,7 @@ class PanTool(Drag):
 
 DEFAULT_RANGE_OVERLAY = lambda: BoxAnnotation(
     level="overlay",
-    render_mode="css",
+    render_mode="canvas",
     fill_color="lightgrey",
     fill_alpha=0.5,
     line_color="black",
diff --git a/examples/plotting/file/range_tool.py b/examples/plotting/file/range_tool.py
--- a/examples/plotting/file/range_tool.py
+++ b/examples/plotting/file/range_tool.py
@@ -9,7 +9,7 @@
 dates = np.array(AAPL['date'], dtype=np.datetime64)
 source = ColumnDataSource(data=dict(date=dates, close=AAPL['adj_close']))
 
-p = figure(plot_height=300, plot_width=800, tools="", toolbar_location=None,
+p = figure(plot_height=300, plot_width=800, tools="xpan", toolbar_location=None,
            x_axis_type="datetime", x_axis_location="above",
            background_fill_color="#efefef", x_range=(dates[1500], dates[2500]))
 


Test patch:
diff --git a/bokehjs/test/models/tools/gestures/range_tool.ts b/bokehjs/test/models/tools/gestures/range_tool.ts
--- a/bokehjs/test/models/tools/gestures/range_tool.ts
+++ b/bokehjs/test/models/tools/gestures/range_tool.ts
@@ -1,6 +1,6 @@
 import {expect} from "chai"
 
-import {compute_value, is_near, is_inside, update_range, RangeTool} from "@bokehjs/models/tools/gestures/range_tool"
+import {compute_value, flip_side, is_near, is_inside, update_range, RangeTool, Side, compute_end_side, compute_start_side} from "@bokehjs/models/tools/gestures/range_tool"
 import {Range1d} from "@bokehjs/models/ranges/range1d"
 import {LinearScale} from "@bokehjs/models/scales/linear_scale"
 
@@ -15,6 +15,26 @@ describe("range_tool module", () => {
     })
   }
 
+  describe("flip_side", () => {
+    it("should flip left to right", () => {
+      expect(flip_side(Side.Left)).to.equal(Side.Right)
+    })
+    it("should flip right to left", () => {
+      expect(flip_side(Side.Right)).to.equal(Side.Left)
+    })
+    it("should flip top to bottom", () => {
+      expect(flip_side(Side.Top)).to.equal(Side.Bottom)
+    })
+    it("should flip bottom to top", () => {
+      expect(flip_side(Side.Bottom)).to.equal(Side.Top)
+    })
+    it("should all others to themselves", () => {
+      expect(flip_side(Side.None)).to.equal(Side.None)
+      expect(flip_side(Side.BottomTop)).to.equal(Side.BottomTop)
+      expect(flip_side(Side.LeftRightBottomTop)).to.equal(Side.LeftRightBottomTop)
+    })
+  })
+
   describe("is_near", () => {
     const scale = generate_scale()
 
@@ -104,6 +124,44 @@ describe("range_tool module", () => {
     })
   })
 
+  describe("compute_start_side", () => {
+    it("should not flip if new start < end", () => {
+      const r = new Range1d({start: 0, end: 1})
+      const side = compute_start_side(0.5, r, Side.Top)
+      expect(r.start).to.be.equal(0.5)
+      expect(r.end).to.be.equal(1)
+      expect(side).to.be.equal(Side.Top)
+    })
+
+    it("should flip if new start >= end", () => {
+      const r = new Range1d({start: 0, end: 1})
+      const side = compute_start_side(1.5, r, Side.Top)
+      expect(r.end).to.be.equal(1.5)
+      expect(r.start).to.be.equal(1)
+      expect(side).to.be.equal(Side.Bottom)
+    })
+
+  })
+
+  describe("compute_end_side", () => {
+    it("should not flip if new end > start", () => {
+      const r = new Range1d({start: 0, end: 1})
+      const side = compute_end_side(1.5, r, Side.Top)
+      expect(r.start).to.be.equal(0)
+      expect(r.end).to.be.equal(1.5)
+      expect(side).to.be.equal(Side.Top)
+    })
+
+    it("should flip if new end <= start", () => {
+      const r = new Range1d({start: 0, end: 1})
+      const side = compute_end_side(-0.5, r, Side.Top)
+      expect(r.start).to.be.equal(-0.5)
+      expect(r.end).to.be.equal(0)
+      expect(side).to.be.equal(Side.Bottom)
+    })
+
+  })
+
   describe("update_range", () => {
     const scale = generate_scale()
 
diff --git a/tests/integration/tools/test_range_tool.py b/tests/integration/tools/test_range_tool.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/tools/test_range_tool.py
@@ -0,0 +1,300 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+
+# Bokeh imports
+from bokeh.models import ColumnDataSource, CustomAction, CustomJS, Plot, Range1d, Rect, RangeTool
+from bokeh._testing.util.selenium import RECORD
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+# TODO (bev) Add tests with y_range
+# TODO (bev) Add tests with both x_range and y_range
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+)
+
+def _make_plot():
+    source = ColumnDataSource(dict(x=[1, 2], y=[1, 1]))
+    r = Range1d(start=0.4, end=0.6)
+    plot = Plot(plot_height=400, plot_width=1100, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+    plot.add_glyph(source, Rect(x='x', y='y', width=0.9, height=0.9))
+    tool = RangeTool(x_range=r)
+    plot.add_tools(tool)
+    plot.min_border_right = 100
+    code = RECORD("start", "t.x_range.start") + RECORD("end", "t.x_range.end")
+    plot.add_tools(CustomAction(callback=CustomJS(args=dict(t=tool), code=code)))
+    plot.toolbar_sticky = False
+    return plot
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_RangeTool(object):
+
+    def test_selected_by_default(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        target = 'range'
+        button = page.get_toolbar_button(target)
+        assert 'active' in button.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    def test_can_be_deselected_and_selected(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        target = 'range'
+
+        # Check is active
+        button = page.get_toolbar_button(target)
+        assert 'active' in button.get_attribute('class')
+
+        # Click and check is not active
+        button = page.get_toolbar_button(target)
+        button.click()
+        assert 'active' not in button.get_attribute('class')
+
+        # Click again and check is active
+        button = page.get_toolbar_button(target)
+        button.click()
+        assert 'active' in button.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    def test_center_pan_has_no_effect_when_deselected(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        target = 'range'
+        button = page.get_toolbar_button(target)
+        button.click()
+
+        page.drag_canvas_at_position(500, 200, 100, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.4
+        assert results['end'] == 0.6
+
+        assert page.has_no_console_errors()
+
+    def test_center_pan_updates_range_when_selected(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(500, 200, 100, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.5
+        assert results['end'] == 0.7
+
+        page.drag_canvas_at_position(600, 200, -300, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.2
+        assert results['end'] == 0.4
+
+        assert page.has_no_console_errors()
+
+    def test_center_pan_with_right_side_outside(self, single_plot_page):
+        plot = _make_plot()
+        plot.tools[0].x_range.end = 1.1
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(500, 200, 100, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.5
+        assert results['end'] == 1.2
+
+        page.drag_canvas_at_position(600, 200, -300, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.2
+        assert results['end'] == 0.9
+
+        assert page.has_no_console_errors()
+
+    def test_center_pan_with_left_side_outside(self, single_plot_page):
+        plot = _make_plot()
+        plot.tools[0].x_range.start = -0.1
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(500, 200, -100, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == -0.2
+        assert results['end'] == 0.5
+
+        page.drag_canvas_at_position(400, 200, 300, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.1
+        assert results['end'] == 0.8
+
+        assert page.has_no_console_errors()
+
+    def test_left_edge_drag_updates_start(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(400, 200, 100, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.5
+        assert results['end'] == 0.6
+
+        page.drag_canvas_at_position(500, 200, -300, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.2
+        assert results['end'] == 0.6
+
+    def test_left_edge_drag_can_flip(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(400, 200, 300, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.6
+        assert results['end'] == 0.7
+
+    def test_left_edge_drag_with_right_edge_outside(self, single_plot_page):
+        plot = _make_plot()
+        plot.tools[0].x_range.end = 1.1
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(400, 200, 300, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.7
+        assert results['end'] == 1.1
+
+    def test_right_edge_drag_updates_end(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(600, 200, 100, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.4
+        assert results['end'] == 0.7
+
+        page.drag_canvas_at_position(700, 200, -200, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.4
+        assert results['end'] == 0.5
+
+    def test_right_edge_drag_can_flip(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(600, 200, -300, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.3
+        assert results['end'] == 0.4
+
+    def test_right_edge_drag_with_left_edge_outside(self, single_plot_page):
+        plot = _make_plot()
+        plot.tools[0].x_range.start = -0.1
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(600, 200, -300, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == -0.1
+        assert results['end'] == 0.3
+
+
+    # TODO (bev) This test is broken due to some dumb reason with tooling
+    @pytest.mark.skip
+    def test_center_pan_stops_at_plot_range_limit(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(500, 200, 300, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.7
+        assert results['end'] == 0.9
+
+        page.drag_canvas_at_position(800, 200, 150, 0)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['start'] == 0.8
+        assert results['end'] == 1
+
+        assert page.has_no_console_errors()