1'''
2
3.. index:: plot
4
5######################################
6GUI elements for plots (``owplotgui``)
7######################################
8
9This module contains functions and classes for creating GUI elements commonly used for plots.
10
11.. autoclass:: OrientedWidget
12    :show-inheritance:
13
14.. autoclass:: StateButtonContainer
15    :show-inheritance:
16
17.. autoclass:: OWToolbar
18    :show-inheritance:
19
20.. autoclass:: OWButton
21    :show-inheritance:
22
23.. autoclass:: OrangeWidgets.plot.OWPlotGUI
24    :members:
25
26'''
27
28import os
29
30from AnyQt.QtWidgets import (
31    QWidget, QToolButton, QVBoxLayout, QHBoxLayout, QGridLayout, QMenu,
32    QAction, QSizePolicy, QLabel, QStyledItemDelegate, QStyle, QListView
33)
34from AnyQt.QtGui import QIcon, QColor, QFont
35from AnyQt.QtCore import Qt, pyqtSignal, QSize, QRect, QPoint, QMimeData
36
37from Orange.data import ContinuousVariable, DiscreteVariable
38from Orange.widgets import gui
39from Orange.widgets.gui import OrangeUserRole
40from Orange.widgets.utils.listfilter import variables_filter
41from Orange.widgets.utils.itemmodels import DomainModel, VariableListModel
42
43from .owconstants import (
44    NOTHING, ZOOMING, SELECT, SELECT_POLYGON, PANNING, SELECTION_ADD,\
45    SELECTION_REMOVE, SELECTION_TOGGLE, SELECTION_REPLACE)
46
47__all__ = ["variables_selection",
48           "OrientedWidget", "OWToolbar", "StateButtonContainer",
49           "OWAction", "OWButton", "OWPlotGUI"]
50
51
52SIZE_POLICY_ADAPTING = (QSizePolicy.Expanding, QSizePolicy.Ignored)
53SIZE_POLICY_FIXED = (QSizePolicy.Minimum, QSizePolicy.Maximum)
54
55
56class VariableSelectionModel(VariableListModel):
57    IsSelected = next(OrangeUserRole)
58    SortRole = next(OrangeUserRole)
59    selection_changed = pyqtSignal()
60
61    def __init__(self, selected_vars, max_vars=None):
62        super().__init__(enable_dnd=True)
63        self.selected_vars = selected_vars
64        self.max_vars = max_vars
65
66    def is_selected(self, index):
67        return self[index.row()] in self.selected_vars
68
69    def is_full(self):
70        if self.max_vars is None:
71            return False
72        else:
73            return len(self.selected_vars) >= self.max_vars
74
75    def data(self, index, role):
76        if role == self.IsSelected:
77            return self.is_selected(index)
78        elif role == Qt.FontRole:
79            font = QFont()
80            font.setBold(self.is_selected(index))
81            return font
82        elif role == self.SortRole:
83            if self.is_selected(index):
84                return self.selected_vars.index(self[index.row()])
85            else:
86                return len(self.selected_vars) + index.row()
87        else:
88            return super().data(index, role)
89
90    def toggle_item(self, index):
91        var = self[index.row()]
92        if var in self.selected_vars:
93            self.selected_vars.remove(var)
94        elif not self.is_full():
95            self.selected_vars.append(var)
96        self.selection_changed.emit()
97
98    def mimeData(self, indexlist):
99        if len(indexlist) != 1:
100            return None
101        mime = QMimeData()
102        mime.setData(self.MIME_TYPE, b'see properties: item_index')
103        mime.setProperty('item_index', indexlist[0])
104        return mime
105
106    def dropMimeData(self, mime, action, row, column, parent):
107        if action == Qt.IgnoreAction:
108            return True  # pragma: no cover
109        if not mime.hasFormat(self.MIME_TYPE):
110            return False  # pragma: no cover
111        prev_index = mime.property('item_index')
112        if prev_index is None:
113            return False
114        var = self[prev_index.row()]
115        if self.is_selected(prev_index):
116            self.selected_vars.remove(var)
117        if row < len(self) and self.is_selected(self.index(row)):
118            postpos = self.selected_vars.index(self[row])
119            self.selected_vars.insert(postpos, var)
120        elif row == 0 or self.is_selected(self.index(row - 1)):
121            self.selected_vars.append(var)
122        self.selection_changed.emit()
123        return True
124
125    # The following methods are disabled to prevent their use by dragging
126    def removeRows(self, *_):
127        return False
128
129    def moveRows(self, *_):
130        return False
131
132    def insertRows(self, *_):
133        return False
134
135
136class VariablesDelegate(QStyledItemDelegate):
137    def paint(self, painter, option, index):
138        rect = QRect(option.rect)
139
140        is_selected = index.data(VariableSelectionModel.IsSelected)
141        full_selection = index.model().sourceModel().is_full()
142        if option.state & QStyle.State_MouseOver:
143            if not full_selection or (full_selection and is_selected):
144                txt = [" Add ", " Remove "][is_selected]
145                txtw = painter.fontMetrics().horizontalAdvance(txt)
146                painter.save()
147                painter.setPen(Qt.NoPen)
148                painter.setBrush(QColor("#ccc"))
149                brect = QRect(rect.x() + rect.width() - 8 - txtw, rect.y(),
150                              txtw, rect.height())
151                painter.drawRoundedRect(brect, 4, 4)
152                painter.restore()
153                painter.drawText(brect, Qt.AlignCenter, txt)
154
155        painter.save()
156        double_pen = painter.pen()
157        double_pen.setWidth(2 * double_pen.width())
158        if is_selected:
159            next = index.sibling(index.row() + 1, index.column())
160            if not next.isValid():
161                painter.setPen(double_pen)
162                painter.drawLine(rect.bottomLeft(), rect.bottomRight())
163            elif not next.data(VariableSelectionModel.IsSelected):
164                painter.drawLine(rect.bottomLeft(), rect.bottomRight())
165        elif not index.row():
166            down = QPoint(0, painter.pen().width())
167            painter.setPen(double_pen)
168            painter.drawLine(rect.topLeft() + down, rect.topRight() + down)
169        else:
170            prev = index.sibling(index.row() - 1, index.column())
171            if prev.data(VariableSelectionModel.IsSelected):
172                painter.drawLine(rect.topLeft(), rect.topRight())
173        painter.restore()
174
175        super().paint(painter, option, index)
176
177
178class VariableSelectionView(QListView):
179    def __init__(self, *args, acceptedType=None, **kwargs):
180        super().__init__(*args, **kwargs)
181        self.setSizePolicy(
182            QSizePolicy(QSizePolicy.Minimum, QSizePolicy.MinimumExpanding))
183        self.setMinimumHeight(10)
184        self.setMouseTracking(True)
185        self.setAttribute(Qt.WA_Hover)
186
187        self.setSelectionMode(self.SingleSelection)
188        self.setAutoScroll(False)  # Prevent scrolling to removed item
189        self.setDragEnabled(True)
190        self.setDropIndicatorShown(True)
191        self.setDragDropMode(self.InternalMove)
192        self.setDefaultDropAction(Qt.MoveAction)
193        self.setDragDropOverwriteMode(False)
194        self.setUniformItemSizes(True)
195
196        self.setItemDelegate(VariablesDelegate())
197        self.setMinimumHeight(50)
198
199    def sizeHint(self):
200        return QSize(1, 150)
201
202    def mouseMoveEvent(self, e):
203        super().mouseMoveEvent(e)
204        self.update()
205
206    def leaveEvent(self, e):
207        super().leaveEvent(e)
208        self.update()  # BUG: This update has no effect, at least not on macOs
209
210    def startDrag(self, supportedActions):
211        super().startDrag(supportedActions)
212        self.selectionModel().clearSelection()
213
214
215def variables_selection(widget, master, model):
216    def update_list():
217        proxy.sort(0)
218        proxy.invalidate()
219        view.selectionModel().clearSelection()
220
221    filter_edit, view = variables_filter(
222        model=model, parent=master, view_type=VariableSelectionView)
223    proxy = view.model()
224    proxy.setSortRole(model.SortRole)
225    model.selection_changed.connect(update_list)
226    model.dataChanged.connect(update_list)
227    model.modelReset.connect(update_list)
228    model.rowsInserted.connect(update_list)
229    view.clicked.connect(
230        lambda index: model.toggle_item(proxy.mapToSource(index)))
231    master.contextOpened.connect(update_list)
232    widget.layout().addWidget(filter_edit)
233    widget.layout().addSpacing(4)
234    widget.layout().addWidget(view)
235
236
237class OrientedWidget(QWidget):
238    '''
239        A simple QWidget with a box layout that matches its ``orientation``.
240    '''
241    def __init__(self, orientation, parent):
242        QWidget.__init__(self, parent)
243        if orientation == Qt.Vertical:
244            self._layout = QVBoxLayout()
245        else:
246            self._layout = QHBoxLayout()
247        self.setLayout(self._layout)
248
249class OWToolbar(OrientedWidget):
250    '''
251        A toolbar is a container that can contain any number of buttons.
252
253        :param gui: Used to create containers and buttons
254        :type gui: :obj:`.OWPlotGUI`
255
256        :param text: The name of this toolbar
257        :type text: str
258
259        :param orientation: The orientation of this toolbar, either Qt.Vertical or Qt.Horizontal
260        :type tex: int
261
262        :param buttons: A list of button identifiers to be added to this toolbar
263        :type buttons: list of (int or tuple)
264
265        :param parent: The toolbar's parent widget
266        :type parent: :obj:`.QWidget`
267    '''
268    def __init__(self, gui, text, orientation, buttons, parent, nomargin=False):
269        OrientedWidget.__init__(self, orientation, parent)
270        self.buttons = {}
271        self.groups = {}
272        i = 0
273        n = len(buttons)
274        while i < n:
275            if buttons[i] == gui.StateButtonsBegin:
276                state_buttons = []
277                for j in range(i+1, n):
278                    if buttons[j] == gui.StateButtonsEnd:
279                        s = gui.state_buttons(orientation, state_buttons, self, nomargin)
280                        self.buttons.update(s.buttons)
281                        self.groups[buttons[i+1]] = s
282                        i = j
283                        self.layout().addStretch()
284                        break
285                    else:
286                        state_buttons.append(buttons[j])
287            elif buttons[i] == gui.Spacing:
288                self.layout().addSpacing(10)
289            elif type(buttons[i] == int):
290                self.buttons[buttons[i]] = gui.tool_button(buttons[i], self)
291            elif len(buttons[i] == 4):
292                gui.tool_button(buttons[i], self)
293            else:
294                self.buttons[buttons[i][0]] = gui.tool_button(buttons[i], self)
295            i = i + 1
296
297    def select_state(self, state):
298        # SELECT_RECTANGLE = SELECT
299        # SELECT_RIGHTCLICK = SELECT
300        state_buttons = {NOTHING: 11, ZOOMING: 11, SELECT: 13, SELECT_POLYGON: 13, PANNING: 12}
301        self.buttons[state_buttons[state]].click()
302
303    def select_selection_behaviour(self, selection_behaviour):
304        # SelectionAdd = 21
305        # SelectionRemove = 22
306        # SelectionToggle = 23
307        # SelectionOne = 24
308        self.buttons[13]._actions[21 + selection_behaviour].trigger()
309
310
311class StateButtonContainer(OrientedWidget):
312    '''
313        This class can contain any number of checkable buttons, of which only one can be selected
314        at any time.
315
316        :param gui: Used to create containers and buttons
317        :type gui: :obj:`.OWPlotGUI`
318
319        :param buttons: A list of button identifiers to be added to this toolbar
320        :type buttons: list of (int or tuple)
321
322        :param orientation: The orientation of this toolbar, either Qt.Vertical or Qt.Horizontal
323        :type tex: int
324
325        :param parent: The toolbar's parent widget
326        :type parent: :obj:`.QWidget`
327    '''
328    def __init__(self, gui, orientation, buttons, parent, nomargin=False):
329        OrientedWidget.__init__(self, orientation, parent)
330        self.buttons = {}
331        if nomargin:
332            self.layout().setContentsMargins(0, 0, 0, 0)
333        self._clicked_button = None
334        for i in buttons:
335            b = gui.tool_button(i, self)
336            b.triggered.connect(self.button_clicked)
337            self.buttons[i] = b
338            self.layout().addWidget(b)
339
340    def button_clicked(self, checked):
341        sender = self.sender()
342        self._clicked_button = sender
343        for button in self.buttons.values():
344            button.setDown(button is sender)
345
346    def button(self, id):
347        return self.buttons[id]
348
349    def setEnabled(self, enabled):
350        OrientedWidget.setEnabled(self, enabled)
351        if enabled and self._clicked_button:
352            self._clicked_button.click()
353
354class OWAction(QAction):
355    '''
356      A :obj:`QAction` with convenience methods for calling a callback or
357      setting an attribute of the plot.
358    '''
359    def __init__(self, plot, icon_name=None, attr_name='', attr_value=None, callback=None,
360                 parent=None):
361        QAction.__init__(self, parent)
362
363        if type(callback) == str:
364            callback = getattr(plot, callback, None)
365        if callback:
366            self.triggered.connect(callback)
367        if attr_name:
368            self._plot = plot
369            self.attr_name = attr_name
370            self.attr_value = attr_value
371            self.triggered.connect(self.set_attribute)
372        if icon_name:
373            self.setIcon(
374                QIcon(os.path.join(os.path.dirname(__file__),
375                                   "../../icons", icon_name + '.png')))
376            self.setIconVisibleInMenu(True)
377
378    def set_attribute(self, clicked):
379        setattr(self._plot, self.attr_name, self.attr_value)
380
381
382class OWButton(QToolButton):
383    '''
384        A custom tool button which signal when its down state changes
385    '''
386    downChanged = pyqtSignal(bool)
387
388    def __init__(self, action=None, parent=None):
389        QToolButton.__init__(self, parent)
390        self.setMinimumSize(30, 30)
391        if action:
392            self.setDefaultAction(action)
393
394    def setDown(self, down):
395        if self.isDown() != down:
396            self.downChanged[bool].emit(down)
397        QToolButton.setDown(self, down)
398
399
400class OWPlotGUI:
401    '''
402        This class contains functions to create common user interface elements (QWidgets)
403        for configuration and interaction with the ``plot``.
404
405        It provides shorter versions of some methods in :obj:`.gui` that are directly related to an
406        :obj:`.OWPlot` object.
407
408        Normally, you don't have to construct this class manually. Instead, first create the plot,
409        then use the :attr:`.OWPlot.gui` attribute.
410
411        Most methods in this class have similar arguments, so they are explaned here in a single
412        place.
413
414        :param widget: The parent widget which will contain the newly created widget.
415        :type widget: QWidget
416
417        :param id: If ``id`` is an ``int``, a button is constructed from the default table.
418                   Otherwise, ``id`` must be tuple with 5 or 6 elements. These elements
419                   are explained in the next table.
420        :type id: int or tuple
421
422        :param ids: A list of widget identifiers
423        :type ids: list of id
424
425        :param text: The text displayed on the widget
426        :type text: str
427
428        When using widgets that are specific to your visualization and not included here, you have
429        to provide your
430        own widgets id's. They are a tuple with the following members:
431
432        :param id: An optional unique identifier for the widget.
433                   This is only needed if you want to retrive this widget using
434                   :obj:`.OWToolbar.buttons`.
435        :type id: int or str
436
437        :param text: The text to be displayed on or next to the widget
438        :type text: str
439
440        :param attr_name: Name of attribute which will be set when the button is clicked.
441                          If this widget is checkable, its check state will be set
442                          according to the current value of this attribute.
443                          If this parameter is empty or None, no attribute will be read or set.
444        :type attr_name: str
445
446        :param attr_value: The value that will be assigned to the ``attr_name`` when the button is
447        clicked.
448        :type attr: any
449
450        :param callback: Function to be called when the button is clicked.
451                         If a string is passed as ``callback``, a method by that name of ``plot``
452                         will be called.
453                         If this parameter is empty or ``None``, no function will be called
454        :type callback: str or function
455
456        :param icon_name: The filename of the icon for this widget, without the '.png' suffix.
457        :type icon_name: str
458
459    '''
460
461    JITTER_SIZES = [0, 0.1, 0.5, 1, 2, 3, 4, 5, 7, 10]
462
463    def __init__(self, master):
464        self._master = master
465        self._plot = master.graph
466        self.color_model = DomainModel(
467            placeholder="(Same color)", valid_types=DomainModel.PRIMITIVE)
468        self.shape_model = DomainModel(
469            placeholder="(Same shape)", valid_types=DiscreteVariable)
470        self.size_model = DomainModel(
471            placeholder="(Same size)", valid_types=ContinuousVariable)
472        self.label_model = DomainModel(placeholder="(No labels)")
473        self.points_models = [self.color_model, self.shape_model,
474                              self.size_model, self.label_model]
475
476    Spacing = 0
477
478    ShowLegend = 2
479    ShowFilledSymbols = 3
480    ShowGridLines = 4
481    PointSize = 5
482    AlphaValue = 6
483    Color = 7
484    Shape = 8
485    Size = 9
486    Label = 10
487
488    Zoom = 11
489    Pan = 12
490    Select = 13
491
492    ZoomSelection = 15
493    ZoomReset = 16
494
495    ToolTipShowsAll = 17
496    ClassDensity = 18
497    RegressionLine = 19
498    LabelOnlySelected = 20
499
500    SelectionAdd = 21
501    SelectionRemove = 22
502    SelectionToggle = 23
503    SelectionOne = 24
504    SimpleSelect = 25
505
506    SendSelection = 31
507    ClearSelection = 32
508    ShufflePoints = 33
509
510    StateButtonsBegin = 35
511    StateButtonsEnd = 36
512
513    AnimatePlot = 41
514    AnimatePoints = 42
515    AntialiasPlot = 43
516    AntialiasPoints = 44
517    AntialiasLines = 45
518    DisableAnimationsThreshold = 48
519    AutoAdjustPerformance = 49
520
521    JitterSizeSlider = 51
522    JitterNumericValues = 52
523
524    UserButton = 100
525
526    default_zoom_select_buttons = [
527        StateButtonsBegin,
528        Zoom,
529        Pan,
530        Select,
531        StateButtonsEnd,
532        Spacing,
533        SendSelection,
534        ClearSelection
535    ]
536
537    _buttons = {
538        Zoom: ('Zoom', 'state', ZOOMING, None, 'Dlg_zoom'),
539        ZoomReset: ('Reset zoom', None, None, None, 'Dlg_zoom_reset'),
540        Pan: ('Pan', 'state', PANNING, None, 'Dlg_pan_hand'),
541        SimpleSelect: ('Select', 'state', SELECT, None, 'Dlg_arrow'),
542        Select: ('Select', 'state', SELECT, None, 'Dlg_arrow'),
543        SelectionAdd: ('Add to selection', 'selection_behavior', SELECTION_ADD, None,
544                       'Dlg_select_add'),
545        SelectionRemove: ('Remove from selection', 'selection_behavior', SELECTION_REMOVE, None,
546                          'Dlg_select_remove'),
547        SelectionToggle: ('Toggle selection', 'selection_behavior', SELECTION_TOGGLE, None,
548                          'Dlg_select_toggle'),
549        SelectionOne: ('Replace selection', 'selection_behavior', SELECTION_REPLACE, None,
550                       'Dlg_arrow'),
551        SendSelection: ('Send selection', None, None, 'send_selection', 'Dlg_send'),
552        ClearSelection: ('Clear selection', None, None, 'clear_selection', 'Dlg_clear'),
553        ShufflePoints: ('ShufflePoints', None, None, 'shuffle_points', 'Dlg_sort')
554    }
555
556    _check_boxes = {
557        AnimatePlot : ('Animate plot', 'animate_plot', 'update_animations'),
558        AnimatePoints : ('Animate points', 'animate_points', 'update_animations'),
559        AntialiasPlot : ('Antialias plot', 'antialias_plot', 'update_antialiasing'),
560        AntialiasPoints : ('Antialias points', 'antialias_points', 'update_antialiasing'),
561        AntialiasLines : ('Antialias lines', 'antialias_lines', 'update_antialiasing'),
562        AutoAdjustPerformance : ('Disable effects for large datasets', 'auto_adjust_performance',
563                                 'update_performance')
564    }
565
566    '''
567        The list of built-in buttons. It is a map of
568        id : (name, attr_name, attr_value, callback, icon_name)
569
570        .. seealso:: :meth:`.tool_button`
571    '''
572
573    def _get_callback(self, name, master=None):
574        if type(name) == str:
575            return getattr(master or self._plot, name)
576        else:
577            return name
578
579    def _check_box(self, widget, value, label, cb_name, stateWhenDisabled=None):
580        '''
581            Adds a :obj:`.QCheckBox` to ``widget``.
582            When the checkbox is toggled, the attribute ``value`` of the plot object is set to
583            the checkbox' check state, and the callback ``cb_name`` is called.
584        '''
585        args = dict(master=self._plot, value=value, label=label,
586                    callback=self._get_callback(cb_name, self._plot),
587                    stateWhenDisabled=stateWhenDisabled)
588        if isinstance(widget.layout(), QGridLayout):
589            widget = widget.layout()
590        if isinstance(widget, QGridLayout):
591            checkbox = gui.checkBox(None, **args)
592            widget.addWidget(checkbox, widget.rowCount(), 1)
593            return checkbox
594        else:
595            return gui.checkBox(widget, **args)
596
597    def antialiasing_check_box(self, widget):
598        '''
599            Creates a check box that toggles the Antialiasing of the plot
600        '''
601        self._check_box(widget, 'use_antialiasing', 'Use antialiasing', 'update_antialiasing')
602
603    def jitter_size_slider(self, widget, label="Jittering: "):
604        return self.add_control(
605            widget, gui.valueSlider, label,
606            master=self._plot, value='jitter_size',
607            values=getattr(self._plot, "jitter_sizes", self.JITTER_SIZES),
608            callback=self._plot.update_jittering)
609
610    def jitter_numeric_check_box(self, widget):
611        self._check_box(
612            widget=widget,
613            value="jitter_continuous", label="Jitter numeric values",
614            cb_name="update_jittering")
615
616    def show_legend_check_box(self, widget):
617        '''
618            Creates a check box that shows and hides the plot legend
619        '''
620        self._check_box(widget, 'show_legend', 'Show legend',
621                        'update_legend_visibility')
622
623    def tooltip_shows_all_check_box(self, widget):
624        gui.checkBox(
625            widget=widget, master=self._master, value="tooltip_shows_all",
626            label='Show all data on mouse hover')
627
628    def class_density_check_box(self, widget):
629        self._master.cb_class_density = \
630            self._check_box(widget=widget, value="class_density",
631                            label="Show color regions",
632                            cb_name=self._plot.update_density,
633                            stateWhenDisabled=False)
634
635    def regression_line_check_box(self, widget):
636        self._master.cb_reg_line = \
637            self._check_box(widget=widget, value="show_reg_line",
638                            label="Show regression line",
639                            cb_name=self._plot.update_regression_line)
640
641    def label_only_selected_check_box(self, widget):
642        self._check_box(widget=widget, value="label_only_selected",
643                        label="Label only selection and subset",
644                        cb_name=self._plot.update_labels)
645
646    def filled_symbols_check_box(self, widget):
647        self._check_box(widget, 'show_filled_symbols', 'Show filled symbols',
648                        'update_filled_symbols')
649
650    def grid_lines_check_box(self, widget):
651        self._check_box(widget, 'show_grid', 'Show gridlines',
652                        'update_grid_visibility')
653
654    def animations_check_box(self, widget):
655        '''
656            Creates a check box that enabled or disables animations
657        '''
658        self._check_box(widget, 'use_animations', 'Use animations', 'update_animations')
659
660    def add_control(self, widget, control, label, **args):
661        if isinstance(widget.layout(), QGridLayout):
662            widget = widget.layout()
663        if isinstance(widget, QGridLayout):
664            row = widget.rowCount()
665            element = control(None, **args)
666            widget.addWidget(QLabel(label), row, 0)
667            widget.addWidget(element, row, 1)
668            return element
669        else:
670            return control(widget, label=label, **args)
671
672    def _slider(self, widget, value, label, min_value, max_value, step, cb_name,
673                show_number=False):
674        return self.add_control(
675            widget, gui.hSlider, label,
676            master=self._plot, value=value, minValue=min_value,
677            maxValue=max_value, step=step, createLabel=show_number,
678            callback=self._get_callback(cb_name, self._master))
679
680    def point_size_slider(self, widget, label="Symbol size: "):
681        '''
682            Creates a slider that controls point size
683        '''
684        return self._slider(widget, 'point_width', label, 1, 20, 1, 'sizes_changed')
685
686    def alpha_value_slider(self, widget, label="Opacity: "):
687        '''
688            Creates a slider that controls point transparency
689        '''
690        return self._slider(widget, 'alpha_value', label, 0, 255, 10, 'colors_changed')
691
692    def _combo(self, widget, value, label, cb_name, items=(), model=None):
693        return self.add_control(
694            widget, gui.comboBox, label,
695            master=self._master, value=value, items=items, model=model,
696            callback=self._get_callback(cb_name, self._master),
697            orientation=Qt.Horizontal,
698            sendSelectedValue=True, contentsLength=12,
699            labelWidth=50, searchable=True)
700
701    def color_value_combo(self, widget, label="Color: "):
702        """Creates a combo box that controls point color"""
703        self._combo(widget, "attr_color", label, "colors_changed",
704                    model=self.color_model)
705
706    def shape_value_combo(self, widget, label="Shape: "):
707        """Creates a combo box that controls point shape"""
708        self._combo(widget, "attr_shape", label, "shapes_changed",
709                    model=self.shape_model)
710
711    def size_value_combo(self, widget, label="Size: "):
712        """Creates a combo box that controls point size"""
713        self._combo(widget, "attr_size", label, "sizes_changed",
714                    model=self.size_model)
715
716    def label_value_combo(self, widget, label="Label: "):
717        """Creates a combo box that controls point label"""
718        self._combo(widget, "attr_label", label, "labels_changed",
719                    model=self.label_model)
720
721    def box_spacing(self, widget):
722        if isinstance(widget.layout(), QGridLayout):
723            widget = widget.layout()
724        if isinstance(widget, QGridLayout):
725            space = QWidget()
726            space.setFixedSize(12, 12)
727            widget.addWidget(space, widget.rowCount(), 0)
728        else:
729            gui.separator(widget)
730
731    def point_properties_box(self, widget, box='Attributes'):
732        '''
733            Creates a box with controls for common point properties.
734            Currently, these properties are point size and transparency.
735        '''
736        box = self.create_gridbox(widget, box)
737        self.add_widgets([
738            self.Color,
739            self.Shape,
740            self.Size,
741            self.Label,
742            self.LabelOnlySelected], box)
743        return box
744
745    def effects_box(self, widget, box=False):
746        """
747        Create a box with controls for common plot settings
748        """
749        box = self.create_gridbox(widget, box)
750        self.add_widgets([
751            self.PointSize,
752            self.AlphaValue,
753            self.JitterSizeSlider], box)
754        return box
755
756    def plot_properties_box(self, widget, box=None):
757        """
758        Create a box with controls for common plot settings
759        """
760        return self.create_box([
761            self.ClassDensity,
762            self.ShowLegend], widget, box, False)
763
764    _functions = {
765        ShowFilledSymbols: filled_symbols_check_box,
766        JitterSizeSlider: jitter_size_slider,
767        JitterNumericValues: jitter_numeric_check_box,
768        ShowLegend: show_legend_check_box,
769        ShowGridLines: grid_lines_check_box,
770        ToolTipShowsAll: tooltip_shows_all_check_box,
771        ClassDensity: class_density_check_box,
772        RegressionLine: regression_line_check_box,
773        LabelOnlySelected: label_only_selected_check_box,
774        PointSize: point_size_slider,
775        AlphaValue: alpha_value_slider,
776        Color: color_value_combo,
777        Shape: shape_value_combo,
778        Size: size_value_combo,
779        Label: label_value_combo,
780        Spacing: box_spacing
781        }
782
783    def add_widget(self, id, widget):
784        if id in self._functions:
785            self._functions[id](self, widget)
786        elif id in self._check_boxes:
787            label, attr, cb = self._check_boxes[id]
788            self._check_box(widget, attr, label, cb)
789
790    def add_widgets(self, ids, widget):
791        for id in ids:
792            self.add_widget(id, widget)
793
794    def create_box(self, ids, widget, box, name):
795        '''
796            Creates a :obj:`.QGroupBox` with text ``name`` and adds it to ``widget``.
797            The ``ids`` argument is a list of widget ID's that will be added to this box
798        '''
799        if box is None:
800            kwargs = {}
801            box = gui.vBox(widget, name, margin=True,
802                           contentsMargins=(8, 4, 8, 4))
803        self.add_widgets(ids, box)
804        return box
805
806    def create_gridbox(self, widget, box=True):
807        grid = QGridLayout()
808        grid.setColumnMinimumWidth(0, 50)
809        grid.setColumnStretch(1, 1)
810        b = gui.widgetBox(widget, box=box, orientation=grid)
811        if not box:
812            b.setContentsMargins(8, 4, 8, 4)
813        # This must come after calling widgetBox, since widgetBox overrides it
814        grid.setVerticalSpacing(8)
815        return b
816
817    def _expand_id(self, id):
818        if type(id) == int:
819            name, attr_name, attr_value, callback, icon_name = self._buttons[id]
820        elif len(id) == 4:
821            name, attr_name, attr_value, callback, icon_name = id
822            id = -1
823        else:
824            id, name, attr_name, attr_value, callback, icon_name = id
825        return id, name, attr_name, attr_value, callback, icon_name
826
827    def tool_button(self, id, widget):
828        '''
829            Creates an :obj:`.OWButton` and adds it to the parent ``widget``.
830        '''
831        id, name, attr_name, attr_value, callback, icon_name = self._expand_id(id)
832        if id == OWPlotGUI.Select:
833            b = self.menu_button(self.Select,
834                                 [self.SelectionOne, self.SelectionAdd,
835                                  self.SelectionRemove, self.SelectionToggle],
836                                 widget)
837        else:
838            b = OWButton(parent=widget)
839            ac = OWAction(self._plot, icon_name, attr_name, attr_value, callback, parent=b)
840            b.setDefaultAction(ac)
841        b.setToolTip(name)
842        if widget.layout() is not None:
843            widget.layout().addWidget(b)
844        return b
845
846    def menu_button(self, main_action_id, ids, widget):
847        '''
848            Creates an :obj:`.OWButton` with a popup-menu and adds it to the parent ``widget``.
849        '''
850        id, _, attr_name, attr_value, callback, icon_name = self._expand_id(main_action_id)
851        b = OWButton(parent=widget)
852        m = QMenu(b)
853        b.setMenu(m)
854        b._actions = {}
855
856        m.triggered[QAction].connect(b.setDefaultAction)
857
858        if main_action_id:
859            main_action = OWAction(self._plot, icon_name, attr_name, attr_value, callback,
860                                   parent=b)
861            m.triggered.connect(main_action.trigger)
862
863        for id in ids:
864            id, _, attr_name, attr_value, callback, icon_name = self._expand_id(id)
865            a = OWAction(self._plot, icon_name, attr_name, attr_value, callback, parent=m)
866            m.addAction(a)
867            b._actions[id] = a
868
869        if m.actions():
870            b.setDefaultAction(m.actions()[0])
871        elif main_action_id:
872            b.setDefaultAction(main_action)
873
874
875        b.setPopupMode(QToolButton.MenuButtonPopup)
876        b.setMinimumSize(40, 30)
877        return b
878
879    def state_buttons(self, orientation, buttons, widget, nomargin=False):
880        '''
881            This function creates a set of checkable buttons and connects them so that only one
882            may be checked at a time.
883        '''
884        c = StateButtonContainer(self, orientation, buttons, widget, nomargin)
885        if widget.layout() is not None:
886            widget.layout().addWidget(c)
887        return c
888
889    def toolbar(self, widget, text, orientation, buttons, nomargin=False):
890        '''
891            Creates an :obj:`.OWToolbar` with the specified ``text``, ``orientation``
892            and ``buttons`` and adds it to ``widget``.
893
894            .. seealso:: :obj:`.OWToolbar`
895        '''
896        t = OWToolbar(self, text, orientation, buttons, widget, nomargin)
897        if nomargin:
898            t.layout().setContentsMargins(0, 0, 0, 0)
899        if widget.layout() is not None:
900            widget.layout().addWidget(t)
901        return t
902
903    def zoom_select_toolbar(self, widget, text='Zoom / Select', orientation=Qt.Horizontal,
904                            buttons=default_zoom_select_buttons, nomargin=False):
905        t = self.toolbar(widget, text, orientation, buttons, nomargin)
906        t.buttons[self.SimpleSelect].click()
907        return t
908
909    def theme_combo_box(self, widget):
910        c = gui.comboBox(widget, self._plot, "theme_name", "Theme",
911                         callback=self._plot.update_theme, sendSelectedValue=1)
912        c.addItem('Default')
913        c.addItem('Light')
914        c.addItem('Dark')
915        return c
916
917    def box_zoom_select(self, parent):
918        box_zoom_select = gui.vBox(parent, "Zoom/Select")
919        zoom_select_toolbar = self.zoom_select_toolbar(
920            box_zoom_select, nomargin=True,
921            buttons=[self.StateButtonsBegin,
922                     self.SimpleSelect, self.Pan, self.Zoom,
923                     self.StateButtonsEnd,
924                     self.ZoomReset]
925        )
926        buttons = zoom_select_toolbar.buttons
927        buttons[self.Zoom].clicked.connect(self._plot.zoom_button_clicked)
928        buttons[self.Pan].clicked.connect(self._plot.pan_button_clicked)
929        buttons[self.SimpleSelect].clicked.connect(self._plot.select_button_clicked)
930        buttons[self.ZoomReset].clicked.connect(self._plot.reset_button_clicked)
931        return box_zoom_select
932