1# qutil.py -- various Qt4-related utility functions
2#
3# Copyright (c) 2008 - 2014 by Wilbert Berendsen
4#
5# This program is free software; you can redistribute it and/or
6# modify it under the terms of the GNU General Public License
7# as published by the Free Software Foundation; either version 2
8# of the License, or (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
18# See http://www.gnu.org/licenses/ for more information.
19
20"""
21Some Qt4-related utility functions.
22"""
23
24
25import contextlib
26import re
27import weakref
28
29from PyQt5.QtCore import QEventLoop, QSettings, QSize, QTimer, Qt
30from PyQt5.QtGui import QColor, QKeySequence
31from PyQt5.QtWidgets import QAction, QApplication, QProgressDialog
32
33import appinfo
34
35
36def saveDialogSize(dialog, key, default=QSize()):
37    """Makes the size of a QDialog persistent.
38
39    Resizes a QDialog from the setting saved in QSettings().value(key),
40    defaulting to the optionally specified default size, and stores the
41    size of the dialog at its finished() signal.
42
43    Call this method at the end of the dialog constructor, when its
44    widgets are instantiated.
45
46    """
47    size = QSettings().value(key, default, QSize)
48    if size:
49        dialog.resize(size)
50    dialogref = weakref.ref(dialog)
51    def save():
52        dialog = dialogref()
53        if dialog:
54            QSettings().setValue(key, dialog.size())
55    dialog.finished.connect(save)
56
57
58@contextlib.contextmanager
59def signalsBlocked(*objs):
60    """Blocks the signals of the given QObjects and then returns a contextmanager"""
61    blocks = [obj.blockSignals(True) for obj in objs]
62    try:
63        yield
64    finally:
65        for obj, block in zip(objs, blocks):
66            obj.blockSignals(block)
67
68
69@contextlib.contextmanager
70def deleteLater(*qobjs):
71    """Performs code and calls deleteLater() when done on the specified QObjects."""
72    try:
73        yield
74    finally:
75        for obj in qobjs:
76            obj.deleteLater()
77
78
79def addAccelerators(actions, used=[]):
80    """Adds accelerators to the list of QActions (or QLabels used as buddy).
81
82    Actions that have accelerators are skipped, the accelerators that they use
83    are not used. This can be used for e.g. menus that are created on the fly.
84
85    used is a sequence of already used accelerators (in lower case).
86
87    """
88    # filter out the actions that already have an accelerator
89    todo = []
90    used = set(used)
91    for a in actions:
92        if a.text():
93            accel = getAccelerator(a.text())
94            used.add(accel) if accel else todo.append(a)
95
96    def finditers(action):
97        """Yields two-tuples (priority, re.finditer object).
98
99        The finditer object finds suitable accelerator positions.
100        The priority can be used if multiple actions want the same shortcut.
101
102        """
103        text = action.text()
104        if isinstance(action, QAction) and not action.shortcut().isEmpty():
105            # if the action has a shortcut with A-Z or 0-9, match that character
106            shortcut = action.shortcut()[action.shortcut().count()-1]
107            key = shortcut & ~Qt.ALT & ~Qt.SHIFT & ~Qt.CTRL & ~Qt.META
108            if 48 < key < 58 or 64 < key < 91 or 96 < key < 123:
109                yield 0, re.finditer(r'\b{0:c}'.format(key), text, re.I)
110        yield 1, re.finditer(r'\b\w', text)
111        yield 2, re.finditer(r'\B\w', text)
112
113    def find(action):
114        """Yields three-tuples (priority, pos, accel) from finditers()."""
115        for prio, matches in finditers(action):
116            for m in matches:
117                yield prio, m.start(), m.group().lower()
118
119    todo = [(a, find(a)) for a in todo]
120
121    while todo:
122        # just pick the first accel for every action
123        accels = {}
124        for a, source in todo:
125            for prio, pos, accel in source:
126                if accel not in used:
127                    accels.setdefault(accel, []).append((prio, pos, a, source))
128                    break
129
130        # now, fore every accel, if more than one action wants the same accel,
131        # pick the action with the first priority or position, and try again the
132        # other actions.
133        todo = []
134        used.update(accels)
135        for action_list in accels.values():
136            action_list.sort(key=lambda i: i[:2])
137            pos, a = action_list[0][1:3]
138            a.setText(a.text()[:pos] + '&' + a.text()[pos:])
139            todo.extend((a, source) for prio, pos, a, source in action_list[1:])
140
141
142def getAccelerator(text):
143    """Returns the accelerator (in lower case) contained in the text, if any.
144
145    An accelerator is a character preceded by an ampersand &.
146
147    """
148    m = re.search(r'&(\w)', text.replace('&&', ''))
149    if m:
150        return m.group(1).lower()
151
152
153def removeAccelerator(text):
154    """Removes accelerator ampersands from a QAction.text() string."""
155    return text.replace('&&', '\0').replace('&', '').replace('\0', '&')
156
157
158def removeShortcut(action, key):
159    """Removes matching QKeySequence from the list of the action."""
160    key = QKeySequence(key)
161    shortcuts = action.shortcuts()
162    for s in action.shortcuts():
163        if key.matches(s) or s.matches(key):
164            shortcuts.remove(s)
165    action.setShortcuts(shortcuts)
166
167
168def addcolor(color, r, g, b):
169    """Adds r, g and b values to the given color and returns a new QColor instance."""
170    r += color.red()
171    g += color.green()
172    b += color.blue()
173    d = max(r, g, b) - 255
174    if d > 0:
175        r = max(0, r - d)
176        g = max(0, g - d)
177        b = max(0, b - d)
178    return QColor(r, g, b)
179
180
181def mixcolor(color1, color2, mix):
182    """Returns a QColor as if color1 is painted on color2 with alpha value mix (0.0 - 1.0)."""
183    r1, g1, b1 = color1.red(), color1.green(), color1.blue()
184    r2, g2, b2 = color2.red(), color2.green(), color2.blue()
185    r = r1 * mix + r2 * (1 - mix)
186    g = g1 * mix + g2 * (1 - mix)
187    b = b1 * mix + b2 * (1 - mix)
188    return QColor(r, g, b)
189
190
191@contextlib.contextmanager
192def busyCursor(cursor=Qt.WaitCursor, processEvents=True):
193    """Performs the contained code using a busy cursor.
194
195    The default cursor used is Qt.WaitCursor.
196    If processEvents is True (the default), QApplication.processEvents()
197    will be called once before the contained code is executed.
198
199    """
200    QApplication.setOverrideCursor(cursor)
201    processEvents and QApplication.processEvents()
202    try:
203        yield
204    finally:
205        QApplication.restoreOverrideCursor()
206
207
208def waitForSignal(signal, message="", timeout=0):
209    """Waits (max timeout msecs if given) for a signal to be emitted.
210
211    It the waiting lasts more than 2 seconds, a progress dialog is displayed
212    with the message.
213
214    Returns True if the signal was emitted.
215    Return False if the wait timed out or the dialog was canceled by the user.
216
217    """
218    loop = QEventLoop()
219    dlg = QProgressDialog(minimum=0, maximum=0, labelText=message)
220    dlg.setWindowTitle(appinfo.appname)
221    dlg.setWindowModality(Qt.ApplicationModal)
222    QTimer.singleShot(2000, dlg.show)
223    dlg.canceled.connect(loop.quit)
224    if timeout:
225        QTimer.singleShot(timeout, dlg.cancel)
226    stop = lambda: loop.quit()
227    signal.connect(stop)
228    loop.exec_()
229    signal.disconnect(stop)
230    dlg.hide()
231    dlg.deleteLater()
232    return not dlg.wasCanceled()
233
234
235