8000 Add CrossSelector widget by philippjfr · Pull Request #153 · holoviz/panel · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Add CrossSelector widget #153

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Nov 15, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions examples/user_guide/Widgets.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,46 @@
"player.loop_policy = 'loop'\n",
"player.length = 20"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## CrossSelector"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The CrossSelector is a more powerful alternative to the ``MultiSelect`` widget. It allows selecting items by moving them between two lists with a set of buttons. It also provides query fields, which support regex expressions, that allow filtering the selected and unselected items to move them in bulk."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"cross_select = CrossSelector(options=[1, 2, 3, 4, 'A', 'B', 'C', 'D'], value=[3, 'A'])\n",
"cross_select"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Like most other widgets the selected values can be accessed on the value parameter:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"cross_select.value"
]
}
],
"metadata": {
Expand Down
42 changes: 41 additions & 1 deletion panel/tests/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
TextInput, StaticText, FloatSlider, IntSlider, RangeSlider,
LiteralInput, Checkbox, Select, MultiSelect, Button, Toggle,
DatePicker, DateRangeSlider, DiscreteSlider, DatetimeInput,
RadioButtons, ToggleButtons
RadioButtons, ToggleButtons, CrossSelector
)


Expand Down Expand Up @@ -515,3 +515,43 @@ def test_discrete_slider_options_dict(document, comm):
discrete_slider.value = 100
assert widget.value == 3
assert label.text == '<b>DiscreteSlider</b>: 100'



def test_cross_select_constructor(document, comm):
cross_select = CrossSelector(options=['A', 'B', 'C', 1, 2, 3], value=['A', 1])

assert cross_select._lists[True].options == {'A': 'A', '1': '1'}
assert cross_select._lists[False].options == {'B': 'B', 'C': 'C', '2': '2', '3': '3'}

# Change selection
cross_select.value = ['B', 2]
assert cross_select._lists[True].options == {'B': 'B', '2': '2'}
assert cross_select._lists[False].options == {'A': 'A', 'C': 'C', '1': '1', '3': '3'}

# Change options
cross_select.options = {'D': 'D', '4': 4}
assert cross_select._lists[True].options == {}
assert cross_select._lists[False].options == {'D': 'D', '4': '4'}

# Query unselected item
cross_select._search[False].value = 'D'
assert cross_select._lists[False].value == ['D']

# Move queried item
cross_select._buttons[True].param.trigger('clicks')
assert cross_select._lists[False].options == {'4': '4'}
assert cross_select._lists[False].value == []
assert cross_select._lists[True].options == {'D': 'D'}
assert cross_select._lists[False].value == []

# Query selected item
cross_select._search[True].value = 'D'
cross_select._buttons[False].param.trigger('clicks')
assert cross_select._lists[False].options == {'D': 'D', '4': '4'}
assert cross_select._lists[False].value == ['D']
assert cross_select._lists[True].options == {}

# Clear query
cross_select._search[False].value = ''
assert cross_select._lists[False].value == []
154 changes: 152 additions & 2 deletions panel/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""
from __future__ import absolute_import

import re
import ast
from collections import OrderedDict
from datetime import datetime
Expand All @@ -20,7 +21,7 @@
CheckboxButtonGroup as _BkCheckboxButtonGroup
)

from .layout import WidgetBox # noqa
from .layout import Column, Row, Spacer, WidgetBox # noqa
from .models.widgets import Player as _BkPlayer
from .viewable import Reactive
from .util import as_unicode, push, value_as_datetime, hashable
Expand Down Expand Up @@ -233,6 +234,9 @@ class Button(_ButtonBase):

_widget_type = _BkButton

def on_click(self, callback):
self.param.watch(callback, 'clicks')


class Toggle(_ButtonBase):

Expand Down Expand Up @@ -523,7 +527,7 @@ def _get_model(self, doc, root=None, parent=None, comm=None):

model.children = [div, slider]
self._models[root.ref['id']] = model

return model

def _link_params(self, model, slider, div, params, doc, root, comm=None):
Expand Down Expand Up @@ -608,3 +612,149 @@ class Player(Widget):
_widget_type = _BkPlayer

_rename = {'name': None}


class CrossSelector(MultiSelect):
"""
A composite widget which allows selecting from a list of items
by moving them between two lists. Supports filtering values by
name to select them in bulk.
"""

width = param.Integer(default=600, doc="""
The number of options shown at once (note this is the
only way to control the height of this widget)""")

height = param.Integer(default=200, doc="""
The number of options shown at once (note this is the
only way to control the height of this widget)""")

size = param.Integer(default=10, doc="""
The number of options shown at once (note this is the
only way to control the height of this widget)""")

def __init__(self, *args, **kwargs):
super(CrossSelector, self).__init__(**kwargs)
# Compute selected and unselected values

mapping = {hashable(v): k for k, v in self.options.items()}
selected = [mapping[hashable(v)] for v in kwargs.get('value', [])]
unselected = [k for k in self.options if k not in selected]

# Define whitelist and blacklist
width = int((self.width-100)/2)
self._lists = {
False: MultiSelect(options=unselected, size=self.size,
height=self.height-50, width=width),
True: MultiSelect(options=selected, size=self.size,
height=self.height-50, width=width)
}
self._lists[False].param.watch(self._update_selection, 'value')
self._lists[True].param.watch(self._update_selection, 'value')

# Define buttons
self._buttons = {False: Button(name='<<', width=50),
True: Button(name='>>', width=50)}

self._buttons[False].param.watch(self._apply_selection, 'clicks')
self._buttons[True].param.watch(self._apply_selection, 'clicks')

# Define search
self._search = {
False: TextInput(placeholder='Filter available options'),
True: TextInput(placeholder='Filter selected options')
}
self._search[False].param.watch(self._filter_options, 'value')
self._search[True].param.watch(self._filter_options, 'value')

# Define Layout
blacklist = WidgetBox(self._search[False], self._lists[False], width=width+10)
whitelist = WidgetBox(self._search[True], self._lists[True], width=width+10)
buttons = WidgetBox(self._buttons[True], self._buttons[False], width=70)

self._layout = Row(blacklist, Column(Spacer(height=110), buttons), whitelist)

self.param.watch(self._update_options, 'options')
self.param.watch(self._update_value, 'value')
self.link(self._lists[False], size='size')
self.link(self._lists[True], size='size')

self._selected = {False: [], True: []}
self._query = {False: '', True: ''}

def _update_value(self, event):
mapping = {hashable(v): k for k, v in self.options.items()}
selected = OrderedDict([(mapping[k], mapping[k]) for k in event.new])
unselected = OrderedDict([(k, k) for k in self.options if k not in selected])
self._lists[True].options = selected
self._lists[True].value = []
self._lists[False].options = unselected
self._lists[False].value = []

def _update_options(self, event):
"""
Updates the options of each of the sublists after the options
for the whole widget are updated.
"""
self._selected[False] = []
self._selected[True] = []
self._lists[True].options = {}
self._lists[True].value = []
self._lists[False].options = OrderedDict([(k, k) for k in event.new])
self._lists[False].value = []

def _apply_filters(self):
self._apply_query(False)
self._apply_query(True)

def _filter_options(self, event):
"""
Filters unselected options based on a text query event.
"""
selected = event.obj is self._search[True]
self._query[selected] = event.new
self._apply_query(selected)

def _apply_query(self, selected):
query = self._query[selected]
other = self._lists[not selected].options
options = OrderedDict([(k, k) for k in self.options if k not in other])
if not query:
self._lists[selected].options = options
self._lists[selected].value = []
else:
try:
match = re.compile(query)
matches = list(filter(match.search, options))
except:
matches = list(options)
self._lists[selected].options = options if options else {}
self._lists[selected].value = [m for m in matches]

def _update_selection(self, event):
"""
Updates the current selection in each list.
"""
selected = event.obj is self._lists[True]
self._selected[selected] = [v for v in event.new if v != '']

def _apply_selection(self, event):
"""
Applies the current selection depending on which button was
pressed.
"""
selected = event.obj is self._buttons[True]

new = OrderedDict([(k, self.options[k]) for k in self._selected[not selected]])
old = self._lists[selected].options
other = self._lists[not selected].options

merged = OrderedDict([(k, k) for k in list(old)+list(new)])
leftovers = OrderedDict([(k, k) for k in other if k not in new])
self._lists[selected].options = merged if merged else {}
self._lists[not selected].options = leftovers if leftovers else {}
self.value = [self.options[o] for o in self._lists[True].options if o != '']
self._apply_filters()

def _get_model(self, doc, root=None, parent=None, comm=None):
return self._layout._get_model(doc, root, parent, comm)
0