Repository: bokeh/bokeh

Issue:
Multiple selections via shift+select don't work
v bokeh 0.12.5dev16
python3
Chrome 57

Confirmed for the LassoSelectTool and BoxSelectTool, but thought to exist for all selection tools.

Best to reproduce with examples/app/selection_histogram.py

After doing a first box selection (which works normally), holding shift to perform another box selection causes the second selection to not "end" (i.e. the overlay will continue following the cursor). The glyphs in the second selection area will be rendering as their selection_glyphs after ending the second selection, but it seems that some "selected" event isn't occuring, because the selection histogram app demo doesn't update the histograms.



Discussion:
> holding shift to perform another box selection causes the second selection to not "end" (i.e. the overlay will continue following the cursor)

FWIW I can't reproduce this aspect. But it is failing to respect the multiple selection (just makes an entirely new one)

EDIT: Sorry that was with a box select. I can reproduce the other problem with lasso
This has been broken in one way or another a long time. Between `0.12.4` and `0.12.5dev1` I'll try to do a quick bisection.
@bryevdv, this is broken since protocol changes. There are some `undefined` values floating around causing problem. I'm investigating what's the root cause.
This is well before that. It's broken with `0.12.5dev1`
This is the commit that broke things: https://github.com/bokeh/bokeh/commit/3995f0f67c0a2a090d06b7b5e50ca31e654da131
I should add: it's possible there are two problems. The problem with "not ending" might be a newer protocol problem. But shift being ignored goes back to the commit above (the "remove underscore PR"), which does *not* have the "not ending" problem. 
OK, both changes are at fault. Protocol changes, because there is `v.buffer` code and `v` can be `undefined`, but `null` is only considered (I even asked if it's possible to have `v` undefined). And 3995f0f for some reason creates arrays like `[undefined]` for indices.
I propose that:

* you look into the protocol issue
* I finish looking into the indices union issue
* luke makes a selenium test when they are both fixed
@bryevdv, I'm already working on a PR.
great - i'll push a single/few selenium tests to matt's PR when he's done.
For both issues?

FYI when this is called:
```
indices['2d'].indices = array_1.union(this.indices['2d'].indices, indices['2d'].indices);
``` 
both arguments are undefined. I'd argue that `union` probably ought to return `undefined` in that case tho we could also check the args before unioning 
The structure of 2d indices is not a simple list BTW so I am not sure a union there is appropriate at all. 
@bryevdv, not at all, but that was like this for ages.
Certainly, but whatever underscore union did and your union are different and exposed it. Removing that line solves the "union" problem I'd suggest that as the immediate solution. I would not expect multiple 2d selections to have been or currently be working anyway. 
removing that line solves both issues. 

Resolving patch:
diff --git a/bokeh/models/sources.py b/bokeh/models/sources.py
--- a/bokeh/models/sources.py
+++ b/bokeh/models/sources.py
@@ -21,7 +21,7 @@ class DataSource(Model):
     selected = Dict(String, Dict(String, Any), default={
         '0d': {'glyph': None, 'indices': []},
         '1d': {'indices': []},
-        '2d': {}
+        '2d': {'indices': {}}
     }, help="""
     A dict to indicate selected indices on different dimensions on this DataSource. Keys are:
 
@@ -46,6 +46,7 @@ class DataSource(Model):
         '2d': {
           # mapping of indices of the multiglyph to array of glyph indices that were hit
           # e.g. {3: [5, 6], 4, [5]}
+          indices: {}
         }
 
     """)
diff --git a/examples/models/server/linked_tap.py b/examples/models/server/linked_tap.py
--- a/examples/models/server/linked_tap.py
+++ b/examples/models/server/linked_tap.py
@@ -61,12 +61,12 @@ def on_reset_click():
     source1.selected = {
         '0d': {'flag': False, 'indices': []},
         '1d': {'indices': []},
-        '2d': {'indices': []}
+        '2d': {'indices': {}}
     }
     source2.selected = {
         '0d': {'flag': False, 'indices': []},
         '1d': {'indices': []},
-        '2d': {'indices': []}
+        '2d': {'indices': {}}
     }
 
 reset.on_click(on_reset_click)


Test patch:
diff --git a/bokehjs/test/core/util/array.coffee b/bokehjs/test/core/util/array.coffee
--- a/bokehjs/test/core/util/array.coffee
+++ b/bokehjs/test/core/util/array.coffee
@@ -5,7 +5,88 @@ array = utils.require "core/util/array"
 
 describe "array module", ->
 
+  it "copy should return a new copy", ->
+    a = [0, 1, 2]
+    b = array.copy(a)
+    expect(b).to.deep.equal [0, 1, 2]
+    a[0] = 10
+    expect(a).to.deep.equal [10, 1, 2]
+    expect(b).to.deep.equal [0, 1, 2]
+
+  it "concat should append all arrays", ->
+    a = [0, 1, 2]
+    b = [1, 5, 2]
+    c = []
+    d = [10, 20]
+    expect(array.concat([a,b,c,d])).to.deep.equal [0, 1, 2, 1, 5, 2, 10, 20]
+
+  it "contains should return true or false", ->
+    a = [0, 1, 2]
+    expect(array.contains(a, 0)).to.equal true
+    expect(array.contains(a, 1)).to.equal true
+    expect(array.contains(a, 5)).to.equal false
+    expect(array.contains(a, null)).to.equal false
+    expect(array.contains(a, undefined)).to.equal false
+
+  it "nth should return the correct index", ->
+    a = [0, 1, 2]
+    expect(array.nth(a, 0)).to.equal 0
+    expect(array.nth(a, 1)).to.equal 1
+    expect(array.nth(a, 5)).to.equal undefined
+    expect(array.nth(a, null)).to.equal undefined
+    expect(array.nth(a, undefined)).to.equal undefined
+
+  it "zip should zip two equal length arrays", ->
+    a = [0, 1, 2]
+    b = [1, 5, 2]
+    expect(array.zip(a, b)).to.deep.equal [[0,1], [1,5], [2,2]]
+
+  it "zip should use shorter length for unequal arrays", ->
+    a = [0, 1, 2]
+    b = [1, 5, 2, 10]
+    expect(array.zip(a, b)).to.deep.equal [[0,1], [1,5], [2,2]]
+
+ it "range should mimic python range", ->
+    expect(array.range(5)).to.deep.equal [0,1,2,3,4]
+    expect(array.range(0, 5)).to.deep.equal [0,1,2,3,4]
+    expect(array.range(1, 5)).to.deep.equal [1,2,3,4]
+    expect(array.range(1, 5, 2)).to.deep.equal [1,3]
+    expect(array.range(5, 0, -1)).to.deep.equal [5,4,3,2,1]
+    expect(array.range(5, 0, -2)).to.deep.equal [5,3,1]
+
+  it "range should work with fractional step", ->
+    expect(array.range(0, 2, 0.5)).to.deep.equal [0,0.5,1,1.5]
+
+  it "unzip should use generate two arrays in a list", ->
+    a = [0, 1, 2]
+    b = [1, 5, 2]
+    expect(array.unzip([[0,1], [1,5], [2,2]])).to.deep.equal [a, b]
+
+  it "uniq should return only the uniq values of an array", ->
+    expect(array.uniq([0,1,2])).to.deep.equal [0,1,2]
+    expect(array.uniq([0,1,2,1,2])).to.deep.equal [0,1,2]
+    expect(array.uniq([0,1,2,1,2,null])).to.deep.equal [0,1,2, null]
+    expect(array.uniq([0,1,2,1,2,null,null])).to.deep.equal [0,1,2,null]
+    expect(array.uniq([0,1,2,1,2,undefined])).to.deep.equal [0,1,2,undefined]
+
   it "intersection should return array of values in first array found in all others", ->
     a = [0, 1, 2]
     b = [1, 5, 2]
     expect(array.intersection(a, b)).to.deep.equal [1, 2]
+    expect(array.intersection(a, [])).to.deep.equal []
+    expect(array.intersection(a, [10, 20])).to.deep.equal []
+    expect(array.intersection(a, b, [2])).to.deep.equal [2]
+
+  it "union should return array of unique values from all arrays", ->
+    a = [0, 1, 2]
+    b = [1, 5, 2]
+    expect(array.union(a, b)).to.deep.equal [0, 1, 2, 5]
+
+  it "difference should return array of values from the first array not in all others ", ->
+    a = [0, 1, 2]
+    b = [1, 5, 2]
+    expect(array.difference(a, b)).to.deep.equal [0]
+    expect(array.difference(a, [])).to.deep.equal [0,1,2]
+    expect(array.difference(a, [10, 20])).to.deep.equal [0,1,2]
+    expect(array.difference(a, b, [2])).to.deep.equal [0]
+    expect(array.difference(a, b, [0])).to.deep.equal []
diff --git a/tests/integration/interaction/test_patches_preselection.py b/tests/integration/interaction/test_patches_preselection.py
--- a/tests/integration/interaction/test_patches_preselection.py
+++ b/tests/integration/interaction/test_patches_preselection.py
@@ -31,7 +31,7 @@ def test_patches_hover_still_works_when_a_seleciton_is_preselcted(output_file_ur
     source.selected = {
         '0d': {'glyph': None, 'indices': []},
         '1d': {'indices': [1]},
-        '2d': {}
+        '2d': {'indices': {}},
     }
     plot.add_glyph(source, Patches(xs='xs', ys='ys', fill_color='color'))
     plot.add_tools(HoverTool())
diff --git a/tests/integration/interaction/test_selection.py b/tests/integration/interaction/test_selection.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/interaction/test_selection.py
@@ -0,0 +1,109 @@
+from __future__ import absolute_import, print_function
+
+import pytest
+from selenium.webdriver.common.action_chains import ActionChains
+from selenium.webdriver.common.keys import Keys
+
+from bokeh.io import save
+from bokeh.plotting import figure
+from tests.integration.utils import has_no_console_errors, wait_for_canvas_resize
+
+pytestmark = pytest.mark.integration
+
+
+def make_plot(tools='box_select'):
+    plot = figure(id='plot-id', height=800, width=800, x_range=(0,6),
+                  y_range=(0,6), tools=tools)
+    plot.rect(name='rect-glyph', x=[1, 3, 5], y=[3, 3, 3], width=1, height=1)
+    return plot
+
+def perform_box_selection(selenium, start, end, hold_shift=False):
+    canvas = selenium.find_element_by_tag_name('canvas')
+    wait_for_canvas_resize(canvas, selenium)
+
+    actions = ActionChains(selenium)
+    if hold_shift:
+        actions.key_down(Keys.LEFT_SHIFT)
+    actions.move_to_element_with_offset(canvas, *start)
+    actions.click_and_hold()
+    actions.move_by_offset(*end)
+    actions.release()
+    if hold_shift:
+        actions.key_up(Keys.LEFT_SHIFT)
+    actions.perform()
+
+def test_selection_tool_make_selection(output_file_url, selenium):
+    plot = make_plot()
+
+    save(plot)
+    selenium.get(output_file_url)
+    assert has_no_console_errors(selenium)
+
+    perform_box_selection(selenium, (50, 200), (450, 400))
+
+    code = "return Bokeh.index['plot-id'].model.select_one('rect-glyph').data_source.selected['1d'].indices"
+    selected = selenium.execute_script(code)
+    assert selected == [0, 1]
+
+def test_selection_tool_selection_ending_outside_frame_stops_selection(output_file_url, selenium):
+    plot = make_plot()
+
+    save(plot)
+    selenium.get(output_file_url)
+    assert has_no_console_errors(selenium)
+
+    # make selection ending outside of frame
+    perform_box_selection(selenium, (50, 50), (1000, 1000))
+
+    code = "return Bokeh.index['plot-id'].model.select_one('rect-glyph').data_source.selected['1d'].indices"
+    selected = selenium.execute_script(code)
+    assert selected == []
+
+def test_selection_tool_non_selection_clears_selected(output_file_url, selenium):
+    plot = make_plot()
+
+    save(plot)
+    selenium.get(output_file_url)
+    assert has_no_console_errors(selenium)
+
+    # make first selection
+    perform_box_selection(selenium, (50, 200), (200, 400))
+    # make selection over no glyphs
+    perform_box_selection(selenium, (50, 50), (50, 50))
+
+    code = "return Bokeh.index['plot-id'].model.select_one('rect-glyph').data_source.selected['1d'].indices"
+    selected = selenium.execute_script(code)
+    assert selected == []
+
+def test_selection_tool_new_selection_clears_old_selection(output_file_url, selenium):
+    plot = make_plot()
+
+    save(plot)
+    selenium.get(output_file_url)
+    assert has_no_console_errors(selenium)
+
+    # make first selection
+    perform_box_selection(selenium, (50, 200), (250, 400))
+    # make second selection
+    perform_box_selection(selenium, (250, 200), (300, 400))
+
+    code = "return Bokeh.index['plot-id'].model.select_one('rect-glyph').data_source.selected['1d'].indices"
+    selected = selenium.execute_script(code)
+    assert selected == [1]
+
+def test_selection_tool_multiselection_with_shift(output_file_url, selenium):
+    plot = make_plot()
+
+    # Save the plot and start the test
+    save(plot)
+    selenium.get(output_file_url)
+    assert has_no_console_errors(selenium)
+
+    # make first selection
+    perform_box_selection(selenium, (50, 200), (250, 400))
+    # make second, multi-selection with shift
+    perform_box_selection(selenium, (475, 200), (275, 400), hold_shift=True)
+
+    code = "return Bokeh.index['plot-id'].model.select_one('rect-glyph').data_source.selected['1d'].indices"
+    selected = selenium.execute_script(code)
+    assert selected == [0, 2]