From 8ab7bc79ed4305b6e8de77096b582e39d55c7d55 2014-08-26 04:40:21 From: Jonathan Frederic Date: 2014-08-26 04:40:21 Subject: [PATCH] Merge pull request #6050 from chronitis/interact-range-widgets Range widgets --- diff --git a/IPython/html/static/style/ipython.min.css b/IPython/html/static/style/ipython.min.css index 367bf10..f5a42c9 100644 --- a/IPython/html/static/style/ipython.min.css +++ b/IPython/html/static/style/ipython.min.css @@ -1365,6 +1365,10 @@ div.cell.text_cell.rendered { height: 28px !important; margin-top: -8px !important; } +.widget-hslider .ui-slider .ui-slider-range { + height: 12px !important; + margin-top: -4px !important; +} .widget-vslider { /* Vertical jQuery Slider */ /* Fix the padding of the slide track so the ui-slider is sized @@ -1442,6 +1446,10 @@ div.cell.text_cell.rendered { height: 14px !important; margin-left: -9px; } +.widget-vslider .ui-slider .ui-slider-range { + width: 12px !important; + margin-left: -1px !important; +} .widget-text { /* String Textbox - used for TextBoxView and TextAreaView */ width: 350px; diff --git a/IPython/html/static/style/style.min.css b/IPython/html/static/style/style.min.css index 2401e38..77a2e26 100644 --- a/IPython/html/static/style/style.min.css +++ b/IPython/html/static/style/style.min.css @@ -9137,6 +9137,10 @@ div.cell.text_cell.rendered { height: 28px !important; margin-top: -8px !important; } +.widget-hslider .ui-slider .ui-slider-range { + height: 12px !important; + margin-top: -4px !important; +} .widget-vslider { /* Vertical jQuery Slider */ /* Fix the padding of the slide track so the ui-slider is sized @@ -9214,6 +9218,10 @@ div.cell.text_cell.rendered { height: 14px !important; margin-left: -9px; } +.widget-vslider .ui-slider .ui-slider-range { + width: 12px !important; + margin-left: -1px !important; +} .widget-text { /* String Textbox - used for TextBoxView and TextAreaView */ width: 350px; diff --git a/IPython/html/static/widgets/js/widget_int.js b/IPython/html/static/widgets/js/widget_int.js index b2db7a2..c8a736b 100644 --- a/IPython/html/static/widgets/js/widget_int.js +++ b/IPython/html/static/widgets/js/widget_int.js @@ -52,6 +52,10 @@ define([ that.$slider.slider("option", key, model_value); } }); + var range_value = this.model.get("_range"); + if (range_value !== undefined) { + this.$slider.slider("option", "range", range_value); + } // WORKAROUND FOR JQUERY SLIDER BUG. // The horizontal position of the slider handle @@ -64,17 +68,28 @@ define([ var orientation = this.model.get('orientation'); var min = this.model.get('min'); var max = this.model.get('max'); - this.$slider.slider('option', 'value', min); + if (this.model.get('_range')) { + this.$slider.slider('option', 'values', [min, min]); + } else { + this.$slider.slider('option', 'value', min); + } this.$slider.slider('option', 'orientation', orientation); var value = this.model.get('value'); - if(value > max) { - value = max; - } - else if(value < min){ - value = min; + if (this.model.get('_range')) { + // values for the range case are validated python-side in + // _Bounded{Int,Float}RangeWidget._validate + this.$slider.slider('option', 'values', value); + this.$readout.text(value.join("-")); + } else { + if(value > max) { + value = max; + } + else if(value < min){ + value = min; + } + this.$slider.slider('option', 'value', value); + this.$readout.text(value); } - this.$slider.slider('option', 'value', value); - this.$readout.text(value); if(this.model.get('value')!=value) { this.model.set('value', value, {updated_view: this}); @@ -140,9 +155,14 @@ define([ // Calling model.set will trigger all of the other views of the // model to update. - var actual_value = this._validate_slide_value(ui.value); + if (this.model.get("_range")) { + var actual_value = ui.values.map(this._validate_slide_value); + this.$readout.text(actual_value.join("-")); + } else { + var actual_value = this._validate_slide_value(ui.value); + this.$readout.text(actual_value); + } this.model.set('value', actual_value, {updated_view: this}); - this.$readout.text(actual_value); this.touch(); }, diff --git a/IPython/html/static/widgets/less/widgets.less b/IPython/html/static/widgets/less/widgets.less index 12f8cf9..c26aa27 100644 --- a/IPython/html/static/widgets/less/widgets.less +++ b/IPython/html/static/widgets/less/widgets.less @@ -122,6 +122,11 @@ height : 28px !important; margin-top : -8px !important; } + + .ui-slider-range { + height : 12px !important; + margin-top : -4px !important; + } } } @@ -160,6 +165,11 @@ height : 14px !important; margin-left : -9px; } + + .ui-slider-range { + width : 12px !important; + margin-left : -1px !important; + } } } diff --git a/IPython/html/widgets/__init__.py b/IPython/html/widgets/__init__.py index c16c7d7..d3cdb11 100644 --- a/IPython/html/widgets/__init__.py +++ b/IPython/html/widgets/__init__.py @@ -3,9 +3,9 @@ from .widget import Widget, DOMWidget, CallbackDispatcher from .widget_bool import Checkbox, ToggleButton from .widget_button import Button from .widget_box import Box, Popup, FlexBox, HBox, VBox -from .widget_float import FloatText, BoundedFloatText, FloatSlider, FloatProgress +from .widget_float import FloatText, BoundedFloatText, FloatSlider, FloatProgress, FloatRangeSlider from .widget_image import Image -from .widget_int import IntText, BoundedIntText, IntSlider, IntProgress +from .widget_int import IntText, BoundedIntText, IntSlider, IntProgress, IntRangeSlider from .widget_selection import RadioButtons, ToggleButtons, Dropdown, Select from .widget_selectioncontainer import Tab, Accordion from .widget_string import HTML, Latex, Text, Textarea diff --git a/IPython/html/widgets/tests/test_interaction.py b/IPython/html/widgets/tests/test_interaction.py index 0ef46d1..7228a58 100644 --- a/IPython/html/widgets/tests/test_interaction.py +++ b/IPython/html/widgets/tests/test_interaction.py @@ -480,3 +480,120 @@ def test_custom_description(): value='text', description='foo', ) + +def test_int_range_logic(): + irsw = widgets.IntRangeSlider + w = irsw(value=(2, 4), min=0, max=6) + check_widget(w, cls=irsw, value=(2, 4), min=0, max=6) + w.value = (4, 2) + check_widget(w, cls=irsw, value=(2, 4), min=0, max=6) + w.value = (-1, 7) + check_widget(w, cls=irsw, value=(0, 6), min=0, max=6) + w.min = 3 + check_widget(w, cls=irsw, value=(3, 6), min=3, max=6) + w.max = 3 + check_widget(w, cls=irsw, value=(3, 3), min=3, max=3) + + w.min = 0 + w.max = 6 + w.lower = 2 + w.upper = 4 + check_widget(w, cls=irsw, value=(2, 4), min=0, max=6) + w.value = (0, 1) #lower non-overlapping range + check_widget(w, cls=irsw, value=(0, 1), min=0, max=6) + w.value = (5, 6) #upper non-overlapping range + check_widget(w, cls=irsw, value=(5, 6), min=0, max=6) + w.value = (-1, 4) #semi out-of-range + check_widget(w, cls=irsw, value=(0, 4), min=0, max=6) + w.lower = 2 + check_widget(w, cls=irsw, value=(2, 4), min=0, max=6) + w.value = (-2, -1) #wholly out of range + check_widget(w, cls=irsw, value=(0, 0), min=0, max=6) + w.value = (7, 8) + check_widget(w, cls=irsw, value=(6, 6), min=0, max=6) + + with nt.assert_raises(ValueError): + w.min = 7 + with nt.assert_raises(ValueError): + w.max = -1 + with nt.assert_raises(ValueError): + w.lower = 5 + with nt.assert_raises(ValueError): + w.upper = 1 + + w = irsw(min=2, max=3) + check_widget(w, min=2, max=3) + w = irsw(min=100, max=200) + check_widget(w, lower=125, upper=175, value=(125, 175)) + + with nt.assert_raises(ValueError): + irsw(value=(2, 4), lower=3) + with nt.assert_raises(ValueError): + irsw(value=(2, 4), upper=3) + with nt.assert_raises(ValueError): + irsw(value=(2, 4), lower=3, upper=3) + with nt.assert_raises(ValueError): + irsw(min=2, max=1) + with nt.assert_raises(ValueError): + irsw(lower=5) + with nt.assert_raises(ValueError): + irsw(upper=5) + + +def test_float_range_logic(): + frsw = widgets.FloatRangeSlider + w = frsw(value=(.2, .4), min=0., max=.6) + check_widget(w, cls=frsw, value=(.2, .4), min=0., max=.6) + w.value = (.4, .2) + check_widget(w, cls=frsw, value=(.2, .4), min=0., max=.6) + w.value = (-.1, .7) + check_widget(w, cls=frsw, value=(0., .6), min=0., max=.6) + w.min = .3 + check_widget(w, cls=frsw, value=(.3, .6), min=.3, max=.6) + w.max = .3 + check_widget(w, cls=frsw, value=(.3, .3), min=.3, max=.3) + + w.min = 0. + w.max = .6 + w.lower = .2 + w.upper = .4 + check_widget(w, cls=frsw, value=(.2, .4), min=0., max=.6) + w.value = (0., .1) #lower non-overlapping range + check_widget(w, cls=frsw, value=(0., .1), min=0., max=.6) + w.value = (.5, .6) #upper non-overlapping range + check_widget(w, cls=frsw, value=(.5, .6), min=0., max=.6) + w.value = (-.1, .4) #semi out-of-range + check_widget(w, cls=frsw, value=(0., .4), min=0., max=.6) + w.lower = .2 + check_widget(w, cls=frsw, value=(.2, .4), min=0., max=.6) + w.value = (-.2, -.1) #wholly out of range + check_widget(w, cls=frsw, value=(0., 0.), min=0., max=.6) + w.value = (.7, .8) + check_widget(w, cls=frsw, value=(.6, .6), min=.0, max=.6) + + with nt.assert_raises(ValueError): + w.min = .7 + with nt.assert_raises(ValueError): + w.max = -.1 + with nt.assert_raises(ValueError): + w.lower = .5 + with nt.assert_raises(ValueError): + w.upper = .1 + + w = frsw(min=2, max=3) + check_widget(w, min=2, max=3) + w = frsw(min=1., max=2.) + check_widget(w, lower=1.25, upper=1.75, value=(1.25, 1.75)) + + with nt.assert_raises(ValueError): + frsw(value=(2, 4), lower=3) + with nt.assert_raises(ValueError): + frsw(value=(2, 4), upper=3) + with nt.assert_raises(ValueError): + frsw(value=(2, 4), lower=3, upper=3) + with nt.assert_raises(ValueError): + frsw(min=.2, max=.1) + with nt.assert_raises(ValueError): + frsw(lower=5) + with nt.assert_raises(ValueError): + frsw(upper=5) diff --git a/IPython/html/widgets/widget_float.py b/IPython/html/widgets/widget_float.py index 0c27456..dc08afd 100644 --- a/IPython/html/widgets/widget_float.py +++ b/IPython/html/widgets/widget_float.py @@ -14,7 +14,7 @@ Represents an unbounded float using a widget. # Imports #----------------------------------------------------------------------------- from .widget import DOMWidget -from IPython.utils.traitlets import Unicode, CFloat, Bool, Enum +from IPython.utils.traitlets import Unicode, CFloat, Bool, Enum, Tuple from IPython.utils.warn import DeprecatedClass #----------------------------------------------------------------------------- @@ -55,12 +55,113 @@ class FloatSlider(_BoundedFloat): _view_name = Unicode('FloatSliderView', sync=True) orientation = Enum([u'horizontal', u'vertical'], u'horizontal', help="Vertical or horizontal.", sync=True) + _range = Bool(False, help="Display a range selector", sync=True) readout = Bool(True, help="Display the current value of the slider next to it.", sync=True) class FloatProgress(_BoundedFloat): _view_name = Unicode('ProgressView', sync=True) +class _FloatRange(_Float): + value = Tuple(CFloat, CFloat, default_value=(0.0, 1.0), help="Tuple of (lower, upper) bounds", sync=True) + lower = CFloat(0.0, help="Lower bound", sync=False) + upper = CFloat(1.0, help="Upper bound", sync=False) + + def __init__(self, *pargs, **kwargs): + value_given = 'value' in kwargs + lower_given = 'lower' in kwargs + upper_given = 'upper' in kwargs + if value_given and (lower_given or upper_given): + raise ValueError("Cannot specify both 'value' and 'lower'/'upper' for range widget") + if lower_given != upper_given: + raise ValueError("Must specify both 'lower' and 'upper' for range widget") + + DOMWidget.__init__(self, *pargs, **kwargs) + + # ensure the traits match, preferring whichever (if any) was given in kwargs + if value_given: + self.lower, self.upper = self.value + else: + self.value = (self.lower, self.upper) + + self.on_trait_change(self._validate, ['value', 'upper', 'lower']) + + def _validate(self, name, old, new): + if name == 'value': + self.lower, self.upper = min(new), max(new) + elif name == 'lower': + self.value = (new, self.value[1]) + elif name == 'upper': + self.value = (self.value[0], new) + +class _BoundedFloatRange(_FloatRange): + step = CFloat(1.0, help="Minimum step that the value can take (ignored by some views)", sync=True) + max = CFloat(100.0, help="Max value", sync=True) + min = CFloat(0.0, help="Min value", sync=True) + + def __init__(self, *pargs, **kwargs): + any_value_given = 'value' in kwargs or 'upper' in kwargs or 'lower' in kwargs + _FloatRange.__init__(self, *pargs, **kwargs) + + # ensure a minimal amount of sanity + if self.min > self.max: + raise ValueError("min must be <= max") + + if any_value_given: + # if a value was given, clamp it within (min, max) + self._validate("value", None, self.value) + else: + # otherwise, set it to 25-75% to avoid the handles overlapping + self.value = (0.75*self.min + 0.25*self.max, + 0.25*self.min + 0.75*self.max) + # callback already set for 'value', 'lower', 'upper' + self.on_trait_change(self._validate, ['min', 'max']) + + + def _validate(self, name, old, new): + if name == "min": + if new > self.max: + raise ValueError("setting min > max") + self.min = new + elif name == "max": + if new < self.min: + raise ValueError("setting max < min") + self.max = new + + low, high = self.value + if name == "value": + low, high = min(new), max(new) + elif name == "upper": + if new < self.lower: + raise ValueError("setting upper < lower") + high = new + elif name == "lower": + if new > self.upper: + raise ValueError("setting lower > upper") + low = new + + low = max(self.min, min(low, self.max)) + high = min(self.max, max(high, self.min)) + + # determine the order in which we should update the + # lower, upper traits to avoid a temporary inverted overlap + lower_first = high < self.lower + + self.value = (low, high) + if lower_first: + self.lower = low + self.upper = high + else: + self.upper = high + self.lower = low + + +class FloatRangeSlider(_BoundedFloatRange): + _view_name = Unicode('FloatSliderView', sync=True) + orientation = Enum([u'horizontal', u'vertical'], u'horizontal', + help="Vertical or horizontal.", sync=True) + _range = Bool(True, help="Display a range selector", sync=True) + readout = Bool(True, help="Display the current value of the slider next to it.", sync=True) # Remove in IPython 4.0 FloatTextWidget = DeprecatedClass(FloatText, 'FloatTextWidget') diff --git a/IPython/html/widgets/widget_int.py b/IPython/html/widgets/widget_int.py index 807b527..5777287 100644 --- a/IPython/html/widgets/widget_int.py +++ b/IPython/html/widgets/widget_int.py @@ -14,7 +14,7 @@ Represents an unbounded int using a widget. # Imports #----------------------------------------------------------------------------- from .widget import DOMWidget -from IPython.utils.traitlets import Unicode, CInt, Bool, Enum +from IPython.utils.traitlets import Unicode, CInt, Bool, Enum, Tuple from IPython.utils.warn import DeprecatedClass #----------------------------------------------------------------------------- @@ -60,6 +60,7 @@ class IntSlider(_BoundedInt): _view_name = Unicode('IntSliderView', sync=True) orientation = Enum([u'horizontal', u'vertical'], u'horizontal', help="Vertical or horizontal.", sync=True) + _range = Bool(False, help="Display a range selector", sync=True) readout = Bool(True, help="Display the current value of the slider next to it.", sync=True) @@ -67,6 +68,104 @@ class IntProgress(_BoundedInt): """Progress bar that represents a int bounded by a minimum and maximum value.""" _view_name = Unicode('ProgressView', sync=True) +class _IntRange(_Int): + value = Tuple(CInt, CInt, default_value=(0, 1), help="Tuple of (lower, upper) bounds", sync=True) + lower = CInt(0, help="Lower bound", sync=False) + upper = CInt(1, help="Upper bound", sync=False) + + def __init__(self, *pargs, **kwargs): + value_given = 'value' in kwargs + lower_given = 'lower' in kwargs + upper_given = 'upper' in kwargs + if value_given and (lower_given or upper_given): + raise ValueError("Cannot specify both 'value' and 'lower'/'upper' for range widget") + if lower_given != upper_given: + raise ValueError("Must specify both 'lower' and 'upper' for range widget") + + DOMWidget.__init__(self, *pargs, **kwargs) + + # ensure the traits match, preferring whichever (if any) was given in kwargs + if value_given: + self.lower, self.upper = self.value + else: + self.value = (self.lower, self.upper) + + self.on_trait_change(self._validate, ['value', 'upper', 'lower']) + + def _validate(self, name, old, new): + if name == 'value': + self.lower, self.upper = min(new), max(new) + elif name == 'lower': + self.value = (new, self.value[1]) + elif name == 'upper': + self.value = (self.value[0], new) + +class _BoundedIntRange(_IntRange): + step = CInt(1, help="Minimum step that the value can take (ignored by some views)", sync=True) + max = CInt(100, help="Max value", sync=True) + min = CInt(0, help="Min value", sync=True) + + def __init__(self, *pargs, **kwargs): + any_value_given = 'value' in kwargs or 'upper' in kwargs or 'lower' in kwargs + _IntRange.__init__(self, *pargs, **kwargs) + + # ensure a minimal amount of sanity + if self.min > self.max: + raise ValueError("min must be <= max") + + if any_value_given: + # if a value was given, clamp it within (min, max) + self._validate("value", None, self.value) + else: + # otherwise, set it to 25-75% to avoid the handles overlapping + self.value = (0.75*self.min + 0.25*self.max, + 0.25*self.min + 0.75*self.max) + # callback already set for 'value', 'lower', 'upper' + self.on_trait_change(self._validate, ['min', 'max']) + + def _validate(self, name, old, new): + if name == "min": + if new > self.max: + raise ValueError("setting min > max") + self.min = new + elif name == "max": + if new < self.min: + raise ValueError("setting max < min") + self.max = new + + low, high = self.value + if name == "value": + low, high = min(new), max(new) + elif name == "upper": + if new < self.lower: + raise ValueError("setting upper < lower") + high = new + elif name == "lower": + if new > self.upper: + raise ValueError("setting lower > upper") + low = new + + low = max(self.min, min(low, self.max)) + high = min(self.max, max(high, self.min)) + + # determine the order in which we should update the + # lower, upper traits to avoid a temporary inverted overlap + lower_first = high < self.lower + + self.value = (low, high) + if lower_first: + self.lower = low + self.upper = high + else: + self.upper = high + self.lower = low + +class IntRangeSlider(_BoundedIntRange): + _view_name = Unicode('IntSliderView', sync=True) + orientation = Enum([u'horizontal', u'vertical'], u'horizontal', + help="Vertical or horizontal.", sync=True) + _range = Bool(True, help="Display a range selector", sync=True) + readout = Bool(True, help="Display the current value of the slider next to it.", sync=True) # Remove in IPython 4.0 IntTextWidget = DeprecatedClass(IntText, 'IntTextWidget')