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