Repository: biolab/orange3

Issue:
BoxPlot crashes when saving selection
**Describe the bug**
Box Plot widget crashes when removed from canvas after selecting some instances on the plot.

**To Reproduce**

1. Place a File (Iris) widget on canvas.
2. Connect it to a BoxPlot.
3. Select e.g. Iris-setosa box in the plot area.
4. Remove the Box Plot widget from canvas.

**Orange version:**
Master

**Expected behavior**
No crash when deleting the widget.
Selection is a schema-only setting of a [valid type](https://github.com/biolab/orange-widget-base/issues/20).



Discussion:
I can replicate the problem, but it's obvious what's wrong.

Writing as many context handlers as possible seems to have become my corona work-at-home project.



Resolving patch:
diff --git a/Orange/data/table.py b/Orange/data/table.py
--- a/Orange/data/table.py
+++ b/Orange/data/table.py
@@ -1242,7 +1242,10 @@ def _filter_to_indicator(self, filter):
         def get_col_indices():
             cols = chain(self.domain.variables, self.domain.metas)
             if isinstance(filter, IsDefined):
-                return list(cols)
+                if filter.columns is not None:
+                    return list(filter.columns)
+                else:
+                    return list(cols)
 
             if filter.column is not None:
                 return [filter.column]
@@ -1282,11 +1285,14 @@ def col_filter(col_idx):
 
         col_indices = get_col_indices()
         if len(col_indices) == 1:
-            return col_filter(col_indices[0])
+            sel = col_filter(col_indices[0])
+        else:
+            sel = np.ones(len(self), dtype=bool)
+            for col_idx in col_indices:
+                sel *= col_filter(col_idx)
 
-        sel = np.ones(len(self), dtype=bool)
-        for col_idx in col_indices:
-            sel *= col_filter(col_idx)
+        if isinstance(filter, IsDefined) and filter.negate:
+            sel = ~sel
         return sel
 
     def _discrete_filter_to_indicator(self, filter, col):
diff --git a/Orange/widgets/settings.py b/Orange/widgets/settings.py
--- a/Orange/widgets/settings.py
+++ b/Orange/widgets/settings.py
@@ -134,10 +134,9 @@ def filter_value(self, setting, data, domain, attrs, metas):
             new_value = {item: val for item, val in value.items()
                          if self.is_valid_item(setting, item, attrs, metas)}
             data[setting.name] = new_value
-        elif value is not None:
-            if (value[1] >= 0 and
-                    not self._var_exists(setting, value, attrs, metas)):
-                del data[setting.name]
+        elif self.is_encoded_var(value) \
+                and not self._var_exists(setting, value, attrs, metas):
+            del data[setting.name]
 
     def settings_to_widget(self, widget, domain, *args):
         # TODO: If https://github.com/biolab/orange-widget-base/pull/56 is:
@@ -208,7 +207,7 @@ def get_var(name):
 
     @classmethod
     def _var_exists(cls, setting, value, attributes, metas):
-        if not isinstance(value, tuple) or len(value) != 2:
+        if not cls.is_encoded_var(value):
             return False
 
         attr_name, attr_type = value
@@ -287,6 +286,12 @@ def is_valid_item(self, setting, item, attrs, metas):
             return True
         return self._var_exists(setting, item, attrs, metas)
 
+    @staticmethod
+    def is_encoded_var(value):
+        return isinstance(value, tuple) \
+            and len(value) == 2 \
+            and isinstance(value[0], str) and isinstance(value[1], int) \
+            and value[1] >= 0
 
 class ClassValuesContextHandler(ContextHandler):
     """Context handler used for widgets that work with
@@ -404,8 +409,7 @@ def _fix(name):
 
     if names is None:
         for name, setting in settings.values.items():
-            if isinstance(setting, tuple) and len(setting) == 2 and \
-                    isinstance(setting[0], str) and isinstance(setting[1], int):
+            if DomainContextHandler.is_encoded_var(setting):
                 _fix(name)
     elif isinstance(names, str):
         _fix(names)
diff --git a/Orange/widgets/visualize/owboxplot.py b/Orange/widgets/visualize/owboxplot.py
--- a/Orange/widgets/visualize/owboxplot.py
+++ b/Orange/widgets/visualize/owboxplot.py
@@ -1,20 +1,22 @@
 import math
-from itertools import chain
+from collections import namedtuple
+from itertools import chain, count
 import numpy as np
 
 from AnyQt.QtWidgets import (
     QGraphicsView, QGraphicsScene, QGraphicsItem, QGraphicsSimpleTextItem,
     QGraphicsTextItem, QGraphicsItemGroup, QGraphicsLineItem,
-    QGraphicsPathItem, QGraphicsRectItem, QSizePolicy
-)
+    QGraphicsPathItem, QGraphicsRectItem, QSizePolicy,
+    QListView)
 from AnyQt.QtGui import QPen, QColor, QBrush, QPainterPath, QPainter, QFont
-from AnyQt.QtCore import Qt, QEvent, QRectF, QSize
+from AnyQt.QtCore import Qt, QEvent, QRectF, QSize, QSortFilterProxyModel
 
 import scipy.special
 from scipy.stats import f_oneway, chi2_contingency
 
 import Orange.data
-from Orange.data.filter import FilterDiscrete, FilterContinuous, Values
+from Orange.data.filter import FilterDiscrete, FilterContinuous, Values, \
+    IsDefined
 from Orange.statistics import contingency, distribution
 
 from Orange.widgets import widget, gui
@@ -76,8 +78,12 @@ def _quantiles(a, freq, q, interpolation="midpoint"):
         raise ValueError("invalid interpolation: '{}'".format(interpolation))
 
 
+ContDataRange = namedtuple("ContDataRange", ["low", "high", "group_value"])
+DiscDataRange = namedtuple("DiscDataRange", ["value", "group_value"])
+
+
 class BoxData:
-    def __init__(self, dist, attr, group_val_index=None, group_var=None):
+    def __init__(self, dist, group_val=None):
         self.dist = dist
         self.n = n = np.sum(dist[1])
         if n == 0:
@@ -93,46 +99,24 @@ def __init__(self, dist, attr, group_val_index=None, group_var=None):
         # The code below omits the q25 or q75 in the plot when they are None
         self.q25 = None if q25 == median else q25
         self.q75 = None if q75 == median else q75
-        self.conditions = [FilterContinuous(attr, FilterContinuous.Between,
-                                            self.q25, self.q75)]
-        if group_val_index is not None:
-            self.conditions.append(FilterDiscrete(group_var, [group_val_index]))
+        self.data_range = ContDataRange(self.q25, self.q75, group_val)
 
 
 class FilterGraphicsRectItem(QGraphicsRectItem):
-    def __init__(self, conditions, *args):
+    def __init__(self, data_range, *args):
         super().__init__(*args)
-        self.filter = Values(conditions) if conditions else None
+        self.data_range = data_range
         self.setFlag(QGraphicsItem.ItemIsSelectable)
 
 
-class OWBoxPlot(widget.OWWidget):
-    """
-    Here's how the widget's functions call each other:
-
-    - `set_data` is a signal handler fills the list boxes and calls
-    `grouping_changed`.
-
-    - `grouping_changed` handles changes of grouping attribute: it enables or
-    disables the box for ordering, orders attributes and calls `attr_changed`.
-
-    - `attr_changed` handles changes of attribute. It recomputes box data by
-    calling `compute_box_data`, shows the appropriate display box
-    (discrete/continuous) and then calls`layout_changed`
+class SortProxyModel(QSortFilterProxyModel):
+    def lessThan(self, left, right):
+        role = self.sortRole()
+        l_score = left.data(role)
+        r_score = right.data(role)
+        return r_score is not None and (l_score is None or l_score < r_score)
 
-    - `layout_changed` constructs all the elements for the scene (as lists of
-    QGraphicsItemGroup) and calls `display_changed`. It is called when the
-    attribute or grouping is changed (by attr_changed) and on resize event.
-
-    - `display_changed` puts the elements corresponding to the current display
-    settings on the scene. It is called when the elements are reconstructed
-    (layout is changed due to selection of attributes or resize event), or
-    when the user changes display settings or colors.
-
-    For discrete attributes, the flow is a bit simpler: the elements are not
-    constructed in advance (by layout_changed). Instead, _display_changed_disc
-    draws everything.
-    """
+class OWBoxPlot(widget.OWWidget):
     name = "Box Plot"
     description = "Visualize the distribution of feature values in a box plot."
     icon = "icons/BoxPlot.svg"
@@ -146,11 +130,17 @@ class Outputs:
         selected_data = Output("Selected Data", Orange.data.Table, default=True)
         annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Orange.data.Table)
 
+    class Warning(widget.OWWidget.Warning):
+        no_vars = widget.Msg(
+            "Data contains no categorical or numeric variables")
+
     #: Comparison types for continuous variables
     CompareNone, CompareMedians, CompareMeans = 0, 1, 2
 
     settingsHandler = DomainContextHandler()
-    conditions = ContextSetting([])
+    # If this was a list, context handler would try to match its elements to
+    # variable names!
+    selection = ContextSetting((), schema_only=True)
 
     attribute = ContextSetting(None)
     order_by_importance = Setting(False)
@@ -195,8 +185,10 @@ class Outputs:
 
     def __init__(self):
         super().__init__()
-        self.stats = []
         self.dataset = None
+        self.stats = []
+        self.dist = self.conts = None
+
         self.posthoc_lines = []
 
         self.label_txts = self.mean_labels = self.boxes = self.labels = \
@@ -206,31 +198,44 @@ def __init__(self):
         self.label_width = 0
 
         self.attrs = VariableListModel()
-        view = gui.listView(
-            self.controlArea, self, "attribute", box="Variable",
-            model=self.attrs, callback=self.attr_changed)
+        sorted_model = SortProxyModel(sortRole=Qt.UserRole)
+        sorted_model.setSourceModel(self.attrs)
+        sorted_model.sort(0)
+        box = gui.vBox(self.controlArea, "Variable")
+        view = self.attr_list = QListView()
+        view.setModel(sorted_model)
+        view.setSelectionMode(view.SingleSelection)
+        view.selectionModel().selectionChanged.connect(self.attr_changed)
         view.setMinimumSize(QSize(30, 30))
         # Any other policy than Ignored will let the QListBox's scrollbar
         # set the minimal height (see the penultimate paragraph of
         # http://doc.qt.io/qt-4.8/qabstractscrollarea.html#addScrollBarWidget)
         view.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored)
+        box.layout().addWidget(view)
         gui.checkBox(
-            view.box, self, "order_by_importance",
+            box, self, "order_by_importance",
             "Order by relevance to subgroups",
             tooltip="Order by 𝜒² or ANOVA over the subgroups",
             callback=self.apply_attr_sorting)
+
         self.group_vars = VariableListModel(placeholder="None")
-        view = gui.listView(
-            self.controlArea, self, "group_var", box="Subgroups",
-            model=self.group_vars, callback=self.grouping_changed)
-        gui.checkBox(
-            view.box, self, "order_grouping_by_importance",
-            "Order by relevance to variable",
-            tooltip="Order by 𝜒² or ANOVA over the variable values",
-            callback=self.on_group_sorting_checkbox)
+        sorted_model = SortProxyModel(sortRole=Qt.UserRole)
+        sorted_model.setSourceModel(self.group_vars)
+        sorted_model.sort(0)
+
+        box = gui.vBox(self.controlArea, "Subgroups")
+        view = self.group_list = QListView()
+        view.setModel(sorted_model)
+        view.selectionModel().selectionChanged.connect(self.grouping_changed)
         view.setMinimumSize(QSize(30, 30))
         # See the comment above
         view.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored)
+        box.layout().addWidget(view)
+        gui.checkBox(
+            box, self, "order_grouping_by_importance",
+            "Order by relevance to variable",
+            tooltip="Order by 𝜒² or ANOVA over the variable values",
+            callback=self.apply_group_sorting)
 
         # TODO: move Compare median/mean to grouping box
         # The vertical size policy is needed to let only the list views expand
@@ -240,11 +245,11 @@ def __init__(self):
             addSpace=False)
 
         gui.checkBox(self.display_box, self, "show_annotations", "Annotate",
-                     callback=self.display_changed)
+                     callback=self.update_graph)
         self.compare_rb = gui.radioButtonsInBox(
             self.display_box, self, 'compare',
             btnLabels=["No comparison", "Compare medians", "Compare means"],
-            callback=self.layout_changed)
+            callback=self.update_graph)
 
         # The vertical size policy is needed to let only the list views expand
         self.stretching_box = box = gui.vBox(
@@ -253,19 +258,19 @@ def __init__(self):
         self.stretching_box.sizeHint = self.display_box.sizeHint
         gui.checkBox(
             box, self, 'stretched', "Stretch bars",
-            callback=self.display_changed,
+            callback=self.update_graph,
             stateWhenDisabled=False)
         gui.checkBox(
             box, self, 'show_labels', "Show box labels",
-            callback=self.display_changed)
+            callback=self.update_graph)
         self.sort_cb = gui.checkBox(
             box, self, 'sort_freqs', "Sort by subgroup frequencies",
-            callback=self.display_changed,
+            callback=self.update_graph,
             stateWhenDisabled=False)
 
         gui.vBox(self.mainArea, addSpace=True)
         self.box_scene = QGraphicsScene(self)
-        self.box_scene.selectionChanged.connect(self.commit)
+        self.box_scene.selectionChanged.connect(self.on_selection_changed)
         self.box_view = QGraphicsView(self.box_scene)
         self.box_view.setRenderHints(QPainter.Antialiasing |
                                      QPainter.TextAntialiasing |
@@ -280,11 +285,7 @@ def __init__(self):
 
         self.info.set_input_summary(self.info.NoInput)
         self.info.set_output_summary(self.info.NoOutput)
-
-        self.stats = self.dist = self.conts = []
-        self.is_continuous = False
-
-        self.update_display_box()
+        self.update_box_visibilities()
 
     def sizeHint(self):
         return QSize(900, 500)
@@ -292,8 +293,7 @@ def sizeHint(self):
     def eventFilter(self, obj, event):
         if obj is self.box_view.viewport() and \
                 event.type() == QEvent.Resize:
-            self.layout_changed()
-
+            self.update_graph()
         return super().eventFilter(obj, event)
 
     @property
@@ -314,28 +314,31 @@ def reset_groups(self):
                 domain.class_vars, domain.metas, domain.attributes)
             if var.is_discrete and not var.attributes.get("hidden", False)]
 
-    # noinspection PyTypeChecker
     @Inputs.data
     def set_data(self, dataset):
         self._set_input_summary(dataset)
-        if dataset is not None and (
-                not bool(dataset) or not len(dataset.domain) and not
-                any(var.is_primitive() for var in dataset.domain.metas)):
-            dataset = None
         self.closeContext()
+        self._reset_all_data()
+        if dataset and not (
+                len(dataset.domain)
+                or any(var.is_primitive() for var in dataset.domain.metas)):
+            self.Warning.no_vars()
+            dataset = None
+
         self.dataset = dataset
-        self.dist = self.stats = self.conts = []
-        self.group_var = None
-        self.attribute = None
         if dataset:
             self.reset_attrs()
             self.reset_groups()
-            self.select_default_variables()
+            self._select_default_variables()
             self.openContext(self.dataset)
-            self.grouping_changed()
-            self.attr_changed()
-        else:
-            self.reset_all_data()
+            self._set_list_view_selections()
+            self.compute_box_data()
+            self.apply_attr_sorting()
+            self.apply_group_sorting()
+            self.update_graph()
+            self.select_box_items()
+
+        self.update_box_visibilities()
         self.commit()
 
     def _set_input_summary(self, dataset):
@@ -343,7 +346,20 @@ def _set_input_summary(self, dataset):
         details = format_summary_details(dataset) if dataset else ""
         self.info.set_input_summary(summary, details)
 
-    def select_default_variables(self):
+    def _reset_all_data(self):
+        self.clear_scene()
+        self.Warning.no_vars.clear()
+
+        self.stats = []
+        self.dist = self.conts = None
+        self.group_var = None
+        self.attribute = None
+        self.stat_test = ""
+        self.attrs[:] = []
+        self.group_vars[:] = [None]
+        self.selection = ()
+
+    def _select_default_variables(self):
         # visualize first non-class variable, group by class (if present)
         domain = self.dataset.domain
         if len(self.attrs) > len(domain.class_vars):
@@ -353,8 +369,21 @@ def select_default_variables(self):
 
         if domain.class_var and domain.class_var.is_discrete:
             self.group_var = domain.class_var
-        else:
-            self.group_var = None  # Reset to trigger selection via callback
+
+    def _set_list_view_selections(self):
+        for view, var, callback in (
+                (self.attr_list, self.attribute, self.attr_changed),
+                (self.group_list, self.group_var, self.grouping_changed)):
+            src_model = view.model().sourceModel()
+            if var not in src_model:
+                continue
+            sel_model = view.selectionModel()
+            sel_model.selectionChanged.disconnect(callback)
+            row = src_model.indexOf(var)
+            index = view.model().index(row, 0)
+            sel_model.select(index, sel_model.ClearAndSelect)
+            self._ensure_selection_visible(view)
+            sel_model.selectionChanged.connect(callback)
 
     def apply_attr_sorting(self):
         def compute_score(attr):
@@ -381,26 +410,15 @@ def compute_score(attr):
         if data is None:
             return
         domain = data.domain
-        attribute = self.attribute
         group_var = self.group_var
         if self.order_by_importance and group_var is not None:
             n_groups = len(group_var.values)
             group_col = data.get_column_view(group_var)[0] if \
                 domain.has_continuous_attributes(
                     include_class=True, include_metas=True) else None
-            self.attrs.sort(key=compute_score)
+            self._sort_list(self.attrs, self.attr_list, compute_score)
         else:
-            self.reset_attrs()
-        self.attribute = attribute  # reset selection
-        self._ensure_selection_visible(self.controls.attribute)
-
-    def on_group_sorting_checkbox(self):
-        if self.order_grouping_by_importance:
-            self.apply_group_sorting()
-        else:
-            self.reset_groups()
-            self.group_var = self.group_var  # reset selection
-            self._ensure_selection_visible(self.controls.group_var)
+            self._sort_list(self.attrs, self.attr_list, None)
 
     def apply_group_sorting(self):
         def compute_stat(group):
@@ -426,15 +444,25 @@ def compute_stat(group):
             return p
 
         data = self.dataset
-        if data is None or not self.order_grouping_by_importance:
+        if data is None:
             return
         attr = self.attribute
-        group_var = self.group_var
-        if attr.is_continuous:
-            attr_col = data.get_column_view(attr)[0].astype(float)
-        self.group_vars.sort(key=compute_stat)
-        self.group_var = group_var  # reset selection
-        self._ensure_selection_visible(self.controls.group_var)
+        if self.order_grouping_by_importance:
+            if attr.is_continuous:
+                attr_col = data.get_column_view(attr)[0].astype(float)
+            self._sort_list(self.group_vars, self.group_list, compute_stat)
+        else:
+            self._sort_list(self.group_vars, self.group_list, None)
+
+    def _sort_list(self, source_model, view, key=None):
+        if key is None:
+            c = count()
+            def key(_):  # pylint: disable=function-redefined
+                return next(c)
+
+        for i, attr in enumerate(source_model):
+            source_model.setData(source_model.index(i), key(attr), Qt.UserRole)
+        self._ensure_selection_visible(view)
 
     @staticmethod
     def _ensure_selection_visible(view):
@@ -454,86 +482,108 @@ def _chi_square(self, group_var, attr):
             return 0, 2, 0
         return chi2_contingency(observed)[:3]
 
-    def reset_all_data(self):
-        self.clear_scene()
-        self.stat_test = ""
-        self.attrs[:] = []
-        self.group_vars[:] = [None]
-        self.is_continuous = False
-        self.update_display_box()
-
-    def grouping_changed(self):
-        self.apply_attr_sorting()
-        self.update_graph()
-        self.controls.stretched.setDisabled(self.group_var is self.attribute)
+    def grouping_changed(self, selected):
+        if not selected:
+            return  # should never come here
+        self.group_var = selected.indexes()[0].data(gui.TableVariable)
+        self._variables_changed(self.apply_attr_sorting)
 
-    def select_box_items(self):
-        temp_cond = self.conditions.copy()
-        for box in self.box_scene.items():
-            if isinstance(box, FilterGraphicsRectItem):
-                box.setSelected(box.filter.conditions in
-                                [c.conditions for c in temp_cond])
+    def attr_changed(self, selected):
+        if not selected:
+            return  # should never come here
+        self.attribute = selected.indexes()[0].data(gui.TableVariable)
+        self._variables_changed(self.apply_group_sorting)
 
-    def attr_changed(self):
-        self.apply_group_sorting()
+    def _variables_changed(self, sorting):
+        self.selection = ()
+        self.compute_box_data()
+        sorting()
         self.update_graph()
-        self.controls.stretched.setDisabled(self.group_var is self.attribute)
+        self.update_box_visibilities()
+        self.commit()
 
     def update_graph(self):
-        self.compute_box_data()
-        self.update_display_box()
-        self.layout_changed()
+        pending_selection = self.selection
+        self.box_scene.selectionChanged.disconnect(self.on_selection_changed)
+        try:  # not for exceptions, just to reconnect after all possible paths
+            self.clear_scene()
 
-        if self.is_continuous:
-            heights = 90 if self.show_annotations else 60
-            self.box_view.centerOn(self.scene_min_x + self.scene_width / 2,
-                                   -30 - len(self.stats) * heights / 2 + 45)
-        else:
-            self.box_view.centerOn(self.scene_width / 2,
-                                   -30 - len(self.boxes) * 40 / 2 + 45)
+            if self.dataset is None or self.attribute is None:
+                return
+
+            if self.attribute.is_continuous:
+                self._display_changed_cont()
+            else:
+                self._display_changed_disc()
+            self.selection = pending_selection
+            self.draw_stat()
+            self.select_box_items()
+
+            if self.attribute.is_continuous:
+                heights = 90 if self.show_annotations else 60
+                self.box_view.centerOn(self.scene_min_x + self.scene_width / 2,
+                                       -30 - len(self.stats) * heights / 2 + 45)
+            else:
+                self.box_view.centerOn(self.scene_width / 2,
+                                       -30 - len(self.boxes) * 40 / 2 + 45)
+        finally:
+            self.box_scene.selectionChanged.connect(self.on_selection_changed)
+
+    def select_box_items(self):
+        selection = set(self.selection)
+        for box in self.box_scene.items():
+            if isinstance(box, FilterGraphicsRectItem):
+                box.setSelected(box.data_range in selection)
 
     def compute_box_data(self):
         attr = self.attribute
         if not attr:
             return
         dataset = self.dataset
-        self.is_continuous = attr.is_continuous
-        if dataset is None or not self.is_continuous and not attr.values or \
-                        self.group_var and not self.group_var.values:
-            self.stats = self.dist = self.conts = []
+        if dataset is None \
+                or not attr.is_continuous and not attr.values \
+                or self.group_var and not self.group_var.values:
+            self.stats = []
+            self.dist = self.conts = None
             return
         if self.group_var:
-            self.dist = []
+            self.dist = None
             self.conts = contingency.get_contingency(
                 dataset, attr, self.group_var)
-            group_var_labels = self.group_var.values + (
-                f"missing '{self.group_var.name}'", )
-            if self.is_continuous:
+            missing_val_str = f"missing '{self.group_var.name}'"
+            group_var_labels = self.group_var.values + ("",)
+            if self.attribute.is_continuous:
                 stats, label_texts = [], []
-                for i, cont in enumerate(self.conts.array_with_unknowns):
+                for cont, value in zip(self.conts.array_with_unknowns,
+                                       group_var_labels):
                     if np.sum(cont[1]):
-                        stats.append(BoxData(cont, attr, i, self.group_var))
-                        label_texts.append(group_var_labels[i])
+                        stats.append(BoxData(cont, value))
+                        label_texts.append(value or missing_val_str)
                 self.stats = stats
                 self.label_txts_all = label_texts
             else:
                 self.label_txts_all = [
-                    v for v, c in zip(
+                    v or missing_val_str for v, c in zip(
                         group_var_labels, self.conts.array_with_unknowns)
                     if np.sum(c) > 0]
         else:
             self.dist = distribution.get_distribution(dataset, attr)
-            self.conts = []
-            if self.is_continuous:
-                self.stats = [BoxData(self.dist, attr, None)]
+            self.conts = None
+            if self.attribute.is_continuous:
+                self.stats = [BoxData(self.dist, None)]
             self.label_txts_all = [""]
         self.label_txts = [txts for stat, txts in zip(self.stats,
                                                       self.label_txts_all)
                            if stat.n > 0]
         self.stats = [stat for stat in self.stats if stat.n > 0]
 
-    def update_display_box(self):
-        if self.is_continuous:
+    def update_box_visibilities(self):
+        self.controls.stretched.setDisabled(self.group_var is self.attribute)
+
+        if not self.attribute:
+            self.stretching_box.hide()
+            self.display_box.hide()
+        elif self.attribute.is_continuous:
             self.stretching_box.hide()
             self.display_box.show()
             self.compare_rb.setEnabled(self.group_var is not None)
@@ -543,8 +593,6 @@ def update_display_box(self):
             self.sort_cb.setEnabled(self.group_var is not None)
 
     def clear_scene(self):
-        self.closeContext()
-        self.box_scene.clearSelection()
         self.box_scene.clear()
         self.box_view.viewport().update()
         self.attr_labels = []
@@ -552,42 +600,19 @@ def clear_scene(self):
         self.boxes = []
         self.mean_labels = []
         self.posthoc_lines = []
-        self.openContext(self.dataset)
-
-    def layout_changed(self):
-        attr = self.attribute
-        if not attr:
-            return
-        self.clear_scene()
-        if self.dataset is None or len(self.conts) == len(self.dist) == 0:
-            return
-
-        if self.is_continuous:
-            self.mean_labels = [self.mean_label(stat, attr, lab)
-                                for stat, lab in zip(self.stats, self.label_txts)]
-            self.draw_axis()
-            self.boxes = [self.box_group(stat) for stat in self.stats]
-            self.labels = [self.label_group(stat, attr, mean_lab)
-                           for stat, mean_lab in zip(self.stats, self.mean_labels)]
-            self.attr_labels = [QGraphicsSimpleTextItem(lab)
-                                for lab in self.label_txts]
-            for it in chain(self.labels, self.attr_labels):
-                self.box_scene.addItem(it)
-
-        self.display_changed()
-
-    def display_changed(self):
-        if self.dataset is None or self.attribute is None:
-            return
-
-        if self.is_continuous:
-            self._display_changed_cont()
-        else:
-            self._display_changed_disc()
-        self.draw_stat()
-        self.select_box_items()
 
     def _display_changed_cont(self):
+        self.mean_labels = [self.mean_label(stat, self.attribute, lab)
+                            for stat, lab in zip(self.stats, self.label_txts)]
+        self.draw_axis()
+        self.boxes = [self.box_group(stat) for stat in self.stats]
+        self.labels = [self.label_group(stat, self.attribute, mean_lab)
+                       for stat, mean_lab in zip(self.stats, self.mean_labels)]
+        self.attr_labels = [QGraphicsSimpleTextItem(lab)
+                            for lab in self.label_txts]
+        for it in chain(self.labels, self.attr_labels):
+            self.box_scene.addItem(it)
+
         self.order = list(range(len(self.stats)))
         criterion = self._sorting_criteria_attrs[self.compare]
         if criterion:
@@ -656,8 +681,9 @@ def _display_changed_disc(self):
         self.draw_axis_disc()
         if self.group_var:
             self.boxes = \
-                [self.strudel(cont, i)
-                 for i, cont in enumerate(self.conts.array_with_unknowns)
+                [self.strudel(cont, val)
+                 for cont, val in zip(self.conts.array_with_unknowns,
+                                      self.group_var.values + ("", ))
                  if np.sum(cont) > 0]
 
             if self.sort_freqs:
@@ -710,7 +736,7 @@ def __draw_row_counts(self, y, row):
         row: int
             row index
         """
-        assert not self.is_continuous
+        assert not self.attribute.is_continuous
         label = self.labels[row]
         b = label.boundingRect()
         if self.group_var:
@@ -732,7 +758,6 @@ def __draw_bar_labels(self, y, bars, labels):
         labels: List[QGraphicsTextItem]
             list of labels for corresponding bars
         """
-        label = bar_part = None
         for text_item, bar_part in zip(labels, bars):
             label = self.Label(
                 text_item.toPlainText())
@@ -925,7 +950,7 @@ def draw_axis_disc(self):
         """
         Draw the horizontal axis and sets self.scale_x for discrete attributes
         """
-        assert not self.is_continuous
+        assert not self.attribute.is_continuous
         if self.show_stretched:
             if not self.attr_labels:
                 return
@@ -1058,7 +1083,7 @@ def line(x0, y0, x1, y1, *args):
             box_from = stat.q25 or stat.median
             box_to = stat.q75 or stat.median
             mbox = FilterGraphicsRectItem(
-                stat.conditions, box_from * scale_x, -height / 2,
+                stat.data_range, box_from * scale_x, -height / 2,
                 (box_to - box_from) * scale_x, height)
             mbox.setBrush(self._box_brush)
             mbox.setPen(QPen(Qt.NoPen))
@@ -1074,52 +1099,55 @@ def line(x0, y0, x1, y1, *args):
 
         return box
 
-    def strudel(self, dist, group_val_index=None):
+    def strudel(self, dist, group_val=None):
         attr = self.attribute
         ss = np.sum(dist)
         box = []
         if ss < 1e-6:
-            cond = [FilterDiscrete(attr, None)]
-            if group_val_index is not None:
-                cond.append(FilterDiscrete(self.group_var, [group_val_index]))
+            cond = DiscDataRange(None, group_val)
             box.append(FilterGraphicsRectItem(cond, 0, -10, 1, 10))
         cum = 0
-        values = attr.values + (f"missing '{attr.name}'", )
-        colors = np.vstack((attr.colors, [128, 128, 128]))
-        for i, v in enumerate(dist):
-            if v < 1e-6:
+        missing_val_str = f"missing '{attr.name}'"
+        values = attr.values + ("",)
+        colors = attr.palette.qcolors_w_nan
+        total = sum(dist)
+        for freq, value, color in zip(dist, values, colors):
+            if freq < 1e-6:
                 continue
+            v = freq
             if self.show_stretched:
                 v /= ss
             v *= self.scale_x
-            cond = [FilterDiscrete(attr, [i])]
-            if group_val_index is not None:
-                cond.append(FilterDiscrete(self.group_var, [group_val_index]))
+            cond = DiscDataRange(value, group_val)
             rect = FilterGraphicsRectItem(cond, cum + 1, -6, v - 2, 12)
-            rect.setBrush(QBrush(QColor(*colors[i])))
+            rect.setBrush(QBrush(color))
             rect.setPen(QPen(Qt.NoPen))
+            value = value or missing_val_str
             if self.show_stretched:
-                tooltip = "{}: {:.2f}%".format(
-                    values[i],
-                    100 * dist[i] / sum(dist))
+                tooltip = f"{value}: {100 * freq / total:.2f}%"
             else:
-                tooltip = "{}: {}".format(values[i], int(dist[i]))
+                tooltip = f"{value}: ({int(freq)})"
             rect.setToolTip(tooltip)
-            text = QGraphicsTextItem(values[i])
+            text = QGraphicsTextItem(value)
             box.append(rect)
             box.append(text)
             cum += v
         return box
 
+    def on_selection_changed(self):
+        self.selection = tuple(item.data_range
+                               for item in self.box_scene.selectedItems()
+                               if item.data_range)
+        self.commit()
+
     def commit(self):
-        self.conditions = [item.filter for item in
-                           self.box_scene.selectedItems() if item.filter]
-        selected, selection = None, []
-        if self.conditions:
-            selected = Values(self.conditions, conjunction=False)(self.dataset)
+        conditions = self._gather_conditions()
+        if conditions:
+            selected = Values(conditions, conjunction=False)(self.dataset)
             selection = np.in1d(
                 self.dataset.ids, selected.ids, assume_unique=True).nonzero()[0]
-
+        else:
+            selected, selection = None, []
         summary = len(selected) if selected else self.info.NoOutput
         details = format_summary_details(selected) if selected else ""
         self.info.set_output_summary(summary, details)
@@ -1127,6 +1155,36 @@ def commit(self):
         self.Outputs.annotated_data.send(
             create_annotated_table(self.dataset, selection))
 
+    def _gather_conditions(self):
+        conditions = []
+        attr = self.attribute
+        group_attr = self.group_var
+        for data_range in self.selection:
+            if attr.is_discrete:
+                # If some value was removed from the data (in case settings are
+                # loaded from a scheme), do not include the corresponding
+                # filter; this is appropriate since data with such value does
+                # not exist anyway
+                if not data_range.value:
+                    condition = IsDefined([attr], negate=True)
+                elif data_range.value not in attr.values:
+                    continue
+                else:
+                    condition = FilterDiscrete(attr, [data_range.value])
+            else:
+                condition = FilterContinuous(attr, FilterContinuous.Between,
+                                             data_range.low, data_range.high)
+            if data_range.group_value:
+                if not data_range.group_value:
+                    grp_filter = IsDefined([group_attr], negate=True)
+                elif data_range.group_value not in group_attr.values:
+                    continue
+                else:
+                    grp_filter = FilterDiscrete(group_attr, [data_range.group_value])
+                condition = Values([condition, grp_filter], conjunction=True)
+            conditions.append(condition)
+        return conditions
+
     def _show_posthoc(self):
         def line(y0, y1):
             it = self.box_scene.addLine(x, y0, x, y1, self._post_line_pen)


Test patch:
diff --git a/Orange/data/tests/test_table.py b/Orange/data/tests/test_table.py
--- a/Orange/data/tests/test_table.py
+++ b/Orange/data/tests/test_table.py
@@ -131,6 +131,22 @@ def test_row_filters_is_defined(self):
         filtered = val_filter(self.table)
         self.assertEqual(list(filtered.metas[:, -2].flatten()), list("abcdef"))
 
+        val_filter = Values([IsDefined()])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("ab"))
+
+        val_filter = Values([IsDefined(negate=True)])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("cdefg"))
+
+        val_filter = Values([IsDefined(["c1"])])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("abdefg"))
+
+        val_filter = Values([IsDefined(["c1"], negate=True)])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("c"))
+
     def test_row_filter_no_discrete(self):
         val_filter = Values([FilterDiscrete(None, "a")])
         self.assertRaises(ValueError, val_filter, self.table)
diff --git a/Orange/widgets/visualize/tests/test_owboxplot.py b/Orange/widgets/visualize/tests/test_owboxplot.py
--- a/Orange/widgets/visualize/tests/test_owboxplot.py
+++ b/Orange/widgets/visualize/tests/test_owboxplot.py
@@ -46,7 +46,7 @@ def test_input_data(self):
         self.assertEqual(len(self.widget.attrs), 0)
         self.assertEqual(len(self.widget.group_vars), 1)
         self.assertTrue(self.widget.display_box.isHidden())
-        self.assertFalse(self.widget.stretching_box.isHidden())
+        self.assertTrue(self.widget.stretching_box.isHidden())
 
     def test_dont_show_hidden_attrs(self):
         """Check widget's data"""
@@ -87,8 +87,8 @@ def test_input_data_missings_disc_group_var(self):
         data.domain.attributes[1]._values = []
         self.send_signal("Data", data)
         self.widget.controls.order_by_importance.setChecked(True)
-        self._select_list_items(self.widget.controls.attribute)
-        self._select_list_items(self.widget.controls.group_var)
+        self._select_list_items(self.widget.attr_list)
+        self._select_list_items(self.widget.group_list)
 
     def test_input_data_missings_disc_no_group_var(self):
         """Check widget discrete data with missing values and no group variable"""
@@ -99,28 +99,34 @@ def test_input_data_missings_disc_no_group_var(self):
         # pylint: disable=protected-access
         data.domain.attributes[1]._values = []
         self.send_signal("Data", data)
-        self._select_list_items(self.widget.controls.attribute)
-        self._select_list_items(self.widget.controls.group_var)
+        self._select_list_items(self.widget.attr_list)
+        self._select_list_items(self.widget.group_list)
 
     def test_attribute_combinations(self):
         self.send_signal(self.widget.Inputs.data, self.heart)
-        group_list = self.widget.controls.group_var
+        group_list = self.widget.group_list
         m = group_list.selectionModel()
-        for i in range(len(group_list.model())):
-            m.setCurrentIndex(group_list.model().index(i), m.ClearAndSelect)
-            self._select_list_items(self.widget.controls.attribute)
+        for i in range(len(group_list.model().sourceModel())):
+            m.setCurrentIndex(group_list.model().index(i, 0), m.ClearAndSelect)
+            self._select_list_items(self.widget.attr_list)
+
+    @staticmethod
+    def model_order(model):
+        return [model.index(i, 0).data() for i in range(model.rowCount())]
 
     def test_apply_sorting_group(self):
-        controls = self.widget.controls
-        group_list = controls.group_var
-        order_check = controls.order_by_importance
-        attributes = self.widget.attrs
+        w = self.widget
+        order_check = w.controls.order_by_importance
+        model = w.attr_list.model()
+
 
         def select_group(i):
-            group_selection = group_list.selectionModel()
-            group_selection.setCurrentIndex(
-                group_list.model().index(i),
+            group_selection = w.group_list.selectionModel()
+            group_selection.select(
+                w.group_list.model().index(i, 0),
                 group_selection.ClearAndSelect)
+            w.grouping_changed(group_selection.selection())
+
 
         data = self.titanic
         self.send_signal("Data", data)
@@ -128,20 +134,23 @@ def select_group(i):
         select_group(2)  # First attribute
 
         order_check.setChecked(False)
-        self.assertEqual(tuple(attributes),
-                         data.domain.class_vars + data.domain.attributes)
+        self.assertEqual(
+            self.model_order(model),
+            [var.name
+             for var in data.domain.class_vars + data.domain.attributes])
         order_check.setChecked(True)
-        self.assertEqual([x.name for x in attributes],
+        self.assertEqual(self.model_order(model),
                          ['sex', 'survived', 'age', 'status'])
         select_group(1)  # Class
-        self.assertEqual([x.name for x in attributes],
+        self.widget.apply_attr_sorting()
+        self.assertEqual(self.model_order(model),
                          ['sex', 'status', 'age', 'survived'])
 
         data = self.heart
         self.send_signal("Data", data)
         select_group(1)  # Class
         order_check.setChecked(True)
-        self.assertEqual([x.name for x in attributes],
+        self.assertEqual(self.model_order(model),
                          ['thal',
                           'chest pain',
                           'major vessels colored',
@@ -162,9 +171,10 @@ def test_apply_sorting_vars(self):
         attr_list = self.widget.attrs
         order_check = controls.order_grouping_by_importance
         groups = self.widget.group_vars
+        model = self.widget.group_list.model()
 
         def select_attr(i):
-            attr_selection = controls.attribute.selectionModel()
+            attr_selection = self.widget.attr_list.selectionModel()
             attr_selection.setCurrentIndex(
                 attr_list.index(i),
                 attr_selection.ClearAndSelect)
@@ -176,23 +186,26 @@ def select_attr(i):
 
         order_check.setChecked(False)
         self.assertEqual(
-            tuple(groups),
-            (None, ) + data.domain.class_vars + data.domain.attributes)
+            self.model_order(model),
+            ["None"] +
+            [var.name
+             for var in data.domain.class_vars + data.domain.attributes])
         order_check.setChecked(True)
         self.assertIsNone(groups[0])
-        self.assertEqual([x.name for x in groups[1:]],
-                         ['sex', 'survived', 'age', 'status'])
+        self.assertEqual(self.model_order(model),
+                         ['None', 'sex', 'survived', 'age', 'status'])
         select_attr(0)  # Class
         self.assertIsNone(groups[0])
-        self.assertEqual([x.name for x in groups[1:]],
-                         ['sex', 'status', 'age', 'survived'])
+        self.assertEqual(self.model_order(model),
+                         ['None', 'sex', 'status', 'age', 'survived'])
 
         data = self.heart
         self.send_signal("Data", data)
         select_attr(0)  # Class
         self.assertIsNone(groups[0])
-        self.assertEqual([x.name for x in groups[1:]],
-                         ['thal',
+        self.assertEqual(self.model_order(model),
+                         ['None',
+                          'thal',
                           'chest pain',
                           'exerc ind ang',
                           'slope peak exc ST',
@@ -268,26 +281,25 @@ def _select_data(self):
                 139, 140, 141, 143, 144, 145, 146, 147, 148]
 
     def _select_list_items(self, _list):
-        model = _list.selectionModel()
-        for i in range(len(_list.model())):
-            model.setCurrentIndex(_list.model().index(i), model.ClearAndSelect)
+        for name in _list.model().sourceModel():
+            self.__select_value(_list, name)
 
     def __select_variable(self, name, widget=None):
         if widget is None:
             widget = self.widget
 
-        self.__select_value(widget.controls.attribute, name)
+        self.__select_value(widget.attr_list, name)
 
     def __select_group(self, name, widget=None):
         if widget is None:
             widget = self.widget
 
-        self.__select_value(widget.controls.group_var, name)
+        self.__select_value(widget.group_list, name)
 
     def __select_value(self, list, value):
         m = list.model()
         for i in range(m.rowCount()):
-            idx = m.index(i)
+            idx = m.index(i, 0)
             if m.data(idx) == value:
                 list.selectionModel().setCurrentIndex(
                     idx, QItemSelectionModel.ClearAndSelect)