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