diff options
Diffstat (limited to 'raphodo/proximity.py')
-rw-r--r-- | raphodo/proximity.py | 2130 |
1 files changed, 2130 insertions, 0 deletions
diff --git a/raphodo/proximity.py b/raphodo/proximity.py new file mode 100644 index 0000000..b3875d3 --- /dev/null +++ b/raphodo/proximity.py @@ -0,0 +1,2130 @@ +# Copyright (C) 2015-2018 Damon Lynch <damonlynch@gmail.com> + +# 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 <http://www.gnu.org/licenses/>. + +__author__ = 'Damon Lynch' +__copyright__ = "Copyright 2015-2018, Damon Lynch" + +from collections import (namedtuple, defaultdict, deque, Counter) +from operator import attrgetter +import locale +from datetime import datetime +import logging +from itertools import groupby +import pickle +from pprint import pprint +import math +from typing import Dict, List, Tuple, Set, Optional, DefaultDict + +import arrow.arrow +from arrow.arrow import Arrow + +from gettext import gettext as _ + +from PyQt5.QtCore import ( + QAbstractTableModel, QModelIndex, Qt, QSize, QRect, QItemSelection, QItemSelectionModel, + QBuffer, QIODevice, pyqtSignal, pyqtSlot, QRectF, QPoint, +) +from PyQt5.QtWidgets import ( + QTableView, QStyledItemDelegate, QSlider, QLabel, QVBoxLayout, QStyleOptionViewItem, QStyle, + QAbstractItemView, QWidget, QHBoxLayout, QSizePolicy, QSplitter, QScrollArea, QStackedWidget, + QToolButton, QAction +) +from PyQt5.QtGui import ( + QPainter, QFontMetrics, QFont, QColor, QGuiApplication, QPixmap, QPalette, QMouseEvent, QIcon +) + +from raphodo.constants import ( + FileType, Align, proximity_time_steps, TemporalProximityState, fileTypeColor, CustomColors, + DarkGray, MediumGray, DoubleDarkGray +) +from raphodo.rpdfile import FileTypeCounter +from raphodo.preferences import Preferences +from raphodo.viewutils import ThumbnailDataForProximity, QFramedWidget, QFramedLabel +from raphodo.timeutils import locale_time, strip_zero, make_long_date_format, strip_am, strip_pm +from raphodo.utilities import runs +from raphodo.constants import Roles + +ProximityRow = namedtuple( + 'ProximityRow', 'year, month, weekday, day, proximity, new_file, tooltip_date_col0, ' + 'tooltip_date_col1, tooltip_date_col2' +) + +UidTime = namedtuple('UidTime', 'ctime, arrowtime, uid, previously_downloaded') + + +def humanize_time_span(start: Arrow, end: Arrow, + strip_leading_zero_from_time: bool=True, + insert_cr_on_long_line: bool=False, + long_format: bool=False) -> str: + r""" + Make times and time spans human readable. + + To run the doc test, install language packs for Russian, German and Chinese + in addition to English. See details in doctest. + + :param start: start time + :param end: end time + :param strip_leading_zero_from_time: strip all leading zeros + :param insert_cr_on_long_line: insert a carriage return on long + lines + :param long_format: if True, return result in long format + :return: tuple of time span to be read by humans, in short and long format + + >>> locale.setlocale(locale.LC_ALL, ('en_US', 'utf-8')) + 'en_US.UTF-8' + >>> start = arrow.Arrow(2015,11,3,9) + >>> end = start + >>> print(humanize_time_span(start, end)) + 9:00 AM + >>> print(humanize_time_span(start, end, long_format=True)) + Nov 3 2015, 9:00 AM + >>> print(humanize_time_span(start, end, False)) + 09:00 AM + >>> print(humanize_time_span(start, end, False, long_format=True)) + Nov 3 2015, 09:00 AM + >>> start = arrow.Arrow(2015,11,3,9,1,23) + >>> end = arrow.Arrow(2015,11,3,9,1,24) + >>> print(humanize_time_span(start, end)) + 9:01 AM + >>> print(humanize_time_span(start, end, long_format=True)) + Nov 3 2015, 9:01 AM + >>> start = arrow.Arrow(2015,11,3,9) + >>> end = arrow.Arrow(2015,11,3,10) + >>> print(humanize_time_span(start, end)) + 9:00 - 10:00 AM + >>> print(humanize_time_span(start, end, long_format=True)) + Nov 3 2015, 9:00 - 10:00 AM + >>> start = arrow.Arrow(2015,11,3,9) + >>> end = arrow.Arrow(2015,11,3,13) + >>> print(humanize_time_span(start, end)) + 9:00 AM - 1:00 PM + >>> print(humanize_time_span(start, end, long_format=True)) + Nov 3 2015, 9:00 AM - 1:00 PM + >>> start = arrow.Arrow(2015,11,3,12) + >>> print(humanize_time_span(start, end)) + 12:00 - 1:00 PM + >>> print(humanize_time_span(start, end, long_format=True)) + Nov 3 2015, 12:00 - 1:00 PM + >>> start = arrow.Arrow(2015,11,3,12, 59) + >>> print(humanize_time_span(start, end)) + 12:59 - 1:00 PM + >>> print(humanize_time_span(start, end, long_format=True)) + Nov 3 2015, 12:59 - 1:00 PM + >>> start = arrow.Arrow(2015,10,31,11,55) + >>> end = arrow.Arrow(2015,11,2,15,15) + >>> print(humanize_time_span(start, end)) + Oct 31, 11:55 AM - Nov 2, 3:15 PM + >>> print(humanize_time_span(start, end, long_format=True)) + Oct 31 2015, 11:55 AM - Nov 2 2015, 3:15 PM + >>> start = arrow.Arrow(2014,10,31,11,55) + >>> print(humanize_time_span(start, end)) + Oct 31 2014, 11:55 AM - Nov 2 2015, 3:15 PM + >>> print(humanize_time_span(start, end, long_format=True)) + Oct 31 2014, 11:55 AM - Nov 2 2015, 3:15 PM + >>> print(humanize_time_span(start, end, False)) + Oct 31 2014, 11:55 AM - Nov 2 2015, 03:15 PM + >>> print(humanize_time_span(start, end, False, long_format=True)) + Oct 31 2014, 11:55 AM - Nov 2 2015, 03:15 PM + >>> print(humanize_time_span(start, end, False, True)) + Oct 31 2014, 11:55 AM - + Nov 2 2015, 03:15 PM + >>> print(humanize_time_span(start, end, False, True, long_format=True)) + Oct 31 2014, 11:55 AM - Nov 2 2015, 03:15 PM + >>> locale.setlocale(locale.LC_ALL, ('ru_RU', 'utf-8')) + 'ru_RU.UTF-8' + >>> start = arrow.Arrow(2015,11,3,9) + >>> end = start + >>> print(humanize_time_span(start, end)) + 9:00 + >>> start = arrow.Arrow(2015,11,3,13) + >>> end = start + >>> print(humanize_time_span(start, end)) + 13:00 + >>> print(humanize_time_span(start, end, long_format=True)) + ноя 3 2015, 13:00 + >>> locale.setlocale(locale.LC_ALL, ('de_DE', 'utf-8')) + 'de_DE.UTF-8' + >>> start = arrow.Arrow(2015,12,18,13,15) + >>> end = start + >>> print(humanize_time_span(start, end)) + 13:15 + >>> print(humanize_time_span(start, end, long_format=True)) + Dez 18 2015, 13:15 + >>> end = start.shift(hours=1) + >>> print(humanize_time_span(start, end)) + 13:15 - 14:15 + >>> locale.setlocale(locale.LC_ALL, ('zh_CN', 'utf-8')) + 'zh_CN.UTF-8' + >>> start = arrow.Arrow(2015,12,18,19,59,33) + >>> end = start + >>> print(humanize_time_span(start, end)) + 下午 07时59分 + >>> end = start.shift(hours=1) + >>> print(humanize_time_span(start, end)) + 07时59分 - 下午 08时59分 + """ + + strip = strip_leading_zero_from_time + + if start.floor('minute') == end.floor('minute'): + short_format = strip_zero(locale_time(start.datetime), strip) + if not long_format: + return short_format + else: + # Translators: for example Nov 3 2015, 11:25 AM + return _('%(date)s, %(time)s') % dict( + date=make_long_date_format(start), + time=short_format + ) + + if start.floor('day') == end.floor('day'): + # both dates are on the same day + start_time = strip_zero(locale_time(start.datetime), strip) + end_time = strip_zero(locale_time(end.datetime), strip) + + if (start.hour < 12 and end.hour < 12): + # both dates are in the same morning + start_time = strip_am(start_time) + elif (start.hour >= 12 and end.hour >= 12): + start_time = strip_pm(start_time) + + time_span = _('%(starttime)s - %(endtime)s') % dict( + starttime=start_time, + endtime=end_time + ) + if not long_format: + # Translators: for example 9:00 AM - 3:55 PM + return time_span + else: + # Translators: for example Nov 3 2015, 11:25 AM + return _('%(date)s, %(time)s') % dict( + date=make_long_date_format(start), + time=time_span + ) + + # The start and end dates are on a different day + + # Translators: for example Nov 3 or Dec 31 + start_date = _('%(month)s %(numeric_day)s') % dict( + month=start.datetime.strftime('%b'), + numeric_day=start.format('D') + ) + end_date = _('%(month)s %(numeric_day)s') % dict( + month=end.datetime.strftime('%b'), + numeric_day=end.format('D') + ) + + if start.floor('year') != end.floor('year') or long_format: + # Translators: for example Nov 3 2015 + start_date = _('%(date)s %(year)s') % dict(date=start_date, year=start.year) + end_date = _('%(date)s %(year)s') % dict(date=end_date, year=end.year) + + # Translators: for example, Nov 3, 12:15 PM + start_datetime = _('%(date)s, %(time)s') % dict( + date=start_date, time=strip_zero(locale_time(start.datetime), strip) + ) + end_datetime = _('%(date)s, %(time)s') % dict( + date=end_date, time=strip_zero(locale_time(end.datetime), strip) + ) + + if not insert_cr_on_long_line or long_format: + # Translators: for example, Nov 3, 12:15 PM - Nov 4, 1:00 AM + return _('%(earlier_time)s - %(later_time)s') % dict( + earlier_time=start_datetime, later_time=end_datetime + ) + else: + # Translators, for example: + # Nov 3 2012, 12:15 PM - + # Nov 4 2012, 1:00 AM + # (please keep the line break signified by \n) + return _('%(earlier_time)s -\n%(later_time)s') % dict( + earlier_time=start_datetime, later_time=end_datetime + ) + +FontKerning = namedtuple('FontKerning', 'font, kerning') + + +def monthFont() -> FontKerning: + font = QFont() + kerning = 1.2 + font.setPointSize(font.pointSize() - 2) + font.setLetterSpacing(QFont.PercentageSpacing, kerning * 100) + font.setStretch(QFont.SemiExpanded) + return FontKerning(font, kerning) + + +def weekdayFont() -> QFont: + font = QFont() + font.setPointSize(font.pointSize() - 3) + return font + + +def dayFont() -> QFont: + font = QFont() + font.setPointSize(font.pointSize() + 1) + return font + + +def proximityFont() -> QFont: + font = QFont() # type: QFont + font.setPointSize(font.pointSize() - 2) + return font + + +def invalidRowFont() -> QFont: + font = QFont() + font.setPointSize(font.pointSize() - 3) + return font + + +class ProximityDisplayValues: + """ + Temporal Proximity cell sizes. + + Calculated in different process to that of main window. + """ + + def __init__(self): + self.depth = None + self.row_heights = [] # type: List[int] + self.col_widths = None # type: Optional[Tuple[int]] + + # row : (width, height) + self.col0_sizes = {} # type: Dict[int, Tuple[int, int]] + self.c2_alignment = {} # type: Dict[int, Align] + self.c2_end_of_day = set() # type: Set[int] + self.c2_end_of_month = set() # type: Set[int] + self.c1_end_of_month = set() # type: Set[int] + + self.assign_fonts() + + # Column 0 - month + year + self.col0_padding = 20 + self.col0_center_space = 2 + self.col0_center_space_half = 1 + + # Column 1 - weekday + day + self.col1_center_space = 2 + self.col1_center_space_half = 1 + self.col1_padding = 10 + self.col1_v_padding = 50 + self.col1_v_padding_top = self.col1_v_padding_bot = self.col1_v_padding // 2 + + self.calculate_max_col1_size() + self.day_proportion = self.max_day_height / self.max_col1_text_height + self.weekday_proportion = self.max_weekday_height / self.max_col1_text_height + + # Column 2 - proximity value e.g. 1:00 - 1:45 PM + self.col2_new_file_dot = False + self.col2_new_file_dot_size = 4 + self.col2_new_file_dot_radius = self.col2_new_file_dot_size / 2 + self.col2_font_descent_adjust = self.proximityMetrics.descent() / 3 + self.col2_font_height_half = self.proximityMetrics.height() / 2 + self.col2_new_file_dot_left_margin = 6 + + if self.col2_new_file_dot: + self.col2_text_left_margin = ( + self.col2_new_file_dot_left_margin * 2 + self.col2_new_file_dot_size + ) + else: + self.col2_text_left_margin = 10 + self.col2_right_margin = 10 + self.col2_v_padding = 6 + self.col2_v_padding_half = 3 + + def assign_fonts(self) -> None: + self.proximityFont = proximityFont() + self.proximityFontPrevious = QFont(self.proximityFont) + self.proximityFontPrevious.setItalic(True) + self.proximityMetrics = QFontMetrics(self.proximityFont) + self.proximityMetricsPrevious = QFontMetrics(self.proximityFontPrevious) + mf = monthFont() + self.monthFont = mf.font + self.month_kerning = mf.kerning + self.monthMetrics = QFontMetrics(self.monthFont) + self.weekdayFont = weekdayFont() + self.dayFont = dayFont() + self.invalidRowFont = invalidRowFont() + self.invalidRowFontMetrics = QFontMetrics(self.invalidRowFont) + self.invalidRowHeightMin = self.invalidRowFontMetrics.height() + \ + self.proximityMetrics.height() + + def prepare_for_pickle(self) -> None: + self.proximityFont = self.proximityMetrics = None + self.proximityFontPrevious = self.proximityMetricsPrevious = None + self.monthFont = self.monthMetrics = None + self.weekdayFont = None + self.dayFont = None + self.invalidRowFont = self.invalidRowFontMetrics = None + + def get_month_size(self, month: str) -> QSize: + boundingRect = self.monthMetrics.boundingRect(month) # type: QRect + height = boundingRect.height() + width = int(boundingRect.width() * self.month_kerning) + size = QSize(width, height) + return size + + def get_month_text(self, month, year) -> str: + if self.depth == 3: + return _('%(month)s %(year)s') % dict(month=month.upper(), year=year) + else: + return month.upper() + + def column0Size(self, year: str, month: str) -> QSize: + # Don't return a cell size for empty cells that have been + # merged into the cell with content. + month = self.get_month_text(month, year) + size = self.get_month_size(month) + # Height and width are reversed because of the rotation + size.transpose() + return QSize(size.width() + self.col0_padding, size.height() + self.col0_padding) + + def calculate_max_col1_size(self) -> None: + """ + Determine largest size for column 1 cells. + + Column 1 cell sizes are fixed. + """ + + dayMetrics = QFontMetrics(dayFont()) + day_width = 0 + day_height = 0 + for day in range(10, 32): + rect = dayMetrics.boundingRect(str(day)) + day_width = max(day_width, rect.width()) + day_height = max(day_height, rect.height()) + + self.max_day_height = day_height + self.max_day_width = day_width + + weekday_width = 0 + weekday_height = 0 + weekdayMetrics = QFontMetrics(weekdayFont()) + for i in range(1, 7): + dt = datetime(2015, 11, i) # Year and month are totally irrelevant, only want day + weekday = dt.strftime('%a').upper() + rect = weekdayMetrics.boundingRect(str(weekday)) + weekday_width = max(weekday_width, rect.width()) + weekday_height = max(weekday_height, rect.height()) + + self.max_weekday_height = weekday_height + self.max_weekday_width = weekday_width + self.max_col1_text_height = weekday_height + day_height + self.col1_center_space + self.max_col1_text_width = max(weekday_width, day_width) + self.col1_width = self.max_col1_text_width + self.col1_padding + self.col1_height = self.max_col1_text_height + + def get_proximity_size(self, text: str) -> QSize: + text = text.split('\n') + width = height = 0 + for t in text: + boundingRect = self.proximityMetrics.boundingRect(t) # type: QRect + width = max(width, boundingRect.width()) + height += boundingRect.height() + size = QSize( + width + self.col2_text_left_margin + self.col2_right_margin, + height + self.col2_v_padding + ) + return size + + def calculate_row_sizes(self, rows: List[ProximityRow], + spans: List[Tuple[int, int, int]], + depth: int) -> None: + """ + Calculate row height and column widths. The latter is trivial, + the former far more complex. + + Assumptions: + * column 1 cell size is fixed + + :param rows: list of row details + :param spans: list of which rows & columns are spanned + :param depth: table depth + """ + + self.depth = depth + + # Phase 1: (1) identify minimal sizes for columns 0 and 2, and group the cells + # (2) assign alignment to column 2 cells + + spans_dict = {(row, column): row_span for column, row, row_span in spans} + next_span_start_c0 = next_span_start_c1 = 0 + + sizes = [] # type: List[Tuple[QSize, List[List[int]]]] + for row, value in enumerate(rows): + if next_span_start_c0 == row: + c0_size = self.column0Size(value.year, value.month) + self.col0_sizes[row] = (c0_size.width(), c0_size.height()) + c0_children = [] + sizes.append((c0_size, c0_children)) + c0_span = spans_dict.get((row, 0), 1) + next_span_start_c0 = row + c0_span + self.c2_end_of_month.add(row + c0_span - 1) + if next_span_start_c1 == row: + c1_children = [] + c0_children.append(c1_children) + c1_span = spans_dict.get((row, 1), 1) + next_span_start_c1 = row + c1_span + + c2_span = spans_dict.get((row + c1_span - 1, 2)) + if c1_span > 1: + self.c2_alignment[row] = Align.bottom + if c2_span is None: + self.c2_alignment[row + c1_span - 1] = Align.top + + if row + c1_span - 1 in self.c2_end_of_month: + self.c1_end_of_month.add(row) + + skip_c2_end_of_day = False + if c2_span: + final_day_in_c2_span = row + c1_span - 2 + c2_span + c1_span_in_c2_span_final_day = spans_dict.get((final_day_in_c2_span, 1)) + skip_c2_end_of_day = c1_span_in_c2_span_final_day is not None + + if not skip_c2_end_of_day: + self.c2_end_of_day.add(row + c1_span - 1) + + minimal_col2_size = self.get_proximity_size(value.proximity) + c1_children.append(minimal_col2_size) + + # Phase 2: determine column 2 cell sizes, and max widths + + c0_max_width = 0 + c2_max_width = 0 + for c0, c0_children in sizes: + c0_height = c0.height() + c0_max_width = max(c0_max_width, c0.width()) + c0_children_height = 0 + for c1_children in c0_children: + c1_children_height = sum(c2.height() for c2 in c1_children) + c2_max_width = max(c2_max_width, max(c2.width() for c2 in c1_children)) + extra = math.ceil(max(self.col1_height - c1_children_height, 0) / 2) + + # Assign in c1's v_padding to first and last child, and any extra + c2 = c1_children[0] # type: QSize + c2.setHeight(c2.height() + self.col1_v_padding_top + extra) + c2 = c1_children[-1] # type: QSize + c2.setHeight(c2.height() + self.col1_v_padding_bot + extra) + + c1_children_height += self.col1_v_padding_top + self.col1_v_padding_bot + extra * 2 + c0_children_height += c1_children_height + + extra = math.ceil(max(c0_height - c0_children_height, 0) / 2) + if extra: + c2 = c0_children[0][0] # type: QSize + c2.setHeight(c2.height() + extra) + c2 = c0_children[-1][-1] # type: QSize + c2.setHeight(c2.height() + extra) + + heights = [c2.height() for c1_children in c0_children for c2 in c1_children] + self.row_heights.extend(heights) + + self.col_widths = (c0_max_width, self.col1_width, c2_max_width) + + def assign_color(self, dominant_file_type: FileType) -> None: + self.tableColor = fileTypeColor(dominant_file_type) + self.tableColorDarker = self.tableColor.darker(110) + + +class MetaUid: + r""" + Stores unique ids for each table cell. + + Used first when generating the proximity table, and then when + displaying tooltips containing thumbnails. + + Operations are performed by tuple of (row, column) or simply + by column. + + + >>> m = MetaUid() + >>> m[(0 , 0)] = [b'0', b'1', b'2'] + >>> print(m) + MetaUid(({0: 3}, {}, {}) ({0: [b'0', b'1', b'2']}, {}, {})) + >>> m[[0, 0]] + [b'0', b'1', b'2'] + >>> m.trim() + >>> m[[0, 0]] + [b'0', b'2'] + >>> m.no_uids((0, 0)) + 3 + """ + + def __init__(self): + self._uids = tuple({} for i in (0, 1 ,2)) # type: Tuple[Dict[int, List[bytes, ...]]] + self._no_uids = tuple({} for i in (0, 1, 2)) # type: Tuple[Dict[int, int]] + self._col2_row_index = dict() # type: Dict[bytes, int] + + def __repr__(self): + return 'MetaUid(%r %r)' % (self._no_uids, self._uids) + + def __setitem__(self, key: Tuple[int, int], uids: List[bytes]) -> None: + row, col = key + assert row not in self._uids[col] + self._uids[col][row] = uids + self._no_uids[col][row] = len(uids) + for uid in uids: + self._col2_row_index[uid] = row + + def __getitem__(self, key: Tuple[int, int]) -> List[bytes]: + row, col = key + return self._uids[col][row] + + def trim(self) -> None: + """ + Remove unique ids unnecessary for table viewing. + + Don't, however, remove ids in col 2, as they're useful, e.g. + when manually marking a file as previously downloaded + """ + + for col in (0, 1): + for row in self._uids[col]: + uids = self._uids[col][row] + if len(uids) > 1: + self._uids[col][row] = [uids[0], uids[-1]] + + def no_uids(self, key: Tuple[int, int]) -> int: + """ + Number of unique ids the cell had before it was trimmed. + """ + + row, col = key + return self._no_uids[col][row] + + def uids(self, column: int) -> Dict[int, List[bytes]]: + return self._uids[column] + + def uid_to_col2_row(self, uid) -> int: + return self._col2_row_index[uid] + + def validate_rows(self, no_rows) -> Tuple[int]: + """ + Very simple validation test to see if all rows are present + in cols 2 or 1. + + :param no_rows: number of rows to validate + :return: Tuple of missing rows + """ + valid = [] + + col0, col1, col2 = self._uids + no_col0, no_col1, no_col2 = self._no_uids + + for i in range(no_rows): + msg0 = '' + msg1 = '' + if i not in col2 and i not in col1: + msg0 = '_uids' + if i not in no_col2 and i not in col1: + msg1 = '_no_uids' + if msg0 or msg1: + msg = ' and '.join((msg0, msg1)) + logging.error("%s: row %s is missing in %s", self.__class__.__name__, i, msg) + valid.append(i) + + return tuple(valid) + + +class TemporalProximityGroups: + """ + Generates values to be displayed in Timeline view. + + The Timeline has 3 columns: + + Col 0: the year and month + Col 1: the day of the month + C0l 3: the proximity groups + """ + + # @profile + def __init__(self, thumbnail_rows: List[ThumbnailDataForProximity], + temporal_span: int = 3600): + self.rows = [] # type: List[ProximityRow] + + self.invalid_rows = tuple() # type: Tuple[int] + + # Store uids for each table cell + self.uids = MetaUid() + + self.file_types_in_cell = dict() # type: Dict[Tuple[int, int], str] + times_by_proximity = defaultdict(list) # type: DefaultDict[int, Arrow] + + # The rows the user sees in column 2 can span more than one row of the Timeline. + # Each day always spans at least one row in the Timeline, possibly more. + + # group_no: no days spanned + day_spans_by_proximity = dict() # type: Dict[int, int] + # group_no: ( + uids_by_day_in_proximity_group = dict() # type: Dict[int, Tuple[[Tuple[int, int, int], List[bytes]]] + + # uid: (year, month, day) + year_month_day = dict() # type: Dict[bytes, Tuple[int, int, int]] + + # group_no: List[uid] + uids_by_proximity = defaultdict(list) # type: Dict[int, List[bytes, ...]] + # Determine if proximity group contains any files have not been previously downloaded + new_files_by_proximity = defaultdict(set) # type: Dict[int, Set[bool]] + + # Text that will appear in column 2 -- they proximity groups + text_by_proximity = deque() + + # (year, month, day): [uid, uid, ...] + self.day_groups = defaultdict(list) # type: DefaultDict[Tuple[int, int, int], List[bytes]] + # (year, month): [uid, uid, ...] + self.month_groups = defaultdict(list) # type: DefaultDict[Tuple[int, int], List[bytes]] + # year: [uid, uid, ...] + self.year_groups = defaultdict(list) # type: DefaultDict[int, List[bytes]] + + # How many columns the Timeline will display - don't display year when the only dates + # are from this year, for instance. + self._depth = None # type: Optional[int] + # Compared to right now, does the Timeline contain an entry from the previous year? + self._previous_year = False + # Compared to right now, does the Timeline contain an entry from the previous month? + self._previous_month = False + + # Tuple of (column, row, row_span): + self.spans = [] # type: List[Tuple[int, int, int]] + self.row_span_for_column_starts_at_row = {} # type: Dict[Tuple[int, int], int] + + # Associate Timeline cells with uids + # Timeline row: id + self.proximity_view_cell_id_col1 = {} # type: Dict[int, int] + # Timeline row: id + self.proximity_view_cell_id_col2 = {} # type: Dict[int, int] + # col1, col2, uid + self.col1_col2_uid = [] # type: List[Tuple[int, int, bytes]] + + if len(thumbnail_rows) == 0: + return + + file_types = (row.file_type for row in thumbnail_rows) + self.dominant_file_type = Counter(file_types).most_common()[0][0] + + self.display_values = ProximityDisplayValues() + + thumbnail_rows.sort(key=attrgetter('ctime')) + + # Generate an arrow date time for every timestamp we have + uid_times = [ + UidTime( + tr.ctime, arrow.get(tr.ctime).to('local'), tr.uid, tr.previously_downloaded + ) + for tr in thumbnail_rows + ] + + self.thumbnail_types = tuple(row.file_type for row in thumbnail_rows) + + now = arrow.now().to('local') + current_year = now.year + current_month = now.month + + # Phase 1: Associate unique ids with their year, month and day + for x in uid_times: + t = x.arrowtime # type: Arrow + year = t.year + month = t.month + day = t.day + + # Could use arrow.floor here, but it's extremely slow + self.day_groups[(year, month, day)].append(x.uid) + self.month_groups[(year, month)].append(x.uid) + self.year_groups[year].append(x.uid) + if year != current_year: + # the Timeline contains an entry from the previous year to now + self._previous_year = True + if month != current_month or self._previous_year: + # the Timeline contains an entry from the previous month to now + self._previous_month = True + # Remember this extracted value + year_month_day[x.uid] = year, month, day + + # Phase 2: Identify the proximity groups + group_no = 0 + prev = uid_times[0] + + times_by_proximity[group_no].append(prev.arrowtime) + uids_by_proximity[group_no].append(prev.uid) + new_files_by_proximity[group_no].add(not prev.previously_downloaded) + + if len(uid_times) > 1: + for current in uid_times[1:]: + ctime = current.ctime + if ctime - prev.ctime > temporal_span: + group_no += 1 + times_by_proximity[group_no].append(current.arrowtime) + uids_by_proximity[group_no].append(current.uid) + new_files_by_proximity[group_no].add(not current.previously_downloaded) + prev = current + + # Phase 3: Generate the proximity group's text that will appear in + # the right-most column and its tooltips. + + # Also calculate the days spanned by each proximity group. + # If the days spanned is greater than 1, meaning the number of calendar days + # in the proximity group is more than 1, then also keep a copy of the group + # where it is broken into separate calendar days + + # The iteration order doesn't really matter here, so can get away with the + # potentially unsorted output of dict.items() + for group_no, group in times_by_proximity.items(): + start = group[0] # type: Arrow + end = group[-1] # type: Arrow + + # Generate the text + short_form = humanize_time_span(start, end, insert_cr_on_long_line=True) + long_form = humanize_time_span(start, end, long_format=True) + text_by_proximity.append((short_form, long_form)) + + # Calculate the number of calendar days spanned by this proximity group + # e.g. 2015-12-1 12:00 - 2015-12-2 15:00 = 2 days + if len(group) > 1: + span = len(Arrow.span_range('day', start, end)) + day_spans_by_proximity[group_no] = span + if span > 1: + # break the proximity group members into calendar days + uids_by_day_in_proximity_group[group_no] = tuple( + (y_m_d, list(day)) + for y_m_d, day in groupby( + uids_by_proximity[group_no], year_month_day.get + ) + ) + else: + # start == end + day_spans_by_proximity[group_no] = 1 + + # Phase 4: Generate the rows to be displayed in the Timeline + + # Keep in mind, the rows the user sees in column 2 can span more than + # one calendar day. In such cases, column 1 will be associated with + # one or more Timeline rows, one or more of which may be visible only in + # column 1. + + timeline_row = -1 # index into each row in the Timeline + thumbnail_index = 0 # index into the + self.prev_row_month = (0, 0) + self.prev_row_day = (0, 0, 0) + + # Iterating through the groups in order is critical. Cannot use dict.items() here. + for group_no in range(len(day_spans_by_proximity)): + + span = day_spans_by_proximity[group_no] + + timeline_row += 1 + + proximity_group_times = times_by_proximity[group_no] + atime = proximity_group_times[0] # type: Arrow + uid = uids_by_proximity[group_no][0] # type: bytes + y_m_d = year_month_day[uid] + + col2_text, tooltip_col2_text = text_by_proximity.popleft() + new_file = any(new_files_by_proximity[group_no]) + + self.rows.append( + self.make_row( + atime=atime, + col2_text=col2_text, + new_file=new_file, + y_m_d= y_m_d, + timeline_row=timeline_row, + thumbnail_index=thumbnail_index, + tooltip_col2_text=tooltip_col2_text, + ) + ) + + uids = uids_by_proximity[group_no] + self.uids[(timeline_row, 2)] = uids + + # self.dump_row(group_no) + + if span == 1: + thumbnail_index += len(proximity_group_times) + continue + + thumbnail_index += len(uids_by_day_in_proximity_group[group_no][0]) + + # For any proximity groups that span more than one Timeline row because they span + # more than one calender day, add the day to the Timeline, with blank values + # for the proximity group (column 2). + i = 0 + for y_m_d, day in uids_by_day_in_proximity_group[group_no][1:]: + i += 1 + + timeline_row += 1 + thumbnail_index += len(uids_by_day_in_proximity_group[group_no][i]) + atime = arrow.get(*y_m_d) + + self.rows.append( + self.make_row( + atime=atime, + col2_text='', + new_file=new_file, + y_m_d=y_m_d, + timeline_row=timeline_row, + thumbnail_index=1, + tooltip_col2_text='' + ) + ) + # self.dump_row(group_no) + + # Phase 5: Determine the row spans for each column + column = -1 + for c in (0, 2, 4): + column += 1 + start_row = 0 + for timeline_row_index, row in enumerate(self.rows): + if row[c]: + row_count = timeline_row_index - start_row + if row_count > 1: + self.spans.append((column, start_row, row_count)) + start_row = timeline_row_index + self.row_span_for_column_starts_at_row[(timeline_row_index, column)] = start_row + + if start_row != len(self.rows) - 1: + self.spans.append((column, start_row, len(self.rows) - start_row)) + for timeline_row_index in range(start_row, len(self.rows)): + self.row_span_for_column_starts_at_row[(timeline_row_index, column)] = start_row + + assert len(self.row_span_for_column_starts_at_row) == len(self.rows) * 3 + + # Phase 6: Determine the height and width of each row + self.display_values.calculate_row_sizes(self.rows, self.spans, self.depth()) + + # Phase 7: Assign appropriate color to table + self.display_values.assign_color(self.dominant_file_type) + + # Phase 8: associate proximity table cells with uids + + uid_rows_c1 = {} + for proximity_view_cell_id, timeline_row_index in enumerate(self.uids.uids(1)): + self.proximity_view_cell_id_col1[timeline_row_index] = proximity_view_cell_id + uids = self.uids.uids(1)[timeline_row_index] + for uid in uids: + uid_rows_c1[uid] = proximity_view_cell_id + + uid_rows_c2 = {} + + for proximity_view_cell_id, timeline_row_index in enumerate(self.uids.uids(2)): + self.proximity_view_cell_id_col2[timeline_row_index] = proximity_view_cell_id + uids = self.uids.uids(2)[timeline_row_index] + for uid in uids: + uid_rows_c2[uid] = proximity_view_cell_id + + assert len(uid_rows_c2) == len(uid_rows_c1) == len(thumbnail_rows) + + self.col1_col2_uid = [ + (uid_rows_c1[row.uid], uid_rows_c2[row.uid], row.uid) for row in thumbnail_rows + ] + + # Assign depth before wiping values used to determine it + self.depth() + self.display_values.prepare_for_pickle() + + # Reduce memory use before pickle. Can save about 100MB with + # when working with approximately 70,000 thumbnails. + + self.uids.trim() + + self.day_groups = None + self.month_groups = None + self.year_groups = None + + self.thumbnail_types = None + + self.invalid_rows = self.validate() + if len(self.invalid_rows): + logging.error('Timeline validation failed') + else: + logging.info('Timeline validation passed') + + def make_file_types_in_cell_text(self, slice_start: int, slice_end: int) -> str: + c = FileTypeCounter(self.thumbnail_types[slice_start:slice_end]) + return c.summarize_file_count()[0] + + def make_row(self, atime: Arrow, + col2_text: str, + new_file: bool, + y_m_d: Tuple[int, int, int], + timeline_row: int, + thumbnail_index: int, + tooltip_col2_text: str) -> ProximityRow: + + atime_month = y_m_d[:2] + if atime_month != self.prev_row_month: + self.prev_row_month = atime_month + month = atime.datetime.strftime('%B') + year = atime.year + uids = self.month_groups[atime_month] + slice_end = thumbnail_index + len(uids) + self.file_types_in_cell[(timeline_row, 0)] = self.make_file_types_in_cell_text( + slice_start=thumbnail_index, slice_end=slice_end + ) + self.uids[(timeline_row, 0)] = uids + else: + month = year = '' + + if y_m_d != self.prev_row_day: + self.prev_row_day = y_m_d + numeric_day = atime.format('D') + weekday = atime.datetime.strftime('%a') + + self.uids[(timeline_row, 1)] = self.day_groups[y_m_d] + else: + weekday = numeric_day = '' + + month_day = _('%(month)s %(numeric_day)s') % dict( + month=atime.datetime.strftime('%b'), + numeric_day=atime.format('D') + ) + # Translators: for example Nov 2 2015 + tooltip_col1 = _('%(date)s %(year)s') % dict(date= month_day, year=atime.year) + # Translators: for example Nov 2015 + tooltip_col0 = _('%(month)s %(year)s') % dict( + month=atime.datetime.strftime('%b'), + year=atime.year + ) + + return ProximityRow( + year=year, month=month, weekday=weekday, day=numeric_day, proximity=col2_text, + new_file=new_file, tooltip_date_col0=tooltip_col0, tooltip_date_col1=tooltip_col1, + tooltip_date_col2=tooltip_col2_text + ) + + def __len__(self) -> int: + return len(self.rows) + + def dump_row(self, group_no, extra='') -> None: + row = self.rows[-1] + print(group_no, extra, row.day, row.proximity.replace('\n', ' ')) + + def __getitem__(self, row_number) -> ProximityRow: + return self.rows[row_number] + + def __setitem__(self, row_number, proximity_row: ProximityRow) -> None: + self.rows[row_number] = proximity_row + + def __iter__(self): + return iter(self.rows) + + def depth(self) -> int: + if self._depth is None: + if len(self.year_groups) > 1 or self._previous_year: + self._depth = 3 + elif len(self.month_groups) > 1 or self._previous_month: + self._depth = 2 + elif len(self.day_groups) > 1: + self._depth = 1 + else: + self._depth = 0 + return self._depth + + def __repr__(self) -> str: + return 'TemporalProximityGroups with {} rows and depth of {}'.format( + len(self.rows), self.depth() + ) + + def validate(self, thumbnailModel=None) -> Tuple[int]: + """ + Partial validation of proximity values + :return: + """ + + return self.uids.validate_rows(len(self.rows)) + + def uid_to_row(self, uid: bytes) -> int: + return self.uids.uid_to_col2_row(uid=uid) + + def row_uids(self, row: int) -> List[bytes]: + return self.uids[row, 2] + + +def base64_thumbnail(pixmap: QPixmap, size: QSize) -> str: + """ + Convert image into format useful for HTML data URIs. + + See https://css-tricks.com/data-uris/ + + :param pixmap: image to convert + :param size: size to scale to + :return: data in base 64 format + """ + + pixmap = pixmap.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + buffer = QBuffer() + buffer.open(QIODevice.WriteOnly) + # Quality 100 means uncompressed, which is faster. + pixmap.save(buffer, "PNG", quality=100) + return bytes(buffer.data().toBase64()).decode() + + +class TemporalProximityModel(QAbstractTableModel): + tooltip_image_size = QSize(90, 90) + + def __init__(self, rapidApp, groups: TemporalProximityGroups=None, parent=None) -> None: + super().__init__(parent) + self.rapidApp = rapidApp + self.groups = groups + + self.show_debug = False + logger = logging.getLogger() + for handler in logger.handlers: + # name set in iplogging.setup_main_process_logging() + if handler.name == 'console': + self.show_debug = handler.level <= logging.DEBUG + + self.force_show_debug = False # set to True to always display debug info in Timeline + + def columnCount(self, parent=QModelIndex()) -> int: + return 3 + + def rowCount(self, parent=QModelIndex()) -> int: + if self.groups: + return len(self.groups) + else: + return 0 + + def data(self, index: QModelIndex, role=Qt.DisplayRole): + if not index.isValid(): + return None + + row = index.row() + if row >= len(self.groups) or row < 0: + return None + + column = index.column() + if column < 0 or column > 3: + return None + proximity_row = self.groups[row] # type: ProximityRow + + if role == Qt.DisplayRole: + invalid_row = self.show_debug and row in self.groups.invalid_rows + invalid_rows = self.show_debug and len(self.groups.invalid_rows) > 0 or \ + self.force_show_debug + if column == 0: + return proximity_row.year, proximity_row.month + elif column == 1: + return proximity_row.weekday, proximity_row.day + else: + return proximity_row.proximity, proximity_row.new_file, invalid_row, invalid_rows + + elif role == Roles.uids: + prow = self.groups.row_span_for_column_starts_at_row[(row, 2)] + uids = self.groups.uids.uids(2)[prow] + return uids + + elif role == Qt.ToolTipRole: + thumbnails = self.rapidApp.thumbnailModel.thumbnails + + try: + + if column == 1: + uids = self.groups.uids.uids(1)[row] + length = self.groups.uids.no_uids((row, 1)) + date = proximity_row.tooltip_date_col1 + file_types= self.rapidApp.thumbnailModel.getTypeCountForProximityCell( + col1id=self.groups.proximity_view_cell_id_col1[row] + ) + elif column == 2: + prow = self.groups.row_span_for_column_starts_at_row[(row, 2)] + uids = self.groups.uids.uids(2)[prow] + length = self.groups.uids.no_uids((prow, 2)) + date = proximity_row.tooltip_date_col2 + file_types = self.rapidApp.thumbnailModel.getTypeCountForProximityCell( + col2id=self.groups.proximity_view_cell_id_col2[prow] + ) + else: + assert column == 0 + uids = self.groups.uids.uids(0)[row] + length = self.groups.uids.no_uids((row, 0)) + date = proximity_row.tooltip_date_col0 + file_types = self.groups.file_types_in_cell[row, column] + + except KeyError as e: + logging.exception('Error in Timeline generation') + self.debugDumpState() + return None + + pixmap = thumbnails[uids[0]] # type: QPixmap + + image = base64_thumbnail(pixmap, self.tooltip_image_size) + html_image1 = '<img src="data:image/png;base64,{}">'.format(image) + + if length == 1: + center = html_image2 = '' + else: + pixmap = thumbnails[uids[-1]] # type: QPixmap + image = base64_thumbnail(pixmap, self.tooltip_image_size) + if length == 2: + center = ' ' + else: + center = ' … ' + html_image2 = '<img src="data:image/png;base64,{}">'.format(image) + + tooltip = '{}<br>{} {} {}<br>{}'.format( + date, html_image1, center, html_image2, file_types + ) + return tooltip + + def debugDumpState(self, selected_rows_col1: List[int]=None, + selected_rows_col2: List[int]=None) -> None: + + thumbnailModel = self.rapidApp.thumbnailModel + logging.debug('%r', self.groups) + + # Print rows and values to the debugging output + if len(self.groups) < 20: + for row, prow in enumerate(self.groups.rows): + logging.debug('Row %s', row) + logging.debug('{} | {} | {}'.format(prow.year, prow.month, prow.day)) + for col in (0, 1, 2): + if row in self.groups.uids._uids[col]: + uids = self.groups.uids._uids[col][row] + files = ', '.join((thumbnailModel.rpd_files[uid].name for uid in uids)) + logging.debug('Col {}: {}'.format(col, files)) + + def updatePreviouslyDownloaded(self, uids: List[bytes]) -> None: + """ + Examine Timeline data to see if any Timeline rows should have their column 2 + formatting updated to reflect that there are no new files to be downloaded in + that particular row + :param uids: list of uids that have been manually marked as previously downloaded + """ + + processed_rows = set() # type: Set[int] + rows_to_update = [] + for uid in uids: + row = self.groups.uid_to_row(uid=uid) + if row not in processed_rows: + processed_rows.add(row) + row_uids = self.groups.row_uids(row) + logging.debug( + 'Examining row %s to see if any have not been previously downloaded', row + ) + if not self.rapidApp.thumbnailModel.anyFileNotPreviouslyDownloaded(uids=row_uids): + proximity_row = self.groups[row] # type: ProximityRow + self.groups[row] = proximity_row._replace(new_file=False) + rows_to_update.append(row) + logging.debug('Row %s will be updated to show it has no new files') + + if rows_to_update: + for first, last in runs(rows_to_update): + self.dataChanged.emit(self.index(first, 2), self.index(last, 2)) + + +class TemporalProximityDelegate(QStyledItemDelegate): + """ + Render table cell for Timeline. + + All cell size calculations are done prior to rendering. + + The table has 3 columns: + + - Col 0: month & year (col will be hidden if all dates are in the current month) + - Col 1: day e.g. 'Fri 16' + - Col 2: time(s), e.g. '5:09 AM', or '4:09 - 5:27 PM' + """ + + def __init__(self, parent=None) -> None: + super().__init__(parent) + + self.darkGray = QColor(DarkGray) + self.darkerGray = self.darkGray.darker(140) + # self.darkerGray = QColor(DoubleDarkGray) + self.midGray = QColor(MediumGray) + + # column 2 cell color is assigned in ProximityDisplayValues + + palette = QGuiApplication.instance().palette() + self.highlight = palette.highlight().color() + self.darkerHighlight = self.highlight.darker(110) + self.highlightText = palette.highlightedText().color() + + self.newFileColor = QColor(CustomColors.color7.value) + + self.dv = None # type: ProximityDisplayValues + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: + row = index.row() + column = index.column() + + if column == 0: + # Month and year + painter.save() + + if option.state & QStyle.State_Selected: + color = self.highlight + textColor = self.highlightText + barColor = self.darkerHighlight + else: + color = self.darkGray + textColor = self.dv.tableColor + barColor = self.darkerGray + painter.fillRect(option.rect, color) + painter.setPen(textColor) + + year, month = index.data() + + month = self.dv.get_month_text(month, year) + + x = option.rect.x() + y = option.rect.y() + + painter.setFont(self.dv.monthFont) + painter.setPen(textColor) + + # Set position in the cell + painter.translate(x, y) + # Rotate the coming text rendering + painter.rotate(270.0) + + # Translate positioning to reflect new rotation + painter.translate(-1 * option.rect.height(), 0) + rect = QRect(0, 0, option.rect.height(), option.rect.width()) + + painter.drawText(rect, Qt.AlignCenter, month) + + painter.setPen(barColor) + painter.drawLine(1, 0, 1, option.rect.width()) + + painter.restore() + + elif column == 1: + # Day of the month + painter.save() + + if option.state & QStyle.State_Selected: + color = self.highlight + weekdayColor = self.highlightText + dayColor = self.highlightText + barColor = self.darkerHighlight + else: + color = self.darkGray + weekdayColor = QColor(221, 221, 221) + dayColor = QColor(Qt.white) + barColor = self.darkerGray + + painter.fillRect(option.rect, color) + weekday, day = index.data() + weekday = weekday.upper() + width = option.rect.width() + height = option.rect.height() + + painter.translate(option.rect.x(), option.rect.y()) + weekday_rect_bottom = int( + height / 2 - self.dv.max_col1_text_height * self.dv.day_proportion + ) + self.dv.max_weekday_height + weekdayRect = QRect(0, 0, width, weekday_rect_bottom) + day_rect_top = weekday_rect_bottom + self.dv.col1_center_space + dayRect = QRect(0, day_rect_top, width, height - day_rect_top) + + painter.setFont(self.dv.weekdayFont) + painter.setPen(weekdayColor) + painter.drawText(weekdayRect, Qt.AlignHCenter | Qt.AlignBottom, weekday) + painter.setFont(self.dv.dayFont) + painter.setPen(dayColor) + painter.drawText(dayRect, Qt.AlignHCenter | Qt.AlignTop, day) + + if row in self.dv.c1_end_of_month: + painter.setPen(barColor) + painter.drawLine( + 0, option.rect.height() - 1, option.rect.width(), option.rect.height() - 1 + ) + + painter.restore() + + elif column == 2: + # Time during the day + text, new_file, invalid_row, invalid_rows = index.data() + + painter.save() + + if invalid_row: + color = self.darkGray + textColor = QColor(Qt.white) + elif option.state & QStyle.State_Selected: + color = self.highlight + # TODO take into account dark themes + if new_file: + textColor = self.highlightText + else: + textColor = self.darkGray + else: + color = self.dv.tableColor + if new_file: + textColor = QColor(Qt.white) + else: + textColor = self.darkGray + + painter.fillRect(option.rect, color) + + align = self.dv.c2_alignment.get(row) + + if new_file and self.dv.col2_new_file_dot: + # Draw a small circle beside the date (currently unused) + painter.setPen(self.newFileColor) + painter.setRenderHint(QPainter.Antialiasing) + painter.setBrush(self.newFileColor) + rect = QRectF( + option.rect.x(), + option.rect.y(), + self.dv.col2_new_file_dot_size, + self.dv.col2_new_file_dot_size + ) + if align is None: + height = option.rect.height() / 2 -self.dv.col2_new_file_dot_radius - \ + self.dv.col2_font_descent_adjust + rect.translate(self.dv.col2_new_file_dot_left_margin, height) + elif align == Align.bottom: + height = ( + option.rect.height() - self.dv.col2_font_height_half - + self.dv.col2_font_descent_adjust - self.dv.col2_new_file_dot_size + ) + rect.translate(self.dv.col2_new_file_dot_left_margin, height) + else: + height = ( + self.dv.col2_font_height_half - self.dv.col2_font_descent_adjust + ) + rect.translate(self.dv.col2_new_file_dot_left_margin, height) + painter.drawEllipse(rect) + + rect = QRect(option.rect) + rect.translate(self.dv.col2_text_left_margin, 0) + + painter.setPen(textColor) + + if invalid_rows: + # Render the row + invalidRightRect = QRect(option.rect) + invalidRightRect.translate(-2, 1) + painter.setFont(self.dv.invalidRowFont) + painter.drawText(invalidRightRect, Qt.AlignRight | Qt.AlignTop, str(row)) + if align != Align.top and self.dv.invalidRowHeightMin < option.rect.height(): + invalidLeftRect = QRect(option.rect) + invalidLeftRect.translate(1, 1) + painter.drawText(invalidLeftRect, Qt.AlignLeft | Qt.AlignTop, 'Debug mode') + + painter.setFont(self.dv.proximityFont) + + if align is None: + painter.drawText(rect, Qt.AlignLeft | Qt.AlignVCenter, text) + elif align == Align.bottom: + rect.setHeight(rect.height() - self.dv.col2_v_padding_half) + painter.drawText(rect, Qt.AlignLeft | Qt.AlignBottom, text) + else: + rect.adjust(0, self.dv.col2_v_padding_half, 0, 0) + painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, text) + + if row in self.dv.c2_end_of_day: + if option.state & QStyle.State_Selected: + painter.setPen(self.darkerHighlight) + else: + painter.setPen(self.dv.tableColorDarker) + painter.translate(option.rect.x(), option.rect.y()) + painter.drawLine( + 0, option.rect.height() - 1, self.dv.col_widths[2], option.rect.height() - 1 + ) + + painter.restore() + else: + super().paint(painter, option, index) + + +class TemporalProximityView(QTableView): + + proximitySelectionHasChanged = pyqtSignal() + + def __init__(self, temporalProximityWidget: 'TemporalProximity', rapidApp) -> None: + super().__init__() + self.rapidApp = rapidApp + self.temporalProximityWidget = temporalProximityWidget + self.verticalHeader().setVisible(False) + self.horizontalHeader().setVisible(False) + # Calling code should set this value to something sensible + self.setMinimumWidth(200) + self.horizontalHeader().setStretchLastSection(True) + self.setWordWrap(True) + self.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setShowGrid(False) + + def _updateSelectionRowChildColumn2(self, row: int, parent_column: int, + model: TemporalProximityModel) -> None: + """ + Select cells in column 2, based on selections in column 0 or 1. + + :param row: the row of the cell that has been selected + :param parent_column: the column of the cell that has been + selected + :param model: the model the view operates on + """ + + for parent_row in range(row, row + self.rowSpan(row, parent_column)): + start_row = model.groups.row_span_for_column_starts_at_row[(parent_row, 2)] + row_span = self.rowSpan(start_row, 2) + + do_selection = False + if row_span > 1: + all_selected = True + for r in range(start_row, start_row + row_span): + if not self.selectionModel().isSelected(model.index(r, 1)): + all_selected = False + break + if all_selected: + do_selection = True + else: + do_selection = True + + if do_selection: + self.selectionModel().select(model.index(start_row, 2), QItemSelectionModel.Select) + model.dataChanged.emit(model.index(start_row, 2), model.index(start_row, 2)) + + def _updateSelectionRowChildColumn1(self, row: int, model: TemporalProximityModel) -> None: + """ + Select cells in column 1, based on selections in column 0. + + :param row: the row of the cell that has been selected + :param model: the model the view operates on + """ + + for r in range(row, row + self.rowSpan(row, 0)): + self.selectionModel().select( + model.index(r, 1), QItemSelectionModel.Select + ) + model.dataChanged.emit(model.index(row, 1), model.index(r, 1)) + + def _updateSelectionRowParent(self, row: int, + parent_column: int, + start_column: int, + examined: set, + model: TemporalProximityModel) -> None: + """ + Select cells in column 0 or 1, based on selections in column 2. + + :param row: the row of the cell that has been selected + :param parent_column: the column in which to select cells + :param start_column: the column of the cell that has been + selected + :param examined: cells that have already been analyzed to see + if they should be selected or not + :param model: the model the view operates on + """ + start_row = model.groups.row_span_for_column_starts_at_row[(row, parent_column)] + if (start_row, parent_column) not in examined: + all_selected = True + for r in range(start_row, start_row + self.rowSpan(row, parent_column)): + if not self.selectionModel().isSelected(model.index(r, start_column)): + all_selected = False + break + if all_selected: + i = model.index(start_row, parent_column) + self.selectionModel().select(i, QItemSelectionModel.Select) + model.dataChanged.emit(i, i) + examined.add((start_row, parent_column)) + + def updateSelection(self) -> None: + """ + Modify user selection to include extra columns. + + When the user is selecting table cells, need to mimic the + behavior of + setSelectionBehavior(QAbstractItemView.SelectRows) + However in our case we need to select multiple rows, depending + on the row spans in columns 0, 1 and 2. Column 2 is a special + case. + """ + + # auto_scroll = self.temporalProximityWidget.prefs.auto_scroll + # if auto_scroll: + # self.temporalProximityWidget.setTimelineThumbnailAutoScroll(False) + + self.selectionModel().blockSignals(True) + + model = self.model() # type: TemporalProximityModel + examined = set() + + for i in self.selectedIndexes(): + row = i.row() + column = i.column() + if column == 0: + examined.add((row, column)) + self._updateSelectionRowChildColumn1(row, model) + examined.add((row, 1)) + self._updateSelectionRowChildColumn2(row, 0, model) + examined.add((row, 2)) + if column == 1: + examined.add((row, column)) + self._updateSelectionRowChildColumn2(row, 1, model) + self._updateSelectionRowParent(row, 0, 1, examined, model) + examined.add((row, 2)) + if column == 2: + for r in range(row, row + self.rowSpan(row, 2)): + for parent_column in (1, 0): + self._updateSelectionRowParent(r, parent_column, 2, examined, model) + + self.selectionModel().blockSignals(False) + + # if auto_scroll: + # self.temporalProximityWidget.setTimelineThumbnailAutoScroll(True) + + @pyqtSlot(QMouseEvent) + def mousePressEvent(self, event: QMouseEvent) -> None: + """ + Checks to see if Timeline selection should be cleared. + + Should be cleared if the cell clicked in already represents + a selection that cannot be expanded or made smaller with the + same click. + + A click outside the selection represents a new selection, + should proceed. + + A click inside a selection, but one that creates a new, smaller + selection, should also proceed. + + :param event: the mouse click event + """ + + do_selection = True + do_selection_confirmed = False + index = self.indexAt(event.pos()) # type: QModelIndex + if index in self.selectedIndexes(): + clicked_column = index.column() + clicked_row = index.row() + row_span = self.rowSpan(clicked_row, clicked_column) + for i in self.selectedIndexes(): + column = i.column() + row = i.row() + # Is any selected column to the left of clicked column? + if column < clicked_column: + # Is the row outside the span of the clicked row? + if (row < clicked_row or + row + self.rowSpan(row, column) > clicked_row + row_span): + do_selection_confirmed = True + break + # Is this the only selected row in the column selected? + if ((row < clicked_row or row >= clicked_row + row_span) and column == + clicked_column): + do_selection_confirmed = True + break + + if not do_selection_confirmed: + self.clearSelection() + self.rapidApp.proximityButton.setHighlighted(False) + do_selection = False + thumbnailView = self.rapidApp.thumbnailView + model = self.model() + uids = model.data(index, Roles.uids) + thumbnailView.scrollToUids(uids=uids) + + if do_selection: + self.temporalProximityWidget.block_update_device_display = True + super().mousePressEvent(event) + + + @pyqtSlot(QMouseEvent) + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + self.temporalProximityWidget.block_update_device_display = False + self.proximitySelectionHasChanged.emit() + super().mouseReleaseEvent(event) + + @pyqtSlot(int) + def scrollThumbnails(self, value) -> None: + index = self.indexAt(QPoint(200, 0)) # type: QModelIndex + if index.isValid(): + if self.selectedIndexes(): + # It's now possible to scroll the Timeline and there will be + # no matching thumbnails to which to scroll to in the display, + # because they are not being displayed. Hence this check: + if not index in self.selectedIndexes(): + return + thumbnailView = self.rapidApp.thumbnailView + thumbnailView.setScrollTogether(False) + model = self.model() + uids = model.data(index, Roles.uids) + thumbnailView.scrollToUids(uids=uids) + thumbnailView.setScrollTogether(True) + + +class TemporalValuePicker(QWidget): + """ + Simple composite widget of QSlider and QLabel + """ + + # Emits number of minutes + valueChanged = pyqtSignal(int) + + def __init__(self, minutes: int, parent=None) -> None: + super().__init__(parent) + self.slider = QSlider(Qt.Horizontal) + self.slider.setTickPosition(QSlider.TicksBelow) + self.slider.setToolTip( + _( + "The time elapsed between consecutive photos and videos that is used to build the " + "Timeline" + ) + ) + self.slider.setMaximum(len(proximity_time_steps) - 1) + self.slider.setValue(proximity_time_steps.index(minutes)) + + self.display = QLabel() + font = QFont() + font.setPointSize(font.pointSize() - 2) + self.display.setFont(font) + self.display.setAlignment(Qt.AlignCenter) + + # Determine maximum width of display label + width = 0 + labelMetrics = QFontMetrics(QFont()) + for m in range(len(proximity_time_steps)): + boundingRect = labelMetrics.boundingRect(self.displayString(m)) # type: QRect + width = max(width, boundingRect.width()) + + self.display.setFixedWidth(width + 6) + + self.slider.valueChanged.connect(self.updateDisplay) + self.slider.sliderPressed.connect(self.sliderPressed) + self.slider.sliderReleased.connect(self.sliderReleased) + + self.display.setText(self.displayString(self.slider.value())) + + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(QFontMetrics(font).height() // 6) + self.setLayout(layout) + layout.addWidget(self.slider) + layout.addWidget(self.display) + + @pyqtSlot() + def sliderPressed(self): + self.pressed_value = self.slider.value() + + @pyqtSlot() + def sliderReleased(self): + if self.pressed_value != self.slider.value(): + self.valueChanged.emit(proximity_time_steps[self.slider.value()]) + + @pyqtSlot(int) + def updateDisplay(self, value: int) -> None: + self.display.setText(self.displayString(value)) + if not self.slider.isSliderDown(): + self.valueChanged.emit(proximity_time_steps[value]) + + def displayString(self, index: int) -> str: + minutes = proximity_time_steps[index] + if minutes < 60: + # Translators: e.g. "45m", which is short for 45 minutes. + # Replace the very last character (after the d) with the correct + # localized value, keeping everything else. In other words, change + # only the m character. + return _("%(minutes)dm") % dict(minutes=minutes) + elif minutes == 90: + # Translators: i.e. "1.5h", which is short for 1.5 hours. + # Replace the entire string with the correct localized value + return _('1.5h') + else: + # Translators: e.g. "5h", which is short for 5 hours. + # Replace the very last character (after the d) with the correct localized value, + # keeping everything else. In other words, change only the h character. + return _('%(hours)dh') % dict(hours=minutes // 60) + + +class TemporalProximity(QWidget): + """ + Displays Timeline and tracks its state. + + Main widget to display and control Timeline. + """ + + proximitySelectionHasChanged = pyqtSignal() + + def __init__(self, rapidApp, + prefs: Preferences, + parent=None) -> None: + """ + :param rapidApp: main application window + :type rapidApp: RapidWindow + :param prefs: program & user preferences + :param parent: parent widget + """ + + super().__init__(parent) + + self.rapidApp = rapidApp + self.thumbnailModel = rapidApp.thumbnailModel + self.prefs = prefs + + self.block_update_device_display = False + + self.state = TemporalProximityState.empty + + self.uids_manually_set_previously_downloaded = [] # type: List[bytes] + + self.temporalProximityView = TemporalProximityView(self, rapidApp=rapidApp) + self.temporalProximityModel = TemporalProximityModel(rapidApp=rapidApp) + self.temporalProximityView.setModel(self.temporalProximityModel) + self.temporalProximityDelegate = TemporalProximityDelegate() + self.temporalProximityView.setItemDelegate(self.temporalProximityDelegate) + self.temporalProximityView.selectionModel().selectionChanged.connect( + self.proximitySelectionChanged + ) + + self.temporalProximityView.setSizePolicy( + QSizePolicy.Preferred, QSizePolicy.Expanding + ) + + self.temporalValuePicker = TemporalValuePicker(self.prefs.get_proximity()) + self.temporalValuePicker.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + + description = _( + 'The Timeline groups photos and videos based on how much time elapsed ' + 'between consecutive shots. Use it to identify photos and videos taken at ' + 'different periods in a single day or over consecutive days.' + ) + adjust = _( + 'Use the slider (below) to adjust the time elapsed between consecutive shots ' + 'that is used to build the Timeline.' + ) + generation_pending = _("Timeline build pending...") + generating = _("Timeline is building...") + ctime_vs_mtime = _( + "The Timeline needs to be rebuilt because the file " + "modification time does not match the time a shot was taken for one or more shots" + ".<br><br>The Timeline shows when shots were taken. The time a shot was taken is " + "found in a photo or video's metadata. " + "Reading the metadata is time consuming, so Rapid Photo Downloader avoids reading the " + "metadata while scanning files. Instead it uses the time the file was last modified " + "as a proxy for when the shot was taken. The time a shot was taken is confirmed when " + "generating thumbnails or downloading, which is when the metadata is read." + ) + + description = '<i>{}</i>'.format(description) + generation_pending = '<i>{}</i>'.format(generation_pending) + generating = '<i>{}</i>'.format(generating) + adjust = '<i>{}</i>'.format(adjust) + ctime_vs_mtime = '<i>{}</i>'.format(ctime_vs_mtime) + + palette = QPalette() + palette.setColor(QPalette.Window, palette.color(palette.Base)) + + # TODO assign this value from somewhere else - rapidApp.standard_spacing not yet defined + margin = 6 + + self.description = QLabel(description) + self.adjust = QLabel(adjust) + self.generating = QLabel(generating) + self.generationPending = QLabel(generation_pending) + self.ctime_vs_mtime = QLabel(ctime_vs_mtime) + + self.explanation = QWidget() + layout = QVBoxLayout() + border_width = QSplitter().lineWidth() + layout.setContentsMargins(border_width, border_width, border_width, border_width) + layout.setSpacing(0) + self.explanation.setLayout(layout) + layout.addWidget(self.description) + layout.addWidget(self.adjust) + + for label in (self.description, self.generationPending, self.generating, self.adjust, + self.ctime_vs_mtime): + label.setMargin(margin) + label.setWordWrap(True) + label.setAutoFillBackground(True) + label.setPalette(palette) + + for label in (self.description, self.generationPending, self.generating, + self.ctime_vs_mtime): + label.setAlignment(Qt.AlignTop) + label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.MinimumExpanding) + self.adjust.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + + layout = QVBoxLayout() + self.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + + self.stackedWidget = QStackedWidget() + + for label in (self.explanation, self.generationPending, self.generating, + self.ctime_vs_mtime): + scrollArea = QScrollArea() + scrollArea.setWidgetResizable(True) + scrollArea.setWidget(label) + self.stackedWidget.addWidget(scrollArea) + + self.stackedWidget.addWidget(self.temporalProximityView) + + self.stack_index_for_state = { + TemporalProximityState.empty: 0, + TemporalProximityState.pending: 1, + TemporalProximityState.generating: 2, + TemporalProximityState.regenerate: 2, + TemporalProximityState.ctime_rebuild: 3, + TemporalProximityState.ctime_rebuild_proceed: 3, + TemporalProximityState.generated: 4 + } + + self.autoScrollButton = QToolButton(self) + icon = QIcon(':/icons/link.svg') + self.autoScrollButton.setIcon(icon) + # self.autoScrollButton.setIconSize(QSize(16, 16)) + self.autoScrollButton.setAutoRaise(True) + self.autoScrollButton.setCheckable(True) + self.autoScrollButton.setToolTip( + _('Toggle synchronizing Timeline and thumbnail scrolling (Ctrl-T)') + ) + self.autoScrollButton.setChecked(not self.prefs.auto_scroll) + self.autoScrollAct = QAction( + '', self, shortcut="Ctrl+T", + triggered=self.autoScrollActed, icon=icon + ) + self.autoScrollButton.addAction(self.autoScrollAct) + style = "QToolButton {padding: 2px;} QToolButton::menu-indicator {image: none;}" + self.autoScrollButton.setStyleSheet(style) + self.autoScrollButton.clicked.connect(self.autoScrollClicked) + + pickerLayout = QHBoxLayout() + pickerLayout.setSpacing(0) + pickerLayout.addWidget(self.temporalValuePicker) + pickerLayout.addWidget(self.autoScrollButton) + + layout.addWidget(self.stackedWidget) + layout.addLayout(pickerLayout) + + self.stackedWidget.setCurrentIndex(0) + + self.temporalValuePicker.valueChanged.connect(self.temporalValueChanged) + if self.prefs.auto_scroll: + self.setTimelineThumbnailAutoScroll(self.prefs.auto_scroll) + + self.suppress_auto_scroll_after_timeline_select = False + + @pyqtSlot(QItemSelection, QItemSelection) + def proximitySelectionChanged(self, current: QItemSelection, previous: QItemSelection) -> None: + """ + Respond to user selections in Temporal Proximity Table. + + User can select / deselect individual cells. Need to: + 1. Automatically update selection to include parent or child + cells in some cases + 2. Filter display of thumbnails + """ + + self.temporalProximityView.updateSelection() + + groups = self.temporalProximityModel.groups + + selected_rows_col2 = [ + i.row() for i in self.temporalProximityView.selectedIndexes() if i.column() == 2 + ] + selected_rows_col1 = [ + i.row() for i in self.temporalProximityView.selectedIndexes() + if i.column() == 1 and groups.row_span_for_column_starts_at_row[(i.row(), 2)] + not in selected_rows_col2 + ] + + try: + selected_col1 = [groups.proximity_view_cell_id_col1[row] for row in selected_rows_col1] + selected_col2 = [groups.proximity_view_cell_id_col2[row] for row in selected_rows_col2] + except KeyError as e: + logging.exception('Error in Timeline generation') + self.temporalProximityModel.debugDumpState(selected_rows_col1, selected_rows_col2) + return + + # Filter display of thumbnails, or reset the filter if lists are empty + self.thumbnailModel.setProximityGroupFilter(selected_col1, selected_col2) + + self.rapidApp.proximityButton.setHighlighted(True) + + if not self.block_update_device_display: + self.proximitySelectionHasChanged.emit() + + self.suppress_auto_scroll_after_timeline_select = True + + def clearThumbnailDisplayFilter(self): + self.thumbnailModel.setProximityGroupFilter([],[]) + self.rapidApp.proximityButton.setHighlighted(False) + + def setState(self, state: TemporalProximityState) -> None: + """ + Set the state of the temporal proximity view, updating the displayed message + :param state: the new state + """ + + if state == self.state: + return + + if state == TemporalProximityState.ctime_rebuild_proceed: + if self.state == TemporalProximityState.ctime_rebuild: + self.state = TemporalProximityState.ctime_rebuild_proceed + logging.debug("Timeline is ready to be rebuilt after ctime change") + return + else: + logging.error( + "Unexpected request to set Timeline state to %s because current state is %s", + state.name, self.state.name + ) + elif self.state == TemporalProximityState.ctime_rebuild and state != \ + TemporalProximityState.empty: + logging.debug( + "Ignoring request to set timeline state to %s because current state is ctime " + "rebuild", state.name + ) + return + + logging.debug("Updating Timeline state from %s to %s", self.state.name, state.name) + + self.stackedWidget.setCurrentIndex(self.stack_index_for_state[state]) + self.clearThumbnailDisplayFilter() + self.state = state + + def setGroups(self, proximity_groups: TemporalProximityGroups) -> bool: + """ + Display the Timeline using data from the generated proximity_groups + :param proximity_groups: Timeline content and formatting hints + :return: True if Timeline was updated, False if not updated due to + current state + """ + + if self.state == TemporalProximityState.regenerate: + self.rapidApp.generateTemporalProximityTableData( + reason="a change was made while it was already generating" + ) + return False + if self.state == TemporalProximityState.ctime_rebuild: + return False + + self.temporalProximityModel.groups = proximity_groups + + depth = proximity_groups.depth() + self.temporalProximityDelegate.depth = depth + if depth in (0, 1): + self.temporalProximityView.hideColumn(0) + else: + self.temporalProximityView.showColumn(0) + + self.temporalProximityView.clearSpans() + self.temporalProximityDelegate.row_span_for_column_starts_at_row = \ + proximity_groups.row_span_for_column_starts_at_row + self.temporalProximityDelegate.dv = proximity_groups.display_values + self.temporalProximityDelegate.dv.assign_fonts() + + for column, row, row_span in proximity_groups.spans: + self.temporalProximityView.setSpan(row, column, row_span, 1) + + self.temporalProximityModel.endResetModel() + + for idx, height in enumerate(proximity_groups.display_values.row_heights): + self.temporalProximityView.setRowHeight(idx, height) + for idx, width in enumerate(proximity_groups.display_values.col_widths): + self.temporalProximityView.setColumnWidth(idx, width) + + # Set the minimum width for the timeline to match the content + # Width of each column + if depth in (0, 1): + min_width = sum(proximity_groups.display_values.col_widths[1:]) + else: + min_width = sum(proximity_groups.display_values.col_widths) + # Width of each scrollbar + scrollbar_width = self.style().pixelMetric(QStyle.PM_ScrollBarExtent) + # Width of frame - without it, the tableview will still be too small + frame_width = QSplitter().lineWidth() * 2 + self.temporalProximityView.setMinimumWidth(min_width + scrollbar_width + frame_width) + + self.setState(TemporalProximityState.generated) + + # Has the user manually set any files as previously downloaded while the Timeline was + # generating? + if self.uids_manually_set_previously_downloaded: + self.temporalProximityModel.updatePreviouslyDownloaded( + uids=self.uids_manually_set_previously_downloaded + ) + self.uids_manually_set_previously_downloaded = [] + + return True + + @pyqtSlot(int) + def temporalValueChanged(self, minutes: int) -> None: + self.prefs.set_proximity(minutes=minutes) + if self.state == TemporalProximityState.generated: + self.setState(TemporalProximityState.generating) + self.rapidApp.generateTemporalProximityTableData( + reason="the duration between consecutive shots has changed") + elif self.state == TemporalProximityState.generating: + self.state = TemporalProximityState.regenerate + + def previouslyDownloadedManuallySet(self, uids: List[bytes]) -> None: + """ + Possibly update the formatting of the Timeline to reflect the user + manually setting files to have been previously downloaded + """ + + logging.debug( + "Updating Timeline to reflect %s files manually set as previously downloaded", + len(uids) + ) + if self.state != TemporalProximityState.generated: + self.uids_manually_set_previously_downloaded.extend(uids) + else: + self.temporalProximityModel.updatePreviouslyDownloaded(uids=uids) + + def scrollToUid(self, uid: bytes) -> None: + """ + Scroll to this uid in the Timeline. + + :param uid: uid to scroll to + """ + + if self.state == TemporalProximityState.generated: + if self.suppress_auto_scroll_after_timeline_select: + self.suppress_auto_scroll_after_timeline_select = False + else: + view = self.temporalProximityView + model = self.temporalProximityModel + row = model.groups.uid_to_row(uid=uid) + index = model.index(row, 2) + view.scrollTo(index, QAbstractItemView.PositionAtTop) + + def setTimelineThumbnailAutoScroll(self, on: bool) -> None: + """ + Turn on or off synchronized scrolling between thumbnails and Timeline + :param on: whether to turn on or off + """ + + self.setScrollTogether(on) + self.rapidApp.thumbnailView.setScrollTogether(on) + + def setScrollTogether(self, on: bool) -> None: + """ + Turn on or off the linking of scrolling the Timeline with the Thumbnail display + :param on: whether to turn on or off + """ + + view = self.temporalProximityView + if on: + view.verticalScrollBar().valueChanged.connect(view.scrollThumbnails) + else: + view.verticalScrollBar().valueChanged.disconnect(view.scrollThumbnails) + + @pyqtSlot(bool) + def autoScrollClicked(self, checked: bool) -> None: + self.prefs.auto_scroll = not checked + self.setTimelineThumbnailAutoScroll(not checked) + + @pyqtSlot(bool) + def autoScrollActed(self, on: bool) -> None: + self.autoScrollButton.animateClick() |