1from datetime import date, datetime
2from functools import partial
3from itertools import filterfalse
4from types import MappingProxyType as MappingProxy
5from typing import (
6    Sequence, Any, Mapping, Dict, TypeVar, Type, Optional, Container, Tuple
7)
8from typing_extensions import Final
9
10import numpy as np
11
12from AnyQt.QtCore import (
13    Qt, QObject, QAbstractItemModel, QModelIndex, QPersistentModelIndex, Slot,
14    QLocale, QRect, QPointF, QSize, QLineF,
15)
16from AnyQt.QtGui import (
17    QFont, QFontMetrics, QPalette, QColor, QBrush, QIcon, QPixmap, QImage,
18    QPainter, QStaticText, QTransform, QPen
19)
20from AnyQt.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem, \
21    QApplication, QStyle
22
23from orangewidget.utils.cache import LRUCache
24
25A = TypeVar("A")
26
27
28def item_data(
29        index: QModelIndex, roles: Sequence[int]
30) -> Dict[int, Any]:
31    """Query `index` for all `roles` and return them as a mapping"""
32    model = index.model()
33    datagetter = partial(model.data, index)
34    values = map(datagetter, roles)
35    return dict(zip(roles, values))
36
37
38class ModelItemCache(QObject):
39    """
40    An item data cache for accessing QAbstractItemModel.data
41
42    >>> cache = ModelItemCache()
43    >>> cache.itemData(index, (Qt.DisplayRole, Qt.DecorationRole))
44    {0: ...
45
46    """
47    __slots__ = ("__model", "__cache_data")
48
49    def __init__(self, *args, maxsize=100 * 200, **kwargs):
50        super().__init__(*args, **kwargs)
51        self.__model: Optional[QAbstractItemModel] = None
52        self.__cache_data: 'LRUCache[QPersistentModelIndex, Any]' = LRUCache(maxsize)
53
54    def __connect_helper(self, model: QAbstractItemModel) -> None:
55        model.dataChanged.connect(self.invalidate)
56        model.layoutAboutToBeChanged.connect(self.invalidate)
57        model.modelAboutToBeReset.connect(self.invalidate)
58        model.rowsAboutToBeInserted.connect(self.invalidate)
59        model.rowsAboutToBeRemoved.connect(self.invalidate)
60        model.rowsAboutToBeMoved.connect(self.invalidate)
61        model.columnsAboutToBeInserted.connect(self.invalidate)
62        model.columnsAboutToBeRemoved.connect(self.invalidate)
63        model.columnsAboutToBeMoved.connect(self.invalidate)
64
65    def __disconnect_helper(self, model: QAbstractItemModel) -> None:
66        model.dataChanged.disconnect(self.invalidate)
67        model.layoutAboutToBeChanged.disconnect(self.invalidate)
68        model.modelAboutToBeReset.disconnect(self.invalidate)
69        model.rowsAboutToBeInserted.disconnect(self.invalidate)
70        model.rowsAboutToBeRemoved.disconnect(self.invalidate)
71        model.rowsAboutToBeMoved.disconnect(self.invalidate)
72        model.columnsAboutToBeInserted.disconnect(self.invalidate)
73        model.columnsAboutToBeRemoved.disconnect(self.invalidate)
74        model.columnsAboutToBeMoved.disconnect(self.invalidate)
75
76    def setModel(self, model: QAbstractItemModel) -> None:
77        if model is self.__model:
78            return
79        if self.__model is not None:
80            self.__disconnect_helper(self.__model)
81            self.__model = None
82        self.__model = model
83        self.__cache_data.clear()
84        if model is not None:
85            self.__connect_helper(model)
86
87    def model(self) -> Optional[QAbstractItemModel]:
88        return self.__model
89
90    @Slot()
91    def invalidate(self) -> None:
92        """Invalidate all cached data."""
93        self.__cache_data.clear()
94
95    def itemData(
96            self, index: QModelIndex, roles: Sequence[int]
97    ) -> Mapping[int, Any]:
98        """
99        Return item data from `index` for `roles`.
100
101        The returned mapping is a read only view of *all* data roles accessed
102        for the index through this caching interface. It will contain at least
103        data for `roles`, but can also contain other ones.
104        """
105        model = index.model()
106        if model is not self.__model:
107            self.setModel(model)
108        # NOTE: QPersistentModelIndex's hash changes when it is invalidated;
109        # it must be purged from __cache_data before that (`__connect_helper`)
110        key = QPersistentModelIndex(index)
111        try:
112            item = self.__cache_data[key]
113        except KeyError:
114            data = item_data(index, roles)
115            view = MappingProxy(data)
116            self.__cache_data[key] = data, view
117        else:
118            data, view = item
119            queryroles = tuple(filterfalse(data.__contains__, roles))
120            if queryroles:
121                data.update(item_data(index, queryroles))
122        return view
123
124    def data(self, index: QModelIndex, role: int) -> Any:
125        """Return item data for `index` and `role`"""
126        model = index.model()
127        if model is not self.__model:
128            self.setModel(model)
129        key = QPersistentModelIndex(index)
130        try:
131            item = self.__cache_data[key]
132        except KeyError:
133            data = item_data(index, (role,))
134            view = MappingProxy(data)
135            self.__cache_data[key] = data, view
136        else:
137            data, view = item
138            if role not in data:
139                data[role] = model.data(index, role)
140        return data[role]
141
142
143def cast_(type_: Type[A], value: Any) -> Optional[A]:
144    # similar but not quite the same as qvariant_cast
145    if value is None:
146        return value
147    if type(value) is type_:  # pylint: disable=unidiomatic-typecheck
148        return value
149    try:
150        return type_(value)
151    except Exception:  # pylint: disable=broad-except  # pragma: no cover
152        return None
153
154
155# QStyleOptionViewItem.Feature aliases as python int. Feature.__ior__
156# implementation is slower then int.__ior__
157_QStyleOptionViewItem_HasDisplay = int(QStyleOptionViewItem.HasDisplay)
158_QStyleOptionViewItem_HasCheckIndicator = int(QStyleOptionViewItem.HasCheckIndicator)
159_QStyleOptionViewItem_HasDecoration = int(QStyleOptionViewItem.HasDecoration)
160
161
162class _AlignmentFlagsCache(dict):
163    # A cached int -> Qt.Alignment cache. Used to avoid temporary Qt.Alignment
164    # flags object (de)allocation.
165    def __missing__(self, key: int) -> Qt.Alignment:
166        a = Qt.Alignment(key)
167        self.setdefault(key, a)
168        return a
169
170
171_AlignmentCache: Mapping[int, Qt.Alignment] = _AlignmentFlagsCache()
172_AlignmentMask = int(Qt.AlignHorizontal_Mask | Qt.AlignVertical_Mask)
173
174
175def init_style_option(
176        delegate: QStyledItemDelegate,
177        option: QStyleOptionViewItem,
178        index: QModelIndex,
179        data: Mapping[int, Any],
180        roles: Optional[Container[int]] = None,
181) -> None:
182    """
183    Like `QStyledItemDelegate.initStyleOption` but fill in the fields from
184    `data` mapping. If `roles` is not `None` init the `option` for the
185    specified `roles` only.
186    """
187    # pylint: disable=too-many-branches
188    option.styleObject = None
189    option.index = index
190    if roles is None:
191        roles = data
192    features = 0
193    if Qt.DisplayRole in roles:
194        value = data.get(Qt.DisplayRole)
195        if value is not None:
196            option.text = delegate.displayText(value, option.locale)
197            features |= _QStyleOptionViewItem_HasDisplay
198    if Qt.FontRole in roles:
199        value = data.get(Qt.FontRole)
200        font = cast_(QFont, value)
201        if font is not None:
202            font = font.resolve(option.font)
203            option.font = font
204            option.fontMetrics = QFontMetrics(option.font)
205    if Qt.ForegroundRole in roles:
206        value = data.get(Qt.ForegroundRole)
207        foreground = cast_(QBrush, value)
208        if foreground is not None:
209            option.palette.setBrush(QPalette.Text, foreground)
210    if Qt.BackgroundRole in roles:
211        value = data.get(Qt.BackgroundRole)
212        background = cast_(QBrush, value)
213        if background is not None:
214            option.backgroundBrush = background
215    if Qt.TextAlignmentRole in roles:
216        value = data.get(Qt.TextAlignmentRole)
217        alignment = cast_(int, value)
218        if alignment is not None:
219            alignment = alignment & _AlignmentMask
220            option.displayAlignment = _AlignmentCache[alignment]
221    if Qt.CheckStateRole in roles:
222        state = data.get(Qt.CheckStateRole)
223        if state is not None:
224            features |= _QStyleOptionViewItem_HasCheckIndicator
225            state = cast_(int, state)
226            if state is not None:
227                option.checkState = state
228    if Qt.DecorationRole in roles:
229        value = data.get(Qt.DecorationRole)
230        if value is not None:
231            features |= _QStyleOptionViewItem_HasDecoration
232        if isinstance(value, QIcon):
233            option.icon = value
234        elif isinstance(value, QColor):
235            pix = QPixmap(option.decorationSize)
236            pix.fill(value)
237            option.icon = QIcon(pix)
238        elif isinstance(value, QPixmap):
239            option.icon = QIcon(value)
240            option.decorationSize = (value.size() / value.devicePixelRatio()).toSize()
241        elif isinstance(value, QImage):
242            pix = QPixmap.fromImage(value)
243            option.icon = QIcon(value)
244            option.decorationSize = (pix.size() / pix.devicePixelRatio()).toSize()
245    option.features |= features
246
247
248class CachedDataItemDelegate(QStyledItemDelegate):
249    """
250    An QStyledItemDelegate with item model data caching.
251
252    Parameters
253    ----------
254    roles: Sequence[int]
255        A set of roles to query the model and fill the `QStyleOptionItemView`
256        with. By specifying only a subset of the roles here the delegate can
257        be speed up (e.g. if you know the model does not provide the relevant
258        roles or you just want to ignore some of them).
259    """
260    __slots__ = ("roles", "__cache",)
261
262    #: The default roles that are filled in initStyleOption
263    DefaultRoles = (
264        Qt.DisplayRole, Qt.TextAlignmentRole, Qt.FontRole, Qt.ForegroundRole,
265        Qt.BackgroundRole, Qt.CheckStateRole, Qt.DecorationRole
266    )
267
268    def __init__(
269            self, *args, roles: Sequence[int] = None, **kwargs
270    ) -> None:
271        super().__init__(*args, **kwargs)
272        if roles is None:
273            roles = self.DefaultRoles
274        self.roles = tuple(roles)
275        self.__cache = ModelItemCache(self)
276
277    def cachedItemData(
278            self, index: QModelIndex, roles: Sequence[int]
279    ) -> Mapping[int, Any]:
280        """
281        Return a mapping of all roles for the index.
282
283        .. note::
284           The returned mapping contains at least `roles`, but will also
285           contain all cached roles that were queried previously.
286        """
287        return self.__cache.itemData(index, roles)
288
289    def cachedData(self, index: QModelIndex, role: int) -> Any:
290        """Return the data for role from `index`."""
291        return self.__cache.data(index, role)
292
293    def initStyleOption(
294            self, option: QStyleOptionViewItem, index: QModelIndex
295    ) -> None:
296        """
297        Reimplemented.
298
299        Use caching to query the model data. Also limit the roles queried
300        from the model and filled in `option` to `self.roles`.
301        """
302        data = self.cachedItemData(index, self.roles)
303        init_style_option(self, option, index, data, self.roles)
304
305
306_Real = (float, np.floating)
307_Integral = (int, np.integer)
308_Number = _Integral + _Real
309_String = (str, np.str_)
310_DateTime = (date, datetime, np.datetime64)
311_TypesAlignRight = _Number + _DateTime
312
313
314class StyledItemDelegate(QStyledItemDelegate):
315    """
316    A `QStyledItemDelegate` subclass supporting a broader range of python
317    and numpy types for display.
318
319    E.g. supports `np.float*`, `np.(u)int`, `datetime.date`,
320    `datetime.datetime`
321    """
322    #: Types that are displayed as real (decimal)
323    RealTypes: Final[Tuple[type, ...]] = _Real
324    #: Types that are displayed as integers
325    IntegralTypes: Final[Tuple[type, ...]] = _Integral
326    #: RealTypes and IntegralTypes combined
327    NumberTypes: Final[Tuple[type, ...]] = _Number
328    #: Date time types
329    DateTimeTypes: Final[Tuple[type, ...]] = _DateTime
330
331    def displayText(self, value: Any, locale: QLocale) -> str:
332        """
333        Reimplemented.
334        """
335        # NOTE: Maybe replace the if,elif with a dispatch a table
336        if value is None:
337            return ""
338        elif type(value) is str:  # pylint: disable=unidiomatic-typecheck
339            return value  # avoid copies
340        elif isinstance(value, _Integral):
341            return super().displayText(int(value), locale)
342        elif isinstance(value, _Real):
343            return super().displayText(float(value), locale)
344        elif isinstance(value, _String):
345            return str(value)
346        elif isinstance(value, datetime):
347            return value.isoformat(sep=" ")
348        elif isinstance(value, date):
349            return value.isoformat()
350        elif isinstance(value, np.datetime64):
351            return self.displayText(value.astype(datetime), locale)
352        return super().displayText(value, locale)
353
354
355_Qt_AlignRight = int(Qt.AlignRight)
356_Qt_AlignLeft = int(Qt.AlignLeft)
357_Qt_AlignHCenter = int(Qt.AlignHCenter)
358_Qt_AlignTop = int(Qt.AlignTop)
359_Qt_AlignBottom = int(Qt.AlignBottom)
360_Qt_AlignVCenter = int(Qt.AlignVCenter)
361
362_StaticTextKey = Tuple[str, QFont, Qt.TextElideMode, int]
363_PenKey = Tuple[str, int]
364_State_Mask = int(QStyle.State_Selected | QStyle.State_Enabled |
365                  QStyle.State_Active)
366
367
368class DataDelegate(CachedDataItemDelegate, StyledItemDelegate):
369    """
370    A QStyledItemDelegate optimized for displaying fixed tabular data.
371
372    This delegate will automatically display numeric and date/time values
373    aligned to the right.
374
375    Note
376    ----
377    Does not support text wrapping
378    """
379    __slots__ = (
380        "__static_text_lru_cache", "__pen_lru_cache", "__style"
381    )
382    #: Types that are right aligned by default (when Qt.TextAlignmentRole
383    #: is not defined by the model or is excluded from self.roles)
384    TypesAlignRight: Final[Tuple[type, ...]] = _TypesAlignRight
385
386    def __init__(self, *args, **kwargs):
387        super().__init__(*args, **kwargs)
388        self.__static_text_lru_cache: LRUCache[_StaticTextKey, QStaticText]
389        self.__static_text_lru_cache = LRUCache(100 * 200)
390        self.__pen_lru_cache: LRUCache[_PenKey, QPen] = LRUCache(100)
391        self.__style = None
392
393    def initStyleOption(
394            self, option: QStyleOptionViewItem, index: QModelIndex
395    ) -> None:
396        data = self.cachedItemData(index, self.roles)
397        init_style_option(self, option, index, data, self.roles)
398        if data.get(Qt.TextAlignmentRole) is None \
399                and Qt.TextAlignmentRole in self.roles \
400                and isinstance(data.get(Qt.DisplayRole), _TypesAlignRight):
401            option.displayAlignment = \
402                (option.displayAlignment & ~Qt.AlignHorizontal_Mask) | \
403                Qt.AlignRight
404
405    def paint(
406            self, painter: QPainter, option: QStyleOptionViewItem,
407            index: QModelIndex
408    ) -> None:
409        opt = QStyleOptionViewItem(option)
410        self.initStyleOption(opt, index)
411        widget = option.widget
412        style = QApplication.style() if widget is None else widget.style()
413        # Keep ref to style wrapper. This is ugly, wrong but the wrapping of
414        # C++ QStyle instance takes ~5% unless the wrapper already exists.
415        self.__style = style
416        text = opt.text
417        opt.text = ""
418        style.drawControl(QStyle.CE_ItemViewItem, opt, painter, widget)
419        trect = style.subElementRect(QStyle.SE_ItemViewItemText, opt, widget)
420        opt.text = text
421        self.drawViewItemText(style, painter, opt, trect)
422
423    def drawViewItemText(
424            self, style: QStyle, painter: QPainter,
425            option: QStyleOptionViewItem, rect: QRect
426    ) -> None:
427        """
428        Draw view item text in `rect` using `style` and `painter`.
429        """
430        margin = style.pixelMetric(
431            QStyle.PM_FocusFrameHMargin, None, option.widget) + 1
432        rect = rect.adjusted(margin, 0, -margin, 0)
433        font = option.font
434        st = self.__static_text_elided_cache(
435            option.text, font, option.fontMetrics, option.textElideMode,
436            rect.width()
437        )
438        tsize = st.size()
439        textalign = int(option.displayAlignment)
440        text_pos_x = text_pos_y = 0.0
441
442        if textalign & _Qt_AlignLeft:
443            text_pos_x = rect.left()
444        elif textalign & _Qt_AlignRight:
445            text_pos_x = rect.x() + rect.width() - tsize.width()
446        elif textalign & _Qt_AlignHCenter:
447            text_pos_x = rect.x() + rect.width() / 2 - tsize.width() / 2
448
449        if textalign & _Qt_AlignVCenter:
450            text_pos_y = rect.y() + rect.height() / 2 - tsize.height() / 2
451        elif textalign & _Qt_AlignTop:
452            text_pos_y = rect.top()
453        elif textalign & _Qt_AlignBottom:
454            text_pos_y = rect.top() + rect.height() - tsize.height()
455
456        painter.setPen(self.__pen_cache(option.palette, option.state))
457        painter.setFont(font)
458        painter.drawStaticText(QPointF(text_pos_x, text_pos_y), st)
459
460    def __static_text_elided_cache(
461            self, text: str, font: QFont, fontMetrics: QFontMetrics,
462            elideMode: Qt.TextElideMode, width: int
463    ) -> QStaticText:
464        """
465        Return a `QStaticText` instance for depicting the text with the `font`
466        """
467        try:
468            return self.__static_text_lru_cache[text, font, elideMode, width]
469        except KeyError:
470            text = fontMetrics.elidedText(text, elideMode, width)
471            st = QStaticText(text)
472            st.prepare(QTransform(), font)
473            # take a copy of the font for cache key
474            key = text, QFont(font), elideMode, width
475            self.__static_text_lru_cache[key] = st
476            return st
477
478    def __pen_cache(self, palette: QPalette, state: QStyle.State) -> QPen:
479        """Return a QPen from the `palette` for `state`."""
480        # NOTE: This method exists mostly to avoid QPen, QColor (de)allocations.
481        key = palette.cacheKey(), int(state) & _State_Mask
482        try:
483            return self.__pen_lru_cache[key]
484        except KeyError:
485            cgroup = QPalette.Normal if state & QStyle.State_Active else QPalette.Inactive
486            cgroup = cgroup if state & QStyle.State_Enabled else QPalette.Disabled
487            role = QPalette.HighlightedText if state & QStyle.State_Selected else QPalette.Text
488            pen = QPen(palette.color(cgroup, role))
489            self.__pen_lru_cache[key] = pen
490            return pen
491
492
493class BarItemDataDelegate(DataDelegate):
494    """
495    An delegate drawing a horizontal bar below its text.
496
497    Can be used to visualise numerical column distribution.
498
499    Parameters
500    ----------
501    parent: Optional[QObject]
502        Parent object
503    color: QColor
504        The default color for the bar. If not set then the palette's
505        foreground role is used.
506    penWidth: int
507        The bar pen width.
508    barFillRatioRole: int
509        The item model role used to query the bar fill ratio (see
510        :method:`barFillRatioData`)
511    barColorRole: int
512        The item model role used to query the bar color.
513    """
514    __slots__ = (
515        "color", "penWidth", "barFillRatioRole", "barColorRole",
516        "__line", "__pen"
517    )
518
519    def __init__(
520            self, parent: Optional[QObject] = None, color=QColor(), penWidth=5,
521            barFillRatioRole=Qt.UserRole + 1, barColorRole=Qt.UserRole + 2,
522            **kwargs
523    ):
524        super().__init__(parent, **kwargs)
525        self.color = color
526        self.penWidth = penWidth
527        self.barFillRatioRole = barFillRatioRole
528        self.barColorRole = barColorRole
529        # Line and pen instances reused
530        self.__line = QLineF()
531        self.__pen = QPen(color, penWidth, Qt.SolidLine, Qt.RoundCap)
532
533    def barFillRatioData(self, index: QModelIndex) -> Optional[float]:
534        """
535        Return a number between 0.0 and 1.0 indicating the bar fill ratio.
536
537        The default implementation queries the model for `barFillRatioRole`
538        """
539        return cast_(float, self.cachedData(index, self.barFillRatioRole))
540
541    def barColorData(self, index: QModelIndex) -> Optional[QColor]:
542        """
543        Return the color for the bar.
544
545        The default implementation queries the model for `barColorRole`
546        """
547        return cast_(QColor, self.cachedData(index, self.barColorRole))
548
549    def sizeHint(
550            self, option: QStyleOptionViewItem, index: QModelIndex
551    ) -> QSize:
552        sh = super().sizeHint(option, index)
553        pw, vmargin = self.penWidth, 1
554        sh.setHeight(sh.height() + pw + vmargin)
555        return sh
556
557    def paint(
558            self, painter: QPainter, option: QStyleOptionViewItem,
559            index: QModelIndex
560    ) -> None:
561        opt = QStyleOptionViewItem(option)
562        self.initStyleOption(opt, index)
563        widget = option.widget
564        style = QApplication.style() if widget is None else widget.style()
565        self.__style = style
566        text = opt.text
567        opt.text = ""
568        style.drawControl(QStyle.CE_ItemViewItem, opt, painter, widget)
569
570        textrect = style.subElementRect(
571            QStyle.SE_ItemViewItemText, opt, widget)
572
573        ratio = self.barFillRatioData(index)
574        if ratio is not None and 0. <= ratio <= 1.:
575            color = self.barColorData(index)
576            if color is None:
577                color = self.color
578            if not color.isValid():
579                color = opt.palette.color(QPalette.Foreground)
580            rect = option.rect
581            pw = self.penWidth
582            hmargin = 3 + pw / 2  # + half pen width for the round line cap
583            vmargin = 1
584            textoffset = pw + vmargin * 2
585            baseline = rect.bottom() - textoffset / 2
586            width = (rect.width() - 2 * hmargin) * ratio
587            painter.save()
588            painter.setRenderHint(QPainter.Antialiasing)
589            pen = self.__pen
590            pen.setColor(color)
591            pen.setWidth(pw)
592            painter.setPen(pen)
593            line = self.__line
594            left = rect.left() + hmargin
595            line.setLine(left, baseline, left + width, baseline)
596            painter.drawLine(line)
597            painter.restore()
598            textrect.adjust(0, 0, 0, -textoffset)
599
600        opt.text = text
601        self.drawViewItemText(style, painter, opt, textrect)
602