From 083849161f075878e4175cd03cb7afa83d64e7f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Frings-F=C3=BCrst?= Date: Thu, 6 Jul 2017 22:55:08 +0200 Subject: New upstream version 0.9.0 --- raphodo/proximity.py | 1674 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1674 insertions(+) create mode 100644 raphodo/proximity.py (limited to 'raphodo/proximity.py') diff --git a/raphodo/proximity.py b/raphodo/proximity.py new file mode 100644 index 0000000..08a14e1 --- /dev/null +++ b/raphodo/proximity.py @@ -0,0 +1,1674 @@ +# Copyright (C) 2015-2016 Damon Lynch + +# 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 . + +__author__ = 'Damon Lynch' +__copyright__ = "Copyright 2015-2016, Damon Lynch" + +from collections import (namedtuple, defaultdict, deque, Counter) +from operator import attrgetter +import locale +from datetime import datetime +import logging +import pickle +from pprint import pprint +import math +from typing import Dict, List, Tuple, Set, Optional + +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) +from PyQt5.QtWidgets import (QTableView, QStyledItemDelegate, QSlider, QLabel, QVBoxLayout, + QStyleOptionViewItem, QStyle, QAbstractItemView, QWidget, QHBoxLayout, + QSizePolicy, QSplitter, QScrollArea, QStackedWidget) +from PyQt5.QtGui import (QPainter, QFontMetrics, QFont, QColor, QGuiApplication, QPixmap, + QPalette, QMouseEvent) + +from raphodo.viewutils import QFramedWidget, QFramedLabel +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 + +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 locale_time(t: datetime) -> str: + """ + Attempt to localize the time without displaying seconds + Adapted from http://stackoverflow.com/questions/2507726/how-to-display + -locale-sensitive-time-format-without-seconds-in-python + :param t: time in datetime format + :return: time in format like "12:08 AM", or locale equivalent + """ + + replacement_fmts = [ + ('.%S', ''), + (':%S', ''), + (',%S', ''), + (':%OS', ''), + ('ཀསར་ཆ%S', ''), + (' %S초', ''), + ('%S秒', ''), + ('%r', '%I:%M'), + ('%t', '%H:%M'), + ('%T', '%H:%M') + ] + + t_fmt = locale.nl_langinfo(locale.T_FMT_AMPM) + + for fmt in replacement_fmts: + new_t_fmt = t_fmt.replace(*fmt) + if new_t_fmt != t_fmt: + return t.strftime(new_t_fmt) + return t.strftime(t_fmt) + + +AM = datetime(2015, 11, 3).strftime('%p') +PM = datetime(2015, 11, 3, 13).strftime('%p') + + +def strip_zero(t: str, strip_zero) -> str: + if not strip_zero: + return t + return t.lstrip('0') + + +def strip_ampm(t: str) -> str: + return t.replace(AM, '').replace(PM, '').strip() + +def make_long_date_format(arrowtime: Arrow) -> str: + # Translators: for example Nov 3 or Dec 31 + long_format = _('%(month)s %(numeric_day)s') % { + 'month': arrowtime.datetime.strftime('%b'), + 'numeric_day': arrowtime.format('D')} + # Translators: for example Nov 15 2015 + return _('%(date)s %(year)s') % dict(date=long_format, year=arrowtime.year) + +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. + + :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 + """ + + 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: + long_format_date = make_long_date_format(start) + # 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) or (start.hour >= 12 and end.hour >= 12): + # both dates are in the same meridiem + start_time = strip_ampm(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') % { + 'month': start.datetime.strftime('%b'), + 'numeric_day': start.format('D')} + end_date = _('%(month)s %(numeric_day)s') % { + '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') % {'date': start_date, 'year': start.year} + end_date = _('%(date)s %(year)s') % {'date': end_date, 'year': end.year} + + # Translators: for example, Nov 3, 12:15 PM + start_datetime = _('%(date)s, %(time)s') % { + 'date': start_date, 'time': strip_zero(locale_time(start.datetime), strip)} + end_datetime = _('%(date)s, %(time)s') % { + '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') % { + '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') % { + '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 + +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() + + 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 + + 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') % {'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]] + + 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) + + 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. + """ + + for col in (0,1,2): + 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] + + +class TemporalProximityGroups: + """ + Generates values to be displayed in Temporal Proximity (Timeline) view. + """ + + # @profile + def __init__(self, thumbnail_rows: List[ThumbnailDataForProximity], + temporal_span: int = 3600): + self.rows = [] # type: List[ProximityRow] + + self.uids = MetaUid() + + self.file_types_in_cell = dict() # type: Dict[Tuple[int, int], str] + self.times_by_proximity = defaultdict(list) + + # group_no: List[uid] + self.uids_by_proximity = defaultdict(list) # type: Dict[int, List[bytes, ...]] + self.new_files_by_proximity = defaultdict(set) # type: Dict[int, Set[bool]] + + self.text_by_proximity = deque() + + self.day_groups = defaultdict(list) + self.month_groups = defaultdict(list) + self.year_groups = defaultdict(list) + + self._depth = None + self._previous_year = False + 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 view cells with uids + # proximity view row: id + self.proximity_view_cell_id_col1 = {} # type: Dict[int, int] + # proximity view 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 = [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: + self._previous_year = True + if month != current_month or self._previous_year: + self._previous_month = True + + # Phase 2: Identify the proximity groups + group_no = 0 + prev = uid_times[0] + + self.times_by_proximity[group_no].append(prev.arrowtime) + self.uids_by_proximity[group_no].append(prev.uid) + self.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 + self.times_by_proximity[group_no].append(current.arrowtime) + self.uids_by_proximity[group_no].append(current.uid) + self.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 + for i in range(len(self.times_by_proximity)): + start = self.times_by_proximity[i][0] # type: Arrow + end = self.times_by_proximity[i][-1] # type: Arrow + short_form = humanize_time_span(start, end, insert_cr_on_long_line=True) + long_form = humanize_time_span(start, end, long_format=True) + self.text_by_proximity.append((short_form, long_form)) + + + # Phase 4: Generate the rows to be displayed in the proximity table view + self.prev_row_month = None # type: Tuple[int, int] + self.prev_row_day = None # type: Tuple[int, int, int] + row_index = -1 + thumbnail_row_index = -1 + column2_span = 0 + for group_no in range(len(self.times_by_proximity)): + arrowtime = self.times_by_proximity[group_no][0] + prev_day = (arrowtime.year, arrowtime.month, arrowtime.day) + + col2_text, tooltip_col2_text = self.text_by_proximity.popleft() + new_file = any(self.new_files_by_proximity[group_no]) + + row_index += 1 + column2_span + thumbnail_row_index += 1 + + self.rows.append(self.make_row(arrowtime, col2_text, new_file, prev_day, row_index, + thumbnail_row_index, tooltip_col2_text)) + uids = self.uids_by_proximity[group_no] + self.uids[(row_index, 2)] = uids + + if len(self.times_by_proximity[group_no]) > 1: + column2_span = 0 + for arrowtime in self.times_by_proximity[group_no][1:]: + thumbnail_row_index += 1 + + day = (arrowtime.year, arrowtime.month, arrowtime.day) + + if prev_day != day: + prev_day = day + column2_span += 1 + self.rows.append(self.make_row(arrowtime, '', new_file, prev_day, + row_index + column2_span, + thumbnail_row_index, '')) + + # Phase 5: Determine the row spans for each column + column = -1 + for c in (0, 2, 4): + column += 1 + start_row = 0 + for row_index, row in enumerate(self.rows): + if row[c]: + row_count = row_index - start_row + if row_count > 1: + self.spans.append((column, start_row, row_count)) + start_row = row_index + self.row_span_for_column_starts_at_row[(row_index, column)] = start_row + + if start_row != len(self.rows) - 1: + self.spans.append((column, start_row, len(self.rows) - start_row)) + for row_index in range(start_row, len(self.rows)): + self.row_span_for_column_starts_at_row[(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, row_index in enumerate(self.uids.uids(1)): + self.proximity_view_cell_id_col1[row_index] = proximity_view_cell_id + uids = self.uids.uids(1)[row_index] + for uid in uids: + uid_rows_c1[uid] = proximity_view_cell_id + + uid_rows_c2 = {} + + for proximity_view_cell_id, row_index in enumerate(self.uids.uids(2)): + self.proximity_view_cell_id_col2[row_index] = proximity_view_cell_id + uids = self.uids.uids(2)[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.new_files_by_proximity = None + self.text_by_proximity = None + + self.uids_by_proximity = None + self.times_by_proximity = None + self.thumbnail_types = None + self.text_by_proximity = None + + 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, arrowtime: Arrow, + col2_text: str, + new_file: bool, + day: Tuple[int, int, int], + row_index: int, + thumbnail_row_index: int, + tooltip_col2_text: str) -> ProximityRow: + + arrowmonth = day[:2] + if arrowmonth != self.prev_row_month: + self.prev_row_month = arrowmonth + month = arrowtime.datetime.strftime('%B') + year = arrowtime.year + uids = self.month_groups[arrowmonth] + slice_end = thumbnail_row_index + len(uids) + self.file_types_in_cell[(row_index, 0)] = self.make_file_types_in_cell_text( + slice_start=thumbnail_row_index, slice_end=slice_end) + self.uids[(row_index, 0)] = uids + else: + month = year = '' + + if day != self.prev_row_day: + self.prev_row_day = day + numeric_day = arrowtime.format('D') + weekday = arrowtime.datetime.strftime('%a') + + self.uids[(row_index, 1)] = self.day_groups[day] + else: + weekday = numeric_day = '' + + month_day = _('%(month)s %(numeric_day)s') % { + 'month': arrowtime.datetime.strftime('%b'), + 'numeric_day': arrowtime.format('D')} + tooltip_col1 = _('%(date)s %(year)s') % {'date': month_day, 'year': arrowtime.year} + # Translators: for example Nov 2015 + tooltip_col0 = _('%(month)s %(year)s') % {'month': arrowtime.datetime.strftime('%b'), + 'year': arrowtime.year} + + return ProximityRow(year, month, weekday, numeric_day, col2_text, new_file, tooltip_col0, + tooltip_col1, tooltip_col2_text) + + def __len__(self): + return len(self.rows) + + def __getitem__(self, row_number) -> ProximityRow: + return self.rows[row_number] + + def __iter__(self): + return iter(self.rows) + + def depth(self): + 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 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): + super().__init__(parent) + self.rapidApp = rapidApp + self.groups = groups + + def columnCount(self, parent=QModelIndex()): + return 3 + + def rowCount(self, parent=QModelIndex()): + 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: + 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 + elif role == Qt.ToolTipRole: + thumbnails = self.rapidApp.thumbnailModel.thumbnails + + 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] + + pixmap = thumbnails[uids[0]] # type: QPixmap + + image = base64_thumbnail(pixmap, self.tooltip_image_size) + html_image1 = ''.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 = ''.format(image) + + tooltip = '{}
{} {} {}
{}'.format(date, + html_image1, center, html_image2, + file_types) + return tooltip + + +class TemporalProximityDelegate(QStyledItemDelegate): + """ + Render table cell for Timeline. + + All cell size calculations are done prior to rendering. + """ + 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: + 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: + 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: + text, new_file = index.data() + + painter.save() + + if 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: + 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) + + painter.setFont(self.dv.proximityFont) + painter.setPen(textColor) + + rect = QRect(option.rect) + rect.translate(self.dv.col2_text_left_margin, 0) + + 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. + """ + + 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) + + @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 + + 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) + + +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.slider.setStyleSheet(""" + # QSlider { + # border: none; + # outline: none; + # } + # """) + + 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.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" + ".

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 = '{}'.format(description) + generation_pending = '{}'.format(generation_pending) + generating = '{}'.format(generating) + adjust = '{}'.format(adjust) + ctime_vs_mtime = '{}'.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 + } + + layout.addWidget(self.stackedWidget) + layout.addWidget(self.temporalValuePicker) + + self.stackedWidget.setCurrentIndex(0) + + self.temporalValuePicker.valueChanged.connect(self.temporalValueChanged) + + self.another_generation_needed = 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] + + 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] + + # 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() + + 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: + 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) + 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 -- cgit v1.2.3