1"""
2Wrappers for controls used in widgets
3"""
4import math
5
6import logging
7import sys
8import warnings
9import weakref
10from collections.abc import Sequence
11
12from AnyQt import QtWidgets, QtCore, QtGui
13from AnyQt.QtCore import Qt, QSize, QItemSelection
14from AnyQt.QtGui import QColor, QWheelEvent
15from AnyQt.QtWidgets import QWidget, QListView, QComboBox
16
17from orangewidget.utils.itemdelegates import (
18    BarItemDataDelegate as _BarItemDataDelegate
19)
20# re-export relevant objects
21from orangewidget.gui import (
22    OWComponent, OrangeUserRole, TableView, resource_filename,
23    miscellanea, setLayout, separator, rubber, widgetBox, hBox, vBox,
24    comboBox as gui_comboBox,
25    indentedBox, widgetLabel, label, spin, doubleSpin, checkBox, lineEdit,
26    button, toolButton, radioButtons, radioButtonsInBox, appendRadioButton,
27    hSlider, labeledSlider, valueSlider, auto_commit, auto_send, auto_apply,
28
29    # ItemDataRole's
30    BarRatioRole, BarBrushRole, SortOrderRole, LinkRole,
31
32    IndicatorItemDelegate, BarItemDelegate, LinkStyledItemDelegate,
33    ColoredBarItemDelegate, HorizontalGridDelegate, VerticalItemDelegate,
34    VerticalLabel, tabWidget, createTabPage, table, tableItem,
35    VisibleHeaderSectionContextEventFilter,
36    checkButtonOffsetHint, toolButtonSizeHint, FloatSlider,
37    CalendarWidgetWithTime, DateTimeEditWCalendarTime,
38    ControlGetter, VerticalScrollArea, ProgressBar,
39    ControlledCallback, ControlledCallFront, ValueCallback, connectControl,
40    is_macstyle
41)
42
43
44try:
45    # Some Orange widgets might expect this here
46    # pylint: disable=unused-import
47    from Orange.widgets.utils.webview import WebviewWidget
48except ImportError:
49    pass  # Neither WebKit nor WebEngine are available
50
51import Orange.data
52from Orange.widgets.utils import getdeepattr
53from Orange.data import \
54    ContinuousVariable, StringVariable, TimeVariable, DiscreteVariable, \
55    Variable, Value
56from Orange.widgets.utils import vartype
57
58__all__ = [
59    # Re-exported
60    "OWComponent", "OrangeUserRole", "TableView", "resource_filename",
61    "miscellanea", "setLayout", "separator", "rubber",
62    "widgetBox", "hBox", "vBox", "indentedBox",
63    "widgetLabel", "label", "spin", "doubleSpin",
64    "checkBox", "lineEdit", "button", "toolButton", "comboBox",
65    "radioButtons", "radioButtonsInBox", "appendRadioButton",
66    "hSlider", "labeledSlider", "valueSlider",
67    "auto_commit", "auto_send", "auto_apply", "ProgressBar",
68    "VerticalLabel", "tabWidget", "createTabPage", "table", "tableItem",
69    "VisibleHeaderSectionContextEventFilter", "checkButtonOffsetHint",
70    "toolButtonSizeHint", "FloatSlider", "ControlGetter",  "VerticalScrollArea",
71    "CalendarWidgetWithTime", "DateTimeEditWCalendarTime",
72    "BarRatioRole", "BarBrushRole", "SortOrderRole", "LinkRole",
73    "BarItemDelegate", "IndicatorItemDelegate", "LinkStyledItemDelegate",
74    "ColoredBarItemDelegate", "HorizontalGridDelegate", "VerticalItemDelegate",
75    "ValueCallback", 'is_macstyle',
76    # Defined here
77    "createAttributePixmap", "attributeIconDict", "attributeItem",
78    "listView", "ListViewWithSizeHint", "listBox", "OrangeListBox",
79    "TableValueRole", "TableClassValueRole", "TableDistribution",
80    "TableVariable", "TableBarItem", "palette_combo_box"
81]
82
83
84log = logging.getLogger(__name__)
85
86
87def palette_combo_box(initial_palette):
88    from Orange.widgets.utils import itemmodels
89    cb = QComboBox()
90    model = itemmodels.ContinuousPalettesModel()
91    cb.setModel(model)
92    cb.setCurrentIndex(model.indexOf(initial_palette))
93    cb.setIconSize(QSize(64, 16))
94    return cb
95
96
97def createAttributePixmap(char, background=Qt.black, color=Qt.white):
98    """
99    Create a QIcon with a given character. The icon is 13 pixels high and wide.
100
101    :param char: The character that is printed in the icon
102    :type char: str
103    :param background: the background color (default: black)
104    :type background: QColor
105    :param color: the character color (default: white)
106    :type color: QColor
107    :rtype: QIcon
108    """
109    icon = QtGui.QIcon()
110    for size in (13, 16, 18, 20, 22, 24, 28, 32, 64):
111        pixmap = QtGui.QPixmap(size, size)
112        pixmap.fill(Qt.transparent)
113        painter = QtGui.QPainter()
114        painter.begin(pixmap)
115        painter.setRenderHints(painter.Antialiasing | painter.TextAntialiasing |
116                               painter.SmoothPixmapTransform)
117        painter.setPen(background)
118        painter.setBrush(background)
119        margin = 1 + size // 16
120        text_margin = size // 20
121        rect = QtCore.QRectF(margin, margin,
122                             size - 2 * margin, size - 2 * margin)
123        painter.drawRoundedRect(rect, 30.0, 30.0, Qt.RelativeSize)
124        painter.setPen(color)
125        font = painter.font()  # type: QtGui.QFont
126        font.setPixelSize(size - 2 * margin - 2 * text_margin)
127        painter.setFont(font)
128        painter.drawText(rect, Qt.AlignCenter, char)
129        painter.end()
130        icon.addPixmap(pixmap)
131    return icon
132
133
134class __AttributeIconDict(dict):
135    def __getitem__(self, key):
136        if not self:
137            for tpe, char, col in ((vartype(ContinuousVariable("c")),
138                                    "N", (202, 0, 32)),
139                                   (vartype(DiscreteVariable("d")),
140                                    "C", (26, 150, 65)),
141                                   (vartype(StringVariable("s")),
142                                    "S", (0, 0, 0)),
143                                   (vartype(TimeVariable("t")),
144                                    "T", (68, 170, 255)),
145                                   (-1, "?", (128, 128, 128))):
146                self[tpe] = createAttributePixmap(char, QtGui.QColor(*col))
147        if key not in self:
148            key = vartype(key) if isinstance(key, Variable) else -1
149        return super().__getitem__(key)
150
151
152#: A dict that returns icons for different attribute types. The dict is
153#: constructed on first use since icons cannot be created before initializing
154#: the application.
155#:
156#: Accepted keys are variable type codes and instances
157#: of :obj:`Orange.data.Variable`: `attributeIconDict[var]` will give the
158#: appropriate icon for variable `var` or a question mark if the type is not
159#: recognized
160attributeIconDict = __AttributeIconDict()
161
162
163def attributeItem(var):
164    """
165    Construct a pair (icon, name) for inserting a variable into a combo or
166    list box
167
168    :param var: variable
169    :type var: Orange.data.Variable
170    :rtype: tuple with QIcon and str
171    """
172    return attributeIconDict[var], var.name
173
174
175class ListViewWithSizeHint(QListView):
176    def __init__(self, *args, preferred_size=None, **kwargs):
177        super().__init__(*args, **kwargs)
178        if isinstance(preferred_size, tuple):
179            preferred_size = QSize(*preferred_size)
180        self.preferred_size = preferred_size
181
182    def sizeHint(self):
183        return self.preferred_size if self.preferred_size is not None \
184            else super().sizeHint()
185
186
187def listView(widget, master, value=None, model=None, box=None, callback=None,
188             sizeHint=None, *, viewType=ListViewWithSizeHint, **misc):
189    if box:
190        bg = vBox(widget, box, addToLayout=False)
191    else:
192        bg = widget
193    view = viewType(preferred_size=sizeHint)
194    view.setModel(model)
195    if value is not None:
196        connectControl(master, value, callback,
197                       view.selectionModel().selectionChanged,
198                       CallFrontListView(view),
199                       CallBackListView(model, view, master, value))
200    misc.setdefault('uniformItemSizes', True)
201    miscellanea(view, bg, widget, **misc)
202    return view
203
204
205def listBox(widget, master, value=None, labels=None, box=None, callback=None,
206            selectionMode=QtWidgets.QListWidget.SingleSelection,
207            enableDragDrop=False, dragDropCallback=None,
208            dataValidityCallback=None, sizeHint=None, **misc):
209    """
210    Insert a list box.
211
212    The value with which the box's value synchronizes (`master.<value>`)
213    is a list of indices of selected items.
214
215    :param widget: the widget into which the box is inserted
216    :type widget: QWidget or None
217    :param master: master widget
218    :type master: OWWidget or OWComponent
219    :param value: the name of the master's attribute with which the value is
220        synchronized (list of ints - indices of selected items)
221    :type value: str
222    :param labels: the name of the master's attribute with the list of items
223        (as strings or tuples with icon and string)
224    :type labels: str
225    :param box: tells whether the widget has a border, and its label
226    :type box: int or str or None
227    :param callback: a function that is called when the selection state is
228        changed
229    :type callback: function
230    :param selectionMode: selection mode - single, multiple etc
231    :type selectionMode: QAbstractItemView.SelectionMode
232    :param enableDragDrop: flag telling whether drag and drop is available
233    :type enableDragDrop: bool
234    :param dragDropCallback: callback function on drop event
235    :type dragDropCallback: function
236    :param dataValidityCallback: function that check the validity on enter
237        and move event; it should return either `ev.accept()` or `ev.ignore()`.
238    :type dataValidityCallback: function
239    :param sizeHint: size hint
240    :type sizeHint: QSize
241    :rtype: OrangeListBox
242    """
243    if box:
244        bg = hBox(widget, box, addToLayout=False)
245    else:
246        bg = widget
247    lb = OrangeListBox(master, enableDragDrop, dragDropCallback,
248                       dataValidityCallback, sizeHint, bg)
249    lb.setSelectionMode(selectionMode)
250    lb.ogValue = value
251    lb.ogLabels = labels
252    lb.ogMaster = master
253
254    if labels is not None:
255        setattr(master, labels, getdeepattr(master, labels))
256        master.connect_control(labels, CallFrontListBoxLabels(lb))
257    if value is not None:
258        clist = getdeepattr(master, value)
259        if not isinstance(clist, (int, ControlledList)):
260            clist = ControlledList(clist, lb)
261            master.__setattr__(value, clist)
262        setattr(master, value, clist)
263        connectControl(master, value, callback, lb.itemSelectionChanged,
264                       CallFrontListBox(lb), CallBackListBox(lb, master))
265
266    miscellanea(lb, bg, widget, **misc)
267    return lb
268
269
270class OrangeListBox(QtWidgets.QListWidget):
271    """
272    List box with drag and drop functionality. Function :obj:`listBox`
273    constructs instances of this class; do not use the class directly.
274
275    .. attribute:: master
276
277        The widget into which the listbox is inserted.
278
279    .. attribute:: ogLabels
280
281        The name of the master's attribute that holds the strings with items
282        in the list box.
283
284    .. attribute:: ogValue
285
286        The name of the master's attribute that holds the indices of selected
287        items.
288
289    .. attribute:: enableDragDrop
290
291        A flag telling whether drag-and-drop is enabled.
292
293    .. attribute:: dragDropCallback
294
295        A callback that is called at the end of drop event.
296
297    .. attribute:: dataValidityCallback
298
299        A callback that is called on dragEnter and dragMove events and returns
300        either `ev.accept()` or `ev.ignore()`.
301
302    .. attribute:: defaultSizeHint
303
304        The size returned by the `sizeHint` method.
305    """
306    def __init__(self, master, enableDragDrop=False, dragDropCallback=None,
307                 dataValidityCallback=None, sizeHint=None, *args):
308        """
309        :param master: the master widget
310        :type master: OWWidget or OWComponent
311        :param enableDragDrop: flag telling whether drag and drop is enabled
312        :type enableDragDrop: bool
313        :param dragDropCallback: callback for the end of drop event
314        :type dragDropCallback: function
315        :param dataValidityCallback: callback that accepts or ignores dragEnter
316            and dragMove events
317        :type dataValidityCallback: function with one argument (event)
318        :param sizeHint: size hint
319        :type sizeHint: QSize
320        :param args: optional arguments for the inherited constructor
321        """
322        self.master = master
323        super().__init__(*args)
324        self.drop_callback = dragDropCallback
325        self.valid_data_callback = dataValidityCallback
326        if not sizeHint:
327            self.size_hint = QtCore.QSize(150, 100)
328        else:
329            self.size_hint = sizeHint
330        if enableDragDrop:
331            self.setDragEnabled(True)
332            self.setAcceptDrops(True)
333            self.setDropIndicatorShown(True)
334
335    def sizeHint(self):
336        return self.size_hint
337
338    def minimumSizeHint(self):
339        return self.size_hint
340
341    def dragEnterEvent(self, event):
342        super().dragEnterEvent(event)
343        if self.valid_data_callback:
344            self.valid_data_callback(event)
345        elif isinstance(event.source(), OrangeListBox):
346            event.setDropAction(Qt.MoveAction)
347            event.accept()
348        else:
349            event.ignore()
350
351    def dropEvent(self, event):
352        event.setDropAction(Qt.MoveAction)
353        super().dropEvent(event)
354
355        items = self.update_master()
356        if event.source() is not self:
357            event.source().update_master(exclude=items)
358
359        if self.drop_callback:
360            self.drop_callback()
361
362    def update_master(self, exclude=()):
363        control_list = [self.item(i).data(Qt.UserRole)
364                        for i in range(self.count())
365                        if self.item(i).data(Qt.UserRole) not in exclude]
366        if self.ogLabels:
367            master_list = getattr(self.master, self.ogLabels)
368
369            if master_list != control_list:
370                setattr(self.master, self.ogLabels, control_list)
371        return control_list
372
373    def updateGeometries(self):
374        # A workaround for a bug in Qt
375        # (see: http://bugreports.qt.nokia.com/browse/QTBUG-14412)
376        if getattr(self, "_updatingGeometriesNow", False):
377            return
378        self._updatingGeometriesNow = True
379        try:
380            return super().updateGeometries()
381        finally:
382            self._updatingGeometriesNow = False
383
384
385class ControlledList(list):
386    """
387    A class derived from a list that is connected to a
388    :obj:`QListBox`: the list contains indices of items that are
389    selected in the list box. Changing the list content changes the
390    selection in the list box.
391    """
392    def __init__(self, content, listBox=None):
393        super().__init__(content if content is not None else [])
394        # Controlled list is created behind the back by gui.listBox and
395        # commonly used as a setting which gets synced into a GLOBAL
396        # SettingsHandler and which keeps the OWWidget instance alive via a
397        # reference in listBox (see gui.listBox)
398        if listBox is not None:
399            self.listBox = weakref.ref(listBox)
400        else:
401            self.listBox = lambda: None
402
403    def __reduce__(self):
404        # cannot pickle self.listBox, but can't discard it
405        # (ControlledList may live on)
406        import copyreg
407        return copyreg._reconstructor, (list, list, ()), None, self.__iter__()
408
409    # TODO ControllgedList.item2name is probably never used
410    def item2name(self, item):
411        item = self.listBox().labels[item]
412        if isinstance(item, tuple):
413            return item[1]
414        else:
415            return item
416
417    def __setitem__(self, index, item):
418        def unselect(i):
419            try:
420                item = self.listBox().item(i)
421            except RuntimeError:  # Underlying C/C++ object has been deleted
422                item = None
423            if item is None:
424                # Labels changed before clearing the selection: clear everything
425                self.listBox().selectionModel().clear()
426            else:
427                item.setSelected(0)
428
429        if isinstance(index, int):
430            unselect(self[index])
431            item.setSelected(1)
432        else:
433            for i in self[index]:
434                unselect(i)
435            for i in item:
436                self.listBox().item(i).setSelected(1)
437        super().__setitem__(index, item)
438
439    def __delitem__(self, index):
440        if isinstance(index, int):
441            self.listBox().item(self[index]).setSelected(0)
442        else:
443            for i in self[index]:
444                self.listBox().item(i).setSelected(0)
445        super().__delitem__(index)
446
447    def append(self, item):
448        super().append(item)
449        item.setSelected(1)
450
451    def extend(self, items):
452        super().extend(items)
453        for i in items:
454            self.listBox().item(i).setSelected(1)
455
456    def insert(self, index, item):
457        item.setSelected(1)
458        super().insert(index, item)
459
460    def pop(self, index=-1):
461        i = super().pop(index)
462        self.listBox().item(i).setSelected(0)
463
464    def remove(self, item):
465        item.setSelected(0)
466        super().remove(item)
467
468
469def comboBox(*args, **kwargs):
470    if "valueType" in kwargs:
471        del kwargs["valueType"]
472        warnings.warn("Argument 'valueType' is deprecated and ignored",
473                      DeprecationWarning)
474    return gui_comboBox(*args, **kwargs)
475
476
477class CallBackListView(ControlledCallback):
478    def __init__(self, model, view, widget, attribute):
479        super().__init__(widget, attribute)
480        self.model = model
481        self.view = view
482
483    # triggered by selectionModel().selectionChanged()
484    def __call__(self, *_):
485        # This must be imported locally to avoid circular imports
486        from Orange.widgets.utils.itemmodels import PyListModel
487        values = [i.row()
488                  for i in self.view.selectionModel().selection().indexes()]
489        if values:
490            # FIXME: irrespective of PyListModel check, this might/should always
491            # callback with values!
492            if isinstance(self.model, PyListModel):
493                values = [self.model[i] for i in values]
494            if self.view.selectionMode() == self.view.SingleSelection:
495                values = values[0]
496            self.acyclic_setattr(values)
497
498
499class CallBackListBox:
500    def __init__(self, control, widget):
501        self.control = control
502        self.widget = widget
503        self.disabled = 0
504
505    def __call__(self, *_):  # triggered by selectionChange()
506        if not self.disabled and self.control.ogValue is not None:
507            clist = getdeepattr(self.widget, self.control.ogValue)
508            control = self.control
509            selection = [i for i in range(control.count())
510                         if control.item(i).isSelected()]
511            if isinstance(clist, int):
512                self.widget.__setattr__(
513                    self.control.ogValue, selection[0] if selection else None)
514            else:
515                list.__setitem__(clist, slice(0, len(clist)), selection)
516                self.widget.__setattr__(self.control.ogValue, clist)
517
518
519##############################################################################
520# call fronts (change of the attribute value changes the related control)
521
522
523class CallFrontListView(ControlledCallFront):
524    def action(self, values):
525        view = self.control
526        model = view.model()
527        sel_model = view.selectionModel()
528
529        if not isinstance(values, Sequence):
530            values = [values]
531
532        selection = QItemSelection()
533        for value in values:
534            index = None
535            if not isinstance(value, int):
536                if isinstance(value, Variable):
537                    search_role = TableVariable
538                else:
539                    search_role = Qt.DisplayRole
540                    value = str(value)
541                for i in range(model.rowCount()):
542                    if model.data(model.index(i), search_role) == value:
543                        index = i
544                        break
545            else:
546                index = value
547            if index is not None:
548                selection.select(model.index(index), model.index(index))
549        sel_model.select(selection, sel_model.ClearAndSelect)
550
551
552class CallFrontListBox(ControlledCallFront):
553    def action(self, value):
554        if value is not None:
555            if isinstance(value, int):
556                for i in range(self.control.count()):
557                    self.control.item(i).setSelected(i == value)
558            else:
559                if not isinstance(value, ControlledList):
560                    setattr(self.control.ogMaster, self.control.ogValue,
561                            ControlledList(value, self.control))
562                for i in range(self.control.count()):
563                    shouldBe = i in value
564                    if shouldBe != self.control.item(i).isSelected():
565                        self.control.item(i).setSelected(shouldBe)
566
567
568class CallFrontListBoxLabels(ControlledCallFront):
569    unknownType = None
570
571    def action(self, values):
572        self.control.clear()
573        if values:
574            for value in values:
575                if isinstance(value, tuple):
576                    text, icon = value
577                    if isinstance(icon, int):
578                        item = QtWidgets.QListWidgetItem(attributeIconDict[icon], text)
579                    else:
580                        item = QtWidgets.QListWidgetItem(icon, text)
581                elif isinstance(value, Variable):
582                    item = QtWidgets.QListWidgetItem(*attributeItem(value))
583                else:
584                    item = QtWidgets.QListWidgetItem(value)
585
586                item.setData(Qt.UserRole, value)
587                self.control.addItem(item)
588
589
590#: Role to retrieve Orange.data.Value
591TableValueRole = next(OrangeUserRole)
592#: Role to retrieve class value for a row
593TableClassValueRole = next(OrangeUserRole)
594# Role to retrieve distribution of a column
595TableDistribution = next(OrangeUserRole)
596#: Role to retrieve the column's variable
597TableVariable = next(OrangeUserRole)
598
599
600class TableBarItem(_BarItemDataDelegate):
601    BarRole = next(OrangeUserRole)
602    BarColorRole = next(OrangeUserRole)
603    __slots__ = ("color_schema",)
604
605    def __init__(
606            self, parent=None, color=QColor(255, 170, 127), width=5,
607            barFillRatioRole=BarRole, barColorRole=BarColorRole,
608            color_schema=None,
609            **kwargs
610    ):
611        """
612        :param QObject parent: Parent object.
613        :param QColor color: Default color of the distribution bar.
614        :param color_schema:
615            If not None it must be an instance of
616            :class:`OWColorPalette.ColorPaletteGenerator` (note: this
617            parameter, if set, overrides the ``color``)
618        :type color_schema: :class:`OWColorPalette.ColorPaletteGenerator`
619        """
620        super().__init__(
621            parent, color=color, penWidth=width,
622            barFillRatioRole=barFillRatioRole, barColorRole=barColorRole,
623            **kwargs
624        )
625        self.color_schema = color_schema
626
627    def barColorData(self, index):
628        class_ = self.cachedData(index, TableClassValueRole)
629        if self.color_schema is not None \
630                and isinstance(class_, Value) \
631                and isinstance(class_.variable, DiscreteVariable) \
632                and not math.isnan(class_):
633            return self.color_schema[int(class_)]
634        return self.cachedData(index, self.BarColorRole)
635
636
637from Orange.widgets.utils.colorpalettes import patch_variable_colors
638patch_variable_colors()
639
640
641class HScrollStepMixin:
642    """
643    Overrides default TableView horizontal behavior (scrolls 1 page at a time)
644    to a friendlier scroll speed akin to that of vertical scrolling.
645    """
646
647    def __init__(self, *args, **kwargs):
648        super().__init__(*args, **kwargs)
649        self.horizontalScrollBar().setSingleStep(20)
650
651    def wheelEvent(self, event: QWheelEvent):
652        if event.source() == Qt.MouseEventNotSynthesized and \
653                (event.modifiers() & Qt.ShiftModifier and sys.platform == 'darwin' or
654                 event.modifiers() & Qt.AltModifier and sys.platform != 'darwin'):
655            new_event = QWheelEvent(
656                event.pos(), event.globalPos(), event.pixelDelta(),
657                event.angleDelta(), event.buttons(), Qt.NoModifier,
658                event.phase(), event.inverted(), Qt.MouseEventSynthesizedByApplication
659            )
660            event.accept()
661            super().wheelEvent(new_event)
662        else:
663            super().wheelEvent(event)
664