Repository: bokeh/bokeh

Issue:
provide selection_line_width and nonselection_line_width
**Is your feature request related to a problem? Please describe.**
I would like to be able to set a line width for selections. In my case these are "rect", so if implemented this would allow a nice effect of a "frame" around the selection.

**Describe the solution you'd like**
I woud like for `selection_line_width` to be usable just like i.e. `selection_line_color`.
I would be willing to try to implement this if I get some guidance on how to proceed.

**Additional context**
In my particular application I have adjacent rectangles which are densely packed in some areas and spread out in other areas, so setting line_width to a larger value and playing with selection_line_alpha is not an option.


Discussion:
This is a duplicate of #2367 
Ni! Thanks @bryevdv, I'm a bit confused as to why this is a duplicate. In that issue it is said that:

> Current behavior is as designed and documented, i.e. only "visual" (line and fill) props are considered for selection/nonselection/hover policies.

In what sense isn't line_width "visual", then? Cheers
@solstag I misunderstood what you were asking about. The relevant code path is here:

https://github.com/bokeh/bokeh/blob/c3a9d4d5ab5662ea34813fd72de50e1bdaae714d/bokeh/plotting/helpers.py#L839-L843

The `_pop_colors_and_alpha` could be renamed and generalized to `_pop_visuals` and inspect the passed-in glyph class to handle any visuals present on the glyph. A new `_pop_visuals` would still need to handle the special cases for "color" being an alias for setting *both* "line_color" and "fill_color", etc. Let me know if I can offer additional guidance. 
Ni!

Ok, I have worked it out. However, before I publish the patch and make a pull request, there's a compatibility issue I want to discuss.

Currently, if you set a non prefixed visual property handled by `_pop_colors_and_alpha` for a glyph, i.e. `line_color`, it does not affect any prefixed glyphs since the key is popped from the dictionary.

However, all other visual properties, i.e. `line_width`, are currently copied from the non prefixed glyph because they are not popped and passed on inside `kws` to `_make_glyph`.

1) I believe the more desirable behavior is for the prefixed glyph to inherit from the non prefixed glyph everything that has not been explicitly declared. However this would break compatibility for properties currently treated by `_pop_colors_and_alpha`.

2) Another option is to not inherit anything from the prefixed glyph. This would also break compatibility, but for the properties not currently treated, which would no longer be inherited from the non prefixed properties.

3) The third solution would be to force compatibility by selectively treating the currently handled properties. I personally think this is undesirable.

I have the two first options working, and the third is of course feasible, but ugly.

Any ideas on how to handle this ? If it doesn't seem like a big issue, I would go for (1). I think giving prefixes the semantics of "this should be different from the basic (unprefixed) condition" makes more sense in bokeh usage.

(Like, if I style a glyph with several visual properties and then set a single `selection_` property, I expect the selected glyph to look just like my regular styled glyph, except for that one thing, not have it look like the default glyph.)

However there may be deeper compatibility issues I am not aware of. So what do you say?!
@solstag I am going to open long running a branch for 2.0 development very soon. It's not our usual process style to do PRs against anything other than `master` but for this case, since we are dropping Python 2 support and making other incompatible changes, it seems necessary if we are to be able to track PRs for individual work until they are ready to merge all at once.

So, my thoughts would be this: I would be happy to accept a PR from you for approach 1) or 2) against the future 2.0 branch. (I have not thought much about 1 vs 2 yet, mostly happy to defer to you) Since it will be for 2.0, it's the perfect time to make any small breaking changes like this (we just need to document them in the migration guide of the release notes) Does that sound good to you?  FYI I would like to have 2.0 within the next 3-4 months. 
Hi @bryevdv, I'm in a busy period so that looks like a good time frame for me. I'm happy to contribute to the migration guide once the PR is merged. Happy to hear Python2 is going into (well deserved) retirement. ;-)

Resolving patch:
diff --git a/bokeh/models/glyphs.py b/bokeh/models/glyphs.py
--- a/bokeh/models/glyphs.py
+++ b/bokeh/models/glyphs.py
@@ -485,7 +485,7 @@ def __init__(self, **kwargs):
 
     # a canonical order for positional args that can be used for any
     # functions derived from this class
-    _args = ('image', 'x', 'y', 'dw', 'dh', 'global_alpha', 'dilate')
+    _args = ('image', 'x', 'y', 'dw', 'dh', 'dilate')
 
     # a hook to specify any additional kwargs handled by an initializer
     _extra_kws = {
@@ -554,7 +554,7 @@ class ImageRGBA(XYGlyph):
 
     # a canonical order for positional args that can be used for any
     # functions derived from this class
-    _args = ('image', 'x', 'y', 'dw', 'dh', 'global_alpha', 'dilate')
+    _args = ('image', 'x', 'y', 'dw', 'dh', 'dilate')
 
     image = NumberSpec(help="""
     The arrays of RGBA data for the images.
@@ -609,7 +609,7 @@ class ImageURL(XYGlyph):
 
     # a canonical order for positional args that can be used for any
     # functions derived from this class
-    _args = ('url', 'x', 'y', 'w', 'h', 'angle', 'global_alpha', 'dilate')
+    _args = ('url', 'x', 'y', 'w', 'h', 'angle', 'dilate')
 
     url = StringSpec(default=None, help="""
     The URLs to retrieve images from.
diff --git a/bokeh/plotting/helpers.py b/bokeh/plotting/helpers.py
--- a/bokeh/plotting/helpers.py
+++ b/bokeh/plotting/helpers.py
@@ -187,42 +187,44 @@ def _graph(node_source, edge_source, **kwargs):
             raise ValueError(msg).with_traceback(sys.exc_info()[2])
 
     ## node stuff
+    node_ca = _pop_visuals(Circle, kwargs, prefix="node_")
+
     if any(x.startswith('node_selection_') for x in kwargs):
-        snode_ca = _pop_colors_and_alpha(Circle, kwargs, prefix="node_selection_")
+        snode_ca = _pop_visuals(Circle, kwargs, prefix="node_selection_", defaults=node_ca)
     else:
         snode_ca = None
 
     if any(x.startswith('node_hover_') for x in kwargs):
-        hnode_ca = _pop_colors_and_alpha(Circle, kwargs, prefix="node_hover_")
+        hnode_ca = _pop_visuals(Circle, kwargs, prefix="node_hover_", defaults=node_ca)
     else:
         hnode_ca = None
 
     if any(x.startswith('node_muted_') for x in kwargs):
-        mnode_ca = _pop_colors_and_alpha(Circle, kwargs, prefix="node_muted_")
+        mnode_ca = _pop_visuals(Circle, kwargs, prefix="node_muted_", defaults=node_ca)
     else:
         mnode_ca = None
 
-    nsnode_ca = _pop_colors_and_alpha(Circle, kwargs, prefix="node_nonselection_")
-    node_ca = _pop_colors_and_alpha(Circle, kwargs, prefix="node_")
+    nsnode_ca = _pop_visuals(Circle, kwargs, prefix="node_nonselection_", defaults=node_ca)
 
     ## edge stuff
+    edge_ca = _pop_visuals(MultiLine, kwargs, prefix="edge_")
+
     if any(x.startswith('edge_selection_') for x in kwargs):
-        sedge_ca = _pop_colors_and_alpha(MultiLine, kwargs, prefix="edge_selection_")
+        sedge_ca = _pop_visuals(MultiLine, kwargs, prefix="edge_selection_", defaults=edge_ca)
     else:
         sedge_ca = None
 
     if any(x.startswith('edge_hover_') for x in kwargs):
-        hedge_ca = _pop_colors_and_alpha(MultiLine, kwargs, prefix="edge_hover_")
+        hedge_ca = _pop_visuals(MultiLine, kwargs, prefix="edge_hover_", defaults=edge_ca)
     else:
         hedge_ca = None
 
     if any(x.startswith('edge_muted_') for x in kwargs):
-        medge_ca = _pop_colors_and_alpha(MultiLine, kwargs, prefix="edge_muted_")
+        medge_ca = _pop_visuals(MultiLine, kwargs, prefix="edge_muted_", defaults=edge_ca)
     else:
         medge_ca = None
 
-    nsedge_ca = _pop_colors_and_alpha(MultiLine, kwargs, prefix="edge_nonselection_")
-    edge_ca = _pop_colors_and_alpha(MultiLine, kwargs, prefix="edge_")
+    nsedge_ca = _pop_visuals(MultiLine, kwargs, prefix="edge_nonselection_", defaults=edge_ca)
 
     ## node stuff
     node_kwargs = {k.lstrip('node_'): v for k, v in kwargs.copy().items() if k.lstrip('node_') in Circle.properties()}
@@ -279,34 +281,78 @@ def _pop_renderer_args(kwargs):
     return result
 
 
-def _pop_colors_and_alpha(glyphclass, kwargs, prefix="", default_alpha=1.0):
-    """
-    Given a kwargs dict, a prefix, and a default value, looks for different
-    color and alpha fields of the given prefix, and fills in the default value
-    if it doesn't exist.
+def _pop_visuals(glyphclass, props, prefix="", defaults={}, trait_defaults={}):
     """
-    result = dict()
-
-    # TODO: The need to do this and the complexity of managing this kind of
-    # thing throughout the codebase really suggests that we need to have
-    # a real stylesheet class, where defaults and Types can declaratively
-    # substitute for this kind of imperative logic.
-    color = kwargs.pop(prefix + "color", get_default_color())
-    for argname in ("fill_color", "line_color"):
-        if argname not in glyphclass.properties():
-            continue
-        result[argname] = kwargs.pop(prefix + argname, color)
+    Applies basic cascading logic to deduce properties for a glyph.
+
+    Args:
+        glyphclass :
+            the type of glyph being handled
+
+        props (dict) :
+            Maps properties and prefixed properties to their values.
+            Keys in `props` matching `glyphclass` visual properties (those of
+            'line_', 'fill_' or 'text_') with added `prefix` will get popped,
+            other keys will be ignored.
+            Keys take the form '[{prefix}][{feature}_]{trait}'. Only {feature}
+              must not contain underscores.
+            Keys of the form '{prefix}{trait}' work as lower precedence aliases
+              for {trait} for all {features}, as long as the glyph has no
+              property called {trait}. I.e. this won't apply to "width" in a
+              `rect` glyph.
+            Ex: {'fill_color': 'blue', 'selection_line_width': 0.5}
+
+        prefix (str) :
+            Prefix used when accessing `props`. Ex: 'selection_'
+
+        defaults (dict) :
+            Property fallback, in case prefixed property not in `props`.
+            Ex. 'line_width' here may be used for 'selection_line_width'.
+
+        trait_defaults (dict) :
+            Fallback based on '{trait}', in case property not in `defaults`.
+            Ex. 'width' here may be used for 'selection_line_width'.
 
-    # NOTE: text fill color should really always default to black, hard coding
-    # this here now until the stylesheet solution exists
-    if "text_color" in glyphclass.properties():
-        result["text_color"] = kwargs.pop(prefix + "text_color", "black")
+    Returns:
+        result (dict) :
+            Resulting properties for the instance (no prefixes).
 
-    alpha = kwargs.pop(prefix + "alpha", default_alpha)
-    for argname in ("fill_alpha", "line_alpha", "text_alpha"):
-        if argname not in glyphclass.properties():
-            continue
-        result[argname] = kwargs.pop(prefix + argname, alpha)
+    Notes:
+        Feature trait 'text_color', as well as traits 'color' and 'alpha', have
+        ultimate defaults in case those can't be deduced.
+    """
+    def split_feature_trait(ft):
+        """Feature is up to first '_'. Ex. 'line_color' => ['line', 'color']"""
+        ft = ft.split('_', 1)
+        return ft if len(ft)==2 else ft+[None]
+
+    def is_visual(ft):
+        """Whether a feature trait name is visual"""
+        feature, trait = split_feature_trait(ft)
+        return feature in ('line', 'fill', 'text', 'global') and trait is not None
+
+    defaults = defaults.copy()
+    defaults.setdefault('text_color', 'black')
+    trait_defaults = trait_defaults.copy()
+    trait_defaults.setdefault('color', get_default_color())
+    trait_defaults.setdefault('alpha', 1.0)
+
+    result, traits = dict(), set()
+    glyphprops = glyphclass.properties()
+    for pname in filter(is_visual, glyphprops):
+        _, trait = split_feature_trait(pname)
+        if prefix+pname in props:
+            result[pname] = props.pop(prefix+pname)
+        elif trait not in glyphprops and prefix+trait in props:
+            result[pname] = props[prefix+trait]
+        elif pname in defaults:
+            result[pname] = defaults[pname]
+        elif trait in trait_defaults:
+            result[pname] = trait_defaults[trait]
+        if trait not in glyphprops:
+            traits.add(trait)
+    for trait in traits:
+        props.pop(prefix+trait, None)
 
     return result
 
@@ -877,12 +923,8 @@ def func(self, **kwargs):
         renderer_kws = _pop_renderer_args(kwargs)
         source = renderer_kws['data_source']
 
-        # Assign global_alpha from alpha if glyph type is an image
-        if 'alpha' in kwargs and glyphclass.__name__ in ('Image', 'ImageRGBA', 'ImageURL'):
-            kwargs['global_alpha'] = kwargs['alpha']
-
         # handle the main glyph, need to process literals
-        glyph_ca = _pop_colors_and_alpha(glyphclass, kwargs)
+        glyph_ca = _pop_visuals(glyphclass, kwargs)
         incompatible_literal_spec_values = []
         incompatible_literal_spec_values += _process_sequence_literals(glyphclass, kwargs, source, is_user_source)
         incompatible_literal_spec_values += _process_sequence_literals(glyphclass, glyph_ca, source, is_user_source)
@@ -890,23 +932,23 @@ def func(self, **kwargs):
             raise RuntimeError(_GLYPH_SOURCE_MSG % nice_join(incompatible_literal_spec_values, conjuction="and"))
 
         # handle the nonselection glyph, we always set one
-        nsglyph_ca = _pop_colors_and_alpha(glyphclass, kwargs, prefix='nonselection_', default_alpha=0.1)
+        nsglyph_ca = _pop_visuals(glyphclass, kwargs, prefix='nonselection_', defaults=glyph_ca, trait_defaults={'alpha':0.1})
 
         # handle the selection glyph, if any properties were given
         if any(x.startswith('selection_') for x in kwargs):
-            sglyph_ca = _pop_colors_and_alpha(glyphclass, kwargs, prefix='selection_')
+            sglyph_ca = _pop_visuals(glyphclass, kwargs, prefix='selection_', defaults=glyph_ca)
         else:
             sglyph_ca = None
 
         # handle the hover glyph, if any properties were given
         if any(x.startswith('hover_') for x in kwargs):
-            hglyph_ca = _pop_colors_and_alpha(glyphclass, kwargs, prefix='hover_')
+            hglyph_ca = _pop_visuals(glyphclass, kwargs, prefix='hover_', defaults=glyph_ca)
         else:
             hglyph_ca = None
 
         # handle the mute glyph, if any properties were given
         if any(x.startswith('muted_') for x in kwargs):
-            mglyph_ca = _pop_colors_and_alpha(glyphclass, kwargs, prefix='muted_')
+            mglyph_ca = _pop_visuals(glyphclass, kwargs, prefix='muted_', defaults=glyph_ca)
         else:
             mglyph_ca = None
 


Test patch: