Repository: bokeh/bokeh

Issue:
Autocomplete should start with a single character
The `AutocompleteInput` widget looks and works great in a Jupyter notebook (with `output_notebook`), but has styling and functional bugs when used with bokeh's server. For example:

```
from bokeh import plotting as bk
from bokeh.models import AutocompleteInput

ac = AutocompleteInput(title="Some countries", value='Albania', \
completions=['Albania','Austria','Australia','Angola','Argentina'])
bk.curdoc().add_root(ac)
```

Yields this when run with `bokeh serve`

![screen shot 2016-07-23 at 6 44 20 pm](https://cloud.githubusercontent.com/assets/1348767/17081238/7f3082bc-5105-11e6-8366-cda7eac14f3a.png)

Notice how the matched completions show up as an unordered list instead of a dropdown. Any bokeh widgets or figures below the autocomplete text box will cover up this list of completions. Also, when you enter characters, the text "[N] results are available..." appears, which does not happen in Jupyter notebooks.

Finally, `on_change('value', myfunc)` does not seem to get triggered when you try to use autocompletion options. If you type what you want directly without using the arrow keys and then press enter, `on_change` triggers successfully.

I noticed that when using `output_notebook`, bokeh gets its styling and javascript from a CDN. I tried replacing bokeh's static JS and CSS with the files from the CDN, which had no effect.

```
$ bokeh info
Python version      :  2.7.12 (default, Jul 22 2016, 17:42:15) 
IPython version     :  5.0.0
Bokeh version       :  0.12.0
BokehJS static path :  /opt/local/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/site-packages/bokeh/server/static
```



Discussion:
+1 on fixing the on_change()  working when using autocompletion.

`on_change` seems to work for me but the styling is still broken in a server app. Presumably some JS/CSS isn't being loaded?

The styling issue seems to be gone in bokeh server as of 1.1.0dev11 (it seems it's not possible to trigger completions with one letter anymore so I used 2):
![image](https://user-images.githubusercontent.com/20061471/55066813-d881d700-507e-11e9-92d6-4b879eba1305.png)

Arrow keys still do not work, but I think it's expected in the recent versions ?

The on_change value however still has problems as described in #2259 .

I believe this is why >= 2 characters are needed to start:

https://github.com/bokeh/bokeh/blob/e2f5a9643c48a6c7a6acfa0530da58e3cd85eed6/bokehjs/src/lib/models/widgets/autocomplete_input.ts#L78-L81

All the other issues are resolved so re-purposing this issue for just this task. Also noting there some console logs in this file that should be removed. 

Resolving patch:
diff --git a/bokeh/core/properties.py b/bokeh/core/properties.py
--- a/bokeh/core/properties.py
+++ b/bokeh/core/properties.py
@@ -93,7 +93,9 @@ class SomeModel(Model):
 .. autoclass:: JSON
 .. autoclass:: MarkerType
 .. autoclass:: MinMaxBounds
+.. autoclass:: NonNegativeInt
 .. autoclass:: Percent
+.. autoclass:: PositiveInt
 .. autoclass:: RGB
 .. autoclass:: Regex
 .. autoclass:: Size
@@ -221,11 +223,13 @@ class SomeModel(Model):
     'MarkerSpec',
     'MarkerType',
     'MinMaxBounds',
+    'NonNegativeInt',
     'NumberSpec',
     'Override',
     'PandasDataFrame',
     'PandasGroupBy',
     'Percent',
+    'PositiveInt',
     'RGB',
     'Regex',
     'RelativeDelta',
@@ -301,6 +305,7 @@ class SomeModel(Model):
 from .property.numeric import Interval; Interval
 from .property.numeric import NonNegativeInt; NonNegativeInt
 from .property.numeric import Percent; Percent
+from .property.numeric import PositiveInt; PositiveInt
 from .property.numeric import Size; Size
 
 from .property.override import Override ; Override
diff --git a/bokeh/core/property/numeric.py b/bokeh/core/property/numeric.py
--- a/bokeh/core/property/numeric.py
+++ b/bokeh/core/property/numeric.py
@@ -38,6 +38,7 @@
     'Interval',
     'NonNegativeInt',
     'Percent',
+    'PositiveInt',
     'Size',
 )
 
@@ -54,6 +55,16 @@ def validate(self, value, detail=True):
         if not (value is None or value >= 0):
             raise ValueError("expected non-negative integer, got %r" % (value))
 
+class PositiveInt(Int):
+    """ Accept positive integers. """
+
+    def validate(self, value, detail=True):
+        super(PositiveInt, self).validate(value, detail)
+
+        if not (value is None or value > 0):
+            raise ValueError("expected positive integer, got %r" % (value))
+
+
 class Interval(ParameterizedProperty):
     ''' Accept numeric values that are contained within a given interval.
 
diff --git a/bokeh/models/widgets/inputs.py b/bokeh/models/widgets/inputs.py
--- a/bokeh/models/widgets/inputs.py
+++ b/bokeh/models/widgets/inputs.py
@@ -26,7 +26,7 @@
 
 # Bokeh imports
 from ...core.has_props import abstract
-from ...core.properties import Date, Either, Float, Instance, Int, List, String, Tuple, Dict, ColorHex
+from ...core.properties import ColorHex, Date, Dict, Either, Float, Instance, Int, List, PositiveInt, String, Tuple
 
 from ..callbacks import Callback
 
@@ -188,6 +188,10 @@ class AutocompleteInput(TextInput):
     user upon typing the beginning of a desired value.
     """)
 
+    min_characters = PositiveInt(default=2, help="""
+    The number of characters a user must type before completions are presented.
+    """)
+
 
 class Select(InputWidget):
     ''' Single-select widget.


Test patch:
diff --git a/bokeh/core/property/tests/test_numeric.py b/bokeh/core/property/tests/test_numeric.py
--- a/bokeh/core/property/tests/test_numeric.py
+++ b/bokeh/core/property/tests/test_numeric.py
@@ -38,6 +38,7 @@
     'Interval',
     'NonNegativeInt',
     'Percent',
+    'PositiveInt',
     'Size',
 )
 
@@ -234,6 +235,89 @@ def test_str(self):
         prop = bcpn.Percent()
         assert str(prop) == "Percent"
 
+class Test_NonNegativeInt(object):
+
+    def test_valid(self):
+        prop = bcpn.NonNegativeInt()
+
+        assert prop.is_valid(None)
+
+        # TODO (bev) should fail
+        assert prop.is_valid(False)
+        assert prop.is_valid(True)
+
+        assert prop.is_valid(0)
+        assert prop.is_valid(1)
+        assert prop.is_valid(2)
+        assert prop.is_valid(100)
+
+    def test_invalid(self):
+        prop = bcpn.NonNegativeInt()
+
+        assert not prop.is_valid(-1)
+        assert not prop.is_valid(0.0)
+        assert not prop.is_valid(1.0)
+        assert not prop.is_valid(1.0+1.0j)
+        assert not prop.is_valid("")
+        assert not prop.is_valid(())
+        assert not prop.is_valid([])
+        assert not prop.is_valid({})
+        assert not prop.is_valid(_TestHasProps())
+        assert not prop.is_valid(_TestModel())
+
+        assert not prop.is_valid(-100)
+        assert not prop.is_valid(-0.001)
+
+    def test_has_ref(self):
+        prop = bcpn.NonNegativeInt()
+        assert not prop.has_ref
+
+    def test_str(self):
+        prop = bcpn.NonNegativeInt()
+        assert str(prop) == "NonNegativeInt"
+
+class Test_PositiveInt(object):
+
+    def test_valid(self):
+        prop = bcpn.PositiveInt()
+
+        assert prop.is_valid(None)
+
+        # TODO (bev) should fail
+        assert prop.is_valid(True)
+
+        assert prop.is_valid(1)
+        assert prop.is_valid(2)
+        assert prop.is_valid(100)
+
+    def test_invalid(self):
+        prop = bcpn.PositiveInt()
+
+        assert not prop.is_valid(False)
+
+        assert not prop.is_valid(-1)
+        assert not prop.is_valid(0)
+        assert not prop.is_valid(0.0)
+        assert not prop.is_valid(1.0)
+        assert not prop.is_valid(1.0+1.0j)
+        assert not prop.is_valid("")
+        assert not prop.is_valid(())
+        assert not prop.is_valid([])
+        assert not prop.is_valid({})
+        assert not prop.is_valid(_TestHasProps())
+        assert not prop.is_valid(_TestModel())
+
+        assert not prop.is_valid(-100)
+        assert not prop.is_valid(-0.001)
+
+    def test_has_ref(self):
+        prop = bcpn.PositiveInt()
+        assert not prop.has_ref
+
+    def test_str(self):
+        prop = bcpn.PositiveInt()
+        assert str(prop) == "PositiveInt"
+
 #-----------------------------------------------------------------------------
 # Dev API
 #-----------------------------------------------------------------------------
diff --git a/bokeh/core/tests/test_properties.py b/bokeh/core/tests/test_properties.py
--- a/bokeh/core/tests/test_properties.py
+++ b/bokeh/core/tests/test_properties.py
@@ -72,11 +72,13 @@
     'MarkerSpec',
     'MarkerType',
     'MinMaxBounds',
+    'NonNegativeInt',
     'NumberSpec',
     'Override',
     'PandasDataFrame',
     'PandasGroupBy',
     'Percent',
+    'PositiveInt',
     'RGB',
     'Regex',
     'RelativeDelta',
diff --git a/tests/integration/widgets/test_autocomplete_input.py b/tests/integration/widgets/test_autocomplete_input.py
--- a/tests/integration/widgets/test_autocomplete_input.py
+++ b/tests/integration/widgets/test_autocomplete_input.py
@@ -119,6 +119,32 @@ def test_displays_menu(self, bokeh_model_page):
 
         assert page.has_no_console_errors()
 
+    def test_min_characters(self, bokeh_model_page):
+        text_input = AutocompleteInput(title="title", css_classes=["foo"],
+                                       completions = ["100001", "12344556", "12344557", "3194567289", "209374209374"],
+                                       min_characters=1)
+
+        page = bokeh_model_page(text_input)
+
+        el = page.driver.find_element_by_css_selector('.foo .bk-menu')
+        assert 'display: none;' in el.get_attribute('style')
+
+        # double click to highlight and overwrite old text
+        el = page.driver.find_element_by_css_selector('.foo input')
+        enter_text_in_element(page.driver, el, "1", click=2, enter=False)
+
+        el = page.driver.find_element_by_css_selector('.foo .bk-menu')
+        assert 'display: none;' not in el.get_attribute('style')
+
+        items = el.find_elements_by_tag_name("div")
+        assert len(items) == 3
+        assert items[0].text == "100001"
+        assert items[1].text == "12344556"
+        assert items[2].text == "12344557"
+        assert "bk-active" in items[0].get_attribute('class')
+        assert "bk-active" not in items[1].get_attribute('class')
+        assert "bk-active" not in items[2].get_attribute('class')
+
     def test_arrow_cannot_escape_menu(self, bokeh_model_page):
         text_input = AutocompleteInput(title="title", css_classes=["foo"], completions = ["100001", "12344556", "12344557", "3194567289", "209374209374"])