Repository: bokeh/bokeh

Issue:
[FEATURE] js_link attr indexing
**Related problem**
With a standard `Slider` you can do:
```python
slider.js_link('value', figure.x_range, "start")
```
However if I now want to use a `RangeSlider` which has `value` of type `Tuple[Float, Float]` I cannot currently do:
```python
range_slider.js_link('value[0]', figure.x_range, "start")
```

More generally, I cannot use js_link for subscriptable attributes.

**Possible solution**
A simple solution would be to change the signature of js_link to, as Bryan mentioned in [this topic](https://discourse.bokeh.org/t/rangeslider-js-link-with-a-range-object/4772/5):
```python
def js_link(self, attr, other, other_attr, attr_selector=None):
        # is it possible to check if attr is subscriptable?
        if attr_selector is None:
            cb = CustomJS(args=dict(other=other), code="other.%s = this.%s" % (other_attr, attr))
        else:
            cb = CustomJS(args=dict(other=other), code="other.%s = this.%s[%d]" % (other_attr, attr, attr_selector))
```
Ideally, as mentioned in the comment, we would check if attr is subscriptable using some form of `get_attr(attr, "__getitem__")`. I'm not sure where the actual retrieval of the attr value occurs, though.

**Ideal solution**
Ideally, a user could essentially register a callback (`callable`) to retrieve a value from attr, this would be much more dynamic, the simplest case being `attr_selector = lambda attr: attr`, where it just returns the unsubscripted or altered `attr`. Again, I believe this depends on where the retrieval of the attr value occurs and how to attach the `attr_selector` callable.

**Additional context**
On version: 2.0.0dev6-36-gff2d2f628-dirty



Discussion:
@RyanRio I would only make one change to the "Possible Solution" which is that I think the selector could also reasonably be a string and not just an int (e.g. if the value is a dict/object)

Re: "Ideal Solution" I think at the point of allowing a JS callback code we've moved into being unhelpfully duplicative of `CustomJS`.  The added value of `js_link` over `CustomJS` is precisely that it *doesn't* have any place for user-supplied JS code. Or conversely: if you need JS code to do something, `CustomJS` already exists and is the best (and intended) option. 

Resolving patch:
diff --git a/bokeh/model.py b/bokeh/model.py
--- a/bokeh/model.py
+++ b/bokeh/model.py
@@ -378,7 +378,7 @@ def js_on_event(self, event, *callbacks):
             self.js_event_callbacks[event].append(callback)
 
 
-    def js_link(self, attr, other, other_attr):
+    def js_link(self, attr, other, other_attr, attr_selector=None):
         ''' Link two Bokeh model properties using JavaScript.
 
         This is a convenience method that simplifies adding a CustomJS callback
@@ -395,6 +395,9 @@ def js_link(self, attr, other, other_attr):
             other_attr (str) :
                 The property on ``other`` to link together
 
+            attr_selector (Union[int, str]) :
+                The index to link an item in a subscriptable ``attr``
+
         Added in version 1.1
 
         Raises:
@@ -420,6 +423,23 @@ def js_link(self, attr, other, other_attr):
                     )
                 )
 
+            Additionally, to use attr_selector to attach the left side of a range slider to a plot's x_range:
+
+            .. code :: python
+
+                range_slider.js_link('value', plot.x_range, 'start', attr_selector=0)
+
+            which is equivalent to:
+
+            .. code :: python
+
+                from bokeh.models import CustomJS
+                range_slider.js_on_change('value',
+                    CustomJS(args=dict(other=plot.x_range),
+                             code="other.start = this.value[0]"
+                    )
+                )
+
         '''
         if attr not in self.properties():
             raise ValueError("%r is not a property of self (%r)" % (attr, self))
@@ -431,7 +451,9 @@ def js_link(self, attr, other, other_attr):
             raise ValueError("%r is not a property of other (%r)" % (other_attr, other))
 
         from bokeh.models import CustomJS
-        cb = CustomJS(args=dict(other=other), code="other.%s = this.%s" % (other_attr, attr))
+
+        selector = f"[{attr_selector!r}]" if attr_selector else ""
+        cb = CustomJS(args=dict(other=other), code=f"other.{other_attr} = this.{attr}{selector}")
 
         self.js_on_change(attr, cb)
 


Test patch:
diff --git a/tests/unit/bokeh/test_model.py b/tests/unit/bokeh/test_model.py
--- a/tests/unit/bokeh/test_model.py
+++ b/tests/unit/bokeh/test_model.py
@@ -127,6 +127,34 @@ def test_creates_customjs(self) -> None:
         assert cb.args == dict(other=m2)
         assert cb.code == "other.b = this.a"
 
+    def test_attr_selector_creates_customjs_int(self) -> None:
+        m1 = SomeModel()
+        m2 = SomeModel()
+        assert len(m1.js_property_callbacks) == 0
+        m1.js_link('a', m2, 'b', 1)
+        assert len(m1.js_property_callbacks) == 1
+        assert "change:a" in m1.js_property_callbacks
+        cbs = m1.js_property_callbacks["change:a"]
+        assert len(cbs) == 1
+        cb = cbs[0]
+        assert isinstance(cb, CustomJS)
+        assert cb.args == dict(other=m2)
+        assert cb.code == "other.b = this.a[1]"
+
+    def test_attr_selector_creates_customjs_str(self) -> None:
+        m1 = SomeModel()
+        m2 = SomeModel()
+        assert len(m1.js_property_callbacks) == 0
+        m1.js_link('a', m2, 'b', "test")
+        assert len(m1.js_property_callbacks) == 1
+        assert "change:a" in m1.js_property_callbacks
+        cbs = m1.js_property_callbacks["change:a"]
+        assert len(cbs) == 1
+        cb = cbs[0]
+        assert isinstance(cb, CustomJS)
+        assert cb.args == dict(other=m2)
+        assert cb.code == "other.b = this.a['test']"
+
 def test_all_builtin_models_default_constructible() -> None:
     bad = []
     for name, cls in Model.model_class_reverse_map.items():