# Copyright (C) 2015-2018 Damon Lynch # Copyright (c) 2012-2014 Alexander Turkin # This file is part of Rapid Photo Downloader. # # Rapid Photo Downloader is free software: you can redistribute it and/or # modify it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Rapid Photo Downloader is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Rapid Photo Downloader. If not, # see . """ Display details of devices like cameras, external drives and folders on the computer. See devices.py for an explanation of what "Device" means in the context of Rapid Photo Downloader. Spinner code is derived from QtWaitingSpinner source, which is under the MIT License: https://github.com/snowwlex/QtWaitingSpinner Copyright notice from QtWaitingSpinner source: Original Work Copyright (c) 2012-2014 Alexander Turkin Modified 2014 by William Hallatt Modified 2015 by Jacob Dawid Ported to Python3 2015 by Luca Weiss """ __author__ = 'Damon Lynch' __copyright__ = "Copyright 2015-2018, Damon Lynch" import math from collections import namedtuple, defaultdict from typing import Optional, Dict, List, Set import logging from pprint import pprint from gettext import gettext as _ from PyQt5.QtCore import (QModelIndex, QSize, Qt, QPoint, QRect, QRectF, QEvent, QAbstractItemModel, QAbstractListModel, pyqtSlot, QTimer) from PyQt5.QtWidgets import (QStyledItemDelegate, QStyleOptionViewItem, QApplication, QStyle, QListView, QStyleOptionButton, QAbstractItemView, QMenu, QWidget, QStyleOptionToolButton) from PyQt5.QtGui import (QPainter, QFontMetrics, QFont, QColor, QLinearGradient, QBrush, QPalette, QPixmap, QPaintEvent, QGuiApplication, QPen, QIcon) from raphodo.viewutils import RowTracker from raphodo.constants import (DeviceState, FileType, CustomColors, DeviceType, Roles, EmptyViewHeight, ViewRowType, minPanelWidth, Checked_Status, DeviceDisplayPadding, DeviceShadingIntensity, DisplayingFilesOfType, DownloadStatus, DownloadWarning, DownloadFailure) from raphodo.devices import Device, display_devices from raphodo.utilities import thousands, format_size_for_user from raphodo.storage import StorageSpace from raphodo.rpdfile import make_key from raphodo.menubutton import MenuButton def icon_size() -> int: height = QFontMetrics(QFont()).height() if height % 2 == 1: height = height + 1 return height number_spinner_lines = 10 revolutions_per_second = 1 class DeviceModel(QAbstractListModel): """ Stores Device / This Computer data. One Device is displayed has multiple rows: 1. Header row 2. One or two rows displaying storage info, depending on how many storage devices the device has (i.e. memory cards or perhaps a combo of onboard flash memory and additional storage) Therefore must map rows to device and back, which is handled by a row having a row id, and row ids being linked to a scan id. """ def __init__(self, parent, device_display_type: str) -> None: super().__init__(parent) self.rapidApp = parent self.device_display_type = device_display_type # scan_id: Device self.devices = {} # type: Dict[int, Device] # scan_id: DeviceState self.spinner_state = {} # type: Dict[int, DeviceState] # scan_id: bool self.checked = defaultdict(lambda: True) # type: Dict[int, bool] self.icons = {} # type: Dict[int, QPixmap] self.rows = RowTracker() # type: RowTracker self.row_id_counter = 0 # type: int self.row_id_to_scan_id = dict() # type: Dict[int, int] self.scan_id_to_row_ids = defaultdict(list) # type: Dict[int, List[int]] self.storage= dict() # type: Dict[int, StorageSpace] self.headers = set() # type: Set[int] self.icon_size = icon_size() self.row_ids_active = [] # type: List[int] # scan_id: 0.0-1.0 self.percent_complete = defaultdict(float) # type: Dict[int, float] self._rotation_position = 0 # type: int self._timer = QTimer(self) self._timer.setInterval(1000 / (number_spinner_lines * revolutions_per_second)) self._timer.timeout.connect(self.rotateSpinner) self._isSpinning = False def columnCount(self, parent=QModelIndex()): return 1 def rowCount(self, parent=QModelIndex()): return len(self.rows) def insertRows(self, position, rows=1, index=QModelIndex()): self.beginInsertRows(QModelIndex(), position, position + rows - 1) self.endInsertRows() return True def removeRows(self, position, rows=1, index=QModelIndex()): self.beginRemoveRows(QModelIndex(), position, position + rows - 1) self.endRemoveRows() return True def addDevice(self, scan_id: int, device: Device) -> None: no_storage = max(len(device.storage_space), 1) no_rows = no_storage + 1 if len(device.storage_space): i = 0 start_row_id = self.row_id_counter + 1 for row_id in range(start_row_id, start_row_id + len(device.storage_space)): self.storage[row_id] = device.storage_space[i] i += 1 else: self.storage[self.row_id_counter + 1] = None self.headers.add(self.row_id_counter) self.row_ids_active.append(self.row_id_counter) row = self.rowCount() self.insertRows(row, no_rows) logging.debug("Adding %s to %s display with scan id %s at row %s", device.name(), self.device_display_type, scan_id, row) for row_id in range(self.row_id_counter, self.row_id_counter + no_rows): self.row_id_to_scan_id[row_id] = scan_id self.rows[row] = row_id self.scan_id_to_row_ids[scan_id].append(row_id) row += 1 self.row_id_counter += no_rows self.devices[scan_id] = device self.spinner_state[scan_id] = DeviceState.scanning self.icons[scan_id] = device.get_pixmap(QSize(self.icon_size, self.icon_size)) if self._isSpinning is False: self.startSpinners() def updateDeviceNameAndStorage(self, scan_id: int, device: Device) -> None: """ Update Cameras with updated storage information and display name as reported by libgphoto2. If number of storage devies is > 1, inserts additional rows for the camera. :param scan_id: id of the camera :param device: camera device """ row_ids = self.scan_id_to_row_ids[scan_id] if len(device.storage_space) > 1: # Add a new row after the current empty storage row row_id = row_ids[1] row = self.rows.row(row_id) logging.debug("Adding row %s for additional storage device for %s", row, device.display_name) for i in range(len(device.storage_space) - 1): row += 1 new_row_id = self.row_id_counter + i self.rows.insert_row(row, new_row_id) self.scan_id_to_row_ids[scan_id].append(new_row_id) self.row_id_to_scan_id[new_row_id] = scan_id self.row_id_counter += len(device.storage_space) - 1 for idx, storage_space in enumerate(device.storage_space): row_id = row_ids[idx + 1] self.storage[row_id] = storage_space row = self.rows.row(row_ids[0]) self.dataChanged.emit(self.index(row, 0), self.index(row + len(self.devices[scan_id].storage_space), 0)) def getHeaderRowId(self, scan_id: int) -> int: row_ids = self.scan_id_to_row_ids[scan_id] return row_ids[0] def removeDevice(self, scan_id: int) -> None: row_ids = self.scan_id_to_row_ids[scan_id] header_row_id = row_ids[0] row = self.rows.row(header_row_id) logging.debug("Removing %s rows from %s display, starting at row %s", len(row_ids), self.device_display_type, row) self.rows.remove_rows(row, len(row_ids)) del self.devices[scan_id] del self.spinner_state[scan_id] if scan_id in self.checked: del self.checked[scan_id] if header_row_id in self.row_ids_active: self.row_ids_active.remove(header_row_id) if len(self.row_ids_active) == 0: self.stopSpinners() self.headers.remove(header_row_id) del self.scan_id_to_row_ids[scan_id] for row_id in row_ids: del self.row_id_to_scan_id[row_id] self.removeRows(row, len(row_ids)) def updateDeviceScan(self, scan_id: int) -> None: row_id = self.scan_id_to_row_ids[scan_id][0] row = self.rows.row(row_id) # TODO perhaps optimize which storage space is updated self.dataChanged.emit(self.index(row + 1, 0), self.index(row + len(self.devices[scan_id].storage_space), 0)) def setSpinnerState(self, scan_id: int, state: DeviceState) -> None: row_id = self.getHeaderRowId(scan_id) row = self.rows.row(row_id) current_state = self.spinner_state[scan_id] current_state_active = current_state in (DeviceState.scanning, DeviceState.downloading) if current_state_active and state in (DeviceState.idle, DeviceState.finished): self.row_ids_active.remove(row_id) self.percent_complete[scan_id] = 0.0 if len(self.row_ids_active) == 0: self.stopSpinners() # Next line assumes spinners were started when a device was added elif not current_state_active and state == DeviceState.downloading: self.row_ids_active.append(row_id) if not self._isSpinning: self.startSpinners() self.spinner_state[scan_id] = state self.dataChanged.emit(self.index(row, 0), self.index(row, 0)) def data(self, index: QModelIndex, role=Qt.DisplayRole): if not index.isValid(): return None row = index.row() if row >= len(self.rows) or row < 0: return None if row not in self.rows: return None row_id = self.rows[row] scan_id = self.row_id_to_scan_id[row_id] if role == Qt.DisplayRole: if row_id in self.headers: return ViewRowType.header else: return ViewRowType.content elif role == Qt.CheckStateRole: return self.checked[scan_id] elif role == Roles.scan_id: return scan_id else: device = self.devices[scan_id] # type: Device if role == Qt.ToolTipRole: if device.device_type in (DeviceType.path, DeviceType.volume): return device.path elif role == Roles.device_details: return (device.display_name, self.icons[scan_id], self.spinner_state[scan_id], self._rotation_position, self.percent_complete[scan_id]) elif role == Roles.storage: return device, self.storage[row_id] elif role == Roles.device_type: return device.device_type elif role == Roles.download_statuses: return device.download_statuses return None def setData(self, index: QModelIndex, value, role: int) -> bool: if not index.isValid(): return False row = index.row() if row >= len(self.rows) or row < 0: return False row_id = self.rows[row] scan_id = self.row_id_to_scan_id[row_id] if role == Qt.CheckStateRole: # In theory, update checkbox immediately, as selecting a very large number of thumbnails # can take time. However the code is probably wrong, as it doesn't work: # self.setCheckedValue(checked=value, scan_id=scan_id, row=row, log_state_change=False) # QApplication.instance().processEvents() self.rapidApp.thumbnailModel.checkAll(value, scan_id=scan_id) return True return False def logState(self) -> None: if len(self.devices): logging.debug("-- Device Model for %s --", self.device_display_type) logging.debug("Known devices: %s", ', '.join(self.devices[device].display_name for device in self.devices)) for row in self.rows.row_to_id: row_id = self.rows.row_to_id[row] scan_id = self.row_id_to_scan_id[row_id] device = self.devices[scan_id] logging.debug('Row %s: %s', row, device.display_name) logging.debug("Spinner states: %s", ', '.join("%s: %s" % (self.devices[scan_id].display_name, self.spinner_state[scan_id].name) for scan_id in self.spinner_state)) logging.debug(', '.join('%s: %s' % (self.devices[scan_id].display_name, Checked_Status[self.checked[scan_id]]) for scan_id in self.checked)) def setCheckedValue(self, checked: Qt.CheckState, scan_id: int, row: Optional[int]=None, log_state_change: Optional[bool]=True) -> None: logging.debug("Setting %s checkbox to %s", self.devices[scan_id].display_name, Checked_Status[checked]) if row is None: row_id = self.scan_id_to_row_ids[scan_id][0] row = self.rows.row(row_id) self.checked[scan_id] = checked self.dataChanged.emit(self.index(row, 0),self.index(row, 0)) if log_state_change: self.logState() def startSpinners(self): self._isSpinning = True if not self._timer.isActive(): self._timer.start() self._rotation_position = 0 def stopSpinners(self): self._isSpinning = False if self._timer.isActive(): self._timer.stop() self._rotation_position = 0 @pyqtSlot() def rotateSpinner(self): self._rotation_position += 1 if self._rotation_position >= number_spinner_lines: self._rotation_position = 0 for row_id in self.row_ids_active: row = self.rows.row(row_id) self.dataChanged.emit(self.index(row, 0),self.index(row, 0)) class DeviceView(QListView): def __init__(self, rapidApp, parent=None) -> None: super().__init__(parent) self.rapidApp = rapidApp # Disallow the user from being able to select the table cells self.setSelectionMode(QAbstractItemView.NoSelection) self.view_width = minPanelWidth() # Assume view is always going to be placed into a splitter self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) self.setMouseTracking(True) self.entered.connect(self.rowEntered) def sizeHint(self): height = self.minimumHeight() return QSize(self.view_width, height) def minimumHeight(self) -> int: model = self.model() # type: DeviceModel if model.rowCount() > 0: height = 0 for row in range(self.model().rowCount()): row_height = self.sizeHintForRow(row) height += row_height height += len(model.headers) + 5 return height return EmptyViewHeight def minimumSizeHint(self): return self.sizeHint() @pyqtSlot(QModelIndex) def rowEntered(self, index: QModelIndex) -> None: if index.data() == ViewRowType.header and len(self.rapidApp.devices) > 1: scan_id = index.data(Roles.scan_id) self.rapidApp.thumbnailModel.highlightDeviceThumbs(scan_id=scan_id) BodyDetails = namedtuple('BodyDetails', 'bytes_total_text, bytes_total, ' 'percent_used_text, ' 'bytes_free_of_total, ' 'comp1_file_size_sum, comp2_file_size_sum, ' 'comp3_file_size_sum, comp4_file_size_sum, ' 'comp1_text, comp2_text, comp3_text, ' 'comp4_text, ' 'comp1_size_text, comp2_size_text, ' 'comp3_size_text, comp4_size_text, ' 'color1, color2, color3,' 'displaying_files_of_type') def standard_height(): return QFontMetrics(QFont()).height() def device_name_height(): return standard_height() + DeviceDisplayPadding * 3 def device_header_row_height(): return device_name_height() + DeviceDisplayPadding def device_name_highlight_color(): palette = QPalette() alternate_color = palette.alternateBase().color() return QColor(alternate_color).darker(105) class EmulatedHeaderRow(QWidget): """ When displaying a view of a destination or source folder, display an empty colored strip with no icon when the folder is not yet valid. """ def __init__(self, select_text: str) -> None: """ :param select_text: text to be displayed e.g. 'Select a destination folder' :return: """ super().__init__() self.setMinimumSize(1, device_header_row_height()) self.select_text = select_text palette = QPalette() palette.setColor(QPalette.Window, palette.color(palette.Base)) self.setAutoFillBackground(True) self.setPalette(palette) def paintEvent(self, event: QPaintEvent) -> None: painter = QPainter() painter.begin(self) rect = self.rect() # type: QRect rect.setHeight(device_name_height()) painter.fillRect(rect, device_name_highlight_color()) rect.adjust(DeviceDisplayPadding, 0, 0, 0) font = QFont() font.setItalic(True) painter.setFont(font) painter.drawText(rect, Qt.AlignLeft | Qt.AlignVCenter, self.select_text) painter.end() class DeviceDisplay: """ Graphically render the storage space, and photos and videos that are currently in it or will be downloaded into it. Used in list view by devices / this computer, and in destination custom widget. """ padding = DeviceDisplayPadding shading_intensity = DeviceShadingIntensity def __init__(self, menuButtonIcon: Optional[QIcon]=None) -> None: self.menuButtonIcon = menuButtonIcon self.sample1_width = self.sample2_width = 40 self.rendering_destination = True self.standard_font = QFont() # type: QFont self.standard_metrics = QFontMetrics(self.standard_font) self.standard_height = standard_height() self.icon_size = icon_size() self.small_font = QFont(self.standard_font) self.small_font.setPointSize(self.standard_font.pointSize() - 2) self.small_font_metrics = QFontMetrics(self.small_font) self.small_height = self.small_font_metrics.height() # Height of the graqient bar that visually shows storage use self.g_height = self.standard_height # Height of the details about the storage e.g. number of photos # videos, etc. self.details_height = self.small_font_metrics.height() * 2 + 2 self.view_width = minPanelWidth() self.device_name_highlight_color = device_name_highlight_color() self.storage_border = QColor('#bcbcbc') # Height of the colored box that includes the device's # spinner/checkbox, icon & name self.device_name_strip_height = device_name_height() self.device_name_height = device_header_row_height() self.icon_y_offset = (self.device_name_strip_height - self.icon_size) / 2 self.header_horizontal_padding = 8 self.icon_x_offset = 0 self.vertical_padding = 10 # Calculate height of storage details: # text above gradient, gradient, and text below # Base height is when there is no storage space to display self.base_height = (self.padding * 2 + self.standard_height) # Storage height is when there is storage space to display self.storage_height = (self.standard_height + self.padding + self.g_height + self.vertical_padding + self.details_height + self.padding * 2) self.emptySpaceColor = QColor('#f2f2f2') self.subtlePenColor = QColor('#6d6d6d') self.menu_button_padding = 3 self.menuHighlightPen = QPen(QBrush(self.subtlePenColor), 0.5) def v_align_header_pixmap(self, y: int, pixmap_height: int) -> float: return y + (self.device_name_strip_height / 2 - pixmap_height / 2) def paint_header(self, painter: QPainter, x: int, y: int, width: int, display_name: str, icon: QPixmap, highlight_menu: bool=False) -> None: """ Render the header portion, which contains the device / folder name, icon, and for download sources, a spinner or checkbox. If needed, draw a pixmap for for a drop-down menu. """ painter.setRenderHint(QPainter.Antialiasing, True) deviceNameRect = QRect(x, y, width, self.device_name_strip_height) painter.fillRect(deviceNameRect, self.device_name_highlight_color) icon_x = float(x + self.padding + self.icon_x_offset) icon_y = self.v_align_header_pixmap(y, self.icon_size) target = QRectF(icon_x, icon_y, self.icon_size, self.icon_size) source = QRectF(0, 0, self.icon_size, self.icon_size) painter.drawPixmap(target, icon, source) text_x = target.right() + self.header_horizontal_padding deviceNameRect.setLeft(text_x) painter.drawText(deviceNameRect, Qt.AlignLeft | Qt.AlignVCenter, display_name) if self.menuButtonIcon: size = icon_size() rect = self.menu_button_rect(x, y, width) if highlight_menu: pen = painter.pen() painter.setPen(self.menuHighlightPen) painter.drawRoundedRect(rect, 2.0, 2.0) painter.setPen(pen) button_x = rect.x() + self.menu_button_padding button_y = rect.y() + self.menu_button_padding pixmap = self.menuButtonIcon.pixmap(QSize(size, size)) painter.drawPixmap(button_x, button_y, pixmap) def menu_button_rect(self, x: int, y: int, width: int) -> QRectF: size = icon_size() + self.menu_button_padding * 2 button_x = x + width - size - self.padding button_y = y + self.device_name_strip_height / 2 - size / 2 return QRectF(button_x, button_y, size, size) def paint_body(self, painter: QPainter, x: int, y: int, width: int, details: BodyDetails) -> None: """ Render the usage portion, which contains basic storage space information, a colored bar with a gradient that visually represents allocation of the storage space, and details about the size and number of photos / videos. For download destinations, also displays excess usage. """ x = x + self.padding y = y + self.padding width = width - self.padding * 2 d = details painter.setRenderHint(QPainter.Antialiasing, False) painter.setFont(self.small_font) standard_pen_color = painter.pen().color() device_size_x = x device_size_y = y + self.standard_height - self.padding text_rect = QRect(device_size_x, y - self.padding, width, self.standard_height) if self.rendering_destination: # bytes free of total size e..g 123 MB free of 2 TB painter.drawText(text_rect, Qt.AlignLeft | Qt.AlignBottom, d.bytes_free_of_total) # Render the used space in the gradient bar before rendering the space # that will be taken by photos and videos comp1_file_size_sum = d.comp3_file_size_sum comp2_file_size_sum = d.comp1_file_size_sum comp3_file_size_sum = d.comp2_file_size_sum color1 = d.color3 color2 = d.color1 color3 = d.color2 else: # Device size e.g. 32 GB painter.drawText(text_rect, Qt.AlignLeft | Qt.AlignBottom, d.bytes_total_text) # Percent used e.g. 79% painter.drawText(text_rect, Qt.AlignRight | Qt.AlignBottom, d.percent_used_text) # Don't change the order comp1_file_size_sum = d.comp1_file_size_sum comp2_file_size_sum = d.comp2_file_size_sum comp3_file_size_sum = d.comp3_file_size_sum color1 = d.color1 color2 = d.color2 color3 = d.color3 skip_comp1 = d.displaying_files_of_type == DisplayingFilesOfType.videos skip_comp2 = d.displaying_files_of_type == DisplayingFilesOfType.photos skip_comp3 = d.comp3_size_text == 0 photos_g_x = device_size_x g_y = device_size_y + self.padding if d.bytes_total: photos_g_width = (comp1_file_size_sum / d.bytes_total * width) linearGradient = QLinearGradient(photos_g_x, g_y, photos_g_x, g_y + self.g_height) rect = QRectF(photos_g_x, g_y, width, self.g_height) # Apply subtle shade to empty space painter.fillRect(rect, self.emptySpaceColor) if comp1_file_size_sum and d.bytes_total: photos_g_rect = QRectF(photos_g_x, g_y, photos_g_width, self.g_height) linearGradient.setColorAt(0.2, color1.lighter(self.shading_intensity)) linearGradient.setColorAt(0.8, color1.darker(self.shading_intensity)) painter.fillRect(photos_g_rect, QBrush(linearGradient)) else: photos_g_width = 0 videos_g_x = photos_g_x + photos_g_width if comp2_file_size_sum and d.bytes_total: videos_g_width = (comp2_file_size_sum / d.bytes_total * width) videos_g_rect = QRectF(videos_g_x, g_y, videos_g_width, self.g_height) linearGradient.setColorAt(0.2, color2.lighter(self.shading_intensity)) linearGradient.setColorAt(0.8, color2.darker(self.shading_intensity)) painter.fillRect(videos_g_rect, QBrush(linearGradient)) else: videos_g_width = 0 if comp3_file_size_sum and d.bytes_total: other_g_width = comp3_file_size_sum / d.bytes_total * width other_g_x = videos_g_x + videos_g_width other_g_rect = QRectF(other_g_x, g_y, other_g_width, self.g_height) linearGradient.setColorAt(0.2, color3.lighter(self.shading_intensity)) linearGradient.setColorAt(0.8, color3.darker(self.shading_intensity)) painter.fillRect(other_g_rect, QBrush(linearGradient)) else: other_g_width = 0 if d.comp4_file_size_sum and d.bytes_total: # Excess usage, only for download destinations color4 = QColor(CustomColors.color6.value) comp4_g_width = d.comp4_file_size_sum / d.bytes_total * width comp4_g_x = x + width - comp4_g_width comp4_g_rect = QRectF(comp4_g_x, g_y, comp4_g_width, self.g_height) linearGradient.setColorAt(0.2, color4.lighter(self.shading_intensity)) linearGradient.setColorAt(0.8, color4.darker(self.shading_intensity)) painter.fillRect(comp4_g_rect, QBrush(linearGradient)) # Rectangle around spatial representation of sizes painter.setPen(self.storage_border) painter.drawRect(rect) bottom = rect.bottom() # Details text indicating number and size of components 1 & 2 gradient_width = 10 spacer = 3 details_y = bottom + self.vertical_padding painter.setFont(self.small_font) # Component 4 details # (excess usage, only displayed if the storage space is not sufficient) # ===================================================================== if d.comp4_file_size_sum: # Gradient comp4_g2_x = x comp4_g2_rect = QRect(comp4_g2_x, details_y, gradient_width, self.details_height) linearGradient = QLinearGradient(comp4_g2_x, details_y, comp4_g2_x, details_y + self.details_height) linearGradient.setColorAt(0.2, color4.lighter(self.shading_intensity)) linearGradient.setColorAt(0.8, color4.darker(self.shading_intensity)) painter.fillRect(comp4_g2_rect, QBrush(linearGradient)) painter.setPen(self.storage_border) painter.drawRect(comp4_g2_rect) # Text comp4_x = comp4_g2_x + gradient_width + spacer comp4_no_width = self.small_font_metrics.boundingRect(d.comp4_text).width() comp4_size_width = self.small_font_metrics.boundingRect(d.comp4_size_text).width() comp4_width = max(comp4_no_width, comp4_size_width, self.sample1_width) comp4_rect = QRect(comp4_x, details_y, comp4_width, self.details_height) painter.setPen(standard_pen_color) painter.drawText(comp4_rect, Qt.AlignLeft|Qt.AlignTop, d.comp4_text) painter.drawText(comp4_rect, Qt.AlignLeft|Qt.AlignBottom, d.comp4_size_text) photos_g2_x = comp4_rect.right() + 10 else: photos_g2_x = x # Component 1 details # =================== if not skip_comp1: # Gradient photos_g2_rect = QRect(photos_g2_x, details_y, gradient_width, self.details_height) linearGradient = QLinearGradient(photos_g2_x, details_y, photos_g2_x, details_y + self.details_height) linearGradient.setColorAt(0.2, d.color1.lighter(self.shading_intensity)) linearGradient.setColorAt(0.8, d.color1.darker(self.shading_intensity)) painter.fillRect(photos_g2_rect, QBrush(linearGradient)) painter.setPen(self.storage_border) painter.drawRect(photos_g2_rect) # Text photos_x = photos_g2_x + gradient_width + spacer photos_no_width = self.small_font_metrics.boundingRect(d.comp1_text).width() photos_size_width = self.small_font_metrics.boundingRect(d.comp1_size_text).width() photos_width = max(photos_no_width, photos_size_width, self.sample1_width) photos_rect = QRect(photos_x, details_y, photos_width, self.details_height) painter.setPen(standard_pen_color) painter.drawText(photos_rect, Qt.AlignLeft|Qt.AlignTop, d.comp1_text) painter.drawText(photos_rect, Qt.AlignLeft|Qt.AlignBottom, d.comp1_size_text) videos_g2_x = photos_rect.right() + 10 else: videos_g2_x = photos_g2_x # Component 2 details # =================== if not skip_comp2: # Gradient videos_g2_rect = QRect(videos_g2_x, details_y, gradient_width, self.details_height) linearGradient.setColorAt(0.2, d.color2.lighter(self.shading_intensity)) linearGradient.setColorAt(0.8, d.color2.darker(self.shading_intensity)) painter.fillRect(videos_g2_rect, QBrush(linearGradient)) painter.setPen(self.storage_border) painter.drawRect(videos_g2_rect) #Text videos_x = videos_g2_x + gradient_width + spacer videos_no_width = self.small_font_metrics.boundingRect(d.comp2_text).width() videos_size_width = self.small_font_metrics.boundingRect(d.comp2_size_text).width() videos_width = max(videos_no_width, videos_size_width, self.sample2_width) videos_rect = QRect(videos_x, details_y, videos_width, self.details_height) painter.setPen(standard_pen_color) painter.drawText(videos_rect, Qt.AlignLeft|Qt.AlignTop, d.comp2_text) painter.drawText(videos_rect, Qt.AlignLeft|Qt.AlignBottom, d.comp2_size_text) other_g2_x = videos_rect.right() + 10 else: other_g2_x = videos_g2_x if not skip_comp3 and (d.comp3_file_size_sum or self.rendering_destination): # Other details # ============= # Gradient other_g2_rect = QRect(other_g2_x, details_y, gradient_width, self.details_height) linearGradient.setColorAt(0.2, d.color3.lighter(self.shading_intensity)) linearGradient.setColorAt(0.8, d.color3.darker(self.shading_intensity)) painter.fillRect(other_g2_rect, QBrush(linearGradient)) painter.setPen(self.storage_border) painter.drawRect(other_g2_rect) #Text other_x = other_g2_x + gradient_width + spacer other_no_width = self.small_font_metrics.boundingRect(d.comp3_text).width() other_size_width = self.small_font_metrics.boundingRect(d.comp3_size_text).width() other_width = max(other_no_width, other_size_width) other_rect = QRect(other_x, details_y, other_width, self.details_height) painter.setPen(standard_pen_color) painter.drawText(other_rect, Qt.AlignLeft|Qt.AlignTop, d.comp3_text) painter.drawText(other_rect, Qt.AlignLeft|Qt.AlignBottom, d.comp3_size_text) class AdvancedDeviceDisplay(DeviceDisplay): """ Subclass to handle header for download devices/ This Computer """ def __init__(self, comp1_sample: str, comp2_sample: str): super().__init__() self.sample1_width = self.small_font_metrics.boundingRect(comp1_sample).width() self.sample2_width = self.small_font_metrics.boundingRect(comp2_sample).width() self.rendering_destination = False self.checkboxStyleOption = QStyleOptionButton() self.checkboxRect = QApplication.style().subElementRect( QStyle.SE_CheckBoxIndicator, self.checkboxStyleOption, None) # type: QRect self.checkbox_right = self.checkboxRect.right() self.checkbox_y_offset = (self.device_name_strip_height - self.checkboxRect.height()) // 2 # Spinner values self.spinner_color = QColor(Qt.black) self.spinner_roundness = 100.0 self.spinner_min_trail_opacity = 0.0 self.spinner_trail_fade_percent = 60.0 self.spinner_line_length = max(self.icon_size // 4, 4) self.spinner_line_width = self.spinner_line_length // 2 self.spinner_inner_radius = self.icon_size // 2 - self.spinner_line_length self.icon_x_offset = self.icon_size + self.header_horizontal_padding self.downloadedPixmap = QPixmap(':/downloaded.png') self.downloadedWarningPixmap = QPixmap(':/downloaded-with-warning.png') self.downloadedErrorPixmap = QPixmap(':/downloaded-with-error.png') self.downloaded_icon_y = self.v_align_header_pixmap(0, self.downloadedPixmap.height()) palette = QGuiApplication.instance().palette() color = palette.highlight().color() self.progressBarPen = QPen(QBrush(color), 2.0) def paint_header(self, painter: QPainter, x: int, y: int, width: int, display_name: str, icon: QPixmap, device_state: DeviceState, rotation: int, checked: bool, download_statuses: Set[DownloadStatus], percent_complete: float) -> None: standard_pen_color = painter.pen().color() super().paint_header(painter=painter, x=x, y=y, width=width, display_name=display_name, icon=icon) if device_state == DeviceState.finished: # indicate that no more files can be downloaded from the device, and if there # were any errors or warnings if download_statuses & DownloadFailure: pixmap = self.downloadedErrorPixmap elif download_statuses & DownloadWarning: pixmap = self.downloadedWarningPixmap else: pixmap = self.downloadedPixmap painter.drawPixmap(x + self.padding, y + self.downloaded_icon_y, pixmap) elif device_state not in (DeviceState.scanning, DeviceState.downloading): checkboxStyleOption = QStyleOptionButton() if checked == Qt.Checked: checkboxStyleOption.state |= QStyle.State_On elif checked == Qt.PartiallyChecked: checkboxStyleOption.state |= QStyle.State_NoChange else: checkboxStyleOption.state |= QStyle.State_Off checkboxStyleOption.state |= QStyle.State_Enabled checkboxStyleOption.rect = self.getCheckBoxRect(x, y) QApplication.style().drawControl(QStyle.CE_CheckBox, checkboxStyleOption, painter) else: x = x + self.padding y = y + self.padding # Draw spinning widget painter.setPen(Qt.NoPen) for i in range(0, number_spinner_lines): painter.save() painter.translate(x + self.spinner_inner_radius + self.spinner_line_length, y + 1 + self.spinner_inner_radius + self.spinner_line_length) rotateAngle = float(360 * i) / float(number_spinner_lines) painter.rotate(rotateAngle) painter.translate(self.spinner_inner_radius, 0) distance = self.lineCountDistanceFromPrimary(i, rotation) color = self.currentLineColor(distance) painter.setBrush(color) rect = QRect(0, -self.spinner_line_width / 2, self.spinner_line_length, self.spinner_line_width) painter.drawRoundedRect(rect, self.spinner_roundness, self.spinner_roundness, Qt.RelativeSize) painter.restore() if percent_complete: painter.setPen(self.progressBarPen) x1 = x - self.padding y = y - self.padding + self.device_name_strip_height - 1 x2 = x1 + percent_complete * width painter.drawLine(x1, y, x2, y) painter.setPen(Qt.SolidLine) painter.setPen(standard_pen_color) def paint_alternate(self, painter: QPainter, x: int, y: int, text: str) -> None: standard_pen_color = painter.pen().color() painter.setPen(standard_pen_color) painter.setFont(self.small_font) probing_y = y + self.small_font_metrics.height() probing_x = x + self.padding painter.drawText(probing_x, probing_y, text) def lineCountDistanceFromPrimary(self, current, primary): distance = primary - current if distance < 0: distance += number_spinner_lines return distance def currentLineColor(self, countDistance: int) -> QColor: color = QColor(self.spinner_color) if countDistance == 0: return color minAlphaF = self.spinner_min_trail_opacity / 100.0 distanceThreshold = int(math.ceil((number_spinner_lines - 1) * self.spinner_trail_fade_percent / 100.0)) if countDistance > distanceThreshold: color.setAlphaF(minAlphaF) else: alphaDiff = color.alphaF() - minAlphaF gradient = alphaDiff / float(distanceThreshold + 1) resultAlpha = color.alphaF() - gradient * countDistance # If alpha is out of bounds, clip it. resultAlpha = min(1.0, max(0.0, resultAlpha)) color.setAlphaF(resultAlpha) return color def getLeftPoint(self, x: int, y: int) -> QPoint: return QPoint(x + self.padding, y + self.checkbox_y_offset) def getCheckBoxRect(self, x: int, y: int) -> QRect: return QRect(self.getLeftPoint(x, y), self.checkboxRect.size()) class DeviceDelegate(QStyledItemDelegate): padding = DeviceDisplayPadding other = _('Other') probing_text = _('Probing device...') shading_intensity = DeviceShadingIntensity def __init__(self, rapidApp, parent=None) -> None: super().__init__(parent) self.rapidApp = rapidApp sample_number = thousands(999) sample_no_photos = '{} {}'.format(sample_number, _('Photos')) sample_no_videos = '{} {}'.format(sample_number, _('Videos')) self.deviceDisplay = AdvancedDeviceDisplay(comp1_sample=sample_no_photos, comp2_sample=sample_no_videos) self.contextMenu = QMenu() self.ignoreDeviceAct = self.contextMenu.addAction(_('Temporarily ignore this device')) self.ignoreDeviceAct.triggered.connect(self.ignoreDevice) self.blacklistDeviceAct = self.contextMenu.addAction(_('Permanently ignore this device')) self.blacklistDeviceAct.triggered.connect(self.blacklistDevice) self.rescanDeviceAct = self.contextMenu.addAction(_('Rescan')) self.rescanDeviceAct.triggered.connect(self.rescanDevice) # store the index in which the user right clicked self.clickedIndex = None # type: QModelIndex @pyqtSlot() def ignoreDevice(self) -> None: index = self.clickedIndex if index: scan_id = index.data(Roles.scan_id) # type: int self.rapidApp.removeDevice(scan_id=scan_id, ignore_in_this_program_instantiation=True) self.clickedIndex = None # type: QModelIndex @pyqtSlot() def blacklistDevice(self) -> None: index = self.clickedIndex if index: scan_id = index.data(Roles.scan_id) # type: int self.rapidApp.blacklistDevice(scan_id=scan_id) self.clickedIndex = None # type: QModelIndex @pyqtSlot() def rescanDevice(self) -> None: index = self.clickedIndex if index: scan_id = index.data(Roles.scan_id) # type: int self.rapidApp.rescanDevice(scan_id=scan_id) self.clickedIndex = None # type: QModelIndex def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: painter.save() x = option.rect.x() y = option.rect.y() width = option.rect.width() view_type = index.data(Qt.DisplayRole) # type: ViewRowType if view_type == ViewRowType.header: display_name, icon, device_state, rotation, percent_complete = index.data( Roles.device_details) if device_state == DeviceState.finished: download_statuses = index.data(Roles.download_statuses) # type: Set[DownloadStatus] else: download_statuses = set() if device_state not in (DeviceState.scanning, DeviceState.downloading): checked = index.model().data(index, Qt.CheckStateRole) else: checked = None self.deviceDisplay.paint_header( painter=painter, x=x, y=y, width=width, rotation=rotation, icon=icon, device_state=device_state, display_name=display_name, checked=checked, download_statuses=download_statuses, percent_complete=percent_complete ) else: assert view_type == ViewRowType.content device, storage_space = index.data(Roles.storage) # type: Device, StorageSpace if storage_space is not None: if device.device_type == DeviceType.camera: photo_key = make_key(FileType.photo, storage_space.path) video_key = make_key(FileType.video, storage_space.path) sum_key = storage_space.path else: photo_key = FileType.photo video_key = FileType.video sum_key = None photos = _('%(no_photos)s Photos') % { 'no_photos': thousands(device.file_type_counter[photo_key])} videos = _('%(no_videos)s Videos') % { 'no_videos': thousands(device.file_type_counter[video_key])} photos_size = format_size_for_user(device.file_size_sum[photo_key]) videos_size = format_size_for_user(device.file_size_sum[video_key]) # Some devices do not report how many bytes total they have, e.g. some SMB shares if storage_space.bytes_total: other_bytes = storage_space.bytes_total - device.file_size_sum.sum(sum_key) - \ storage_space.bytes_free other_size = format_size_for_user(other_bytes) bytes_total_text = format_size_for_user( storage_space.bytes_total, no_decimals=0 ) bytes_used = storage_space.bytes_total-storage_space.bytes_free percent_used = '{0:.0%}'.format(bytes_used / storage_space.bytes_total) # Translators: percentage full e.g. 75% full percent_used = _('%s full') % percent_used bytes_total = storage_space.bytes_total else: percent_used = _('Device size unknown') bytes_total = device.file_size_sum.sum(sum_key) other_bytes = 0 bytes_total_text = format_size_for_user(bytes_total, no_decimals=0) other_size = '0' details = BodyDetails( bytes_total_text=bytes_total_text, bytes_total=bytes_total, percent_used_text=percent_used, bytes_free_of_total='', comp1_file_size_sum=device.file_size_sum[photo_key], comp2_file_size_sum=device.file_size_sum[video_key], comp3_file_size_sum=other_bytes, comp4_file_size_sum=0, comp1_text = photos, comp2_text = videos, comp3_text = self.other, comp4_text = '', comp1_size_text=photos_size, comp2_size_text=videos_size, comp3_size_text=other_size, comp4_size_text='', color1=QColor(CustomColors.color1.value), color2=QColor(CustomColors.color2.value), color3=QColor(CustomColors.color3.value), displaying_files_of_type=DisplayingFilesOfType.photos_and_videos ) self.deviceDisplay.paint_body( painter=painter, x=x, y=y, width=width, details=details ) else: assert len(device.storage_space) == 0 # Storage space not available, which for cameras means libgphoto2 is currently # still trying to access the device if device.device_type == DeviceType.camera: self.deviceDisplay.paint_alternate(painter=painter, x=x, y=y, text=self.probing_text) painter.restore() def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: view_type = index.data(Qt.DisplayRole) # type: ViewRowType if view_type == ViewRowType.header: height = self.deviceDisplay.device_name_height else: device, storage_space = index.data(Roles.storage) if storage_space is None: height = self.deviceDisplay.base_height else: height = self.deviceDisplay.storage_height return QSize(self.deviceDisplay.view_width, height) def editorEvent(self, event: QEvent, model: QAbstractItemModel, option: QStyleOptionViewItem, index: QModelIndex) -> bool: """ Change the data in the model and the state of the checkbox if the user presses the left mousebutton or presses Key_Space or Key_Select and this cell is editable. Otherwise do nothing. """ if (event.type() == QEvent.MouseButtonRelease or event.type() == QEvent.MouseButtonDblClick): if event.button() == Qt.RightButton: # Disable ignore and blacklist menus if the device is a This Computer path self.clickedIndex = index scan_id = index.data(Roles.scan_id) device_type = index.data(Roles.device_type) downloading = self.rapidApp.devices.downloading self.ignoreDeviceAct.setEnabled(device_type != DeviceType.path and scan_id not in downloading) self.blacklistDeviceAct.setEnabled(device_type != DeviceType.path and scan_id not in downloading) self.rescanDeviceAct.setEnabled(scan_id not in downloading) view = self.rapidApp.mapView(scan_id) globalPos = view.viewport().mapToGlobal(event.pos()) self.contextMenu.popup(globalPos) return False if event.button() != Qt.LeftButton or not self.deviceDisplay.getCheckBoxRect( option.rect.x(), option.rect.y()).contains(event.pos()): return False if event.type() == QEvent.MouseButtonDblClick: return True elif event.type() == QEvent.KeyPress: if event.key() != Qt.Key_Space and event.key() != Qt.Key_Select: return False else: return False # Change the checkbox-state self.setModelData(None, model, index) return True def setModelData (self, editor: QWidget, model: QAbstractItemModel, index: QModelIndex) -> None: newValue = not (index.model().data(index, Qt.CheckStateRole)) model.setData(index, newValue, Qt.CheckStateRole)