1""" 2Widget Tool Box 3=============== 4 5 6A tool box with a tool grid for each category. 7 8""" 9from typing import Optional, Iterable, Any 10 11from AnyQt.QtWidgets import ( 12 QAbstractButton, QSizePolicy, QAction, QApplication, QToolButton, 13 QWidget 14) 15from AnyQt.QtGui import ( 16 QDrag, QPalette, QBrush, QIcon, QColor, QGradient, QActionEvent, 17 QMouseEvent 18) 19from AnyQt.QtCore import ( 20 Qt, QObject, QAbstractItemModel, QModelIndex, QSize, QEvent, QMimeData, 21 QByteArray, QDataStream, QIODevice, QPoint 22) 23from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property 24 25from ..gui.toolbox import ToolBox 26from ..gui.toolgrid import ToolGrid 27from ..gui.quickhelp import StatusTipPromoter 28from ..gui.utils import create_gradient 29from ..registry.qt import QtWidgetRegistry 30 31 32def iter_index(model, index): 33 # type: (QAbstractItemModel, QModelIndex) -> Iterable[QModelIndex] 34 """ 35 Iterate over child indexes of a `QModelIndex` in a `model`. 36 """ 37 for row in range(model.rowCount(index)): 38 yield model.index(row, 0, index) 39 40 41def item_text(index): # type: (QModelIndex) -> str 42 value = index.data(Qt.DisplayRole) 43 if value is None: 44 return "" 45 else: 46 return str(value) 47 48 49def item_icon(index): # type: (QModelIndex) -> QIcon 50 value = index.data(Qt.DecorationRole) 51 if isinstance(value, QIcon): 52 return value 53 else: 54 return QIcon() 55 56 57def item_tooltip(index): # type: (QModelIndex) -> str 58 value = index.data(Qt.ToolTipRole) 59 if isinstance(value, str): 60 return value 61 return item_text(index) 62 63 64def item_background(index): # type: (QModelIndex) -> Optional[QBrush] 65 value = index.data(Qt.BackgroundRole) 66 if isinstance(value, QBrush): 67 return value 68 elif isinstance(value, (QColor, Qt.GlobalColor, QGradient)): 69 return QBrush(value) 70 else: 71 return None 72 73 74class WidgetToolGrid(ToolGrid): 75 """ 76 A Tool Grid with widget buttons. Populates the widget buttons 77 from a item model. Also adds support for drag operations. 78 79 """ 80 def __init__(self, *args, **kwargs): 81 # type: (Any, Any) -> None 82 super().__init__(*args, **kwargs) 83 84 self.__model = None # type: Optional[QAbstractItemModel] 85 self.__rootIndex = QModelIndex() # type: QModelIndex 86 self.__actionRole = QtWidgetRegistry.WIDGET_ACTION_ROLE # type: int 87 88 self.__dragListener = DragStartEventListener(self) 89 self.__dragListener.dragStartOperationRequested.connect( 90 self.__startDrag 91 ) 92 self.__statusTipPromoter = StatusTipPromoter(self) 93 94 def setModel(self, model, rootIndex=QModelIndex()): 95 # type: (QAbstractItemModel, QModelIndex) -> None 96 """ 97 Set a model (`QStandardItemModel`) for the tool grid. The 98 widget actions are children of the rootIndex. 99 100 .. warning:: The model should not be deleted before the 101 `WidgetToolGrid` instance. 102 103 """ 104 if self.__model is not None: 105 self.__model.rowsInserted.disconnect(self.__on_rowsInserted) 106 self.__model.rowsRemoved.disconnect(self.__on_rowsRemoved) 107 self.__model = None 108 109 self.__model = model 110 self.__rootIndex = rootIndex 111 112 if self.__model is not None: 113 self.__model.rowsInserted.connect(self.__on_rowsInserted) 114 self.__model.rowsRemoved.connect(self.__on_rowsRemoved) 115 116 self.__initFromModel(model, rootIndex) 117 118 def model(self): # type: () -> Optional[QAbstractItemModel] 119 """ 120 Return the model for the tool grid. 121 """ 122 return self.__model 123 124 def rootIndex(self): # type: () -> QModelIndex 125 """ 126 Return the root index of the model. 127 """ 128 return self.__rootIndex 129 130 def setActionRole(self, role): 131 # type: (int) -> None 132 """ 133 Set the action role. This is the model role containing a 134 `QAction` instance. 135 """ 136 if self.__actionRole != role: 137 self.__actionRole = role 138 if self.__model: 139 self.__update() 140 141 def actionRole(self): # type: () -> int 142 """ 143 Return the action role. 144 """ 145 return self.__actionRole 146 147 def actionEvent(self, event): # type: (QActionEvent) -> None 148 if event.type() == QEvent.ActionAdded: 149 # Creates and inserts the button instance. 150 super().actionEvent(event) 151 152 button = self.buttonForAction(event.action()) 153 button.installEventFilter(self.__dragListener) 154 button.installEventFilter(self.__statusTipPromoter) 155 return 156 elif event.type() == QEvent.ActionRemoved: 157 button = self.buttonForAction(event.action()) 158 button.removeEventFilter(self.__dragListener) 159 button.removeEventFilter(self.__statusTipPromoter) 160 161 # Removes the button 162 super().actionEvent(event) 163 return 164 else: 165 super().actionEvent(event) 166 167 def __initFromModel(self, model, rootIndex): 168 # type: (QAbstractItemModel, QModelIndex) -> None 169 """ 170 Initialize the grid from the model with rootIndex as the root. 171 """ 172 for i, index in enumerate(iter_index(model, rootIndex)): 173 self.__insertItem(i, index) 174 175 def __insertItem(self, index, item): 176 # type: (int, QModelIndex) -> None 177 """ 178 Insert a widget action from `item` (`QModelIndex`) at `index`. 179 """ 180 value = item.data(self.__actionRole) 181 if isinstance(value, QAction): 182 action = value 183 else: 184 action = QAction(item_text(item), self) 185 action.setIcon(item_icon(item)) 186 action.setToolTip(item_tooltip(item)) 187 188 self.insertAction(index, action) 189 190 def __update(self): # type: () -> None 191 self.clear() 192 if self.__model is not None: 193 self.__initFromModel(self.__model, self.__rootIndex) 194 195 def __on_rowsInserted(self, parent, start, end): 196 # type: (QModelIndex, int, int) -> None 197 """ 198 Insert items from range start:end into the grid. 199 """ 200 if parent == self.__rootIndex: 201 for i in range(start, end + 1): 202 item = self.__rootIndex.child(i, 0) 203 self.__insertItem(i, item) 204 205 def __on_rowsRemoved(self, parent, start, end): 206 # type: (QModelIndex, int, int) -> None 207 """ 208 Remove items from range start:end from the grid. 209 """ 210 if parent == self.__rootIndex: 211 for i in reversed(range(start - 1, end)): 212 action = self.actions()[i] 213 self.removeAction(action) 214 215 def __startDrag(self, button): 216 # type: (QToolButton) -> None 217 """ 218 Start a drag from button 219 """ 220 action = button.defaultAction() 221 desc = action.data() # Widget Description 222 icon = action.icon() 223 drag_data = QMimeData() 224 drag_data.setData( 225 "application/vnd.orange-canvas.registry.qualified-name", 226 desc.qualified_name.encode("utf-8") 227 ) 228 drag = QDrag(button) 229 drag.setPixmap(icon.pixmap(self.iconSize())) 230 drag.setMimeData(drag_data) 231 drag.exec_(Qt.CopyAction) 232 233 234class DragStartEventListener(QObject): 235 """ 236 An event filter object that can be used to detect drag start 237 operation on buttons which otherwise do not support it. 238 239 """ 240 dragStartOperationRequested = Signal(QAbstractButton) 241 """A drag operation started on a button.""" 242 243 def __init__(self, parent=None, **kwargs): 244 # type: (Optional[QObject], Any) -> None 245 super().__init__(parent, **kwargs) 246 self.button = None # type: Optional[Qt.MouseButton] 247 self.buttonDownObj = None # type: Optional[QAbstractButton] 248 self.buttonDownPos = None # type: Optional[QPoint] 249 250 def eventFilter(self, obj, event): 251 # type: (QObject, QEvent) -> bool 252 if event.type() == QEvent.MouseButtonPress: 253 assert isinstance(event, QMouseEvent) 254 self.buttonDownPos = event.pos() 255 self.buttonDownObj = obj 256 self.button = event.button() 257 258 elif event.type() == QEvent.MouseMove and obj is self.buttonDownObj: 259 assert self.buttonDownObj is not None 260 if (self.buttonDownPos - event.pos()).manhattanLength() > \ 261 QApplication.startDragDistance() and \ 262 not self.buttonDownObj.hitButton(event.pos()): 263 # Process the widget's mouse event, before starting the 264 # drag operation, so the widget can update its state. 265 obj.mouseMoveEvent(event) 266 self.dragStartOperationRequested.emit(obj) 267 268 obj.setDown(False) 269 270 self.button = None 271 self.buttonDownPos = None 272 self.buttonDownObj = None 273 return True # Already handled 274 275 return super().eventFilter(obj, event) 276 277 278class WidgetToolBox(ToolBox): 279 """ 280 `WidgetToolBox` widget shows a tool box containing button grids of 281 actions for a :class:`QtWidgetRegistry` item model. 282 """ 283 284 triggered = Signal(QAction) 285 hovered = Signal(QAction) 286 287 def __init__(self, parent=None): 288 # type: (Optional[QWidget]) -> None 289 super().__init__(parent) 290 self.__model = None # type: Optional[QAbstractItemModel] 291 self.__iconSize = QSize(25, 25) 292 self.__buttonSize = QSize(50, 50) 293 self.setSizePolicy(QSizePolicy.Fixed, 294 QSizePolicy.Expanding) 295 296 def setIconSize(self, size): # type: (QSize) -> None 297 """ 298 Set the widget icon size (icons in the button grid). 299 """ 300 if self.__iconSize != size: 301 self.__iconSize = QSize(size) 302 for widget in map(self.widget, range(self.count())): 303 widget.setIconSize(size) 304 305 def iconSize(self): # type: () -> QSize 306 """ 307 Return the widget buttons icon size. 308 """ 309 return QSize(self.__iconSize) 310 311 iconSize_ = Property(QSize, fget=iconSize, fset=setIconSize, 312 designable=True) 313 314 def setButtonSize(self, size): # type: (QSize) -> None 315 """ 316 Set fixed widget button size. 317 """ 318 if self.__buttonSize != size: 319 self.__buttonSize = QSize(size) 320 for widget in map(self.widget, range(self.count())): 321 widget.setButtonSize(size) 322 323 def buttonSize(self): # type: () -> QSize 324 """Return the widget button size 325 """ 326 return QSize(self.__buttonSize) 327 328 buttonSize_ = Property(QSize, fget=buttonSize, fset=setButtonSize, 329 designable=True) 330 331 def saveState(self): # type: () -> QByteArray 332 """ 333 Return the toolbox state (as a `QByteArray`). 334 335 .. note:: Individual tabs are stored by their action's text. 336 337 """ 338 version = 2 339 340 actions = map(self.tabAction, range(self.count())) 341 expanded = [action for action in actions if action.isChecked()] 342 expanded = [action.text() for action in expanded] 343 344 byte_array = QByteArray() 345 stream = QDataStream(byte_array, QIODevice.WriteOnly) 346 stream.writeInt(version) 347 stream.writeQStringList(expanded) 348 349 return byte_array 350 351 def restoreState(self, state): # type: (QByteArray) -> bool 352 """ 353 Restore the toolbox from a :class:`QByteArray` `state`. 354 355 .. note:: The toolbox should already be populated for the state 356 changes to take effect. 357 358 """ 359 stream = QDataStream(state, QIODevice.ReadOnly) 360 version = stream.readInt() 361 if version == 2: 362 expanded = stream.readQStringList() 363 for action in map(self.tabAction, range(self.count())): 364 if (action.text() in expanded) != action.isChecked(): 365 action.trigger() 366 return True 367 return False 368 369 def setModel(self, model): 370 # type: (QAbstractItemModel) -> None 371 """ 372 Set the widget registry model (:class:`QAbstractItemModel`) for 373 this toolbox. 374 """ 375 if self.__model is not None: 376 self.__model.dataChanged.disconnect(self.__on_dataChanged) 377 self.__model.rowsInserted.disconnect(self.__on_rowsInserted) 378 self.__model.rowsRemoved.disconnect(self.__on_rowsRemoved) 379 380 self.__model = model 381 if self.__model is not None: 382 self.__model.dataChanged.connect(self.__on_dataChanged) 383 self.__model.rowsInserted.connect(self.__on_rowsInserted) 384 self.__model.rowsRemoved.connect(self.__on_rowsRemoved) 385 386 self.__initFromModel(self.__model) 387 388 def __initFromModel(self, model): 389 # type: (QAbstractItemModel) -> None 390 for row in range(model.rowCount()): 391 self.__insertItem(model.index(row, 0), self.count()) 392 393 def __insertItem(self, item, index): 394 # type: (QModelIndex, int) -> None 395 """ 396 Insert category item (`QModelIndex`) at index. 397 """ 398 grid = WidgetToolGrid() 399 grid.setModel(item.model(), item) 400 grid.actionTriggered.connect(self.triggered) 401 grid.actionHovered.connect(self.hovered) 402 403 grid.setIconSize(self.__iconSize) 404 grid.setButtonSize(self.__buttonSize) 405 grid.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) 406 407 text = item_text(item) 408 icon = item_icon(item) 409 tooltip = item_tooltip(item) 410 411 # Set the 'tab-title' property to text. 412 grid.setProperty("tab-title", text) 413 grid.setObjectName("widgets-toolbox-grid") 414 415 self.insertItem(index, grid, text, icon, tooltip) 416 button = self.tabButton(index) 417 418 # Set the 'highlight' color if applicable 419 highlight_foreground = None 420 highlight = item_background(item) 421 if highlight is None \ 422 and item.data(QtWidgetRegistry.BACKGROUND_ROLE) is not None: 423 highlight = item.data(QtWidgetRegistry.BACKGROUND_ROLE) 424 425 if isinstance(highlight, QBrush) and highlight.style() != Qt.NoBrush: 426 if not highlight.gradient(): 427 value = highlight.color().value() 428 gradient = create_gradient(highlight.color()) 429 highlight = QBrush(gradient) 430 highlight_foreground = Qt.black if value > 128 else Qt.white 431 432 palette = button.palette() 433 434 if highlight is not None: 435 palette.setBrush(QPalette.Highlight, highlight) 436 if highlight_foreground is not None: 437 palette.setBrush(QPalette.HighlightedText, highlight_foreground) 438 button.setPalette(palette) 439 440 def __on_dataChanged(self, topLeft, bottomRight): 441 # type: (QModelIndex, QModelIndex) -> None 442 parent = topLeft.parent() 443 if not parent.isValid(): 444 for row in range(topLeft.row(), bottomRight.row() + 1): 445 item = topLeft.sibling(row, topLeft.column()) 446 button = self.tabButton(row) 447 button.setIcon(item_icon(item)) 448 button.setText(item_text(item)) 449 button.setToolTip(item_tooltip(item)) 450 451 def __on_rowsInserted(self, parent, start, end): 452 # type: (QModelIndex, int, int) -> None 453 """ 454 Items have been inserted in the model. 455 """ 456 # Only the top level items (categories) are handled here. 457 assert self.__model is not None 458 if not parent.isValid(): 459 for i in range(start, end + 1): 460 item = self.__model.index(i, 0) 461 self.__insertItem(item, i) 462 463 def __on_rowsRemoved(self, parent, start, end): 464 # type: (QModelIndex, int, int) -> None 465 """ 466 Rows have been removed from the model. 467 """ 468 # Only the top level items (categories) are handled here. 469 if not parent.isValid(): 470 for i in range(end, start - 1, -1): 471 self.removeItem(i) 472