1# Copyright (C) 2015-2020 Damon Lynch <damonlynch@gmail.com>
2
3# This file is part of Rapid Photo Downloader.
4#
5# Rapid Photo Downloader is free software: you can redistribute it and/or
6# modify it under the terms of the GNU General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# (at your option) any later version.
9#
10# Rapid Photo Downloader 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 Rapid Photo Downloader.  If not,
17# see <http://www.gnu.org/licenses/>.
18
19__author__ = 'Damon Lynch'
20__copyright__ = "Copyright 2015-2020, Damon Lynch"
21
22from typing import List, Dict, Tuple, Optional
23from collections import namedtuple
24from pkg_resources import parse_version
25import sys
26
27from PyQt5.QtWidgets import (
28    QStyleOptionFrame, QStyle, QStylePainter, QWidget, QLabel, QListWidget, QProxyStyle,
29    QStyleOption, QDialogButtonBox, QMessageBox
30)
31from PyQt5.QtGui import QFontMetrics, QFont, QPainter, QPixmap, QIcon, QGuiApplication
32from PyQt5.QtCore import QSize, Qt, QT_VERSION_STR, QPoint
33
34QT5_VERSION = parse_version(QT_VERSION_STR)
35
36from raphodo.constants import ScalingDetected
37import raphodo.xsettings as xsettings
38
39
40class RowTracker:
41    r"""
42    Simple class to map model rows to ids and vice versa, used in
43    table and list views.
44
45    >>> r = RowTracker()
46    >>> r[0] = 100
47    >>> r
48    {0: 100} {100: 0}
49    >>> r[1] = 110
50    >>> r[2] = 120
51    >>> len(r)
52    3
53    >>> r.insert_row(1, 105)
54    >>> r[1]
55    105
56    >>> r[2]
57    110
58    >>> len(r)
59    4
60    >>> 1 in r
61    True
62    >>> 3 in r
63    True
64    >>> 4 in r
65    False
66    >>> r.remove_rows(1)
67    [105]
68    >>> len(r)
69    3
70    >>> r[0]
71    100
72    >>> r[1]
73    110
74    >>> r.remove_rows(100)
75    []
76    >>> len(r)
77    3
78    >>> r.insert_row(0, 90)
79    >>> r[0]
80    90
81    >>> r[1]
82    100
83    """
84    def __init__(self) -> None:
85        self.row_to_id = {}  # type: Dict[int, int]
86        self.id_to_row = {}  # type: Dict[int, int]
87
88    def __getitem__(self, row) -> int:
89        return self.row_to_id[row]
90
91    def __setitem__(self, row, id_value) -> None:
92        self.row_to_id[row] = id_value
93        self.id_to_row[id_value] = row
94
95    def __len__(self) -> int:
96        return len(self.row_to_id)
97
98    def __contains__(self, row) -> bool:
99        return row in self.row_to_id
100
101    def __delitem__(self, row) -> None:
102        id_value = self.row_to_id[row]
103        del self.row_to_id[row]
104        del self.id_to_row[id_value]
105
106    def __repr__(self) -> str:
107        return '%r %r' % (self.row_to_id, self.id_to_row)
108
109    def __str__(self) -> str:
110        return 'Row to id: %r\nId to row: %r' % (self.row_to_id, self.id_to_row)
111
112    def row(self, id_value) -> int:
113        """
114        :param id_value: the ID, e.g. scan_id, uid, row_id
115        :return: the row associated with the ID
116        """
117        return self.id_to_row[id_value]
118
119    def insert_row(self, position: int, id_value) -> List:
120        """
121        Inserts row into the model at the given position, assigning
122        the id_id_value.
123
124        :param position: the position of the first row to insert
125        :param id_value: the id to be associated with the new row
126        """
127
128        ids = [id_value for row, id_value in self.row_to_id.items() if row < position]
129        ids_to_move = [id_value for row, id_value in self.row_to_id.items() if row >= position]
130        ids.append(id_value)
131        ids.extend(ids_to_move)
132        self.row_to_id = dict(enumerate(ids))
133        self.id_to_row =  dict(((y, x) for x, y in list(enumerate(ids))))
134
135    def remove_rows(self, position, rows=1) -> List:
136        """
137        :param position: the position of the first row to remove
138        :param rows: how many rows to remove
139        :return: the ids of those rows which were removed
140        """
141        final_pos = position + rows - 1
142        ids_to_keep = [id_value for row, id_value in self.row_to_id.items() if
143                       row < position or row > final_pos]
144        ids_to_remove = [idValue for row, idValue in self.row_to_id.items() if
145                         row >= position and row <= final_pos]
146        self.row_to_id = dict(enumerate(ids_to_keep))
147        self.id_to_row =  dict(((y, x) for x, y in list(enumerate(ids_to_keep))))
148        return ids_to_remove
149
150
151ThumbnailDataForProximity = namedtuple(
152    'ThumbnailDataForProximity', 'uid, ctime, file_type, previously_downloaded'
153)
154
155
156class QFramedWidget(QWidget):
157    """
158    Draw a Frame around the widget in the style of the application.
159
160    Use this instead of using a stylesheet to draw a widget's border.
161    """
162
163    def paintEvent(self, *opts):
164        painter = QStylePainter(self)
165        option = QStyleOptionFrame()
166        option.initFrom(self)
167        painter.drawPrimitive(QStyle.PE_Frame, option)
168        super().paintEvent(*opts)
169
170
171class QFramedLabel(QLabel):
172    """
173    Draw a Frame around the label in the style of the application.
174
175    Use this instead of using a stylesheet to draw a label's border.
176    """
177
178    def paintEvent(self, *opts):
179        painter = QStylePainter(self)
180        option = QStyleOptionFrame()
181        option.initFrom(self)
182        painter.drawPrimitive(QStyle.PE_Frame, option)
183        super().paintEvent(*opts)
184
185
186class ProxyStyleNoFocusRectangle(QProxyStyle):
187    """
188    Remove the focus rectangle from a widget
189    """
190
191    def drawPrimitive(self, element: QStyle.PrimitiveElement,
192                      option: QStyleOption, painter: QPainter,
193                      widget: QWidget) -> None:
194
195        if QStyle.PE_FrameFocusRect == element:
196            pass
197        else:
198            super().drawPrimitive(element, option, painter, widget)
199
200
201class QNarrowListWidget(QListWidget):
202    """
203    Create a list widget that is not by default enormously wide.
204
205    See http://stackoverflow.com/questions/6337589/qlistwidget-adjust-size-to-content
206    """
207
208    def __init__(self, minimum_rows: int=0,
209                 minimum_width: int=0,
210                 no_focus_recentangle: bool=False,
211                 parent=None) -> None:
212        super().__init__(parent=parent)
213        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
214        self._minimum_rows = minimum_rows
215        self._minimum_width = minimum_width
216        if no_focus_recentangle:
217            self.setStyle(ProxyStyleNoFocusRectangle())
218
219    @property
220    def minimum_width(self) -> int:
221        return self._minimum_width
222
223    @minimum_width.setter
224    def minimum_width(self, width: int) -> None:
225        self._minimum_width = width
226        self.updateGeometry()
227
228    def sizeHint(self):
229        s = QSize()
230        if self._minimum_rows:
231            s.setHeight(self.count() * self.sizeHintForRow(0) + self.frameWidth() * 2)
232        else:
233            s.setHeight(super().sizeHint().height())
234        s.setWidth(max(self.sizeHintForColumn(0) + self.frameWidth() * 2, self._minimum_width))
235        return s
236
237
238def standardIconSize() -> QSize:
239    size = QFontMetrics(QFont()).height() * 6
240    return QSize(size, size)
241
242
243# If set to True, do translation of QMessageBox and QDialogButtonBox buttons
244# Set at program startup
245Do_Message_And_Dialog_Box_Button_Translation = True
246
247
248def translateDialogBoxButtons(buttonBox: QDialogButtonBox) -> None:
249    if not Do_Message_And_Dialog_Box_Button_Translation:
250        return
251
252    buttons = (
253        (QDialogButtonBox.Ok, _('&OK')),
254        (QDialogButtonBox.Close, _('&Close') ),
255        (QDialogButtonBox.Cancel, _('&Cancel')),
256        (QDialogButtonBox.Save, _('&Save')),
257        (QDialogButtonBox.Help, _('&Help')),
258        (QDialogButtonBox.RestoreDefaults, _('Restore Defaults')),
259        (QDialogButtonBox.Yes, _('&Yes')),
260        (QDialogButtonBox.No, _('&No')),
261    )
262    for role, text in buttons:
263        button = buttonBox.button(role)
264        if button:
265            button.setText(text)
266
267
268def translateMessageBoxButtons(messageBox: QMessageBox) -> None:
269    if not Do_Message_And_Dialog_Box_Button_Translation:
270        return
271
272    buttons = (
273        (QMessageBox.Ok, _('&OK')),
274        (QMessageBox.Close, _('&Close') ),
275        (QMessageBox.Cancel, _('&Cancel')),
276        (QMessageBox.Save, _('&Save')),
277        (QMessageBox.Yes, _('&Yes')),
278        (QMessageBox.No, _('&No')),
279    )
280    for role, text in buttons:
281        button = messageBox.button(role)
282        if button:
283            button.setText(text)
284
285
286def standardMessageBox(message: str,
287                       rich_text: bool,
288                       standardButtons: QMessageBox.StandardButton,
289                       defaultButton: Optional[QMessageBox.StandardButton]=None,
290                       parent=None,
291                       title: Optional[str]=None,
292                       icon: Optional[QIcon]=None,
293                       iconPixmap: Optional[QPixmap]=None,
294                       iconType: Optional[QMessageBox.Icon]=None) -> QMessageBox:
295    """
296    Create a QMessageBox to be displayed to the user.
297
298    :param message: the text to display
299    :param rich_text: whether it text to display is in HTML format
300    :param standardButtons: or'ed buttons or button to display (Qt style)
301    :param defaultButton: if specified, set this button to be the default
302    :param parent: parent widget,
303    :param title: optional title for message box, else defaults to
304     localized 'Rapid Photo Downloader'
305    :param iconType: type of QMessageBox.Icon to display. If standardButtons
306     are equal to QMessageBox.Yes | QMessageBox.No, then QMessageBox.Question
307     will be assigned to iconType
308    :param iconPixmap: icon to display, in QPixmap format. Used only if
309    iconType is None
310    :param icon: icon to display, in QIcon format. Used only if iconType is
311    None
312    :return: the message box
313    """
314
315    msgBox = QMessageBox(parent)
316    if title is None:
317        title = _("Rapid Photo Downloader")
318    if rich_text:
319        msgBox.setTextFormat(Qt.RichText)
320    msgBox.setWindowTitle(title)
321    msgBox.setText(message)
322
323    msgBox.setStandardButtons(standardButtons)
324    if defaultButton:
325        msgBox.setDefaultButton(defaultButton)
326    translateMessageBoxButtons(messageBox=msgBox)
327
328    if iconType is None:
329        if standardButtons == QMessageBox.Yes | QMessageBox.No:
330            iconType = QMessageBox.Question
331
332    if iconType:
333        msgBox.setIcon(iconType)
334    else:
335        if iconPixmap is None:
336            if icon:
337                iconPixmap = icon.pixmap(standardIconSize())
338            else:
339                iconPixmap = QIcon(':/rapid-photo-downloader.svg').pixmap(standardIconSize())
340        msgBox.setIconPixmap(iconPixmap)
341
342    return msgBox
343
344
345def qt5_screen_scale_environment_variable() -> str:
346    """
347    Get application scaling environment variable applicable to version of Qt 5
348    See https://doc.qt.io/qt-5/highdpi.html#high-dpi-support-in-qt
349
350    Assumes Qt >= 5.4
351
352    :return: correct variable
353    """
354
355    if QT5_VERSION < parse_version('5.14.0'):
356        return 'QT_AUTO_SCREEN_SCALE_FACTOR'
357    else:
358        return 'QT_ENABLE_HIGHDPI_SCALING'
359
360
361def validateWindowSizeLimit(available: QSize, desired: QSize) -> Tuple[bool, QSize]:
362    """"
363    Validate the window size to ensure it fits within the available screen size.
364
365    Important if scaling makes the saved values invalid.
366
367    :param available: screen geometry available for use by applications
368    :param desired: size as requested by Rapid Photo Downloader
369    :return: bool indicating whether size was valid, and the (possibly
370     corrected) size
371    """
372
373    width_valid = desired.width() <= available.width()
374    height_valid = desired.height() <= available.height()
375    if width_valid and height_valid:
376        return True, desired
377    else:
378        return False, QSize(
379            min(desired.width(), available.width()), min(desired.height(), available.height())
380        )
381
382
383def validateWindowPosition(pos: QPoint, available: QSize, size: QSize) -> Tuple[bool, QPoint]:
384    """
385    Validate the window position to ensure it will be displayed in the screen.
386
387    Important if scaling makes the saved values invalid.
388
389    :param pos: saved position
390    :param available: screen geometry available for use by applications
391    :param size: main window size
392    :return: bool indicating whether the position was valid, and the
393     (possibly corrected) position
394    """
395
396    x_valid = available.width() - size.width() >= pos.x()
397    y_valid = available.height() - size.height() >= pos.y()
398    if x_valid and y_valid:
399        return True, pos
400    else:
401        return False, QPoint(
402            available.width() - size.width(), available.height() - size.height()
403        )
404
405
406def scaledPixmap(path: str, scale: float) -> QPixmap:
407    pixmap = QPixmap(path)
408    if scale > 1.0:
409        pixmap = pixmap.scaledToWidth(pixmap.width() * scale, Qt.SmoothTransformation)
410        pixmap.setDevicePixelRatio(scale)
411    return pixmap
412
413
414def standard_font_size(shrink_on_odd: bool=True) -> int:
415    h = QFontMetrics(QFont()).height()
416    if h % 2 == 1:
417        if shrink_on_odd:
418            h -= 1
419        else:
420            h += 1
421    return h
422
423
424def scaledIcon(path: str, size: Optional[QSize]=None) -> QIcon:
425    """
426    Create a QIcon that scales well
427    Uses .addFile()
428
429    :param path:
430    :param scale:
431    :param size:
432    :return:
433    """
434    i = QIcon()
435    if size is None:
436        s = standard_font_size()
437        size = QSize(s, s)
438    i.addFile(path, size)
439    return i
440
441
442def screen_scaled_xsettings() -> bool:
443    """
444    Use xsettings to detect if screen scaling is on.
445
446    No error checking.
447
448    :return: True if detected, False otherwise
449    """
450
451    x11 = xsettings.get_xsettings()
452    return x11.get(b'Gdk/WindowScalingFactor', 1) > 1
453
454
455def any_screen_scaled_qt() -> bool:
456    """
457    Detect if any of the screens on this system have scaling enabled.
458
459    Call before QApplication is initialized. Uses temporary QGuiApplication.
460
461    :return: True if found, else False
462    """
463
464    app = QGuiApplication(sys.argv)
465    ratio = app.devicePixelRatio()
466    del app
467
468    return ratio > 1.0
469
470
471def any_screen_scaled() -> Tuple[ScalingDetected, bool]:
472    """
473    Detect if any of the screens on this system have scaling enabled.
474
475    Uses Qt and xsettings to do detection.
476
477    :return: True if found, else False
478    """
479
480    qt_detected_scaling = any_screen_scaled_qt()
481    try:
482        xsettings_detected_scaling = screen_scaled_xsettings()
483        xsettings_running = True
484    except:
485        xsettings_detected_scaling = False
486        xsettings_running = False
487
488    if qt_detected_scaling:
489        if xsettings_detected_scaling:
490            return ScalingDetected.Qt_and_Xsetting, xsettings_running
491        return ScalingDetected.Qt, xsettings_running
492    if xsettings_detected_scaling:
493        return ScalingDetected.Xsetting, xsettings_running
494    return ScalingDetected.undetected, xsettings_running
495
496