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